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
Abdias J b5a78dca6f docs(install): document Bun install and improve missing-binary error (#418)
Bun does not run dependency lifecycle scripts by default, so the npm
wrapper's postinstall (which fetches the native Zero binary from GitHub
Releases) is silently skipped. The first run then fails with an
unhelpful "No native binary found" message that points at a source-only
build command, leaving Bun users stuck.

Add a Bun section to README.md and docs/INSTALL.md with the two working
install paths (run postinstall.mjs manually, or add the package to the
project's trustedDependencies). Rewrite the wrapper's missing-binary
error to give an actionable fix for everyone and a Bun-specific hint
when run under Bun, instead of only suggesting a source checkout.
2026-07-03 07:38:25 +08:00
KRATOS 952788f72d fix(tui): resolve every permission request so the agent can't deadlock (#397)
A goroutine dump of a run "stuck for 33 minutes" showed the agent parked
forever in executeToolCall -> requestPermission, blocked on the TUI's
decision channel. Root cause: a shell command whose sandbox decision was
Allow but which requested additional sandbox permissions
(sandbox_permissions: with_additional_permissions) made shouldRequestPermission
block on OnPermissionRequest, yet buildPermissionEvent stamped the event
Action=allow. The TUI's permissionRequestMsg handler only rendered/resolved
Action=prompt requests and silently dropped everything else — so the decide
callback was never called, the agent waited on a channel nothing would signal
(no ctx cancellation on the active run), and the UI showed "Working" with no
prompt and zero network activity. Intermittent and model-dependent: gpt-5.5's
folder-creation command set the additional-permissions flag, glm's did not,
which is exactly why one hung and the other ran clean.

Two-part fix:

- agent (buildPermissionEvent): an additional-permissions request is an
  ELEVATION the user must consent to, even over a sandbox Allow, so mark it
  Action=prompt (matching shouldRequestPermission's own condition). It now
  surfaces as a real prompt instead of a dropped auto-allow.

- tui (permissionRequestMsg handler): enforce the contract that a blocked
  agent ALWAYS gets a decision. A non-prompt action resolves immediately and
  FAILS CLOSED (deny; cancel stays cancel) so the UI can never silently grant
  an unsurfaced permission; a superseded-run (runID mismatch) resolves as
  cancel so its parked goroutine unblocks without depending on ctx-cancel
  ordering. Matches the ACP handler's always-resolve, fail-closed contract.

Red-green: reverting the tui branch reproduces the deadlock (decide never
called); reverting the agent override reproduces Action=allow. End-to-end
against a mock backend emitting the exact triggering tool call, the real TUI
now renders the permission prompt and the run completes on approval, where
before it parked forever.
2026-07-03 06:02:57 +08:00
Gautam ManchandaniandGautam Manchandani f915f70e5a fix(mcp): block cross-origin credential redirects (#396)
Co-authored-by: Gautam Manchandani <[email protected]>
2026-07-03 06:02:05 +08:00
Vasanth T cdf9d839ae fix(tui): title /model rows by model name, not the catalog description (#395)
The model picker rendered each row's title from the model's description via
modelPickerDisplayName, which returned the description whenever it was non-empty
and not a known-generic placeholder. Catalog/discovery descriptions come from
models.dev as full sentences (e.g. "DeepSeek chat model for instruction
following, coding, and analysis"), and several models share the same sentence —
so the picker showed sentence-long titles and rows that looked like exact
duplicates, with the real model id surviving only in the meta line.

Prefer the (prettified) model id as the row title; fall back to a non-generic
description only when there is no id to name the row. Distinct ids that share a
catalog description now render as distinct rows.
2026-07-02 23:29:12 +08:00
Gautam ManchandaniandGautam Manchandani 960db9660e fix(tools): require permission before web_search requests (#382)
* fix(tools): gate web_search behind network permission

* chore(tools): clarify web_search permission reason

---------

Co-authored-by: Gautam Manchandani <[email protected]>
2026-07-02 22:14:58 +08:00
Ashwinhegde19andVasanthdev2004 b1ccb6d9c1 feat(providers): add KiloCode and OpenCode provider support (#388)
* feat(providers): add KiloCode and OpenCode provider support

Add KiloCode (Kilo Gateway), OpenCode Zen, and OpenCode Go as catalog
entries using the existing openAICompat transport. No new runtime code
needed — both gateways expose standard OpenAI-compatible /chat/completions.

Fixes #383

* fix(providers): swap OpenCode Zen default model to deepseek-v4-flash

gpt-5.5 is served via /responses on Zen, not /chat/completions.
deepseek-v4-flash works with openAICompat transport.

Co-Authored-By: Vasanthdev2004
2026-07-02 22:10:54 +08:00
Vasanth T 0bc8074c97 feat(providers): add zero providers models to discover a provider's models (#386)
`zero providers` had no way to list the models a provider actually serves, so a
custom OpenAI-/Anthropic-compatible endpoint (e.g. a self-hosted proxy serving a
dozen models) forced users to hand-write a config object per model. Live model
discovery already existed for the TUI picker but had no headless CLI surface.

Add `zero providers models [name] [--json]`: it probes the provider's live
model-listing endpoint (an OpenAI-compatible /v1/models or the Anthropic /models)
via providermodeldiscovery.Discover, using the profile's base URL and resolved
credential (inline -> stored -> env). This uses the raw discovery primitive with
no curated-catalog merge or coding-model filtering, so a custom provider's full
model list is returned. Configure the provider once, run `zero providers models`
to see what it serves, and use any id with `zero exec --model <id>` (Zero passes
unknown model ids through to the provider) -- no per-model config needed.

Wired through a new appDeps.discoverProviderModels hook (default:
providermodeldiscovery.Discover) so the command is unit-tested hermetically.

Fixes #381.
2026-07-02 22:10:08 +08:00
KRATOS 72eed06b4f fix(config): unbrick first-run setup — default google/anthropic models, enter setup on fixable config errors (#385)
* fix(config): default missing google/anthropic models from the catalog

A hand-written provider entry like {name: "google", provider_kind:
"google", apiKey: ...} with no model bricked Zero: config resolution
failed with "provider google requires model", and since zero config and
bare zero setup both resolve up front, the only commands that could have
fixed the config died with the same error (reported in the wild — the
one working escape, zero setup google --model <m>, is undiscoverable
from that message).

The openai kind has always defaulted a missing model
(modelregistry.DefaultModelID); google and anthropic were missed. Fall
back to their provider-catalog default models (gemini-2.5-pro /
claude-sonnet-4.5) the same way, behind the same gate: provider-command
configs (normalizeProvidersWithoutModelDefaults) still surface exactly
what the external command returned. An explicitly configured model
always wins, and profiles with a catalogID already got this via
applyCatalogDescriptor — this covers the catalog-less hand-written case.

The residual requires-model error (openai-compatible customs, which
have no catalog default) now carries an actionable hint: add "model" in
config.json or re-run zero setup with --model.

Tests: google/anthropic defaulting (red-green: reverting the google
fallback reproduces the exact reported error), explicit model wins,
provider-command gate still errors with the hint, and validation treats
official-kind model-less profiles as clean while still flagging customs.

* fix(cli): enter setup on a requires-model config and hint zero auth login

Two residual first-run gaps after the catalog model-defaulting fix:

- An active provider whose model has NO catalog default to fall back on
  (custom openai-/anthropic-compatible endpoints) still bricked bare
  `zero` and `zero setup` — the exact commands that could have repaired
  the config. Tag that resolve failure with ErrProviderRequiresModel and
  extend the interactive TUI's fixable-error fallback (previously only
  ErrNoActiveProvider) to drop into the setup wizard for it. The sentinel
  is attached via a message-preserving wrapper (multi-target Unwrap), so
  headless commands (zero config / zero exec) print the exact actionable
  text unchanged and every other resolve error stays fatal.

- `zero login` / `zero logout` don't exist (it's `zero auth login`);
  first-run users try them and got only "unknown command" (reported in
  the wild). The unknown-command error now answers: did you mean
  "zero auth login"? Unrelated unknown commands get no suggestion.

Tests: the sentinel comes from a REAL Resolve over a real config file
(message-prefix asserted, no key leak); the TUI fallback is red-green
(reverting the condition reproduces the exact brick with the old error);
the existing no-active-provider fallback and other-errors-stay-fatal
tests still pass; login/logout hint with a no-false-positive check.
2026-07-02 22:09:25 +08:00
Kunj SHAH 33dc7ae2cc fix(tools): CRLF line ending mismatch in edit_file tool on Windows (#378)
* fix(tools): CRLF line ending mismatch in edit_file tool on Windows

* fix: resolve gofmt error, add robust CRLF guard, and fix Windows sandbox and OAuth test flakiness
2026-07-02 22:07:26 +08:00
Kunj SHAH 292e1edea5 security(config): project config should not override sandbox.network to allow (#379)
* security(config): project config should not override sandbox.network to allow

* fix(config): use NormalizeNetworkMode in project config merge, per coderabbit review
2026-07-02 21:01:35 +08:00
KRATOS 39e7100674 fix(gemini): strip unsupported JSON Schema fields from tool declarations (#374)
Every exec against the Google/Gemini provider failed: Zero emits tool
parameter schemas in OpenAI's JSON Schema dialect, which always includes
`additionalProperties` (schemaToRuntimeMap writes it unconditionally).
Google's functionDeclarations schema doesn't recognize that keyword and
400s the whole request — "Unknown name \"additionalProperties\" …
Cannot find field" — so no tool-using call ever reached the model.

Sanitize tool parameters in the Gemini mapper (only there — the field is
valid and wanted for OpenAI strict mode and harmless for Anthropic):

- sanitizeGeminiSchema keeps only the keywords Gemini's Schema type
  supports (type, description, enum, properties, required, items, anyOf,
  default, nullable, format, and the min/max/length constraints) via an
  allowlist, so any future OpenAI-ism or MCP-supplied field can't leak.
- Recurses through properties, items, and anyOf so nested schemas are
  cleaned at every depth, not just the top level.

Tests: a unit test on the sanitizer (nesting, legit fields preserved,
nil-safe) and an end-to-end test asserting the outgoing request body's
functionDeclarations[].parameters carry no additionalProperties while the
real schema survives. Bypassing the sanitizer reproduces the reported
payload, so the coverage is red-green.

Fixes #373
2026-07-02 20:20:25 +08:00
smallnestandsmallnest 027a8f2768 fix(config): use ~/.config on macOS and enter setup when no provider (#371) (#372)
On macOS, DefaultUserConfigPath used Go's os.UserConfigDir which returns
~/Library/Application Support, while users (and Claude Code) place config
at ~/.config/zero/config.json. The user config was never read, leaving
activeProvider empty and the launch failing with `active provider "" not
found` on a fresh `go run cmd/zero setup`.

- Add config.UserConfigDir(): on darwin, resolve to ~/.config (honoring
  XDG_CONFIG_HOME), matching Linux and Claude Code. DefaultUserConfigPath,
  the TUI user-commands dir, and specialist manifests all route through it.
- Add config.ErrNoActiveProvider sentinel; the interactive TUI now drops
  into the setup wizard when resolve fails solely due to a missing/
  unresolvable active provider, instead of exiting fatally. Other resolve
  errors (malformed JSON, directory conflicts) remain fatal.

Note: `zero setup`/`zero auth` already MkdirAll the config dir before
writing, so the directory is auto-created on first write.

Co-authored-by: smallnest <smallnest@gmail>
2026-07-02 20:03:44 +08:00
Kevin Codex bd89a1f451 feat: require manual approval before npm publish + drop release-as pin (#369)
The publish-npm job now targets the npm-publish environment, whose
required-reviewer rule pauses the job for maintainer approval in the
Actions UI before anything reaches the registry. Also removes the
release-as 0.1.0 pin from release-please-config.json now that v0.1.0
has shipped — leaving it would stamp every future release 0.1.0.
2026-07-02 16:15:22 +08:00
github-actions[bot] c807f92b72 chore(main): release 0.1.0 (#368)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-07-02 15:20:45 +08:00
Kevin Codexandcoderabbitai[bot] 8eccc26698 feat: publish zero to npm via release-please (#367)
* feat: publish zero to npm via release-please

Adds release-please to automate versioning and releases from
conventional commits on main:

- release-please-config.json / .release-please-manifest.json: node
  release type (package.json is the version source of truth), plain
  v-prefixed tags, bootstrap-sha at current HEAD, and release-as 0.1.0
  to pin the first release (remove after v0.1.0 ships).
- .github/workflows/release-please.yml: maintains the release PR; on
  merge it tags + creates the GitHub Release, then dispatches
  release-artifacts.yml at the tag (GITHUB_TOKEN-created tags cannot
  fire on-push-tags workflows).
- .github/workflows/release-artifacts.yml: tag-gated steps now match
  the ref instead of the push event so the dispatched-at-tag run
  publishes assets, and a new publish-npm job publishes @gitlawb/zero
  after verifying every postinstall asset URL is publicly downloadable
  (also blocks publishing while the repo is private).

* Update .github/workflows/release-please.yml

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* feat: publish to npm via OIDC trusted publishing instead of NPM_TOKEN

npm's trusted publishing exchanges the workflow's OIDC token for
publish auth, so no long-lived NPM_TOKEN secret exists to leak, and
provenance is generated automatically. Drops registry-url from
setup-node (its .npmrc line hard-fails on the unset NODE_AUTH_TOKEN)
and updates npm first (OIDC needs npm >= 11.5.1). One-time caveat:
npm only lets you configure a trusted publisher on an existing
package, so the first @gitlawb/zero publish is a manual bootstrap.

* fix: drop npm@latest from the OIDC publish path

Rely on the npm bundled with the pinned Node 24 toolchain (>= 11.5.1,
the trusted-publishing minimum) instead of pulling an unpinned
npm@latest from the registry inside the publish job, and fail fast
with a clear error if a future toolchain change regresses it.

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-07-02 15:05:07 +08:00
KRATOS 6e0a665118 fix(auth): propagate credentials to every provider-build surface and pin children to the live provider (#366)
* fix(auth): propagate credentials to every provider-build surface and pin children to the live provider

Sub-agents, swarm members, ACP sessions, and mid-run escalation switches
could build providers without the credentials the parent was running with.
This consolidates credential resolution so every surface gets the same auth:

- Wrap deps.newProvider once in fillAppDeps to fold the stored API key in
  centrally; ACP, exec escalation, and buildProvider no longer patch it
  per call site (an apiKeyStored profile previously went out keyless on
  those paths and 401'd).
- Export ZERO_PROVIDER at startup (buildProvider) and keep exporting on
  every runtime switch (wizard, onboarding, /model, /provider), so spawned
  children resolve the parent's live provider instead of whatever another
  process last persisted to config.json. Injected via appDeps so tests
  never mutate the process environment.
- Share one OAuth login selection end to end: ProviderProfile.
  OAuthLoginCandidates (name, then catalog-ID fallback; nothing for a
  profile with its own configured credential) + oauth.FirstStored resolve
  the login ONCE, and the bound store key is threaded through
  providers.Options.OAuthLoginKey so the Codex account-id header always
  reads the same login that issued the bearer token. A renamed profile
  (name != catalog ID) now finds its login; an explicit API key is never
  silently overridden by a same-name or catalog-shared OAuth login.
- Export ProviderProfile.HasConfiguredCredential as the single "profile is
  key-authed" predicate (cli/tui/config previously had three copies).
- applyProviderWizard commits live state (provider, profile, name, env
  export) only after BOTH the build and the config persist succeed, so a
  persist failure no longer leaves the chat running on a provider the
  status line and children disagree with.

Covered by red-green tests for each path: stored-key wrap, escalation
switch, catalog-ID fallback (incl. case-variant names), explicit-key
protection, same-login account selection, wizard persist-failure rollback,
onboarding/startup env export.

* test: isolate OAuth/config test storage and fix the flaky MCP response-id test

Review + CI fixes, all test-only:

- factory/cli OAuth tests: pin ZERO_OAUTH_STORAGE=file next to the temp
  ZERO_OAUTH_TOKENS_PATH — an inherited "keyring" value sends NewStore to
  the OS keychain and ignores the temp path entirely, so the tests could
  read (or clobber) the developer's real logins. newOAuthStoreForTest now
  owns both pins so a future call site can't forget them.
- TestApplyProviderWizardExportsActiveProviderEnv: point m.userConfigPath
  at a temp config and stub newProvider, so the SUCCESS path exercises the
  full build+persist+commit sequence in isolation (the default empty path
  silently skipped persist; a changed default must never reach the real
  user config).
- TestClientRequestWaitsForMatchingResponseID (ubuntu smoke flake): the
  responses were pre-loaded into a bytes.Buffer, so the client's reader
  goroutine could consume the matching response — and hit EOF — before
  request() registered its pending id, failing with "request() error =
  EOF" on fast runners. The fake server now sits on pipes and responds
  only after reading the outgoing request, matching production ordering;
  survives -race -count=50.
2026-07-02 12:21:27 +05:30
Kevin CodexandGnanam 142c548c89 feat(tui): FILES sidebar panel with click-to-select and file drill-in (#365)
* feat(tui): FILES sidebar panel with click-to-select and file drill-in

Add a FILES section to the right context sidebar showing the files the
session has touched — newest first, with A/M status badges, aggregated
+/− diffstats, and a pulsing row for the file being written right now.

Clicking a row selects the file (accent marker, edit cards tint in the
chat, transcript scrolls to the latest edit); a second click opens a
drill-in view: the chat column swaps to the file's stacked edit cards,
`f` toggles to the syntax-highlighted on-disk file with markers on
session-added lines, `d` back to diff, Esc returns to the chat.

The roster is fed from two sources: changedFiles carried by
write_file/edit_file/apply_patch results (persisted per session, so it
survives /resume), and a git sweep — baseline snapshot at startup,
re-check after command tools and at turn end — so files created through
bash/exec_command scaffolding or subagents show up too.

* fix(tui): address FILES sidebar review findings

Review fixes for the FILES panel / drill-in / git sweep:

- touchedFiles: split multi-file diffs per file (perFileDiffStats) so an
  apply_patch spanning several files no longer charges the whole patch's
  +/- totals to every file it touched; order by lastRowIndex descending
  so a re-touched file lists first (reversing first-seen order kept it in
  its original slot).
- sidebarFileLines: filter the live-writing row out before computing the
  "+N more" trailer so the skipped entry no longer inflates the count.
- git sweep: run `git diff --numstat -z` and parse the NUL-terminated
  rename records (counts + preimage + postimage) — plain --numstat
  brace-mangles renamed paths ("src/{old => new}"), so their diffstat
  never matched a porcelain path and renamed files stayed at 0/0;
  gitTouchedFiles now yields newest detection first to match the
  roster's ordering.
- file view: stream the full-mode read and stop at fileViewMaxLines
  (os.ReadFile loaded a multi-GB file wholesale before truncating — the
  exact freeze the cap exists to prevent); re-opening the file already
  being viewed is a no-op so a stray re-click can't bounce full mode
  back to diff or reset scroll; skip sub-4-char lines in the changed-line
  markers so common braces/returns don't mark unrelated occurrences.
- keys: gate the drill-in's d/f and Esc handling on noBlockingModal so a
  permission prompt / ask-user / wizard keeps its own key handling.
- sidebar: a live in-flight write counts as content, so the FILES pulse
  shows for the session's first mutation.
- tests: numstat test now feeds real -z record shapes and asserts the
  rename postimage key; new coverage for per-file stats, re-touch
  ordering, live-row overflow, same-file reopen, truncation, modal
  gating, live-write content, and a real-repo rename sweep.

---------

Co-authored-by: Gnanam <[email protected]>
2026-07-02 12:15:41 +05:30
KRATOS 02f0c0929b fix: cut the content-stall dead-wait and auto-retry a mid-tool-call stall (#362)
* fix: cut the content-stall dead-wait and auto-retry a mid-tool-call stall

Root-caused (via a verified multi-agent audit) the chatgpt/gpt-5.5 "stuck"
reports: the ChatGPT Codex backend pauses real generation mid-stream on
large single tool-call outputs (a ~1000-line write_file) while continuing
SSE keep-alive heartbeats. That is an upstream limitation, not a Zero bug
or a connection-pooling issue (the darwin DisableKeepAlives fix is applied
and confirmed not the cause). Zero already detected it, but two UX gaps
made it feel like a hang:

1. Faster abort. The content-stall watchdog fired at idle*2 = 10 minutes.
   A heartbeat-pause stall on gpt-5.x rarely recovers, so 10 minutes of a
   frozen token counter was a terrible wait for a doomed turn. Replaced the
   StreamContentStallFactor=2 const with ContentStallTimeout(idle)=idle*1.2
   (6 minutes at the default). It still only ever fires on a TRUE zero-output
   freeze — any real data line resets it — so healthy slow-reasoning runs are
   unaffected. Scales with ZERO_STREAM_IDLE_TIMEOUT; returns 0 (disabled)
   when idle is disabled.

2. Auto-retry the actual stall. The existing stall-retry only fired when
   NOTHING had been forwarded — so the common case (model streamed transient
   reasoning + began the big write_file, then froze mid-arguments) was NOT
   retried, because the incomplete tool call is collected and transient
   output was forwarded. Relaxed the gate to retry when no visible PROSE was
   shown (forwardedVisibleText, marked only by OnText) and no final text was
   collected — dropping the len(ToolCalls)==0 exclusion, since an incomplete
   tool call from a timed-out stream is never executed (the turn returns on
   the error before dispatch) and never appended to messages, so the retry
   re-sends clean context with no duplication. A visible
   "[no output — model stalled; retrying N/max]" notice fires per attempt.
   Forwarded prose still blocks the retry (it would duplicate answer text).

Verified: full -race suite green; a 4-dimension root-cause audit and a
3-dimension adversarial review of this diff both passed with no confirmed
defects. New tests: TestRunRetriesStallAfterIncompleteToolCall (the exact
gpt-5.x case), TestRunRetriesStallAfterOnlyReasoning; preserved
TestRunDoesNotRetryStallAfterPartialOutput (prose still blocks retry).

* fix(providerio): make ContentStallTimeout overflow-safe

Address PR #362 review: idleTimeout*6/5 could overflow int64 for a
pathologically large ZERO_STREAM_IDLE_TIMEOUT and wrap to a NEGATIVE
duration, which would arm the content-stall timer to fire immediately and
abort every stream. Compute 1.2× as idle + idle/5 with a clamp to the max
duration on overflow (which just means "effectively no content watchdog",
the sane result for an absurd idle timeout). The normal 1.2× values are
unchanged (5m->6m, 30s->36s, 100ms->120ms).

New test: TestContentStallTimeout covers the scaled values, the
zero/disabled case, and the MaxInt64 overflow guard.
2026-07-02 00:14:51 +05:30
KRATOS 3fde03a900 fix(tui): show the queued-message preview above the composer, not below (#361)
A message typed while a run is active is queued for the next turn. Its
"[queued] …" preview rendered on the line BELOW the input box; move it to
directly ABOVE the box so it reads as "waiting to send," sitting on top of
what you're currently typing, and doesn't get visually detached from the
composer by the status line beneath it. Footer height is unchanged (the
preview line just moves from below to above the box).

New test: TestQueuedPromptPreviewSitsAboveComposer asserts the preview
line index is above the composer box top border.
2026-07-01 23:55:54 +05:30
KRATOS 85273ea72d fix(providerio): disable HTTP keep-alive reuse on macOS to defeat degraded-connection stalls (#360)
This codebase already has a documented, previously-diagnosed macOS-only
bug: "sharedHTTPClient... defeats the stale-pooled-connection hang...
reproduced on BOTH chatgpt and ollama... surfaces on macOS far more than
Linux because macOS keeps dead pooled connections around longer." The
existing mitigation (ResponseHeaderTimeout=120s, IdleConnTimeout=30s)
catches a reused connection that never responds at all, or one that sat
idle too long.

Traced a live report that doesn't fit either case: a session accumulated
301 real completion tokens over ~12.5 minutes (~0.4 tok/s) on
chatgpt/gpt-5.5 before being manually cancelled - not silent, just
catastrophically slow, on a retry shortly after a prior attempt on the
same host. That's a third failure mode: a reused connection that's
alive-but-severely-degraded, still delivering real bytes (enough to keep
resetting Zero's stream idle/content-stall watchdogs, which only fire on
true silence) but never at a usable rate. Basic network diagnostics on
the affected machine (DNS, TCP connect, TLS handshake) were all healthy
and routed directly with no VPN/proxy involved, pointing at the same
connection-reuse class of bug rather than a local network misconfig.

DisableKeepAlives removes pooling from the equation entirely rather than
just tightening the timeout window, since a degraded-not-dead connection
isn't reliably distinguishable from genuine backend slowness once inside
the stream. Scoped to darwin only via runtime.GOOS: a fresh TCP+TLS
handshake per request costs low tens of milliseconds (measured ~92ms
against a real host on the affected machine) against potential
minutes-long stalls, and Linux/Windows don't exhibit this OS-level
pooled-connection behavior, so they keep full connection reuse.

New test: TestHTTPClientReturnsStallHardenedSharedClient extended to
assert DisableKeepAlives matches runtime.GOOS == "darwin".
2026-07-01 21:48:47 +05:30
KRATOS 0914ced9e1 test: widen timing margins on two Windows-flaky CI tests (#359)
Both failed on windows-latest today, on merges of otherwise-unrelated
PRs (#349, #356) - neither PR touched internal/specialist or
internal/agenteval. Windows GitHub Actions runners have coarser default
timer granularity and more scheduling contention than Linux/macOS, so
tight timing margins that hold on the other two platforms aren't
reliable there.

- TestOutputToolBlocksUntilTaskCompletes: a 100ms poll timeout against a
  5ms background write left only 20x margin. Widened the timeout to
  2000ms (a ceiling, not a target - PollInterval keeps the test fast in
  the success case; it only changes how much CI jitter it tolerates
  before failing).
- TestHarnessTimeoutCancelsBlockedAgent: confirmed there's no actual data
  race (harness.Agent.Run is a direct synchronous call, not a spawned
  goroutine racing the assertions) - the failure was workspace
  materialization alone consuming the full 10s budget on a contended
  Windows runner, so the agent was never reached. Widened to 30s. This
  test's own design blocks for the full timeout to observe cancellation,
  so unlike the other fix this directly costs ~20s more wall-clock time,
  but that's inherent to what it's testing.
2026-07-01 21:28:52 +05:30
KRATOS 68669073c2 fix(tui): explicitly request the initial window size at startup (#358)
Zero is always in alt-screen mode (useAltScreen() is unconditionally
true), and transcriptView's `if m.altScreen && m.height > 0` gate is what
pads the rendered frame out to fill the full terminal height
(scrollableTranscriptItemsView pads bodyWindow to window.height). If
m.height is ever 0 - Bubble Tea documents an initial WindowSizeMsg as
delivered automatically on program start, but that's the terminal
proactively pushing a size, and nothing else ever asks again if that
push is missed (a slow/unusual terminal, a multiplexer, a startup race)
- Zero falls back to the unpadded, non-fullscreen render path for the
rest of the session: content only occupies as many rows as it needs,
leaving the remaining terminal height as plain unfilled background
instead of the alt-screen's painted panel color.

Fixed by explicitly firing tea.RequestWindowSize in Init(), the same
defensive pattern already used for tea.RequestBackgroundColor right below
it - Zero doesn't rely solely on an unprompted push for either.
2026-07-01 21:25:47 +05:30
KRATOS 33410d8730 fix(tui): make the stall watchdog visible before it fires, not just after (#357)
Zero already has a content-stall watchdog built specifically for this
exact case (providerio.ErrStreamStalled's own doc comment: "observed on
chatgpt/gpt-5.x and ollama reasoning models") - a heartbeating-but-silent
upstream is aborted after idleTimeout * StreamContentStallFactor (5min *
2 = 10min by default). It works. The gap is that until it fires, the
"still generating... Xs" cue is the ONLY signal the user has, and it
looks completely identical whether real (if slow) content is still
coming or the stream is already dead and just hasn't been caught yet -
confirmed against a live report where the token count sat frozen for
7+ minutes with no way to tell from the UI alone.

Past half the effective idle timeout, quietGenerationHint() now escalates
to name what's actually happening and when Zero will act on its own:
"still generating... Xs - unusually quiet, Zero will auto-recover by
~Ys if it doesn't resume". Exported StreamContentStallFactor from
providerio (was unexported) so the TUI can compute the same ceiling the
watchdog itself uses, instead of duplicating the constant.

New test: TestQuietGenerationHintEscalatesPastHalfIdleTimeout, using
ZERO_STREAM_IDLE_TIMEOUT to shrink the timeout so the test runs fast
while exercising the exact same math the real watchdog uses.
2026-07-01 21:25:27 +05:30
KRATOS c0254e3266 fix(tui): context-fill gauge for custom/local Ollama models + related fixes (#356)
* fix(tui): show the context-fill gauge for custom/local Ollama models

The colored context gauge (used/window · N%) only renders when Zero knows
a model's context window. It resolves that two ways: a curated remote
catalog (how Grok/hosted models are known), or live /v1/models discovery —
but parseModelsResponse only ever extracts id/description from that
response; OpenAI-compatible model listings don't carry context-window
metadata at all. A custom/local Ollama model tag (e.g. a locally-pulled
":cloud"-tagged model) has no curated-catalog entry to borrow one from
either, so it always resolved to an unknown window and the gauge silently
fell back to a bare token count with no color grading.

Ollama does expose this, just via a different, Ollama-native endpoint
(/api/show, not /v1/models) whose model_info map reports it under an
architecture-prefixed key like "qwen2.context_length". Added
DiscoverOllamaContextWindow (providermodeldiscovery) to probe it, wired
through an async command scoped to the local "ollama" catalog ID only
(not "ollama-cloud", a different hosted service this endpoint isn't
assumed to exist on) — fired from Init() for the provider active at
launch, and from switchProviderModel for a provider switched to
mid-session (which previously warmed no discovery at all for the new
provider, a separate gap this also fixes).

New tests: DiscoverOllamaContextWindow's endpoint construction and
model_info parsing (including a mixed-type model_info and a missing
*.context_length key), the discovery command's scoping to local Ollama
only, the Update() handler populating modelContextWindow's fallback, and
switchProviderModel warming discovery for the newly active provider.

Local only — not pushed. I can't run a live Ollama daemon in this
sandbox, so the actual /api/show request/response shape (verified against
Ollama's documented API, not a live instance) needs confirming against a
real daemon before this goes out.

* fix(tui): stop showing the token count twice next to the context gauge

contextWindowSegment (the gauge) and usageStatusSegment (the plain "N tok"
figure) both read the exact same value (latestUsageTokens) — the code
already suppressed the plain figure when the sidebar shows it ("the token
figure shows on both sides"), but never extended that same dedup to the
gauge. So as soon as a model's context window becomes known (e.g. after
the previous commit's Ollama fix), the footer showed the identical number
twice: once inside the gauge, once again right next to it.

Fixed by falling back to cost-only whenever the gauge is shown, same as
the sidebar-active branch already does.

* fix: check collect()'s deadline even while output keeps draining

The wait deadline in execSession.collect() was only checked in the branch
reached when drainString() returns empty. A background process producing
output continuously enough to keep the buffer perpetually non-empty could
theoretically starve that check forever, turning the caller's requested
timeout into an unbounded block regardless of yield_time_ms.

Investigated as part of a cross-provider "stuck" report (multiple sessions
across different models/providers all hanging on the same prompt, each
with a background terminal running and no forward progress). Could not
empirically reproduce a hang via a synthetic continuous-writer test under
Go's real scheduler — the reader won the race often enough in practice
that this doesn't appear to be the actual cause of those reports. Keeping
the fix regardless since it closes a real, if hard-to-trigger, gap: the
deadline is now checked in both branches, so continuous output can never
prevent collect() from returning within its requested window.
2026-07-01 20:48:50 +05:30
Vasanth T c4c14d62d4 fix(oauth): make xAI / Hugging Face OAuth actually launch from the wizard (#355)
The provider wizard offered "Sign in with OAuth" for xAI and Hugging Face, but
clicking did nothing: the login managers were built without an env, so the preset
opt-in (ZERO_OAUTH_ALLOW_PRESETS) read as unset, ResolveConfig errored before any
browser step, and the error was never rendered on the provider list. Only ChatGPT
worked, because its login path force-enables presets.

Add ManagerOptions.AllowPresets, which layers the opt-in onto the manager env
(snapshotting the process environment so ZERO_OAUTH_<NAME>_* overrides survive
envValue's hermetic-map rule) without the operator exporting the flag. Set it on
every user-initiated OAuth manager: the wizard loopback login, the device-code
prepare/complete, the post-login token refresh, the runtime resolver that
authenticates model calls, and `zero auth login`. This fixes xAI end to end (it
ships a public client_id, so the browser now opens).

Also surface a failed OAuth attempt on the provider list (renderProviderStep) so
the click is no longer a silent no-op, with a Hugging Face hint pointing at app
registration + ZERO_OAUTH_HUGGINGFACE_CLIENT_ID (HF ships no public client_id, so
it still requires that one env var by design).
2026-07-01 18:37:56 +05:30
Vasanth T 49bec0274f feat(tui): interactive theme picker + color theme catalog (#354)
* feat(tui): interactive theme picker + color theme catalog

Bare `/theme` now opens a popup that live-previews each palette as the cursor
moves and applies on select (Esc reverts), matching /model and /effort. Adds ten
built-in color themes alongside the dark/light built-ins — dracula, nord, gruvbox,
tokyo-night, catppuccin, one-dark, solarized-dark, rose-pine, everforest, and
solarized-light — plus a reworked light palette. Every palette is contrast-audited
to WCAG AA; the contrast tests now loop the whole registry.

Palettes and an ordered registry live in a new internal/tui/theme_palettes.go (now
the sole home of hex literals); selection, the picker, /theme <name>, --theme, and
ZERO_THEME all resolve through it. The whole frame is painted with the active
theme's surface (tea.View.BackgroundColor) so every theme is legible on any
terminal — a light theme's dark text no longer lands invisibly on a dark terminal,
and color themes show their real surface.

The chosen theme persists to user config (preferences.theme) and is reapplied at
startup, below the --theme flag and ZERO_THEME.

* fix(tui): address CodeRabbit review on the theme catalog

- CHANGELOG: reconcile the --theme bullet with the named themes (was still
  documenting {auto|dark|light}).
- /theme usage string advertises the list and auto forms (/theme list is still
  accepted) so /help matches the command surface.
- Theme-persistence test parses the written config and asserts preferences.theme
  directly instead of a raw substring, so a wrong-key regression fails.
2026-07-01 15:46:15 +05:30
KRATOS bcc65da2a0 fix(tui): require a second Esc press to cancel a running turn (#352)
* fix(tui): require a second Esc press to cancel a running turn

A single stray Esc during an active run cancelled it instantly with no
chance to reconsider - e.g. a press meant to dismiss an overlay that had
already closed. Ctrl+C already guards app-exit with a double-press
confirmation (exitConfirmActive/exitConfirmSeq); Esc now gets its own
independent confirmation (cancelConfirmActive/cancelConfirmSeq) guarding
run-cancellation specifically, since the two are different actions with
different consequences armed by different keys.

Mirrors the existing seq-gated tea.Tick pattern: first Esc arms a 3s
window and shows "Press Esc again to cancel" in the footer; a second Esc
within the window cancels the run; letting it expire, or the run finishing
on its own, disarms without cancelling.

Updated the three existing tests that assumed single-press cancellation
(TestEscCancelsPendingRun -> TestEscRequiresSecondPressToCancelPendingRun,
TestEscCancellationLeavesVisibleMarker, TestEscCancelRecordsSessionError,
TestCancelledRunFlushesCheckpointSessionEvents) and added
TestEscCancelConfirmationExpires mirroring the Ctrl+C expiry test.

* fix: pre-push review findings on the Esc cancel-confirmation gate

Ran an adversarial review before opening the PR. Three independent
reviewers converged on the same root defect: unlike exitConfirmActive
(disarmed by any key other than Ctrl+C), cancelConfirmActive had no
"anything else disarms it" safety net, so it behaved as a plain 3s timer
rather than a true immediate-second-press gate. Concretely:

- Typing, Ctrl+O, or any other non-Esc key left it armed, so a later,
  unrelated Esc press could silently cancel the run.
- A mid-turn ask_user or permission prompt lands asynchronously and is
  checked earlier in the Esc handler than the cancel-confirm block, so it
  silently "stole" the confirming press (denying the prompt instead) while
  leaving cancelConfirmActive armed for a subsequent, unrelated Esc to act
  on.
- Mouse clicks resolving a permission prompt or entering a subchat had the
  same gap.

Fixed by mirroring the Ctrl+C pattern: any non-Esc key disarms it (model.go,
next to the existing exitConfirmAction reset), and any left/right mouse
click disarms it too (mouse.go — scoped to clicks, not hover/motion, since
motion fires continuously and would otherwise defeat the window whenever
the mouse sits over the terminal). Within the Esc handler itself, restructured
to capture whether this press was really a confirm *before* any of the
earlier branches (subchat, MCP wizard, ask-user, permission, picker,
suggestions, queued-message, detailed-transcript) can run, then disarm
unconditionally — so an Esc consumed by any of those no longer leaves a
stale confirmation for a later press to trip.

Also updated two now-stale help strings the review flagged: the `?`
overlay's Esc row and pickerBusyText()'s "press Esc to cancel" hint, both
of which still described the old single-press behavior.

Added TestEscCancelConfirmationDisarmsOnInterveningKey (mirrors the
existing Ctrl+C equivalent) and
TestEscCancelConfirmationDisarmsWhenStolenByAskUser covering the two
confirmed gaps.

* fix: address CodeRabbit review comments on PR #352

- model.go: the first Esc (arming the cancel confirmation) was clearing the
  composer draft immediately, even though nothing had actually been
  cancelled yet — a leftover from the old single-press behavior, where
  clearing the draft and cancelling the run happened atomically as one
  action. Now that cancellation is two presses, only the confirming second
  Esc (or a plain Esc with no run pending) clears the composer; arming
  preserves whatever the user was typing, mirroring how Ctrl+C already
  preserves a draft (TestCtrlCClearsComposerBeforeExitConfirmation).

- clipboard.go: routePaste (bracketed paste and right-click paste) never
  disarmed a stale cancelConfirmActive — it isn't a tea.KeyPressMsg, so it
  bypassed the generic "any non-Esc key disarms it" hook added in the
  previous commit. A paste is a deliberate action like typing or clicking,
  so it should disarm the confirmation too; otherwise a later, unrelated
  Esc could silently cancel the run.

New tests: TestEscArmingCancelConfirmationPreservesComposerDraft,
TestPasteDisarmsCancelConfirmation.
2026-07-01 15:45:28 +05:30
190 changed files with 13494 additions and 931 deletions

No files matched your search

+97 -7
View File
@@ -54,8 +54,10 @@ jobs:
# is published under the pushed git tag. If they disagree, install.sh/install.ps1
# and the npm postinstall all build a download URL that 404s. Fail fast so a
# forgotten version bump can never publish mis-versioned, un-installable assets.
# Matches on the ref (not event_name) because release-please dispatches this
# workflow at the tag ref — its GITHUB_TOKEN-created tag cannot fire `push`.
- name: Check tag matches package.json version
if: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') }}
if: ${{ startsWith(github.ref, 'refs/tags/v') }}
shell: bash
run: |
pkg_version="$(node -p "require('./package.json').version")"
@@ -78,13 +80,15 @@ jobs:
path: dist/release/*
if-no-files-found: error
# On a `v*` tag push, publish the packaged archive + .sha256 as GitHub Release
# assets so the documented `scripts/install.sh` / `install.ps1` path (which reads
# /releases/latest) works. Uses the preinstalled `gh` (no new action dependency);
# each matrix OS creates the release if absent (best-effort) then uploads its own
# assets with --clobber. NOTE: maintainers must push the tag — this never tags.
# On a `v*` tag ref (pushed manually, or dispatched at the tag by the
# release-please workflow), publish the packaged archive + .sha256 as GitHub
# Release assets so the documented `scripts/install.sh` / `install.ps1` path
# (which reads /releases/latest) works. Uses the preinstalled `gh` (no new
# action dependency); each matrix OS creates the release if absent
# (best-effort — release-please normally creates it first) then uploads its
# own assets with --clobber. Tags come from merging the release-please PR.
- name: Publish to GitHub Release
if: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') }}
if: ${{ startsWith(github.ref, 'refs/tags/v') }}
shell: bash
env:
GH_TOKEN: ${{ github.token }}
@@ -92,3 +96,89 @@ jobs:
tag="${GITHUB_REF_NAME}"
gh release create "$tag" --repo "$GITHUB_REPOSITORY" --title "$tag" --generate-notes 2>/dev/null || true
gh release upload "$tag" dist/release/* --repo "$GITHUB_REPOSITORY" --clobber
# Publishes the npm wrapper (@gitlawb/zero) once every platform's release
# assets are live — the package's postinstall downloads its binary from the
# GitHub Release, so publishing before the assets exist would ship a package
# that fails to install.
#
# Auth is npm OIDC trusted publishing: no NPM_TOKEN secret. The package's
# npmjs.com settings must list this repo + workflow file as a trusted
# publisher, and provenance is then generated automatically. Note npm only
# allows configuring a trusted publisher on an EXISTING package, so the very
# first @gitlawb/zero publish is a one-time manual `npm publish` bootstrap.
publish-npm:
name: Publish npm package
needs: package
if: ${{ startsWith(github.ref, 'refs/tags/v') }}
runs-on: ubuntu-latest
# Manual approval gate: this environment carries a required-reviewer rule
# (Settings -> Environments -> npm-publish), so the job pauses until a
# maintainer approves it in the Actions UI before anything reaches npm.
environment:
name: npm-publish
url: https://www.npmjs.com/package/@gitlawb/zero
permissions:
contents: read
id-token: write # OIDC token exchange for npm trusted publishing + provenance
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
# No registry-url here on purpose: setup-node's registry-url writes an
# .npmrc line referencing $NODE_AUTH_TOKEN, and npm hard-fails on the
# unset env var. Trusted publishing needs no token at all.
- name: Setup Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 24
# OIDC trusted publishing needs npm >= 11.5.1. Use the npm bundled with
# the pinned Node toolchain (Node 24 ships >= 11.5.1) rather than pulling
# npm@latest from the registry inside the publish path — an unpinned
# install there would be avoidable supply-chain exposure. Fail fast if a
# future runner/toolchain change ever regresses the bundled npm.
- name: Check npm supports trusted publishing
run: |
npm --version | node -e '
const version = require("fs").readFileSync(0, "utf8").trim();
const [major, minor, patch] = version.split(".").map(Number);
const ok =
major > 11 ||
(major === 11 && (minor > 5 || (minor === 5 && patch >= 1)));
if (!ok) {
console.error(
`::error::bundled npm ${version} is too old for OIDC trusted ` +
`publishing (needs >= 11.5.1) — bump node-version in this job.`,
);
process.exit(1);
}
console.log(`npm ${version} supports trusted publishing.`);
'
# Probe the exact URLs postinstall will build (internal/release/release.go
# asset-name scheme) as an anonymous client. This also blocks publishing
# while the repo is still private — private release assets 404 without
# auth, exactly as they would for an installing user.
- name: Verify release assets are downloadable
shell: bash
run: |
version="$(node -p "require('./package.json').version")"
base="https://github.com/${GITHUB_REPOSITORY}/releases/download/v${version}"
for asset in \
"zero-v${version}-linux-x64.tar.gz" \
"zero-v${version}-linux-arm64.tar.gz" \
"zero-v${version}-macos-x64.tar.gz" \
"zero-v${version}-macos-arm64.tar.gz" \
"zero-v${version}-windows-x64.zip"; do
for url in "${base}/${asset}" "${base}/${asset}.sha256"; do
if ! curl -fsSLI -o /dev/null "$url"; then
echo "::error::${url} is not publicly downloadable — npm postinstall would fail. Is the repo public and are all assets uploaded?" >&2
exit 1
fi
done
done
echo "all release assets for v${version} are publicly downloadable."
- name: Publish to npm
run: npm publish --access public
+35
View File
@@ -0,0 +1,35 @@
name: Release Please
on:
push:
branches:
- main
permissions:
contents: write # push the release-PR branch, create the tag + GitHub Release
pull-requests: write # open/update the release PR
issues: write # create/apply release-tracking labels on the release PR
actions: write # dispatch release-artifacts.yml once a release is cut
jobs:
release-please:
name: Release PR / tag
runs-on: ubuntu-latest
steps:
- name: Run release-please
id: release
uses: googleapis/release-please-action@a02a34c4d625f9be7cb89156071d8567266a2445 # v4.2.0
# Tags created with the default GITHUB_TOKEN never fire `on: push: tags`
# workflows (GitHub suppresses workflows triggered by workflow-created
# events), so release-artifacts.yml would not run for a release-please
# tag. workflow_dispatch invoked through the API is exempt from that
# suppression — dispatch the artifacts build at the new tag explicitly.
- name: Build and publish release artifacts
if: ${{ steps.release.outputs.release_created }}
env:
GH_TOKEN: ${{ github.token }}
run: |
gh workflow run release-artifacts.yml \
--repo "$GITHUB_REPOSITORY" \
--ref "${{ steps.release.outputs.tag_name }}"
+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
View File
@@ -0,0 +1 @@
{".":"0.1.0"}
+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.
+21 -1
View File
@@ -6,12 +6,32 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html) once the first release is
tagged. Until then, source builds report the version `dev`.
## 0.1.0 (2026-07-02)
### Features
* publish zero to npm via release-please ([#367](https://github.com/Gitlawb/zero/issues/367)) ([8eccc26](https://github.com/Gitlawb/zero/commit/8eccc2669887bc38d35bc16a315c888e4d9ec43a))
* **tui:** FILES sidebar panel with click-to-select and file drill-in ([#365](https://github.com/Gitlawb/zero/issues/365)) ([142c548](https://github.com/Gitlawb/zero/commit/142c548c89a8652ce300e64ddf1228ee36df7606))
### Bug Fixes
* **auth:** propagate credentials to every provider-build surface and pin children to the live provider ([#366](https://github.com/Gitlawb/zero/issues/366)) ([6e0a665](https://github.com/Gitlawb/zero/commit/6e0a665118fe0e09c4b07d482dd18f86045acd2b))
## [Unreleased]
### Added
- `SECURITY.md` with a private vulnerability-reporting path, `CODE_OF_CONDUCT.md`, this changelog, and
GitHub issue/PR templates.
- `--theme {auto|dark|light}` flag for the TUI (previously only the `ZERO_THEME` env var existed).
- Interactive `/theme` picker: bare `/theme` opens a popup that live-previews each palette as you move
and applies on select (Esc reverts).
- Ten built-in color themes alongside the `dark`/`light` built-ins — `dracula`, `nord`, `gruvbox`,
`tokyo-night`, `catppuccin`, `one-dark`, `solarized-dark`, `rose-pine`, `everforest`, and
`solarized-light` — selectable via `/theme <name>`, `--theme <name>`, or `ZERO_THEME`. Every palette
is contrast-audited to WCAG AA. The built-in light theme was reworked for legibility.
- `--theme <name>` flag for the TUI, accepting `auto` or any registered theme (previously only the
`ZERO_THEME` env var existed).
- "Accessibility / Appearance" section in the README documenting `NO_COLOR`, `ZERO_THEME`, `/theme`,
and `ZERO_NO_FADE`.
+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
+37 -3
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,
@@ -51,6 +53,31 @@ The npm package installs a small wrapper plus the matching Zero binary for your
platform from GitHub Releases. It supports Linux, macOS, and Windows on x64 and
arm64.
### Bun
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`.
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
bun pm trust @gitlawb/zero
# global install
bun add -g @gitlawb/zero
bun pm -g trust @gitlawb/zero
```
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
Linux/macOS:
@@ -121,6 +148,13 @@ the key in the wizard:
export OPENAI_API_KEY=sk-...
export ANTHROPIC_API_KEY=...
export GEMINI_API_KEY=...
export LONGCAT_API_KEY=...
```
To configure Meituan LongCat (LongCat-2.0) directly, run:
```bash
zero providers setup longcat --set-active
```
For local models, run Ollama or LM Studio and then use `zero setup` or
@@ -254,9 +288,9 @@ zero update check for newer releases
| Control | Effect |
|---|---|
| `NO_COLOR=<anything>` | disables color output |
| `ZERO_THEME=auto|dark|light` | selects the startup theme |
| `--theme auto|dark|light` | selects the TUI theme from the CLI |
| `/theme auto|dark|light` | switches theme inside the TUI |
| `ZERO_THEME=<name>` | selects the startup theme (`auto`, `dark`, `light`, or a color theme like `dracula`, `nord`, `gruvbox`, `tokyo-night`, `catppuccin`, `one-dark`, `solarized-dark`, `rose-pine`, `everforest`, `solarized-light`) |
| `--theme <name>` | selects the TUI theme from the CLI (same names) |
| `/theme` | opens the theme picker inside the TUI (live preview; `/theme <name>` switches directly) |
| `ZERO_NO_FADE=1` | disables streaming fade animation |
Meaning does not rely on color alone; diffs, permissions, and statuses also use
+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}"
+21 -1
View File
@@ -54,8 +54,28 @@ const nativePath = join(packageRoot, zeroBinaryName());
const localControlHelpers = localControlHelperManifest(packageRoot);
if (!existsSync(nativePath)) {
const postinstallScript = join(packageRoot, 'scripts', 'postinstall.mjs');
const ranByBun = process.execPath.includes('bun') || !!process.versions?.bun;
console.error(
'[zero] No native binary found next to the npm wrapper. Reinstall the zero package or run `go run ./cmd/zero-release build` from the repository.'
'[zero] No native binary found next to the npm wrapper.\n' +
'The platform binary is fetched at install time by a postinstall script,\n' +
'which did not run (or was skipped) for this install.\n' +
'\n' +
'Fix it now by running the installer manually:\n' +
` node "${postinstallScript}"\n` +
'\n' +
(ranByBun
? 'You installed with Bun, which does not run dependency lifecycle scripts\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' +
'to your project package.json and reinstall.\n' +
'\n'
: '') +
'If that fails, build from source: https://github.com/Gitlawb/zero\n' +
'(go run ./cmd/zero, requires Go 1.25+).',
);
process.exit(1);
}
+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
+45
View File
@@ -24,6 +24,51 @@ Requirements:
- Node.js 18+
- network access to npm and GitHub Releases
## Bun
Bun is "default-secure" and does not run lifecycle scripts of installed
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`.
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
bun pm trust @gitlawb/zero
# global install
bun add -g @gitlawb/zero
bun pm -g trust @gitlawb/zero
```
`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
{
"trustedDependencies": ["@gitlawb/zero"]
}
```
```bash
bun add @gitlawb/zero
```
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>
## Linux And macOS Script
Install the latest release:
@@ -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).
+155 -81
View File
@@ -210,27 +210,29 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
}
}
// Wrapped callbacks flag ANY output forwarded this turn, so a stall retry
// never re-streams on top of output the user already saw — even across the
// reactive-compaction retry below, which overwrites `collected`.
forwardedAnything := false
mark := func() { forwardedAnything = true }
// OnUsage is telemetry, not visible output, so it does NOT mark
// forwardedAnything: a provider that emits an early usage event (e.g. prompt
// tokens) then stalls with no content must still be safely retried. The gate
// only guards against re-streaming output the user actually SAW.
// forwardedVisibleText flags forwarded final PROSE (OnText) specifically —
// the only output whose re-stream on a stall retry would visibly DUPLICATE
// answer text the user already read. Transient working indicators
// (reasoning previews, tool-call "writing…" previews) are deliberately NOT
// counted: a turn that streamed only those then stalled — the common
// gpt-5.x / ollama "froze mid-write_file" case — is safe to re-issue, since
// the TUI resets the tool-call preview on the next tool-call-start and a
// stalled turn is never appended to `messages` (it returns on the error
// before the append), so the retry re-sends clean context with no
// conversation-state duplication.
forwardedVisibleText := false
forwardingOpts := zeroruntime.CollectOptions{OnUsage: options.OnUsage}
if options.OnText != nil {
forwardingOpts.OnText = func(s string) { mark(); options.OnText(s) }
forwardingOpts.OnText = func(s string) { forwardedVisibleText = true; options.OnText(s) }
}
if options.OnReasoning != nil {
forwardingOpts.OnReasoning = func(s string) { mark(); options.OnReasoning(s) }
forwardingOpts.OnReasoning = func(s string) { options.OnReasoning(s) }
}
if options.OnToolCallStart != nil {
forwardingOpts.OnToolCallStart = func(id, name string) { mark(); options.OnToolCallStart(id, name) }
forwardingOpts.OnToolCallStart = func(id, name string) { options.OnToolCallStart(id, name) }
}
if options.OnToolCallDelta != nil {
forwardingOpts.OnToolCallDelta = func(id, fragment string) { mark(); options.OnToolCallDelta(id, fragment) }
forwardingOpts.OnToolCallDelta = func(id, fragment string) { options.OnToolCallDelta(id, fragment) }
}
// recoverStreamError applies the same non-stall recovery the initial stream
// gets to ANY collected error — including one from a reissued (stall-retry)
@@ -284,15 +286,28 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
result.Messages = copyMessages(messages)
return result, ctx.Err()
}
// A stream idle/stall timeout with NO output forwarded yet this turn can be
// safely re-issued on a fresh connection — nothing was shown, so there's no
// duplication (this recovers the macOS stale-pooled-connection hang when it
// slips past the transport's response-header timeout). A turn that already
// streamed partial text/tool-calls is NOT retried (it would duplicate) and
// falls through to the error return below. Capped + exponential backoff.
// A stream idle/stall timeout is safely re-issued when the turn committed NO
// answer text — no forwarded visible prose (forwardedVisibleText) and no
// collected final text (collected.Text). This covers two cases:
// 1. Nothing streamed at all before the connection died (the original
// macOS stale-pooled-connection hang past the response-header timeout).
// 2. The model streamed transient reasoning and began a tool call (e.g. a
// large write_file) then froze mid-arguments — the common gpt-5.x /
// ollama heartbeat-pause stall. That partial tool call is NEVER executed
// (a turn with collected.Error returns before dispatch below) and NEVER
// appended to `messages`, so a retry re-issues clean context; the only
// re-render is transient previews, not duplicated answer text. This is
// why the gate no longer excludes collected.ToolCalls: an incomplete
// tool call from a timed-out stream is discard-and-retry, not output.
// A turn that forwarded real prose is NOT retried (it would duplicate visible
// answer text) and falls through to the error return below. Capped +
// exponential backoff, with a user-visible notice per attempt.
for attempt := 1; attempt <= maxStreamStallRetries &&
isStreamTimeoutError(collected.Error) && !forwardedAnything &&
collected.Text == "" && len(collected.ToolCalls) == 0; attempt++ {
isStreamTimeoutError(collected.Error) && !forwardedVisibleText &&
collected.Text == ""; attempt++ {
if notify := stallRetryNoticeFor(options); notify != nil {
notify(attempt, maxStreamStallRetries)
}
if err := sleepWithContext(ctx, backoffFor(attempt)); err != nil {
result.Messages = copyMessages(messages)
return result, err
@@ -801,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().
@@ -2144,6 +2178,18 @@ func buildPermissionEvent(call ToolCall, tool tools.Tool, args map[string]any, p
}
}
// A command that explicitly requests additional sandbox permissions
// (sandbox_permissions: with_additional_permissions) is an ELEVATION the user
// must consent to, even when the base command's sandbox decision was Allow.
// shouldRequestPermission blocks on OnPermissionRequest for exactly this case,
// so the event MUST be a prompt — otherwise it carried Action=allow while the
// loop waited on a decision, and a UI that renders only prompts (the TUI)
// dropped it, deadlocking the run. Keep this consistent with
// shouldRequestPermission's own condition.
if action == PermissionActionAllow && !permissionGranted && shellCommandAdditionalPermissionsRequested(args) {
action = PermissionActionPrompt
}
if safety.Permission == tools.PermissionAllow && action == PermissionActionAllow && !grantMatched && block == nil {
return PermissionEvent{}, false
}
@@ -2295,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 {
@@ -2310,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 {
@@ -2443,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)
}
+76 -1
View File
@@ -37,6 +37,35 @@ func (provider *mockProvider) StreamCompletion(ctx context.Context, request zero
return ch, nil
}
type recordingWebSearchTool struct {
calls []map[string]any
}
func (tool *recordingWebSearchTool) Name() string { return "web_search" }
func (tool *recordingWebSearchTool) Description() string { return "test web search tool" }
func (tool *recordingWebSearchTool) Parameters() tools.Schema {
return tools.Schema{
Type: "object",
Properties: map[string]tools.PropertySchema{
"query": {Type: "string"},
},
Required: []string{"query"},
AdditionalProperties: false,
}
}
func (tool *recordingWebSearchTool) Safety() tools.Safety {
return tools.Safety{
SideEffect: tools.SideEffectNetwork,
Permission: tools.PermissionPrompt,
Reason: "Sends model-provided search query text to the configured web search backend.",
AdvertiseInAuto: true,
}
}
func (tool *recordingWebSearchTool) Run(_ context.Context, args map[string]any) tools.Result {
tool.calls = append(tool.calls, cloneArgs(args))
return tools.Result{Status: tools.StatusOK, Output: "1. T — https://x.test"}
}
type sandboxDeniedRetryTool struct {
calls []map[string]any
}
@@ -865,7 +894,7 @@ func TestRunRejectsLocalWebFetchBeforePermissionRequest(t *testing.T) {
}
}
func TestRunAdvertisesAllowedWebSearchInAutoMode(t *testing.T) {
func TestRunAdvertisesPromptedWebSearchInAutoMode(t *testing.T) {
t.Setenv("ZERO_WEBSEARCH_BASE_URL", "https://search.example/api")
registry := tools.NewRegistry()
for _, tool := range tools.CoreNetworkTools() {
@@ -896,6 +925,52 @@ func TestRunAdvertisesAllowedWebSearchInAutoMode(t *testing.T) {
}
}
func TestRunRequestsPermissionBeforeWebSearchExecution(t *testing.T) {
search := &recordingWebSearchTool{}
registry := tools.NewRegistry()
registry.Register(search)
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "web_search"},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"query":"private workspace detail"}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
{Type: zeroruntime.StreamEventDone},
},
{
{Type: zeroruntime.StreamEventText, Content: "done"},
{Type: zeroruntime.StreamEventDone},
},
},
}
var requests []PermissionRequest
result, err := Run(context.Background(), "search", provider, Options{
Registry: registry,
PermissionMode: PermissionModeAsk,
OnPermissionRequest: func(_ context.Context, request PermissionRequest) (PermissionDecision, error) {
requests = append(requests, request)
return PermissionDecision{Action: PermissionDecisionDeny, Reason: "network not approved"}, nil
},
})
if err != nil {
t.Fatal(err)
}
if result.FinalAnswer != "done" {
t.Fatalf("expected final answer after denied tool call, got %q", result.FinalAnswer)
}
if len(requests) != 1 {
t.Fatalf("expected one permission request, got %#v", requests)
}
if requests[0].ToolName != "web_search" || requests[0].Permission != string(tools.PermissionPrompt) || requests[0].SideEffect != string(tools.SideEffectNetwork) {
t.Fatalf("unexpected permission request: %#v", requests[0])
}
if len(search.calls) != 0 {
t.Fatalf("web_search backend must not run when permission is denied, got calls %#v", search.calls)
}
}
func TestRunFiltersAdvertisedTools(t *testing.T) {
root := t.TempDir()
registry := tools.NewRegistry()
@@ -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)
}
}
@@ -0,0 +1,70 @@
package agent
import (
"context"
"testing"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/tools"
)
// promptShellTool is a minimal shell-style tool whose Safety requires a prompt,
// standing in for bash so buildPermissionEvent can be exercised directly.
type promptShellTool struct{}
func (promptShellTool) Name() string { return "bash" }
func (promptShellTool) Description() string { return "run a shell command" }
func (promptShellTool) Parameters() tools.Schema {
return tools.Schema{Type: "object", AdditionalProperties: false}
}
func (promptShellTool) Safety() tools.Safety {
return tools.Safety{SideEffect: tools.SideEffectShell, Permission: tools.PermissionPrompt, Reason: "runs a shell command"}
}
func (promptShellTool) Run(context.Context, map[string]any) tools.Result {
return tools.Result{Status: tools.StatusOK}
}
// A command whose sandbox decision is Allow but that explicitly requests
// additional sandbox permissions is an ELEVATION: it must be surfaced as a
// prompt, never Action=allow. Previously it carried allow while the loop still
// blocked on OnPermissionRequest, and the TUI (which renders only prompts)
// dropped it → the run deadlocked forever. This is the exact gpt-5.5
// folder-creation shape captured in the reported hang.
func TestBuildPermissionEventPromptsForAdditionalPermissions(t *testing.T) {
call := ToolCall{ID: "call_1", Name: "bash", Arguments: `{}`}
args := map[string]any{
"command": "mkdir foo && cd foo && go mod init foo",
"sandbox_permissions": string(tools.SandboxPermissionsWithAdditionalPermissions),
}
// Base command allowed by the sandbox…
decision := &sandbox.Decision{Action: sandbox.ActionAllow}
event, ok := buildPermissionEvent(call, promptShellTool{}, args, false, PermissionModeAsk, Options{}, decision)
if !ok {
t.Fatal("buildPermissionEvent returned ok=false; the elevation request must produce an event")
}
if event.Action != PermissionActionPrompt {
t.Fatalf("Action = %q, want prompt — an additional-permissions elevation must ask the user, not auto-allow", event.Action)
}
// Sanity: shouldRequestPermission agrees the loop blocks on this.
if !shouldRequestPermission(promptShellTool{}, args, false, decision) {
t.Fatal("shouldRequestPermission must be true for an additional-permissions request")
}
}
// Without the additional-permissions flag, a sandbox-allowed command is NOT a
// prompt (shouldRequestPermission is false; it just runs) — the override must
// not over-reach and prompt for ordinary allowed commands.
func TestBuildPermissionEventKeepsAllowForOrdinaryAllowedCommand(t *testing.T) {
call := ToolCall{ID: "call_2", Name: "bash", Arguments: `{}`}
args := map[string]any{"command": "ls"}
decision := &sandbox.Decision{Action: sandbox.ActionAllow}
event, _ := buildPermissionEvent(call, promptShellTool{}, args, false, PermissionModeAsk, Options{}, decision)
if event.Action == PermissionActionPrompt {
t.Fatalf("Action = prompt for an ordinary allowed command; want allow (no spurious prompt)")
}
if shouldRequestPermission(promptShellTool{}, args, false, decision) {
t.Fatal("shouldRequestPermission must be false for an ordinary sandbox-allowed command")
}
}
+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)
}
}
+15
View File
@@ -40,6 +40,21 @@ func reconnectNoticeFor(options Options) reconnectNotifier {
}
}
// stallRetryNoticeFor builds a notifier for the loop's content-stall retry (a
// stream that connected and produced only transient output, then went silent).
// Distinct wording from reconnectNoticeFor's "connection lost" because the
// connection was fine — the model stalled. Surfaced through OnReasoning, the
// non-content channel, so it never folds into the answer text. Nil when there
// is no reasoning sink (the retry still happens silently).
func stallRetryNoticeFor(options Options) reconnectNotifier {
if options.OnReasoning == nil {
return nil
}
return func(attempt, max int) {
options.OnReasoning(fmt.Sprintf("\n[no output — model stalled; retrying %d/%d…]\n", attempt, max))
}
}
// streamWithReconnect issues request via provider.StreamCompletion and, on a
// transient disconnect error, retries the connect up to maxStreamReconnects
// times with exponential backoff. It returns the live stream on success, or the
+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 -19
View File
@@ -10,19 +10,25 @@ import (
)
// stallProvider connects successfully (HTTP 200) but the stream emits a stall/idle
// timeout error for the first stallBefore calls, then succeeds with "done". When
// partialText is set it emits that text BEFORE the stall, simulating a stall after
// partial output (which must NOT be retried — re-issuing would duplicate it).
// timeout error for the first stallBefore calls, then succeeds with "done". The
// pre-stall emission simulates different partial-output shapes:
// - partialText: forwarded PROSE (StreamEventText) — must NOT be retried
// (re-issuing would duplicate visible answer text).
// - reasoningText: transient reasoning preview — IS retried.
// - partialToolCall: a tool call started (+ arg delta) but never ended, the
// "froze mid-write_file" case — IS retried (the incomplete call is never
// executed or committed).
type stallProvider struct {
calls int32
stallBefore int32
partialText string
reasoningText string
calls int32
stallBefore int32
partialText string
reasoningText string
partialToolCall string
}
func (p *stallProvider) StreamCompletion(_ context.Context, _ zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
n := atomic.AddInt32(&p.calls, 1)
ch := make(chan zeroruntime.StreamEvent, 3)
ch := make(chan zeroruntime.StreamEvent, 5)
if n <= p.stallBefore {
if p.partialText != "" {
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventText, Content: p.partialText}
@@ -30,9 +36,16 @@ func (p *stallProvider) StreamCompletion(_ context.Context, _ zeroruntime.Comple
if p.reasoningText != "" {
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventReasoning, Content: p.reasoningText}
}
if p.partialToolCall != "" {
// A tool call that starts and streams a partial argument fragment but
// never gets StreamEventToolCallEnd, then the stream errors — exactly
// the gpt-5.x "began the big write_file then froze" shape.
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "tc_1", ToolName: p.partialToolCall}
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "tc_1", ArgumentsFragment: `{"path":"x.html","content":"<!doctype`}
}
ch <- zeroruntime.StreamEvent{
Type: zeroruntime.StreamEventError,
Error: "provider stream error: no output for 10m (the model produced nothing)",
Error: "provider stream error: no output for 6m (the model produced nothing)",
}
close(ch)
return ch, nil
@@ -72,21 +85,54 @@ func TestRunDoesNotRetryStallAfterPartialOutput(t *testing.T) {
}
}
// A stall after only REASONING was forwarded (no text/tool-calls) must NOT be
// retried: the user already saw the reasoning, so re-issuing with callbacks
// re-enabled would duplicate it. collected.Text is empty here, so this exercises
// the turn-level forwarded-output gate specifically, not the text check.
func TestRunDoesNotRetryStallAfterForwardedReasoning(t *testing.T) {
// A stall after only transient REASONING was forwarded (no prose text, no
// completed tool call) IS retried: reasoning is a "thinking" preview, not
// committed answer text, so re-streaming it on a clearly-signalled retry is
// acceptable and recovers the common "reasoned then froze" stall. Only
// forwarded final prose (OnText) blocks the retry (collected.Text is empty
// here, so this exercises the transient-vs-prose gate specifically).
func TestRunRetriesStallAfterOnlyReasoning(t *testing.T) {
p := &stallProvider{stallBefore: 1, reasoningText: "thinking hard"}
_, err := Run(context.Background(), "go", p, Options{
result, err := Run(context.Background(), "go", p, Options{
Registry: tools.NewRegistry(),
OnReasoning: func(string) {},
})
if err == nil {
t.Fatal("a stall after forwarded reasoning must NOT be retried; want an error")
if err != nil {
t.Fatalf("a stall after only reasoning should retry to success, got %v", err)
}
if got := atomic.LoadInt32(&p.calls); got != 1 {
t.Fatalf("reasoning-then-stall must not retry, got %d calls", got)
if result.FinalAnswer != "done" {
t.Fatalf("final answer = %q, want %q", result.FinalAnswer, "done")
}
if got := atomic.LoadInt32(&p.calls); got != 2 {
t.Fatalf("want 2 calls (1 stall + 1 retry), got %d", got)
}
}
// A stall mid-way through an INCOMPLETE tool call (started + partial args, never
// ended) IS retried — the exact gpt-5.x / ollama "began the big write_file then
// froze" case. The incomplete call is never executed (a turn returns on the
// stall error before dispatch) and never committed to history, so re-issuing is
// safe and recovers to success. This is the primary scenario this change fixes.
func TestRunRetriesStallAfterIncompleteToolCall(t *testing.T) {
starts := 0
p := &stallProvider{stallBefore: 1, reasoningText: "planning", partialToolCall: "write_file"}
result, err := Run(context.Background(), "go", p, Options{
Registry: tools.NewRegistry(),
OnReasoning: func(string) {},
OnToolCallStart: func(string, string) { starts++ },
OnToolCallDelta: func(string, string) {},
})
if err != nil {
t.Fatalf("a stall mid-incomplete-tool-call should retry to success, got %v", err)
}
if result.FinalAnswer != "done" {
t.Fatalf("final answer = %q, want %q", result.FinalAnswer, "done")
}
if got := atomic.LoadInt32(&p.calls); got != 2 {
t.Fatalf("want 2 calls (1 stall + 1 retry), got %d", got)
}
if starts != 1 {
t.Fatalf("the incomplete tool call should have been forwarded once before the stall, got %d starts", starts)
}
}
+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
+6 -2
View File
@@ -455,11 +455,15 @@ func TestHarnessTimeoutCancelsBlockedAgent(t *testing.T) {
// The timeout is longer than materialization of the small fixture even on
// slower Windows CI runners, so the agent is reached before it blocks until
// the deadline fires.
// the deadline fires. 10s wasn't generous enough in practice — it failed
// on an otherwise-unrelated PR merge (materialization alone consumed the
// full budget on a contended Windows runner, so the agent was never
// reached); widened for headroom, since the assertions below still bound
// worst-case test time via that same deadline, not this constant.
report := harness.Run(context.Background(), suitePath, suite, BenchmarkInput{
TaskID: "document-stream-json-verify-events",
WorkRoot: t.TempDir(),
Timeout: 10 * time.Second,
Timeout: 30 * time.Second,
})
if !agentReached {
+5 -2
View File
@@ -43,8 +43,11 @@ func runACP(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int
conn := acp.NewConn(deps.stdin, stdout)
acp.NewAgent(conn, acp.Deps{
ResolveConfig: deps.resolveConfig,
NewProvider: deps.newProvider,
RunAgent: agent.Run,
// deps.newProvider is wrapped in fillAppDeps to apply the stored API key,
// so ACP is authenticated for apiKeyStored profiles like every other
// surface — no ACP-specific credential handling needed.
NewProvider: deps.newProvider,
RunAgent: agent.Run,
// Build the SCOPED registry + sandbox engine per workspace, exactly like the
// exec surface, so ACP shell/file tools are confined — never run unconfined.
BuildWorkspace: func(workspaceRoot string, resolved config.ResolvedConfig) (*tools.Registry, *sandbox.Engine, error) {
+159 -58
View File
@@ -22,6 +22,7 @@ import (
"github.com/Gitlawb/zero/internal/observability"
"github.com/Gitlawb/zero/internal/plugins"
"github.com/Gitlawb/zero/internal/providerhealth"
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
"github.com/Gitlawb/zero/internal/provideronboarding"
"github.com/Gitlawb/zero/internal/providers"
"github.com/Gitlawb/zero/internal/redaction"
@@ -43,37 +44,44 @@ import (
var version = "dev"
type appDeps struct {
getwd func() (string, error)
stdin io.Reader
userConfigPath func() (string, error)
resolveConfig func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error)
resolveMCPConfig func(workspaceRoot string) (config.MCPConfig, error)
newProvider func(config.ProviderProfile) (zeroruntime.Provider, error)
probeProviderHealth func(context.Context, providerhealth.Options) providerhealth.Result
detectLocalRuntimes func(context.Context, provideronboarding.LocalDetectOptions) []provideronboarding.DetectedLocalRuntime
newSessionStore func() *sessions.Store
loadPlugins func(plugins.LoadOptions) (plugins.LoadResult, error)
loadHooks func(hooks.LoadOptions) (hooks.LoadResult, error)
skillsDir func() string
pluginsDir func() string
toolsDir func() string
newMCPStore func() (*mcp.PermissionStore, error)
newMCPTokenStore func() (*mcp.TokenStore, error)
newSandboxStore func() (*sandbox.GrantStore, error)
selectSandboxBackend func(sandbox.BackendOptions) sandbox.Backend
runSandboxSetupHelper func(path string, args []string, stdout io.Writer, stderr io.Writer) error
registerMCPTools func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error)
prepareWorktree func(context.Context, worktrees.Options) (worktrees.Result, error)
detectVerifyPlan func(string) (verify.Plan, error)
runVerify func(context.Context, verify.Plan, verify.RunOptions) verify.Report
runSelfVerify func(context.Context, verify.Plan, selfverify.Options) selfverify.Report
runAgentEval func(context.Context, agentEvalOptions) (agentEvalReport, error)
inspectChanges func(context.Context, zerogit.InspectOptions) (zerogit.ChangeSummary, error)
commitChanges func(context.Context, zerogit.CommitOptions) (zerogit.CommitResult, error)
runTUI func(context.Context, tui.Options) int
runEditor func(string) error
checkUpdate func(context.Context, update.Options) (update.Result, error)
now func() time.Time
getwd func() (string, error)
stdin io.Reader
userConfigPath func() (string, error)
resolveConfig func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error)
resolveMCPConfig func(workspaceRoot string) (config.MCPConfig, error)
newProvider func(config.ProviderProfile) (zeroruntime.Provider, error)
// exportActiveProvider pins spawned children to the run's provider (production:
// config.SetActiveProviderEnv, set in defaultAppDeps — deliberately NOT filled
// by fillAppDeps, so tests never mutate the process environment unless they
// inject it). nil ⇒ no export.
exportActiveProvider func(providerName string)
probeProviderHealth func(context.Context, providerhealth.Options) providerhealth.Result
discoverProviderModels func(context.Context, config.ProviderProfile) ([]providermodeldiscovery.Model, error)
detectLocalRuntimes func(context.Context, provideronboarding.LocalDetectOptions) []provideronboarding.DetectedLocalRuntime
newSessionStore func() *sessions.Store
loadPlugins func(plugins.LoadOptions) (plugins.LoadResult, error)
loadHooks func(hooks.LoadOptions) (hooks.LoadResult, error)
skillsDir func() string
pluginsDir func() string
toolsDir func() string
newMCPStore func() (*mcp.PermissionStore, error)
newMCPTokenStore func() (*mcp.TokenStore, error)
newSandboxStore func() (*sandbox.GrantStore, error)
selectSandboxBackend func(sandbox.BackendOptions) sandbox.Backend
runSandboxSetupHelper func(path string, args []string, stdout io.Writer, stderr io.Writer) error
registerMCPTools func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error)
prepareWorktree func(context.Context, worktrees.Options) (worktrees.Result, error)
detectVerifyPlan func(string) (verify.Plan, error)
runVerify func(context.Context, verify.Plan, verify.RunOptions) verify.Report
runSelfVerify func(context.Context, verify.Plan, selfverify.Options) selfverify.Report
runAgentEval func(context.Context, agentEvalOptions) (agentEvalReport, error)
inspectChanges func(context.Context, zerogit.InspectOptions) (zerogit.ChangeSummary, error)
commitChanges func(context.Context, zerogit.CommitOptions) (zerogit.CommitResult, error)
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
}
type mcpToolRuntime interface {
@@ -99,9 +107,10 @@ func Run(args []string, stdout io.Writer, stderr io.Writer) int {
func defaultAppDeps() appDeps {
return appDeps{
getwd: os.Getwd,
stdin: os.Stdin,
userConfigPath: config.DefaultUserConfigPath,
getwd: os.Getwd,
stdin: os.Stdin,
userConfigPath: config.DefaultUserConfigPath,
exportActiveProvider: config.SetActiveProviderEnv,
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
options, err := config.DefaultResolveOptions(workspaceRoot)
if err != nil {
@@ -118,13 +127,19 @@ func defaultAppDeps() appDeps {
return config.ResolveMCP(options)
},
newProvider: func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
// Resolve the OAuth login ONCE: the bearer resolver and the login key it
// bound must describe the same login (the key is passed on to the Codex
// account-header resolver so it never re-selects independently).
resolver, loginKey := oauthLoginForProfile(profile)
return providers.New(profile, providers.Options{
UserAgent: userAgent(),
OAuthResolver: oauthResolverForProfile(profile),
OAuthResolver: resolver,
OAuthLoginKey: loginKey,
})
},
probeProviderHealth: providerhealth.Probe,
detectLocalRuntimes: provideronboarding.DetectLocalRuntimes,
probeProviderHealth: providerhealth.Probe,
discoverProviderModels: defaultDiscoverProviderModels,
detectLocalRuntimes: provideronboarding.DetectLocalRuntimes,
newSessionStore: func() *sessions.Store {
return sessions.NewStore(sessions.StoreOptions{})
},
@@ -166,6 +181,7 @@ func defaultAppDeps() appDeps {
runTUI: tui.Run,
runEditor: openEditor,
checkUpdate: update.Check,
applyUpdate: update.Apply,
now: time.Now,
}
}
@@ -217,9 +233,9 @@ func runWithDeps(args []string, stdout io.Writer, stderr io.Writer, deps appDeps
if err != nil {
return writeAppError(stderr, err.Error(), 1)
}
// --theme {auto|dark|light} selects the TUI palette non-interactively (populates
// tui.Options.Theme, which resolveThemeMode prefers over ZERO_THEME). Re-split
// --add-dir afterward so it may appear on either side of --theme.
// --theme <name> selects the TUI palette non-interactively (auto or any registered
// theme; populates tui.Options.Theme, which resolveThemeMode prefers over
// ZERO_THEME). Re-split --add-dir afterward so it may appear on either side of --theme.
theme, args, err := splitLeadingThemeFlag(args)
if err != nil {
return writeAppError(stderr, err.Error(), 1)
@@ -369,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":
@@ -391,6 +409,14 @@ func runWithDeps(args []string, stdout io.Writer, stderr io.Writer, deps appDeps
if _, err := fmt.Fprintf(stderr, "unknown command %q\n", args[0]); err != nil {
return 1
}
// First-run users reach for `zero login`/`zero logout` (reported in the
// wild); point them at the real command instead of a bare usage pointer.
switch strings.ToLower(args[0]) {
case "login", "logout":
if _, err := fmt.Fprintf(stderr, "did you mean %q?\n", "zero auth "+strings.ToLower(args[0])); err != nil {
return 1
}
}
if _, err := fmt.Fprintln(stderr, "Run zero --help for usage."); err != nil {
return 1
}
@@ -421,6 +447,9 @@ func fillAppDeps(deps appDeps) appDeps {
if deps.probeProviderHealth == nil {
deps.probeProviderHealth = defaults.probeProviderHealth
}
if deps.discoverProviderModels == nil {
deps.discoverProviderModels = defaults.discoverProviderModels
}
if deps.detectLocalRuntimes == nil {
deps.detectLocalRuntimes = defaults.detectLocalRuntimes
}
@@ -490,9 +519,23 @@ 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
}
// Wrap newProvider ONCE, after all defaults are filled, so every surface that
// builds the runtime provider gets the credential-store key applied. The
// resolver is pure and leaves apiKeyStored profiles keyless; an unwrapped
// build sends unauthenticated requests. config.ApplyStoredAPIKey is a no-op
// when the key is already set or the profile isn't stored-key, so this is
// safe and idempotent for every profile kind and every injected newProvider.
baseNewProvider := deps.newProvider
userConfigPath := deps.userConfigPath
deps.newProvider = func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
return baseNewProvider(applyStoredProviderKeyAt(profile, userConfigPath))
}
return deps
}
@@ -508,7 +551,32 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
resolved, err := deps.resolveConfig(workspaceRoot, config.Overrides{})
if err != nil {
return writeAppError(stderr, err.Error(), 1)
// A resolve failure the setup wizard can FIX is not fatal for the
// interactive TUI: drop into the wizard with an empty config so the user
// can onboard/repair, instead of exiting with an error they can only fix
// by hand-editing config.json. That covers a missing/unresolvable active
// provider and an active provider without a model (custom endpoints have
// no catalog default) — previously the second shape bricked bare `zero`
// and `zero setup`, the exact commands that could have fixed it. Any
// other error (malformed JSON, directory conflict, etc.) is still fatal,
// and headless commands (zero config / zero exec) still fail with the
// actionable message.
if !errors.Is(err, config.ErrNoActiveProvider) && !errors.Is(err, config.ErrProviderRequiresModel) {
return writeAppError(stderr, err.Error(), 1)
}
// 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 {
@@ -641,6 +709,7 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
return deps.runTUI(context.Background(), tui.Options{
Cwd: workspaceRoot,
Theme: theme,
SavedTheme: resolved.Preferences.Theme,
UserConfigPath: userConfigPath,
DoctorUserConfigPath: doctorUserConfigPath,
ProjectConfigPath: projectConfigPath,
@@ -689,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,
@@ -719,20 +790,50 @@ func tuiSandboxSetupCommand(backend sandbox.Backend, deps appDeps) func(context.
}
}
// buildProvider constructs the run's provider at STARTUP — it is called only from
// the two launch paths (interactive TUI and headless exec), never from mid-run
// rebuilds (escalation, wizard, ACP go through deps.newProvider directly).
func buildProvider(resolved config.ResolvedConfig, deps appDeps) (zeroruntime.Provider, error) {
if !config.HasProviderProfile(resolved.Provider) {
return nil, nil
}
// Load the API key from the encrypted credential store when it isn't already
// resolved from inline config or an env var. This keeps the resolver pure (no
// I/O) and is a no-op for env/inline-key providers and when nothing is stored.
profile := resolved.Provider
if path, perr := deps.userConfigPath(); perr == nil {
// deps.newProvider is wrapped in fillAppDeps to apply the stored key, so this
// (and every other newProvider caller) needs no per-site key handling.
provider, err := deps.newProvider(resolved.Provider)
if err != nil {
return nil, err
}
// Pin spawned children (sub-agents / swarm members inherit the environment) to
// THIS run's provider from launch, not only after an in-session switch. Without
// the launch-time export a child re-resolves config.json at spawn time, so a
// provider switch persisted by ANOTHER zero process mid-session would silently
// move new children onto a different provider (and different credentials) than
// the parent is running. Runtime switches (/model, /provider, wizard,
// onboarding) re-export on commit, keeping the pin current. Injected via deps
// (nil in tests) because it mutates the process environment.
if deps.exportActiveProvider != nil {
deps.exportActiveProvider(resolved.Provider.Name)
}
return provider, nil
}
// applyStoredProviderKeyAt loads the API key from the encrypted credential store
// when the resolver left it empty (the resolver is pure — no I/O — so an
// apiKeyStored profile reaches this layer keyless). No-op for inline/env-key
// providers and when nothing is stored. This is applied once, centrally, by the
// deps.newProvider wrapper in fillAppDeps so EVERY surface that builds a runtime
// provider (headless exec, the ACP builder, exec's mid-run escalation switcher,
// and any future caller) is covered without a per-site invariant to forget.
func applyStoredProviderKeyAt(profile config.ProviderProfile, userConfigPath func() (string, error)) config.ProviderProfile {
if userConfigPath == nil {
return profile
}
if path, perr := userConfigPath(); perr == nil {
if store, err := config.ProviderKeyStoreAt(filepath.Dir(path)); err == nil {
profile = config.ApplyStoredAPIKey(profile, store)
}
}
return deps.newProvider(profile)
return profile
}
func newCoreRegistry(workspaceRoot string) *tools.Registry {
@@ -1033,9 +1134,10 @@ func splitLeadingAddDirFlags(args []string) ([]string, []string, error) {
return addDirs, args, nil
}
// splitLeadingThemeFlag strips a leading --theme {auto|dark|light} (space or =form)
// from the root argument list and validates it. The last occurrence wins. A value
// outside the allowed set is a loud error rather than a silent fallback.
// splitLeadingThemeFlag strips a leading --theme <auto|theme-name> (space or =form)
// from the root argument list and validates it against the registered themes. The
// last occurrence wins. A value outside the allowed set is a loud error rather than
// a silent fallback.
func splitLeadingThemeFlag(args []string) (string, []string, error) {
theme := ""
for len(args) > 0 {
@@ -1043,7 +1145,7 @@ func splitLeadingThemeFlag(args []string) (string, []string, error) {
switch {
case args[0] == "--theme":
if len(args) < 2 {
return "", nil, errors.New("--theme requires a value (auto, dark, or light)")
return "", nil, errors.New("--theme requires a value (auto or a theme name; try --theme auto)")
}
value = strings.TrimSpace(args[1])
args = args[2:]
@@ -1053,12 +1155,10 @@ func splitLeadingThemeFlag(args []string) (string, []string, error) {
default:
return theme, args, nil
}
switch strings.ToLower(value) {
case "auto", "dark", "light":
theme = strings.ToLower(value)
default:
return "", nil, fmt.Errorf("--theme must be auto, dark, or light (got %q)", value)
if !tui.ValidThemeArg(value) {
return "", nil, fmt.Errorf("--theme must be auto or a registered theme name (got %q)", value)
}
theme = strings.ToLower(value)
}
return theme, args, nil
}
@@ -1078,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 == "" {
@@ -1093,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
}
+356 -1
View File
@@ -5,6 +5,7 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
@@ -157,6 +158,141 @@ func TestRunNoArgsLaunchesSetupTUIWithNilProviderWhenNoProviderConfigured(t *tes
assertAgentOptions(t, launchedOptions, 12, agent.PermissionModeAsk)
}
func TestRunNoArgsEntersSetupWhenResolveReportsNoActiveProvider(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
exitCode := runWithDeps([]string{}, &stdout, &stderr, appDeps{
getwd: func() (string, error) {
return cwd, nil
},
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
return config.ResolvedConfig{}, fmt.Errorf("%w: active provider %q not found", config.ErrNoActiveProvider, "ghost")
},
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
t.Fatal("newProvider should not be called without a resolved provider")
return nil, 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 (setup TUI launched), stderr=%q", exitCode, stderr.String())
}
if !launched {
t.Fatal("TUI was not launched; expected fallback into setup instead of a fatal error")
}
if !launchedOptions.Setup.Visible || !launchedOptions.Setup.Required {
t.Fatalf("Setup = %#v, want visible required setup", launchedOptions.Setup)
}
if launchedOptions.Provider != nil {
t.Fatalf("Provider = %#v, want nil for no-provider fallback", launchedOptions.Provider)
}
}
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
cwd := t.TempDir()
setCLIUserConfigRoot(t)
exitCode := runWithDeps([]string{}, &stdout, &stderr, appDeps{
getwd: func() (string, error) {
return cwd, nil
},
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
return config.ResolvedConfig{}, fmt.Errorf("invalid config JSON")
},
runTUI: func(ctx context.Context, options tui.Options) int {
t.Fatal("TUI must not launch on a non-provider resolve error")
return 0
},
})
if exitCode == 0 {
t.Fatal("exit code = 0, want non-zero for fatal config error")
}
if !strings.Contains(stderr.String(), "invalid config JSON") {
t.Fatalf("stderr = %q, want the underlying config error", stderr.String())
}
}
func TestRunNoArgsLaunchesTUIWithMCPState(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
@@ -1192,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()
@@ -1397,7 +1680,79 @@ func TestRunThemeFlagRejectsBadValue(t *testing.T) {
if launched {
t.Fatal("the TUI must not launch on a bad --theme value")
}
if !strings.Contains(stderr.String(), "--theme must be auto, dark, or light") {
if !strings.Contains(stderr.String(), "--theme must be auto or a registered theme name") {
t.Fatalf("expected a clear --theme error, got %q", stderr.String())
}
}
// A custom endpoint saved without a model previously bricked bare `zero` and
// `zero setup` — the exact commands that could have fixed it (the resolve
// error escaped before the wizard could open). The interactive TUI now treats
// the requires-model failure as "needs onboarding", same as a missing active
// provider. The error comes from a REAL Resolve over a real config file, so
// this exercises the production sentinel wrapping, not a hand-built error.
func TestRunNoArgsEntersSetupWhenActiveProviderMissesModel(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
cwd := t.TempDir()
setCLIUserConfigRoot(t)
brokenConfig := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(brokenConfig, []byte(`{
"activeProvider": "gw",
"providers": [{"name": "gw", "provider_kind": "openai-compatible", "baseURL": "https://gw.example/v1", "apiKey": "sk-x"}]
}`), 0o600); err != nil {
t.Fatal(err)
}
launched := false
var launchedOptions tui.Options
exitCode := runWithDeps([]string{}, &stdout, &stderr, appDeps{
getwd: func() (string, error) { return cwd, nil },
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
return config.Resolve(config.ResolveOptions{UserConfigPath: brokenConfig, Env: map[string]string{}})
},
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
t.Fatal("newProvider must not be called without a resolved provider")
return nil, nil
},
userConfigPath: func() (string, error) { return filepath.Join(t.TempDir(), "zero", "config.json"), nil },
registerMCPTools: func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) {
return noopMCPRuntime{}, nil
},
runTUI: func(_ context.Context, options tui.Options) int {
launched = true
launchedOptions = options
return 0
},
})
if exitCode != 0 {
t.Fatalf("exit code = %d, want 0 (setup TUI launched), stderr=%q", exitCode, stderr.String())
}
if !launched {
t.Fatal("TUI was not launched; a fixable requires-model config must fall into setup, not exit fatally")
}
if !launchedOptions.Setup.Visible || !launchedOptions.Setup.Required {
t.Fatalf("Setup = %#v, want visible required setup", launchedOptions.Setup)
}
}
// `zero login`/`zero logout` don't exist (it's `zero auth login`); first-run
// users try them (reported in the wild), so the unknown-command error points at
// the real command.
func TestRunUnknownLoginSuggestsAuthLogin(t *testing.T) {
var stdout, stderr bytes.Buffer
exitCode := runWithDeps([]string{"login"}, &stdout, &stderr, appDeps{})
if exitCode != 2 {
t.Fatalf("exit = %d, want 2 (unknown command)", exitCode)
}
if !strings.Contains(stderr.String(), `"zero auth login"`) {
t.Fatalf("stderr = %q, want a zero auth login suggestion", stderr.String())
}
// An unrelated unknown command gets no misleading suggestion.
stderr.Reset()
runWithDeps([]string{"frobnicate"}, &stdout, &stderr, appDeps{})
if strings.Contains(stderr.String(), "did you mean") {
t.Fatalf("stderr = %q, unrelated commands must not get the auth hint", stderr.String())
}
}
+22 -3
View File
@@ -283,6 +283,9 @@ func newAuthManager(deps appDeps, out io.Writer) (*oauth.Manager, error) {
// carries no token material. A real browser launch is intentionally not
// performed (the sandbox/headless contexts make printing the safer default).
OpenBrowser: func(string) error { return nil },
// `zero auth login <preset>` (e.g. xai) should resolve the baked-in preset
// without the operator exporting ZERO_OAUTH_ALLOW_PRESETS first.
AllowPresets: true,
})
}
@@ -298,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,
})
@@ -486,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
+180
View File
@@ -0,0 +1,180 @@
package cli
import (
"bytes"
"errors"
"os"
"path/filepath"
"testing"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// seedStoredProviderKey writes key into a credential store rooted beside the
// returned config path, and returns deps fields wired at that path — the same
// layout applyStoredProviderKey resolves through in production.
func seedStoredProviderKey(t *testing.T, providerName, key string) (userConfigPath string) {
t.Helper()
t.Setenv("ZERO_CRED_STORAGE", "encrypted-file") // never touch the real OS keychain in tests
dir := t.TempDir()
store, err := config.ProviderKeyStoreAt(dir)
if err != nil {
t.Fatalf("ProviderKeyStoreAt: %v", err)
}
if err := store.Set(providerName, key); err != nil {
t.Fatalf("seed stored key: %v", err)
}
return filepath.Join(dir, "config.json")
}
// applyStoredProviderKeyAt must fill APIKey from the credential store for an
// apiKeyStored profile — the single central helper the newProvider wrapper uses.
func TestApplyStoredProviderKeyAtFillsFromCredstore(t *testing.T) {
configPath := seedStoredProviderKey(t, "echo", "sk-stored-test")
ucp := func() (string, error) { return configPath, nil }
stored := applyStoredProviderKeyAt(config.ProviderProfile{Name: "echo", APIKeyStored: true}, ucp)
if stored.APIKey != "sk-stored-test" {
t.Fatalf("APIKey = %q, want the stored key", stored.APIKey)
}
// A profile that did NOT opt into stored-key auth must be left unchanged —
// a stale store entry must not silently reactivate credentials.
plain := applyStoredProviderKeyAt(config.ProviderProfile{Name: "echo"}, ucp)
if plain.APIKey != "" {
t.Fatalf("non-stored profile APIKey = %q, want empty", plain.APIKey)
}
}
// fillAppDeps wraps newProvider so EVERY surface that builds a runtime provider
// (buildProvider, the ACP builder — which is just deps.newProvider — and exec's
// escalation switcher) gets the stored key applied. Passing the raw resolved
// profile previously sent unauthenticated requests for apiKeyStored profiles,
// the default onboarding outcome. Testing the wrap covers all those surfaces.
func TestFillAppDepsWrapsNewProviderWithStoredKey(t *testing.T) {
configPath := seedStoredProviderKey(t, "echo", "sk-stored-wrap")
var captured config.ProviderProfile
deps := fillAppDeps(appDeps{
userConfigPath: func() (string, error) { return configPath, nil },
newProvider: func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
captured = profile
return nil, nil // only the captured profile matters here
},
})
if _, err := deps.newProvider(config.ProviderProfile{Name: "echo", APIKeyStored: true}); err != nil {
t.Fatalf("wrapped newProvider: %v", err)
}
if captured.APIKey != "sk-stored-wrap" {
t.Fatalf("wrapped newProvider built with APIKey = %q, want the stored key (unauthenticated regression)", captured.APIKey)
}
}
// buildProvider (the TUI/exec STARTUP construction site) must export
// ZERO_PROVIDER so children spawned at any point in the run are pinned to the
// parent's provider from launch — not only after an in-session switch. Without
// this, a provider switch persisted by another zero process mid-session moves
// new children onto a different provider (and credentials) than the parent.
func TestBuildProviderExportsActiveProviderEnv(t *testing.T) {
t.Setenv(config.ActiveProviderEnv, "stale-from-elsewhere")
// The exporter is injected (defaultAppDeps wires config.SetActiveProviderEnv;
// fillAppDeps deliberately leaves it nil so ordinary tests never mutate the
// process env). Inject the real one here to assert the actual env effect.
deps := appDeps{
exportActiveProvider: config.SetActiveProviderEnv,
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
return nil, nil
},
}
resolved := config.ResolvedConfig{Provider: config.ProviderProfile{Name: "pinned", ProviderKind: config.ProviderKindAnthropic, Model: "m"}}
if _, err := buildProvider(resolved, deps); err != nil {
t.Fatalf("buildProvider: %v", err)
}
if got := os.Getenv(config.ActiveProviderEnv); got != "pinned" {
t.Fatalf("%s = %q after startup build, want %q (children would spawn on the stale provider)", config.ActiveProviderEnv, got, "pinned")
}
// A FAILED build must not move the pin: the process never committed to the
// new provider, so children must keep resolving whatever was in effect.
t.Setenv(config.ActiveProviderEnv, "still-current")
failing := appDeps{
exportActiveProvider: config.SetActiveProviderEnv,
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
return nil, errors.New("boom")
},
}
if _, err := buildProvider(resolved, failing); err == nil {
t.Fatal("expected build error")
}
if got := os.Getenv(config.ActiveProviderEnv); got != "still-current" {
t.Fatalf("%s = %q after failed build, want it untouched", config.ActiveProviderEnv, got)
}
// No provider profile at all → no provider, no pin change.
if _, err := buildProvider(config.ResolvedConfig{}, deps); err != nil {
t.Fatalf("buildProvider(empty): %v", err)
}
if got := os.Getenv(config.ActiveProviderEnv); got != "still-current" {
t.Fatalf("%s = %q after profile-less build, want it untouched", config.ActiveProviderEnv, got)
}
}
// A mid-run --allow-escalation model switch previously rebuilt the provider
// from the PURE resolved profile (the stored key lives only in buildProvider's
// local copy), so the escalated provider was keyless and the next completion
// call 401'd mid-run. Every profile handed to newProvider on this path must
// carry the stored key.
func TestRunExecEscalationSwitchKeepsStoredKey(t *testing.T) {
dataHome := t.TempDir()
t.Setenv("XDG_DATA_HOME", dataHome)
cwd := t.TempDir()
if _, ok := mustUpgradeTarget(t, "claude-haiku-4.5"); !ok {
t.Skip("registry has no upgrade target for claude-haiku-4.5")
}
configPath := seedStoredProviderKey(t, "echo", "sk-stored-escalation")
var builtProfiles []config.ProviderProfile
var stdout, stderr bytes.Buffer
exitCode := runWithDeps([]string{
"exec",
"--allow-escalation",
"--model", "claude-haiku-4.5",
"--init-session-id", "escalation_stored_key_run",
"escalate please",
}, &stdout, &stderr, appDeps{
getwd: func() (string, error) { return cwd, nil },
userConfigPath: func() (string, error) { return configPath, nil },
resolveConfig: func(_ string, overrides config.Overrides) (config.ResolvedConfig, error) {
model := "claude-haiku-4.5"
if overrides.Provider.Model != "" {
model = overrides.Provider.Model
}
cfg := execResolvedConfig()
cfg.Provider.ProviderKind = config.ProviderKindAnthropic
cfg.Provider.Model = model
cfg.Provider.APIKeyStored = true // key lives ONLY in the credstore
cfg.MaxTurns = 3
return cfg, nil
},
newProvider: func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
builtProfiles = append(builtProfiles, profile)
return &usageEmittingEscalatingProvider{escalate: len(builtProfiles) == 1}, nil
},
})
if exitCode != exitSuccess {
t.Fatalf("exit = %d, want success; stderr=%s", exitCode, stderr.String())
}
if len(builtProfiles) < 2 {
t.Fatalf("want at least 2 provider builds (initial + escalated), got %d", len(builtProfiles))
}
for i, profile := range builtProfiles {
if profile.APIKey != "sk-stored-escalation" {
t.Fatalf("provider build %d APIKey = %q, want the stored key (escalated build went out keyless)", i, profile.APIKey)
}
}
}
+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())
}
}
+5
View File
@@ -81,6 +81,9 @@ func runProviders(args []string, stdout io.Writer, stderr io.Writer, deps appDep
if command == "detect" {
return runProvidersDetect(args, stdout, stderr, deps)
}
if command == "models" {
return runProvidersModels(args, stdout, stderr, deps)
}
if command != "list" && command != "current" && command != "catalog" {
return writeExecUsageError(stderr, fmt.Sprintf("unknown providers command %q", command))
}
@@ -465,9 +468,11 @@ func writeProvidersHelp(w io.Writer) error {
zero providers use <name> [flags]
zero providers setup <catalog-id> [flags]
zero providers detect [flags]
zero providers models [name] [flags]
Inspects resolved provider profiles and provider catalog descriptors without printing secrets.
Detect probes for running local runtimes (Ollama, LM Studio) and prints adopt commands plus per-provider next steps.
Models probes a provider's live model-listing endpoint (e.g. an OpenAI-compatible /v1/models) and lists the models it serves — including custom OpenAI-/Anthropic-compatible endpoints — so a self-hosted provider needs no per-model config.
Flags:
--json Print JSON summary
+4
View File
@@ -365,6 +365,9 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
var modelSwitcher func(context.Context, string) (agent.Provider, error)
if options.allowEscalation {
modelSwitcher = func(_ context.Context, modelID string) (agent.Provider, error) {
// deps.newProvider is wrapped (fillAppDeps) to apply the stored key, so
// the escalated provider is authenticated even though resolved.Provider
// is the pure profile — no per-site key handling here.
switchedProfile := resolved.Provider
switchedProfile.Model = modelID
switchedProvider, err := deps.newProvider(switchedProfile)
@@ -505,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,
+86
View File
@@ -24,6 +24,7 @@ func TestProviderHasOAuthLogin(t *testing.T) {
func TestSetupRequiredRecognizesOAuthLogin(t *testing.T) {
path := filepath.Join(t.TempDir(), "tok.json")
t.Setenv("ZERO_OAUTH_STORAGE", "file") // an inherited "keyring" would ignore the temp path and hit the OS keychain
t.Setenv("ZERO_OAUTH_TOKENS_PATH", path)
store, err := oauth.NewStore(oauth.StoreOptions{FilePath: path})
if err != nil {
@@ -45,3 +46,88 @@ func TestSetupRequiredRecognizesOAuthLogin(t *testing.T) {
t.Fatal("a keyless provider with no OAuth login must require onboarding")
}
}
// A profile renamed away from its catalog ID must still get a runtime OAuth
// resolver when the login is stored under the catalog name — and a CASE-VARIANT
// profile name must not drop the catalog-ID candidate (the store is
// case-sensitive; a case-insensitive dedupe would swallow it).
func TestOAuthResolverForProfileFallsBackToCatalogID(t *testing.T) {
path := filepath.Join(t.TempDir(), "tok.json")
t.Setenv("ZERO_OAUTH_STORAGE", "file") // an inherited "keyring" would ignore the temp path and hit the OS keychain
t.Setenv("ZERO_OAUTH_TOKENS_PATH", path)
store, err := oauth.NewStore(oauth.StoreOptions{FilePath: path})
if err != nil {
t.Fatalf("NewStore: %v", err)
}
if err := store.Save(oauth.ProviderKey("chatgpt"), oauth.Token{AccessToken: "tok", ExpiresAt: time.Now().Add(time.Hour)}); err != nil {
t.Fatalf("seed token: %v", err)
}
// A renamed profile resolves the catalog-ID login, and the returned key MUST be
// that login's key — the same key the Codex account-header resolver reads from,
// so bearer and account can never come from different logins.
if resolver, key := oauthLoginForProfile(config.ProviderProfile{Name: "codex", CatalogID: "chatgpt"}); resolver == nil {
t.Fatal("renamed profile with a catalog-ID login must get a resolver (unauthenticated-children regression)")
} else if key != oauth.ProviderKey("chatgpt") {
t.Fatalf("bound key = %q, want the chatgpt login key (bearer/account must share it)", key)
}
// Name differs from catalog ID only in CASE: the exact-case store key is
// "provider:chatgpt", so "chatgpt" must survive as a distinct candidate.
if resolver, key := oauthLoginForProfile(config.ProviderProfile{Name: "ChatGPT", CatalogID: "chatgpt"}); resolver == nil {
t.Fatal("case-variant profile name must still resolve the catalog-ID login (case-insensitive dedupe regression)")
} else if key != oauth.ProviderKey("chatgpt") {
t.Fatalf("case-variant bound key = %q, want the chatgpt login key", key)
}
if resolver, _ := oauthLoginForProfile(config.ProviderProfile{Name: "chatgpt", CatalogID: "chatgpt"}); resolver == nil {
t.Fatal("catalog-named profile must get a resolver")
}
if resolver, key := oauthLoginForProfile(config.ProviderProfile{Name: "openai", CatalogID: "openai"}); resolver != nil || key != "" {
t.Fatalf("a profile with no stored login must get no resolver and an empty key, got key=%q", key)
}
}
// The catalog-ID fallback must NOT reach a sibling profile's OAuth login when
// this profile has its own key — withBearer would erase the key and silently
// bill the model call under the sibling's account (a real cross-profile
// override the fallback introduced). A profile WITH its own resolved key gets no
// resolver even when a catalog-shared login exists.
func TestOAuthResolverForProfileDoesNotOverrideExplicitKey(t *testing.T) {
path := filepath.Join(t.TempDir(), "tok.json")
t.Setenv("ZERO_OAUTH_STORAGE", "file") // an inherited "keyring" would ignore the temp path and hit the OS keychain
t.Setenv("ZERO_OAUTH_TOKENS_PATH", path)
store, err := oauth.NewStore(oauth.StoreOptions{FilePath: path})
if err != nil {
t.Fatalf("NewStore: %v", err)
}
// A personal login stored under the catalog name.
if err := store.Save(oauth.ProviderKey("anthropic"), oauth.Token{AccessToken: "personal-tok", ExpiresAt: time.Now().Add(time.Hour)}); err != nil {
t.Fatalf("seed token: %v", err)
}
// Work profile: same catalog, its OWN resolved API key, deliberately NOT the
// personal OAuth account. It must keep using its key (no resolver attached).
work := config.ProviderProfile{Name: "anthropic-work", CatalogID: "anthropic", APIKey: "sk-work-key"}
if resolver, key := oauthLoginForProfile(work); resolver != nil || key != "" {
t.Fatalf("a profile with its own API key must not get a catalog-shared OAuth resolver (cross-profile override), got key=%q", key)
}
// A raw auth-header credential must be protected the same way.
header := config.ProviderProfile{Name: "anthropic-hdr", CatalogID: "anthropic", AuthHeaderValue: "Bearer byo"}
if resolver, _ := oauthLoginForProfile(header); resolver != nil {
t.Fatal("a profile with its own auth header must not get a catalog-shared OAuth resolver")
}
// A same-NAME login must not override an explicit key either (the name
// candidate is gated on the profile being keyless, not just the catalog one).
if err := store.Save(oauth.ProviderKey("anthropic-work"), oauth.Token{AccessToken: "name-tok", ExpiresAt: time.Now().Add(time.Hour)}); err != nil {
t.Fatalf("seed name token: %v", err)
}
if resolver, _ := oauthLoginForProfile(work); resolver != nil {
t.Fatal("a profile with its own API key must not get an OAuth resolver even from a same-name login")
}
// Sanity: a keyless sibling on the same catalog DOES resolve the shared login.
keyless := config.ProviderProfile{Name: "anthropic-oauth", CatalogID: "anthropic"}
if resolver, key := oauthLoginForProfile(keyless); resolver == nil {
t.Fatal("a keyless profile should still resolve the catalog-shared login")
} else if key != oauth.ProviderKey("anthropic") {
t.Fatalf("keyless sibling bound key = %q, want the catalog login key", key)
}
}
+32 -26
View File
@@ -4,7 +4,6 @@ import (
"context"
"errors"
"net/http"
"strings"
"time"
"github.com/Gitlawb/zero/internal/config"
@@ -12,42 +11,48 @@ import (
"github.com/Gitlawb/zero/internal/providers/providerio"
)
// providerOAuthName resolves the OAuth login name for a provider profile. It is
// the name a user logs in under (`zero auth login <name>`) whose token then
// authenticates this provider's model calls. For now it is the profile name;
// the provider presets map well-known kinds (openai/anthropic) to canonical
// names on top of this.
func providerOAuthName(profile config.ProviderProfile) string {
return strings.TrimSpace(profile.Name)
}
// oauthResolverForProfile returns a TokenResolver that authenticates a provider's
// model calls with the user's OAuth login, or nil when no login exists for this
// provider. Returning nil for the no-login case keeps API-key users free of any
// per-request store lookups: the resolver is only attached once a login is
// present at construction time.
func oauthResolverForProfile(profile config.ProviderProfile) providerio.TokenResolver {
name := providerOAuthName(profile)
if name == "" {
return nil
// oauthLoginForProfile resolves the user's OAuth login for a provider ONCE and
// returns both a TokenResolver that authenticates model calls with it and the
// credential-store key it bound to. It returns (nil, "") when no login exists —
// keeping API-key users free of any per-request store lookups, since the resolver
// is only attached when a login is present at construction time.
//
// The returned key is the single source of truth for "which login is this
// provider using": callers pass it to providers.Options.OAuthLoginKey so the
// Codex chatgpt-account-id header reads its account from the exact login that
// issued the bearer token, instead of doing a second, independent lookup that
// could select a different login (a backend-rejected mismatch).
//
// Candidate login names (profile name, then a catalog-ID fallback, both gated on
// the profile having no own configured credential) come from the shared
// ProviderProfile.OAuthLoginCandidates so the runtime resolver, the Codex account
// resolver, and the onboarding presence check never diverge.
func oauthLoginForProfile(profile config.ProviderProfile) (providerio.TokenResolver, string) {
candidates := profile.OAuthLoginCandidates()
if len(candidates) == 0 {
return nil, ""
}
store, err := oauth.NewStore(oauth.StoreOptions{})
if err != nil {
return nil
return nil, ""
}
key := oauth.ProviderKey(name)
if _, ok, loadErr := store.Load(key); loadErr != nil || !ok {
// No login (or an unreadable/invalid key) → API-key auth, no resolver.
return nil
_, key, ok := oauth.FirstStored(store, candidates)
if !ok {
// No login under any candidate (or unreadable/invalid keys) → API-key
// auth, no resolver.
return nil, ""
}
manager, err := oauth.NewManager(oauth.ManagerOptions{
Store: store,
HTTPClient: &http.Client{Timeout: 30 * time.Second},
// Refreshing a token the user logged into (possibly a preset provider like
// xAI) re-resolves that provider's OAuth config, which needs the preset.
AllowPresets: true,
})
if err != nil {
return nil
return nil, ""
}
return func(ctx context.Context, forceRefresh bool) (string, string, bool, error) {
resolver := func(ctx context.Context, forceRefresh bool) (string, string, bool, error) {
var token string
var rerr error
if forceRefresh {
@@ -64,4 +69,5 @@ func oauthResolverForProfile(profile config.ProviderProfile) providerio.TokenRes
}
return "Authorization", "Bearer " + token, true, nil
}
return resolver, key
}
+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
@@ -18,6 +18,7 @@ func TestProviderCatalogRuntimeProvidersShowOnboardingSetupHints(t *testing.T) {
}{
{id: "openai", name: "OpenAI", transport: "openai", defaultModel: "gpt-4.1"},
{id: "groq", name: "Groq", transport: "openai-compatible", defaultModel: "llama-3.3-70b-versatile"},
{id: "longcat", name: "LongCat", transport: "openai-compatible", defaultModel: "LongCat-2.0"},
{id: "ollama-cloud", name: "Ollama Cloud", transport: "openai-compatible", defaultModel: "qwen3-coder:480b"},
{id: "ollama", name: "Ollama Local", transport: "openai-compatible", defaultModel: "llama3.1"},
}
@@ -46,6 +47,21 @@ func TestProviderCatalogRuntimeProvidersShowOnboardingSetupHints(t *testing.T) {
}
}
func TestProviderCatalogLongCatRequiresAPIKey(t *testing.T) {
output := runProviderCatalogOnboarding(t)
block := providerCatalogOnboardingBlock(t, output, "longcat")
for _, want := range []string{
"requiresAuth=true",
"authEnvVars=LONGCAT_API_KEY",
"setup: zero providers setup longcat --set-active",
} {
if !strings.Contains(block, want) {
t.Fatalf("expected LongCat catalog block to contain %q, got:\n%s", want, block)
}
}
}
func TestProviderCatalogOllamaCloudRequiresAPIKey(t *testing.T) {
output := runProviderCatalogOnboarding(t)
block := providerCatalogOnboardingBlock(t, output, "ollama-cloud")
+148
View File
@@ -0,0 +1,148 @@
package cli
import (
"context"
"fmt"
"io"
"os"
"strings"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
)
type providerModelsOptions struct {
name string
json bool
}
// runProvidersModels lists the models a saved provider actually serves by probing
// its live model-discovery endpoint (e.g. an OpenAI-compatible `/v1/models`). It
// works for custom OpenAI-/Anthropic-compatible providers too: discovery runs off
// the profile's base URL + credentials, so a self-hosted endpoint serving a dozen
// models no longer needs a config object per model — configure the provider once,
// then run any listed model with `zero exec --model <id>` (Zero passes unknown
// model ids through to the provider).
func runProvidersModels(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
options, help, err := parseProviderModelsArgs(args)
if err != nil {
return writeExecUsageError(stderr, err.Error())
}
if help {
if err := writeProvidersHelp(stdout); err != nil {
return exitCrash
}
return exitSuccess
}
resolved, exitCode := resolveCommandCenterConfig(stderr, deps)
if exitCode != exitSuccess {
return exitCode
}
profile, err := selectProviderForCheck(resolved, options.name)
if err != nil {
return writeExecUsageError(stderr, err.Error())
}
ctx, stop := signalContext()
defer stop()
models, err := deps.discoverProviderModels(ctx, discoveryCredentialProfile(profile))
if err != nil {
return writeAppError(stderr, err.Error(), exitProvider)
}
if options.json {
items := make([]map[string]any, 0, len(models))
for _, model := range models {
entry := map[string]any{"id": model.ID}
if description := strings.TrimSpace(model.Description); description != "" {
entry["description"] = description
}
items = append(items, entry)
}
payload := map[string]any{
"provider": profile.Name,
"count": len(items),
"models": items,
}
if err := writePrettyJSON(stdout, payload); err != nil {
return exitCrash
}
return exitSuccess
}
name := strings.TrimSpace(profile.Name)
if name == "" {
name = "provider"
}
if _, err := fmt.Fprintf(stdout, "Provider models (%s)\n", name); err != nil {
return exitCrash
}
for _, model := range models {
line := strings.TrimSpace(model.ID)
if description := strings.TrimSpace(model.Description); description != "" {
line += " — " + description
}
if _, err := fmt.Fprintln(stdout, line); err != nil {
return exitCrash
}
}
suffix := "s"
if len(models) == 1 {
suffix = ""
}
if _, err := fmt.Fprintf(stdout, "%d model%s discovered\n", len(models), suffix); err != nil {
return exitCrash
}
if len(models) > 0 {
if _, err := fmt.Fprintf(stdout, "next: zero exec %q --model %s\n", "hello", setupCommandArg(models[0].ID)); err != nil {
return exitCrash
}
}
return exitSuccess
}
// discoveryCredentialProfile resolves the profile's API key the same way the
// runtime does — inline, then the stored credential, then the configured env var —
// so a `providers models` probe authenticates exactly like a real request. Mirrors
// discoveredModelContextWindow's credential resolution.
func discoveryCredentialProfile(profile config.ProviderProfile) config.ProviderProfile {
authed := profile
if strings.TrimSpace(authed.APIKey) == "" {
if store, err := config.ProviderKeyStore(); err == nil {
authed = config.ApplyStoredAPIKey(authed, store)
}
}
if strings.TrimSpace(authed.APIKey) == "" && strings.TrimSpace(authed.APIKeyEnv) != "" {
authed.APIKey = strings.TrimSpace(os.Getenv(authed.APIKeyEnv))
}
return authed
}
// defaultDiscoverProviderModels is the production discovery hook: a live probe of
// the provider's model-listing endpoint with no curated-catalog merge or
// coding-model filtering, so a custom provider's full model list is returned.
func defaultDiscoverProviderModels(ctx context.Context, profile config.ProviderProfile) ([]providermodeldiscovery.Model, error) {
return providermodeldiscovery.Discover(ctx, profile, providermodeldiscovery.Options{})
}
func parseProviderModelsArgs(args []string) (providerModelsOptions, bool, error) {
options := providerModelsOptions{}
for index := 0; index < len(args); index++ {
arg := args[index]
switch {
case arg == "-h" || arg == "--help" || arg == "help":
return options, true, nil
case arg == "--json":
options.json = true
case strings.HasPrefix(arg, "-"):
return options, false, execUsageError{fmt.Sprintf("unknown flag %q", arg)}
default:
if options.name != "" {
return options, false, execUsageError{fmt.Sprintf("unexpected argument %q", arg)}
}
options.name = arg
}
}
return options, false, nil
}
+151
View File
@@ -0,0 +1,151 @@
package cli
import (
"bytes"
"context"
"encoding/json"
"errors"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
)
func TestRunProvidersModelsListsDiscoveredModels(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
deps := commandCenterDeps(t)
var probed config.ProviderProfile
deps.discoverProviderModels = func(_ context.Context, profile config.ProviderProfile) ([]providermodeldiscovery.Model, error) {
probed = profile
return []providermodeldiscovery.Model{
{ID: "team/model-a", Description: "first"},
{ID: "team/model-b"},
}, nil
}
exitCode := runWithDeps([]string{"providers", "models"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("exit = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
}
// No name given → the active provider is probed, with its resolved credential.
if probed.Name != "work" {
t.Fatalf("probed provider = %q, want active provider work", probed.Name)
}
out := stdout.String()
for _, want := range []string{"Provider models (work)", "team/model-a — first", "team/model-b", "2 models discovered", "--model team/model-a"} {
if !strings.Contains(out, want) {
t.Fatalf("output missing %q:\n%s", want, out)
}
}
}
func TestRunProvidersModelsJSON(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
deps := commandCenterDeps(t)
deps.discoverProviderModels = func(_ context.Context, _ config.ProviderProfile) ([]providermodeldiscovery.Model, error) {
return []providermodeldiscovery.Model{{ID: "m1", Description: "d1"}, {ID: "m2"}}, nil
}
exitCode := runWithDeps([]string{"providers", "models", "--json"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("exit = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
}
var payload struct {
Provider string `json:"provider"`
Count int `json:"count"`
Models []struct {
ID string `json:"id"`
Description string `json:"description"`
} `json:"models"`
}
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("decode json: %v\n%s", err, stdout.String())
}
if payload.Provider != "work" || payload.Count != 2 || len(payload.Models) != 2 {
t.Fatalf("payload = %#v", payload)
}
if payload.Models[0].ID != "m1" || payload.Models[0].Description != "d1" {
t.Fatalf("model[0] = %#v", payload.Models[0])
}
// A model with no description omits the field rather than emitting an empty string.
if payload.Models[1].ID != "m2" || payload.Models[1].Description != "" {
t.Fatalf("model[1] = %#v", payload.Models[1])
}
}
func TestRunProvidersModelsSelectsNamedProvider(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
deps := commandCenterDeps(t)
custom := config.ProviderProfile{
Name: "self-hosted",
ProviderKind: config.ProviderKindOpenAICompatible,
BaseURL: "https://models.internal/v1",
APIKey: "sk-internal",
Model: "house-model",
}
deps.resolveConfig = func(string, config.Overrides) (config.ResolvedConfig, error) {
work := config.ProviderProfile{Name: "work", ProviderKind: config.ProviderKindOpenAI, BaseURL: config.OpenAIBaseURL, APIKey: "sk", Model: "gpt-4.1"}
return config.ResolvedConfig{ActiveProvider: "work", Provider: work, Providers: []config.ProviderProfile{work, custom}}, nil
}
var probed config.ProviderProfile
deps.discoverProviderModels = func(_ context.Context, profile config.ProviderProfile) ([]providermodeldiscovery.Model, error) {
probed = profile
return []providermodeldiscovery.Model{{ID: "house-model"}}, nil
}
exitCode := runWithDeps([]string{"providers", "models", "self-hosted"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("exit = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
}
if probed.Name != "self-hosted" || probed.BaseURL != "https://models.internal/v1" {
t.Fatalf("probed = %#v, want the named custom provider", probed)
}
}
func TestRunProvidersModelsUnknownProviderFails(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
deps := commandCenterDeps(t)
called := false
deps.discoverProviderModels = func(_ context.Context, _ config.ProviderProfile) ([]providermodeldiscovery.Model, error) {
called = true
return nil, nil
}
exitCode := runWithDeps([]string{"providers", "models", "ghost"}, &stdout, &stderr, deps)
if exitCode == exitSuccess {
t.Fatalf("exit = %d, want non-zero for unknown provider", exitCode)
}
if called {
t.Fatal("discovery must not run for an unknown provider")
}
if !strings.Contains(stderr.String(), `provider "ghost" not found`) {
t.Fatalf("stderr = %q, want provider-not-found", stderr.String())
}
}
func TestRunProvidersModelsSurfacesDiscoveryError(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
deps := commandCenterDeps(t)
deps.discoverProviderModels = func(_ context.Context, _ config.ProviderProfile) ([]providermodeldiscovery.Model, error) {
return nil, errors.New("models endpoint returned 404")
}
exitCode := runWithDeps([]string{"providers", "models"}, &stdout, &stderr, deps)
if exitCode != exitProvider {
t.Fatalf("exit = %d, want exitProvider %d", exitCode, exitProvider)
}
if !strings.Contains(stderr.String(), "models endpoint returned 404") {
t.Fatalf("stderr = %q, want the discovery error", stderr.String())
}
}
+1 -1
View File
@@ -488,7 +488,7 @@ func validateProviderRuntimeReady(profile config.ProviderProfile) error {
}
func providerProfileHasCredential(profile config.ProviderProfile) bool {
return strings.TrimSpace(profile.APIKey) != "" || profile.APIKeyStored || strings.TrimSpace(profile.AuthHeaderValue) != ""
return profile.HasConfiguredCredential()
}
func providerKindForDescriptor(descriptor providercatalog.Descriptor) config.ProviderKind {
+13 -3
View File
@@ -3,6 +3,7 @@ package cli
import (
"fmt"
"io"
"net"
"net/url"
"strconv"
"strings"
@@ -314,10 +315,14 @@ func setupRequired(resolved config.ResolvedConfig) bool {
}
// providerHasOAuthLogin reports whether a stored OAuth login exists for the
// provider, keyed by its profile name or catalog id.
// provider. It uses the same ProviderProfile.OAuthLoginCandidates the runtime
// resolver does, so the "authenticated in the UI" gate and the "authenticated at
// runtime" resolver can never diverge. This is only consulted for a profile with
// no usable API-key credential (setupRequired returns early otherwise), where
// the candidate set is permissive (profile name + catalog ID).
func providerHasOAuthLogin(profile config.ProviderProfile, oauthLogins map[string]bool) bool {
for _, name := range []string{strings.TrimSpace(profile.Name), strings.TrimSpace(profile.CatalogID)} {
if name != "" && oauthLogins[name] {
for _, name := range profile.OAuthLoginCandidates() {
if oauthLogins[name] {
return true
}
}
@@ -449,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
}
+3 -1
View File
@@ -8,6 +8,8 @@ import (
"runtime"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/config"
)
func TestRunSpecialistListShowAndPath(t *testing.T) {
@@ -326,7 +328,7 @@ func setSpecialistConfigRoot(t *testing.T) string {
default:
t.Setenv("XDG_CONFIG_HOME", root)
}
configRoot, err := os.UserConfigDir()
configRoot, err := config.UserConfigDir()
if err != nil {
t.Fatalf("UserConfigDir() error = %v", err)
}
+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
}
+57
View File
@@ -155,6 +155,63 @@ func MigratePlaintextProviderKeys(path string, store APIKeySetter) (int, error)
return migrated, nil
}
// HasConfiguredCredential reports whether the profile is set up to authenticate
// with its own API key (inline, stored, or a raw auth header). It is the single
// definition of "this profile is key-authed", shared by OAuthLoginCandidates and
// the cli/tui setup surfaces so they never disagree about whether a profile is
// keyed. APIKeyEnv is deliberately NOT included: an env var may be unset at
// runtime while the profile actually relies on an OAuth login, so an
// env-configured-but-empty profile must still be allowed to resolve a token.
func (profile ProviderProfile) HasConfiguredCredential() bool {
return strings.TrimSpace(profile.APIKey) != "" ||
profile.APIKeyStored ||
strings.TrimSpace(profile.AuthHeaderValue) != ""
}
// OAuthLoginCandidates returns the login names to try, in order, when resolving
// this profile's OAuth token — used by the runtime bearer resolver, the Codex
// account-header resolver, and the onboarding presence check so all three agree.
//
// It returns NO candidates for a profile that carries its own configured
// credential: attaching an OAuth resolver to such a profile makes withBearer
// erase the key the instant a token resolves, silently routing/billing the call
// through the OAuth account instead of the intended key (the cross-profile
// override the review caught — and, via the profile name, a same-name login
// overriding an explicit key). A stored-key profile that reaches this keyless
// (e.g. a transiently unreadable keyring) is likewise treated as key-auth, so it
// fails with a clear missing-credential error rather than silently borrowing an
// unrelated OAuth login. APIKeyEnv is intentionally not part of that gate (an
// env var may be unset while the profile relies on OAuth).
//
// For a keyless profile the profile name comes first (a login under your own
// profile name is unambiguously yours), then the catalog ID as a FALLBACK (so a
// profile renamed away from its catalog ID — e.g. {name:"codex",
// catalogID:"chatgpt"} — still finds a `zero auth login chatgpt` token).
// Candidates are trimmed, blank-skipped, and de-duplicated CASE-SENSITIVELY — the
// OAuth token store is a case-sensitive map, so "ChatGPT" and "chatgpt" are
// distinct keys and both must survive.
func (profile ProviderProfile) OAuthLoginCandidates() []string {
if profile.HasConfiguredCredential() {
return nil
}
var names []string
add := func(s string) {
s = strings.TrimSpace(s)
if s == "" {
return
}
for _, existing := range names {
if existing == s { // case-sensitive: the store lookup is case-sensitive
return
}
}
names = append(names, s)
}
add(profile.Name)
add(profile.CatalogID)
return names
}
// ApplyStoredAPIKey fills profile.APIKey from the credential store when it is not
// already resolved — an inline config key or a resolved APIKeyEnv always wins, so
// this only supplies a key for providers whose secret lives in the store. A nil
+66
View File
@@ -172,3 +172,69 @@ func TestProviderProfileAPIKeyStoredRoundTrips(t *testing.T) {
t.Fatalf("no inline key expected, got %q", p.APIKey)
}
}
// OAuthLoginCandidates always offers the profile name, adds the catalog ID as a
// fallback ONLY when the profile has no effective own credential, and dedupes
// case-sensitively (the OAuth store is a case-sensitive map).
func TestOAuthLoginCandidates(t *testing.T) {
cases := []struct {
name string
profile ProviderProfile
want []string
}{
{
name: "renamed keyless profile falls back to catalog id",
profile: ProviderProfile{Name: "codex", CatalogID: "chatgpt"},
want: []string{"codex", "chatgpt"},
},
{
name: "case-variant name keeps distinct catalog-id candidate",
profile: ProviderProfile{Name: "ChatGPT", CatalogID: "chatgpt"},
want: []string{"ChatGPT", "chatgpt"},
},
{
name: "exact-duplicate name and catalog id collapse",
profile: ProviderProfile{Name: "chatgpt", CatalogID: "chatgpt"},
want: []string{"chatgpt"},
},
{
// A configured key must block ALL candidates (name included): a login
// under the profile's own name would otherwise erase the key too.
name: "own inline key blocks every candidate",
profile: ProviderProfile{Name: "anthropic-work", CatalogID: "anthropic", APIKey: "sk-work"},
want: nil,
},
{
name: "own auth header blocks every candidate",
profile: ProviderProfile{Name: "acme", CatalogID: "anthropic", AuthHeaderValue: "Bearer x"},
want: nil,
},
{
// APIKeyStored means key-auth even if the key isn't loaded here (e.g. a
// transiently unreadable keyring): don't silently borrow an OAuth login.
name: "stored-key profile blocks every candidate",
profile: ProviderProfile{Name: "openai-stored", CatalogID: "openai", APIKeyStored: true},
want: nil,
},
{
// APIKeyEnv is NOT a configured credential for gating: the env var may be
// unset while the profile relies on an OAuth login, so candidates stand.
name: "env-only profile still yields candidates",
profile: ProviderProfile{Name: "xai", CatalogID: "xai", APIKeyEnv: "XAI_API_KEY"},
want: []string{"xai"},
},
{
name: "empty profile yields no candidates",
profile: ProviderProfile{},
want: nil,
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := c.profile.OAuthLoginCandidates()
if strings.Join(got, ",") != strings.Join(c.want, ",") {
t.Fatalf("OAuthLoginCandidates() = %#v, want %#v", got, c.want)
}
})
}
}
+21 -1
View File
@@ -4,6 +4,7 @@ import (
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
)
@@ -33,13 +34,32 @@ func DefaultResolveOptions(workspaceRoot string) (ResolveOptions, error) {
}
func DefaultUserConfigPath() (string, error) {
userConfigDir, err := os.UserConfigDir()
userConfigDir, err := UserConfigDir()
if err != nil {
return "", fmt.Errorf("resolve user config directory: %w", err)
}
return filepath.Join(userConfigDir, "zero", "config.json"), nil
}
// UserConfigDir returns the base directory Zero stores per-user config under.
// It mirrors os.UserConfigDir everywhere except macOS: there Go defaults to
// ~/Library/Application Support, but Zero deliberately uses ~/.config (XDG-style,
// matching Linux and Claude Code) so a single config path works cross-platform.
// $XDG_CONFIG_HOME still wins on macOS when set.
func UserConfigDir() (string, error) {
if runtime.GOOS == "darwin" {
if xdg := strings.TrimSpace(os.Getenv("XDG_CONFIG_HOME")); xdg != "" {
return xdg, nil
}
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("resolve home directory: %w", err)
}
return filepath.Join(home, ".config"), nil
}
return os.UserConfigDir()
}
func existingConfigFile(path string) (string, error) {
info, err := os.Stat(path)
if err != nil {
+19 -1
View File
@@ -105,7 +105,7 @@ func setUserConfigRoot(t *testing.T) string {
t.Setenv("XDG_CONFIG_HOME", root)
}
configRoot, err := os.UserConfigDir()
configRoot, err := UserConfigDir()
if err != nil {
t.Fatalf("UserConfigDir() error = %v", err)
}
@@ -130,3 +130,21 @@ func mkdirAll(t *testing.T, path string) {
t.Fatalf("create directory %s: %v", path, err)
}
}
func TestDefaultUserConfigPathUsesXDGConfigOnMacOS(t *testing.T) {
if runtime.GOOS != "darwin" {
t.Skip("macOS-specific config path behavior")
}
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("XDG_CONFIG_HOME", "")
path, err := DefaultUserConfigPath()
if err != nil {
t.Fatalf("DefaultUserConfigPath() error = %v", err)
}
want := filepath.Join(home, ".config", "zero", "config.json")
if path != want {
t.Fatalf("DefaultUserConfigPath() = %q, want %q", path, want)
}
}
+102 -8
View File
@@ -2,6 +2,7 @@ package config
import (
"encoding/json"
"errors"
"fmt"
"net/url"
"os"
@@ -14,6 +15,31 @@ import (
"github.com/Gitlawb/zero/internal/sandbox"
)
// ErrNoActiveProvider marks a resolve failure caused solely by a missing or
// unresolvable active provider. The interactive TUI treats this as "needs
// onboarding" (drop into the setup wizard) rather than a fatal config error.
var ErrNoActiveProvider = errors.New("no active provider configured")
// ErrProviderRequiresModel marks a resolve failure caused solely by the active
// provider missing a model with no catalog default to fall back on (custom
// openai-/anthropic-compatible endpoints — Zero cannot guess a gateway's model).
// Like ErrNoActiveProvider, the interactive TUI treats it as "needs onboarding"
// and drops into the setup wizard so the user can fix it; headless commands
// (zero config, zero exec) still fail with the actionable message.
var ErrProviderRequiresModel = errors.New("provider requires model")
// setupFixableError tags an error with a sentinel for errors.Is WITHOUT
// changing its message: the sentinel's own text must not prefix what the user
// sees (the wrapped message is already complete and actionable).
type setupFixableError struct {
err error
sentinel error
}
func (e *setupFixableError) Error() string { return e.err.Error() }
func (e *setupFixableError) Unwrap() []error { return []error{e.err, e.sentinel} }
// defaultMaxTurns is the per-run tool-turn budget when none is configured. 30 was
// too low for real multi-step agentic work (agents ran out mid-task before reaching
// later steps); 50 matches the old "deep" preset. Raise per-session with /turns.
@@ -104,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{
@@ -118,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
}
@@ -195,7 +226,11 @@ func mergeConfig(dst *FileConfig, src FileConfig) {
if src.Preferences.Recaps != nil {
dst.Preferences.Recaps = src.Preferences.Recaps
}
if strings.TrimSpace(src.Preferences.Theme) != "" {
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 {
@@ -217,8 +252,17 @@ func mergeProjectConfig(dst *FileConfig, src FileConfig) error {
// config: a cloned repo's .zero/config.json must not be able to grant
// itself write access outside the workspace. Global config and CLI flags
// are the only config sources for write roots.
//
// Sandbox.Network from project config may only TIGHTEN (→ "deny"), never
// WEAKEN (→ "allow"). A malicious repo must not be able to open network
// access for shell commands. Matches the AdditionalWriteRoots posture.
if network := strings.TrimSpace(src.Sandbox.Network); network != "" {
dst.Sandbox.Network = network
// Normalize: anything that isn't literally "allow" counts as deny.
if sandbox.NormalizeNetworkMode(sandbox.NetworkMode(network)) != sandbox.NetworkAllow {
dst.Sandbox.Network = network
}
// Silently ignore "allow" from project config — not an error, just
// a privilege the project scope does not have.
}
if src.Sandbox.BlockUnixSockets {
dst.Sandbox.BlockUnixSockets = true
@@ -239,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.
@@ -367,6 +412,18 @@ func effectiveProviderKind(profile ProviderProfile) ProviderKind {
return ""
}
// catalogDefaultModel returns the default model of a catalog provider ("" when
// the id is unknown), used to fill a missing profile.Model for the official-API
// kinds so a hand-written profile without a model resolves instead of bricking
// every command that resolves config up front (zero config, bare zero setup).
func catalogDefaultModel(catalogID string) string {
descriptor, ok := providercatalog.Get(catalogID)
if !ok {
return ""
}
return strings.TrimSpace(descriptor.DefaultModel)
}
func catalogDescriptorForProfile(profile ProviderProfile) (providercatalog.Descriptor, bool) {
catalogID := providercatalog.NormalizeID(profile.CatalogID)
if catalogID == "" {
@@ -615,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)
}
@@ -650,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
@@ -728,11 +804,16 @@ func unionStrings(base []string, extra []string) []string {
}
type normalizeOptions struct {
defaultOpenAIModel bool
// defaultModels fills a missing profile.Model for the official-API provider
// kinds (openai from the model registry; anthropic/google from their catalog
// descriptors). Off for provider-command configs (command.go), which must
// surface exactly what the external command returned rather than inventing
// a model behind its back.
defaultModels bool
}
func normalizeProviders(providers []ProviderProfile, activeName string, envMaps ...map[string]string) ([]ProviderProfile, ProviderProfile, error) {
return normalizeProvidersWithOptions(providers, activeName, normalizeOptions{defaultOpenAIModel: true}, envMaps...)
return normalizeProvidersWithOptions(providers, activeName, normalizeOptions{defaultModels: true}, envMaps...)
}
func normalizeProvidersWithoutModelDefaults(providers []ProviderProfile, activeName string, envMaps ...map[string]string) ([]ProviderProfile, ProviderProfile, error) {
@@ -747,7 +828,7 @@ func normalizeProvidersWithOptions(providers []ProviderProfile, activeName strin
}
if len(providers) == 0 {
if activeName != "" {
return nil, ProviderProfile{}, fmt.Errorf("active provider %q not found", activeName)
return nil, ProviderProfile{}, fmt.Errorf("%w: active provider %q not found", ErrNoActiveProvider, activeName)
}
return []ProviderProfile{}, ProviderProfile{}, nil
}
@@ -779,10 +860,17 @@ func normalizeProvidersWithOptions(providers []ProviderProfile, activeName strin
}
if !activeFound {
return nil, ProviderProfile{}, fmt.Errorf("active provider %q not found", 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{}, providerError(active, "provider %s requires model", active.Name)
return nil, ProviderProfile{}, &setupFixableError{
err: providerError(active, "provider %s requires model — add \"model\" to its entry in config.json, or re-run: zero setup <catalog-id> --model <model>", active.Name),
sentinel: ErrProviderRequiresModel,
}
}
return normalized, active, nil
@@ -829,7 +917,7 @@ func normalizeProvider(profile ProviderProfile, env map[string]string, options n
case ProviderKindOpenAI:
if profile.BaseURL == "" || isOfficialOpenAIBaseURL(profile.BaseURL) {
profile.BaseURL = OpenAIBaseURL
if options.defaultOpenAIModel && profile.Model == "" {
if options.defaultModels && profile.Model == "" {
profile.Model = modelregistry.DefaultModelID
}
return profile, nil
@@ -846,6 +934,9 @@ func normalizeProvider(profile ProviderProfile, env map[string]string, options n
case ProviderKindAnthropic:
if profile.BaseURL == "" || isOfficialAnthropicBaseURL(profile.BaseURL) {
profile.BaseURL = AnthropicBaseURL
if options.defaultModels && profile.Model == "" {
profile.Model = catalogDefaultModel("anthropic")
}
return profile, nil
}
return ProviderProfile{}, providerError(profile, "anthropic provider %s requires official baseURL %s", profile.Name, AnthropicBaseURL)
@@ -860,6 +951,9 @@ func normalizeProvider(profile ProviderProfile, env map[string]string, options n
case ProviderKindGoogle:
if profile.BaseURL == "" || isOfficialGoogleBaseURL(profile.BaseURL) {
profile.BaseURL = GoogleBaseURL
if options.defaultModels && profile.Model == "" {
profile.Model = catalogDefaultModel("google")
}
return profile, nil
}
return ProviderProfile{}, providerError(profile, "google provider %s requires official baseURL %s", profile.Name, GoogleBaseURL)
+280
View File
@@ -1,6 +1,7 @@
package config
import (
"errors"
"os"
"path/filepath"
"reflect"
@@ -898,6 +899,9 @@ func TestResolveRejectsActiveProviderWithoutConfiguredProfiles(t *testing.T) {
if !strings.Contains(err.Error(), `active provider "ghost" not found`) {
t.Fatalf("error = %q, want active provider missing message", err.Error())
}
if !errors.Is(err, ErrNoActiveProvider) {
t.Fatalf("error = %v, want errors.Is(err, ErrNoActiveProvider)", err)
}
if resolved.ActiveProvider != "" {
t.Fatalf("ActiveProvider = %q, want empty", resolved.ActiveProvider)
}
@@ -912,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",
@@ -1055,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",
@@ -1269,6 +1416,45 @@ func TestResolveSandboxBlockUnixSocketsFromFile(t *testing.T) {
}
}
func TestResolveSandboxNetworkProjectConfigCannotWeaken(t *testing.T) {
// 1. Project config tries to set network to "allow" (should be ignored).
userPath := writeConfig(t, `{}`)
projectPath := writeConfig(t, `{
"sandbox": {"network": "allow"}
}`)
resolved, err := Resolve(ResolveOptions{
UserConfigPath: userPath,
ProjectConfigPath: projectPath,
Env: map[string]string{},
})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if resolved.Sandbox.Network == "allow" {
t.Fatal("resolved.Sandbox.Network = allow, want empty/deny to prevent project config from weakening sandbox")
}
// 2. User config sets network to "allow", project config sets it to "deny" (tightening is allowed).
userPath2 := writeConfig(t, `{
"sandbox": {"network": "allow"}
}`)
projectPath2 := writeConfig(t, `{
"sandbox": {"network": "deny"}
}`)
resolved2, err := Resolve(ResolveOptions{
UserConfigPath: userPath2,
ProjectConfigPath: projectPath2,
Env: map[string]string{},
})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if resolved2.Sandbox.Network != "deny" {
t.Fatalf("resolved.Sandbox.Network = %q, want deny (project config allowed to tighten)", resolved2.Sandbox.Network)
}
}
func TestResolveNotifyValid(t *testing.T) {
path := writeConfig(t, `{"notify":{"mode":"both","focusMode":"always"}}`)
resolved, err := Resolve(ResolveOptions{UserConfigPath: path, Env: map[string]string{}})
@@ -1551,3 +1737,97 @@ func TestResolveMaxTurnsZeroFallsBackToDefault(t *testing.T) {
t.Fatalf("MaxTurns = %d, want default %d", resolved.MaxTurns, defaultMaxTurns)
}
}
// The reported brick: a hand-written google profile with an apiKey but no
// model made EVERY resolving command fail ("provider google requires model"),
// including zero config and bare zero setup — the only commands that could
// have fixed it. Official-API kinds now fall back to their catalog default
// model, exactly like the openai kind always has.
func TestResolveDefaultsGoogleModelFromCatalog(t *testing.T) {
path := writeConfig(t, `{
"activeProvider": "google",
"providers": [{"name": "google", "provider_kind": "google", "apiKey": "AIza-x"}]
}`)
resolved, err := Resolve(ResolveOptions{UserConfigPath: path, Env: map[string]string{}})
if err != nil {
t.Fatalf("Resolve() error = %v, want model defaulted from the google catalog", err)
}
if resolved.Provider.Model != "gemini-2.5-pro" {
t.Fatalf("Model = %q, want the google catalog default gemini-2.5-pro", resolved.Provider.Model)
}
if resolved.Provider.BaseURL != GoogleBaseURL {
t.Fatalf("BaseURL = %q, want %q", resolved.Provider.BaseURL, GoogleBaseURL)
}
}
func TestResolveDefaultsAnthropicModelFromCatalog(t *testing.T) {
path := writeConfig(t, `{
"activeProvider": "anthropic",
"providers": [{"name": "anthropic", "provider_kind": "anthropic", "apiKey": "sk-ant-x"}]
}`)
resolved, err := Resolve(ResolveOptions{UserConfigPath: path, Env: map[string]string{}})
if err != nil {
t.Fatalf("Resolve() error = %v, want model defaulted from the anthropic catalog", err)
}
if resolved.Provider.Model != "claude-sonnet-4.5" {
t.Fatalf("Model = %q, want the anthropic catalog default claude-sonnet-4.5", resolved.Provider.Model)
}
}
// An explicitly configured model must always win over the catalog default.
func TestResolveKeepsExplicitGoogleModel(t *testing.T) {
path := writeConfig(t, `{
"activeProvider": "google",
"providers": [{"name": "google", "provider_kind": "google", "apiKey": "AIza-x", "model": "gemini-2.5-flash"}]
}`)
resolved, err := Resolve(ResolveOptions{UserConfigPath: path, Env: map[string]string{}})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if resolved.Provider.Model != "gemini-2.5-flash" {
t.Fatalf("Model = %q, want the explicitly configured gemini-2.5-flash", resolved.Provider.Model)
}
}
// Provider-command configs must NOT get invented models: the without-defaults
// path surfaces exactly what the external command returned.
func TestNormalizeWithoutModelDefaultsStillRequiresGoogleModel(t *testing.T) {
_, _, err := normalizeProvidersWithoutModelDefaults(
[]ProviderProfile{{Name: "google", ProviderKind: ProviderKindGoogle, APIKey: "AIza-x"}},
"google", map[string]string{})
if err == nil {
t.Fatal("provider-command path must still require an explicit model")
}
if !strings.Contains(err.Error(), "requires model") {
t.Fatalf("error = %q, want requires-model", err.Error())
}
// The residual error must tell the user how to fix it.
if !strings.Contains(err.Error(), "config.json") {
t.Fatalf("error = %q, want an actionable config.json hint", err.Error())
}
}
// The residual requires-model failure (custom endpoint, no catalog default)
// must be tagged setup-fixable WITHOUT changing its message: the interactive
// TUI matches the sentinel to drop into the wizard, while headless commands
// print the exact actionable text.
func TestResolveRequiresModelErrorIsSetupFixable(t *testing.T) {
path := writeConfig(t, `{
"activeProvider": "gw",
"providers": [{"name": "gw", "provider_kind": "openai-compatible", "baseURL": "https://gw.example/v1", "apiKey": "sk-x"}]
}`)
_, err := Resolve(ResolveOptions{UserConfigPath: path, Env: map[string]string{}})
if err == nil {
t.Fatal("expected requires-model error for a custom endpoint without model")
}
if !errors.Is(err, ErrProviderRequiresModel) {
t.Fatalf("error = %v, want errors.Is(err, ErrProviderRequiresModel)", err)
}
// The sentinel must NOT prefix the user-facing message.
if !strings.HasPrefix(err.Error(), "provider gw requires model") {
t.Fatalf("message = %q, want it to start with the provider error, not the sentinel text", err.Error())
}
if strings.Contains(err.Error(), "sk-x") {
t.Fatalf("error leaked API key: %q", err.Error())
}
}
+30
View File
@@ -97,6 +97,10 @@ type ToolsConfig struct {
type PreferencesConfig struct {
FavoriteModels []string `json:"favoriteModels,omitempty"`
// Theme is the persisted TUI palette preference — "auto" or a registered theme
// name (e.g. "dracula"). Applied at startup below the --theme flag and
// ZERO_THEME, so a /theme choice survives restart. Empty = unset (defaults auto).
Theme string `json:"theme,omitempty"`
// Recaps is a tri-state: nil (unset) defaults to ON; an explicit false means
// the user turned post-turn recaps off. A *bool is its own tri-state, so no
// custom unmarshal is needed (unlike ToolsConfig.DeferThreshold's int).
@@ -108,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.
@@ -202,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"`
}
@@ -216,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{
@@ -228,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
@@ -252,6 +278,7 @@ type Overrides struct {
Sandbox SandboxConfig
Notify NotifyConfig
Tools ToolsConfig
KeyBindings KeyBindingsConfig
LocalControl LocalControlConfig
}
@@ -266,6 +293,7 @@ type ResolvedConfig struct {
Tools ToolsConfig
Swarm SwarmConfig
Preferences PreferencesConfig
KeyBindings KeyBindingsConfig
LocalControl LocalControlConfig
}
@@ -312,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"`
@@ -338,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{}
+28 -1
View File
@@ -66,10 +66,13 @@ func TestValidateFileRedactsSecretInIssue(t *testing.T) {
}
func TestValidateFileMissingModelWarns(t *testing.T) {
// An openai-compatible CUSTOM endpoint has no catalog default to fall back
// on, so a missing model is still a real issue — and the message must tell
// the user how to fix it.
path := writeValidateFixture(t, `{
"activeProvider": "main",
"providers": [
{"name": "main", "provider_kind": "anthropic"}
{"name": "main", "provider_kind": "openai-compatible", "baseURL": "https://gateway.example/v1"}
]
}`)
@@ -80,6 +83,30 @@ func TestValidateFileMissingModelWarns(t *testing.T) {
if !strings.Contains(issues[0].Message, "requires model") {
t.Fatalf("expected requires-model issue, got %#v", issues)
}
if !strings.Contains(issues[0].Message, "config.json") {
t.Fatalf("requires-model issue should carry an actionable hint, got %#v", issues)
}
}
func TestValidateFileDefaultsOfficialKindModels(t *testing.T) {
// Official-API kinds (anthropic/google) fall back to their catalog default
// model, so a hand-written model-less profile validates clean instead of
// bricking zero config / bare zero setup — the only commands that could
// have fixed it (the reported google case).
path := writeValidateFixture(t, `{
"activeProvider": "google",
"providers": [
{"name": "google", "provider_kind": "google", "apiKey": "AIza-x"},
{"name": "anthropic", "provider_kind": "anthropic", "apiKey": "sk-ant-x"}
]
}`)
_, issues := ValidateFile(path)
for _, issue := range issues {
if strings.Contains(issue.Message, "requires model") {
t.Fatalf("official-kind profiles must default their model, got issue %#v", issue)
}
}
}
func TestValidateFileValidConfigHasNoIssues(t *testing.T) {
+22
View File
@@ -154,6 +154,28 @@ func SetRecapsEnabled(path string, enabled bool) (FileConfig, error) {
return cfg, nil
}
// SetTheme persists the TUI theme preference, mirroring SetFavoriteModels
// (read-modify-atomic-write). A blank theme clears the stored preference.
func SetTheme(path string, theme string) (FileConfig, error) {
path = strings.TrimSpace(path)
if path == "" {
return FileConfig{}, fmt.Errorf("config path is required")
}
cfg := FileConfig{}
if data, err := os.ReadFile(path); err == nil {
if err := json.Unmarshal(data, &cfg); err != nil {
return FileConfig{}, fmt.Errorf("invalid config JSON %s: %w", path, err)
}
} else if !os.IsNotExist(err) {
return FileConfig{}, fmt.Errorf("read config %s: %w", path, err)
}
cfg.Preferences.Theme = strings.TrimSpace(theme)
if err := writeConfigFile(path, cfg); err != nil {
return FileConfig{}, err
}
return cfg, nil
}
func normalizeFavoriteModels(models []string) []string {
seen := map[string]bool{}
favorites := make([]string, 0, len(models))
+33
View File
@@ -272,6 +272,39 @@ func TestSetFavoriteModelsPersistsUserPreferences(t *testing.T) {
}
}
func TestSetThemePersistsUserPreference(t *testing.T) {
path := filepath.Join(t.TempDir(), "zero.json")
writeConfigFixture(t, path, FileConfig{
ActiveProvider: "openai",
Providers: []ProviderProfile{
{Name: "openai", ProviderKind: ProviderKindOpenAI, Model: "gpt-4.1"},
},
}, 0o600)
cfg, err := SetTheme(path, " dracula ")
if err != nil {
t.Fatalf("SetTheme() error = %v", err)
}
if cfg.Preferences.Theme != "dracula" {
t.Fatalf("Theme = %q, want dracula (trimmed)", cfg.Preferences.Theme)
}
persisted := readConfigFixture(t, path)
if persisted.Preferences.Theme != "dracula" {
t.Fatalf("persisted Theme = %q, want dracula", persisted.Preferences.Theme)
}
if persisted.ActiveProvider != "openai" || len(persisted.Providers) != 1 {
t.Fatalf("provider config was not preserved by SetTheme: %#v", persisted)
}
// A blank value clears the stored preference.
if cfg, err = SetTheme(path, ""); err != nil {
t.Fatalf("SetTheme(\"\") error = %v", err)
}
if cfg.Preferences.Theme != "" {
t.Fatalf("SetTheme(\"\") should clear the theme, got %q", cfg.Preferences.Theme)
}
}
func TestRecapsPreferenceRoundTrips(t *testing.T) {
// Default (unset) is ON.
if !(PreferencesConfig{}).RecapsEnabled() {
+166 -21
View File
@@ -1,16 +1,17 @@
package mcp
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
@@ -211,6 +212,85 @@ func TestHTTPClientListsAndCallsTools(t *testing.T) {
}
}
func TestHTTPClientFollowsSameOriginRedirect(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
testServer := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
if request.URL.Path == "/mcp" {
http.Redirect(response, request, "/redirected", http.StatusTemporaryRedirect)
return
}
if request.Method != http.MethodPost || request.URL.Path != "/redirected" {
t.Errorf("request = %s %s, want POST /redirected", request.Method, request.URL.Path)
http.Error(response, "bad request", http.StatusNotFound)
return
}
if got := request.Header.Get("X-Api-Key"); got != "secret" {
t.Errorf("X-Api-Key = %q, want secret", got)
http.Error(response, "missing auth", http.StatusUnauthorized)
return
}
message := readHTTPRPCMessage(t, request)
switch message.Method {
case "initialize":
writeHTTPRPCResponse(t, response, message.ID, map[string]any{"protocolVersion": "2024-11-05"})
case "notifications/initialized":
response.WriteHeader(http.StatusAccepted)
default:
writeHTTPRPCResponse(t, response, message.ID, map[string]any{})
}
}))
defer testServer.Close()
client, err := Connect(ctx, Server{
Name: "docs",
Type: ServerTypeHTTP,
URL: testServer.URL + "/mcp",
Headers: map[string]string{"X-Api-Key": "secret"},
})
if err != nil {
t.Fatalf("Connect() error = %v", err)
}
if err := client.Close(); err != nil {
t.Fatalf("Close() error = %v", err)
}
}
func TestHTTPClientRejectsCrossOriginRedirectBeforeSendingHeaders(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var targetHits int32
target := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
atomic.AddInt32(&targetHits, 1)
if got := request.Header.Get("X-Api-Key"); got != "" {
t.Errorf("redirect target received X-Api-Key = %q", got)
}
response.WriteHeader(http.StatusOK)
}))
defer target.Close()
redirector := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
http.Redirect(response, request, target.URL+"/mcp", http.StatusTemporaryRedirect)
}))
defer redirector.Close()
_, err := Connect(ctx, Server{
Name: "docs",
Type: ServerTypeHTTP,
URL: redirector.URL + "/mcp",
Headers: map[string]string{"X-Api-Key": "secret"},
})
if err == nil || !strings.Contains(err.Error(), "cross-origin redirect") {
t.Fatalf("Connect() error = %v, want cross-origin redirect error", err)
}
if got := atomic.LoadInt32(&targetHits); got != 0 {
t.Fatalf("redirect target hits = %d, want 0", got)
}
}
func TestSSEClientListsAndCallsToolsFromRemoteStream(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
@@ -340,6 +420,53 @@ func TestSSEClientListsAndCallsToolsFromRemoteStream(t *testing.T) {
}
}
func TestSSEClientRejectsCrossOriginEndpointBeforeSendingHeaders(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var targetHits int32
target := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
atomic.AddInt32(&targetHits, 1)
if got := request.Header.Get("X-Api-Key"); got != "" {
t.Errorf("SSE endpoint target received X-Api-Key = %q", got)
}
response.WriteHeader(http.StatusAccepted)
}))
defer target.Close()
stream := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
if got := request.Header.Get("X-Api-Key"); got != "secret" {
t.Errorf("X-Api-Key = %q, want secret on configured SSE server", got)
http.Error(response, "missing auth", http.StatusUnauthorized)
return
}
if request.Method != http.MethodGet || request.URL.Path != "/sse" {
t.Errorf("request = %s %s, want GET /sse", request.Method, request.URL.Path)
http.Error(response, "bad request", http.StatusNotFound)
return
}
response.Header().Set("Content-Type", "text/event-stream")
_, _ = fmt.Fprintf(response, "event: endpoint\ndata: %s/messages\n\n", target.URL)
if flusher, ok := response.(http.Flusher); ok {
flusher.Flush()
}
}))
defer stream.Close()
_, err := Connect(ctx, Server{
Name: "docs",
Type: ServerTypeSSE,
URL: stream.URL + "/sse",
Headers: map[string]string{"X-Api-Key": "secret"},
})
if err == nil || !strings.Contains(err.Error(), "endpoint origin") {
t.Fatalf("Connect() error = %v, want cross-origin endpoint error", err)
}
if got := atomic.LoadInt32(&targetHits); got != 0 {
t.Fatalf("SSE endpoint target hits = %d, want 0", got)
}
}
func TestHTTPClientReportsNonOKStatus(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
@@ -393,28 +520,43 @@ func TestConnectRejectsUnsupportedTransport(t *testing.T) {
}
func TestClientRequestWaitsForMatchingResponseID(t *testing.T) {
var incoming bytes.Buffer
incomingWriter := newMessageWriter(&incoming)
if err := incomingWriter.write(rpcMessage{Method: "notifications/progress"}); err != nil {
t.Fatal(err)
}
if err := incomingWriter.write(rpcMessage{
ID: 99,
Error: &rpcError{Code: -32000, Message: "wrong response"},
}); err != nil {
t.Fatal(err)
}
if err := incomingWriter.write(rpcMessage{
ID: 1,
Result: mustRaw(map[string]any{"value": "matched"}),
}); err != nil {
t.Fatal(err)
}
// Pipes, not a pre-filled bytes.Buffer: the fake server responds only AFTER
// reading the outgoing request, matching production ordering. With responses
// pre-loaded, the client's reader goroutine could consume the matching
// response — and hit EOF — before request() registered its pending id,
// flaking as "request() error = EOF" on fast runners.
serverReader, clientWriter := io.Pipe() // client → server
clientReader, serverWriter := io.Pipe() // server → client
t.Cleanup(func() {
_ = clientWriter.Close()
_ = serverWriter.Close()
})
serverErr := make(chan error, 1)
go func() {
requests := newMessageReader(serverReader)
request, err := requests.read()
if err != nil {
serverErr <- err
return
}
responses := newMessageWriter(serverWriter)
for _, message := range []rpcMessage{
{Method: "notifications/progress"},
{ID: 99, Error: &rpcError{Code: -32000, Message: "wrong response"}},
{ID: request.ID, Result: mustRaw(map[string]any{"value": "matched"})},
} {
if err := responses.write(message); err != nil {
serverErr <- err
return
}
}
serverErr <- nil
}()
var outgoing bytes.Buffer
client := &Client{
reader: newMessageReader(&incoming),
writer: newMessageWriter(&outgoing),
reader: newMessageReader(clientReader),
writer: newMessageWriter(clientWriter),
nextID: 1,
}
var result struct {
@@ -426,6 +568,9 @@ func TestClientRequestWaitsForMatchingResponseID(t *testing.T) {
if result.Value != "matched" {
t.Fatalf("result.Value = %q, want matched response", result.Value)
}
if err := <-serverErr; err != nil {
t.Fatalf("fake server error: %v", err)
}
}
type errorCloser struct {
+8 -4
View File
@@ -733,7 +733,11 @@ func resolveSSEEndpointURL(baseValue string, endpointValue string) (string, erro
if err != nil {
return "", fmt.Errorf("parse MCP SSE endpoint URL: %w", err)
}
return baseURL.ResolveReference(endpointURL).String(), nil
resolvedURL := baseURL.ResolveReference(endpointURL)
if !sameMCPOrigin(baseURL, resolvedURL) {
return "", fmt.Errorf("MCP SSE endpoint origin %s differs from configured server origin %s", mcpOrigin(resolvedURL), mcpOrigin(baseURL))
}
return resolvedURL.String(), nil
}
func rpcResponseKey(id any) string {
@@ -873,10 +877,10 @@ func (source *storeTokenSource) Refresh(ctx context.Context) (string, error) {
// oauthHTTPClient returns an HTTP client whose transport attaches OAuth bearer
// tokens and refreshes them on 401 for OAuth-configured servers. Servers that do
// not declare OAuth get the default client unchanged.
// not declare OAuth use the default transport with the same MCP redirect guard.
func oauthHTTPClient(server Server) (*http.Client, error) {
if !strings.EqualFold(strings.TrimSpace(server.Auth), ServerAuthOAuth) {
return http.DefaultClient, nil
return mcpHTTPClient(server, nil), nil
}
store, err := NewTokenStore(TokenStoreOptions{})
if err != nil {
@@ -888,5 +892,5 @@ func oauthHTTPClient(server Server) (*http.Client, error) {
httpClient: http.DefaultClient,
now: time.Now,
}
return &http.Client{Transport: newOAuthRoundTripper(http.DefaultTransport, source, server.Name)}, nil
return mcpHTTPClient(server, newOAuthRoundTripper(http.DefaultTransport, source, server.Name)), nil
}
+136
View File
@@ -4,6 +4,7 @@ import (
"context"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync/atomic"
"testing"
@@ -149,6 +150,141 @@ func TestOAuthRoundTripperDoesNotRetryAfterSuccessfulNon401(t *testing.T) {
}
}
func TestOAuthRoundTripperRejectsCrossOriginRedirectBeforeBearerLeak(t *testing.T) {
source := &fakeTokenSource{}
source.access.Store("access-secret")
source.refreshFunc = func() (string, error) { return "access-fresh", nil }
var targetHits int32
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&targetHits, 1)
if got := r.Header.Get("Authorization"); got != "" {
t.Errorf("redirect target received Authorization = %q", got)
}
w.WriteHeader(http.StatusOK)
}))
defer target.Close()
redirector := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, target.URL+"/mcp", http.StatusTemporaryRedirect)
}))
defer redirector.Close()
client := mcpHTTPClient(
Server{Name: "oauth-docs", Type: ServerTypeHTTP},
newOAuthRoundTripper(http.DefaultTransport, source, "oauth-docs"),
)
resp, err := client.Get(redirector.URL + "/mcp")
if resp != nil {
_ = resp.Body.Close()
}
if err == nil || !strings.Contains(err.Error(), "cross-origin redirect") {
t.Fatalf("Get() error = %v, want cross-origin redirect error", err)
}
if got := atomic.LoadInt32(&targetHits); got != 0 {
t.Fatalf("redirect target hits = %d, want 0", got)
}
}
func TestSameMCPOriginNormalizesDefaultPorts(t *testing.T) {
for _, tc := range []struct {
name string
left string
right string
want bool
}{
{
name: "https default port",
left: "https://example.test/mcp",
right: "https://EXAMPLE.test:443/other",
want: true,
},
{
name: "http default port",
left: "http://example.test/mcp",
right: "http://example.test:80/other",
want: true,
},
{
name: "different scheme",
left: "https://example.test/mcp",
right: "http://example.test:443/other",
want: false,
},
{
name: "different port",
left: "https://example.test/mcp",
right: "https://example.test:8443/other",
want: false,
},
} {
t.Run(tc.name, func(t *testing.T) {
left, err := url.Parse(tc.left)
if err != nil {
t.Fatal(err)
}
right, err := url.Parse(tc.right)
if err != nil {
t.Fatal(err)
}
if got := sameMCPOrigin(left, right); got != tc.want {
t.Fatalf("sameMCPOrigin(%q, %q) = %v, want %v", tc.left, tc.right, got, tc.want)
}
})
}
}
func TestResolveSSEEndpointURLRestrictsOrigin(t *testing.T) {
for _, tc := range []struct {
name string
base string
endpoint string
want string
wantErr string
}{
{
name: "relative endpoint",
base: "https://example.test/sse",
endpoint: "/messages",
want: "https://example.test/messages",
},
{
name: "absolute same origin",
base: "https://example.test/sse",
endpoint: "https://example.test/messages",
want: "https://example.test/messages",
},
{
name: "default port equivalent",
base: "https://example.test/sse",
endpoint: "https://example.test:443/messages",
want: "https://example.test:443/messages",
},
{
name: "cross origin",
base: "https://example.test/sse",
endpoint: "https://evil.test/messages",
wantErr: "differs from configured server origin",
},
} {
t.Run(tc.name, func(t *testing.T) {
got, err := resolveSSEEndpointURL(tc.base, tc.endpoint)
if tc.wantErr != "" {
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
t.Fatalf("resolveSSEEndpointURL() error = %v, want %q", err, tc.wantErr)
}
return
}
if err != nil {
t.Fatalf("resolveSSEEndpointURL() error = %v", err)
}
if got != tc.want {
t.Fatalf("resolveSSEEndpointURL() = %q, want %q", got, tc.want)
}
})
}
}
func TestNonOAuthServerIsUnaffected(t *testing.T) {
// Regression: a server without auth must not get a bearer header or any
// OAuth round tripper, and must connect normally.
+74
View File
@@ -0,0 +1,74 @@
package mcp
import (
"fmt"
"net"
"net/http"
"net/url"
"strings"
)
const maxMCPRedirects = 10
func mcpHTTPClient(server Server, transport http.RoundTripper) *http.Client {
return &http.Client{
Transport: transport,
CheckRedirect: checkMCPRedirect(server),
}
}
func checkMCPRedirect(server Server) func(*http.Request, []*http.Request) error {
return func(request *http.Request, via []*http.Request) error {
if len(via) >= maxMCPRedirects {
return fmt.Errorf("MCP %s server %s stopped after %d redirects", server.Type, server.Name, maxMCPRedirects)
}
if len(via) == 0 {
return nil
}
if !sameMCPOrigin(via[0].URL, request.URL) {
return fmt.Errorf("MCP %s server %s refused cross-origin redirect to %s", server.Type, server.Name, mcpOrigin(request.URL))
}
return nil
}
}
func sameMCPOrigin(left *url.URL, right *url.URL) bool {
if left == nil || right == nil {
return false
}
return strings.EqualFold(left.Scheme, right.Scheme) &&
strings.EqualFold(left.Hostname(), right.Hostname()) &&
effectiveMCPPort(left) == effectiveMCPPort(right)
}
func effectiveMCPPort(value *url.URL) string {
if value == nil {
return ""
}
if port := value.Port(); port != "" {
return port
}
switch strings.ToLower(value.Scheme) {
case "http":
return "80"
case "https":
return "443"
default:
return ""
}
}
func mcpOrigin(value *url.URL) string {
if value == nil {
return "<nil>"
}
host := value.Hostname()
port := effectiveMCPPort(value)
if port != "" {
host = net.JoinHostPort(host, port)
}
if value.Scheme == "" {
return host
}
return strings.ToLower(value.Scheme) + "://" + host
}
+8 -1
View File
@@ -370,7 +370,14 @@ func TestFullFlowStoresTokens(t *testing.T) {
redirect := parsed.Query().Get("redirect_uri")
go func() {
cbURL := redirect + "?code=auth-code&state=" + url.QueryEscape(state)
_, _ = http.Get(cbURL)
for i := 0; i < 20; i++ {
resp, err := http.Get(cbURL)
if err == nil {
resp.Body.Close()
return
}
time.Sleep(50 * time.Millisecond)
}
}()
return nil
}
+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 {
+16 -5
View File
@@ -44,10 +44,17 @@ type Manager struct {
// ManagerOptions configures a Manager.
type ManagerOptions struct {
Store *Store
Registry *Registry
HTTPClient *http.Client
Env map[string]string
Store *Store
Registry *Registry
HTTPClient *http.Client
Env map[string]string
// AllowPresets opts this manager into the baked-in OAuth presets without the
// operator exporting ZERO_OAUTH_ALLOW_PRESETS — used by the interactive wizard
// and CLI login (and the runtime token refresh) for a provider the user chose
// to sign into, whose preset client identity is public (e.g. xAI). It layers the
// flag onto Env (or the process environment when Env is nil), preserving any
// ZERO_OAUTH_<NAME>_* overrides. Leave false for hermetic tests.
AllowPresets bool
Now func() time.Time
RefreshBuffer time.Duration
Out io.Writer
@@ -83,9 +90,13 @@ func NewManager(opts ManagerOptions) (*Manager, error) {
if open == nil {
open = func(string) error { return nil }
}
env := opts.Env
if opts.AllowPresets {
env = envWithPresetsAllowed(env)
}
return &Manager{
store: opts.Store, registry: registry, client: client,
env: opts.Env, now: now, buffer: buffer, out: out, openBrowser: open,
env: env, now: now, buffer: buffer, out: out, openBrowser: open,
}, nil
}
+24
View File
@@ -52,6 +52,30 @@ func managerFor(t *testing.T, env map[string]string, openBrowser func(string) er
return m
}
// AllowPresets layers the opt-in onto the manager's env so a login/refresh for a
// preset provider resolves; without it (and no Env) the env stays nil so callers
// and tests remain hermetic.
func TestNewManagerAllowPresetsForcesOptIn(t *testing.T) {
store, err := NewStore(StoreOptions{FilePath: filepath.Join(t.TempDir(), "tok.json")})
if err != nil {
t.Fatalf("NewStore: %v", err)
}
m, err := NewManager(ManagerOptions{Store: store, AllowPresets: true})
if err != nil {
t.Fatalf("NewManager: %v", err)
}
if m.env["ZERO_OAUTH_ALLOW_PRESETS"] != "1" {
t.Fatalf("AllowPresets should force the opt-in; got env[%q]=%q", "ZERO_OAUTH_ALLOW_PRESETS", m.env["ZERO_OAUTH_ALLOW_PRESETS"])
}
m2, err := NewManager(ManagerOptions{Store: store})
if err != nil {
t.Fatalf("NewManager: %v", err)
}
if m2.env != nil {
t.Fatalf("env should stay nil without AllowPresets, got %v", m2.env)
}
}
func TestManagerLoginLoopback(t *testing.T) {
fp := newFakeProvider(t, `{"access_token":"at","refresh_token":"rt","token_type":"Bearer","expires_in":3600}`)
env := map[string]string{
+28 -1
View File
@@ -1,6 +1,33 @@
package oauth
import "strings"
import (
"os"
"strings"
)
// envWithPresetsAllowed returns an env map that opts into the baked-in presets
// (as if ZERO_OAUTH_ALLOW_PRESETS=1 were exported) so an interactive wizard/CLI
// login for a well-known public client (e.g. xAI) can use the preset without the
// operator setting the flag themselves. It copies base — or snapshots the process
// environment when base is nil — because envValue treats a non-nil map as hermetic
// (a missing key does NOT fall back to os.Getenv), so a partial map would silently
// drop the operator's ZERO_OAUTH_<NAME>_* overrides. The flag is then forced on.
func envWithPresetsAllowed(base map[string]string) map[string]string {
env := make(map[string]string, len(base)+1)
if base == nil {
for _, kv := range os.Environ() {
if eq := strings.IndexByte(kv, '='); eq > 0 {
env[kv[:eq]] = kv[eq+1:]
}
}
} else {
for k, v := range base {
env[k] = v
}
}
env["ZERO_OAUTH_ALLOW_PRESETS"] = "1"
return env
}
// providerPreset is a baked-in default OAuth configuration for a well-known
// provider. Every field is overridable per provider via ZERO_OAUTH_<NAME>_*
+40
View File
@@ -46,6 +46,46 @@ func TestResolveConfigUsesXAIPreset(t *testing.T) {
}
}
// envWithPresetsAllowed lets a caller (the wizard / CLI login / runtime refresh)
// opt into the preset without exporting ZERO_OAUTH_ALLOW_PRESETS: an otherwise-inert
// xAI config now resolves from the baked-in preset.
func TestEnvWithPresetsAllowedEnablesPreset(t *testing.T) {
r := NewRegistry()
// Baseline: a hermetic empty map keeps the preset inert.
if _, _, err := r.ResolveConfig("xai", map[string]string{}); err == nil {
t.Fatal("xai should not resolve without the opt-in")
}
// The helper forces the opt-in so the preset resolves.
cfg, _, err := r.ResolveConfig("xai", envWithPresetsAllowed(map[string]string{}))
if err != nil {
t.Fatalf("envWithPresetsAllowed should enable the xai preset: %v", err)
}
if cfg.ClientID != "b1a00492-073a-47ea-816f-4c329264a828" {
t.Fatalf("client_id = %q", cfg.ClientID)
}
}
// The helper copies the base map (never mutating it) and keeps ZERO_OAUTH_<NAME>_*
// overrides — critical because envValue treats a non-nil map as hermetic, so a
// partial map would silently drop them.
func TestEnvWithPresetsAllowedPreservesOverrides(t *testing.T) {
base := map[string]string{"ZERO_OAUTH_XAI_CLIENT_ID": "custom-id"}
env := envWithPresetsAllowed(base)
if env["ZERO_OAUTH_ALLOW_PRESETS"] != "1" {
t.Fatalf("opt-in flag not set: %v", env)
}
if _, mutated := base["ZERO_OAUTH_ALLOW_PRESETS"]; mutated {
t.Fatal("envWithPresetsAllowed mutated the caller's base map")
}
cfg, _, err := NewRegistry().ResolveConfig("xai", env)
if err != nil {
t.Fatalf("ResolveConfig with override env: %v", err)
}
if cfg.ClientID != "custom-id" {
t.Fatalf("override dropped through the helper: client_id = %q", cfg.ClientID)
}
}
func TestResolveConfigEnvOverridesPreset(t *testing.T) {
r := NewRegistry()
env := map[string]string{
+20
View File
@@ -41,6 +41,26 @@ func ValidateKey(key string) error {
// ProviderKey builds the store key for a provider login.
func ProviderKey(name string) string { return KeyPrefixProvider + name }
// FirstStored returns the token and its ProviderKey for the FIRST candidate name
// that has a token in the store, with ok=false when none do. Callers pass
// ProviderProfile.OAuthLoginCandidates() so that everything derived from a login
// — the bearer token AND any header claim like chatgpt-account-id — comes from
// the SAME login; selecting independently per consumer could otherwise pair a
// bearer from one login with an account header from another. A load error on a
// candidate is treated as a miss (skip to the next), never a hard failure.
func FirstStored(store *Store, candidates []string) (Token, string, bool) {
if store == nil {
return Token{}, "", false
}
for _, name := range candidates {
key := ProviderKey(name)
if token, ok, err := store.Load(key); err == nil && ok {
return token, key, true
}
}
return Token{}, "", false
}
// Status is a redaction-safe summary of a stored token (no secret material).
type Status struct {
Key string `json:"key"`
+7 -1
View File
@@ -123,8 +123,10 @@ var descriptors = []Descriptor{
openAICompat("together", "Together AI", "https://api.together.xyz/v1", "meta-llama/Llama-3.3-70B-Instruct-Turbo", []string{"TOGETHER_API_KEY"}),
openAICompat("dashscope", "DashScope", "https://dashscope-intl.aliyuncs.com/compatible-mode/v1", "qwen-plus", []string{"DASHSCOPE_API_KEY", "QWEN_API_KEY"}, "qwen"),
openAICompat("moonshot", "Moonshot AI", "https://api.moonshot.ai/v1", "kimi-k2-0905-preview", []string{"MOONSHOT_API_KEY"}, "kimi"),
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),
@@ -133,7 +135,11 @@ 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"),
openAICompat("atomic-chat", "Atomic Chat", "https://api.atomic.chat/v1", "gpt-4.1", []string{"ATOMIC_CHAT_API_KEY"}),
// ChatGPT subscription via a local OAuth proxy. A ChatGPT (Plus/Pro) OAuth
// token only works against ChatGPT's own backend (which is Cloudflare-gated to
+30 -2
View File
@@ -23,8 +23,10 @@ var expectedCatalogIDs = []string{
"together",
"dashscope",
"moonshot",
"longcat",
"nvidia-nim",
"minimax",
"minimaxi-cn",
"mistral",
"github",
"bedrock",
@@ -34,6 +36,10 @@ var expectedCatalogIDs = []string{
"xiaomi-mimo",
"bankr",
"zai",
"zai-cn",
"kilocode",
"opencode",
"opencode-go",
"atomic-chat",
"chatgpt-proxy",
"custom-openai-compatible",
@@ -96,6 +102,28 @@ func TestRecommendedProviderEndpoint(t *testing.T) {
}
}
func TestLongCatDescriptor(t *testing.T) {
descriptor, err := Require("longcat")
if err != nil {
t.Fatalf("Require(longcat) error = %v", err)
}
if descriptor.Name != "LongCat" {
t.Fatalf("Name = %q, want LongCat", descriptor.Name)
}
if descriptor.DefaultBaseURL != "https://api.longcat.chat/openai" {
t.Fatalf("DefaultBaseURL = %q, want LongCat OpenAI-compatible endpoint", descriptor.DefaultBaseURL)
}
if descriptor.DefaultModel != "LongCat-2.0" {
t.Fatalf("DefaultModel = %q, want LongCat-2.0", descriptor.DefaultModel)
}
if descriptor.Transport != TransportOpenAICompatible {
t.Fatalf("Transport = %q, want %q", descriptor.Transport, TransportOpenAICompatible)
}
if !reflect.DeepEqual(descriptor.AuthEnvVars, []string{"LONGCAT_API_KEY"}) {
t.Fatalf("AuthEnvVars = %#v, want LONGCAT_API_KEY", descriptor.AuthEnvVars)
}
}
func TestCatalogDescriptorsExposeRequiredDefaults(t *testing.T) {
for _, descriptor := range All() {
if descriptor.ID == "" {
@@ -263,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", "nvidia-nim", "mistral", "github", "xai", "venice", "xiaomi-mimo", "bankr", "zai", "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)
@@ -85,6 +85,104 @@ func Discover(ctx context.Context, profile config.ProviderProfile, options Optio
}
}
// DiscoverOllamaContextWindow asks a local Ollama daemon for a model's context
// length via its native /api/show endpoint. The generic /v1/models probe
// (parseModelsResponse) only ever returns id/description — OpenAI-compatible
// listings don't carry context-window metadata, and a custom/local model tag
// (e.g. a user's own Ollama pull, including a ":cloud"-tagged model proxied
// through the local daemon) has no curated-catalog entry to borrow one from
// either, so modelContextWindow has nothing to show for it without this. Only
// meaningful for the local Ollama provider (baseURL like
// http://localhost:11434/v1) — Ollama Cloud's hosted API is a different
// service and isn't assumed to expose the same endpoint.
func DiscoverOllamaContextWindow(ctx context.Context, baseURL string, model string, options Options) (int, error) {
model = strings.TrimSpace(model)
if model == "" {
return 0, fmt.Errorf("model name is required")
}
endpoint, err := ollamaShowEndpoint(baseURL)
if err != nil {
return 0, err
}
payload, err := json.Marshal(struct {
Model string `json:"model"`
}{Model: model})
if err != nil {
return 0, err
}
request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(string(payload)))
if err != nil {
return 0, err
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Accept", "application/json")
client := options.HTTPClient
if client == nil {
client = &http.Client{Timeout: 10 * time.Second}
}
response, err := client.Do(request)
if err != nil {
return 0, err
}
defer response.Body.Close()
body, err := io.ReadAll(io.LimitReader(response.Body, 1<<20))
if err != nil {
return 0, err
}
if response.StatusCode < 200 || response.StatusCode >= 300 {
return 0, fmt.Errorf("ollama show endpoint returned %s: %s", response.Status, strings.TrimSpace(string(body)))
}
return parseOllamaShowContextWindow(body)
}
// ollamaShowEndpoint derives the native Ollama API root from the
// OpenAI-compatible base URL Zero stores for the provider (".../v1") and
// appends /api/show.
func ollamaShowEndpoint(baseURL string) (string, error) {
baseURL = strings.TrimSpace(baseURL)
if baseURL == "" {
return "", fmt.Errorf("provider base URL is required for ollama model discovery")
}
parsed, err := url.Parse(baseURL)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return "", fmt.Errorf("invalid provider base URL %q", baseURL)
}
path := strings.TrimRight(parsed.Path, "/")
path = strings.TrimSuffix(path, "/v1")
parsed.Path = strings.TrimRight(path, "/") + "/api/show"
parsed.RawQuery = ""
parsed.Fragment = ""
return parsed.String(), nil
}
// parseOllamaShowContextWindow extracts the context length from an
// /api/show response. Ollama reports it under model_info with an
// architecture-prefixed key (e.g. "llama.context_length",
// "qwen2.context_length") — the prefix varies by model family, so this scans
// for any key ending in ".context_length" rather than hardcoding one.
func parseOllamaShowContextWindow(body []byte) (int, error) {
// model_info mixes value types (strings like "general.architecture", numbers
// like "*.context_length"), so decode values generically and only try to
// read a number out of the one key we actually care about.
var payload struct {
ModelInfo map[string]any `json:"model_info"`
}
if err := json.Unmarshal(body, &payload); err != nil {
return 0, fmt.Errorf("decode ollama show response: %w", err)
}
for key, value := range payload.ModelInfo {
if !strings.HasSuffix(key, ".context_length") {
continue
}
if window, ok := value.(float64); ok && window > 0 {
return int(window), nil
}
}
return 0, fmt.Errorf("ollama show response did not report a context length")
}
func discoverOpenAIModels(ctx context.Context, profile config.ProviderProfile, options Options) ([]Model, error) {
endpoint, err := modelsEndpoint(profile.BaseURL)
if err != nil {
@@ -2,6 +2,7 @@ package providermodeldiscovery
import (
"context"
"io"
"net/http"
"net/http/httptest"
"strings"
@@ -267,3 +268,61 @@ func modelIDs(models []Model) []string {
}
return ids
}
// TestDiscoverOllamaContextWindowFetchesFromNativeShowEndpoint: the generic
// /v1/models probe never carries context-window metadata (parseModelsResponse
// only extracts id/description), so a custom/local Ollama model tag with no
// curated-catalog match has no other source for it. This exercises the
// Ollama-native /api/show fallback that fills that gap.
func TestDiscoverOllamaContextWindowFetchesFromNativeShowEndpoint(t *testing.T) {
var gotPath, gotMethod, gotBody string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
gotMethod = r.Method
body, _ := io.ReadAll(r.Body)
gotBody = string(body)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"model_info": {
"general.architecture": "qwen2",
"qwen2.context_length": 131072
}
}`))
}))
defer server.Close()
window, err := DiscoverOllamaContextWindow(context.Background(), server.URL+"/v1", "kimi-k2.7-code:cloud", Options{HTTPClient: server.Client()})
if err != nil {
t.Fatalf("DiscoverOllamaContextWindow returned error: %v", err)
}
if window != 131072 {
t.Fatalf("context window = %d, want 131072", window)
}
if gotPath != "/api/show" {
t.Fatalf("requested path = %q, want /api/show (not under /v1)", gotPath)
}
if gotMethod != http.MethodPost {
t.Fatalf("method = %q, want POST", gotMethod)
}
if !strings.Contains(gotBody, `"kimi-k2.7-code:cloud"`) {
t.Fatalf("request body = %q, want it to name the model", gotBody)
}
}
func TestDiscoverOllamaContextWindowRequiresModelName(t *testing.T) {
if _, err := DiscoverOllamaContextWindow(context.Background(), "http://localhost:11434/v1", "", Options{}); err == nil {
t.Fatal("expected an error for an empty model name")
}
}
func TestDiscoverOllamaContextWindowErrorsWhenShowOmitsContextLength(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"model_info": {"general.architecture": "qwen2"}}`))
}))
defer server.Close()
if _, err := DiscoverOllamaContextWindow(context.Background(), server.URL+"/v1", "some-model", Options{HTTPClient: server.Client()}); err == nil {
t.Fatal("expected an error when no *.context_length key is present")
}
}
+31 -13
View File
@@ -26,6 +26,13 @@ type Options struct {
// OAuth bearer token (preferred over the API key). nil => API-key auth only.
// Applied to the OpenAI and Anthropic providers.
OAuthResolver providerio.TokenResolver
// OAuthLoginKey is the credential-store key OAuthResolver bound to (empty when
// there is no OAuth login). It is the SAME selection the bearer resolver made,
// resolved ONCE by the caller so the Codex chatgpt-account-id header reads its
// account from the exact login that issued the bearer token — never a second,
// independent lookup that could pick a different login (a backend-rejected
// mismatch).
OAuthLoginKey string
}
// New creates a runtime provider for a resolved provider profile.
@@ -251,14 +258,22 @@ func isCodexCatalog(profile config.ProviderProfile, _ resolvedProfile) bool {
// newCodexProvider builds a Codex-flavored openai provider for the chatgpt
// catalog. The Codex headers (`originator`, `chatgpt-account-id`) are
// injected by the CodexProvider wrapper. The `chatgpt-account-id` is
// resolved dynamically from the stored OAuth token's Account field on
// every request so a refresh that rotates the token (and its account
// claim) takes effect immediately — a static AccountID captured at
// construction would go stale on the first refresh.
// injected by the CodexProvider wrapper. The `chatgpt-account-id` is read from
// the stored OAuth token's Account field on every request so a refresh that
// rotates the token (and its account claim) takes effect immediately — a static
// AccountID captured at construction would go stale on the first refresh.
//
// The *login* the header reads from is options.OAuthLoginKey — the SAME key the
// bearer resolver bound, resolved ONCE by the caller. The bearer resolver
// refreshes the token in place under that key; reading the account from the same
// key keeps header and token on one login for the provider's life. Resolving the
// key independently here (a second oauth.FirstStored) could, after a transient
// per-candidate load error or a mid-session `zero auth login`, pick a different
// login than the bearer — a mismatch the backend rejects.
func newCodexProvider(profile config.ProviderProfile, resolved resolvedProfile, options Options) (zeroruntime.Provider, error) {
accountKey := options.OAuthLoginKey
resolver := openai.CodexAccountResolver(func(ctx context.Context) (string, bool, error) {
account := codexAccountFromStore(profile.Name)
account := codexAccountForKey(accountKey)
if account == "" {
return "", false, nil
}
@@ -288,17 +303,20 @@ func newCodexProvider(profile config.ProviderProfile, resolved resolvedProfile,
})
}
// codexAccountFromStore reads the chatgpt_account_id from the stored OAuth
// token for the given provider name. It is called per-request (via the
// resolver) so a refresh that rotates the token takes effect immediately
// without restarting the agent. Returns "" when no token is stored (the
// Codex provider then omits the header and the user sees a clear 401).
func codexAccountFromStore(providerName string) string {
// codexAccountForKey reads the chatgpt_account_id from the token currently
// stored under key. Called per-request (via the resolver) so a refresh that
// rotates the token — saved back in place under the same key — takes effect
// immediately without restarting the agent. Returns "" when key is empty (no
// OAuth login), or the token is absent or carries no account claim.
func codexAccountForKey(key string) string {
if key == "" {
return ""
}
store, err := oauth.NewStore(oauth.StoreOptions{})
if err != nil {
return ""
}
token, ok, err := store.Load(oauth.ProviderKey(providerName))
token, ok, err := store.Load(key)
if err != nil || !ok {
return ""
}
+64 -12
View File
@@ -269,6 +269,7 @@ func TestNewRoutesChatGPTCatalogToCodexProvider(t *testing.T) {
// OAuth token and the "want empty chatgpt-account-id" assertion fails locally
// (it still passes in CI, where no login is stored). Mirrors the isolation in
// TestNewRoutesChatGPTCatalogWithStoredAccountID.
t.Setenv("ZERO_OAUTH_STORAGE", "file")
t.Setenv("ZERO_OAUTH_TOKENS_PATH", t.TempDir()+"/tokens.json")
transport := &captureTransport{
@@ -322,11 +323,10 @@ func TestNewRoutesChatGPTCatalogToCodexProvider(t *testing.T) {
func TestNewRoutesChatGPTCatalogWithStoredAccountID(t *testing.T) {
// The factory reads the stored OAuth token's Account field for the
// chatgpt-account-id header. Seed a token in a temp store and point
// ZERO_OAUTH_TOKENS_PATH at it so the factory picks it up.
dir := t.TempDir()
t.Setenv("ZERO_OAUTH_TOKENS_PATH", dir+"/tokens.json")
store, err := newOAuthStoreForTest()
// chatgpt-account-id header, from the login key the CALLER supplies in
// Options.OAuthLoginKey (the same key the bearer resolver bound). Seed a
// token in an isolated temp store, then pass that key.
store, err := newOAuthStoreForTest(t)
if err != nil {
t.Fatalf("NewStore: %v", err)
}
@@ -344,8 +344,9 @@ func TestNewRoutesChatGPTCatalogWithStoredAccountID(t *testing.T) {
CatalogID: "chatgpt",
Model: "gpt-5",
}, Options{
HTTPClient: &http.Client{Transport: transport},
UserAgent: "zero-factory-test",
HTTPClient: &http.Client{Transport: transport},
UserAgent: "zero-factory-test",
OAuthLoginKey: oauth.ProviderKey("chatgpt"),
})
if err != nil {
t.Fatalf("New() error = %v", err)
@@ -407,10 +408,61 @@ func (transport *captureTransport) body() io.Reader {
return strings.NewReader(transport.requestBody)
}
// newOAuthStoreForTest builds a Store pointed at the current
// ZERO_OAUTH_TOKENS_PATH (set by the caller). It exists so the chatgpt
// factory tests can seed a token without copying the path-handling dance
// from internal/cli.
func newOAuthStoreForTest() (*oauth.Store, error) {
// newOAuthStoreForTest pins the OAuth token store to a plain temp FILE and
// returns a Store on it. Pinning ZERO_OAUTH_STORAGE matters as much as the
// path: an inherited "keyring" value would send NewStore to the OS keychain
// and ignore ZERO_OAUTH_TOKENS_PATH entirely, making the test read/write the
// developer's real logins. Exists so the chatgpt factory tests can seed a
// token without copying the path-handling dance from internal/cli.
func newOAuthStoreForTest(t *testing.T) (*oauth.Store, error) {
t.Helper()
t.Setenv("ZERO_OAUTH_STORAGE", "file")
t.Setenv("ZERO_OAUTH_TOKENS_PATH", t.TempDir()+"/tokens.json")
return oauth.NewStore(oauth.StoreOptions{})
}
// codexAccountForKey reads the chatgpt-account-id from the token stored under a
// FIXED key — the key the caller (cli.oauthLoginForProfile) already bound for the
// bearer token, passed through providers.Options.OAuthLoginKey. The account is
// therefore always read from the same login that issued the bearer (no second,
// independent selection). It re-reads per call, so an in-place token refresh (new
// account claim under the SAME key) is picked up; an empty key (no OAuth login)
// or a missing/account-less token yields "".
func TestCodexAccountForKey(t *testing.T) {
store, err := newOAuthStoreForTest(t)
if err != nil {
t.Fatalf("NewStore: %v", err)
}
if err := store.Save(oauth.ProviderKey("chatgpt"), oauth.Token{AccessToken: "tok-1", Account: "acc-catalog-7"}); err != nil {
t.Fatalf("Save chatgpt: %v", err)
}
// A different login WITHOUT an account claim, to prove the read stays on the
// requested key and never crosses to another login's account.
if err := store.Save(oauth.ProviderKey("codex"), oauth.Token{AccessToken: "tok-codex"}); err != nil {
t.Fatalf("Save codex: %v", err)
}
if got := codexAccountForKey(oauth.ProviderKey("chatgpt")); got != "acc-catalog-7" {
t.Fatalf("account = %q, want acc-catalog-7", got)
}
// The bound key's token has no account → "", NOT the other login's account.
if got := codexAccountForKey(oauth.ProviderKey("codex")); got != "" {
t.Fatalf("account = %q, want empty (must not cross to another login's account)", got)
}
// Empty key (no OAuth login) → header omitted.
if got := codexAccountForKey(""); got != "" {
t.Fatalf("account for empty key = %q, want empty", got)
}
// Unknown key → "".
if got := codexAccountForKey(oauth.ProviderKey("nope")); got != "" {
t.Fatalf("account for unknown key = %q, want empty", got)
}
// An in-place refresh under the bound key IS reflected per request.
if err := store.Save(oauth.ProviderKey("chatgpt"), oauth.Token{AccessToken: "tok-refreshed", Account: "acc-rotated"}); err != nil {
t.Fatalf("refresh chatgpt: %v", err)
}
if got := codexAccountForKey(oauth.ProviderKey("chatgpt")); got != "acc-rotated" {
t.Fatalf("account = %q, want acc-rotated (in-place refresh must be picked up)", got)
}
}
+66 -1
View File
@@ -342,7 +342,7 @@ func (provider *Provider) geminiRequest(request zeroruntime.CompletionRequest) (
declarations = append(declarations, geminiFunctionDeclaration{
Name: tool.Name,
Description: tool.Description,
Parameters: tool.Parameters,
Parameters: sanitizeGeminiSchema(tool.Parameters),
})
}
mapped.Tools = []geminiToolGroup{{FunctionDeclarations: declarations}}
@@ -350,6 +350,71 @@ func (provider *Provider) geminiRequest(request zeroruntime.CompletionRequest) (
return mapped, nil
}
// geminiSchemaFields is the subset of JSON Schema keywords Google's
// functionDeclarations[].parameters accepts (the Generative AI Schema type).
// Anything outside it — most notably OpenAI's `additionalProperties`, which Zero
// emits on every tool's parameters and which Gemini 400s on ("Unknown name
// \"additionalProperties\" … Cannot find field") — must be dropped before the
// request goes out. Kept as an allowlist rather than a denylist so a schema
// keyword Zero (or an MCP server) adds later can't silently leak an unsupported
// field into the Gemini payload.
var geminiSchemaFields = map[string]bool{
"type": true, "format": true, "title": true, "description": true,
"nullable": true, "enum": true, "items": true, "properties": true,
"required": true, "anyOf": true, "propertyOrdering": true, "default": true,
"minimum": true, "maximum": true, "minItems": true, "maxItems": true,
"minLength": true, "maxLength": true, "minProperties": true,
"maxProperties": true, "pattern": true, "example": true,
}
// sanitizeGeminiSchema returns a copy of a JSON-Schema map keeping only the
// keywords Gemini supports (geminiSchemaFields), recursing through the nested
// schemas under `properties`, `items`, and `anyOf`. Returns nil for a nil input
// so a parameterless tool stays parameterless.
func sanitizeGeminiSchema(schema map[string]any) map[string]any {
if schema == nil {
return nil
}
out := make(map[string]any, len(schema))
for key, value := range schema {
if !geminiSchemaFields[key] {
continue
}
switch key {
case "properties":
if props, ok := value.(map[string]any); ok {
cleaned := make(map[string]any, len(props))
for name, sub := range props {
if subMap, ok := sub.(map[string]any); ok {
cleaned[name] = sanitizeGeminiSchema(subMap)
} else {
cleaned[name] = sub
}
}
value = cleaned
}
case "items":
if subMap, ok := value.(map[string]any); ok {
value = sanitizeGeminiSchema(subMap)
}
case "anyOf":
if variants, ok := value.([]any); ok {
cleaned := make([]any, len(variants))
for i, variant := range variants {
if subMap, ok := variant.(map[string]any); ok {
cleaned[i] = sanitizeGeminiSchema(subMap)
} else {
cleaned[i] = variant
}
}
value = cleaned
}
}
out[key] = value
}
return out
}
func mapMessages(messages []zeroruntime.Message) (*geminiContent, []geminiContent, error) {
systemParts := []geminiPart{}
contents := []geminiContent{}
+127
View File
@@ -3,6 +3,7 @@ package gemini
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"reflect"
@@ -504,3 +505,129 @@ func eventsOfType(events []zeroruntime.StreamEvent, eventType zeroruntime.Stream
}
return matching
}
// TestSanitizeGeminiSchemaStripsUnsupportedFields: the sanitizer keeps only
// Gemini-supported keywords and recurses into properties/items/anyOf, so no
// OpenAI-ism (additionalProperties, $schema, patternProperties) survives at any
// depth while legitimate fields (type/description/enum/required/default) stay.
func TestSanitizeGeminiSchemaStripsUnsupportedFields(t *testing.T) {
in := map[string]any{
"type": "object",
"additionalProperties": false,
"$schema": "https://json-schema.org/draft/2020-12/schema",
"required": []string{"path"},
"properties": map[string]any{
"path": map[string]any{
"type": "string",
"description": "a path",
"default": ".",
},
"nested": map[string]any{
"type": "object",
"additionalProperties": false, // must be stripped at depth too
"properties": map[string]any{
"tags": map[string]any{
"type": "array",
"items": map[string]any{"type": "string", "additionalProperties": true},
},
},
},
},
}
out := sanitizeGeminiSchema(in)
assertNoAdditionalProps(t, out, "root")
if out["additionalProperties"] != nil {
t.Fatal("top-level additionalProperties must be stripped")
}
if out["$schema"] != nil {
t.Fatal("$schema must be stripped")
}
// Legitimate fields survive.
if out["type"] != "object" {
t.Fatalf("type should survive, got %v", out["type"])
}
props := out["properties"].(map[string]any)
pathProp := props["path"].(map[string]any)
if pathProp["description"] != "a path" || pathProp["default"] != "." {
t.Fatalf("legitimate property fields must survive: %#v", pathProp)
}
// nil input stays nil (parameterless tool).
if sanitizeGeminiSchema(nil) != nil {
t.Fatal("nil schema should map to nil")
}
}
// assertNoAdditionalProps fails if additionalProperties appears anywhere in the
// (recursively walked) schema.
func assertNoAdditionalProps(t *testing.T, node any, path string) {
t.Helper()
switch v := node.(type) {
case map[string]any:
if _, ok := v["additionalProperties"]; ok {
t.Fatalf("additionalProperties leaked at %s", path)
}
for k, sub := range v {
assertNoAdditionalProps(t, sub, path+"."+k)
}
case []any:
for i, sub := range v {
assertNoAdditionalProps(t, sub, fmt.Sprintf("%s[%d]", path, i))
}
}
}
// TestGeminiRequestOmitsAdditionalPropertiesInToolSchema: end-to-end, the tool
// parameters Zero emits (schemaToRuntimeMap always writes additionalProperties)
// must not reach Gemini — otherwise every functionDeclaration is 400-rejected
// ("Unknown name additionalProperties"), which broke all tool-using exec calls
// against Google (issue #373).
func TestGeminiRequestOmitsAdditionalPropertiesInToolSchema(t *testing.T) {
var gotBody map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil {
t.Fatalf("decode request body: %v", err)
}
writeSSE(w, `{"usageMetadata":{"promptTokenCount":1}}`)
}))
defer server.Close()
provider, err := New(Options{APIKey: "k", BaseURL: server.URL + "/", Model: "models/gemini-2.5-flash"})
if err != nil {
t.Fatalf("New returned error: %v", err)
}
stream, err := provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "hi"}},
Tools: []zeroruntime.ToolDefinition{{
Name: "grep",
Description: "search",
Parameters: map[string]any{ // exactly what schemaToRuntimeMap produces
"type": "object",
"additionalProperties": false,
"required": []string{"pattern"},
"properties": map[string]any{
"pattern": map[string]any{"type": "string", "description": "regex"},
},
},
}},
})
if err != nil {
t.Fatalf("StreamCompletion returned error: %v", err)
}
drain(stream)
tools := gotBody["tools"].([]any)
decls := tools[0].(map[string]any)["functionDeclarations"].([]any)
params := decls[0].(map[string]any)["parameters"].(map[string]any)
if _, ok := params["additionalProperties"]; ok {
t.Fatalf("additionalProperties must not be sent to Gemini: %#v", params)
}
// The tool is still usable: its real schema survived.
if params["type"] != "object" {
t.Fatalf("tool parameters type should survive, got %v", params["type"])
}
props := params["properties"].(map[string]any)
if _, ok := props["pattern"]; !ok {
t.Fatalf("tool property must survive sanitization: %#v", props)
}
}
+49 -12
View File
@@ -6,10 +6,12 @@ import (
"errors"
"fmt"
"io"
"math"
"net/http"
"net/url"
"os"
"regexp"
"runtime"
"strconv"
"strings"
"time"
@@ -25,9 +27,9 @@ var ErrStreamIdle = errors.New("idle timeout (upstream stopped sending data)")
// ErrStreamStalled reports that a streaming upstream kept the connection alive
// (SSE keep-alives reset the idle watchdog, so it never fired) but produced no
// actual output for streamContentStallFactor × the idle timeout. Without this an
// upstream that heartbeats-but-stalls — observed on chatgpt/gpt-5.x and ollama
// reasoning models — would hang the agent indefinitely.
// actual output for ContentStallTimeout(idle). Without this an upstream that
// heartbeats-but-stalls — observed on chatgpt/gpt-5.x and ollama reasoning
// models — would hang the agent indefinitely.
var ErrStreamStalled = errors.New("stream stalled (upstream kept the connection alive but produced no output)")
// StreamTimeoutMessage returns the human-readable detail for a stream-timeout
@@ -38,7 +40,7 @@ var ErrStreamStalled = errors.New("stream stalled (upstream kept the connection
// stuck or very slow.
func StreamTimeoutMessage(err error, idleTimeout time.Duration) string {
if errors.Is(err, ErrStreamStalled) {
return fmt.Sprintf("no output for %s (the model kept the connection alive but produced nothing — it may be stuck; try a faster model or lower reasoning effort)", idleTimeout*streamContentStallFactor)
return fmt.Sprintf("no output for %s (the model kept the connection alive but produced nothing — it may be stuck; try a faster model or lower reasoning effort)", ContentStallTimeout(idleTimeout))
}
return fmt.Sprintf("idle timeout after %s (upstream stopped sending data)", idleTimeout)
}
@@ -53,13 +55,32 @@ func StreamTimeoutMessage(err error, idleTimeout time.Duration) string {
// Override globally with ZERO_STREAM_IDLE_TIMEOUT.
const DefaultStreamIdleTimeout = 5 * time.Minute
// streamContentStallFactor bounds a heartbeat-but-no-output stream. Keep-alives
// reset the idle watchdog (a heartbeating upstream is not "dead"), but if NO real
// data line arrives for streamContentStallFactor × the idle timeout, the stream is
// treated as stalled and aborted (ErrStreamStalled). It is > 1 so a slow-but-
// producing request — one that emits data between heartbeats — is never killed; the
// content watchdog only catches a stream that heartbeats while producing nothing.
const streamContentStallFactor = 2
// ContentStallTimeout bounds a heartbeat-but-no-output stream: keep-alives reset
// the idle watchdog (a heartbeating upstream is not "dead"), but if NO real data
// line arrives for this long the stream is aborted (ErrStreamStalled). Scaled
// above idleTimeout (so a slow-but-producing request that emits data between
// heartbeats is never killed — the watchdog only ever fires when NOTHING real
// arrives), but only 1.2× rather than the old 2×: a genuine heartbeat-pause
// stall on chatgpt/gpt-5.x rarely recovers, so a ~10-minute dead wait (at the 5m
// idle default) was a terrible UX for a doomed turn. At the default idle this is
// 6 minutes; it still scales with ZERO_STREAM_IDLE_TIMEOUT. A returned value <= 0
// (idle watchdog disabled) leaves the content watchdog off too.
func ContentStallTimeout(idleTimeout time.Duration) time.Duration {
if idleTimeout <= 0 {
return 0
}
// 1.2× computed as idle + idle/5 (not idle*6/5), plus a clamp: a
// pathologically large ZERO_STREAM_IDLE_TIMEOUT could make idle*6 overflow
// int64 and wrap to a NEGATIVE duration, which would arm the content timer
// to fire immediately and abort every stream. Clamping to the max duration
// on overflow just means "effectively no content watchdog" — the sane
// result for an absurd idle timeout.
extra := idleTimeout / 5
if idleTimeout > math.MaxInt64-extra {
return math.MaxInt64
}
return idleTimeout + extra
}
// streamIdleTimeoutEnv is the global override for the stream idle timeout. It
// accepts a Go duration ("5m", "300s", "90s") or a bare number of seconds
@@ -122,6 +143,21 @@ func NormalizeBaseURL(baseURL string, defaultBaseURL string, label string) (stri
// unaffected (the stream-idle + content watchdogs cover that).
// - IdleConnTimeout is shortened so a connection that went idle across a pause
// is closed (and re-dialed fresh) rather than reused after it has gone stale.
// - DisableKeepAlives on darwin only: ResponseHeaderTimeout/IdleConnTimeout
// above catch a reused connection that's fully dead (never responds) or
// idle past the timeout, but not one that's alive-but-severely-degraded —
// e.g. reused shortly after a prior request on the same host (well within
// 30s, common across quick retries of the same turn), where it still
// delivers real bytes, just at a crippled rate, resetting Zero's stream
// idle/content-stall watchdogs (which only fire on true silence) without
// ever recovering. That degraded-not-dead case is indistinguishable from
// genuine backend slowness from inside the stream, so the only reliable
// fix is removing pooling from the equation entirely on the one platform
// where this class of bug has actually reproduced. A fresh TCP+TLS
// handshake per request costs low tens of milliseconds — negligible next
// to the minutes-long stalls this avoids — and this doesn't touch
// Linux/Windows, where the underlying OS doesn't keep dead/degraded
// pooled connections around as long.
var sharedHTTPClient = func() *http.Client {
transport := http.DefaultTransport.(*http.Transport).Clone()
// 120s, not 60s: a slow cloud proxy (e.g. ollama `*:cloud`) can withhold its
@@ -132,6 +168,7 @@ var sharedHTTPClient = func() *http.Client {
// content-stall watchdogs.
transport.ResponseHeaderTimeout = 120 * time.Second
transport.IdleConnTimeout = 30 * time.Second
transport.DisableKeepAlives = runtime.GOOS == "darwin"
return &http.Client{Transport: transport}
}()
@@ -285,7 +322,7 @@ func ScanSSEDataWithContext(
// Content watchdog: only real data lines reset it (keep-alives do not), so a
// stream that heartbeats without producing output is bounded instead of
// hanging forever.
contentTimeout := idleTimeout * streamContentStallFactor
contentTimeout := ContentStallTimeout(idleTimeout)
content := time.NewTimer(contentTimeout)
defer content.Stop()
contentC = content.C
@@ -4,7 +4,9 @@ import (
"context"
"errors"
"io"
"math"
"net/http"
"runtime"
"strings"
"testing"
"time"
@@ -203,11 +205,36 @@ func TestUpstreamUnreachable(t *testing.T) {
}
}
// ContentStallTimeout is 1.2× the idle timeout, disabled (0) when idle is
// disabled, and must never overflow to a negative duration for an absurdly
// large idle timeout (which would arm the content timer to fire immediately
// and abort every stream).
func TestContentStallTimeout(t *testing.T) {
cases := []struct {
idle time.Duration
want time.Duration
}{
{5 * time.Minute, 6 * time.Minute},
{30 * time.Second, 36 * time.Second},
{100 * time.Millisecond, 120 * time.Millisecond},
{0, 0},
{-1, 0},
}
for _, c := range cases {
if got := ContentStallTimeout(c.idle); got != c.want {
t.Fatalf("ContentStallTimeout(%v) = %v, want %v", c.idle, got, c.want)
}
}
// Overflow guard: idle*6 would wrap negative; the clamp keeps it positive.
if got := ContentStallTimeout(math.MaxInt64); got <= 0 {
t.Fatalf("ContentStallTimeout(MaxInt64) = %v, must stay positive (no overflow wrap)", got)
}
}
// A heartbeating-but-output-less upstream must not hang forever: SSE keep-alives
// feed the idle watchdog (so it never fires), but the content watchdog
// (idleTimeout × streamContentStallFactor) aborts with ErrStreamStalled when no
// real data line arrives. This is the gpt-5.x / ollama "still generating forever"
// hang.
// (ContentStallTimeout(idle)) aborts with ErrStreamStalled when no real data
// line arrives. This is the gpt-5.x / ollama "still generating forever" hang.
func TestScanSSEDataWithContextAbortsOnContentStall(t *testing.T) {
pr, pw := io.Pipe()
defer func() { _ = pw.Close() }()
@@ -235,7 +262,8 @@ func TestScanSSEDataWithContextAbortsOnContentStall(t *testing.T) {
done := make(chan error, 1)
go func() {
// idle 100ms → content stall at 200ms. Keep-alives reset idle but not content.
// idle 100ms → content stall at 120ms (ContentStallTimeout = idle*1.2).
// Keep-alives reset idle but not content.
done <- ScanSSEDataWithContext(context.Background(), cancel, pr, 100*time.Millisecond, func(string) bool { return true })
}()
@@ -258,7 +286,7 @@ func TestScanSSEDataWithContextContentResetsOnData(t *testing.T) {
pr, pw := io.Pipe()
go func() {
// One data line every 30ms for ~300ms (well past the 200ms content window,
// One data line every 30ms for ~300ms (well past the 120ms content window,
// with comfortable slack under the 100ms idle timeout), so the content
// watchdog keeps resetting, then close cleanly.
for i := 0; i < 10; i++ {
@@ -290,16 +318,16 @@ func TestScanSSEDataWithContextContentResetsOnData(t *testing.T) {
}
// StreamTimeoutMessage must give a stalled stream a distinct, accurate detail —
// it reports the content window (idle × factor) and must NOT claim the upstream
// "stopped sending data" (keep-alives were still arriving).
// it reports the content window (ContentStallTimeout) and must NOT claim the
// upstream "stopped sending data" (keep-alives were still arriving).
func TestStreamTimeoutMessage(t *testing.T) {
idle := 5 * time.Minute
if msg := StreamTimeoutMessage(ErrStreamIdle, idle); !strings.Contains(msg, "idle timeout after 5m") {
t.Fatalf("idle message = %q, want it to mention the 5m idle timeout", msg)
}
stalled := StreamTimeoutMessage(ErrStreamStalled, idle)
if !strings.Contains(stalled, "no output for 10m") {
t.Fatalf("stalled message = %q, want it to report the 10m content window", stalled)
if !strings.Contains(stalled, "no output for 6m") {
t.Fatalf("stalled message = %q, want it to report the 6m content window (idle 5m × 1.2)", stalled)
}
if strings.Contains(stalled, "stopped sending data") {
t.Fatalf("stalled message must not claim the upstream stopped sending data: %q", stalled)
@@ -324,6 +352,17 @@ func TestHTTPClientReturnsStallHardenedSharedClient(t *testing.T) {
if tr.IdleConnTimeout != 30*time.Second {
t.Fatalf("IdleConnTimeout = %v, want 30s", tr.IdleConnTimeout)
}
// DisableKeepAlives is the stronger mitigation scoped to darwin only: the
// two timeouts above catch a reused connection that's fully dead or idle
// too long, but not one that's alive-and-degraded (still delivers real
// bytes, just at a crippled rate) — which resets Zero's stream watchdogs
// without ever recovering. Removing pooling from the equation entirely is
// only worth its reconnect cost on the platform this class of bug has
// actually reproduced on.
wantDisableKeepAlives := runtime.GOOS == "darwin"
if tr.DisableKeepAlives != wantDisableKeepAlives {
t.Fatalf("DisableKeepAlives = %v, want %v (GOOS=%s)", tr.DisableKeepAlives, wantDisableKeepAlives, runtime.GOOS)
}
if HTTPClient(nil) != got {
t.Fatal("HTTPClient(nil) must return a shared instance so the conn pool is reused")
}
+11 -8
View File
@@ -9,22 +9,25 @@ import (
"time"
)
// TestEvaluateExemptsNetworkToolsFromShellNetworkPolicy verifies that the
// sandbox network policy gates sandboxed shell egress, not first-party
// in-process web tools.
func TestEvaluateExemptsNetworkToolsFromShellNetworkPolicy(t *testing.T) {
// TestEvaluatePromptsForNetworkToolsButExemptsThemFromShellNetworkPolicy verifies
// that first-party in-process network tools are governed by their permission
// metadata, not by the sandboxed shell egress policy.
func TestEvaluatePromptsForNetworkToolsButExemptsThemFromShellNetworkPolicy(t *testing.T) {
base := Policy{Mode: ModeEnforce, Network: NetworkDeny}
engine := NewEngine(EngineOptions{Policy: base})
d := engine.Evaluate(context.Background(), Request{
ToolName: "web_search", SideEffect: SideEffectNetwork, Permission: PermissionAllow,
ToolName: "web_search", SideEffect: SideEffectNetwork, Permission: PermissionPrompt,
})
if d.Action != ActionAllow || d.Block != nil {
t.Fatalf("web_search must be allowed by the sandbox gate, got %#v", d)
if d.Action != ActionPrompt || d.Block != nil {
t.Fatalf("web_search must prompt before permission is granted, got %#v", d)
}
if d.Reason == ReasonNetworkBlocked {
t.Fatalf("web_search prompt should come from tool permission, not shell network policy: %#v", d)
}
allowed := engine.Evaluate(context.Background(), Request{
ToolName: "web_search", SideEffect: SideEffectNetwork, Permission: PermissionAllow, PermissionGranted: true,
ToolName: "web_search", SideEffect: SideEffectNetwork, Permission: PermissionPrompt, PermissionGranted: true,
})
if allowed.Action != ActionAllow || allowed.Block != nil {
t.Fatalf("granted web_search must be allowed under deny, got %#v", allowed)
+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
+4 -1
View File
@@ -72,7 +72,10 @@ func TestPermissionProfileFromPolicyIncludesDefaultTempWriteRoots(t *testing.T)
if !writeRootsContain(profile.FileSystem.WriteRoots, tmpdir) {
t.Fatalf("write roots = %#v, want temp root %q", profile.FileSystem.WriteRoots, tmpdir)
}
if pathExists("/tmp") && !writeRootsContain(profile.FileSystem.WriteRoots, "/tmp") {
// /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 != "" {
+5 -1
View File
@@ -3,6 +3,7 @@ package sandbox
import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
@@ -128,7 +129,10 @@ func TestNewScopeNormalizesAndValidatesExtraRoots(t *testing.T) {
if !stringSliceContains(roots, normalizeWorkspaceRootBestEffort(extra)) {
t.Fatalf("Roots()=%v want extra root %q", roots, normalizeWorkspaceRootBestEffort(extra))
}
if pathExists("/tmp") && !stringSliceContains(roots, normalizeWorkspaceRootBestEffort("/tmp")) {
// /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
Loaded 100 of 190 files, more files were not shown because too many files have changed in this diff. Show more