Compare commits

..
Author SHA1 Message Date
Gnanam 533b41495d feat(tui): fast-mode marker/hint and transient /fast toast
Surface which models have a fast variant: a dim "f" badge on the
active-model chip, the /model picker, and the /provider wizard, plus a
focused-row "fast: <name>" hint (the pickers are keyboard-driven, so the
selected row is the reveal). All read Registry.FastVariant, so coverage
tracks the catalog automatically.

Give /fast a distinctive voice and stop it cluttering the transcript:
reword its messages and show the result as a transient toast pill in the
row above the composer, auto-dismissed after 3s (seq-gated, same pattern
as the copy-status flash).
2026-07-04 19:10:03 +05:30
Gnanam bb66a44ab3 feat(modelregistry): add curated FastVariantID and memoize DefaultRegistry
Add a FastVariantID field to ModelEntry (mirroring UpgradeTargetID),
populated in the catalog for the four same-family base/fast pairs and
validated at registry build. Refactor the /fast helpers to derive both
directions from this field instead of a hardcoded table, and add a
HasFastVariant predicate for UI callers.

Memoize DefaultRegistry with sync.OnceValues: the default catalog is
static, every accessor returns clones, and a built registry is never
mutated, so per-frame TUI callers no longer re-clone entries and
recompile fuzzy-match regexes each render.
2026-07-04 19:10:03 +05:30
Gnanam 6f97c90316 feat(tui): add /fast command to toggle a model's fast variant
/fast [on|off] switches the session model between its base and a faster
same-family variant (e.g. Sonnet<->Haiku, GPT-4.1<->GPT-4.1-mini). State is
DERIVED from the model identity, never stored: the session is "in fast mode"
iff the current model has a base variant.

- modelregistry: a config-driven base<->fast mapping table (fastVariantPairs)
  plus Registry.FastVariant / BaseVariant, mirroring the existing UpgradeTarget
  pattern. Adding a pair is a one-line data change — no logic edit. Populated
  for the models with a meaningful fast variant (Sonnet/Haiku, GPT-4.1/mini,
  GPT-4o/mini, Gemini-2.5 Pro/Flash), guarded by tests that every id resolves
  and no id is both a base and a fast.
- tui: /fast registered in commandDefinitions (model group) and dispatched via
  a pure planFastCommand decision + handleFastCommand, which delegates the real
  switch to handleModelCommand (provider rebuild, compaction guard, persistence)
  rather than mutating state directly. Notices use the existing appendSystem
  mechanism.
- Tests: table-driven over every case — invalid arg, no model set, no-op both
  ways, no fast variant, switch to fast (⚡) / back to base, switch-error
  surfaced, and the variant helpers.

Gate: make build / go vet ./... / go test ./... -race / make lint all green.
2026-07-04 19:10:03 +05:30
Anandan f401c66e81 polish: cut per-turn overhead, stabilize cache, add /new (#478)
* polish: cut per-turn overhead, stabilize cache, add /new

Context economy
- Compaction: preserveLast 8->6, triggerRatio 0.8->0.7 (both copies in sync).
- System prompt: kill the "ELABORATE summary" directive; scale summary to
  the work. Comment discipline moved into the core prompt for all model
  families (was Anthropic-only); Minimax/DeepSeek/Qwen/etc. now get it.
- Token-budget regression test ratchets the fixed per-turn overhead (base
  prompt + eager tool schemas) using the runtime's own estimator, so silent
  prompt/schema growth fails CI.

Response quality
- defaultResponseStyle balanced->concise. Reversible via /style.

Tool discovery
- <available_skills> block in the system prompt lists installed skills
  (name + one-line description) so the model loads the right one on the
  first call instead of guessing and reading the failure. Budget-capped at
  640 bytes with an "...and N more" fallback; renders nothing when no
  skills are installed (byte-identical to prior prompt).

Session lifecycle
- /new starts a fresh session in place; previous session stays on disk and
  is named in the confirmation for /resume. Guards against mid-run reset.
- /clear now notes that context persists and points to /new.

Performance & caching
- partitionTools no longer alpha-sorts the whole tools array every turn,
  which inserted a loaded deferred tool mid-array and shifted the cached
  prefix on every load. It now builds append-only regions
  [eager sorted] + [tool_search] + [loaded deferred sorted] so a
  mid-session load grows the tail instead of busting the eager prefix.
  Regression test asserts the eager block precedes any loaded tool.
- /context now shows cache hit rate (flags low rate over >5 turns as a
  possible unstable prefix) and rolling average turn latency with
  time-to-first-token. Both reset on /new.

Measured baselines (auto-mode, no workspace): base prompt 3162 tokens,
eager tool schemas 2433 across 12 tools. Budget ceilings set ~10% above.

Deliberately deferred (need a decision): inverted safety-policy rewrite
(sandbox doesn't catch ~half the B-tier rules), core-tool/web_fetch
deferral (activation-semantics blocker), aggressive prompt slim.

* fix(tui): clear staged attachments + queued message on /new

CodeRabbit: startNewSession reset conversation/usage/latency state but not
the staged input (pendingImages, pendingImageLabels, pendingDocuments,
queuedMessage), which is only consumed at prompt-submit. Without clearing it,
the fresh session's first prompt silently inherited the previous session's
images/documents/queued text. Test now stages all four and asserts they clear.
2026-07-04 20:56:52 +08:00
Pengkuh Dwi Septiandi 949ee43f71 fix: preserve conversation context in exec prompts (#460)
* fix: preserve conversation context in exec prompts

* fix: remove redundant

* test: cover exec prompt
2026-07-04 08:53:47 +08:00
Ashwinhegde19 2fcea71778 feat(tui): add search/filter to provider picker in setup wizard (#400)
* feat(tui): add search/filter to provider picker in setup wizard

Add a search input to the provider step of the setup wizard, matching
the existing model picker search UX. Typing filters the provider list
in real-time via case-insensitive substring matching on name, ID, and
aliases.

Fixes #392

* fix(tui): resolve provider index before clearing search on advance

Clearing providerSearch before using currentProvider() caused the wrong
provider to be selected — selectedProvider is an index into the filtered
slice, and clearing the search swaps to the full unfiltered list.

Also update placeholder to reflect that search matches ID and aliases.

* fix(tui): guard empty-filter Enter and add provider search tests

- Enter on an empty provider filter is now a no-op instead of advancing
  with the wrong provider (first full-list entry)
- Add table tests for filteredProviders/name/ID/alias matching
- Add tests for selectedProvider reset on edit, empty-match noop,
  Ctrl+U clear restores full list, and filtered advance selection

Addresses review feedback from @Vasanthdev2004 and @gnanam1990 on PR #400
2026-07-04 08:53:16 +08:00
PierrunoYTandVasanthdev2004 5f36349c18 feat(update): add zero upgrade command to apply self-updates (#461)
* feat(update): add zero upgrade command to apply self-updates

Extends the existing `zero update --check` (which only reported an
available release) with an actual install step:

- `zero update --apply` / `zero upgrade` download, sha256-verify, and
  install the latest release.
- npm installs delegate to `npm install -g @gitlawb/zero@latest`.
- Standalone installs download the release archive, verify its
  checksum, extract it (zip-slip guarded), and atomically replace the
  running binary plus any pre-existing optional sandbox helpers. On
  Windows, the running exe is renamed aside (NTFS allows this even
  while it's executing) since it can't be overwritten directly; the
  leftover ".old" file is cleaned up on a later invocation.

Closes #459.

* fix(update): correct macOS expectations in apply test

The optional-helper assertion in TestApplyStandaloneUpdateReplacesBinary
assumed every non-Windows platform has a linux-style "zero-seccomp"
helper to refresh, but applyStandaloneUpdate correctly ships no optional
helpers on macOS (matching scripts/postinstall.mjs). This made the test
fail on macOS CI. Only assert the helper refresh on linux/windows.

* fix(update): address CodeRabbit review findings on zero upgrade

- Reject --target when combined with --apply/upgrade: applying installs
  onto the current machine, so a mismatched --target could replace the
  running binary with an incompatible-arch build.
- Bound archive/checksum downloads with a dedicated timeout separate
  from the release-metadata check timeout, so a stalled connection
  can't hang zero upgrade forever.
- Surface a failed rollback in the Windows replaceBinary path instead
  of swallowing it, so a botched install is reported rather than
  silently leaving the binary missing.
- Run CleanupStaleBinary regardless of whether an update is available,
  so a leftover Windows ".old" file isn't stuck waiting for a future
  upgrade that may never come.
- Report optional sandbox-helper refresh failures as warnings on
  ApplyResult (surfaced in text and JSON output) instead of discarding
  them silently.

* fix(oauth): retry secret-lock on Windows delete-pending race to unblock CI

TestLoadOrCreateSecretConcurrentConverges was failing this PR's Windows
smoke job with "create token secret lock: ...Access is denied." — a
pre-existing flake in internal/oauth, not in the upgrade code. On Windows
a concurrent holder's os.Remove leaves the .lock in a delete-pending
state, so an O_EXCL create races it with ERROR_ACCESS_DENIED
(os.ErrPermission) rather than ErrExist; createSecretFile only treated
ErrExist as contention and hard-failed otherwise.

Treat os.ErrPermission as retryable too, mirroring acquireFileLock in
lock.go. This is the same root-cause fix as #445 (diagnosis by
@gnanam1990); folded in here to make this PR's CI green. Verified on
Windows: the concurrent-converge test passes 30/30.

---------

Co-authored-by: Vasanthdev2004 <[email protected]>
2026-07-04 08:52:07 +08:00
euxaristiaandClaude Fable 5 e4f760ee8b fix(agent): reject a malformed additional_permissions payload before prompting (#453)
* fix(agent): reject a malformed additional_permissions payload before prompting

A shell tool call that sets sandbox_permissions: with_additional_permissions
but omits (or malforms) the additional_permissions payload was accepted into
the normal permission-prompt flow. buildPermissionEvent silently swallowed
the validation error while rendering the prompt's scope text, so the user
saw an ordinary-looking request, approved it, and only then hit a hard
denial from the same validation running again at grant time (loop.go's
grantInlineAdditionalPermissions), with the confusing message "failed to
grant session additional permissions: missing additional_permissions".

A malformed payload can never succeed regardless of the user's decision, so
run the same check (inlineAdditionalPermissionsProfile, already the single
source of truth for this shape) up front, before any prompt, and return a
plain tool error the driving model can see and retry from. This also covers
the mismatched case (additional_permissions present without the flag).

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

* fix(agent): make the additional_permissions rejection message actionable

The rejection added in the previous commit correctly stops a malformed
sandbox_permissions: with_additional_permissions call before any prompt,
but its message ("missing additional_permissions; provide at least one of
network or file_system") gave a model nothing concrete to correct itself
with. Observed in practice: a weaker tool-calling model (xai/grok-build-0.1)
kept re-attaching sandbox_permissions out of habit from an earlier, correct
gh/git call onto later commands that didn't need it (network was already
open via a session-scoped grant), hit this same rejection 4 times in a row
with no change in its retry, and gave up.

Both messages (missing payload, and empty-but-present payload) now include
a concrete, schema-accurate example - {"network": {"enabled": true}} or
{"file_system": {"write": ["/path"]}} - and the missing-payload message
adds the other half of the fix: if the command doesn't actually need
elevated permissions, omit sandbox_permissions entirely and retry.

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

---------

Co-authored-by: Claude Fable 5 <[email protected]>
2026-07-04 08:51:14 +08:00
euxaristiaandClaude Fable 5 068c5ad3ad docs(install): recommend bun pm trust for Bun installs (#452)
The Bun instructions told global installers to run the postinstall script
manually against the global install path, because trustedDependencies only
works in a project package.json. Current Bun has a simpler sanctioned path
that works for both project and global installs: `bun pm trust
@gitlawb/zero` (with -g for globals) runs the blocked postinstall after the
fact. Verified on Bun 1.3.14 on Windows 11: `bun pm -g trust @gitlawb/zero`
fetched the binary and `zero --version` runs.

Lead with that in README.md, README_ZH.md, and docs/INSTALL.md, keep
trustedDependencies and the manual installer as alternatives for project
installs and older Bun versions, and teach the wrapper's missing-binary
message the same commands for the Bun case.

Co-authored-by: Claude Fable 5 <[email protected]>
2026-07-04 08:45:49 +08:00
Gautam ManchandaniandGautam Manchandani 407a92739f fix(action): keep provider key scoped to zero step (#448)
Co-authored-by: Gautam Manchandani <[email protected]>
2026-07-04 08:41:38 +08:00
KRATOS d05e9148a7 fix(oauth): treat Windows ERROR_ACCESS_DENIED as lock contention in createSecretFile (#445)
createSecretFile's secret-lock loop only retried on os.ErrExist. On Windows a
concurrent holder's `defer os.Remove(lockPath)` leaves the lock file in a
"delete pending" state, so a racer's O_EXCL create returns ERROR_ACCESS_DENIED
(os.ErrPermission) rather than ErrExist — which fell through to a hard
"create token secret lock: Access is denied" error instead of retrying. Mirrors
the identical handling already present in acquireFileLock (lock.go, from #261).

Fixes the flaky Windows-CI failure TestLoadOrCreateSecretConcurrentConverges
that was intermittently red-flagging every PR's Windows smoke job.

Verified on macOS: build/vet/gofmt clean, oauth package green, concurrent-
converge test passes under -race (5x). The Windows delete-pending path can't be
reproduced off Windows, so the definitive proof is the Windows CI on this PR.
2026-07-04 08:40:20 +08:00
Anandan 1d8638466c fix: allow non-TLS connections to private-network provider endpoints (#444)
* fix: allow non-TLS connections to private-network provider endpoints

The provider wizard and health probe rejected `http://` endpoints with a
non-loopback host, so users could not configure a local-network provider
like llama.cpp on another LAN machine (e.g. `http://192.168.1.100:8080/v1`).
Only loopback was relaxed.

Expand the "loopback-only" checks to also cover private-network addresses
(192.168.0.0/16, 10.0.0.0/8, 172.16.0.0/12) via `netip.Addr.IsPrivate()`:

- `providerhealth.endpointIsLoopback` → `endpointIsLoopbackOrPrivate`
  (also returns true for private IP literals).
- `providerhealth.allowLoopback` → `allowLoopbackOrPrivate` — the flag
  now relaxes both loopback and private addresses in `validateEndpoint`
  and `safeDialContext`.
- `providerWizardIsLoopbackHost` in the TUI wizard now accepts private
  IPs, so `http://192.168.x.y` passes the endpoint URL validation.
- `providerProfileIsLocal` in headless setup recognizes private-network
  IPs as local (skip API key for keyless LAN providers).
- `baseURLIsLoopback` in app.go recognizes private-network IPs for
  credential-requirement logic.

Link-local (169.254.x.x), cloud metadata, and other special ranges
remain blocked. Redirects to any local/private address remain blocked
regardless of the flag (SSRF protection for 3xx targets is unchanged).

Fixes #431

* chore: fix gofmt indentation in connectivity client test
2026-07-04 08:35:14 +08:00
Anandan 305a62c954 fix(auth): route zero auth login chatgpt to the dedicated ChatGPT flow (#443)
* fix(auth): route `zero auth login chatgpt` to the dedicated ChatGPT flow

`zero auth login chatgpt` went through the generic OAuth manager loopback
path, which builds a random-port http://127.0.0.1:<port>/callback
redirect_uri with no extra authorize params. OpenAI's ChatGPT client
registration requires exactly http://localhost:1455/auth/callback plus the
id_token_add_organizations, codex_cli_simplified_flow, and originator
params, so the authorize endpoint rejected the request with an
"Authentication Error" before any browser interaction.

Route the chatgpt provider to the existing dedicated ChatGPT login
(provideroauth.ChatGPTLogin), which already binds the fixed port, uses the
localhost host, the /auth/callback path, and sends the required params.
`zero auth login chatgpt` now behaves identically to `zero auth chatgpt`.
--device is rejected with a clear message since ChatGPT has no device-code
path.

Fixes #430

* fix(auth): reject --scope on ChatGPT login instead of silently dropping it

`zero auth login chatgpt --scope <x>` reached the dedicated ChatGPT flow,
which ignores the generic manager's ExtraScopes — the scopes were silently
dropped while --device was explicitly rejected. Reject --scope up front
with a clear message, since the Codex client registration pins a fixed
scope set (incl. api.connectors.*) that custom scopes would risk dropping.

Addresses CodeRabbit review on #443.
2026-07-04 08:34:30 +08:00
Vasanth T 9134148f4d fix(tui): keep the profile name on /model switch so the stored key resolves (#441)
normalizeProfileForProvider rewrote profile.Name to the catalog provider.ID
whenever normalizeIdentity was true — which includes any first-party provider
whose saved baseURL matches the catalog default. The credential store is keyed
by profile.Name, and this rewrite runs before profileWithCredential, so the
stored-key lookup missed and the rebuilt provider went out with no key: a 401 on
every /model switch for a profile named differently from its catalog id (e.g.
"my-openai").

Only canonicalize an empty or generic placeholder name now; a real user-provided
name is preserved. CatalogID canonicalization is unchanged (it is not the store
key). Adds regression tests covering a custom-named first-party profile surviving
normalization and placeholder names still canonicalizing.

Fixes #440
2026-07-04 08:27:41 +08:00
euxaristiaandClaude Fable 5 f653dcac36 fix(sandbox): gate /tmp test assertions on GOOS, not path existence (#426)
* fix(sandbox): gate /tmp test assertions on GOOS, not path existence

TestPermissionProfileFromPolicyIncludesDefaultTempWriteRoots and
TestNewScopeNormalizesAndValidatesExtraRoots guard their /tmp assertion with
pathExists("/tmp"), intending to skip it on Windows. But Go resolves the bare
path /tmp against the current drive on Windows, so on any host where C:\tmp
exists the guard passes while the product code (correctly, by design - see
defaultTempWriteRootCandidatesForGOOS) uses %TEMP%/%TMP% instead, and both
tests fail. Gate the assertion on runtime.GOOS != "windows" as well; POSIX
behavior is unchanged.

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

* chore: retrigger CI

The Windows smoke job failed on TestLoadOrCreateSecretConcurrentConverges in internal/oauth, unrelated to this change. Retriggering to confirm it was a flake.

---------

Co-authored-by: Claude Fable 5 <[email protected]>
2026-07-04 08:26:22 +08:00
Eli Lap 071ea3a495 Make shortcuts reconfigurable via the config file. (#417)
* Make shortcuts reconfigurable via the config file.

* Use sidebarToggleAllowed. Fix fmt errors.

* Fix: combination matching superset.

* Remove non-default shortcut combination from the comment.
2026-07-04 08:25:33 +08:00
Kunj SHAH b0abde7d06 feat: add --auto flag for LLM-generated commit messages (#423)
* feat: add --auto flag to changes commit for LLM-generated commit messages

* fix: address CodeRabbit review suggestions on auto-commit message generation

* fix: resolve auto-commit message and flag validation tests

* fix: add 60s timeout for LLM call in --auto and surface --dry-run in help

Addresses gnanam1990 non-blocking feedback:
- Wrap provider call with context.WithTimeout (60s) to prevent hung provider
- Note --dry-run in --auto help text as preview escape hatch
2026-07-04 08:10:18 +08:00
PierrunoYTandClaude Sonnet 5 5658a36627 fix(tools): flag piped POSIX utilities before running on Windows (#412)
* fix(tools): flag piped POSIX utilities (head/tail/grep/...) before running on Windows

Zero always executes the bash tool through native cmd.exe on Windows
(shellExecutable() in bash.go), but the pre-flight Windows-syntax
detector only caught bash-style `cd /...` and bare `ls`. Anything
piped into head/tail/grep/wc/awk/sed/cut/xargs/tr — very common in
model-generated commands like `git log ... | head` — ran straight
through to cmd.exe, which has no such builtins, wasting a turn on a
guaranteed failure. Reported in the wild via `git log ... | head`
and a `git blame ... | grep` pipeline.

detectShellCommandIssue now also flags these before the command
runs, with the same "translate to cmd.exe / use Zero's native tools"
guidance already used for the cd/ls cases.

Note: the existing detectShellOutputIssue path (matching cmd.exe's
error text post-execution) only recognizes English strings and will
miss this on non-English Windows locales — the pre-flight check
above is locale-independent since it inspects the model-generated
command text rather than cmd.exe's translated error output.

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

* fix(tools): require whitespace/end after POSIX utility name, not just \b

CodeRabbit review on PR #412: windowsPosixUtilityPattern used \b after
the utility alternation, but \b only requires a non-word character
next — so tail-log.sh, grep-cli, and sed.exe would all false-positive
(the hyphen/dot satisfies \b without being a real command boundary).
Match windowsLSCommandPattern's existing convention: require
whitespace or end-of-string after the utility name instead.

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

---------

Co-authored-by: Claude Sonnet 5 <[email protected]>
2026-07-04 08:09:22 +08:00
c60ad8729f fix(config): fall back to a usable saved provider instead of forcing full re-onboarding (#410)
When config.json has providers configured but none marked active
(e.g. a blank/stale activeProvider field, or a config with 2+
providers where none was ever selected), normalizeProviders threw
away the already-normalized provider list on ErrNoActiveProvider.
That meant the interactive TUI's existing "fall back to an
already-configured usable provider" logic (firstUsableProvider)
never got a chance to run for this case — the app instead wiped
resolved to a zero value and forced the user into a brand-new
onboarding wizard, even though a usable, already-saved provider
existed.

- normalizeProviders/Resolve now return the normalized providers
  list alongside ErrNoActiveProvider instead of discarding it.
- runInteractiveTUIWithSetup tries firstUsableProvider on that list
  before forcing setup; onboarding only runs when no configured
  provider is actually usable, matching the existing fallback
  already used for a different flavor of this problem
  (active-provider-present-but-no-credential).

Co-authored-by: Claude Sonnet 5 <[email protected]>
Co-authored-by: Vasanth T <[email protected]>
2026-07-04 08:08:55 +08:00
euxaristiaandClaude Fable 5 0562e3bef7 fix(tui): show an M suffix for million-scale token counts (#457)
humanCount (the status-line/sidebar token formatter) only ever produced a K
suffix, so a 1,000,000-token context window rendered as "1000K" instead of
"1M" - visible with any 1M-context model (e.g. xai/grok-build-0.1). Its
sibling formatContextWindow already switches to "M" above a million; extend
the same behavior to humanCount, generalized for arbitrary (non-round)
values rather than formatContextWindow's round-number-only heuristic, since
humanCount also renders live, arbitrary "used" token counts.

Co-authored-by: Claude Fable 5 <[email protected]>
2026-07-03 22:50:44 +08:00
Anandan 9bd93c62f8 fix: add android platform support for Termux npm install (#455)
`npm install -g @gitlawb/zero` failed on Android/Termux with EBADPLATFORM
because `package.json` `os` array lacked `"android"`. Termux provides a
Linux userspace — the existing `linux-arm64` binary works natively.

- Add `"android"` to the `os` array in package.json.
- Map `android` platform to `linux` in postinstall.mjs resolvePlatform().

Fixes #449
2026-07-03 22:44:47 +08:00
PierrunoYTandClaude Sonnet 5 bdb1b0ecd1 fix(install): persist install dir to user PATH on Windows (#407)
* fix(install): persist install dir to user PATH on Windows

The PowerShell installer only printed a suggestion to add the
install directory to PATH but never actually did it, so `zero`
was unusable from a fresh terminal after install.

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

* fix(install): don't abort install if PATH persistence fails

Address CodeRabbit review on PR #407:
- Wrap the [Environment]::SetEnvironmentVariable call in try/catch
  so a PATH-persistence failure (e.g. restricted user profile) warns
  instead of aborting the whole install under $ErrorActionPreference
  = Stop.
- Factor the repeated PATH-contains check for user/session scopes
  into a Test-ZeroPathContainsDir helper.

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

---------

Co-authored-by: Claude Sonnet 5 <[email protected]>
2026-07-03 20:10:01 +08:00
Leonardo Faoro 4266baf222 fix(docs): rename AGENTS.MD > AGENTS.md (#438)
* fix(docs): rename AGENTS.MD > AGENTS.md

* fix(docs): fix references to AGENTS.MD > AGENTS.md
2026-07-03 19:40:50 +08:00
euxaristiaandClaude Fable 5 8d0c5feccb fix(sandbox): self-heal a corrupt unelevated setup marker (#437)
A malformed/truncated windows-unelevated-setup.json returned a hard error
from loadWindowsUnelevatedSetupMarker, bricking every unelevated command
until the file was hand-deleted, while the unknown-schema path already
reset to an empty marker. Treat corrupt JSON the same way: the marker only
memoizes idempotent ACL applies, so a reset can only cost redundant work,
never skip enforcement.

Addresses the review feedback from coderabbitai and @anandh8x on #427.

Co-authored-by: Claude Fable 5 <[email protected]>
2026-07-03 19:34:56 +08:00
CengSinandzhangzetao b63fe696fd modify readme.md and add readme_zh.md (#436)
Co-authored-by: zhangzetao <[email protected]>
2026-07-03 17:16:41 +08:00
Baran3575andBaran3575 3fbbd28e4c fix(provider-wizard): allow multiple custom OpenAI-compatible providers (#403)
* fix(provider-wizard): allow multiple custom OpenAI-compatible providers

Generic/custom providers (custom-openai-compatible, custom-anthropic-compatible)
all share the same CatalogID. wizardProviderStoredKey matched by CatalogID,
which blocked creating more than one custom provider.

Fix: in advanceProviderWizard, skip the ManageKey diversion for custom
providers and route directly to the endpoint step instead. The user can
overwrite an existing instance by re-entering the same name, or create a
new one with a different name.

Named providers (OpenAI, Anthropic, etc.) are unaffected — ManageKey is
still shown for them.

Fixes #375

* refactor(provider-wizard): deduplicate advance/discovery branch, add anthropic-compatible test

---------

Co-authored-by: Baran3575 <[email protected]>
2026-07-03 17:03:37 +08:00
euxaristiaandClaude Fable 5 b9ddd6f421 feat(sandbox): unelevated Windows fallback tier instead of prompts-only degrade (#427)
Before the one-time elevated `zero sandbox setup` has run, Windows dropped
straight from the native sandbox to the in-process policy gate: commands ran
with no OS enforcement at all. But only the WFP network filters actually need
Administrator rights - the write-restricted token and the workspace ACL grants
(DACL edits on user-owned roots) work unelevated.

Add an `unelevated` enforcement tier between native and degraded: when the
elevated setup marker is missing and the profile restricts the filesystem, the
command still wraps through the command runner at a new `unelevated` sandbox
level. The runner applies the workspace ACL plan itself (recording applied
plans by hash in windows-unelevated-setup.json so repeat commands skip the
apply) and launches under the same write-restricted token, so the OS-level
write-jail holds. Network enforcement stays with the per-command approval gate
at this tier, and the downgrade reason says so; profiles that need the sandbox
only for network isolation keep the old degrade behavior, and `--sandbox
require` still errors with the setup hint.

Verified end to end on Windows 11 without elevation: a write inside the
workspace succeeds under the restricted token, a write outside every granted
root is denied by the OS (new ZERO_SANDBOX_REAL_SMOKE integration test, using
cmd.exe because managed PowerShell cannot initialize its crypto provider under
a write-restricted token on some hosts).

Co-authored-by: Claude Fable 5 <[email protected]>
2026-07-03 16:38:28 +08:00
Jochen aaad4d2712 feat(providers): split minimax zai into intl cn (#398)
* feat(providers): split minimax zai into intl cn

- Add minimaxi-cn (api.minimaxi.com) sharing model catalog with minimax
- Add zai-cn (open.bigmodel.cn) splitting zai from china to intl
- Decouple env vars: ZAI_API_KEY vs ZHIPU_API_KEY for zai/zai-cn,
  MINIMAX_API_KEY vs MINIMAXI_API_KEY for minimax/minimaxi-cn
- Map zai-cn and minimaxi-cn to models.dev providers "zai" and "minimax"
  so the model catalog hydrates for both endpoints

* chore(providers): address review feedback on intl/cn split

- Run gofmt on internal/providermodelcatalog/catalog.go (map-value
  alignment on the \"zai\" entry was off by one space, fails CI lint)
- Update stale comment in TestResolveAppliesZaiInternationalCatalogDefaults
  to say glm-4.5 default (live discovery upgrades to glm-5.2)
- Add missing ProviderKind, APIKeyEnv, and APIKey assertions to
  TestResolveAppliesZaiInternationalCatalogDefaults so it would catch
  a silent regression in ZAI_API_KEY routing
2026-07-03 16:33:11 +08:00
KRATOS b4275e3504 feat(providers): add Meituan LongCat catalog preset (#424)
Add LongCat-2.0 as a first-class OpenAI-compatible provider preset so it can be
selected by name in the setup wizard / `zero providers setup longcat` instead of
hand-entering a custom endpoint.

LongCat-2.0 is Meituan's open, OpenAI- and Anthropic-compatible model. This
wires the OpenAI-compatible transport:
  base URL: https://api.longcat.chat/openai  (Zero appends /chat/completions)
  model:    LongCat-2.0
  auth:     Bearer via LONGCAT_API_KEY

Purely additive — one descriptor plus its catalog/onboarding tests and a README
env-var line; no shared plumbing is touched, so existing providers are
unaffected. Placed with the other OpenAI-compatible third parties (after
moonshot), below the first-party providers.

Verified: go build / vet / test green (catalog + cli onboarding), gofmt clean,
and `zero providers catalog` lists the descriptor with the correct fields.
Endpoint is wired by-spec from LongCat's published docs; a live completion still
needs a real LONGCAT_API_KEY to confirm.
2026-07-03 12:44:11 +08:00
Alaa Elsamouly 5b2b4dea1a fix(tui): compose help overlay through the viewport overlay pipeline (#421)
* fix(tui): compose help overlay through the viewport overlay pipeline

exactly like how other popups works.

* fix for the CI/CD of formatting

* Added regression tests for the help overlay

* formatted code
2026-07-03 11:33:17 +08:00
103 changed files with 6070 additions and 383 deletions

No files matched your search

+17 -1
View File
@@ -4,12 +4,14 @@ on:
pull_request:
paths:
- action.yml
- docs/GITHUB_ACTION.md
- .github/workflows/zero-action-smoke.yml
push:
branches:
- main
paths:
- action.yml
- docs/GITHUB_ACTION.md
- .github/workflows/zero-action-smoke.yml
jobs:
@@ -29,7 +31,8 @@ jobs:
import sys, yaml
with open("action.yml", "r", encoding="utf-8") as handle:
doc = yaml.safe_load(handle)
action_raw = handle.read()
doc = yaml.safe_load(action_raw)
assert isinstance(doc, dict), "action.yml must be a mapping"
assert doc.get("name"), "action must declare a name"
@@ -65,6 +68,19 @@ jobs:
assert "--skip-permissions-unsafe" not in serialized, \
"action must never pass --skip-permissions-unsafe"
run_zero = next((step for step in steps if step.get("name") == "Run ZERO"), None)
assert run_zero is not None, "action.yml must contain a Run ZERO step"
run_zero_script = run_zero.get("run") or ""
assert 'export "${ZERO_INPUT_API_KEY_ENV}=${ZERO_INPUT_API_KEY}"' in run_zero_script, \
"Run ZERO must export the provider key for the current step"
assert "later steps inherit" not in action_raw.lower(), \
"provider keys must not be documented as inherited by later steps"
for line in run_zero_script.splitlines():
if "GITHUB_ENV" in line and (
"ZERO_INPUT_API_KEY" in line or "ZERO_INPUT_API_KEY_ENV" in line
):
raise AssertionError("provider key must not be persisted to GITHUB_ENV")
print(f"action.yml OK: {len(inputs)} inputs, {len(outputs)} outputs, {len(steps)} steps")
PY
+1 -1
View File
@@ -26,7 +26,7 @@ Accepted file names, in priority order at each level:
| `./ZERO.md` | Brand-specific alias. Same format, lower priority. |
| `./.zero/AGENTS.md` | Project-local, hidden, gitignored. Personal notes that stay out of git. |
Matching is **case-insensitive** on the basename, so `AGENTS.md`, `Agents.md`, and `agents.md` resolve to the same file on Windows and macOS. The git-tracked filename in this repo is `AGENTS.MD` (uppercase `MD`) — keep that on case-sensitive filesystems (Linux, the WSL filesystem, or a CI runner) to match what the loader looks for.
Matching is **case-insensitive** on the basename, so `AGENTS.md`, `Agents.md`, and `agents.md` resolve to the same file on Windows and macOS. The git-tracked filename in this repo is `AGENTS.md` — keep that on case-sensitive filesystems (Linux, the WSL filesystem, or a CI runner) to match what the loader looks for.
Both files use the same format. YAML frontmatter is optional; the markdown body is loaded as instructions for the agent. Zero reads the file once at session start, so changes take effect on the next `zero` launch — not mid-session.
+1 -1
View File
@@ -1,4 +1,4 @@
# Zero build/test/lint targets. AGENTS.MD says "Build with `make`" and "Run `make
# Zero build/test/lint targets. AGENTS.md says "Build with `make`" and "Run `make
# lint` before opening a PR" — these targets back those instructions.
.DEFAULT_GOAL := build
.PHONY: build build-all test test-race vet fmt fmt-check lint tidy clean help
+14 -8
View File
@@ -8,6 +8,8 @@
<a href="LICENSE"><img alt="license" src="https://img.shields.io/badge/license-MIT-blue"></a>
<img alt="Go 1.25+" src="https://img.shields.io/badge/Go-1.25+-00ADD8?logo=go&logoColor=white">
<img alt="25+ providers" src="https://img.shields.io/badge/providers-25+-34E2EA">
<br>
<strong>English</strong> | <a href="README_ZH.md">中文</a>
</p>
Zero is an AI coding agent for your local terminal. It can inspect a repository,
@@ -57,20 +59,24 @@ Bun does not run dependency lifecycle scripts by default, so the `postinstall`
that fetches the Zero binary is skipped and the first run fails with
`No native binary found next to the npm wrapper`.
Install with Bun, then fetch the binary in one of two ways:
The simplest fix is to trust the package after installing, which runs the
blocked postinstall. This works for project and global installs:
```bash
# Option A: run the installer manually
# project install
bun add @gitlawb/zero
node node_modules/@gitlawb/zero/scripts/postinstall.mjs
bun pm trust @gitlawb/zero
# Option B: allow the postinstall to run on install
# add to your package.json: "trustedDependencies": ["@gitlawb/zero"]
bun add @gitlawb/zero
# global install
bun add -g @gitlawb/zero
bun pm -g trust @gitlawb/zero
```
For global installs (`bun add -g @gitlawb/zero`), use Option A against the
global install path, or prefer the install scripts below.
Alternatives: allow the postinstall up front by adding
`"trustedDependencies": ["@gitlawb/zero"]` to your project's package.json
before `bun add`, or run the installer manually
(`node node_modules/@gitlawb/zero/scripts/postinstall.mjs`) on Bun versions
that do not have `bun pm trust`.
### Install scripts
+297
View File
@@ -0,0 +1,297 @@
<p align="center">
<img src="docs/assets/zero-logo.png" alt="Zero" width="385">
</p>
<p align="center"><strong>一个独属于你的终端编码智能体。</strong></p>
<p align="center">
<a href="LICENSE"><img alt="license" src="https://img.shields.io/badge/license-MIT-blue"></a>
<img alt="Go 1.25+" src="https://img.shields.io/badge/Go-1.25+-00ADD8?logo=go&logoColor=white">
<img alt="25+ providers" src="https://img.shields.io/badge/providers-25+-34E2EA">
<br>
<a href="README.md">English</a> | <strong>中文</strong>
</p>
Zero 是一个用于本地终端的 AI 编码智能体。它可以检查仓库、编辑文件、运行命令、使用浏览器/终端辅助工具,并在你选择模型和权限级别的同时保持持久的本地会话。
```bash
zero
zero exec "修复 ./pkg 中失败的测试"
zero exec --output-format stream-json < turns.jsonl
```
## 为什么选择 Zero
- **使用你想要的模型。** 支持 OpenAI、Anthropic、Gemini、Groq、OpenRouter、DeepSeek、Mistral、xAI、Qwen、Kimi、GitHub Models、Ollama、LM Studio,或任何 OpenAI/Anthropic 兼容端点。
- **保持控制权。** 文件写入、Shell 命令、网络访问和工作区外写入都经过 Zero 的权限和沙箱策略。
- **在终端中工作。** TUI 具有模型/提供商选择器、图片输入、斜杠命令、实时计划/工具渲染、回滚滚动、主题以及恢复/分叉支持。
- **无 TUI 也能工作。** `zero exec` 可脚本化,支持文本/JSON/stream-JSON I/O、隔离的工作树、规范优先运行,以及用于 CI 的有意义的退出码。
- **保持上下文本地化。** 会话存储在磁盘上,可搜索、可恢复,且 Zero 不会作为遥测数据上传。
- **可扩展。** 使用 MCP 服务器、技能、插件、钩子和来自同一 CLI 的专业子智能体。
## 安装
### npm
```bash
npm install -g @gitlawb/zero
zero
```
npm 包安装一个小型包装器以及与你平台匹配的 Zero 二进制文件(从 GitHub Releases 获取)。支持 Linux、macOS 和 Windows 的 x64 和 arm64。
### Bun
Bun 默认不运行依赖的生命周期脚本,因此获取 Zero 二进制文件的 `postinstall` 会被跳过,首次运行会失败并显示 `No native binary found next to the npm wrapper`。
最简单的解决方法是安装后信任该包,这会运行被阻止的 postinstall。项目安装和全局安装均适用:
```bash
# 项目安装
bun add @gitlawb/zero
bun pm trust @gitlawb/zero
# 全局安装
bun add -g @gitlawb/zero
bun pm -g trust @gitlawb/zero
```
其他方式:在 `bun add` 之前将 `"trustedDependencies": ["@gitlawb/zero"]` 添加到项目的 package.json 以提前允许 postinstall;或在不支持 `bun pm trust` 的旧版 Bun 上手动运行安装程序(`node node_modules/@gitlawb/zero/scripts/postinstall.mjs`)。
### 安装脚本
Linux/macOS:
```bash
curl -fsSL https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.sh | bash
```
Windows PowerShell:
```powershell
irm https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.ps1 | iex
```
### 从源码构建
源码构建需要 Go 1.25+。
```bash
git clone https://github.com/Gitlawb/zero.git
cd zero
go run ./cmd/zero
```
发布安装器和 npm 包需要已发布的 GitHub Release 资源。如果你在首次公开发布之前进行测试,请从源码构建:
```bash
go build -o zero ./cmd/zero
```
在 Linux 上,如果你需要原生沙箱,还需要构建沙箱辅助程序:
```bash
go build -o zero-linux-sandbox ./cmd/zero-linux-sandbox
go build -o zero-seccomp ./cmd/zero-seccomp # 可选的兼容性包装器
```
将 `zero` 和 `zero-linux-sandbox` 放在 `PATH` 上的同一目录中(`~/.local/bin` 是一个好的默认选择)。macOS 不需要额外的辅助二进制文件。Windows 源码构建可以使用主 `zero.exe` 作为沙箱辅助程序;发布包仍然附带独立的 Windows 辅助可执行文件。
更多安装细节:[docs/INSTALL.md](docs/INSTALL.md)。
## 首次运行
启动 TUI:
```bash
zero
```
设置向导帮助你选择提供商和模型。你也可以从命令行配置提供商:
```bash
zero setup
zero providers list
zero models list
zero doctor
```
对于 API 提供商,在设置之前设置匹配的环境变量或在向导中输入密钥:
```bash
export OPENAI_API_KEY=sk-...
export ANTHROPIC_API_KEY=...
export GEMINI_API_KEY=...
export LONGCAT_API_KEY=...
```
要直接配置美团 LongCat(LongCat-2.0),运行:
```bash
zero providers setup longcat --set-active
```
对于本地模型,运行 Ollama 或 LM Studio,然后使用 `zero setup` 或 `zero providers detect`。
## 日常使用
### 交互式 TUI
```bash
zero
```
常用控制:
| 控制 | 操作 |
|---|---|
| `Enter` | 发送提示 |
| `/` | 打开斜杠命令建议 |
| `Shift+Tab` | 切换权限模式 |
| `Ctrl+B` | 显示/隐藏侧边栏 |
| `Ctrl+C` | 取消或退出 |
常用斜杠命令:
| 命令 | 用途 |
|---|---|
| `/model`、`/provider` | 切换活动模型/提供商 |
| `/spec`、`/plan` | 在构建之前起草和审查计划 |
| `/image` | 为视觉模型附加图片 |
| `/resume`、`/rewind` | 继续或回滚本地会话 |
| `/compact`、`/context` | 管理上下文使用 |
| `/permissions`、`/tools` | 检查可用工具和策略 |
| `/add-dir` | 为此会话授予额外的写入目录 |
| `/theme`、`/doctor`、`/config` | 调整外观和检查设置 |
### 无头 `exec` 模式
```bash
zero exec "解释 internal/agent/loop.go"
zero exec --model claude-sonnet-4.5 "重构配置加载器"
zero exec --use-spec "为 API 客户端添加速率限制"
zero exec --worktree "在隔离的工作树中尝试迁移"
zero exec --resume
zero exec --fork <session-id> "尝试另一种方法"
```
编程使用:
```bash
zero exec --input-format stream-json --output-format stream-json < turns.jsonl
```
Stream-JSON 协议文档在 [docs/STREAM_JSON_PROTOCOL.md](docs/STREAM_JSON_PROTOCOL.md)。
## 安全模型
Zero 旨在使副作用可见。
- 工作区读取默认允许。
- 文件写入限制在工作区内,除非你授予其他目录。
- Shell 命令、网络访问、破坏性命令和提权操作需要权限授权。
- `--add-dir <path>` 和 `/add-dir <path>` 授予额外的写入根目录,而不会给智能体整个文件系统。
- 不安全/自主模式是显式选择加入。
- 在 Zero 控制的界面上,密钥会从工具输出和日志中被脱敏。
示例:
```bash
zero --add-dir ../docs-site
zero exec --add-dir ../shared "更新两个仓库"
```
沙箱行为可以通过以下方式检查:
```bash
zero sandbox policy
zero sandbox grants list
```
## Web 和本地控制
Zero 包含本地文件/搜索/编辑/Shell 工具、用于公共 URL 的 `web_fetch`,以及用于额外工具的 MCP 支持。
对于本地开发服务器,使用 Shell 命令(如通过 `exec_command` 的 `curl`),这样正常的沙箱和权限策略就会生效。长时间运行的命令保持附加到后台终端会话,可以从 TUI 中列出或停止。
npm 包还包含本地浏览器/终端工具使用的浏览器和终端辅助包。源码构建可以在它们位于 `PATH` 上或在 Zero 的本地控制设置中配置时使用相同的辅助工具。
## 常用命令
```text
zero 交互式 TUI
zero exec 一次性或脚本化智能体运行
zero setup 首次运行提供商设置
zero auth 支持提供商的 OAuth/登录辅助
zero models 模型注册表和能力
zero providers 提供商配置和检测
zero doctor 设置、密钥和连接检查
zero context 上下文预算报告
zero repo-map 确定性仓库映射
zero repo-info 本地仓库摘要
zero search | find 搜索本地会话历史
zero sessions 检查、恢复、分叉和回滚会话
zero spec 管理规范模式草稿
zero specialist 管理专业子智能体
zero skills 管理 Markdown 指令技能
zero plugins 管理插件
zero hooks 管理生命周期钩子
zero mcp 管理 MCP 服务器和工具
zero serve --mcp 通过 MCP stdio 暴露 Zero 工具
zero sandbox 检查沙箱策略和授权
zero worktrees 准备隔离的 Git 工作树
zero verify 检测和运行本地验证检查
zero changes 检查和提交本地 Git 变更
zero usage Token 使用量和估算成本
zero cron 定时智能体任务
zero update 检查更新版本
```
## 外观和无障碍
| 控制 | 效果 |
|---|---|
| `NO_COLOR=<任意值>` | 禁用颜色输出 |
| `ZERO_THEME=<名称>` | 选择启动主题(`auto`、`dark`、`light`,或颜色主题如 `dracula`、`nord`、`gruvbox`、`tokyo-night`、`catppuccin`、`one-dark`、`solarized-dark`、`rose-pine`、`everforest`、`solarized-light`) |
| `--theme <名称>` | 从 CLI 选择 TUI 主题(相同名称) |
| `/theme` | 在 TUI 中打开主题选择器(实时预览;`/theme <名称>` 直接切换) |
| `ZERO_NO_FADE=1` | 禁用流式淡入动画 |
含义不仅仅依赖于颜色;差异、权限和状态也使用文本或标记符号。
## 开发
```bash
go test ./...
go run ./cmd/zero-release build
go run ./cmd/zero-release smoke
go run ./cmd/zero-perf-bench
```
交叉编译示例:
```bash
go run ./cmd/zero-release build --goos linux --goarch amd64
go run ./cmd/zero-release build --goos windows --goarch amd64 --output dist/zero.exe
```
## 文档
- [安装](docs/INSTALL.md)
- [更新流程](docs/UPDATE.md)
- [Stream-JSON 协议](docs/STREAM_JSON_PROTOCOL.md)
- [专家](docs/SPECIALISTS.md)
- [GitHub Action](docs/GITHUB_ACTION.md)
- [基准测试](docs/BENCHMARK.md)
- [性能](docs/PERFORMANCE.md)
- [智能体评估](docs/AGENT_EVALS.md)
## 贡献
欢迎贡献。阅读 [CONTRIBUTING.md](CONTRIBUTING.md),运行相关测试,然后提交一个聚焦的拉取请求。
安全报告应遵循 [SECURITY.md](SECURITY.md)。
## 许可证
Zero 基于 [MIT 许可证](LICENSE) 发布。
+4 -5
View File
@@ -27,14 +27,14 @@ inputs:
api-key:
description: >-
The provider API key. Pass it from a repository or organization secret;
it is exported into the step environment and never written to the log.
it is exported only for the ZERO step and never written to the log.
required: false
default: ""
api-key-env:
description: >-
Name of the environment variable the chosen provider reads its key from
(for example OPENAI_API_KEY or ANTHROPIC_API_KEY). When set together with
api-key, that variable is exported for the run. Leave the action
api-key, that variable is exported only for the ZERO step. Leave the action
provider-agnostic by passing the env name your provider expects rather
than hardcoding one here.
required: false
@@ -192,10 +192,9 @@ runs:
echo "::error::api-key-env must be a valid environment variable name." >&2
exit 2
fi
# Export in-process so the zero invocation in this same run block sees
# it, and append to GITHUB_ENV so any later steps inherit it too.
# Export in-process for this Run ZERO step so zero can read it; do
# not persist provider credentials to later job steps.
export "${ZERO_INPUT_API_KEY_ENV}=${ZERO_INPUT_API_KEY}"
printf '%s=%s\n' "${ZERO_INPUT_API_KEY_ENV}" "${ZERO_INPUT_API_KEY}" >> "${GITHUB_ENV}"
fi
if [ -n "${ZERO_INPUT_PROVIDER}" ]; then
export ZERO_PROVIDER="${ZERO_INPUT_PROVIDER}"
+5 -3
View File
@@ -66,10 +66,12 @@ if (!existsSync(nativePath)) {
'\n' +
(ranByBun
? 'You installed with Bun, which does not run dependency lifecycle scripts\n' +
'by default. To let it run on future installs, add zero to your\n' +
'project\'s trustedDependencies:\n' +
'by default. Trust the package to run the blocked postinstall:\n' +
' bun pm trust @gitlawb/zero (project install)\n' +
' bun pm -g trust @gitlawb/zero (global install)\n' +
'On Bun versions without `bun pm trust`, add\n' +
' "trustedDependencies": ["@gitlawb/zero"]\n' +
'then reinstall.\n' +
'to your project package.json and reinstall.\n' +
'\n'
: '') +
'If that fails, build from source: https://github.com/Gitlawb/zero\n' +
+9 -7
View File
@@ -41,8 +41,9 @@ jobs:
> provider-neutral: `api-key-env` is the environment variable name the chosen
> provider reads its key from (for example `ANTHROPIC_API_KEY`, `GEMINI_API_KEY`,
> `OPENROUTER_API_KEY`), and `api-key` is the secret value. The action exports
> that variable for the run and never prints it. If your repository already
> commits a `.zero/config.json` with an active provider, you can omit `provider`.
> that variable only for the ZERO step and never prints it. If your repository
> already commits a `.zero/config.json` with an active provider, you can omit
> `provider`.
## Inputs
@@ -51,8 +52,8 @@ jobs:
| `prompt` | one of `prompt`/`prompt-file` | `""` | The instruction for ZERO to execute. |
| `prompt-file` | one of `prompt`/`prompt-file` | `""` | Path (relative to `working-directory`) to a file whose contents are the prompt. |
| `provider` | no | `""` | Provider id to activate (e.g. `openai`, `anthropic`, `gemini`, `ollama`, or any compatible endpoint). |
| `api-key` | no | `""` | The provider API key. Pass from a secret; never logged. |
| `api-key-env` | no | `""` | Env var name the provider reads its key from (e.g. `OPENAI_API_KEY`). Exported with `api-key`. |
| `api-key` | no | `""` | The provider API key. Pass from a secret; exported only for the ZERO step and never logged. |
| `api-key-env` | no | `""` | Env var name the provider reads its key from (e.g. `OPENAI_API_KEY`). Exported only for the ZERO step when used with `api-key`. |
| `model` | no | `""` | Model id. Defaults to the resolved provider's default. |
| `mode` | no | `""` | Run mode (`zero exec --mode`), e.g. `smart`, `deep`, `fast`. |
| `auto` | no | `low` | Autonomy ceiling (`zero exec --auto`): `low`, `medium`, or `high`. Conservative by default. |
@@ -162,9 +163,10 @@ jobs:
## Security notes
- **Secrets are passed as Action secrets and never logged.** The action exports
`api-key` (under `api-key-env`) and `slack-webhook-url` into the step
environment only. They are never echoed, and ZERO redacts secret-shaped
strings from its own output.
`api-key` (under `api-key-env`) only for the ZERO step. `slack-webhook-url`
is passed only to the Slack post step. They are never echoed or persisted to
later workflow steps, and ZERO redacts secret-shaped strings from its own
output.
- **The sandbox is always active.** This action never passes
`--skip-permissions-unsafe`, so writes stay inside the checked-out repository
(plus any roots you grant with `add-dir`). Unsafe mode is never enabled
+19 -6
View File
@@ -31,15 +31,24 @@ dependencies (only the installing project's own scripts), so the `postinstall`
that fetches the Zero binary is silently skipped. The first run then fails with
`No native binary found next to the npm wrapper`.
To install with Bun, either run the installer manually after installing:
The simplest fix is to trust the package after installing, which runs the
blocked postinstall. This works for project and global installs:
```bash
# project install
bun add @gitlawb/zero
node node_modules/@gitlawb/zero/scripts/postinstall.mjs
bun pm trust @gitlawb/zero
# global install
bun add -g @gitlawb/zero
bun pm -g trust @gitlawb/zero
```
Or allow the postinstall to run by adding the package to your project's
`trustedDependencies` before installing:
`bun pm untrusted` (or `bun pm -g untrusted`) lists the blocked postinstalls if
you want to inspect before trusting.
Alternatively, allow the postinstall to run at install time by adding the
package to your project's `trustedDependencies` before installing:
```json
{
@@ -51,8 +60,12 @@ Or allow the postinstall to run by adding the package to your project's
bun add @gitlawb/zero
```
For global installs (`bun add -g @gitlawb/zero`), run the installer manually
against the global install path, or use the install scripts below.
On Bun versions that do not have `bun pm trust`, run the installer manually
after installing:
```bash
node node_modules/@gitlawb/zero/scripts/postinstall.mjs
```
Reference: <https://bun.sh/docs/pm/lifecycle>
@@ -0,0 +1,146 @@
package agent
import (
"context"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/tools"
)
// A shell call that sets sandbox_permissions: with_additional_permissions but
// omits (or malforms) additional_permissions can never succeed: the same
// validation runs again at grant time regardless of the user's decision. It
// must be rejected as a plain tool error BEFORE any permission prompt, not
// surfaced as a confusing "approved but denied" prompt outcome.
func TestExecuteToolCallRejectsMissingAdditionalPermissionsBeforePrompt(t *testing.T) {
root := t.TempDir()
registry := tools.NewRegistry()
registry.Register(tools.NewBashTool(root))
promptCalls := 0
result, err := executeToolCall(
context.Background(),
registry,
ToolCall{
ID: "c1",
Name: "bash",
Arguments: `{"command":"echo hi","sandbox_permissions":"with_additional_permissions"}`,
},
PermissionModeAuto,
Options{
OnPermissionRequest: func(ctx context.Context, request PermissionRequest) (PermissionDecision, error) {
promptCalls++
return PermissionDecision{Action: PermissionDecisionAllow}, nil
},
},
)
if err != nil {
t.Fatalf("executeToolCall: %v", err)
}
if result.Status != tools.StatusError {
t.Fatalf("status = %q, want error", result.Status)
}
if promptCalls != 0 {
t.Fatalf("OnPermissionRequest called %d times, want 0 (must reject before prompting)", promptCalls)
}
}
// The mismatched-flag case (additional_permissions present without the
// sandbox_permissions flag) must be rejected the same way.
func TestExecuteToolCallRejectsAdditionalPermissionsWithoutFlagBeforePrompt(t *testing.T) {
root := t.TempDir()
registry := tools.NewRegistry()
registry.Register(tools.NewBashTool(root))
promptCalls := 0
result, err := executeToolCall(
context.Background(),
registry,
ToolCall{
ID: "c1",
Name: "bash",
Arguments: `{"command":"echo hi","additional_permissions":{"network":{"enabled":true}}}`,
},
PermissionModeAuto,
Options{
OnPermissionRequest: func(ctx context.Context, request PermissionRequest) (PermissionDecision, error) {
promptCalls++
return PermissionDecision{Action: PermissionDecisionAllow}, nil
},
},
)
if err != nil {
t.Fatalf("executeToolCall: %v", err)
}
if result.Status != tools.StatusError {
t.Fatalf("status = %q, want error", result.Status)
}
if promptCalls != 0 {
t.Fatalf("OnPermissionRequest called %d times, want 0 (must reject before prompting)", promptCalls)
}
}
// The rejection message must show a concrete, correctly-shaped example: a
// model that gets this wrong once (attaching sandbox_permissions out of habit
// from an earlier call, without a valid payload) needs enough in the error to
// self-correct on retry instead of repeating the identical mistake.
func TestExecuteToolCallMissingAdditionalPermissionsErrorIsActionable(t *testing.T) {
root := t.TempDir()
registry := tools.NewRegistry()
registry.Register(tools.NewBashTool(root))
result, err := executeToolCall(
context.Background(),
registry,
ToolCall{
ID: "c1",
Name: "bash",
Arguments: `{"command":"ping example.com","sandbox_permissions":"with_additional_permissions"}`,
},
PermissionModeAuto,
Options{},
)
if err != nil {
t.Fatalf("executeToolCall: %v", err)
}
if !strings.Contains(result.Output, `{"network": {"enabled": true}}`) {
t.Fatalf("output = %q, want a concrete additional_permissions example", result.Output)
}
if !strings.Contains(result.Output, "omit sandbox_permissions") {
t.Fatalf("output = %q, want guidance that the flag can simply be omitted", result.Output)
}
}
// A well-formed additional_permissions payload must still reach the normal
// permission prompt: this fix only rejects malformed payloads.
func TestExecuteToolCallStillPromptsForValidAdditionalPermissions(t *testing.T) {
root := t.TempDir()
registry := tools.NewRegistry()
registry.Register(tools.NewBashTool(root))
promptCalls := 0
_, err := executeToolCall(
context.Background(),
registry,
ToolCall{
ID: "c1",
Name: "bash",
Arguments: `{"command":"echo hi","sandbox_permissions":"with_additional_permissions",` +
`"additional_permissions":{"network":{"enabled":true}}}`,
},
PermissionModeAuto,
Options{
OnPermissionRequest: func(ctx context.Context, request PermissionRequest) (PermissionDecision, error) {
promptCalls++
return PermissionDecision{Action: PermissionDecisionDeny}, nil
},
},
)
if err != nil {
t.Fatalf("executeToolCall: %v", err)
}
if promptCalls != 1 {
t.Fatalf("OnPermissionRequest called %d times, want exactly 1 for a valid elevation request", promptCalls)
}
}
+9 -4
View File
@@ -21,12 +21,17 @@ import (
// the agent loop owns the provider wiring.
// defaultCompactionPreserveLast is how many trailing messages are kept verbatim
// when the caller does not specify a preserve count.
const defaultCompactionPreserveLast = 8
// when the caller does not specify a preserve count. Kept in sync with the replay
// reconstruction default in internal/sessions/replay.go.
const defaultCompactionPreserveLast = 6
// compactionTriggerRatio is the fraction of the context window at which
// proactive compaction kicks in (top of each turn).
const compactionTriggerRatio = 0.8
// proactive compaction kicks in (top of each turn). NOTE: this is speculative
// tuning — validate against the per-turn latency/compaction metrics (roadmap 5.2)
// before trusting it. Measured fixed overhead is ~7.4k tokens (not the ~16k the
// roadmap assumed), so context fills slower; if data shows 0.7 triggers redundant
// summarization, raise it back toward 0.8.
const compactionTriggerRatio = 0.7
// summaryLabel prefixes the injected summary so it is unmistakable in the
// transcript (and so tests can assert on it).
+107 -60
View File
@@ -816,6 +816,25 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
}
}
// A shell command that sets sandbox_permissions: with_additional_permissions
// must carry a valid additional_permissions payload; inlineAdditionalPermissionsProfile
// is the single source of truth for that shape, and buildPermissionEvent (below)
// calls it again to render the prompt's scope text. Check it here, before any
// permission prompt is shown: a malformed payload can never be satisfied no
// matter what the user decides, so surfacing it as a plain tool error (which the
// model can see and retry) is both clearer and avoids presenting a normal-looking
// prompt whose "allow" path is guaranteed to fail with a confusing denial.
if toolFound && isShellCommandTool(call.Name) {
if _, _, err := inlineAdditionalPermissionsProfile(args, options.Cwd); err != nil {
return ToolResult{
ToolCallID: call.ID,
Name: call.Name,
Status: tools.StatusError,
Output: "Error: " + err.Error(),
}, nil
}
}
// ask_user is intercepted in the loop (like permissions) so the question can
// be routed to an interactive front-end instead of blocking inside the tool.
// When no front-end is wired up it degrades to the tool's own graceful Run().
@@ -2322,7 +2341,12 @@ func inlineAdditionalPermissionsProfile(args map[string]any, basePath string) (s
}
raw, ok := args["additional_permissions"]
if !ok || raw == nil {
return sandbox.RequestPermissionProfile{}, true, fmt.Errorf("missing additional_permissions; provide at least one of network or file_system")
return sandbox.RequestPermissionProfile{}, true, fmt.Errorf(
"sandbox_permissions was set to %q but no additional_permissions object was provided. "+
`Include one, for example additional_permissions: {"network": {"enabled": true}} or `+
`{"file_system": {"write": ["/path"]}}. If this command does not need elevated permissions, `+
"omit sandbox_permissions entirely and retry",
tools.SandboxPermissionsWithAdditionalPermissions)
}
data, err := json.Marshal(raw)
if err != nil {
@@ -2337,7 +2361,9 @@ func inlineAdditionalPermissionsProfile(args map[string]any, basePath string) (s
return sandbox.RequestPermissionProfile{}, true, err
}
if normalized.Empty() {
return sandbox.RequestPermissionProfile{}, true, fmt.Errorf("additional_permissions must include at least one requested permission in network or file_system")
return sandbox.RequestPermissionProfile{}, true, fmt.Errorf(
"additional_permissions must include at least one of network or file_system, for example " +
`{"network": {"enabled": true}} or {"file_system": {"write": ["/path"]}}`)
}
grantProfile, err := sandbox.RequestPermissionGrantProfile(normalized)
if err != nil {
@@ -2470,76 +2496,97 @@ func partitionTools(registry *tools.Registry, permissionMode PermissionMode, opt
active := options.DeferThreshold > 0 && eligible >= options.DeferThreshold && loaderUsable
definitions := make([]zeroruntime.ToolDefinition, 0, len(visible))
exposedNames := make(map[string]bool, len(visible))
// INACTIVE: every visible tool eager (except tool_search), alpha-sorted. With no
// deferral there is no mid-session loading, so this is byte-stable across turns
// and byte-identical to the pre-deferral output.
if !active {
definitions := make([]zeroruntime.ToolDefinition, 0, len(visible))
for _, tool := range visible {
if tool.Name() == tools.ToolSearchToolName {
continue
}
definitions = append(definitions, runtimeToolDefinition(tool))
}
sort.Slice(definitions, func(left int, right int) bool {
return definitions[left].Name < definitions[right].Name
})
return definitions, ""
}
// ACTIVE: lay the tools array out so its cacheable prefix does NOT shift when a
// deferred tool loads mid-session. The tools block is part of the provider's
// cached prefix; if a load reorders it, the cache invalidates from that point
// through the system prompt and messages — on EVERY load. Previously the whole
// array was alpha-sorted each turn, so a newly loaded tool was INSERTED into the
// middle and shifted every later definition. Instead we build three append-only
// regions:
// 1. non-deferred tools, alpha-sorted — always present, identical every turn;
// 2. tool_search — always present, right after the stable block;
// 3. loaded deferred tools, alpha-sorted — APPENDED, so an unloaded->loaded
// transition grows the tail instead of inserting into the eager block.
// (tool_search's discovery text still shrinks as tools load, so keeping it and
// the loaded tail AFTER the eager block preserves the eager tools' cache across a
// load; fully stabilizing the loader's own description is a scoped follow-up.)
eager := make([]zeroruntime.ToolDefinition, 0, len(visible))
loadedTail := make([]zeroruntime.ToolDefinition, 0)
var hiddenTools []tools.Tool
for _, tool := range visible {
name := tool.Name()
deferred := tools.IsDeferred(tool)
if !active {
// Inactive: byte-identical to legacy, but tool_search is never advertised.
if name == tools.ToolSearchToolName {
continue
if name == tools.ToolSearchToolName {
continue // added explicitly between the eager block and the loaded tail
}
if tools.IsDeferred(tool) {
if loaded[name] {
loadedTail = append(loadedTail, runtimeToolDefinition(tool))
} else {
hiddenTools = append(hiddenTools, tool)
}
definitions = append(definitions, zeroruntime.ToolDefinition{
Name: name,
Description: tool.Description(),
Parameters: schemaToRuntimeMap(tool.Parameters()),
})
exposedNames[name] = true
continue
}
// Active path.
if deferred && !loaded[name] {
hiddenTools = append(hiddenTools, tool)
continue
}
definitions = append(definitions, zeroruntime.ToolDefinition{
Name: name,
Description: tool.Description(),
Parameters: schemaToRuntimeMap(tool.Parameters()),
})
exposedNames[name] = true
eager = append(eager, runtimeToolDefinition(tool))
}
// On the ACTIVE path tool_search is guaranteed runnable (active implies
// loaderUsable), so it must ALWAYS be reachable — even when a non-empty
// EnabledTools allowlist omits it (the operator allowlisted the deferred
// tools, not the loader). Expose its full definition whenever it is not
// already in the exposed set. This never runs on the inactive path, so the
// byte-identical below-threshold output is preserved.
discovery := ""
if active && len(hiddenTools) > 0 {
discovery = tools.BuildToolSearchDescription(hiddenTools)
}
if active && !exposedNames[tools.ToolSearchToolName] {
description := loader.Description()
if discovery != "" {
description = discovery
}
definitions = append(definitions, zeroruntime.ToolDefinition{
Name: loader.Name(),
Description: description,
Parameters: schemaToRuntimeMap(loader.Parameters()),
})
} else if active && discovery != "" {
for i := range definitions {
if definitions[i].Name == tools.ToolSearchToolName {
definitions[i].Description = discovery
break
}
}
}
sort.Slice(definitions, func(left int, right int) bool {
return definitions[left].Name < definitions[right].Name
sort.Slice(eager, func(left int, right int) bool {
return eager[left].Name < eager[right].Name
})
sort.Slice(loadedTail, func(left int, right int) bool {
return loadedTail[left].Name < loadedTail[right].Name
})
discovery := ""
if len(hiddenTools) > 0 {
discovery = tools.BuildToolSearchDescription(hiddenTools)
}
// tool_search is guaranteed runnable on the active path, so it is ALWAYS exposed
// — even when a non-empty EnabledTools allowlist omits it (the operator
// allowlisted the deferred tools, not the loader). It sits right after the stable
// eager block and carries the discovery list for still-hidden tools.
description := loader.Description()
if discovery != "" {
description = discovery
}
definitions := make([]zeroruntime.ToolDefinition, 0, len(eager)+1+len(loadedTail))
definitions = append(definitions, eager...)
definitions = append(definitions, zeroruntime.ToolDefinition{
Name: loader.Name(),
Description: description,
Parameters: schemaToRuntimeMap(loader.Parameters()),
})
definitions = append(definitions, loadedTail...)
return definitions, discovery
}
// runtimeToolDefinition renders a tool's advertised definition (name, description,
// JSON-schema parameters) as sent to the provider.
func runtimeToolDefinition(tool tools.Tool) zeroruntime.ToolDefinition {
return zeroruntime.ToolDefinition{
Name: tool.Name(),
Description: tool.Description(),
Parameters: schemaToRuntimeMap(tool.Parameters()),
}
}
func ToolVisible(tool tools.Tool, permissionMode PermissionMode, enabledTools []string, disabledTools []string) bool {
return ToolAllowedByFilters(tool.Name(), enabledTools, disabledTools) && ToolAdvertised(tool, permissionMode)
}
@@ -0,0 +1,44 @@
package agent
import (
"testing"
"github.com/Gitlawb/zero/internal/tools"
)
func TestPartitionToolsActiveAppendsLoadedToolAfterEagerBlock(t *testing.T) {
// Cache-stability property: loading a deferred tool must APPEND it after the
// always-eager tools, not sort it into the middle of them — otherwise the tool
// definitions (the provider's cacheable prefix) shift on every load. The old
// behaviour alpha-sorted the whole array, so "mcp__srv__alpha" jumped ahead of
// "read_file".
root := t.TempDir()
registry := tools.NewRegistry()
registry.Register(tools.NewReadFileTool(root)) // non-deferred, "read_file"
registry.Register(fakeToolSearchTool{})
registry.Register(fakeDeferredTool{name: "mcp__srv__alpha", desc: "alpha"})
registry.Register(fakeDeferredTool{name: "mcp__srv__beta", desc: "beta"})
exposed, _ := partitionTools(registry, PermissionModeAuto, Options{DeferThreshold: 2}, map[string]bool{"mcp__srv__alpha": true})
pos := func(name string) int {
for i := range exposed {
if exposed[i].Name == name {
return i
}
}
return -1
}
readPos, searchPos, alphaPos := pos("read_file"), pos("tool_search"), pos("mcp__srv__alpha")
if readPos < 0 || searchPos < 0 || alphaPos < 0 {
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) {
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.
if pos("mcp__srv__beta") != -1 {
t.Fatalf("unloaded deferred tool must stay hidden, got %#v", exposed)
}
}
+51
View File
@@ -0,0 +1,51 @@
package agent
import (
"testing"
"github.com/Gitlawb/zero/internal/tools"
)
// These ceilings are a deliberate ratchet on Zero's fixed per-turn overhead — the
// system prompt and the eager tool schemas that ride on EVERY request. They are
// set ~10% above the current measured cost, using ApproxTextTokens (the same
// estimate the compaction loop and /context use, ~non-whitespace-bytes/4). A change
// that pushes either past its ceiling must be justified (and the ceiling raised
// deliberately) or trimmed — the per-turn floor should not creep up silently.
//
// Measured baselines (2026-07): base system prompt ~3160 tokens; the tools a normal
// (auto permission-mode) interactive turn sends ~2430 tokens. The tool figure is
// the auto-mode advertised set, not the full core registry — higher-risk tools that
// only advertise in other modes are excluded, matching what a real turn actually
// pays.
const (
maxBaseSystemPromptTokens = 3500
maxEagerToolSchemaTokens = 2700
)
func TestSystemPromptTokenBudget(t *testing.T) {
// Minimal render: a model is set (so the session block renders) but no Cwd, so
// the workspace map, project guidelines, and repo map are excluded — this is the
// fixed base every session pays regardless of the workspace it runs in.
prompt := buildSystemPrompt(Options{Model: "claude-opus-4-8"})
got := ApproxTextTokens(prompt)
t.Logf("base system prompt: %d tokens (%d bytes)", got, len(prompt))
if got > maxBaseSystemPromptTokens {
t.Fatalf("base system prompt is %d tokens, over the %d ceiling — trim it or raise the ceiling deliberately", got, maxBaseSystemPromptTokens)
}
}
func TestEagerToolSchemaTokenBudget(t *testing.T) {
registry := tools.NewRegistry()
for _, tool := range tools.CoreToolsScoped(t.TempDir(), nil) {
registry.Register(tool)
}
// Options{} keeps DeferThreshold at 0, so deferral is inactive and every core
// tool is exposed eagerly — exactly what a plugin-free session sends each turn.
exposed, _ := partitionTools(registry, PermissionModeAuto, Options{}, map[string]bool{})
got := estimateToolDefTokens(exposed)
t.Logf("eager core tool schemas: %d tokens across %d tools", got, len(exposed))
if got > maxEagerToolSchemaTokens {
t.Fatalf("eager tool schemas are %d tokens, over the %d ceiling — defer a tool or raise the ceiling deliberately", got, maxEagerToolSchemaTokens)
}
}
+63
View File
@@ -0,0 +1,63 @@
package agent
import (
"strconv"
"strings"
"testing"
)
func TestSkillsContextCapsLongList(t *testing.T) {
skills := make([]SkillInfo, 0, 40)
for i := 0; i < 40; i++ {
n := strconv.Itoa(i)
skills = append(skills, SkillInfo{Name: "skill-" + n, Description: "does something useful, number " + n})
}
got := skillsContext(Options{Skills: skills})
if len(got) > 1200 {
t.Fatalf("skills block should stay bounded, got %d bytes:\n%s", len(got), got)
}
if !strings.Contains(got, "more (call skill") {
t.Fatalf("expected an overflow summary line, got:\n%s", got)
}
// The first skill is always listed regardless of budget.
if !strings.Contains(got, "- skill-0:") {
t.Fatalf("expected the first skill to always be listed, got:\n%s", got)
}
}
func TestSkillsContext(t *testing.T) {
if got := skillsContext(Options{}); got != "" {
t.Fatalf("no skills should yield an empty section, got %q", got)
}
got := skillsContext(Options{Skills: []SkillInfo{
{Name: "commit-writer", Description: "Write a conventional-commit message."},
{Name: " ", Description: "nameless, should be skipped"},
{Name: "reviewer"},
}})
if !strings.Contains(got, "<available_skills>") || !strings.Contains(got, "</available_skills>") {
t.Fatalf("missing available_skills block: %q", got)
}
if !strings.Contains(got, "- commit-writer: Write a conventional-commit message.") {
t.Fatalf("missing commit-writer line: %q", got)
}
if !strings.Contains(got, "- reviewer\n") {
t.Fatalf("reviewer (no description) line missing: %q", got)
}
if strings.Contains(got, "nameless") {
t.Fatalf("nameless entry should be skipped: %q", got)
}
}
func TestSystemPromptIncludesSkillsOnlyWhenInstalled(t *testing.T) {
with := buildSystemPrompt(Options{Skills: []SkillInfo{
{Name: "commit-writer", Description: "Write a commit message."},
}})
if !strings.Contains(with, "<available_skills>") || !strings.Contains(with, "skill tool") {
t.Fatalf("expected available_skills guidance in system prompt: %q", with)
}
// Default (no skills) must reproduce the prior prompt: no skills block.
without := buildSystemPrompt(Options{})
if strings.Contains(without, "<available_skills>") {
t.Fatalf("available_skills block must not appear without skills")
}
}
+65 -3
View File
@@ -5,6 +5,7 @@ import (
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"unicode/utf8"
@@ -86,6 +87,9 @@ func buildSystemPrompt(options Options) string {
if delegation := specialistDelegationContext(options); delegation != "" {
sections = append(sections, delegation)
}
if skillsBlock := skillsContext(options); skillsBlock != "" {
sections = append(sections, skillsBlock)
}
if style := responseStyleContext(options); style != "" {
sections = append(sections, style)
}
@@ -164,6 +168,64 @@ func specialistDelegationContext(options Options) string {
return b.String()
}
// skillsContext lists the reusable skills the model can pull in on demand via the
// skill tool, so it invokes the right one on the first try instead of guessing a
// name, failing, and reading the error. It mirrors specialistDelegationContext:
// names + one-line descriptions only (never skill bodies), and it renders nothing
// when no skills are installed, so a skill-less run reproduces the previous prompt
// byte-for-byte.
// skillsContextListBudget bounds the bytes spent listing individual skills so a
// workspace with dozens of them can't bloat every turn's prompt. Skills past the
// budget are summarized as a count rather than dropped — the model can still load
// any of them by name (an unknown name returns the full list from the skill tool).
const skillsContextListBudget = 640
func skillsContext(options Options) string {
if len(options.Skills) == 0 {
return ""
}
var b strings.Builder
b.WriteString("<available_skills>\n")
b.WriteString("Reusable, on-demand instruction sets you can load with the skill tool. When a request matches one, call skill with its exact name to pull in its guidance before acting — do not guess names or skip a relevant skill.\n")
listed, spent, omitted := 0, 0, 0
for _, info := range options.Skills {
name := strings.TrimSpace(info.Name)
if name == "" {
continue
}
line := "- " + name
if desc := strings.TrimSpace(info.Description); desc != "" {
line += ": " + truncateForSkillLine(desc)
}
line += "\n"
// Always list at least one skill; past the budget, summarize the remainder
// as a count instead of silently dropping it.
if listed > 0 && spent+len(line) > skillsContextListBudget {
omitted++
continue
}
b.WriteString(line)
spent += len(line)
listed++
}
if omitted > 0 {
b.WriteString("- …and " + strconv.Itoa(omitted) + " more (call skill with a name; an unknown name lists them all)\n")
}
b.WriteString("</available_skills>")
return b.String()
}
// truncateForSkillLine keeps a skill's one-line description short so a single
// verbose description can't dominate the skills-list budget.
func truncateForSkillLine(desc string) string {
const maxDescRunes = 100
runes := []rune(desc)
if len(runes) <= maxDescRunes {
return desc
}
return strings.TrimSpace(string(runes[:maxDescRunes])) + "…"
}
func sessionRuntimeContext(options Options) string {
provider := strings.TrimSpace(options.ProviderName)
model := strings.TrimSpace(options.Model)
@@ -310,9 +372,9 @@ func projectGuidelineLabel(match, gitRoot string) string {
// findProjectContextFile returns the first matching project guideline file
// in dir. Lookup is case-insensitive on the basename and on the actual
// filename returned, so a git-tracked file like AGENTS.MD (uppercase MD)
// still resolves to its true filename on case-sensitive filesystems — the
// returned path is what should appear in the project guidelines label.
// filename returned, so a git-tracked file like AGENTS.md still resolves to
// its true filename on case-sensitive filesystems — the returned path is what
// should appear in the project guidelines label.
// Returns "" when nothing matches.
func findProjectContextFile(dir string) string {
for _, name := range projectContextFiles {
+7 -9
View File
@@ -46,14 +46,10 @@ work.
formatting-only edits unless the user asked for them.
4. **Verify.** Verify after edits; see the testing gate below. This is
mandatory.
5. **Summarize.** For a substantial task, close with an ELABORATE, well-structured
summary — never a single throwaway line. Use short headings and bullets: a
one-line "what I built/changed"; **Where it lives** — the key files and how they
fit together; **How each requirement is met** — walk through each requirement
from the request and name the specifics that satisfy it; what validation ran and
what was NOT verified; and a brief "Next steps" or "Want me to…?" offer. Scale
the depth to the work — a big build earns several sections, a trivial fix earns
a line or two.
5. **Summarize.** Close with a summary scaled to the work: a trivial fix earns one
line; a substantial change earns a few short bullets covering what changed, where
it lives, and what you did and did not verify. Lead with the outcome, and never
pad — do not restate the task or narrate steps the user already watched you take.
## Editing discipline
@@ -64,7 +60,9 @@ work.
- Make one tool call per file. Do not batch multi-file writes into a single
shell or script invocation.
- For edits to existing files, prefer edit_file or apply_patch with minimal,
targeted diffs. Match the existing indentation, imports, and idioms.
targeted diffs. Match the existing indentation, imports, and idioms. Match the
file's comment density: do not add explanatory comments unless the user asks or
the code is already comment-dense.
- Preserve behavior you were not asked to change. Do not delete or rewrite code
you did not author unless the task requires it; if you must, say so.
+10 -12
View File
@@ -6,11 +6,13 @@ import "strings"
//
// Different model families respond to different framing: the GPT family benefits
// from an explicit markdown/output spec and a strong "prefer native tools" nudge;
// Gemini benefits from explicit tool-preference and conciseness guidance; the
// Claude family is already well-aligned with the core prompt and only needs a
// light comment-discipline nudge. modelPromptAddendum returns the family-specific
// block appended after the core prompt. Classification is by model id (the agent
// only has the model string — no registry dependency).
// Gemini benefits from explicit tool-preference and conciseness guidance. The
// Claude family is already aligned with the core prompt and needs no addendum —
// comment discipline is universal in the core prompt now, so every family gets it.
// modelPromptAddendum returns the family-specific block appended after the core
// prompt, or "" when the family is unknown or needs no specialization.
// Classification is by model id (the agent only has the model string — no registry
// dependency).
const (
familyOpenAI = "openai"
@@ -45,9 +47,10 @@ func modelPromptAddendum(model string) string {
return openAIPromptAddendum
case familyGemini:
return geminiPromptAddendum
case familyAnthropic:
return anthropicPromptAddendum
default:
// Anthropic (aligned with the core prompt) and unknown families get no
// family-specific block; comment discipline now lives in the core prompt
// for every model.
return ""
}
}
@@ -70,8 +73,3 @@ const geminiPromptAddendum = `<model_guidance>
in one short clause why it is needed.
- Use update_plan for any multi-step task and keep it current.
</model_guidance>`
const anthropicPromptAddendum = `<model_guidance>
- Do not add code comments unless the user asks or the surrounding code is
comment-dense; match the file's existing comment density.
</model_guidance>`
+4 -2
View File
@@ -29,9 +29,11 @@ func TestBuildSystemPromptAppendsModelAddendum(t *testing.T) {
if got := buildSystemPrompt(Options{Model: "gpt-5"}); !strings.Contains(got, openAIPromptAddendum) {
t.Fatalf("expected the OpenAI addendum in the gpt-5 prompt")
}
// Claude is aligned with the core prompt and gets no family addendum now that
// comment discipline is universal; it must not pick up another family's block.
claude := buildSystemPrompt(Options{Model: "claude-opus-4-6"})
if !strings.Contains(claude, anthropicPromptAddendum) {
t.Fatalf("expected the Anthropic addendum in the claude prompt")
if strings.Contains(claude, "<model_guidance>") {
t.Fatalf("expected no model_guidance block for Claude (aligned with core prompt)")
}
if strings.Contains(claude, openAIPromptAddendum) {
t.Fatalf("the claude prompt must not contain the OpenAI addendum")
+3 -2
View File
@@ -23,7 +23,8 @@ func TestCoreSystemPromptIncludesCodingQualityRules(t *testing.T) {
"avoid broad refactors",
"search the web before answering",
"do not recognize",
"how each requirement is met",
"scaled to the work",
"comment density",
} {
if !strings.Contains(prompt, want) {
t.Fatalf("expected core system prompt to include %q, got:\n%s", want, buildSystemPrompt(Options{}))
@@ -128,7 +129,7 @@ func systemPromptTestBlock(t *testing.T, prompt, start, end string) string {
}
func TestBuildSystemPromptInjectsProjectGuidelinesCaseInsensitive(t *testing.T) {
// Git tracks AGENTS.MD (uppercase MD) on a case-sensitive filesystem; the
// Git tracks AGENTS.md on a case-sensitive filesystem; the
// loader must still resolve it when the cwd lookup uses lowercase.
cwd := t.TempDir()
if err := os.WriteFile(filepath.Join(cwd, "AGENTS.MD"), []byte("Always run `make lint`."), 0o644); err != nil {
+16
View File
@@ -174,6 +174,16 @@ type SpecialistInfo struct {
WhenToUse string
}
// SkillInfo is a one-line summary of a reusable, on-demand skill (its name and
// frontmatter description) surfaced to the system prompt so the model can invoke
// the right skill with the skill tool on the first try instead of guessing a name
// and reading the failure. Like SpecialistInfo it is plain data, so the agent
// package needs no dependency on internal/skills.
type SkillInfo struct {
Name string
Description string
}
type Options struct {
MaxTurns int
// DeferThreshold activates deferred MCP-tool loading: when the number of
@@ -189,6 +199,12 @@ type Options struct {
// populated only where the Task tool is actually registered, so an empty slice
// (the default) reproduces the previous prompt byte-for-byte.
Specialists []SpecialistInfo
// Skills lists the reusable skills installed for this run (the default skills
// dir merged with any plugin skill roots). When non-empty the system prompt
// gains an <available_skills> block naming them so the model loads the right one
// via the skill tool on the first try. Empty (the default) reproduces the
// previous prompt byte-for-byte.
Skills []SkillInfo
// Specialist/sub-agent metadata is carried through exec now and consumed by
// the specialist runtime in later slices.
SessionID string
+25 -4
View File
@@ -80,6 +80,7 @@ type appDeps struct {
runTUI func(context.Context, tui.Options) int
runEditor func(string) error
checkUpdate func(context.Context, update.Options) (update.Result, error)
applyUpdate func(context.Context, update.Options) (update.ApplyResult, error)
now func() time.Time
}
@@ -180,6 +181,7 @@ func defaultAppDeps() appDeps {
runTUI: tui.Run,
runEditor: openEditor,
checkUpdate: update.Check,
applyUpdate: update.Apply,
now: time.Now,
}
}
@@ -383,6 +385,8 @@ func runWithDeps(args []string, stdout io.Writer, stderr io.Writer, deps appDeps
return runSandbox(args[1:], stdout, stderr, deps)
case "update":
return runUpdate(args[1:], stdout, stderr, deps)
case "upgrade":
return runUpgrade(args[1:], stdout, stderr, deps)
case "worktrees", "worktree":
return runWorktrees(args[1:], stdout, stderr, deps)
case "verify":
@@ -515,6 +519,9 @@ func fillAppDeps(deps appDeps) appDeps {
if deps.checkUpdate == nil {
deps.checkUpdate = defaults.checkUpdate
}
if deps.applyUpdate == nil {
deps.applyUpdate = defaults.applyUpdate
}
if deps.now == nil {
deps.now = defaults.now
}
@@ -557,8 +564,19 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
if !errors.Is(err, config.ErrNoActiveProvider) && !errors.Is(err, config.ErrProviderRequiresModel) {
return writeAppError(stderr, err.Error(), 1)
}
resolved = config.ResolvedConfig{}
forceSetup = true
// ErrNoActiveProvider can mean "nothing configured yet" (needs onboarding)
// or "providers ARE configured, just none marked active" (e.g. config.json's
// activeProvider is blank/stale). In the second case resolved.Providers still
// carries the already-normalized list — prefer falling back to one of those
// over wiping everything and forcing the user to re-enter credentials they
// already saved.
if usable, ok := firstUsableProvider(resolved.Providers); errors.Is(err, config.ErrNoActiveProvider) && ok {
resolved.Provider = usable
resolved.ActiveProvider = usable.Name
} else {
resolved = config.ResolvedConfig{}
forceSetup = true
}
}
userConfigPath, err := deps.userConfigPath()
if err != nil {
@@ -740,9 +758,11 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
Hooks: newHookDispatcherWithExtra(workspaceRoot, pluginActivation.hooks),
DeferThreshold: resolved.Tools.DeferThreshold,
Specialists: specialistRuntime.specialists,
Skills: pluginActivation.skillInfos(deps.skillsDir()),
},
PermissionMode: permissionMode,
Notify: resolved.Notify,
KeyBindings: resolved.KeyBindings,
Setup: tui.SetupOptions{
Visible: setupVisible,
Required: needsSetup,
@@ -1158,7 +1178,8 @@ func profileHasCredential(profile config.ProviderProfile) bool {
}
// baseURLIsLoopback reports whether a provider base_url points at a loopback host
// (a keyless local provider like Ollama/LM Studio), which needs no API key.
// or a private-network host (192.168.x.y / 10.x.y.z / 172.16.x.y) — a local
// provider like Ollama/LM Studio that needs no API key.
func baseURLIsLoopback(baseURL string) bool {
u := strings.TrimSpace(baseURL)
if u == "" {
@@ -1173,7 +1194,7 @@ func baseURLIsLoopback(baseURL string) bool {
return true
}
if addr, err := netip.ParseAddr(host); err == nil {
return addr.IsLoopback()
return addr.IsLoopback() || addr.IsPrivate()
}
return false
}
+208
View File
@@ -205,6 +205,67 @@ func TestRunNoArgsEntersSetupWhenResolveReportsNoActiveProvider(t *testing.T) {
}
}
func TestRunNoArgsFallsBackToUsableProviderWhenNoneMarkedActive(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
cwd := t.TempDir()
setCLIUserConfigRoot(t)
userConfigPath := filepath.Join(t.TempDir(), "zero", "config.json")
var launchedOptions tui.Options
launched := false
var providerProfile config.ProviderProfile
fake := &cliFakeProvider{}
usable := config.ProviderProfile{
Name: "work",
ProviderKind: config.ProviderKindOpenAI,
BaseURL: config.OpenAIBaseURL,
APIKey: "sk-test",
Model: "gpt-test",
}
exitCode := runWithDeps([]string{}, &stdout, &stderr, appDeps{
getwd: func() (string, error) {
return cwd, nil
},
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
// config.json has providers configured but none marked active (e.g. a
// blank/stale activeProvider field) — Resolve returns the successfully
// normalized providers list alongside ErrNoActiveProvider.
return config.ResolvedConfig{Providers: []config.ProviderProfile{usable}},
fmt.Errorf("%w: active provider %q not found", config.ErrNoActiveProvider, "")
},
newProvider: func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
providerProfile = profile
return fake, nil
},
userConfigPath: func() (string, error) {
return userConfigPath, nil
},
registerMCPTools: func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) {
return noopMCPRuntime{}, nil
},
runTUI: func(ctx context.Context, options tui.Options) int {
launched = true
launchedOptions = options
return 0
},
})
if exitCode != 0 {
t.Fatalf("exit code = %d, want 0, stderr=%q", exitCode, stderr.String())
}
if !launched {
t.Fatal("TUI was not launched")
}
if launchedOptions.Setup.Visible || launchedOptions.Setup.Required {
t.Fatalf("Setup = %#v, want no setup wizard — a usable saved provider exists", launchedOptions.Setup)
}
if providerProfile.Name != "work" {
t.Fatalf("provider used = %q, want fallback to the usable saved provider %q", providerProfile.Name, "work")
}
}
func TestRunNoArgsFailsWhenResolveErrorIsNotProviderRelated(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
@@ -1267,6 +1328,153 @@ func TestRunUpdateReportsUpToDate(t *testing.T) {
}
}
func TestRunUpdateApplyTextAndJSON(t *testing.T) {
applyResult := update.ApplyResult{
Result: update.Result{
CurrentVersion: "dev",
LatestVersion: "0.2.0",
UpdateAvailable: true,
},
Applied: true,
InstallMethod: update.InstallMethodStandalone,
BinaryPath: "/usr/local/bin/zero",
Message: "updated to 0.2.0",
}
var gotOptions update.Options
deps := appDeps{
applyUpdate: func(_ context.Context, options update.Options) (update.ApplyResult, error) {
gotOptions = options
return applyResult, nil
},
checkUpdate: func(context.Context, update.Options) (update.Result, error) {
t.Fatal("checkUpdate should not run for --apply")
return update.Result{}, nil
},
}
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"update", "--apply"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
}
if !strings.Contains(stdout.String(), "updated to 0.2.0") {
t.Fatalf("unexpected apply text: %q", stdout.String())
}
if gotOptions.CurrentVersion != "dev" {
t.Fatalf("unexpected options passed to applyUpdate: %#v", gotOptions)
}
stdout.Reset()
stderr.Reset()
exitCode = runWithDeps([]string{"update", "--apply", "--json"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
}
var payload struct {
Applied bool `json:"applied"`
InstallMethod string `json:"installMethod"`
BinaryPath string `json:"binaryPath"`
}
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("apply JSON did not decode: %v\n%s", err, stdout.String())
}
if !payload.Applied || payload.InstallMethod != string(update.InstallMethodStandalone) || payload.BinaryPath != applyResult.BinaryPath {
t.Fatalf("unexpected apply JSON: %#v", payload)
}
}
func TestRunUpgradeDefaultsToApply(t *testing.T) {
var applyCalled bool
deps := appDeps{
applyUpdate: func(context.Context, update.Options) (update.ApplyResult, error) {
applyCalled = true
return update.ApplyResult{Message: "already up to date"}, nil
},
checkUpdate: func(context.Context, update.Options) (update.Result, error) {
t.Fatal("checkUpdate should not run for `zero upgrade`")
return update.Result{}, nil
},
}
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"upgrade"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
}
if !applyCalled {
t.Fatal("expected `zero upgrade` to call applyUpdate")
}
}
func TestRunUpdateRejectsCheckAndApplyTogether(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"update", "--check", "--apply"}, &stdout, &stderr, appDeps{
checkUpdate: func(context.Context, update.Options) (update.Result, error) {
t.Fatal("checkUpdate should not run")
return update.Result{}, nil
},
applyUpdate: func(context.Context, update.Options) (update.ApplyResult, error) {
t.Fatal("applyUpdate should not run")
return update.ApplyResult{}, nil
},
})
if exitCode != exitUsage {
t.Fatalf("expected usage exit code, got %d", exitCode)
}
if got := stderr.String(); !strings.Contains(got, "only one of --check or --apply") {
t.Fatalf("expected --check/--apply usage error, got %q", got)
}
}
func TestRunUpdateRejectsTargetWithApply(t *testing.T) {
for _, args := range [][]string{
{"update", "--apply", "--target", "linux-arm64"},
{"upgrade", "--target", "linux-arm64"},
} {
t.Run(strings.Join(args, " "), func(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps(args, &stdout, &stderr, appDeps{
applyUpdate: func(context.Context, update.Options) (update.ApplyResult, error) {
t.Fatal("applyUpdate should not run when --target is combined with --apply")
return update.ApplyResult{}, nil
},
})
if exitCode != exitUsage {
t.Fatalf("expected usage exit code, got %d", exitCode)
}
if got := stderr.String(); !strings.Contains(got, "--target cannot be combined with --apply") {
t.Fatalf("expected --target/--apply usage error, got %q", got)
}
})
}
}
func TestRunUpdateReportsApplyError(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"update", "--apply"}, &stdout, &stderr, appDeps{
applyUpdate: func(context.Context, update.Options) (update.ApplyResult, error) {
return update.ApplyResult{}, errors.New("download failed")
},
})
if exitCode == exitSuccess {
t.Fatalf("expected non-success exit code, got %d", exitCode)
}
if got := stderr.String(); !strings.Contains(got, "download failed") {
t.Fatalf("expected apply error, got %q", got)
}
}
func assertHelpOutput(t *testing.T, args []string) {
t.Helper()
+19 -3
View File
@@ -301,12 +301,27 @@ func runAuthLogin(args []string, stdout io.Writer, stderr io.Writer, deps appDep
if len(parsed.positional) != 1 {
return writeExecUsageError(stderr, "usage: zero auth login <provider> [--device] [--scope <scope>]")
}
provider := parsed.positional[0]
// ChatGPT (Codex) requires a fixed redirect_uri (http://localhost:1455/
// auth/callback) and mandatory authorize params (id_token_add_organizations,
// codex_cli_simplified_flow, originator) that the generic loopback flow
// cannot supply. Route it to the dedicated ChatGPT login so
// `zero auth login chatgpt` behaves identically to `zero auth chatgpt`.
if strings.EqualFold(provider, "chatgpt") {
if parsed.device {
return writeExecUsageError(stderr, "ChatGPT login does not support --device (it is loopback-only)")
}
if len(parsed.scopes) > 0 {
return writeExecUsageError(stderr, "ChatGPT login does not support --scope (the required scopes are fixed by the Codex client registration)")
}
return runAuthChatGPT(nil, stdout, stderr, deps)
}
manager, err := newAuthManager(deps, stdout)
if err != nil {
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
}
status, err := manager.Login(context.Background(), oauth.LoginOptions{
Provider: parsed.positional[0],
Provider: provider,
Device: parsed.device,
ExtraScopes: parsed.scopes,
})
@@ -489,8 +504,9 @@ Commands:
A provider is any OAuth 2.0 / OIDC server. "openrouter" ('zero auth openrouter')
works out of the box. "xai" ('zero auth login xai') uses a built-in preset that is
off by default — enable it with ZERO_OAUTH_ALLOW_PRESETS=1, or set the
ZERO_OAUTH_XAI_* vars yourself. Any preset field is overridable via the env vars
below. For a custom provider named <name>, set:
ZERO_OAUTH_XAI_* vars yourself. "chatgpt" ('zero auth login chatgpt' or
'zero auth chatgpt') uses a fixed-port loopback flow against the Codex backend.
Any preset field is overridable via the env vars below. For a custom provider named <name>, set:
ZERO_OAUTH_<NAME>_CLIENT_ID (required)
ZERO_OAUTH_<NAME>_CLIENT_SECRET (optional)
ZERO_OAUTH_<NAME>_AUTHORIZE_URL ZERO_OAUTH_<NAME>_TOKEN_URL
+41
View File
@@ -156,3 +156,44 @@ func TestRunAuthHelp(t *testing.T) {
}
}
}
// TestRunAuthLoginChatGPTRoutesToDedicatedFlow verifies `zero auth login
// chatgpt` reaches the dedicated ChatGPT login (fixed-port loopback + mandatory
// authorize params), not the generic manager path. The generic login accepts
// --device, so a ChatGPT-specific rejection proves the routing took effect.
// See issue #430: the generic path built a random-port 127.0.0.1 redirect_uri
// without the required extra params, so OpenAI's authorize endpoint rejected it.
func TestRunAuthLoginChatGPTRoutesToDedicatedFlow(t *testing.T) {
withAuthStore(t)
var stdout, stderr bytes.Buffer
if code := runWithDeps([]string{"auth", "login", "chatgpt", "--device"}, &stdout, &stderr, appDeps{}); code == exitSuccess {
t.Fatal("auth login chatgpt --device should be rejected (ChatGPT is loopback-only)")
}
if !strings.Contains(stderr.String(), "ChatGPT login does not support --device") {
t.Fatalf("stderr = %q, want the ChatGPT-specific --device rejection", stderr.String())
}
// Case-insensitive provider name should still route.
stdout.Reset()
stderr.Reset()
if code := runWithDeps([]string{"auth", "login", "ChatGPT", "--device"}, &stdout, &stderr, appDeps{}); code == exitSuccess {
t.Fatal("auth login ChatGPT --device should be rejected")
}
if !strings.Contains(stderr.String(), "ChatGPT login does not support --device") {
t.Fatalf("stderr = %q, want the ChatGPT-specific rejection (case-insensitive)", stderr.String())
}
}
// TestRunAuthLoginChatGPTRejectsScope mirrors the --device rejection: --scope
// must not be silently dropped on the ChatGPT path. The Codex client
// registration pins a fixed scope set (incl. api.connectors.*), so custom
// scopes are rejected up front rather than plumbed through.
func TestRunAuthLoginChatGPTRejectsScope(t *testing.T) {
withAuthStore(t)
var stdout, stderr bytes.Buffer
if code := runWithDeps([]string{"auth", "login", "chatgpt", "--scope", "custom-scope"}, &stdout, &stderr, appDeps{}); code == exitSuccess {
t.Fatal("auth login chatgpt --scope should be rejected")
}
if !strings.Contains(stderr.String(), "ChatGPT login does not support --scope") {
t.Fatalf("stderr = %q, want the ChatGPT-specific --scope rejection", stderr.String())
}
}
+1
View File
@@ -508,6 +508,7 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
ContextWindow: resolveAgentContextWindow(runCtx, modelRegistry, resolved.Provider),
DeferThreshold: effectiveDeferThreshold,
Specialists: specialistRuntime.specialistInfos(),
Skills: pluginActivation.skillInfos(deps.skillsDir()),
SessionID: preparedSession.Session.SessionID,
CallingSessionID: options.callingSessionID,
CallingToolUseID: options.callingToolUseID,
+23
View File
@@ -3,7 +3,9 @@ package cli
import (
"fmt"
"io"
"strings"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/hooks"
"github.com/Gitlawb/zero/internal/plugins"
"github.com/Gitlawb/zero/internal/tools"
@@ -57,6 +59,27 @@ func activatePlugins(workspaceRoot string, registry *tools.Registry, deps appDep
return pluginActivation{hooks: result.Hooks, skillRoots: result.SkillRoots}
}
// skillInfos resolves the reusable skills the model can load via the skill tool —
// the default skills dir merged with any plugin skill roots, the same set the
// plugin-aware skill tool resolves against — as plain data for the agent's system
// prompt. It returns nil when no skills are installed, so a skill-less run leaves
// the prompt byte-identical.
func (a pluginActivation) skillInfos(defaultDir string) []agent.SkillInfo {
merged, _ := plugins.MergedSkills(defaultDir, a.skillRoots)
if len(merged) == 0 {
return nil
}
infos := make([]agent.SkillInfo, 0, len(merged))
for _, skill := range merged {
name := strings.TrimSpace(skill.Name)
if name == "" {
continue
}
infos = append(infos, agent.SkillInfo{Name: name, Description: strings.TrimSpace(skill.Description)})
}
return infos
}
// formatLoadDiagnostic renders a plugin load diagnostic into a single warning
// line, prefixing the diagnostic kind and appending the most specific locator
// available (manifest path, then plugin path, then plugin ID) so an operator can
+6
View File
@@ -3,6 +3,7 @@ package cli
import (
"fmt"
"io"
"net"
"net/url"
"strconv"
"strings"
@@ -453,6 +454,11 @@ func providerProfileIsLocal(profile config.ProviderProfile) bool {
case "localhost", "127.0.0.1", "::1":
return true
}
// Also treat explicit private-network IPs (192.168.x.y, 10.x.y.z,
// 172.16-31.x.y) as local — the user is pointing at their own LAN box.
if ip := net.ParseIP(parsed.Hostname()); ip != nil && ip.IsPrivate() {
return true
}
return false
}
+51 -8
View File
@@ -12,6 +12,7 @@ import (
type updateOptions struct {
check bool
apply bool
json bool
repository string
endpoint string
@@ -20,6 +21,18 @@ type updateOptions struct {
}
func runUpdate(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
return runUpdateCommand(args, stdout, stderr, deps, false)
}
func runUpgrade(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
return runUpdateCommand(args, stdout, stderr, deps, true)
}
// runUpdateCommand backs both `zero update` and `zero upgrade`. defaultApply
// makes `--apply` the implicit behavior for `zero upgrade` when neither
// `--check` nor `--apply` is passed explicitly; `zero update` keeps requiring
// one of the two so existing scripts around `zero update --check` don't change.
func runUpdateCommand(args []string, stdout io.Writer, stderr io.Writer, deps appDeps, defaultApply bool) int {
options, help, err := parseUpdateArgs(args)
if err != nil {
return writeUsageError(stderr, err.Error())
@@ -30,10 +43,19 @@ func runUpdate(args []string, stdout io.Writer, stderr io.Writer, deps appDeps)
}
return exitSuccess
}
if !options.check {
return writeUsageError(stderr, "Only `zero update --check` is available right now.")
if options.check && options.apply {
return writeUsageError(stderr, "Pass only one of --check or --apply.")
}
checkOptions := update.Options{
if !options.check && !options.apply {
if !defaultApply {
return writeUsageError(stderr, "Pass --check to check for updates or --apply to install one (or use `zero upgrade`).")
}
options.apply = true
}
if options.apply && options.target != "" {
return writeUsageError(stderr, "--target cannot be combined with --apply; it only verifies release assets for --check.")
}
updateOptions := update.Options{
CurrentVersion: version,
Repository: options.repository,
Endpoint: options.endpoint,
@@ -44,10 +66,26 @@ func runUpdate(args []string, stdout io.Writer, stderr io.Writer, deps appDeps)
if err != nil {
return writeUsageError(stderr, err.Error())
}
checkOptions.GOOS = target.GOOS
checkOptions.GOARCH = target.GOARCH
updateOptions.GOOS = target.GOOS
updateOptions.GOARCH = target.GOARCH
}
result, err := deps.checkUpdate(context.Background(), checkOptions)
if options.apply {
result, err := deps.applyUpdate(context.Background(), updateOptions)
if err != nil {
return writeAppError(stderr, "Could not install update: "+err.Error(), exitCrash)
}
if options.json {
if err := writePrettyJSON(stdout, result); err != nil {
return exitCrash
}
return exitSuccess
}
if _, err := fmt.Fprintln(stdout, update.FormatApply(result)); err != nil {
return exitCrash
}
return exitSuccess
}
result, err := deps.checkUpdate(context.Background(), updateOptions)
if err != nil {
return writeAppError(stderr, "Could not check for updates: "+err.Error(), exitCrash)
}
@@ -72,6 +110,8 @@ func parseUpdateArgs(args []string) (updateOptions, bool, error) {
return options, true, nil
case arg == "--check":
options.check = true
case arg == "--apply":
options.apply = true
case arg == "--json":
options.json = true
case arg == "--repo":
@@ -155,14 +195,17 @@ func parseUpdateTarget(value string) (string, error) {
func writeUpdateHelp(w io.Writer) error {
_, err := fmt.Fprint(w, `Usage:
zero update --check [flags]
zero update --apply [flags]
zero upgrade [flags]
Flags:
--check Check the latest GitHub release without installing
--json Print the update check result as JSON
--apply Download, verify, and install the latest release
--json Print the update result as JSON
--repo <owner/repo> Repository to check when no endpoint is provided
--endpoint <url|repo> Release API URL or owner/repo slug to check
--timeout <duration> Release check timeout (default 5s)
--target <platform> Release target to verify (for example windows-x64)
--target <platform> Release target to verify with --check (for example windows-x64); not valid with --apply
-h, --help Show this help
`)
return err
+141
View File
@@ -662,3 +662,144 @@ func TestRunExecRejectsWorktreeDirWithoutWorktree(t *testing.T) {
t.Fatalf("expected empty stdout, got %q", stdout.String())
}
}
func TestParseChangesArgsAuto(t *testing.T) {
// 1. Correct parsing of auto
for _, args := range [][]string{
{"--auto"},
{"-a"},
} {
options, help, err := parseChangesArgs(args, "commit")
if err != nil {
t.Fatalf("parseChangesArgs(%v) error: %v", args, err)
}
if help {
t.Fatalf("parseChangesArgs(%v) returned help", args)
}
if !options.auto {
t.Fatalf("auto = false, want true (args %v)", args)
}
}
// 2. Reject --auto on inspect
_, _, err := parseChangesArgs([]string{"--auto"}, "inspect")
if err == nil || !strings.Contains(err.Error(), "--auto") {
t.Fatalf("expected --auto rejection on inspect, got %v", err)
}
// 3. Reject both --message and --auto on commit
_, _, err = parseChangesArgs([]string{"--message", "foo", "--auto"}, "commit")
if err == nil || !strings.Contains(err.Error(), "cannot specify both --message and --auto") {
t.Fatalf("expected --message and --auto conflict error, got %v", err)
}
// 4. Reject both empty message and --auto on commit
_, _, err = parseChangesArgs([]string{"--message=", "--auto"}, "commit")
if err == nil || !strings.Contains(err.Error(), "cannot specify both --message and --auto") {
t.Fatalf("expected empty --message and --auto conflict error, got %v", err)
}
}
type mockCommitMsgProvider struct {
response string
req zeroruntime.CompletionRequest
}
func (p *mockCommitMsgProvider) StreamCompletion(ctx context.Context, request zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
p.req = request
events := make(chan zeroruntime.StreamEvent, 2)
events <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventText, Content: p.response}
events <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone}
close(events)
return events, nil
}
func TestRunChangesCommitAuto(t *testing.T) {
cwd := t.TempDir()
summary := zerogit.ChangeSummary{
Root: cwd,
Branch: "main",
Files: []zerogit.FileChange{{Path: "README.md", Status: "modified"}},
Diff: "some diff content with ghp_SECRETKEYHERE",
}
mockProv := &mockCommitMsgProvider{
response: "```\nfeat: auto commit message\n```",
}
var stdout bytes.Buffer
var stderr bytes.Buffer
commitCalled := false
exitCode := runWithDeps([]string{"changes", "commit", "--auto"}, &stdout, &stderr, appDeps{
getwd: func() (string, error) { return cwd, nil },
inspectChanges: func(ctx context.Context, options zerogit.InspectOptions) (zerogit.ChangeSummary, error) {
return summary, nil
},
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
cfg := execResolvedConfig()
cfg.Provider.Name = "openai"
cfg.Provider.Model = "gpt-4o"
return cfg, nil
},
newProvider: func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
return mockProv, nil
},
commitChanges: func(ctx context.Context, options zerogit.CommitOptions) (zerogit.CommitResult, error) {
commitCalled = true
if options.Message != "feat: auto commit message" {
t.Fatalf("expected commit message 'feat: auto commit message', got %q", options.Message)
}
return zerogit.CommitResult{
Root: cwd,
Message: options.Message,
Committed: true,
CommitHash: "abc1234",
Before: summary,
}, nil
},
})
if exitCode != exitSuccess {
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
}
if !commitCalled {
t.Fatal("expected commitChanges to be called")
}
// Verify that secret in the diff was redacted
promptContent := mockProv.req.Messages[0].Content
if strings.Contains(promptContent, "ghp_SECRETKEYHERE") {
t.Fatal("expected secret in diff to be redacted, but it was found in the prompt")
}
if !strings.Contains(promptContent, "[REDACTED]") && !strings.Contains(promptContent, "REDACTED") {
t.Fatalf("expected redacted diff content in prompt, got: %q", promptContent)
}
if !strings.Contains(stdout.String(), "Generating commit message using LLM...") {
t.Fatalf("expected status message in stdout, got %q", stdout.String())
}
t.Run("EmptyLLMResponse", func(t *testing.T) {
mockProvEmpty := &mockCommitMsgProvider{
response: " \n\n ",
}
var stdout, stderr bytes.Buffer
code := runWithDeps([]string{"changes", "commit", "--auto"}, &stdout, &stderr, appDeps{
getwd: func() (string, error) { return cwd, nil },
inspectChanges: func(ctx context.Context, options zerogit.InspectOptions) (zerogit.ChangeSummary, error) {
return summary, nil
},
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
return execResolvedConfig(), nil
},
newProvider: func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
return mockProvEmpty, nil
},
})
if code == exitSuccess {
t.Fatal("expected command to fail when LLM returns empty response, got success")
}
if !strings.Contains(stderr.String(), "empty commit message") {
t.Fatalf("expected empty commit message error in stderr, got %q", stderr.String())
}
})
}
+96 -3
View File
@@ -6,13 +6,16 @@ import (
"io"
"strconv"
"strings"
"time"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/redaction"
"github.com/Gitlawb/zero/internal/selfverify"
"github.com/Gitlawb/zero/internal/testrunner"
"github.com/Gitlawb/zero/internal/verify"
"github.com/Gitlawb/zero/internal/worktrees"
"github.com/Gitlawb/zero/internal/zerogit"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
type worktreeCommandOptions struct {
@@ -35,8 +38,10 @@ type changesCommandOptions struct {
cwd string
baseRef string
message string
hasMessage bool
dryRun bool
maxDiffBytes int
auto bool
}
func runWorktrees(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
@@ -198,9 +203,52 @@ func runChanges(args []string, stdout io.Writer, stderr io.Writer, deps appDeps)
}
return exitSuccess
case "commit":
var message string
if options.auto {
summary, err := deps.inspectChanges(context.Background(), zerogit.InspectOptions{
Cwd: workspaceRoot,
MaxDiffBytes: options.maxDiffBytes,
})
if err != nil {
return writeExecUsageError(stderr, fmt.Sprintf("failed to inspect changes: %v", err))
}
if summary.Clean {
return writeExecUsageError(stderr, "no changes to commit")
}
resolved, err := deps.resolveConfig(workspaceRoot, config.Overrides{})
if err != nil {
return writeExecUsageError(stderr, fmt.Sprintf("failed to resolve config: %v", err))
}
if !config.HasProviderProfile(resolved.Provider) {
return writeExecUsageError(stderr, "no provider configured for auto-commit message")
}
provider, err := deps.newProvider(resolved.Provider)
if err != nil {
return writeExecUsageError(stderr, fmt.Sprintf("failed to create provider: %v", err))
}
if !options.json {
if _, err := fmt.Fprintln(stdout, "Generating commit message using LLM..."); err != nil {
return exitCrash
}
}
safeSummary := redactChangeSummary(summary)
genCtx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
msg, err := generateAutoCommitMessage(genCtx, provider, resolved.Provider.Model, safeSummary)
if err != nil {
return writeExecUsageError(stderr, fmt.Sprintf("failed to generate commit message: %v", err))
}
message = msg
} else {
message = options.message
}
result, err := deps.commitChanges(context.Background(), zerogit.CommitOptions{
Cwd: workspaceRoot,
Message: options.message,
Message: message,
DryRun: options.dryRun,
MaxDiffBytes: options.maxDiffBytes,
})
@@ -395,9 +443,11 @@ func parseChangesArgs(args []string, command string) (changesCommandOptions, boo
return options, false, err
}
options.message = value
options.hasMessage = true
index = next
case strings.HasPrefix(arg, "--message="):
options.message = strings.TrimSpace(strings.TrimPrefix(arg, "--message="))
options.hasMessage = true
case arg == "--diff-bytes":
value, next, err := nextFlagValue(args, index, arg)
if err != nil {
@@ -415,14 +465,19 @@ func parseChangesArgs(args []string, command string) (changesCommandOptions, boo
return options, false, err
}
options.maxDiffBytes = maxDiffBytes
case arg == "-a" || arg == "--auto":
options.auto = true
case strings.HasPrefix(arg, "-"):
return options, false, execUsageError{fmt.Sprintf("unknown changes flag %q", arg)}
default:
return options, false, execUsageError{fmt.Sprintf("unexpected changes argument %q", arg)}
}
}
if command != "commit" && (options.message != "" || options.dryRun) {
return options, false, execUsageError{"--message and --dry-run are only valid with `zero changes commit`"}
if command != "commit" && (options.hasMessage || options.dryRun || options.auto) {
return options, false, execUsageError{"--message, --dry-run, and --auto are only valid with `zero changes commit`"}
}
if command == "commit" && options.hasMessage && options.auto {
return options, false, execUsageError{"cannot specify both --message and --auto"}
}
if command == "commit" && options.baseRef != "" {
return options, false, execUsageError{"--base is only valid with `zero changes inspect`"}
@@ -712,9 +767,47 @@ Flags:
--base <ref> Diff against <ref>...HEAD instead of the working tree
--diff-bytes <n> Maximum diff bytes to include
-m, --message <text> Commit message for `+"`zero changes commit`"+`
-a, --auto Auto-generate commit message using LLM (use --dry-run to preview)
--dry-run Preview commit metadata without mutating git state
--json Print JSON output
-h, --help Show this help
`)
return err
}
func generateAutoCommitMessage(ctx context.Context, provider zeroruntime.Provider, model string, summary zerogit.ChangeSummary) (string, error) {
var promptBuilder strings.Builder
promptBuilder.WriteString("Analyze the following git diff and generate a concise, conventional commit message.\n")
promptBuilder.WriteString("The commit message subject line must be 72 characters or fewer, starting with a conventional commit type (e.g., feat, fix, docs, style, refactor, test, chore) followed by a colon and space, and a lowercase description.\n")
promptBuilder.WriteString("You may optionally include a blank line and a bulleted list of changes for the body if there are multiple files or complex changes.\n")
promptBuilder.WriteString("Output ONLY the raw commit message text. Do not wrap the message in markdown code block fence, do not include quotes or any introduction/explanation.\n\n")
promptBuilder.WriteString("Git Diff:\n")
promptBuilder.WriteString(summary.Diff)
request := zeroruntime.CompletionRequest{
Messages: []zeroruntime.Message{
{Role: zeroruntime.MessageRoleUser, Content: promptBuilder.String()},
},
}
stream, err := provider.StreamCompletion(ctx, request)
if err != nil {
return "", err
}
collected := zeroruntime.CollectStream(ctx, stream)
if collected.Error != "" {
return "", fmt.Errorf("%s", collected.Error)
}
msg := strings.TrimSpace(collected.Text)
if strings.HasPrefix(msg, "```") {
if idx := strings.Index(msg, "\n"); idx != -1 {
msg = msg[idx+1:]
}
}
msg = strings.TrimSuffix(msg, "```")
msg = strings.TrimSpace(msg)
if msg == "" {
return "", fmt.Errorf("provider returned empty commit message")
}
return msg, nil
}
+32 -2
View File
@@ -130,7 +130,11 @@ func Resolve(options ResolveOptions) (ResolvedConfig, error) {
providers, active, err := normalizeProviders(cfg.Providers, cfg.ActiveProvider, options.Env)
if err != nil {
return ResolvedConfig{}, err
// On ErrNoActiveProvider, providers may still hold the successfully
// normalized (but active-less) profile list — keep it so a caller can fall
// back to an already-configured usable provider instead of treating this
// like a config with nothing set up at all.
return ResolvedConfig{Providers: providers}, err
}
return ResolvedConfig{
@@ -144,6 +148,7 @@ func Resolve(options ResolveOptions) (ResolvedConfig, error) {
Tools: cfg.Tools,
Swarm: cfg.Swarm,
Preferences: cfg.Preferences,
KeyBindings: cfg.KeyBindings,
LocalControl: cfg.LocalControl,
}, nil
}
@@ -225,6 +230,7 @@ func mergeConfig(dst *FileConfig, src FileConfig) {
dst.Preferences.Theme = strings.TrimSpace(src.Preferences.Theme)
}
mergeLocalControlConfig(&dst.LocalControl, src.LocalControl)
mergeKeyBindings(&dst.KeyBindings, src.KeyBindings)
}
func mergeProjectConfig(dst *FileConfig, src FileConfig) error {
@@ -277,6 +283,7 @@ func mergeProjectConfig(dst *FileConfig, src FileConfig) error {
if src.Swarm.MaxTeamSize != 0 {
dst.Swarm.MaxTeamSize = src.Swarm.MaxTeamSize
}
mergeKeyBindings(&dst.KeyBindings, src.KeyBindings)
// Local control is intentionally user-config/override only. A cloned project
// must not be able to make browser, desktop, or terminal automation tools
// appear in the model's tool surface.
@@ -665,6 +672,7 @@ func applyOverrides(cfg *FileConfig, overrides Overrides) {
cfg.Tools.deferThresholdSet = true
}
mergeLocalControlConfig(&cfg.LocalControl, overrides.LocalControl)
mergeKeyBindings(&cfg.KeyBindings, overrides.KeyBindings)
for _, provider := range overrides.Providers {
mergeProvider(cfg, provider)
}
@@ -700,6 +708,24 @@ func mergeLocalControlDriverConfig(dst *LocalControlDriverConfig, src LocalContr
}
}
func mergeKeyBindings(dst *KeyBindingsConfig, src KeyBindingsConfig) {
if src.ToggleDetailed != "" {
dst.ToggleDetailed = src.ToggleDetailed
}
if src.ToggleMouse != "" {
dst.ToggleMouse = src.ToggleMouse
}
if src.CycleReasoning != "" {
dst.CycleReasoning = src.CycleReasoning
}
if src.TogglePlan != "" {
dst.TogglePlan = src.TogglePlan
}
if src.ToggleSidebar != "" {
dst.ToggleSidebar = src.ToggleSidebar
}
}
func mergeMCPConfig(dst *MCPConfig, src MCPConfig) {
if len(src.Servers) == 0 {
return
@@ -834,7 +860,11 @@ func normalizeProvidersWithOptions(providers []ProviderProfile, activeName strin
}
if !activeFound {
return nil, ProviderProfile{}, fmt.Errorf("%w: active provider %q not found", ErrNoActiveProvider, activeName)
// Return the successfully normalized list alongside the error (rather than
// nil) so a caller like the interactive TUI can still fall back to an
// already-configured, usable provider instead of forcing a full
// re-onboarding wizard just because none was marked active.
return normalized, ProviderProfile{}, fmt.Errorf("%w: active provider %q not found", ErrNoActiveProvider, activeName)
}
if active.Model == "" {
return nil, ProviderProfile{}, &setupFixableError{
+143
View File
@@ -916,6 +916,25 @@ func TestResolveRejectsActiveProviderWithoutConfiguredProfiles(t *testing.T) {
}
}
func TestResolveKeepsNormalizedProvidersWhenNoneMarkedActive(t *testing.T) {
// Multiple providers configured (e.g. via `zero provider add`) but
// activeProvider is blank/stale — a caller like the interactive TUI still
// needs the normalized list to fall back to an already-usable provider
// instead of forcing a full re-onboarding wizard.
path := writeConfig(t, `{"providers":[
{"name":"work","provider_kind":"openai","apiKey":"sk-test","model":"gpt-test"},
{"name":"other","provider_kind":"openai","apiKey":"sk-other","model":"gpt-test"}
]}`)
resolved, err := Resolve(ResolveOptions{ProjectConfigPath: path, Env: map[string]string{}})
if !errors.Is(err, ErrNoActiveProvider) {
t.Fatalf("error = %v, want errors.Is(err, ErrNoActiveProvider)", err)
}
if len(resolved.Providers) != 2 {
t.Fatalf("Providers = %#v, want the 2 normalized profiles preserved despite the error", resolved.Providers)
}
}
func TestResolveTrimsProviderProfileAliasesBeforeFallback(t *testing.T) {
path := writeConfig(t, `{
"activeProvider": "custom",
@@ -1059,6 +1078,130 @@ func TestResolveAppliesProviderCatalogDefaults(t *testing.T) {
}
}
func TestResolveAppliesMiniMaxCNCatalogDefaults(t *testing.T) {
path := writeConfig(t, `{
"activeProvider": "mini-cn",
"providers": [{
"name": "mini-cn",
"catalog_id": "minimaxi-cn"
}]
}`)
resolved, err := Resolve(ResolveOptions{
ProjectConfigPath: path,
Env: map[string]string{
"MINIMAXI_API_KEY": "sk-cn-mini",
},
})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if resolved.Provider.CatalogID != "minimaxi-cn" {
t.Fatalf("CatalogID = %q, want minimaxi-cn", resolved.Provider.CatalogID)
}
if resolved.Provider.ProviderKind != ProviderKindAnthropicCompat {
t.Fatalf("ProviderKind = %q, want %q", resolved.Provider.ProviderKind, ProviderKindAnthropicCompat)
}
if resolved.Provider.BaseURL != "https://api.minimaxi.com/anthropic" {
t.Fatalf("BaseURL = %q, want MiniMax CN default", resolved.Provider.BaseURL)
}
if resolved.Provider.Model != "MiniMax-M3" {
t.Fatalf("Model = %q, want MiniMax-M3", resolved.Provider.Model)
}
if resolved.Provider.APIKeyEnv != "MINIMAXI_API_KEY" {
t.Fatalf("APIKeyEnv = %q, want MINIMAXI_API_KEY", resolved.Provider.APIKeyEnv)
}
if resolved.Provider.APIKey != "sk-cn-mini" {
t.Fatalf("APIKey = %q, want env-resolved secret", resolved.Provider.APIKey)
}
}
func TestResolveAppliesZaiCNCatalogDefaults(t *testing.T) {
// The "zai-cn" catalog entry preserves the legacy open.bigmodel.cn endpoint
// for users who still target the China hosting; "zai" itself now points at
// api.z.ai (international). The two env-vars are intentionally distinct
// (ZHIPU_API_KEY for China, ZAI_API_KEY for international) so an account on
// one side cannot be silently used against the other.
path := writeConfig(t, `{
"activeProvider": "zai-cn",
"providers": [{
"name": "zai-cn",
"catalog_id": "zai-cn"
}]
}`)
resolved, err := Resolve(ResolveOptions{
ProjectConfigPath: path,
Env: map[string]string{
"ZHIPU_API_KEY": "sk-zhipu-cn",
},
})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if resolved.Provider.CatalogID != "zai-cn" {
t.Fatalf("CatalogID = %q, want zai-cn", resolved.Provider.CatalogID)
}
if resolved.Provider.ProviderKind != ProviderKindOpenAICompatible {
t.Fatalf("ProviderKind = %q, want %q", resolved.Provider.ProviderKind, ProviderKindOpenAICompatible)
}
if resolved.Provider.BaseURL != "https://open.bigmodel.cn/api/paas/v4" {
t.Fatalf("BaseURL = %q, want Z.ai China default", resolved.Provider.BaseURL)
}
if resolved.Provider.Model != "glm-4.5" {
t.Fatalf("Model = %q, want glm-4.5 (China default)", resolved.Provider.Model)
}
if resolved.Provider.APIKeyEnv != "ZHIPU_API_KEY" {
t.Fatalf("APIKeyEnv = %q, want ZHIPU_API_KEY", resolved.Provider.APIKeyEnv)
}
if resolved.Provider.APIKey != "sk-zhipu-cn" {
t.Fatalf("APIKey = %q, want env-resolved secret", resolved.Provider.APIKey)
}
}
func TestResolveAppliesZaiInternationalCatalogDefaults(t *testing.T) {
// "zai" now resolves to the international endpoint; the catalog default
// model stays glm-4.5 (live discovery upgrades to glm-5.2 at runtime).
path := writeConfig(t, `{
"activeProvider": "zai-intl",
"providers": [{
"name": "zai-intl",
"catalog_id": "zai"
}]
}`)
resolved, err := Resolve(ResolveOptions{
ProjectConfigPath: path,
Env: map[string]string{
"ZAI_API_KEY": "sk-zai-intl",
},
})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if resolved.Provider.CatalogID != "zai" {
t.Fatalf("CatalogID = %q, want zai", resolved.Provider.CatalogID)
}
if resolved.Provider.ProviderKind != ProviderKindOpenAICompatible {
t.Fatalf("ProviderKind = %q, want %q", resolved.Provider.ProviderKind, ProviderKindOpenAICompatible)
}
if resolved.Provider.BaseURL != "https://api.z.ai/api/paas/v4" {
t.Fatalf("BaseURL = %q, want Z.ai international default", resolved.Provider.BaseURL)
}
if resolved.Provider.Model != "glm-4.5" {
t.Fatalf("Model = %q, want glm-4.5 (preserved default; live discovery upgrades to glm-5.2 at runtime)", resolved.Provider.Model)
}
if resolved.Provider.APIKeyEnv != "ZAI_API_KEY" {
t.Fatalf("APIKeyEnv = %q, want ZAI_API_KEY", resolved.Provider.APIKeyEnv)
}
if resolved.Provider.APIKey != "sk-zai-intl" {
t.Fatalf("APIKey = %q, want env-resolved secret", resolved.Provider.APIKey)
}
}
func TestResolveProviderCatalogAliasAndLocalNoAuth(t *testing.T) {
path := writeConfig(t, `{
"activeProvider": "local",
+26
View File
@@ -112,6 +112,25 @@ func (p PreferencesConfig) RecapsEnabled() bool {
return p.Recaps == nil || *p.Recaps
}
// KeyBindingDef defines one key binding string (e.g. "ctrl+o") that the TUI
// can remap. An empty string means "use the built-in default" for that action.
type KeyBindingDef string
// KeyBindingsConfig holds the subset of TUI keybindings that users may remap
// via config.json. Each field defaults to a sensible built-in when empty.
type KeyBindingsConfig struct {
// ToggleDetailed toggles the detailed transcript view (default: ctrl+o).
ToggleDetailed KeyBindingDef `json:"toggleDetailed,omitempty"`
// ToggleMouse toggles mouse capture release (default: ctrl+e).
ToggleMouse KeyBindingDef `json:"toggleMouse,omitempty"`
// CycleReasoning cycles through reasoning effort levels (default: ctrl+t).
CycleReasoning KeyBindingDef `json:"cycleReasoning,omitempty"`
// TogglePlan toggles the plan panel expansion (default: ctrl+p).
TogglePlan KeyBindingDef `json:"togglePlan,omitempty"`
// ToggleSidebar toggles the right context sidebar (default: ctrl+b).
ToggleSidebar KeyBindingDef `json:"toggleSidebar,omitempty"`
}
// LocalControlConfig controls local browser/desktop/terminal automation helpers.
// Helpers are discovered lazily by the tool that needs them; no setup command or
// background probe is required during startup.
@@ -206,6 +225,7 @@ type FileConfig struct {
Tools ToolsConfig `json:"tools,omitempty"`
Swarm SwarmConfig `json:"swarm,omitempty"`
Preferences PreferencesConfig `json:"preferences,omitempty"`
KeyBindings KeyBindingsConfig `json:"keybindings,omitempty"`
LocalControl LocalControlConfig `json:"localControl,omitempty"`
}
@@ -220,6 +240,7 @@ func (cfg FileConfig) MarshalJSON() ([]byte, error) {
Tools ToolsConfig `json:"tools,omitempty"`
Swarm SwarmConfig `json:"swarm,omitempty"`
Preferences PreferencesConfig `json:"preferences,omitempty"`
KeyBindings KeyBindingsConfig `json:"keybindings,omitempty"`
LocalControl *LocalControlConfig `json:"localControl,omitempty"`
}
raw := rawConfig{
@@ -232,6 +253,7 @@ func (cfg FileConfig) MarshalJSON() ([]byte, error) {
Tools: cfg.Tools,
Swarm: cfg.Swarm,
Preferences: cfg.Preferences,
KeyBindings: cfg.KeyBindings,
}
if !cfg.LocalControl.Empty() {
raw.LocalControl = &cfg.LocalControl
@@ -256,6 +278,7 @@ type Overrides struct {
Sandbox SandboxConfig
Notify NotifyConfig
Tools ToolsConfig
KeyBindings KeyBindingsConfig
LocalControl LocalControlConfig
}
@@ -270,6 +293,7 @@ type ResolvedConfig struct {
Tools ToolsConfig
Swarm SwarmConfig
Preferences PreferencesConfig
KeyBindings KeyBindingsConfig
LocalControl LocalControlConfig
}
@@ -316,6 +340,7 @@ func (cfg *FileConfig) UnmarshalJSON(data []byte) error {
Tools ToolsConfig `json:"tools"`
Swarm SwarmConfig `json:"swarm"`
Preferences PreferencesConfig `json:"preferences"`
KeyBindings KeyBindingsConfig `json:"keybindings"`
LocalControl LocalControlConfig `json:"localControl"`
MCPServers map[string]MCPServerConfig `json:"mcpServers"`
MCPServersSnake map[string]MCPServerConfig `json:"mcp_servers"`
@@ -342,6 +367,7 @@ func (cfg *FileConfig) UnmarshalJSON(data []byte) error {
cfg.Tools = raw.Tools
cfg.Swarm = raw.Swarm
cfg.Preferences = raw.Preferences
cfg.KeyBindings = raw.KeyBindings
cfg.LocalControl = raw.LocalControl
if cfg.MCP.Servers == nil && (len(raw.MCPServers) > 0 || len(raw.MCPServersSnake) > 0) {
cfg.MCP.Servers = map[string]MCPServerConfig{}
+26 -1
View File
@@ -3,6 +3,7 @@ package modelregistry
import (
"fmt"
"strings"
"sync"
)
const DefaultModelID = "gpt-4.1"
@@ -42,8 +43,20 @@ type ListOptions struct {
Capability ModelCapability
}
func DefaultRegistry() (Registry, error) {
// defaultRegistry builds the default registry exactly once and caches the result.
// The default catalog is static, every accessor returns clones, and a built
// Registry is never mutated (concurrent reads of its maps are safe), so a single
// shared instance is correct — and it avoids re-cloning all entries and
// recompiling every fuzzy-match regex on the per-frame TUI paths (title chip,
// context gauge, pickers) that call DefaultRegistry each render.
var defaultRegistry = sync.OnceValues(func() (Registry, error) {
return NewRegistry(DefaultModelEntries())
})
// DefaultRegistry returns the shared, immutable default model registry, building
// it on first use.
func DefaultRegistry() (Registry, error) {
return defaultRegistry()
}
func DefaultModelEntries() []ModelEntry {
@@ -76,11 +89,13 @@ func decorateModelDepth(entries []ModelEntry) {
patterns []string
deprecation *DeprecationRule
upgradeTarget string
fastVariant string
}{
"claude-sonnet-4.5": {
defaultEffort: ReasoningEffortMedium,
patterns: []string{`(?i)^sonnet[^a-z0-9]*4[.\s]?5$`},
upgradeTarget: "claude-opus-4.1",
fastVariant: "claude-haiku-4.5",
},
"claude-opus-4.1": {
defaultEffort: ReasoningEffortHigh,
@@ -94,6 +109,7 @@ func decorateModelDepth(entries []ModelEntry) {
"gemini-2.5-pro": {
defaultEffort: ReasoningEffortMedium,
patterns: []string{`(?i)^gemini[^a-z0-9]*pro$`},
fastVariant: "gemini-2.5-flash",
},
"gemini-2.5-flash": {
defaultEffort: ReasoningEffortLow,
@@ -102,6 +118,12 @@ func decorateModelDepth(entries []ModelEntry) {
"gemini-2.5-flash-lite": {
upgradeTarget: "gemini-2.5-flash",
},
"gpt-4.1": {
fastVariant: "gpt-4.1-mini",
},
"gpt-4o": {
fastVariant: "gpt-4o-mini",
},
"gpt-4.1-mini": {
upgradeTarget: "gpt-4.1",
},
@@ -143,6 +165,9 @@ func decorateModelDepth(entries []ModelEntry) {
if extra.upgradeTarget != "" {
entries[index].UpgradeTargetID = extra.upgradeTarget
}
if extra.fastVariant != "" {
entries[index].FastVariantID = extra.fastVariant
}
}
}
+20 -1
View File
@@ -110,7 +110,14 @@ type ModelEntry struct {
// during mid-run escalation. Empty means no escalation target (e.g. top-tier
// models). Resolved and availability-checked by Registry.UpgradeTarget.
UpgradeTargetID string
Description string
// FastVariantID, when set, names a genuinely faster same-family model this one
// can drop down to (e.g. claude-sonnet-4.5 -> claude-haiku-4.5). Empty means no
// fast variant. It is the single source of truth for the /fast command and the
// "f" fast-mode marker: Registry.FastVariant walks it forward and BaseVariant
// walks it in reverse, so a model is "in fast mode" iff another model names it
// here. Resolved and availability-checked like UpgradeTargetID.
FastVariantID string
Description string
}
// DeprecationRule describes how a deprecated model is phased out and what to use
@@ -368,6 +375,18 @@ func NewRegistry(entries []ModelEntry) (Registry, error) {
return Registry{}, fmt.Errorf("model %q upgrade target %q does not resolve to a known model", entry.ID, targetID)
}
}
// A non-empty FastVariantID must resolve to a known model, mirroring the
// UpgradeTargetID check — otherwise /fast and the "f" fast-mode marker would
// silently vanish at runtime on a catalog typo instead of failing loudly here.
for _, entry := range registry.models {
fastID := strings.TrimSpace(entry.FastVariantID)
if fastID == "" {
continue
}
if _, ok := registry.Get(fastID); !ok {
return Registry{}, fmt.Errorf("model %q fast variant %q does not resolve to a known model", entry.ID, fastID)
}
}
return registry, nil
}
+75
View File
@@ -0,0 +1,75 @@
package modelregistry
import "strings"
// The base<->fast model mapping is data, not logic: it lives entirely in each
// ModelEntry's FastVariantID field (populated in the catalog, validated in
// NewRegistry). Adding a pair is a one-line catalog change — never a code change
// here. Both directions derive from that one field: FastVariant walks a model to
// the fast id it names, BaseVariant scans for the model that names id as its fast
// variant. This is why "fast mode" is encoded by model identity and needs no flag.
//
// Only models with an unambiguous 1:1 fast counterpart carry a FastVariantID. A
// model with no clean counterpart (e.g. a top-tier Opus, where both Sonnet and
// Haiku would be candidates) is intentionally left unset, so /fast reports "no
// fast mode available" for it rather than guessing.
// FastVariant resolves id to its configured faster same-family counterpart. It
// resolves id to a concrete entry, reads that entry's FastVariantID, and returns
// the target only when it resolves and is not deprecated. Returns (_, false) when
// id is unknown, has no fast variant, or the variant is unavailable/deprecated.
// Mirrors Registry.UpgradeTarget.
func (registry Registry) FastVariant(id string) (ModelEntry, bool) {
source, ok := registry.Resolve(id)
if !ok {
return ModelEntry{}, false
}
fastID := strings.TrimSpace(source.FastVariantID)
if fastID == "" {
return ModelEntry{}, false
}
target, ok := registry.Resolve(fastID)
if !ok || target.Status == ModelStatusDeprecated {
return ModelEntry{}, false
}
return target, true
}
// BaseVariant resolves id to the base model that declares id as its fast variant
// — the reverse of FastVariant. A non-empty result therefore means id IS a fast
// model: this is exactly how the /fast command derives "currently in fast mode"
// without storing any flag. Same resolution and availability rules as FastVariant.
func (registry Registry) BaseVariant(id string) (ModelEntry, bool) {
source, ok := registry.Resolve(id)
if !ok {
return ModelEntry{}, false
}
for _, entry := range registry.models {
fastID := strings.TrimSpace(entry.FastVariantID)
if fastID == "" {
continue
}
target, ok := registry.Resolve(fastID)
if !ok || target.ID != source.ID {
continue
}
if entry.Status == ModelStatusDeprecated {
return ModelEntry{}, false
}
base, ok := registry.Resolve(entry.ID)
if !ok {
return ModelEntry{}, false
}
return base, true
}
return ModelEntry{}, false
}
// HasFastVariant reports whether id has an available fast variant — i.e. whether
// `/fast on` would switch to a faster model from here. It is the predicate the
// TUI uses to show the "f" fast-mode marker on a model (in the model/provider
// pickers and the active-model indicator).
func (registry Registry) HasFastVariant(id string) bool {
_, ok := registry.FastVariant(id)
return ok
}
+145
View File
@@ -0,0 +1,145 @@
package modelregistry
import "testing"
func testRegistry(t *testing.T) Registry {
t.Helper()
registry, err := DefaultRegistry()
if err != nil {
t.Fatalf("DefaultRegistry() error = %v", err)
}
return registry
}
// expectedFastPairs is the known-good base->fast mapping. It is a literal
// regression guard: if the catalog silently drops or mis-wires a FastVariantID,
// the forward/reverse assertions below fail loudly rather than making /fast a
// quiet no-op. Keep in sync with the FastVariantID fields in catalog.go.
var expectedFastPairs = map[string]string{
"claude-sonnet-4.5": "claude-haiku-4.5",
"gpt-4.1": "gpt-4.1-mini",
"gpt-4o": "gpt-4o-mini",
"gemini-2.5-pro": "gemini-2.5-flash",
}
// Every FastVariantID declared in the catalog must resolve to a real, non-self,
// non-deprecated model. NewRegistry already rejects an unresolvable id at build
// time; this additionally guards the runtime availability contract that /fast and
// the "f" marker depend on.
func TestCatalogFastVariantsResolve(t *testing.T) {
registry := testRegistry(t)
for _, entry := range registry.List(ListOptions{IncludeDeprecated: true}) {
if entry.FastVariantID == "" {
continue
}
target, ok := registry.Resolve(entry.FastVariantID)
if !ok {
t.Errorf("model %q fast variant %q does not resolve", entry.ID, entry.FastVariantID)
continue
}
if target.ID == entry.ID {
t.Errorf("model %q names itself as its fast variant", entry.ID)
}
if target.Status == ModelStatusDeprecated {
t.Errorf("model %q fast variant %q is deprecated", entry.ID, entry.FastVariantID)
}
}
}
// No model may be both a base and a fast, or the two directions become ambiguous
// (a model would be "currently fast" AND advertise a fast variant). Derived from
// the registry so a bad catalog edit — e.g. chaining mini->nano — is caught.
func TestFastVariantPairsUnambiguous(t *testing.T) {
registry := testRegistry(t)
role := map[string]string{} // canonical id -> "base" | "fast"
assign := func(id, r string) {
entry, ok := registry.Resolve(id)
if !ok {
return
}
if prev, seen := role[entry.ID]; seen && prev != r {
t.Fatalf("model %q is declared as both a base and a fast variant", entry.ID)
}
role[entry.ID] = r
}
for _, entry := range registry.List(ListOptions{IncludeDeprecated: true}) {
if entry.FastVariantID == "" {
continue
}
assign(entry.ID, "base")
assign(entry.FastVariantID, "fast")
}
}
// The known pairs must be present and wired in both directions, and neither end
// may leak into the other role (a base is not itself fast; a fast has no fast).
func TestFastVariantAndBaseVariant(t *testing.T) {
registry := testRegistry(t)
for base, fast := range expectedFastPairs {
// base -> fast (forward), and the fast id has NO fast variant of its own.
if got, ok := registry.FastVariant(base); !ok {
t.Errorf("FastVariant(%q) = not found, want the fast variant", base)
} else if canon, _ := registry.Resolve(fast); got.ID != canon.ID {
t.Errorf("FastVariant(%q).ID = %q, want %q", base, got.ID, canon.ID)
}
if _, ok := registry.FastVariant(fast); ok {
t.Errorf("FastVariant(%q) resolved, but a fast model should have no fast variant", fast)
}
// fast -> base (reverse), and the base id is NOT itself a fast variant.
if got, ok := registry.BaseVariant(fast); !ok {
t.Errorf("BaseVariant(%q) = not found, want the base variant", fast)
} else if canon, _ := registry.Resolve(base); got.ID != canon.ID {
t.Errorf("BaseVariant(%q).ID = %q, want %q", fast, got.ID, canon.ID)
}
if _, ok := registry.BaseVariant(base); ok {
t.Errorf("BaseVariant(%q) resolved, but a base model is not a fast variant", base)
}
}
}
// HasFastVariant is the TUI marker predicate: true only for base models with an
// available fast variant, false for the fast models themselves, unpaired models,
// and unknown ids.
func TestHasFastVariant(t *testing.T) {
registry := testRegistry(t)
for base, fast := range expectedFastPairs {
if !registry.HasFastVariant(base) {
t.Errorf("HasFastVariant(%q) = false, want true", base)
}
if registry.HasFastVariant(fast) {
t.Errorf("HasFastVariant(%q) = true, want false (a fast model has no fast variant)", fast)
}
}
for _, id := range []string{"claude-opus-4.1", "definitely-not-a-real-model", ""} {
if registry.HasFastVariant(id) {
t.Errorf("HasFastVariant(%q) = true, want false", id)
}
}
}
func TestFastVariantAbsentAndUnknown(t *testing.T) {
registry := testRegistry(t)
// A real model with no configured fast/base variant reports none in both
// directions (a valid, handled case — /fast will say "no fast mode available").
const noVariant = "claude-opus-4.1"
if _, ok := registry.Resolve(noVariant); !ok {
t.Skipf("%q not in registry; skipping no-variant case", noVariant)
}
if _, ok := registry.FastVariant(noVariant); ok {
t.Errorf("FastVariant(%q) resolved, want none", noVariant)
}
if _, ok := registry.BaseVariant(noVariant); ok {
t.Errorf("BaseVariant(%q) resolved, want none", noVariant)
}
// An unknown id resolves to nothing in either direction.
if _, ok := registry.FastVariant("definitely-not-a-real-model"); ok {
t.Error("FastVariant(unknown) resolved, want none")
}
if _, ok := registry.BaseVariant("definitely-not-a-real-model"); ok {
t.Error("BaseVariant(unknown) resolved, want none")
}
}
+6 -1
View File
@@ -114,7 +114,12 @@ func createSecretFile(path string) ([]byte, error) {
}
return writeNewSecretFile(path)
}
if !errors.Is(err, os.ErrExist) {
// On Windows a concurrent holder's os.Remove leaves the lock file in a
// "delete pending" state, so an O_EXCL create races it with
// ERROR_ACCESS_DENIED (os.ErrPermission) rather than ErrExist. Treat that
// as contention and retry too — mirroring acquireFileLock in lock.go —
// otherwise concurrent secret creation spuriously fails on Windows.
if !errors.Is(err, os.ErrExist) && !errors.Is(err, os.ErrPermission) {
return nil, fmt.Errorf("oauth: create token secret lock: %w", err)
}
if data, rerr := readSecretFileRetry(path); rerr == nil {
+3 -1
View File
@@ -126,6 +126,7 @@ var descriptors = []Descriptor{
openAICompat("longcat", "LongCat", "https://api.longcat.chat/openai", "LongCat-2.0", []string{"LONGCAT_API_KEY"}, "meituan longcat", "meituan", "longcat-2.0"),
openAICompat("nvidia-nim", "NVIDIA NIM", "https://integrate.api.nvidia.com/v1", "nvidia/llama-3.1-nemotron-70b-instruct", []string{"NVIDIA_API_KEY"}, "nvidia nim"),
anthropicCompat("minimax", "MiniMax", "https://api.minimax.io/anthropic", "MiniMax-M3", []string{"MINIMAX_API_KEY"}, "mini-max", "mini_max"),
anthropicCompat("minimaxi-cn", "MiniMax CN", "https://api.minimaxi.com/anthropic", "MiniMax-M3", []string{"MINIMAXI_API_KEY"}, "minimax cn", "minimax-cn"),
openAICompat("mistral", "Mistral", "https://api.mistral.ai/v1", "mistral-large-latest", []string{"MISTRAL_API_KEY"}),
openAICompat("github", "GitHub Models", "https://models.inference.ai.azure.com", "openai/gpt-4.1", []string{"GITHUB_TOKEN"}, "github-models"),
transportDescriptor("bedrock", "Amazon Bedrock", TransportBedrock, "https://bedrock-runtime.${AWS_REGION}.amazonaws.com", "anthropic.claude-3-5-sonnet-20241022-v2:0", []string{"AWS_ACCESS_KEY_ID", "AWS_PROFILE"}, []APIFormat{APIFormatBedrockConverse}, true),
@@ -134,7 +135,8 @@ var descriptors = []Descriptor{
openAICompat("venice", "Venice AI", "https://api.venice.ai/api/v1", "qwen-2.5-qwq-32b", []string{"VENICE_API_KEY"}),
openAICompat("xiaomi-mimo", "Xiaomi MiMo", "https://api.mimo.xiaomi.com/openai/v1", "mimo-vl", []string{"MIMO_API_KEY", "XIAOMI_API_KEY"}, "xiaomi mimo"),
openAICompat("bankr", "Bankr", "https://api.bankr.bot/v1", "bankr-large", []string{"BANKR_API_KEY"}),
openAICompat("zai", "Z.ai", "https://open.bigmodel.cn/api/paas/v4", "glm-4.5", []string{"ZAI_API_KEY", "ZHIPU_API_KEY"}),
openAICompat("zai", "Z.ai", "https://api.z.ai/api/paas/v4", "glm-4.5", []string{"ZAI_API_KEY"}, "z.ai"),
openAICompat("zai-cn", "Z.ai CN", "https://open.bigmodel.cn/api/paas/v4", "glm-4.5", []string{"ZHIPU_API_KEY"}, "z.ai cn"),
openAICompat("kilocode", "KiloCode", "https://api.kilo.ai/api/gateway", "anthropic/claude-sonnet-4.6", []string{"KILO_API_KEY"}, "kilo", "kilo gateway"),
openAICompat("opencode", "OpenCode Zen", "https://opencode.ai/zen/v1", "deepseek-v4-flash", []string{"OPENCODE_API_KEY"}, "opencode zen"),
openAICompat("opencode-go", "OpenCode Go", "https://opencode.ai/zen/go/v1", "deepseek-v4-pro", []string{"OPENCODE_API_KEY"}, "opencode go"),
+4 -2
View File
@@ -26,6 +26,7 @@ var expectedCatalogIDs = []string{
"longcat",
"nvidia-nim",
"minimax",
"minimaxi-cn",
"mistral",
"github",
"bedrock",
@@ -35,6 +36,7 @@ var expectedCatalogIDs = []string{
"xiaomi-mimo",
"bankr",
"zai",
"zai-cn",
"kilocode",
"opencode",
"opencode-go",
@@ -289,8 +291,8 @@ func TestListByTransportPreservesCatalogOrder(t *testing.T) {
TransportGoogle: {"google"},
TransportBedrock: {"bedrock"},
TransportVertex: {"vertex"},
TransportAnthropicCompat: {"minimax", "custom-anthropic-compatible"},
TransportOpenAICompat: {"gitlawb-opengateway", "ollama-cloud", "ollama", "lmstudio", "openrouter", "huggingface", "chatgpt", "groq", "deepseek", "together", "dashscope", "moonshot", "longcat", "nvidia-nim", "mistral", "github", "xai", "venice", "xiaomi-mimo", "bankr", "zai", "kilocode", "opencode", "opencode-go", "atomic-chat", "chatgpt-proxy", "custom-openai-compatible"},
TransportAnthropicCompat: {"minimax", "minimaxi-cn", "custom-anthropic-compatible"},
TransportOpenAICompat: {"gitlawb-opengateway", "ollama-cloud", "ollama", "lmstudio", "openrouter", "huggingface", "chatgpt", "groq", "deepseek", "together", "dashscope", "moonshot", "longcat", "nvidia-nim", "mistral", "github", "xai", "venice", "xiaomi-mimo", "bankr", "zai", "zai-cn", "kilocode", "opencode", "opencode-go", "atomic-chat", "chatgpt-proxy", "custom-openai-compatible"},
}
for transport, wantIDs := range cases {
@@ -169,8 +169,8 @@ func TestValidateEndpointAllowsLoopbackOnlyForLocalProvider(t *testing.T) {
if err := validateEndpoint(ctx, ep, r, true); err != nil {
t.Errorf("local provider endpoint %q should be allowed with allowLoopback=true, got %v", ep, err)
}
if !endpointIsLoopback(ep) {
t.Errorf("endpointIsLoopback(%q) = false, want true", ep)
if !endpointIsLoopbackOrPrivate(ep) {
t.Errorf("endpointIsLoopbackOrPrivate(%q) = false, want true", ep)
}
}
@@ -179,14 +179,27 @@ func TestValidateEndpointAllowsLoopbackOnlyForLocalProvider(t *testing.T) {
t.Error("loopback must remain blocked when allowLoopback=false (redirects / non-local)")
}
// allowLoopback relaxes loopback ONLY — other special ranges (cloud metadata,
// link-local, private) stay blocked even for a local provider config.
for _, ep := range []string{"http://169.254.169.254/latest/meta-data", "http://10.0.0.5:8080/v1"} {
// allowLoopbackOrPrivate relaxes loopback AND private-network ranges — but
// link-local and other special ranges (cloud metadata, documentation) stay
// blocked even for a local provider config.
for _, ep := range []string{"http://169.254.169.254/latest/meta-data", "http://192.0.2.1:8080/v1"} {
if err := validateEndpoint(ctx, ep, staticResolver{addr: netip.MustParseAddr("169.254.169.254")}, true); err == nil {
t.Errorf("non-loopback special address %q must stay blocked even with allowLoopback=true", ep)
t.Errorf("special-use address %q must stay blocked even with flag=true", ep)
}
if endpointIsLoopback(ep) {
t.Errorf("endpointIsLoopback(%q) = true, want false", ep)
if endpointIsLoopbackOrPrivate(ep) {
t.Errorf("endpointIsLoopbackOrPrivate(%q) = true, want false", ep)
}
}
// Private-network addresses (192.168.x.y / 10.x.y.z / 172.16.x.y) are
// now allowed when the flag is true — the user is pointing at their own
// LAN box (e.g. llama.cpp on another local machine).
for _, ep := range []string{"http://192.168.1.100:8080/v1", "http://10.0.0.5:8080/v1", "http://172.16.0.10:8080/v1"} {
if err := validateEndpoint(ctx, ep, staticResolver{addr: netip.MustParseAddr("192.168.1.100")}, true); err != nil {
t.Errorf("private-network address %q should be allowed with flag=true, got %v", ep, err)
}
if !endpointIsLoopbackOrPrivate(ep) {
t.Errorf("endpointIsLoopbackOrPrivate(%q) = false, want true", ep)
}
}
}
+27 -25
View File
@@ -269,7 +269,7 @@ func connectivityCheck(ctx context.Context, profile config.ProviderProfile, kind
requestCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
request, allowLoopback, err := healthRequest(requestCtx, profile, kind, options)
request, allowLoopbackOrPrivate, err := healthRequest(requestCtx, profile, kind, options)
if err != nil {
category := CategoryConfig
var safety endpointSafetyError
@@ -280,7 +280,7 @@ func connectivityCheck(ctx context.Context, profile config.ProviderProfile, kind
}
client := options.HTTPClient
if client == nil {
client = newConnectivityClient(timeout, options.Resolver, sensitiveAuthHeaderNames(profile, kind), allowLoopback)
client = newConnectivityClient(timeout, options.Resolver, sensitiveAuthHeaderNames(profile, kind), allowLoopbackOrPrivate)
}
response, err := client.Do(request)
if err != nil {
@@ -332,8 +332,8 @@ func healthRequest(ctx context.Context, profile config.ProviderProfile, kind con
// Loopback is permitted only when the user's OWN configured base_url is loopback
// (a local provider like Ollama/LM Studio) — never for a redirect target. This is
// the flag returned to the caller so the dial path applies the same policy. (AUDIT-H1)
allowLoopback := endpointIsLoopback(endpoint)
if err := validateEndpoint(ctx, endpoint, options.Resolver, allowLoopback); err != nil {
allowLoopbackOrPrivate := endpointIsLoopbackOrPrivate(endpoint)
if err := validateEndpoint(ctx, endpoint, options.Resolver, allowLoopbackOrPrivate); err != nil {
return nil, false, err
}
request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
@@ -344,7 +344,7 @@ func healthRequest(ctx context.Context, profile config.ProviderProfile, kind con
request.Header.Set("User-Agent", options.UserAgent)
}
applyAuth(request, profile, kind)
return request, allowLoopback, nil
return request, allowLoopbackOrPrivate, nil
}
func resolvedBaseURL(profile config.ProviderProfile, kind config.ProviderKind) (string, error) {
@@ -367,11 +367,11 @@ func resolvedBaseURL(profile config.ProviderProfile, kind config.ProviderKind) (
}
// validateEndpoint enforces the SSRF policy for the provider connectivity probe.
// allowLoopback permits loopback hosts (localhost / 127.0.0.0/8 / ::1) ONLY — it is
// set true exclusively for the user's own explicitly-configured local provider
// base_url (e.g. Ollama/LM Studio), never for redirect targets, and it relaxes
// loopback alone (other private/special ranges, incl. cloud metadata, stay blocked).
func validateEndpoint(ctx context.Context, endpoint string, resolver Resolver, allowLoopback bool) error {
// allowLoopbackOrPrivate permits loopback hosts (localhost / 127.0.0.0/8 / ::1)
// and private-network hosts (192.168.0.0/16 / 10.0.0.0/8 / 172.16.0.0/12) — it
// is set true exclusively for the user's own explicitly-configured base_url
// (e.g. Ollama/LM Studio on the LAN), never for redirect targets.
func validateEndpoint(ctx context.Context, endpoint string, resolver Resolver, allowLoopbackOrPrivate bool) error {
parsed, err := url.Parse(endpoint)
if err != nil {
return err
@@ -385,13 +385,13 @@ func validateEndpoint(ctx context.Context, endpoint string, resolver Resolver, a
}
normalized := strings.TrimSuffix(strings.ToLower(host), ".")
if normalized == "localhost" || strings.HasSuffix(normalized, ".localhost") {
if allowLoopback {
if allowLoopbackOrPrivate {
return nil // user-configured local provider; loopback is intentional
}
return endpointSafetyError{message: "provider connectivity URL is unsafe: localhost hosts are blocked"}
}
if addr, err := netip.ParseAddr(normalized); err == nil {
if reason := blockedAddrReason(addr); reason != "" && !(allowLoopback && addr.IsLoopback()) {
if reason := blockedAddrReason(addr); reason != "" && !(allowLoopbackOrPrivate && (addr.IsLoopback() || addr.IsPrivate())) {
return endpointSafetyError{message: "provider connectivity URL is unsafe: " + reason}
}
return nil
@@ -407,17 +407,19 @@ func validateEndpoint(ctx context.Context, endpoint string, resolver Resolver, a
return endpointSafetyError{message: "provider connectivity host resolved to no addresses"}
}
for _, addr := range addrs {
if reason := blockedAddrReason(addr); reason != "" && !(allowLoopback && addr.IsLoopback()) {
if reason := blockedAddrReason(addr); reason != "" && !(allowLoopbackOrPrivate && (addr.IsLoopback() || addr.IsPrivate())) {
return endpointSafetyError{message: "provider connectivity URL is unsafe: " + reason}
}
}
return nil
}
// endpointIsLoopback reports whether the configured endpoint's host is a loopback
// host the user typed themselves (localhost or a 127.0.0.0/8 / ::1 literal). Used to
// decide whether the connectivity probe may reach the user's own local provider.
func endpointIsLoopback(endpoint string) bool {
// endpointIsLoopbackOrPrivate reports whether the configured endpoint's host is a loopback
// or private-network host the user typed themselves — a local provider on the LAN
// (e.g. 192.168.x.y / 10.x.y.z) or localhost. Used to decide whether the
// connectivity probe may relax address-blocking and reach the user's own
// explicitly-configured provider.
func endpointIsLoopbackOrPrivate(endpoint string) bool {
parsed, err := url.Parse(endpoint)
if err != nil {
return false
@@ -427,7 +429,7 @@ func endpointIsLoopback(endpoint string) bool {
return true
}
if addr, err := netip.ParseAddr(host); err == nil {
return addr.IsLoopback()
return addr.IsLoopback() || addr.IsPrivate()
}
return false
}
@@ -446,7 +448,7 @@ const maxConnectivityRedirects = 5
// time and then dials the validated IP literal, closing the TOCTOU window
// between the pre-flight validateEndpoint check and the actual connection
// (DNS rebinding).
func newConnectivityClient(timeout time.Duration, resolver Resolver, sensitiveHeaders []string, allowLoopback bool) *http.Client {
func newConnectivityClient(timeout time.Duration, resolver Resolver, sensitiveHeaders []string, allowLoopbackOrPrivate bool) *http.Client {
if resolver == nil {
resolver = defaultResolver{}
}
@@ -456,7 +458,7 @@ func newConnectivityClient(timeout time.Duration, resolver Resolver, sensitiveHe
} else {
transport = &http.Transport{}
}
transport.DialContext = safeDialContext(resolver, allowLoopback)
transport.DialContext = safeDialContext(resolver, allowLoopbackOrPrivate)
return &http.Client{
Timeout: timeout,
Transport: transport,
@@ -476,8 +478,8 @@ func newConnectivityClient(timeout time.Duration, resolver Resolver, sensitiveHe
}
}
// Redirects are NEVER allowed to reach loopback, even for a local provider:
// allowLoopback covers only the user's own configured base_url, not a 3xx
// target (which could be attacker-controlled SSRF). (AUDIT-H1)
// allowLoopbackOrPrivate covers only the user's own configured base_url,
// not a 3xx target (which could be attacker-controlled SSRF). (AUDIT-H1)
return validateEndpoint(req.Context(), req.URL.String(), resolver, false)
},
}
@@ -519,7 +521,7 @@ func sensitiveAuthHeaderNames(profile config.ProviderProfile, kind config.Provid
// safeDialContext returns a dial function that resolves the target host, refuses
// the connection if any resolved address is blocked, then dials the validated IP
// literal so the kernel cannot re-resolve to a different address after the check.
func safeDialContext(resolver Resolver, allowLoopback bool) func(context.Context, string, string) (net.Conn, error) {
func safeDialContext(resolver Resolver, allowLoopbackOrPrivate bool) func(context.Context, string, string) (net.Conn, error) {
dialer := &net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second}
return func(ctx context.Context, network, address string) (net.Conn, error) {
host, port, err := net.SplitHostPort(address)
@@ -527,7 +529,7 @@ func safeDialContext(resolver Resolver, allowLoopback bool) func(context.Context
return nil, err
}
if addr, parseErr := netip.ParseAddr(host); parseErr == nil {
if reason := blockedAddrReason(addr); reason != "" && !(allowLoopback && addr.IsLoopback()) {
if reason := blockedAddrReason(addr); reason != "" && !(allowLoopbackOrPrivate && (addr.IsLoopback() || addr.IsPrivate())) {
return nil, endpointSafetyError{message: "provider connectivity URL is unsafe: " + reason}
}
return dialer.DialContext(ctx, network, address)
@@ -540,7 +542,7 @@ func safeDialContext(resolver Resolver, allowLoopback bool) func(context.Context
return nil, endpointSafetyError{message: "provider connectivity host resolved to no addresses"}
}
for _, addr := range addrs {
if reason := blockedAddrReason(addr); reason != "" && !(allowLoopback && addr.IsLoopback()) {
if reason := blockedAddrReason(addr); reason != "" && !(allowLoopbackOrPrivate && (addr.IsLoopback() || addr.IsPrivate())) {
return nil, endpointSafetyError{message: "provider connectivity URL is unsafe: " + reason}
}
}
+20 -10
View File
@@ -21,6 +21,22 @@ type Model struct {
Source string
}
// Shared by both "minimax" and "minimaxi-cn"; Models() rebuilds a fresh slice
// per call, so the shared backing slice cannot be mutated by callers.
var minimaxCuratedModels = []Model{
{ID: "MiniMax-M3", Description: "catalog default"},
{ID: "MiniMax-M2.1", Description: "agentic coding model"},
}
// Shared by both "zai" (international, api.z.ai) and "zai-cn" (China,
// open.bigmodel.cn); same model lineup on both endpoints.
var zaiCuratedModels = []Model{
{ID: "glm-4.5", Description: "catalog default"},
{ID: "glm-4.5-air", Description: "fast model"},
{ID: "glm-4.6", Description: "latest general model"},
{ID: "glm-z1-air", Description: "reasoning model"},
}
var curatedModels = map[string][]Model{
"ollama-cloud": {
{ID: "qwen3-coder:480b", Description: "catalog default"},
@@ -88,10 +104,8 @@ var curatedModels = map[string][]Model{
{ID: "meta/llama-3.1-70b-instruct", Description: "general model"},
{ID: "mistralai/mixtral-8x7b-instruct-v0.1", Description: "mixture model"},
},
"minimax": {
{ID: "MiniMax-M3", Description: "catalog default"},
{ID: "MiniMax-M2.1", Description: "agentic coding model"},
},
"minimax": minimaxCuratedModels,
"minimaxi-cn": minimaxCuratedModels,
"mistral": {
{ID: "mistral-large-latest", Description: "catalog default"},
{ID: "codestral-latest", Description: "coding model"},
@@ -123,12 +137,8 @@ var curatedModels = map[string][]Model{
"bankr": {
{ID: "bankr-large", Description: "catalog default"},
},
"zai": {
{ID: "glm-4.5", Description: "catalog default"},
{ID: "glm-4.5-air", Description: "fast model"},
{ID: "glm-4.6", Description: "latest general model"},
{ID: "glm-z1-air", Description: "reasoning model"},
},
"zai": zaiCuratedModels,
"zai-cn": zaiCuratedModels,
// OpenGateway smart-routes by model id across its upstream providers
// (see /health: xiaomi-mimo, minimax, qwen, google, nvidia, z-ai). These are
// the curated coding defaults; the gateway accepts any model its upstreams
@@ -37,6 +37,16 @@ func TestModelsAreProviderScoped(t *testing.T) {
want: []string{"mistral-large-latest", "codestral-latest"},
notWant: []string{"gpt-4.1", "claude-sonnet-4.5"},
},
{
provider: "minimaxi-cn",
want: []string{"MiniMax-M3", "MiniMax-M2.1"},
notWant: []string{"gpt-4.1", "claude-sonnet-4.5"},
},
{
provider: "zai-cn",
want: []string{"glm-4.5", "glm-4.6", "glm-4.5-air", "glm-z1-air"},
notWant: []string{"gpt-4.1", "claude-sonnet-4.5"},
},
}
for _, tt := range tests {
@@ -88,3 +98,66 @@ func modelIDs(models []Model) []string {
}
return ids
}
// Pins the contract that MiniMax CN exposes exactly the same model IDs as the
// international provider; a divergence here is almost always a mistake.
func TestMiniMaxCNModelsMirrorInternational(t *testing.T) {
intl, ok := providercatalog.Get("minimax")
if !ok {
t.Fatal("provider minimax missing from catalog")
}
cn, ok := providercatalog.Get("minimaxi-cn")
if !ok {
t.Fatal("provider minimaxi-cn missing from catalog")
}
intlModels := Models(intl)
cnModels := Models(cn)
if len(intlModels) != len(cnModels) {
t.Fatalf("model count mismatch: international=%d china=%d", len(intlModels), len(cnModels))
}
intlIDs := map[string]string{}
for _, m := range intlModels {
intlIDs[m.ID] = m.Description
}
for _, m := range cnModels {
wantDesc, ok := intlIDs[m.ID]
if !ok {
t.Fatalf("china provider exposes %q which is not in the international catalog", m.ID)
}
if m.Description != wantDesc {
t.Fatalf("model %q description diverged: international=%q china=%q", m.ID, wantDesc, m.Description)
}
}
}
// Same contract as TestMiniMaxCNModelsMirrorInternational, for Z.ai: the
// international (api.z.ai) and China (open.bigmodel.cn) endpoints expose the
// same model lineup.
func TestZaiCNModelsMirrorInternational(t *testing.T) {
intl, ok := providercatalog.Get("zai")
if !ok {
t.Fatal("provider zai missing from catalog")
}
cn, ok := providercatalog.Get("zai-cn")
if !ok {
t.Fatal("provider zai-cn missing from catalog")
}
intlModels := Models(intl)
cnModels := Models(cn)
if len(intlModels) != len(cnModels) {
t.Fatalf("model count mismatch: international=%d china=%d", len(intlModels), len(cnModels))
}
intlIDs := map[string]string{}
for _, m := range intlModels {
intlIDs[m.ID] = m.Description
}
for _, m := range cnModels {
wantDesc, ok := intlIDs[m.ID]
if !ok {
t.Fatalf("china provider exposes %q which is not in the international catalog", m.ID)
}
if m.Description != wantDesc {
t.Fatalf("model %q description diverged: international=%q china=%q", m.ID, wantDesc, m.Description)
}
}
}
+4
View File
@@ -128,6 +128,10 @@ func ModelsDevProviderID(provider providercatalog.Descriptor) string {
return "nvidia"
case "xiaomi-mimo":
return "xiaomi"
case "zai-cn":
return "zai"
case "minimaxi-cn":
return "minimax"
default:
return strings.TrimSpace(provider.ID)
}
@@ -118,6 +118,8 @@ func TestModelsDevProviderIDMapsZeroAliases(t *testing.T) {
"xiaomi-mimo": "xiaomi",
"dashscope": "alibaba",
"ollama-cloud": "ollama-cloud",
"zai-cn": "zai",
"minimaxi-cn": "minimax",
}
for zeroID, want := range tests {
provider, ok := providercatalog.Get(zeroID)
+28 -8
View File
@@ -19,6 +19,13 @@ var errWindowsSandboxNotInitialized = errors.New(
const windowsSetupDowngradeReason = "Windows OS sandbox inactive — commands run with workspace path-confinement and " +
"per-command approval only. Run `zero sandbox setup` from an elevated (Administrator) terminal for full filesystem and network isolation."
// windowsUnelevatedDowngradeReason is surfaced when Windows runs the sandbox in
// the unelevated tier: the restricted-token write-jail is enforced (the command
// runner applies the workspace ACLs itself, which needs no Administrator
// rights), but the WFP network filters are Administrator-only and absent.
const windowsUnelevatedDowngradeReason = "Windows sandbox running unelevated — the filesystem write-jail is enforced, but network " +
"isolation still relies on per-command approval. Run `zero sandbox setup` from an elevated (Administrator) terminal for OS-level network enforcement."
// windowsSandboxInitialized reports whether the per-host Windows sandbox setup
// marker exists. A missing marker is the common fresh-install state, so the
// caller degrades rather than bricking the command. Indirected through a var so
@@ -174,19 +181,28 @@ func (manager SandboxManager) BuildExecutionRequest(request SandboxManagerReques
if request.ValidateExecution && preference == SandboxPreferenceRequire && backend.SupportLevel() != BackendSupportNative {
return SandboxExecutionRequest{}, nativeSandboxUnavailableError(backend)
}
// Windows: the OS sandbox needs a one-time elevated `zero sandbox setup` (it
// applies WFP network filters + workspace ACLs and writes a marker). Without
// it the command runner hard-fails — so on the default preference, DEGRADE to
// the in-process policy gate + per-command approval (matching Linux/macOS when
// their sandbox is unavailable) instead of bricking every command. A strict
// caller (--sandbox require) still gets a clear error pointing at setup.
// Windows: the FULL OS sandbox needs a one-time elevated `zero sandbox setup`
// (it applies WFP network filters + workspace ACLs and writes a marker).
// Without it, a restricted-filesystem profile can still run in the UNELEVATED
// tier: the command runner applies the workspace ACL plan itself (DACL edits
// on user-owned roots need no Administrator rights) and wraps the command in
// the write-restricted token, so the filesystem write-jail holds. Only the
// WFP network filters are Administrator-only, so network stays with the
// in-process approval gate at that tier. A profile that needs the sandbox
// solely for network isolation gains nothing from the unelevated tier and
// DEGRADES to the policy gate as before. A strict caller (--sandbox require)
// still gets a clear error pointing at setup.
windowsNeedsSetup := false
if manager.goos == "windows" && requiresPlatformSandbox && enforcementLevel == EnforcementNative && !windowsSandboxInitialized() {
if preference == SandboxPreferenceRequire {
return SandboxExecutionRequest{}, errWindowsSandboxNotInitialized
}
enforcementLevel = EnforcementDegraded
windowsNeedsSetup = true
if profile.FileSystem.Kind == FileSystemRestricted {
enforcementLevel = EnforcementUnelevated
} else {
enforcementLevel = EnforcementDegraded
}
}
targetBackend := manager.targetBackend(preference, policy, requiresPlatformSandbox)
downgradeReason := ""
@@ -196,7 +212,11 @@ func (manager SandboxManager) BuildExecutionRequest(request SandboxManagerReques
downgradeReason = windowsSetupDowngradeReason
}
}
wrapped := backend.CommandWrapping && backend.Available && enforcementLevel == EnforcementNative
if enforcementLevel == EnforcementUnelevated {
downgradeReason = windowsUnelevatedDowngradeReason
}
wrapped := backend.CommandWrapping && backend.Available &&
(enforcementLevel == EnforcementNative || enforcementLevel == EnforcementUnelevated)
markers := backend.SandboxEnvMarkers(policy)
if !wrapped && backend.Name != BackendWSL {
markers = nil
+3
View File
@@ -72,6 +72,9 @@ func TestPermissionProfileFromPolicyIncludesDefaultTempWriteRoots(t *testing.T)
if !writeRootsContain(profile.FileSystem.WriteRoots, tmpdir) {
t.Fatalf("write roots = %#v, want temp root %q", profile.FileSystem.WriteRoots, tmpdir)
}
// /tmp is a default temp write root on POSIX only (see
// defaultTempWriteRootCandidatesForGOOS); on Windows the bare path resolves
// against the current drive, so a stray C:\tmp must not turn this on.
if runtime.GOOS != "windows" && pathExists("/tmp") && !writeRootsContain(profile.FileSystem.WriteRoots, "/tmp") {
t.Fatalf("write roots = %#v, want /tmp", profile.FileSystem.WriteRoots)
}
@@ -132,6 +132,64 @@ exit 0
}
}
// TestWindowsUnelevatedRealSandboxSmoke exercises the unelevated tier end to
// end WITHOUT running the elevated setup helper: the command runner applies
// the workspace ACLs itself, a write inside the workspace succeeds under the
// restricted token, and a write outside every granted root is denied. Unlike
// the elevated smoke above it needs no Administrator terminal.
func TestWindowsUnelevatedRealSandboxSmoke(t *testing.T) {
if os.Getenv("ZERO_SANDBOX_REAL_SMOKE") != "1" {
t.Skip("set ZERO_SANDBOX_REAL_SMOKE=1 to run real Windows sandbox smoke tests")
}
runnerExe := realSmokeExecutable(t, "ZERO_WINDOWS_COMMAND_RUNNER_EXE", WindowsSandboxCommandRunnerName)
root := t.TempDir()
outside := t.TempDir()
sandboxHome := filepath.Join(root, ".zero-sandbox")
profile := PermissionProfile{
FileSystem: FileSystemPolicy{
Kind: FileSystemRestricted,
ReadRoots: []string{root},
WriteRoots: []WritableRoot{{Root: root, ProtectedMetadataNames: []string{".git", ".zero", ".agents"}}},
IncludePlatformRoots: true,
AllowTemp: true,
},
Network: NetworkPolicy{Mode: NetworkDeny},
}
config := WindowsSandboxCommandArgsOptions{
SandboxHome: sandboxHome,
CommandCWD: root,
WorkspaceRoots: []string{root},
PermissionProfile: profile,
SandboxLevel: WindowsSandboxLevelUnelevated,
}
// cmd.exe rather than powershell.exe: managed PowerShell cannot initialize
// its crypto provider under a write-restricted token on some hosts
// (0x8009001d, the same restricted-token limitation the runner documents for
// Schannel), and the write-jail assertion only needs a native shell.
insideMarker := filepath.Join(root, "unelevated-write-ok.txt")
runWindowsRealSmokeCommand(t, runnerExe, config, []string{
"cmd.exe", "/d", "/s", "/c", "echo ok>" + insideMarker,
}, 0)
if bytes, err := os.ReadFile(insideMarker); err != nil || strings.TrimSpace(string(bytes)) != "ok" {
t.Fatalf("unelevated sandboxed write marker = %q, %v; want ok", bytes, err)
}
if _, err := os.Stat(WindowsUnelevatedSetupMarkerPath(sandboxHome)); err != nil {
t.Fatalf("expected the unelevated setup marker to be recorded: %v", err)
}
outsideMarker := filepath.Join(outside, "unelevated-write-denied.txt")
runWindowsRealSmokeCommand(t, runnerExe, config, []string{
"cmd.exe", "/d", "/s", "/c", "echo leaked>" + outsideMarker,
}, 1)
if _, err := os.Stat(outsideMarker); err == nil {
t.Fatalf("unelevated sandbox allowed a write outside every granted root")
} else if !os.IsNotExist(err) {
t.Fatalf("stat outside marker: %v", err)
}
}
func realSmokeExecutable(t *testing.T, envKey string, fallbackName string) string {
t.Helper()
if path := os.Getenv(envKey); path != "" {
+3
View File
@@ -129,6 +129,9 @@ func TestNewScopeNormalizesAndValidatesExtraRoots(t *testing.T) {
if !stringSliceContains(roots, normalizeWorkspaceRootBestEffort(extra)) {
t.Fatalf("Roots()=%v want extra root %q", roots, normalizeWorkspaceRootBestEffort(extra))
}
// /tmp is a default temp write root on POSIX only (see
// defaultTempWriteRootCandidatesForGOOS); on Windows the bare path resolves
// against the current drive, so a stray C:\tmp must not turn this on.
if runtime.GOOS != "windows" && pathExists("/tmp") && !stringSliceContains(roots, normalizeWorkspaceRootBestEffort("/tmp")) {
t.Fatalf("Roots()=%v want default /tmp write root", roots)
}
+10 -3
View File
@@ -143,9 +143,16 @@ const (
)
const (
EnforcementNative EnforcementLevel = "native"
EnforcementDegraded EnforcementLevel = "degraded"
EnforcementDisabled EnforcementLevel = "disabled"
EnforcementNative EnforcementLevel = "native"
// EnforcementUnelevated is the Windows-only middle tier used when the
// elevated `zero sandbox setup` has not run: commands still wrap through the
// command runner with a write-restricted token and workspace ACLs (which the
// runner can apply without Administrator rights), but the WFP network
// filters are absent, so network isolation stays with the in-process
// approval gate.
EnforcementUnelevated EnforcementLevel = "unelevated"
EnforcementDegraded EnforcementLevel = "degraded"
EnforcementDisabled EnforcementLevel = "disabled"
)
type Policy struct {
@@ -8,14 +8,21 @@ import (
)
func runWindowsSandboxCommand(config WindowsSandboxCommandConfig, stderr io.Writer) int {
if config.SandboxLevel != WindowsSandboxLevelRestrictedToken {
switch config.SandboxLevel {
case WindowsSandboxLevelRestrictedToken:
if err := ValidateWindowsSandboxSetupMarker(WindowsSandboxSetupConfigFromCommand(config)); err != nil {
fmt.Fprintln(stderr, WindowsSandboxCommandRunnerName+": "+err.Error())
return 1
}
case WindowsSandboxLevelUnelevated:
if err := ensureWindowsUnelevatedSetup(config); err != nil {
fmt.Fprintln(stderr, WindowsSandboxCommandRunnerName+": "+err.Error())
return 1
}
default:
fmt.Fprintf(stderr, "%s: unsupported Windows sandbox level %q\n", WindowsSandboxCommandRunnerName, config.SandboxLevel)
return 1
}
if err := ValidateWindowsSandboxSetupMarker(WindowsSandboxSetupConfigFromCommand(config)); err != nil {
fmt.Fprintln(stderr, WindowsSandboxCommandRunnerName+": "+err.Error())
return 1
}
if err := ValidateWindowsNetworkPolicy(config.PermissionProfile.Network); err != nil {
fmt.Fprintln(stderr, WindowsSandboxCommandRunnerName+": "+err.Error())
return 1
@@ -57,3 +64,32 @@ func runWindowsSandboxCommand(config WindowsSandboxCommandConfig, stderr io.Writ
}
return exitCode
}
// ensureWindowsUnelevatedSetup applies the workspace ACL plan from the current
// (non-elevated) process so the write-restricted token has somewhere its
// capability SIDs are granted. DACL edits on user-owned workspace and temp
// roots need no Administrator rights; the WFP network filters DO, so this tier
// provisions no network enforcement — the offline-marker SID composed into the
// token stays inert until an elevated `zero sandbox setup` installs the block
// filters. Applied plans are recorded by hash so repeat commands skip the
// re-apply; like the elevated setup, grants are left in place (the rollback is
// deliberately discarded) because they only name synthetic capability SIDs
// that no other token carries.
func ensureWindowsUnelevatedSetup(config WindowsSandboxCommandConfig) error {
applied, plan, err := buildWindowsUnelevatedAppliedPlan(config)
if err != nil {
return err
}
marker, err := loadWindowsUnelevatedSetupMarker(config.SandboxHome)
if err != nil {
return err
}
if marker.contains(applied) {
return nil
}
if _, err := applyWindowsACLPlan(plan); err != nil {
return fmt.Errorf("apply unelevated workspace ACLs: %w — the workspace may be on a filesystem the current user does not own; "+
"run `zero sandbox setup` from an elevated (Administrator) terminal, or re-run with `--sandbox forbid` to skip OS sandboxing", err)
}
return recordWindowsUnelevatedAppliedPlan(config.SandboxHome, applied)
}
+66 -24
View File
@@ -2,14 +2,16 @@ package sandbox
import (
"errors"
"strings"
"testing"
)
// On Windows, a not-yet-set-up sandbox must DEGRADE (run unwrapped with a
// downgrade reason) on the default preference — never brick the command — while
// a strict --sandbox require still errors with a setup hint. Once the marker
// exists, enforcement is native and the command wraps.
func TestWindowsDegradesWhenSandboxNotInitialized(t *testing.T) {
// On Windows, a not-yet-set-up sandbox must fall back to the UNELEVATED tier
// (wrapped through the runner with the write-restricted token, network left to
// the approval gate) on the default preference — never brick the command —
// while a strict --sandbox require still errors with a setup hint. Once the
// marker exists, enforcement is native and the command wraps.
func TestWindowsFallsBackUnelevatedWhenSandboxNotInitialized(t *testing.T) {
restore := windowsSandboxInitialized
t.Cleanup(func() { windowsSandboxInitialized = restore })
@@ -24,13 +26,70 @@ func TestWindowsDegradesWhenSandboxNotInitialized(t *testing.T) {
ValidateExecution: true,
}
// Marker missing + auto -> degrade (unwrapped, with a reason), no error.
// Marker missing + auto -> unelevated tier: still wrapped, with a reason
// documenting the missing network enforcement, no error.
windowsSandboxInitialized = func() bool { return false }
auto := base
auto.Preference = SandboxPreferenceAuto
req, err := mgr.BuildExecutionRequest(auto)
if err != nil {
t.Fatalf("auto must degrade, not error: %v", err)
t.Fatalf("auto must fall back, not error: %v", err)
}
if req.EnforcementLevel != EnforcementUnelevated {
t.Fatalf("enforcement = %v, want unelevated", req.EnforcementLevel)
}
if !req.CommandWrapped {
t.Fatal("unelevated command must still be wrapped through the sandbox runner")
}
if req.DowngradeReason == "" || !strings.Contains(req.DowngradeReason, "network") {
t.Fatalf("downgrade reason = %q, want an explanation of the missing network enforcement", req.DowngradeReason)
}
// Marker missing + require -> hard error pointing at setup.
require := base
require.Preference = SandboxPreferenceRequire
if _, err := mgr.BuildExecutionRequest(require); !errors.Is(err, errWindowsSandboxNotInitialized) {
t.Fatalf("require must error with the setup hint, got %v", err)
}
// Marker present -> native, wrapped, no downgrade reason.
windowsSandboxInitialized = func() bool { return true }
req, err = mgr.BuildExecutionRequest(auto)
if err != nil {
t.Fatalf("initialized auto: %v", err)
}
if req.EnforcementLevel != EnforcementNative || !req.CommandWrapped {
t.Fatalf("initialized -> native wrapped, got enforcement=%v wrapped=%v", req.EnforcementLevel, req.CommandWrapped)
}
if req.DowngradeReason != "" {
t.Fatalf("native downgrade reason = %q, want empty", req.DowngradeReason)
}
}
// A profile that needs the platform sandbox ONLY for network isolation gains
// nothing from the unelevated tier (which cannot enforce network), so it must
// keep the old degrade-to-policy-gate behavior.
func TestWindowsNetworkOnlyProfileStillDegradesWhenNotInitialized(t *testing.T) {
restore := windowsSandboxInitialized
t.Cleanup(func() { windowsSandboxInitialized = restore })
windowsSandboxInitialized = func() bool { return false }
mgr := NewSandboxManager(SandboxManagerOptions{
GOOS: "windows",
Backend: Backend{Name: BackendWindowsRestrictedToken, Available: true, Executable: "zero.exe", Platform: "windows"},
})
req, err := mgr.BuildExecutionRequest(SandboxManagerRequest{
WorkspaceRoot: `C:\ws`,
Command: CommandSpec{Name: "bash", Args: []string{"-c", "echo hi"}},
Policy: DefaultPolicy(),
Profile: PermissionProfile{
FileSystem: FileSystemPolicy{Kind: FileSystemUnrestricted, IncludePlatformRoots: true, AllowTemp: true},
Network: NetworkPolicy{Mode: NetworkDeny},
},
Preference: SandboxPreferenceAuto,
})
if err != nil {
t.Fatalf("network-only profile must degrade, not error: %v", err)
}
if req.EnforcementLevel != EnforcementDegraded {
t.Fatalf("enforcement = %v, want degraded", req.EnforcementLevel)
@@ -41,21 +100,4 @@ func TestWindowsDegradesWhenSandboxNotInitialized(t *testing.T) {
if req.DowngradeReason == "" {
t.Fatal("expected a downgrade reason explaining the missing setup")
}
// Marker missing + require -> hard error pointing at setup.
require := base
require.Preference = SandboxPreferenceRequire
if _, err := mgr.BuildExecutionRequest(require); !errors.Is(err, errWindowsSandboxNotInitialized) {
t.Fatalf("require must error with the setup hint, got %v", err)
}
// Marker present -> native, wrapped.
windowsSandboxInitialized = func() bool { return true }
req, err = mgr.BuildExecutionRequest(auto)
if err != nil {
t.Fatalf("initialized auto: %v", err)
}
if req.EnforcementLevel != EnforcementNative || !req.CommandWrapped {
t.Fatalf("initialized -> native wrapped, got enforcement=%v wrapped=%v", req.EnforcementLevel, req.CommandWrapped)
}
}
+17 -4
View File
@@ -95,8 +95,14 @@ type WindowsSandboxLevel string
const (
WindowsSandboxLevelRestrictedToken WindowsSandboxLevel = "restricted-token"
WindowsSandboxLevelElevated WindowsSandboxLevel = "elevated"
WindowsSandboxLevelDisabled WindowsSandboxLevel = "disabled"
// WindowsSandboxLevelUnelevated is the restricted-token tier without the
// elevated setup: the runner applies the workspace ACL plan itself before
// launching (no Administrator rights needed) and skips the elevated setup
// marker check. Network is NOT enforced at this level — the WFP filters need
// the elevated setup — so callers must keep network behind the approval gate.
WindowsSandboxLevelUnelevated WindowsSandboxLevel = "unelevated"
WindowsSandboxLevelElevated WindowsSandboxLevel = "elevated"
WindowsSandboxLevelDisabled WindowsSandboxLevel = "disabled"
)
type WindowsSandboxCommandArgsOptions struct {
@@ -271,13 +277,20 @@ func windowsRestrictedTokenCommandPlan(execRequest SandboxExecutionRequest, poli
return CommandPlan{}, err
}
childEnv := windowsSandboxChildEnv(spec.Env, policy, execRequest.WorkspaceRoot)
// The unelevated enforcement tier maps to the runner's unelevated level: same
// restricted token, but the runner applies the workspace ACLs itself instead
// of requiring the elevated setup marker.
level := WindowsSandboxLevelRestrictedToken
if execRequest.EnforcementLevel == EnforcementUnelevated {
level = WindowsSandboxLevelUnelevated
}
args, err := BuildWindowsSandboxCommandArgs(WindowsSandboxCommandArgsOptions{
SandboxHome: sandboxHome,
CommandCWD: spec.Dir,
WorkspaceRoots: []string{execRequest.WorkspaceRoot},
PermissionProfile: execRequest.PermissionProfile,
Env: childEnv,
SandboxLevel: WindowsSandboxLevelRestrictedToken,
SandboxLevel: level,
Command: append([]string{spec.Name}, spec.Args...),
})
if err != nil {
@@ -381,7 +394,7 @@ func nextWindowsSandboxFlagValue(args []string, index int) (string, int, error)
func validWindowsSandboxLevel(level WindowsSandboxLevel) bool {
switch level {
case WindowsSandboxLevelRestrictedToken, WindowsSandboxLevelElevated, WindowsSandboxLevelDisabled:
case WindowsSandboxLevelRestrictedToken, WindowsSandboxLevelUnelevated, WindowsSandboxLevelElevated, WindowsSandboxLevelDisabled:
return true
default:
return false
+146
View File
@@ -0,0 +1,146 @@
package sandbox
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
)
const windowsUnelevatedSetupMarkerSchemaVersion = 1
// windowsUnelevatedSetupMarkerMaxPlans bounds the applied-plan history so the
// marker cannot grow without limit when a user hops between many workspaces.
// Oldest entries are dropped first; re-applying an already-granted plan is
// harmless, so eviction only costs a redundant ACL apply.
const windowsUnelevatedSetupMarkerMaxPlans = 64
// WindowsUnelevatedAppliedPlan fingerprints one workspace ACL plan the
// unelevated tier has applied. Unlike the elevated setup marker it carries no
// network infrastructure hash: the unelevated tier never provisions WFP
// filters, so there is nothing network-side to fingerprint.
type WindowsUnelevatedAppliedPlan struct {
ACLPlanHash string `json:"aclPlanHash"`
ACLPlanEntries int `json:"aclPlanEntries"`
}
// WindowsUnelevatedSetupMarker records which workspace ACL plans the command
// runner has applied without elevation, keyed by plan hash, so repeat commands
// in a known workspace skip the re-apply. One file serves every workspace under
// a sandbox home (the elevated marker is instead a single-plan snapshot,
// because its WFP half genuinely is machine-global).
type WindowsUnelevatedSetupMarker struct {
SchemaVersion int `json:"schemaVersion"`
AppliedPlans []WindowsUnelevatedAppliedPlan `json:"appliedPlans,omitempty"`
}
func WindowsUnelevatedSetupMarkerPath(sandboxHome string) string {
return filepath.Join(filepath.Clean(sandboxHome), "windows-unelevated-setup.json")
}
// buildWindowsUnelevatedAppliedPlan derives the ACL plan for the command's
// permission profile plus its marker fingerprint. The same BuildWindowsACLPlan
// output later feeds the apply step, so the fingerprint and the applied grants
// can never drift apart.
func buildWindowsUnelevatedAppliedPlan(config WindowsSandboxCommandConfig) (WindowsUnelevatedAppliedPlan, WindowsACLPlan, error) {
plan, err := BuildWindowsACLPlan(config)
if err != nil {
return WindowsUnelevatedAppliedPlan{}, WindowsACLPlan{}, err
}
hash, err := WindowsACLPlanHash(plan)
if err != nil {
return WindowsUnelevatedAppliedPlan{}, WindowsACLPlan{}, err
}
return WindowsUnelevatedAppliedPlan{
ACLPlanHash: hash,
ACLPlanEntries: len(plan.Entries),
}, plan, nil
}
// loadWindowsUnelevatedSetupMarker reads the marker; a missing file is the
// common first-run state and returns an empty marker, not an error. A marker
// with an unexpected schema version or a corrupt/truncated body is likewise
// treated as empty so the runner re-applies (and rewrites) rather than failing:
// the marker only memoizes idempotent ACL applies, so resetting it can only
// cost redundant work, never skip enforcement.
func loadWindowsUnelevatedSetupMarker(sandboxHome string) (WindowsUnelevatedSetupMarker, error) {
sandboxHome = strings.TrimSpace(sandboxHome)
if sandboxHome == "" {
return WindowsUnelevatedSetupMarker{}, errors.New("windows sandbox home is required")
}
path := WindowsUnelevatedSetupMarkerPath(sandboxHome)
bytes, err := os.ReadFile(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return WindowsUnelevatedSetupMarker{SchemaVersion: windowsUnelevatedSetupMarkerSchemaVersion}, nil
}
return WindowsUnelevatedSetupMarker{}, fmt.Errorf("read windows unelevated setup marker: %w", err)
}
var marker WindowsUnelevatedSetupMarker
if err := json.Unmarshal(bytes, &marker); err != nil {
return WindowsUnelevatedSetupMarker{SchemaVersion: windowsUnelevatedSetupMarkerSchemaVersion}, nil
}
if marker.SchemaVersion != windowsUnelevatedSetupMarkerSchemaVersion {
return WindowsUnelevatedSetupMarker{SchemaVersion: windowsUnelevatedSetupMarkerSchemaVersion}, nil
}
return marker, nil
}
func (marker WindowsUnelevatedSetupMarker) contains(applied WindowsUnelevatedAppliedPlan) bool {
if strings.TrimSpace(applied.ACLPlanHash) == "" {
return false
}
for _, plan := range marker.AppliedPlans {
if plan.ACLPlanHash == applied.ACLPlanHash && plan.ACLPlanEntries == applied.ACLPlanEntries {
return true
}
}
return false
}
// recordWindowsUnelevatedAppliedPlan appends the plan to the marker (dropping
// the oldest entries past the cap) and writes it atomically, following the
// temp-file-then-rename pattern of the sibling marker writers.
func recordWindowsUnelevatedAppliedPlan(sandboxHome string, applied WindowsUnelevatedAppliedPlan) error {
marker, err := loadWindowsUnelevatedSetupMarker(sandboxHome)
if err != nil {
return err
}
if marker.contains(applied) {
return nil
}
marker.SchemaVersion = windowsUnelevatedSetupMarkerSchemaVersion
marker.AppliedPlans = append(marker.AppliedPlans, applied)
if overflow := len(marker.AppliedPlans) - windowsUnelevatedSetupMarkerMaxPlans; overflow > 0 {
marker.AppliedPlans = append([]WindowsUnelevatedAppliedPlan(nil), marker.AppliedPlans[overflow:]...)
}
path := WindowsUnelevatedSetupMarkerPath(sandboxHome)
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return fmt.Errorf("create windows unelevated setup marker dir: %w", err)
}
bytes, err := json.MarshalIndent(marker, "", " ")
if err != nil {
return fmt.Errorf("marshal windows unelevated setup marker: %w", err)
}
tmp, err := os.CreateTemp(filepath.Dir(path), ".windows-unelevated-setup-*.tmp")
if err != nil {
return fmt.Errorf("create windows unelevated setup marker temp file: %w", err)
}
tmpPath := tmp.Name()
if _, err := tmp.Write(bytes); err != nil {
_ = tmp.Close()
_ = os.Remove(tmpPath)
return fmt.Errorf("write windows unelevated setup marker temp file: %w", err)
}
if err := tmp.Close(); err != nil {
_ = os.Remove(tmpPath)
return fmt.Errorf("close windows unelevated setup marker temp file: %w", err)
}
if err := os.Rename(tmpPath, path); err != nil {
_ = os.Remove(tmpPath)
return fmt.Errorf("replace windows unelevated setup marker: %w", err)
}
return nil
}
+189
View File
@@ -0,0 +1,189 @@
package sandbox
import (
"fmt"
"os"
"path/filepath"
"testing"
)
func testWindowsUnelevatedCommandConfig(t *testing.T) WindowsSandboxCommandConfig {
t.Helper()
root := t.TempDir()
return WindowsSandboxCommandConfig{
SandboxHome: filepath.Join(root, ".zero-sandbox"),
CommandCWD: root,
WorkspaceRoots: []string{root},
PermissionProfile: PermissionProfile{
FileSystem: FileSystemPolicy{
Kind: FileSystemRestricted,
ReadRoots: []string{root},
WriteRoots: []WritableRoot{{Root: root, ProtectedMetadataNames: []string{".git", ".zero", ".agents"}}},
AllowTemp: true,
},
Network: NetworkPolicy{Mode: NetworkDeny},
},
SandboxLevel: WindowsSandboxLevelUnelevated,
Command: []string{"cmd.exe", "/c", "echo hi"},
}
}
func TestWindowsSandboxCommandArgsRoundTripUnelevatedLevel(t *testing.T) {
config := testWindowsUnelevatedCommandConfig(t)
args, err := BuildWindowsSandboxCommandArgs(WindowsSandboxCommandArgsOptions{
SandboxHome: config.SandboxHome,
CommandCWD: config.CommandCWD,
WorkspaceRoots: config.WorkspaceRoots,
PermissionProfile: config.PermissionProfile,
SandboxLevel: WindowsSandboxLevelUnelevated,
Command: config.Command,
})
if err != nil {
t.Fatalf("BuildWindowsSandboxCommandArgs: %v", err)
}
parsed, err := ParseWindowsSandboxCommandArgs(args)
if err != nil {
t.Fatalf("ParseWindowsSandboxCommandArgs: %v", err)
}
if parsed.SandboxLevel != WindowsSandboxLevelUnelevated {
t.Fatalf("round-tripped sandbox level = %q, want %q", parsed.SandboxLevel, WindowsSandboxLevelUnelevated)
}
}
func TestBuildWindowsUnelevatedAppliedPlanFingerprint(t *testing.T) {
config := testWindowsUnelevatedCommandConfig(t)
applied, plan, err := buildWindowsUnelevatedAppliedPlan(config)
if err != nil {
t.Fatalf("buildWindowsUnelevatedAppliedPlan: %v", err)
}
if applied.ACLPlanHash == "" || applied.ACLPlanEntries == 0 || applied.ACLPlanEntries != len(plan.Entries) {
t.Fatalf("applied plan = %+v with %d plan entries, want a non-empty consistent fingerprint", applied, len(plan.Entries))
}
// Same config -> same fingerprint (the marker's dedupe key).
again, _, err := buildWindowsUnelevatedAppliedPlan(config)
if err != nil {
t.Fatalf("rebuild: %v", err)
}
if again != applied {
t.Fatalf("fingerprint not stable: %+v then %+v", applied, again)
}
// An unrestricted profile has no ACL plan to apply and must error.
config.PermissionProfile.FileSystem.Kind = FileSystemUnrestricted
if _, _, err := buildWindowsUnelevatedAppliedPlan(config); err == nil {
t.Fatal("expected an error for an unrestricted filesystem profile")
}
}
func TestWindowsUnelevatedSetupMarkerRecordAndLoad(t *testing.T) {
config := testWindowsUnelevatedCommandConfig(t)
applied, _, err := buildWindowsUnelevatedAppliedPlan(config)
if err != nil {
t.Fatalf("buildWindowsUnelevatedAppliedPlan: %v", err)
}
// Missing marker file is the fresh-install state: empty, no error, no match.
marker, err := loadWindowsUnelevatedSetupMarker(config.SandboxHome)
if err != nil {
t.Fatalf("load missing marker: %v", err)
}
if marker.contains(applied) {
t.Fatal("empty marker must not contain the plan")
}
if err := recordWindowsUnelevatedAppliedPlan(config.SandboxHome, applied); err != nil {
t.Fatalf("record: %v", err)
}
marker, err = loadWindowsUnelevatedSetupMarker(config.SandboxHome)
if err != nil {
t.Fatalf("load recorded marker: %v", err)
}
if !marker.contains(applied) || len(marker.AppliedPlans) != 1 {
t.Fatalf("marker = %+v, want exactly the recorded plan", marker)
}
// Recording the same plan again must not duplicate it.
if err := recordWindowsUnelevatedAppliedPlan(config.SandboxHome, applied); err != nil {
t.Fatalf("re-record: %v", err)
}
marker, err = loadWindowsUnelevatedSetupMarker(config.SandboxHome)
if err != nil {
t.Fatalf("reload marker: %v", err)
}
if len(marker.AppliedPlans) != 1 {
t.Fatalf("marker has %d plans after duplicate record, want 1", len(marker.AppliedPlans))
}
}
func TestWindowsUnelevatedSetupMarkerEvictsOldestPastCap(t *testing.T) {
home := filepath.Join(t.TempDir(), ".zero-sandbox")
first := WindowsUnelevatedAppliedPlan{ACLPlanHash: "hash-0", ACLPlanEntries: 1}
if err := recordWindowsUnelevatedAppliedPlan(home, first); err != nil {
t.Fatalf("record first: %v", err)
}
for index := 1; index <= windowsUnelevatedSetupMarkerMaxPlans; index++ {
plan := WindowsUnelevatedAppliedPlan{ACLPlanHash: fmt.Sprintf("hash-%d", index), ACLPlanEntries: index}
if err := recordWindowsUnelevatedAppliedPlan(home, plan); err != nil {
t.Fatalf("record %d: %v", index, err)
}
}
marker, err := loadWindowsUnelevatedSetupMarker(home)
if err != nil {
t.Fatalf("load: %v", err)
}
if len(marker.AppliedPlans) != windowsUnelevatedSetupMarkerMaxPlans {
t.Fatalf("marker has %d plans, want the cap of %d", len(marker.AppliedPlans), windowsUnelevatedSetupMarkerMaxPlans)
}
if marker.contains(first) {
t.Fatal("oldest plan should have been evicted past the cap")
}
}
func TestLoadWindowsUnelevatedSetupMarkerSelfHealsCorruptFile(t *testing.T) {
home := filepath.Join(t.TempDir(), ".zero-sandbox")
if err := os.MkdirAll(home, 0o700); err != nil {
t.Fatalf("mkdir sandbox home: %v", err)
}
path := WindowsUnelevatedSetupMarkerPath(home)
// A truncated body must reset like an unknown schema, not brick every
// unelevated command until the file is hand-deleted.
if err := os.WriteFile(path, []byte(`{"schemaVersion":1,"appliedPlans":[{"aclPlanHash":"h`), 0o600); err != nil {
t.Fatalf("write corrupt marker: %v", err)
}
marker, err := loadWindowsUnelevatedSetupMarker(home)
if err != nil {
t.Fatalf("corrupt marker must self-heal, got error: %v", err)
}
if len(marker.AppliedPlans) != 0 || marker.SchemaVersion != windowsUnelevatedSetupMarkerSchemaVersion {
t.Fatalf("corrupt marker must reset to empty current-schema marker, got %+v", marker)
}
// The healed marker must accept new recordings over the corrupt file.
applied := WindowsUnelevatedAppliedPlan{ACLPlanHash: "hash-heal", ACLPlanEntries: 2}
if err := recordWindowsUnelevatedAppliedPlan(home, applied); err != nil {
t.Fatalf("record over corrupt marker: %v", err)
}
marker, err = loadWindowsUnelevatedSetupMarker(home)
if err != nil {
t.Fatalf("reload healed marker: %v", err)
}
if !marker.contains(applied) {
t.Fatalf("healed marker = %+v, want the recorded plan", marker)
}
}
func TestLoadWindowsUnelevatedSetupMarkerResetsUnknownSchema(t *testing.T) {
home := filepath.Join(t.TempDir(), ".zero-sandbox")
if err := os.MkdirAll(home, 0o700); err != nil {
t.Fatalf("mkdir sandbox home: %v", err)
}
path := WindowsUnelevatedSetupMarkerPath(home)
if err := os.WriteFile(path, []byte(`{"schemaVersion":99,"appliedPlans":[{"aclPlanHash":"h","aclPlanEntries":1}]}`), 0o600); err != nil {
t.Fatalf("write marker: %v", err)
}
marker, err := loadWindowsUnelevatedSetupMarker(home)
if err != nil {
t.Fatalf("load: %v", err)
}
if len(marker.AppliedPlans) != 0 || marker.SchemaVersion != windowsUnelevatedSetupMarkerSchemaVersion {
t.Fatalf("unknown schema must reset to empty current-schema marker, got %+v", marker)
}
}
+20 -4
View File
@@ -160,10 +160,7 @@ func FormatExecPrompt(prompt string, prepared PreparedExec) string {
if prepared.Mode == ModeNew || len(prepared.ContextEvents) == 0 {
return prompt
}
events := prepared.ContextEvents
if len(events) > 20 {
events = events[len(events)-20:]
}
events := promptContextEvents(prepared.ContextEvents)
lines := []string{}
for _, event := range events {
@@ -187,6 +184,25 @@ func FormatExecPrompt(prompt string, prepared PreparedExec) string {
}, "\n")
}
func promptContextEvents(events []Event) []Event {
const maxPromptContextEvents = 80
filtered := make([]Event, 0, len(events))
for _, event := range events {
switch event.Type {
case EventMessage, EventCompaction, EventSessionFork, EventSessionChild, EventSpecialistStart, EventSpecialistStop, EventError:
filtered = append(filtered, event)
}
}
if len(filtered) == 0 {
filtered = append(filtered, events...)
}
if len(filtered) > maxPromptContextEvents {
filtered = filtered[len(filtered)-maxPromptContextEvents:]
}
return filtered
}
func forkTitle(title string) string {
if title == "" {
return ""
+1 -1
View File
@@ -71,7 +71,7 @@ type CompactionPayload struct {
Truncated bool `json:"truncated,omitempty"`
}
const defaultCompactionPreserveLast = 8
const defaultCompactionPreserveLast = 6
const defaultCompactionMaxPromptChars = 8000
func (store *Store) PlanRewind(sessionID string, options RewindOptions) (RewindPlan, error) {
+85
View File
@@ -555,6 +555,91 @@ func TestPrepareExecSessionResolvesResumeAndFork(t *testing.T) {
}
}
func TestFormatExecPromptKeepsConversationMessagesWhenNoisyEventsFollow(t *testing.T) {
events := []Event{
{Sequence: 1, Type: EventMessage, Payload: json.RawMessage(`{"role":"user","content":"first user request"}`)},
{Sequence: 2, Type: EventMessage, Payload: json.RawMessage(`{"role":"assistant","content":"first assistant answer"}`)},
}
for sequence := 3; sequence <= 45; sequence++ {
events = append(events, Event{Sequence: sequence, Type: EventToolResult, Payload: json.RawMessage(`{"name":"read_file","output":"noisy tool result"}`)})
}
events = append(events, Event{Sequence: 46, Type: EventMessage, Payload: json.RawMessage(`{"role":"user","content":"latest user request"}`)})
prompt := FormatExecPrompt("continue", PreparedExec{
Mode: ModeResume,
Session: Metadata{SessionID: "session-with-noise"},
ContextEvents: events,
})
for _, want := range []string{"first user request", "first assistant answer", "latest user request", "Current user request:", "continue"} {
if !strings.Contains(prompt, want) {
t.Fatalf("expected prompt to contain %q, got %q", want, prompt)
}
}
if strings.Contains(prompt, "noisy tool result") {
t.Fatalf("expected prompt to omit noisy non-conversation events, got %q", prompt)
}
}
func TestFormatExecPromptTruncatesConversationMessagesAfterFilteringNoise(t *testing.T) {
events := make([]Event, 0, 100)
for sequence := 1; sequence <= 85; sequence++ {
events = append(events, Event{
Sequence: sequence,
Type: EventMessage,
Payload: json.RawMessage(fmt.Sprintf(`{"role":"user","content":"conversation message %03d"}`, sequence)),
})
if sequence%10 == 0 {
events = append(events, Event{
Sequence: 1000 + sequence,
Type: EventToolResult,
Payload: json.RawMessage(fmt.Sprintf(`{"name":"read_file","output":"noisy tool result %03d"}`, sequence)),
})
}
}
events = append(events, Event{
Sequence: 2000,
Type: EventToolResult,
Payload: json.RawMessage(`{"name":"bash","output":"trailing noisy tool result"}`),
})
contextEvents := promptContextEvents(events)
if len(contextEvents) != 80 {
t.Fatalf("expected prompt context to keep 80 events, got %d", len(contextEvents))
}
if contextEvents[0].Sequence != 6 || contextEvents[len(contextEvents)-1].Sequence != 85 {
t.Fatalf("expected prompt context to keep message sequences 6-85, got first=%d last=%d", contextEvents[0].Sequence, contextEvents[len(contextEvents)-1].Sequence)
}
prompt := FormatExecPrompt("continue", PreparedExec{
Mode: ModeResume,
Session: Metadata{SessionID: "long-session-with-noise"},
ContextEvents: events,
})
if got, want := strings.Count(prompt, "- #"), len(contextEvents); got != want {
t.Fatalf("expected prompt to contain %d context event lines, got %d in %q", want, got, prompt)
}
for _, want := range []string{"conversation message 006", "conversation message 085", "Current user request:", "continue"} {
if !strings.Contains(prompt, want) {
t.Fatalf("expected prompt to contain %q, got %q", want, prompt)
}
}
for _, unwanted := range []string{
"conversation message 001",
"conversation message 002",
"conversation message 003",
"conversation message 004",
"conversation message 005",
"noisy tool result",
"trailing noisy tool result",
} {
if strings.Contains(prompt, unwanted) {
t.Fatalf("expected prompt to omit %q, got %q", unwanted, prompt)
}
}
}
func TestPrepareExecPersistsSpecialistMetadataForNewSession(t *testing.T) {
store := NewStore(StoreOptions{RootDir: t.TempDir(), Now: fixedClock("2026-06-04T14:30:00Z")})
+29
View File
@@ -161,6 +161,35 @@ func TestDetectShellCommandIssueRequiresActualLSCommand(t *testing.T) {
}
}
func TestDetectShellCommandIssueFlagsPipedPosixUtilities(t *testing.T) {
for _, command := range []string{
`git log --oneline -- pinokio_compat.py | head`,
`git blame -L 40,46 pinokio_compat.py --format=%author | grep summary`,
`cat file.txt | wc -l`,
`some_command | tail -n 20`,
} {
if issue := detectShellCommandIssue(command, "windows"); issue == nil {
t.Fatalf("expected POSIX utility pipeline to be flagged for %q", command)
}
}
}
func TestDetectShellCommandIssueAllowsUnrelatedCommands(t *testing.T) {
for _, command := range []string{
`git log --oneline`,
`dir /b`,
`findstr /r "summary" file.txt`,
`echo header text`,
`tail-log.sh`,
`grep-cli --version`,
`sed.exe --help`,
} {
if issue := detectShellCommandIssue(command, "windows"); issue != nil {
t.Fatalf("expected unrelated command to pass for %q, got %#v", command, issue)
}
}
}
func TestDetectShellOutputIssueAddsWindowsSyntaxHint(t *testing.T) {
issue := detectShellOutputIssue(`cd /d/tmp/zero-pr-158 && ls -la`, "The syntax of the command is incorrect.", "windows")
if issue == nil {
+12
View File
@@ -20,6 +20,11 @@ type shellIssue struct {
var (
windowsBashStyleCDPattern = regexp.MustCompile(`(?i)(^|[&|;]\s*)cd\s+/(?:[a-ce-z0-9_./~-]|d[a-z0-9_./~-])[a-z0-9_./~-]*`)
windowsLSCommandPattern = regexp.MustCompile(`(?i)(^|[&|;]\s*)ls\b(?:\s+|$)`)
// windowsPosixUtilityPattern catches the other common POSIX coreutils/grep
// family commands cmd.exe has no equivalent for (unlike `dir`/`findstr`,
// which sound similar enough that a model might reach for the POSIX name
// instead) — most often seen piped in, e.g. `git log ... | head`.
windowsPosixUtilityPattern = regexp.MustCompile(`(?i)(^|[&|;]\s*)(head|tail|grep|wc|awk|sed|cut|xargs|tr)(?:\s+|$)`)
)
func detectShellRuntime(goos string) shellRuntime {
@@ -57,6 +62,13 @@ func detectShellCommandIssue(command string, goos string) *shellIssue {
Suggestion: "Use the cwd argument instead of cd, use Windows cmd.exe syntax, or use native tools such as list_directory, read_file, grep, and glob.",
}
}
if windowsPosixUtilityPattern.MatchString(trimmed) {
return &shellIssue{
Kind: "windows_shell_syntax",
Message: "Command uses a POSIX utility (head/tail/grep/wc/awk/sed/cut/xargs/tr) that Windows cmd.exe does not have.",
Suggestion: "Use cmd.exe equivalents (e.g. `findstr` for grep, `more` to page output) or Zero's native tools (grep, read_file with offset/limit) instead of piping to a POSIX utility.",
}
}
return nil
}
+291
View File
@@ -0,0 +1,291 @@
package tui
import (
"testing"
tea "charm.land/bubbletea/v2"
"github.com/Gitlawb/zero/internal/config"
)
func TestParseBindingLabel(t *testing.T) {
tests := []struct {
input string
want string // expected Label()
}{
{"option+o", "Alt+O"},
{"ctrl+o", "Ctrl+O"},
{"ctrl+e", "Ctrl+E"},
{"option+b", "Alt+B"},
{"option+O", "Alt+O"},
{"alt+o", "Alt+O"},
{"cmd+o", "Cmd+O"},
{"super+o", "Cmd+O"},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
p := parseBinding(tt.input)
got := p.Label()
if got != tt.want {
t.Errorf("parseBinding(%q).Label() = %q, want %q", tt.input, got, tt.want)
}
if p.isZero() {
t.Errorf("parseBinding(%q).isZero() = true, want false", tt.input)
}
})
}
}
func TestOptionOMatchesOptionO(t *testing.T) {
p := parseBinding("option+o")
matcher := p.Matcher()
// Simulate iTerm2 with "Option as Meta" pressing Option+O
// Terminal sends ESC o → KeyPressEvent{Code: 'o', Mod: ModAlt}
msg := tea.KeyPressMsg(tea.Key{Code: 'o', Mod: tea.ModAlt})
if !matcher(msg) {
t.Errorf("option+o matcher should match {Code:'o', Mod:ModAlt}")
}
// Should NOT match plain 'o'
msg2 := tea.KeyPressMsg(tea.Key{Code: 'o'})
if matcher(msg2) {
t.Errorf("option+o matcher should NOT match {Code:'o'} without ModAlt")
}
// Should NOT match ctrl+o
msg3 := tea.KeyPressMsg(tea.Key{Code: 'o', Mod: tea.ModCtrl})
if matcher(msg3) {
t.Errorf("option+o matcher should NOT match {Code:'o', Mod:ModCtrl}")
}
}
func TestOptionOMatchesComposedCharacters(t *testing.T) {
p := parseBinding("option+o")
matcher := p.Matcher()
// On macOS without "Option as Meta", Option+O produces ø (U+00F8)
msg := tea.KeyPressMsg(tea.Key{Code: 0xF8, Text: "ø"})
if matcher(msg) {
t.Logf("NOTE: option+o matcher also matches option+ø (macOS compose behavior)")
}
}
func TestCtrlEMatchesDefault(t *testing.T) {
p := parseBinding("ctrl+e")
matcher := p.Matcher()
// Terminal sends Ctrl+E → byte 0x05 → KeyPressEvent{Code: 'e', Mod: ModCtrl}
msg := tea.KeyPressMsg(tea.Key{Code: 'e', Mod: tea.ModCtrl})
if !matcher(msg) {
t.Errorf("ctrl+e matcher should match {Code:'e', Mod:ModCtrl}")
}
}
func TestCtrlBindingRejectsSupersetModifiers(t *testing.T) {
// Configured ctrl+e must NOT match ctrl+alt+e or ctrl+shift+e
// (Mod.Contains superset correctness for the ctrl-letter fast path).
p := parseBinding("ctrl+e")
matcher := p.Matcher()
bad := []struct {
name string
msg tea.KeyMsg
}{
{"ctrl+alt+e", tea.KeyPressMsg(tea.Key{Code: 'e', Mod: tea.ModCtrl | tea.ModAlt})},
{"ctrl+shift+e", tea.KeyPressMsg(tea.Key{Code: 'e', Mod: tea.ModCtrl | tea.ModShift})},
{"ctrl+alt+shift+e", tea.KeyPressMsg(tea.Key{Code: 'e', Mod: tea.ModCtrl | tea.ModAlt | tea.ModShift})},
{"ctrl+cmd+e", tea.KeyPressMsg(tea.Key{Code: 'e', Mod: tea.ModCtrl | tea.ModSuper})},
}
for _, tt := range bad {
t.Run(tt.name, func(t *testing.T) {
if matcher(tt.msg) {
t.Errorf("ctrl+e matcher should NOT match %s", tt.name)
}
})
}
}
func TestAltBindingRejectsSupersetModifiers(t *testing.T) {
// Configured alt+o must NOT match alt+shift+o (code path, exact mod).
p := parseBinding("alt+o")
matcher := p.Matcher()
bad := []struct {
name string
msg tea.KeyMsg
}{
{"alt+shift+o", tea.KeyPressMsg(tea.Key{Code: 'o', Mod: tea.ModAlt | tea.ModShift})},
{"alt+ctrl+o", tea.KeyPressMsg(tea.Key{Code: 'o', Mod: tea.ModAlt | tea.ModCtrl})},
{"alt+cmd+o", tea.KeyPressMsg(tea.Key{Code: 'o', Mod: tea.ModAlt | tea.ModSuper})},
}
for _, tt := range bad {
t.Run(tt.name, func(t *testing.T) {
if matcher(tt.msg) {
t.Errorf("alt+o matcher should NOT match %s", tt.name)
}
})
}
}
func TestPlainKeyBindingRejectsModifiers(t *testing.T) {
// A configured plain-key binding (no modifiers, e.g. "o") should only
// match when Mod is exactly 0, not when a modifier is held.
p := parseBinding("o")
matcher := p.Matcher()
plain := tea.KeyPressMsg(tea.Key{Code: 'o'})
if !matcher(plain) {
t.Errorf("'o' matcher should match {Code:'o'} with no modifiers")
}
bad := []struct {
name string
msg tea.KeyMsg
}{
{"ctrl+o", tea.KeyPressMsg(tea.Key{Code: 'o', Mod: tea.ModCtrl})},
{"alt+o", tea.KeyPressMsg(tea.Key{Code: 'o', Mod: tea.ModAlt})},
{"shift+o", tea.KeyPressMsg(tea.Key{Code: 'o', Mod: tea.ModShift})},
}
for _, tt := range bad {
t.Run(tt.name, func(t *testing.T) {
if matcher(tt.msg) {
t.Errorf("'o' matcher should NOT match %s", tt.name)
}
})
}
}
func TestParseBindingUTF8Character(t *testing.T) {
// A 2-byte UTF-8 key like é (U+00E9) should parse as a single character,
// not be rejected by a byte-length check.
p := parseBinding("é")
if p.isZero() {
t.Errorf("parseBinding('é') should not be zero — it's a valid single character")
}
if p.code != 'é' {
t.Errorf("parseBinding('é').code = %d (U+%04X), want %d (U+00E9)", p.code, p.code, 'é')
}
if p.text != "" {
t.Errorf("parseBinding('é').text = %q, want empty string", p.text)
}
// Also verify the matcher works
matcher := p.Matcher()
msg := tea.KeyPressMsg(tea.Key{Code: 'é', Text: "é"})
if !matcher(msg) {
t.Errorf("'é' matcher should match {Code:'é', Text:'é'}")
}
// Should not match with a modifier
msgAlt := tea.KeyPressMsg(tea.Key{Code: 'é', Text: "é", Mod: tea.ModAlt})
if matcher(msgAlt) {
t.Errorf("'é' matcher should NOT match with modifier")
}
}
func TestDispatchRejectsSupersetModifiers(t *testing.T) {
// Integration: model.keyMatch with a configured binding must reject
// modifier supersets.
cfg := config.KeyBindingsConfig{
ToggleDetailed: "ctrl+o",
}
bindings := resolveKeyBindings(cfg)
defaultFn := func(tea.KeyMsg) bool { return false }
m := model{keyBindings: bindings}
// Ctrl+O should match
msgCtrlO := tea.KeyPressMsg(tea.Key{Code: 'o', Mod: tea.ModCtrl})
if !m.keyMatch(bindings.toggleDetailed, msgCtrlO, defaultFn) {
t.Errorf("keyMatch should match ctrl+o when configured")
}
// Ctrl+Alt+O should NOT match
msgCtrlAltO := tea.KeyPressMsg(tea.Key{Code: 'o', Mod: tea.ModCtrl | tea.ModAlt})
if m.keyMatch(bindings.toggleDetailed, msgCtrlAltO, defaultFn) {
t.Errorf("keyMatch should NOT match ctrl+alt+o when ctrl+o is configured")
}
}
func TestConfigToBindingPipeline(t *testing.T) {
cfg := config.KeyBindingsConfig{
ToggleDetailed: "option+o",
ToggleMouse: "ctrl+e",
CycleReasoning: "ctrl+t",
TogglePlan: "ctrl+p",
ToggleSidebar: "option+b",
}
bindings := resolveKeyBindings(cfg)
tests := []struct {
name string
binding parsedBinding
wantKey string
}{
{"toggleDetailed", bindings.toggleDetailed, "Alt+O"},
{"toggleMouse", bindings.toggleMouse, "Ctrl+E"},
{"cycleReasoning", bindings.cycleReasoning, "Ctrl+T"},
{"togglePlan", bindings.togglePlan, "Ctrl+P"},
{"toggleSidebar", bindings.toggleSidebar, "Alt+B"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.binding.isZero() {
t.Errorf("%s binding is zero (unset) — config not loaded", tt.name)
}
if got := tt.binding.Label(); got != tt.wantKey {
t.Errorf("%s.Label() = %q, want %q", tt.name, got, tt.wantKey)
}
})
}
}
func TestHelpBindingSubstitution(t *testing.T) {
// Verify that the help overlay correctly shows user-configured bindings
// by checking that Label() returns non-empty for config-sourced bindings.
cfg := config.KeyBindingsConfig{
ToggleDetailed: "option+o",
ToggleSidebar: "option+b",
}
bindings := resolveKeyBindings(cfg)
if l := bindings.toggleDetailed.Label(); l != "Alt+O" {
t.Errorf("toggleDetailed.Label() = %q, want %q", l, "Alt+O")
}
if l := bindings.toggleSidebar.Label(); l != "Alt+B" {
t.Errorf("toggleSidebar.Label() = %q, want %q", l, "Alt+B")
}
}
// TestDispatchOptionO is an integration-style test: it verifies that pressing
// Option+O dispatches as toggleDetailed when configured.
func TestDispatchOptionO(t *testing.T) {
cfg := config.KeyBindingsConfig{
ToggleDetailed: "option+o",
}
bindings := resolveKeyBindings(cfg)
// Simulate Option+O in iTerm2 with "Option as Meta" → ESC o → {Code:'o', Mod:ModAlt}
msg := tea.KeyPressMsg(tea.Key{Code: 'o', Mod: tea.ModAlt})
defaultFn := func(tea.KeyMsg) bool { return false }
// This is the dispatch check the model uses
m := model{keyBindings: bindings}
if !m.keyMatch(bindings.toggleDetailed, msg, defaultFn) {
t.Errorf("model.keyMatch should match option+o")
}
// Also verify default doesn't match
msgCtrlO := tea.KeyPressMsg(tea.Key{Code: 'o', Mod: tea.ModCtrl})
if m.keyMatch(bindings.toggleDetailed, msgCtrlO, defaultFn) {
t.Errorf("model.keyMatch should NOT match ctrl+o when option+o is configured")
}
}
+1 -1
View File
@@ -377,7 +377,7 @@ func TestContextCommandCardHandlesNilRegistryAndStableStyle(t *testing.T) {
for _, want := range []string{
"Context",
"0 tools",
"style balanced",
"style concise",
"root unknown",
} {
assertContains(t, text, want)
+17
View File
@@ -559,6 +559,21 @@ func (m model) modelText(args string) string {
})
}
// avgTurnLatencyText reports the session's rolling average turn wall-time for
// /context — the "is it slow?" signal a user otherwise can only feel. "n/a" until
// a turn has completed.
func (m model) avgTurnLatencyText() string {
if m.turnLatencyCount == 0 {
return "n/a"
}
avgSeconds := m.turnLatencySum.Seconds() / float64(m.turnLatencyCount)
if m.turnTTFTCount > 0 {
ttftSeconds := m.turnTTFTSum.Seconds() / float64(m.turnTTFTCount)
return fmt.Sprintf("%.1fs avg (%.1fs to first token, %d turns)", avgSeconds, ttftSeconds, m.turnLatencyCount)
}
return fmt.Sprintf("%.1fs avg (%d turns)", avgSeconds, m.turnLatencyCount)
}
func (m model) contextText() string {
toolCount := len(m.registeredTools())
return renderCommandCardTranscript(commandCard{
@@ -578,6 +593,8 @@ func (m model) contextText() string {
{Key: "effort", Value: m.effortDisplay()},
{Key: "style", Value: displayValue(m.responseStyle, defaultResponseStyle)},
{Key: "usage", Value: m.usageSummaryText()},
{Key: "cache", Value: m.cacheEfficiencyText()},
{Key: "latency", Value: m.avgTurnLatencyText()},
{Key: "max turns", Value: fmt.Sprint(m.agentOptions.MaxTurns)},
},
},
+16
View File
@@ -41,6 +41,8 @@ const (
commandAddDir
commandSelfCorrect
commandTurns
commandNew
commandFast
commandUnknown
)
@@ -154,6 +156,13 @@ var commandDefinitions = []commandDefinition{
description: "Clear the visible transcript.",
kind: commandClear,
},
{
name: "/new",
usage: "/new",
group: commandGroupSession,
description: "Start a fresh session; the current one stays resumable via /resume.",
kind: commandNew,
},
{
name: "/search",
aliases: []string{"/find"},
@@ -227,6 +236,13 @@ var commandDefinitions = []commandDefinition{
description: "Show or set reasoning effort for supported models.",
kind: commandEffort,
},
{
name: "/fast",
usage: "/fast [on|off]",
group: commandGroupModel,
description: "Enable fast mode for the current model (/fast off to disable).",
kind: commandFast,
},
{
name: "/style",
usage: "/style [list|balanced|concise|explanatory|review]",
+111
View File
@@ -0,0 +1,111 @@
package tui
import (
"fmt"
"strings"
"github.com/Gitlawb/zero/internal/modelregistry"
)
// fastPlan is the pure decision `/fast` makes before touching any state: either
// a terminal message to show the user, or a target model to switch to.
type fastPlan struct {
message string // terminal notice; shown as-is when targetID is empty
targetID string // model to switch to ("" means no switch — show message)
toFast bool // the target is a fast model (drives the ⚡ prefix)
}
// planFastCommand resolves what `/fast [on|off]` should do for the current model
// using only the registry — no side effects. State is DERIVED from the model
// identity: the session is "in fast mode" iff the current model has a base
// variant. Returns a terminal message, or a target model to switch to.
func planFastCommand(registry modelregistry.Registry, currentModel, arg string) fastPlan {
// 1. Parse the argument (case-insensitive): no arg / "on" => enable fast,
// "off" => return to base, anything else => usage error.
enable := true
switch strings.ToLower(strings.TrimSpace(arg)) {
case "", "on":
enable = true
case "off":
enable = false
default:
return fastPlan{message: fmt.Sprintf("⚡ /fast takes on or off, not %q", strings.TrimSpace(arg))}
}
// 2. Read the current model.
current := strings.TrimSpace(currentModel)
if current == "" {
return fastPlan{message: "⚡ Pick a model first · /model"}
}
short := modelShortName(registry, current)
// 3. Derived state: in fast mode iff the current model has a base variant.
_, isFast := registry.BaseVariant(current)
// 4. No-op cases — already in the requested state.
if enable && isFast {
return fastPlan{message: "⚡ Already in the fast lane · " + short}
}
if !enable && !isFast {
return fastPlan{message: "Already on base · " + short}
}
// 5. Resolve the target variant.
var target modelregistry.ModelEntry
var ok bool
if enable {
target, ok = registry.FastVariant(current)
} else {
target, ok = registry.BaseVariant(current)
}
if !ok {
return fastPlan{message: "⚡ No fast lane for " + short}
}
// Switching TO a fast model iff the target itself has a base variant.
_, toFast := registry.BaseVariant(target.ID)
return fastPlan{targetID: target.ID, toFast: toFast}
}
// handleFastCommand implements `/fast [on|off]`: toggle the session model between
// its base and fast variant within the same family. The actual switch is
// delegated to handleModelCommand — the same path /model uses — so provider
// rebuild, the compaction-before-switch guard, and persistence all apply; this
// never mutates model state directly.
func (m model) handleFastCommand(arg string) (model, string) {
registry, err := modelregistry.DefaultRegistry()
if err != nil {
return m, "Failed to load the model registry: " + err.Error()
}
plan := planFastCommand(registry, m.modelName, arg)
if plan.targetID == "" {
return m, plan.message
}
var switchText string
m, switchText = m.handleModelCommand(plan.targetID)
if m.modelName != plan.targetID {
// The switch did not complete — either an error, or a "compact first"
// guard prompt from handleModelCommand. Surface its own message so the
// user sees the real reason instead of a misleading "Switched to ...".
return m, switchText
}
short := modelShortName(registry, plan.targetID)
if plan.toFast {
return m, "⚡ Fast lane on · " + short
}
return m, "Fast lane off · " + short
}
// modelShortName returns a model's short display name, falling back to the id
// when the model is unknown or has no display name.
func modelShortName(registry modelregistry.Registry, id string) string {
if entry, ok := registry.Get(id); ok {
if name := strings.TrimSpace(entry.DisplayName); name != "" {
return name
}
}
return strings.TrimSpace(id)
}
+200
View File
@@ -0,0 +1,200 @@
package tui
import (
"context"
"path/filepath"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/modelregistry"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
func fastTestRegistry(t *testing.T) modelregistry.Registry {
t.Helper()
registry, err := modelregistry.DefaultRegistry()
if err != nil {
t.Fatalf("DefaultRegistry() error = %v", err)
}
return registry
}
// planFastCommand is the pure decision layer — every argument/state case is
// exercised here without touching the switch machinery.
func TestPlanFastCommand(t *testing.T) {
registry := fastTestRegistry(t)
cases := []struct {
name string
model string
arg string
wantMessage string // substring expected when the plan is terminal
wantTarget string // model id expected when the plan switches
wantToFast bool
}{
{name: "invalid arg", model: "gpt-4.1", arg: "turbo", wantMessage: `/fast takes on or off, not "turbo"`},
{name: "no model set", model: "", arg: "on", wantMessage: "Pick a model first"},
{name: "enable while already fast", model: "gpt-4.1-mini", arg: "on", wantMessage: "Already in the fast lane"},
{name: "disable while already base", model: "gpt-4.1", arg: "off", wantMessage: "Already on base"},
{name: "enable with no fast variant", model: "claude-opus-4.1", arg: "on", wantMessage: "No fast lane"},
{name: "enable (no arg) resolves fast", model: "gpt-4.1", arg: "", wantTarget: "gpt-4.1-mini", wantToFast: true},
{name: "enable (on) resolves fast", model: "gpt-4.1", arg: "ON", wantTarget: "gpt-4.1-mini", wantToFast: true},
{name: "disable resolves base", model: "gpt-4.1-mini", arg: "off", wantTarget: "gpt-4.1", wantToFast: false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
plan := planFastCommand(registry, tc.model, tc.arg)
if tc.wantTarget != "" {
canon, ok := registry.Resolve(tc.wantTarget)
if !ok {
t.Fatalf("test fixture %q does not resolve", tc.wantTarget)
}
if plan.targetID != canon.ID {
t.Fatalf("targetID = %q, want %q", plan.targetID, canon.ID)
}
if plan.toFast != tc.wantToFast {
t.Fatalf("toFast = %v, want %v", plan.toFast, tc.wantToFast)
}
if plan.message != "" {
t.Fatalf("switching plan should carry no message, got %q", plan.message)
}
return
}
if plan.targetID != "" {
t.Fatalf("expected a terminal message, got target %q", plan.targetID)
}
if !strings.Contains(plan.message, tc.wantMessage) {
t.Fatalf("message = %q, want it to contain %q", plan.message, tc.wantMessage)
}
})
}
}
// handleFastCommand's terminal cases return the plan message without switching,
// so a bare model exercises them.
func TestHandleFastCommandTerminalCases(t *testing.T) {
cases := []struct {
name string
model string
arg string
wantContains string
}{
{"invalid arg", "gpt-4.1", "sideways", `/fast takes on or off, not "sideways"`},
{"no model", "", "on", "Pick a model first"},
{"already fast", "gpt-4.1-mini", "on", "Already in the fast lane"},
{"already base", "gpt-4.1", "off", "Already on base"},
{"no fast variant", "claude-opus-4.1", "on", "No fast lane"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
m := model{modelName: tc.model}
got, notice := m.handleFastCommand(tc.arg)
if got.modelName != tc.model {
t.Fatalf("model must not change on a terminal case: %q -> %q", tc.model, got.modelName)
}
if !strings.Contains(notice, tc.wantContains) {
t.Fatalf("notice = %q, want it to contain %q", notice, tc.wantContains)
}
})
}
}
func newFastTestModel(t *testing.T, modelID string) model {
t.Helper()
configPath := filepath.Join(t.TempDir(), "zero.json")
profile := config.ProviderProfile{
Name: "openai",
ProviderKind: config.ProviderKindOpenAI,
BaseURL: config.OpenAIBaseURL,
APIKey: "sk-test",
Model: modelID,
}
if _, err := config.UpsertProvider(configPath, profile, true); err != nil {
t.Fatalf("write user config: %v", err)
}
return newModel(context.Background(), Options{
UserConfigPath: configPath,
ProviderName: "openai",
ModelName: modelID,
ProviderProfile: profile,
Provider: &fakeProvider{},
NewProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
return &fakeProvider{}, nil
},
})
}
// Enabling switches base -> fast with a ⚡ notice; disabling switches back to the
// base with a plain notice. Uses the same switch path /model uses.
func TestHandleFastCommandSwitchesToFastAndBack(t *testing.T) {
registry := fastTestRegistry(t)
m := newFastTestModel(t, "gpt-4.1")
m, notice := m.handleFastCommand("on")
if m.modelName != "gpt-4.1-mini" {
t.Fatalf("enable: modelName = %q, want gpt-4.1-mini", m.modelName)
}
if want := "⚡ Fast lane on · " + modelShortName(registry, "gpt-4.1-mini"); notice != want {
t.Fatalf("enable notice = %q, want exactly %q (bolt + fast-lane-on + target short name)", notice, want)
}
m, notice = m.handleFastCommand("off")
if m.modelName != "gpt-4.1" {
t.Fatalf("disable: modelName = %q, want gpt-4.1", m.modelName)
}
if want := "Fast lane off · " + modelShortName(registry, "gpt-4.1"); notice != want {
t.Fatalf("disable notice = %q, want exactly %q (plain fast-lane-off, no bolt)", notice, want)
}
}
// The no-op and no-variant notices must carry their full spec decoration — the ⚡
// prefix and the (<short>) name — so a regression dropping either fails loudly
// rather than passing a bare substring assertion.
func TestFastCommandNoOpAndNoVariantMessagesAreDecorated(t *testing.T) {
registry := fastTestRegistry(t)
plan := planFastCommand(registry, "gpt-4.1-mini", "on")
if want := "⚡ Already in the fast lane · " + modelShortName(registry, "gpt-4.1-mini"); plan.message != want {
t.Fatalf("already-fast message = %q, want %q", plan.message, want)
}
plan = planFastCommand(registry, "gpt-4.1", "off")
if want := "Already on base · " + modelShortName(registry, "gpt-4.1"); plan.message != want {
t.Fatalf("already-base message = %q, want %q", plan.message, want)
}
// An unknown / off-catalog model id also reaches the no-variant branch, where
// <short> falls back to the raw id.
plan = planFastCommand(registry, "some-custom-live-provider-model", "on")
if want := "⚡ No fast lane for some-custom-live-provider-model"; plan.message != want {
t.Fatalf("no-variant (unknown id) message = %q, want %q", plan.message, want)
}
}
// When the underlying switch fails, /fast surfaces the error and leaves the
// model unchanged (here: a valid base model but no provider profile to rebuild).
func TestHandleFastCommandSurfacesSwitchError(t *testing.T) {
m := model{modelName: "gpt-4.1"} // no providerProfile / newProvider wired
got, notice := m.handleFastCommand("on")
if got.modelName != "gpt-4.1" {
t.Fatalf("model must be unchanged on switch failure, got %q", got.modelName)
}
if strings.HasPrefix(notice, "⚡ Fast lane on ") || strings.HasPrefix(notice, "Fast lane off ") {
t.Fatalf("a failed switch must not claim success, got %q", notice)
}
if strings.TrimSpace(notice) == "" {
t.Fatal("a failed switch must surface a reason")
}
}
func TestModelShortName(t *testing.T) {
registry := fastTestRegistry(t)
if got := modelShortName(registry, "gpt-4.1"); strings.TrimSpace(got) == "" {
t.Fatal("a known model should resolve to a non-empty short name")
}
if got := modelShortName(registry, "totally-unknown-model-xyz"); got != "totally-unknown-model-xyz" {
t.Fatalf("an unknown model short name = %q, want the raw id", got)
}
}
+87
View File
@@ -0,0 +1,87 @@
package tui
import (
"strings"
"time"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/Gitlawb/zero/internal/modelregistry"
)
// fastMarkerGlyph is the compact "f" badge shown next to a model that has an
// available fast variant — i.e. one where `/fast on` would switch down to a
// faster same-family model. It marks the BASE model, never the fast one.
const fastMarkerGlyph = "f"
// fastVariantEntryFor resolves the fast variant of an arbitrary model id, if any.
// Like modelContextWindow, it reads the default registry on demand rather than
// caching derived state, so a marker can never go stale after a model switch.
// DefaultRegistry is memoized, so even the per-frame callers (the active-model
// chip renders every frame; the pickers render per keystroke) pay only a map
// lookup here, not a registry rebuild.
func fastVariantEntryFor(id string) (modelregistry.ModelEntry, bool) {
id = strings.TrimSpace(id)
if id == "" {
return modelregistry.ModelEntry{}, false
}
registry, err := modelregistry.DefaultRegistry()
if err != nil {
return modelregistry.ModelEntry{}, false
}
return registry.FastVariant(id)
}
// currentModelFastVariant resolves the fast variant of the session's active model.
func (m model) currentModelFastVariant() (modelregistry.ModelEntry, bool) {
return fastVariantEntryFor(m.modelName)
}
// renderFastBadge renders the "f" fast-mode marker in the given (surface-aware)
// style, prefixed with a space so it reads as a distinct badge rather than a
// trailing letter of the adjacent model name.
func renderFastBadge(style lipgloss.Style) string {
return " " + style.Render(fastMarkerGlyph)
}
// fastVariantHint is the one-line reveal shown against a focused/selected
// fast-capable model. The TUI has no floating-tooltip primitive, so the "hover"
// affordance is this inline detail on the active row: it names the target model
// and the command that gets there.
func fastVariantHint(variantName string) string {
variantName = strings.TrimSpace(variantName)
if variantName == "" {
return ""
}
return "⚡ fast: " + variantName + " · /fast on"
}
// fastToastDuration is how long a /fast toast stays up before auto-dismissing.
const fastToastDuration = 3 * time.Second
// fastToastExpiredMsg clears a fast-lane toast after fastToastDuration. It is
// seq-gated so a newer toast is never dismissed by an older timer.
type fastToastExpiredMsg struct{ seq int }
// showFastToast raises a transient fast-lane notice in the row above the composer
// and schedules its dismissal. It replaces the persistent transcript line the
// /fast command used to append, so repeated toggles never pile up in history.
func (m model) showFastToast(text string) (model, tea.Cmd) {
text = strings.TrimSpace(strings.ReplaceAll(text, "\n", " "))
if text == "" {
return m, nil
}
m.fastToastSeq++
m.fastToast = text
seq := m.fastToastSeq
return m, tea.Tick(fastToastDuration, func(time.Time) tea.Msg {
return fastToastExpiredMsg{seq: seq}
})
}
// renderFastToast styles the toast as a filled brand pill so it reads as a
// transient popup rather than a permanent status line.
func renderFastToast(text string) string {
return zeroTheme.badge.Render(" " + strings.TrimSpace(text) + " ")
}
+151
View File
@@ -0,0 +1,151 @@
package tui
import (
"strings"
"testing"
"github.com/charmbracelet/x/ansi"
"github.com/Gitlawb/zero/internal/modelregistry"
)
// hasFastBadge reports whether a rendered (styled) line ends with the " f"
// fast-mode badge, ignoring trailing layout padding. Labels used in these tests
// deliberately contain no trailing " f" so the badge is unambiguous.
func hasFastBadge(rendered string) bool {
return strings.HasSuffix(strings.TrimRight(ansi.Strip(rendered), " "), " "+fastMarkerGlyph)
}
func TestFastVariantHint(t *testing.T) {
if got := fastVariantHint("Claude Haiku 4.5"); got != "⚡ fast: Claude Haiku 4.5 · /fast on" {
t.Errorf("fastVariantHint = %q", got)
}
if got := fastVariantHint(" "); got != "" {
t.Errorf("fastVariantHint(blank) = %q, want empty", got)
}
}
func TestRenderFastBadge(t *testing.T) {
if got := ansi.Strip(renderFastBadge(zeroTheme.accent)); got != " "+fastMarkerGlyph {
t.Errorf("renderFastBadge visible text = %q, want %q", got, " "+fastMarkerGlyph)
}
}
// The active-model chip carries the "f" badge only when the current model has a
// fast variant — base models yes, fast models and unpaired top-tier models no.
func TestTitleModelSegmentFastBadge(t *testing.T) {
cases := []struct {
model string
want bool
}{
{"gpt-4.1", true}, // base with a fast variant
{"gpt-4.1-mini", false}, // the fast variant itself has none
{"claude-opus-4.1", false}, // top-tier, intentionally unpaired
{"definitely-not-real", false},
{"", false},
}
for _, tc := range cases {
seg := model{modelName: tc.model}.titleModelSegment()
if got := hasFastBadge(seg); got != tc.want {
t.Errorf("titleModelSegment(%q) badge = %v, want %v (visible %q)", tc.model, got, tc.want, ansi.Strip(seg))
}
}
}
// A /model picker row shows the badge iff its FastVariant field is populated,
// and the field is populated only for base models by decorateFastVariantItems.
func TestRenderModelPickerRowFastBadge(t *testing.T) {
withFast := renderModelPickerRow(60, false, pickerItem{Label: "gpt-4.1", Value: "gpt-4.1", FastVariant: "GPT-4.1 mini"})
if !hasFastBadge(withFast) {
t.Errorf("fast-capable row missing badge: %q", ansi.Strip(withFast))
}
withoutFast := renderModelPickerRow(60, false, pickerItem{Label: "gpt-4.1", Value: "gpt-4.1"})
if hasFastBadge(withoutFast) {
t.Errorf("row without FastVariant should have no badge: %q", ansi.Strip(withoutFast))
}
}
func TestModelPickerItemDetailIncludesFastHint(t *testing.T) {
detail := modelPickerItemDetail(pickerItem{Label: "GPT-4.1", Value: "gpt-4.1", FastVariant: "GPT-4.1 mini"})
if !strings.Contains(detail, "⚡ fast: GPT-4.1 mini · /fast on") {
t.Errorf("detail missing fast hint: %q", detail)
}
plain := modelPickerItemDetail(pickerItem{Label: "Claude Opus 4.1", Value: "claude-opus-4.1"})
if strings.Contains(plain, "fast:") {
t.Errorf("non-fast detail should have no hint: %q", plain)
}
}
// decorateFastVariantItems stamps the fast variant's display name onto base-model
// rows only, using the real registry — the single source of truth for the badge.
func TestDecorateFastVariantItems(t *testing.T) {
registry, err := modelregistry.DefaultRegistry()
if err != nil {
t.Fatalf("DefaultRegistry: %v", err)
}
items := []pickerItem{
{Value: "gpt-4.1"}, // base -> GPT-4.1 mini
{Value: "gpt-4.1-mini"}, // fast, no variant
{Value: "claude-opus-4.1"}, // unpaired
{Value: "some-custom-model"}, // not in registry
{Value: ""},
}
decorateFastVariantItems(registry, items)
if items[0].FastVariant != "GPT-4.1 mini" {
t.Errorf("gpt-4.1 FastVariant = %q, want %q", items[0].FastVariant, "GPT-4.1 mini")
}
for _, i := range []int{1, 2, 3, 4} {
if items[i].FastVariant != "" {
t.Errorf("items[%d].FastVariant = %q, want empty", i, items[i].FastVariant)
}
}
}
func TestShowFastToast(t *testing.T) {
// A non-empty message raises the toast, bumps the seq, and schedules dismissal.
m, cmd := model{}.showFastToast("⚡ Fast lane on · GPT-4.1 mini")
if m.fastToast != "⚡ Fast lane on · GPT-4.1 mini" {
t.Fatalf("fastToast = %q", m.fastToast)
}
if m.fastToastSeq != 1 {
t.Fatalf("fastToastSeq = %d, want 1", m.fastToastSeq)
}
if cmd == nil {
t.Fatal("showFastToast must return a dismissal tick cmd")
}
// A multi-line switch-guard message is flattened so the toast stays one line
// (the footer slot is fixed-height).
m2, _ := model{}.showFastToast("Model\nCannot switch while a run is active.")
if strings.Contains(m2.fastToast, "\n") {
t.Fatalf("toast must be flattened to one line, got %q", m2.fastToast)
}
// Blank text is a no-op: no toast, no timer.
m3, cmd3 := model{}.showFastToast(" ")
if m3.fastToast != "" || cmd3 != nil {
t.Fatalf("blank text should not raise a toast, got %q / cmd!=nil=%v", m3.fastToast, cmd3 != nil)
}
}
func TestRenderFastToastShowsMessage(t *testing.T) {
got := ansi.Strip(renderFastToast("⚡ No fast lane for gpt-5.5"))
if !strings.Contains(got, "⚡ No fast lane for gpt-5.5") {
t.Fatalf("rendered toast = %q, want it to contain the message", got)
}
}
func TestProviderWizardModelFastBadgeAndHint(t *testing.T) {
wizard := &providerWizardState{selectedModel: 0}
withFast := wizard.renderSelectableModel(60, 0, providerWizardModel{ID: "gpt-4.1"})
if !hasFastBadge(withFast) {
t.Errorf("provider wizard base-model row missing badge: %q", ansi.Strip(withFast))
}
plain := wizard.renderSelectableModel(60, 1, providerWizardModel{ID: "claude-opus-4.1"})
if hasFastBadge(plain) {
t.Errorf("provider wizard unpaired-model row should have no badge: %q", ansi.Strip(plain))
}
if detail := providerWizardModelDetail(providerWizardModel{ID: "gpt-4.1"}); !strings.Contains(detail, "⚡ fast:") {
t.Errorf("provider wizard detail missing fast hint: %q", detail)
}
}
+7 -4
View File
@@ -105,11 +105,14 @@ func TestClearResetsFlushFrontier(t *testing.T) {
m.input.SetValue("/clear")
updated, _ := m.Update(testKey(tea.KeyEnter))
next := updated.(model)
if len(next.transcript) != 1 || next.transcript[0].kind != rowWelcome {
t.Fatalf("expected /clear to reset transcript, got %#v", next.transcript)
if len(next.transcript) != 2 || next.transcript[0].kind != rowWelcome {
t.Fatalf("expected /clear to reset transcript to welcome + note, got %#v", next.transcript)
}
if next.flushed > 1 {
t.Fatalf("expected frontier reset after /clear, got %d", next.flushed)
if !transcriptContains(next.transcript, "/new") {
t.Fatalf("expected /clear to point users to /new, got %#v", next.transcript)
}
if next.flushed != len(next.transcript) {
t.Fatalf("expected frontier reset to match cleared transcript (%d rows), got %d", len(next.transcript), next.flushed)
}
}
+28
View File
@@ -0,0 +1,28 @@
package tui
import "testing"
func TestHumanCount(t *testing.T) {
cases := []struct {
n int
want string
}{
{0, "0"},
{999, "999"},
{1000, "1K"},
{12400, "12.4K"},
{200000, "200K"},
{999999, "1000K"},
// The bug: a million-scale count must switch to an M suffix instead
// of continuing to render as "1000K", "1200K", etc.
{1_000_000, "1M"},
{1_048_576, "1M"},
{1_200_000, "1.2M"},
{-5, "0"},
}
for _, c := range cases {
if got := humanCount(c.n); got != c.want {
t.Errorf("humanCount(%d) = %q, want %q", c.n, got, c.want)
}
}
}
+55 -47
View File
@@ -5,6 +5,10 @@
// lists them grouped, so the keymap is discoverable the way the reference TUIs
// do it. The list is declarative and hand-curated to match the real handlers in
// model.go's Update switch; keep them in sync when a binding changes.
//
// Configurable bindings (toggleDetailed, toggleMouse, cycleReasoning,
// togglePlan, toggleSidebar) pull their key label from m.keyBindings so a user
// who remaps them in config.json sees the actual chords, not the defaults.
package tui
import "strings"
@@ -21,63 +25,67 @@ type keybindingGroup struct {
bindings []keybinding
}
// keybindingGroups is the full, grouped shortcut list shown by the `?` overlay.
// buildKeybindingGroups returns the full, grouped shortcut list shown by the
// `?` overlay. Configurable bindings use the user's remapped labels (via
// m.keyBindings) when present; unconfigured bindings get their built-in labels.
// Sourced from the real key cases in model.go (Update) — not invented. When a
// binding is added/changed there, update it here too (a test guards that the
// data is non-empty and well-formed, but it can't verify the bindings exist).
var keybindingGroups = []keybindingGroup{
{
title: "Chat",
bindings: []keybinding{
{"Enter", "send the message"},
{"Alt+Enter", "insert a newline (multi-line compose)"},
{"Esc (×2)", "cancel the run / dismiss a popup / clear the input"},
{"Ctrl+C", "cancel the run, then quit"},
{"?", "show this help (on an empty input)"},
// binding is added/changed there, update this method too.
func (m model) buildKeybindingGroups() []keybindingGroup {
return []keybindingGroup{
{
title: "Chat",
bindings: []keybinding{
{"Enter", "send the message"},
{"Alt+Enter", "insert a newline (multi-line compose)"},
{"Esc (\u00d72)", "cancel the run / dismiss a popup / clear the input"},
{"Ctrl+C", "cancel the run, then quit"},
{"?", "show this help (on an empty input)"},
},
},
},
{
title: "Model & run controls",
bindings: []keybinding{
{"Ctrl+T", "cycle reasoning effort (auto → low → medium → high)"},
{"Shift+Tab", "cycle permission mode (auto ↔ ask)"},
{"Ctrl+P", "expand / collapse the plan panel"},
{
title: "Model & run controls",
bindings: []keybinding{
{labelOr(m.keyBindings.cycleReasoning, "Ctrl+T"), "cycle reasoning effort (auto \u2192 low \u2192 medium \u2192 high)"},
{"Shift+Tab", "cycle permission mode (auto \u2194 ask)"},
{labelOr(m.keyBindings.togglePlan, "Ctrl+P"), "expand / collapse the plan panel"},
},
},
},
{
title: "Navigation & scrollback",
bindings: []keybinding{
{"PgUp / PgDn", "scroll the transcript by a page"},
{"↑ / ↓", "scroll, or move within a popup / multi-line input"},
{"Ctrl+O", "toggle the detailed (full-screen) transcript"},
{"Ctrl+B", "hide / show the right context sidebar"},
{"Ctrl+E", "release the mouse to drag-select & copy text"},
{"Tab", "accept the autocomplete / picker selection"},
{
title: "Navigation & scrollback",
bindings: []keybinding{
{"PgUp / PgDn", "scroll the transcript by a page"},
{"\u2191 / \u2193", "scroll, or move within a popup / multi-line input"},
{labelOr(m.keyBindings.toggleDetailed, "Ctrl+O"), "toggle the detailed (full-screen) transcript"},
{labelOr(m.keyBindings.toggleSidebar, "Ctrl+B"), "hide / show the right context sidebar"},
{labelOr(m.keyBindings.toggleMouse, "Ctrl+E"), "release the mouse to drag-select & copy text"},
{"Tab", "accept the autocomplete / picker selection"},
},
},
},
{
title: "Specialists & pickers",
bindings: []keybinding{
{"Click a specialist card", "drill into its sub-session"},
{"↑ / Esc (in a sub-session)", "return to the main chat"},
{"Ctrl+F (in /model)", "toggle the highlighted model as a favorite"},
{"Click a tool card", "expand / collapse its output"},
{"Right-click", "paste the clipboard"},
{
title: "Specialists & pickers",
bindings: []keybinding{
{"Click a specialist card", "drill into its sub-session"},
{"\u2191 / Esc (in a sub-session)", "return to the main chat"},
{"Ctrl+F (in /model)", "toggle the highlighted model as a favorite"},
{"Click a tool card", "expand / collapse its output"},
{"Right-click", "paste the clipboard"},
},
},
},
}
}
// keybindingHelpFooter is the dismiss hint shown at the bottom of the overlay.
const keybindingHelpFooter = "? or Esc to close · /help for slash commands"
const keybindingHelpFooter = "? or Esc to close \u00b7 /help for slash commands"
// renderKeybindingHelpLines builds the overlay body lines (group titles +
// aligned key/description rows + footer), wrapped to the inner width. Exposed
// separately from the framed renderer so tests can assert on content without
// the border chrome.
func (m model) renderKeybindingHelpLines(innerWidth int) []string {
keyColumn := keybindingKeyColumnWidth()
groups := m.buildKeybindingGroups()
keyColumn := keybindingKeyColumnWidth(groups)
lines := make([]string, 0, 64)
for index, group := range keybindingGroups {
for index, group := range groups {
if index > 0 {
lines = append(lines, "")
}
@@ -93,9 +101,9 @@ func (m model) renderKeybindingHelpLines(innerWidth int) []string {
// keybindingKeyColumnWidth returns the width of the key column: the widest key
// chord across all groups, so the descriptions align in a clean second column.
func keybindingKeyColumnWidth() int {
func keybindingKeyColumnWidth(groups []keybindingGroup) int {
widest := 0
for _, group := range keybindingGroups {
for _, group := range groups {
for _, binding := range group.bindings {
if n := len([]rune(binding.keys)); n > widest {
widest = n
@@ -124,9 +132,9 @@ func formatKeybindingLine(binding keybinding, keyColumn int, innerWidth int) str
return " " + keyCell + " " + desc
}
// renderKeybindingHelpOverlay renders the framed, centered `?` help overlay for
// the given terminal dimensions.
func (m model) renderKeybindingHelpOverlay(width int, height int) string {
// the given terminal width. Vertical centering is handled by the caller's
// overlay compositing pipeline (overlayViewportLines in transcriptView).
func (m model) renderKeybindingHelpOverlay(width int) string {
overlayWidth := keybindingHelpOverlayWidth(width)
lines := m.renderKeybindingHelpLines(overlayWidth - 4)
block := styledBlockFillTitle(overlayWidth, "Keyboard Shortcuts", lines, zeroTheme.line, zeroTheme.panel)
+58 -4
View File
@@ -87,11 +87,13 @@ func TestHelpOverlayViewRendersGroupsAndKeys(t *testing.T) {
}
}
func TestKeybindingGroupsAreWellFormed(t *testing.T) {
if len(keybindingGroups) == 0 {
t.Fatal("keybindingGroups must not be empty")
func TestBuildKeybindingGroupsAreWellFormed(t *testing.T) {
m := newModel(context.Background(), Options{ModelName: "gpt-4o"})
groups := m.buildKeybindingGroups()
if len(groups) == 0 {
t.Fatal("buildKeybindingGroups must not return empty")
}
for _, group := range keybindingGroups {
for _, group := range groups {
if strings.TrimSpace(group.title) == "" {
t.Fatal("every keybinding group needs a title")
}
@@ -105,3 +107,55 @@ func TestKeybindingGroupsAreWellFormed(t *testing.T) {
}
}
}
// #419: the `?` help overlay must render ON TOP of the chat (like the model
// picker), not REPLACE the whole screen. The old full-screen replace produced
// only the centered shortcut block on a blank canvas — no title bar, no
// composer. With the overlay open, surrounding chat chrome that is NOT covered
// by the centered box (the model title bar at top, the composer at bottom) must
// still be present alongside "Keyboard Shortcuts".
func TestHelpOverlayCompositesOverChatNotReplacingIt(t *testing.T) {
m := newModel(context.Background(), Options{ModelName: "gpt-4o"})
m.width = 100
m.height = 40
m.altScreen = true
base := plainRender(t, m.View()) // no overlay: baseline chrome
m.helpOverlay = true
over := plainRender(t, m.View())
if !strings.Contains(over, "Keyboard Shortcuts") {
t.Fatalf("help overlay not rendered:\n%s", over)
}
// Chrome that renders in the baseline (and sits outside the centered overlay
// box) must survive behind the overlay. The full-screen replace showed none.
for _, marker := range []string{"gpt-4o", "describe a task"} {
if !strings.Contains(base, marker) {
t.Fatalf("precondition: baseline chat should contain %q:\n%s", marker, base)
}
if !strings.Contains(over, marker) {
t.Fatalf("#419: help replaced the chat instead of overlaying it; %q is gone:\n%s", marker, over)
}
}
}
// A populated transcript row also survives behind the overlay (peeking out to
// the left of the centered box), proving the chat body — not just the chrome —
// is composited under the overlay rather than discarded.
func TestHelpOverlayKeepsTranscriptBodyBehindIt(t *testing.T) {
m := newModel(context.Background(), Options{ModelName: "gpt-4o"})
m.width = 120
m.height = 40
m.altScreen = true
m.transcript = appendTranscriptRow(m.transcript, transcriptRow{kind: rowUser, text: "hello there this is a chat line"})
m.helpOverlay = true
view := plainRender(t, m.View())
if !strings.Contains(view, "Keyboard Shortcuts") {
t.Fatalf("help overlay not rendered:\n%s", view)
}
// The start of the transcript line peeks to the left of the centered box.
if !strings.Contains(view, "hello") {
t.Fatalf("#419: transcript body was replaced by the help overlay:\n%s", view)
}
}
+265
View File
@@ -0,0 +1,265 @@
package tui
import (
"strings"
"unicode/utf8"
tea "charm.land/bubbletea/v2"
"github.com/Gitlawb/zero/internal/config"
)
// parsedBinding is the parsed representation of a keybinding string such as
// "ctrl+o" or "alt+enter". The zero value is a sentinel meaning "use default".
type parsedBinding struct {
ctrl bool
alt bool
shift bool
cmd bool // ⌘ Command (macOS) / ⊞ Win → ModSuper
code rune // tea.KeyCode or 0 for text-based matching
text string // for text-based matching (e.g. "?")
}
// isZero returns true when p is the nil sentinel (no binding configured).
func (p parsedBinding) isZero() bool {
return p.code == 0 && p.text == ""
}
// Label returns a human-readable representation of the binding, e.g. "Ctrl+O"
// or "Cmd+Shift+Enter". Used in the help overlay. Returns empty string for
// zero (unset) bindings.
func (p parsedBinding) Label() string {
if p.isZero() {
return ""
}
var b strings.Builder
if p.ctrl {
b.WriteString("Ctrl+")
}
if p.alt {
b.WriteString("Alt+")
}
if p.shift {
b.WriteString("Shift+")
}
if p.cmd {
b.WriteString("Cmd+")
}
if p.text != "" {
b.WriteString(p.text)
} else if p.code != 0 {
switch p.code {
case tea.KeyEnter:
b.WriteString("Enter")
case tea.KeyTab:
b.WriteString("Tab")
case tea.KeyEsc:
b.WriteString("Esc")
case tea.KeySpace:
b.WriteString("Space")
case tea.KeyBackspace:
b.WriteString("Backspace")
case tea.KeyDelete:
b.WriteString("Delete")
case tea.KeyUp:
b.WriteString("↑")
case tea.KeyDown:
b.WriteString("↓")
case tea.KeyLeft:
b.WriteString("←")
case tea.KeyRight:
b.WriteString("→")
case tea.KeyHome:
b.WriteString("Home")
case tea.KeyEnd:
b.WriteString("End")
case tea.KeyPgUp:
b.WriteString("PgUp")
case tea.KeyPgDown:
b.WriteString("PgDn")
default:
// Printable character — uppercase for display
if p.code >= 'a' && p.code <= 'z' {
b.WriteRune(p.code - 32)
} else {
b.WriteRune(p.code)
}
}
}
return b.String()
}
// Matcher returns a function that matches a tea.KeyMsg against this binding.
// It is the hot path for the key dispatch — kept cheap intentionally.
func (p parsedBinding) Matcher() func(tea.KeyMsg) bool {
if p.isZero() {
// A zero binding should never be matched — the caller is expected to
// check useDefault() first and fall through to the built-in check.
return func(tea.KeyMsg) bool { return false }
}
// Build the required modifier mask from the parsed flags.
var mod tea.KeyMod
if p.ctrl {
mod |= tea.ModCtrl
}
if p.alt {
mod |= tea.ModAlt
}
if p.shift {
mod |= tea.ModShift
}
if p.cmd {
mod |= tea.ModSuper // ⌘ Command on macOS, ⊞ Win on Windows
}
// ctrl+letter fast path — use exact modifier equality so that a
// configured ctrl+o does NOT fire on ctrl+alt+o or ctrl+shift+o.
// Note: in raw terminal mode Bubble Tea may report ctrl+letter as
// {Code:letter, Mod:ModCtrl} (handled below), or as a control char
// code (e.g. 0x0F) without a modifier flag. The latter is handled by
// the text-based fallback in model.go for default bindings; for
// configured bindings the code+mod path below is what the user
// expressed.
if mod == tea.ModCtrl && p.code >= 'a' && p.code <= 'z' {
return func(msg tea.KeyMsg) bool {
return msg.Key().Code == p.code && msg.Key().Mod == mod
}
}
if p.text != "" {
return func(msg tea.KeyMsg) bool {
return msg.Key().Text == p.text && msg.Key().Mod == mod
}
}
return func(msg tea.KeyMsg) bool {
return msg.Key().Code == p.code && msg.Key().Mod == mod
}
}
// parseBinding converts a user-written keybinding string (e.g. "ctrl+o") into
// a parsedBinding. The empty string returns zero parsedBinding (the "use
// default" sentinel).
func parseBinding(s string) parsedBinding {
s = strings.TrimSpace(s)
if s == "" {
return parsedBinding{}
}
parts := strings.Split(strings.ToLower(s), "+")
var p parsedBinding
var keyPart string
for _, part := range parts {
part = strings.TrimSpace(part)
switch part {
case "ctrl", "control":
p.ctrl = true
case "alt", "option":
p.alt = true
case "shift":
p.shift = true
case "cmd", "command":
p.cmd = true
case "super":
// "super" matches the Bubble Tea naming; on macOS this is ⌘
p.cmd = true
default:
keyPart = part
}
}
if p.ctrl && len(keyPart) == 1 && keyPart[0] >= 'a' && keyPart[0] <= 'z' {
// ctrl+<letter> — store the code so the match is exact
p.code = []rune(keyPart)[0]
p.text = ""
return p
}
// Map named keys to their tea.KeyCode
switch keyPart {
case "enter", "return":
p.code = tea.KeyEnter
case "tab":
p.code = tea.KeyTab
case "esc", "escape":
p.code = tea.KeyEsc
case "space":
p.code = tea.KeySpace
case "backspace":
p.code = tea.KeyBackspace
case "delete":
p.code = tea.KeyDelete
case "up":
p.code = tea.KeyUp
case "down":
p.code = tea.KeyDown
case "left":
p.code = tea.KeyLeft
case "right":
p.code = tea.KeyRight
case "home":
p.code = tea.KeyHome
case "end":
p.code = tea.KeyEnd
case "pgup", "pageup":
p.code = tea.KeyPgUp
case "pgdown", "pagedown":
p.code = tea.KeyPgDown
case "?":
p.text = "?"
p.code = 0
default:
// Single character, any modifier context
if utf8.RuneCountInString(keyPart) == 1 {
p.code = []rune(keyPart)[0]
}
// else unrecognised — leave zero so it falls through to default
}
return p
}
// labelOr returns b.Label() when b is configured (non-zero), otherwise it
// returns the caller-supplied default label string. This is the display-layer
// counterpart to keyMatch — dispatch falls through to the hardcoded default
// function, so the label displayed in help / hints must match that fallback.
func labelOr(b parsedBinding, defaultLabel string) string {
if !b.isZero() {
return b.Label()
}
return defaultLabel
}
// keyBindings holds the parsed, resolved key bindings the model uses at
// dispatch time. When a binding's parsedBinding is zero, the built-in default
// check in model.go's updateModel should be used.
type keyBindings struct {
toggleDetailed parsedBinding
toggleMouse parsedBinding
cycleReasoning parsedBinding
togglePlan parsedBinding
toggleSidebar parsedBinding
}
// resolveKeyBindings converts a user-facing KeyBindingsConfig into the
// dispatch-ready parsed form, using empty-is-default semantics.
func resolveKeyBindings(cfg config.KeyBindingsConfig) keyBindings {
return keyBindings{
toggleDetailed: parseBinding(string(cfg.ToggleDetailed)),
toggleMouse: parseBinding(string(cfg.ToggleMouse)),
cycleReasoning: parseBinding(string(cfg.CycleReasoning)),
togglePlan: parseBinding(string(cfg.TogglePlan)),
toggleSidebar: parseBinding(string(cfg.ToggleSidebar)),
}
}
// keyMatch returns true when msg matches either the user-configured binding b
// or (when b is zero/unset) the built-in default matcher defaultFn. This is
// the bridge between the config surface and the hot dispatch path in model.go.
func (m model) keyMatch(b parsedBinding, msg tea.KeyMsg, defaultFn func(tea.KeyMsg) bool) bool {
if !b.isZero() {
return b.Matcher()(msg)
}
return defaultFn(msg)
}
+33
View File
@@ -0,0 +1,33 @@
package tui
import (
"context"
"testing"
"time"
"github.com/Gitlawb/zero/internal/sessions"
)
func TestAvgTurnLatencyText(t *testing.T) {
m := newModel(context.Background(), Options{})
if got := m.avgTurnLatencyText(); got != "n/a" {
t.Fatalf("empty latency = %q, want n/a", got)
}
m.turnLatencySum = 9 * time.Second
m.turnLatencyCount = 2
if got := m.avgTurnLatencyText(); got != "4.5s avg (2 turns)" {
t.Fatalf("avgTurnLatencyText = %q, want \"4.5s avg (2 turns)\"", got)
}
// With TTFT recorded, the line also reports time-to-first-token.
m.turnTTFTSum = 3 * time.Second
m.turnTTFTCount = 2
if got := m.avgTurnLatencyText(); got != "4.5s avg (1.5s to first token, 2 turns)" {
t.Fatalf("avgTurnLatencyText with ttft = %q, want \"4.5s avg (1.5s to first token, 2 turns)\"", got)
}
// /new must reset the rolling latency + ttft so a fresh session starts from zero.
m.activeSession = sessions.Metadata{SessionID: "x"}
next := m.startNewSession()
if next.turnLatencyCount != 0 || next.turnLatencySum != 0 || next.turnTTFTCount != 0 || next.turnTTFTSum != 0 {
t.Fatalf("startNewSession must reset latency+ttft, got latency=%d/%v ttft=%d/%v", next.turnLatencyCount, next.turnLatencySum, next.turnTTFTCount, next.turnTTFTSum)
}
}
+128 -49
View File
@@ -36,7 +36,7 @@ import (
)
const tuiToolOutputLimit = 240
const defaultResponseStyle = "balanced"
const defaultResponseStyle = "concise"
const chatWheelScrollLines = 5
const ctrlCExitConfirmDuration = 3 * time.Second
const ctrlCExitConfirmText = "Press Ctrl+C again to exit"
@@ -100,35 +100,43 @@ type model struct {
titledSessions map[string]bool
// retitle* drive the sequential /retitle backfill: queued session ids still
// awaiting a title, whether a backfill is running, and its progress counters.
retitleQueue []string
retitleActive bool
retitleTotal int
retitleDone int
retitleOK int
usageTracker *usage.Tracker
sessionCompactor SessionCompactor
prService *PrService
prState PrState
prWatcherStop func()
runtimeMessageSink func(tea.Msg)
agentOptions agent.Options
notifier *notify.Notifier
permissionMode agent.PermissionMode
selfCorrectTests bool
reasoningEffort modelregistry.ReasoningEffort
responseStyle string
themeMode themeMode // palette preference: auto (default), dark, light
hasDarkBg bool // last terminal background-detection result (auto mode)
userAgent string
compactRequests int
compactInFlight bool
compactFrame int
lastCompactResult *CompactResult
lastCompactError string
unpricedRequests int
unpricedTokens int
lastUsage usage.Normalized
lastUsageSeen bool
retitleQueue []string
retitleActive bool
retitleTotal int
retitleDone int
retitleOK int
usageTracker *usage.Tracker
sessionCompactor SessionCompactor
prService *PrService
prState PrState
prWatcherStop func()
runtimeMessageSink func(tea.Msg)
agentOptions agent.Options
notifier *notify.Notifier
permissionMode agent.PermissionMode
selfCorrectTests bool
reasoningEffort modelregistry.ReasoningEffort
responseStyle string
keyBindings keyBindings
themeMode themeMode // palette preference: auto (default), dark, light
hasDarkBg bool // last terminal background-detection result (auto mode)
userAgent string
compactRequests int
compactInFlight bool
compactFrame int
lastCompactResult *CompactResult
lastCompactError string
unpricedRequests int
unpricedTokens int
lastUsage usage.Normalized
lastUsageSeen bool
// turnLatencySum / turnLatencyCount accumulate completed-run wall time so
// /context can show a rolling average turn latency (the "is it slow?" signal).
// Reset by /new.
turnLatencySum time.Duration
turnLatencyCount int
turnTTFTSum time.Duration
turnTTFTCount int
transcript []transcriptRow
transcriptDetailed bool
helpOverlay bool // the `?` keyboard-shortcut overlay is open
@@ -318,9 +326,14 @@ type model struct {
// mouse cursor with no button pressed, so it renders in a distinct style —
// the visual cue that it's clickable. Requires AllMotion mouse reporting
// (see wantsMouseCapture) since idle cursor movement carries no button.
hover hoverTarget
copyStatus string
copyStatusSeq int
hover hoverTarget
copyStatus string
copyStatusSeq int
// fastToast is a transient /fast notice shown in the row above the composer for
// fastToastDuration, then auto-cleared (seq-gated). It keeps ephemeral fast-lane
// feedback out of the persistent transcript.
fastToast string
fastToastSeq int
exitConfirmActive bool
exitConfirmSeq int
// cancelConfirmActive/cancelConfirmSeq mirror exitConfirmActive/exitConfirmSeq
@@ -451,6 +464,9 @@ type agentResponseMsg struct {
// Turn metadata for settled rows that do not otherwise carry it.
turnTools int
turnElapsed time.Duration
// ttft is time-to-first-token for the turn (0 when nothing streamed — a
// tool-only or errored turn). Set only on the success path.
ttft time.Duration
}
type agentRowMsg struct {
@@ -726,6 +742,7 @@ func newModel(ctx context.Context, options Options) model {
permissionMode: permissionMode,
reasoningEffort: options.ReasoningEffort,
responseStyle: defaultedResponseStyle(options.ResponseStyle),
keyBindings: resolveKeyBindings(options.KeyBindings),
themeMode: resolveThemeMode(options.Theme, os.Getenv("ZERO_THEME"), options.SavedTheme),
hasDarkBg: true,
userAgent: options.UserAgent,
@@ -1003,6 +1020,11 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
m.copyStatus = ""
}
return m, nil
case fastToastExpiredMsg:
if msg.seq == m.fastToastSeq {
m.fastToast = ""
}
return m, nil
case exitConfirmExpiredMsg:
if msg.seq == m.exitConfirmSeq {
m.exitConfirmActive = false
@@ -1078,7 +1100,7 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
switch {
case keyCtrl(msg, 'c'):
return m.handleCtrlC()
case keyCtrl(msg, 'o'):
case m.keyMatch(m.keyBindings.toggleDetailed, msg, func(tea.KeyMsg) bool { return keyCtrl(msg, 'o') }):
return m.toggleDetailedTranscript(), nil
case m.fileView.active && m.noBlockingModal() && m.composerValue() == "" && (keyText(msg) == "d" || keyText(msg) == "f"):
// Mode toggle for the file drill-in, only while the composer is empty
@@ -1088,12 +1110,13 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
return m.setFileViewMode(fileViewFull), nil
}
return m.setFileViewMode(fileViewDiff), nil
case keyCtrl(msg, 'e'):
case m.keyMatch(m.keyBindings.toggleMouse, msg, func(tea.KeyMsg) bool { return keyCtrl(msg, 'e') }):
// Release/recapture the mouse so the user can drag-select and copy text
// natively (mouse capture otherwise intercepts terminal selection).
m.mouseReleased = !m.mouseReleased
if m.mouseReleased {
return m.appendSystemNotice("Mouse released — drag to select and copy text. Press Ctrl+E again to re-enable mouse interaction (clicks, right-click paste)."), nil
mouseKey := labelOr(m.keyBindings.toggleMouse, "Ctrl+E")
return m.appendSystemNotice(fmt.Sprintf("Mouse released — drag to select and copy text. Press %s again to re-enable mouse interaction (clicks, right-click paste).", mouseKey)), nil
}
return m.appendSystemNotice("Mouse interaction re-enabled."), nil
case keyIs(msg, tea.KeyEsc):
@@ -1268,7 +1291,7 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
m.permissionMode = nextPermissionMode(m.permissionMode)
return m, nil
}
case keyCtrl(msg, 't'):
case m.keyMatch(m.keyBindings.cycleReasoning, msg, func(tea.KeyMsg) bool { return keyCtrl(msg, 't') }):
if m.transcriptDetailed {
return m, nil
}
@@ -1281,20 +1304,20 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.noBlockingModal() {
return m.cycleReasoningEffort()
}
case keyCtrl(msg, 'p'):
case m.keyMatch(m.keyBindings.togglePlan, msg, func(tea.KeyMsg) bool { return keyCtrl(msg, 'p') }):
// Ctrl+P toggles the plan panel expansion (collapse/expand step list).
if m.noBlockingModal() && !m.plan.isEmpty() {
m.plan.expanded = !m.plan.expanded
return m, nil
}
case keyCtrl(msg, 'b'):
case m.keyMatch(m.keyBindings.toggleSidebar, msg, func(tea.KeyMsg) bool { return keyCtrl(msg, 'b') }):
// Ctrl+B collapses / restores the right context sidebar. Only acts when
// the sidebar would otherwise be on screen (managed mode, wide enough,
// real conversation) so it's a no-op — not a confusing notice — on the
// home screen or a narrow terminal. Hiding reflows the chat to full
// width, so mirror the width-change bookkeeping (re-wrap the streaming
// fade, resize the composer) the WindowSizeMsg path does.
if m.noBlockingModal() && m.sidebarAvailable() {
if m.noBlockingModal() && m.sidebarToggleAllowed() {
// Just show/hide — no transcript notice. The reflow IS the feedback,
// and emitting a line every toggle piled up noise in the chat.
m.sidebarHidden = !m.sidebarHidden
@@ -1856,6 +1879,16 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
m.streamingText = ""
m.streamingReasoning = ""
m.streamingReasoningExpanded = false
// Roll the completed run's wall-time into the session's rolling average so
// /context can surface typical turn latency, not just token counts.
if msg.turnElapsed > 0 {
m.turnLatencySum += msg.turnElapsed
m.turnLatencyCount++
}
if msg.ttft > 0 {
m.turnTTFTSum += msg.ttft
m.turnTTFTCount++
}
if msg.specReview != nil {
m = m.activateSpecReview(*msg.specReview)
}
@@ -2118,12 +2151,13 @@ func (m model) View() tea.View {
var content string
if m.setup.visible {
content = m.setupView(chatWidth(m.width))
} else if m.helpOverlay {
content = m.renderKeybindingHelpOverlay(chatWidth(m.width), m.height)
} else if m.transcriptDetailed {
content = m.detailedTranscriptView()
} else {
} else if m.helpOverlay || !m.transcriptDetailed {
// When helpOverlay is active the help panel is composited into the normal
// transcript view as a true overlay (scrim + vertical centering), matching
// how the suggestion picker / provider wizard / pickers are drawn.
content = m.transcriptView()
} else {
content = m.detailedTranscriptView()
}
view := tea.NewView(content)
@@ -2194,6 +2228,11 @@ func (m model) transcriptView() string {
return body + footer
}
helpOverlayContent := ""
if m.helpOverlay {
helpOverlayContent = m.renderKeybindingHelpOverlay(width)
}
suggestionOverlay := m.suggestionOverlay(width)
providerOverlay := m.providerWizardOverlay(width)
mcpAddOverlay := m.mcpAddWizardOverlay(width)
@@ -2201,6 +2240,8 @@ func (m model) transcriptView() string {
pickerOverlay := m.pickerOverlay(width)
viewportOverlay := ""
switch {
case helpOverlayContent != "":
viewportOverlay = helpOverlayContent
case providerOverlay != "":
viewportOverlay = providerOverlay
case mcpAddOverlay != "":
@@ -2316,7 +2357,9 @@ func (m model) footerView(width int) string {
// a faint idle affordance — discoverable key hints on the left, a jump-to-bottom
// cue on the right when scrolled up. Always one line (blank when nothing shows),
// so the footer height is unchanged.
if copyStatus := strings.TrimSpace(m.copyStatus); copyStatus != "" {
if toast := strings.TrimSpace(m.fastToast); toast != "" {
footer.WriteString(rightAlignedLine(renderFastToast(toast), width))
} else if copyStatus := strings.TrimSpace(m.copyStatus); copyStatus != "" {
footer.WriteString(rightAlignedLine(zeroTheme.ink.Render(copyStatus), width))
} else if left, right := m.composerIdleHint(), m.jumpToBottomHint(); left != "" || right != "" {
footer.WriteString(fitStyledLine(joinHeaderLine(" "+left, right, width), width))
@@ -2352,6 +2395,10 @@ func (m model) composerIdleHint() string {
m.subchat.active || m.suggestionsActive() || m.transcriptDetailed {
return ""
}
sidebarKey := labelOr(m.keyBindings.toggleSidebar, "Ctrl+B")
detailKey := labelOr(m.keyBindings.toggleDetailed, "Ctrl+O")
mouseKey := labelOr(m.keyBindings.toggleMouse, "Ctrl+E")
var hint string
switch widthTier(m.width) {
case tierTiny:
@@ -2359,9 +2406,9 @@ func (m model) composerIdleHint() string {
case tierNarrow:
hint = "? shortcuts"
case tierMedium:
hint = "? shortcuts · Ctrl+B sidebar · Ctrl+E copy"
hint = fmt.Sprintf("? shortcuts · %s sidebar · %s copy", sidebarKey, mouseKey)
default:
hint = "? shortcuts · Ctrl+B sidebar · Ctrl+O detail · Ctrl+E copy · Shift+Tab mode"
hint = fmt.Sprintf("? shortcuts · %s sidebar · %s detail · %s copy · Shift+Tab mode", sidebarKey, detailKey, mouseKey)
}
return zeroTheme.faint.Render(hint)
}
@@ -3607,10 +3654,23 @@ func (m model) handleSubmit() (tea.Model, tea.Cmd) {
return m, nil
case commandClear:
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
// point to /new, so "cleared screen" isn't mistaken for "fresh context."
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: "Transcript cleared. The agent still has the full session context — use /new to start a fresh session."})
// 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 ·")
return m, nil
case commandNew:
// A fresh session mid-run would strand the in-flight turn's events; make the
// user cancel first. Idle, /new saves the current session (already on disk)
// and clears the conversation in place.
if m.pending || m.compactInFlight {
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: "A run is in progress. Press Esc to cancel it first, then /new."})
return m, nil
}
return m.startNewSession(), nil
case commandExit:
// /exit gets the same protection as Ctrl+C: cancel any in-flight run and
// defer the quit until its checkpoint session events flush — quitting
@@ -3766,6 +3826,12 @@ func (m model) handleSubmit() (tea.Model, tea.Cmd) {
m, text = m.handleEffortCommand(command.text)
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: text})
return m, nil
case commandFast:
var text string
m, text = m.handleFastCommand(command.text)
// Fast-lane feedback is ephemeral: show it as a transient toast above the
// composer (auto-dismissed) instead of a permanent transcript line.
return m.showFastToast(text)
case commandStyle:
text := ""
m, text = m.handleStyleCommand(command.text)
@@ -4095,6 +4161,9 @@ func selfCorrectAutonomyForMode(mode agent.PermissionMode) string {
func (m model) runAgentWithOptions(runID int, runCtx context.Context, prompt string, images []zeroruntime.ImageBlock, runOptions tuiAgentRunOptions) tea.Cmd {
return func() tea.Msg {
started := m.now()
// firstTokenAt is stamped when the first token (reasoning or text) streams,
// so the turn can report time-to-first-token alongside total wall time.
var firstTokenAt time.Time
toolCalls := 0
rows := []transcriptRow{}
usageEvents := []zeroruntime.Usage{}
@@ -4187,6 +4256,9 @@ func (m model) runAgentWithOptions(runID int, runCtx context.Context, prompt str
onText := options.OnText
options.OnText = func(delta string) {
if firstTokenAt.IsZero() {
firstTokenAt = m.now()
}
if strings.TrimSpace(reasoningText) != "" {
flushReasoning(m.now())
}
@@ -4282,6 +4354,9 @@ func (m model) runAgentWithOptions(runID int, runCtx context.Context, prompt str
onReasoning := options.OnReasoning
options.OnReasoning = func(delta string) {
now := m.now()
if firstTokenAt.IsZero() && strings.TrimSpace(delta) != "" {
firstTokenAt = now
}
if strings.TrimSpace(reasoningText) == "" && strings.TrimSpace(delta) != "" {
reasoningStarted = now
}
@@ -4518,6 +4593,10 @@ func (m model) runAgentWithOptions(runID int, runCtx context.Context, prompt str
}
flushReasoning(m.now())
elapsed := m.now().Sub(started)
ttft := time.Duration(0)
if !firstTokenAt.IsZero() {
ttft = firstTokenAt.Sub(started)
}
rows = append(rows, transcriptRow{
kind: rowAssistant,
text: result.FinalAnswer,
@@ -4535,7 +4614,7 @@ func (m model) runAgentWithOptions(runID int, runCtx context.Context, prompt str
"content": result.FinalAnswer,
},
})
return agentResponseMsg{runID: runID, rows: rows, usageEvents: usageEvents, usageModelID: usageModelID, sessionEvents: sessionEvents, turnTools: toolCalls, turnElapsed: elapsed}
return agentResponseMsg{runID: runID, rows: rows, usageEvents: usageEvents, usageModelID: usageModelID, sessionEvents: sessionEvents, turnTools: toolCalls, turnElapsed: elapsed, ttft: ttft}
}
}
+5 -2
View File
@@ -340,8 +340,11 @@ func TestClearCommandResetsTranscript(t *testing.T) {
updated, _ := m.Update(testKey(tea.KeyEnter))
next := updated.(model)
if len(next.transcript) != 1 || next.transcript[0].kind != rowWelcome {
t.Fatalf("expected clear to reset transcript, got %#v", next.transcript)
if len(next.transcript) != 2 || next.transcript[0].kind != rowWelcome {
t.Fatalf("expected clear to reset transcript to welcome + note, got %#v", next.transcript)
}
if !transcriptContains(next.transcript, "/new") {
t.Fatalf("expected clear to point users to /new, got %#v", next.transcript)
}
}
+72
View File
@@ -0,0 +1,72 @@
package tui
import (
"context"
"testing"
tea "charm.land/bubbletea/v2"
"github.com/Gitlawb/zero/internal/sessions"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
func TestStartNewSessionResetsState(t *testing.T) {
m := newModel(context.Background(), Options{})
m.activeSession = sessions.Metadata{SessionID: "sess-old"}
m.sessionEvents = []sessions.Event{{Type: sessions.EventMessage}}
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendUser, text: "hello"})
// Stage attachments + a queued message that /new must not leak into the new session.
m.pendingImages = make([]zeroruntime.ImageBlock, 1)
m.pendingImageLabels = []string{"pic.png"}
m.pendingDocuments = []pendingDocument{{label: "doc.pdf"}}
m.queuedMessage = "queued"
next := m.startNewSession()
if next.activeSession.SessionID != "" {
t.Fatalf("expected active session id cleared, got %q", next.activeSession.SessionID)
}
if len(next.sessionEvents) != 0 {
t.Fatalf("expected session events cleared, got %d", len(next.sessionEvents))
}
if len(next.transcript) != 2 || next.transcript[0].kind != rowWelcome {
t.Fatalf("expected transcript reset to welcome + note, got %#v", next.transcript)
}
// The note must name the prior session id so the user can /resume it.
if !transcriptContains(next.transcript, "sess-old") {
t.Fatalf("expected note to reference previous session id, got %#v", next.transcript)
}
// Staged attachments and the queued message must not leak into the new session.
if len(next.pendingImages) != 0 || len(next.pendingImageLabels) != 0 || len(next.pendingDocuments) != 0 || next.queuedMessage != "" {
t.Fatalf("startNewSession must clear staged input, got images=%d labels=%d docs=%d queued=%q",
len(next.pendingImages), len(next.pendingImageLabels), len(next.pendingDocuments), next.queuedMessage)
}
}
func TestNewCommandStartsFreshSession(t *testing.T) {
m := newModel(context.Background(), Options{})
m.activeSession = sessions.Metadata{SessionID: "sess-old"}
m.input.SetValue("/new")
updated, _ := m.Update(testKey(tea.KeyEnter))
next := updated.(model)
if next.activeSession.SessionID != "" {
t.Fatalf("expected /new to clear the active session, got %q", next.activeSession.SessionID)
}
}
func TestNewCommandDoesNotResetDuringRun(t *testing.T) {
m := newModel(context.Background(), Options{})
m.activeSession = sessions.Metadata{SessionID: "sess-old"}
m.pending = true
m.input.SetValue("/new")
updated, _ := m.Update(testKey(tea.KeyEnter))
next := updated.(model)
// The safety invariant: /new must never strand an in-flight session.
if next.activeSession.SessionID != "sess-old" {
t.Fatalf("/new must not reset an in-flight session, got %q", next.activeSession.SessionID)
}
}
+4
View File
@@ -64,6 +64,10 @@ type Options struct {
// Notify configures completion / awaiting-input notifications.
Notify config.NotifyConfig
// KeyBindings configures remappable TUI keybindings. An empty/zero
// KeyBindingsConfig means "use built-in defaults" for each action.
KeyBindings config.KeyBindingsConfig
// AltScreen tells the model it is running inside Bubble Tea's alternate
// screen. Run sets this for the interactive app; tests can leave it false
// to exercise the native scrollback renderer.
+39 -1
View File
@@ -43,6 +43,10 @@ type pickerItem struct {
Remote bool
Local bool
Favorite bool
// FastVariant is the display name of this model's faster same-family variant
// (empty when it has none). Set only for /model rows; drives the "f" fast-mode
// badge and the focused-row "⚡ fast: … · /fast on" hint.
FastVariant string
}
// commandPicker is a generic single-select overlay reused by /model and /effort
@@ -155,9 +159,35 @@ func (m model) newModelPicker() *commandPicker {
if len(items) == 0 {
return nil
}
decorateFastVariantItems(registry, items)
return &commandPicker{kind: pickerModel, title: "Choose a model", items: items, allItems: append([]pickerItem{}, items...), selected: 0}
}
// decorateFastVariantItems stamps each /model row with the display name of its
// fast variant (if any), in one pass over the assembled list rather than in every
// item builder. Rows whose model id is not in the curated registry (custom or
// live-discovered) simply keep an empty FastVariant and show no badge.
func decorateFastVariantItems(registry modelregistry.Registry, items []pickerItem) {
for index := range items {
id := strings.TrimSpace(items[index].Value)
if id == "" {
continue
}
if fast, ok := registry.FastVariant(id); ok {
items[index].FastVariant = fastVariantDisplayName(fast)
}
}
}
// fastVariantDisplayName is the user-facing name of a fast variant, preferring
// its display name and falling back to the id.
func fastVariantDisplayName(entry modelregistry.ModelEntry) string {
if name := strings.TrimSpace(entry.DisplayName); name != "" {
return name
}
return strings.TrimSpace(entry.ID)
}
// modelPickerProviders returns the providers to list in /model: all saved
// providers, falling back to the active profile when none were threaded in.
func (m model) modelPickerProviders() []config.ProviderProfile {
@@ -619,7 +649,15 @@ func (m model) normalizeProfileForProvider(provider providercatalog.Descriptor)
strings.TrimSpace(profile.Name) == "" ||
strings.TrimSpace(profile.CatalogID) == ""
if normalizeIdentity {
profile.Name = provider.ID
// Only canonicalize an empty or generic placeholder name — never clobber a
// real user-provided profile name. The credential store is keyed by
// profile.Name (SecureProviderProfile stores under Name), so rewriting it
// here made the later profileWithCredential lookup miss the saved key and
// rebuild a keyless provider — a 401 on every /model switch for any profile
// named differently from its catalog id (e.g. "my-openai").
if strings.TrimSpace(profile.Name) == "" || genericProviderCatalogID(profile.Name) {
profile.Name = provider.ID
}
profile.CatalogID = provider.ID
}
if strings.TrimSpace(profile.BaseURL) == "" {
+46
View File
@@ -976,3 +976,49 @@ func TestSwitchProviderModelWarmsDiscoveryForTheNewProvider(t *testing.T) {
t.Fatal("switching to ollama should warm both the generic and ollama-specific discovery commands")
}
}
func TestNormalizeProfileForProviderPreservesCustomName(t *testing.T) {
openai, ok := providercatalog.Get("openai")
if !ok {
t.Fatal("openai descriptor missing from catalog")
}
// A first-party provider set up with a custom profile name and the baseURL at
// the catalog default. normalizeIdentity is true here (baseURL matches), but
// the real name must survive: it is the credential-store key, so clobbering it
// dropped the saved key and 401'd every /model switch (issue #440).
m := model{providerProfile: config.ProviderProfile{
Name: "my-openai",
CatalogID: "openai",
BaseURL: openai.DefaultBaseURL,
APIKeyStored: true,
}}
got := m.normalizeProfileForProvider(openai)
if got.Name != "my-openai" {
t.Fatalf("Name = %q, want it preserved as %q (credential store is keyed by Name)", got.Name, "my-openai")
}
if got.CatalogID != "openai" {
t.Fatalf("CatalogID = %q, want it canonicalized to %q", got.CatalogID, "openai")
}
}
func TestNormalizeProfileForProviderCanonicalizesPlaceholderName(t *testing.T) {
openai, ok := providercatalog.Get("openai")
if !ok {
t.Fatal("openai descriptor missing from catalog")
}
// Empty and generic placeholder names still get canonicalized to the catalog
// id — those carry no meaningful credential-store key to preserve.
for _, name := range []string{"", "custom-openai-compatible"} {
m := model{providerProfile: config.ProviderProfile{
Name: name,
BaseURL: openai.DefaultBaseURL,
CatalogID: name,
}}
got := m.normalizeProfileForProvider(openai)
if got.Name != "openai" {
t.Fatalf("Name = %q for placeholder %q, want canonicalized to %q", got.Name, name, "openai")
}
}
}
+114 -8
View File
@@ -297,6 +297,7 @@ type providerWizardState struct {
selectedProvider int
models []providerWizardModel
selectedModel int
providerSearch string
modelSearch string
baseURL string
profileName string
@@ -343,7 +344,18 @@ func providerWizardProviders() []providercatalog.Descriptor {
}
func (wizard *providerWizardState) currentProvider() providercatalog.Descriptor {
if wizard == nil || len(wizard.providers) == 0 {
if wizard == nil {
return providercatalog.Descriptor{}
}
if wizard.step == providerWizardStepProvider {
providers := wizard.filteredProviders()
if len(providers) == 0 {
return providercatalog.Descriptor{}
}
wizard.selectedProvider = clampInt(wizard.selectedProvider, 0, len(providers)-1)
return providers[wizard.selectedProvider]
}
if len(wizard.providers) == 0 {
return providercatalog.Descriptor{}
}
wizard.selectedProvider = clampInt(wizard.selectedProvider, 0, len(wizard.providers)-1)
@@ -399,10 +411,11 @@ func (wizard *providerWizardState) move(delta int) {
}
wizard.selectedMethod = ((wizard.selectedMethod+delta)%len(options) + len(options)) % len(options)
case providerWizardStepProvider:
if len(wizard.providers) == 0 {
providers := wizard.filteredProviders()
if len(providers) == 0 {
return
}
wizard.selectedProvider = ((wizard.selectedProvider+delta)%len(wizard.providers) + len(wizard.providers)) % len(wizard.providers)
wizard.selectedProvider = ((wizard.selectedProvider+delta)%len(providers) + len(providers)) % len(providers)
wizard.selectedModel = 0
wizard.modelSearch = ""
wizard.baseURL = ""
@@ -450,6 +463,21 @@ func (wizard *providerWizardState) advance() {
if wizard.oauthMode {
return
}
if len(wizard.filteredProviders()) == 0 {
return
}
// Resolve the selected provider's index in the full list before clearing
// the search — selectedProvider is an index into the filtered slice, and
// clearing the search swaps to the full list.
if selected := wizard.currentProvider(); selected.ID != "" {
for i, p := range wizard.providers {
if p.ID == selected.ID {
wizard.selectedProvider = i
break
}
}
}
wizard.providerSearch = ""
wizard.refreshModels()
wizard.err = ""
if providerWizardNeedsEndpoint(wizard.currentProvider()) {
@@ -513,6 +541,7 @@ func (wizard *providerWizardState) retreat() {
wizard.step = providerWizardStepMethod
case providerWizardStepEndpoint:
wizard.step = providerWizardStepProvider
wizard.providerSearch = ""
case providerWizardStepName:
wizard.step = providerWizardStepEndpoint
case providerWizardStepCredential:
@@ -628,6 +657,20 @@ func (m model) handleProviderWizardKey(msg tea.KeyMsg) (model, tea.Cmd) {
m.providerWizard.currentProvider().OAuthDeviceFlow {
return m.startProviderDeviceLogin()
}
if m.providerWizard.step == providerWizardStepProvider {
switch {
case keyText(msg) != "":
m.providerWizard.appendProviderSearch(keyRunes(msg))
return m, nil
case keyBackspace(msg):
m.providerWizard.deleteProviderSearchRune()
return m, nil
case keyCtrl(msg, 'u'):
m.providerWizard.providerSearch = ""
m.providerWizard.selectedProvider = 0
return m, nil
}
}
if m.providerWizard.step == providerWizardStepEndpoint {
switch {
case keyText(msg) != "":
@@ -746,6 +789,8 @@ func (m model) handleProviderWizardPaste(content string) (model, tea.Cmd) {
return m, nil
}
switch m.providerWizard.step {
case providerWizardStepProvider:
m.providerWizard.appendProviderSearch([]rune(content))
case providerWizardStepEndpoint:
m.providerWizard.appendBaseURL([]rune(content))
case providerWizardStepName:
@@ -879,6 +924,50 @@ func (wizard *providerWizardState) deleteModelSearchRune() {
wizard.selectedModel = 0
}
func (wizard *providerWizardState) appendProviderSearch(runes []rune) {
for _, r := range runes {
if unicode.IsControl(r) {
continue
}
wizard.providerSearch += string(r)
}
wizard.selectedProvider = 0
}
func (wizard *providerWizardState) deleteProviderSearchRune() {
if wizard.providerSearch == "" {
return
}
runes := []rune(wizard.providerSearch)
wizard.providerSearch = string(runes[:len(runes)-1])
wizard.selectedProvider = 0
}
func (wizard *providerWizardState) filteredProviders() []providercatalog.Descriptor {
if wizard == nil {
return nil
}
query := strings.ToLower(strings.TrimSpace(wizard.providerSearch))
if query == "" {
return append([]providercatalog.Descriptor{}, wizard.providers...)
}
providers := make([]providercatalog.Descriptor, 0, len(wizard.providers))
for _, provider := range wizard.providers {
if providerMatchesQuery(provider, query) {
providers = append(providers, provider)
}
}
return providers
}
func providerMatchesQuery(provider providercatalog.Descriptor, query string) bool {
if query == "" {
return true
}
haystack := strings.ToLower(strings.Join([]string{provider.ID, provider.Name, strings.Join(provider.Aliases, " ")}, " "))
return strings.Contains(haystack, query)
}
func (m model) applyProviderWizard() (model, tea.Cmd) {
wizard := m.providerWizard
if wizard == nil {
@@ -1248,9 +1337,15 @@ func (wizard *providerWizardState) renderProviderStep(width int) []string {
header = "Choose an OAuth provider"
}
lines := []string{zeroTheme.accent.Render(header)}
maxVisible := minInt(maxProviderWizardProvidersVisible, len(wizard.providers))
start := selectableListStart(len(wizard.providers), maxVisible, wizard.selectedProvider)
for offset, provider := range wizard.providers[start : start+maxVisible] {
lines = append(lines, wizard.renderProviderSearch(width))
providers := wizard.filteredProviders()
if len(providers) == 0 {
lines = append(lines, zeroTheme.faint.Render(" no matching providers"))
return lines
}
maxVisible := minInt(maxProviderWizardProvidersVisible, len(providers))
start := selectableListStart(len(providers), maxVisible, wizard.selectedProvider)
for offset, provider := range providers[start : start+maxVisible] {
lines = append(lines, wizard.renderSelectableProvider(width, start+offset, provider))
}
// A failed OAuth attempt leaves the wizard on this list (it does not advance),
@@ -1265,6 +1360,11 @@ func (wizard *providerWizardState) renderProviderStep(width int) []string {
return lines
}
func (wizard *providerWizardState) renderProviderSearch(width int) string {
query := strings.TrimSpace(wizard.providerSearch)
return providerWizardInputLine("search > ", query, "provider name, id, or alias...", width)
}
// providerWizardOAuthErrHint returns a provider-specific next step for a failed
// OAuth login. Hugging Face ships no public client_id (unlike xAI), so a login
// can only work once the operator registers an app and supplies its client_id.
@@ -1467,6 +1567,9 @@ func (wizard *providerWizardState) renderSelectableModel(width int, index int, m
marker = surface(zeroTheme.accent).Render("❯ ")
}
left := marker + surface(zeroTheme.ink).Render(model.displayLabel())
if _, ok := fastVariantEntryFor(model.ID); ok {
left += renderFastBadge(surface(zeroTheme.accent))
}
return fitStyledLine(left, width)
}
@@ -1478,6 +1581,9 @@ func providerWizardModelDetail(model providerWizardModel) string {
if meta := strings.TrimSpace(model.Meta); meta != "" {
parts = append(parts, meta)
}
if fast, ok := fastVariantEntryFor(model.ID); ok {
parts = append(parts, fastVariantHint(fastVariantDisplayName(fast)))
}
return strings.Join(parts, " · ")
}
@@ -1602,7 +1708,7 @@ func providerWizardEndpointError(value string) string {
return "endpoint URL must start with http:// or https://"
}
if parsed.Scheme == "http" && !providerWizardIsLoopbackHost(parsed.Hostname()) {
return "endpoint URL must use https:// unless it is local loopback"
return "endpoint URL must use https:// unless it is local loopback or a private network address"
}
return ""
}
@@ -1613,7 +1719,7 @@ func providerWizardIsLoopbackHost(host string) bool {
return true
}
ip := net.ParseIP(host)
return ip != nil && ip.IsLoopback()
return ip != nil && (ip.IsLoopback() || ip.IsPrivate())
}
func providerWizardDisplayName(provider providercatalog.Descriptor, baseURL string, profileName string) string {
+13 -5
View File
@@ -44,11 +44,19 @@ func (m model) advanceProviderWizard() (model, tea.Cmd) {
// keep/replace/remove before re-entering credentials.
if m.providerWizard.step == providerWizardStepProvider && !m.providerWizard.oauthMode {
if name, ok := m.wizardProviderStoredKey(m.providerWizard.currentProvider()); ok {
m.providerWizard.manageProviderName = name
m.providerWizard.manageKeyCursor = 0
m.providerWizard.err = ""
m.providerWizard.step = providerWizardStepManageKey
return m, nil
// Generic/custom providers (custom-openai-compatible etc.) all share
// the same CatalogID — matching on CatalogID would block creating a
// second instance. Skip ManageKey and fall through to the shared
// advance() path below; the user can overwrite by re-entering the
// same name or create a new one with a different name.
if !providerWizardNeedsEndpoint(m.providerWizard.currentProvider()) {
m.providerWizard.manageProviderName = name
m.providerWizard.manageKeyCursor = 0
m.providerWizard.err = ""
m.providerWizard.step = providerWizardStepManageKey
return m, nil
}
m.providerWizard.manageProviderName = ""
}
}
previous := m.providerWizard.step
+201
View File
@@ -1167,6 +1167,71 @@ func TestProviderWizardManageKeyReplaceAndKeep(t *testing.T) {
}
}
func TestAdvanceProviderWizardCustomSkipsManageKey(t *testing.T) {
tests := []struct {
name string
catalogID string
transport providercatalog.Transport
}{
{
name: "custom-openai-compatible",
catalogID: "custom-openai-compatible",
transport: providercatalog.TransportOpenAICompatible,
},
{
name: "custom-anthropic-compatible",
catalogID: "custom-anthropic-compatible",
transport: providercatalog.TransportAnthropicCompatible,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
m := model{savedProviders: []config.ProviderProfile{
{Name: "my-gateway", CatalogID: tt.catalogID, APIKeyStored: true},
}}
m.providerWizard = &providerWizardState{
step: providerWizardStepProvider,
providers: []providercatalog.Descriptor{
{ID: tt.catalogID, Name: tt.name, Transport: tt.transport},
},
selectedProvider: 0,
}
next, _ := m.advanceProviderWizard()
if next.providerWizard == nil {
t.Fatal("wizard should not be nil after advancing from provider step")
}
if next.providerWizard.step == providerWizardStepManageKey {
t.Fatal("custom provider should skip ManageKey and route to endpoint, got ManageKey")
}
if next.providerWizard.step != providerWizardStepEndpoint {
t.Fatalf("custom provider should route to endpoint step, got step: %v", next.providerWizard.step)
}
})
}
}
func TestAdvanceProviderWizardNamedShowsManageKey(t *testing.T) {
m := model{savedProviders: []config.ProviderProfile{
{Name: "openai", CatalogID: "openai", APIKeyStored: true},
}}
m.providerWizard = &providerWizardState{
step: providerWizardStepProvider,
providers: []providercatalog.Descriptor{
{ID: "openai", Name: "OpenAI", Transport: providercatalog.TransportOpenAI},
},
selectedProvider: 0,
}
next, _ := m.advanceProviderWizard()
if next.providerWizard == nil {
t.Fatal("wizard should not be nil after advancing from provider step")
}
if next.providerWizard.step != providerWizardStepManageKey {
t.Fatalf("named provider should route to ManageKey step, got step: %v", next.providerWizard.step)
}
}
func readProviderWizardConfigFixture(t *testing.T, path string) config.FileConfig {
t.Helper()
@@ -1290,3 +1355,139 @@ func TestApplyProviderWizardPersistFailureLeavesLiveStateUnchanged(t *testing.T)
t.Fatalf("%s = %q, want it unchanged on persist failure (children would diverge from parent)", config.ActiveProviderEnv, got)
}
}
func TestProviderSearchFiltersByNameIDAndAlias(t *testing.T) {
wizard := &providerWizardState{
step: providerWizardStepProvider,
providers: []providercatalog.Descriptor{
{ID: "openai", Name: "OpenAI", Aliases: []string{"chatgpt"}},
{ID: "anthropic", Name: "Anthropic", Aliases: []string{"claude"}},
{ID: "google", Name: "Google", Aliases: []string{"gemini"}},
{ID: "groq", Name: "Groq", Aliases: []string{}},
},
}
tests := []struct {
query string
want []string
}{
{"openai", []string{"openai"}},
{"anthropic", []string{"anthropic"}},
{"claude", []string{"anthropic"}},
{"gemini", []string{"google"}},
{"chatgpt", []string{"openai"}},
{"groq", []string{"groq"}},
{"GROQ", []string{"groq"}},
{"nomatch", nil},
{"", []string{"openai", "anthropic", "google", "groq"}},
}
for _, tt := range tests {
t.Run("query="+tt.query, func(t *testing.T) {
wizard.providerSearch = tt.query
got := wizard.filteredProviders()
if len(got) != len(tt.want) {
t.Fatalf("filteredProviders(%q) = %d results, want %d", tt.query, len(got), len(tt.want))
}
for i, id := range tt.want {
if got[i].ID != id {
t.Fatalf("filteredProviders(%q)[%d] = %q, want %q", tt.query, i, got[i].ID, id)
}
}
})
}
}
func TestProviderSearchResetsSelectedProviderOnEdit(t *testing.T) {
m := newModel(context.Background(), Options{})
m = openProviderWizardForTest(t, m)
// Move to a later provider, then type a search query.
m.providerWizard.selectedProvider = 3
updated, _ := m.Update(testKeyText("anth"))
next := updated.(model)
if next.providerWizard.selectedProvider != 0 {
t.Fatalf("selectedProvider after typing = %d, want 0 (reset on edit)", next.providerWizard.selectedProvider)
}
}
func TestProviderSearchFilteredAdvanceSelectsCorrectProvider(t *testing.T) {
wizard := &providerWizardState{
step: providerWizardStepProvider,
providers: []providercatalog.Descriptor{
{ID: "openai", Name: "OpenAI", AuthEnvVars: []string{"OPENAI_API_KEY"}, RequiresAuth: true},
{ID: "anthropic", Name: "Anthropic", AuthEnvVars: []string{"ANTHROPIC_API_KEY"}, RequiresAuth: true},
{ID: "google", Name: "Google", AuthEnvVars: []string{"GOOGLE_API_KEY"}, RequiresAuth: true},
{ID: "groq", Name: "Groq", AuthEnvVars: []string{"GROQ_API_KEY"}, RequiresAuth: true},
},
selectedProvider: 0,
}
// Type a search that matches exactly "groq".
wizard.providerSearch = "groq"
filtered := wizard.filteredProviders()
if len(filtered) != 1 || filtered[0].ID != "groq" {
t.Fatalf("expected exactly groq in filtered results, got %v", providerWizardIDs(filtered))
}
// currentProvider() via filtered list should be groq.
if got := wizard.currentProvider().ID; got != "groq" {
t.Fatalf("currentProvider() = %q, want groq", got)
}
}
func TestProviderSearchEmptyMatchEnterIsNoop(t *testing.T) {
m := newModel(context.Background(), Options{})
m = openProviderWizardForTest(t, m)
// Type a query that matches nothing.
updated, _ := m.Update(testKeyText("zzzzz"))
next := updated.(model)
if len(next.providerWizard.filteredProviders()) != 0 {
t.Fatal("expected zero filtered providers")
}
// Enter should be a no-op.
updated, _ = next.Update(testKey(tea.KeyEnter))
next = updated.(model)
if next.providerWizard.step != providerWizardStepProvider {
t.Fatalf("wizard step = %v, want provider (empty match Enter should be no-op)", next.providerWizard.step)
}
if next.providerWizard.providerSearch != "zzzzz" {
t.Fatalf("providerSearch = %q, want query preserved on noop", next.providerWizard.providerSearch)
}
}
func TestProviderSearchClearRestoresFullList(t *testing.T) {
m := newModel(context.Background(), Options{})
m = openProviderWizardForTest(t, m)
initialCount := len(m.providerWizard.filteredProviders())
if initialCount == 0 {
t.Fatal("expected providers in the wizard")
}
// Type a search query.
updated, _ := m.Update(testKeyText("open"))
next := updated.(model)
filtered := next.providerWizard.filteredProviders()
if len(filtered) >= initialCount {
t.Fatalf("search should narrow results: got %d, want < %d", len(filtered), initialCount)
}
// Ctrl+U clears the search.
updated, _ = next.Update(testKeyCtrl('u'))
next = updated.(model)
restored := next.providerWizard.filteredProviders()
if len(restored) != initialCount {
t.Fatalf("after clear, filtered count = %d, want %d", len(restored), initialCount)
}
}
func providerWizardIDs(descriptors []providercatalog.Descriptor) []string {
ids := make([]string, len(descriptors))
for i, d := range descriptors {
ids[i] = d.ID
}
return ids
}
+55
View File
@@ -11,6 +11,7 @@ import (
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/sessions"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/usage"
)
const tuiSessionTitleLimit = 80
@@ -39,6 +40,60 @@ func (m model) ensureActiveSession(prompt string) (model, error) {
return m, nil
}
// startNewSession abandons the visible conversation and the agent's in-context
// history and begins a fresh session in place. The current session already lives
// on disk (its events are persisted as they happen), so it stays resumable via
// /resume; here we only clear the in-memory conversation and the per-session
// usage/compaction counters, then let the next prompt lazily create a new session
// through ensureActiveSession — the same seam a cold start uses. Model, provider,
// permission mode, and response style are intentionally preserved: /new starts a
// clean conversation, not a clean configuration.
func (m model) startNewSession() model {
previousID := m.activeSession.SessionID
m.activeSession = sessions.Metadata{}
m.sessionEvents = nil
// Reset the per-session usage + compaction display so the new session starts
// from zero instead of inheriting the previous conversation's token/cost totals.
if m.usageTracker != nil {
m.usageTracker.Reset()
}
m.lastUsage = usage.Normalized{}
m.lastUsageSeen = false
m.unpricedRequests = 0
m.unpricedTokens = 0
m.compactRequests = 0
m.compactFrame = 0
m.lastCompactResult = nil
m.lastCompactError = ""
m.turnLatencySum = 0
m.turnLatencyCount = 0
m.turnTTFTSum = 0
m.turnTTFTCount = 0
// Staged input belongs to the previous conversation. Attachments and a queued
// message are only consumed at prompt-submit, so without clearing them here the
// fresh session's first prompt would silently inherit the old session's images,
// documents, or queued text.
m.pendingImages = nil
m.pendingImageLabels = nil
m.pendingDocuments = nil
m.queuedMessage = ""
note := "Started a new session."
if previousID != "" {
note = "New session started. Previous session saved as " + previousID +
" — resume it anytime with /resume " + previousID + "."
}
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionClear})
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: note})
// 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 ·")
return m
}
func (m model) appendSessionEvent(eventType sessions.EventType, payload any) (model, error) {
if m.activeSession.SessionID == "" {
return m, nil
+19
View File
@@ -803,6 +803,25 @@ func (m model) usageSummaryText() string {
return usage.FormatSummary(summary) + "; " + formatUnpricedUsage(m.unpricedRequests, m.unpricedTokens)
}
// cacheEfficiencyText reports the session's prompt-cache hit rate for /context so a
// user can see whether cache reads are actually saving work. A low rate across
// several turns usually means the cacheable prefix is churning (e.g. a tool list
// that shifts mid-session), so it flags that case to make slowness diagnosable.
func (m model) cacheEfficiencyText() string {
if m.usageTracker == nil {
return "usage unavailable"
}
summary := m.usageTracker.Summary()
if summary.InputTokens <= 0 {
return "n/a"
}
text := usage.FormatCacheEfficiency(summary)
if summary.RecordCount > 5 && summary.CacheHitRate() < 0.5 {
text += " — low; prefix may be unstable"
}
return text
}
func formatUnpricedUsage(requests int, tokens int) string {
requestLabel := "requests"
if requests == 1 {
+7 -7
View File
@@ -148,21 +148,21 @@ func TestStyleCommandListsAndSetsSessionPreference(t *testing.T) {
if cmd != nil {
t.Fatal("expected /style to be handled without starting an agent run")
}
if !transcriptContains(next.transcript, "active style: balanced") || !transcriptContains(next.transcript, "concise") {
if !transcriptContains(next.transcript, "active style: concise") || !transcriptContains(next.transcript, "explanatory") {
t.Fatalf("expected style list transcript, got %#v", next.transcript)
}
next.input.SetValue("/style concise")
next.input.SetValue("/style explanatory")
updated, cmd = next.Update(testKey(tea.KeyEnter))
next = updated.(model)
if cmd != nil {
t.Fatal("expected /style concise to be handled without starting an agent run")
t.Fatal("expected /style explanatory to be handled without starting an agent run")
}
if next.responseStyle != "concise" {
t.Fatalf("expected concise style, got %q", next.responseStyle)
if next.responseStyle != "explanatory" {
t.Fatalf("expected explanatory style, got %q", next.responseStyle)
}
if !transcriptContains(next.transcript, "active style: concise") {
if !transcriptContains(next.transcript, "active style: explanatory") {
t.Fatalf("expected style switch transcript, got %#v", next.transcript)
}
}
@@ -621,7 +621,7 @@ func TestUsageEventsUpdateFooterAndContext(t *testing.T) {
if cmd != nil {
t.Fatal("expected /context to be handled without starting an agent run")
}
for _, want := range []string{"usage 1 request, 120 tokens", "style balanced", "effort auto", "compaction idle"} {
for _, want := range []string{"usage 1 request, 120 tokens", "style concise", "effort auto", "compaction idle"} {
if !transcriptContains(next.transcript, want) {
t.Fatalf("expected context transcript to contain %q, got %#v", want, next.transcript)
}
+29 -1
View File
@@ -47,6 +47,34 @@ func (m model) sidebarActive() bool {
return !m.sidebarHidden && m.sidebarAvailable()
}
// sidebarToggleAllowed reports whether the toggle-sidebar keybinding should
// respond. Unlike sidebarAvailable it OMITS the content check
// (sidebarHasContent) so the user can toggle their show/hide preference even
// when the sidebar auto-hid due to having nothing to show. The content gate
// is still applied at render time (sidebarActive chains sidebarAvailable), so
// toggling on when there's no content just records the preference for when
// content arrives — the sidebar stays hidden until then.
func (m model) sidebarToggleAllowed() bool {
if !m.altScreen || m.height <= 0 || m.subchat.active {
return false
}
if sidebarWidth(m.width) <= 0 {
return false
}
if widthTier(m.width) < tierMedium {
return false
}
if m.setup.visible || m.providerWizard != nil || m.mcpAddWizard != nil ||
m.mcpManager != nil || m.picker != nil || m.suggestionsActive() {
return false
}
// Home/welcome screen: stay single-column until there's real conversation.
if m.transcriptEmpty() {
return false
}
return true
}
// sidebarAvailable reports whether the two-column layout CAN render: only in
// alt-screen managed mode, with a measured height, on a wide-enough terminal,
// outside subchat/overlays, with real conversation. It ignores the user's Ctrl+B
@@ -70,7 +98,7 @@ func (m model) sidebarAvailable() bool {
// list) take over the chat column and render at full width; suppress the
// second column while any is active so their geometry and mouse hit-testing
// stay full-width as before.
if m.setup.visible || m.providerWizard != nil || m.mcpAddWizard != nil ||
if m.setup.visible || m.helpOverlay || m.providerWizard != nil || m.mcpAddWizard != nil ||
m.mcpManager != nil || m.picker != nil || m.suggestionsActive() {
return false
}
+32 -7
View File
@@ -153,16 +153,23 @@ func (m model) titlePRSegment() string {
func (m model) titleModelSegment() string {
provider := strings.TrimSpace(m.providerDisplayName())
model := strings.TrimSpace(m.modelName)
var segment string
switch {
case provider == "" && model == "":
return zeroTheme.muted.Render("no provider")
case model == "":
return zeroTheme.ink.Render(provider)
case provider == "":
return zeroTheme.ink.Render(model)
segment = zeroTheme.ink.Render(model)
default:
return zeroTheme.ink.Render(provider + "/" + model)
segment = zeroTheme.ink.Render(provider + "/" + model)
}
// Mark the active model with a quiet "f" when a fast variant is available, so
// the user knows /fast is on offer here without opening a picker.
if _, ok := m.currentModelFastVariant(); ok {
segment += renderFastBadge(zeroTheme.accent)
}
return segment
}
func (m model) composerDividerLine(width int) string {
@@ -386,17 +393,24 @@ func (m model) contextWindowSegment() string {
}
// humanCount renders a token count the way the status line wants it: 999,
// 12.4K, 200K.
// 12.4K, 200K, 1M, 1.2M.
func humanCount(n int) string {
if n < 0 {
n = 0
}
if n < 1000 {
switch {
case n < 1000:
return strconv.Itoa(n)
case n < 1_000_000:
return humanCountScaled(float64(n)/1000, "K")
default:
return humanCountScaled(float64(n)/1_000_000, "M")
}
value := float64(n) / 1000
text := fmt.Sprintf("%.1fK", value)
return strings.Replace(text, ".0K", "K", 1)
}
func humanCountScaled(value float64, suffix string) string {
text := fmt.Sprintf("%.1f%s", value, suffix)
return strings.Replace(text, ".0"+suffix, suffix, 1)
}
// formatContextWindow renders a model's context window for the title bar
@@ -859,6 +873,9 @@ func modelPickerOverlayWidth(terminalWidth int, picker *commandPicker) int {
if item.Favorite {
labelWidth += lipgloss.Width("* ")
}
if strings.TrimSpace(item.FastVariant) != "" {
labelWidth += lipgloss.Width(renderFastBadge(zeroTheme.accent))
}
target = maxInt(target, lipgloss.Width("❯ ")+labelWidth)
if detail := modelPickerItemDetail(item); detail != "" {
target = maxInt(target, lipgloss.Width(" "+detail))
@@ -895,6 +912,11 @@ func renderModelPickerRow(width int, selected bool, item pickerItem) string {
prefix = "* "
}
left := marker + surface(zeroTheme.ink).Render(prefix+label)
// A quiet "f" badge marks rows whose model has a fast variant; the focused-row
// detail line spells out the target and command.
if strings.TrimSpace(item.FastVariant) != "" {
left += renderFastBadge(surface(zeroTheme.accent))
}
// The provider is shown as a section header above each group, so rows no longer
// repeat it as a right-aligned tag (matches a grouped provider+model list).
return fillPaletteLine(left, width, surface)
@@ -910,6 +932,9 @@ func modelPickerItemDetail(item pickerItem) string {
if meta := strings.TrimSpace(item.Meta); meta != "" {
parts = append(parts, meta)
}
if hint := fastVariantHint(item.FastVariant); hint != "" {
parts = append(parts, hint)
}
return strings.Join(parts, " · ")
}
+269
View File
@@ -0,0 +1,269 @@
package update
import (
"context"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"time"
"github.com/Gitlawb/zero/internal/release"
)
// DefaultDownloadTimeout bounds the archive/checksum download phase of a
// standalone Apply, separately from Options.Timeout (which only covers the
// small release-metadata check), so a stalled connection can't hang forever.
const DefaultDownloadTimeout = 5 * time.Minute
// ApplyResult reports the outcome of Apply.
type ApplyResult struct {
Result
Applied bool `json:"applied"`
InstallMethod InstallMethod `json:"installMethod,omitempty"`
BinaryPath string `json:"binaryPath,omitempty"`
Message string `json:"message,omitempty"`
Warnings []string `json:"warnings,omitempty"`
}
// windowsOptionalBinaries/linuxOptionalBinaries mirror the helper binary
// names scripts/postinstall.mjs copies alongside the main binary when
// present, so `zero upgrade` refreshes them too instead of leaving them stale.
var (
windowsOptionalBinaries = []string{"zero-windows-command-runner.exe", "zero-windows-sandbox-setup.exe"}
linuxOptionalBinaries = []string{"zero-linux-sandbox", "zero-seccomp"}
)
// Apply checks for an update and, if one is available, installs it: via
// `npm install -g` for npm-managed installs, or by downloading, verifying,
// and atomically replacing the binary for standalone installs.
func Apply(ctx context.Context, options Options) (ApplyResult, error) {
checkResult, err := Check(ctx, options)
if err != nil {
return ApplyResult{}, err
}
executablePath, err := os.Executable()
if err != nil {
return ApplyResult{}, fmt.Errorf("resolve current executable: %w", err)
}
if resolved, err := filepath.EvalSymlinks(executablePath); err == nil {
executablePath = resolved
}
// Best-effort: remove a "<binary>.old" left behind by a previous Windows
// replaceBinary call now that enough time (a whole separate invocation)
// has passed for the old process to have released the file. Runs
// regardless of whether an update is available, so it isn't stuck waiting
// on a future upgrade that may never come.
CleanupStaleBinary(executablePath)
if !checkResult.UpdateAvailable {
return ApplyResult{Result: checkResult, Message: "already up to date"}, nil
}
method := DetectInstallMethod(executablePath)
switch method {
case InstallMethodNpm:
if err := applyNpmUpdate(ctx); err != nil {
return ApplyResult{}, err
}
return ApplyResult{
Result: checkResult,
Applied: true,
InstallMethod: method,
BinaryPath: executablePath,
Message: fmt.Sprintf("updated via npm to %s", checkResult.LatestVersion),
}, nil
default:
warnings, err := applyStandaloneUpdate(ctx, checkResult, executablePath)
if err != nil {
return ApplyResult{}, err
}
return ApplyResult{
Result: checkResult,
Applied: true,
InstallMethod: method,
BinaryPath: executablePath,
Message: fmt.Sprintf("updated to %s", checkResult.LatestVersion),
Warnings: warnings,
}, nil
}
}
// FormatApply renders an ApplyResult as human-readable text.
func FormatApply(result ApplyResult) string {
if !result.Applied {
return Format(result.Result)
}
lines := []string{
fmt.Sprintf("[zero] %s (%s -> %s)", result.Message, result.CurrentVersion, result.LatestVersion),
"Binary: " + result.BinaryPath,
}
for _, warning := range result.Warnings {
lines = append(lines, "Warning: "+warning)
}
return strings.Join(lines, "\n")
}
func applyNpmUpdate(ctx context.Context) error {
npmPath, err := exec.LookPath("npm")
if err != nil {
return fmt.Errorf("npm not found on PATH: reinstall with `npm install -g %s@latest`", npmPackageName)
}
command := exec.CommandContext(ctx, npmPath, "install", "-g", npmPackageName+"@latest")
command.Stdout = os.Stdout
command.Stderr = os.Stderr
if err := command.Run(); err != nil {
return fmt.Errorf("npm install -g %s@latest: %w", npmPackageName, err)
}
return nil
}
func applyStandaloneUpdate(ctx context.Context, result Result, executablePath string) ([]string, error) {
asset := result.ReleaseAsset
if !asset.Verified {
return nil, fmt.Errorf("release asset for %s-%s could not be verified", asset.Platform, asset.Arch)
}
tempDir, err := os.MkdirTemp("", "zero-update-*")
if err != nil {
return nil, err
}
defer func() {
_ = os.RemoveAll(tempDir)
}()
downloadCtx, cancel := context.WithTimeout(ctx, DefaultDownloadTimeout)
defer cancel()
archivePath := filepath.Join(tempDir, asset.ArchiveName)
if err := downloadFile(downloadCtx, asset.ArchiveURL, archivePath); err != nil {
return nil, fmt.Errorf("download release archive: %w", err)
}
checksumPath := filepath.Join(tempDir, asset.ChecksumName)
if err := downloadFile(downloadCtx, asset.ChecksumURL, checksumPath); err != nil {
return nil, fmt.Errorf("download release checksum: %w", err)
}
if _, err := release.VerifySHA256Checksum(checksumPath); err != nil {
return nil, fmt.Errorf("verify release checksum: %w", err)
}
extractDir := filepath.Join(tempDir, "extracted")
if err := os.MkdirAll(extractDir, 0o755); err != nil {
return nil, err
}
if err := extractArchive(archivePath, extractDir); err != nil {
return nil, fmt.Errorf("extract release archive: %w", err)
}
binaryName := "zero"
optionalBinaries := linuxOptionalBinaries
if runtime.GOOS == "windows" {
binaryName = "zero.exe"
optionalBinaries = windowsOptionalBinaries
} else if runtime.GOOS == "darwin" {
optionalBinaries = nil
}
newBinaryPath, err := findByBasename(extractDir, binaryName)
if err != nil {
return nil, err
}
if newBinaryPath == "" {
return nil, fmt.Errorf("release archive did not contain %s", binaryName)
}
targetDir := filepath.Dir(executablePath)
if err := installBinary(newBinaryPath, executablePath); err != nil {
return nil, err
}
var warnings []string
for _, name := range optionalBinaries {
source, err := findByBasename(extractDir, name)
if err != nil || source == "" {
continue // optional: the sandbox degrades gracefully without it
}
destPath := filepath.Join(targetDir, name)
if _, err := os.Stat(destPath); err != nil {
continue // only refresh helpers this install already has
}
if err := installBinary(source, destPath); err != nil {
warnings = append(warnings, fmt.Sprintf("failed to update helper %s: %v", name, err))
}
}
return warnings, 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 {
stagedPath := targetPath + ".new"
if err := copyFile(sourcePath, stagedPath); err != nil {
return fmt.Errorf("stage %s: %w", filepath.Base(targetPath), err)
}
defer func() {
_ = os.Remove(stagedPath)
}()
if err := replaceBinary(targetPath, stagedPath); err != nil {
return fmt.Errorf("install %s: %w", filepath.Base(targetPath), err)
}
return nil
}
func copyFile(sourcePath string, destPath string) (retErr error) {
source, err := os.Open(sourcePath)
if err != nil {
return err
}
defer func() {
_ = source.Close()
}()
dest, err := os.OpenFile(destPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o755)
if err != nil {
return err
}
defer func() {
if closeErr := dest.Close(); closeErr != nil && retErr == nil {
retErr = closeErr
}
}()
_, retErr = io.Copy(dest, source)
return retErr
}
func downloadFile(ctx context.Context, url string, destPath string) error {
if url == "" {
return fmt.Errorf("missing download URL")
}
request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return err
}
request.Header.Set("User-Agent", "zero/update")
response, err := http.DefaultClient.Do(request)
if err != nil {
return err
}
defer func() {
_ = response.Body.Close()
}()
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
return fmt.Errorf("download failed (%s): %s", response.Status, url)
}
out, err := os.Create(destPath)
if err != nil {
return err
}
_, copyErr := io.Copy(out, response.Body)
closeErr := out.Close()
if copyErr != nil {
return copyErr
}
return closeErr
}
+302
View File
@@ -0,0 +1,302 @@
package update
import (
"context"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/release"
)
func TestApplyReturnsNoopWhenUpToDate(t *testing.T) {
result, err := Apply(context.Background(), Options{
CurrentVersion: "0.2.0",
GOOS: "linux",
GOARCH: "amd64",
Fetch: func(_ context.Context, endpoint string) (Release, error) {
return releaseForTarget(t, "v0.2.0", "linux", "amd64"), nil
},
})
if err != nil {
t.Fatalf("Apply returned error: %v", err)
}
if result.Applied {
t.Fatalf("expected Applied=false when already up to date, got %#v", result)
}
}
func TestApplyStandaloneUpdateReplacesBinary(t *testing.T) {
binaryName := "zero"
// macOS ships no optional helper binaries (matching scripts/postinstall.mjs),
// so there's nothing to refresh there; only linux/windows have one to check.
optionalName := ""
switch runtime.GOOS {
case "windows":
binaryName = "zero.exe"
optionalName = "zero-windows-command-runner.exe"
case "linux":
optionalName = "zero-seccomp"
}
installDir := t.TempDir()
executablePath := filepath.Join(installDir, binaryName)
if err := os.WriteFile(executablePath, []byte("old-binary"), 0o755); err != nil {
t.Fatalf("WriteFile executable: %v", err)
}
// Only pre-existing optional helpers should be refreshed; an absent one
// (e.g. the platform's other optional helper) must not be introduced.
var existingHelperPath string
if optionalName != "" {
existingHelperPath = filepath.Join(installDir, optionalName)
if err := os.WriteFile(existingHelperPath, []byte("old-helper"), 0o755); err != nil {
t.Fatalf("WriteFile helper: %v", err)
}
}
archiveName := "zero-v0.2.0-linux-x64.tar.gz"
archiveDir := t.TempDir()
archivePath := filepath.Join(archiveDir, archiveName)
writeTestTarGz(t, archivePath, map[string]string{
"zero": "new-binary",
"zero.exe": "new-binary-exe",
"zero-seccomp": "new-helper",
"zero-windows-command-runner.exe": "new-helper-exe",
})
checksum, err := release.SHA256File(archivePath)
if err != nil {
t.Fatalf("SHA256File: %v", err)
}
checksumText, err := release.FormatSHA256Checksum(checksum, archiveName)
if err != nil {
t.Fatalf("FormatSHA256Checksum: %v", err)
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/" + archiveName:
http.ServeFile(w, r, archivePath)
case "/" + archiveName + ".sha256":
_, _ = w.Write([]byte(checksumText))
default:
http.NotFound(w, r)
}
}))
defer server.Close()
result := Result{
LatestVersion: "0.2.0",
ReleaseAsset: AssetCheck{
Platform: "linux",
Arch: "x64",
ArchiveName: archiveName,
ArchiveURL: server.URL + "/" + archiveName,
ChecksumName: archiveName + ".sha256",
ChecksumURL: server.URL + "/" + archiveName + ".sha256",
ArchiveFound: true,
ChecksumFound: true,
Verified: true,
},
}
if warnings, err := applyStandaloneUpdate(context.Background(), result, executablePath); err != nil {
t.Fatalf("applyStandaloneUpdate returned error: %v", err)
} else if len(warnings) != 0 {
t.Fatalf("unexpected warnings: %v", warnings)
}
data, err := os.ReadFile(executablePath)
if err != nil {
t.Fatalf("ReadFile executable: %v", err)
}
wantBinary := "new-binary"
if runtime.GOOS == "windows" {
wantBinary = "new-binary-exe"
}
if string(data) != wantBinary {
t.Fatalf("executable content = %q, want %q", data, wantBinary)
}
if optionalName != "" {
helperData, err := os.ReadFile(existingHelperPath)
if err != nil {
t.Fatalf("ReadFile helper: %v", err)
}
wantHelper := "new-helper"
if runtime.GOOS == "windows" {
wantHelper = "new-helper-exe"
}
if string(helperData) != wantHelper {
t.Fatalf("helper content = %q, want %q", helperData, wantHelper)
}
}
if entries, err := os.ReadDir(installDir); err == nil {
for _, entry := range entries {
name := entry.Name()
// On Windows, replaceBinary leaves "<name>.old" behind (the running
// binary is renamed aside, not deleted) for later best-effort cleanup.
if name == binaryName || (optionalName != "" && name == optionalName) || name == binaryName+".old" || (optionalName != "" && name == optionalName+".old") {
continue
}
t.Fatalf("unexpected extra file left in install dir: %s", name)
}
}
}
func TestApplyStandaloneUpdateWarnsWhenHelperRefreshFails(t *testing.T) {
binaryName := "zero"
optionalName := "zero-seccomp"
if runtime.GOOS == "windows" {
binaryName = "zero.exe"
optionalName = "zero-windows-command-runner.exe"
} else if runtime.GOOS == "darwin" {
t.Skip("macOS ships no optional helper binaries to refresh")
}
installDir := t.TempDir()
executablePath := filepath.Join(installDir, binaryName)
if err := os.WriteFile(executablePath, []byte("old-binary"), 0o755); err != nil {
t.Fatalf("WriteFile executable: %v", err)
}
// The helper must already exist for a refresh to be attempted at all.
existingHelperPath := filepath.Join(installDir, optionalName)
if err := os.WriteFile(existingHelperPath, []byte("old-helper"), 0o755); err != nil {
t.Fatalf("WriteFile helper: %v", err)
}
// Force installBinary's staging copy to fail by occupying its staged
// "<helper>.new" path with a directory instead of a file.
if err := os.MkdirAll(existingHelperPath+".new", 0o755); err != nil {
t.Fatalf("MkdirAll staged path: %v", err)
}
archiveName := "zero-v0.2.0-linux-x64.tar.gz"
archiveDir := t.TempDir()
archivePath := filepath.Join(archiveDir, archiveName)
writeTestTarGz(t, archivePath, map[string]string{
"zero": "new-binary",
"zero.exe": "new-binary-exe",
"zero-seccomp": "new-helper",
"zero-windows-command-runner.exe": "new-helper-exe",
})
checksum, err := release.SHA256File(archivePath)
if err != nil {
t.Fatalf("SHA256File: %v", err)
}
checksumText, err := release.FormatSHA256Checksum(checksum, archiveName)
if err != nil {
t.Fatalf("FormatSHA256Checksum: %v", err)
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/" + archiveName:
http.ServeFile(w, r, archivePath)
case "/" + archiveName + ".sha256":
_, _ = w.Write([]byte(checksumText))
default:
http.NotFound(w, r)
}
}))
defer server.Close()
result := Result{
LatestVersion: "0.2.0",
ReleaseAsset: AssetCheck{
Platform: "linux",
Arch: "x64",
ArchiveName: archiveName,
ArchiveURL: server.URL + "/" + archiveName,
ChecksumName: archiveName + ".sha256",
ChecksumURL: server.URL + "/" + archiveName + ".sha256",
ArchiveFound: true,
ChecksumFound: true,
Verified: true,
},
}
warnings, err := applyStandaloneUpdate(context.Background(), result, executablePath)
if err != nil {
t.Fatalf("applyStandaloneUpdate returned error: %v", err)
}
if len(warnings) != 1 || !strings.Contains(warnings[0], optionalName) {
t.Fatalf("expected one warning mentioning %s, got %v", optionalName, warnings)
}
data, err := os.ReadFile(executablePath)
if err != nil {
t.Fatalf("ReadFile executable: %v", err)
}
wantBinary := "new-binary"
if runtime.GOOS == "windows" {
wantBinary = "new-binary-exe"
}
if string(data) != wantBinary {
t.Fatalf("main binary should still be updated despite helper failure: got %q, want %q", data, wantBinary)
}
}
func TestApplyStandaloneUpdateRejectsChecksumMismatch(t *testing.T) {
binaryName := "zero"
if runtime.GOOS == "windows" {
binaryName = "zero.exe"
}
installDir := t.TempDir()
executablePath := filepath.Join(installDir, binaryName)
if err := os.WriteFile(executablePath, []byte("old-binary"), 0o755); err != nil {
t.Fatalf("WriteFile executable: %v", err)
}
archiveName := "zero-v0.2.0-linux-x64.tar.gz"
archiveDir := t.TempDir()
archivePath := filepath.Join(archiveDir, archiveName)
writeTestTarGz(t, archivePath, map[string]string{"zero": "new-binary", "zero.exe": "new-binary-exe"})
badChecksumText, err := release.FormatSHA256Checksum("0000000000000000000000000000000000000000000000000000000000000000"[:64], archiveName)
if err != nil {
t.Fatalf("FormatSHA256Checksum: %v", err)
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/" + archiveName:
http.ServeFile(w, r, archivePath)
case "/" + archiveName + ".sha256":
_, _ = w.Write([]byte(badChecksumText))
default:
http.NotFound(w, r)
}
}))
defer server.Close()
result := Result{
ReleaseAsset: AssetCheck{
Platform: "linux",
Arch: "x64",
ArchiveName: archiveName,
ArchiveURL: server.URL + "/" + archiveName,
ChecksumName: archiveName + ".sha256",
ChecksumURL: server.URL + "/" + archiveName + ".sha256",
ArchiveFound: true,
ChecksumFound: true,
Verified: true,
},
}
if _, err := applyStandaloneUpdate(context.Background(), result, executablePath); err == nil {
t.Fatal("expected checksum mismatch error")
}
data, err := os.ReadFile(executablePath)
if err != nil {
t.Fatalf("ReadFile executable: %v", err)
}
if string(data) != "old-binary" {
t.Fatalf("executable should be untouched after checksum failure, got %q", data)
}
}
+165
View File
@@ -0,0 +1,165 @@
package update
import (
"archive/tar"
"archive/zip"
"compress/gzip"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"strings"
)
// extractArchive extracts a .tar.gz or .zip release archive into destDir.
func extractArchive(archivePath string, destDir string) error {
if strings.HasSuffix(archivePath, ".zip") {
return extractZip(archivePath, destDir)
}
return extractTarGz(archivePath, destDir)
}
func extractTarGz(archivePath string, destDir string) error {
file, err := os.Open(archivePath)
if err != nil {
return err
}
defer func() {
_ = file.Close()
}()
gzipReader, err := gzip.NewReader(file)
if err != nil {
return err
}
defer func() {
_ = gzipReader.Close()
}()
tarReader := tar.NewReader(gzipReader)
for {
header, err := tarReader.Next()
if err == io.EOF {
return nil
}
if err != nil {
return err
}
target, err := safeExtractPath(destDir, header.Name)
if err != nil {
return err
}
switch header.Typeflag {
case tar.TypeDir:
if err := os.MkdirAll(target, 0o755); err != nil {
return err
}
case tar.TypeReg:
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return err
}
if err := writeExtractedFile(target, tarReader, fs.FileMode(header.Mode)); err != nil {
return err
}
default:
// Release archives only ever contain regular files and directories;
// reject anything else (symlinks, devices) rather than silently skip it.
return fmt.Errorf("unsupported archive entry type for %s", header.Name)
}
}
}
func extractZip(archivePath string, destDir string) error {
reader, err := zip.OpenReader(archivePath)
if err != nil {
return err
}
defer func() {
_ = reader.Close()
}()
for _, entry := range reader.File {
target, err := safeExtractPath(destDir, entry.Name)
if err != nil {
return err
}
if entry.FileInfo().IsDir() {
if err := os.MkdirAll(target, 0o755); err != nil {
return err
}
continue
}
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return err
}
if err := func() error {
entryReader, err := entry.Open()
if err != nil {
return err
}
defer func() {
_ = entryReader.Close()
}()
return writeExtractedFile(target, entryReader, entry.Mode())
}(); err != nil {
return err
}
}
return nil
}
func writeExtractedFile(target string, source io.Reader, mode fs.FileMode) error {
if mode == 0 {
mode = 0o644
}
out, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode)
if err != nil {
return err
}
_, copyErr := io.Copy(out, source)
closeErr := out.Close()
if copyErr != nil {
return copyErr
}
return closeErr
}
// safeExtractPath resolves an archive entry name against destDir, rejecting
// absolute paths or entries that would escape destDir via "..".
func safeExtractPath(destDir string, name string) (string, error) {
cleanName := filepath.Clean(strings.ReplaceAll(name, "\\", "/"))
if cleanName == "." {
return destDir, nil
}
if filepath.IsAbs(cleanName) || cleanName == ".." || strings.HasPrefix(cleanName, "../") {
return "", fmt.Errorf("archive entry escapes destination: %s", name)
}
target := filepath.Join(destDir, cleanName)
destDirClean := filepath.Clean(destDir)
if target != destDirClean && !strings.HasPrefix(target, destDirClean+string(os.PathSeparator)) {
return "", fmt.Errorf("archive entry escapes destination: %s", name)
}
return target, nil
}
// findByBasename recursively searches root for the first regular file whose
// basename matches name, mirroring scripts/postinstall.mjs's lookup so
// helper binaries nested under archive subdirectories (e.g. helpers/) are
// still found.
func findByBasename(root string, name string) (string, error) {
var found string
err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if found != "" {
return fs.SkipAll
}
if !entry.IsDir() && entry.Name() == name {
found = path
}
return nil
})
if err != nil {
return "", err
}
return found, nil
}
+177
View File
@@ -0,0 +1,177 @@
package update
import (
"archive/tar"
"archive/zip"
"compress/gzip"
"os"
"path/filepath"
"testing"
)
func writeTestTarGz(t *testing.T, archivePath string, entries map[string]string) {
t.Helper()
file, err := os.Create(archivePath)
if err != nil {
t.Fatalf("Create archive: %v", err)
}
defer func() { _ = file.Close() }()
gzipWriter := gzip.NewWriter(file)
tarWriter := tar.NewWriter(gzipWriter)
for name, content := range entries {
header := &tar.Header{Name: name, Mode: 0o755, Size: int64(len(content))}
if err := tarWriter.WriteHeader(header); err != nil {
t.Fatalf("WriteHeader %s: %v", name, err)
}
if _, err := tarWriter.Write([]byte(content)); err != nil {
t.Fatalf("Write %s: %v", name, err)
}
}
if err := tarWriter.Close(); err != nil {
t.Fatalf("close tar writer: %v", err)
}
if err := gzipWriter.Close(); err != nil {
t.Fatalf("close gzip writer: %v", err)
}
}
func writeTestZip(t *testing.T, archivePath string, entries map[string]string) {
t.Helper()
file, err := os.Create(archivePath)
if err != nil {
t.Fatalf("Create archive: %v", err)
}
defer func() { _ = file.Close() }()
zipWriter := zip.NewWriter(file)
for name, content := range entries {
writer, err := zipWriter.Create(name)
if err != nil {
t.Fatalf("Create entry %s: %v", name, err)
}
if _, err := writer.Write([]byte(content)); err != nil {
t.Fatalf("Write %s: %v", name, err)
}
}
if err := zipWriter.Close(); err != nil {
t.Fatalf("close zip writer: %v", err)
}
}
func TestExtractTarGzRoundTrip(t *testing.T) {
dir := t.TempDir()
archivePath := filepath.Join(dir, "archive.tar.gz")
writeTestTarGz(t, archivePath, map[string]string{
"zero": "main-binary",
"helpers/zero-seccomp": "helper-binary",
})
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.Fatalf("extractArchive: %v", err)
}
data, err := os.ReadFile(filepath.Join(destDir, "zero"))
if err != nil {
t.Fatalf("ReadFile zero: %v", err)
}
if string(data) != "main-binary" {
t.Fatalf("zero content = %q", data)
}
data, err = os.ReadFile(filepath.Join(destDir, "helpers", "zero-seccomp"))
if err != nil {
t.Fatalf("ReadFile helpers/zero-seccomp: %v", err)
}
if string(data) != "helper-binary" {
t.Fatalf("helpers/zero-seccomp content = %q", data)
}
}
func TestExtractZipRoundTrip(t *testing.T) {
dir := t.TempDir()
archivePath := filepath.Join(dir, "archive.zip")
writeTestZip(t, archivePath, map[string]string{
"zero.exe": "main-binary",
})
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.Fatalf("extractArchive: %v", err)
}
data, err := os.ReadFile(filepath.Join(destDir, "zero.exe"))
if err != nil {
t.Fatalf("ReadFile zero.exe: %v", err)
}
if string(data) != "main-binary" {
t.Fatalf("zero.exe content = %q", data)
}
}
func TestExtractTarGzRejectsPathTraversal(t *testing.T) {
dir := t.TempDir()
archivePath := filepath.Join(dir, "archive.tar.gz")
writeTestTarGz(t, archivePath, map[string]string{
"../escape": "malicious",
})
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 path-traversal entry")
}
if _, err := os.Stat(filepath.Join(dir, "escape")); err == nil {
t.Fatal("path traversal entry was written outside the destination directory")
}
}
func TestExtractZipRejectsPathTraversal(t *testing.T) {
dir := t.TempDir()
archivePath := filepath.Join(dir, "archive.zip")
writeTestZip(t, archivePath, map[string]string{
"../../escape.exe": "malicious",
})
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 path-traversal entry")
}
}
func TestFindByBasenameSearchesRecursively(t *testing.T) {
dir := t.TempDir()
nested := filepath.Join(dir, "helpers")
if err := os.MkdirAll(nested, 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
wantPath := filepath.Join(nested, "zero-seccomp")
if err := os.WriteFile(wantPath, []byte("helper"), 0o755); err != nil {
t.Fatalf("WriteFile: %v", err)
}
found, err := findByBasename(dir, "zero-seccomp")
if err != nil {
t.Fatalf("findByBasename: %v", err)
}
if found != wantPath {
t.Fatalf("findByBasename = %q, want %q", found, wantPath)
}
notFound, err := findByBasename(dir, "does-not-exist")
if err != nil {
t.Fatalf("findByBasename: %v", err)
}
if notFound != "" {
t.Fatalf("findByBasename = %q, want empty", notFound)
}
}
+45
View File
@@ -0,0 +1,45 @@
package update
import (
"encoding/json"
"os"
"path/filepath"
)
// npmPackageName is the published package name for the npm distribution of
// zero (see package.json). scripts/postinstall.mjs downloads the native
// binary into the same directory as package.json and leaves a
// ".zero-binary-version" marker file next to it — both are reliable signals
// that a given executable came from an npm install.
const npmPackageName = "@gitlawb/zero"
// InstallMethod identifies how the running zero binary was installed.
type InstallMethod string
const (
InstallMethodNpm InstallMethod = "npm"
InstallMethodStandalone InstallMethod = "standalone"
)
// DetectInstallMethod inspects the directory containing executablePath for
// npm-install markers left by scripts/postinstall.mjs.
func DetectInstallMethod(executablePath string) InstallMethod {
dir := filepath.Dir(executablePath)
if _, err := os.Stat(filepath.Join(dir, ".zero-binary-version")); err == nil {
return InstallMethodNpm
}
data, err := os.ReadFile(filepath.Join(dir, "package.json"))
if err != nil {
return InstallMethodStandalone
}
var pkg struct {
Name string `json:"name"`
}
if err := json.Unmarshal(data, &pkg); err != nil {
return InstallMethodStandalone
}
if pkg.Name == npmPackageName {
return InstallMethodNpm
}
return InstallMethodStandalone
}
+60
View File
@@ -0,0 +1,60 @@
package update
import (
"os"
"path/filepath"
"testing"
)
func TestDetectInstallMethodStandaloneByDefault(t *testing.T) {
dir := t.TempDir()
exePath := filepath.Join(dir, "zero")
if err := os.WriteFile(exePath, []byte("binary"), 0o755); err != nil {
t.Fatalf("WriteFile: %v", err)
}
if method := DetectInstallMethod(exePath); method != InstallMethodStandalone {
t.Fatalf("DetectInstallMethod = %q, want standalone", method)
}
}
func TestDetectInstallMethodNpmViaMarkerFile(t *testing.T) {
dir := t.TempDir()
exePath := filepath.Join(dir, "zero")
if err := os.WriteFile(exePath, []byte("binary"), 0o755); err != nil {
t.Fatalf("WriteFile: %v", err)
}
if err := os.WriteFile(filepath.Join(dir, ".zero-binary-version"), []byte("0.1.0\n"), 0o644); err != nil {
t.Fatalf("WriteFile marker: %v", err)
}
if method := DetectInstallMethod(exePath); method != InstallMethodNpm {
t.Fatalf("DetectInstallMethod = %q, want npm", method)
}
}
func TestDetectInstallMethodNpmViaPackageJSON(t *testing.T) {
dir := t.TempDir()
exePath := filepath.Join(dir, "zero")
if err := os.WriteFile(exePath, []byte("binary"), 0o755); err != nil {
t.Fatalf("WriteFile: %v", err)
}
if err := os.WriteFile(filepath.Join(dir, "package.json"), []byte(`{"name":"@gitlawb/zero"}`), 0o644); err != nil {
t.Fatalf("WriteFile package.json: %v", err)
}
if method := DetectInstallMethod(exePath); method != InstallMethodNpm {
t.Fatalf("DetectInstallMethod = %q, want npm", method)
}
}
func TestDetectInstallMethodIgnoresUnrelatedPackageJSON(t *testing.T) {
dir := t.TempDir()
exePath := filepath.Join(dir, "zero")
if err := os.WriteFile(exePath, []byte("binary"), 0o755); err != nil {
t.Fatalf("WriteFile: %v", err)
}
if err := os.WriteFile(filepath.Join(dir, "package.json"), []byte(`{"name":"some-other-package"}`), 0o644); err != nil {
t.Fatalf("WriteFile package.json: %v", err)
}
if method := DetectInstallMethod(exePath); method != InstallMethodStandalone {
t.Fatalf("DetectInstallMethod = %q, want standalone", method)
}
}
+19
View File
@@ -0,0 +1,19 @@
//go:build !windows
package update
import "os"
// replaceBinary installs newPath over targetPath. On POSIX systems renaming
// over a running executable is safe: the process currently executing it keeps
// its open inode, and the rename is atomic within the same filesystem.
func replaceBinary(targetPath string, newPath string) error {
if err := os.Chmod(newPath, 0o755); err != nil {
return err
}
return os.Rename(newPath, targetPath)
}
// CleanupStaleBinary is a no-op outside Windows, which is the only platform
// that requires renaming a running binary aside instead of replacing it directly.
func CleanupStaleBinary(targetPath string) {}
+34
View File
@@ -0,0 +1,34 @@
//go:build windows
package update
import (
"fmt"
"os"
)
// replaceBinary installs newPath over targetPath. Windows will not let a
// running executable be overwritten or deleted directly, but NTFS does allow
// renaming it aside — the same trick already used for locked config files in
// internal/cli/mcp_config.go's replaceMCPWritableConfigFile.
func replaceBinary(targetPath string, newPath string) error {
oldPath := targetPath + ".old"
_ = os.Remove(oldPath) // best-effort cleanup of a leftover from a previous upgrade
if err := os.Rename(targetPath, oldPath); err != nil {
return fmt.Errorf("rename running binary aside: %w", err)
}
if err := os.Rename(newPath, targetPath); err != nil {
if restoreErr := os.Rename(oldPath, targetPath); restoreErr != nil {
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)
}
return nil
}
// 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.
func CleanupStaleBinary(targetPath string) {
_ = os.Remove(targetPath + ".old")
}
+23
View File
@@ -0,0 +1,23 @@
package usage
import "testing"
func TestSummaryCacheHitRate(t *testing.T) {
s := Summary{InputTokens: 13100, CachedInputTokens: 8200}
if got := s.CacheHitRate(); got < 0.62 || got > 0.63 {
t.Fatalf("CacheHitRate = %v, want ~0.626", got)
}
if got := (Summary{}).CacheHitRate(); got != 0 {
t.Fatalf("empty CacheHitRate = %v, want 0", got)
}
}
func TestFormatCacheEfficiency(t *testing.T) {
s := Summary{InputTokens: 13100, CachedInputTokens: 8200}
if got := FormatCacheEfficiency(s); got != "63% (8,200 cached / 13,100 input)" {
t.Fatalf("FormatCacheEfficiency = %q", got)
}
if got := FormatCacheEfficiency(Summary{}); got != "n/a" {
t.Fatalf("empty FormatCacheEfficiency = %q, want n/a", got)
}
}
+23
View File
@@ -219,6 +219,29 @@ func FormatSummary(summary Summary) string {
return fmt.Sprintf("%s %s, %s tokens, %s", comma(summary.RecordCount), requestLabel, comma(summary.TotalTokens), summary.FormattedTotalCost)
}
// CacheHitRate is the fraction of input tokens served from the provider's prompt
// cache. CachedInputTokens is clamped to InputTokens in Normalize, so the result is
// always in [0,1]; it is 0 when no input has been recorded.
func (summary Summary) CacheHitRate() float64 {
if summary.InputTokens <= 0 {
return 0
}
return float64(summary.CachedInputTokens) / float64(summary.InputTokens)
}
// FormatCacheEfficiency renders the prompt-cache hit rate for display, e.g.
// "63% (8,200 cached / 13,100 input)", so a user can see whether cache reads are
// actually saving work. Returns "n/a" until some input has been recorded.
func FormatCacheEfficiency(summary Summary) string {
if summary.InputTokens <= 0 {
return "n/a"
}
return fmt.Sprintf("%.0f%% (%s cached / %s input)",
summary.CacheHitRate()*100,
comma(summary.CachedInputTokens),
comma(summary.InputTokens))
}
func addUsageToSummary(summary *Summary, usage Normalized) {
summary.InputTokens += usage.InputTokens
summary.CachedInputTokens += usage.CachedInputTokens
Loaded 100 of 103 files, more files were not shown because too many files have changed in this diff. Show more