Compare commits

..
Author SHA1 Message Date
Gnanam c427052f9b 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 20:49:24 +05:30
Gnanam fce9569362 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.
2026-07-05 19:46:03 +05:30
Gnanam 3259ea3f6a 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.
2026-07-05 19:26:10 +05:30
Gnanam f6e298ad20 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.
2026-07-05 19:26:10 +05:30
Gnanam 15ddc79e75 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.
2026-07-05 19:26:10 +05:30
92 changed files with 286 additions and 7894 deletions

No files matched your search

+3 -14
View File
@@ -100,10 +100,9 @@ jobs:
go-version-file: go.mod
cache: true
# 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.
# 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.
- name: Pin toolchain from go.mod
run: |
toolchain="$(awk '/^toolchain /{print $2}' go.mod)"
@@ -121,13 +120,3 @@ 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 -1
View File
@@ -1 +1 @@
{".":"0.2.0"}
{".":"0.1.0"}
-65
View File
@@ -6,71 +6,6 @@ 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,7 +186,6 @@ 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 |
@@ -284,39 +283,6 @@ 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,7 +180,6 @@ 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
@@ -248,9 +247,10 @@ runs:
echo "exit-code=${code}" >> "${GITHUB_OUTPUT}"
echo "output-file=${output_file}" >> "${GITHUB_OUTPUT}"
# 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)"
# 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)"
{
echo "summary<<ZERO_EOF"
printf '%s\n' "${summary}"
+21 -54
View File
@@ -5,7 +5,6 @@ import (
"encoding/base64"
"encoding/json"
"errors"
"log"
"strings"
"sync"
@@ -160,14 +159,8 @@ 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, historyErr := a.loadHistory(meta.SessionID)
history := 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
@@ -260,14 +253,7 @@ func (a *Agent) runTurn(ctx context.Context, sess *acpSession, userText string,
if stopErr != nil {
return "", RPCError(codeInternalError, stopErr.Error())
}
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,
)
}
a.persistTurn(sess, userText, result.FinalAnswer)
return reason, nil
}
@@ -400,34 +386,29 @@ func (a *Agent) modeState(s *acpSession) *SessionModeState {
// ---- persistence + continuity ----
func (a *Agent) persistTurn(sess *acpSession, user, assistant string) error {
defer sess.appendHistory(turnRecord{user: user, assistant: assistant})
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 {
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, err
return nil
}
var records []turnRecord
var pendingUser string
@@ -463,21 +444,7 @@ func (a *Agent) loadHistory(sessionID string) ([]turnRecord, error) {
if havePending {
records = append(records, turnRecord{user: pendingUser})
}
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")
}
return records
}
// buildPrompt prepends prior conversation as context, since agent.Run drives a
+1 -72
View File
@@ -5,8 +5,6 @@ import (
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"testing"
"time"
@@ -244,77 +242,8 @@ 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)
@@ -322,7 +251,7 @@ func drainTextUntil(t *testing.T, ch <-chan string, done func(string) bool) stri
select {
case s := <-ch:
b.WriteString(s)
if done(b.String()) {
if strings.Contains(b.String(), "Hello from ZERO") {
return b.String()
}
case <-deadline:
-185
View File
@@ -1,185 +0,0 @@
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
@@ -1,338 +0,0 @@
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")
}
}
+16 -33
View File
@@ -2,7 +2,6 @@ package agent
import (
"fmt"
"runtime"
"strings"
"github.com/Gitlawb/zero/internal/sandbox"
@@ -28,20 +27,10 @@ func proposedCommandPrefix(toolName string, args map[string]any) []string {
}
return nil
}
// 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
for _, tokens := range segments {
if !knownSafeCommandSegment(tokens) && sandbox.ValidCommandPrefix(tokens) {
return append([]string(nil), tokens...)
}
if !sandbox.ValidCommandPrefix(tokens) || !otherSegmentsKnownSafe(segments, index) {
return nil
}
return append([]string(nil), tokens...)
}
if len(segments) == 0 || !sandbox.ValidCommandPrefix(segments[0]) {
return nil
@@ -49,20 +38,6 @@ 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
@@ -193,6 +168,10 @@ 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
@@ -217,6 +196,14 @@ 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 == "" {
@@ -288,11 +275,7 @@ func knownSafeCommandSegment(command []string) bool {
if len(command) == 0 {
return false
}
name := commandName(command[0])
if runtime.GOOS == "windows" && tools.MsysProneCommandName(name) {
return false
}
switch name {
switch commandName(command[0]) {
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":
+1 -50
View File
@@ -1,11 +1,9 @@
package agent
import (
"runtime"
"testing"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/tools"
)
func TestProposedCommandPrefixUsesSafeSimpleCommands(t *testing.T) {
@@ -37,37 +35,12 @@ 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",
@@ -147,35 +120,13 @@ 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": command}, Options{Sandbox: engine})
grant, ok, session := matchCommandPrefix("bash", map[string]any{"command": "ps aux | head -5"}, 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"})
+8 -58
View File
@@ -137,12 +137,6 @@ 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).
@@ -165,17 +159,6 @@ 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
@@ -491,19 +474,6 @@ 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
@@ -620,14 +590,11 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
failureHint = toolFailureHint(call.Name, toolSchemaJSON(registry, call.Name), toolResult.Output)
}
// 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 {
// 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 {
changedFilesThisBatch = append(changedFilesThisBatch, toolResult.ChangedFiles...)
postEditDiagnostics.enqueue(ctx, toolResult.ChangedFiles)
}
}
@@ -710,19 +677,7 @@ 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.
//
// 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) != "" {
if answer, finalMessages, finishReason := finalAnswerAfterMaxTurns(ctx, provider, messages, options); strings.TrimSpace(answer) != "" {
result.FinalAnswer = answer
result.FinishReason = finishReason
result.Messages = copyMessages(finalMessages)
@@ -742,7 +697,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, toolDefs []zeroruntime.ToolDefinition, options Options) (string, []zeroruntime.Message, string) {
func finalAnswerAfterMaxTurns(ctx context.Context, provider Provider, messages []zeroruntime.Message, options Options) (string, []zeroruntime.Message, string) {
finalMessages := copyMessages(messages)
finalMessages = append(finalMessages, zeroruntime.Message{
Role: zeroruntime.MessageRoleUser,
@@ -753,7 +708,6 @@ 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))
@@ -1146,12 +1100,8 @@ 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,
// 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.
// Inline post-edit diagnostics for mutating tools (nil = disabled).
Diagnostics: options.FileDiagnostics,
// 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,
+6 -14
View File
@@ -4,7 +4,6 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"runtime"
@@ -1946,11 +1945,6 @@ 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)
@@ -1964,7 +1958,7 @@ func TestRunCommandPrefixApprovalCoversSegmentedShellWithSafeTail(t *testing.T)
},
{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-2", ToolName: "bash"},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-2", ArgumentsFragment: fmt.Sprintf(`{"command":%q}`, segmentedCommand)},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-2", ArgumentsFragment: `{"command":"ps aux | head -5"}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-2"},
{Type: zeroruntime.StreamEventDone},
},
@@ -2768,11 +2762,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 finalization request after max turns, got %d requests", len(provider.requests))
t.Fatalf("expected final no-tools request after max turns, got %d requests", len(provider.requests))
}
finalRequest := provider.requests[1]
if toolDefinitionByName(finalRequest.Tools, "read_file") == nil {
t.Fatalf("finalization request must keep prior tools exposed for strict provider replay, got %#v", finalRequest.Tools)
if len(finalRequest.Tools) != 0 {
t.Fatalf("finalization request must not advertise tools, got %#v", finalRequest.Tools)
}
lastMessage := finalRequest.Messages[len(finalRequest.Messages)-1]
if lastMessage.Role != zeroruntime.MessageRoleUser || !strings.Contains(lastMessage.Content, "tool-turn limit") {
@@ -2913,10 +2907,8 @@ func TestBuildSystemPromptInjectsHostShellContext(t *testing.T) {
t.Fatalf("expected operating system in environment block, got %q", prompt)
}
if runtime.GOOS == "windows" {
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)
}
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)
}
} 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.
+1 -1
View File
@@ -286,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. 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")
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")
} 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")
}
+5 -7
View File
@@ -285,13 +285,11 @@ 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, 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, 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 func(ctx context.Context, absPath string) string
// RequireCompletionSignal gates run completion for HEADLESS exec. Without it,
-1
View File
@@ -731,7 +731,6 @@ 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,
+2 -45
View File
@@ -11,46 +11,10 @@ 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.
@@ -174,10 +138,8 @@ func runAuthChatGPT(args []string, stdout io.Writer, stderr io.Writer, deps appD
if _, err := fmt.Fprint(stdout, statuses); err != nil {
return exitCrash
}
if line := ensureLoginProviderProfile(deps, "chatgpt"); line != "" {
if _, err := fmt.Fprintf(stdout, "\n%s\n", line); 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
}
return exitSuccess
}
@@ -369,11 +331,6 @@ 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
}
+1 -85
View File
@@ -2,24 +2,18 @@ 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,
// pinning the file backend so an inherited ZERO_OAUTH_STORAGE=keyring can't
// ignore the temp path and hit the OS keychain.
// withAuthStore points the provider OAuth store at a temp file for the test.
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
}
@@ -203,81 +197,3 @@ 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
}
+2 -33
View File
@@ -75,12 +75,6 @@ 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)
}
@@ -252,20 +246,6 @@ 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
@@ -343,7 +323,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: "+providerCredentialState(provider),
"api key: "+apiKeyState(provider.APIKeySet),
)
if provider.Message != "" {
lines = append(lines, "status: "+provider.Status+" - "+provider.Message)
@@ -360,7 +340,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"), providerCredentialState(provider))
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))
if provider.Message != "" {
line += " (" + provider.Status + ": " + provider.Message + ")"
}
@@ -433,15 +413,6 @@ 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
@@ -495,8 +466,6 @@ 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,7 +11,6 @@ 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"
@@ -836,57 +835,3 @@ 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)
}
}
+3 -4
View File
@@ -711,10 +711,9 @@ 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 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.
// 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.
manager := lsp.NewManager(workspaceRoot)
cleanup := func() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
-141
View File
@@ -3,7 +3,6 @@ package cli
import (
"fmt"
"io"
"path/filepath"
"strconv"
"strings"
"unicode"
@@ -307,143 +306,3 @@ 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,48 +234,3 @@ 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")
}
}
+1 -5
View File
@@ -457,11 +457,7 @@ func selectProviderForCheck(resolved config.ResolvedConfig, name string) (config
}
func validateProviderRuntimeReady(profile config.ProviderProfile) error {
// 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())
hasCredential := providerProfileHasCredential(profile)
if profile.CatalogID != "" {
descriptor, err := providercatalog.Require(profile.CatalogID)
if err != nil {
+7 -14
View File
@@ -322,9 +322,7 @@ 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() {
// 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))] {
if oauthLogins[name] {
return true
}
}
@@ -346,19 +344,18 @@ func oauthLoggedInProviders() map[string]bool {
}
for _, status := range statuses {
if status.HasToken {
out[strings.ToLower(strings.TrimPrefix(status.Key, oauth.KeyPrefixProvider))] = true
out[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, 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.
// 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.
func firstUsableProvider(providers []config.ProviderProfile) (config.ProviderProfile, bool) {
logins := oauthLoggedInProviders()
var localFallback config.ProviderProfile
haveLocal := false
for _, profile := range providers {
@@ -374,11 +371,7 @@ func firstUsableProvider(providers []config.ProviderProfile) (config.ProviderPro
continue
}
}
// 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) {
if _, missing := setupMissingCredentialEnv(profile); missing {
continue
}
if providerProfileIsLocal(profile) {
-62
View File
@@ -2,14 +2,11 @@ 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
@@ -22,7 +19,6 @@ 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
@@ -34,7 +30,6 @@ 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"},
@@ -47,10 +42,6 @@ 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"},
@@ -65,7 +56,6 @@ 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"},
@@ -76,29 +66,6 @@ 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
@@ -122,32 +89,3 @@ 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,8 +7,6 @@ import (
"path/filepath"
"sort"
"strings"
"github.com/Gitlawb/zero/internal/providercatalog"
)
func UpsertProvider(path string, profile ProviderProfile, setActive bool) (FileConfig, error) {
@@ -31,20 +29,6 @@ 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
}
@@ -55,62 +39,6 @@ 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 == "" {
@@ -144,296 +72,6 @@ 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,7 +5,6 @@ import (
"errors"
"io/fs"
"os"
"os/exec"
"path/filepath"
"reflect"
"runtime"
@@ -386,545 +385,3 @@ 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")
}
}
+4 -28
View File
@@ -14,7 +14,6 @@ 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"
)
@@ -144,19 +143,12 @@ 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. 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.
// 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).
credential := "not set"
switch {
case profile.HasConfiguredCredential():
if strings.TrimSpace(profile.APIKey) != "" || strings.TrimSpace(profile.AuthHeaderValue) != "" {
credential = "set"
case providerHasStoredOAuthLogin(profile):
credential = "oauth login"
}
details := map[string]any{
"name": profile.Name,
@@ -177,22 +169,6 @@ 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)
}
return nil, fmt.Errorf("oauth: timed out waiting for token secret %s: %w", path, lastErr)
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)
}
func writeNewSecretFile(path string) ([]byte, error) {
+2 -10
View File
@@ -38,16 +38,8 @@ func ValidateKey(key string) error {
return nil
}
// 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))
}
// ProviderKey builds the store key for a provider login.
func ProviderKey(name string) string { return KeyPrefixProvider + 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,20 +126,3 @@ 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)
}
}
+5 -16
View File
@@ -200,23 +200,20 @@ 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 != "" && scopePathEqual(grant.Scope, reqScope)
return reqScope != "" && reqScope == grant.Scope
case ScopeDir:
if reqScope == "" || grant.Scope == "" {
return false
}
return pathWithinRoot(grant.Scope, reqScope)
if reqScope == grant.Scope {
return true
}
return strings.HasPrefix(reqScope, grant.Scope+string(filepath.Separator))
case ScopeHost:
return reqScope != "" && normalizeHostScope(reqScope) == normalizeHostScope(grant.Scope)
default:
@@ -224,14 +221,6 @@ 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,8 +2,6 @@ package sandbox
import (
"path/filepath"
"runtime"
"strings"
"testing"
)
@@ -236,25 +234,3 @@ 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))
}
}
+8 -15
View File
@@ -26,8 +26,6 @@ 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 {
@@ -109,7 +107,7 @@ func createSecretFile(path string) ([]byte, error) {
lockPath := path + ".lock"
var lastErr error
for attempt := 0; attempt < secretRetryAttempts; attempt++ {
lock, err := openSecretLockFile(lockPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
lock, err := os.OpenFile(lockPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
if err == nil {
_ = lock.Close()
defer os.Remove(lockPath)
@@ -120,25 +118,20 @@ func createSecretFile(path string) ([]byte, error) {
}
return writeNewSecretFile(path)
}
// 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) {
if !errors.Is(err, os.ErrExist) {
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)
}
return nil, fmt.Errorf("securefile: timed out waiting for secret %s: %w", path, lastErr)
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)
}
func writeNewSecretFile(path string) ([]byte, error) {
-27
View File
@@ -7,33 +7,6 @@ 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
@@ -1,233 +0,0 @@
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
}
+1 -15
View File
@@ -5,7 +5,6 @@ import (
"fmt"
"log"
"strings"
"unicode/utf8"
)
type ExecMode string
@@ -222,24 +221,11 @@ func summarizePayload(payload any) string {
text = string(data)
}
if len(text) > 500 {
return truncateUTF8(text, 500)
return 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:
+39 -99
View File
@@ -153,11 +153,6 @@ type AppendEventInput struct {
Payload any
}
type preparedAppendEvent struct {
Type EventType
Payload json.RawMessage
}
type RecordSpecInput struct {
SpecID string
SpecFilePath string
@@ -431,7 +426,7 @@ func (store *Store) Fork(parentSessionID string, input ForkInput) (Metadata, err
if err != nil {
return Metadata{}, err
}
copyInputs := []AppendEventInput{}
copied := 0
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
@@ -440,12 +435,11 @@ func (store *Store) Fork(parentSessionID string, input ForkInput) (Metadata, err
if event.Type == EventUsage {
continue
}
copyInputs = append(copyInputs, AppendEventInput{Type: event.Type, Payload: event.Payload})
if _, err := store.AppendEvent(fork.SessionID, AppendEventInput{Type: event.Type, Payload: event.Payload}); err != nil {
return Metadata{}, err
}
copied++
}
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
@@ -519,34 +513,19 @@ func (store *Store) RecordSpec(sessionID string, input RecordSpecInput) (Metadat
}
func (store *Store) AppendEvent(sessionID string, input AppendEventInput) (Event, error) {
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)
return Event{}, 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
if strings.TrimSpace(string(input.Type)) == "" {
return Event{}, fmt.Errorf("zero session event type is required")
}
unlock, err := store.lockSession(sessionID)
if err != nil {
return nil, err
return Event{}, err
}
defer unlock()
return store.appendPreparedEventsLocked(sessionID, prepared)
return store.appendEventLocked(sessionID, input)
}
// AppendEventUnlessExists appends input only when exists(currentEvents) is false,
@@ -593,26 +572,13 @@ 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) {
prepared, err := prepareAppendEventInputs([]AppendEventInput{input})
if err != nil {
return Event{}, err
}
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
return Event{}, err
}
payload, err := rawPayload(input.Payload)
if err != nil {
return Event{}, err
}
sequence := session.EventCount + 1
// The events log is the source of truth for sequencing. metadata.EventCount is
@@ -624,54 +590,46 @@ func (store *Store) appendPreparedEventsLocked(sessionID string, inputs []prepar
if logSeq, err := store.lastEventSequence(sessionID); err == nil && logSeq >= sequence {
sequence = logSeq + 1
}
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)
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)
}
file, err := os.OpenFile(store.eventsPath(sessionID), os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0o600)
if err != nil {
return nil, fmt.Errorf("append zero session event: %w", err)
return Event{}, fmt.Errorf("append zero session event: %w", err)
}
if _, err := file.Write(batch.Bytes()); err != nil {
if _, err := file.Write(append(data, '\n')); err != nil {
_ = file.Close()
return nil, fmt.Errorf("append zero session event: %w", err)
return Event{}, fmt.Errorf("append zero session event: %w", err)
}
// fsync the events before reporting success: the derived metadata.json IS
// fsync the event 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 just-appended events (incl. checkpoints
// durable log — silently losing the just-appended event (incl. the checkpoint
// that /rewind targets). Make the log at least as durable as its metadata. (AUDIT-M12)
if err := file.Sync(); err != nil {
_ = file.Close()
return nil, fmt.Errorf("sync zero session event: %w", err)
return Event{}, fmt.Errorf("sync zero session event: %w", err)
}
if err := file.Close(); err != nil {
return nil, fmt.Errorf("close zero session event file: %w", err)
return Event{}, fmt.Errorf("close zero session event file: %w", err)
}
last := events[len(events)-1]
session.UpdatedAt = last.CreatedAt
session.EventCount = last.Sequence
session.LastEventType = last.Type
session.UpdatedAt = timestamp
session.EventCount = sequence
session.LastEventType = input.Type
if err := store.writeMetadata(session); err != nil {
return nil, err
return Event{}, err
}
return events, nil
return event, nil
}
// UpdateTitle replaces a session's Title and returns the updated metadata. It is
@@ -1006,24 +964,6 @@ 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,7 +11,6 @@ import (
"sync"
"testing"
"time"
"unicode/utf8"
)
func TestStoreCreatesAppendsListsAndReadsEvents(t *testing.T) {
@@ -641,23 +640,6 @@ 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")})
+22 -89
View File
@@ -83,12 +83,7 @@ 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())
}
// 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) {
if issue := detectShellCommandIssue(commandText, runtime.GOOS); issue != nil {
return shellIssueBlockResult(*issue)
}
@@ -111,6 +106,7 @@ 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)
}
@@ -130,8 +126,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(bashCaptureBudgetBytes, bashCaptureBudgetBytes)
stderr := newBoundedBuffer(bashCaptureBudgetBytes, bashCaptureBudgetBytes)
stdout := newBoundedBuffer(bashOutputBudgetBytes, bashOutputBudgetBytes)
stderr := newBoundedBuffer(bashOutputBudgetBytes, bashOutputBudgetBytes)
command.Stdout = stdout
command.Stderr = stderr
@@ -171,7 +167,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(outText, errText, exitCode, meta),
Output: formatBashOutputWithShellHint(commandText, outText, errText, exitCode, meta),
Truncated: truncated,
Meta: meta,
}
@@ -182,7 +178,7 @@ func (tool bashTool) run(ctx context.Context, args map[string]any, engine *zeroS
if meta[SandboxLikelyDeniedMeta] == "true" {
return Result{
Status: StatusError,
Output: formatBashOutputWithShellHint(outText, errText, exitCode, meta),
Output: formatBashOutputWithShellHint(commandText, outText, errText, exitCode, meta),
Truncated: truncated,
Meta: meta,
}
@@ -202,18 +198,6 @@ 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.
@@ -386,21 +370,12 @@ func formatBashOutput(stdout string, stderr string, exitCode int) string {
return strings.Join(parts, "\n")
}
// 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
// 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
// budgetBashOutput truncates stdout and stderr to bashOutputBudgetBytes each,
// keeping the head and tail of anything larger, and records raw/emitted byte
@@ -420,19 +395,6 @@ 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)
@@ -445,43 +407,6 @@ 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
@@ -536,6 +461,14 @@ 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
@@ -555,9 +488,9 @@ func truncateHeadTailWithTotal(value string, total, maxBytes int) (string, int,
return utf8Prefix(value, head) + marker + utf8Suffix(value, tail), total, true
}
func formatBashOutputWithShellHint(stdout string, stderr string, exitCode int, meta map[string]string) string {
func formatBashOutputWithShellHint(command string, stdout string, stderr string, exitCode int, meta map[string]string) string {
output := formatBashOutput(stdout, stderr, exitCode)
if issue := detectShellOutputIssue(stdout+"\n"+stderr, runtime.GOOS); issue != nil {
if issue := detectShellOutputIssue(command, stdout+"\n"+stderr, runtime.GOOS); issue != nil {
meta["shell_issue"] = issue.Kind
output = appendShellIssueHint(output, *issue)
}
+7 -63
View File
@@ -2,23 +2,11 @@ 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{}
@@ -38,10 +26,8 @@ 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, meta is flagged, and the captured text
// is spilled to a re-readable file.
// the middle is dropped behind a marker, and meta is flagged.
func TestBudgetBashOutputTruncatesHeadAndTail(t *testing.T) {
setTestTempDir(t)
head := "FIRST_LINE_MARKER\n"
tail := "\nLAST_LINE_MARKER"
big := head + strings.Repeat("x", bashOutputBudgetBytes) + tail
@@ -61,23 +47,9 @@ func TestBudgetBashOutputTruncatesHeadAndTail(t *testing.T) {
if !strings.Contains(out, "output truncated") {
t.Fatalf("expected a truncation marker, got:\n%s", out[:200])
}
// The spill hint (path length varies) is the only allowed overage past the budget.
if len(out) > bashOutputBudgetBytes+512 {
if len(out) > bashOutputBudgetBytes {
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)
}
@@ -128,12 +100,10 @@ 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) {
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
// 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
meta := map[string]string{}
out, _, truncated := budgetBashCapture(retained, total, "", 0, meta)
@@ -144,8 +114,7 @@ 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))])
}
// Spill hint is the only allowed overage past the budget.
if len(out) > bashOutputBudgetBytes+512 {
if len(out) > bashOutputBudgetBytes {
t.Fatalf("emitted %d bytes exceeds budget %d", len(out), bashOutputBudgetBytes)
}
if meta["raw_bytes"] != strconv.Itoa(total) {
@@ -155,29 +124,4 @@ 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)
}
}
+3 -131
View File
@@ -181,13 +181,9 @@ func TestDetectShellCommandIssueFlagsPipedPosixUtilities(t *testing.T) {
`cat file.txt | wc -l`,
`some_command | tail -n 20`,
} {
issue := detectShellCommandIssue(command, "windows")
if issue == nil {
if issue := detectShellCommandIssue(command, "windows"); 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)
}
}
}
@@ -199,7 +195,7 @@ func TestDetectShellCommandIssueAllowsUnrelatedCommands(t *testing.T) {
`echo header text`,
`tail-log.sh`,
`grep-cli --version`,
`mytool.exe --help`,
`sed.exe --help`,
} {
if issue := detectShellCommandIssue(command, "windows"); issue != nil {
t.Fatalf("expected unrelated command to pass for %q, got %#v", command, issue)
@@ -207,33 +203,8 @@ 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("The syntax of the command is incorrect.", "windows")
issue := detectShellOutputIssue(`cd /d/tmp/zero-pr-158 && ls -la`, "The syntax of the command is incorrect.", "windows")
if issue == nil {
t.Fatal("expected Windows syntax error to get shell guidance")
}
@@ -463,105 +434,6 @@ 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.Contains(content, search) {
if strings.Index(content, search) < 0 {
continue
}
candidates = append(candidates, search)
+1 -12
View File
@@ -517,12 +517,7 @@ 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())
}
// 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) {
if issue := detectShellCommandIssue(commandText, runtimeGOOS()); issue != nil {
return shellIssueBlockResult(*issue)
}
if interactive := zeroSandbox.DetectInteractiveCommand(commandText, runtimeGOOS()); interactive.Interactive {
@@ -865,12 +860,6 @@ 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,46 +141,6 @@ 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
@@ -1,81 +0,0 @@
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
@@ -1,140 +0,0 @@
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")
}
}
+2 -13
View File
@@ -123,24 +123,13 @@ 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. 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)
}
}()
// args/paths) are redacted at the boundary just like tool output.
defer func() { result = scrubResultSecrets(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
+21 -179
View File
@@ -3,7 +3,6 @@ package tools
import (
"regexp"
"strings"
"unicode"
)
type shellRuntime struct {
@@ -18,31 +17,14 @@ 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_./~-]*`)
// 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)`)
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+|$)`)
)
func detectShellRuntime(goos string) shellRuntime {
@@ -55,162 +37,48 @@ 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. MSYS/Cygwin coreutils on PATH (Git for Windows usr\\bin) are not sandbox-compatible; prefer native Zero file tools."
return "Uses " + runtime.Syntax + " syntax on Windows; prefer cwd over cd when changing directories."
}
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) {
if windowsBashStyleCDPattern.MatchString(trimmed) ||
windowsLSCommandPattern.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.",
}
}
// 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.")
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.",
}
}
return nil
}
// 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 {
func detectShellOutputIssue(command string, output string, goos string) *shellIssue {
if goos != "windows" {
return nil
}
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") {
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") {
return &shellIssue{
Kind: "windows_shell_syntax",
Message: "Windows cmd.exe rejected the command syntax.",
@@ -220,32 +88,6 @@ func detectShellOutputIssue(output string, goos string) *shellIssue {
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
@@ -1,208 +0,0 @@
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)
}
}
+2 -6
View File
@@ -8,7 +8,6 @@ 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
@@ -91,10 +90,8 @@ 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 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.
// tool boundary, so a spilled file never holds a secret the transcript would
// have hidden.
func spillTruncatedOutput(toolName, output string) string {
dir, err := spillDir()
if err != nil {
@@ -113,7 +110,6 @@ 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,33 +63,6 @@ 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,6 +426,7 @@ 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 {
-95
View File
@@ -289,98 +289,3 @@ 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)
}
}
+20 -79
View File
@@ -3,7 +3,6 @@ package tui
import (
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"time"
@@ -11,10 +10,8 @@ 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"
@@ -496,37 +493,29 @@ 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.
// 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) {
func (m model) switchProviderModel(providerName, modelID string) (model, string, tea.Cmd) {
if m.pending {
return m, "Model\nCannot switch providers while a run is active.", false, nil
return m, "Model\nCannot switch providers while a run is active.", nil
}
if m.newProvider == nil {
return m, "Model\nProvider rebuild is not available for this TUI session.", false, nil
return m, "Model\nProvider rebuild is not available for this TUI session.", nil
}
target, ok := m.savedProviderByName(providerName)
if !ok {
return m, "Model\nunknown provider " + strconv.Quote(providerName), false, nil
return m, "Model\nunknown provider " + strconv.Quote(providerName), 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,
// 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
// 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
}
next, err := m.newProvider(target)
if err != nil {
return m, "Model\n" + redaction.RedactString(err.Error(), redaction.Options{ExtraSecretValues: []string{target.APIKey}}), false, nil
return m, "Model\n" + redaction.RedactString(err.Error(), redaction.Options{ExtraSecretValues: []string{target.APIKey}}), nil
}
m.provider = next
m.providerProfile = target
@@ -554,80 +543,32 @@ func (m model) switchProviderModel(providerName, modelID string) (model, string,
if warn := m.visionDropWarning(); warn != "" {
status += "\n" + warn
}
return m, status, true, tea.Batch(cmds...)
return m, status, 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.
// 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.
// 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.
func (m model) profileWithCredential(profile config.ProviderProfile) config.ProviderProfile {
if strings.TrimSpace(profile.APIKey) == "" {
if store, err := m.providerKeyStore(); err == nil {
if store, err := config.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 {
+2 -13
View File
@@ -419,23 +419,12 @@ 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: "+credentialState,
"api key: "+apiKeyState(snapshot.APIKeySet),
)
if snapshot.Message != "" {
profileLines = append(profileLines, "provider status: "+snapshot.Status+" - "+snapshot.Message)
@@ -443,7 +432,7 @@ func (m model) providerText() string {
status := commandStatusOK
actionLines := providerNextActionLines(m.providerProfile, snapshot, m.providerName)
if providerCredentialRequired(m.providerProfile, snapshot.ProviderKind) && !providerProfileHasCredential(m.providerProfile) && !snapshot.OAuthLogin {
if providerCredentialRequired(m.providerProfile, snapshot.ProviderKind) && !providerProfileHasCredential(m.providerProfile) {
status = commandStatusWarning
}
return renderCommandOutput(commandOutput{
+2 -10
View File
@@ -47,7 +47,6 @@ const (
commandExport
commandNew
commandSkills
commandLoop
commandUnknown
)
@@ -79,9 +78,9 @@ type parsedCommand struct {
var commandDefinitions = []commandDefinition{
{
name: "/provider",
usage: "/provider [add|status]",
usage: "/provider [status]",
group: commandGroupModel,
description: "Manage providers: activate, add, edit, delete.",
description: "Open provider setup.",
kind: commandProvider,
},
{
@@ -298,13 +297,6 @@ 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 [add|status] - Manage providers: activate, add, edit, delete.",
" /provider [status] - Open provider setup.",
" /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,7 +1,6 @@
package tui
import (
"fmt"
"strings"
"unicode/utf8"
@@ -264,96 +263,3 @@ 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
@@ -1,693 +0,0 @@
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
@@ -1,460 +0,0 @@
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
@@ -1,156 +0,0 @@
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")
}
}
+16 -119
View File
@@ -62,7 +62,6 @@ 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
@@ -191,22 +190,10 @@ type model struct {
// like a frozen terminal (for ANY provider, not just slow ones). Zero = idle.
turnStartedAt time.Time
queuedMessage string
// 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
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
@@ -745,12 +732,9 @@ 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,
@@ -785,7 +769,7 @@ func newModel(ctx context.Context, options Options) model {
permissionMode: permissionMode,
reasoningEffort: options.ReasoningEffort,
responseStyle: defaultedResponseStyle(options.ResponseStyle),
keyBindings: resolvedKeyBindings,
keyBindings: resolveKeyBindings(options.KeyBindings),
themeMode: resolveThemeMode(options.Theme, os.Getenv("ZERO_THEME"), options.SavedTheme),
hasDarkBg: true,
userAgent: options.UserAgent,
@@ -821,9 +805,6 @@ 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
}
@@ -1104,10 +1085,6 @@ 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
@@ -1234,10 +1211,8 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
return m.resolvePermission(permissionDecisionDeny)
}
if m.providerWizard != 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)
m.providerWizard = nil
return m, nil
}
if m.mcpAddWizard != nil {
m.mcpAddWizard = nil
@@ -1921,16 +1896,6 @@ 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
@@ -2100,25 +2065,8 @@ 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, loopTickCmd)
return next, tea.Batch(titleCmd, recapCmd, sweepCmd, queuedCmd)
case sessionTitleGeneratedMsg:
return m.handleSessionTitleGenerated(msg)
case recapGeneratedMsg:
@@ -3768,7 +3716,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)
}
@@ -3841,13 +3789,6 @@ 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.
@@ -3881,14 +3822,6 @@ 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
@@ -3897,11 +3830,6 @@ 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
@@ -3912,17 +3840,7 @@ 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.
@@ -3961,20 +3879,14 @@ func (m model) handleSubmit() (tea.Model, tea.Cmd) {
case commandSandboxSetup:
return m.startSandboxSetupCommand(command.text)
case commandProvider:
arg := strings.ToLower(strings.TrimSpace(command.text))
if arg == "" || arg == "add" {
if strings.TrimSpace(command.text) == "" {
if m.pending {
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: pickerBusyText(command.name)})
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.providerWizard = m.newProviderWizard()
m.clearSuggestions()
return m, nil
}
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: m.providerText()})
return m, nil
@@ -4421,21 +4333,6 @@ 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
@@ -4571,9 +4468,9 @@ func (m model) runAgentWithOptions(runID int, runCtx context.Context, prompt str
IncludeLSP: true,
Autonomy: selfCorrectAutonomyForMode(options.PermissionMode),
})
// 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.
// 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.
options.FileDiagnostics = agent.NewFileDiagnostics(lspManager, options.Cwd)
}
-7
View File
@@ -586,13 +586,6 @@ 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
-1
View File
@@ -22,7 +22,6 @@ 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
+1 -1
View File
@@ -388,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 := m.providerKeyStore(); err == nil {
if store, err := config.ProviderKeyStore(); err == nil {
authed = config.ApplyStoredAPIKey(authed, store)
}
key := strings.TrimSpace(authed.APIKey)
+3 -77
View File
@@ -14,7 +14,6 @@ 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"
@@ -966,9 +965,9 @@ func TestSwitchProviderModelWarmsDiscoveryForTheNewProvider(t *testing.T) {
},
})
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)
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)
}
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)
@@ -1023,76 +1022,3 @@ 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
@@ -1,828 +0,0 @@
// 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
@@ -1,647 +0,0 @@
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)
}
}
+2 -150
View File
@@ -50,18 +50,6 @@ 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.
@@ -153,48 +141,6 @@ 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.
@@ -303,12 +249,6 @@ 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.
@@ -379,20 +319,6 @@ 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 {
@@ -698,26 +624,6 @@ 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 {
@@ -726,10 +632,6 @@ 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 {
@@ -897,13 +799,6 @@ 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
}
@@ -1117,9 +1012,6 @@ 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
@@ -1239,23 +1131,13 @@ 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()),
)
title := "Provider setup"
if wizard.managerStep() {
title = "Providers"
}
block := styledBlockFillTitle(overlayWidth, title, lines, zeroTheme.lineStrong, lipgloss.NewStyle())
block := styledBlockFillTitle(overlayWidth, "Provider setup", lines, zeroTheme.lineStrong, lipgloss.NewStyle())
if width > overlayWidth {
return indentBlock(block, (width-overlayWidth)/2)
}
@@ -1263,30 +1145,8 @@ 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:
@@ -1329,7 +1189,7 @@ func providerWizardOverlayWidth(width int, step providerWizardStep) int {
switch step {
case providerWizardStepProvider:
target = providerWizardProviderWidth
case providerWizardStepModel, providerWizardStepManage:
case providerWizardStepModel:
target = providerWizardModelWidth
}
target = minInt(target, width)
@@ -1343,14 +1203,6 @@ 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,14 +1,10 @@
package tui
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/providercatalog"
)
@@ -388,64 +384,3 @@ 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)
}
}
+12 -39
View File
@@ -99,12 +99,6 @@ 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
}
@@ -129,24 +123,13 @@ 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 {
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...)
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
}
return m, rows
}
@@ -159,12 +142,13 @@ 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 {
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()})
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()})
}
}
return rows
}
@@ -211,10 +195,6 @@ 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 == "" {
@@ -223,16 +203,9 @@ 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,46 +108,6 @@ 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})
+3 -21
View File
@@ -92,14 +92,10 @@ 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 "version · cwd · branch · model" line
// for the home screen, omitting any piece that's unknown. Empty when nothing
// is known.
// emptyStateOrientation renders a faint "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, 4)
if version := displayVersion(m.appVersion); version != "" {
parts = append(parts, version)
}
parts := make([]string, 0, 3)
if cwd := strings.TrimSpace(m.cwd); cwd != "" {
parts = append(parts, shortenPath(cwd))
}
@@ -115,20 +111,6 @@ 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,29 +21,6 @@ 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
-7
View File
@@ -218,13 +218,6 @@ 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
+2 -19
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 := verifyArchiveChecksum(checksumPath, asset.ArchiveName); err != nil {
return nil, err
if _, err := release.VerifySHA256Checksum(checksumPath); err != nil {
return nil, fmt.Errorf("verify release checksum: %w", err)
}
extractDir := filepath.Join(tempDir, "extracted")
@@ -200,23 +200,6 @@ 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,38 +300,3 @@ 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,14 +87,6 @@ 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,46 +148,6 @@ 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")
+1 -24
View File
@@ -5,12 +5,6 @@ package update
import (
"fmt"
"os"
"time"
)
const (
restoreRenameRetryAttempts = 10
restoreRenameRetryDelay = 100 * time.Millisecond
)
// replaceBinary installs newPath over targetPath. Windows will not let a
@@ -24,11 +18,7 @@ func replaceBinary(targetPath string, newPath string) error {
return fmt.Errorf("rename running binary aside: %w", err)
}
if err := os.Rename(newPath, targetPath); err != 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 {
if restoreErr := os.Rename(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)
@@ -36,19 +26,6 @@ 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
@@ -1,65 +0,0 @@
//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")
}
}
+6 -13
View File
@@ -49,12 +49,8 @@ type ProviderSnapshot struct {
APIModel string `json:"apiModel,omitempty"`
Active bool `json:"active"`
APIKeySet bool `json:"apiKeySet"`
// 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"`
Status string `json:"status,omitempty"`
Message string `json:"message,omitempty"`
}
type ModelSnapshotOptions struct {
@@ -168,13 +164,10 @@ func ProviderSnapshotFromProfile(profile config.ProviderProfile, active bool) Pr
BaseURL: redactProviderBaseURL(profile.BaseURL, profile.APIKey, profile.AuthHeaderValue),
Model: profile.Model,
Active: active,
// 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(),
// 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) != "",
Status: "ok",
}
metadata, err := providers.ResolveRuntimeMetadata(profile, providers.Options{})
-3
View File
@@ -340,9 +340,6 @@ 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.2.0",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@gitlawb/zero",
"version": "0.2.0",
"version": "0.1.0",
"cpu": [
"x64",
"arm64"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@gitlawb/zero",
"version": "0.2.0",
"version": "0.1.0",
"description": "Zero — a fast, multi-provider terminal coding agent.",
"type": "module",
"module": "bin/zero.js",
-83
View File
@@ -1,83 +0,0 @@
#!/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
@@ -1,148 +0,0 @@
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 });
}
});