Compare commits

...
Author SHA1 Message Date
Kevin Codex e992526198 Fix provider manager test formatting 2026-07-07 10:26:34 +08:00
Kevin Codex 620f1af884 fix(providers): resolve self-review findings — atomic edits, key normalization, threading
Findings from a multi-agent code review of this branch (8 finder angles,
independently verified); all confirmed against the code, several with
empirical repros.

Correctness:
- config.EditProvider: one atomic read-modify-write for the manager's Save
  (rename + fields + verbatim description + stored-key marker + key
  migration with write-failure rollback). Replaces the rename→upsert→
  describe sequence that (a) turned a case-only rename (groq → Groq) into a
  DUPLICATE profile via UpsertProvider's case-sensitive merge, and (b) could
  fail halfway, leaving config.json renamed while savedProviders and
  ZERO_PROVIDER still held the old name (retry then failed 'not found').
  A rename now also migrates a freshly captured replacement key (captured
  under the current name; the migration moves it).
- savedProviders is mutated surgically on manager delete/edit instead of
  being replaced with the raw user-config list — the session list is seeded
  from the RESOLVED (project-config layered) and usability-FILTERED set, so
  wholesale replacement dropped project-contributed providers and
  resurrected filtered stubs for the rest of the session.
- oauth.ProviderKey normalizes provider names to lower case at the single
  write/lookup choke point: `zero auth login xAI` stored provider:xAI while
  every candidate lookup was case-sensitive — a successful login invisible
  to the runtime. Map-based presence checks lowercase to match.
- OAuth login persistence moved onto the Update goroutine (message handlers)
  — a background tea.Cmd (device-code polls land minutes later) doing an
  unlocked config read-modify-write raced UI-thread writes (lost updates).
  The write error is now surfaced and the in-memory mirror is skipped on
  failure, so a profile that never reached disk can't show as a working
  provider that vanishes on restart.

UX / consistency:
- Esc inside a manager-launched add flow walks back to the provider list
  from EVERY step (was: first step only; deeper steps destroyed the overlay
  and the user's place). In-flight OAuth logins are invalidated on the way
  out; footers say 'Esc back'.
- switchProviderModel returns a structured outcome; the manager branches on
  it instead of strings.Contains(text, "Switched to") — UI copy is no longer
  a control-flow contract (a provider named 'Switched to prod' could close
  the manager on a refusal).
- Manager delete's keychain subprocess + OAuth lookup run in a follow-up
  tea.Cmd (the confirm keypress no longer blocks the render loop); the
  credential probe takes ONE token-store snapshot instead of 2 reads per
  row, and resolved states carry over across reloads (no 'checking…' flash).
- Credential-state reporting converges: /provider status recognizes OAuth
  logins (was 'api key: not set' + warning for a WORKING provider), doctor
  uses the shared HasConfiguredCredential definition plus OAuth awareness
  (its 'matching ProviderSnapshot.APIKeySet' claim is true again), and the
  manager's classifier falls through a stale stored-key marker to env/OAuth
  the way the runtime does.
2026-07-07 09:46:14 +08:00
Kevin Codex 3a02281fd4 fix(tui): read stored keys from the active config's credential store
The TUI's write paths (SecureProviderProfile capture, rename migration,
manager delete) all target the store beside m.userConfigPath, but the read
paths still opened the DEFAULT-path store: profileWithCredential (feeding
the /model and manager switch gate), the model picker's discovery
credential lookup, and the manager's async credential-status probe. With a
non-default config path, a provider saved with only apiKeyStored had its
key in the config-adjacent store, so the gate rejected it as having no
usable credential and the manager showed 'stored key missing' for a
healthy profile.

All TUI stored-key reads now resolve through providerKeyStoreForPath
(config-adjacent, default-path fallback for ephemeral sessions), so the UI
reads from the same store it writes to. Regression test covers both the
credential probe and the switch path with a key seeded only beside the
config.

Also consolidates the OAuth test-store seeding on withAuthStore (now pins
ZERO_OAUTH_STORAGE=file so an inherited keyring backend can't bypass the
temp path) per review nitpick.
2026-07-07 08:31:42 +08:00
Kevin Codex 8705e789b3 fix(providers): second review round — case-only rename, logout hint, test hermeticity
- Skip the credential-store migration on case-only renames (groq -> Groq):
  the store normalizes names case-insensitively, so Set(new)+Delete(old)
  target ONE entry and the delete removed the key that was just "moved".
  The existing entry already serves the new name. [CodeRabbit]
- Point the delete flow's cleanup hint at the stored login that actually
  exists: after a rename ({name:"codex", catalogID:"chatgpt"}) the token
  lives under the catalog id, and `zero auth logout codex` would delete
  nothing. oauthLoginName resolves the same FirstStored candidate the
  runtime uses. [jatmn P3]
- Lock in APIKeyEnv semantics after a key edit: the env reference survives
  as an explicit override (a SET env var wins at resolve time; the stored
  key applies when it is absent). [CodeRabbit nitpick]
- Make main's new firstUsableProvider tests hermetic: they read the REAL
  OAuth token store, so any developer with an actual xai/chatgpt login gets
  spurious failures (found because this machine has one).

Also rebased onto main; the only conflict was comment wording in
firstUsableProvider, where the audit commit (#481) independently landed the
same OAuth-login fallback check this branch added.
2026-07-07 07:59:03 +08:00
Kevin Codex a1d4e57a94 fix(providers): address PR #560 review findings
All six review findings validated as real; fixes with regression tests:

- Persist the apiKeyStored marker when a key is captured onto an existing
  profile: mergeProfile deliberately ignores the marker (a project config
  must not claim the user's stored keys at resolve time), so the user-config
  WRITE path (UpsertProvider) now re-applies it — previously editing a key
  onto an env/no-key profile stranded the secret in the credential store
  while every ApplyStoredAPIKey gate skipped it. [jatmn P2]
- Let active-pointer recovery select OAuth-login providers:
  firstUsableProvider now applies the same providerHasOAuthLogin check as
  setupRequired/usableSavedProviders, so a stale activeProvider plus a saved
  ChatGPT/xAI login recovers instead of forcing onboarding. [jatmn P2]
- Delete stored keys from the store BESIDE the config being edited:
  providers remove and the TUI manager delete now use ProviderKeyStoreAt
  (matching setup/rename) instead of the default-path store. [jatmn P2]
- Roll back the key migration when RenameProvider's config write fails, so
  the store and config.json can't desync (key under the new name, config
  still naming the old one). [CodeRabbit]
- Clear provider descriptions for real: new SetProviderDescription writes
  the field verbatim (the upsert merge treats empty as unchanged); the
  manager's editor routes description changes through it. [jatmn P3]
- Surface stored-key deletion failures in providers remove (stderr warning
  + keyError in the JSON payload) and the TUI delete status, instead of
  letting a lingering secret read as a clean removal. [CodeRabbit]
2026-07-07 07:50:01 +08:00
Kevin Codex c12fb463eb feat(providers): OAuth login profiles and list-first /provider manager
Make multiple providers a first-class workflow: every OAuth login is now a
visible, switchable provider, and /provider manages the whole lifecycle.

OAuth logins become provider profiles:
- zero auth chatgpt / zero auth login <preset> and all four TUI login paths
  (wizard + first-run, browser + device-code) now upsert a catalog-backed
  profile via config.EnsureCatalogProvider after storing the token — without
  stealing the active provider. Previously the token was stored invisibly:
  the provider showed up nowhere and 'providers use' failed not-found.
- Switching to an OAuth provider no longer inlines the bearer as an API key,
  which suppressed the runtime resolver (no token refresh, no Codex
  chatgpt-account-id header → guaranteed 401s). The credential gate now
  recognizes stored OAuth logins on keyless profiles.
- providers list / current report truthful credential state: stored keys
  count as configured (APIKeySet honors apiKeyStored) and token logins show
  'oauth login'; providers check accepts an OAuth login as a credential.

List-first /provider manager (TUI):
- /provider opens the saved-provider list: Enter activates via the shared
  switch path, 'a' adds (existing wizard), 'e' opens a field-level editor
  (endpoint, model, key, description, and safe rename — the stored key,
  activeProvider, and live ZERO_PROVIDER follow the new name), 'd' deletes
  with an inline confirm and stored-key cleanup. Esc walks back one level.
- Credential states resolve asynchronously so keychain reads never block
  the render loop; 'stored key missing' surfaces broken keychain entries.
- Implemented as manager steps on providerWizardState so every existing
  overlay gate, key route, and mouse hook covers it unchanged.

CLI + config parity:
- zero providers remove <name> (deletes the stored key, hands the active
  pointer off) and zero providers rename <old> <new>.
- config.RemoveProvider / RenameProvider: read-modify-atomic-write; rename
  migrates the credential-store entry write-new-then-delete-old so an
  interruption can never lose the key.
2026-07-07 07:48:31 +08:00
PierrunoYTandCopilot Autofix powered by AI 008bc9b3f3 fix: address bugs found in a multi-agent codebase audit (#481)
* fix: address bugs found in a multi-agent codebase audit

Five parallel review agents each swept a different area (self-update,
TUI keybindings, agent/session handling, sandbox/shell safety, and
auth/credential storage) and surfaced nine concrete defects, all fixed
here with regression tests:

- sandbox: grant_scope path comparisons were case-sensitive, letting a
  persisted deny grant be bypassed by spelling a Windows path with
  different case
- securefile/oauth: createSecretFile's Windows ERROR_ACCESS_DENIED
  handling was only applied to the oauth token store, not the shared
  credstore backend; both also masked the real lock-contention error
  behind a misleading "file does not exist" timeout
- cli: firstUsableProvider ignored OAuth logins when picking a
  fallback provider, forcing unnecessary re-onboarding for an
  already-authenticated user
- sessions: exec prompt summarization truncated on a raw byte offset,
  which could split a multi-byte UTF-8 rune and embed invalid UTF-8
  into a resumed session's prompt
- tui: configurable keybindings had no collision detection, so a
  remapped chord could silently make a hardcoded shortcut (or another
  configured binding) permanently unreachable
- tools: the Windows shell-syntax pre-flight check wasn't quote-aware
  and could block harmless commands whose quoted arguments merely
  contained operator-like text
- update: checksum verification didn't cross-check the archive
  filename it verified against the one being extracted; extractZip
  didn't reject symlink-mode entries like extractTarGz already did;
  replaceBinary's restore-on-failure rename had no retry, risking a
  permanently missing binary on a transient Windows file lock

* fix: close remaining PR 481 review findings

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <[email protected]>

* fix(tools): drop stale ; test case after rebasing onto main's MSYS check

Rebasing fix/bug-hunt-findings onto main pulled in #476, which replaced
this file's regex-based windows_shell_syntax detection with a
segment/word-anchored check and deliberately stopped treating ; as a
command separator on Windows (cmd.exe doesn't split on ; the way bash
does). detectShellCommandIssue itself came out of the rebase unchanged
from main (our quote-masking version was fully superseded by #476's
more thorough segment-based approach), but the quoted-content test
this PR added still asserted the old bash-style semantics for
`echo "foo" ; ls -la`, which now correctly returns no issue since it's
a single echo invocation with literal arguments under cmd.exe, not a
real ls invocation.

---------

Co-authored-by: Copilot Autofix powered by AI <[email protected]>
2026-07-06 18:48:28 +08:00
Kevin Codex fd69233e33 feat(tui): show CLI version on the startup home screen (#538)
The home screen orientation line now leads with the build version
(v0.2.0 · cwd · branch · model). Release builds already inject
internal/cli.version via ldflags; dev builds show "dev".
2026-07-06 12:29:23 +08:00
Pengkuh Dwi Septiandi 3f0503bc23 fix(agent): keep tools exposed for max-turn finalization (#533)
* fix(agent): keep tools exposed for max-turn finalization

* chore(agent): rename max-turn tool definitions parameter
2026-07-06 12:07:07 +08:00
冯基魁 f951aee65d surface ACP session persistence failures (#535) 2026-07-06 12:06:38 +08:00
github-actions[bot] 82ecac5281 chore(main): release 0.2.0 (#370)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-07-06 11:51:03 +08:00
Vasanth T 387fe67ee7 feat(tui): /loop — repeat a prompt or command on an interval or self-paced (#502)
* feat(tui): /loop foundation — command parsing, loop state, safety bounds

First slice of the /loop feature (#501): the self-contained core with no TUI
wiring yet. parseLoopCommand handles the leading-interval, trailing-"every",
bare-self-paced, and list/stop forms; loopState models one session-scoped loop
with doom-loop + iteration-cap fields; interval/self-pace clamps and duration
formatting round it out, with unit tests. Because Zero drives loops off a Go
timer rather than a cron backend, arbitrary intervals (90s, 7m) are supported
with no whitelist limitation.

* feat(tui): wire /loop into the turn lifecycle — dispatch, idle tick, continuation

Second slice of #501. Adds the controller and wires it in:

- /loop command registered (commands.go) and dispatched to handleLoopCommand.
- Loop state on the model (loops, activeLoopID, loopSeq/loopTicking poll guards,
  loopNextWake/loopDone self-pace channel).
- An idle poll tick (loopTickMsg, ~1s) fires the earliest due loop only when the
  session is idle — mirrors the launchQueuedMessageIfReady guard so loops and a
  user's queued prompt never collide; a self-perpetuating tea.Tick that stops when
  no loops remain.
- The agentResponseMsg completion seam advances a loop iteration (schedule next
  wake for fixed, model delay for self-paced) with doom-loop, iteration-cap, and
  done stop conditions, restarting the ticker if other loops remain.
- /loop list|stop|stop all, footer summary + list rendering, and a guard that only
  a prompt or custom /command may loop (not a built-in).

Controller tests cover start/advance/self-paced-delay/done/doom/cap/stop/busy.

* feat(tui): /loop self-paced adaptive cadence + footer summary + /clear note

Third slice of #501.

- Self-paced loops now use an adaptive cadence (2m early -> 30m heartbeat as
  iterations accrue), with the loopNextWake channel kept as an override for a
  future explicit model signal.
- Persistent footer segment "↻ N loops · next 3:05pm" while loops are active.
- /clear notes that loops keep running (it wipes the transcript, not the session)
  so a loop never silently fires into a cleared screen.
- Tests for adaptive cadence + footer summary.

* test(tui): drive /loop end-to-end through Update (submit + stale tick)

* docs(readme): add /loop to the slash-command table

* fix(tui): stop looping on repeated failure + clear loop tag on cancel

Addresses CodeRabbit review on #502:
- advanceLoop now counts consecutive failed iterations (loopMaxFailures=3) and
  stops the loop, so a loop whose turns keep erroring no longer churns to the
  iteration cap (the doom guard only sees identical non-empty answers).
- cancelRun clears activeLoopID and re-arms the interrupted loop for its next
  cadence, so a cancelled iteration is not left running forever and the next
  unrelated turn is not misattributed as that loop's completion.

* fix(tui): finish /loop v1 — real self-paced completion, session scope, leave gate

Response to the review. The self-paced path was the real gap: it advertised
model-chosen completion but nothing ever wrote the done/next-wake fields, so it
was just adaptive polling dressed up. Replaced that with an actual control
channel — each self-paced iteration runs a short maintenance playbook and ends
its reply with a control line (LOOP: DONE / LOOP: CONTINUE [interval]) that
advanceLoop parses to stop the loop or pick the next wake. The two dead model
fields are gone; done and next-wake now come from the model's output.

Rest of the review:
- loops stop on a session switch (/new, /resume) instead of firing the previous
  conversation's prompt into the new one
- validateLoopTarget resolves custom /commands up front and rejects unknowns, so
  a mistyped `/loop 5m /nope` isn't scheduled and then re-sent as literal text
- a one-shot confirm gates /clear and /quit while loops are active
- age-based auto-expire (24h) next to the iteration cap
- the two CodeRabbit test nits (HasPrefix footer check, bare `/loop stop` on a
  single loop)

Hardening from a self-review pass over the above:
- normalize CRLF and allow a trailing note on the control line, so
  `LOOP: DONE — all clean` and \r\n replies still stop the loop
- disarm the leave-confirm before the queue/exit early returns, so an interposed
  prompt can't leave it falsely armed
- a loop no longer fires behind an open picker/modal; a run launched behind the
  /resume picker would otherwise complete into whatever session you switch to

* fix(tui): address /loop review round 2 — ticker re-arm, self-paced command, resume guard

- stopLoop re-arms the poll ticker after removing one loop, so stopping one of
  several no longer strands the rest with no tick scheduled (jatmn).
- a bare self-paced /command is rejected with a pointer to the interval form:
  self-paced drives a free-form goal via the playbook + LOOP: control line, which
  a custom command's expanded template never carries, so it would silently
  degrade to adaptive polling (jatmn).
- /resume only tears loops down on an actual session-id change; /resume latest or
  /resume <currentID> can resolve to the already-active session, whose loops are
  its own, not a "previous" session's (CodeRabbit).
- tests: skipped-behind-modal loop stays scheduled, footer carries the next-wake
  time, the confirmed /clear actually wipes the transcript, plus multi-loop stop
  keeping the ticker alive and self-paced-/command rejection.

* docs(readme): clarify /loop runs a prompt or custom command, not built-ins
2026-07-06 11:42:46 +08:00
Gautam ManchandaniandGautam Manchandani d185f3ef0d Fix action summary for structured output (#485)
* Fix action summary for structured output

* Address action summary review comments

* Keep action summary extraction best effort

---------

Co-authored-by: Gautam Manchandani <[email protected]>
2026-07-06 08:52:43 +08:00
euxaristia 8c6c49ad52 chore: fix golangci-lint findings (dead code, ineffassign, staticcheck) (#527)
- Remove unused safeRequestedPrefix, safeShellCommandTokens
  (internal/agent/command_prefix.go), and truncateHeadTail
  (internal/tools/bash.go): each was a thin wrapper with no remaining
  callers.
- Drop an ineffectual assignment in stringListArgWebSearch: every
  return path after it already sets the named return explicitly.
- Simplify a negated-and condition in
  partition_cache_stable_test.go via De Morgan's law.
- Use strings.Contains instead of strings.Index(...) < 0 in
  edit_replacers.go.
- Fix error string style (ST1005): lowercase "unsafe redirect URL"
  and drop the trailing period from webFetchPublicOnlyHint.

Skipped the errcheck findings on this pass (unchecked defer
Close()/Write() errors): those are stylistic, not correctness bugs,
and this repo's CI only runs go vet, not golangci-lint.
2026-07-06 08:09:32 +08:00
Gautam ManchandaniandGautam Manchandani 809489a6e3 Batch session event persistence (#526)
Co-authored-by: Gautam Manchandani <[email protected]>
2026-07-06 08:08:58 +08:00
euxaristia 56147a708e ci: add golangci-lint as an advisory check (#528)
go vet, gofmt, govulncheck, and deadcode don't catch unused private
functions/assignments reachable within a package but never called, or
staticcheck-style correctness/readability issues. deadcode's whole-program
reachability analysis specifically missed several of these; golangci-lint's
truncateHeadTail nitpick on #476 assumed it would fail CI, but nothing in
this repo actually ran it.

Scoped to unused, ineffassign, and staticcheck rather than the full default
battery, and continue-on-error like deadcode, so this surfaces the existing
findings across the repo (being cleaned up incrementally per #527) without
blocking in-flight PRs on a big-bang adoption.
2026-07-06 08:07:29 +08:00
euxaristiaandClaude Fable 5 81aad58d97 fix(tools): Block MSYS coreutils under Windows sandbox (#476)
* fix(tools): block MSYS coreutils under Windows sandbox

Git for Windows usr\bin coreutils (head, grep, cat, ...) are MSYS PE binaries that fail with ACCESS_DENIED and cygheap/signal-pipe errors when Zero runs them in the write-restricted Windows sandbox. Detect and block these commands before execution, surface MSYS runtime failures from stderr, steer agents toward native Zero tools or require_escalated, and exclude MSYS-prone names from known-safe command tails.

Refs #458

* fix(tools): anchor the last MSYS failure heuristic to MSYS markers

CodeRabbit review feedback: the win32-error-5 plus terminating check
had no MSYS-specific anchor, so unrelated access-denied failures could
be mislabeled as windows_msys_sandbox. Require an MSYS marker like the
neighboring checks do.

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

* fix(tools): let escalation bypass the MSYS guard and make detection quote-aware

Addresses jatmn's review on PR #476:
- bash and exec_command called detectShellCommandIssue before resolving
  sandbox_permissions into a command engine, so a require_escalated retry of
  a blocked command was still hard-blocked by the same preflight check.
  Resolve the command engine first and only bypass the windows_msys_sandbox
  issue kind when that resolves to true unsandboxed execution; other issue
  kinds (real cmd.exe syntax problems) still block regardless of escalation.
- Unify all MSYS-prone command name checks (MsysProneCommandName, the
  preflight scan, and transitively command_prefix.go's known-safe guard)
  behind one windowsMsysProneNames set. This fixes expr (in the name set but
  missing from the old regex alternations, so never proactively flagged) and
  ls (hit the older windows_shell_syntax branch first, so it got different
  metadata than every other MSYS-prone name).
- Make the preflight scan quote-aware: split the command into cmd.exe-
  operator-separated segments respecting double-quote grouping, and only
  check the first word of each segment instead of scanning raw text anywhere.
  Fixes false positives on quoted text like commit messages or PR comment
  bodies that merely mention a coreutil name.

* test(tools): decouple MSYS-guard bypass tests from real command output

TestBashToolRequireEscalatedMsysGuard and
TestExecCommandRequireEscalatedBypassesMsysGuardAfterApproval asserted
shell_issue == "" to prove the require_escalated bypass worked. Once
bypassed, "cat somefile.txt" actually runs unsandboxed, and its real,
PATH-dependent output could trip the unrelated post-execution
detectShellOutputIssue heuristic, failing the assertion for reasons
unrelated to the preflight guard under test.

Assert on the exit_code "-1" sentinel (set only by shellIssueBlockResult)
instead, which directly reflects whether the preflight guard blocked the
command.

* fix(tools): anchor MSYS path detection to command position, fix prefix approval gap

Address jatmn's latest review on PR #476:

- shell_runtime.go: the explicit MSYS binary-path check (usr\bin\,
  mingw64\bin\, msys-2.0.dll, cygwin1.dll) ran against the raw command
  string, so a quoted mention (e.g. a commit message describing a
  usr\bin\head.exe failure) was still hard-blocked. It now runs against
  the first word of each operator-separated segment, same as the
  coreutil-name check. windowsCommandSegments also stopped treating ;
  as a command separator (cmd.exe does not split on ;, unlike bash) and
  now respects cmd.exe's ^ escape character, so escaped operators like
  echo ^| head are no longer misread as a real head invocation.

- command_prefix.go: proposedCommandPrefix could offer to approve a
  single segment (e.g. "ps aux" in "ps aux | head -5") without checking
  that every other segment was independently safe. Since approving a
  prefix escalates the whole command to bypass the sandbox, an
  MSYS-prone or otherwise unsafe segment could ride along uncovered.
  It now only proposes a segment as the prefix when every other
  segment in the command is known-safe on its own.

Added regression tests for quoted MSYS path mentions, caret-escaped
operators, semicolon text, and the prefix-approval gap.

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

* fix(tools): treat redirection as a command boundary, stop using command text as output evidence

Addresses jatmn's latest review on PR #476:
- firstCommandWord only split on whitespace, so cmd.exe redirection
  attached directly to a command name (head>out.txt, cat<in.txt,
  grep>matches.txt) was read as one word and never matched against the
  MSYS-prone name set. It now also stops at < and >.
- detectShellOutputIssue scanned command+output together, so a command
  whose own argument text merely quoted an MSYS failure string (e.g. a
  gh pr comment --body describing the bug) could be mislabeled after
  running even though the real stdout/stderr was unrelated. Removed the
  command parameter entirely from detectShellOutputIssue and both call
  sites (bash.go, exec_command.go), so the command line can never be
  used as post-execution evidence again.

* fix(tools): remove unused truncateHeadTail wrapper

CodeRabbit nitpick: truncateHeadTailWithTotal is called directly
everywhere, so this wrapper is dead code that trips golangci-lint's
unused check and fails CI.

---------

Co-authored-by: Claude Fable 5 <[email protected]>
2026-07-06 08:03:32 +08:00
Kevin Codex 95ccd5bc32 perf: universal tool-output ceiling with spill + async post-edit diagnostics (#518)
* feat(tools): universal token-based output ceiling with spill-to-disk

Cap every tool result at the registry boundary (16k estimated tokens,
ZERO_TOOL_OUTPUT_CEILING_TOKENS-tunable, 0 disables) unless the tool
manages its own deliberate budget. Head+tail truncation, full output
spilled to the existing redacted spill dir and re-readable via
grep/read_file, so nothing is lost — only deferred.

This closes the three unbounded paths a single call could flood the
context window through (and then re-bill every following turn):
- web_fetch: up to 256KiB default / 2MiB max of non-HTML body
- skill: full SKILL.md returned uncapped
- MCP-served tools: no budget at all

Self-budgeting tools opt out via an unexported marker interface (an MCP
server can never exempt itself): bash, exec_command (model-raisable),
read_file, read_minified_file, grep, glob, list_directory. Browser
snapshots deliberately stay under the net.

bash itself is tightened to match the unified token model: emit budget
96KiB -> 32KiB per stream (~24k -> ~8k tokens worst case per stream),
while capture retention stays at 96KiB head+tail so the new spill file
covers 6x more of the output than the transcript shows.

* perf(agent): collect post-edit LSP diagnostics off the tool-call path

Inline post-edit diagnostics blocked every edit_file/write_file result on
the language server: a >=300ms quiet-period debounce that reset on each
publish, seconds on the first edit per language, capped at 10s — the
largest recurring latency tax in an edit-heavy session.

Diagnostics are now collected in the background: a file is enqueued the
moment a mutating tool finishes, a single worker checks it while the rest
of the batch executes, and the loop drains completed results just before
building the NEXT provider request, appending errors as a user nudge.
The model always issues another request after an edit turn (to read its
tool results), so it still sees introduced errors at the same decision
point — no tool call ever stalls on the language server.

A file re-edited before its check runs is checked once in its final
state (the checker re-reads the file); re-edited after, it is re-queued
and the newer result wins. A drain that finds the worker still busy
gives up after 3s and delivers on a later turn — results stay accurate
because any further edit re-enqueues the file.

The tools-side inline mechanism (tools.RunOptions.Diagnostics) is kept
for direct API callers; the loop just no longer wires it.

* fix: address review — max-turns diagnostics drain, capture-gap marker, test hardening

- Drain background diagnostics before the max-turns final-answer request
  so an error introduced by the LAST turn's edit is reported in the
  summary instead of silently dropped (+ regression test).
- spillBashStreams marks the head/tail junction with a capture-gap
  marker when boundedBuffer dropped the middle of a stream, so a spilled
  log never reads as contiguous when it is not; junction position is
  asserted in the test.
- Tests override TMP/TEMP alongside TMPDIR (os.TempDir reads TMP/TEMP on
  Windows) via a shared setTestTempDir helper.
- TestBudgetBashCaptureReportsTrueTotal now asserts spill_path meta and
  spill file content; exemption-list test gains read_minified_file.

* fix(test): use a real workspace root in async diagnostics tests

filepath.IsAbs("/ws") is false on Windows (no drive letter), so the
collector test's workspace-absolute assertion failed on the windows
smoke job. Root the fake workspace at t.TempDir() — absolute on every
platform — and assert against it.

* fix: pattern-scrub spill files and gate finalization on pending diagnostics

Review follow-ups (Vasanthdev2004):

- Spill files now pass the pattern-based secret scanner (AWS keys,
  GitHub/Slack/OpenAI tokens, PEM blocks, JWTs) in addition to the
  configured-key scrub. A bash spill runs before formatBashOutput's
  scan, so without this a spilled file held in cleartext exactly the
  credentials the transcript redacts. Applied centrally in
  spillTruncatedOutput, covering bash, exec_command, and ceiling spills.

- Finalization diagnostics gate: a no-tool-call final answer now drains
  pending post-edit diagnostics with the full inline-era budget (10s,
  vs the 3s per-turn wait) before the run is accepted; introduced
  errors append the nudge and give the model one more turn instead of
  being silently dropped when no later turn exists. The max-turns
  summary path uses the same finalization budget. Free for runs that
  never edited — an idle collector returns immediately.
2026-07-05 23:50:31 +08:00
KRATOS 284a6c86cb Skills: deterministic invocation — /<skill-name>, searchable /skills picker, discovery-budget fix (#520)
* feat(skills): make skill invocation deterministic and discoverable

Skills were model-pulled only: the model had to notice a skill in a
640-byte system-prompt list (~6 skills visible; the rest collapsed into
'…and N more') and call the skill tool on its own. Users could not run a
skill at all — typing /<skill-name> reported 'unknown command'. The two
halves of the fix:

User invocation
- /<skill-name> [args] now runs an installed skill directly: resolved
  after builtins and user commands, the skill body is inlined as the
  prompt with the typed args appended, and submitted through the same
  launch path user commands use.
- /skills lists installed skills with their slash form, live from disk.
  Labels are honest: a skill shadowed by a builtin, alias, user command,
  or a case-colliding sibling is listed bare with a '(via skill tool)'
  note instead of advertising a dead /name.
- The slash palette suggests skills (labeled '(skill)') read through the
  same loader dispatch uses, so mid-session installs appear and shadowed
  or case-colliding names are never advertised.
- Skill and user-command invocations now apply the plain-prompt run-state
  guards: mid-run submissions queue the EXPANDED prompt (the queue flush
  submits literal text), compaction-in-flight warns, exiting drops.
- Skills resolve through a lazy plugin-aware loader (default dir merged
  with plugin skill roots — the same set the skill tool and the system
  prompt see), TTL-cached so palette keystrokes don't re-read SKILL.md
  bodies.

Model triggering
- Raise the skills-list budget 640 -> 4096 bytes: the list is the model's
  only discovery surface, and the old cap made skills past ~6 invisible —
  invocation reliability collapsed exactly for users invested in skills.
- Raise description truncation 100 -> 200 runes: the description carries
  the trigger conditions ('use when…'); cutting them is what stopped the
  model from firing the skill.
- Strengthen the instruction line: scan the list before acting, call the
  matching skill FIRST, don't substitute your own approach.

* feat(tui): /skills opens a searchable skill picker

Replace the static /skills card with the same picker UX /model uses:
type-to-filter (ranked search), arrow-key selection, Enter runs the
chosen skill immediately. Selection is by the skill's exact name, so
skills the slash path cannot reach — names shadowed by a builtin or
user command, or names that don't fit a slash token — are still
runnable from the picker, where the choice is unambiguous. The text
card remains only as the no-skills install hint, and the picker path
applies the same run-state guards as slash invocation.

* feat(tui): ask-first guard for bare skill invocations

A skill invoked with no request submits only its body — instructions
with no target ("review the PR" — which PR?) — and the model improvises
a target instead of asking, which is especially wrong in a fresh
session. Bare invocations (slash and picker) now append a conditional
note: if the instructions need a target or details not already clear
from the conversation, ask first; self-contained skills still just run.
Invocations that carry a request are unchanged.

* fix(tui): review follow-ups — restore typed invocation on compaction guard, cap merged suggestions

- The compaction-in-flight guard for skill/user-command invocations fired
  after the dispatch path had already cleared the composer, so the typed
  "/name args" was lost and had to be retyped; thread the raw invocation
  through and restore it into the composer (the plain-prompt guard keeps
  the composer intact by firing before the clear). The picker path has no
  typed form and restores nothing.
- Cap the merged autocomplete list at the shared bound: each source
  self-caps, but three sources could stack to ~3x the palette limit.
- Tighten the shadowed-skill palette test to assert on the advertised
  Name rather than the fallback Desc wording.

* chore: ignore zero-skills local build artifact

A locally built test binary under this name was accidentally committed
(and has been scrubbed from the branch history); ignore the name so it
cannot recur.
2026-07-05 21:14:53 +05:30
8f3b8f0d13 docs: document AGENTS.md/ZERO.md and plugin manifest format (#522)
* docs: document AGENTS.md/ZERO.md and plugin manifest format

Add an "Extending Zero" section to the README covering the
AGENTS.md/ZERO.md project-instructions lookup chain and the
plugin.json manifest fields, neither of which were previously
documented outside the source.

Closes #521

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

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <[email protected]>

* docs: fix plugin path scope and add per-user ZERO.md guidelines

- Correct project-scope plugin discovery to <cwd>/.zero/plugins, not
  the repo root.
- Clarify that user-scope plugin discovery always resolves under
  ~/.config regardless of OS, separate from config.UserConfigDir()
  (which is OS-aware and used for the per-user ZERO.md file).
- Document the per-user ZERO.md guidelines file added in #475.

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

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <[email protected]>

---------

Co-authored-by: Claude Sonnet 5 <[email protected]>
Co-authored-by: Copilot Autofix powered by AI <[email protected]>
2026-07-05 22:51:20 +08:00
98 changed files with 8727 additions and 302 deletions

No files matched your search

+14 -3
View File
@@ -100,9 +100,10 @@ jobs:
go-version-file: go.mod
cache: true
# govulncheck and deadcode resolve a toolchain from their own modules, which
# can drop below the version go.mod requires and then fail to load our
# packages. Pin GOTOOLCHAIN to the go.mod toolchain so both run under it.
# govulncheck, deadcode, and golangci-lint each resolve a toolchain from
# their own modules, which can drop below the version go.mod requires and
# then fail to load our packages. Pin GOTOOLCHAIN to the go.mod toolchain
# so all three run under it.
- name: Pin toolchain from go.mod
run: |
toolchain="$(awk '/^toolchain /{print $2}' go.mod)"
@@ -120,3 +121,13 @@ jobs:
- name: deadcode (advisory)
continue-on-error: true
run: go run golang.org/x/tools/cmd/[email protected] -test=false ./...
# Advisory: catches what deadcode's whole-program reachability analysis
# doesn't, unused private functions/assignments reachable within a
# package but never actually called, plus staticcheck-style correctness
# and readability issues. Scoped to a few linters rather than the full
# default battery, and non-blocking, while the existing findings across
# the repo are cleaned up incrementally (see #527).
- name: golangci-lint (advisory)
continue-on-error: true
run: go run github.com/golangci/golangci-lint/v2/cmd/[email protected] run --enable-only unused,ineffassign,staticcheck ./...
+1
View File
@@ -37,6 +37,7 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
/zero
/zero.exe
/zero-skills
/snake-game.html
# Superpowers skill artifacts (brainstorm mockups, specs, plans) — local only
+1 -1
View File
@@ -1 +1 @@
{".":"0.1.0"}
{".":"0.2.0"}
+65
View File
@@ -6,6 +6,71 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html) once the first release is
tagged. Until then, source builds report the version `dev`.
## [0.2.0](https://github.com/Gitlawb/zero/compare/v0.1.0...v0.2.0) (2026-07-06)
### Features
* add --auto flag for LLM-generated commit messages ([#423](https://github.com/Gitlawb/zero/issues/423)) ([b0abde7](https://github.com/Gitlawb/zero/commit/b0abde7d0697e808480cd59d69a6f4d0c6320475))
* add zero changes push and pr subcommands, and extra repo-info metrics ([#391](https://github.com/Gitlawb/zero/issues/391)) ([2312abe](https://github.com/Gitlawb/zero/commit/2312abe5ddd95f4c6ef373cfb61cc03092f48cdd))
* agent quality, caching, retry, and tooling upgrades ([#506](https://github.com/Gitlawb/zero/issues/506)) ([3c81fea](https://github.com/Gitlawb/zero/commit/3c81fea22873ee3df7fc97b10cb4f77792706c4b))
* **agent:** curb over-engineering the solution in the editing discipline ([#517](https://github.com/Gitlawb/zero/issues/517)) ([f4c998a](https://github.com/Gitlawb/zero/commit/f4c998ac30c4f07ff313a2d706791e857293be49)), closes [#516](https://github.com/Gitlawb/zero/issues/516)
* **agent:** inject per-user config.UserConfigDir()/zero/ZERO.md guidelines into system prompt ([#475](https://github.com/Gitlawb/zero/issues/475)) ([7b10aab](https://github.com/Gitlawb/zero/commit/7b10aab74bf14a01166b2cea22deab79bba9850b))
* **openai:** forward prompt_cache_key for server-side prefix cache routing ([#515](https://github.com/Gitlawb/zero/issues/515)) ([87e7e69](https://github.com/Gitlawb/zero/commit/87e7e69afd18b5539579856f3a61c6a95bc445ae))
* **providers:** add `zero providers models` to discover a provider's models ([#386](https://github.com/Gitlawb/zero/issues/386)) ([0bc8074](https://github.com/Gitlawb/zero/commit/0bc8074c97b0310e4a9d70c3f967003ee5e8a59f))
* **providers:** add KiloCode and OpenCode provider support ([#388](https://github.com/Gitlawb/zero/issues/388)) ([b1ccb6d](https://github.com/Gitlawb/zero/commit/b1ccb6d9c1875377f5e5ea81a1304edd1e41ab4f))
* **providers:** add Meituan LongCat catalog preset ([#424](https://github.com/Gitlawb/zero/issues/424)) ([b4275e3](https://github.com/Gitlawb/zero/commit/b4275e350472b2490212bf814709819d354c1216))
* **providers:** split minimax zai into intl cn ([#398](https://github.com/Gitlawb/zero/issues/398)) ([aaad4d2](https://github.com/Gitlawb/zero/commit/aaad4d271270f41af837b6f3b60ae80beba0c645))
* require manual approval before npm publish + drop release-as pin ([#369](https://github.com/Gitlawb/zero/issues/369)) ([bd89a1f](https://github.com/Gitlawb/zero/commit/bd89a1f451643c1b65ec803070abc7b116631ebe))
* **sandbox:** unelevated Windows fallback tier instead of prompts-only degrade ([#427](https://github.com/Gitlawb/zero/issues/427)) ([b9ddd6f](https://github.com/Gitlawb/zero/commit/b9ddd6f42138312a1fee8d8bb67c46c8eb1dea2f))
* support shift enter for composer newlines ([#462](https://github.com/Gitlawb/zero/issues/462)) ([daf65e0](https://github.com/Gitlawb/zero/commit/daf65e0af9a040314d4ab337b0ad59c55416b7bc))
* **tui:** /loop — repeat a prompt or command on an interval or self-paced ([#502](https://github.com/Gitlawb/zero/issues/502)) ([387fe67](https://github.com/Gitlawb/zero/commit/387fe67ee7cd81317c9c969f5906a4437080fea3))
* **tui:** add search/filter to provider picker in setup wizard ([#400](https://github.com/Gitlawb/zero/issues/400)) ([2fcea71](https://github.com/Gitlawb/zero/commit/2fcea71778d23e050c93409c471aef45b68c1621))
* **update:** add zero upgrade command to apply self-updates ([#461](https://github.com/Gitlawb/zero/issues/461)) ([5f36349](https://github.com/Gitlawb/zero/commit/5f36349c1884e81fa9bc66bb5fe813b627e897b7))
### Bug Fixes
* **action:** keep provider key scoped to zero step ([#448](https://github.com/Gitlawb/zero/issues/448)) ([407a927](https://github.com/Gitlawb/zero/commit/407a92739ff508cba32d2c12b3f36f0efcdd54c3))
* add android platform support for Termux npm install ([#455](https://github.com/Gitlawb/zero/issues/455)) ([9bd93c6](https://github.com/Gitlawb/zero/commit/9bd93c62f8d57fb74057284aa66a1b6e1429dcdd)), closes [#449](https://github.com/Gitlawb/zero/issues/449)
* **agent:** reject a malformed additional_permissions payload before prompting ([#453](https://github.com/Gitlawb/zero/issues/453)) ([e4f760e](https://github.com/Gitlawb/zero/commit/e4f760ee8bd57299cd2fcb37e8e23130037c2607))
* allow non-TLS connections to private-network provider endpoints ([#444](https://github.com/Gitlawb/zero/issues/444)) ([1d86384](https://github.com/Gitlawb/zero/commit/1d8638466ca31517eb9db2b9353d3dce1cbeeabc))
* **auth:** route zero auth login chatgpt to the dedicated ChatGPT flow ([#443](https://github.com/Gitlawb/zero/issues/443)) ([305a62c](https://github.com/Gitlawb/zero/commit/305a62c954ca6cec00bc58d5398f933415156aff))
* **config:** fall back to a usable saved provider instead of forcing full re-onboarding ([#410](https://github.com/Gitlawb/zero/issues/410)) ([c60ad87](https://github.com/Gitlawb/zero/commit/c60ad8729f79bb841114d352ee2d2fe29d5d0e41))
* **config:** let a gateway ANTHROPIC_BASE_URL resolve as anthropic-compatible ([#497](https://github.com/Gitlawb/zero/issues/497)) ([30dd7c3](https://github.com/Gitlawb/zero/commit/30dd7c3112ad22d42fa12b5addd4e38f4beda42a)), closes [#479](https://github.com/Gitlawb/zero/issues/479)
* **config:** unbrick first-run setup — default google/anthropic models, enter setup on fixable config errors ([#385](https://github.com/Gitlawb/zero/issues/385)) ([72eed06](https://github.com/Gitlawb/zero/commit/72eed06b4f94c43d75d31fe54a58d2f566de059e))
* **config:** use ~/.config on macOS and enter setup when no provider ([#371](https://github.com/Gitlawb/zero/issues/371)) ([#372](https://github.com/Gitlawb/zero/issues/372)) ([027a8f2](https://github.com/Gitlawb/zero/commit/027a8f2768b17b89f5c8270887f156e2ccda69ea))
* **docs:** rename AGENTS.MD &gt; AGENTS.md ([#438](https://github.com/Gitlawb/zero/issues/438)) ([4266baf](https://github.com/Gitlawb/zero/commit/4266baf222df583ed2078b776687f12d496475b5))
* **gemini:** strip unsupported JSON Schema fields from tool declarations ([#374](https://github.com/Gitlawb/zero/issues/374)) ([39e7100](https://github.com/Gitlawb/zero/commit/39e7100674150144a1152e3110c64c7cf0321d64)), closes [#373](https://github.com/Gitlawb/zero/issues/373)
* **install:** persist install dir to user PATH on Windows ([#407](https://github.com/Gitlawb/zero/issues/407)) ([bdb1b0e](https://github.com/Gitlawb/zero/commit/bdb1b0ecd15859b1712a6037d296dace7f9c3c3f))
* **mcp:** block cross-origin credential redirects ([#396](https://github.com/Gitlawb/zero/issues/396)) ([f915f70](https://github.com/Gitlawb/zero/commit/f915f70e5a3096e2419fa8d961a0f84a626fa4a9))
* **oauth:** treat Windows ERROR_ACCESS_DENIED as lock contention in createSecretFile ([#445](https://github.com/Gitlawb/zero/issues/445)) ([d05e914](https://github.com/Gitlawb/zero/commit/d05e9148a7f79f67d1d3c31fca2775f21fbd331e))
* **openai:** handle Ollama reasoning stream deltas ([#486](https://github.com/Gitlawb/zero/issues/486)) ([f6c0606](https://github.com/Gitlawb/zero/commit/f6c060631e18e082dda24cc4dc0903c31c2120d6))
* preserve conversation context in exec prompts ([#460](https://github.com/Gitlawb/zero/issues/460)) ([949ee43](https://github.com/Gitlawb/zero/commit/949ee43f71e5cb7fab4695c5cb7b442fe4ecfbf7))
* **provider-wizard:** allow multiple custom OpenAI-compatible providers ([#403](https://github.com/Gitlawb/zero/issues/403)) ([3fbbd28](https://github.com/Gitlawb/zero/commit/3fbbd28e4c586822cc4312c86232d94befe56e87))
* **sandbox:** fix nested pipe creation under the Windows restricted token ([#456](https://github.com/Gitlawb/zero/issues/456)) ([563a6db](https://github.com/Gitlawb/zero/commit/563a6dbe91e65d5daeefd7626e8a77e30a6d8fb2))
* **sandbox:** gate /tmp test assertions on GOOS, not path existence ([#426](https://github.com/Gitlawb/zero/issues/426)) ([f653dca](https://github.com/Gitlawb/zero/commit/f653dcac363fb69ad7be5b35e6e0fa6d2bce476d))
* **sandbox:** self-heal a corrupt unelevated setup marker ([#437](https://github.com/Gitlawb/zero/issues/437)) ([8d0c5fe](https://github.com/Gitlawb/zero/commit/8d0c5feccb8bdbfb015df0508aa6e3bcbd1fd0e8))
* **specialist:** cap max specialist nesting depth ([#491](https://github.com/Gitlawb/zero/issues/491)) ([177442c](https://github.com/Gitlawb/zero/commit/177442cfe4015bd8df04cc9894f98b468ee796d4))
* Termux/Android support — PRoot scroll, SIGSYS sandbox, build docs ([#509](https://github.com/Gitlawb/zero/issues/509)) ([0f69d99](https://github.com/Gitlawb/zero/commit/0f69d995e9b586b774f66c066b21abab5e03024a))
* **tools:** Block MSYS coreutils under Windows sandbox ([#476](https://github.com/Gitlawb/zero/issues/476)) ([81aad58](https://github.com/Gitlawb/zero/commit/81aad58d97839e51c068e2f08907618991fdc3fb))
* **tools:** CRLF line ending mismatch in edit_file tool on Windows ([#378](https://github.com/Gitlawb/zero/issues/378)) ([33dc7ae](https://github.com/Gitlawb/zero/commit/33dc7ae2cc82c5389675531e1416856dae7151ce))
* **tools:** fix cmd.exe /S/C corrupting commands with embedded quotes ([#465](https://github.com/Gitlawb/zero/issues/465)) ([190241b](https://github.com/Gitlawb/zero/commit/190241bd593f43211b766e0b13c8e89802d4bb37))
* **tools:** flag piped POSIX utilities before running on Windows ([#412](https://github.com/Gitlawb/zero/issues/412)) ([5658a36](https://github.com/Gitlawb/zero/commit/5658a366274fc59a9d5336b06a21019c9c25cbf1))
* **tools:** make grep and glob respect run cancellation ([#464](https://github.com/Gitlawb/zero/issues/464)) ([ba6c026](https://github.com/Gitlawb/zero/commit/ba6c0264697b7d7ed479f6e782fba9700a481e3d))
* **tools:** require permission before web_search requests ([#382](https://github.com/Gitlawb/zero/issues/382)) ([960db96](https://github.com/Gitlawb/zero/commit/960db9660e4e31dc588fe8f7d6f116ff5e225566))
* **tui:** compose help overlay through the viewport overlay pipeline ([#421](https://github.com/Gitlawb/zero/issues/421)) ([5b2b4de](https://github.com/Gitlawb/zero/commit/5b2b4dea1aaf9e0f68baa25e97e83296fb17b1a2))
* **tui:** keep the profile name on /model switch so the stored key resolves ([#441](https://github.com/Gitlawb/zero/issues/441)) ([9134148](https://github.com/Gitlawb/zero/commit/9134148f4df3e4e556fba6c2f8babfdf6fcfeee1)), closes [#440](https://github.com/Gitlawb/zero/issues/440)
* **tui:** resolve every permission request so the agent can't deadlock ([#397](https://github.com/Gitlawb/zero/issues/397)) ([952788f](https://github.com/Gitlawb/zero/commit/952788f72d32957659fe004521fcc8372b9ba9b4))
* **tui:** show an M suffix for million-scale token counts ([#457](https://github.com/Gitlawb/zero/issues/457)) ([0562e3b](https://github.com/Gitlawb/zero/commit/0562e3bef7df2328610a48a1e81632a8da4aec64))
* **tui:** title /model rows by model name, not the catalog description ([#395](https://github.com/Gitlawb/zero/issues/395)) ([cdf9d83](https://github.com/Gitlawb/zero/commit/cdf9d839ae57a729f292f36f7c5b0c67b41b288d))
### Performance Improvements
* cache TUI model registry ([#496](https://github.com/Gitlawb/zero/issues/496)) ([e7d88b4](https://github.com/Gitlawb/zero/commit/e7d88b4b518049733da25a8447c00144bd1da518))
* universal tool-output ceiling with spill + async post-edit diagnostics ([#518](https://github.com/Gitlawb/zero/issues/518)) ([95ccd5b](https://github.com/Gitlawb/zero/commit/95ccd5bc327f6fb464ff0239f7229de789f578dc))
## 0.1.0 (2026-07-02)
+34
View File
@@ -186,6 +186,7 @@ Common slash commands:
| `/spec`, `/plan` | draft and review a plan before building |
| `/image` | attach an image for vision-capable models |
| `/resume`, `/rewind` | continue or roll back local sessions |
| `/loop` | repeat a prompt or custom `/command` on an interval (`/loop 5m /babysit-prs`) or self-paced |
| `/compact`, `/context` | manage context usage |
| `/permissions`, `/tools` | inspect available tools and policy |
| `/add-dir` | allow an extra write directory for this session |
@@ -283,6 +284,39 @@ zero cron scheduled agent jobs
zero update check for newer releases
```
## Extending Zero
### Project and personal instructions
Zero appends project-specific guidance to the system prompt from the first
`AGENTS.md`, `ZERO.md`, or `.zero/AGENTS.md` file found in each directory from
the git root down to your current working directory (checked in that order
per directory). Files are injected general-to-specific, capped at 8 KiB per
file and 32 KiB total.
A personal `ZERO.md` under `config.UserConfigDir()/zero/ZERO.md`
(`$XDG_CONFIG_HOME/zero/ZERO.md` or `~/.config/zero/ZERO.md` on Linux/macOS,
`%AppData%\Roaming\zero\ZERO.md` on Windows) applies across every workspace, ahead of any project guidelines.
### Plugins
Plugins are discovered from `~/.config/zero/plugins/<name>/plugin.json` (user
scope — `$XDG_CONFIG_HOME` or `~/.config` on every OS, independent of the
`config.UserConfigDir()` path used above) and `<cwd>/.zero/plugins/<name>/plugin.json`
(project scope — resolved from the current working directory, not the repo
root), and managed with `zero plugins`. A manifest can declare:
- `tools` — custom tools (`command`, `args`, `inputSchema`, and a
`permission` of `prompt` or `deny`; `allow` is honored only when manifest tool
auto-approval is enabled)
- `hooks` — commands run on `beforeTool`, `afterTool`, `sessionStart`, or
`sessionEnd`
- `prompts` and `skills` — additional prompt/skill files
MCP servers (`zero mcp`) and standalone markdown skills (`zero skills`) use
the same extension points and can also be wired up outside of a plugin
manifest.
## Appearance And Accessibility
| Control | Effect |
+4 -4
View File
@@ -180,6 +180,7 @@ runs:
ZERO_INPUT_ADD_DIR: ${{ inputs.add-dir }}
ZERO_INPUT_WORKTREE: ${{ inputs.worktree }}
ZERO_INPUT_OUTPUT_FORMAT: ${{ inputs.output-format }}
ACTION_PATH: ${{ github.action_path }}
RUNNER_TEMP: ${{ runner.temp }}
run: |
set -euo pipefail
@@ -247,10 +248,9 @@ runs:
echo "exit-code=${code}" >> "${GITHUB_OUTPUT}"
echo "output-file=${output_file}" >> "${GITHUB_OUTPUT}"
# Best-effort one-line summary: last non-empty line of the captured output.
summary="$(grep -v '^[[:space:]]*$' "${output_file}" | tail -n 1 || true)"
# Keep the summary single-line and bounded for the step output.
summary="$(printf '%s' "${summary}" | cut -c1-280)"
# Best-effort one-line summary. Structured output ends with lifecycle
# metadata, so parse final/error events instead of tailing stdout.
summary="$(node "${ACTION_PATH}/scripts/action-summary.mjs" "${ZERO_INPUT_OUTPUT_FORMAT}" "${output_file}" || true)"
{
echo "summary<<ZERO_EOF"
printf '%s\n' "${summary}"
+54 -21
View File
@@ -5,6 +5,7 @@ import (
"encoding/base64"
"encoding/json"
"errors"
"log"
"strings"
"sync"
@@ -159,8 +160,14 @@ func (a *Agent) handleSessionLoad(_ context.Context, params json.RawMessage) (an
// Load history BEFORE publishing the session so no concurrent prompt observes
// a half-initialized session (registerSession sets history under the lock and
// reuses an already-live session rather than orphaning its in-flight turn).
history := a.loadHistory(meta.SessionID)
history, historyErr := a.loadHistory(meta.SessionID)
sess := a.registerSession(meta.SessionID, root, history)
a.warnPersistence(
&notifier{conn: a.conn, sessionID: sess.id},
"load session history",
"Could not load session history. The session is open, but earlier turns may be missing until storage recovers.",
historyErr,
)
return LoadSessionResult{
Modes: a.modeState(sess),
}, nil
@@ -253,7 +260,14 @@ func (a *Agent) runTurn(ctx context.Context, sess *acpSession, userText string,
if stopErr != nil {
return "", RPCError(codeInternalError, stopErr.Error())
}
a.persistTurn(sess, userText, result.FinalAnswer)
if err := a.persistTurn(sess, userText, result.FinalAnswer); err != nil {
a.warnPersistence(
note,
"save session history",
"Could not save session history. This turn is available in memory, but future resume may miss it until storage recovers.",
err,
)
}
return reason, nil
}
@@ -386,29 +400,34 @@ func (a *Agent) modeState(s *acpSession) *SessionModeState {
// ---- persistence + continuity ----
func (a *Agent) persistTurn(sess *acpSession, user, assistant string) {
if a.deps.Store != nil {
_, _ = a.deps.Store.AppendEvent(sess.id, sessions.AppendEventInput{
Type: sessions.EventMessage,
Payload: map[string]any{"role": "user", "content": user},
})
if assistant != "" {
_, _ = a.deps.Store.AppendEvent(sess.id, sessions.AppendEventInput{
Type: sessions.EventMessage,
Payload: map[string]any{"role": "assistant", "content": assistant},
})
}
}
sess.appendHistory(turnRecord{user: user, assistant: assistant})
}
func (a *Agent) loadHistory(sessionID string) []turnRecord {
func (a *Agent) persistTurn(sess *acpSession, user, assistant string) error {
defer sess.appendHistory(turnRecord{user: user, assistant: assistant})
if a.deps.Store == nil {
return nil
}
events := []sessions.AppendEventInput{
{
Type: sessions.EventMessage,
Payload: map[string]any{"role": "user", "content": user},
},
}
if assistant != "" {
events = append(events, sessions.AppendEventInput{
Type: sessions.EventMessage,
Payload: map[string]any{"role": "assistant", "content": assistant},
})
}
_, err := a.deps.Store.AppendEvents(sess.id, events)
return err
}
func (a *Agent) loadHistory(sessionID string) ([]turnRecord, error) {
if a.deps.Store == nil {
return nil, nil
}
events, err := a.deps.Store.ReadEvents(sessionID)
if err != nil {
return nil
return nil, err
}
var records []turnRecord
var pendingUser string
@@ -444,7 +463,21 @@ func (a *Agent) loadHistory(sessionID string) []turnRecord {
if havePending {
records = append(records, turnRecord{user: pendingUser})
}
return records
return records, nil
}
func (a *Agent) warnPersistence(note *notifier, action string, message string, err error) {
if err == nil {
return
}
sessionID := ""
if note != nil {
sessionID = note.sessionID
}
log.Printf("zero acp: failed to %s for session %s: %v", action, sessionID, err)
if note != nil {
note.text("\n\n[zero warning] " + message + "\n")
}
}
// buildPrompt prepends prior conversation as context, since agent.Run drives a
+72 -1
View File
@@ -5,6 +5,8 @@ import (
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"testing"
"time"
@@ -242,8 +244,77 @@ func TestACPRejectsInvalidCwd(t *testing.T) {
}
}
func TestACPPromptWarnsWhenTurnPersistenceFails(t *testing.T) {
deps := testDeps(t)
h := newHarness(t, deps)
defer h.stop()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var newRes NewSessionResult
if err := h.client.Call(ctx, MethodSessionNew, NewSessionParams{Cwd: t.TempDir(), McpServers: []McpServer{}}, &newRes); err != nil {
t.Fatalf("session/new: %v", err)
}
metadataPath := filepath.Join(deps.Store.RootDir, newRes.SessionID, sessions.MetadataFile)
if err := os.Remove(metadataPath); err != nil {
t.Fatalf("remove metadata: %v", err)
}
var promptRes PromptResult
if err := h.client.Call(ctx, MethodSessionPrompt, PromptParams{
SessionID: newRes.SessionID,
Prompt: []ContentBlock{TextBlock("hi")},
}, &promptRes); err != nil {
t.Fatalf("session/prompt: %v", err)
}
if promptRes.StopReason != StopEndTurn {
t.Fatalf("stopReason = %q, want %q", promptRes.StopReason, StopEndTurn)
}
got := drainTextUntil(t, h.updates, func(text string) bool {
return strings.Contains(text, "Hello from ZERO") &&
strings.Contains(text, "Could not save session history")
})
if !strings.Contains(got, "Could not save session history") {
t.Fatalf("streamed text = %q, want persistence warning", got)
}
}
func TestACPLoadWarnsWhenHistoryReadFails(t *testing.T) {
deps := testDeps(t)
cwd := t.TempDir()
meta, err := deps.Store.Create(sessions.CreateInput{Title: "ACP session", Cwd: cwd})
if err != nil {
t.Fatalf("create session: %v", err)
}
eventsPath := filepath.Join(deps.Store.RootDir, meta.SessionID, sessions.EventsFile)
if err := os.WriteFile(eventsPath, []byte("{bad json}\n"), 0o600); err != nil {
t.Fatalf("write corrupt events: %v", err)
}
h := newHarness(t, deps)
defer h.stop()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := h.client.Call(ctx, MethodSessionLoad, LoadSessionParams{SessionID: meta.SessionID, Cwd: cwd, McpServers: []McpServer{}}, &LoadSessionResult{}); err != nil {
t.Fatalf("session/load: %v", err)
}
got := drainTextUntil(t, h.updates, func(text string) bool {
return strings.Contains(text, "Could not load session history")
})
if !strings.Contains(got, "Could not load session history") {
t.Fatalf("streamed text = %q, want load warning", got)
}
}
// drainText collects streamed chunks for a short window and concatenates them.
func drainText(t *testing.T, ch <-chan string) string {
t.Helper()
return drainTextUntil(t, ch, func(text string) bool {
return strings.Contains(text, "Hello from ZERO")
})
}
func drainTextUntil(t *testing.T, ch <-chan string, done func(string) bool) string {
t.Helper()
var b strings.Builder
deadline := time.After(2 * time.Second)
@@ -251,7 +322,7 @@ func drainText(t *testing.T, ch <-chan string) string {
select {
case s := <-ch:
b.WriteString(s)
if strings.Contains(b.String(), "Hello from ZERO") {
if done(b.String()) {
return b.String()
}
case <-deadline:
+185
View File
@@ -0,0 +1,185 @@
package agent
import (
"context"
"path/filepath"
"sort"
"strings"
"sync"
"time"
)
// Post-edit LSP diagnostics used to run synchronously inside edit_file /
// write_file: every edit blocked its tool result on a fresh-publish wait with
// a quiet-period debounce (≥300ms floor that reset on each publish, 10s cap) —
// the largest recurring latency tax in an edit-heavy session. Collection is
// now asynchronous: a file is enqueued the moment a mutating tool finishes, a
// single worker checks it while the rest of the tool batch (and any
// self-correct verification) runs, and the loop drains completed results just
// before building the NEXT provider request, appending errors as a user-role
// nudge. The model always issues another request after an edit turn — it has
// to read its tool results — so it still sees introduced errors at the same
// decision point as before, without any tool call stalling on the language
// server.
//
// A file re-edited before its check runs is checked once in its final state
// (the checker re-reads the file); a file re-edited after its check completed
// is re-enqueued and the newer result wins. When the worker is still busy at
// drain time, the drain gives up after a short wait and the results are
// delivered on the following turn instead — they stay accurate because any
// further edit re-enqueues the file.
// asyncDiagnosticsDrainTimeout bounds how long a turn waits on the in-flight
// check before deferring delivery to the next turn. Most checks finish well
// under it (debounce floor + analysis); a cold language server on a large
// repo does not get to stall the loop the way the old inline 10s cap could.
// A var so tests can shorten the wait.
var asyncDiagnosticsDrainTimeout = 3 * time.Second
// asyncDiagnosticsFinalDrainTimeout is the wait used when the run is about to
// FINALIZE (natural completion or the max-turns summary): there is no later
// turn to defer to, so the gate waits out the old inline-era budget rather
// than silently dropping an error the last edit introduced. A var so tests
// can shorten the wait.
var asyncDiagnosticsFinalDrainTimeout = 10 * time.Second
// asyncDiagnosticsNudge prefixes the drained diagnostics blocks; phrased like
// the old inline block so the model reacts the same way.
const asyncDiagnosticsNudge = "Diagnostics after your recent edits (fix any errors you introduced):\n"
type asyncDiagnostics struct {
check func(context.Context, string) string
workspaceRoot string
mu sync.Mutex
queue []string
queued map[string]bool
results map[string]string
// working is non-nil while the worker goroutine runs and is closed when it
// exits, so drain can wait for quiescence without polling.
working chan struct{}
}
// newAsyncDiagnostics returns nil when check is nil, and every method on a nil
// receiver is a no-op — callers wire it unconditionally.
func newAsyncDiagnostics(check func(context.Context, string) string, workspaceRoot string) *asyncDiagnostics {
if check == nil {
return nil
}
return &asyncDiagnostics{
check: check,
workspaceRoot: workspaceRoot,
queued: map[string]bool{},
results: map[string]string{},
}
}
// enqueue schedules changed files for a background check. Paths are
// workspace-relative from Result.ChangedFiles (absolute when under a granted
// extra write root), mirroring SelfCorrector's resolution.
func (diagnostics *asyncDiagnostics) enqueue(ctx context.Context, files []string) {
if diagnostics == nil || len(files) == 0 {
return
}
diagnostics.mu.Lock()
defer diagnostics.mu.Unlock()
for _, file := range files {
path := diagnostics.absPath(file)
if diagnostics.queued[path] {
continue
}
diagnostics.queued[path] = true
// A completed result for this file describes a pre-edit state now.
delete(diagnostics.results, path)
diagnostics.queue = append(diagnostics.queue, path)
}
if diagnostics.working == nil && len(diagnostics.queue) > 0 {
done := make(chan struct{})
diagnostics.working = done
go diagnostics.work(ctx, done)
}
}
func (diagnostics *asyncDiagnostics) work(ctx context.Context, done chan struct{}) {
defer close(done)
for {
diagnostics.mu.Lock()
if len(diagnostics.queue) == 0 || ctx.Err() != nil {
diagnostics.working = nil
diagnostics.mu.Unlock()
return
}
path := diagnostics.queue[0]
diagnostics.queue = diagnostics.queue[1:]
// Un-mark before checking so an edit that lands DURING the check
// re-enqueues the file and it is checked again in its final state.
delete(diagnostics.queued, path)
diagnostics.mu.Unlock()
block := diagnostics.check(ctx, path)
diagnostics.mu.Lock()
if block != "" {
diagnostics.results[path] = block
} else {
delete(diagnostics.results, path)
}
diagnostics.mu.Unlock()
}
}
// drain waits briefly for the worker to go quiet, then formats and consumes
// all completed error blocks as one nudge. Returns "" when there is nothing
// to report or the worker is still busy (results then surface next turn).
func (diagnostics *asyncDiagnostics) drain(ctx context.Context) string {
return diagnostics.drainWithin(ctx, asyncDiagnosticsDrainTimeout)
}
// drainFinal is drain with the finalization budget: called before the run's
// last model request, where an undelivered result would otherwise be lost.
func (diagnostics *asyncDiagnostics) drainFinal(ctx context.Context) string {
return diagnostics.drainWithin(ctx, asyncDiagnosticsFinalDrainTimeout)
}
func (diagnostics *asyncDiagnostics) drainWithin(ctx context.Context, timeout time.Duration) string {
if diagnostics == nil {
return ""
}
diagnostics.mu.Lock()
busy := diagnostics.working
diagnostics.mu.Unlock()
if busy != nil {
timer := time.NewTimer(timeout)
defer timer.Stop()
select {
case <-busy:
case <-timer.C:
return ""
case <-ctx.Done():
return ""
}
}
diagnostics.mu.Lock()
defer diagnostics.mu.Unlock()
if len(diagnostics.results) == 0 {
return ""
}
paths := make([]string, 0, len(diagnostics.results))
for path := range diagnostics.results {
paths = append(paths, path)
}
sort.Strings(paths)
blocks := make([]string, 0, len(paths))
for _, path := range paths {
blocks = append(blocks, diagnostics.results[path])
}
diagnostics.results = map[string]string{}
return asyncDiagnosticsNudge + strings.Join(blocks, "\n")
}
func (diagnostics *asyncDiagnostics) absPath(path string) string {
if filepath.IsAbs(path) {
return path
}
return filepath.Join(diagnostics.workspaceRoot, path)
}
+338
View File
@@ -0,0 +1,338 @@
package agent
import (
"context"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
func TestAsyncDiagnosticsNilCollectorNoOps(t *testing.T) {
var diagnostics *asyncDiagnostics
if diagnostics != newAsyncDiagnostics(nil, "/ws") {
t.Fatal("nil check must yield a nil collector")
}
diagnostics.enqueue(context.Background(), []string{"a.go"})
if nudge := diagnostics.drain(context.Background()); nudge != "" {
t.Fatalf("nil collector drain = %q, want empty", nudge)
}
}
func TestAsyncDiagnosticsCollectsAndDrainsOnce(t *testing.T) {
// A real absolute root: a literal "/ws" is not filepath.IsAbs on Windows
// (no drive letter), which is exactly what the assertions below check.
root := t.TempDir()
var mu sync.Mutex
var checked []string
check := func(_ context.Context, absPath string) string {
mu.Lock()
checked = append(checked, absPath)
mu.Unlock()
if filepath.Base(absPath) == "clean.go" {
return ""
}
return "ERR " + filepath.Base(absPath)
}
diagnostics := newAsyncDiagnostics(check, root)
diagnostics.enqueue(context.Background(), []string{"broken.go", "clean.go"})
nudge := diagnostics.drain(context.Background())
if !strings.HasPrefix(nudge, asyncDiagnosticsNudge) {
t.Fatalf("nudge missing prefix: %q", nudge)
}
if !strings.Contains(nudge, "ERR broken.go") || strings.Contains(nudge, "clean.go") {
t.Fatalf("nudge = %q, want broken.go errors only", nudge)
}
mu.Lock()
for _, path := range checked {
if !filepath.IsAbs(path) || !strings.HasPrefix(path, root+string(filepath.Separator)) {
t.Fatalf("check received %q, want path under workspace root %q", path, root)
}
}
mu.Unlock()
if second := diagnostics.drain(context.Background()); second != "" {
t.Fatalf("second drain = %q, want empty (results are consumed)", second)
}
}
// A check still running at drain time defers delivery to the next drain
// instead of blocking the turn.
func TestAsyncDiagnosticsSlowCheckDefersToNextDrain(t *testing.T) {
previous := asyncDiagnosticsDrainTimeout
asyncDiagnosticsDrainTimeout = 20 * time.Millisecond
defer func() { asyncDiagnosticsDrainTimeout = previous }()
release := make(chan struct{})
check := func(_ context.Context, absPath string) string {
<-release
return "ERR " + filepath.Base(absPath)
}
diagnostics := newAsyncDiagnostics(check, "/ws")
diagnostics.enqueue(context.Background(), []string{"slow.go"})
start := time.Now()
if nudge := diagnostics.drain(context.Background()); nudge != "" {
t.Fatalf("drain during in-flight check = %q, want empty", nudge)
}
if elapsed := time.Since(start); elapsed > time.Second {
t.Fatalf("drain blocked %v, want bounded by the (shortened) timeout", elapsed)
}
close(release)
deadline := time.Now().Add(2 * time.Second)
for {
if nudge := diagnostics.drain(context.Background()); nudge != "" {
if !strings.Contains(nudge, "ERR slow.go") {
t.Fatalf("deferred nudge = %q", nudge)
}
return
}
if time.Now().After(deadline) {
t.Fatal("deferred diagnostics never delivered")
}
time.Sleep(5 * time.Millisecond)
}
}
// Re-enqueueing a file after its check completed replaces the stale result.
func TestAsyncDiagnosticsReEditReplacesResult(t *testing.T) {
var mu sync.Mutex
response := "ERR old"
check := func(context.Context, string) string {
mu.Lock()
defer mu.Unlock()
return response
}
diagnostics := newAsyncDiagnostics(check, "/ws")
diagnostics.enqueue(context.Background(), []string{"a.go"})
waitForIdle(t, diagnostics)
mu.Lock()
response = "ERR new"
mu.Unlock()
diagnostics.enqueue(context.Background(), []string{"a.go"})
nudge := diagnostics.drain(context.Background())
if !strings.Contains(nudge, "ERR new") || strings.Contains(nudge, "ERR old") {
t.Fatalf("nudge = %q, want only the re-check result", nudge)
}
}
func waitForIdle(t *testing.T, diagnostics *asyncDiagnostics) {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for {
diagnostics.mu.Lock()
busy := diagnostics.working
diagnostics.mu.Unlock()
if busy == nil {
return
}
if time.Now().After(deadline) {
t.Fatal("worker never went idle")
}
time.Sleep(2 * time.Millisecond)
}
}
// changedFilesTool is a fake mutating tool reporting a changed file without
// touching the filesystem.
type changedFilesTool struct{}
func (changedFilesTool) Name() string { return "fake_edit" }
func (changedFilesTool) Description() string { return "test mutating tool" }
func (changedFilesTool) Parameters() tools.Schema {
return tools.Schema{Type: "object", Properties: map[string]tools.PropertySchema{}}
}
func (changedFilesTool) Safety() tools.Safety {
return tools.Safety{SideEffect: tools.SideEffectWrite, Permission: tools.PermissionAllow, Reason: "test"}
}
func (changedFilesTool) Run(context.Context, map[string]any) tools.Result {
return tools.Result{Status: tools.StatusOK, Output: "edited", ChangedFiles: []string{"main.go"}}
}
// End-to-end through Run: an edit turn does NOT carry diagnostics in the tool
// result (the old inline path), but the next request contains the background
// diagnostics nudge, before the model finalizes.
func TestRunDeliversAsyncDiagnosticsNudgeNextTurn(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(changedFilesTool{})
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "fake_edit"},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
{Type: zeroruntime.StreamEventDone},
},
{
{Type: zeroruntime.StreamEventText, Content: "done"},
{Type: zeroruntime.StreamEventDone},
},
},
}
root := t.TempDir()
var checkedPath string
var mu sync.Mutex
result, err := Run(context.Background(), "fix main.go", provider, Options{
Registry: registry,
Cwd: root,
FileDiagnostics: func(_ context.Context, absPath string) string {
mu.Lock()
checkedPath = absPath
mu.Unlock()
return "main.go:1:1 error: boom"
},
})
if err != nil {
t.Fatal(err)
}
if result.FinalAnswer != "done" {
t.Fatalf("final answer = %q", result.FinalAnswer)
}
if len(provider.requests) != 2 {
t.Fatalf("expected 2 provider turns, got %d", len(provider.requests))
}
// The tool result itself must NOT block on / embed diagnostics.
for _, message := range provider.requests[1].Messages {
if message.Role == zeroruntime.MessageRoleTool && strings.Contains(message.Content, "boom") {
t.Fatalf("diagnostics leaked into the tool result: %q", message.Content)
}
}
// The nudge must be present in the second request as a user message.
found := false
for _, message := range provider.requests[1].Messages {
if message.Role == zeroruntime.MessageRoleUser && strings.HasPrefix(message.Content, asyncDiagnosticsNudge) {
if !strings.Contains(message.Content, "boom") {
t.Fatalf("nudge missing diagnostics: %q", message.Content)
}
found = true
}
}
if !found {
t.Fatal("no async diagnostics nudge in the follow-up request")
}
mu.Lock()
defer mu.Unlock()
if want := filepath.Join(root, "main.go"); checkedPath != want {
t.Fatalf("checked path = %q, want %q", checkedPath, want)
}
}
// When the run hits the maxTurns ceiling right after an edit turn, the
// final-answer request must still carry the diagnostics nudge — otherwise an
// error introduced by the last edit would go unreported in the summary.
func TestRunDrainsAsyncDiagnosticsBeforeMaxTurnsFinalAnswer(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(changedFilesTool{})
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "fake_edit"},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
{Type: zeroruntime.StreamEventDone},
},
{
{Type: zeroruntime.StreamEventText, Content: "summary"},
{Type: zeroruntime.StreamEventDone},
},
},
}
result, err := Run(context.Background(), "fix main.go", provider, Options{
Registry: registry,
Cwd: t.TempDir(),
MaxTurns: 1,
FileDiagnostics: func(context.Context, string) string {
return "main.go:1:1 error: boom"
},
})
if err != nil {
t.Fatal(err)
}
if result.FinalAnswer != "summary" {
t.Fatalf("final answer = %q", result.FinalAnswer)
}
if len(provider.requests) != 2 {
t.Fatalf("expected the max-turns final-answer request, got %d requests", len(provider.requests))
}
found := false
for _, message := range provider.requests[1].Messages {
if message.Role == zeroruntime.MessageRoleUser && strings.HasPrefix(message.Content, asyncDiagnosticsNudge) {
found = true
}
}
if !found {
t.Fatal("max-turns final-answer request missing the diagnostics nudge")
}
}
// When the model finalizes while a slow check is still in flight (the
// per-turn drain missed it), the finalization gate must wait it out and give
// the model one more turn with the nudge instead of dropping the errors.
func TestRunFinalizationGateDeliversLateDiagnostics(t *testing.T) {
previous := asyncDiagnosticsDrainTimeout
asyncDiagnosticsDrainTimeout = time.Millisecond
defer func() { asyncDiagnosticsDrainTimeout = previous }()
registry := tools.NewRegistry()
registry.Register(changedFilesTool{})
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "fake_edit"},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
{Type: zeroruntime.StreamEventDone},
},
{
{Type: zeroruntime.StreamEventText, Content: "done"},
{Type: zeroruntime.StreamEventDone},
},
{
{Type: zeroruntime.StreamEventText, Content: "done after fix"},
{Type: zeroruntime.StreamEventDone},
},
},
}
result, err := Run(context.Background(), "fix main.go", provider, Options{
Registry: registry,
Cwd: t.TempDir(),
FileDiagnostics: func(context.Context, string) string {
// Slower than the (shortened) per-turn drain, well under the
// finalization budget.
time.Sleep(100 * time.Millisecond)
return "main.go:1:1 error: boom"
},
})
if err != nil {
t.Fatal(err)
}
if result.FinalAnswer != "done after fix" {
t.Fatalf("final answer = %q, want the post-nudge answer", result.FinalAnswer)
}
if len(provider.requests) != 3 {
t.Fatalf("expected a third request carrying the late nudge, got %d requests", len(provider.requests))
}
found := false
for _, message := range provider.requests[2].Messages {
if message.Role == zeroruntime.MessageRoleUser && strings.HasPrefix(message.Content, asyncDiagnosticsNudge) {
if !strings.Contains(message.Content, "boom") {
t.Fatalf("nudge missing diagnostics: %q", message.Content)
}
found = true
}
}
if !found {
t.Fatal("finalization gate did not deliver the late diagnostics nudge")
}
}
+33 -16
View File
@@ -2,6 +2,7 @@ package agent
import (
"fmt"
"runtime"
"strings"
"github.com/Gitlawb/zero/internal/sandbox"
@@ -27,10 +28,20 @@ func proposedCommandPrefix(toolName string, args map[string]any) []string {
}
return nil
}
for _, tokens := range segments {
if !knownSafeCommandSegment(tokens) && sandbox.ValidCommandPrefix(tokens) {
return append([]string(nil), tokens...)
// Only propose approving a prefix of one segment when every other segment
// in the command is independently known-safe. Once a prefix is approved,
// shellExecutionArgsForApproval escalates the whole command (every
// segment, not just the approved one) to bypass the sandbox, so offering a
// prefix that leaves an MSYS-prone (or otherwise unsafe) segment uncovered
// would let that segment run unsandboxed without ever being reviewed.
for index, tokens := range segments {
if knownSafeCommandSegment(tokens) {
continue
}
if !sandbox.ValidCommandPrefix(tokens) || !otherSegmentsKnownSafe(segments, index) {
return nil
}
return append([]string(nil), tokens...)
}
if len(segments) == 0 || !sandbox.ValidCommandPrefix(segments[0]) {
return nil
@@ -38,6 +49,20 @@ func proposedCommandPrefix(toolName string, args map[string]any) []string {
return append([]string(nil), segments[0]...)
}
// otherSegmentsKnownSafe reports whether every segment other than the one at
// skip is known-safe on its own.
func otherSegmentsKnownSafe(segments [][]string, skip int) bool {
for index, tokens := range segments {
if index == skip {
continue
}
if !knownSafeCommandSegment(tokens) {
return false
}
}
return true
}
func matchCommandPrefix(toolName string, args map[string]any, options Options) (sandbox.CommandPrefixGrant, bool, bool) {
if !isShellCommandTool(toolName) || options.Sandbox == nil {
return sandbox.CommandPrefixGrant{}, false, false
@@ -168,10 +193,6 @@ func cleanPrefixRule(prefix []string) []string {
return cleaned
}
func safeRequestedPrefix(prefix []string, command []string) bool {
return safeRequestedPrefixForSegments(prefix, [][]string{command})
}
func safeRequestedPrefixForSegments(prefix []string, segments [][]string) bool {
if len(prefix) == 0 || !sandbox.ValidCommandPrefix(prefix) {
return false
@@ -196,14 +217,6 @@ func safeRequestedPrefixForSegments(prefix []string, segments [][]string) bool {
return matched
}
func safeShellCommandTokens(command string) ([]string, bool) {
segments, ok := safeShellCommandSegments(command)
if !ok || len(segments) != 1 {
return nil, false
}
return segments[0], true
}
func safeShellCommandSegments(command string) ([][]string, bool) {
command = strings.TrimSpace(command)
if command == "" {
@@ -275,7 +288,11 @@ func knownSafeCommandSegment(command []string) bool {
if len(command) == 0 {
return false
}
switch commandName(command[0]) {
name := commandName(command[0])
if runtime.GOOS == "windows" && tools.MsysProneCommandName(name) {
return false
}
switch name {
case "cat", "cd", "cut", "echo", "expr", "false", "grep", "head", "id",
"ls", "nl", "paste", "pwd", "rev", "seq", "stat", "tail", "tr",
"true", "uname", "uniq", "wc", "which", "whoami":
+50 -1
View File
@@ -1,9 +1,11 @@
package agent
import (
"runtime"
"testing"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/tools"
)
func TestProposedCommandPrefixUsesSafeSimpleCommands(t *testing.T) {
@@ -35,12 +37,37 @@ func TestProposedCommandPrefixHonorsValidatedRequestedPrefix(t *testing.T) {
func TestProposedCommandPrefixSupportsSegmentedCommands(t *testing.T) {
got := proposedCommandPrefix("bash", map[string]any{"command": "ps aux | head -5"})
if runtime.GOOS == "windows" {
// head is MSYS-prone on Windows (#458), so proposedCommandPrefix must
// not offer "ps aux" as a reusable prefix here: approving it would
// escalate the whole command, including the uncovered head segment,
// to bypass the sandbox unreviewed. See
// TestProposedCommandPrefixRejectsPrefixLeavingUnsafeTailUncovered for
// the platform-independent regression coverage of this behavior.
if got != nil {
t.Fatalf("expected no prefix on Windows because head is MSYS-prone, got %#v", got)
}
return
}
want := []string{"ps", "aux"}
if !equalStringSlices(got, want) {
t.Fatalf("prefix = %#v, want %#v", got, want)
}
}
// TestProposedCommandPrefixRejectsPrefixLeavingUnsafeTailUncovered guards
// against proposedCommandPrefix offering to approve one segment of a
// multi-segment command (e.g. "ps aux") while a different segment (e.g. "npm
// install") is not known-safe. shellExecutionArgsForApproval escalates the
// entire command once any prefix is approved, so an uncovered unsafe segment
// would bypass the sandbox unreviewed. Uses npm, which is never known-safe on
// any platform, so the assertion does not depend on runtime.GOOS.
func TestProposedCommandPrefixRejectsPrefixLeavingUnsafeTailUncovered(t *testing.T) {
if got := proposedCommandPrefix("bash", map[string]any{"command": "ps aux && npm install"}); got != nil {
t.Fatalf("expected no prefix because npm segment is not known-safe, got %#v", got)
}
}
func TestProposedCommandPrefixHonorsRequestedPrefixAcrossSegments(t *testing.T) {
got := proposedCommandPrefix("bash", map[string]any{
"command": "git status --short && git status --branch",
@@ -120,13 +147,35 @@ func TestProposedCommandPrefixRejectsUnsafeLaunchers(t *testing.T) {
func TestMatchCommandPrefixCoversSegmentedCommandWithSafeTail(t *testing.T) {
engine := sandbox.NewEngine(sandbox.EngineOptions{WorkspaceRoot: t.TempDir()})
engine.GrantCommandPrefixForSession("bash", []string{"ps", "aux"})
// head is MSYS-prone on Windows (#458) and must not count as a known-safe tail.
command := "ps aux | head -5"
if runtime.GOOS == "windows" {
command = "ps aux | echo ok"
}
grant, ok, session := matchCommandPrefix("bash", map[string]any{"command": "ps aux | head -5"}, Options{Sandbox: engine})
grant, ok, session := matchCommandPrefix("bash", map[string]any{"command": command}, Options{Sandbox: engine})
if !ok || !session || !equalStringSlices(grant.Prefix, []string{"ps", "aux"}) {
t.Fatalf("match = %#v ok=%v session=%v, want session ps aux prefix", grant, ok, session)
}
}
func TestKnownSafeCommandSegmentRejectsMsysProneOnWindows(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("windows-only known-safe MSYS guard")
}
for _, command := range [][]string{{"head", "-5"}, {"cat", "file.txt"}, {"grep", "pat"}} {
if knownSafeCommandSegment(command) {
t.Fatalf("expected %q to be unsafe on Windows, got known-safe", command)
}
}
if !knownSafeCommandSegment([]string{"echo", "ok"}) {
t.Fatal("expected echo to remain known-safe on Windows")
}
if !tools.MsysProneCommandName("head") {
t.Fatal("expected head to be MSYS-prone")
}
}
func TestMatchCommandPrefixRejectsUncoveredSegment(t *testing.T) {
engine := sandbox.NewEngine(sandbox.EngineOptions{WorkspaceRoot: t.TempDir()})
engine.GrantCommandPrefixForSession("bash", []string{"ps", "aux"})
+58 -8
View File
@@ -137,6 +137,12 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
guards := newGuardState()
compactor := newCompactionState(options)
// Background post-edit diagnostics: files changed by mutating tools are
// checked off the tool-call critical path and any errors are appended as a
// nudge before the NEXT request (see async_diagnostics.go). nil when no
// FileDiagnostics callback is wired; every method no-ops on nil.
postEditDiagnostics := newAsyncDiagnostics(options.FileDiagnostics, options.Cwd)
// loaded tracks deferred-eligible tools the model has pulled via tool_search
// during THIS run. It is consulted by partitionTools each turn to expose a
// loaded tool's full schema; it lives only for the run (v1 within-run scope).
@@ -159,6 +165,17 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
for turn := 0; turn < maxTurns; turn++ {
result.Turns = turn + 1
// Deliver background post-edit diagnostics from the previous turn's edits
// BEFORE compaction so the nudge is part of the request being budgeted.
// A brief wait at most (asyncDiagnosticsDrainTimeout); an unfinished check
// simply delivers on a later turn.
if nudge := postEditDiagnostics.drain(ctx); nudge != "" {
messages = append(messages, zeroruntime.Message{
Role: zeroruntime.MessageRoleUser,
Content: nudge,
})
}
// Build the per-turn tool list first so proactive compaction can include
// the tool-definition tokens (they ride on every request) in its estimate.
// partitionTools depends only on registry/permissions/options/loaded, not on
@@ -474,6 +491,19 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
continue
}
}
// Finalization diagnostics gate: edits from this run may still have
// checks in flight — the per-turn drain waits only briefly and defers
// to "a later turn", but a final answer means there is no later turn.
// Wait out the full inline-era budget once; an introduced error gives
// the model one more turn to see (and fix) it instead of being lost.
// Free for runs that never edited: an idle collector returns "".
if nudge := postEditDiagnostics.drainFinal(ctx); nudge != "" {
messages = append(messages, zeroruntime.Message{
Role: zeroruntime.MessageRoleUser,
Content: nudge,
})
continue
}
result.FinalAnswer = collected.Text
result.Messages = copyMessages(messages)
return result, nil
@@ -590,11 +620,14 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
failureHint = toolFailureHint(call.Name, toolSchemaJSON(registry, call.Name), toolResult.Output)
}
// Post-edit self-correction: collect the files this successful mutating
// tool changed; verification runs once over the union after the batch.
// A read-only tool (no ChangedFiles) never contributes.
if options.SelfCorrect != nil && toolResult.Status == tools.StatusOK && len(toolResult.ChangedFiles) > 0 {
// Collect the files this successful mutating tool changed. Self-correct
// verification runs once over the union after the batch; background
// diagnostics start NOW so the language server analyzes while the rest
// of the batch executes. A read-only tool (no ChangedFiles) never
// contributes.
if toolResult.Status == tools.StatusOK && len(toolResult.ChangedFiles) > 0 {
changedFilesThisBatch = append(changedFilesThisBatch, toolResult.ChangedFiles...)
postEditDiagnostics.enqueue(ctx, toolResult.ChangedFiles)
}
}
@@ -677,7 +710,19 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
// mid-run, not a natural completion (a finished run returns via the no-tool-call
// success path before this). Under the headless completion gate that is INCOMPLETE,
// not success, so a run that loops to the turn limit isn't reported as done.
if answer, finalMessages, finishReason := finalAnswerAfterMaxTurns(ctx, provider, messages, options); strings.TrimSpace(answer) != "" {
//
// The final-answer call is one more model request, so diagnostics from the
// LAST turn's edits get a drain too — with the finalization budget, since
// there is no later turn to defer to — otherwise an error introduced by
// the final edit would go unreported in the summary.
if nudge := postEditDiagnostics.drainFinal(ctx); nudge != "" {
messages = append(messages, zeroruntime.Message{
Role: zeroruntime.MessageRoleUser,
Content: nudge,
})
}
finalExposed, _ := partitionToolsCached(registry, permissionMode, options, loaded, toolDefCache)
if answer, finalMessages, finishReason := finalAnswerAfterMaxTurns(ctx, provider, messages, finalExposed, options); strings.TrimSpace(answer) != "" {
result.FinalAnswer = answer
result.FinishReason = finishReason
result.Messages = copyMessages(finalMessages)
@@ -697,7 +742,7 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
return result, nil
}
func finalAnswerAfterMaxTurns(ctx context.Context, provider Provider, messages []zeroruntime.Message, options Options) (string, []zeroruntime.Message, string) {
func finalAnswerAfterMaxTurns(ctx context.Context, provider Provider, messages []zeroruntime.Message, toolDefs []zeroruntime.ToolDefinition, options Options) (string, []zeroruntime.Message, string) {
finalMessages := copyMessages(messages)
finalMessages = append(finalMessages, zeroruntime.Message{
Role: zeroruntime.MessageRoleUser,
@@ -708,6 +753,7 @@ func finalAnswerAfterMaxTurns(ctx context.Context, provider Provider, messages [
// transient hiccup doesn't drop the final summary (AUDIT-L1).
stream, err := streamWithReconnect(ctx, provider, zeroruntime.CompletionRequest{
Messages: copyMessages(finalMessages),
Tools: toolDefs,
ReasoningEffort: options.ReasoningEffort,
PromptCacheKey: options.SessionID,
}, reconnectNoticeFor(options))
@@ -1100,8 +1146,12 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
// Per-session file version tracker so write_file/edit_file refuse to clobber
// a file that changed on disk outside Zero since it was last read.
FileTracker: options.FileTracker,
// Inline post-edit diagnostics for mutating tools (nil = disabled).
Diagnostics: options.FileDiagnostics,
// Post-edit diagnostics are NOT forwarded to the tools: they used to run
// synchronously inside edit_file/write_file and block every mutating tool
// call on the language server (≥300ms debounce floor, 10s cap). The loop
// now checks changed files in the background and nudges the model before
// its next request instead (see async_diagnostics.go). The tools' inline
// mechanism (tools.RunOptions.Diagnostics) stays for direct API callers.
// Forward the run's operator tool filters so a filter-aware tool
// (tool_search) never discloses or loads an operator-hidden deferred tool.
EnabledTools: options.EnabledTools,
+14 -6
View File
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"runtime"
@@ -1945,6 +1946,11 @@ func TestRunCommandPrefixApprovalBypassesSandboxForMatchingShellCalls(t *testing
func TestRunCommandPrefixApprovalCoversSegmentedShellWithSafeTail(t *testing.T) {
root := t.TempDir()
segmentedCommand := `ps aux | head -5`
if runtime.GOOS == "windows" {
// head is MSYS-prone on Windows (#458) and no longer counts as a known-safe tail.
segmentedCommand = `ps aux | echo ok`
}
retryTool := &sandboxDeniedRetryTool{}
registry := tools.NewRegistry()
registry.Register(retryTool)
@@ -1958,7 +1964,7 @@ func TestRunCommandPrefixApprovalCoversSegmentedShellWithSafeTail(t *testing.T)
},
{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-2", ToolName: "bash"},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-2", ArgumentsFragment: `{"command":"ps aux | head -5"}`},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-2", ArgumentsFragment: fmt.Sprintf(`{"command":%q}`, segmentedCommand)},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-2"},
{Type: zeroruntime.StreamEventDone},
},
@@ -2762,11 +2768,11 @@ func TestRunRequestsFinalAnswerAfterMaxTurns(t *testing.T) {
t.Fatalf("expected final answer from finalization turn, got %q", result.FinalAnswer)
}
if len(provider.requests) != 2 {
t.Fatalf("expected final no-tools request after max turns, got %d requests", len(provider.requests))
t.Fatalf("expected finalization request after max turns, got %d requests", len(provider.requests))
}
finalRequest := provider.requests[1]
if len(finalRequest.Tools) != 0 {
t.Fatalf("finalization request must not advertise tools, got %#v", finalRequest.Tools)
if toolDefinitionByName(finalRequest.Tools, "read_file") == nil {
t.Fatalf("finalization request must keep prior tools exposed for strict provider replay, got %#v", finalRequest.Tools)
}
lastMessage := finalRequest.Messages[len(finalRequest.Messages)-1]
if lastMessage.Role != zeroruntime.MessageRoleUser || !strings.Contains(lastMessage.Content, "tool-turn limit") {
@@ -2907,8 +2913,10 @@ func TestBuildSystemPromptInjectsHostShellContext(t *testing.T) {
t.Fatalf("expected operating system in environment block, got %q", prompt)
}
if runtime.GOOS == "windows" {
if !strings.Contains(prompt, "Windows cmd.exe syntax") || !strings.Contains(prompt, "cwd argument") {
t.Fatalf("expected Windows cmd.exe shell guidance in prompt, got %q", prompt)
for _, want := range []string{"Windows cmd.exe syntax", "cwd argument", "MSYS binaries", "grep", "require_escalated"} {
if !strings.Contains(prompt, want) {
t.Fatalf("expected Windows shell guidance to mention %q, got %q", want, prompt)
}
}
} else if !strings.Contains(prompt, "/bin/sh syntax") {
t.Fatalf("expected POSIX shell guidance in prompt, got %q", prompt)
@@ -34,7 +34,7 @@ func TestPartitionToolsActiveAppendsLoadedToolAfterEagerBlock(t *testing.T) {
t.Fatalf("expected read_file, tool_search and loaded alpha all exposed, got %#v", exposed)
}
// The eager tools must precede the loaded deferred tool (appended, not inserted).
if !(readPos < alphaPos && searchPos < alphaPos) {
if readPos >= alphaPos || searchPos >= alphaPos {
t.Fatalf("loaded deferred tool must be appended after the eager block; got read=%d search=%d alpha=%d", readPos, searchPos, alphaPos)
}
// beta was never loaded — it stays hidden.
+30 -5
View File
@@ -7,14 +7,18 @@ import (
)
func TestSkillsContextCapsLongList(t *testing.T) {
skills := make([]SkillInfo, 0, 40)
for i := 0; i < 40; i++ {
// 60 skills with realistic trigger-rich descriptions (~180 chars each, under
// the 200-rune truncation) total well past the 4096-byte list budget, so the
// overflow summary must kick in while the block stays bounded.
longDesc := strings.Repeat("use when the request touches deployments, release notes, or verification; ", 2) + "number "
skills := make([]SkillInfo, 0, 60)
for i := 0; i < 60; i++ {
n := strconv.Itoa(i)
skills = append(skills, SkillInfo{Name: "skill-" + n, Description: "does something useful, number " + n})
skills = append(skills, SkillInfo{Name: "skill-" + n, Description: longDesc + n})
}
got := skillsContext(Options{Skills: skills})
if len(got) > 1200 {
t.Fatalf("skills block should stay bounded, got %d bytes:\n%s", len(got), got)
if len(got) > skillsContextListBudget+1000 {
t.Fatalf("skills block should stay bounded near the %d-byte budget, got %d bytes:\n%s", skillsContextListBudget, len(got), got)
}
if !strings.Contains(got, "more (call skill") {
t.Fatalf("expected an overflow summary line, got:\n%s", got)
@@ -25,6 +29,27 @@ func TestSkillsContextCapsLongList(t *testing.T) {
}
}
// A realistic mid-size skill set (20 skills, trigger-rich descriptions) must be
// listed in FULL — no overflow summary. This pins the fix for the old 640-byte
// budget, under which skills past ~#6 were invisible to the model and therefore
// never triggered.
func TestSkillsContextListsRealisticSetInFull(t *testing.T) {
desc := "Use when the user asks about deployments, release notes, or pre-merge verification runs."
skills := make([]SkillInfo, 0, 20)
for i := 0; i < 20; i++ {
skills = append(skills, SkillInfo{Name: "skill-" + strconv.Itoa(i), Description: desc})
}
got := skillsContext(Options{Skills: skills})
if strings.Contains(got, "more (call skill") {
t.Fatalf("20 described skills must all be listed without overflow, got:\n%s", got)
}
for i := 0; i < 20; i++ {
if !strings.Contains(got, "- skill-"+strconv.Itoa(i)+":") {
t.Fatalf("skill-%d missing from the list:\n%s", i, got)
}
}
}
func TestSkillsContext(t *testing.T) {
if got := skillsContext(Options{}); got != "" {
t.Fatalf("no skills should yield an empty section, got %q", got)
+19 -8
View File
@@ -191,10 +191,19 @@ func specialistDelegationContext(options Options) string {
// when no skills are installed, so a skill-less run reproduces the previous prompt
// byte-for-byte.
// skillsContextListBudget bounds the bytes spent listing individual skills so a
// workspace with dozens of them can't bloat every turn's prompt. Skills past the
// budget are summarized as a count rather than dropped — the model can still load
// any of them by name (an unknown name returns the full list from the skill tool).
const skillsContextListBudget = 640
// workspace with an extreme number of them can't bloat every turn's prompt.
// Skills past the budget are summarized as a count rather than dropped — the
// model can still load any of them by name (an unknown name returns the full
// list from the skill tool).
//
// The budget is deliberately generous — roughly 18 skills with maximally long
// (200-rune, ASCII) descriptions, or 40+ with typical terser ones; non-ASCII
// descriptions consume it faster since the budget counts bytes. This list is
// the model's ONLY discovery surface, so a skill squeezed out of it effectively
// never triggers. The previous 640-byte cap silently broke invocation for
// anyone with more than ~6 skills — the model cannot match a request against a
// skill it cannot see.
const skillsContextListBudget = 4096
func skillsContext(options Options) string {
if len(options.Skills) == 0 {
@@ -202,7 +211,7 @@ func skillsContext(options Options) string {
}
var b strings.Builder
b.WriteString("<available_skills>\n")
b.WriteString("Reusable, on-demand instruction sets you can load with the skill tool. When a request matches one, call skill with its exact name to pull in its guidance before acting — do not guess names or skip a relevant skill.\n")
b.WriteString("Reusable, on-demand instruction sets you can load with the skill tool. Before acting on a request, scan this list; when the request matches a skill's name or description, call skill with its exact name FIRST and follow the loaded guidance — do not guess names, do not skip a matching skill, and do not substitute your own approach for its instructions.\n")
listed, spent, omitted := 0, 0, 0
for _, info := range options.Skills {
name := strings.TrimSpace(info.Name)
@@ -232,9 +241,11 @@ func skillsContext(options Options) string {
}
// truncateForSkillLine keeps a skill's one-line description short so a single
// verbose description can't dominate the skills-list budget.
// verbose description can't dominate the skills-list budget. The cap is roomy on
// purpose: the description carries the skill's trigger conditions ("use when…"),
// and truncating those is what stops the model from ever invoking the skill.
func truncateForSkillLine(desc string) string {
const maxDescRunes = 100
const maxDescRunes = 200
runes := []rune(desc)
if len(runes) <= maxDescRunes {
return desc
@@ -275,7 +286,7 @@ func workspaceContext(cwd string) string {
b.WriteString("Working directory: " + cwd + "\n")
b.WriteString("Operating system: " + runtime.GOOS + "\n")
if runtime.GOOS == "windows" {
b.WriteString("Shell syntax: Windows cmd.exe syntax for exec_command/bash tools; prefer the workdir/cwd argument instead of cd when changing directories.\n")
b.WriteString("Shell syntax: Windows cmd.exe syntax for exec_command/bash tools; prefer the workdir/cwd argument instead of cd when changing directories. Do not pipe to or invoke POSIX coreutils from Git for Windows (usr\\bin head/grep/tail/cat/...): they are MSYS binaries and fail under the write-restricted sandbox; use native Zero tools (grep, read_file, list_directory, glob) or cmd.exe findstr/more instead, or sandbox_permissions require_escalated only when host-level execution is truly required.\n")
} else {
b.WriteString("Shell syntax: /bin/sh syntax for exec_command/bash tools; prefer the workdir/cwd argument instead of cd when changing directories.\n")
}
+7 -5
View File
@@ -285,11 +285,13 @@ type Options struct {
// ceiling and the autonomy gate. nil disables it entirely (the loop is
// byte-identical to before). One instance per run — it holds attempt state.
SelfCorrect *SelfCorrector
// FileDiagnostics, when set, is passed to mutating tools so edit_file /
// write_file append error-severity language diagnostics for the file they
// just wrote directly to their tool output — the model sees an error it
// introduced in the same turn instead of a later verification pass. Build
// one with NewFileDiagnostics. nil disables inline diagnostics.
// FileDiagnostics, when set, checks files changed by mutating tools for
// error-severity language diagnostics IN THE BACKGROUND and appends any
// errors as a nudge before the model's next request — the model still sees
// an error it introduced at its next decision point, but no tool call ever
// blocks on the language server (the old inline path stalled every edit on
// a ≥300ms debounce, 10s cap). Build one with NewFileDiagnostics. nil
// disables post-edit diagnostics.
FileDiagnostics func(ctx context.Context, absPath string) string
// RequireCompletionSignal gates run completion for HEADLESS exec. Without it,
+33
View File
@@ -12,6 +12,7 @@ import (
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
"github.com/Gitlawb/zero/internal/agent"
@@ -730,6 +731,7 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
lastKnownMCPConfig := mcpConfig
return deps.runTUI(context.Background(), tui.Options{
Cwd: workspaceRoot,
Version: version,
Theme: theme,
SavedTheme: resolved.Preferences.Theme,
UserConfigPath: userConfigPath,
@@ -782,6 +784,14 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
Specialists: specialistRuntime.specialists,
Skills: pluginActivation.skillInfos(deps.skillsDir()),
},
// LoadSkills backs /skills and direct /<skill-name> invocation in the TUI.
// It resolves against the same merged set (default dir + plugin skill
// roots) as the skill tool and the system-prompt list, re-read per use so
// newly installed skills work without a restart.
LoadSkills: cachedSkillsLoader(func() []skills.Skill {
merged, _ := plugins.MergedSkillsLoaded(deps.skillsDir(), pluginActivation.skillRoots)
return merged
}),
PermissionMode: permissionMode,
Notify: resolved.Notify,
KeyBindings: resolved.KeyBindings,
@@ -1277,3 +1287,26 @@ Flags:
`)
return err
}
// cachedSkillsLoader memoizes a skills loader for a short interval. The TUI
// calls the loader from autocomplete on every "/x" keystroke; without a cache
// each keystroke would re-read every SKILL.md body. Two seconds is fresh enough
// that a newly installed skill still shows up "immediately" while typing.
func cachedSkillsLoader(load func() []skills.Skill) func() []skills.Skill {
var (
mu sync.Mutex
at time.Time
cached []skills.Skill
)
const ttl = 2 * time.Second
return func() []skills.Skill {
mu.Lock()
defer mu.Unlock()
if cached != nil && time.Since(at) < ttl {
return cached
}
cached = load()
at = time.Now()
return cached
}
}
+45 -2
View File
@@ -11,10 +11,46 @@ import (
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/oauth"
"github.com/Gitlawb/zero/internal/providercatalog"
"github.com/Gitlawb/zero/internal/provideroauth"
"github.com/Gitlawb/zero/internal/redaction"
)
// ensureLoginProviderProfile makes a freshly stored OAuth login visible as a
// provider: without a profile in config.json the login shows up nowhere — not in
// `zero providers list`, not in the TUI picker — and `zero providers use <name>`
// fails "not found", so the user's working active provider looks broken while
// the new login looks lost. Returns user-facing guidance lines ("" when the
// login has no catalog entry to scaffold from). Best-effort by design: the
// token is already stored, so a profile-write failure degrades to a warning
// line instead of failing a login that succeeded.
func ensureLoginProviderProfile(deps appDeps, provider string) string {
if _, ok := providercatalog.Get(provider); !ok {
// Custom OAuth server without a catalog entry — no endpoint/model defaults
// to scaffold a profile from; the user wires their own profile.
return ""
}
configPath, err := deps.userConfigPath()
if err != nil {
return "warning: login saved, but no provider profile was written: " + err.Error()
}
ensured, err := config.EnsureCatalogProvider(configPath, provider)
if err != nil {
return "warning: login saved, but no provider profile was written: " + err.Error()
}
active := strings.EqualFold(strings.TrimSpace(ensured.Active), strings.TrimSpace(ensured.Name))
switch {
case ensured.Created && active:
return fmt.Sprintf("Added provider %q to your config and set it active.", ensured.Name)
case ensured.Created:
return fmt.Sprintf("Added provider %q to your config; the active provider is still %q.\nSwitch with: zero providers use %s", ensured.Name, ensured.Active, ensured.Name)
case active:
return fmt.Sprintf("Provider %q is configured and active.", ensured.Name)
default:
return fmt.Sprintf("Provider %q is already configured.\nSwitch with: zero providers use %s", ensured.Name, ensured.Name)
}
}
// runAuth dispatches `zero auth <command>` for provider OAuth login. It is
// additive and independent of `zero mcp oauth` (MCP server auth), which is
// unchanged.
@@ -138,8 +174,10 @@ func runAuthChatGPT(args []string, stdout io.Writer, stderr io.Writer, deps appD
if _, err := fmt.Fprint(stdout, statuses); err != nil {
return exitCrash
}
if _, err := fmt.Fprint(stdout, "\nUse it with zero, e.g.:\n zero --provider chatgpt --model gpt-5.5\n"); err != nil {
return exitCrash
if line := ensureLoginProviderProfile(deps, "chatgpt"); line != "" {
if _, err := fmt.Fprintf(stdout, "\n%s\n", line); err != nil {
return exitCrash
}
}
return exitSuccess
}
@@ -331,6 +369,11 @@ func runAuthLogin(args []string, stdout io.Writer, stderr io.Writer, deps appDep
if _, err := fmt.Fprintf(stdout, "Logged in to %s.\n%s\n", parsed.positional[0], oauth.FormatStatuses([]oauth.Status{status})); err != nil {
return exitCrash
}
if line := ensureLoginProviderProfile(deps, provider); line != "" {
if _, err := fmt.Fprintln(stdout, line); err != nil {
return exitCrash
}
}
return exitSuccess
}
+85 -1
View File
@@ -2,18 +2,24 @@ package cli
import (
"bytes"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/oauth"
)
// withAuthStore points the provider OAuth store at a temp file for the test.
// withAuthStore points the provider OAuth store at a temp file for the test,
// pinning the file backend so an inherited ZERO_OAUTH_STORAGE=keyring can't
// ignore the temp path and hit the OS keychain.
func withAuthStore(t *testing.T) string {
t.Helper()
path := filepath.Join(t.TempDir(), "oauth-tokens.json")
t.Setenv("ZERO_OAUTH_TOKENS_PATH", path)
t.Setenv("ZERO_OAUTH_STORAGE", "file")
return path
}
@@ -197,3 +203,81 @@ func TestRunAuthLoginChatGPTRejectsScope(t *testing.T) {
t.Fatalf("stderr = %q, want the ChatGPT-specific --scope rejection", stderr.String())
}
}
func TestEnsureLoginProviderProfileAddsProviderWithoutStealingActive(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
seed := `{"activeProvider":"opengateway","providers":[{"name":"opengateway","provider_kind":"openai-compatible","baseURL":"https://gateway.example.com/v1","apiKeyStored":true,"model":"some-model"}]}`
if err := os.WriteFile(configPath, []byte(seed), 0o600); err != nil {
t.Fatalf("seed config: %v", err)
}
deps := appDeps{userConfigPath: func() (string, error) { return configPath, nil }}
line := ensureLoginProviderProfile(deps, "chatgpt")
if !strings.Contains(line, `Added provider "chatgpt"`) {
t.Fatalf("expected added-provider guidance, got %q", line)
}
if !strings.Contains(line, "zero providers use chatgpt") {
t.Fatalf("expected switch hint, got %q", line)
}
cfg := readCLIConfigFixture(t, configPath)
if cfg.ActiveProvider != "opengateway" {
t.Fatalf("active provider changed to %q", cfg.ActiveProvider)
}
names := make([]string, 0, len(cfg.Providers))
for _, provider := range cfg.Providers {
names = append(names, provider.Name)
}
if len(cfg.Providers) != 2 || cfg.Providers[1].CatalogID != "chatgpt" {
t.Fatalf("expected chatgpt profile appended, got %v", names)
}
// A second login must be a no-op with switch guidance, not a duplicate.
line = ensureLoginProviderProfile(deps, "chatgpt")
if !strings.Contains(line, "already configured") {
t.Fatalf("expected already-configured guidance, got %q", line)
}
cfg = readCLIConfigFixture(t, configPath)
if len(cfg.Providers) != 2 {
t.Fatalf("repeat login duplicated the profile: %d providers", len(cfg.Providers))
}
}
func TestEnsureLoginProviderProfileActivatesOnFreshConfig(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
deps := appDeps{userConfigPath: func() (string, error) { return configPath, nil }}
line := ensureLoginProviderProfile(deps, "chatgpt")
if !strings.Contains(line, "set it active") {
t.Fatalf("fresh config should adopt the login as active, got %q", line)
}
cfg := readCLIConfigFixture(t, configPath)
if cfg.ActiveProvider != "chatgpt" {
t.Fatalf("active provider = %q, want chatgpt", cfg.ActiveProvider)
}
}
func TestEnsureLoginProviderProfileSkipsNonCatalogProviders(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
deps := appDeps{userConfigPath: func() (string, error) { return configPath, nil }}
if line := ensureLoginProviderProfile(deps, "my-custom-oauth-server"); line != "" {
t.Fatalf("custom OAuth server must not scaffold a profile, got %q", line)
}
if _, err := os.Stat(configPath); err == nil {
t.Fatalf("config must not be created for a non-catalog login")
}
}
func readCLIConfigFixture(t *testing.T, path string) config.FileConfig {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read config: %v", err)
}
var cfg config.FileConfig
if err := json.Unmarshal(data, &cfg); err != nil {
t.Fatalf("decode config: %v", err)
}
return cfg
}
+33 -2
View File
@@ -75,6 +75,12 @@ func runProviders(args []string, stdout io.Writer, stderr io.Writer, deps appDep
if command == "use" {
return runProvidersUse(args, stdout, stderr, deps)
}
if command == "remove" || command == "rm" {
return runProvidersRemove(args, stdout, stderr, deps)
}
if command == "rename" {
return runProvidersRename(args, stdout, stderr, deps)
}
if command == "setup" {
return runProvidersSetup(args, stdout, stderr, deps)
}
@@ -246,6 +252,20 @@ func parseCommandCenterArgs(args []string, allowModelFilters bool, allowProvider
func summarizeConfig(resolved config.ResolvedConfig) configSummary {
summary := zerocommands.ConfigSnapshotFromResolved(resolved)
// Mark keyless profiles that authenticate via a stored OAuth login (e.g.
// ChatGPT) so the list shows "oauth login" instead of "api key: not set" —
// the same candidate matching the runtime resolver uses.
logins := oauthLoggedInProviders()
if len(logins) > 0 {
for index := range summary.Providers {
for _, profile := range resolved.Providers {
if profile.Name == summary.Providers[index].Name {
summary.Providers[index].OAuthLogin = providerHasOAuthLogin(profile, logins)
break
}
}
}
}
sort.SliceStable(summary.Providers, func(i int, j int) bool {
if summary.Providers[i].Active != summary.Providers[j].Active {
return summary.Providers[i].Active
@@ -323,7 +343,7 @@ func formatProviderSummaries(command string, providers []providerSummary) string
"model: "+displayCLIValue(provider.Model, "none"),
"api model: "+displayCLIValue(provider.APIModel, "unknown"),
"base url: "+displayCLIValue(provider.BaseURL, "default"),
"api key: "+apiKeyState(provider.APIKeySet),
"api key: "+providerCredentialState(provider),
)
if provider.Message != "" {
lines = append(lines, "status: "+provider.Status+" - "+provider.Message)
@@ -340,7 +360,7 @@ func formatProviderLine(provider providerSummary) string {
if provider.Active {
marker = "*"
}
line := fmt.Sprintf("%s %s [%s] model=%s apiModel=%s api key: %s", marker, displayCLIValue(provider.Name, "none"), displayCLIValue(provider.ProviderKind, "unknown"), displayCLIValue(provider.Model, "none"), displayCLIValue(provider.APIModel, "unknown"), apiKeyState(provider.APIKeySet))
line := fmt.Sprintf("%s %s [%s] model=%s apiModel=%s api key: %s", marker, displayCLIValue(provider.Name, "none"), displayCLIValue(provider.ProviderKind, "unknown"), displayCLIValue(provider.Model, "none"), displayCLIValue(provider.APIModel, "unknown"), providerCredentialState(provider))
if provider.Message != "" {
line += " (" + provider.Status + ": " + provider.Message + ")"
}
@@ -413,6 +433,15 @@ func apiKeyState(set bool) string {
return "not set"
}
// providerCredentialState renders the credential column: a configured key wins,
// then a stored OAuth login, then "not set".
func providerCredentialState(provider providerSummary) string {
if !provider.APIKeySet && provider.OAuthLogin {
return "oauth login"
}
return apiKeyState(provider.APIKeySet)
}
func displayCLIValue(value string, fallback string) string {
if strings.TrimSpace(value) == "" {
return fallback
@@ -466,6 +495,8 @@ func writeProvidersHelp(w io.Writer) error {
zero providers add <catalog-id> [flags]
zero providers check [name] [flags]
zero providers use <name> [flags]
zero providers remove <name> [flags]
zero providers rename <old> <new> [flags]
zero providers setup <catalog-id> [flags]
zero providers detect [flags]
zero providers models [name] [flags]
+55
View File
@@ -11,6 +11,7 @@ import (
"testing"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/oauth"
"github.com/Gitlawb/zero/internal/providerhealth"
"github.com/Gitlawb/zero/internal/zerocommands"
"github.com/Gitlawb/zero/internal/zeroruntime"
@@ -835,3 +836,57 @@ func (commandCenterProvider) StreamCompletion(context.Context, zeroruntime.Compl
close(ch)
return ch, nil
}
func TestProviderCredentialStateShowsOAuthLogin(t *testing.T) {
if got := providerCredentialState(providerSummary{APIKeySet: true}); got != "set" {
t.Fatalf("keyed provider = %q, want set", got)
}
if got := providerCredentialState(providerSummary{OAuthLogin: true}); got != "oauth login" {
t.Fatalf("token-login provider = %q, want oauth login", got)
}
if got := providerCredentialState(providerSummary{}); got != "not set" {
t.Fatalf("credential-less provider = %q, want not set", got)
}
}
// TestProvidersListMarksOAuthLoginProviders: a keyless profile whose credential
// is a stored OAuth login (the shape `zero auth chatgpt` now writes) must render
// as "oauth login", not as a broken "api key: not set" entry.
func TestProvidersListMarksOAuthLoginProviders(t *testing.T) {
tokensPath := filepath.Join(t.TempDir(), "oauth-tokens.json")
t.Setenv("ZERO_OAUTH_TOKENS_PATH", tokensPath)
store, err := oauth.NewStore(oauth.StoreOptions{})
if err != nil {
t.Fatalf("oauth store: %v", err)
}
if err := store.Save(oauth.ProviderKey("chatgpt"), oauth.Token{AccessToken: "bearer-123"}); err != nil {
t.Fatalf("save token: %v", err)
}
resolved := config.ResolvedConfig{
ActiveProvider: "opengateway",
Providers: []config.ProviderProfile{
{Name: "opengateway", ProviderKind: config.ProviderKindOpenAICompatible, BaseURL: "https://gateway.example.com/v1", APIKeyStored: true, Model: "some-model"},
{Name: "chatgpt", CatalogID: "chatgpt", ProviderKind: config.ProviderKindOpenAICompatible, BaseURL: "https://chatgpt.com/backend-api/codex", Model: "gpt-5.5"},
},
}
summary := summarizeConfig(resolved)
byName := map[string]providerSummary{}
for _, provider := range summary.Providers {
byName[provider.Name] = provider
}
if !byName["opengateway"].APIKeySet {
t.Fatalf("stored-key provider must report a credential: %+v", byName["opengateway"])
}
if byName["opengateway"].OAuthLogin {
t.Fatalf("key-authed provider must not claim the OAuth login: %+v", byName["opengateway"])
}
if !byName["chatgpt"].OAuthLogin || byName["chatgpt"].APIKeySet {
t.Fatalf("chatgpt must be marked oauth-login and keyless: %+v", byName["chatgpt"])
}
rendered := formatProviderSummaries("list", summary.Providers)
if !strings.Contains(rendered, "oauth login") {
t.Fatalf("list should render the oauth login state, got:\n%s", rendered)
}
}
+4 -3
View File
@@ -711,9 +711,10 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
// sessions); it is a no-op when self-correct is off.
func newExecSelfCorrector(enabled bool, workspaceRoot string, autonomy string) (*agent.SelfCorrector, func(context.Context, string) string, func()) {
// The manager is created regardless of --self-correct: it also backs the
// always-on inline post-edit diagnostics (agent.NewFileDiagnostics), and it
// stays lazy — no language server is spawned unless an edited file's
// language actually has one installed on PATH.
// always-on background post-edit diagnostics (agent.NewFileDiagnostics,
// collected off the tool-call path by the loop), and it stays lazy — no
// language server is spawned unless an edited file's language actually has
// one installed on PATH.
manager := lsp.NewManager(workspaceRoot)
cleanup := func() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+141
View File
@@ -3,6 +3,7 @@ package cli
import (
"fmt"
"io"
"path/filepath"
"strconv"
"strings"
"unicode"
@@ -306,3 +307,143 @@ func providerCommandArg(value string) string {
}
return value
}
type providerNamesOptions struct {
names []string
json bool
}
func parseProviderNamesArgs(args []string, want int, usage string) (providerNamesOptions, bool, error) {
options := providerNamesOptions{}
for _, arg := range args {
switch {
case arg == "-h" || arg == "--help" || arg == "help":
return options, true, nil
case arg == "--json":
options.json = true
case strings.HasPrefix(arg, "-"):
return options, false, execUsageError{fmt.Sprintf("unknown flag %q", arg)}
default:
options.names = append(options.names, arg)
}
}
if len(options.names) != want {
return options, false, execUsageError{usage}
}
return options, false, nil
}
// runProvidersRemove deletes a saved provider profile and its stored API key.
// The OAuth token (if any) is kept — logins outlive profiles so re-adding the
// provider needs no new browser round-trip; `zero auth logout <name>` removes it.
func runProvidersRemove(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
options, help, err := parseProviderNamesArgs(args, 1, "usage: zero providers remove <name>")
if err != nil {
return writeExecUsageError(stderr, err.Error())
}
if help {
if err := writeProvidersHelp(stdout); err != nil {
return exitCrash
}
return exitSuccess
}
configPath, err := deps.userConfigPath()
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
name := options.names[0]
cfg, err := config.RemoveProvider(configPath, name)
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
// Delete the key from the store BESIDE the config being edited — the same
// store setup/rename write to — not the default-path store, so a
// non-default config path cannot leave the encrypted key behind.
keyRemoved, keyErr := removeStoredProviderKeyAt(configPath, name)
if options.json {
payload := map[string]any{
"removed": name,
"keyRemoved": keyRemoved,
"activeProvider": cfg.ActiveProvider,
"configPath": configPath,
}
if keyErr != nil {
// A lingering secret must not read as a clean removal.
payload["keyError"] = keyErr.Error()
}
if err := writePrettyJSON(stdout, payload); err != nil {
return exitCrash
}
return exitSuccess
}
if _, err := fmt.Fprintf(stdout, "Removed provider %s\n", name); err != nil {
return exitCrash
}
if keyErr != nil {
if _, err := fmt.Fprintf(stderr, "warning: its stored API key could not be deleted and remains in the credential store: %v\n", keyErr); err != nil {
return exitCrash
}
} else if keyRemoved {
if _, err := fmt.Fprintln(stdout, "Deleted its stored API key."); err != nil {
return exitCrash
}
}
if active := strings.TrimSpace(cfg.ActiveProvider); active != "" {
if _, err := fmt.Fprintf(stdout, "Active provider: %s\n", active); err != nil {
return exitCrash
}
} else {
if _, err := fmt.Fprintln(stdout, "No providers remain — run zero setup to add one."); err != nil {
return exitCrash
}
}
return exitSuccess
}
// removeStoredProviderKeyAt deletes a provider's API key from the credential
// store co-located with configPath (the store SecureProviderProfile captured
// it into and RenameProvider migrates within).
func removeStoredProviderKeyAt(configPath string, provider string) (bool, error) {
store, err := config.ProviderKeyStoreAt(filepath.Dir(configPath))
if err != nil {
return false, err
}
return store.Delete(provider)
}
// runProvidersRename renames a saved provider profile, migrating its stored
// API key and the activeProvider pointer along with it (config.RenameProvider).
func runProvidersRename(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
options, help, err := parseProviderNamesArgs(args, 2, "usage: zero providers rename <old> <new>")
if err != nil {
return writeExecUsageError(stderr, err.Error())
}
if help {
if err := writeProvidersHelp(stdout); err != nil {
return exitCrash
}
return exitSuccess
}
configPath, err := deps.userConfigPath()
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
cfg, err := config.RenameProvider(configPath, options.names[0], options.names[1])
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
if options.json {
if err := writePrettyJSON(stdout, map[string]any{
"renamed": map[string]string{"from": options.names[0], "to": options.names[1]},
"activeProvider": cfg.ActiveProvider,
"configPath": configPath,
}); err != nil {
return exitCrash
}
return exitSuccess
}
if _, err := fmt.Fprintf(stdout, "Renamed provider %s to %s\n", options.names[0], options.names[1]); err != nil {
return exitCrash
}
return exitSuccess
}
+45
View File
@@ -234,3 +234,48 @@ func writeProviderOnboardingConfig(t *testing.T, path string, cfg config.FileCon
t.Fatalf("write config: %v", err)
}
}
// TestRunProvidersRemoveDeletesKeyBesideConfig: the stored key must be deleted
// from the credential store CO-LOCATED with the config being edited (where
// SecureProviderProfile captured it), not the default-path store.
func TestRunProvidersRemoveDeletesKeyBesideConfig(t *testing.T) {
t.Setenv("ZERO_CRED_STORAGE", "encrypted-file")
dir := t.TempDir()
configPath := filepath.Join(dir, "config.json")
seed := `{"activeProvider":"gw","providers":[{"name":"gw","provider_kind":"openai-compatible","baseURL":"https://gw.example.com/v1","apiKeyStored":true,"model":"m1"},{"name":"other","provider_kind":"openai-compatible","baseURL":"https://o.example.com/v1","model":"m2"}]}`
if err := os.WriteFile(configPath, []byte(seed), 0o600); err != nil {
t.Fatalf("seed config: %v", err)
}
store, err := config.ProviderKeyStoreAt(dir)
if err != nil {
t.Fatalf("open store: %v", err)
}
if err := store.Set("gw", "sk-secret"); err != nil {
t.Fatalf("seed key: %v", err)
}
var stdout, stderr bytes.Buffer
deps := appDeps{userConfigPath: func() (string, error) { return configPath, nil }}
if code := runWithDeps([]string{"providers", "remove", "gw", "--json"}, &stdout, &stderr, deps); code != exitSuccess {
t.Fatalf("remove failed: code=%d stderr=%s", code, stderr.String())
}
var payload struct {
Removed string `json:"removed"`
KeyRemoved bool `json:"keyRemoved"`
KeyError string `json:"keyError"`
ActiveProvider string `json:"activeProvider"`
}
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("decode JSON: %v\n%s", err, stdout.String())
}
if payload.Removed != "gw" || !payload.KeyRemoved || payload.KeyError != "" {
t.Fatalf("unexpected payload: %+v", payload)
}
if payload.ActiveProvider != "other" {
t.Fatalf("active must hand off, got %q", payload.ActiveProvider)
}
if _, ok, _ := store.Get("gw"); ok {
t.Fatalf("stored key must be deleted from the store beside the config")
}
}
+5 -1
View File
@@ -457,7 +457,11 @@ func selectProviderForCheck(resolved config.ResolvedConfig, name string) (config
}
func validateProviderRuntimeReady(profile config.ProviderProfile) error {
hasCredential := providerProfileHasCredential(profile)
// A stored OAuth login (e.g. `zero auth chatgpt`) is a credential too: a
// keyless token-login profile must pass the readiness check instead of being
// told to set an API key it will never have.
hasCredential := providerProfileHasCredential(profile) ||
providerHasOAuthLogin(profile, oauthLoggedInProviders())
if profile.CatalogID != "" {
descriptor, err := providercatalog.Require(profile.CatalogID)
if err != nil {
+14 -7
View File
@@ -322,7 +322,9 @@ func setupRequired(resolved config.ResolvedConfig) bool {
// the candidate set is permissive (profile name + catalog ID).
func providerHasOAuthLogin(profile config.ProviderProfile, oauthLogins map[string]bool) bool {
for _, name := range profile.OAuthLoginCandidates() {
if oauthLogins[name] {
// Store keys are normalized to lower case (oauth.ProviderKey), so the
// presence check must compare the same way.
if oauthLogins[strings.ToLower(strings.TrimSpace(name))] {
return true
}
}
@@ -344,18 +346,19 @@ func oauthLoggedInProviders() map[string]bool {
}
for _, status := range statuses {
if status.HasToken {
out[strings.TrimPrefix(status.Key, oauth.KeyPrefixProvider)] = true
out[strings.ToLower(strings.TrimPrefix(status.Key, oauth.KeyPrefixProvider))] = true
}
}
return out
}
// firstUsableProvider returns the saved provider best suited to run without
// onboarding: the first usable (inline credential present, or no-auth/local)
// non-local provider, else the first usable local one. It lets the CLI fall back
// to an already-configured login when the active provider happens to lack a
// credential, instead of re-running onboarding every launch.
// onboarding: the first usable (inline credential, stored OAuth login, or
// no-auth/local) non-local provider, else the first usable local one. It lets
// the CLI fall back to an already-configured login when the active provider
// happens to lack a credential, instead of re-running onboarding every launch.
func firstUsableProvider(providers []config.ProviderProfile) (config.ProviderProfile, bool) {
logins := oauthLoggedInProviders()
var localFallback config.ProviderProfile
haveLocal := false
for _, profile := range providers {
@@ -371,7 +374,11 @@ func firstUsableProvider(providers []config.ProviderProfile) (config.ProviderPro
continue
}
}
if _, missing := setupMissingCredentialEnv(profile); missing {
// A stored OAuth login (e.g. `zero auth login xai`) is a credential too, even
// when the profile has no inline key / env var — mirrors setupRequired and
// usableSavedProviders so this fallback doesn't force onboarding for a
// provider the user is already authenticated with.
if _, missing := setupMissingCredentialEnv(profile); missing && !providerHasOAuthLogin(profile, logins) {
continue
}
if providerProfileIsLocal(profile) {
+62
View File
@@ -2,11 +2,14 @@ package cli
import (
"testing"
"time"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/oauth"
)
func TestFirstUsableProviderPrefersRemoteKeyed(t *testing.T) {
withAuthStore(t)
providers := []config.ProviderProfile{
{Name: "ollama", CatalogID: "ollama", BaseURL: "http://localhost:11434/v1", APIKey: "k"}, // usable but local
{Name: "moonshot", CatalogID: "moonshot", BaseURL: "https://api.moonshot.ai/v1", APIKey: "k"}, // usable, remote
@@ -19,6 +22,7 @@ func TestFirstUsableProviderPrefersRemoteKeyed(t *testing.T) {
}
func TestFirstUsableProviderFallsBackToLocal(t *testing.T) {
withAuthStore(t)
providers := []config.ProviderProfile{
{Name: "xai", CatalogID: "xai", APIKeyEnv: "XAI_API_KEY"}, // not usable
{Name: "ollama", CatalogID: "ollama", BaseURL: "http://localhost:11434/v1", APIKey: "k"}, // local, usable
@@ -30,6 +34,7 @@ func TestFirstUsableProviderFallsBackToLocal(t *testing.T) {
}
func TestFirstUsableProviderNoneUsable(t *testing.T) {
withAuthStore(t)
providers := []config.ProviderProfile{
{Name: "xai", CatalogID: "xai", APIKeyEnv: "XAI_API_KEY"},
{Name: "openai", CatalogID: "openai", APIKeyEnv: "OPENAI_API_KEY"},
@@ -42,6 +47,10 @@ func TestFirstUsableProviderNoneUsable(t *testing.T) {
// A keyless local proxy (chatgpt-proxy, RequiresAuth=false) is usable without a
// credential, so it can serve as a fallback rather than forcing onboarding.
func TestFirstUsableProviderAcceptsKeylessLocalProxy(t *testing.T) {
// Isolate the OAuth token store: on a developer machine with a real xai
// login, the env-only xai profile would count as usable and win over the
// local proxy this test is about.
withAuthStore(t)
providers := []config.ProviderProfile{
{Name: "xai", CatalogID: "xai", APIKeyEnv: "XAI_API_KEY"},
{Name: "chatgpt", CatalogID: "chatgpt-proxy", BaseURL: "http://localhost:10531/v1"},
@@ -56,6 +65,7 @@ func TestFirstUsableProviderAcceptsKeylessLocalProxy(t *testing.T) {
// BaseURL has no endpoint, so it must be skipped rather than selected as a
// fallback that fails at first use. A stale CatalogID with a BaseURL still works.
func TestFirstUsableProviderSkipsUnresolvableCatalogWithoutBaseURL(t *testing.T) {
withAuthStore(t)
providers := []config.ProviderProfile{
{Name: "ghost", CatalogID: "no-such-catalog-entry", APIKey: "k"}, // unusable: no endpoint
{Name: "custom", CatalogID: "no-such-catalog-entry", BaseURL: "https://api.custom.test/v1", APIKey: "k"},
@@ -66,6 +76,29 @@ func TestFirstUsableProviderSkipsUnresolvableCatalogWithoutBaseURL(t *testing.T)
}
}
// An OAuth-only provider (no inline key, no env var) must be selectable as a
// fallback, matching setupRequired/usableSavedProviders — otherwise a fully
// authenticated user gets forced back into onboarding when activeProvider
// goes stale.
func TestFirstUsableProviderRecognizesOAuthLogin(t *testing.T) {
withAuthStore(t)
store, err := oauth.NewStore(oauth.StoreOptions{})
if err != nil {
t.Fatalf("NewStore: %v", err)
}
if err := store.Save(oauth.ProviderKey("xai"), oauth.Token{AccessToken: "tok", ExpiresAt: time.Now().Add(time.Hour)}); err != nil {
t.Fatalf("seed token: %v", err)
}
providers := []config.ProviderProfile{
{Name: "xai", CatalogID: "xai", APIKeyEnv: "XAI_API_KEY"}, // no inline key/env, but logged in via OAuth
}
got, ok := firstUsableProvider(providers)
if !ok || got.Name != "xai" {
t.Fatalf("want OAuth-logged-in provider (xai), got %q ok=%v", got.Name, ok)
}
}
func TestProviderProfileIsLocal(t *testing.T) {
cases := []struct {
name string
@@ -89,3 +122,32 @@ func TestProviderProfileIsLocal(t *testing.T) {
})
}
}
// TestFirstUsableProviderAcceptsOAuthLoginProfile: a keyless catalog profile
// whose credential is a stored OAuth login (the shape the login flows persist)
// must be usable during active-pointer recovery — the same rule setupRequired
// and usableSavedProviders apply — instead of falling through to onboarding.
func TestFirstUsableProviderAcceptsOAuthLoginProfile(t *testing.T) {
withAuthStore(t)
providers := []config.ProviderProfile{
{Name: "chatgpt", CatalogID: "chatgpt", BaseURL: "https://chatgpt.com/backend-api/codex", Model: "gpt-5.5"},
}
// No login stored: still not usable.
if got, ok := firstUsableProvider(providers); ok {
t.Fatalf("keyless profile without a login must not be usable, got %q", got.Name)
}
store, err := oauth.NewStore(oauth.StoreOptions{})
if err != nil {
t.Fatalf("oauth store: %v", err)
}
if err := store.Save(oauth.ProviderKey("chatgpt"), oauth.Token{AccessToken: "bearer-123"}); err != nil {
t.Fatalf("save token: %v", err)
}
got, ok := firstUsableProvider(providers)
if !ok || got.Name != "chatgpt" {
t.Fatalf("want OAuth-login profile to be usable, got %q ok=%v", got.Name, ok)
}
}
+362
View File
@@ -7,6 +7,8 @@ import (
"path/filepath"
"sort"
"strings"
"github.com/Gitlawb/zero/internal/providercatalog"
)
func UpsertProvider(path string, profile ProviderProfile, setActive bool) (FileConfig, error) {
@@ -29,6 +31,20 @@ func UpsertProvider(path string, profile ProviderProfile, setActive bool) (FileC
}
mergeProvider(&cfg, profile)
// mergeProfile deliberately ignores APIKeyStored — during resolve-time
// layering a project config must not be able to claim the user's stored
// keys. This user-config WRITE path re-applies the marker: capturing a key
// via SecureProviderProfile onto a previously env/no-key profile must
// persist apiKeyStored, or the secret sits in the credential store while
// every ApplyStoredAPIKey gate skips it (PR #560 review).
if profile.APIKeyStored {
for index := range cfg.Providers {
if cfg.Providers[index].Name == profile.Name {
cfg.Providers[index].APIKeyStored = true
break
}
}
}
if setActive || strings.TrimSpace(cfg.ActiveProvider) == "" {
cfg.ActiveProvider = profile.Name
}
@@ -39,6 +55,62 @@ func UpsertProvider(path string, profile ProviderProfile, setActive bool) (FileC
return cfg, nil
}
// EnsuredProvider reports the outcome of EnsureCatalogProvider: the profile name
// that serves the catalog entry, whether it was newly created, and which provider
// is active after the call (unchanged unless it was blank).
type EnsuredProvider struct {
Name string
Created bool
Active string
}
// EnsureCatalogProvider guarantees a provider profile exists in the config at
// path for the given catalog entry. OAuth login flows call this right after
// storing a token: a login is only reachable from the provider list and
// `zero providers use` when a profile exists, but a login must never replace or
// deactivate the user's current active provider — so an existing profile whose
// Name or CatalogID already matches is left completely untouched (its name,
// credentials, and model are the user's), and a created profile is NOT marked
// active unless no provider was active at all.
func EnsureCatalogProvider(path string, catalogID string) (EnsuredProvider, error) {
path = strings.TrimSpace(path)
if path == "" {
return EnsuredProvider{}, fmt.Errorf("config path is required")
}
descriptor, err := providercatalog.Require(catalogID)
if err != nil {
return EnsuredProvider{}, err
}
cfg := FileConfig{}
if data, err := os.ReadFile(path); err == nil {
if err := json.Unmarshal(data, &cfg); err != nil {
return EnsuredProvider{}, fmt.Errorf("invalid config JSON %s: %w", path, err)
}
} else if !os.IsNotExist(err) {
return EnsuredProvider{}, fmt.Errorf("read config %s: %w", path, err)
}
for _, provider := range cfg.Providers {
if strings.EqualFold(strings.TrimSpace(provider.CatalogID), descriptor.ID) ||
strings.EqualFold(strings.TrimSpace(provider.Name), descriptor.ID) {
return EnsuredProvider{Name: provider.Name, Active: cfg.ActiveProvider}, nil
}
}
profile := ProviderProfile{
Name: descriptor.ID,
ProviderKind: providerKindForCatalogTransport(descriptor.Transport),
CatalogID: descriptor.ID,
BaseURL: descriptor.DefaultBaseURL,
Model: descriptor.DefaultModel,
}
written, err := UpsertProvider(path, profile, false)
if err != nil {
return EnsuredProvider{}, err
}
return EnsuredProvider{Name: profile.Name, Created: true, Active: written.ActiveProvider}, nil
}
func SetActiveProvider(path string, name string) (FileConfig, error) {
path = strings.TrimSpace(path)
if path == "" {
@@ -72,6 +144,296 @@ func SetActiveProvider(path string, name string) (FileConfig, error) {
return FileConfig{}, fmt.Errorf("provider %q not found", name)
}
// 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
// a profile that no longer exists. The caller owns cleaning up the credential
// store entry — config stays pure of secret I/O on the read path, and remove
// keeps that symmetry by only touching config.json.
func RemoveProvider(path string, name string) (FileConfig, error) {
path = strings.TrimSpace(path)
if path == "" {
return FileConfig{}, fmt.Errorf("config path is required")
}
name = strings.TrimSpace(name)
if name == "" {
return FileConfig{}, fmt.Errorf("provider name is required")
}
data, err := os.ReadFile(path)
if err != nil {
return FileConfig{}, fmt.Errorf("read config %s: %w", path, err)
}
cfg := FileConfig{}
if err := json.Unmarshal(data, &cfg); err != nil {
return FileConfig{}, fmt.Errorf("invalid config JSON %s: %w", path, err)
}
index := -1
for i, provider := range cfg.Providers {
if strings.EqualFold(strings.TrimSpace(provider.Name), name) {
index = i
break
}
}
if index < 0 {
return FileConfig{}, fmt.Errorf("provider %q not found", name)
}
removed := cfg.Providers[index]
cfg.Providers = append(cfg.Providers[:index], cfg.Providers[index+1:]...)
if strings.EqualFold(strings.TrimSpace(cfg.ActiveProvider), strings.TrimSpace(removed.Name)) {
cfg.ActiveProvider = ""
if len(cfg.Providers) > 0 {
cfg.ActiveProvider = cfg.Providers[0].Name
}
}
if err := writeConfigFile(path, cfg); err != nil {
return FileConfig{}, err
}
return cfg, nil
}
// RenameProvider renames a provider profile, keeping everything keyed by the
// profile name consistent: the activeProvider pointer follows the rename, and a
// key in the encrypted credential store (APIKeyStored) is migrated to the new
// name BEFORE the config is rewritten — the store write must succeed first so a
// failed migration never strands the config pointing at a key that no longer
// resolves. OAuth tokens are deliberately not migrated: the runtime's login
// candidates fall back to the profile's CatalogID, which every OAuth-capable
// catalog profile carries, so a rename keeps the login reachable.
func RenameProvider(path string, oldName string, newName string) (FileConfig, error) {
path = strings.TrimSpace(path)
if path == "" {
return FileConfig{}, fmt.Errorf("config path is required")
}
oldName = strings.TrimSpace(oldName)
newName = strings.TrimSpace(newName)
if oldName == "" || newName == "" {
return FileConfig{}, fmt.Errorf("provider names are required")
}
data, err := os.ReadFile(path)
if err != nil {
return FileConfig{}, fmt.Errorf("read config %s: %w", path, err)
}
cfg := FileConfig{}
if err := json.Unmarshal(data, &cfg); err != nil {
return FileConfig{}, fmt.Errorf("invalid config JSON %s: %w", path, err)
}
index := -1
for i, provider := range cfg.Providers {
providerName := strings.TrimSpace(provider.Name)
if strings.EqualFold(providerName, oldName) {
index = i
continue
}
if strings.EqualFold(providerName, newName) {
return FileConfig{}, fmt.Errorf("provider %q already exists", newName)
}
}
if index < 0 {
return FileConfig{}, fmt.Errorf("provider %q not found", oldName)
}
if strings.EqualFold(oldName, newName) && cfg.Providers[index].Name == newName {
return cfg, nil
}
previousName := cfg.Providers[index].Name
keyMigrated := false
if cfg.Providers[index].APIKeyStored {
if err := migrateStoredProviderKey(path, previousName, newName); err != nil {
return FileConfig{}, fmt.Errorf("migrate stored key for %q: %w", oldName, err)
}
keyMigrated = true
}
if strings.EqualFold(strings.TrimSpace(cfg.ActiveProvider), strings.TrimSpace(previousName)) {
cfg.ActiveProvider = newName
}
cfg.Providers[index].Name = newName
if err := writeConfigFile(path, cfg); err != nil {
if keyMigrated {
// Compensate best-effort: config.json still names the OLD profile, so
// move the key back where that config can find it — otherwise a failed
// rewrite strands the key under a name no profile carries.
_ = migrateStoredProviderKey(path, newName, previousName)
}
return FileConfig{}, err
}
return cfg, nil
}
// ProviderEdit is a field-level edit of one saved provider, applied by
// EditProvider in a single atomic write. Name is the CURRENT profile name
// (matched case-insensitively); NewName renames (case-only renames included).
// Empty BaseURL/Model/APIKey mean "leave unchanged"; Description is applied
// VERBATIM (the editor always knows the full desired text, so clearing works).
type ProviderEdit struct {
Name string
NewName string
BaseURL string
Model string
APIKey string
APIKeyStored bool
Description string
}
// EditProvider applies a provider edit in ONE read-modify-write: rename
// (activeProvider follows; a stored key migrates, with a best-effort rollback
// if the config write fails), field updates, the stored-key marker, and the
// verbatim description. A single write keeps the operation atomic — the
// previous rename+upsert+describe sequence could fail halfway and leave
// config.json renamed while every in-memory consumer still held the old name —
// and, unlike UpsertProvider's exact-name merge, the case-insensitive match
// here makes a case-only rename (groq -> Groq) an in-place update instead of
// an appended duplicate profile.
func EditProvider(path string, edit ProviderEdit) (FileConfig, error) {
path = strings.TrimSpace(path)
if path == "" {
return FileConfig{}, fmt.Errorf("config path is required")
}
oldName := strings.TrimSpace(edit.Name)
if oldName == "" {
return FileConfig{}, fmt.Errorf("provider name is required")
}
newName := strings.TrimSpace(edit.NewName)
if newName == "" {
newName = oldName
}
data, err := os.ReadFile(path)
if err != nil {
return FileConfig{}, fmt.Errorf("read config %s: %w", path, err)
}
cfg := FileConfig{}
if err := json.Unmarshal(data, &cfg); err != nil {
return FileConfig{}, fmt.Errorf("invalid config JSON %s: %w", path, err)
}
index := -1
for i, provider := range cfg.Providers {
providerName := strings.TrimSpace(provider.Name)
if strings.EqualFold(providerName, oldName) {
index = i
continue
}
if strings.EqualFold(providerName, newName) {
return FileConfig{}, fmt.Errorf("provider %q already exists", newName)
}
}
if index < 0 {
return FileConfig{}, fmt.Errorf("provider %q not found", oldName)
}
previousName := cfg.Providers[index].Name
renamed := previousName != newName
keyMigrated := false
// A rename moves the stored key along: either the profile's existing entry,
// or a replacement key the caller just captured — the contract is that a
// captured key is stored under the CURRENT name before EditProvider runs, so
// one migration covers both. migrateStoredProviderKey no-ops on case-only
// renames (the store normalizes names), so it cannot delete the key it just
// moved.
if renamed && (cfg.Providers[index].APIKeyStored || edit.APIKeyStored) {
if err := migrateStoredProviderKey(path, previousName, newName); err != nil {
return FileConfig{}, fmt.Errorf("migrate stored key for %q: %w", oldName, err)
}
keyMigrated = true
}
if renamed && strings.EqualFold(strings.TrimSpace(cfg.ActiveProvider), strings.TrimSpace(previousName)) {
cfg.ActiveProvider = newName
}
profile := &cfg.Providers[index]
profile.Name = newName
if baseURL := strings.TrimSpace(edit.BaseURL); baseURL != "" {
profile.BaseURL = baseURL
}
if model := strings.TrimSpace(edit.Model); model != "" {
profile.Model = model
}
if apiKey := strings.TrimSpace(edit.APIKey); apiKey != "" {
profile.APIKey = apiKey
}
if edit.APIKeyStored {
profile.APIKeyStored = true
}
profile.Description = strings.TrimSpace(edit.Description)
if err := writeConfigFile(path, cfg); err != nil {
if keyMigrated {
// Compensate best-effort: config.json still names the OLD profile, so
// move the key back where that config can find it.
_ = migrateStoredProviderKey(path, newName, previousName)
}
return FileConfig{}, err
}
return cfg, nil
}
// SetProviderDescription sets a provider's description VERBATIM — including to
// empty. The generic UpsertProvider merge treats empty fields as "leave
// unchanged", so clearing a description needs this dedicated setter.
func SetProviderDescription(path string, name string, description string) (FileConfig, error) {
path = strings.TrimSpace(path)
if path == "" {
return FileConfig{}, fmt.Errorf("config path is required")
}
name = strings.TrimSpace(name)
if name == "" {
return FileConfig{}, fmt.Errorf("provider name is required")
}
data, err := os.ReadFile(path)
if err != nil {
return FileConfig{}, fmt.Errorf("read config %s: %w", path, err)
}
cfg := FileConfig{}
if err := json.Unmarshal(data, &cfg); err != nil {
return FileConfig{}, fmt.Errorf("invalid config JSON %s: %w", path, err)
}
for index := range cfg.Providers {
if strings.EqualFold(strings.TrimSpace(cfg.Providers[index].Name), name) {
cfg.Providers[index].Description = strings.TrimSpace(description)
if err := writeConfigFile(path, cfg); err != nil {
return FileConfig{}, err
}
return cfg, nil
}
}
return FileConfig{}, fmt.Errorf("provider %q not found", name)
}
// migrateStoredProviderKey moves a credential-store entry to a new provider
// name: write-new-then-delete-old, so an interruption can leave a duplicate but
// never a missing key. A missing source entry is a no-op (the marker may be
// stale); only a failed WRITE aborts the rename.
func migrateStoredProviderKey(configPath string, oldName string, newName string) error {
// The store normalizes names case-insensitively, so a case-only rename
// (groq -> Groq) targets ONE entry: Set(new) rewrites it in place and
// Delete(old) would then remove the key that was just "moved". Nothing to
// migrate — the existing entry already serves the new name.
if strings.EqualFold(strings.TrimSpace(oldName), strings.TrimSpace(newName)) {
return nil
}
store, err := ProviderKeyStoreAt(filepath.Dir(configPath))
if err != nil {
return err
}
key, ok, err := store.Get(oldName)
if err != nil {
return err
}
if !ok || strings.TrimSpace(key) == "" {
return nil
}
if err := store.Set(newName, key); err != nil {
return err
}
_, _ = store.Delete(oldName)
return nil
}
func SetProviderModel(path string, name string, model string) (FileConfig, error) {
path = strings.TrimSpace(path)
if path == "" {
+543
View File
@@ -5,6 +5,7 @@ import (
"errors"
"io/fs"
"os"
"os/exec"
"path/filepath"
"reflect"
"runtime"
@@ -385,3 +386,545 @@ func readConfigFixture(t *testing.T, path string) FileConfig {
}
return cfg
}
func TestEnsureCatalogProviderCreatesProfileWithoutStealingActive(t *testing.T) {
path := filepath.Join(t.TempDir(), "zero.json")
writeConfigFixture(t, path, FileConfig{
ActiveProvider: "opengateway",
Providers: []ProviderProfile{
{
Name: "opengateway",
ProviderKind: ProviderKindOpenAICompatible,
BaseURL: "https://gateway.example.com/v1",
APIKeyStored: true,
Model: "some-model",
},
},
}, 0o600)
ensured, err := EnsureCatalogProvider(path, "chatgpt")
if err != nil {
t.Fatalf("EnsureCatalogProvider: %v", err)
}
if !ensured.Created {
t.Fatalf("expected profile to be created")
}
if ensured.Name != "chatgpt" {
t.Fatalf("expected profile name chatgpt, got %q", ensured.Name)
}
if ensured.Active != "opengateway" {
t.Fatalf("active provider must stay opengateway, got %q", ensured.Active)
}
cfg := readConfigFixture(t, path)
if cfg.ActiveProvider != "opengateway" {
t.Fatalf("persisted active provider changed to %q", cfg.ActiveProvider)
}
if len(cfg.Providers) != 2 {
t.Fatalf("expected 2 providers, got %d", len(cfg.Providers))
}
chatgpt := cfg.Providers[1]
if chatgpt.Name != "chatgpt" || chatgpt.CatalogID != "chatgpt" {
t.Fatalf("unexpected created profile: %+v", chatgpt)
}
if chatgpt.Model == "" || chatgpt.BaseURL == "" {
t.Fatalf("created profile must carry catalog defaults, got %+v", chatgpt)
}
if chatgpt.APIKey != "" || chatgpt.APIKeyStored {
t.Fatalf("OAuth-created profile must stay keyless, got %+v", chatgpt)
}
}
func TestEnsureCatalogProviderLeavesExistingProfileUntouched(t *testing.T) {
path := filepath.Join(t.TempDir(), "zero.json")
original := FileConfig{
ActiveProvider: "opengateway",
Providers: []ProviderProfile{
{Name: "opengateway", ProviderKind: ProviderKindOpenAICompatible, BaseURL: "https://gateway.example.com/v1", Model: "some-model"},
// Renamed profile that already serves the chatgpt catalog entry.
{Name: "codex", CatalogID: "chatgpt", Model: "gpt-5.5"},
},
}
data := writeConfigFixture(t, path, original, 0o600)
ensured, err := EnsureCatalogProvider(path, "chatgpt")
if err != nil {
t.Fatalf("EnsureCatalogProvider: %v", err)
}
if ensured.Created {
t.Fatalf("existing profile must not be recreated")
}
if ensured.Name != "codex" {
t.Fatalf("expected existing profile name codex, got %q", ensured.Name)
}
after, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read config: %v", err)
}
if string(after) != string(data) {
t.Fatalf("config rewritten for a no-op ensure:\nbefore: %s\nafter: %s", data, after)
}
}
func TestEnsureCatalogProviderActivatesOnEmptyConfig(t *testing.T) {
path := filepath.Join(t.TempDir(), "zero.json")
ensured, err := EnsureCatalogProvider(path, "chatgpt")
if err != nil {
t.Fatalf("EnsureCatalogProvider: %v", err)
}
if !ensured.Created {
t.Fatalf("expected profile to be created")
}
if ensured.Active != "chatgpt" {
t.Fatalf("blank active must adopt the new provider, got %q", ensured.Active)
}
}
func TestEnsureCatalogProviderRejectsUnknownCatalogID(t *testing.T) {
path := filepath.Join(t.TempDir(), "zero.json")
if _, err := EnsureCatalogProvider(path, "no-such-provider"); err == nil {
t.Fatalf("expected unknown catalog id to error")
}
if _, err := os.Stat(path); !errors.Is(err, fs.ErrNotExist) {
t.Fatalf("config must not be written for an unknown catalog id")
}
}
func TestRemoveProviderDeletesAndHandsOffActive(t *testing.T) {
path := filepath.Join(t.TempDir(), "zero.json")
writeConfigFixture(t, path, FileConfig{
ActiveProvider: "beta",
Providers: []ProviderProfile{
{Name: "alpha", ProviderKind: ProviderKindOpenAICompatible, BaseURL: "https://a.example.com/v1", Model: "m1"},
{Name: "beta", ProviderKind: ProviderKindOpenAICompatible, BaseURL: "https://b.example.com/v1", Model: "m2"},
},
}, 0o600)
cfg, err := RemoveProvider(path, " BETA ")
if err != nil {
t.Fatalf("RemoveProvider() error = %v", err)
}
if len(cfg.Providers) != 1 || cfg.Providers[0].Name != "alpha" {
t.Fatalf("expected only alpha to remain, got %+v", cfg.Providers)
}
if cfg.ActiveProvider != "alpha" {
t.Fatalf("active must hand off to the first remaining provider, got %q", cfg.ActiveProvider)
}
persisted := readConfigFixture(t, path)
if len(persisted.Providers) != 1 || persisted.ActiveProvider != "alpha" {
t.Fatalf("persisted config wrong: %+v", persisted)
}
// Removing the last provider clears the active pointer entirely.
cfg, err = RemoveProvider(path, "alpha")
if err != nil {
t.Fatalf("RemoveProvider(last) error = %v", err)
}
if len(cfg.Providers) != 0 || cfg.ActiveProvider != "" {
t.Fatalf("expected empty providers and no active, got %+v", cfg)
}
}
func TestRemoveProviderKeepsActiveWhenOtherRemoved(t *testing.T) {
path := filepath.Join(t.TempDir(), "zero.json")
writeConfigFixture(t, path, FileConfig{
ActiveProvider: "alpha",
Providers: []ProviderProfile{
{Name: "alpha", ProviderKind: ProviderKindOpenAICompatible, BaseURL: "https://a.example.com/v1", Model: "m1"},
{Name: "beta", ProviderKind: ProviderKindOpenAICompatible, BaseURL: "https://b.example.com/v1", Model: "m2"},
},
}, 0o600)
cfg, err := RemoveProvider(path, "beta")
if err != nil {
t.Fatalf("RemoveProvider() error = %v", err)
}
if cfg.ActiveProvider != "alpha" {
t.Fatalf("active provider must be untouched, got %q", cfg.ActiveProvider)
}
}
func TestRemoveProviderRejectsUnknownWithoutRewriting(t *testing.T) {
path := filepath.Join(t.TempDir(), "zero.json")
before := writeConfigFixture(t, path, FileConfig{
ActiveProvider: "alpha",
Providers: []ProviderProfile{{Name: "alpha", ProviderKind: ProviderKindOpenAICompatible, BaseURL: "https://a.example.com/v1", Model: "m1"}},
}, 0o600)
if _, err := RemoveProvider(path, "ghost"); err == nil {
t.Fatal("RemoveProvider() error = nil, want not-found error")
}
after, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read config: %v", err)
}
if string(after) != string(before) {
t.Fatalf("config was rewritten for unknown provider")
}
}
func TestRenameProviderFollowsActiveAndMigratesStoredKey(t *testing.T) {
dir := t.TempDir()
// Force the file credential backend so the test never touches the real OS
// keyring regardless of platform.
t.Setenv("ZERO_CRED_STORAGE", "encrypted-file")
path := filepath.Join(dir, "config.json")
writeConfigFixture(t, path, FileConfig{
ActiveProvider: "oldname",
Providers: []ProviderProfile{
{Name: "oldname", ProviderKind: ProviderKindOpenAICompatible, BaseURL: "https://a.example.com/v1", APIKeyStored: true, Model: "m1"},
{Name: "other", ProviderKind: ProviderKindOpenAICompatible, BaseURL: "https://b.example.com/v1", Model: "m2"},
},
}, 0o600)
store, err := ProviderKeyStoreAt(dir)
if err != nil {
t.Fatalf("open store: %v", err)
}
if err := store.Set("oldname", "sk-secret"); err != nil {
t.Fatalf("seed store: %v", err)
}
cfg, err := RenameProvider(path, "oldname", "newname")
if err != nil {
t.Fatalf("RenameProvider() error = %v", err)
}
if cfg.ActiveProvider != "newname" {
t.Fatalf("active must follow the rename, got %q", cfg.ActiveProvider)
}
if cfg.Providers[0].Name != "newname" || !cfg.Providers[0].APIKeyStored {
t.Fatalf("renamed profile wrong: %+v", cfg.Providers[0])
}
if cfg.Providers[1].Name != "other" {
t.Fatalf("unrelated profile changed: %+v", cfg.Providers[1])
}
key, ok, err := store.Get("newname")
if err != nil || !ok || key != "sk-secret" {
t.Fatalf("stored key must migrate to the new name, got key=%q ok=%v err=%v", key, ok, err)
}
if _, ok, _ := store.Get("oldname"); ok {
t.Fatalf("old credential entry must be deleted after migration")
}
}
func TestRenameProviderRejectsCollisionAndUnknown(t *testing.T) {
path := filepath.Join(t.TempDir(), "zero.json")
before := writeConfigFixture(t, path, FileConfig{
ActiveProvider: "alpha",
Providers: []ProviderProfile{
{Name: "alpha", ProviderKind: ProviderKindOpenAICompatible, BaseURL: "https://a.example.com/v1", Model: "m1"},
{Name: "beta", ProviderKind: ProviderKindOpenAICompatible, BaseURL: "https://b.example.com/v1", Model: "m2"},
},
}, 0o600)
if _, err := RenameProvider(path, "alpha", "BETA"); err == nil {
t.Fatal("rename onto an existing name must fail")
}
if _, err := RenameProvider(path, "ghost", "gamma"); err == nil {
t.Fatal("renaming an unknown provider must fail")
}
after, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read config: %v", err)
}
if string(after) != string(before) {
t.Fatalf("config was rewritten by a rejected rename")
}
}
func TestUpsertProviderPreservesStoredKeyMarkerOnExistingProfile(t *testing.T) {
path := filepath.Join(t.TempDir(), "zero.json")
// An env-keyed profile with NO stored-key marker — the shape a provider has
// before its key is captured into the credential store.
writeConfigFixture(t, path, FileConfig{
ActiveProvider: "groq",
Providers: []ProviderProfile{
{Name: "groq", ProviderKind: ProviderKindOpenAICompatible, BaseURL: "https://api.groq.com/openai/v1", APIKeyEnv: "GROQ_API_KEY", Model: "m1"},
},
}, 0o600)
// The manager/setup edit paths persist a SecureProviderProfile-shaped
// profile: key already in the store, marker set, inline key cleared.
cfg, err := UpsertProvider(path, ProviderProfile{Name: "groq", APIKeyStored: true}, false)
if err != nil {
t.Fatalf("UpsertProvider() error = %v", err)
}
if !cfg.Providers[0].APIKeyStored {
t.Fatalf("APIKeyStored marker must survive the merge, got %+v", cfg.Providers[0])
}
if cfg.Providers[0].APIKeyEnv != "GROQ_API_KEY" || cfg.Providers[0].BaseURL == "" {
t.Fatalf("other fields must be preserved: %+v", cfg.Providers[0])
}
persisted := readConfigFixture(t, path)
if !persisted.Providers[0].APIKeyStored {
t.Fatalf("marker not persisted to disk: %+v", persisted.Providers[0])
}
}
func TestSetProviderDescriptionSetsAndClears(t *testing.T) {
path := filepath.Join(t.TempDir(), "zero.json")
writeConfigFixture(t, path, FileConfig{
ActiveProvider: "alpha",
Providers: []ProviderProfile{
{Name: "alpha", ProviderKind: ProviderKindOpenAICompatible, BaseURL: "https://a.example.com/v1", Model: "m1", Description: "old text"},
},
}, 0o600)
cfg, err := SetProviderDescription(path, " ALPHA ", "new text")
if err != nil {
t.Fatalf("SetProviderDescription() error = %v", err)
}
if cfg.Providers[0].Description != "new text" {
t.Fatalf("description not set: %+v", cfg.Providers[0])
}
// Clearing must persist too — the reason this setter exists (UpsertProvider's
// merge treats an empty description as "leave unchanged").
cfg, err = SetProviderDescription(path, "alpha", " ")
if err != nil {
t.Fatalf("SetProviderDescription(clear) error = %v", err)
}
if cfg.Providers[0].Description != "" {
t.Fatalf("description not cleared: %+v", cfg.Providers[0])
}
persisted := readConfigFixture(t, path)
if persisted.Providers[0].Description != "" {
t.Fatalf("cleared description not persisted: %+v", persisted.Providers[0])
}
if _, err := SetProviderDescription(path, "ghost", "x"); err == nil {
t.Fatal("unknown provider must error")
}
}
func TestRenameProviderRollsBackKeyMigrationWhenConfigWriteFails(t *testing.T) {
if runtime.GOOS != "darwin" {
t.Skip("uses chflags uchg to force the config write to fail; macOS only")
}
dir := t.TempDir()
t.Setenv("ZERO_CRED_STORAGE", "encrypted-file")
path := filepath.Join(dir, "config.json")
writeConfigFixture(t, path, FileConfig{
ActiveProvider: "oldname",
Providers: []ProviderProfile{
{Name: "oldname", ProviderKind: ProviderKindOpenAICompatible, BaseURL: "https://a.example.com/v1", APIKeyStored: true, Model: "m1"},
},
}, 0o600)
store, err := ProviderKeyStoreAt(dir)
if err != nil {
t.Fatalf("open store: %v", err)
}
if err := store.Set("oldname", "sk-secret"); err != nil {
t.Fatalf("seed store: %v", err)
}
// Immutable flag: temp-file creation and store writes in the directory keep
// working, but the final rename over config.json fails — the exact window
// where a migrated key would otherwise strand under the new name.
if out, err := exec.Command("chflags", "uchg", path).CombinedOutput(); err != nil {
t.Skipf("chflags uchg unavailable: %v (%s)", err, out)
}
t.Cleanup(func() { _ = exec.Command("chflags", "nouchg", path).Run() })
if _, err := RenameProvider(path, "oldname", "newname"); err == nil {
t.Fatal("expected the config write to fail under the immutable flag")
}
key, ok, err := store.Get("oldname")
if err != nil || !ok || key != "sk-secret" {
t.Fatalf("key must be rolled back to the old name, got key=%q ok=%v err=%v", key, ok, err)
}
if _, ok, _ := store.Get("newname"); ok {
t.Fatalf("rolled-back migration must not leave a key under the new name")
}
}
// TestRenameProviderCaseOnlyKeepsStoredKey: the credential store normalizes
// names case-insensitively, so a case-only rename targets ONE store entry —
// migrating would Set and then Delete the same key, losing it (PR #560 review).
func TestRenameProviderCaseOnlyKeepsStoredKey(t *testing.T) {
dir := t.TempDir()
t.Setenv("ZERO_CRED_STORAGE", "encrypted-file")
path := filepath.Join(dir, "config.json")
writeConfigFixture(t, path, FileConfig{
ActiveProvider: "groq",
Providers: []ProviderProfile{
{Name: "groq", ProviderKind: ProviderKindOpenAICompatible, BaseURL: "https://api.groq.com/openai/v1", APIKeyStored: true, Model: "m1"},
},
}, 0o600)
store, err := ProviderKeyStoreAt(dir)
if err != nil {
t.Fatalf("open store: %v", err)
}
if err := store.Set("groq", "sk-secret"); err != nil {
t.Fatalf("seed store: %v", err)
}
cfg, err := RenameProvider(path, "groq", "Groq")
if err != nil {
t.Fatalf("RenameProvider() error = %v", err)
}
if cfg.Providers[0].Name != "Groq" || cfg.ActiveProvider != "Groq" {
t.Fatalf("case-only rename must still apply to config: %+v", cfg)
}
// The store is case-insensitive: the key must remain retrievable under the
// new casing (same entry), not deleted by a same-entry "migration".
if key, ok, err := store.Get("Groq"); err != nil || !ok || key != "sk-secret" {
t.Fatalf("stored key lost on case-only rename: key=%q ok=%v err=%v", key, ok, err)
}
}
func TestEditProviderAppliesRenameFieldsAndDescriptionAtomically(t *testing.T) {
dir := t.TempDir()
t.Setenv("ZERO_CRED_STORAGE", "encrypted-file")
path := filepath.Join(dir, "config.json")
writeConfigFixture(t, path, FileConfig{
ActiveProvider: "groq",
Providers: []ProviderProfile{
{Name: "groq", ProviderKind: ProviderKindOpenAICompatible, BaseURL: "https://api.groq.com/openai/v1", APIKeyStored: true, Model: "m1", Description: "old text"},
{Name: "other", ProviderKind: ProviderKindOpenAICompatible, BaseURL: "https://o.example.com/v1", Model: "m2"},
},
}, 0o600)
store, err := ProviderKeyStoreAt(dir)
if err != nil {
t.Fatalf("open store: %v", err)
}
if err := store.Set("groq", "sk-old"); err != nil {
t.Fatalf("seed store: %v", err)
}
cfg, err := EditProvider(path, ProviderEdit{
Name: "groq",
NewName: "grok-main",
Model: "m1-pro",
Description: "", // cleared — must persist verbatim
})
if err != nil {
t.Fatalf("EditProvider() error = %v", err)
}
edited := cfg.Providers[0]
if edited.Name != "grok-main" || edited.Model != "m1-pro" || edited.Description != "" {
t.Fatalf("edit not applied: %+v", edited)
}
if edited.BaseURL != "https://api.groq.com/openai/v1" || !edited.APIKeyStored {
t.Fatalf("untouched fields must survive: %+v", edited)
}
if cfg.ActiveProvider != "grok-main" {
t.Fatalf("active must follow the rename, got %q", cfg.ActiveProvider)
}
if key, ok, _ := store.Get("grok-main"); !ok || key != "sk-old" {
t.Fatalf("stored key must migrate with the rename, got %q ok=%v", key, ok)
}
if len(cfg.Providers) != 2 || cfg.Providers[1].Name != "other" {
t.Fatalf("unrelated profile changed: %+v", cfg.Providers)
}
}
// TestEditProviderCaseOnlyRenameUpdatesInPlace: the manager previously skipped
// RenameProvider on case-insensitively-equal names and fell into UpsertProvider,
// whose case-SENSITIVE merge appended a duplicate profile. EditProvider matches
// case-insensitively, so a case-only rename is an in-place update and the store
// entry (case-normalized) survives.
func TestEditProviderCaseOnlyRenameUpdatesInPlace(t *testing.T) {
dir := t.TempDir()
t.Setenv("ZERO_CRED_STORAGE", "encrypted-file")
path := filepath.Join(dir, "config.json")
writeConfigFixture(t, path, FileConfig{
ActiveProvider: "groq",
Providers: []ProviderProfile{
{Name: "groq", ProviderKind: ProviderKindOpenAICompatible, BaseURL: "https://api.groq.com/openai/v1", APIKeyStored: true, Model: "m1"},
},
}, 0o600)
store, err := ProviderKeyStoreAt(dir)
if err != nil {
t.Fatalf("open store: %v", err)
}
if err := store.Set("groq", "sk-secret"); err != nil {
t.Fatalf("seed store: %v", err)
}
cfg, err := EditProvider(path, ProviderEdit{Name: "groq", NewName: "Groq"})
if err != nil {
t.Fatalf("EditProvider() error = %v", err)
}
if len(cfg.Providers) != 1 {
t.Fatalf("case-only rename must not duplicate the profile: %+v", cfg.Providers)
}
if cfg.Providers[0].Name != "Groq" || !cfg.Providers[0].APIKeyStored {
t.Fatalf("in-place update wrong: %+v", cfg.Providers[0])
}
if cfg.Providers[0].BaseURL != "https://api.groq.com/openai/v1" {
t.Fatalf("fields must survive a case-only rename: %+v", cfg.Providers[0])
}
if cfg.ActiveProvider != "Groq" {
t.Fatalf("active must follow, got %q", cfg.ActiveProvider)
}
if key, ok, _ := store.Get("Groq"); !ok || key != "sk-secret" {
t.Fatalf("stored key lost on case-only rename: %q ok=%v", key, ok)
}
}
// TestEditProviderRenameMigratesFreshlyCapturedKey: replacing the key AND
// renaming in one edit — the caller captures under the CURRENT name and the
// rename migration moves it, so the new key lands under the new name.
func TestEditProviderRenameMigratesFreshlyCapturedKey(t *testing.T) {
dir := t.TempDir()
t.Setenv("ZERO_CRED_STORAGE", "encrypted-file")
path := filepath.Join(dir, "config.json")
writeConfigFixture(t, path, FileConfig{
ActiveProvider: "gw",
Providers: []ProviderProfile{
{Name: "gw", ProviderKind: ProviderKindOpenAICompatible, BaseURL: "https://gw.example.com/v1", APIKeyEnv: "GW_KEY", Model: "m1"},
},
}, 0o600)
store, err := ProviderKeyStoreAt(dir)
if err != nil {
t.Fatalf("open store: %v", err)
}
// The caller's contract: a replacement key is stored under the CURRENT name
// before EditProvider runs (what SecureProviderProfile does).
if err := store.Set("gw", "sk-new"); err != nil {
t.Fatalf("seed store: %v", err)
}
cfg, err := EditProvider(path, ProviderEdit{Name: "gw", NewName: "gateway", APIKeyStored: true})
if err != nil {
t.Fatalf("EditProvider() error = %v", err)
}
if !cfg.Providers[0].APIKeyStored || cfg.Providers[0].Name != "gateway" {
t.Fatalf("marker/rename wrong: %+v", cfg.Providers[0])
}
if key, ok, _ := store.Get("gateway"); !ok || key != "sk-new" {
t.Fatalf("captured key must migrate to the new name, got %q ok=%v", key, ok)
}
if _, ok, _ := store.Get("gw"); ok {
t.Fatalf("old entry must be cleaned up after migration")
}
}
func TestEditProviderRejectsCollisionAndUnknown(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.json")
before := writeConfigFixture(t, path, FileConfig{
ActiveProvider: "alpha",
Providers: []ProviderProfile{
{Name: "alpha", ProviderKind: ProviderKindOpenAICompatible, BaseURL: "https://a.example.com/v1", Model: "m1"},
{Name: "beta", ProviderKind: ProviderKindOpenAICompatible, BaseURL: "https://b.example.com/v1", Model: "m2"},
},
}, 0o600)
if _, err := EditProvider(path, ProviderEdit{Name: "alpha", NewName: "BETA"}); err == nil {
t.Fatal("rename onto an existing name must fail")
}
if _, err := EditProvider(path, ProviderEdit{Name: "ghost", Model: "x"}); err == nil {
t.Fatal("editing an unknown provider must fail")
}
after, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read config: %v", err)
}
if string(after) != string(before) {
t.Fatalf("config was rewritten by a rejected edit")
}
}
+28 -4
View File
@@ -14,6 +14,7 @@ import (
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/modelregistry"
"github.com/Gitlawb/zero/internal/oauth"
"github.com/Gitlawb/zero/internal/providerhealth"
"github.com/Gitlawb/zero/internal/redaction"
)
@@ -143,12 +144,19 @@ func providerConfigCheck(profile config.ProviderProfile) Check {
// Report credential PRESENCE, never the value. Reported under a non-sensitive
// key ("credentialConfigured"): the prior "apiKey" key was itself sensitive, so
// check()'s redaction scrubbed the indicator to [REDACTED] — making "set"/"not
// set" invisible. A profile can authenticate via a raw auth-header value instead
// of APIKey, and both are trimmed so a whitespace-only value reads "not set"
// (matching ProviderSnapshot.APIKeySet).
// set" invisible. HasConfiguredCredential is the shared definition of
// "key-authed" (inline key, raw auth header, or a key in the encrypted
// credential store), matching ProviderSnapshot.APIKeySet — checking only the
// inline fields made doctor and `zero providers list` disagree about the
// same stored-key profile. A stored OAuth login counts too: a keyless
// token-login provider (e.g. ChatGPT) is fully able to make requests and
// must not fail the one tool meant to verify setup.
credential := "not set"
if strings.TrimSpace(profile.APIKey) != "" || strings.TrimSpace(profile.AuthHeaderValue) != "" {
switch {
case profile.HasConfiguredCredential():
credential = "set"
case providerHasStoredOAuthLogin(profile):
credential = "oauth login"
}
details := map[string]any{
"name": profile.Name,
@@ -169,6 +177,22 @@ func providerConfigCheck(profile config.ProviderProfile) Check {
return check("provider.config", "Provider config", StatusPass, fmt.Sprintf("Provider config loaded for %s.", providerName(profile)), details)
}
// providerHasStoredOAuthLogin reports whether the token store holds a login for
// any of the profile's candidates — the same candidate set and normalized-key
// matching the runtime resolver uses. Errors degrade to false.
func providerHasStoredOAuthLogin(profile config.ProviderProfile) bool {
candidates := profile.OAuthLoginCandidates()
if len(candidates) == 0 {
return false
}
store, err := oauth.NewStore(oauth.StoreOptions{})
if err != nil {
return false
}
_, _, ok := oauth.FirstStored(store, candidates)
return ok
}
// localProviderBaseURL reports whether the configured base_url is a loopback host
// (a keyless local provider like Ollama/LM Studio), which needs no API key.
func localProviderBaseURL(baseURL string) bool {
+6 -6
View File
@@ -122,17 +122,17 @@ func createSecretFile(path string) ([]byte, error) {
if !errors.Is(err, os.ErrExist) && !errors.Is(err, os.ErrPermission) {
return nil, fmt.Errorf("oauth: create token secret lock: %w", err)
}
// Remember the lock-creation error itself rather than a subsequent
// "secret file doesn't exist yet" read error -- otherwise a real
// contention/ACL problem is masked by the expected-while-waiting
// ErrNotExist once the retries are exhausted.
lastErr = err
if data, rerr := readSecretFileRetry(path); rerr == nil {
return data, nil
} else {
lastErr = rerr
}
time.Sleep(secretRetryDelay)
}
if lastErr != nil {
return nil, fmt.Errorf("oauth: timed out waiting for token secret %s: %w", path, lastErr)
}
return nil, fmt.Errorf("oauth: timed out waiting for token secret lock %s", lockPath)
return nil, fmt.Errorf("oauth: timed out waiting for token secret %s: %w", path, lastErr)
}
func writeNewSecretFile(path string) ([]byte, error) {
+10 -2
View File
@@ -38,8 +38,16 @@ func ValidateKey(key string) error {
return nil
}
// ProviderKey builds the store key for a provider login.
func ProviderKey(name string) string { return KeyPrefixProvider + name }
// ProviderKey builds the store key for a provider login, normalizing the name
// to lower case. Every write (Manager.Login, the ChatGPT flow) and every
// lookup (FirstStored, GetFresh, logout, status filters) funnels through here,
// so normalizing at this one choke point keeps them symmetric: without it,
// `zero auth login xAI` stored "provider:xAI" while the profile scaffolded for
// it looked up "provider:xai" case-sensitively — a fresh, successful login
// that was invisible to the runtime.
func ProviderKey(name string) string {
return KeyPrefixProvider + strings.ToLower(strings.TrimSpace(name))
}
// FirstStored returns the token and its ProviderKey for the FIRST candidate name
// that has a token in the store, with ok=false when none do. Callers pass
+17
View File
@@ -126,3 +126,20 @@ func TestResolveStorePathHonorsOverride(t *testing.T) {
t.Fatalf("path = %q, want %q", path, override)
}
}
// TestProviderKeyNormalizesCase: every provider-login write and lookup funnels
// through ProviderKey, so `zero auth login xAI` and the scaffolded profile's
// candidate "xai" must resolve to the SAME store entry — the raw-typed casing
// previously produced an invisible login.
func TestProviderKeyNormalizesCase(t *testing.T) {
if got := ProviderKey(" xAI "); got != "provider:xai" {
t.Fatalf("ProviderKey must trim and lower-case, got %q", got)
}
store, _ := newTestStore(t)
if err := store.Save(ProviderKey("xAI"), Token{AccessToken: "tok"}); err != nil {
t.Fatalf("save: %v", err)
}
if _, key, ok := FirstStored(store, []string{"xai"}); !ok || key != "provider:xai" {
t.Fatalf("candidate \"xai\" must find the mixed-case login, ok=%v key=%q", ok, key)
}
}
+16 -5
View File
@@ -200,20 +200,23 @@ func normalizeHostScope(raw string) string {
// directory itself or any descendant; a host grant matches its exact normalized
// host. A narrower grant never covers a tool-wide request (reqScope == ""), so
// such a request re-prompts (fail-safe).
//
// File and dir comparisons go through filepath.Rel (via scopePathEqual and the
// shared pathWithinRoot helper) rather than a plain string comparison, so they
// apply the same case-folding as the workspace-boundary check on Windows --
// otherwise a persisted grant (including a deny) could be silently bypassed by
// spelling the same path with different case.
func grantCovers(grant Grant, reqScope string) bool {
switch grant.ScopeKind {
case ScopeToolWide:
return true
case ScopeFile:
return reqScope != "" && reqScope == grant.Scope
return reqScope != "" && scopePathEqual(grant.Scope, reqScope)
case ScopeDir:
if reqScope == "" || grant.Scope == "" {
return false
}
if reqScope == grant.Scope {
return true
}
return strings.HasPrefix(reqScope, grant.Scope+string(filepath.Separator))
return pathWithinRoot(grant.Scope, reqScope)
case ScopeHost:
return reqScope != "" && normalizeHostScope(reqScope) == normalizeHostScope(grant.Scope)
default:
@@ -221,6 +224,14 @@ func grantCovers(grant Grant, reqScope string) bool {
}
}
// scopePathEqual reports whether two absolute, cleaned scope paths refer to
// the same file, applying the same case-folding filepath.Rel already uses for
// directory-boundary checks (case-insensitive path components on Windows).
func scopePathEqual(a, b string) bool {
rel, err := filepath.Rel(a, b)
return err == nil && rel == "."
}
// scopeSpecificity ranks scope kinds so the most precise covering allow wins when
// several grants match the same request.
func scopeSpecificity(kind ScopeKind) int {
+24
View File
@@ -2,6 +2,8 @@ package sandbox
import (
"path/filepath"
"runtime"
"strings"
"testing"
)
@@ -234,3 +236,25 @@ func TestGrantCovers(t *testing.T) {
})
}
}
// TestGrantCoversCaseInsensitiveOnWindows guards against a persisted grant
// (including a deny) being silently bypassed by spelling the same path with
// different case, since Windows path components are case-insensitive.
func TestGrantCoversCaseInsensitiveOnWindows(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("path case-insensitivity is a Windows-specific filesystem property")
}
dir := filepath.Join(string(filepath.Separator)+"proj", "src")
file := filepath.Join(dir, "main.go")
descendant := filepath.Join(dir, "api", "z.go")
fileGrant := Grant{Scope: file, ScopeKind: ScopeFile}
dirGrant := Grant{Scope: dir, ScopeKind: ScopeDir}
if !grantCovers(fileGrant, strings.ToUpper(file)) {
t.Fatalf("file grant %q should cover differently-cased request %q on Windows", file, strings.ToUpper(file))
}
if !grantCovers(dirGrant, strings.ToUpper(descendant)) {
t.Fatalf("dir grant %q should cover differently-cased descendant %q on Windows", dir, strings.ToUpper(descendant))
}
}
+15 -8
View File
@@ -26,6 +26,8 @@ const (
secretRetryDelay = 2 * time.Millisecond
)
var openSecretLockFile = os.OpenFile
// Crypter encrypts a blob at rest with AES-256-GCM under a per-user random secret
// persisted (0600) at secretPath.
type Crypter struct {
@@ -107,7 +109,7 @@ func createSecretFile(path string) ([]byte, error) {
lockPath := path + ".lock"
var lastErr error
for attempt := 0; attempt < secretRetryAttempts; attempt++ {
lock, err := os.OpenFile(lockPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
lock, err := openSecretLockFile(lockPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
if err == nil {
_ = lock.Close()
defer os.Remove(lockPath)
@@ -118,20 +120,25 @@ func createSecretFile(path string) ([]byte, error) {
}
return writeNewSecretFile(path)
}
if !errors.Is(err, os.ErrExist) {
// On Windows a concurrent holder's os.Remove leaves the lock file in a
// "delete pending" state, so an O_EXCL create races it with
// ERROR_ACCESS_DENIED (os.ErrPermission) rather than ErrExist. Treat that
// as contention and retry too -- mirroring oauth's createSecretFile --
// otherwise concurrent secret creation spuriously fails on Windows.
if !errors.Is(err, os.ErrExist) && !errors.Is(err, os.ErrPermission) {
return nil, fmt.Errorf("securefile: create secret lock: %w", err)
}
// Remember the lock-creation error itself rather than a subsequent
// "secret file doesn't exist yet" read error -- otherwise a real
// contention/ACL problem is masked by the expected-while-waiting
// ErrNotExist once the retries are exhausted.
lastErr = err
if data, rerr := readSecretFileRetry(path); rerr == nil {
return data, nil
} else {
lastErr = rerr
}
time.Sleep(secretRetryDelay)
}
if lastErr != nil {
return nil, fmt.Errorf("securefile: timed out waiting for secret %s: %w", path, lastErr)
}
return nil, fmt.Errorf("securefile: timed out waiting for secret lock %s", lockPath)
return nil, fmt.Errorf("securefile: timed out waiting for secret %s: %w", path, lastErr)
}
func writeNewSecretFile(path string) ([]byte, error) {
+27
View File
@@ -7,6 +7,33 @@ import (
"testing"
)
func TestCreateSecretFileRetriesOnPermissionContention(t *testing.T) {
secretPath := filepath.Join(t.TempDir(), "k.secret")
attempts := 0
origOpen := openSecretLockFile
openSecretLockFile = func(path string, flag int, perm os.FileMode) (*os.File, error) {
attempts++
if attempts == 1 {
return nil, os.ErrPermission
}
return os.OpenFile(path, flag, perm)
}
t.Cleanup(func() {
openSecretLockFile = origOpen
})
secret, err := createSecretFile(secretPath)
if err != nil {
t.Fatalf("createSecretFile should retry on permission contention: %v", err)
}
if len(secret) != secretBytes {
t.Fatalf("secret length = %d, want %d", len(secret), secretBytes)
}
if attempts < 2 {
t.Fatalf("expected at least 2 lock attempts, got %d", attempts)
}
}
func TestSealOpenRoundTrip(t *testing.T) {
secret := filepath.Join(t.TempDir(), "k.secret")
c := NewCrypter(secret)
+233
View File
@@ -0,0 +1,233 @@
package sessions
import (
"encoding/json"
"fmt"
"reflect"
"strings"
"sync"
"testing"
"time"
)
func TestStoreAppendEventsBatchesSequencesAndMetadata(t *testing.T) {
store := NewStore(StoreOptions{RootDir: t.TempDir(), Now: sequenceClock([]time.Time{
time.Date(2026, 6, 4, 15, 0, 0, 0, time.UTC),
time.Date(2026, 6, 4, 15, 0, 1, 0, time.UTC),
time.Date(2026, 6, 4, 15, 0, 2, 0, time.UTC),
time.Date(2026, 6, 4, 15, 0, 3, 0, time.UTC),
})})
session, err := store.Create(CreateInput{SessionID: "batch"})
if err != nil {
t.Fatalf("Create returned error: %v", err)
}
appended, err := store.AppendEvents(session.SessionID, []AppendEventInput{
{Type: EventMessage, Payload: map[string]any{"role": "user", "content": "one"}},
{Type: EventToolCall, Payload: map[string]any{"id": "call_1", "name": "read_file"}},
{Type: EventToolResult, Payload: map[string]any{"toolCallId": "call_1", "status": "ok"}},
})
if err != nil {
t.Fatalf("AppendEvents returned error: %v", err)
}
if len(appended) != 3 {
t.Fatalf("expected 3 appended events, got %#v", appended)
}
for index, event := range appended {
wantSequence := index + 1
if event.Sequence != wantSequence || event.ID != fmt.Sprintf("batch:%d", wantSequence) {
t.Fatalf("event identity mismatch at %d: %#v", index, event)
}
}
if appended[0].CreatedAt != "2026-06-04T15:00:01Z" || appended[2].CreatedAt != "2026-06-04T15:00:03Z" {
t.Fatalf("unexpected event timestamps: %#v", appended)
}
loaded, err := store.Get(session.SessionID)
if err != nil {
t.Fatalf("Get returned error: %v", err)
}
if loaded == nil || loaded.EventCount != 3 || loaded.LastEventType != EventToolResult || loaded.UpdatedAt != appended[2].CreatedAt {
t.Fatalf("metadata not updated from final batch event: %#v", loaded)
}
events, err := store.ReadEvents(session.SessionID)
if err != nil {
t.Fatalf("ReadEvents returned error: %v", err)
}
if !reflect.DeepEqual(eventTypesForTest(events), []EventType{EventMessage, EventToolCall, EventToolResult}) {
t.Fatalf("unexpected event types: %#v", events)
}
}
func TestStoreAppendEventsEmptyBatchDoesNotRewriteMetadata(t *testing.T) {
store := NewStore(StoreOptions{RootDir: t.TempDir(), Now: fixedClock("2026-06-04T15:10:00Z")})
session, err := store.Create(CreateInput{SessionID: "empty_batch", Title: "unchanged"})
if err != nil {
t.Fatalf("Create returned error: %v", err)
}
appended, err := store.AppendEvents(session.SessionID, nil)
if err != nil {
t.Fatalf("AppendEvents empty returned error: %v", err)
}
if len(appended) != 0 {
t.Fatalf("empty AppendEvents returned %#v", appended)
}
loaded, err := store.Get(session.SessionID)
if err != nil {
t.Fatalf("Get returned error: %v", err)
}
if loaded == nil || !reflect.DeepEqual(*loaded, session) {
t.Fatalf("empty AppendEvents rewrote metadata: before=%#v after=%#v", session, loaded)
}
}
func TestStoreAppendEventsInvalidPayloadDoesNotPartiallyAppend(t *testing.T) {
store := NewStore(StoreOptions{RootDir: t.TempDir(), Now: fixedClock("2026-06-04T15:20:00Z")})
session, err := store.Create(CreateInput{SessionID: "invalid_batch"})
if err != nil {
t.Fatalf("Create returned error: %v", err)
}
_, err = store.AppendEvents(session.SessionID, []AppendEventInput{
{Type: EventMessage, Payload: map[string]any{"content": "valid first"}},
{Type: EventMessage, Payload: json.RawMessage(`{"broken"`)},
})
if err == nil || !strings.Contains(err.Error(), "invalid raw JSON payload") {
t.Fatalf("expected invalid raw payload error, got %v", err)
}
events, err := store.ReadEvents(session.SessionID)
if err != nil {
t.Fatalf("ReadEvents returned error: %v", err)
}
if len(events) != 0 {
t.Fatalf("invalid batch should not append partial events: %#v", events)
}
loaded, err := store.Get(session.SessionID)
if err != nil {
t.Fatalf("Get returned error: %v", err)
}
if loaded == nil || loaded.EventCount != 0 || loaded.LastEventType != "" {
t.Fatalf("invalid batch should not update metadata: %#v", loaded)
}
}
func TestStoreAppendEventsSequencesAfterMetadataLag(t *testing.T) {
store := NewStore(StoreOptions{RootDir: t.TempDir(), Now: fixedClock("2026-06-04T15:30:00Z")})
session, err := store.Create(CreateInput{SessionID: "lagging"})
if err != nil {
t.Fatalf("Create returned error: %v", err)
}
if _, err := store.AppendEvent(session.SessionID, AppendEventInput{Type: EventMessage, Payload: map[string]any{"content": "already durable"}}); err != nil {
t.Fatalf("AppendEvent returned error: %v", err)
}
lagging, err := store.Get(session.SessionID)
if err != nil {
t.Fatalf("Get returned error: %v", err)
}
lagging.EventCount = 0
lagging.LastEventType = ""
if err := store.writeMetadata(*lagging); err != nil {
t.Fatalf("writeMetadata returned error: %v", err)
}
appended, err := store.AppendEvents(session.SessionID, []AppendEventInput{
{Type: EventToolCall, Payload: map[string]any{"id": "call"}},
{Type: EventToolResult, Payload: map[string]any{"toolCallId": "call"}},
})
if err != nil {
t.Fatalf("AppendEvents returned error: %v", err)
}
if len(appended) != 2 || appended[0].Sequence != 2 || appended[1].Sequence != 3 {
t.Fatalf("batch did not sequence after durable log tail: %#v", appended)
}
loaded, err := store.Get(session.SessionID)
if err != nil {
t.Fatalf("Get returned error: %v", err)
}
if loaded == nil || loaded.EventCount != 3 || loaded.LastEventType != EventToolResult {
t.Fatalf("metadata not repaired by batch append: %#v", loaded)
}
}
func TestStoreAppendEventsSerializesConcurrentBatches(t *testing.T) {
store := NewStore(StoreOptions{RootDir: t.TempDir(), Now: fixedClock("2026-06-04T15:40:00Z")})
session, err := store.Create(CreateInput{SessionID: "concurrent_batches"})
if err != nil {
t.Fatalf("Create returned error: %v", err)
}
const writers = 8
const perBatch = 3
var wg sync.WaitGroup
errs := make(chan error, writers)
for writer := 0; writer < writers; writer++ {
wg.Add(1)
go func(writer int) {
defer wg.Done()
appended, err := store.AppendEvents(session.SessionID, []AppendEventInput{
{Type: EventMessage, Payload: map[string]int{"writer": writer, "index": 0}},
{Type: EventMessage, Payload: map[string]int{"writer": writer, "index": 1}},
{Type: EventMessage, Payload: map[string]int{"writer": writer, "index": 2}},
})
if err != nil {
errs <- err
return
}
if len(appended) != perBatch {
errs <- fmt.Errorf("writer %d appended %d events", writer, len(appended))
return
}
for index := 1; index < len(appended); index++ {
if appended[index].Sequence != appended[index-1].Sequence+1 {
errs <- fmt.Errorf("writer %d batch was interleaved: %#v", writer, appended)
return
}
}
errs <- nil
}(writer)
}
wg.Wait()
close(errs)
for err := range errs {
if err != nil {
t.Fatalf("AppendEvents returned error: %v", err)
}
}
events, err := store.ReadEvents(session.SessionID)
if err != nil {
t.Fatalf("ReadEvents returned error: %v", err)
}
total := writers * perBatch
if len(events) != total {
t.Fatalf("expected %d events, got %d", total, len(events))
}
seen := map[int]bool{}
for _, event := range events {
if seen[event.Sequence] {
t.Fatalf("duplicate sequence %d in %#v", event.Sequence, events)
}
seen[event.Sequence] = true
}
for sequence := 1; sequence <= total; sequence++ {
if !seen[sequence] {
t.Fatalf("missing sequence %d in %#v", sequence, events)
}
}
loaded, err := store.Get(session.SessionID)
if err != nil {
t.Fatalf("Get returned error: %v", err)
}
if loaded == nil || loaded.EventCount != total || loaded.LastEventType != EventMessage {
t.Fatalf("metadata not updated after concurrent batches: %#v", loaded)
}
}
func eventTypesForTest(events []Event) []EventType {
types := make([]EventType, 0, len(events))
for _, event := range events {
types = append(types, event.Type)
}
return types
}
+15 -1
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"log"
"strings"
"unicode/utf8"
)
type ExecMode string
@@ -221,11 +222,24 @@ func summarizePayload(payload any) string {
text = string(data)
}
if len(text) > 500 {
return text[:500]
return truncateUTF8(text, 500)
}
return text
}
// truncateUTF8 returns the longest prefix of s that is at most n bytes,
// backing off to the nearest rune boundary so a multi-byte character isn't
// split — a split rune here would embed invalid UTF-8 into the exec prompt.
func truncateUTF8(s string, n int) string {
if len(s) <= n {
return s
}
for n > 0 && !utf8.RuneStart(s[n]) {
n--
}
return s[:n]
}
func extractText(value any) string {
switch typed := value.(type) {
case string:
+99 -39
View File
@@ -153,6 +153,11 @@ type AppendEventInput struct {
Payload any
}
type preparedAppendEvent struct {
Type EventType
Payload json.RawMessage
}
type RecordSpecInput struct {
SpecID string
SpecFilePath string
@@ -426,7 +431,7 @@ func (store *Store) Fork(parentSessionID string, input ForkInput) (Metadata, err
if err != nil {
return Metadata{}, err
}
copied := 0
copyInputs := []AppendEventInput{}
for _, event := range events {
// Do NOT copy usage accounting into the fork. It already counted against the
// parent, and a usage report that aggregates the parent and the fork would
@@ -435,11 +440,12 @@ func (store *Store) Fork(parentSessionID string, input ForkInput) (Metadata, err
if event.Type == EventUsage {
continue
}
if _, err := store.AppendEvent(fork.SessionID, AppendEventInput{Type: event.Type, Payload: event.Payload}); err != nil {
return Metadata{}, err
}
copied++
copyInputs = append(copyInputs, AppendEventInput{Type: event.Type, Payload: event.Payload})
}
if _, err := store.AppendEvents(fork.SessionID, copyInputs); err != nil {
return Metadata{}, err
}
copied := len(copyInputs)
// Copy the parent's content-addressed checkpoint blobs into the fork so the
// copied EventSessionCheckpoint events resolve to real blobs and a rewind on
// the fork can restore file content (otherwise rewind reads missing blobs
@@ -513,19 +519,34 @@ func (store *Store) RecordSpec(sessionID string, input RecordSpecInput) (Metadat
}
func (store *Store) AppendEvent(sessionID string, input AppendEventInput) (Event, error) {
if !ValidSessionID(sessionID) {
return Event{}, fmt.Errorf("invalid zero session id %q", sessionID)
}
if strings.TrimSpace(string(input.Type)) == "" {
return Event{}, fmt.Errorf("zero session event type is required")
}
unlock, err := store.lockSession(sessionID)
events, err := store.AppendEvents(sessionID, []AppendEventInput{input})
if err != nil {
return Event{}, err
}
return events[0], nil
}
// AppendEvents appends a batch of events atomically under one session lock and
// durability pass. The returned events have contiguous sequences in input order.
// An empty batch is a no-op and does not rewrite metadata.
func (store *Store) AppendEvents(sessionID string, inputs []AppendEventInput) ([]Event, error) {
if !ValidSessionID(sessionID) {
return nil, fmt.Errorf("invalid zero session id %q", sessionID)
}
prepared, err := prepareAppendEventInputs(inputs)
if err != nil {
return nil, err
}
if len(prepared) == 0 {
return []Event{}, nil
}
unlock, err := store.lockSession(sessionID)
if err != nil {
return nil, err
}
defer unlock()
return store.appendEventLocked(sessionID, input)
return store.appendPreparedEventsLocked(sessionID, prepared)
}
// AppendEventUnlessExists appends input only when exists(currentEvents) is false,
@@ -572,14 +593,27 @@ func (store *Store) AppendEventUnlessExists(sessionID string, input AppendEventI
// the single lock they already hold, instead of re-locking (which would deadlock
// on the non-reentrant in-process mutex).
func (store *Store) appendEventLocked(sessionID string, input AppendEventInput) (Event, error) {
session, err := store.readMetadata(sessionID)
prepared, err := prepareAppendEventInputs([]AppendEventInput{input})
if err != nil {
return Event{}, err
}
payload, err := rawPayload(input.Payload)
events, err := store.appendPreparedEventsLocked(sessionID, prepared)
if err != nil {
return Event{}, err
}
return events[0], nil
}
// appendPreparedEventsLocked appends pre-validated events WITHOUT acquiring the
// session lock. The caller MUST already hold store.lockSession(sessionID).
func (store *Store) appendPreparedEventsLocked(sessionID string, inputs []preparedAppendEvent) ([]Event, error) {
if len(inputs) == 0 {
return []Event{}, nil
}
session, err := store.readMetadata(sessionID)
if err != nil {
return nil, err
}
sequence := session.EventCount + 1
// The events log is the source of truth for sequencing. metadata.EventCount is
// persisted separately (after the event line below), so a crash between the two
@@ -590,46 +624,54 @@ func (store *Store) appendEventLocked(sessionID string, input AppendEventInput)
if logSeq, err := store.lastEventSequence(sessionID); err == nil && logSeq >= sequence {
sequence = logSeq + 1
}
timestamp := store.timestamp()
event := Event{
ID: fmt.Sprintf("%s:%d", sessionID, sequence),
SessionID: sessionID,
Sequence: sequence,
Type: input.Type,
CreatedAt: timestamp,
Payload: payload,
}
data, err := json.Marshal(event)
if err != nil {
return Event{}, fmt.Errorf("encode zero session event: %w", err)
events := make([]Event, 0, len(inputs))
var batch bytes.Buffer
for index, input := range inputs {
timestamp := store.timestamp()
event := Event{
ID: fmt.Sprintf("%s:%d", sessionID, sequence+index),
SessionID: sessionID,
Sequence: sequence + index,
Type: input.Type,
CreatedAt: timestamp,
Payload: input.Payload,
}
data, err := json.Marshal(event)
if err != nil {
return nil, fmt.Errorf("encode zero session event: %w", err)
}
batch.Write(data)
batch.WriteByte('\n')
events = append(events, event)
}
file, err := os.OpenFile(store.eventsPath(sessionID), os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0o600)
if err != nil {
return Event{}, fmt.Errorf("append zero session event: %w", err)
return nil, fmt.Errorf("append zero session event: %w", err)
}
if _, err := file.Write(append(data, '\n')); err != nil {
if _, err := file.Write(batch.Bytes()); err != nil {
_ = file.Close()
return Event{}, fmt.Errorf("append zero session event: %w", err)
return nil, fmt.Errorf("append zero session event: %w", err)
}
// fsync the event before reporting success: the derived metadata.json IS
// fsync the events before reporting success: the derived metadata.json IS
// fsync'd (writeMetadata), so without this a crash after the metadata flush
// but before the events.jsonl page reaches disk leaves EventCount ahead of the
// durable log — silently losing the just-appended event (incl. the checkpoint
// durable log — silently losing just-appended events (incl. checkpoints
// that /rewind targets). Make the log at least as durable as its metadata. (AUDIT-M12)
if err := file.Sync(); err != nil {
_ = file.Close()
return Event{}, fmt.Errorf("sync zero session event: %w", err)
return nil, fmt.Errorf("sync zero session event: %w", err)
}
if err := file.Close(); err != nil {
return Event{}, fmt.Errorf("close zero session event file: %w", err)
return nil, fmt.Errorf("close zero session event file: %w", err)
}
session.UpdatedAt = timestamp
session.EventCount = sequence
session.LastEventType = input.Type
last := events[len(events)-1]
session.UpdatedAt = last.CreatedAt
session.EventCount = last.Sequence
session.LastEventType = last.Type
if err := store.writeMetadata(session); err != nil {
return Event{}, err
return nil, err
}
return event, nil
return events, nil
}
// UpdateTitle replaces a session's Title and returns the updated metadata. It is
@@ -964,6 +1006,24 @@ func rawPayload(payload any) (json.RawMessage, error) {
return data, nil
}
func prepareAppendEventInputs(inputs []AppendEventInput) ([]preparedAppendEvent, error) {
prepared := make([]preparedAppendEvent, 0, len(inputs))
for _, input := range inputs {
if strings.TrimSpace(string(input.Type)) == "" {
return nil, fmt.Errorf("zero session event type is required")
}
payload, err := rawPayload(input.Payload)
if err != nil {
return nil, err
}
prepared = append(prepared, preparedAppendEvent{
Type: input.Type,
Payload: payload,
})
}
return prepared, nil
}
func envValue(env map[string]string, key string) string {
if env != nil {
return env[key]
+18
View File
@@ -11,6 +11,7 @@ import (
"sync"
"testing"
"time"
"unicode/utf8"
)
func TestStoreCreatesAppendsListsAndReadsEvents(t *testing.T) {
@@ -640,6 +641,23 @@ func TestFormatExecPromptTruncatesConversationMessagesAfterFilteringNoise(t *tes
}
}
// A truncation cut at a raw byte offset can land in the middle of a
// multi-byte UTF-8 rune (e.g. CJK text), embedding invalid UTF-8 into the
// exec prompt. summarizePayload must back off to a rune boundary instead.
func TestSummarizePayloadTruncatesOnRuneBoundary(t *testing.T) {
content := strings.Repeat("中文", 300) // 900 bytes of 3-byte runes
payload := json.RawMessage(fmt.Sprintf(`{"role":"user","content":%q}`, content))
got := summarizePayload(payload)
if !utf8.ValidString(got) {
t.Fatalf("summarizePayload produced invalid UTF-8: %q", got)
}
if len(got) > 500 {
t.Fatalf("expected summary to be at most 500 bytes, got %d", len(got))
}
}
func TestPrepareExecPersistsSpecialistMetadataForNewSession(t *testing.T) {
store := NewStore(StoreOptions{RootDir: t.TempDir(), Now: fixedClock("2026-06-04T14:30:00Z")})
+89 -22
View File
@@ -83,7 +83,12 @@ func (tool bashTool) run(ctx context.Context, args map[string]any, engine *zeroS
if err != nil {
return errorResult("Error: Invalid arguments for bash: " + err.Error())
}
if issue := detectShellCommandIssue(commandText, runtime.GOOS); issue != nil {
// Resolve the command engine before the MSYS preflight check so an
// approved require_escalated call (commandEngine == nil, truly
// unsandboxed) can actually bypass the MSYS guard instead of being
// hard-blocked by the same check it was meant to escalate past.
commandEngine := commandEngineForSandboxPermissions(engine, sandboxPermissions)
if issue := detectShellCommandIssue(commandText, runtime.GOOS); issue != nil && !msysGuardBypassed(issue, commandEngine) {
return shellIssueBlockResult(*issue)
}
@@ -106,7 +111,6 @@ func (tool bashTool) run(ctx context.Context, args map[string]any, engine *zeroS
"cwd": relativeCwd,
"timeout_ms": strconv.Itoa(timeoutMS),
}
commandEngine := commandEngineForSandboxPermissions(engine, sandboxPermissions)
if commandEngine == nil && sandboxPermissions == SandboxPermissionsRequireEscalated {
meta["sandbox_permissions"] = string(SandboxPermissionsRequireEscalated)
}
@@ -126,8 +130,8 @@ func (tool bashTool) run(ctx context.Context, args map[string]any, engine *zeroS
// can't grow Zero's memory before truncation: only the head+tail each stream
// will ever surface to the model are retained, the middle is discarded as it
// streams, and the true size is counted for the truncation marker.
stdout := newBoundedBuffer(bashOutputBudgetBytes, bashOutputBudgetBytes)
stderr := newBoundedBuffer(bashOutputBudgetBytes, bashOutputBudgetBytes)
stdout := newBoundedBuffer(bashCaptureBudgetBytes, bashCaptureBudgetBytes)
stderr := newBoundedBuffer(bashCaptureBudgetBytes, bashCaptureBudgetBytes)
command.Stdout = stdout
command.Stderr = stderr
@@ -167,7 +171,7 @@ func (tool bashTool) run(ctx context.Context, args map[string]any, engine *zeroS
outText, errText, truncated := budgetBashCapture(stdoutText, stdout.total, stderrText, stderrTotal, meta)
return Result{
Status: StatusError,
Output: formatBashOutputWithShellHint(commandText, outText, errText, exitCode, meta),
Output: formatBashOutputWithShellHint(outText, errText, exitCode, meta),
Truncated: truncated,
Meta: meta,
}
@@ -178,7 +182,7 @@ func (tool bashTool) run(ctx context.Context, args map[string]any, engine *zeroS
if meta[SandboxLikelyDeniedMeta] == "true" {
return Result{
Status: StatusError,
Output: formatBashOutputWithShellHint(commandText, outText, errText, exitCode, meta),
Output: formatBashOutputWithShellHint(outText, errText, exitCode, meta),
Truncated: truncated,
Meta: meta,
}
@@ -198,6 +202,18 @@ func commandEngineForSandboxPermissions(engine *zeroSandbox.Engine, sandboxPermi
return engine
}
// msysGuardBypassed reports whether a windows_msys_sandbox preflight issue no
// longer applies because sandbox_permissions: require_escalated actually
// resolved to unsandboxed, host-level execution (commandEngine is nil only
// then, per commandEngineForSandboxPermissions). MSYS coreutils fail because
// of the sandbox's restricted token/handles, so running outside it removes
// the failure mode this guard exists for. Any other issue kind (e.g. a real
// cmd.exe syntax problem) still blocks regardless of escalation, since
// running outside the sandbox does not fix a syntax error.
func msysGuardBypassed(issue *shellIssue, commandEngine *zeroSandbox.Engine) bool {
return issue.Kind == windowsMsysSandboxKind && commandEngine == nil
}
// appendSandboxBlocks appends a <sandbox_blocks> block listing the denials
// the sandbox log monitor captured, so the model can see what was blocked. With no
// blocks the stderr is returned unchanged.
@@ -370,12 +386,21 @@ func formatBashOutput(stdout string, stderr string, exitCode int) string {
return strings.Join(parts, "\n")
}
// bashOutputBudgetBytes caps each of stdout/stderr shown to the model. bash is the
// one tool that can emit unbounded output (`cat large.log`, `find /`, verbose test
// runs); every other read/search tool already budgets its output. Head+tail
// truncation keeps both the start and the end of an oversized stream, since
// build/test failures usually surface at the tail.
const bashOutputBudgetBytes = 96 * 1024
// bashOutputBudgetBytes caps each of stdout/stderr shown to the model (32 KiB
// ≈ 8k tokens per stream). bash is the one tool that can emit unbounded output
// (`cat large.log`, `find /`, verbose test runs); a chatty build log at the old
// 96 KiB budget injected ~24k tokens per call and was then re-billed on every
// following turn. Head+tail truncation keeps both the start and the end of an
// oversized stream, since build/test failures usually surface at the tail, and
// the full captured text is spilled to disk so nothing is lost — the model
// greps/reads the spill instead of re-running the command.
const bashOutputBudgetBytes = 32 * 1024
// bashCaptureBudgetBytes bounds the head and tail retained IN MEMORY per
// stream during capture. Deliberately larger than the emit budget: the extra
// retained bytes never reach the transcript, but they make the spill file —
// the model's recovery path — cover 6× more of the output.
const bashCaptureBudgetBytes = 96 * 1024
// budgetBashOutput truncates stdout and stderr to bashOutputBudgetBytes each,
// keeping the head and tail of anything larger, and records raw/emitted byte
@@ -395,6 +420,19 @@ func budgetBashCapture(out string, outTotal int, errStr string, errTotal int, me
outText, outRaw, outTrunc := truncateHeadTailWithTotal(out, outTotal, bashOutputBudgetBytes)
errText, errRaw, errTrunc := truncateHeadTailWithTotal(errStr, errTotal, bashOutputBudgetBytes)
truncated := outTrunc || errTrunc
if truncated {
if spillPath := spillBashStreams(out, outTotal, errStr, errTotal); spillPath != "" {
hint := "\n[zero] captured output saved to " + spillPath + " (grep or read_file it instead of re-running)"
if errTrunc {
errText += hint
} else {
outText += hint
}
if meta != nil {
meta["spill_path"] = spillPath
}
}
}
if meta != nil {
emitted := len(outText) + len(errText)
meta["raw_bytes"] = strconv.Itoa(outRaw + errRaw)
@@ -407,6 +445,43 @@ func budgetBashCapture(out string, outTotal int, errStr string, errTotal int, me
return outText, errText, truncated
}
// spillBashStreams writes the retained (capture-bounded) stdout and stderr to
// the spill directory as one sectioned file and returns its path, or "" when
// spilling fails. The spill holds up to bashCaptureBudgetBytes of head and
// tail per stream — everything zero kept in memory, which is more than the
// emit budget shows the model but not necessarily the whole output. When the
// capture itself dropped the middle of a stream (total exceeds the retained
// bytes), a gap marker is inserted at the head/tail junction so the spilled
// log never reads as contiguous when it is not.
func spillBashStreams(stdout string, stdoutTotal int, stderr string, stderrTotal int) string {
stdout = sectionWithCaptureGap(stdout, stdoutTotal)
stderr = sectionWithCaptureGap(stderr, stderrTotal)
var combined strings.Builder
combined.Grow(len(stdout) + len(stderr) + 64)
combined.WriteString("### stdout\n")
combined.WriteString(stdout)
if !strings.HasSuffix(stdout, "\n") {
combined.WriteString("\n")
}
combined.WriteString("### stderr\n")
combined.WriteString(stderr)
return spillTruncatedOutput("bash", combined.String())
}
// sectionWithCaptureGap marks the point where boundedBuffer dropped the middle
// of a stream. When total exceeds the retained bytes, the retained text is the
// frozen head (bashCaptureBudgetBytes, always full once overflow happened)
// followed immediately by the rolling tail — the junction sits at the head cap,
// snapped back to a rune boundary.
func sectionWithCaptureGap(text string, total int) string {
if total <= len(text) || len(text) <= bashCaptureBudgetBytes {
return text
}
head := utf8Prefix(text, bashCaptureBudgetBytes)
marker := fmt.Sprintf("\n[zero] capture gap: %d bytes omitted from the middle of this stream\n", total-len(text))
return head + marker + text[len(head):]
}
// boundedBuffer is an io.Writer that retains at most headCap bytes from the start
// and tailCap bytes from the end of a stream while counting the total written, so
// a command emitting unbounded output (`cat huge.log`, `yes`) cannot grow Zero's
@@ -461,14 +536,6 @@ func (b *boundedBuffer) retained() string {
return string(b.head) + string(b.tail)
}
// truncateHeadTail keeps the first and last halves of value when it exceeds
// maxBytes, dropping the middle behind a marker. Returns the possibly-truncated
// text, the raw byte length, and whether truncation happened.
func truncateHeadTail(value string, maxBytes int) (string, int, bool) {
// value holds the whole stream, so its length is the true raw size.
return truncateHeadTailWithTotal(value, len(value), maxBytes)
}
// truncateHeadTailWithTotal head+tail-truncates value to maxBytes, using total —
// the full original byte count — for the "N bytes omitted" marker and the raw
// count. total may exceed len(value) when the middle was already discarded during
@@ -488,9 +555,9 @@ func truncateHeadTailWithTotal(value string, total, maxBytes int) (string, int,
return utf8Prefix(value, head) + marker + utf8Suffix(value, tail), total, true
}
func formatBashOutputWithShellHint(command string, stdout string, stderr string, exitCode int, meta map[string]string) string {
func formatBashOutputWithShellHint(stdout string, stderr string, exitCode int, meta map[string]string) string {
output := formatBashOutput(stdout, stderr, exitCode)
if issue := detectShellOutputIssue(command, stdout+"\n"+stderr, runtime.GOOS); issue != nil {
if issue := detectShellOutputIssue(stdout+"\n"+stderr, runtime.GOOS); issue != nil {
meta["shell_issue"] = issue.Kind
output = appendShellIssueHint(output, *issue)
}
+63 -7
View File
@@ -2,11 +2,23 @@ package tools
import (
"fmt"
"os"
"strconv"
"strings"
"testing"
)
// setTestTempDir points every platform's temp-dir lookup at a per-test
// directory so spill files never land in the real temp dir: os.TempDir reads
// TMPDIR on Unix but TMP/TEMP on Windows.
func setTestTempDir(t *testing.T) {
t.Helper()
dir := t.TempDir()
t.Setenv("TMPDIR", dir)
t.Setenv("TMP", dir)
t.Setenv("TEMP", dir)
}
// Small output passes through untouched and records raw==emitted, no truncated flag.
func TestBudgetBashOutputSmallPassesThrough(t *testing.T) {
meta := map[string]string{}
@@ -26,8 +38,10 @@ func TestBudgetBashOutputSmallPassesThrough(t *testing.T) {
}
// Oversized stdout is truncated head+tail: both the first and last lines survive,
// the middle is dropped behind a marker, and meta is flagged.
// the middle is dropped behind a marker, meta is flagged, and the captured text
// is spilled to a re-readable file.
func TestBudgetBashOutputTruncatesHeadAndTail(t *testing.T) {
setTestTempDir(t)
head := "FIRST_LINE_MARKER\n"
tail := "\nLAST_LINE_MARKER"
big := head + strings.Repeat("x", bashOutputBudgetBytes) + tail
@@ -47,9 +61,23 @@ func TestBudgetBashOutputTruncatesHeadAndTail(t *testing.T) {
if !strings.Contains(out, "output truncated") {
t.Fatalf("expected a truncation marker, got:\n%s", out[:200])
}
if len(out) > bashOutputBudgetBytes {
// The spill hint (path length varies) is the only allowed overage past the budget.
if len(out) > bashOutputBudgetBytes+512 {
t.Fatalf("emitted %d bytes exceeds budget %d", len(out), bashOutputBudgetBytes)
}
if !strings.Contains(out, "captured output saved to ") {
t.Fatalf("truncated bash output must carry a spill hint:\n%s", out[len(out)-300:])
}
if meta["spill_path"] == "" {
t.Fatal("spill path missing from meta")
}
content, err := os.ReadFile(meta["spill_path"])
if err != nil {
t.Fatalf("spill file unreadable: %v", err)
}
if !strings.Contains(string(content), "### stdout") || !strings.Contains(string(content), strings.Repeat("x", 1024)) {
t.Fatalf("spill must hold the sectioned captured streams (got %d bytes)", len(content))
}
if meta["truncated"] != "true" {
t.Fatalf("expected truncated=true, got %v", meta)
}
@@ -100,10 +128,12 @@ func TestBoundedBufferKeepsHeadAndTailBounded(t *testing.T) {
// budgetBashCapture reports the TRUE total (not the retained size) in the marker
// and raw_bytes, even though only a bounded head+tail was ever held in memory.
func TestBudgetBashCaptureReportsTrueTotal(t *testing.T) {
// Retained head+tail as boundedBuffer would hand over; the real command produced
// far more than was kept.
retained := "HEAD_START" + strings.Repeat("y", bashOutputBudgetBytes) + "TAIL_END"
total := 10 * bashOutputBudgetBytes
setTestTempDir(t)
// Retained head+tail as boundedBuffer would hand over after overflow: the
// frozen head is full at bashCaptureBudgetBytes, then the rolling tail; the
// real command produced far more than was kept.
retained := "HEAD_START" + strings.Repeat("y", bashCaptureBudgetBytes) + "TAIL_END"
total := 10 * bashCaptureBudgetBytes
meta := map[string]string{}
out, _, truncated := budgetBashCapture(retained, total, "", 0, meta)
@@ -114,7 +144,8 @@ func TestBudgetBashCaptureReportsTrueTotal(t *testing.T) {
if !strings.Contains(out, "HEAD_START") || !strings.Contains(out, "TAIL_END") {
t.Fatalf("head/tail lost after budgeting:\n%s", out[:min(120, len(out))])
}
if len(out) > bashOutputBudgetBytes {
// Spill hint is the only allowed overage past the budget.
if len(out) > bashOutputBudgetBytes+512 {
t.Fatalf("emitted %d bytes exceeds budget %d", len(out), bashOutputBudgetBytes)
}
if meta["raw_bytes"] != strconv.Itoa(total) {
@@ -124,4 +155,29 @@ func TestBudgetBashCaptureReportsTrueTotal(t *testing.T) {
if !strings.Contains(out, strconv.Itoa(total-bashOutputBudgetBytes)) {
t.Fatalf("marker should cite the true omitted byte count:\n%s", out[:min(200, len(out))])
}
// The spill must be recorded, sectioned, and carry a capture-gap marker at
// the head/tail junction — the capture dropped the middle, so the spilled
// log must never read as contiguous.
if meta["spill_path"] == "" {
t.Fatal("spill path missing from meta")
}
content, err := os.ReadFile(meta["spill_path"])
if err != nil {
t.Fatalf("spill file unreadable: %v", err)
}
if !strings.Contains(string(content), "### stdout") {
t.Fatal("spill must be sectioned by stream")
}
gapMarker := fmt.Sprintf("capture gap: %d bytes omitted", total-len(retained))
gapIndex := strings.Index(string(content), gapMarker)
if gapIndex < 0 {
t.Fatalf("spill missing capture-gap marker %q", gapMarker)
}
// The junction sits right after the frozen head (plus the section header),
// not appended at the end of the stream.
headerOffset := len("### stdout\n")
if gapIndex < headerOffset+bashCaptureBudgetBytes-64 || gapIndex > headerOffset+bashCaptureBudgetBytes+64 {
t.Fatalf("capture-gap marker at offset %d, want ~%d (the head cap)", gapIndex, headerOffset+bashCaptureBudgetBytes)
}
}
+131 -3
View File
@@ -181,9 +181,13 @@ func TestDetectShellCommandIssueFlagsPipedPosixUtilities(t *testing.T) {
`cat file.txt | wc -l`,
`some_command | tail -n 20`,
} {
if issue := detectShellCommandIssue(command, "windows"); issue == nil {
issue := detectShellCommandIssue(command, "windows")
if issue == nil {
t.Fatalf("expected POSIX utility pipeline to be flagged for %q", command)
}
if issue.Kind != "windows_msys_sandbox" {
t.Fatalf("expected windows_msys_sandbox for %q, got %q", command, issue.Kind)
}
}
}
@@ -195,7 +199,7 @@ func TestDetectShellCommandIssueAllowsUnrelatedCommands(t *testing.T) {
`echo header text`,
`tail-log.sh`,
`grep-cli --version`,
`sed.exe --help`,
`mytool.exe --help`,
} {
if issue := detectShellCommandIssue(command, "windows"); issue != nil {
t.Fatalf("expected unrelated command to pass for %q, got %#v", command, issue)
@@ -203,8 +207,33 @@ func TestDetectShellCommandIssueAllowsUnrelatedCommands(t *testing.T) {
}
}
// Operator/utility-name text that only appears inside a quoted argument must
// not be mistaken for real shell syntax — e.g. `echo "foo; ls -la"` is a
// harmless echo, not a bash-style `ls` invocation, and quoted `head`/`grep`
// text isn't a piped POSIX utility.
func TestDetectShellCommandIssueIgnoresQuotedContent(t *testing.T) {
for _, command := range []string{
`echo "foo; ls -la"`,
`echo "run head over the file"`,
`echo 'ls -la is a common bash command'`,
`echo "grep for the pattern"`,
} {
if issue := detectShellCommandIssue(command, "windows"); issue != nil {
t.Fatalf("expected quoted content to be ignored for %q, got %#v", command, issue)
}
}
// Sanity: the same utility names still get flagged when they're NOT quoted.
// cmd.exe (unlike bash) does not treat ; as a statement separator, so
// `echo "foo" ; ls -la` is a single echo invocation with literal
// arguments, not a real ls invocation, and must not be flagged.
if issue := detectShellCommandIssue(`git log | head`, "windows"); issue == nil {
t.Fatalf("expected unquoted shell syntax to still be flagged for %q", `git log | head`)
}
}
func TestDetectShellOutputIssueAddsWindowsSyntaxHint(t *testing.T) {
issue := detectShellOutputIssue(`cd /d/tmp/zero-pr-158 && ls -la`, "The syntax of the command is incorrect.", "windows")
issue := detectShellOutputIssue("The syntax of the command is incorrect.", "windows")
if issue == nil {
t.Fatal("expected Windows syntax error to get shell guidance")
}
@@ -434,6 +463,105 @@ func TestRegistryRunsWithDegradedUnavailableNativeSandbox(t *testing.T) {
}
}
// TestBashToolRequireEscalatedMsysGuard covers the boundary of the fix for an
// escalation-vs-preflight ordering bug: the MSYS sandbox guard exists only
// because MSYS/Cygwin coreutils fail under the write-restricted sandbox, so
// once sandbox_permissions: require_escalated is actually approved (running
// the command unsandboxed), the guard must get out of the way, but an
// unrelated windows_shell_syntax issue (a real cmd.exe syntax problem, not a
// sandbox interaction) must still block regardless of escalation.
func TestBashToolRequireEscalatedMsysGuard(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("windows-only MSYS sandbox guard")
}
root := t.TempDir()
newEngine := func() *sandbox.Engine {
return sandbox.NewEngine(sandbox.EngineOptions{
WorkspaceRoot: root,
Policy: sandbox.DefaultPolicy(),
Backend: sandbox.Backend{Name: sandbox.BackendUnavailable, Message: "native sandbox unavailable"},
})
}
registry := NewRegistry()
registry.Register(NewBashTool(root))
t.Run("default sandboxing still blocks an MSYS-prone command", func(t *testing.T) {
result := registry.RunWithOptions(context.Background(), "bash", map[string]any{
"command": "cat somefile.txt",
}, RunOptions{
PermissionGranted: true,
Sandbox: newEngine(),
PermissionMode: string(sandbox.PermissionModeAsk),
})
if result.Meta["shell_issue"] != "windows_msys_sandbox" {
t.Fatalf("expected windows_msys_sandbox block without escalation, got %#v", result)
}
})
t.Run("approved require_escalated bypasses the MSYS guard", func(t *testing.T) {
result := registry.RunWithOptions(context.Background(), "bash", map[string]any{
"command": "cat somefile.txt",
"sandbox_permissions": string(SandboxPermissionsRequireEscalated),
}, RunOptions{
PermissionGranted: true,
Sandbox: newEngine(),
PermissionMode: string(sandbox.PermissionModeAsk),
})
// Assert on the preflight block sentinel (exit_code "-1", set only by
// shellIssueBlockResult) rather than shell_issue: once the guard is
// bypassed, "cat somefile.txt" actually runs, and its real,
// PATH-dependent output could otherwise trip the unrelated
// post-execution detectShellOutputIssue heuristic and make this
// assertion flaky for reasons unrelated to the guard under test.
if result.Meta["exit_code"] == "-1" {
t.Fatalf("expected approved require_escalated to bypass the MSYS guard, got blocked: %#v", result)
}
})
t.Run("approved require_escalated still blocks an unrelated syntax issue", func(t *testing.T) {
result := registry.RunWithOptions(context.Background(), "bash", map[string]any{
"command": `cd /d/tmp/zero-pr-158 && dir`,
"sandbox_permissions": string(SandboxPermissionsRequireEscalated),
}, RunOptions{
PermissionGranted: true,
Sandbox: newEngine(),
PermissionMode: string(sandbox.PermissionModeAsk),
})
if result.Meta["shell_issue"] != "windows_shell_syntax" {
t.Fatalf("expected windows_shell_syntax block to still apply under require_escalated, got %#v", result)
}
})
}
// TestBashToolIgnoresMsysMarkersInCommandArgumentsAfterFailure guards the
// post-execution MSYS heuristic against treating the command line itself as
// evidence. The helper command below fails for a reason unrelated to MSYS
// (a plain non-zero exit), but its own argument text quotes an MSYS crash
// marker the way a PR-comment or commit-message argument might. Before the
// fix, detectShellOutputIssue scanned command+output together and would have
// misdiagnosed this as an MSYS sandbox failure even though the real
// stdout/stderr never mentions MSYS at all.
func TestBashToolIgnoresMsysMarkersInCommandArgumentsAfterFailure(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("windows-only MSYS output guard")
}
root := t.TempDir()
// The helper only reads argv[2] ("fail"); this trailing quoted argument is
// otherwise unused by the helper but still part of the command line text.
command := helperCommand("fail") + ` "fatal error - CreateFileMapping S-1-5-21, Win32 error 5. Terminating. cygheap_user::init"`
result := NewBashTool(root).Run(context.Background(), map[string]any{
"command": command,
})
if result.Status != StatusError || result.Meta["exit_code"] != "7" {
t.Fatalf("expected the helper's real exit 7 failure, got %s: %#v", result.Status, result)
}
if result.Meta["shell_issue"] == "windows_msys_sandbox" {
t.Fatalf("expected the MSYS marker in the command's own argument text to be ignored, got %#v", result)
}
}
func TestBashToolRunsWithDegradedUnavailableNativeSandbox(t *testing.T) {
root := t.TempDir()
policy := sandbox.DefaultPolicy()
+1 -1
View File
@@ -58,7 +58,7 @@ func fuzzyEditMatch(content, find string, replaceAll bool) (string, error) {
continue
}
seen[search] = true
if strings.Index(content, search) < 0 {
if !strings.Contains(content, search) {
continue
}
candidates = append(candidates, search)
+12 -1
View File
@@ -517,7 +517,12 @@ func (tool execCommandTool) run(ctx context.Context, args map[string]any, engine
if err != nil {
return errorResult("Error: Invalid arguments for exec_command: " + err.Error())
}
if issue := detectShellCommandIssue(commandText, runtimeGOOS()); issue != nil {
// Resolve the command engine before the MSYS preflight check so an
// approved require_escalated call (commandEngine == nil, truly
// unsandboxed) can actually bypass the MSYS guard instead of being
// hard-blocked by the same check it was meant to escalate past.
commandEngine := commandEngineForSandboxPermissions(engine, sandboxPermissions)
if issue := detectShellCommandIssue(commandText, runtimeGOOS()); issue != nil && !msysGuardBypassed(issue, commandEngine) {
return shellIssueBlockResult(*issue)
}
if interactive := zeroSandbox.DetectInteractiveCommand(commandText, runtimeGOOS()); interactive.Interactive {
@@ -860,6 +865,12 @@ func execToolResult(input execToolResultInput) Result {
status = StatusError
}
body := formatExecCommandOutput(output, input.sessionID, input.exited, input.exitCode, input.interrupted)
if status == StatusError && input.exited && !input.interrupted {
if issue := detectShellOutputIssue(output, runtimeGOOS()); issue != nil {
meta["shell_issue"] = issue.Kind
body = appendShellIssueHint(body, *issue)
}
}
if input.outputBufferTruncated {
// Appended after truncateExecOutput's own head/tail slicing, not
// embedded in the text that goes through it — a marker inside that
+40
View File
@@ -141,6 +141,46 @@ func TestExecCommandRequireEscalatedBypassesNativeSandboxAfterApproval(t *testin
}
}
// TestExecCommandRequireEscalatedBypassesMsysGuardAfterApproval mirrors
// TestBashToolRequireEscalatedMsysGuard for exec_command: the MSYS sandbox
// guard exists only because MSYS/Cygwin coreutils fail under the
// write-restricted sandbox, so once require_escalated is actually approved
// (unsandboxed execution), the guard must not block the same command it was
// meant to let escalate past.
func TestExecCommandRequireEscalatedBypassesMsysGuardAfterApproval(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("windows-only MSYS sandbox guard")
}
root := t.TempDir()
manager := newExecSessionManager()
registry := NewRegistry()
registry.Register(NewScopedExecCommandTool(root, nil, manager))
engine := sandbox.NewEngine(sandbox.EngineOptions{
WorkspaceRoot: root,
Policy: sandbox.DefaultPolicy(),
Backend: sandbox.Backend{Name: sandbox.BackendUnavailable, Message: "native sandbox unavailable"},
})
result := registry.RunWithOptions(context.Background(), ExecCommandToolName, map[string]any{
"cmd": "cat somefile.txt",
"sandbox_permissions": string(SandboxPermissionsRequireEscalated),
}, RunOptions{
PermissionGranted: true,
Sandbox: engine,
PermissionMode: string(sandbox.PermissionModeAsk),
})
// Assert on the preflight block sentinel (exit_code "-1", set only by
// shellIssueBlockResult) rather than shell_issue: once the guard is
// bypassed, "cat somefile.txt" actually runs, and its real,
// PATH-dependent output could otherwise trip the unrelated
// post-execution detectShellOutputIssue heuristic and make this
// assertion flaky for reasons unrelated to the guard under test.
if result.Meta["exit_code"] == "-1" {
t.Fatalf("expected approved require_escalated to bypass the MSYS guard, got blocked: %#v", result)
}
}
func TestExecCommandReturnsExitCodeWhenCommandCompletesDuringInitialYield(t *testing.T) {
root := t.TempDir()
manager := newExecSessionManager()
+81
View File
@@ -0,0 +1,81 @@
package tools
import (
"os"
"strconv"
"strings"
)
// Universal output ceiling. Every tool result leaving the registry boundary is
// capped at a single token-denominated budget unless the tool manages its own
// deliberate budget (selfBudgeting below). This is the safety net for tools
// with no cap of their own — web_fetch bodies, skill files, MCP server tools,
// browser snapshots, anything added later — so no single call can flood the
// context window with output that is then re-billed on every following turn.
// Truncation keeps head+tail and spills the full text to disk (re-readable via
// grep/read_file), so nothing is lost, only deferred.
// defaultOutputCeilingTokens is the per-call ceiling in estimated tokens
// (bytes/4). 16k tokens = 64 KiB — deliberately equal to the search-tool
// budget: generous enough for a large fetched document, small enough that one
// call cannot eat a third of a small context window.
const defaultOutputCeilingTokens = 16_000
// outputCeilingEnv overrides the ceiling (in tokens). Zero or negative
// disables the ceiling entirely; unset or unparsable keeps the default.
const outputCeilingEnv = "ZERO_TOOL_OUTPUT_CEILING_TOKENS"
// selfBudgeting marks a tool that enforces its own deliberate output budget —
// possibly model-raisable (exec_command) — which the registry ceiling must not
// second-guess. The method is unexported on purpose: only tools in this
// package can opt out, so an MCP-served tool can never exempt itself.
type selfBudgeting interface{ managesOutputBudget() }
// The exemption list, kept in one place. Each of these applies its own budget
// before returning: bash (bashOutputBudgetBytes per stream + spill),
// exec_command (model-raisable token budget + spill), read tools (128 KiB),
// search tools (64 KiB).
func (bashTool) managesOutputBudget() {}
func (execCommandTool) managesOutputBudget() {}
func (readFileTool) managesOutputBudget() {}
func (readMinifiedFileTool) managesOutputBudget() {}
func (grepTool) managesOutputBudget() {}
func (globTool) managesOutputBudget() {}
func (listDirectoryTool) managesOutputBudget() {}
func resolveOutputCeilingTokens() int {
raw := strings.TrimSpace(os.Getenv(outputCeilingEnv))
if raw == "" {
return defaultOutputCeilingTokens
}
parsed, err := strconv.Atoi(raw)
if err != nil {
return defaultOutputCeilingTokens
}
return parsed
}
// enforceOutputCeiling truncates an over-ceiling result to head+tail within
// the token budget, spilling the full (already redaction-scrubbed) output to
// disk with a recovery hint. Runs after scrubResultSecrets so the transcript
// and the spill file agree on what was hidden.
func enforceOutputCeiling(toolName string, result Result) Result {
ceiling := resolveOutputCeilingTokens()
if ceiling <= 0 {
return result
}
if len(result.Output) <= ceiling*4 {
return result
}
rawBytes := len(result.Output)
truncated, _ := truncateExecOutputSpill(result.Output, ceiling, toolName)
result.Output = truncated
result.Truncated = true
if result.Meta == nil {
result.Meta = map[string]string{}
}
result.Meta["raw_bytes"] = strconv.Itoa(rawBytes)
result.Meta["emitted_bytes"] = strconv.Itoa(len(result.Output))
result.Meta["estimated_tokens"] = strconv.Itoa(estimatedTokensFromBytes(len(result.Output)))
return result
}
+140
View File
@@ -0,0 +1,140 @@
package tools
import (
"context"
"os"
"strconv"
"strings"
"testing"
)
// ceilingFakeTool is a minimal tool with no output budget of its own — the
// stand-in for an MCP-served tool or any future builtin that forgets to cap
// its output.
type ceilingFakeTool struct {
baseTool
output string
}
func (tool ceilingFakeTool) Run(context.Context, map[string]any) Result {
return okResult(tool.output)
}
// ceilingExemptTool is the same fake but marked self-budgeting.
type ceilingExemptTool struct{ ceilingFakeTool }
func (ceilingExemptTool) managesOutputBudget() {}
func newCeilingFakeTool(name, output string) ceilingFakeTool {
return ceilingFakeTool{
baseTool: baseTool{name: name, safety: readOnlySafety("test tool")},
output: output,
}
}
// An unbudgeted tool's oversized output is capped at the universal ceiling,
// head and tail survive, and the full output is spilled to a re-readable file.
func TestOutputCeilingCapsUnbudgetedTool(t *testing.T) {
setTestTempDir(t)
big := "HEAD_MARK\n" + strings.Repeat("z", defaultOutputCeilingTokens*4*3) + "\nTAIL_MARK"
registry := NewRegistry()
registry.Register(newCeilingFakeTool("fake_big", big))
result := registry.Run(context.Background(), "fake_big", map[string]any{})
if !result.Truncated {
t.Fatal("over-ceiling output must report truncated=true")
}
maxBytes := defaultOutputCeilingTokens * 4
if len(result.Output) > maxBytes+512 {
t.Fatalf("emitted %d bytes, ceiling is %d", len(result.Output), maxBytes)
}
if !strings.Contains(result.Output, "HEAD_MARK") || !strings.Contains(result.Output, "TAIL_MARK") {
t.Fatal("head/tail lost at the ceiling")
}
if result.Meta["raw_bytes"] != strconv.Itoa(len(big)) {
t.Fatalf("raw_bytes = %s, want %d", result.Meta["raw_bytes"], len(big))
}
if !strings.Contains(result.Output, "full output saved to ") {
t.Fatalf("ceiling truncation must include a spill hint: %q", result.Output[:200])
}
start := strings.Index(result.Output, "full output saved to ") + len("full output saved to ")
end := strings.Index(result.Output[start:], " (grep")
content, err := os.ReadFile(result.Output[start : start+end])
if err != nil {
t.Fatalf("spill file unreadable: %v", err)
}
if string(content) != big {
t.Fatalf("spill must hold the full output: got %d bytes, want %d", len(content), len(big))
}
}
// A self-budgeting tool's output passes the boundary untouched even when it
// exceeds the universal ceiling — its own (possibly model-raised) budget wins.
func TestOutputCeilingSkipsSelfBudgetingTool(t *testing.T) {
big := strings.Repeat("z", defaultOutputCeilingTokens*4*2)
registry := NewRegistry()
registry.Register(ceilingExemptTool{newCeilingFakeTool("fake_exempt", big)})
result := registry.Run(context.Background(), "fake_exempt", map[string]any{})
if result.Truncated || result.Output != big {
t.Fatalf("self-budgeting tool must bypass the ceiling (truncated=%v, %d bytes)", result.Truncated, len(result.Output))
}
}
// Under-ceiling output is untouched and gains no budget metadata.
func TestOutputCeilingSmallOutputUntouched(t *testing.T) {
registry := NewRegistry()
registry.Register(newCeilingFakeTool("fake_small", "hello"))
result := registry.Run(context.Background(), "fake_small", map[string]any{})
if result.Truncated || result.Output != "hello" {
t.Fatalf("small output altered: %+v", result)
}
if _, ok := result.Meta["raw_bytes"]; ok {
t.Fatal("small output must not gain budget metadata")
}
}
// ZERO_TOOL_OUTPUT_CEILING_TOKENS tightens, loosens, or disables the ceiling.
func TestOutputCeilingEnvOverride(t *testing.T) {
setTestTempDir(t)
big := strings.Repeat("z", 100*1024)
t.Setenv(outputCeilingEnv, "1000")
registry := NewRegistry()
registry.Register(newCeilingFakeTool("fake_env", big))
result := registry.Run(context.Background(), "fake_env", map[string]any{})
if !result.Truncated || len(result.Output) > 1000*4+512 {
t.Fatalf("ceiling override ignored: truncated=%v, %d bytes", result.Truncated, len(result.Output))
}
t.Setenv(outputCeilingEnv, "0")
result = registry.Run(context.Background(), "fake_env", map[string]any{})
if result.Truncated || len(result.Output) != len(big) {
t.Fatalf("ceiling=0 must disable the net: truncated=%v, %d bytes", result.Truncated, len(result.Output))
}
}
// The exemption list matches the tools that really do budget themselves, and
// the unbudgeted ones (web_fetch, skill, browser) stay under the net.
func TestSelfBudgetingExemptionList(t *testing.T) {
dir := t.TempDir()
exempt := []Tool{
NewBashTool(dir),
NewExecCommandTool(dir, newExecSessionManager()),
NewReadFileTool(dir),
NewReadMinifiedFileTool(dir),
NewGrepTool(dir),
NewGlobTool(dir),
NewListDirectoryTool(dir),
}
for _, tool := range exempt {
if _, ok := tool.(selfBudgeting); !ok {
t.Errorf("%s must be exempt from the output ceiling", tool.Name())
}
}
if _, ok := NewWebFetchTool().(selfBudgeting); ok {
t.Error("web_fetch must NOT be exempt — the ceiling is its only budget")
}
}
+13 -2
View File
@@ -123,13 +123,24 @@ func (registry *Registry) Run(ctx context.Context, name string, args map[string]
func (registry *Registry) RunWithOptions(ctx context.Context, name string, args map[string]any, options RunOptions) (result Result) {
// Every return path passes through scrubResultSecrets exactly once, so denial,
// permission, and unknown-tool error messages (which can echo secret-bearing
// args/paths) are redacted at the boundary just like tool output.
defer func() { result = scrubResultSecrets(result) }()
// args/paths) are redacted at the boundary just like tool output. The output
// ceiling runs after the scrub so the transcript and the spill file agree on
// what was hidden.
ceilingExempt := false
defer func() {
result = scrubResultSecrets(result)
if !ceilingExempt {
result = enforceOutputCeiling(name, result)
}
}()
tool, ok := registry.Get(name)
if !ok {
return errorResult(`Error: Unknown tool "` + name + `".`)
}
if _, ok := tool.(selfBudgeting); ok {
ceilingExempt = true
}
if rejecter, ok := tool.(PrePermissionRejecter); ok {
if res, rejected := rejecter.RejectBeforePermission(args); rejected {
return res
+179 -21
View File
@@ -3,6 +3,7 @@ package tools
import (
"regexp"
"strings"
"unicode"
)
type shellRuntime struct {
@@ -17,14 +18,31 @@ type shellIssue struct {
Suggestion string
}
const windowsMsysSandboxKind = "windows_msys_sandbox"
const windowsMsysSandboxSuggestion = "MSYS/Cygwin coreutils from Git for Windows cannot run under Zero's write-restricted Windows sandbox. Prefer Zero native tools (grep, read_file with offset/limit, list_directory, glob), cmd.exe findstr/more, or PowerShell Select-Object -First/-Last. If host-level execution is truly required, rerun with sandbox_permissions: \"require_escalated\" and a narrow justification."
// windowsMsysProneNames is the single source of truth for POSIX coreutil names
// that commonly resolve to a Git-for-Windows MSYS/Cygwin binary rather than a
// cmd.exe-native command, and so fail under the write-restricted Windows
// sandbox (#458). Every Windows MSYS-detection path (the preflight command
// scan below, the exported MsysProneCommandName, and the known-safe-segment
// guard in internal/agent/command_prefix.go) derives from this one set, so
// they cannot drift out of sync with each other.
var windowsMsysProneNames = map[string]bool{
"cat": true, "cut": true, "expr": true, "grep": true, "head": true,
"id": true, "ls": true, "nl": true, "paste": true, "rev": true,
"seq": true, "stat": true, "tail": true, "tr": true, "uname": true,
"uniq": true, "wc": true, "which": true, "awk": true, "sed": true,
"xargs": true,
}
var (
windowsBashStyleCDPattern = regexp.MustCompile(`(?i)(^|[&|;]\s*)cd\s+/(?:[a-ce-z0-9_./~-]|d[a-z0-9_./~-])[a-z0-9_./~-]*`)
windowsLSCommandPattern = regexp.MustCompile(`(?i)(^|[&|;]\s*)ls\b(?:\s+|$)`)
// windowsPosixUtilityPattern catches the other common POSIX coreutils/grep
// family commands cmd.exe has no equivalent for (unlike `dir`/`findstr`,
// which sound similar enough that a model might reach for the POSIX name
// instead) — most often seen piped in, e.g. `git log ... | head`.
windowsPosixUtilityPattern = regexp.MustCompile(`(?i)(^|[&|;]\s*)(head|tail|grep|wc|awk|sed|cut|xargs|tr)(?:\s+|$)`)
// windowsMsysBinaryPathPattern catches explicit Git-for-Windows / MSYS usr\bin
// paths. These executables are valid Windows PE files but fail under the
// write-restricted sandbox with CreateFileMapping ACCESS_DENIED (#458).
windowsMsysBinaryPathPattern = regexp.MustCompile(`(?i)(?:\\usr\\bin\\|\\mingw64\\bin\\|msys-2\.0\.dll|cygwin1\.dll)`)
)
func detectShellRuntime(goos string) shellRuntime {
@@ -37,48 +55,162 @@ func detectShellRuntime(goos string) shellRuntime {
func shellGuidanceForGOOS(goos string) string {
runtime := detectShellRuntime(goos)
if goos == "windows" {
return "Uses " + runtime.Syntax + " syntax on Windows; prefer cwd over cd when changing directories."
return "Uses " + runtime.Syntax + " syntax on Windows; prefer cwd over cd when changing directories. MSYS/Cygwin coreutils on PATH (Git for Windows usr\\bin) are not sandbox-compatible; prefer native Zero file tools."
}
guidance := "Uses " + runtime.Syntax + " syntax."
if goos == "darwin" {
// `ps` is setuid root and cannot run under the macOS sandbox; `pgrep` needs a
// blocked system service. Point the model at the tools that DO work so it
// doesn't waste turns: lsof to find a process, kill to stop it.
guidance += " To find or stop a process, use `lsof -i :PORT` (or `lsof -nP -iTCP -sTCP:LISTEN`) for the PID then `kill <pid>`; `ps` and `pgrep` do not work under the sandbox."
}
return guidance
}
// MsysProneCommandName reports whether a bare command name commonly resolves to
// a Git-for-Windows MSYS binary that fails under the Windows restricted sandbox.
func MsysProneCommandName(name string) bool {
return windowsMsysProneNames[strings.ToLower(strings.TrimSpace(name))]
}
func windowsMsysSandboxIssue(message string) *shellIssue {
return &shellIssue{
Kind: windowsMsysSandboxKind,
Message: message,
Suggestion: windowsMsysSandboxSuggestion,
}
}
// windowsCommandSegments splits a command into cmd.exe-operator-separated
// segments (&, |, and their doubled forms &&/||), respecting double quotes
// (cmd.exe's grouping construct) and the caret (^) escape character, so an
// operator or command name mentioned inside a quoted argument (e.g. a commit
// message or PR comment body), or an operator escaped with ^ (cmd.exe prints
// `echo ^| head` as literal text instead of piping to head), is not mistaken
// for a real segment boundary or invocation. Unlike bash, cmd.exe does not
// treat ; as a statement separator, so it is left as ordinary argument text
// (e.g. `echo foo; head` is a single `echo` invocation with literal args).
func windowsCommandSegments(command string) []string {
var segments []string
var current strings.Builder
inQuotes := false
runes := []rune(command)
for i := 0; i < len(runes); i++ {
c := runes[i]
if !inQuotes && c == '^' && i+1 < len(runes) {
current.WriteRune(c)
i++
current.WriteRune(runes[i])
continue
}
if c == '"' {
inQuotes = !inQuotes
current.WriteRune(c)
continue
}
if !inQuotes && (c == '&' || c == '|') {
if seg := strings.TrimSpace(current.String()); seg != "" {
segments = append(segments, seg)
}
current.Reset()
continue
}
current.WriteRune(c)
}
if seg := strings.TrimSpace(current.String()); seg != "" {
segments = append(segments, seg)
}
return segments
}
// firstCommandWord returns the first token of a command segment. A leading
// double-quoted span counts as one token with its quotes stripped, since
// cmd.exe treats a quoted path as a single argument: the command invoked by
// `"C:\Program Files\Git\usr\bin\head.exe" file.txt` is the quoted path, not
// "C:\Program. For an unquoted command, the token ends at whitespace or a
// redirection operator (<, >): cmd.exe accepts redirection attached directly
// to the command name with no separating space (head>out.txt, cat<in.txt), so
// stopping only at whitespace would return "head>out.txt" as one word and
// miss the invoked command.
func firstCommandWord(segment string) string {
trimmed := strings.TrimSpace(segment)
if trimmed == "" {
return ""
}
if trimmed[0] == '"' {
if end := strings.IndexByte(trimmed[1:], '"'); end >= 0 {
return trimmed[1 : end+1]
}
return trimmed[1:]
}
if end := strings.IndexFunc(trimmed, isCommandWordBoundary); end >= 0 {
return trimmed[:end]
}
return trimmed
}
func isCommandWordBoundary(r rune) bool {
return unicode.IsSpace(r) || r == '<' || r == '>'
}
// msysProneCommandWord reports whether word (the first token of a command
// segment, as returned by firstCommandWord) names an MSYS-prone coreutil,
// bare or with a directory prefix and/or .exe suffix (head, head.exe,
// C:\...\usr\bin\head.exe, ...).
func msysProneCommandWord(word string) bool {
word = strings.Trim(word, `"`)
if i := strings.LastIndexAny(word, `\/`); i >= 0 {
word = word[i+1:]
}
word = strings.TrimSuffix(strings.ToLower(word), ".exe")
return MsysProneCommandName(word)
}
func detectShellCommandIssue(command string, goos string) *shellIssue {
if goos != "windows" {
return nil
}
trimmed := strings.TrimSpace(command)
if windowsBashStyleCDPattern.MatchString(trimmed) ||
windowsLSCommandPattern.MatchString(trimmed) {
if windowsBashStyleCDPattern.MatchString(trimmed) {
return &shellIssue{
Kind: "windows_shell_syntax",
Message: "Command looks like POSIX/Bash syntax, but Zero runs bash tool commands through Windows cmd.exe on this host.",
Suggestion: "Use the cwd argument instead of cd, use Windows cmd.exe syntax, or use native tools such as list_directory, read_file, grep, and glob.",
}
}
if windowsPosixUtilityPattern.MatchString(trimmed) {
return &shellIssue{
Kind: "windows_shell_syntax",
Message: "Command uses a POSIX utility (head/tail/grep/wc/awk/sed/cut/xargs/tr) that Windows cmd.exe does not have.",
Suggestion: "Use cmd.exe equivalents (e.g. `findstr` for grep, `more` to page output) or Zero's native tools (grep, read_file with offset/limit) instead of piping to a POSIX utility.",
// Check the first word of each operator-separated segment (not the raw
// text anywhere in the command) against the MSYS binary-path pattern and
// the single MSYS-prone name set, covering bare names (head), .exe names
// (head.exe), and directory-prefixed forms (C:\...\head.exe) uniformly.
// Being segment/word anchored rather than a whole-string regex or scan,
// neither check matches text that only appears inside a quoted argument
// (e.g. a commit message or PR comment body discussing head.exe).
for _, segment := range windowsCommandSegments(trimmed) {
word := firstCommandWord(segment)
if windowsMsysBinaryPathPattern.MatchString(word) {
return windowsMsysSandboxIssue("Command invokes an MSYS/Cygwin binary path that cannot run under Zero's Windows sandbox.")
}
if msysProneCommandWord(word) {
return windowsMsysSandboxIssue("Command uses a POSIX coreutil (head/tail/grep/cat/...) that commonly resolves to Git-for-Windows MSYS binaries incompatible with the Windows sandbox.")
}
}
return nil
}
func detectShellOutputIssue(command string, output string, goos string) *shellIssue {
// detectShellOutputIssue looks for MSYS runtime crash markers and cmd.exe
// syntax-error text in output only, never in the command that was run. The
// command line is attacker/user-controlled argument text (e.g. a `gh pr
// comment --body` quoting a sample failure), not something the shell
// produced, so treating it as evidence would reintroduce the same
// quoted-text false positives the preflight command-position check exists to
// avoid, just after execution instead of before it.
func detectShellOutputIssue(output string, goos string) *shellIssue {
if goos != "windows" {
return nil
}
haystack := strings.ToLower(command + "\n" + output)
if strings.Contains(haystack, "the syntax of the command is incorrect") ||
strings.Contains(haystack, "is not recognized as an internal or external command") {
lower := strings.ToLower(output)
if msysRuntimeFailedInOutput(lower) {
return windowsMsysSandboxIssue("An MSYS/Cygwin runtime failed under Zero's Windows sandbox (ACCESS_DENIED during MSYS startup).")
}
if strings.Contains(lower, "the syntax of the command is incorrect") ||
strings.Contains(lower, "is not recognized as an internal or external command") {
return &shellIssue{
Kind: "windows_shell_syntax",
Message: "Windows cmd.exe rejected the command syntax.",
@@ -88,6 +220,32 @@ func detectShellOutputIssue(command string, output string, goos string) *shellIs
return nil
}
func msysRuntimeFailedInOutput(lower string) bool {
if strings.Contains(lower, "fatal error - createfilemapping") {
return true
}
if strings.Contains(lower, "couldn't create signal pipe") && strings.Contains(lower, "win32 error 5") {
return true
}
if strings.Contains(lower, "cygheap_user::init") && strings.Contains(lower, "fatal error") {
return true
}
if strings.Contains(lower, "usr\\bin\\") && strings.Contains(lower, "fatal error") {
return true
}
if !strings.Contains(lower, "win32 error 5") || !strings.Contains(lower, "terminating") {
return false
}
// Anchor the broad win32-error-5 fallback to an MSYS-specific marker so
// unrelated access-denied failures are not mislabeled as MSYS sandbox
// incompatibilities.
return strings.Contains(lower, `usr\bin\`) ||
strings.Contains(lower, "cygheap") ||
strings.Contains(lower, "msys-2.0.dll") ||
strings.Contains(lower, "cygwin1.dll") ||
strings.Contains(lower, "[main]")
}
func appendShellIssueHint(output string, issue shellIssue) string {
output = strings.TrimRight(output, "\r\n")
hint := "[zero] shell issue: " + issue.Message
+208
View File
@@ -0,0 +1,208 @@
package tools
import (
"strings"
"testing"
)
func TestDetectShellCommandIssueFlagsMsysBinaryPaths(t *testing.T) {
for _, command := range []string{
`for /F %i in ('whoami') do echo %i | "C:\Program Files\Git\usr\bin\head.exe" -1`,
`C:\Git\usr\bin\grep.exe pattern file.txt`,
} {
issue := detectShellCommandIssue(command, "windows")
if issue == nil {
t.Fatalf("expected MSYS path issue for %q", command)
}
if issue.Kind != "windows_msys_sandbox" {
t.Fatalf("expected windows_msys_sandbox kind, got %q", issue.Kind)
}
if !strings.Contains(issue.Suggestion, "require_escalated") {
t.Fatalf("expected escalation guidance, got %#v", issue)
}
}
}
func TestDetectShellCommandIssueFlagsStandaloneCat(t *testing.T) {
issue := detectShellCommandIssue(`cat README.md`, "windows")
if issue == nil || issue.Kind != "windows_msys_sandbox" {
t.Fatalf("expected MSYS sandbox issue for cat, got %#v", issue)
}
}
func TestDetectShellOutputIssueFlagsMsysCreateFileMappingError(t *testing.T) {
output := `0 [main] head (3568) C:\Program Files\Git\usr\bin\head.exe: *** fatal error - CreateFileMapping S-1-5-21-3149109338-1484423945-518236903-1001.1, Win32 error 5. Terminating.`
issue := detectShellOutputIssue(output, "windows")
if issue == nil || issue.Kind != "windows_msys_sandbox" {
t.Fatalf("expected MSYS output issue, got %#v", issue)
}
if !strings.Contains(issue.Suggestion, "require_escalated") {
t.Fatalf("expected escalation guidance, got %#v", issue)
}
}
func TestDetectShellOutputIssueFlagsMsysSignalPipeError(t *testing.T) {
output := `0 [main] head (39684) cygheap_user::init: NtSetInformationToken (TokenDefaultDacl), 0xC0000022
648 [main] head (39684) C:\Program Files\Git\usr\bin\head.exe: *** fatal error - couldn't create signal pipe, Win32 error 5`
issue := detectShellOutputIssue(output, "windows")
if issue == nil || issue.Kind != "windows_msys_sandbox" {
t.Fatalf("expected MSYS output issue, got %#v", issue)
}
}
func TestDetectShellOutputIssueFlagsMsysTerminatingWithMsysMarker(t *testing.T) {
output := `1 [main] tail (4321) tail: *** MapViewOfFileEx failed, Win32 error 5. Terminating.`
issue := detectShellOutputIssue(output, "windows")
if issue == nil || issue.Kind != "windows_msys_sandbox" {
t.Fatalf("expected MSYS output issue, got %#v", issue)
}
}
func TestDetectShellOutputIssueIgnoresNonMsysWin32Error5(t *testing.T) {
output := `myapp.exe: unable to open service handle, Win32 error 5 (access denied). Terminating worker.`
issue := detectShellOutputIssue(output, "windows")
if issue != nil {
t.Fatalf("expected no issue for non-MSYS access-denied output, got %#v", issue)
}
}
func TestShellIssueBlockResultMsysCommand(t *testing.T) {
result := shellIssueBlockResult(*detectShellCommandIssue(`cat README.md`, "windows"))
if result.Status != StatusError {
t.Fatalf("status = %q, want error", result.Status)
}
for _, want := range []string{"[zero] shell issue:", "MSYS/Cygwin", "grep", "read_file", "require_escalated"} {
if !strings.Contains(result.Output, want) {
t.Fatalf("expected %q in blocked output, got %q", want, result.Output)
}
}
if result.Meta["shell_issue"] != "windows_msys_sandbox" {
t.Fatalf("meta shell_issue = %q", result.Meta["shell_issue"])
}
}
func TestMsysProneCommandName(t *testing.T) {
if !MsysProneCommandName("HEAD") || MsysProneCommandName("echo") {
t.Fatalf("unexpected MsysProneCommandName results")
}
}
// TestDetectShellCommandIssueFlagsExprAndLsConsistently guards against the
// preflight regex list drifting from the canonical windowsMsysProneNames set
// (both listed expr and ls as MSYS-prone, but the old regex alternations
// omitted expr entirely and let ls hit the older windows_shell_syntax branch
// first, so it never got MSYS-kind guidance).
func TestDetectShellCommandIssueFlagsExprAndLsConsistently(t *testing.T) {
for _, command := range []string{
`expr 1 + 1`,
`expr.exe 1 + 1`,
`ls -la`,
`ls`,
} {
issue := detectShellCommandIssue(command, "windows")
if issue == nil || issue.Kind != windowsMsysSandboxKind {
t.Fatalf("expected windows_msys_sandbox for %q, got %#v", command, issue)
}
}
}
// TestDetectShellCommandIssueIgnoresQuotedMsysMentions guards against
// treating an MSYS-prone name that only appears inside a quoted argument
// (e.g. a commit message, a PR comment body, or a doc string discussing the
// command) as an actual invocation. The preflight check must anchor on the
// first word of each command segment, not scan the raw text anywhere.
func TestDetectShellCommandIssueIgnoresQuotedMsysMentions(t *testing.T) {
for _, command := range []string{
`git commit -m "fix head.exe crash"`,
`gh pr comment --body "grep.exe fails under MSYS"`,
`echo "log | head is broken on windows"`,
`git commit -m "note: | head does not work here"`,
} {
if issue := detectShellCommandIssue(command, "windows"); issue != nil {
t.Fatalf("expected quoted MSYS mention to pass for %q, got %#v", command, issue)
}
}
// A real invocation alongside quoted text must still be caught.
if issue := detectShellCommandIssue(`echo "not a real command" && head file.txt`, "windows"); issue == nil || issue.Kind != windowsMsysSandboxKind {
t.Fatalf("expected real head invocation to still be flagged, got %#v", issue)
}
}
// TestDetectShellCommandIssueIgnoresQuotedMsysPathMentions guards the
// explicit MSYS-binary-path check the same way as the coreutil-name check:
// a full usr\bin\ path that only appears inside a quoted argument (e.g. a
// commit message describing the failure) must not be treated as an
// invocation, since the path check is now anchored to the first word of each
// command segment rather than scanned across the raw command text.
func TestDetectShellCommandIssueIgnoresQuotedMsysPathMentions(t *testing.T) {
for _, command := range []string{
`git commit -m "C:\Program Files\Git\usr\bin\head.exe fails"`,
`gh pr comment --body "C:\Git\usr\bin\grep.exe is blocked"`,
} {
if issue := detectShellCommandIssue(command, "windows"); issue != nil {
t.Fatalf("expected quoted MSYS path mention to pass for %q, got %#v", command, issue)
}
}
// A real invocation by full path must still be caught.
if issue := detectShellCommandIssue(`C:\Git\usr\bin\grep.exe pattern file.txt`, "windows"); issue == nil || issue.Kind != windowsMsysSandboxKind {
t.Fatalf("expected real MSYS path invocation to still be flagged, got %#v", issue)
}
}
// TestDetectShellCommandIssueRespectsCaretEscapedOperators guards against
// misreading cmd.exe's ^ escape character: `echo ^| head` prints the pipe and
// "head" as literal text (the caret escapes the pipe so it never splits into
// a separate `head` invocation), and `echo foo; head` is a single `echo`
// command with literal arguments since cmd.exe (unlike bash) does not treat
// ; as a statement separator.
func TestDetectShellCommandIssueRespectsCaretEscapedOperators(t *testing.T) {
for _, command := range []string{
`echo ^| head`,
`echo ^& head`,
`echo foo; head`,
} {
if issue := detectShellCommandIssue(command, "windows"); issue != nil {
t.Fatalf("expected no issue for %q, got %#v", command, issue)
}
}
// An unescaped pipe must still split into a real head invocation.
if issue := detectShellCommandIssue(`echo foo | head`, "windows"); issue == nil || issue.Kind != windowsMsysSandboxKind {
t.Fatalf("expected real head invocation to still be flagged, got %#v", issue)
}
}
// TestDetectShellCommandIssueFlagsRedirectionAttachedToCommand guards against
// firstCommandWord treating cmd.exe redirection operators as part of the
// command name. cmd.exe accepts redirection with no separating space
// (head>out.txt, cat<in.txt), so splitting only on whitespace would return
// "head>out.txt" as one word and miss the invoked command entirely.
func TestDetectShellCommandIssueFlagsRedirectionAttachedToCommand(t *testing.T) {
for _, command := range []string{
`some-command | head>out.txt`,
`cat<in.txt`,
`grep>matches.txt pattern`,
} {
issue := detectShellCommandIssue(command, "windows")
if issue == nil || issue.Kind != windowsMsysSandboxKind {
t.Fatalf("expected windows_msys_sandbox for %q, got %#v", command, issue)
}
}
}
// TestDetectShellOutputIssueSignatureOmitsCommandText documents, at the type
// level, that detectShellOutputIssue can no longer take the command line as
// evidence: it only accepts the real output. Harmless output must not be
// flagged, and output that genuinely carries the MSYS failure markers must
// still be flagged.
func TestDetectShellOutputIssueSignatureOmitsCommandText(t *testing.T) {
if issue := detectShellOutputIssue("hello from bash", "windows"); issue != nil {
t.Fatalf("expected no issue for harmless output, got %#v", issue)
}
output := `0 [main] head (3568) C:\Program Files\Git\usr\bin\head.exe: *** fatal error - CreateFileMapping ..., Win32 error 5. Terminating.`
if issue := detectShellOutputIssue(output, "windows"); issue == nil || issue.Kind != windowsMsysSandboxKind {
t.Fatalf("expected real MSYS output to still be flagged, got %#v", issue)
}
}
+6 -2
View File
@@ -8,6 +8,7 @@ import (
"time"
"github.com/Gitlawb/zero/internal/redaction"
"github.com/Gitlawb/zero/internal/secrets"
)
// Spill-to-disk for truncated tool output. When a command produces more than
@@ -90,8 +91,10 @@ func spillDir() (string, error) {
// spillTruncatedOutput writes the full pre-truncation output to the spill
// directory and returns the file path, or "" when spilling fails. Output is
// scrubbed with the same configured-key redaction the registry applies at the
// tool boundary, so a spilled file never holds a secret the transcript would
// have hidden.
// tool boundary PLUS the pattern-based secret scanner (AWS keys, tokens, PEM
// blocks, JWTs) that bash applies to its model-visible output — a spill runs
// before that formatter, so without the scan here a spilled file would hold
// pattern-matched credentials in cleartext that the transcript hides.
func spillTruncatedOutput(toolName, output string) string {
dir, err := spillDir()
if err != nil {
@@ -110,6 +113,7 @@ func spillTruncatedOutput(toolName, output string) string {
}
defer file.Close()
scrubbed := redaction.RedactString(output, redaction.Options{})
scrubbed, _ = secrets.Redact(scrubbed)
if _, err := file.WriteString(scrubbed); err != nil {
_ = os.Remove(file.Name())
return ""
+27
View File
@@ -63,6 +63,33 @@ func TestSweepSpillDirRemovesOnlyOldFiles(t *testing.T) {
}
}
// A spill happens BEFORE bash's model-facing formatter runs its pattern-based
// secret scan, so the spill itself must scrub pattern-matched credentials —
// otherwise the file would hold in cleartext exactly what the transcript hides.
func TestSpillTruncatedOutputScrubsPatternSecrets(t *testing.T) {
setTestTempDir(t)
githubToken := "ghp_" + strings.Repeat("a", 36)
body := "before\nAKIAIOSFODNN7EXAMPLE\n" + githubToken + "\nafter"
path := spillTruncatedOutput("bash", body)
if path == "" {
t.Fatal("spill must return a file path")
}
content, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(content), "AKIAIOSFODNN7EXAMPLE") {
t.Fatal("AWS access key reached the spill file in cleartext")
}
if strings.Contains(string(content), githubToken) {
t.Fatal("GitHub token reached the spill file in cleartext")
}
if !strings.Contains(string(content), "before") || !strings.Contains(string(content), "after") {
t.Fatalf("non-secret content must survive the scrub: %q", content)
}
}
func TestSpillTruncatedOutputWritesFile(t *testing.T) {
t.Setenv("TMPDIR", t.TempDir())
path := spillTruncatedOutput("exec_command", "some output body")
+2 -2
View File
@@ -28,7 +28,7 @@ const (
webFetchTimeout = 30 * time.Second
webFetchRedirectLimit = 5
webFetchErrorBodyLimit = 4 * 1024
webFetchPublicOnlyHint = "web_fetch only supports public remote HTTP/HTTPS URLs. For localhost or private network URLs, use bash with curl so sandbox network permission can apply."
webFetchPublicOnlyHint = "web_fetch only supports public remote HTTP/HTTPS URLs. For localhost or private network URLs, use bash with curl so sandbox network permission can apply"
)
type webFetchTool struct {
@@ -348,7 +348,7 @@ func webFetchRedirectPolicy(previous func(*http.Request, []*http.Request) error,
return fmt.Errorf("too many redirects: maximum is %d", webFetchRedirectLimit)
}
if err := validateParsedWebFetchURL(request.Context(), request.URL, resolver); err != nil {
return fmt.Errorf("Unsafe redirect URL: %w", err)
return fmt.Errorf("unsafe redirect URL: %w", err)
}
if previous != nil {
return previous(request, via)
+1 -1
View File
@@ -405,7 +405,7 @@ func TestWebFetchToolRejectsUnsafeRedirects(t *testing.T) {
if result.Status != StatusError {
t.Fatalf("expected redirect safety error, got %s: %s", result.Status, result.Output)
}
if !strings.Contains(result.Output, "Unsafe redirect URL") {
if !strings.Contains(result.Output, "unsafe redirect URL") {
t.Fatalf("expected unsafe redirect message, got %q", result.Output)
}
})
-1
View File
@@ -426,7 +426,6 @@ func stringListArgWebSearch(args map[string]any, key string) (domains []string,
if !ok || value == nil {
return nil, false, nil
}
provided = true
raw, ok := value.([]any)
if !ok {
if asString, ok2 := value.([]string); ok2 {
+48 -1
View File
@@ -188,7 +188,54 @@ func (m model) matchCommandSuggestions(token string) []commandSuggestion {
out := matchCommandSuggestionsWithFilter(token, func(command commandDefinition) bool {
return command.kind != commandSandboxSetup || m.sandboxSetupCommand != nil
})
return append(out, m.matchUserCommandSuggestions(token)...)
out = append(out, m.matchUserCommandSuggestions(token)...)
out = append(out, m.matchSkillSuggestions(token)...)
// Each source caps itself, but the merged list must honor the shared cap too
// (three sources could otherwise stack up to ~3x the palette bound).
if len(out) > maxCommandSuggestions {
out = out[:maxCommandSuggestions]
}
return out
}
// matchSkillSuggestions returns installed skills whose slash name has the typed
// prefix, so skills are discoverable and invocable from the palette like user
// commands. Precedence (builtin > user command > skill) is enforced here by
// skipping any skill whose name is claimed by a builtin (or alias) or a user
// command — unlike the builtin/user pair, a shadowed skill row would be dead at
// dispatch time, so it must not be advertised at all.
func (m model) matchSkillSuggestions(token string) []commandSuggestion {
prefix := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(token, "/")))
if prefix == "" || m.loadSkills == nil {
return nil
}
taken := m.takenSlashNames()
var out []commandSuggestion
// Read through the loader (not the startup snapshot in agentOptions) so the
// palette matches what dispatch will actually resolve — a skill installed or
// removed mid-session appears/disappears without a restart.
for _, skill := range m.installedSkills() {
name := skillSlashName(skill.Name)
if name == "" || taken[name] || !strings.HasPrefix(name, prefix) {
continue
}
// Two skills whose names collide after lowercasing (e.g. "Deploy" and
// "deploy") map to one slash name; only the first — the one dispatch will
// run — may be advertised, or the row's description and the executed
// instructions silently diverge.
taken[name] = true
desc := strings.TrimSpace(skill.Description)
if desc == "" {
desc = "Skill: /" + name
} else {
desc += " (skill)"
}
out = append(out, commandSuggestion{Name: "/" + name, Desc: desc})
if len(out) >= maxCommandSuggestions {
break
}
}
return out
}
// matchUserCommandSuggestions returns file-sourced /commands whose name has the
+95
View File
@@ -289,3 +289,98 @@ func TestDispatchOptionO(t *testing.T) {
t.Errorf("model.keyMatch should NOT match ctrl+o when option+o is configured")
}
}
// A configured binding equal to a reserved hardcoded chord must be reverted
// to default rather than silently making the hardcoded action unreachable
// (e.g. configuring toggleDetailed to ctrl+f would otherwise swallow the
// /model picker's "favorite" shortcut).
func TestSanitizeKeyBindingsDropsReservedCollision(t *testing.T) {
cfg := config.KeyBindingsConfig{
ToggleDetailed: "ctrl+f", // collides with the hardcoded favorite-model shortcut
ToggleMouse: "ctrl+e", // unaffected, should survive untouched
}
sanitized, warnings := sanitizeKeyBindings(resolveKeyBindings(cfg))
if !sanitized.toggleDetailed.isZero() {
t.Errorf("toggleDetailed should be reverted to default, got %q", sanitized.toggleDetailed.Label())
}
if sanitized.toggleMouse.isZero() || sanitized.toggleMouse.Label() != "Ctrl+E" {
t.Errorf("toggleMouse should be untouched, got %q", sanitized.toggleMouse.Label())
}
if len(warnings) != 1 {
t.Fatalf("want exactly 1 warning, got %d: %v", len(warnings), warnings)
}
}
// Two configurable actions bound to the same chord must not both fire —
// sanitizeKeyBindings should keep the first and revert the rest to default.
func TestSanitizeKeyBindingsDropsMutualCollision(t *testing.T) {
cfg := config.KeyBindingsConfig{
ToggleDetailed: "ctrl+o",
CycleReasoning: "ctrl+o", // collides with toggleDetailed above
}
sanitized, warnings := sanitizeKeyBindings(resolveKeyBindings(cfg))
if sanitized.toggleDetailed.isZero() || sanitized.toggleDetailed.Label() != "Ctrl+O" {
t.Errorf("toggleDetailed (first claimant) should keep ctrl+o, got %q", sanitized.toggleDetailed.Label())
}
if !sanitized.cycleReasoning.isZero() {
t.Errorf("cycleReasoning (second claimant) should be reverted to default, got %q", sanitized.cycleReasoning.Label())
}
if len(warnings) != 1 {
t.Fatalf("want exactly 1 warning, got %d: %v", len(warnings), warnings)
}
}
// Non-colliding configured bindings must pass through unchanged with no
// warnings.
func TestSanitizeKeyBindingsNoCollisions(t *testing.T) {
cfg := config.KeyBindingsConfig{
ToggleDetailed: "option+o",
ToggleSidebar: "option+b",
}
sanitized, warnings := sanitizeKeyBindings(resolveKeyBindings(cfg))
if len(warnings) != 0 {
t.Fatalf("want no warnings, got %v", warnings)
}
if sanitized.toggleDetailed.Label() != "Alt+O" || sanitized.toggleSidebar.Label() != "Alt+B" {
t.Fatalf("bindings should be unchanged: toggleDetailed=%q toggleSidebar=%q",
sanitized.toggleDetailed.Label(), sanitized.toggleSidebar.Label())
}
}
// A configured binding must not shadow another action that is still on its
// built-in default chord.
func TestSanitizeKeyBindingsDropsCollisionWithOtherDefault(t *testing.T) {
cfg := config.KeyBindingsConfig{
ToggleDetailed: "ctrl+t", // collides with cycleReasoning default
}
sanitized, warnings := sanitizeKeyBindings(resolveKeyBindings(cfg))
if !sanitized.toggleDetailed.isZero() {
t.Errorf("toggleDetailed should be reverted to default, got %q", sanitized.toggleDetailed.Label())
}
if !sanitized.cycleReasoning.isZero() {
t.Errorf("cycleReasoning should remain default (zero), got %q", sanitized.cycleReasoning.Label())
}
if len(warnings) != 1 {
t.Fatalf("want exactly 1 warning, got %d: %v", len(warnings), warnings)
}
}
// Bare hardcoded keys handled directly in updateModel (e.g. Tab navigation)
// are reserved too and must not be shadowed by configurable bindings.
func TestSanitizeKeyBindingsDropsBareHardcodedCollision(t *testing.T) {
cfg := config.KeyBindingsConfig{
ToggleDetailed: "tab",
}
sanitized, warnings := sanitizeKeyBindings(resolveKeyBindings(cfg))
if !sanitized.toggleDetailed.isZero() {
t.Errorf("toggleDetailed should be reverted to default, got %q", sanitized.toggleDetailed.Label())
}
if len(warnings) != 1 {
t.Fatalf("want exactly 1 warning, got %d: %v", len(warnings), warnings)
}
}
+79 -20
View File
@@ -3,6 +3,7 @@ package tui
import (
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"time"
@@ -10,8 +11,10 @@ import (
tea "charm.land/bubbletea/v2"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/credstore"
"github.com/Gitlawb/zero/internal/doctor"
"github.com/Gitlawb/zero/internal/modelregistry"
"github.com/Gitlawb/zero/internal/oauth"
"github.com/Gitlawb/zero/internal/providermodelcatalog"
"github.com/Gitlawb/zero/internal/providers"
"github.com/Gitlawb/zero/internal/redaction"
@@ -493,29 +496,37 @@ func (m model) handleModelCommand(args string) (model, string) {
// picker calls this when a model from a non-active provider is chosen, so the
// picker can list every saved provider and switch across them (like a unified
// provider+model selector). The key is loaded from the encrypted store / env.
func (m model) switchProviderModel(providerName, modelID string) (model, string, tea.Cmd) {
// The returned bool reports whether the switch actually committed — callers
// that branch on the outcome (the provider manager) must use it, never the
// display text: UI copy is not a control-flow contract (a refusal quoting a
// provider name could contain any substring, and rewording the success line
// must not change behavior).
func (m model) switchProviderModel(providerName, modelID string) (model, string, bool, tea.Cmd) {
if m.pending {
return m, "Model\nCannot switch providers while a run is active.", nil
return m, "Model\nCannot switch providers while a run is active.", false, nil
}
if m.newProvider == nil {
return m, "Model\nProvider rebuild is not available for this TUI session.", nil
return m, "Model\nProvider rebuild is not available for this TUI session.", false, nil
}
target, ok := m.savedProviderByName(providerName)
if !ok {
return m, "Model\nunknown provider " + strconv.Quote(providerName), nil
return m, "Model\nunknown provider " + strconv.Quote(providerName), false, nil
}
target = m.profileWithCredential(target)
target.Model = strings.TrimSpace(modelID)
descriptor, hasDescriptor := m.descriptorForProfile(target)
// Gate on the resolved credential, not the APIKeyStored marker: if the stored key
// was deleted/unreadable the marker can still be set, and building a keyless
// provider would only fail later with a 401. Local/no-auth providers need no key.
if strings.TrimSpace(target.APIKey) == "" && strings.TrimSpace(target.AuthHeaderValue) == "" && !(hasDescriptor && descriptor.Local) {
return m, "Model\nprovider " + strconv.Quote(providerName) + " has no usable credential — run setup or `zero auth login " + providerName + "`.", nil
// provider would only fail later with a 401. Local/no-auth providers need no key,
// and a stored OAuth login (e.g. ChatGPT) is a credential too — the profile stays
// keyless on purpose so newProvider attaches the bearer resolver + login key.
if strings.TrimSpace(target.APIKey) == "" && strings.TrimSpace(target.AuthHeaderValue) == "" &&
!(hasDescriptor && descriptor.Local) && !oauthLoginAvailable(target) {
return m, "Model\nprovider " + strconv.Quote(providerName) + " has no usable credential — run setup or `zero auth login " + providerName + "`.", false, nil
}
next, err := m.newProvider(target)
if err != nil {
return m, "Model\n" + redaction.RedactString(err.Error(), redaction.Options{ExtraSecretValues: []string{target.APIKey}}), nil
return m, "Model\n" + redaction.RedactString(err.Error(), redaction.Options{ExtraSecretValues: []string{target.APIKey}}), false, nil
}
m.provider = next
m.providerProfile = target
@@ -543,32 +554,80 @@ func (m model) switchProviderModel(providerName, modelID string) (model, string,
if warn := m.visionDropWarning(); warn != "" {
status += "\n" + warn
}
return m, status, tea.Batch(cmds...)
return m, status, true, tea.Batch(cmds...)
}
// profileWithCredential fills a profile's APIKey for provider construction the same
// way the runtime resolves it: a stored key (encrypted credstore), then an env var,
// then a stored OAuth bearer for token-login providers. The config resolver is pure
// (no secret I/O), so a stored-key profile carries an empty APIKey until this runs —
// every place that rebuilds the provider (model switch, provider switch) must call
// this or the request goes out with no key.
// way the runtime resolves it: a stored key (encrypted credstore), then an env var.
// The config resolver is pure (no secret I/O), so a stored-key profile carries an
// empty APIKey until this runs — every place that rebuilds the provider (model
// switch, provider switch) must call this or the request goes out with no key.
//
// A stored OAuth bearer is deliberately NOT copied into APIKey: an inline key makes
// HasConfiguredCredential true, which strips the OAuth login candidates at build
// time, so newProvider would construct the client without the bearer resolver and
// login key — for ChatGPT (Codex) that drops the `chatgpt-account-id` header and
// token refresh, 401-ing every call. Keyless is the correct shape for a token-login
// profile; newProvider resolves the login itself.
func (m model) profileWithCredential(profile config.ProviderProfile) config.ProviderProfile {
if strings.TrimSpace(profile.APIKey) == "" {
if store, err := config.ProviderKeyStore(); err == nil {
if store, err := m.providerKeyStore(); err == nil {
profile = config.ApplyStoredAPIKey(profile, store)
}
}
if strings.TrimSpace(profile.APIKey) == "" && strings.TrimSpace(profile.APIKeyEnv) != "" {
profile.APIKey = strings.TrimSpace(os.Getenv(profile.APIKeyEnv))
}
if descriptor, ok := m.descriptorForProfile(profile); ok && strings.TrimSpace(profile.APIKey) == "" && descriptor.OAuth && !descriptor.OAuthMintsKey {
if token := oauthStoredToken(m.ctx, descriptor.ID); token != "" {
profile.APIKey = token
}
}
return profile
}
// providerKeyStore opens the credential store co-located with THIS session's
// config path — the store every TUI write path (SecureProviderProfile capture,
// rename migration, delete) uses. Reading from the default-path store instead
// makes a stored key invisible whenever userConfigPath is non-default, so the
// switch gate rejects a provider whose key is right there beside its config.
// Ephemeral sessions without a config path fall back to the default store.
func (m model) providerKeyStore() (*credstore.Store, error) {
return providerKeyStoreForPath(m.userConfigPath)
}
func providerKeyStoreForPath(configPath string) (*credstore.Store, error) {
if strings.TrimSpace(configPath) != "" {
return config.ProviderKeyStoreAt(filepath.Dir(configPath))
}
return config.ProviderKeyStore()
}
// oauthLoginAvailable reports whether a stored OAuth login exists for any of the
// profile's login candidates — the SAME candidate set the runtime bearer resolver
// uses (profile name, then catalog id, only for keyless profiles), so the switch
// gate and the runtime can never disagree about whether a login counts.
func oauthLoginAvailable(profile config.ProviderProfile) bool {
_, ok := oauthLoginName(profile)
return ok
}
// oauthLoginName resolves WHICH stored login serves this profile — the same
// FirstStored selection the runtime makes. User-facing hints must name this
// entry, not the profile: after a rename ({name:"codex", catalogID:"chatgpt"})
// the token lives under the catalog id, and `zero auth logout codex` would
// delete nothing while the real login stays behind.
func oauthLoginName(profile config.ProviderProfile) (string, bool) {
candidates := profile.OAuthLoginCandidates()
if len(candidates) == 0 {
return "", false
}
store, err := oauth.NewStore(oauth.StoreOptions{})
if err != nil {
return "", false
}
_, key, ok := oauth.FirstStored(store, candidates)
if !ok {
return "", false
}
return strings.TrimPrefix(key, oauth.KeyPrefixProvider), true
}
func (m model) savedProviderByName(name string) (config.ProviderProfile, bool) {
name = strings.TrimSpace(name)
for _, profile := range m.savedProviders {
+29 -2
View File
@@ -419,12 +419,23 @@ func (m model) providerText() string {
}
snapshot := zerocommands.ProviderSnapshotFromProfile(m.providerProfile, true)
// A keyless profile backed by a stored OAuth login (e.g. ChatGPT) is fully
// authenticated — without this, /provider status showed a WORKING provider
// as "api key: not set" with a warning, unlike `zero providers list` which
// already fills OAuthLogin.
if !snapshot.APIKeySet {
snapshot.OAuthLogin = oauthLoginAvailable(m.providerProfile)
}
credentialState := apiKeyState(snapshot.APIKeySet)
if !snapshot.APIKeySet && snapshot.OAuthLogin {
credentialState = "oauth login"
}
profileLines = append(profileLines,
"active: "+boolText(snapshot.Active),
"kind: "+displayValue(snapshot.ProviderKind, "unknown"),
"api model: "+displayValue(snapshot.APIModel, "unknown"),
"base url: "+displayValue(snapshot.BaseURL, "default"),
"api key: "+apiKeyState(snapshot.APIKeySet),
"api key: "+credentialState,
)
if snapshot.Message != "" {
profileLines = append(profileLines, "provider status: "+snapshot.Status+" - "+snapshot.Message)
@@ -432,7 +443,7 @@ func (m model) providerText() string {
status := commandStatusOK
actionLines := providerNextActionLines(m.providerProfile, snapshot, m.providerName)
if providerCredentialRequired(m.providerProfile, snapshot.ProviderKind) && !providerProfileHasCredential(m.providerProfile) {
if providerCredentialRequired(m.providerProfile, snapshot.ProviderKind) && !providerProfileHasCredential(m.providerProfile) && !snapshot.OAuthLogin {
status = commandStatusWarning
}
return renderCommandOutput(commandOutput{
@@ -698,3 +709,19 @@ func (m model) debugText() string {
}},
})
}
// skillsText is the /skills fallback when NO skills are installed — an install
// hint. With skills present /skills opens the searchable skill picker instead
// (see newSkillPicker), matching how /model works.
func (m model) skillsText() string {
return renderCommandOutput(commandOutput{
Title: "Skills",
Status: commandStatusInfo,
Sections: []commandSection{{
Lines: []string{"No skills installed."},
}},
Hints: []string{
"install one: create <skills-dir>/<name>/SKILL.md (see `zero skills`)",
},
})
}
+18 -2
View File
@@ -46,6 +46,8 @@ const (
commandCopy
commandExport
commandNew
commandSkills
commandLoop
commandUnknown
)
@@ -77,9 +79,9 @@ type parsedCommand struct {
var commandDefinitions = []commandDefinition{
{
name: "/provider",
usage: "/provider [status]",
usage: "/provider [add|status]",
group: commandGroupModel,
description: "Open provider setup.",
description: "Manage providers: activate, add, edit, delete.",
kind: commandProvider,
},
{
@@ -131,6 +133,13 @@ var commandDefinitions = []commandDefinition{
description: "List registered tools.",
kind: commandTools,
},
{
name: "/skills",
usage: "/skills",
group: commandGroupTools,
description: "List installed skills; run one directly with /<skill-name> [args].",
kind: commandSkills,
},
{
name: "/context",
usage: "/context",
@@ -289,6 +298,13 @@ var commandDefinitions = []commandDefinition{
description: "Write the conversation transcript to a file.",
kind: commandExport,
},
{
name: "/loop",
usage: "/loop [interval] <prompt|/command> | /loop list | /loop stop [id|all]",
group: commandGroupSession,
description: "Repeat a prompt or command on an interval (e.g. /loop 5m /babysit-prs), or self-paced when no interval is given.",
kind: commandLoop,
},
{
name: "/help",
usage: "/help",
+1 -1
View File
@@ -45,7 +45,7 @@ func TestFormatCommandHelpLinesGroupsCommandsByStableOrder(t *testing.T) {
for _, want := range []string{
"model:",
" /provider [status] - Open provider setup.",
" /provider [add|status] - Manage providers: activate, add, edit, delete.",
" /model [list|id] - Show or switch the active model.",
" /effort [list|low|medium|high|auto] - Show or set reasoning effort for supported models.",
"session:",
+94
View File
@@ -1,6 +1,7 @@
package tui
import (
"fmt"
"strings"
"unicode/utf8"
@@ -263,3 +264,96 @@ func (m model) keyMatch(b parsedBinding, msg tea.KeyMsg, defaultFn func(tea.KeyM
}
return defaultFn(msg)
}
// reservedBindings lists hardcoded (non-configurable) chords handled directly in
// model.go's key dispatch. If a configurable binding uses one of these chords,
// one of the actions becomes unreachable (depending on switch order), so
// sanitizeKeyBindings reverts the configurable binding back to its default.
var reservedBindings = []struct {
binding parsedBinding
description string
}{
{parseBinding("ctrl+c"), "cancel / exit"},
{parseBinding("esc"), "cancel / close"},
{parseBinding("enter"), "submit"},
{parseBinding("shift+tab"), "cycle permission mode"},
{parseBinding("tab"), "navigation / completion"},
{parseBinding("backspace"), "composer edit / attachment removal"},
{parseBinding("up"), "history/navigation"},
{parseBinding("down"), "history/navigation"},
{parseBinding("pgup"), "transcript scroll"},
{parseBinding("pgdown"), "transcript scroll"},
{parseBinding("ctrl+f"), "favorite model (in the /model picker)"},
{parseBinding("?"), "help overlay"},
}
// sanitizeKeyBindings drops (reverts to default) any configured binding that
// collides with a reserved hardcoded chord above, or with another
// configured binding, since either collision would silently make one of the
// two actions permanently unreachable. Returns the sanitized bindings plus a
// human-readable warning for each dropped binding, for the caller to surface
// as a startup notice.
func sanitizeKeyBindings(b keyBindings) (keyBindings, []string) {
entries := []struct {
name string
binding *parsedBinding
defaultBinding parsedBinding
}{
{"toggleDetailed", &b.toggleDetailed, parseBinding("ctrl+o")},
{"toggleMouse", &b.toggleMouse, parseBinding("ctrl+e")},
{"cycleReasoning", &b.cycleReasoning, parseBinding("ctrl+t")},
{"togglePlan", &b.togglePlan, parseBinding("ctrl+p")},
{"toggleSidebar", &b.toggleSidebar, parseBinding("ctrl+b")},
}
var warnings []string
for _, e := range entries {
if e.binding.isZero() {
continue
}
for _, other := range entries {
if other.name == e.name || !other.binding.isZero() {
continue
}
if *e.binding == other.defaultBinding {
warnings = append(warnings, fmt.Sprintf(
"keybindings.%s (%s) conflicts with keybindings.%s default (%s); using the default instead.",
e.name, e.binding.Label(), other.name, other.defaultBinding.Label()))
*e.binding = parsedBinding{}
break
}
}
}
for _, e := range entries {
if e.binding.isZero() {
continue
}
for _, reserved := range reservedBindings {
if *e.binding == reserved.binding {
warnings = append(warnings, fmt.Sprintf(
"keybindings.%s (%s) conflicts with the built-in %s shortcut; using the default instead.",
e.name, e.binding.Label(), reserved.description))
*e.binding = parsedBinding{}
break
}
}
}
claimedBy := map[parsedBinding]string{}
for _, e := range entries {
if e.binding.isZero() {
continue
}
if other, ok := claimedBy[*e.binding]; ok {
warnings = append(warnings, fmt.Sprintf(
"keybindings.%s (%s) conflicts with keybindings.%s; using the default instead.",
e.name, e.binding.Label(), other))
*e.binding = parsedBinding{}
continue
}
claimedBy[*e.binding] = e.name
}
return b, warnings
}
+693
View File
@@ -0,0 +1,693 @@
package tui
import (
"fmt"
"regexp"
"strconv"
"strings"
"time"
tea "charm.land/bubbletea/v2"
)
// The /loop command repeats a prompt or slash command in one of two modes:
//
// - Fixed interval — `/loop 5m <prompt>` re-fires on a wall-clock cadence.
// - Self-paced — bare `/loop <prompt>` runs a maintenance playbook: the model
// does one increment of work each iteration and ends its reply with a control
// line (`LOOP: DONE` / `LOOP: CONTINUE [interval]`) that stops the loop or picks
// the next wake. With no control line it falls back to an adaptive cadence.
//
// Loops are foreground and session-scoped: a tick only fires while the session is
// idle between turns (it never interrupts a streaming turn), the loop lives with the
// session that created it, and switching sessions (/new, /resume) stops it. Safety
// rails cap iterations, age, consecutive failures, and no-progress repetition.
// Everything here is pure/loop-local; the turn-lifecycle wiring (ticker, launch,
// continuation) lives in model.go.
type loopMode int
const (
loopModeFixed loopMode = iota // re-fire every fixed interval
loopModeSelfPaced // model chooses the next wake each iteration
)
// Safety bounds. A loop is foreground + session-scoped, but these keep a runaway
// or forgotten loop from burning tokens forever.
const (
loopMinInterval = 30 * time.Second
loopMaxInterval = 24 * time.Hour
loopMaxIterations = 500 // hard ceiling; the loop auto-stops after this many
// loopMaxAge auto-expires a loop this long after it was created, so a forgotten
// loop stops on its own even if it never reaches the iteration cap.
loopMaxAge = 24 * time.Hour
loopSelfPaceMin = 1 * time.Minute
loopSelfPaceMax = 1 * time.Hour
loopSelfPaceDefault = 15 * time.Minute
// loopDoomThreshold ends a loop after this many consecutive identical final
// answers — a self-paced or interval loop that gets stuck repeating the same
// no-progress result should stop rather than churn.
loopDoomThreshold = 5
// loopMaxFailures ends a loop after this many consecutive failed iterations, so
// a loop whose iterations keep erroring stops instead of churning to the
// iteration cap (a failed turn has no final answer, so the doom guard can't see
// the lack of progress).
loopMaxFailures = 3
// loopPollInterval is how often the controller checks whether a loop is due.
// A loop only fires while idle, so this is a cheap between-turn poll.
loopPollInterval = 1 * time.Second
)
// loopState is one active loop owned by the current session.
type loopState struct {
id string
mode loopMode
prompt string // the prompt or "/command" re-run each iteration
interval time.Duration
iteration int
maxIter int // 0 => loopMaxIterations
createdAt time.Time
nextRunAt time.Time
// lastResult / repeatRun drive doom-loop detection: consecutive identical
// final answers past loopDoomThreshold end the loop.
lastResult string
repeatRun int
// failRun counts consecutive failed iterations; past loopMaxFailures the loop
// stops (a failed turn has no final answer for the doom guard to compare).
failRun int
paused bool
}
func (l *loopState) iterationCap() int {
if l.maxIter > 0 {
return l.maxIter
}
return loopMaxIterations
}
// due reports whether the loop should fire at now (active, not paused, past its
// next-run mark).
func (l *loopState) due(now time.Time) bool {
return !l.paused && !l.nextRunAt.IsZero() && !now.Before(l.nextRunAt)
}
// cadenceText renders the loop's cadence for status lines.
func (l *loopState) cadenceText() string {
if l.mode == loopModeSelfPaced {
return "self-paced"
}
return "every " + formatLoopDuration(l.interval)
}
type loopAction int
const (
loopActionUsage loopAction = iota // show usage / list when empty
loopActionStart // start a new loop
loopActionStop // stop one by id
loopActionStopAll // stop every loop
loopActionList // list active loops
)
// loopCommand is the parsed form of a `/loop ...` invocation (the text after the
// command name).
type loopCommand struct {
action loopAction
targetID string
mode loopMode
prompt string
interval time.Duration
// note carries a non-fatal advisory (e.g. an interval that was clamped) to
// surface back to the user.
note string
}
var loopIntervalRe = regexp.MustCompile(`^(\d+)(s|m|h|d)$`)
// loopControlRe matches a self-paced loop's control line at the start of a line,
// tolerant of a leading markdown bullet/quote marker and any trailing note after
// the keyword: "LOOP: DONE", "- loop: continue", "LOOP: CONTINUE 10m — still tidying".
// The leading ^ anchor keeps a mid-sentence mention ("I'll emit LOOP: DONE when…")
// from matching; parseLoopControl normalizes CRLF first so `$` can reach line end.
var loopControlRe = regexp.MustCompile(`(?im)^[^\S\r\n]*(?:[-*>•][^\S\r\n]*)?loop:[^\S\r\n]*(done|continue)\b(?:[^\S\r\n]+(\d+[smhd])\b)?.*$`)
// parseLoopCommand interprets the arguments to `/loop`. Forms:
//
// (empty) -> usage/list
// list | status -> list active loops
// stop | stop all | stop <id>
// <interval> <prompt> -> fixed-interval loop (e.g. "5m /babysit")
// <prompt> every <interval>
// <prompt> -> self-paced loop (no interval)
func parseLoopCommand(args string) loopCommand {
trimmed := strings.TrimSpace(args)
if trimmed == "" {
return loopCommand{action: loopActionUsage}
}
first := strings.ToLower(strings.Fields(trimmed)[0])
switch first {
case "list", "status", "ls":
return loopCommand{action: loopActionList}
case "stop", "cancel", "kill":
rest := strings.TrimSpace(trimmed[len(strings.Fields(trimmed)[0]):])
if rest == "" || strings.EqualFold(rest, "all") {
if strings.EqualFold(rest, "all") {
return loopCommand{action: loopActionStopAll}
}
// bare "stop" stops all when ambiguous; the caller may special-case a
// single active loop.
return loopCommand{action: loopActionStop, targetID: ""}
}
return loopCommand{action: loopActionStop, targetID: strings.TrimSpace(rest)}
}
// Leading interval token: "5m <prompt>".
if fields := strings.Fields(trimmed); len(fields) >= 2 {
if d, ok := parseLoopInterval(fields[0]); ok {
prompt := strings.TrimSpace(trimmed[len(fields[0]):])
cmd := loopCommand{action: loopActionStart, mode: loopModeFixed, prompt: prompt}
cmd.interval, cmd.note = clampLoopInterval(d)
return cmd
}
}
// Trailing "every <interval>".
if prompt, d, ok := splitTrailingEvery(trimmed); ok {
cmd := loopCommand{action: loopActionStart, mode: loopModeFixed, prompt: prompt}
cmd.interval, cmd.note = clampLoopInterval(d)
return cmd
}
// No interval anywhere -> self-paced.
return loopCommand{action: loopActionStart, mode: loopModeSelfPaced, prompt: trimmed}
}
// splitTrailingEvery matches "<prompt> every <interval>" (the interval must be a
// real duration token so "check every PR" is not mis-parsed as a cadence).
func splitTrailingEvery(input string) (string, time.Duration, bool) {
fields := strings.Fields(input)
if len(fields) < 3 {
return "", 0, false
}
last := fields[len(fields)-1]
prev := fields[len(fields)-2]
if !strings.EqualFold(prev, "every") {
return "", 0, false
}
d, ok := parseLoopInterval(last)
if !ok {
return "", 0, false
}
prompt := strings.TrimSpace(strings.Join(fields[:len(fields)-2], " "))
if prompt == "" {
return "", 0, false
}
return prompt, d, true
}
// parseLoopInterval parses a bare "<N><unit>" token (s/m/h/d) into a duration.
func parseLoopInterval(token string) (time.Duration, bool) {
m := loopIntervalRe.FindStringSubmatch(strings.ToLower(strings.TrimSpace(token)))
if m == nil {
return 0, false
}
n, err := strconv.Atoi(m[1])
if err != nil || n <= 0 {
return 0, false
}
unit := map[string]time.Duration{
"s": time.Second,
"m": time.Minute,
"h": time.Hour,
"d": 24 * time.Hour,
}[m[2]]
return time.Duration(n) * unit, true
}
// clampLoopInterval bounds an interval to [loopMinInterval, loopMaxInterval] and
// returns an advisory note when it was adjusted.
func clampLoopInterval(d time.Duration) (time.Duration, string) {
switch {
case d < loopMinInterval:
return loopMinInterval, fmt.Sprintf("interval raised to the %s minimum", formatLoopDuration(loopMinInterval))
case d > loopMaxInterval:
return loopMaxInterval, fmt.Sprintf("interval lowered to the %s maximum", formatLoopDuration(loopMaxInterval))
default:
return d, ""
}
}
// loopTickMsg is the between-turn poll that fires a due loop while the session is
// idle. seq invalidates a tick left over from before the loop set changed.
type loopTickMsg struct{ seq int }
// handleLoopCommand dispatches a parsed `/loop ...` invocation.
func (m model) handleLoopCommand(args string) (model, tea.Cmd) {
cmd := parseLoopCommand(args)
switch cmd.action {
case loopActionList:
return m.appendLoopSystem(m.loopListText()), nil
case loopActionUsage:
if len(m.loops) > 0 {
return m.appendLoopSystem(m.loopListText()), nil
}
return m.appendLoopSystem(loopUsageText()), nil
case loopActionStop:
return m.stopLoop(cmd.targetID)
case loopActionStopAll:
return m.stopAllLoops()
case loopActionStart:
return m.startLoop(cmd)
}
return m, nil
}
// startLoop registers a new loop and (re)starts the idle poll ticker. The first
// iteration fires on the next idle tick (nextRunAt = now).
func (m model) startLoop(cmd loopCommand) (model, tea.Cmd) {
if reason, ok := m.validateLoopTarget(cmd.prompt); !ok {
return m.appendLoopSystem(reason), nil
}
// A /command loop must be fixed-interval. Self-paced mode drives a free-form
// goal through the playbook + LOOP: control line (see firePrompt), which a
// custom command's expanded template never carries — so a bare self-paced
// /command would silently degrade to adaptive polling with no way to report
// done. Require an explicit interval for command loops instead.
if cmd.mode == loopModeSelfPaced && strings.HasPrefix(strings.TrimSpace(cmd.prompt), "/") {
return m.appendLoopSystem("A /command loop needs an interval — e.g. /loop 5m " + strings.Fields(cmd.prompt)[0] + ". Self-paced mode (no interval) is for a free-form goal."), nil
}
m.loopCounter++
id := "L" + strconv.Itoa(m.loopCounter)
loop := &loopState{
id: id,
mode: cmd.mode,
prompt: cmd.prompt,
interval: cmd.interval,
createdAt: m.now(),
nextRunAt: m.now(), // fire the first iteration on the next idle tick
}
m.loops = append(m.loops, loop)
note := ""
if cmd.note != "" {
note = " (" + cmd.note + ")"
}
m = m.appendLoopSystem(fmt.Sprintf(
"Loop %s started — %s%s. Runs while this session is idle; stop with /loop stop %s.",
id, loop.cadenceText(), note, id))
return m.ensureLoopTick()
}
// stopLoop stops one loop by id, or the single active loop when the id is omitted.
func (m model) stopLoop(id string) (model, tea.Cmd) {
if len(m.loops) == 0 {
return m.appendLoopSystem("No active loops."), nil
}
if id == "" {
if len(m.loops) == 1 {
return m.removeLoop(m.loops[0].id, "Loop "+m.loops[0].id+" stopped.").ensureLoopTick()
}
return m.appendLoopSystem("Multiple loops active — use /loop stop <id> or /loop stop all.\n" + m.loopListText()), nil
}
if m.findLoop(id) == nil {
return m.appendLoopSystem("No loop " + id + ". " + m.loopListText()), nil
}
// removeLoop stops the poll ticker; re-arm it so any OTHER loops that remain
// keep firing (ensureLoopTick no-ops when this was the last loop).
return m.removeLoop(id, "Loop "+id+" stopped.").ensureLoopTick()
}
func (m model) stopAllLoops() (model, tea.Cmd) {
if len(m.loops) == 0 {
return m.appendLoopSystem("No active loops."), nil
}
n := len(m.loops)
m.loops = nil
m.loopSeq++ // invalidate the pending poll tick
m.loopTicking = false
return m.appendLoopSystem(fmt.Sprintf("Stopped all %d loop(s).", n)), nil
}
// fireDueLoopIfIdle fires the earliest due loop when the session is idle. Called
// from the poll tick; a no-op while a turn, modal, or queued user message is
// pending (the loop simply waits for the next idle tick).
func (m model) fireDueLoopIfIdle() (model, tea.Cmd) {
if m.loopBusy() || len(m.loops) == 0 {
return m, nil
}
now := m.now()
var due *loopState
for _, l := range m.loops {
if l.due(now) && (due == nil || l.nextRunAt.Before(due.nextRunAt)) {
due = l
}
}
if due == nil {
return m, nil
}
m.activeLoopID = due.id
due.nextRunAt = time.Time{} // mark running
return m.fireLoopPrompt(due.firePrompt())
}
// firePrompt is the text actually sent for this iteration. A self-paced plain
// prompt is wrapped in the maintenance playbook so the model knows to do one
// increment of work and end with a LOOP: control line; a /command or a
// fixed-interval prompt is sent verbatim.
func (l *loopState) firePrompt() string {
if l.mode == loopModeSelfPaced && !strings.HasPrefix(strings.TrimSpace(l.prompt), "/") {
return loopSelfPacePlaybook(l)
}
return l.prompt
}
// fireLoopPrompt runs a loop's prompt as a turn — a custom /command if it resolves
// to one, otherwise a plain prompt.
func (m model) fireLoopPrompt(prompt string) (model, tea.Cmd) {
if strings.HasPrefix(strings.TrimSpace(prompt), "/") {
if next, teaCmd, ok := m.handleUserCommand(prompt); ok {
return next, teaCmd
}
}
return m.launchPrompt(prompt)
}
// advanceLoop updates a loop after one of its iterations completes: it folds the
// result into the failure/no-progress guards, honors a self-paced DONE/next-wake
// control line, enforces the iteration and age caps, then schedules the next wake
// (or stops the loop).
func (m model) advanceLoop(id, finalAnswer string, runErr error) model {
l := m.findLoop(id)
if l == nil {
return m // stopped mid-run
}
l.iteration++
if runErr != nil {
l.failRun++
} else {
l.failRun = 0
}
res := strings.TrimSpace(finalAnswer)
if res != "" && res == l.lastResult {
l.repeatRun++
} else {
l.repeatRun = 0
}
l.lastResult = res
// Only self-paced iterations run the playbook, so only they can carry a control
// line; a failed turn has no trustworthy final answer to read one from.
done, wake := false, time.Duration(0)
if l.mode == loopModeSelfPaced && runErr == nil {
done, wake = parseLoopControl(finalAnswer)
}
switch {
case done:
return m.removeLoop(id, fmt.Sprintf("Loop %s finished — the task reported done after %d iteration(s).", id, l.iteration))
case l.failRun >= loopMaxFailures:
return m.removeLoop(id, fmt.Sprintf("Loop %s stopped after %d consecutive failed iterations.", id, l.failRun))
case l.iteration >= l.iterationCap():
return m.removeLoop(id, fmt.Sprintf("Loop %s stopped at its %d-iteration cap.", id, l.iteration))
case !l.createdAt.IsZero() && m.now().Sub(l.createdAt) >= loopMaxAge:
return m.removeLoop(id, fmt.Sprintf("Loop %s expired after %s.", id, formatLoopDuration(loopMaxAge)))
case l.repeatRun >= loopDoomThreshold:
return m.removeLoop(id, fmt.Sprintf("Loop %s stopped — %d identical results in a row (no progress).", id, l.repeatRun))
}
if l.mode == loopModeSelfPaced {
// A model-chosen next wake (from the control line) wins; otherwise fall back to
// an adaptive cadence that checks back often while work is fresh and settles to
// a slow heartbeat as iterations accrue.
delay := wake
if delay <= 0 {
delay = adaptiveSelfPaceDelay(l.iteration)
}
l.nextRunAt = m.now().Add(clampSelfPaceDelay(delay))
} else {
l.nextRunAt = m.now().Add(l.interval)
}
return m
}
// parseLoopControl reads a self-paced loop's control signal from the model's final
// answer. The last control line wins (the model may reference the protocol before
// emitting it). Returns done=true to stop the loop, or a next-wake hint (0 = none).
func parseLoopControl(answer string) (done bool, wake time.Duration) {
// Normalize CRLF/CR so the line-end anchor can reach `$` (Go's (?m)$ sits right
// before `\n`, and the horizontal-whitespace class can't consume a stray `\r`).
norm := strings.ReplaceAll(answer, "\r\n", "\n")
norm = strings.ReplaceAll(norm, "\r", "\n")
matches := loopControlRe.FindAllStringSubmatch(norm, -1)
if len(matches) == 0 {
return false, 0
}
last := matches[len(matches)-1]
if strings.EqualFold(last[1], "done") {
return true, 0
}
if last[2] != "" {
if d, ok := parseLoopInterval(last[2]); ok {
return false, d
}
}
return false, 0
}
// loopSelfPacePlaybook wraps a self-paced loop's goal in the maintenance-loop
// instructions the model follows each iteration: do one real increment of work,
// then emit a single control line so the harness knows whether to stop or when to
// wake next — the self-paced maintenance playbook the feature ships with.
func loopSelfPacePlaybook(l *loopState) string {
var b strings.Builder
b.WriteString("You are running in a self-paced maintenance loop (iteration ")
b.WriteString(strconv.Itoa(l.iteration + 1))
b.WriteString("). Do one concrete increment of work toward the goal below — actual work, not just a plan — then stop and report.\n\n")
b.WriteString("Goal: ")
b.WriteString(strings.TrimSpace(l.prompt))
b.WriteString("\n\nEnd your reply with a control line — on its own line, starting with \"LOOP:\":\n")
b.WriteString(" LOOP: DONE — the goal is fully met; the loop stops.\n")
b.WriteString(" LOOP: CONTINUE — more to do; the loop wakes again automatically.\n")
b.WriteString(" LOOP: CONTINUE 10m — more to do; wake after the delay you name (e.g. 5m, 1h).\n")
b.WriteString("A short note after the keyword is fine. Report DONE only when the goal is genuinely complete — the loop trusts that signal to stop.")
return b.String()
}
// adaptiveSelfPaceDelay picks a self-paced loop's next-wake by iteration: frequent
// early (work is actively progressing), slowing to a heartbeat as it matures.
func adaptiveSelfPaceDelay(iteration int) time.Duration {
switch {
case iteration < 2:
return 2 * time.Minute
case iteration < 5:
return 5 * time.Minute
case iteration < 10:
return 15 * time.Minute
default:
return 30 * time.Minute
}
}
func (m model) findLoop(id string) *loopState {
for _, l := range m.loops {
if l.id == id {
return l
}
}
return nil
}
func (m model) removeLoop(id, note string) model {
kept := make([]*loopState, 0, len(m.loops))
for _, l := range m.loops {
if l.id != id {
kept = append(kept, l)
}
}
m.loops = kept
m.loopSeq++ // invalidate the pending poll tick; a fresh one is scheduled if loops remain
m.loopTicking = false
if note != "" {
m = m.appendLoopSystem(note)
}
return m
}
// ensureLoopTick starts the idle poll ticker if it is not already running.
func (m model) ensureLoopTick() (model, tea.Cmd) {
if m.loopTicking || len(m.loops) == 0 {
return m, nil
}
m.loopTicking = true
return m, m.scheduleLoopTick()
}
func (m model) scheduleLoopTick() tea.Cmd {
seq := m.loopSeq
return tea.Tick(loopPollInterval, func(time.Time) tea.Msg {
return loopTickMsg{seq: seq}
})
}
// loopBusy reports whether the session is too busy for a loop to fire — a turn,
// queued user message, compaction, or ANY blocking modal (permission/askUser/spec
// review, provider or MCP wizard, MCP manager, or an open picker) is in flight.
// The modal check is load-bearing: a loop must not launch a run behind an open
// /resume picker, or that run would complete into whatever session the user then
// switches to. Mirrors launchQueuedMessageIfReady so loops and queued prompts never
// collide.
func (m model) loopBusy() bool {
return m.pending || m.exiting || m.compactInFlight || m.hasQueuedMessage() || !m.noBlockingModal()
}
func (m model) appendLoopSystem(text string) model {
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: text})
return m
}
// loopActive reports whether any loop is running (for the footer + leave warning).
func (m model) loopActive() bool { return len(m.loops) > 0 }
// loopFooterSummary renders the persistent "N loops · next 3:05pm" footer segment,
// or "" when no loops are active.
func (m model) loopFooterSummary() string {
if len(m.loops) == 0 {
return ""
}
var next time.Time
for _, l := range m.loops {
if l.nextRunAt.IsZero() || l.paused {
continue
}
if next.IsZero() || l.nextRunAt.Before(next) {
next = l.nextRunAt
}
}
label := fmt.Sprintf("%d loop", len(m.loops))
if len(m.loops) != 1 {
label += "s"
}
if next.IsZero() {
return label
}
return label + " · next " + next.Format("3:04pm")
}
func (m model) loopListText() string {
if len(m.loops) == 0 {
return "No active loops."
}
var b strings.Builder
b.WriteString("Active loops:\n")
now := m.now()
for _, l := range m.loops {
when := "running"
if !l.nextRunAt.IsZero() {
if d := l.nextRunAt.Sub(now); d > 0 {
when = "next in " + formatLoopDuration(d.Round(time.Second))
} else {
when = "due"
}
}
b.WriteString(fmt.Sprintf(" %s · %s · iter %d · %s · %s\n",
l.id, l.cadenceText(), l.iteration, when, truncateLoopPrompt(l.prompt)))
}
b.WriteString("Stop with /loop stop <id> or /loop stop all.")
return b.String()
}
func loopUsageText() string {
return "Repeat a prompt or command:\n" +
" /loop 5m /babysit-prs — run a command every 5 minutes\n" +
" /loop watch CI every 2m — trailing interval form\n" +
" /loop keep tidying docs — self-paced (the model picks its own cadence and stops when done)\n" +
" /loop list — show active loops\n" +
" /loop stop [id|all] — stop a loop\n" +
"Loops run while this session is idle and stop when you close it."
}
// validateLoopTarget rejects looping a built-in command or an unresolved custom
// command — only a prompt or an existing custom /command (.zero/commands/<name>.md)
// may loop. Resolving custom commands up front stops a typo'd `/loop 5m /nope` from
// being scheduled and then re-sent as literal text on every tick.
func (m model) validateLoopTarget(prompt string) (string, bool) {
p := strings.TrimSpace(prompt)
if p == "" {
return "Nothing to loop — give a prompt or a /command.", false
}
if strings.HasPrefix(p, "/") {
switch parseCommand(p).kind {
case commandPrompt:
// A bare "/" or similar that isn't a command — treat as a plain prompt.
case commandUnknown:
name, _ := splitUserCommand(p)
if _, ok := m.lookupUserCommand(name); !ok {
return "No command /" + name + " — a loop can only run a prompt or an existing custom /command.", false
}
default:
return "A loop can only run a prompt or a custom /command, not a built-in like " + strings.Fields(p)[0] + ".", false
}
}
return "", true
}
// clearLoopsForSessionSwitch drops every active loop and its ticker. Loops belong to
// the session that created them; carrying them across /new or /resume would fire the
// old session's prompt into an unrelated conversation. Returns the count cleared so
// the caller can note it. Pure state reset — no transcript writes.
func (m model) clearLoopsForSessionSwitch() (model, int) {
n := len(m.loops)
if n == 0 {
return m, 0
}
m.loops = nil
m.activeLoopID = ""
m.loopTicking = false
m.loopSeq++ // invalidate any pending poll tick
return m, n
}
func truncateLoopPrompt(prompt string) string {
const max = 48
p := strings.TrimSpace(strings.ReplaceAll(prompt, "\n", " "))
runes := []rune(p)
if len(runes) <= max {
return p
}
return strings.TrimSpace(string(runes[:max])) + "…"
}
// clampSelfPaceDelay bounds a model-chosen self-paced delay to the self-pace
// band, defaulting an unset/invalid value.
func clampSelfPaceDelay(d time.Duration) time.Duration {
switch {
case d <= 0:
return loopSelfPaceDefault
case d < loopSelfPaceMin:
return loopSelfPaceMin
case d > loopSelfPaceMax:
return loopSelfPaceMax
default:
return d
}
}
// formatLoopDuration renders a duration compactly (90s, 5m, 2h, 1d) for status
// text, preferring the largest whole unit.
func formatLoopDuration(d time.Duration) string {
switch {
case d%(24*time.Hour) == 0 && d >= 24*time.Hour:
return strconv.Itoa(int(d/(24*time.Hour))) + "d"
case d%time.Hour == 0 && d >= time.Hour:
return strconv.Itoa(int(d/time.Hour)) + "h"
case d%time.Minute == 0 && d >= time.Minute:
return strconv.Itoa(int(d/time.Minute)) + "m"
default:
return strconv.Itoa(int(d/time.Second)) + "s"
}
}
+460
View File
@@ -0,0 +1,460 @@
package tui
import (
"context"
"errors"
"strings"
"testing"
"time"
tea "charm.land/bubbletea/v2"
"github.com/Gitlawb/zero/internal/usercommands"
)
func loopTestModel(t *testing.T, now time.Time) model {
t.Helper()
m := newModel(context.Background(), Options{})
m.now = func() time.Time { return now }
return m
}
func startFixedLoop(m model, prompt string, interval time.Duration) model {
m, _ = m.startLoop(loopCommand{action: loopActionStart, mode: loopModeFixed, prompt: prompt, interval: interval})
return m
}
func TestStartLoopRegistersAndSchedules(t *testing.T) {
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
m := loopTestModel(t, now)
m = startFixedLoop(m, "check the build", 5*time.Minute)
if len(m.loops) != 1 {
t.Fatalf("want 1 loop, got %d", len(m.loops))
}
l := m.loops[0]
if l.mode != loopModeFixed || l.interval != 5*time.Minute || l.prompt != "check the build" {
t.Fatalf("unexpected loop %+v", l)
}
// First iteration is due immediately (nextRunAt == now).
if !l.due(now) {
t.Errorf("first iteration should be due at creation")
}
if !m.loopTicking {
t.Errorf("starting a loop should start the poll ticker")
}
}
func TestAdvanceLoopSchedulesNextWake(t *testing.T) {
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
m := loopTestModel(t, now)
m = startFixedLoop(m, "poll", 5*time.Minute)
id := m.loops[0].id
m = m.advanceLoop(id, "did some work", nil)
l := m.loops[0]
if l.iteration != 1 {
t.Fatalf("iteration = %d, want 1", l.iteration)
}
if !l.nextRunAt.Equal(now.Add(5 * time.Minute)) {
t.Fatalf("nextRunAt = %v, want +5m", l.nextRunAt)
}
}
func TestSelfPacedUsesModelDelay(t *testing.T) {
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
m := loopTestModel(t, now)
m, _ = m.startLoop(loopCommand{action: loopActionStart, mode: loopModeSelfPaced, prompt: "keep tidying"})
id := m.loops[0].id
// The model carries its chosen next wake in the reply's control line.
m = m.advanceLoop(id, "made progress\nLOOP: CONTINUE 10m", nil)
if len(m.loops) == 0 {
t.Fatal("a CONTINUE control line should keep the loop running")
}
if !m.loops[0].nextRunAt.Equal(now.Add(10 * time.Minute)) {
t.Fatalf("self-paced nextRunAt = %v, want +10m", m.loops[0].nextRunAt)
}
}
func TestLoopDoneStops(t *testing.T) {
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
m := loopTestModel(t, now)
m, _ = m.startLoop(loopCommand{action: loopActionStart, mode: loopModeSelfPaced, prompt: "finish the task"})
id := m.loops[0].id
m = m.advanceLoop(id, "all done\nLOOP: DONE", nil)
if len(m.loops) != 0 {
t.Fatalf("loop should stop when the task reports done, %d remain", len(m.loops))
}
}
func TestFixedLoopIgnoresControlLine(t *testing.T) {
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
m := loopTestModel(t, now)
m = startFixedLoop(m, "watch CI", time.Minute)
id := m.loops[0].id
// A fixed-interval loop never runs the playbook, so a stray control line in the
// output is not a completion signal — it keeps its wall-clock cadence.
m = m.advanceLoop(id, "LOOP: DONE", nil)
if len(m.loops) != 1 {
t.Fatal("a fixed-interval loop should ignore a control line and keep running")
}
if !m.loops[0].nextRunAt.Equal(now.Add(time.Minute)) {
t.Fatalf("fixed nextRunAt = %v, want +1m", m.loops[0].nextRunAt)
}
}
func TestLoopExpiresByAge(t *testing.T) {
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
m := loopTestModel(t, now)
m = startFixedLoop(m, "forgotten", time.Minute)
id := m.loops[0].id
m.loops[0].createdAt = now.Add(-loopMaxAge - time.Hour) // created past the age cap
m = m.advanceLoop(id, "still going", nil)
if len(m.loops) != 0 {
t.Fatal("a loop past its age cap should auto-expire")
}
}
func TestLoopDoomGuardStops(t *testing.T) {
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
m := loopTestModel(t, now)
m = startFixedLoop(m, "spin", time.Minute)
id := m.loops[0].id
for i := 0; i < loopDoomThreshold+1 && len(m.loops) > 0; i++ {
m = m.advanceLoop(id, "identical answer", nil)
}
if len(m.loops) != 0 {
t.Fatalf("doom guard should stop a loop repeating identical results")
}
}
func TestLoopIterationCapStops(t *testing.T) {
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
m := loopTestModel(t, now)
m = startFixedLoop(m, "bounded", time.Minute)
m.loops[0].maxIter = 3
id := m.loops[0].id
for i := 0; i < 3 && len(m.loops) > 0; i++ {
m = m.advanceLoop(id, "answer "+string(rune('a'+i)), nil) // distinct answers so doom doesn't fire
}
if len(m.loops) != 0 {
t.Fatalf("loop should stop at its iteration cap")
}
}
func TestStopLoopByID(t *testing.T) {
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
m := loopTestModel(t, now)
m = startFixedLoop(m, "a", time.Minute)
m = startFixedLoop(m, "b", time.Minute)
id := m.loops[0].id
m, cmd := m.stopLoop(id)
if len(m.loops) != 1 || m.findLoop(id) != nil {
t.Fatalf("stopLoop(%s) should remove exactly that loop", id)
}
// Stopping one of several loops must keep the poll ticker alive for the rest:
// removeLoop kills the ticker, so stopLoop has to re-arm it (jatmn).
if cmd == nil || !m.loopTicking {
t.Fatal("stopping one of several loops must leave the remaining loop scheduled (ticker re-armed)")
}
// Bare stop with a single active loop stops that sole loop (parseLoopCommand
// leaves the target empty and stopLoop special-cases the one-loop case).
soleID := m.loops[0].id
m, _ = m.stopLoop("")
if len(m.loops) != 0 || m.findLoop(soleID) != nil {
t.Fatalf("bare stop should remove the sole active loop")
}
m = startFixedLoop(m, "c", time.Minute)
m = startFixedLoop(m, "d", time.Minute)
m, _ = m.stopAllLoops()
if len(m.loops) != 0 {
t.Fatalf("stopAllLoops should clear all loops")
}
}
func TestFireDueLoopSkipsWhenBusy(t *testing.T) {
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
m := loopTestModel(t, now)
m = startFixedLoop(m, "x", time.Minute)
m.pending = true // a turn is in flight
got, cmd := m.fireDueLoopIfIdle()
if got.activeLoopID != "" || cmd != nil {
t.Fatalf("a due loop must not fire while a turn is pending")
}
}
func TestFireDueLoopSkipsBehindModal(t *testing.T) {
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
m := loopTestModel(t, now)
m = startFixedLoop(m, "poll", time.Minute) // due immediately
m.picker = &commandPicker{} // e.g. an open /resume session picker
got, cmd := m.fireDueLoopIfIdle()
if got.activeLoopID != "" || cmd != nil {
t.Fatal("a due loop must not launch a run behind an open picker/modal — it would complete into whatever session the user switches to")
}
if got.loops[0].nextRunAt.IsZero() {
t.Fatal("a loop skipped because a modal is open must stay scheduled, not have its next run cleared")
}
}
func TestAdaptiveSelfPaceDelayWidensOverTime(t *testing.T) {
if adaptiveSelfPaceDelay(0) >= adaptiveSelfPaceDelay(5) {
t.Error("early iterations should check back sooner than later ones")
}
if adaptiveSelfPaceDelay(20) != 30*time.Minute {
t.Errorf("a mature loop should settle to a 30m heartbeat, got %v", adaptiveSelfPaceDelay(20))
}
}
func TestLoopFooterSummary(t *testing.T) {
now := time.Date(2026, 7, 5, 15, 0, 0, 0, time.UTC)
m := loopTestModel(t, now)
if m.loopFooterSummary() != "" {
t.Error("no loops -> empty footer summary")
}
m = startFixedLoop(m, "a", 5*time.Minute)
m.loops[0].nextRunAt = now.Add(5 * time.Minute)
got := m.loopFooterSummary()
wantWake := "next " + now.Add(5*time.Minute).Format("3:04pm")
if !strings.HasPrefix(got, "1 loop") || !strings.Contains(got, wantWake) {
t.Fatalf("footer summary = %q, want it to start with a loop count and contain %q", got, wantWake)
}
}
func TestLoopCommandThroughSubmit(t *testing.T) {
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
m := loopTestModel(t, now)
m.input.SetValue("/loop 5m tidy the imports")
updated, _ := m.Update(testKey(tea.KeyEnter))
next := updated.(model)
if len(next.loops) != 1 {
t.Fatalf("submitting /loop should create a loop, got %d", len(next.loops))
}
if next.loops[0].mode != loopModeFixed || next.loops[0].interval != 5*time.Minute {
t.Fatalf("unexpected loop from submit: %+v", next.loops[0])
}
if !next.loopTicking {
t.Fatalf("submitting /loop should start the poll ticker")
}
// /loop stop all through the same path clears it.
next.input.SetValue("/loop stop all")
updated2, _ := next.Update(testKey(tea.KeyEnter))
final := updated2.(model)
if len(final.loops) != 0 {
t.Fatalf("/loop stop all should clear loops, got %d", len(final.loops))
}
}
func TestStaleLoopTickIgnored(t *testing.T) {
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
m := loopTestModel(t, now)
m = startFixedLoop(m, "x", time.Minute)
m.loopSeq = 7
// A tick from before the seq changed must be dropped and stop the ticker.
updated, cmd := m.Update(loopTickMsg{seq: 3})
next := updated.(model)
if next.loopTicking {
t.Error("a stale loop tick should stop the ticker")
}
if cmd != nil {
t.Error("a stale loop tick should not reschedule")
}
}
func TestLoopStopsAfterConsecutiveFailures(t *testing.T) {
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
m := loopTestModel(t, now)
m = startFixedLoop(m, "flaky", time.Minute)
id := m.loops[0].id
boom := errors.New("boom")
for i := 0; i < loopMaxFailures && len(m.loops) > 0; i++ {
m = m.advanceLoop(id, "", boom)
}
if len(m.loops) != 0 {
t.Fatalf("loop should stop after %d consecutive failed iterations", loopMaxFailures)
}
}
func TestLoopFailureCounterResetsOnSuccess(t *testing.T) {
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
m := loopTestModel(t, now)
m = startFixedLoop(m, "recovers", time.Minute)
id := m.loops[0].id
m = m.advanceLoop(id, "", errors.New("x"))
m = m.advanceLoop(id, "", errors.New("x"))
m = m.advanceLoop(id, "back to healthy", nil) // a success resets the streak
if len(m.loops) != 1 {
t.Fatalf("a recovered failure streak should not stop the loop")
}
if m.loops[0].failRun != 0 {
t.Fatalf("a successful iteration should reset failRun, got %d", m.loops[0].failRun)
}
}
func TestCancelRunClearsActiveLoopAndRearms(t *testing.T) {
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
m := loopTestModel(t, now)
m = startFixedLoop(m, "x", 5*time.Minute)
m.activeLoopID = m.loops[0].id
m.loops[0].nextRunAt = time.Time{} // as if the iteration is running
m.pending = true
m.activeRunID = 7
m.runCancel = func() {}
m.cancelRun()
if m.activeLoopID != "" {
t.Fatal("cancel should clear the active-loop tag so the next turn isn't misattributed")
}
if m.loops[0].nextRunAt.IsZero() {
t.Fatal("cancel should re-arm the interrupted loop, not leave it running forever")
}
if !m.loops[0].nextRunAt.Equal(now.Add(5 * time.Minute)) {
t.Fatalf("re-armed nextRunAt = %v, want +5m", m.loops[0].nextRunAt)
}
}
func TestValidateLoopTargetRejectsBuiltin(t *testing.T) {
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
m := loopTestModel(t, now)
if _, ok := m.validateLoopTarget("/model list"); ok {
t.Error("looping a built-in command should be rejected")
}
if _, ok := m.validateLoopTarget("keep improving docs"); !ok {
t.Error("a plain prompt should be a valid loop target")
}
if _, ok := m.validateLoopTarget(""); ok {
t.Error("an empty target should be rejected")
}
// A custom command that does not exist is rejected up front, so it is never
// scheduled and then re-sent as literal "/…" text on every tick.
if _, ok := m.validateLoopTarget("/babysit-prs"); ok {
t.Error("an unresolved custom command should be rejected")
}
// A registered custom command is accepted.
m.userCommands = []usercommands.Command{{Name: "babysit-prs", Template: "check the PRs"}}
if _, ok := m.validateLoopTarget("/babysit-prs"); !ok {
t.Error("an existing custom command should be a valid loop target")
}
}
func TestSelfPacedCommandLoopRejected(t *testing.T) {
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
m := loopTestModel(t, now)
m.userCommands = []usercommands.Command{{Name: "babysit-prs", Template: "check the PRs"}}
// A /command with no interval parses as self-paced, but self-paced mode drives
// a free-form goal via the playbook + LOOP: protocol, which a command's
// expanded template never carries — so a bare self-paced /command is rejected.
m, _ = m.startLoop(loopCommand{action: loopActionStart, mode: loopModeSelfPaced, prompt: "/babysit-prs"})
if len(m.loops) != 0 {
t.Fatal("a bare self-paced /command loop should be rejected (a command loop needs an interval)")
}
// The same command with an interval (fixed) is accepted.
m, _ = m.startLoop(loopCommand{action: loopActionStart, mode: loopModeFixed, prompt: "/babysit-prs", interval: 5 * time.Minute})
if len(m.loops) != 1 {
t.Fatal("a fixed-interval /command loop should be accepted")
}
}
func TestClearLoopsForSessionSwitch(t *testing.T) {
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
m := loopTestModel(t, now)
m = startFixedLoop(m, "a", time.Minute)
m = startFixedLoop(m, "b", time.Minute)
m.activeLoopID = m.loops[0].id
m, n := m.clearLoopsForSessionSwitch()
if n != 2 || len(m.loops) != 0 || m.activeLoopID != "" || m.loopTicking {
t.Fatalf("session switch should clear all loop state; got n=%d loops=%d active=%q ticking=%v",
n, len(m.loops), m.activeLoopID, m.loopTicking)
}
}
func TestStartNewSessionStopsLoops(t *testing.T) {
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
m := loopTestModel(t, now)
m = startFixedLoop(m, "x", time.Minute)
m = m.startNewSession()
if len(m.loops) != 0 || m.loopTicking {
t.Fatal("/new should stop loops tied to the previous session")
}
}
func TestLoopClearRequiresConfirm(t *testing.T) {
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
m := loopTestModel(t, now)
m = startFixedLoop(m, "x", time.Minute)
// First /clear with a loop active arms the confirm and does not clear yet.
m.input.SetValue("/clear")
u1, _ := m.Update(testKey(tea.KeyEnter))
n1 := u1.(model)
if n1.loopLeavePrompt != commandClear {
t.Fatal("first /clear with active loops should arm the confirm, not clear")
}
if len(n1.loops) != 1 {
t.Fatal("the loop should still be active after the warning")
}
// A different command disarms the confirm.
n1.input.SetValue("/loop list")
u2, _ := n1.Update(testKey(tea.KeyEnter))
n2 := u2.(model)
if n2.loopLeavePrompt != commandEmpty {
t.Fatal("an intervening command should disarm the leave confirm")
}
// Re-arm, then a second consecutive /clear confirms (disarms + clears).
n2.input.SetValue("/clear")
u3, _ := n2.Update(testKey(tea.KeyEnter))
n3 := u3.(model)
n3.input.SetValue("/clear")
u4, _ := n3.Update(testKey(tea.KeyEnter))
n4 := u4.(model)
if n4.loopLeavePrompt != commandEmpty {
t.Fatal("a second consecutive /clear should confirm and disarm")
}
if len(n4.loops) != 1 {
t.Fatal("/clear keeps loops running (it wipes the screen, not the session)")
}
// The confirmed clear must actually wipe the transcript, not silently no-op:
// only the clear path appends the "Transcript cleared" note, and the pre-clear
// loop-start row must be gone.
clearedNote, staleRow := false, false
for _, r := range n4.transcript {
if strings.Contains(r.text, "Transcript cleared") {
clearedNote = true
}
if strings.Contains(r.text, "Loop L1 started") {
staleRow = true
}
}
if !clearedNote || staleRow {
t.Fatalf("the second /clear must wipe the transcript (clearedNote=%v staleRow=%v)", clearedNote, staleRow)
}
}
func TestLeaveConfirmDisarmedByQueuedPrompt(t *testing.T) {
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
m := loopTestModel(t, now)
m = startFixedLoop(m, "x", time.Minute)
// Arm the /clear confirm.
m.input.SetValue("/clear")
u1, _ := m.Update(testKey(tea.KeyEnter))
n1 := u1.(model)
if n1.loopLeavePrompt != commandClear {
t.Fatal("first /clear should arm the confirm")
}
// A prompt submitted while a run streams is queued via an early return in
// handleSubmit; it must still disarm the confirm (the disarm runs before those
// returns). Otherwise a later /clear would be treated as the second press.
n1.pending = true
n1.input.SetValue("a follow-up question")
u2, _ := n1.Update(testKey(tea.KeyEnter))
n2 := u2.(model)
if n2.loopLeavePrompt != commandEmpty {
t.Fatal("a queued prompt should disarm the leave confirm; the early return must not skip the disarm")
}
}
+156
View File
@@ -0,0 +1,156 @@
package tui
import (
"strings"
"testing"
"time"
)
func TestParseLoopCommandModes(t *testing.T) {
cases := []struct {
in string
action loopAction
mode loopMode
prompt string
interval time.Duration
}{
{"", loopActionUsage, 0, "", 0},
{"list", loopActionList, 0, "", 0},
{"status", loopActionList, 0, "", 0},
{"stop all", loopActionStopAll, 0, "", 0},
{"stop ab12", loopActionStop, 0, "", 0},
{"5m /babysit-prs", loopActionStart, loopModeFixed, "/babysit-prs", 5 * time.Minute},
{"90s check the build", loopActionStart, loopModeFixed, "check the build", 90 * time.Second},
{"watch CI every 2m", loopActionStart, loopModeFixed, "watch CI", 2 * time.Minute},
{"keep improving the docs", loopActionStart, loopModeSelfPaced, "keep improving the docs", 0},
// "every PR" is not a cadence — the token after "every" isn't a duration.
{"review every PR", loopActionStart, loopModeSelfPaced, "review every PR", 0},
}
for _, c := range cases {
got := parseLoopCommand(c.in)
if got.action != c.action {
t.Errorf("%q action = %v, want %v", c.in, got.action, c.action)
}
if c.action == loopActionStart {
if got.mode != c.mode {
t.Errorf("%q mode = %v, want %v", c.in, got.mode, c.mode)
}
if got.prompt != c.prompt {
t.Errorf("%q prompt = %q, want %q", c.in, got.prompt, c.prompt)
}
if got.interval != c.interval {
t.Errorf("%q interval = %v, want %v", c.in, got.interval, c.interval)
}
}
}
}
func TestParseLoopCommandStopTarget(t *testing.T) {
if got := parseLoopCommand("stop"); got.action != loopActionStop || got.targetID != "" {
t.Fatalf("bare stop = %+v, want stop with empty target", got)
}
if got := parseLoopCommand("stop XyZ9"); got.targetID != "XyZ9" {
t.Fatalf("stop target = %q, want XyZ9", got.targetID)
}
}
func TestClampLoopInterval(t *testing.T) {
if d, note := clampLoopInterval(5 * time.Second); d != loopMinInterval || note == "" {
t.Fatalf("below-min = (%v,%q), want raised to min with note", d, note)
}
if d, note := clampLoopInterval(48 * time.Hour); d != loopMaxInterval || note == "" {
t.Fatalf("above-max = (%v,%q), want lowered to max with note", d, note)
}
if d, note := clampLoopInterval(5 * time.Minute); d != 5*time.Minute || note != "" {
t.Fatalf("in-range = (%v,%q), want unchanged, no note", d, note)
}
}
func TestClampSelfPaceDelay(t *testing.T) {
if got := clampSelfPaceDelay(0); got != loopSelfPaceDefault {
t.Errorf("zero delay = %v, want default %v", got, loopSelfPaceDefault)
}
if got := clampSelfPaceDelay(10 * time.Second); got != loopSelfPaceMin {
t.Errorf("below-min = %v, want %v", got, loopSelfPaceMin)
}
if got := clampSelfPaceDelay(3 * time.Hour); got != loopSelfPaceMax {
t.Errorf("above-max = %v, want %v", got, loopSelfPaceMax)
}
if got := clampSelfPaceDelay(10 * time.Minute); got != 10*time.Minute {
t.Errorf("in-band = %v, want unchanged", got)
}
}
func TestFormatLoopDuration(t *testing.T) {
cases := map[time.Duration]string{
30 * time.Second: "30s",
90 * time.Second: "90s",
5 * time.Minute: "5m",
2 * time.Hour: "2h",
24 * time.Hour: "1d",
}
for d, want := range cases {
if got := formatLoopDuration(d); got != want {
t.Errorf("formatLoopDuration(%v) = %q, want %q", d, got, want)
}
}
}
func TestParseLoopControl(t *testing.T) {
cases := []struct {
in string
done bool
wake time.Duration
}{
{"did the work\nLOOP: DONE", true, 0},
{"progress\nLOOP: CONTINUE", false, 0},
{"progress\nLOOP: CONTINUE 10m", false, 10 * time.Minute},
{"progress\n- loop: continue 1h", false, time.Hour}, // markdown bullet, lowercase
{"LOOP: CONTINUE 5m\n...more...\nLOOP: DONE", true, 0}, // last control line wins
{"no control line here", false, 0}, // absent -> adaptive
{"LOOP: DONE — all tests pass", true, 0}, // a trailing note is fine
{"progress\nLOOP: CONTINUE 5m — still tidying", false, 5 * time.Minute}, // note after the interval
{"work done.\r\nLOOP: DONE\r\n", true, 0}, // CRLF endings still parse
{"work\r\nLOOP: CONTINUE 10m\r\n", false, 10 * time.Minute}, // CRLF + interval
{"I will emit LOOP: DONE when finished", false, 0}, // mid-sentence mention doesn't match
{"LOOP: CONTINUE 99z", false, 0}, // bad unit -> continue, no wake hint
}
for _, c := range cases {
done, wake := parseLoopControl(c.in)
if done != c.done || wake != c.wake {
t.Errorf("parseLoopControl(%q) = (%v,%v), want (%v,%v)", c.in, done, wake, c.done, c.wake)
}
}
}
func TestFirePromptWrapsSelfPacedOnly(t *testing.T) {
self := &loopState{mode: loopModeSelfPaced, prompt: "tidy the docs"}
got := self.firePrompt()
if !strings.Contains(got, "LOOP: DONE") || !strings.Contains(got, "tidy the docs") {
t.Errorf("self-paced firePrompt should embed the playbook and goal, got %q", got)
}
fixed := &loopState{mode: loopModeFixed, prompt: "check the build"}
if fixed.firePrompt() != "check the build" {
t.Errorf("fixed-interval firePrompt should be verbatim, got %q", fixed.firePrompt())
}
// A self-paced /command is a real command, not a goal — send it verbatim, no playbook.
cmd := &loopState{mode: loopModeSelfPaced, prompt: "/babysit"}
if cmd.firePrompt() != "/babysit" {
t.Errorf("self-paced /command firePrompt should be verbatim, got %q", cmd.firePrompt())
}
}
func TestLoopStateDue(t *testing.T) {
base := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
l := &loopState{nextRunAt: base}
if l.due(base.Add(-time.Second)) {
t.Error("loop should not be due before nextRunAt")
}
if !l.due(base) {
t.Error("loop should be due at nextRunAt")
}
l.paused = true
if l.due(base.Add(time.Hour)) {
t.Error("paused loop should never be due")
}
}
+141 -16
View File
@@ -29,6 +29,7 @@ import (
"github.com/Gitlawb/zero/internal/providers/providerio"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/sessions"
"github.com/Gitlawb/zero/internal/skills"
"github.com/Gitlawb/zero/internal/streamjson"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/usage"
@@ -61,7 +62,9 @@ const dragEdgeScrollStep = 1
type model struct {
ctx context.Context
cwd string
appVersion string
userCommands []usercommands.Command // file-sourced /commands (.zero/commands)
loadSkills func() []skills.Skill // lazy installed-skills loader for /skills + /<skill-name>
userConfigPath string
doctorUserConfigPath string
projectConfigPath string
@@ -188,10 +191,22 @@ type model struct {
// like a frozen terminal (for ANY provider, not just slow ones). Zero = idle.
turnStartedAt time.Time
queuedMessage string
exiting bool
runCancel context.CancelFunc
runID int
activeRunID int
// loops holds the session's active /loop definitions (see loop.go). activeLoopID
// tags the in-flight run when it is a loop iteration (empty = a user turn), so the
// completion seam knows whether to advance a loop. loopSeq invalidates a stale
// pending poll tick when loops are stopped; loopTicking guards against scheduling
// a second poll ticker. loopLeavePrompt arms a one-shot confirm before /clear or
// /quit while loops are active (see handleSubmit).
loops []*loopState
activeLoopID string
loopSeq int
loopCounter int
loopTicking bool
loopLeavePrompt commandKind
exiting bool
runCancel context.CancelFunc
runID int
activeRunID int
// flushRunIDs holds the ids of runs cancelled while still in flight, mapped
// to the session they were recording into AT CANCEL TIME. Each cancelled
// agent goroutine keeps running to completion and returns its accumulated
@@ -730,11 +745,15 @@ func newModel(ctx context.Context, options Options) model {
notify.MaybeAddWebhookSink(notifier, os.Getenv, nil)
notifier.SetFocused(true)
resolvedKeyBindings, keyBindingWarnings := sanitizeKeyBindings(resolveKeyBindings(options.KeyBindings))
m := model{
ctx: ctx,
cwd: cwd,
appVersion: strings.TrimSpace(options.Version),
swarmDoneAt: map[string]time.Time{},
userCommands: loadedUserCommands,
loadSkills: options.LoadSkills,
composerCursorVisible: true,
userConfigPath: options.UserConfigPath,
doctorUserConfigPath: doctorUserConfigPath,
@@ -766,7 +785,7 @@ func newModel(ctx context.Context, options Options) model {
permissionMode: permissionMode,
reasoningEffort: options.ReasoningEffort,
responseStyle: defaultedResponseStyle(options.ResponseStyle),
keyBindings: resolveKeyBindings(options.KeyBindings),
keyBindings: resolvedKeyBindings,
themeMode: resolveThemeMode(options.Theme, os.Getenv("ZERO_THEME"), options.SavedTheme),
hasDarkBg: true,
userAgent: options.UserAgent,
@@ -802,6 +821,9 @@ func newModel(ctx context.Context, options Options) model {
m.lspManager = lsp.NewManager(cwd)
}
m.refreshMCPViewState()
for _, warning := range keyBindingWarnings {
m = m.appendSystemNotice(warning)
}
return m
}
@@ -1082,6 +1104,10 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
return m.applyProviderWizardOAuth(msg)
case providerWizardDeviceCodeMsg:
return m.applyProviderWizardDeviceCode(msg)
case providerManagerCredsMsg:
return m.applyProviderManagerCreds(msg)
case providerManagerCleanupMsg:
return m.applyProviderManagerCleanup(msg)
case clipboardReadMsg:
// Result of a right-click paste. Insert on success; surface a brief
// status if the clipboard couldn't be read (e.g. no clipboard utility on
@@ -1208,8 +1234,10 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
return m.resolvePermission(permissionDecisionDeny)
}
if m.providerWizard != nil {
m.providerWizard = nil
return m, nil
// Delegate so multi-level surfaces (provider manager list → edit →
// field, manage-key step) can walk BACK one level; the wizard's own
// handler closes the overlay for the single-level steps.
return m.handleProviderWizardKey(msg)
}
if m.mcpAddWizard != nil {
m.mcpAddWizard = nil
@@ -1893,6 +1921,16 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
m.clearComposer()
m.clearSuggestions()
return m, nil
case loopTickMsg:
// Idle poll for due loops. A stale tick (loops changed) or an empty loop set
// ends the ticker; otherwise fire the earliest due loop if idle and reschedule.
if msg.seq != m.loopSeq || len(m.loops) == 0 {
m.loopTicking = false
return m, nil
}
var fireCmd tea.Cmd
m, fireCmd = m.fireDueLoopIfIdle()
return m, tea.Batch(fireCmd, m.scheduleLoopTick())
case agentResponseMsg:
if msg.runID != m.activeRunID {
// A run cancelled while in flight still finishes in its goroutine and
@@ -2062,8 +2100,25 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
// complete once the turn settles.
var sweepCmd tea.Cmd
m, sweepCmd = m.maybeGitSweep()
// If this run was a loop iteration, advance that loop (schedule its next wake
// or stop it). Done before launchQueuedMessageIfReady so a user's queued prompt
// still wins the immediate re-launch; the loop fires on the next idle tick.
var loopTickCmd tea.Cmd
if loopID := m.activeLoopID; loopID != "" {
m.activeLoopID = ""
loopFinalAnswer := ""
for _, row := range msg.rows {
if row.kind == rowAssistant && row.final {
loopFinalAnswer = row.text
}
}
m = m.advanceLoop(loopID, loopFinalAnswer, msg.err)
// advanceLoop -> removeLoop may have stopped the ticker; restart it if
// other loops remain.
m, loopTickCmd = m.ensureLoopTick()
}
next, queuedCmd := m.launchQueuedMessageIfReady()
return next, tea.Batch(titleCmd, recapCmd, sweepCmd, queuedCmd)
return next, tea.Batch(titleCmd, recapCmd, sweepCmd, queuedCmd, loopTickCmd)
case sessionTitleGeneratedMsg:
return m.handleSessionTitleGenerated(msg)
case recapGeneratedMsg:
@@ -3713,7 +3768,7 @@ func (m model) choosePicker() (tea.Model, tea.Cmd) {
text := ""
if owner := strings.TrimSpace(item.OwnerProvider); owner != "" && !strings.EqualFold(owner, strings.TrimSpace(m.providerName)) {
// A model from another saved provider: switch provider + model together.
m, text, cmd = m.switchProviderModel(owner, item.Value)
m, text, _, cmd = m.switchProviderModel(owner, item.Value)
} else {
m, text = m.handleModelCommand(item.Value)
}
@@ -3730,6 +3785,11 @@ func (m model) choosePicker() (tea.Model, tea.Cmd) {
if text != "" {
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: text})
}
case pickerSkill:
// Fill the composer with "/name " so the user adds their request before
// submitting (a bare second Enter runs it without one); names the slash
// path cannot reach run immediately instead.
m, cmd = m.chooseSkillFromPicker(item)
case pickerTheme:
// The hovered palette is already live from the preview; handleThemeCommand
// records the choice (m.themeMode) and re-applies it, and reports the switch.
@@ -3781,6 +3841,13 @@ func (m model) handleSubmit() (tea.Model, tea.Cmd) {
return m, nil
}
command := parseCommand(input)
// A pending /clear or /quit leave-confirmation is armed for the immediately-next
// repeat of that same command only; any other submission — including one queued
// or deferred by the early returns below — disarms it. Runs before those returns
// so an interposed prompt can't be skipped over and leave the gate falsely armed.
if m.loopLeavePrompt != commandEmpty && command.kind != m.loopLeavePrompt {
m.loopLeavePrompt = commandEmpty
}
// While exiting (Ctrl+C waiting on the cancelled run's checkpoint flush) a
// new run must not start: the deferred tea.Quit would abort it mid-flight
// and orphan its checkpoint blobs — the exact loss flushRunIDs prevents.
@@ -3814,6 +3881,14 @@ func (m model) handleSubmit() (tea.Model, tea.Cmd) {
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: helpText()})
return m, nil
case commandClear:
// A foreground loop keeps firing after /clear (it wipes the screen, not the
// session), so warn once before clearing the context it will run into.
if m.loopActive() && m.loopLeavePrompt != commandClear {
m.loopLeavePrompt = commandClear
m = m.appendLoopSystem(m.loopFooterSummary() + " still running — /clear keeps them firing behind a cleared screen. Run /clear again to confirm, or /loop stop all first.")
return m, nil
}
m.loopLeavePrompt = commandEmpty
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionClear})
// Clearing wipes the visible transcript only — the session's context is
// intact, so the next prompt still replays the full history. Say so, and
@@ -3822,6 +3897,11 @@ func (m model) handleSubmit() (tea.Model, tea.Cmd) {
// Scrollback above can't be un-printed; a faint divider marks where the
// cleared surface ended and the frontier restarts for the fresh transcript.
m.resetFlushFrontier("· cleared ·")
// /clear wipes the transcript but not the session, so loops keep running;
// say so rather than let them silently fire into a "cleared" screen.
if m.loopActive() {
m = m.appendLoopSystem(m.loopFooterSummary() + " still running — /loop stop all to end them.")
}
return m, nil
case commandNew:
// A fresh session mid-run would strand the in-flight turn's events; make the
@@ -3832,7 +3912,17 @@ func (m model) handleSubmit() (tea.Model, tea.Cmd) {
return m, nil
}
return m.startNewSession(), nil
case commandLoop:
return m.handleLoopCommand(command.text)
case commandExit:
// Closing the session stops its foreground loops mid-task; warn once so a
// token-spending loop isn't ended by reflex.
if m.loopActive() && m.loopLeavePrompt != commandExit {
m.loopLeavePrompt = commandExit
m = m.appendLoopSystem(m.loopFooterSummary() + " active — closing the session stops them. Run /exit again to confirm, or /loop stop all first.")
return m, nil
}
m.loopLeavePrompt = commandEmpty
// /exit gets the same protection as Ctrl+C: cancel any in-flight run and
// defer the quit until its checkpoint session events flush — quitting
// immediately would orphan the blobs and break /rewind.
@@ -3845,6 +3935,15 @@ func (m model) handleSubmit() (tea.Model, tea.Cmd) {
case commandTools:
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: m.toolsText()})
return m, nil
case commandSkills:
// With skills installed, /skills opens a searchable picker (like /model);
// the text card remains only as the no-skills install hint.
if picker := m.newSkillPicker(); picker != nil {
m.picker = picker
return m, nil
}
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: m.skillsText()})
return m, nil
case commandMCP:
if strings.TrimSpace(command.text) == "" {
return m.openMCPManager(), nil
@@ -3862,14 +3961,20 @@ func (m model) handleSubmit() (tea.Model, tea.Cmd) {
case commandSandboxSetup:
return m.startSandboxSetupCommand(command.text)
case commandProvider:
if strings.TrimSpace(command.text) == "" {
arg := strings.ToLower(strings.TrimSpace(command.text))
if arg == "" || arg == "add" {
if m.pending {
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: pickerBusyText(command.name)})
return m, nil
}
m.providerWizard = m.newProviderWizard()
m.clearSuggestions()
return m, nil
// Bare /provider opens the list-first manager over the saved
// providers; /provider add jumps straight into the add wizard.
if arg == "add" {
m.providerWizard = m.newProviderWizard()
m.clearSuggestions()
return m, nil
}
return m.openProviderManager()
}
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: m.providerText()})
return m, nil
@@ -4040,6 +4145,11 @@ func (m model) handleSubmit() (tea.Model, tea.Cmd) {
if next, cmd, handled := m.handleUserCommand(command.text); handled {
return next, cmd
}
// Then an installed skill: "/<skill-name> [args]" runs the skill directly
// (deterministic invocation, vs waiting for the model to pull it in).
if next, cmd, handled := m.handleSkillCommand(command.text); handled {
return next, cmd
}
m.transcript = reduceTranscript(m.transcript, transcriptAction{
kind: actionAppendError,
text: "unknown command: " + command.text,
@@ -4311,6 +4421,21 @@ func (m *model) cancelRun() {
m.runCancel()
}
m.clearStreamingToolCall() // a cancelled file-write must not linger into the next run
// A cancelled loop iteration bypasses the agentResponseMsg completion seam (its
// late message is drained through flushRunIDs, not advanceLoop), so clear the
// loop tag here and re-arm the interrupted loop for its next cadence. Otherwise
// the loop is left "running" forever (nextRunAt stays zero) and the NEXT
// unrelated turn would be misattributed as this loop's completion.
if m.activeLoopID != "" {
if l := m.findLoop(m.activeLoopID); l != nil {
if l.mode == loopModeSelfPaced {
l.nextRunAt = m.now().Add(clampSelfPaceDelay(adaptiveSelfPaceDelay(l.iteration)))
} else {
l.nextRunAt = m.now().Add(l.interval)
}
}
m.activeLoopID = ""
}
// Remember the in-flight run — and the session it was recording into — so
// its final agentResponseMsg is still drained for session-event persistence
// after activeRunID is cleared. Otherwise the checkpoint blobs it captured
@@ -4446,9 +4571,9 @@ func (m model) runAgentWithOptions(runID int, runCtx context.Context, prompt str
IncludeLSP: true,
Autonomy: selfCorrectAutonomyForMode(options.PermissionMode),
})
// Inline post-edit diagnostics: edit_file/write_file append error
// diagnostics for the file they just wrote to their own output, so the
// model sees a break in the same turn. Shares the run's lazy manager.
// Background post-edit diagnostics: the loop checks files changed by
// edit_file/write_file off the tool-call path and nudges the model with
// any errors before its next request. Shares the run's lazy manager.
options.FileDiagnostics = agent.NewFileDiagnostics(lspManager, options.Cwd)
}
+7
View File
@@ -586,6 +586,13 @@ func (m model) applySetupOAuth(msg setupOAuthMsg) (tea.Model, tea.Cmd) {
if msg.apiKey != "" {
m.setup.apiKey.SetValue(msg.apiKey)
}
if msg.tokenLogin {
// Early persist on the Update goroutine (see persistOAuthLoginProvider's
// threading contract) so quitting setup after the login doesn't lose it;
// completeSetup persists the full profile with the chosen model anyway,
// so a failure here is recoverable and not fatal to setup.
_ = persistOAuthLoginProvider(m.setup.configPath, msg.providerID)
}
m.setup.oauthErr = ""
m.setup.err = ""
m.setup.oauthDevice = false
+9 -1
View File
@@ -13,6 +13,7 @@ import (
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/sessions"
"github.com/Gitlawb/zero/internal/skills"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/usage"
"github.com/Gitlawb/zero/internal/zeroruntime"
@@ -21,6 +22,7 @@ import (
// Options configures the reusable Zero terminal UI shell.
type Options struct {
Cwd string
Version string // CLI build version, shown on the home screen; empty hides it
UserConfigPath string
DoctorUserConfigPath string
ProjectConfigPath string
@@ -48,7 +50,13 @@ type Options struct {
SessionCompactor SessionCompactor
PrService *PrService
AgentOptions agent.Options
AgentOptions agent.Options
// LoadSkills returns the installed skills (default skills dir merged with any
// plugin skill roots), bodies included, for /skills and direct /<skill-name>
// invocation. Called lazily per use so newly installed skills are picked up
// without a restart. Nil means the session has no skills wiring (skills stay
// model-pulled via the skill tool only).
LoadSkills func() []skills.Skill
PermissionMode agent.PermissionMode
ReasoningEffort modelregistry.ReasoningEffort
ResponseStyle string
+31 -1
View File
@@ -24,6 +24,7 @@ const (
pickerEffort
pickerSession
pickerTheme
pickerSkill
)
// pickerItem is one selectable row: Label is shown, Value is passed to the
@@ -225,6 +226,35 @@ func (m model) newModelPicker() *commandPicker {
return &commandPicker{kind: pickerModel, title: "Choose a model", items: items, allItems: append([]pickerItem{}, items...), selected: 0}
}
// newSkillPicker lists the installed skills for browse-and-run: type to filter,
// arrow keys to select, Enter runs the chosen skill immediately. Selection is by
// the skill's EXACT name (item.Value), so even skills the slash path cannot
// reach — names shadowed by a builtin or user command, or names that don't fit
// a slash token — are runnable here: picking from the Skills picker is
// unambiguous. Returns nil when no skills are installed (the caller falls back
// to the install-hint card).
func (m model) newSkillPicker() *commandPicker {
installed := m.installedSkills()
items := make([]pickerItem, 0, len(installed))
for _, skill := range installed {
name := strings.TrimSpace(skill.Name)
if name == "" {
continue
}
label := name
if slash := skillSlashName(name); slash != "" {
// Show the slash form where one exists — it doubles as documentation
// of the direct "/name [args]" invocation.
label = "/" + slash
}
items = append(items, pickerItem{Label: label, Value: name, Meta: strings.TrimSpace(skill.Description)})
}
if len(items) == 0 {
return nil
}
return &commandPicker{kind: pickerSkill, title: "pick a skill · ⏎ fills /name — add your request", items: items, allItems: append([]pickerItem{}, items...), selected: 0}
}
// modelPickerProviders returns the providers to list in /model: all saved
// providers, falling back to the active profile when none were threaded in.
func (m model) modelPickerProviders() []config.ProviderProfile {
@@ -358,7 +388,7 @@ func (m model) modelPickerDiscoveryCmds() tea.Cmd {
// OAuth bearer for token-login providers (e.g. xAI).
func (m model) modelPickerProviderDiscoveryCmd(descriptor providercatalog.Descriptor, profile config.ProviderProfile) tea.Cmd {
authed := profile
if store, err := config.ProviderKeyStore(); err == nil {
if store, err := m.providerKeyStore(); err == nil {
authed = config.ApplyStoredAPIKey(authed, store)
}
key := strings.TrimSpace(authed.APIKey)
+77 -3
View File
@@ -14,6 +14,7 @@ import (
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/modelregistry"
"github.com/Gitlawb/zero/internal/oauth"
"github.com/Gitlawb/zero/internal/providercatalog"
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
"github.com/Gitlawb/zero/internal/zeroruntime"
@@ -965,9 +966,9 @@ func TestSwitchProviderModelWarmsDiscoveryForTheNewProvider(t *testing.T) {
},
})
next, text, cmd := m.switchProviderModel("ollama", "kimi-k2.7-code:cloud")
if !strings.Contains(text, "Switched to ollama") {
t.Fatalf("switch notice = %q, want it to confirm the switch", text)
next, text, ok, cmd := m.switchProviderModel("ollama", "kimi-k2.7-code:cloud")
if !ok || !strings.Contains(text, "Switched to ollama") {
t.Fatalf("switch notice = %q (ok=%v), want a committed switch", text, ok)
}
if next.modelName != "kimi-k2.7-code:cloud" || next.providerName != "ollama" {
t.Fatalf("model/provider not switched: modelName=%q providerName=%q", next.modelName, next.providerName)
@@ -1022,3 +1023,76 @@ func TestNormalizeProfileForProviderCanonicalizesPlaceholderName(t *testing.T) {
}
}
}
// TestSwitchProviderModelUsesOAuthLoginWithoutInliningBearer: switching to a
// token-login provider (ChatGPT) must pass the credential gate on the stored
// OAuth login and hand newProvider a KEYLESS profile. Inlining the bearer as
// APIKey makes HasConfiguredCredential true, which strips the OAuth login
// candidates at build time — the client is then constructed without the bearer
// resolver and login key, dropping token refresh and the Codex
// `chatgpt-account-id` header, so every call 401s.
func TestSwitchProviderModelUsesOAuthLoginWithoutInliningBearer(t *testing.T) {
tokensPath := filepath.Join(t.TempDir(), "oauth-tokens.json")
t.Setenv("ZERO_OAUTH_TOKENS_PATH", tokensPath)
store, err := oauth.NewStore(oauth.StoreOptions{})
if err != nil {
t.Fatalf("oauth store: %v", err)
}
if err := store.Save(oauth.ProviderKey("chatgpt"), oauth.Token{AccessToken: "bearer-123", TokenType: "Bearer"}); err != nil {
t.Fatalf("save token: %v", err)
}
var built config.ProviderProfile
m := newModel(context.Background(), Options{
ProviderName: "opengateway",
ModelName: "some-model",
Provider: &fakeProvider{},
ProviderProfile: config.ProviderProfile{Name: "opengateway", ProviderKind: config.ProviderKindOpenAICompatible, BaseURL: "https://gateway.example.com/v1", Model: "some-model"},
SavedProviders: []config.ProviderProfile{
{Name: "opengateway", ProviderKind: config.ProviderKindOpenAICompatible, BaseURL: "https://gateway.example.com/v1", Model: "some-model"},
{Name: "chatgpt", CatalogID: "chatgpt", ProviderKind: config.ProviderKindOpenAICompatible, BaseURL: "https://chatgpt.com/backend-api/codex", Model: "gpt-5.5"},
},
NewProvider: func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
built = profile
return &fakeProvider{}, nil
},
})
next, text, ok, _ := m.switchProviderModel("chatgpt", "gpt-5.5")
if !ok || !strings.Contains(text, "Switched to chatgpt") {
t.Fatalf("switch should succeed on the stored OAuth login, got %q (ok=%v)", text, ok)
}
if next.providerName != "chatgpt" || next.modelName != "gpt-5.5" {
t.Fatalf("switch did not commit: provider=%q model=%q", next.providerName, next.modelName)
}
if built.APIKey != "" {
t.Fatalf("OAuth bearer must not be inlined as APIKey (suppresses the runtime resolver), got %q", built.APIKey)
}
}
// TestSwitchProviderModelStillRejectsProviderWithNoCredential: with no key, no
// header, no local runtime, and no OAuth login, the gate must keep refusing.
func TestSwitchProviderModelStillRejectsProviderWithNoCredential(t *testing.T) {
tokensPath := filepath.Join(t.TempDir(), "oauth-tokens.json")
t.Setenv("ZERO_OAUTH_TOKENS_PATH", tokensPath)
m := newModel(context.Background(), Options{
ProviderName: "opengateway",
ModelName: "some-model",
Provider: &fakeProvider{},
ProviderProfile: config.ProviderProfile{Name: "opengateway", ProviderKind: config.ProviderKindOpenAICompatible, BaseURL: "https://gateway.example.com/v1", Model: "some-model"},
SavedProviders: []config.ProviderProfile{
{Name: "opengateway", ProviderKind: config.ProviderKindOpenAICompatible, BaseURL: "https://gateway.example.com/v1", Model: "some-model"},
{Name: "chatgpt", CatalogID: "chatgpt", ProviderKind: config.ProviderKindOpenAICompatible, BaseURL: "https://chatgpt.com/backend-api/codex", Model: "gpt-5.5"},
},
NewProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
t.Fatal("newProvider must not run for a credential-less provider")
return nil, nil
},
})
_, text, ok, _ := m.switchProviderModel("chatgpt", "gpt-5.5")
if ok || !strings.Contains(text, "no usable credential") {
t.Fatalf("expected the credential gate to refuse, got %q (ok=%v)", text, ok)
}
}
+828
View File
@@ -0,0 +1,828 @@
// Provider manager: the list-first /provider surface. It shares
// providerWizardState (steps providerWizardStepManage / EditMenu / EditValue)
// so the existing overlay gating, key routing, and mouse plumbing that check
// m.providerWizard cover it without touching those call sites.
//
// UX contract: the list IS the home screen — Enter activates the selected
// provider, `a` opens the add wizard, `e` opens a field-level editor, `d`
// asks inline and deletes. Esc walks back one level and closes from the list.
package tui
import (
"os"
"strings"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/oauth"
)
const providerManagerMaxVisible = 10
// providerManagerRow is one saved provider in the list. cred is resolved
// asynchronously (keychain reads shell out to `security` on macOS and must
// never block the render loop); empty means "still checking".
type providerManagerRow struct {
profile config.ProviderProfile
local bool
cred string
}
type providerEditField int
const (
providerEditFieldName providerEditField = iota
providerEditFieldEndpoint
providerEditFieldModel
providerEditFieldAPIKey
providerEditFieldDescription
providerEditFieldSave
)
var providerEditFields = []struct {
field providerEditField
label string
hint string
}{
{providerEditFieldName, "Name", "Renames the profile; a stored key and the active pointer follow automatically."},
{providerEditFieldEndpoint, "Endpoint", "Base URL for the provider's API."},
{providerEditFieldModel, "Model", "Default model for this provider."},
{providerEditFieldAPIKey, "API key", "Enter a new key to replace the stored one; leave empty to keep it."},
{providerEditFieldDescription, "Description", "Optional label shown in the provider list."},
{providerEditFieldSave, "Save", "Persist the changes."},
}
// providerManagerCredsMsg delivers the asynchronously resolved credential
// states for the manager rows. gen guards against a stale probe finishing
// after the manager was reopened with fresh rows.
type providerManagerCredsMsg struct {
gen int
creds map[string]string
}
// openProviderManager opens /provider on the list of saved providers. With
// nothing saved yet there is nothing to manage — fall through to the add
// wizard, which is also the first-run behavior users already know.
func (m model) openProviderManager() (model, tea.Cmd) {
if len(m.savedProviders) == 0 {
m.providerWizard = m.newProviderWizard()
m.clearSuggestions()
return m, nil
}
wizard := &providerWizardState{
step: providerWizardStepManage,
manage: true,
providers: providerWizardProviders(),
}
wizard.refreshModels()
m.providerWizard = wizard
m.clearSuggestions()
return m.reloadProviderManagerRows()
}
// reloadProviderManagerRows rebuilds the list from savedProviders and kicks off
// the async credential probe for the new generation.
func (m model) reloadProviderManagerRows() (model, tea.Cmd) {
if m.providerWizard == nil {
return m, nil
}
// Carry resolved credential states forward for names still present, so a
// single-row mutation doesn't flash every other row back to "checking…"
// while the async probe re-runs.
previous := make(map[string]string, len(m.providerWizard.manageRows))
for _, row := range m.providerWizard.manageRows {
previous[row.profile.Name] = row.cred
}
rows := make([]providerManagerRow, 0, len(m.savedProviders))
for _, profile := range m.savedProviders {
row := providerManagerRow{profile: profile, cred: previous[profile.Name]}
if descriptor, ok := m.descriptorForProfile(profile); ok {
row.local = descriptor.Local
}
rows = append(rows, row)
}
m.providerWizard.manageRows = rows
m.providerWizard.manageCursor = clampInt(m.providerWizard.manageCursor, 0, maxInt(0, len(rows)-1))
// The session's live provider is the truth the user cares about (config's
// activeProvider follows it on every switch).
m.providerWizard.manageActiveName = m.providerName
m.providerWizard.manageCredGen++
return m, providerManagerCredsCmd(m.providerWizard.manageCredGen, rows, m.userConfigPath)
}
// providerManagerCredsCmd resolves each row's credential state off the UI
// goroutine: encrypted-store reads (keychain subprocess on macOS), env lookups,
// and the OAuth token store. Keys are read from the store BESIDE configPath —
// the one the manager's write paths use.
func providerManagerCredsCmd(gen int, rows []providerManagerRow, configPath string) tea.Cmd {
profiles := make([]config.ProviderProfile, len(rows))
locals := make([]bool, len(rows))
for i, row := range rows {
profiles[i] = row.profile
locals[i] = row.local
}
return func() tea.Msg {
store, storeErr := providerKeyStoreForPath(configPath)
var getter config.APIKeyGetter
if storeErr == nil {
getter = store
}
// One token-store snapshot answers every row's OAuth question — a fresh
// store open + full file read per row (×2 candidates) answered the same
// question N times over.
logins := storedOAuthLogins()
creds := make(map[string]string, len(profiles))
for i, profile := range profiles {
creds[profile.Name] = providerManagerCredState(profile, locals[i], getter, logins)
}
return providerManagerCredsMsg{gen: gen, creds: creds}
}
}
// providerManagerCredState classifies how a profile authenticates, surfacing
// broken states ("stored key missing") instead of hiding them — that exact
// state is how a lost keychain entry shows up. A stale APIKeyStored marker
// falls THROUGH to the env/OAuth checks first, matching the runtime's own
// fallback order (profileWithCredential): a provider that will switch fine via
// its env var must not render as broken.
func providerManagerCredState(profile config.ProviderProfile, local bool, store config.APIKeyGetter, logins map[string]bool) string {
if strings.TrimSpace(profile.APIKey) != "" || strings.TrimSpace(profile.AuthHeaderValue) != "" {
return "key set"
}
if profile.APIKeyStored && store != nil {
if key, ok, err := store.Get(profile.Name); err == nil && ok && strings.TrimSpace(key) != "" {
return "key stored"
}
}
if env := strings.TrimSpace(profile.APIKeyEnv); env != "" && strings.TrimSpace(os.Getenv(env)) != "" {
return "env " + env
}
// OAuthLoginCandidates is nil for key-authed profiles, so probe a marker-free
// copy: a stale marker must not hide a usable login.
keyless := profile
keyless.APIKeyStored = false
for _, candidate := range keyless.OAuthLoginCandidates() {
// Store keys are normalized to lower case (oauth.ProviderKey).
if logins[strings.ToLower(strings.TrimSpace(candidate))] {
return "oauth login"
}
}
if local {
return "local"
}
if profile.APIKeyStored {
return "stored key missing"
}
return "no credential"
}
// storedOAuthLogins snapshots which provider logins exist in the token store —
// the same one-read pattern as cli's oauthLoggedInProviders. Errors degrade to
// an empty set.
func storedOAuthLogins() map[string]bool {
logins := map[string]bool{}
store, err := oauth.NewStore(oauth.StoreOptions{})
if err != nil {
return logins
}
statuses, err := store.Status(oauth.KeyPrefixProvider)
if err != nil {
return logins
}
for _, status := range statuses {
if status.HasToken {
logins[strings.ToLower(strings.TrimPrefix(status.Key, oauth.KeyPrefixProvider))] = true
}
}
return logins
}
func (m model) applyProviderManagerCreds(msg providerManagerCredsMsg) (model, tea.Cmd) {
if m.providerWizard == nil || !m.providerWizard.manage || msg.gen != m.providerWizard.manageCredGen {
return m, nil
}
for i := range m.providerWizard.manageRows {
if cred, ok := msg.creds[m.providerWizard.manageRows[i].profile.Name]; ok {
m.providerWizard.manageRows[i].cred = cred
}
}
return m, nil
}
// providerWizardManagerStep reports whether the wizard is on one of the
// manager-owned steps (list / edit menu / edit value).
func (wizard *providerWizardState) managerStep() bool {
if wizard == nil {
return false
}
switch wizard.step {
case providerWizardStepManage, providerWizardStepEditMenu, providerWizardStepEditValue:
return true
}
return false
}
func (wizard *providerWizardState) currentManagerRow() (providerManagerRow, bool) {
if wizard == nil || len(wizard.manageRows) == 0 {
return providerManagerRow{}, false
}
wizard.manageCursor = clampInt(wizard.manageCursor, 0, len(wizard.manageRows)-1)
return wizard.manageRows[wizard.manageCursor], true
}
func (m model) handleProviderManagerKey(msg tea.KeyMsg) (model, tea.Cmd) {
wizard := m.providerWizard
switch wizard.step {
case providerWizardStepManage:
return m.handleProviderManageListKey(msg)
case providerWizardStepEditMenu:
return m.handleProviderEditMenuKey(msg)
case providerWizardStepEditValue:
return m.handleProviderEditValueKey(msg)
}
return m, nil
}
func (m model) handleProviderManageListKey(msg tea.KeyMsg) (model, tea.Cmd) {
wizard := m.providerWizard
if wizard.manageDeleting {
switch {
case keyIs(msg, tea.KeyEnter) || strings.EqualFold(keyText(msg), "y"):
return m.deleteManagerSelection()
case keyIs(msg, tea.KeyEsc) || strings.EqualFold(keyText(msg), "n"):
wizard.manageDeleting = false
}
return m, nil
}
switch {
case keyIs(msg, tea.KeyEsc):
m.providerWizard = nil
return m, nil
case keyIs(msg, tea.KeyUp):
m.moveProviderManager(-1)
return m, nil
case keyIs(msg, tea.KeyDown) || keyIs(msg, tea.KeyTab):
m.moveProviderManager(1)
return m, nil
case keyIs(msg, tea.KeyEnter):
return m.activateManagerSelection()
case strings.EqualFold(keyText(msg), "a"):
// Add: hand over to the normal wizard flow; Esc from its first step
// returns here (fromManage-style handling in handleProviderWizardKey).
wizard.step = providerWizardStepMethod
wizard.err = ""
wizard.manageStatus = ""
return m, nil
case strings.EqualFold(keyText(msg), "e"):
if row, ok := wizard.currentManagerRow(); ok {
wizard.beginProviderEdit(row.profile)
}
return m, nil
case strings.EqualFold(keyText(msg), "d"):
if _, ok := wizard.currentManagerRow(); ok {
wizard.manageDeleting = true
wizard.manageStatus = ""
}
return m, nil
}
return m, nil
}
func (m *model) moveProviderManager(delta int) {
wizard := m.providerWizard
count := len(wizard.manageRows)
if count == 0 {
wizard.manageCursor = 0
return
}
wizard.manageCursor = ((wizard.manageCursor+delta)%count + count) % count
wizard.manageStatus = ""
}
// activateManagerSelection makes the selected provider active via the shared
// switch path (persists activeProvider+model, rebuilds the client OAuth-aware,
// exports ZERO_PROVIDER, warms discovery). On success the manager closes and
// the switch notice lands in the transcript; a refusal (busy run, missing
// credential) stays inline so the user keeps their place in the list.
func (m model) activateManagerSelection() (model, tea.Cmd) {
wizard := m.providerWizard
row, ok := wizard.currentManagerRow()
if !ok {
return m, nil
}
if m.pending {
wizard.manageStatus = "Cannot switch providers while a run is active."
return m, nil
}
next, text, switched, cmd := m.switchProviderModel(row.profile.Name, row.profile.Model)
if switched {
next.providerWizard = nil
next.transcript = reduceTranscript(next.transcript, transcriptAction{kind: actionAppendSystem, text: text})
return next, cmd
}
if next.providerWizard != nil {
next.providerWizard.manageStatus = strings.TrimPrefix(text, "Model\n")
}
return next, cmd
}
// deleteManagerSelection removes the confirmed provider: the config write runs
// synchronously (the list must reflect the config the instant the confirm
// resolves), while the stored-key delete and the OAuth-login lookup — a
// keychain subprocess and a token-store read — run in a follow-up tea.Cmd so
// the confirm keypress never stalls the render loop. The OAuth token is
// deliberately kept — logins outlive profiles so re-adding the provider
// doesn't force a browser round-trip; zero auth logout removes it.
func (m model) deleteManagerSelection() (model, tea.Cmd) {
wizard := m.providerWizard
wizard.manageDeleting = false
row, ok := wizard.currentManagerRow()
if !ok {
return m, nil
}
name := row.profile.Name
if strings.TrimSpace(m.userConfigPath) == "" {
wizard.manageStatus = "No user config path — cannot delete."
return m, nil
}
cfg, err := config.RemoveProvider(m.userConfigPath, name)
if err != nil {
wizard.manageStatus = "Delete failed: " + err.Error()
return m, nil
}
// 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+".")
}
cleanup := providerManagerCleanupCmd(m.userConfigPath, row.profile)
if len(m.savedProviders) == 0 {
m.providerWizard = nil
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: "Provider\n" + strings.Join(notes, " ")})
return m, cleanup
}
next, cmd := m.reloadProviderManagerRows()
next.providerWizard.manageStatus = strings.Join(notes, " ")
return next, tea.Batch(cmd, cleanup)
}
// removeSavedProvider drops one profile from the in-memory saved list.
func removeSavedProvider(saved []config.ProviderProfile, name string) []config.ProviderProfile {
kept := saved[:0]
for _, profile := range saved {
if strings.EqualFold(strings.TrimSpace(profile.Name), strings.TrimSpace(name)) {
continue
}
kept = append(kept, profile)
}
return kept
}
// providerManagerCleanupMsg reports the off-thread half of a delete: the
// stored-key removal outcome and the OAuth-login hint.
type providerManagerCleanupMsg struct {
notes []string
}
// providerManagerCleanupCmd finishes a delete off the UI goroutine: the
// keychain delete shells out to `security` on macOS and the OAuth-login lookup
// reads the token store — blocking work the confirm keypress must not wait on.
// A failed key delete is surfaced rather than letting a lingering secret read
// as a clean removal.
func providerManagerCleanupCmd(configPath string, profile config.ProviderProfile) tea.Cmd {
name := profile.Name
catalogID := profile.CatalogID
return func() tea.Msg {
notes := []string{}
keyStore, storeErr := providerKeyStoreForPath(configPath)
if storeErr == nil {
_, storeErr = keyStore.Delete(name)
}
if storeErr != nil {
notes = append(notes, "Warning: its stored API key could not be deleted ("+storeErr.Error()+").")
}
if login, ok := oauthLoginName(config.ProviderProfile{Name: name, CatalogID: catalogID}); ok {
notes = append(notes, "OAuth login kept — remove with `zero auth logout "+login+"`.")
}
return providerManagerCleanupMsg{notes: notes}
}
}
func (m model) applyProviderManagerCleanup(msg providerManagerCleanupMsg) (model, tea.Cmd) {
if len(msg.notes) == 0 {
return m, nil
}
extra := strings.Join(msg.notes, " ")
if m.providerWizard != nil && m.providerWizard.manage {
if m.providerWizard.manageStatus != "" {
m.providerWizard.manageStatus += " " + extra
} else {
m.providerWizard.manageStatus = extra
}
return m, nil
}
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: "Provider\n" + extra})
return m, nil
}
// --- edit -------------------------------------------------------------------
func (wizard *providerWizardState) beginProviderEdit(profile config.ProviderProfile) {
wizard.editOriginal = profile
wizard.editDraft = profile
wizard.editDraft.APIKey = "" // key field is enter-to-replace, never prefilled
wizard.editCursor = 0
wizard.err = ""
wizard.manageStatus = ""
wizard.step = providerWizardStepEditMenu
}
func (wizard *providerWizardState) editFieldValue(field providerEditField, draft config.ProviderProfile) string {
switch field {
case providerEditFieldName:
return draft.Name
case providerEditFieldEndpoint:
return draft.BaseURL
case providerEditFieldModel:
return draft.Model
case providerEditFieldAPIKey:
return draft.APIKey
case providerEditFieldDescription:
return draft.Description
}
return ""
}
func (m model) handleProviderEditMenuKey(msg tea.KeyMsg) (model, tea.Cmd) {
wizard := m.providerWizard
switch {
case keyIs(msg, tea.KeyEsc) || keyIs(msg, tea.KeyLeft):
wizard.step = providerWizardStepManage
wizard.err = ""
return m, nil
case keyIs(msg, tea.KeyUp):
wizard.editCursor = ((wizard.editCursor-1)%len(providerEditFields) + len(providerEditFields)) % len(providerEditFields)
return m, nil
case keyIs(msg, tea.KeyDown) || keyIs(msg, tea.KeyTab):
wizard.editCursor = (wizard.editCursor + 1) % len(providerEditFields)
return m, nil
case keyIs(msg, tea.KeyEnter):
entry := providerEditFields[clampInt(wizard.editCursor, 0, len(providerEditFields)-1)]
if entry.field == providerEditFieldSave {
return m.saveManagerEdit()
}
wizard.editField = entry.field
wizard.editBuffer = wizard.editFieldValue(entry.field, wizard.editDraft)
wizard.err = ""
wizard.step = providerWizardStepEditValue
return m, nil
}
return m, nil
}
func (m model) handleProviderEditValueKey(msg tea.KeyMsg) (model, tea.Cmd) {
wizard := m.providerWizard
switch {
case keyIs(msg, tea.KeyEsc):
wizard.step = providerWizardStepEditMenu
wizard.err = ""
return m, nil
case keyIs(msg, tea.KeyEnter):
if err := wizard.commitEditBuffer(); err != "" {
wizard.err = err
return m, nil
}
wizard.err = ""
wizard.step = providerWizardStepEditMenu
return m, nil
case keyCtrl(msg, 'u'):
wizard.editBuffer = ""
wizard.err = ""
return m, nil
case keyBackspace(msg):
if wizard.editBuffer != "" {
runes := []rune(wizard.editBuffer)
wizard.editBuffer = string(runes[:len(runes)-1])
}
wizard.err = ""
return m, nil
case keyText(msg) != "":
for _, r := range keyRunes(msg) {
if r == '\n' || r == '\r' || r == '\t' {
continue
}
wizard.editBuffer += string(r)
}
wizard.err = ""
return m, nil
}
return m, nil
}
// commitEditBuffer validates and folds the edited value into the draft.
// Returns a non-empty error string to keep the user on the field.
func (wizard *providerWizardState) commitEditBuffer() string {
value := strings.TrimSpace(wizard.editBuffer)
switch wizard.editField {
case providerEditFieldName:
if value == "" {
return "name cannot be empty"
}
wizard.editDraft.Name = value
case providerEditFieldEndpoint:
if err := providerWizardEndpointError(value); err != "" {
return err
}
wizard.editDraft.BaseURL = value
case providerEditFieldModel:
if value == "" {
return "model cannot be empty"
}
wizard.editDraft.Model = value
case providerEditFieldAPIKey:
wizard.editDraft.APIKey = strings.TrimSpace(wizard.editBuffer)
case providerEditFieldDescription:
wizard.editDraft.Description = value
}
return ""
}
// saveManagerEdit persists the draft in ONE atomic config write
// (config.EditProvider handles rename — case-only included — field merge,
// verbatim description, the stored-key marker, and key migration together, so
// a failure leaves nothing half-applied). A freshly entered key is captured
// into the encrypted store under the CURRENT name first (EditProvider's rename
// migration then moves it), so config.json never holds it in cleartext. The
// live session follows a rename so ZERO_PROVIDER and the status line never
// point at a name that no longer exists.
func (m model) saveManagerEdit() (model, tea.Cmd) {
wizard := m.providerWizard
if strings.TrimSpace(m.userConfigPath) == "" {
wizard.err = "no user config path — cannot save"
return m, nil
}
oldName := strings.TrimSpace(wizard.editOriginal.Name)
newName := strings.TrimSpace(wizard.editDraft.Name)
if newName == "" {
wizard.err = "name cannot be empty"
return m, nil
}
edit := config.ProviderEdit{
Name: oldName,
NewName: newName,
BaseURL: strings.TrimSpace(wizard.editDraft.BaseURL),
Model: strings.TrimSpace(wizard.editDraft.Model),
Description: wizard.editDraft.Description,
}
if key := strings.TrimSpace(wizard.editDraft.APIKey); key != "" {
captured := config.SecureProviderProfile(config.ProviderProfile{Name: oldName, APIKey: key}, m.userConfigPath)
// On a store failure SecureProviderProfile keeps the inline key, which
// EditProvider then persists (the startup migration re-captures later) —
// the same fail-soft posture as every other capture path.
edit.APIKey = captured.APIKey
edit.APIKeyStored = captured.APIKeyStored
}
if _, err := config.EditProvider(m.userConfigPath, edit); err != nil {
wizard.err = err.Error()
return m, nil
}
// Mirror the edit into the in-memory list surgically. savedProviders was
// seeded from the RESOLVED (project-config layered) and usability-FILTERED
// provider set — substituting the raw user-file list here would drop
// project-contributed providers and resurrect filtered stubs for the session.
m.savedProviders = applySavedProviderEdit(m.savedProviders, oldName, edit)
// Keep the live session's identity in sync with a rename of the provider it
// is running on: the exported ZERO_PROVIDER must resolve for spawned children.
if strings.EqualFold(strings.TrimSpace(m.providerName), oldName) {
m.providerName = newName
m.providerProfile.Name = newName
config.SetActiveProviderEnv(newName)
}
wizard.step = providerWizardStepManage
next, cmd := m.reloadProviderManagerRows()
next.providerWizard.manageStatus = "Updated " + newName + "." + providerEditRestartNote(next.providerName, newName)
return next, cmd
}
// providerEditRestartNote reminds the user when the edited provider is the one
// this session is running on — endpoint/model/key changes only apply to the
// built client after a switch (Enter on the row re-activates and rebuilds).
// liveName is the session's provider AFTER any rename sync, so a single
// comparison against the edited profile's final name suffices.
func providerEditRestartNote(liveName string, editedName string) string {
if strings.EqualFold(strings.TrimSpace(liveName), strings.TrimSpace(editedName)) {
return " Press Enter on it to apply the changes to this session."
}
return ""
}
// applySavedProviderEdit mirrors a persisted config.EditProvider into the
// in-memory saved list without wholesale replacement (see saveManagerEdit).
func applySavedProviderEdit(saved []config.ProviderProfile, oldName string, edit config.ProviderEdit) []config.ProviderProfile {
for index := range saved {
if !strings.EqualFold(strings.TrimSpace(saved[index].Name), strings.TrimSpace(oldName)) {
continue
}
profile := &saved[index]
if newName := strings.TrimSpace(edit.NewName); newName != "" {
profile.Name = newName
}
if baseURL := strings.TrimSpace(edit.BaseURL); baseURL != "" {
profile.BaseURL = baseURL
}
if model := strings.TrimSpace(edit.Model); model != "" {
profile.Model = model
}
if apiKey := strings.TrimSpace(edit.APIKey); apiKey != "" {
profile.APIKey = apiKey
}
if edit.APIKeyStored {
profile.APIKeyStored = true
profile.APIKey = ""
}
profile.Description = strings.TrimSpace(edit.Description)
break
}
return saved
}
// --- rendering ---------------------------------------------------------------
// upsertSavedProviderProfile mirrors a freshly persisted profile into the
// in-memory saved list (replace by name, else append).
func upsertSavedProviderProfile(saved []config.ProviderProfile, profile config.ProviderProfile) []config.ProviderProfile {
for index := range saved {
if strings.EqualFold(strings.TrimSpace(saved[index].Name), strings.TrimSpace(profile.Name)) {
saved[index] = profile
return saved
}
}
return append(saved, profile)
}
func (wizard *providerWizardState) renderManageStep(width int) []string {
rows := wizard.manageRows
lines := []string{}
if wizard.manageStatus != "" {
lines = append(lines, fitStyledLine(zeroTheme.accent.Render(wizard.manageStatus), width), "")
}
if len(rows) == 0 {
lines = append(lines, zeroTheme.faint.Render(" No providers saved — press a to add one."))
return lines
}
wizard.manageCursor = clampInt(wizard.manageCursor, 0, len(rows)-1)
nameWidth := 0
for _, row := range rows {
nameWidth = maxInt(nameWidth, len([]rune(row.profile.Name)))
}
nameWidth = minInt(nameWidth, 28)
maxVisible := minInt(providerManagerMaxVisible, len(rows))
start := selectableListStart(len(rows), maxVisible, wizard.manageCursor)
for offset, row := range rows[start : start+maxVisible] {
index := start + offset
surface := transparentSurface
marker := surface(zeroTheme.faintest).Render(" ")
if index == wizard.manageCursor {
surface = zeroTheme.onSel
marker = surface(zeroTheme.accent).Render("❯ ")
}
active := ""
if strings.EqualFold(strings.TrimSpace(row.profile.Name), strings.TrimSpace(wizard.manageActiveName)) {
active = surface(zeroTheme.accent).Render(" ● active")
}
name := padProviderManagerCell(row.profile.Name, nameWidth)
meta := providerManagerRowMeta(row.profile)
left := marker + surface(zeroTheme.ink).Render(name) + " " + surface(zeroTheme.faint).Render(meta)
right := surface(zeroTheme.faint).Render(providerManagerCredDisplay(row)) + active
gap := width - lipgloss.Width(left) - lipgloss.Width(right)
line := left + surface(zeroTheme.ink).Render(strings.Repeat(" ", maxInt(1, gap))) + right
lines = append(lines, fillPaletteLine(line, width, surface))
}
if row, ok := wizard.currentManagerRow(); ok {
lines = append(lines, zeroTheme.line.Render(strings.Repeat("─", width)))
detail := strings.TrimSpace(row.profile.BaseURL)
if description := strings.TrimSpace(row.profile.Description); description != "" {
if detail != "" {
detail += " — "
}
detail += description
}
if detail == "" {
detail = "(no endpoint)"
}
lines = append(lines, fitStyledLine(zeroTheme.faint.Render(detail), width))
if wizard.manageDeleting {
lines = append(lines, fitStyledLine(zeroTheme.red.Render("Delete "+row.profile.Name+"? This also removes its stored API key. Enter/y confirm · Esc/n cancel"), width))
}
}
return lines
}
// manageActiveName is resolved at render time via the field set in
// reloadProviderManagerRows — kept on the struct so the pure render funcs
// don't need the model.
func providerManagerRowMeta(profile config.ProviderProfile) string {
kind := strings.TrimSpace(string(profile.ProviderKind))
if kind == "" {
kind = strings.TrimSpace(profile.Provider)
}
if catalog := strings.TrimSpace(profile.CatalogID); catalog != "" && !strings.EqualFold(catalog, profile.Name) {
kind = catalog
}
model := strings.TrimSpace(profile.Model)
switch {
case kind != "" && model != "":
return kind + " · " + model
case kind != "":
return kind
default:
return model
}
}
func providerManagerCredDisplay(row providerManagerRow) string {
if row.cred == "" {
return "checking…"
}
return row.cred
}
func padProviderManagerCell(value string, width int) string {
runes := []rune(value)
if len(runes) > width {
if width <= 1 {
return string(runes[:width])
}
return string(runes[:width-1]) + "…"
}
return value + strings.Repeat(" ", width-len(runes))
}
func (wizard *providerWizardState) renderEditMenuStep(width int) []string {
lines := []string{zeroTheme.accent.Render("Edit " + wizard.editOriginal.Name)}
wizard.editCursor = clampInt(wizard.editCursor, 0, len(providerEditFields)-1)
for index, entry := range providerEditFields {
surface := transparentSurface
marker := surface(zeroTheme.faintest).Render(" ")
if index == wizard.editCursor {
surface = zeroTheme.onSel
marker = surface(zeroTheme.accent).Render("❯ ")
}
value := wizard.editFieldValue(entry.field, wizard.editDraft)
display := ""
switch entry.field {
case providerEditFieldAPIKey:
if value != "" {
display = maskedProviderWizardKey(value) + " (new)"
} else if wizard.editOriginal.APIKeyStored {
display = "stored — enter to replace"
} else {
display = "none — enter to add"
}
case providerEditFieldSave:
display = ""
default:
display = displayValue(value, "(empty)")
}
left := marker + surface(zeroTheme.ink).Render(padProviderManagerCell(entry.label, 12))
if display != "" {
left += surface(zeroTheme.faint).Render(display)
}
lines = append(lines, fillPaletteLine(fitStyledLine(left, width), width, surface))
}
entry := providerEditFields[wizard.editCursor]
lines = append(lines, "", fitStyledLine(zeroTheme.faint.Render(entry.hint), width))
return lines
}
func (wizard *providerWizardState) renderEditValueStep(width int) []string {
entry := providerEditFields[clampInt(wizard.editCursor, 0, len(providerEditFields)-1)]
value := wizard.editBuffer
if wizard.editField == providerEditFieldAPIKey {
value = maskedProviderWizardKey(value)
}
prompt := zeroTheme.userPrompt.Render(entry.label + " > ")
if value == "" {
value = zeroTheme.faint.Render("(empty)")
} else {
value = zeroTheme.ink.Render(value)
}
return []string{
zeroTheme.accent.Render("Edit " + wizard.editOriginal.Name + " — " + entry.label),
fitStyledLine(prompt+value, width),
"",
fitStyledLine(zeroTheme.faint.Render(entry.hint), width),
}
}
+647
View File
@@ -0,0 +1,647 @@
package tui
import (
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
tea "charm.land/bubbletea/v2"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/oauth"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// managerTestModel builds a model with two saved providers, a seeded config
// file, and a fake provider factory, then opens /provider on the manager list.
func managerTestModel(t *testing.T) model {
t.Helper()
// Isolate every credential surface the manager touches: XDG redirects the
// default config dir (oauth token store, default credential store location)
// and the file backend keeps the OS keychain out of tests entirely.
home := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", home)
t.Setenv("ZERO_OAUTH_TOKENS_PATH", filepath.Join(home, "oauth-tokens.json"))
t.Setenv("ZERO_CRED_STORAGE", "encrypted-file")
configPath := filepath.Join(t.TempDir(), "config.json")
seed := config.FileConfig{
ActiveProvider: "opengateway",
Providers: []config.ProviderProfile{
{Name: "opengateway", ProviderKind: config.ProviderKindOpenAICompatible, BaseURL: "https://gateway.example.com/v1", APIKey: "sk-gw", Model: "mimo-v2.5-pro", Description: "Main gateway"},
{Name: "backup", ProviderKind: config.ProviderKindOpenAICompatible, BaseURL: "https://backup.example.com/v1", APIKey: "sk-backup", Model: "backup-model"},
},
}
data, err := json.MarshalIndent(seed, "", " ")
if err != nil {
t.Fatalf("encode seed config: %v", err)
}
if err := os.WriteFile(configPath, data, 0o600); err != nil {
t.Fatalf("write seed config: %v", err)
}
m := newModel(context.Background(), Options{
ProviderName: "opengateway",
ModelName: "mimo-v2.5-pro",
Provider: &fakeProvider{},
ProviderProfile: seed.Providers[0],
SavedProviders: seed.Providers,
UserConfigPath: configPath,
NewProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
return &fakeProvider{}, nil
},
})
m.width = 120
m.height = 40
next, _ := m.openProviderManager()
return next
}
func managerKey(t *testing.T, m model, msg tea.KeyMsg) model {
t.Helper()
next, _ := m.handleProviderWizardKey(msg)
return next
}
func TestOpenProviderManagerListsSavedProviders(t *testing.T) {
m := managerTestModel(t)
if m.providerWizard == nil || m.providerWizard.step != providerWizardStepManage {
t.Fatalf("expected the manager list to open, got %+v", m.providerWizard)
}
if len(m.providerWizard.manageRows) != 2 {
t.Fatalf("expected 2 rows, got %d", len(m.providerWizard.manageRows))
}
rendered := m.providerWizard.render(m.width)
for _, want := range []string{"opengateway", "backup", "active", "Enter activate"} {
if !strings.Contains(rendered, want) {
t.Fatalf("manager render missing %q:\n%s", want, rendered)
}
}
}
func TestOpenProviderManagerFallsBackToWizardWhenEmpty(t *testing.T) {
m := newModel(context.Background(), Options{})
next, _ := m.openProviderManager()
if next.providerWizard == nil || next.providerWizard.step != providerWizardStepMethod {
t.Fatalf("no saved providers should open the add wizard, got %+v", next.providerWizard)
}
}
func TestProviderManagerEnterActivatesSelection(t *testing.T) {
m := managerTestModel(t)
m = managerKey(t, m, testKey(tea.KeyDown)) // move to "backup"
next, _ := m.handleProviderWizardKey(testKey(tea.KeyEnter))
if next.providerWizard != nil {
t.Fatalf("manager should close after a successful switch")
}
if next.providerName != "backup" || next.modelName != "backup-model" {
t.Fatalf("switch did not commit: provider=%q model=%q", next.providerName, next.modelName)
}
persisted := readManagerConfig(t, next.userConfigPath)
if persisted.ActiveProvider != "backup" {
t.Fatalf("activeProvider not persisted, got %q", persisted.ActiveProvider)
}
}
func TestProviderManagerActivateBlockedWhilePending(t *testing.T) {
m := managerTestModel(t)
m.pending = true
next, _ := m.handleProviderWizardKey(testKey(tea.KeyEnter))
if next.providerWizard == nil || next.providerWizard.manageStatus == "" {
t.Fatalf("a pending run must keep the manager open with a status, got %+v", next.providerWizard)
}
if next.providerName != "opengateway" {
t.Fatalf("provider must not switch while pending, got %q", next.providerName)
}
}
func TestProviderManagerDeleteConfirmsAndRemoves(t *testing.T) {
m := managerTestModel(t)
m = managerKey(t, m, testKey(tea.KeyDown)) // select "backup"
m = managerKey(t, m, testKeyText("d"))
if !m.providerWizard.manageDeleting {
t.Fatalf("d must arm the inline delete confirm")
}
// Esc cancels the confirm without closing the manager.
m = managerKey(t, m, testKey(tea.KeyEsc))
if m.providerWizard == nil || m.providerWizard.manageDeleting {
t.Fatalf("Esc must cancel the confirm, keeping the manager open")
}
if got := readManagerConfig(t, m.userConfigPath); len(got.Providers) != 2 {
t.Fatalf("cancelled delete must not touch config, got %d providers", len(got.Providers))
}
m = managerKey(t, m, testKeyText("d"))
next, _ := m.handleProviderWizardKey(testKeyText("y"))
if next.providerWizard == nil {
t.Fatalf("manager should stay open while providers remain")
}
if len(next.savedProviders) != 1 || next.savedProviders[0].Name != "opengateway" {
t.Fatalf("savedProviders not updated: %+v", next.savedProviders)
}
persisted := readManagerConfig(t, next.userConfigPath)
if len(persisted.Providers) != 1 || persisted.Providers[0].Name != "opengateway" {
t.Fatalf("config not updated: %+v", persisted.Providers)
}
if persisted.ActiveProvider != "opengateway" {
t.Fatalf("active must be untouched when a non-active profile is deleted, got %q", persisted.ActiveProvider)
}
if !strings.Contains(next.providerWizard.manageStatus, "Deleted backup") {
t.Fatalf("expected delete status, got %q", next.providerWizard.manageStatus)
}
}
func TestProviderManagerEditModelPersists(t *testing.T) {
m := managerTestModel(t)
m = managerKey(t, m, testKeyText("e"))
if m.providerWizard.step != providerWizardStepEditMenu {
t.Fatalf("e must open the field editor, got step %v", m.providerWizard.step)
}
// Field order: Name, Endpoint, Model, API key, Description, Save.
m = managerKey(t, m, testKey(tea.KeyDown))
m = managerKey(t, m, testKey(tea.KeyDown))
m = managerKey(t, m, testKey(tea.KeyEnter)) // edit Model
if m.providerWizard.step != providerWizardStepEditValue || m.providerWizard.editField != providerEditFieldModel {
t.Fatalf("expected model value editor, got step %v field %v", m.providerWizard.step, m.providerWizard.editField)
}
m.providerWizard.editBuffer = "mimo-v3"
m = managerKey(t, m, testKey(tea.KeyEnter)) // commit field
if m.providerWizard.step != providerWizardStepEditMenu {
t.Fatalf("commit should return to the field menu")
}
// Move to Save (cursor still on Model=index 2; Save is index 5).
m = managerKey(t, m, testKey(tea.KeyDown))
m = managerKey(t, m, testKey(tea.KeyDown))
m = managerKey(t, m, testKey(tea.KeyDown))
next, _ := m.handleProviderWizardKey(testKey(tea.KeyEnter))
if next.providerWizard == nil || next.providerWizard.step != providerWizardStepManage {
t.Fatalf("save should land back on the manager list")
}
persisted := readManagerConfig(t, next.userConfigPath)
if persisted.Providers[0].Model != "mimo-v3" {
t.Fatalf("edited model not persisted: %+v", persisted.Providers[0])
}
if persisted.Providers[0].APIKey != "sk-gw" {
t.Fatalf("unrelated credential must survive the edit: %+v", persisted.Providers[0])
}
}
func TestProviderManagerRenameFollowsLiveSession(t *testing.T) {
m := managerTestModel(t)
m = managerKey(t, m, testKeyText("e"))
m = managerKey(t, m, testKey(tea.KeyEnter)) // edit Name (cursor 0)
m.providerWizard.editBuffer = "gateway-main"
m = managerKey(t, m, testKey(tea.KeyEnter))
// Save is 5 rows below Name.
for range 5 {
m = managerKey(t, m, testKey(tea.KeyDown))
}
next, _ := m.handleProviderWizardKey(testKey(tea.KeyEnter))
if next.providerWizard == nil || next.providerWizard.step != providerWizardStepManage {
t.Fatalf("save should return to the list, got %+v", next.providerWizard)
}
if next.providerName != "gateway-main" {
t.Fatalf("live session name must follow the rename, got %q", next.providerName)
}
persisted := readManagerConfig(t, next.userConfigPath)
if persisted.ActiveProvider != "gateway-main" {
t.Fatalf("activeProvider must follow the rename, got %q", persisted.ActiveProvider)
}
names := []string{}
for _, profile := range persisted.Providers {
names = append(names, profile.Name)
}
if len(persisted.Providers) != 2 || persisted.Providers[0].Name != "gateway-main" {
t.Fatalf("rename not persisted, providers: %v", names)
}
}
func TestProviderManagerEscWalksBackThenCloses(t *testing.T) {
m := managerTestModel(t)
m = managerKey(t, m, testKeyText("e"))
m = managerKey(t, m, testKey(tea.KeyEnter)) // into Name editor
m = managerKey(t, m, testKey(tea.KeyEsc))
if m.providerWizard.step != providerWizardStepEditMenu {
t.Fatalf("Esc from a field editor must return to the field menu")
}
m = managerKey(t, m, testKey(tea.KeyEsc))
if m.providerWizard.step != providerWizardStepManage {
t.Fatalf("Esc from the field menu must return to the list")
}
m = managerKey(t, m, testKey(tea.KeyEsc))
if m.providerWizard != nil {
t.Fatalf("Esc from the list must close the manager")
}
}
func TestProviderManagerAddReturnsToListOnEsc(t *testing.T) {
m := managerTestModel(t)
m = managerKey(t, m, testKeyText("a"))
if m.providerWizard.step != providerWizardStepMethod {
t.Fatalf("a must open the add wizard's method step")
}
m = managerKey(t, m, testKey(tea.KeyEsc))
if m.providerWizard == nil || m.providerWizard.step != providerWizardStepManage {
t.Fatalf("Esc from a manager-opened add flow must return to the list")
}
}
func TestProviderManagerCredsMsgFillsRowsAndIgnoresStale(t *testing.T) {
m := managerTestModel(t)
gen := m.providerWizard.manageCredGen
next, _ := m.applyProviderManagerCreds(providerManagerCredsMsg{gen: gen, creds: map[string]string{
"opengateway": "key set",
"backup": "no credential",
}})
if next.providerWizard.manageRows[0].cred != "key set" || next.providerWizard.manageRows[1].cred != "no credential" {
t.Fatalf("creds not applied: %+v", next.providerWizard.manageRows)
}
// A stale generation must not clobber fresh rows.
next, _ = next.applyProviderManagerCreds(providerManagerCredsMsg{gen: gen - 1, creds: map[string]string{
"opengateway": "stale",
}})
if next.providerWizard.manageRows[0].cred != "key set" {
t.Fatalf("stale creds applied: %+v", next.providerWizard.manageRows[0])
}
}
func readManagerConfig(t *testing.T, path string) config.FileConfig {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read config: %v", err)
}
var cfg config.FileConfig
if err := json.Unmarshal(data, &cfg); err != nil {
t.Fatalf("decode config: %v", err)
}
return cfg
}
// TestProviderManagerEditKeyPersistsStoredMarker: replacing the key of a
// provider that previously had NO stored-key marker (e.g. env-authed) must
// persist apiKeyStored — otherwise the secret sits in the credential store
// while every ApplyStoredAPIKey gate skips it (PR #560 review, P2).
func TestProviderManagerEditKeyPersistsStoredMarker(t *testing.T) {
m := managerTestModel(t)
// Reshape "backup" into an env-authed profile with no marker and no inline key.
seedCfg := readManagerConfig(t, m.userConfigPath)
seedCfg.Providers[1].APIKey = ""
seedCfg.Providers[1].APIKeyEnv = "BACKUP_API_KEY"
data, err := json.MarshalIndent(seedCfg, "", " ")
if err != nil {
t.Fatalf("encode config: %v", err)
}
if err := os.WriteFile(m.userConfigPath, data, 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
m = managerKey(t, m, testKey(tea.KeyDown)) // select "backup"
m = managerKey(t, m, testKeyText("e"))
for range 3 { // Name → Endpoint → Model → API key
m = managerKey(t, m, testKey(tea.KeyDown))
}
m = managerKey(t, m, testKey(tea.KeyEnter))
if m.providerWizard.editField != providerEditFieldAPIKey {
t.Fatalf("expected API key editor, got field %v", m.providerWizard.editField)
}
m.providerWizard.editBuffer = "sk-new-secret"
m = managerKey(t, m, testKey(tea.KeyEnter))
m = managerKey(t, m, testKey(tea.KeyDown)) // Description
m = managerKey(t, m, testKey(tea.KeyDown)) // Save
next, _ := m.handleProviderWizardKey(testKey(tea.KeyEnter))
if next.providerWizard == nil || next.providerWizard.step != providerWizardStepManage {
t.Fatalf("save should return to the list, err=%q", next.providerWizard.err)
}
persisted := readManagerConfig(t, next.userConfigPath)
backup := persisted.Providers[1]
if !backup.APIKeyStored {
t.Fatalf("apiKeyStored marker must persist after a key edit: %+v", backup)
}
if backup.APIKey != "" {
t.Fatalf("cleartext key must never land in config.json: %+v", backup)
}
// The env reference is deliberately preserved as an explicit override: the
// resolver fills APIKey from a SET env var first, and the stored key applies
// only when the env var is absent — locked in here so a future merge change
// can't silently flip the precedence.
if backup.APIKeyEnv != "BACKUP_API_KEY" {
t.Fatalf("APIKeyEnv must survive a key edit as an explicit override, got %q", backup.APIKeyEnv)
}
store, err := config.ProviderKeyStoreAt(filepath.Dir(next.userConfigPath))
if err != nil {
t.Fatalf("open store: %v", err)
}
if key, ok, err := store.Get("backup"); err != nil || !ok || key != "sk-new-secret" {
t.Fatalf("key must be captured into the store beside the config, got key=%q ok=%v err=%v", key, ok, err)
}
}
// TestProviderManagerDescriptionClearPersists: clearing a description must
// actually persist (the upsert merge treats empty as "unchanged" — PR #560, P3).
func TestProviderManagerDescriptionClearPersists(t *testing.T) {
m := managerTestModel(t) // opengateway has description "Main gateway"
m = managerKey(t, m, testKeyText("e"))
for range 4 { // Name → Endpoint → Model → API key → Description
m = managerKey(t, m, testKey(tea.KeyDown))
}
m = managerKey(t, m, testKey(tea.KeyEnter))
if m.providerWizard.editField != providerEditFieldDescription {
t.Fatalf("expected description editor, got field %v", m.providerWizard.editField)
}
m.providerWizard.editBuffer = ""
m = managerKey(t, m, testKey(tea.KeyEnter))
m = managerKey(t, m, testKey(tea.KeyDown)) // Save
next, _ := m.handleProviderWizardKey(testKey(tea.KeyEnter))
if next.providerWizard == nil || next.providerWizard.step != providerWizardStepManage {
t.Fatalf("save should return to the list, err=%q", next.providerWizard.err)
}
persisted := readManagerConfig(t, next.userConfigPath)
if persisted.Providers[0].Description != "" {
t.Fatalf("cleared description must persist, got %q", persisted.Providers[0].Description)
}
if row, ok := next.providerWizard.currentManagerRow(); !ok || row.profile.Description != "" {
t.Fatalf("manager rows must reflect the cleared description: %+v", row.profile)
}
}
// TestProviderManagerDeleteHintNamesActualOAuthLogin: after a rename the token
// lives under the catalog id, not the profile name — the cleanup hint must name
// the entry `zero auth logout` would actually delete (PR #560 review, P3).
func TestProviderManagerDeleteHintNamesActualOAuthLogin(t *testing.T) {
m := managerTestModel(t)
// Reshape "backup" into a renamed OAuth catalog profile: keyless, named
// "codex", backed by a token stored under the chatgpt catalog id.
seedCfg := readManagerConfig(t, m.userConfigPath)
seedCfg.Providers[1] = config.ProviderProfile{Name: "codex", CatalogID: "chatgpt", ProviderKind: config.ProviderKindOpenAICompatible, BaseURL: "https://chatgpt.com/backend-api/codex", Model: "gpt-5.5"}
data, err := json.MarshalIndent(seedCfg, "", " ")
if err != nil {
t.Fatalf("encode config: %v", err)
}
if err := os.WriteFile(m.userConfigPath, data, 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
m.savedProviders = seedCfg.Providers
next, _ := m.reloadProviderManagerRows()
m = next
store, err := oauth.NewStore(oauth.StoreOptions{})
if err != nil {
t.Fatalf("oauth store: %v", err)
}
if err := store.Save(oauth.ProviderKey("chatgpt"), oauth.Token{AccessToken: "bearer-123"}); err != nil {
t.Fatalf("save token: %v", err)
}
m = managerKey(t, m, testKey(tea.KeyDown)) // select "codex"
m = managerKey(t, m, testKeyText("d"))
next, cmd := m.handleProviderWizardKey(testKeyText("y"))
if next.providerWizard == nil {
t.Fatalf("manager should stay open")
}
// The stored-key delete and OAuth hint run in a follow-up cmd off the UI
// goroutine; drain it into the model like the runtime would.
next = drainProviderManagerCmds(t, next, cmd)
status := next.providerWizard.manageStatus
if !strings.Contains(status, "zero auth logout chatgpt") {
t.Fatalf("hint must name the stored login (chatgpt), got %q", status)
}
if strings.Contains(status, "logout codex") {
t.Fatalf("hint must not point at a login key that does not exist, got %q", status)
}
}
// TestProviderManagerReadsStoredKeyBesideConfig: the TUI must READ stored keys
// from the same config-adjacent store its write paths use. managerTestModel
// deliberately puts userConfigPath outside XDG_CONFIG_HOME, so a key seeded
// beside the config is invisible to the default-path store — with the old
// default-store reads, the switch gate rejected the provider and the manager
// showed "stored key missing" for a perfectly healthy profile (PR #560, P2).
func TestProviderManagerReadsStoredKeyBesideConfig(t *testing.T) {
m := managerTestModel(t)
// Reshape "backup" into a stored-key profile whose key lives ONLY in the
// store beside userConfigPath.
seedCfg := readManagerConfig(t, m.userConfigPath)
seedCfg.Providers[1].APIKey = ""
seedCfg.Providers[1].APIKeyStored = true
data, err := json.MarshalIndent(seedCfg, "", " ")
if err != nil {
t.Fatalf("encode config: %v", err)
}
if err := os.WriteFile(m.userConfigPath, data, 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
store, err := config.ProviderKeyStoreAt(filepath.Dir(m.userConfigPath))
if err != nil {
t.Fatalf("open store: %v", err)
}
if err := store.Set("backup", "sk-beside-config"); err != nil {
t.Fatalf("seed key: %v", err)
}
m.savedProviders = seedCfg.Providers
next, _ := m.reloadProviderManagerRows()
m = next
// The async credential probe must find the key in the config-adjacent store.
cmd := providerManagerCredsCmd(m.providerWizard.manageCredGen, m.providerWizard.manageRows, m.userConfigPath)
msg, ok := cmd().(providerManagerCredsMsg)
if !ok {
t.Fatalf("expected creds msg")
}
if msg.creds["backup"] != "key stored" {
t.Fatalf("creds probe must read the store beside the config, got %q", msg.creds["backup"])
}
// And the switch path must load the key instead of rejecting the provider.
var built config.ProviderProfile
m.newProvider = func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
built = profile
return &fakeProvider{}, nil
}
m = managerKey(t, m, testKey(tea.KeyDown)) // select "backup"
next, _ = m.handleProviderWizardKey(testKey(tea.KeyEnter))
if next.providerWizard != nil {
t.Fatalf("switch must succeed on a config-adjacent stored key, status=%q", next.providerWizard.manageStatus)
}
if built.APIKey != "sk-beside-config" {
t.Fatalf("switch must load the key from the store beside the config, got %q", built.APIKey)
}
}
// drainProviderManagerCmds executes a manager action's follow-up cmds (batch
// or single) and applies any providerManagerCleanupMsg, mirroring what the
// bubbletea runtime does with the returned tea.Cmd.
func drainProviderManagerCmds(t *testing.T, m model, cmd tea.Cmd) model {
t.Helper()
var apply func(c tea.Cmd)
apply = func(c tea.Cmd) {
if c == nil {
return
}
switch msg := c().(type) {
case tea.BatchMsg:
for _, sub := range msg {
apply(sub)
}
case providerManagerCleanupMsg:
m, _ = m.applyProviderManagerCleanup(msg)
}
}
apply(cmd)
return m
}
// TestProviderManagerCaseOnlyRenameUpdatesInPlace: groq → Groq through the
// editor must rename in place — the old EqualFold-skip + case-sensitive upsert
// combination appended a duplicate profile (review finding, empirically shown).
func TestProviderManagerCaseOnlyRenameUpdatesInPlace(t *testing.T) {
m := managerTestModel(t)
m = managerKey(t, m, testKeyText("e"))
m = managerKey(t, m, testKey(tea.KeyEnter)) // edit Name (cursor 0)
m.providerWizard.editBuffer = "OpenGateway"
m = managerKey(t, m, testKey(tea.KeyEnter))
for range 5 {
m = managerKey(t, m, testKey(tea.KeyDown))
}
next, _ := m.handleProviderWizardKey(testKey(tea.KeyEnter)) // Save
if next.providerWizard == nil || next.providerWizard.step != providerWizardStepManage {
t.Fatalf("save should return to the list, err=%q", next.providerWizard.err)
}
persisted := readManagerConfig(t, next.userConfigPath)
if len(persisted.Providers) != 2 {
t.Fatalf("case-only rename must not duplicate: %d providers", len(persisted.Providers))
}
if persisted.Providers[0].Name != "OpenGateway" || persisted.Providers[0].APIKey != "sk-gw" {
t.Fatalf("in-place update wrong: %+v", persisted.Providers[0])
}
if persisted.ActiveProvider != "OpenGateway" {
t.Fatalf("active must follow, got %q", persisted.ActiveProvider)
}
if len(next.savedProviders) != 2 || next.savedProviders[0].Name != "OpenGateway" {
t.Fatalf("in-memory list must mirror the rename: %+v", next.savedProviders)
}
}
// TestProviderManagerMutationsKeepResolvedProviders: savedProviders is seeded
// from the RESOLVED+FILTERED provider set — a manager delete/edit must mutate
// it surgically, not replace it with the raw user-config list (which would drop
// project-config-contributed providers for the session).
func TestProviderManagerMutationsKeepResolvedProviders(t *testing.T) {
m := managerTestModel(t)
// Simulate a project-config-contributed provider: present in the session's
// resolved list, absent from the user config.json the writers operate on.
projectProvider := config.ProviderProfile{Name: "team-gateway", ProviderKind: config.ProviderKindOpenAICompatible, BaseURL: "https://team.example.com/v1", APIKey: "sk-team", Model: "team-model"}
m.savedProviders = append(m.savedProviders, projectProvider)
next, _ := m.reloadProviderManagerRows()
m = next
// Delete "backup" (a user-config profile): team-gateway must survive.
m = managerKey(t, m, testKey(tea.KeyDown)) // select "backup"
m = managerKey(t, m, testKeyText("d"))
next, _ = m.handleProviderWizardKey(testKeyText("y"))
names := []string{}
for _, profile := range next.savedProviders {
names = append(names, profile.Name)
}
if len(next.savedProviders) != 2 || names[0] != "opengateway" || names[1] != "team-gateway" {
t.Fatalf("project-contributed provider lost by delete: %v", names)
}
// Edit opengateway's model: team-gateway must still survive.
m = next
m.providerWizard.manageCursor = 0
m = managerKey(t, m, testKeyText("e"))
for range 2 {
m = managerKey(t, m, testKey(tea.KeyDown))
}
m = managerKey(t, m, testKey(tea.KeyEnter)) // Model field
m.providerWizard.editBuffer = "mimo-next"
m = managerKey(t, m, testKey(tea.KeyEnter))
for range 3 {
m = managerKey(t, m, testKey(tea.KeyDown))
}
next, _ = m.handleProviderWizardKey(testKey(tea.KeyEnter)) // Save
names = names[:0]
for _, profile := range next.savedProviders {
names = append(names, profile.Name)
}
if len(next.savedProviders) != 2 || names[1] != "team-gateway" {
t.Fatalf("project-contributed provider lost by edit: %v", names)
}
if next.savedProviders[0].Model != "mimo-next" {
t.Fatalf("edit not mirrored in-memory: %+v", next.savedProviders[0])
}
}
// TestProviderManagerEscWalksBackFromDeepAddFlow: Esc anywhere inside a
// manager-launched add flow returns to the provider list ("Esc walks back one
// level"), never destroying the manager context — previously only the first
// step walked back and every deeper step hard-closed the overlay.
func TestProviderManagerEscWalksBackFromDeepAddFlow(t *testing.T) {
m := managerTestModel(t)
m = managerKey(t, m, testKeyText("a")) // add flow, method step
m = managerKey(t, m, testKey(tea.KeyEnter))
if m.providerWizard == nil || m.providerWizard.step == providerWizardStepMethod {
t.Fatalf("expected to advance past the method step, got %+v", m.providerWizard)
}
deepStep := m.providerWizard.step
m = managerKey(t, m, testKey(tea.KeyEsc))
if m.providerWizard == nil {
t.Fatalf("Esc on step %v must not destroy the manager overlay", deepStep)
}
if m.providerWizard.step != providerWizardStepManage {
t.Fatalf("Esc must return to the provider list, got step %v", m.providerWizard.step)
}
}
// TestProviderManagerActivationUsesStructuredResult: a refusal whose display
// text happens to contain "Switched to" (a provider literally named that) must
// NOT close the manager as a success — the outcome bool, not UI copy, decides.
func TestProviderManagerActivationUsesStructuredResult(t *testing.T) {
m := managerTestModel(t)
seedCfg := readManagerConfig(t, m.userConfigPath)
seedCfg.Providers[1] = config.ProviderProfile{Name: "Switched to prod", ProviderKind: config.ProviderKindOpenAICompatible, BaseURL: "https://p.example.com/v1", Model: "m"}
data, err := json.MarshalIndent(seedCfg, "", " ")
if err != nil {
t.Fatalf("encode config: %v", err)
}
if err := os.WriteFile(m.userConfigPath, data, 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
m.savedProviders = seedCfg.Providers
next, _ := m.reloadProviderManagerRows()
m = next
m = managerKey(t, m, testKey(tea.KeyDown)) // select the credential-less provider
next, _ = m.handleProviderWizardKey(testKey(tea.KeyEnter))
if next.providerWizard == nil {
t.Fatalf("a refused switch must keep the manager open even when the refusal text contains \"Switched to\"")
}
if !strings.Contains(next.providerWizard.manageStatus, "no usable credential") {
t.Fatalf("expected the refusal inline, got %q", next.providerWizard.manageStatus)
}
if next.providerName != "opengateway" {
t.Fatalf("provider must not switch, got %q", next.providerName)
}
}
// TestProviderManagerCredStateFallsThroughStaleMarker: a stale APIKeyStored
// marker with a SET env var must render the env credential (the runtime falls
// back the same way and switches fine), not "stored key missing".
func TestProviderManagerCredStateFallsThroughStaleMarker(t *testing.T) {
t.Setenv("STALE_MARKER_KEY", "sk-env")
profile := config.ProviderProfile{Name: "gw", APIKeyStored: true, APIKeyEnv: "STALE_MARKER_KEY"}
state := providerManagerCredState(profile, false, nil, map[string]bool{})
if state != "env STALE_MARKER_KEY" {
t.Fatalf("stale marker must fall through to the env credential, got %q", state)
}
// Marker with neither store entry nor fallback: the broken state still shows.
t.Setenv("STALE_MARKER_KEY", "")
state = providerManagerCredState(profile, false, nil, map[string]bool{})
if state != "stored key missing" {
t.Fatalf("expected stored key missing with no fallback, got %q", state)
}
}
+150 -2
View File
@@ -50,6 +50,18 @@ func (m model) applyProviderWizardOAuth(msg providerWizardOAuthMsg) (model, tea.
if msg.apiKey != "" {
m.providerWizard.apiKey = msg.apiKey
}
if msg.tokenLogin {
// Persist the profile HERE, on the Update goroutine (see
// persistOAuthLoginProvider for the threading contract), and only mirror
// it into the in-memory saved list when the write succeeded — a mirror of
// a profile that never reached disk shows a phantom provider that works
// this session and silently vanishes on restart.
if err := persistOAuthLoginProvider(m.userConfigPath, msg.providerID); err != nil {
m.providerWizard.oauthErr = "Signed in, but the provider profile could not be saved: " + redaction.ErrorMessage(err, redaction.Options{})
return m, nil
}
m.savedProviders = appendOAuthLoginProfile(m.savedProviders, msg.providerID)
}
// OAuth succeeded (key minted, or a refreshable token stored for the runtime
// resolver). Skip the endpoint/credential steps and go straight to model
// selection.
@@ -141,6 +153,48 @@ func runProviderChatGPTLogin() error {
return store.Save(oauth.ProviderKey("chatgpt"), token)
}
// appendOAuthLoginProfile mirrors the profile persistOAuthLoginProvider wrote to
// config into an in-memory saved-provider list, skipping when a profile already
// serves the catalog entry (by name or catalog id).
func appendOAuthLoginProfile(saved []config.ProviderProfile, providerID string) []config.ProviderProfile {
descriptor, ok := providercatalog.Get(providerID)
if !ok {
return saved
}
for _, profile := range saved {
if strings.EqualFold(strings.TrimSpace(profile.CatalogID), descriptor.ID) ||
strings.EqualFold(strings.TrimSpace(profile.Name), descriptor.ID) {
return saved
}
}
return append(saved, config.ProviderProfile{
Name: descriptor.ID,
ProviderKind: providerWizardProviderKind(descriptor),
CatalogID: descriptor.ID,
BaseURL: descriptor.DefaultBaseURL,
Model: descriptor.DefaultModel,
})
}
// persistOAuthLoginProvider guarantees the stored login is reachable from the
// provider list even when the wizard is abandoned before the model step: the
// token alone is invisible — no profile means no entry in /provider, /model, or
// `zero providers use`, which reads as the login having vanished.
//
// MUST run on the Update goroutine (call it from a message handler, never from
// inside a tea.Cmd): config.json writes are unlocked read-modify-writes, and a
// background login — a device-code poll can complete minutes later — racing a
// UI-thread write would silently clobber it (lost update). The returned error
// lets the caller surface a failed write instead of showing a provider that
// never reached disk.
func persistOAuthLoginProvider(configPath string, catalogID string) error {
if strings.TrimSpace(configPath) == "" {
return nil
}
_, err := config.EnsureCatalogProvider(configPath, catalogID)
return err
}
// buildOAuthPresetEnv layers the process env with the preset opt-in so a
// `zero` launch from a TUI session can use the baked-in ChatGPT client_id
// without requiring the user to export ZERO_OAUTH_ALLOW_PRESETS themselves.
@@ -249,6 +303,12 @@ const (
providerWizardStepCredential
providerWizardStepModel
providerWizardStepDone
// Manager steps (provider_manager.go): the list-first /provider surface.
// They live on the same state struct so every overlay gate, key route, and
// mouse hook that already checks m.providerWizard covers them for free.
providerWizardStepManage
providerWizardStepEditMenu
providerWizardStepEditValue
)
// providerWizardMethodOption is a row in the "How do you want to connect?" step.
@@ -319,6 +379,20 @@ type providerWizardState struct {
oauthDevice bool
deviceUserCode string
deviceVerificationURI string
// Manager state (provider_manager.go): the list-first /provider surface.
manage bool
manageRows []providerManagerRow
manageCursor int
manageDeleting bool
manageStatus string
manageCredGen int
manageActiveName string
// Edit state: field-level editor for one saved profile.
editOriginal config.ProviderProfile
editDraft config.ProviderProfile
editCursor int
editField providerEditField
editBuffer string
}
func (m model) newProviderWizard() *providerWizardState {
@@ -624,6 +698,26 @@ func (m model) handleProviderWizardKey(msg tea.KeyMsg) (model, tea.Cmd) {
if m.providerWizard == nil {
return m, nil
}
// A manager-launched add flow honors the manager's "Esc walks back one
// level" contract on EVERY step (not just the first): Esc returns to the
// provider list instead of destroying the overlay — and with it the user's
// cursor and status — from a step deep in the flow. An in-flight OAuth login
// is abandoned the same way; bumping the attempt id makes its late result
// stale so applyProviderWizardOAuth drops it.
if m.providerWizard.manage && !m.providerWizard.managerStep() && keyIs(msg, tea.KeyEsc) {
wizard := m.providerWizard
if wizard.oauthPending {
wizard.oauthPending = false
wizard.oauthAttemptID++
}
wizard.oauthDevice = false
wizard.deviceUserCode = ""
wizard.deviceVerificationURI = ""
wizard.err = ""
wizard.oauthErr = ""
wizard.step = providerWizardStepManage
return m, nil
}
// While a browser/device OAuth login is in flight, ignore input except Esc,
// which abandons the wizard (the background flow times out and is dropped).
if m.providerWizard.oauthPending {
@@ -632,6 +726,10 @@ func (m model) handleProviderWizardKey(msg tea.KeyMsg) (model, tea.Cmd) {
}
return m, nil
}
// Manager steps (list / edit) own their keys entirely.
if m.providerWizard.managerStep() {
return m.handleProviderManagerKey(msg)
}
// Manage-key step: keep/replace/remove a provider's stored key.
if m.providerWizard.step == providerWizardStepManageKey {
switch {
@@ -799,6 +897,13 @@ func (m model) handleProviderWizardPaste(content string) (model, tea.Cmd) {
m.providerWizard.appendAPIKey([]rune(content))
case providerWizardStepModel:
m.providerWizard.appendModelSearch([]rune(content))
case providerWizardStepEditValue:
for _, r := range content {
if r == '\n' || r == '\r' || r == '\t' {
continue
}
m.providerWizard.editBuffer += string(r)
}
}
return m, nil
}
@@ -1012,6 +1117,9 @@ func (m model) applyProviderWizard() (model, tea.Cmd) {
m.providerProfile = profile
m.providerName = profile.Name
m.modelName = profile.Model
// Keep the in-memory saved list in sync so the provider manager and /model
// picker show the new profile without a restart.
m.savedProviders = upsertSavedProviderProfile(m.savedProviders, profile)
// Keep sub-agent child processes on the same provider we just switched to —
// same as the /model and /provider switch paths (command_center.go). Without
// this, a ZERO_PROVIDER exported by an earlier switch stays pointing at the
@@ -1131,13 +1239,23 @@ func (wizard *providerWizardState) render(width int) string {
lines = append(lines, wizard.renderModelStep(innerWidth)...)
case providerWizardStepDone:
lines = append(lines, wizard.renderDoneStep(innerWidth)...)
case providerWizardStepManage:
lines = append(lines, wizard.renderManageStep(innerWidth)...)
case providerWizardStepEditMenu:
lines = append(lines, wizard.renderEditMenuStep(innerWidth)...)
case providerWizardStepEditValue:
lines = append(lines, wizard.renderEditValueStep(innerWidth)...)
}
lines = append(lines,
zeroTheme.line.Render(strings.Repeat("─", innerWidth)),
zeroTheme.faint.Render(wizard.footer()),
)
block := styledBlockFillTitle(overlayWidth, "Provider setup", lines, zeroTheme.lineStrong, lipgloss.NewStyle())
title := "Provider setup"
if wizard.managerStep() {
title = "Providers"
}
block := styledBlockFillTitle(overlayWidth, title, lines, zeroTheme.lineStrong, lipgloss.NewStyle())
if width > overlayWidth {
return indentBlock(block, (width-overlayWidth)/2)
}
@@ -1145,8 +1263,30 @@ func (wizard *providerWizardState) render(width int) string {
}
func (wizard *providerWizardState) footer() string {
text := wizard.footerText()
// Inside a manager-launched add flow, Esc walks back to the provider list
// (see handleProviderWizardKey) — the hint must match.
if wizard.manage && !wizard.managerStep() {
text = strings.ReplaceAll(text, "Esc close", "Esc back")
}
return text
}
func (wizard *providerWizardState) footerText() string {
canRight := wizard.canAdvanceWithRight()
switch wizard.step {
case providerWizardStepManage:
if wizard.manageDeleting {
return "Enter/y delete Esc/n cancel"
}
if len(wizard.manageRows) == 0 {
return "a add Esc close"
}
return "↑/↓ move Enter activate a add e edit d delete Esc close"
case providerWizardStepEditMenu:
return "↑/↓ move Enter edit field / save Esc back"
case providerWizardStepEditValue:
return "type to edit Enter apply Ctrl+U clear Esc back"
case providerWizardStepMethod:
return "↑/↓ move Enter/→ continue Esc close"
case providerWizardStepProvider:
@@ -1189,7 +1329,7 @@ func providerWizardOverlayWidth(width int, step providerWizardStep) int {
switch step {
case providerWizardStepProvider:
target = providerWizardProviderWidth
case providerWizardStepModel:
case providerWizardStepModel, providerWizardStepManage:
target = providerWizardModelWidth
}
target = minInt(target, width)
@@ -1203,6 +1343,14 @@ func providerWizardStepLine(wizard *providerWizardState) string {
if wizard == nil {
return ""
}
if wizard.managerStep() {
switch wizard.step {
case providerWizardStepManage:
return pluralCount(len(wizard.manageRows), "saved provider")
default:
return "edit provider"
}
}
step := wizard.step
type stepLabel struct {
step providerWizardStep
@@ -1,10 +1,14 @@
package tui
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/providercatalog"
)
@@ -384,3 +388,64 @@ func TestProviderWizardDeviceCodeIgnoresStaleAttempt(t *testing.T) {
t.Fatalf("stale device-code result applied device details: %+v", out.providerWizard)
}
}
func TestPersistOAuthLoginProviderWritesKeylessProfileWithoutStealingActive(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
seed := `{"activeProvider":"opengateway","providers":[{"name":"opengateway","provider_kind":"openai-compatible","baseURL":"https://gateway.example.com/v1","apiKeyStored":true,"model":"some-model"}]}`
if err := os.WriteFile(configPath, []byte(seed), 0o600); err != nil {
t.Fatalf("seed config: %v", err)
}
persistOAuthLoginProvider(configPath, "chatgpt")
data, err := os.ReadFile(configPath)
if err != nil {
t.Fatalf("read config: %v", err)
}
var cfg config.FileConfig
if err := json.Unmarshal(data, &cfg); err != nil {
t.Fatalf("decode config: %v", err)
}
if cfg.ActiveProvider != "opengateway" {
t.Fatalf("active provider changed to %q", cfg.ActiveProvider)
}
if len(cfg.Providers) != 2 || cfg.Providers[1].CatalogID != "chatgpt" {
t.Fatalf("expected chatgpt profile appended, got %+v", cfg.Providers)
}
if cfg.Providers[1].APIKey != "" || cfg.Providers[1].APIKeyStored {
t.Fatalf("OAuth profile must stay keyless so the token resolver attaches, got %+v", cfg.Providers[1])
}
// Blank path (no user config, e.g. tests/ephemeral runs) must be a no-op.
persistOAuthLoginProvider("", "chatgpt")
}
func TestAppendOAuthLoginProfileAddsOnceAndRespectsRenames(t *testing.T) {
saved := []config.ProviderProfile{{Name: "opengateway", ProviderKind: config.ProviderKindOpenAICompatible}}
saved = appendOAuthLoginProfile(saved, "chatgpt")
if len(saved) != 2 || saved[1].Name != "chatgpt" || saved[1].CatalogID != "chatgpt" {
t.Fatalf("expected chatgpt appended, got %+v", saved)
}
if saved[1].Model == "" || saved[1].BaseURL == "" {
t.Fatalf("appended profile must carry catalog defaults, got %+v", saved[1])
}
// Idempotent: a second login must not duplicate.
saved = appendOAuthLoginProfile(saved, "chatgpt")
if len(saved) != 2 {
t.Fatalf("duplicate appended: %+v", saved)
}
// A renamed profile serving the same catalog entry blocks the append too.
renamed := []config.ProviderProfile{{Name: "codex", CatalogID: "chatgpt"}}
renamed = appendOAuthLoginProfile(renamed, "chatgpt")
if len(renamed) != 1 {
t.Fatalf("renamed profile not respected: %+v", renamed)
}
// Unknown catalog id: no-op.
if got := appendOAuthLoginProfile(nil, "no-such-provider"); got != nil {
t.Fatalf("unknown provider must not append, got %+v", got)
}
}
+39 -12
View File
@@ -99,6 +99,12 @@ func (m model) startNewSession() model {
// Scrollback above can't be un-printed; a faint divider marks the boundary and
// the flush frontier restarts for the fresh transcript (mirrors /clear, /resume).
m.resetFlushFrontier("· new session ·")
// Loops belong to the previous session; stop them so they don't fire the old
// conversation's prompt into the fresh one.
if updated, cleared := m.clearLoopsForSessionSwitch(); cleared > 0 {
m = updated
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: fmt.Sprintf("Stopped %d loop(s) tied to the previous session.", cleared)})
}
return m
}
@@ -123,13 +129,24 @@ func (m model) appendSessionEvent(eventType sessions.EventType, payload any) (mo
func (m model) appendSessionEvents(events []pendingSessionEvent) (model, []transcriptRow) {
rows := []transcriptRow{}
if m.activeSession.SessionID == "" || len(events) == 0 {
return m, rows
}
inputs := make([]sessions.AppendEventInput, 0, len(events))
for _, event := range events {
next, err := m.appendSessionEvent(event.Type, event.Payload)
if err != nil {
rows = append(rows, transcriptRow{kind: rowError, text: "session record error: " + err.Error()})
continue
}
m = next
inputs = append(inputs, sessions.AppendEventInput{Type: event.Type, Payload: event.Payload})
}
appended, err := m.sessionStore.AppendEvents(m.activeSession.SessionID, inputs)
if err != nil {
rows = append(rows, transcriptRow{kind: rowError, text: "session record error: " + err.Error()})
return m, rows
}
if len(appended) > 0 {
last := appended[len(appended)-1]
m.activeSession.UpdatedAt = last.CreatedAt
m.activeSession.EventCount = last.Sequence
m.activeSession.LastEventType = last.Type
m.sessionEvents = append(m.sessionEvents, appended...)
}
return m, rows
}
@@ -142,13 +159,12 @@ func (m model) appendSessionEventsTo(sessionID string, events []pendingSessionEv
if m.sessionStore == nil || sessionID == "" {
return rows
}
inputs := make([]sessions.AppendEventInput, 0, len(events))
for _, event := range events {
if _, err := m.sessionStore.AppendEvent(sessionID, sessions.AppendEventInput{
Type: event.Type,
Payload: event.Payload,
}); err != nil {
rows = append(rows, transcriptRow{kind: rowError, text: "session record error: " + err.Error()})
}
inputs = append(inputs, sessions.AppendEventInput{Type: event.Type, Payload: event.Payload})
}
if _, err := m.sessionStore.AppendEvents(sessionID, inputs); err != nil {
rows = append(rows, transcriptRow{kind: rowError, text: "session record error: " + err.Error()})
}
return rows
}
@@ -195,6 +211,10 @@ func (m model) handleResumeCommand(args string) (model, string) {
return m, "Sessions\nerror: " + err.Error()
}
// Capture the current session id before switching so loops are only torn down
// on a real change — `/resume latest` or `/resume <currentID>` can resolve to
// the already-active session, whose loops belong to it, not a "previous" one.
previousID := m.activeSession.SessionID
m.activeSession = *session
m.sessionEvents = append([]sessions.Event{}, events...)
if m.providerName == "" {
@@ -203,9 +223,16 @@ func (m model) handleResumeCommand(args string) (model, string) {
if m.modelName == "" {
m.modelName = session.ModelID
}
loopsCleared := 0
if session.SessionID != previousID {
m, loopsCleared = m.clearLoopsForSessionSwitch()
}
rows := initialTranscript()
rows = appendRow(rows, rowSystem, m.formatResumeSummary(*session, len(events)))
if loopsCleared > 0 {
rows = appendRow(rows, rowSystem, fmt.Sprintf("Stopped %d loop(s) tied to the previous session.", loopsCleared))
}
rows = appendTranscriptRowsDedup(rows, transcriptRowsFromSessionEvents(events))
m.transcript = rows
// Every rehydrated row is settled by construction, so resetting the flush
+40
View File
@@ -108,6 +108,46 @@ func TestPromptSubmitPersistsTUISessionEvents(t *testing.T) {
}
}
func TestAppendSessionEventsBatchesAndUpdatesActiveSession(t *testing.T) {
store := testSessionStore(t)
session, err := store.Create(sessions.CreateInput{SessionID: "tui_batch", Title: "batch"})
if err != nil {
t.Fatalf("Create returned error: %v", err)
}
m := model{
sessionStore: store,
activeSession: session,
}
next, rows := m.appendSessionEvents([]pendingSessionEvent{
{Type: sessions.EventMessage, Payload: map[string]any{"role": "assistant", "content": "done"}},
{Type: sessions.EventUsage, Payload: map[string]any{"totalTokens": 3}},
})
if len(rows) != 0 {
t.Fatalf("appendSessionEvents returned error rows: %#v", rows)
}
if next.activeSession.EventCount != 2 || next.activeSession.LastEventType != sessions.EventUsage {
t.Fatalf("active session metadata not updated from batch: %#v", next.activeSession)
}
if len(next.sessionEvents) != 2 || next.sessionEvents[0].Sequence != 1 || next.sessionEvents[1].Sequence != 2 {
t.Fatalf("in-memory session events not updated from batch: %#v", next.sessionEvents)
}
events, err := store.ReadEvents(session.SessionID)
if err != nil {
t.Fatalf("ReadEvents returned error: %v", err)
}
if got := eventTypes(events); !equalEventTypes(got, []sessions.EventType{sessions.EventMessage, sessions.EventUsage}) {
t.Fatalf("unexpected persisted event types: %#v", got)
}
loaded, err := store.Get(session.SessionID)
if err != nil {
t.Fatalf("Get returned error: %v", err)
}
if loaded == nil || loaded.EventCount != next.activeSession.EventCount || loaded.LastEventType != next.activeSession.LastEventType {
t.Fatalf("store metadata diverged from active session: store=%#v active=%#v", loaded, next.activeSession)
}
}
func TestPromptWithoutProviderDoesNotCreateSession(t *testing.T) {
store := testSessionStore(t)
m := newModel(context.Background(), Options{SessionStore: store})
+205
View File
@@ -0,0 +1,205 @@
package tui
import (
"strings"
tea "charm.land/bubbletea/v2"
"github.com/Gitlawb/zero/internal/skills"
)
// Skill slash invocation: "/<skill-name> [args]" runs an installed skill
// directly, mirroring user commands (.zero/commands). Skills were previously
// model-pulled only (the skill tool), which made invocation a matter of model
// discretion; typing the skill's name makes it deterministic. Precedence is
// builtin > user command > skill: parseCommand resolves builtins first, and the
// commandUnknown fallback tries user commands before skills.
// handleSkillCommand resolves a "/name args" that wasn't a builtin or a user
// command against the installed skills. On a match it launches a normal agent
// turn whose prompt inlines the skill body (its instructions) followed by the
// typed args, returning handled=true. handled=false means no skill matched, so
// the caller falls through to "unknown command".
func (m model) handleSkillCommand(raw string) (model, tea.Cmd, bool) {
name, args := splitUserCommand(raw)
if name == "" {
return m, nil, false
}
skill, ok := m.lookupSkillCommand(name)
if !ok {
return m, nil, false
}
body := strings.TrimSpace(skill.Content)
if body == "" {
// The name matched a real skill, so falling through to "unknown command"
// would mislead; surface the actual problem instead.
m.transcript = reduceTranscript(m.transcript, transcriptAction{
kind: actionAppendError,
text: "skill /" + name + " has an empty SKILL.md body (" + skill.Path + ")",
})
return m, nil, true
}
return m.launchOrDeferExpandedPrompt(raw, skillInvocationPrompt(body, args))
}
// bareSkillInvocationNote is appended when a skill is invoked with no request.
// The body alone is instructions with no target ("review the PR" — which PR?),
// and without this note the model improvises one instead of asking. The wording
// is conditional so self-contained skills (no target needed) still just run.
const bareSkillInvocationNote = "The user invoked this skill directly without providing a request. " +
"If these instructions need a target or details that are not already clear from the conversation " +
"(which pull request, file, branch, topic, …), ask for them first — do not guess or pick one yourself. " +
"If the instructions are self-contained, proceed."
// skillInvocationPrompt builds the agent prompt for a skill invocation: the
// skill body (its instructions), then either the user's request or — for a bare
// invocation — the ask-first note above. Mirrors usercommands.Expand's
// no-placeholder behavior for the args case.
func skillInvocationPrompt(body, args string) string {
if args != "" {
return body + "\n\n" + args
}
return body + "\n\n" + bareSkillInvocationNote
}
// launchOrDeferExpandedPrompt applies the same run-state guards the plain
// commandPrompt path has to an expanded (user command / skill) prompt: while
// exiting nothing may start, a prompt submitted mid-run is queued for the next
// turn, and compaction-in-flight warns instead of racing the compactor. The
// EXPANDED prompt is what gets queued — the queue flush path resubmits text as
// a literal prompt, so queuing the raw "/name args" would send it to the model
// as prose instead of re-dispatching it. raw is the typed invocation; on the
// compaction warning it is restored into the composer for an easy re-submit
// (the commandUnknown dispatch cleared the composer before we got here, unlike
// the plain-prompt path whose guard fires before the clear). Empty raw (the
// picker path has no typed form) restores nothing.
func (m model) launchOrDeferExpandedPrompt(raw, prompt string) (model, tea.Cmd, bool) {
if m.exiting {
return m, nil, true
}
if m.pending {
return m.queueMessage(prompt), nil, true
}
if m.compactInFlight {
if strings.TrimSpace(raw) != "" {
m.input.SetValue(raw)
m.input.CursorEnd()
}
m.transcript = reduceTranscript(m.transcript, transcriptAction{
kind: actionAppendSystem,
text: "Compact\nstatus: warning\nCompaction is running. Re-run the command when it finishes.",
})
return m, nil, true
}
next, teaCmd := m.launchPrompt(prompt)
return next, teaCmd, true
}
// takenSlashNames returns every slash token already claimed by a builtin
// command (or alias) or a user command — the names a typed "/x" would dispatch
// to before ever reaching a skill.
func (m model) takenSlashNames() map[string]bool {
taken := map[string]bool{}
for _, command := range commandDefinitions {
taken[strings.TrimPrefix(command.name, "/")] = true
for _, alias := range command.aliases {
taken[strings.TrimPrefix(alias, "/")] = true
}
}
for _, cmd := range m.userCommands {
taken[cmd.Name] = true
}
return taken
}
// chooseSkillFromPicker handles Enter on a skill-picker row. Most skills want a
// request ("review WHICH pr?"), so selection fills the composer with
// "/name " — cursor at the end — for the user to complete and submit (Enter
// again runs it bare). Skills whose names cannot be typed as a slash command
// (non-slash shapes, or names shadowed by a builtin or user command) fall back
// to running immediately: the picker is the only path that reaches them, and an
// unfillable composer would be a dead end.
func (m model) chooseSkillFromPicker(item pickerItem) (model, tea.Cmd) {
slash := skillSlashName(item.Value)
if slash == "" || m.takenSlashNames()[slash] {
return m.invokeSkillByName(item.Value)
}
m.input.SetValue("/" + slash + " ")
m.input.CursorEnd()
return m, nil
}
// invokeSkillByName runs the installed skill with the given EXACT raw name (the
// skill-picker path). The same run-state guards as slash invocation apply; the
// picker offers no args affordance, so the prompt is the skill body alone.
func (m model) invokeSkillByName(name string) (model, tea.Cmd) {
for _, skill := range m.installedSkills() {
if strings.TrimSpace(skill.Name) != name {
continue
}
body := strings.TrimSpace(skill.Content)
if body == "" {
m.transcript = reduceTranscript(m.transcript, transcriptAction{
kind: actionAppendError,
text: "skill " + name + " has an empty SKILL.md body (" + skill.Path + ")",
})
return m, nil
}
next, teaCmd, _ := m.launchOrDeferExpandedPrompt("", skillInvocationPrompt(body, ""))
return next, teaCmd
}
// The picker row came from a slightly older load (TTL cache) and the skill
// has since been removed.
m.transcript = reduceTranscript(m.transcript, transcriptAction{
kind: actionAppendError,
text: "skill " + name + " is no longer installed",
})
return m, nil
}
// lookupSkillCommand returns the installed skill whose slash name matches the
// given (lowercased) name. Linear scan over a fresh load — skills are re-read
// per invocation so a skill installed mid-session is invocable without a
// restart, and the body is never held on the model.
func (m model) lookupSkillCommand(name string) (skills.Skill, bool) {
name = strings.ToLower(strings.TrimSpace(name))
if name == "" {
return skills.Skill{}, false
}
for _, skill := range m.installedSkills() {
if skillSlashName(skill.Name) == name {
return skill, true
}
}
return skills.Skill{}, false
}
// installedSkills returns the session's installed skills (default dir merged
// with plugin skill roots) via the injected loader, or nil when the session has
// no skills wiring (e.g. bare test models).
func (m model) installedSkills() []skills.Skill {
if m.loadSkills == nil {
return nil
}
return m.loadSkills()
}
// skillSlashName maps a skill's frontmatter name to its slash-command form:
// lowercased, and only if it fits the slash-token shape (letters, digits,
// dot/underscore/hyphen — a superset of user-command names, since skill names
// are free-form frontmatter). Returns "" for names that cannot be typed as a
// /command (e.g. containing spaces); those skills remain loadable by the model
// via the skill tool and are still listed by /skills.
func skillSlashName(name string) string {
name = strings.ToLower(strings.TrimSpace(name))
if name == "" {
return ""
}
for _, r := range name {
valid := (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' || r == '_' || r == '.'
if !valid {
return ""
}
}
return name
}
+412
View File
@@ -0,0 +1,412 @@
package tui
import (
"context"
"strings"
"testing"
tea "charm.land/bubbletea/v2"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/skills"
)
// newSkillTestModel builds a model with an injected skills loader (and the
// matching palette metadata), no filesystem involved.
func newSkillTestModel(t *testing.T, installed ...skills.Skill) model {
t.Helper()
infos := make([]agent.SkillInfo, 0, len(installed))
for _, s := range installed {
infos = append(infos, agent.SkillInfo{Name: s.Name, Description: s.Description})
}
return newModel(context.Background(), Options{
Cwd: t.TempDir(),
LoadSkills: func() []skills.Skill { return installed },
AgentOptions: agent.Options{Skills: infos},
})
}
func submitInput(t *testing.T, m model, input string) model {
t.Helper()
m.input.SetValue(input)
updated, _ := m.Update(testKey(tea.KeyEnter))
return updated.(model)
}
func TestSkillCommandInvokesAndSubmits(t *testing.T) {
m := newSkillTestModel(t, skills.Skill{
Name: "deploy-checks",
Description: "Run the pre-deploy verification suite.",
Content: "Run every verification step and summarize failures.",
Path: "/skills/deploy-checks/SKILL.md",
})
next := submitInput(t, m, "/deploy-checks ship v2 to production")
if transcriptContains(next.transcript, "unknown command") {
t.Fatalf("an installed skill must not be 'unknown', got %#v", next.transcript)
}
if !transcriptContains(next.transcript, "Run every verification step and summarize failures.") {
t.Fatalf("skill body should be inlined into the prompt, got %#v", next.transcript)
}
if !transcriptContains(next.transcript, "ship v2 to production") {
t.Fatalf("typed args should follow the skill body, got %#v", next.transcript)
}
}
func TestSkillCommandWithoutArgs(t *testing.T) {
m := newSkillTestModel(t, skills.Skill{Name: "reviewer", Content: "Review the diff for correctness."})
next := submitInput(t, m, "/reviewer")
if !transcriptContains(next.transcript, "Review the diff for correctness.") {
t.Fatalf("bare skill invocation should submit the body, got %#v", next.transcript)
}
// A bare invocation carries the ask-first note: instructions with no target
// must make the model ask ("which PR?") instead of improvising one.
if !transcriptContains(next.transcript, "ask for them first") {
t.Fatalf("bare invocation should carry the clarify-first note, got %#v", next.transcript)
}
}
// An invocation WITH a request must not carry the ask-first note — the user
// already said what to apply the skill to.
func TestSkillCommandWithArgsHasNoClarifyNote(t *testing.T) {
m := newSkillTestModel(t, skills.Skill{Name: "reviewer", Content: "Review the diff."})
next := submitInput(t, m, "/reviewer PR 484 reconnect changes")
if transcriptContains(next.transcript, "ask for them first") {
t.Fatalf("args invocation must not carry the clarify note, got %#v", next.transcript)
}
}
// A skill's frontmatter name is free-form; invocation matches its lowercased
// slash form.
func TestSkillCommandNameIsCaseInsensitive(t *testing.T) {
m := newSkillTestModel(t, skills.Skill{Name: "Deploy-Checks", Content: "Check things."})
next := submitInput(t, m, "/deploy-checks")
if !transcriptContains(next.transcript, "Check things.") {
t.Fatalf("uppercase frontmatter name should be invocable lowercased, got %#v", next.transcript)
}
}
// Precedence: a user command (.zero/commands) shadows a same-named skill.
func TestUserCommandShadowsSkill(t *testing.T) {
root := t.TempDir()
writeUserCommand(t, root, "greet.md", "Say hello from the user command.")
m := newModel(context.Background(), Options{
Cwd: root,
LoadSkills: func() []skills.Skill { return []skills.Skill{{Name: "greet", Content: "Skill body must not run."}} },
})
next := submitInput(t, m, "/greet")
if !transcriptContains(next.transcript, "Say hello from the user command.") {
t.Fatalf("user command should win the name collision, got %#v", next.transcript)
}
if transcriptContains(next.transcript, "Skill body must not run.") {
t.Fatalf("shadowed skill must not run, got %#v", next.transcript)
}
}
// An empty skill body is a real, named match — surface the problem instead of
// falling through to a misleading "unknown command".
func TestSkillCommandEmptyBody(t *testing.T) {
m := newSkillTestModel(t, skills.Skill{Name: "hollow", Path: "/skills/hollow/SKILL.md"})
next := submitInput(t, m, "/hollow")
if transcriptContains(next.transcript, "unknown command") {
t.Fatalf("an empty skill must not be 'unknown', got %#v", next.transcript)
}
if !transcriptContains(next.transcript, "empty SKILL.md body") {
t.Fatalf("empty-body error should be surfaced, got %#v", next.transcript)
}
}
func TestUnknownSlashStillUnknownWithSkillsInstalled(t *testing.T) {
m := newSkillTestModel(t, skills.Skill{Name: "reviewer", Content: "Review."})
next := submitInput(t, m, "/definitelynotaskill")
if !transcriptContains(next.transcript, "unknown command") {
t.Fatalf("non-matching name should still be unknown, got %#v", next.transcript)
}
}
func TestSkillSlashName(t *testing.T) {
cases := map[string]string{
"deploy-checks": "deploy-checks",
"Deploy-Checks": "deploy-checks",
" pdf_tools ": "pdf_tools",
"v2.migrate": "v2.migrate",
"has space": "",
"emoji✨": "",
"": "",
"slash/inside": "",
"tab\tseparated": "",
}
for in, want := range cases {
if got := skillSlashName(in); got != want {
t.Errorf("skillSlashName(%q) = %q, want %q", in, got, want)
}
}
}
func TestSkillAppearsInAutocomplete(t *testing.T) {
m := newSkillTestModel(t, skills.Skill{Name: "deploy-checks", Description: "Run the pre-deploy suite.", Content: "x"})
got := m.matchCommandSuggestions("/dep")
found := false
for _, s := range got {
if s.Name == "/deploy-checks" {
found = true
if s.Desc != "Run the pre-deploy suite. (skill)" {
t.Fatalf("skill suggestion should be labeled, got %q", s.Desc)
}
}
}
if !found {
t.Fatalf("skill /deploy-checks should appear for '/dep', got %#v", got)
}
}
// A skill shadowed by a builtin (or its alias) or a user command would be dead
// at dispatch time, so the palette must not advertise it.
func TestShadowedSkillNotSuggested(t *testing.T) {
root := t.TempDir()
writeUserCommand(t, root, "deploy.md", "User deploy.")
installed := []skills.Skill{
{Name: "help", Content: "x"}, // builtin collision
{Name: "sessions", Content: "x"}, // builtin ALIAS collision (/resume alias)
{Name: "deploy", Content: "x"}, // user-command collision
}
infos := make([]agent.SkillInfo, 0, len(installed))
for _, s := range installed {
infos = append(infos, agent.SkillInfo{Name: s.Name})
}
m := newModel(context.Background(), Options{
Cwd: root,
LoadSkills: func() []skills.Skill { return installed },
AgentOptions: agent.Options{Skills: infos},
})
// Assert on the advertised NAME with a skill-marked Desc, not on the exact
// fallback Desc format — the invariant is that no skill row carries a
// builtin/user-command name, regardless of description wording.
for _, token := range []string{"/hel", "/ses", "/dep"} {
for _, s := range m.matchSkillSuggestions(token) {
if s.Name == "/help" || s.Name == "/sessions" || s.Name == "/deploy" {
t.Fatalf("shadowed skill advertised for %q: %#v", token, s)
}
}
}
}
// Invalid slash shapes (spaces etc.) and a bare "/" must not surface skills.
func TestSkillSuggestionEdgeCases(t *testing.T) {
m := newSkillTestModel(t, skills.Skill{Name: "has space", Description: "not invocable", Content: "x"})
if got := m.matchSkillSuggestions("/has"); len(got) != 0 {
t.Fatalf("invalid slash-shape skill must not be suggested, got %#v", got)
}
if got := m.matchSkillSuggestions("/"); got != nil {
t.Fatalf("bare '/' should not list skills, got %#v", got)
}
}
func TestSkillsCommandResolves(t *testing.T) {
command, ok := resolveCommand("/skills")
if !ok || command.kind != commandSkills {
t.Fatalf("resolveCommand(/skills) = %#v, %v", command, ok)
}
}
// With skills installed, /skills opens a searchable picker (like /model).
func TestSkillsCommandOpensPicker(t *testing.T) {
m := newSkillTestModel(t,
skills.Skill{Name: "deploy-checks", Description: "Run the pre-deploy suite.", Content: "x"},
skills.Skill{Name: "has space", Description: "tool-only skill", Content: "x"},
)
next := submitInput(t, m, "/skills")
if next.picker == nil || next.picker.kind != pickerSkill {
t.Fatalf("/skills should open the skill picker, got %#v", next.picker)
}
labels := map[string]string{}
for _, item := range next.picker.items {
labels[item.Label] = item.Value
}
if labels["/deploy-checks"] != "deploy-checks" {
t.Fatalf("slash-invocable skill should show its slash form, got %#v", labels)
}
// A non-slash-shaped name is still selectable (bare label, exact-name value).
if labels["has space"] != "has space" {
t.Fatalf("non-slash skill should be listed bare but selectable, got %#v", labels)
}
}
// Enter on a picker row fills the composer with "/name " so the user can add
// their request (which PR? which file?) before submitting — it must NOT fire
// the skill with no request attached.
func TestSkillPickerEnterFillsComposer(t *testing.T) {
m := newSkillTestModel(t, skills.Skill{Name: "reviewer", Content: "Review the diff for correctness."})
next := submitInput(t, m, "/skills")
if next.picker == nil {
t.Fatal("picker should be open")
}
updated, _ := next.Update(testKey(tea.KeyEnter))
after := updated.(model)
if after.picker != nil {
t.Fatal("picker should close on Enter")
}
if got := after.input.Value(); got != "/reviewer " {
t.Fatalf("composer should be filled with the invocation, got %q", got)
}
if transcriptContains(after.transcript, "Review the diff for correctness.") {
t.Fatalf("skill must not run before the user submits, got %#v", after.transcript)
}
// A second Enter submits the (bare) invocation and runs the skill.
final, _ := after.Update(testKey(tea.KeyEnter))
done := final.(model)
if !transcriptContains(done.transcript, "Review the diff for correctness.") {
t.Fatalf("submitting the filled composer should run the skill, got %#v", done.transcript)
}
}
// Picker selection is by exact name, so a skill shadowed by a builtin (dead on
// the slash path) is still runnable from the picker.
func TestSkillPickerRunsBuiltinShadowedSkill(t *testing.T) {
m := newSkillTestModel(t, skills.Skill{Name: "help", Description: "shadowed", Content: "Shadowed skill body."})
next := submitInput(t, m, "/skills")
if next.picker == nil {
t.Fatal("picker should be open")
}
updated, _ := next.Update(testKey(tea.KeyEnter))
after := updated.(model)
if !transcriptContains(after.transcript, "Shadowed skill body.") {
t.Fatalf("picker must run the shadowed skill directly, got %#v", after.transcript)
}
}
func TestSkillsCommandEmptyState(t *testing.T) {
m := newModel(context.Background(), Options{Cwd: t.TempDir()})
next := submitInput(t, m, "/skills")
if !transcriptContains(next.transcript, "No skills installed.") {
t.Fatalf("empty state should say no skills, got %#v", next.transcript)
}
}
// Run-state guards: an expanded skill prompt must not race an active run — it
// queues (as the EXPANDED prompt, since the queue flush submits literal text).
func TestSkillInvocationQueuedWhileRunPending(t *testing.T) {
m := newSkillTestModel(t, skills.Skill{Name: "reviewer", Content: "Review the diff."})
m.pending = true
next, _, handled := m.handleSkillCommand("/reviewer focus on tests")
if !handled {
t.Fatal("skill must be handled even while a run is pending")
}
if next.queuedMessage == "" || !strings.Contains(next.queuedMessage, "Review the diff.") {
t.Fatalf("expanded prompt should be queued, got %q", next.queuedMessage)
}
if !strings.Contains(next.queuedMessage, "focus on tests") {
t.Fatalf("queued prompt should carry the args, got %q", next.queuedMessage)
}
}
func TestSkillInvocationDroppedWhileExiting(t *testing.T) {
m := newSkillTestModel(t, skills.Skill{Name: "reviewer", Content: "Review."})
m.exiting = true
next, cmd, handled := m.handleSkillCommand("/reviewer")
if !handled || cmd != nil || next.queuedMessage != "" {
t.Fatalf("exiting must swallow the invocation: handled=%v cmd=%v queued=%q", handled, cmd, next.queuedMessage)
}
}
func TestSkillInvocationWarnsDuringCompaction(t *testing.T) {
m := newSkillTestModel(t, skills.Skill{Name: "reviewer", Content: "Review."})
m.compactInFlight = true
next, _, handled := m.handleSkillCommand("/reviewer")
if !handled {
t.Fatal("skill must be handled during compaction")
}
if !transcriptContains(next.transcript, "Compaction is running") {
t.Fatalf("compaction warning expected, got %#v", next.transcript)
}
// The typed invocation is restored for an easy re-submit (the dispatch path
// cleared the composer before the guard could fire).
if got := next.input.Value(); got != "/reviewer" {
t.Fatalf("typed invocation should be restored into the composer, got %q", got)
}
}
// The same guard now protects user commands (previously they could start a
// second concurrent run).
func TestUserCommandQueuedWhileRunPending(t *testing.T) {
root := t.TempDir()
writeUserCommand(t, root, "greet.md", "Say hello to $1.")
m := newModel(context.Background(), Options{Cwd: root})
m.pending = true
next, _, handled := m.handleUserCommand("/greet world")
if !handled {
t.Fatal("user command must be handled while pending")
}
if !strings.Contains(next.queuedMessage, "Say hello to world.") {
t.Fatalf("expanded user-command prompt should be queued, got %q", next.queuedMessage)
}
}
// Two skills whose names collide after lowercasing map to one slash name; only
// the one dispatch will run may be advertised.
func TestCaseCollidingSkillsAdvertisedOnce(t *testing.T) {
m := newSkillTestModel(t,
skills.Skill{Name: "Deploy", Description: "wins", Content: "a"},
skills.Skill{Name: "deploy", Description: "loses", Content: "b"},
)
rows := 0
for _, s := range m.matchSkillSuggestions("/dep") {
if s.Name == "/deploy" {
rows++
if !strings.Contains(s.Desc, "wins") {
t.Fatalf("advertised row must describe the skill dispatch runs, got %q", s.Desc)
}
}
}
if rows != 1 {
t.Fatalf("case-colliding skills must yield exactly one /deploy row, got %d", rows)
}
}
// The palette reads through the loader, so a skill installed mid-session (the
// loader's next result) appears without a restart.
func TestSkillPaletteReadsThroughLoader(t *testing.T) {
installed := []skills.Skill{}
m := newModel(context.Background(), Options{
Cwd: t.TempDir(),
LoadSkills: func() []skills.Skill { return installed },
})
if got := m.matchSkillSuggestions("/lat"); len(got) != 0 {
t.Fatalf("no skills installed yet, got %#v", got)
}
installed = append(installed, skills.Skill{Name: "late-arrival", Description: "installed mid-session", Content: "x"})
got := m.matchSkillSuggestions("/lat")
if len(got) != 1 || got[0].Name != "/late-arrival" {
t.Fatalf("mid-session skill should appear via the loader, got %#v", got)
}
}
+21 -3
View File
@@ -92,10 +92,14 @@ func (m model) emptyStateLines(width int) []string {
// emptyStateExamples seeds the first prompt with a few representative asks.
const emptyStateExamples = `Try "explain this codebase" · "fix the failing test" · "add a --json flag"`
// emptyStateOrientation renders a faint "cwd · branch · model" line for the home
// screen, omitting any piece that's unknown. Empty when nothing is known.
// emptyStateOrientation renders a faint "version · cwd · branch · model" line
// for the home screen, omitting any piece that's unknown. Empty when nothing
// is known.
func (m model) emptyStateOrientation() string {
parts := make([]string, 0, 3)
parts := make([]string, 0, 4)
if version := displayVersion(m.appVersion); version != "" {
parts = append(parts, version)
}
if cwd := strings.TrimSpace(m.cwd); cwd != "" {
parts = append(parts, shortenPath(cwd))
}
@@ -111,6 +115,20 @@ func (m model) emptyStateOrientation() string {
return zeroTheme.faint.Render(strings.Join(parts, " · "))
}
// displayVersion formats the CLI build version for display: numeric releases
// gain a "v" prefix (0.2.0 → v0.2.0), anything else (dev, git SHAs) passes
// through unchanged. Empty stays empty so unset builds show nothing.
func displayVersion(version string) string {
version = strings.TrimSpace(version)
if version == "" {
return ""
}
if version[0] >= '0' && version[0] <= '9' {
return "v" + version
}
return version
}
func zeroWordmarkLines() []string {
lines := make([]string, 0, minInt(len(zeroWordmarkPrefixLines), len(zeroWordmarkOLines)))
for index := 0; index < len(zeroWordmarkPrefixLines) && index < len(zeroWordmarkOLines); index++ {
+23
View File
@@ -21,6 +21,29 @@ func TestEmptyStateShowsBrandAndTaglineOnly(t *testing.T) {
assertNotContains(t, view, "fix the failing test in internal/tools")
}
func TestEmptyStateShowsVersion(t *testing.T) {
m := newModel(context.Background(), Options{Version: "0.2.0"})
m.width, m.height = 100, 30
view := plainRender(t, m.View())
assertContains(t, view, "v0.2.0")
}
func TestDisplayVersion(t *testing.T) {
cases := []struct{ in, want string }{
{"0.2.0", "v0.2.0"},
{"v0.2.0", "v0.2.0"},
{"dev", "dev"},
{" ", ""},
{"", ""},
}
for _, tc := range cases {
if got := displayVersion(tc.in); got != tc.want {
t.Fatalf("displayVersion(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}
func TestEmptyStateDisappearsAfterFirstRow(t *testing.T) {
m := newModel(context.Background(), Options{})
m.width, m.height = 100, 30
+3 -2
View File
@@ -30,8 +30,9 @@ func (m model) handleUserCommand(raw string) (model, tea.Cmd, bool) {
if strings.TrimSpace(prompt) == "" {
return m, nil, false
}
next, teaCmd := m.launchPrompt(prompt)
return next, teaCmd, true
// Same run-state guards as a plain prompt: a user command invoked mid-run is
// queued (as its expanded prompt), not raced into a second concurrent turn.
return m.launchOrDeferExpandedPrompt(raw, prompt)
}
// lookupUserCommand returns the loaded user command with the given (lowercased)
+7
View File
@@ -218,6 +218,13 @@ func (m model) statusLine(width int) string {
} else if summary := m.backgroundTerminalSummary(); summary != "" {
left += separator + zeroTheme.muted.Render(summary)
}
// Active loops surface a persistent "↻ N loops · next 3:05pm" segment so a
// running loop is always visible (hidden during an exit/cancel confirm above).
if !m.exitConfirmActive && !m.cancelConfirmActive {
if loopSummary := m.loopFooterSummary(); loopSummary != "" {
left += separator + zeroTheme.accent.Render("↻ ") + zeroTheme.muted.Render(loopSummary)
}
}
rightGroups := []string{}
// Context-fill gauge: surface it down to the narrow tier (where it matters
+19 -2
View File
@@ -148,8 +148,8 @@ func applyStandaloneUpdate(ctx context.Context, result Result, executablePath st
if err := downloadFile(downloadCtx, asset.ChecksumURL, checksumPath); err != nil {
return nil, fmt.Errorf("download release checksum: %w", err)
}
if _, err := release.VerifySHA256Checksum(checksumPath); err != nil {
return nil, fmt.Errorf("verify release checksum: %w", err)
if err := verifyArchiveChecksum(checksumPath, asset.ArchiveName); err != nil {
return nil, err
}
extractDir := filepath.Join(tempDir, "extracted")
@@ -200,6 +200,23 @@ func applyStandaloneUpdate(ctx context.Context, result Result, executablePath st
return warnings, nil
}
// verifyArchiveChecksum verifies the checksum file at checksumPath and
// cross-checks it names expectedArchiveName. VerifySHA256Checksum hashes
// whichever file the checksum text names, not necessarily the archive we
// downloaded — this mirrors the cross-check VerifyReleaseChecksums already
// does, so a checksum file that names a different file can't be used to
// verify (and thereby vouch for) the wrong bytes before extraction.
func verifyArchiveChecksum(checksumPath string, expectedArchiveName string) error {
verified, err := release.VerifySHA256Checksum(checksumPath)
if err != nil {
return fmt.Errorf("verify release checksum: %w", err)
}
if verified.ArchiveName != expectedArchiveName {
return fmt.Errorf("checksum file references %s, expected %s", verified.ArchiveName, expectedArchiveName)
}
return nil
}
// installBinary stages sourcePath next to targetPath (same directory, so the
// final rename is atomic/same-filesystem) and then swaps it into place.
func installBinary(sourcePath string, targetPath string) error {
+35
View File
@@ -300,3 +300,38 @@ func TestApplyStandaloneUpdateRejectsChecksumMismatch(t *testing.T) {
t.Fatalf("executable should be untouched after checksum failure, got %q", data)
}
}
// VerifySHA256Checksum hashes whichever file the checksum text names, which
// isn't necessarily the archive the caller asked to verify. A checksum file
// that correctly verifies against a DIFFERENT (but real, correctly-hashed)
// file must still be rejected, not treated as vouching for the requested
// archive.
func TestVerifyArchiveChecksumRejectsFilenameMismatch(t *testing.T) {
dir := t.TempDir()
decoyName := "decoy.tar.gz"
decoyPath := filepath.Join(dir, decoyName)
if err := os.WriteFile(decoyPath, []byte("decoy contents"), 0o644); err != nil {
t.Fatalf("WriteFile decoy: %v", err)
}
decoyChecksum, err := release.SHA256File(decoyPath)
if err != nil {
t.Fatalf("SHA256File: %v", err)
}
checksumText, err := release.FormatSHA256Checksum(decoyChecksum, decoyName)
if err != nil {
t.Fatalf("FormatSHA256Checksum: %v", err)
}
checksumPath := filepath.Join(dir, "real.tar.gz.sha256")
if err := os.WriteFile(checksumPath, []byte(checksumText), 0o644); err != nil {
t.Fatalf("WriteFile checksum: %v", err)
}
err = verifyArchiveChecksum(checksumPath, "real.tar.gz")
if err == nil {
t.Fatal("expected error when checksum file references a different archive")
}
if !strings.Contains(err.Error(), decoyName) || !strings.Contains(err.Error(), "real.tar.gz") {
t.Fatalf("expected error to name both the referenced file and the expected one, got %q", err)
}
}
+8
View File
@@ -87,6 +87,14 @@ func extractZip(archivePath string, destDir string) error {
}
continue
}
// Release archives only ever contain regular files and directories;
// reject anything else (symlinks, devices) rather than silently write
// the link-target string (or other special content) out as an
// ordinary file — mirrors extractTarGz's rejection of non-regular tar
// entries.
if !entry.Mode().IsRegular() {
return fmt.Errorf("unsupported archive entry type for %s", entry.Name)
}
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return err
}
+40
View File
@@ -148,6 +148,46 @@ func TestExtractZipRejectsPathTraversal(t *testing.T) {
}
}
// A zip entry with the Unix symlink mode bit set must be rejected, not
// silently written out as an ordinary file containing the link-target
// string — mirroring extractTarGz's rejection of non-regular tar entries.
func TestExtractZipRejectsSymlinkEntry(t *testing.T) {
dir := t.TempDir()
archivePath := filepath.Join(dir, "archive.zip")
file, err := os.Create(archivePath)
if err != nil {
t.Fatalf("Create archive: %v", err)
}
zipWriter := zip.NewWriter(file)
header := &zip.FileHeader{Name: "zero"}
header.SetMode(os.ModeSymlink | 0o777)
writer, err := zipWriter.CreateHeader(header)
if err != nil {
t.Fatalf("CreateHeader: %v", err)
}
if _, err := writer.Write([]byte("/some/other/path")); err != nil {
t.Fatalf("Write symlink target: %v", err)
}
if err := zipWriter.Close(); err != nil {
t.Fatalf("close zip writer: %v", err)
}
if err := file.Close(); err != nil {
t.Fatalf("close archive file: %v", err)
}
destDir := filepath.Join(dir, "extracted")
if err := os.MkdirAll(destDir, 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
if err := extractArchive(archivePath, destDir); err == nil {
t.Fatal("expected extractArchive to reject a symlink entry")
}
if _, err := os.Lstat(filepath.Join(destDir, "zero")); err == nil {
t.Fatal("symlink entry should not have been written to the destination")
}
}
func TestFindByBasenameSearchesRecursively(t *testing.T) {
dir := t.TempDir()
nested := filepath.Join(dir, "helpers")
+24 -1
View File
@@ -5,6 +5,12 @@ package update
import (
"fmt"
"os"
"time"
)
const (
restoreRenameRetryAttempts = 10
restoreRenameRetryDelay = 100 * time.Millisecond
)
// replaceBinary installs newPath over targetPath. Windows will not let a
@@ -18,7 +24,11 @@ func replaceBinary(targetPath string, newPath string) error {
return fmt.Errorf("rename running binary aside: %w", err)
}
if err := os.Rename(newPath, targetPath); err != nil {
if restoreErr := os.Rename(oldPath, targetPath); restoreErr != nil {
// Retry the restore: a transient Windows file lock (antivirus/indexer
// scanning the just-renamed file, a lingering handle) can make a rename
// fail momentarily, and here failure means targetPath is left missing
// entirely rather than merely stale — worth a short retry to avoid that.
if restoreErr := renameWithRetry(oldPath, targetPath); restoreErr != nil {
return fmt.Errorf("install new binary: %w; additionally failed to restore the original binary: %v (original preserved at %s)", err, restoreErr, oldPath)
}
return fmt.Errorf("install new binary: %w", err)
@@ -26,6 +36,19 @@ func replaceBinary(targetPath string, newPath string) error {
return nil
}
func renameWithRetry(oldPath string, newPath string) error {
var lastErr error
for attempt := 0; attempt < restoreRenameRetryAttempts; attempt++ {
if err := os.Rename(oldPath, newPath); err == nil {
return nil
} else {
lastErr = err
}
time.Sleep(restoreRenameRetryDelay)
}
return lastErr
}
// CleanupStaleBinary best-effort removes a "<path>.old" file left behind by a
// previous replaceBinary call once the old process holding it has exited.
// Callers should invoke this once at startup for the current executable.
+65
View File
@@ -0,0 +1,65 @@
//go:build windows
package update
import (
"os"
"path/filepath"
"testing"
)
func TestReplaceBinaryReplacesRunningBinary(t *testing.T) {
dir := t.TempDir()
targetPath := filepath.Join(dir, "zero.exe")
newPath := filepath.Join(dir, "zero.exe.new")
if err := os.WriteFile(targetPath, []byte("old-binary"), 0o755); err != nil {
t.Fatalf("WriteFile target: %v", err)
}
if err := os.WriteFile(newPath, []byte("new-binary"), 0o755); err != nil {
t.Fatalf("WriteFile new: %v", err)
}
if err := replaceBinary(targetPath, newPath); err != nil {
t.Fatalf("replaceBinary: %v", err)
}
data, err := os.ReadFile(targetPath)
if err != nil {
t.Fatalf("ReadFile target: %v", err)
}
if string(data) != "new-binary" {
t.Fatalf("target content = %q, want %q", data, "new-binary")
}
if _, err := os.Stat(targetPath + ".old"); err != nil {
t.Fatalf("expected the original binary to be preserved at %s.old: %v", targetPath, err)
}
}
func TestRenameWithRetrySucceedsImmediately(t *testing.T) {
dir := t.TempDir()
src := filepath.Join(dir, "src")
dst := filepath.Join(dir, "dst")
if err := os.WriteFile(src, []byte("data"), 0o644); err != nil {
t.Fatalf("WriteFile src: %v", err)
}
if err := renameWithRetry(src, dst); err != nil {
t.Fatalf("renameWithRetry: %v", err)
}
if _, err := os.Stat(dst); err != nil {
t.Fatalf("expected dst to exist after rename: %v", err)
}
}
// A permanently-failing rename (source never appears) must exhaust its
// retries and surface the underlying error, rather than retrying forever.
func TestRenameWithRetryFailsAfterExhaustingAttempts(t *testing.T) {
dir := t.TempDir()
missing := filepath.Join(dir, "does-not-exist")
dst := filepath.Join(dir, "dst")
if err := renameWithRetry(missing, dst); err == nil {
t.Fatal("expected renameWithRetry to fail for a source that never appears")
}
}
+13 -6
View File
@@ -49,8 +49,12 @@ type ProviderSnapshot struct {
APIModel string `json:"apiModel,omitempty"`
Active bool `json:"active"`
APIKeySet bool `json:"apiKeySet"`
Status string `json:"status,omitempty"`
Message string `json:"message,omitempty"`
// OAuthLogin marks a keyless profile whose credential is a stored OAuth login
// (e.g. ChatGPT). Filled by callers with access to the oauth store — the
// snapshot builder itself does no secret I/O.
OAuthLogin bool `json:"oauthLogin,omitempty"`
Status string `json:"status,omitempty"`
Message string `json:"message,omitempty"`
}
type ModelSnapshotOptions struct {
@@ -164,10 +168,13 @@ func ProviderSnapshotFromProfile(profile config.ProviderProfile, active bool) Pr
BaseURL: redactProviderBaseURL(profile.BaseURL, profile.APIKey, profile.AuthHeaderValue),
Model: profile.Model,
Active: active,
// A profile can authenticate via a raw auth-header value instead of APIKey;
// treat either as a configured credential so auth-header-only profiles don't
// render as "not set".
APIKeySet: strings.TrimSpace(profile.APIKey) != "" || strings.TrimSpace(profile.AuthHeaderValue) != "",
// HasConfiguredCredential is the shared definition of "this profile is
// key-authed": inline key, raw auth-header value, or a key in the encrypted
// credential store (APIKeyStored). The resolver is pure and never loads
// stored keys into APIKey, so checking only the inline key rendered every
// stored-key profile as "api key: not set" — a healthy provider that read
// as broken.
APIKeySet: profile.HasConfiguredCredential(),
Status: "ok",
}
metadata, err := providers.ResolveRuntimeMetadata(profile, providers.Options{})
+3
View File
@@ -340,6 +340,9 @@ func TestProviderSnapshotAPIKeySetCountsAuthHeaderValue(t *testing.T) {
{"auth header only", config.ProviderProfile{Name: "p", AuthHeaderValue: "Bearer t"}, true},
{"both", config.ProviderProfile{Name: "p", APIKey: "sk-x", AuthHeaderValue: "Bearer t"}, true},
{"neither", config.ProviderProfile{Name: "p"}, false},
// A key in the encrypted credential store is a configured credential even
// though the pure resolver leaves APIKey empty.
{"stored key marker", config.ProviderProfile{Name: "p", APIKeyStored: true}, true},
{"whitespace api key only", config.ProviderProfile{Name: "p", APIKey: " "}, false},
{"whitespace auth header only", config.ProviderProfile{Name: "p", AuthHeaderValue: " "}, false},
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@gitlawb/zero",
"version": "0.1.0",
"version": "0.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@gitlawb/zero",
"version": "0.1.0",
"version": "0.2.0",
"cpu": [
"x64",
"arm64"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@gitlawb/zero",
"version": "0.1.0",
"version": "0.2.0",
"description": "Zero — a fast, multi-provider terminal coding agent.",
"type": "module",
"module": "bin/zero.js",
+83
View File
@@ -0,0 +1,83 @@
#!/usr/bin/env node
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { pathToFileURL } from 'node:url';
const SUMMARY_LIMIT = 280;
const STRUCTURED_FORMATS = new Set(['json', 'stream-json']);
function normalizeSummary(value) {
return String(value ?? '')
.replace(/\s+/g, ' ')
.trim()
.slice(0, SUMMARY_LIMIT);
}
function truncateSummary(value) {
return String(value ?? '').slice(0, SUMMARY_LIMIT);
}
function lastNonEmptyLine(contents) {
let last = '';
for (const line of String(contents ?? '').split(/\r?\n/)) {
if (line.trim()) {
last = line;
}
}
return last;
}
function summarizeStructured(contents) {
let summary;
for (const line of String(contents ?? '').split(/\r?\n/)) {
if (!line.trim()) {
continue;
}
let event;
try {
event = JSON.parse(line);
} catch {
continue;
}
if (event?.type === 'final' && typeof event.text === 'string') {
summary = event.text;
} else if (event?.type === 'error' && typeof event.message === 'string') {
summary = event.message;
}
}
if (summary !== undefined) {
return normalizeSummary(summary);
}
return normalizeSummary(lastNonEmptyLine(contents));
}
export function summarizeOutput(format, contents) {
const normalizedFormat = String(format ?? 'text').toLowerCase();
if (!STRUCTURED_FORMATS.has(normalizedFormat)) {
return truncateSummary(lastNonEmptyLine(contents));
}
return summarizeStructured(contents);
}
function isMainModule() {
return (
process.argv[1] !== undefined &&
pathToFileURL(resolve(process.argv[1])).href === import.meta.url
);
}
if (isMainModule()) {
const [format, outputFile] = process.argv.slice(2);
let contents = '';
try {
contents = readFileSync(outputFile, 'utf8');
} catch {
contents = '';
}
process.stdout.write(`${summarizeOutput(format, contents)}\n`);
}
+148
View File
@@ -0,0 +1,148 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { summarizeOutput } from './action-summary.mjs';
const scriptPath = fileURLToPath(new URL('./action-summary.mjs', import.meta.url));
const repoRoot = fileURLToPath(new URL('..', import.meta.url));
function jsonl(events) {
return events.map((event) => JSON.stringify(event)).join('\n');
}
test('stream-json final followed by run_end returns final text', () => {
const output = jsonl([
{ type: 'final', text: 'Done. Tests passed.' },
{ type: 'run_end', status: 'success', exitCode: 0 },
]);
assert.equal(summarizeOutput('stream-json', output), 'Done. Tests passed.');
});
test('stream-json error followed by run_end returns error message', () => {
const output = jsonl([
{ type: 'error', code: 'provider_error', message: 'provider request failed' },
{ type: 'run_end', status: 'error', exitCode: 3 },
]);
assert.equal(summarizeOutput('stream-json', output), 'provider request failed');
});
test('stream-json final then error returns error message', () => {
const output = jsonl([
{ type: 'final', text: 'partial answer' },
{ type: 'error', code: 'incomplete', message: 'run stopped with work unfinished' },
{ type: 'run_end', status: 'incomplete', exitCode: 4 },
]);
assert.equal(summarizeOutput('stream-json', output), 'run stopped with work unfinished');
});
test('json final followed by done returns final text', () => {
const output = jsonl([
{ type: 'final', text: 'Review complete.' },
{ type: 'done', exit_code: 0 },
]);
assert.equal(summarizeOutput('json', output), 'Review complete.');
});
test('json error followed by done returns error message', () => {
const output = jsonl([
{ type: 'error', code: 'exec_failed', message: 'command failed' },
{ type: 'done', exit_code: 1 },
]);
assert.equal(summarizeOutput('json', output), 'command failed');
});
test('json final then error returns error message', () => {
const output = jsonl([
{ type: 'final', text: 'partial answer' },
{ type: 'error', code: 'incomplete', message: 'run stopped with work unfinished' },
{ type: 'done', exit_code: 4 },
]);
assert.equal(summarizeOutput('json', output), 'run stopped with work unfinished');
});
test('text output returns last non-empty line', () => {
const output = '\nfirst line\n\n final streamed line \n';
assert.equal(summarizeOutput('text', output), ' final streamed line ');
});
test('unknown format falls back to text behavior', () => {
const output = 'alpha\n beta \n';
assert.equal(summarizeOutput('xml', output), ' beta ');
});
test('malformed structured output falls back safely', () => {
const output = '{not-json}\nraw fallback\n';
assert.equal(summarizeOutput('stream-json', output), 'raw fallback');
});
test('long multiline final becomes one-line 280-char summary', () => {
const text = `first line\n${'x'.repeat(400)}`;
const summary = summarizeOutput('stream-json', jsonl([{ type: 'final', text }]));
assert.equal(summary.length, 280);
assert.equal(summary.includes('\n'), false);
assert.equal(summary, `first line ${'x'.repeat(400)}`.slice(0, 280));
});
test('cli reads output file and prints summary', () => {
const dir = mkdtempSync(join(tmpdir(), 'zero-action-summary-'));
try {
const outputFile = join(dir, 'zero-output.jsonl');
writeFileSync(
outputFile,
jsonl([
{ type: 'final', text: 'CLI summary' },
{ type: 'run_end', status: 'success', exitCode: 0 },
]),
);
const result = spawnSync(process.execPath, [scriptPath, 'stream-json', outputFile], {
encoding: 'utf8',
});
assert.equal(result.status, 0);
assert.equal(result.stderr, '');
assert.equal(result.stdout, 'CLI summary\n');
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('cli runs when invoked with relative script path', () => {
const dir = mkdtempSync(join(tmpdir(), 'zero-action-summary-'));
try {
const outputFile = join(dir, 'zero-output.jsonl');
writeFileSync(
outputFile,
jsonl([
{ type: 'final', text: 'relative CLI summary' },
{ type: 'run_end', status: 'success', exitCode: 0 },
]),
);
const result = spawnSync(process.execPath, ['scripts/action-summary.mjs', 'stream-json', outputFile], {
cwd: repoRoot,
encoding: 'utf8',
});
assert.equal(result.status, 0);
assert.equal(result.stderr, '');
assert.equal(result.stdout, 'relative CLI summary\n');
} finally {
rmSync(dir, { recursive: true, force: true });
}
});