Compare commits

..
Author SHA1 Message Date
Vasanthdev2004 80e4ce2421 fix(providers): relocate aimlapi preset and drop referral header
Addresses the two review asks on #402:

- Move the AI/ML API descriptor out of catalog slot #2 (it sat above the
  first-party OpenAI/Anthropic/Google entries) down next to openrouter in
  the aggregator cluster. Re-sync expectedCatalogIDs and the
  TransportOpenAICompat order-list to match.
- Drop the always-on X-AIMLAPI-Partner-ID / Integration-Repo /
  Integration-Version attribution headers. There's no partner arrangement
  behind the preset, so a non-overridable referral tag on every user's
  request isn't something to ship on a community preset. aimlapi now rides
  through plain CopyHeaders like every other provider. Removes
  HeadersForCatalog (it existed only for this special-case), reverts the
  factory and discovery call sites to CopyHeaders, and drops the header
  test. The factory/discovery tests now assert the endpoint, auth, and the
  user's own custom headers survive while no attribution header is sent.
2026-07-07 11:21:56 +05:30
Dmitry Tumanov 34ab360679 test(providers): reuse aimlapi catalog id 2026-07-03 13:21:37 +02:00
Dmitry Tumanov 4b462206ee Merge remote-tracking branch 'origin/main' into d1m7asis/aimlapi-provider
# Conflicts:
#	README.md
#	internal/cli/provider_catalog_onboarding_test.go
#	internal/providercatalog/catalog_test.go
2026-07-03 12:47:29 +02: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
Dmitry Tumanov c844cbbb60 feat(providers): add AI/ML API preset 2026-07-02 19:21:51 +02: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
122 changed files with 7627 additions and 498 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 }}"
+1
View File
@@ -0,0 +1 @@
{".":"0.1.0"}
+13
View File
@@ -6,6 +6,19 @@ 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
+37
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,27 @@ 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`.
Install with Bun, then fetch the binary in one of two ways:
```bash
# Option A: run the installer manually
bun add @gitlawb/zero
node node_modules/@gitlawb/zero/scripts/postinstall.mjs
# Option B: allow the postinstall to run on install
# add to your package.json: "trustedDependencies": ["@gitlawb/zero"]
bun add @gitlawb/zero
```
For global installs (`bun add -g @gitlawb/zero`), use Option A against the
global install path, or prefer the install scripts below.
### Install scripts
Linux/macOS:
@@ -121,6 +144,20 @@ the key in the wizard:
export OPENAI_API_KEY=sk-...
export ANTHROPIC_API_KEY=...
export GEMINI_API_KEY=...
export AIMLAPI_API_KEY=...
export LONGCAT_API_KEY=...
```
To configure AI/ML API directly, run:
```bash
zero providers setup aimlapi --set-active
```
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
+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`。
使用 Bun 安装后,通过以下两种方式之一获取二进制文件:
```bash
# 选项 A:手动运行安装程序
bun add @gitlawb/zero
node node_modules/@gitlawb/zero/scripts/postinstall.mjs
# 选项 B:允许 postinstall 在安装时运行
# 添加到你的 package.json: "trustedDependencies": ["@gitlawb/zero"]
bun add @gitlawb/zero
```
对于全局安装(`bun add -g @gitlawb/zero`),请对全局安装路径使用选项 A,或使用下面的安装脚本。
### 安装脚本
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) 发布。
+19 -1
View File
@@ -54,8 +54,26 @@ 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. To let it run on future installs, add zero to your\n' +
'project\'s trustedDependencies:\n' +
' "trustedDependencies": ["@gitlawb/zero"]\n' +
'then 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);
}
+32
View File
@@ -24,6 +24,38 @@ 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`.
To install with Bun, either run the installer manually after installing:
```bash
bun add @gitlawb/zero
node node_modules/@gitlawb/zero/scripts/postinstall.mjs
```
Or allow the postinstall to run by adding the package to your project's
`trustedDependencies` before installing:
```json
{
"trustedDependencies": ["@gitlawb/zero"]
}
```
```bash
bun add @gitlawb/zero
```
For global installs (`bun add -g @gitlawb/zero`), run the installer manually
against the global install path, or use the install scripts below.
Reference: <https://bun.sh/docs/pm/lifecycle>
## Linux And macOS Script
Install the latest release:
+48 -21
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
@@ -2144,6 +2159,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
}
+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,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")
}
}
+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
+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)
}
}
+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) {
+124 -44
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,43 @@ 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)
now func() time.Time
}
type mcpToolRuntime interface {
@@ -99,9 +106,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 +126,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{})
},
@@ -391,6 +405,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 +443,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
}
@@ -493,6 +518,17 @@ func fillAppDeps(deps appDeps) appDeps {
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 +544,21 @@ 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)
}
resolved = config.ResolvedConfig{}
forceSetup = true
}
userConfigPath, err := deps.userConfigPath()
if err != nil {
@@ -720,20 +770,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 {
+147
View File
@@ -5,6 +5,7 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
@@ -157,6 +158,80 @@ 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 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
@@ -1401,3 +1476,75 @@ func TestRunThemeFlagRejectsBadValue(t *testing.T) {
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())
}
}
+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,
})
}
+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)
}
}
}
+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
+3
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)
+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
}
@@ -17,7 +17,9 @@ func TestProviderCatalogRuntimeProvidersShowOnboardingSetupHints(t *testing.T) {
defaultModel string
}{
{id: "openai", name: "OpenAI", transport: "openai", defaultModel: "gpt-4.1"},
{id: "aimlapi", name: "AI/ML API", transport: "openai-compatible", defaultModel: "openai/gpt-5-chat"},
{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 +48,36 @@ func TestProviderCatalogRuntimeProvidersShowOnboardingSetupHints(t *testing.T) {
}
}
func TestProviderCatalogAIMLAPIRequiresAPIKey(t *testing.T) {
output := runProviderCatalogOnboarding(t)
block := providerCatalogOnboardingBlock(t, output, "aimlapi")
for _, want := range []string{
"requiresAuth=true",
"authEnvVars=AIMLAPI_API_KEY",
"setup: zero providers setup aimlapi --set-active",
} {
if !strings.Contains(block, want) {
t.Fatalf("expected AIMLAPI catalog block to contain %q, got:\n%s", want, block)
}
}
}
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 {
+7 -3
View File
@@ -314,10 +314,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
}
}
+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)
}
+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)
}
}
+68 -7
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.
@@ -220,8 +246,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
@@ -370,6 +405,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 == "" {
@@ -731,11 +778,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) {
@@ -750,7 +802,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
}
@@ -782,10 +834,13 @@ func normalizeProvidersWithOptions(providers []ProviderProfile, activeName strin
}
if !activeFound {
return nil, ProviderProfile{}, fmt.Errorf("active provider %q not found", activeName)
return nil, 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
@@ -832,7 +887,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
@@ -849,6 +904,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)
@@ -863,6 +921,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)
+261
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)
}
@@ -1055,6 +1059,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 +1397,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 +1718,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())
}
}
+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) {
+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
}
+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"`
+10 -1
View File
@@ -34,6 +34,8 @@ const (
var ErrUnknownProvider = errors.New("unknown provider")
const AIMLAPIID = "aimlapi"
type Descriptor struct {
ID string
Name string
@@ -99,6 +101,7 @@ var descriptors = []Descriptor{
localOpenAI("ollama", "Ollama Local", "http://localhost:11434/v1", "llama3.1", "ollama local"),
localOpenAI("lmstudio", "LM Studio", "http://localhost:1234/v1", "local-model", "lm-studio", "lm studio"),
oauthProvider(openAICompat("openrouter", "OpenRouter", "https://openrouter.ai/api/v1", "openai/gpt-4.1", []string{"OPENROUTER_API_KEY"}), true, false),
openAICompat(AIMLAPIID, "AI/ML API", "https://api.aimlapi.com/v1", "openai/gpt-5-chat", []string{"AIMLAPI_API_KEY"}, "aiml api", "ai/ml api"),
// Hugging Face Inference Providers — OpenAI-compatible router at
// https://router.huggingface.co/v1 exposes hundreds of OSS models. OAuth
// requires a one-time app registration at huggingface.co/settings/applications/new
@@ -123,8 +126,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 +138,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
+53 -2
View File
@@ -16,6 +16,7 @@ var expectedCatalogIDs = []string{
"ollama",
"lmstudio",
"openrouter",
"aimlapi",
"huggingface",
"chatgpt",
"groq",
@@ -23,8 +24,10 @@ var expectedCatalogIDs = []string{
"together",
"dashscope",
"moonshot",
"longcat",
"nvidia-nim",
"minimax",
"minimaxi-cn",
"mistral",
"github",
"bedrock",
@@ -34,6 +37,10 @@ var expectedCatalogIDs = []string{
"xiaomi-mimo",
"bankr",
"zai",
"zai-cn",
"kilocode",
"opencode",
"opencode-go",
"atomic-chat",
"chatgpt-proxy",
"custom-openai-compatible",
@@ -96,6 +103,50 @@ func TestRecommendedProviderEndpoint(t *testing.T) {
}
}
func TestAIMLAPIDescriptor(t *testing.T) {
descriptor, err := Require("aimlapi")
if err != nil {
t.Fatalf("Require(aimlapi) error = %v", err)
}
if descriptor.Name != "AI/ML API" {
t.Fatalf("Name = %q, want AI/ML API", descriptor.Name)
}
if descriptor.DefaultBaseURL != "https://api.aimlapi.com/v1" {
t.Fatalf("DefaultBaseURL = %q, want AI/ML API endpoint", descriptor.DefaultBaseURL)
}
if descriptor.DefaultModel != "openai/gpt-5-chat" {
t.Fatalf("DefaultModel = %q, want openai/gpt-5-chat", descriptor.DefaultModel)
}
if descriptor.Transport != TransportOpenAICompatible {
t.Fatalf("Transport = %q, want %q", descriptor.Transport, TransportOpenAICompatible)
}
if !reflect.DeepEqual(descriptor.AuthEnvVars, []string{"AIMLAPI_API_KEY"}) {
t.Fatalf("AuthEnvVars = %#v, want AIMLAPI_API_KEY", descriptor.AuthEnvVars)
}
}
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 +314,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", "aimlapi", "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 {
+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)
+99 -1
View File
@@ -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 {
@@ -97,7 +195,7 @@ func discoverOpenAIModels(ctx context.Context, profile config.ProviderProfile, o
AuthHeader: profile.AuthHeader,
AuthScheme: profile.AuthScheme,
AuthHeaderValue: profile.AuthHeaderValue,
CustomHeaders: profile.CustomHeaders,
CustomHeaders: providerio.CopyHeaders(profile.CustomHeaders),
}, nil)
}
@@ -2,6 +2,7 @@ package providermodeldiscovery
import (
"context"
"io"
"net/http"
"net/http/httptest"
"strings"
@@ -51,6 +52,44 @@ func TestDiscoverOpenAICompatibleModelsFetchesModelsEndpoint(t *testing.T) {
}
}
func TestDiscoverAIMLAPIModelsSendsAuthAndCustomHeadersWithoutAttribution(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
for header, want := range map[string]string{
"Authorization": "Bearer test-key",
"X-Trace": "test",
} {
if got := r.Header.Get(header); got != want {
t.Errorf("%s = %q, want %q", header, got, want)
}
}
// No first-party referral/attribution headers are injected for catalog
// presets; aimlapi rides through CopyHeaders like every other provider.
for _, header := range []string{
"X-AIMLAPI-Partner-ID",
"X-AIMLAPI-Integration-Repo",
"X-AIMLAPI-Integration-Version",
} {
if got := r.Header.Get(header); got != "" {
t.Errorf("%s = %q, want no attribution header", header, got)
}
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"data":[{"id":"openai/gpt-5-chat"}]}`))
}))
defer server.Close()
_, err := Discover(context.Background(), config.ProviderProfile{
CatalogID: "aimlapi",
ProviderKind: config.ProviderKindOpenAICompatible,
BaseURL: server.URL + "/v1",
APIKey: "test-key",
CustomHeaders: map[string]string{"X-Trace": "test"},
}, Options{HTTPClient: server.Client()})
if err != nil {
t.Fatalf("Discover returned error: %v", err)
}
}
func TestDiscoverOpenAICompatibleModelsHonorsAuthHeaderValue(t *testing.T) {
// A profile can authenticate via a raw auth-header value instead of APIKey;
// discovery must send it rather than probe unauthenticated.
@@ -267,3 +306,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")
}
}
+32 -14
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.
@@ -54,7 +61,7 @@ func New(profile config.ProviderProfile, options Options) (zeroruntime.Provider,
AuthHeader: profile.AuthHeader,
AuthScheme: profile.AuthScheme,
AuthHeaderValue: profile.AuthHeaderValue,
CustomHeaders: profile.CustomHeaders,
CustomHeaders: providerio.CopyHeaders(profile.CustomHeaders),
OAuthResolver: options.OAuthResolver,
MaxTokens: resolved.maxOutputTokens,
HTTPClient: options.HTTPClient,
@@ -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 ""
}
+113 -12
View File
@@ -95,6 +95,55 @@ func TestNewThreadsCustomProviderHeaders(t *testing.T) {
}
}
func TestNewAIMLAPIProviderSendsEndpointAndAuthWithoutAttribution(t *testing.T) {
transport := &captureTransport{responseBody: "data: [DONE]\n\n"}
provider, err := New(config.ProviderProfile{
Name: "aimlapi",
CatalogID: "aimlapi",
ProviderKind: config.ProviderKindOpenAICompatible,
APIKey: "aimlapi-test-key",
Model: "openai/gpt-5-chat",
CustomHeaders: map[string]string{"X-Trace": "test"},
}, Options{HTTPClient: &http.Client{Transport: transport}})
if err != nil {
t.Fatalf("New() error = %v", err)
}
stream, err := provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "hello"}},
})
if err != nil {
t.Fatalf("StreamCompletion() error = %v", err)
}
for range stream {
}
if transport.request == nil {
t.Fatal("HTTP client was not used")
}
if got := transport.request.URL.String(); got != "https://api.aimlapi.com/v1/chat/completions" {
t.Fatalf("request URL = %q, want AI/ML API endpoint", got)
}
for header, want := range map[string]string{
"Authorization": "Bearer aimlapi-test-key",
"X-Trace": "test",
} {
if got := transport.request.Header.Get(header); got != want {
t.Fatalf("%s = %q, want %q", header, got, want)
}
}
// No first-party referral/attribution headers are injected for catalog
// presets; aimlapi rides through CopyHeaders like every other provider.
for _, header := range []string{
"X-AIMLAPI-Partner-ID",
"X-AIMLAPI-Integration-Repo",
"X-AIMLAPI-Integration-Version",
} {
if got := transport.request.Header.Get(header); got != "" {
t.Fatalf("%s = %q, want no attribution header", header, got)
}
}
}
func TestNewSupportsOpenAIProviderKind(t *testing.T) {
provider, err := New(config.ProviderProfile{
Name: "openai",
@@ -269,6 +318,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 +372,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 +393,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 +457,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
+1 -1
View File
@@ -72,7 +72,7 @@ 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") {
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 != "" {
+2 -1
View File
@@ -3,6 +3,7 @@ package sandbox
import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
@@ -128,7 +129,7 @@ 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")) {
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
+144
View File
@@ -0,0 +1,144 @@
package sandbox
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
)
const windowsUnelevatedSetupMarkerSchemaVersion = 1
// windowsUnelevatedSetupMarkerMaxPlans bounds the applied-plan history so the
// marker cannot grow without limit when a user hops between many workspaces.
// Oldest entries are dropped first; re-applying an already-granted plan is
// harmless, so eviction only costs a redundant ACL apply.
const windowsUnelevatedSetupMarkerMaxPlans = 64
// WindowsUnelevatedAppliedPlan fingerprints one workspace ACL plan the
// unelevated tier has applied. Unlike the elevated setup marker it carries no
// network infrastructure hash: the unelevated tier never provisions WFP
// filters, so there is nothing network-side to fingerprint.
type WindowsUnelevatedAppliedPlan struct {
ACLPlanHash string `json:"aclPlanHash"`
ACLPlanEntries int `json:"aclPlanEntries"`
}
// WindowsUnelevatedSetupMarker records which workspace ACL plans the command
// runner has applied without elevation, keyed by plan hash, so repeat commands
// in a known workspace skip the re-apply. One file serves every workspace under
// a sandbox home (the elevated marker is instead a single-plan snapshot,
// because its WFP half genuinely is machine-global).
type WindowsUnelevatedSetupMarker struct {
SchemaVersion int `json:"schemaVersion"`
AppliedPlans []WindowsUnelevatedAppliedPlan `json:"appliedPlans,omitempty"`
}
func WindowsUnelevatedSetupMarkerPath(sandboxHome string) string {
return filepath.Join(filepath.Clean(sandboxHome), "windows-unelevated-setup.json")
}
// buildWindowsUnelevatedAppliedPlan derives the ACL plan for the command's
// permission profile plus its marker fingerprint. The same BuildWindowsACLPlan
// output later feeds the apply step, so the fingerprint and the applied grants
// can never drift apart.
func buildWindowsUnelevatedAppliedPlan(config WindowsSandboxCommandConfig) (WindowsUnelevatedAppliedPlan, WindowsACLPlan, error) {
plan, err := BuildWindowsACLPlan(config)
if err != nil {
return WindowsUnelevatedAppliedPlan{}, WindowsACLPlan{}, err
}
hash, err := WindowsACLPlanHash(plan)
if err != nil {
return WindowsUnelevatedAppliedPlan{}, WindowsACLPlan{}, err
}
return WindowsUnelevatedAppliedPlan{
ACLPlanHash: hash,
ACLPlanEntries: len(plan.Entries),
}, plan, nil
}
// loadWindowsUnelevatedSetupMarker reads the marker; a missing file is the
// common first-run state and returns an empty marker, not an error. A marker
// with an unexpected schema version is treated as empty so the runner re-applies
// (and rewrites) rather than failing.
func loadWindowsUnelevatedSetupMarker(sandboxHome string) (WindowsUnelevatedSetupMarker, error) {
sandboxHome = strings.TrimSpace(sandboxHome)
if sandboxHome == "" {
return WindowsUnelevatedSetupMarker{}, errors.New("windows sandbox home is required")
}
path := WindowsUnelevatedSetupMarkerPath(sandboxHome)
bytes, err := os.ReadFile(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return WindowsUnelevatedSetupMarker{SchemaVersion: windowsUnelevatedSetupMarkerSchemaVersion}, nil
}
return WindowsUnelevatedSetupMarker{}, fmt.Errorf("read windows unelevated setup marker: %w", err)
}
var marker WindowsUnelevatedSetupMarker
if err := json.Unmarshal(bytes, &marker); err != nil {
return WindowsUnelevatedSetupMarker{}, fmt.Errorf("parse windows unelevated setup marker %s: %w", path, err)
}
if marker.SchemaVersion != windowsUnelevatedSetupMarkerSchemaVersion {
return WindowsUnelevatedSetupMarker{SchemaVersion: windowsUnelevatedSetupMarkerSchemaVersion}, nil
}
return marker, nil
}
func (marker WindowsUnelevatedSetupMarker) contains(applied WindowsUnelevatedAppliedPlan) bool {
if strings.TrimSpace(applied.ACLPlanHash) == "" {
return false
}
for _, plan := range marker.AppliedPlans {
if plan.ACLPlanHash == applied.ACLPlanHash && plan.ACLPlanEntries == applied.ACLPlanEntries {
return true
}
}
return false
}
// recordWindowsUnelevatedAppliedPlan appends the plan to the marker (dropping
// the oldest entries past the cap) and writes it atomically, following the
// temp-file-then-rename pattern of the sibling marker writers.
func recordWindowsUnelevatedAppliedPlan(sandboxHome string, applied WindowsUnelevatedAppliedPlan) error {
marker, err := loadWindowsUnelevatedSetupMarker(sandboxHome)
if err != nil {
return err
}
if marker.contains(applied) {
return nil
}
marker.SchemaVersion = windowsUnelevatedSetupMarkerSchemaVersion
marker.AppliedPlans = append(marker.AppliedPlans, applied)
if overflow := len(marker.AppliedPlans) - windowsUnelevatedSetupMarkerMaxPlans; overflow > 0 {
marker.AppliedPlans = append([]WindowsUnelevatedAppliedPlan(nil), marker.AppliedPlans[overflow:]...)
}
path := WindowsUnelevatedSetupMarkerPath(sandboxHome)
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return fmt.Errorf("create windows unelevated setup marker dir: %w", err)
}
bytes, err := json.MarshalIndent(marker, "", " ")
if err != nil {
return fmt.Errorf("marshal windows unelevated setup marker: %w", err)
}
tmp, err := os.CreateTemp(filepath.Dir(path), ".windows-unelevated-setup-*.tmp")
if err != nil {
return fmt.Errorf("create windows unelevated setup marker temp file: %w", err)
}
tmpPath := tmp.Name()
if _, err := tmp.Write(bytes); err != nil {
_ = tmp.Close()
_ = os.Remove(tmpPath)
return fmt.Errorf("write windows unelevated setup marker temp file: %w", err)
}
if err := tmp.Close(); err != nil {
_ = os.Remove(tmpPath)
return fmt.Errorf("close windows unelevated setup marker temp file: %w", err)
}
if err := os.Rename(tmpPath, path); err != nil {
_ = os.Remove(tmpPath)
return fmt.Errorf("replace windows unelevated setup marker: %w", err)
}
return nil
}
+157
View File
@@ -0,0 +1,157 @@
package sandbox
import (
"fmt"
"os"
"path/filepath"
"testing"
)
func testWindowsUnelevatedCommandConfig(t *testing.T) WindowsSandboxCommandConfig {
t.Helper()
root := t.TempDir()
return WindowsSandboxCommandConfig{
SandboxHome: filepath.Join(root, ".zero-sandbox"),
CommandCWD: root,
WorkspaceRoots: []string{root},
PermissionProfile: PermissionProfile{
FileSystem: FileSystemPolicy{
Kind: FileSystemRestricted,
ReadRoots: []string{root},
WriteRoots: []WritableRoot{{Root: root, ProtectedMetadataNames: []string{".git", ".zero", ".agents"}}},
AllowTemp: true,
},
Network: NetworkPolicy{Mode: NetworkDeny},
},
SandboxLevel: WindowsSandboxLevelUnelevated,
Command: []string{"cmd.exe", "/c", "echo hi"},
}
}
func TestWindowsSandboxCommandArgsRoundTripUnelevatedLevel(t *testing.T) {
config := testWindowsUnelevatedCommandConfig(t)
args, err := BuildWindowsSandboxCommandArgs(WindowsSandboxCommandArgsOptions{
SandboxHome: config.SandboxHome,
CommandCWD: config.CommandCWD,
WorkspaceRoots: config.WorkspaceRoots,
PermissionProfile: config.PermissionProfile,
SandboxLevel: WindowsSandboxLevelUnelevated,
Command: config.Command,
})
if err != nil {
t.Fatalf("BuildWindowsSandboxCommandArgs: %v", err)
}
parsed, err := ParseWindowsSandboxCommandArgs(args)
if err != nil {
t.Fatalf("ParseWindowsSandboxCommandArgs: %v", err)
}
if parsed.SandboxLevel != WindowsSandboxLevelUnelevated {
t.Fatalf("round-tripped sandbox level = %q, want %q", parsed.SandboxLevel, WindowsSandboxLevelUnelevated)
}
}
func TestBuildWindowsUnelevatedAppliedPlanFingerprint(t *testing.T) {
config := testWindowsUnelevatedCommandConfig(t)
applied, plan, err := buildWindowsUnelevatedAppliedPlan(config)
if err != nil {
t.Fatalf("buildWindowsUnelevatedAppliedPlan: %v", err)
}
if applied.ACLPlanHash == "" || applied.ACLPlanEntries == 0 || applied.ACLPlanEntries != len(plan.Entries) {
t.Fatalf("applied plan = %+v with %d plan entries, want a non-empty consistent fingerprint", applied, len(plan.Entries))
}
// Same config -> same fingerprint (the marker's dedupe key).
again, _, err := buildWindowsUnelevatedAppliedPlan(config)
if err != nil {
t.Fatalf("rebuild: %v", err)
}
if again != applied {
t.Fatalf("fingerprint not stable: %+v then %+v", applied, again)
}
// An unrestricted profile has no ACL plan to apply and must error.
config.PermissionProfile.FileSystem.Kind = FileSystemUnrestricted
if _, _, err := buildWindowsUnelevatedAppliedPlan(config); err == nil {
t.Fatal("expected an error for an unrestricted filesystem profile")
}
}
func TestWindowsUnelevatedSetupMarkerRecordAndLoad(t *testing.T) {
config := testWindowsUnelevatedCommandConfig(t)
applied, _, err := buildWindowsUnelevatedAppliedPlan(config)
if err != nil {
t.Fatalf("buildWindowsUnelevatedAppliedPlan: %v", err)
}
// Missing marker file is the fresh-install state: empty, no error, no match.
marker, err := loadWindowsUnelevatedSetupMarker(config.SandboxHome)
if err != nil {
t.Fatalf("load missing marker: %v", err)
}
if marker.contains(applied) {
t.Fatal("empty marker must not contain the plan")
}
if err := recordWindowsUnelevatedAppliedPlan(config.SandboxHome, applied); err != nil {
t.Fatalf("record: %v", err)
}
marker, err = loadWindowsUnelevatedSetupMarker(config.SandboxHome)
if err != nil {
t.Fatalf("load recorded marker: %v", err)
}
if !marker.contains(applied) || len(marker.AppliedPlans) != 1 {
t.Fatalf("marker = %+v, want exactly the recorded plan", marker)
}
// Recording the same plan again must not duplicate it.
if err := recordWindowsUnelevatedAppliedPlan(config.SandboxHome, applied); err != nil {
t.Fatalf("re-record: %v", err)
}
marker, err = loadWindowsUnelevatedSetupMarker(config.SandboxHome)
if err != nil {
t.Fatalf("reload marker: %v", err)
}
if len(marker.AppliedPlans) != 1 {
t.Fatalf("marker has %d plans after duplicate record, want 1", len(marker.AppliedPlans))
}
}
func TestWindowsUnelevatedSetupMarkerEvictsOldestPastCap(t *testing.T) {
home := filepath.Join(t.TempDir(), ".zero-sandbox")
first := WindowsUnelevatedAppliedPlan{ACLPlanHash: "hash-0", ACLPlanEntries: 1}
if err := recordWindowsUnelevatedAppliedPlan(home, first); err != nil {
t.Fatalf("record first: %v", err)
}
for index := 1; index <= windowsUnelevatedSetupMarkerMaxPlans; index++ {
plan := WindowsUnelevatedAppliedPlan{ACLPlanHash: fmt.Sprintf("hash-%d", index), ACLPlanEntries: index}
if err := recordWindowsUnelevatedAppliedPlan(home, plan); err != nil {
t.Fatalf("record %d: %v", index, err)
}
}
marker, err := loadWindowsUnelevatedSetupMarker(home)
if err != nil {
t.Fatalf("load: %v", err)
}
if len(marker.AppliedPlans) != windowsUnelevatedSetupMarkerMaxPlans {
t.Fatalf("marker has %d plans, want the cap of %d", len(marker.AppliedPlans), windowsUnelevatedSetupMarkerMaxPlans)
}
if marker.contains(first) {
t.Fatal("oldest plan should have been evicted past the cap")
}
}
func TestLoadWindowsUnelevatedSetupMarkerResetsUnknownSchema(t *testing.T) {
home := filepath.Join(t.TempDir(), ".zero-sandbox")
if err := os.MkdirAll(home, 0o700); err != nil {
t.Fatalf("mkdir sandbox home: %v", err)
}
path := WindowsUnelevatedSetupMarkerPath(home)
if err := os.WriteFile(path, []byte(`{"schemaVersion":99,"appliedPlans":[{"aclPlanHash":"h","aclPlanEntries":1}]}`), 0o600); err != nil {
t.Fatalf("write marker: %v", err)
}
marker, err := loadWindowsUnelevatedSetupMarker(home)
if err != nil {
t.Fatalf("load: %v", err)
}
if len(marker.AppliedPlans) != 0 || marker.SchemaVersion != windowsUnelevatedSetupMarkerSchemaVersion {
t.Fatalf("unknown schema must reset to empty current-schema marker, got %+v", marker)
}
}
+2 -1
View File
@@ -11,6 +11,7 @@ import (
"strings"
"time"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/modelregistry"
)
@@ -120,7 +121,7 @@ var knownToolNames = map[string]bool{
}
func DefaultPaths(workspaceRoot string) (Paths, error) {
userConfigDir, err := os.UserConfigDir()
userConfigDir, err := config.UserConfigDir()
if err != nil {
return Paths{}, fmt.Errorf("resolve user config directory: %w", err)
}
+7 -1
View File
@@ -124,10 +124,16 @@ func TestOutputToolBlocksUntilTaskCompletes(t *testing.T) {
tool := NewOutputTool(manager)
tool.PollInterval = time.Millisecond
// timeout is a ceiling, not a target: the test still returns as soon as the
// 5ms write lands (PollInterval keeps it responsive), it just tolerates a
// slow/contended CI runner without failing. A 100ms budget against a 5ms
// write only left 20x margin, which Windows CI runners (coarser default
// timer granularity, heavier scheduling contention than Linux/macOS) blew
// through and failed the test on an otherwise-unrelated PR merge.
result := tool.Run(context.Background(), map[string]any{
"task_id": "child_task",
"block": true,
"timeout": 100,
"timeout": 2000,
})
if result.Status != tools.StatusOK || !strings.Contains(result.Output, "output:\nfinished") {
+19
View File
@@ -79,6 +79,25 @@ func (tool editFileTool) RunWithOptions(_ context.Context, args map[string]any,
content := string(contentBytes)
occurrences := strings.Count(content, oldString)
// CRLF fallback: read_file normalizes \r\n → \n before presenting content to
// the model, so the model's old_string will use \n line endings. When the raw
// file uses \r\n (common on Windows), the \n-based old_string won't match.
// Detect this and transparently normalize: if the direct match fails, translate
// old_string's line endings to \r\n and search again. If found, use the CRLF-translated
// old_string and new_string for the replacement to preserve the file's existing EOL style
// without rewriting EOLs in unrelated parts of the file.
if occurrences == 0 && strings.Contains(content, "\r\n") && !strings.Contains(oldString, "\r\n") {
crlfOldString := strings.ReplaceAll(oldString, "\n", "\r\n")
normalizedOccurrences := strings.Count(content, crlfOldString)
if normalizedOccurrences > 0 {
occurrences = normalizedOccurrences
oldString = crlfOldString
if !strings.Contains(newString, "\r\n") {
newString = strings.ReplaceAll(newString, "\n", "\r\n")
}
}
}
if occurrences == 0 {
return errorResult("Error: Could not find the exact string to replace in " + relativePath + ". The old_string must match the file byte-for-byte.")
}
+10
View File
@@ -630,6 +630,16 @@ func (session *execSession) collect(ctx context.Context, wait time.Duration) (st
if session.output.consumeTruncated() {
truncated = true
}
// A background process that keeps writing output faster than this
// loop can drain it (a chatty dev server, a watch-mode rebuild loop,
// a crash-restart spew) would otherwise never let drainString return
// empty, so the deadline check below — only reached on an empty
// drain — would never fire. That turns `wait` into an unbounded
// block regardless of what the caller asked for. Check it here too,
// so continuous output can never starve the timeout.
if time.Now().After(deadline) {
return finish()
}
continue
}
if session.doneClosed() {
+59
View File
@@ -450,6 +450,65 @@ func TestExecToolResultSurfacesBufferTruncationOutsideByteBudget(t *testing.T) {
}
}
// TestCollectRespectsDeadlineUnderContinuousOutput asserts collect() returns
// close to its requested deadline even while output keeps arriving. Before
// the corresponding fix, the deadline was only checked in the branch reached
// when drainString returns empty — a background process producing output
// fast/continuously enough to keep the buffer perpetually non-empty could
// theoretically starve that check indefinitely. This synthetic writer
// (8 goroutines, tight loop) did not reliably reproduce that starvation under
// Go's scheduler in practice — the reader still won the race often enough —
// so this test doesn't prove the old code could hang; it just pins down the
// intended behavior (bounded by wait) going forward.
func TestCollectRespectsDeadlineUnderContinuousOutput(t *testing.T) {
session := &execSession{
id: 1000,
output: newExecOutputBuffer(),
done: make(chan struct{}),
}
stop := make(chan struct{})
var wg sync.WaitGroup
// Several concurrent writers, each pushing a decent-sized chunk in a tight
// loop: a single slow writer lets the reader win the race often enough
// (the runtime schedules a gap between writes) to mask the bug. Enough
// parallel writers keep the buffer non-empty essentially continuously,
// closer to a real chatty process whose PTY reads land in bursts.
const writers = 8
chunk := []byte(strings.Repeat("x", 256))
wg.Add(writers)
for i := 0; i < writers; i++ {
go func() {
defer wg.Done()
for {
select {
case <-stop:
return
default:
session.output.Write(chunk)
}
}
}()
}
t.Cleanup(func() {
close(stop)
wg.Wait()
})
const wait = 200 * time.Millisecond
start := time.Now()
_, _ = session.collect(context.Background(), wait)
elapsed := time.Since(start)
// Generous slack over `wait` for scheduling jitter under a continuously
// writing goroutine — this must stay a small multiple of wait, not
// "however long the writer keeps going" (which is what the bug produced:
// this test would hang past the 30s test timeout without the fix).
if elapsed > 3*wait {
t.Fatalf("collect took %v under continuous output, want close to the %v deadline", elapsed, wait)
}
}
// resilientTempDir is like t.TempDir() but tolerates the Windows handle-release
// lag: a SIGKILL'd child process that had the dir as its cwd may not have
// released it the instant it is reaped, so the immediate RemoveAll t.TempDir()
+2 -2
View File
@@ -104,8 +104,8 @@ func TestCoreNetworkToolsExposeSafetyMetadata(t *testing.T) {
if property, ok := search.Parameters().Properties["query"]; !ok || property.Type != "string" {
t.Fatalf("web_search must expose a string query property, got %#v", search.Parameters().Properties["query"])
}
if safety := search.Safety(); safety.Permission != PermissionAllow || safety.AdvertiseInAuto {
t.Fatalf("web_search safety = %#v, want allow without prompt-advertise override", safety)
if safety := search.Safety(); safety.Permission != PermissionPrompt || !safety.AdvertiseInAuto {
t.Fatalf("web_search safety = %#v, want prompt and advertised in auto", safety)
}
}
+9 -7
View File
@@ -81,13 +81,14 @@ func newWebSearchToolWithBackend(backend searchBackend) Tool {
Required: []string{"query"},
AdditionalProperties: false,
},
// Hosted search is a read-only discovery action. It remains marked as
// network for metadata, but sandbox network policy is the shell egress
// boundary; web_search keeps its own result-domain filtering safeguards.
// Hosted search sends model-provided query text to a configured network
// backend. Keep it visible in auto mode, but guard execution through the
// normal permission flow like web_fetch.
safety: Safety{
SideEffect: SideEffectNetwork,
Permission: PermissionAllow,
Reason: "Performs a web search over the network.",
SideEffect: SideEffectNetwork,
Permission: PermissionPrompt,
Reason: "Sends model-provided search query text to the configured web search backend.",
AdvertiseInAuto: true,
},
},
backend: backend,
@@ -95,7 +96,8 @@ func newWebSearchToolWithBackend(backend searchBackend) Tool {
}
// RunWithSandbox follows the normal web_search path. The sandbox network policy
// gates sandboxed shell egress, not this in-process hosted search tool.
// gates sandboxed shell egress; this in-process hosted search tool is guarded by
// the permission flow plus backend and result-domain safeguards.
func (tool webSearchTool) RunWithSandbox(ctx context.Context, args map[string]any, engine *zeroSandbox.Engine) Result {
return tool.Run(ctx, args)
}
+40 -5
View File
@@ -116,17 +116,52 @@ func TestWebSearchRegisteredInCoreNetworkTools(t *testing.T) {
}
}
func TestWebSearchSafetyAllowsHostedSearchWithoutPrompt(t *testing.T) {
func TestWebSearchSafetyPromptsForHostedSearchButAdvertisesInAuto(t *testing.T) {
tool := newWebSearchToolWithBackend(&fakeSearchBackend{})
safety := tool.Safety()
if safety.SideEffect != SideEffectNetwork {
t.Fatalf("side effect = %s, want network", safety.SideEffect)
}
if safety.Permission != PermissionAllow {
t.Fatalf("permission = %s, want allow", safety.Permission)
if safety.Permission != PermissionPrompt {
t.Fatalf("permission = %s, want prompt", safety.Permission)
}
if safety.AdvertiseInAuto {
t.Fatal("web_search should not need the prompt-tool auto advertisement override")
if !safety.AdvertiseInAuto {
t.Fatal("web_search should be advertised in auto mode while still requiring permission")
}
}
func TestWebSearchRegistryRequiresPermissionBeforeBackendCall(t *testing.T) {
backend := &fakeSearchBackend{results: []searchResult{{Title: "T", URL: "https://x.test"}}}
registry := NewRegistry()
registry.Register(newWebSearchToolWithBackend(backend))
res := registry.Run(context.Background(), "web_search", map[string]any{"query": "private workspace detail"})
if res.Status != StatusError {
t.Fatalf("expected permission error, got %s: %s", res.Status, res.Output)
}
if !strings.Contains(res.Output, "Permission required for web_search") {
t.Fatalf("expected permission-required output, got %q", res.Output)
}
if backend.gotQuery != "" {
t.Fatalf("backend must not be called before permission, got query %q", backend.gotQuery)
}
}
func TestWebSearchRegistryRunsAfterPermissionGranted(t *testing.T) {
backend := &fakeSearchBackend{results: []searchResult{{Title: "T", URL: "https://x.test"}}}
registry := NewRegistry()
registry.Register(newWebSearchToolWithBackend(backend))
res := registry.RunWithOptions(context.Background(), "web_search", map[string]any{"query": "go errors"}, RunOptions{
PermissionGranted: true,
})
if res.Status != StatusOK {
t.Fatalf("expected ok, got %s: %s", res.Status, res.Output)
}
if backend.gotQuery != "go errors" {
t.Fatalf("backend query = %q, want %q", backend.gotQuery, "go errors")
}
}
+23
View File
@@ -250,6 +250,29 @@ func TestEditFileToolReplacesExactStrings(t *testing.T) {
}
}
func TestEditFileToolReplacesCRLF(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, "code.go")
writeTestFile(t, path, "const a = 1\r\nconst b = 2\r\n")
result := NewEditFileTool(root).Run(context.Background(), map[string]any{
"path": "code.go",
"old_string": "const a = 1\nconst b = 2",
"new_string": "const a = 42\nconst b = 24",
})
if result.Status != StatusOK {
t.Fatalf("expected edit ok, got %s: %s", result.Status, result.Output)
}
content, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if string(content) != "const a = 42\r\nconst b = 24\r\n" {
t.Fatalf("unexpected edited content: %q", string(content))
}
}
func TestEditFileToolEmitsUnifiedDiff(t *testing.T) {
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "code.go"), "const a = 1\nconst b = 2\n")
+7
View File
@@ -56,6 +56,13 @@ func pasteFromClipboardCmd() tea.Cmd {
// spec prompt, the MCP manager, an open picker, the detailed transcript) swallow
// the paste; empty content is a no-op.
func (m model) routePaste(content string) (tea.Model, tea.Cmd) {
// A paste is a deliberate action, same as a keypress or click — it means
// the user moved on to something else, so it disarms a stale Esc
// cancel-confirmation. tea.PasteMsg/clipboardReadMsg aren't
// tea.KeyPressMsg, so they don't go through that generic keypress hook;
// do it once here, before any early return, so both the bracketed-paste
// and right-click-paste paths are covered uniformly.
m = m.disarmCancelConfirmation()
if content == "" {
// Empty text clipboard — the user may have pasted a screenshot.
// Probe the OS clipboard for image content asynchronously.
+19 -7
View File
@@ -490,16 +490,16 @@ func (m model) handleModelCommand(args string) (model, string) {
// picker calls this when a model from a non-active provider is chosen, so the
// picker can list every saved provider and switch across them (like a unified
// provider+model selector). The key is loaded from the encrypted store / env.
func (m model) switchProviderModel(providerName, modelID string) (model, string) {
func (m model) switchProviderModel(providerName, modelID string) (model, string, tea.Cmd) {
if m.pending {
return m, "Model\nCannot switch providers while a run is active."
return m, "Model\nCannot switch providers while a run is active.", nil
}
if m.newProvider == nil {
return m, "Model\nProvider rebuild is not available for this TUI session."
return m, "Model\nProvider rebuild is not available for this TUI session.", nil
}
target, ok := m.savedProviderByName(providerName)
if !ok {
return m, "Model\nunknown provider " + strconv.Quote(providerName)
return m, "Model\nunknown provider " + strconv.Quote(providerName), nil
}
target = m.profileWithCredential(target)
target.Model = strings.TrimSpace(modelID)
@@ -508,11 +508,11 @@ func (m model) switchProviderModel(providerName, modelID string) (model, string)
// was deleted/unreadable the marker can still be set, and building a keyless
// provider would only fail later with a 401. Local/no-auth providers need no key.
if strings.TrimSpace(target.APIKey) == "" && strings.TrimSpace(target.AuthHeaderValue) == "" && !(hasDescriptor && descriptor.Local) {
return m, "Model\nprovider " + strconv.Quote(providerName) + " has no usable credential — run setup or `zero auth login " + providerName + "`."
return m, "Model\nprovider " + strconv.Quote(providerName) + " has no usable credential — run setup or `zero auth login " + providerName + "`.", nil
}
next, err := m.newProvider(target)
if err != nil {
return m, "Model\n" + redaction.RedactString(err.Error(), redaction.Options{ExtraSecretValues: []string{target.APIKey}})
return m, "Model\n" + redaction.RedactString(err.Error(), redaction.Options{ExtraSecretValues: []string{target.APIKey}}), nil
}
m.provider = next
m.providerProfile = target
@@ -524,7 +524,19 @@ func (m model) switchProviderModel(providerName, modelID string) (model, string)
_, _ = config.SetActiveProvider(m.userConfigPath, target.Name)
_, _ = config.SetProviderModel(m.userConfigPath, target.Name, target.Model)
}
return m, fmt.Sprintf("Model\nSwitched to %s · %s", target.Name, target.Model)
// Warm discovery for the provider we just switched to, same as Init() does
// for the provider active at launch — otherwise the context-usage gauge has
// no window for this provider until /model happens to be opened separately.
var cmds []tea.Cmd
if hasDescriptor {
if cmd := m.modelPickerProviderDiscoveryCmd(descriptor, target); cmd != nil {
cmds = append(cmds, cmd)
}
if cmd := m.ollamaContextWindowDiscoveryCmd(descriptor, target.BaseURL, target.Model); cmd != nil {
cmds = append(cmds, cmd)
}
}
return m, fmt.Sprintf("Model\nSwitched to %s · %s", target.Name, target.Model), tea.Batch(cmds...)
}
// profileWithCredential fills a profile's APIKey for provider construction the same
+1 -1
View File
@@ -467,7 +467,7 @@ func providerNextActionLines(profile config.ProviderProfile, snapshot zerocomman
}
func providerProfileHasCredential(profile config.ProviderProfile) bool {
return strings.TrimSpace(profile.APIKey) != "" || profile.APIKeyStored || strings.TrimSpace(profile.AuthHeaderValue) != ""
return profile.HasConfiguredCredential()
}
func providerCredentialRequired(profile config.ProviderProfile, providerKind string) bool {
+26
View File
@@ -27,3 +27,29 @@ func TestContextWindowSegment(t *testing.T) {
t.Fatalf("gauge = %q, want 161K/200K · 81%%", got)
}
}
// TestStatusLineDoesNotDuplicateTokenFigureNextToGauge: the gauge's "used"
// figure and the plain usage segment's token count both read the exact same
// latestUsageTokens value — once the gauge is showing it, the usage segment
// must fall back to cost-only, or the same number renders twice side by
// side in the footer (e.g. "◔ 161K/200K · 81% │ 161K tok").
func TestStatusLineDoesNotDuplicateTokenFigureNextToGauge(t *testing.T) {
m := newModel(t.Context(), Options{ModelName: "claude-sonnet-4.5"})
if _, err := m.usageTracker.Record(usage.RecordInput{
ModelID: "claude-sonnet-4.5",
Usage: zeroruntime.Usage{InputTokens: 160_000, OutputTokens: 1000},
}); err != nil {
t.Fatalf("Record: %v", err)
}
status := plainRender(t, m.statusLine(110))
if !strings.Contains(status, "161K/200K") {
t.Fatalf("status line = %q, expected the context gauge to render", status)
}
if strings.Count(status, "161K") != 1 {
t.Fatalf("status line = %q, the 161K token figure must appear exactly once, not duplicated next to the gauge", status)
}
if strings.Contains(status, "tok") {
t.Fatalf("status line = %q, the plain token segment should be suppressed once the gauge shows the same figure", status)
}
}
+251
View File
@@ -0,0 +1,251 @@
// file_view.go is the drill-in view for a touched file, opened from the FILES
// sidebar section (files_panel.go). It reuses the subchat pattern: while
// active, the chat column's body swaps to this file's content — the sidebar,
// composer, and scroll engine keep working unchanged (transcriptBodyItems is
// the single source the viewport, renderer, and hit-testers all read, so
// swapping there keeps every consumer consistent). Two modes:
//
// diff (default) — the file's edit cards from this session, full-depth
// full — the file as it stands on disk, syntax highlighted, with
// gutter markers on the lines this session's diffs added
//
// d/f switch modes (with an empty composer), Esc returns to the chat at the
// scroll position it was left at.
package tui
import (
"bufio"
"fmt"
"os"
"path/filepath"
"strings"
)
// fileViewMaxLines caps the full-file mode so a giant generated file can't
// freeze a render; the tail collapses into a "… N more lines" trailer.
const fileViewMaxLines = 4000
const (
fileViewDiff = iota
fileViewFull
)
// fileViewState manages the drill-in view for a touched file. When active, the
// transcript body swaps to the file's diff/content instead of the chat rows.
type fileViewState struct {
active bool
path string // workspace-relative, as carried by changedFiles
mode int // fileViewDiff | fileViewFull
// parentScrollOffset preserves the chat scroll position so closing the view
// returns to the same spot (mirrors subchatState).
parentScrollOffset int
}
// openFileView activates the drill-in for path in diff mode. Opening from an
// already-open view (clicking another FILES row) keeps the original saved chat
// scroll position rather than saving the file view's own offset as "parent".
// Re-opening the file that is ALREADY being viewed is a no-op: a stray
// re-click must not bounce the user from full mode back to diff or reset
// their scroll position.
func (m model) openFileView(path string) model {
if m.fileView.active && m.fileView.path == path {
return m
}
if !m.fileView.active {
m.fileView.parentScrollOffset = m.chatScrollOffset
}
m.fileView.active = true
m.fileView.path = path
m.fileView.mode = fileViewDiff
// A file only the git sweep knows about (bash/subagent mutation) has no edit
// cards to stack — open straight on the full file instead of a placeholder.
if len(m.fileViewResultRows()) == 0 {
m.fileView.mode = fileViewFull
}
m.chatScrollOffset = 0
m = m.clearHover() // bodyY numbering differs between the file body and the chat
return m
}
// exitFileView deactivates the view and restores the chat scroll position.
func (m model) exitFileView() model {
if !m.fileView.active {
return m
}
m.chatScrollOffset = m.fileView.parentScrollOffset
m.fileView = fileViewState{}
m = m.clearHover()
return m
}
// setFileViewMode switches diff/full, resetting the scroll to the bottom-anchored
// start since the two bodies have unrelated heights.
func (m model) setFileViewMode(mode int) model {
if !m.fileView.active || m.fileView.mode == mode {
return m
}
m.fileView.mode = mode
m.chatScrollOffset = 0
return m
}
// fileViewNavBar renders the single-line header shown in place of the pinned
// title bar while the view is active: the path plus the key hints. One line
// exactly, so every scrollableTranscriptFrame computed against it agrees with
// the title bar's geometry.
func (m model) fileViewNavBar(width int) string {
mode := "diff"
other := "f full"
if m.fileView.mode == fileViewFull {
mode = "full"
other = "d diff"
}
left := zeroTheme.accent.Render("← "+truncatePathLeft(m.fileView.path, maxInt(8, width/2))) +
zeroTheme.faint.Render(" · "+mode)
right := zeroTheme.faint.Render(other + " · esc back")
return fitStyledLine(joinHeaderLine(left, right, width), width)
}
// fileViewBodyItems builds the body items the transcript machinery renders
// while the view is active — one pre-rendered block, so scrolling and height
// accounting flow through the exact same path as chat rows.
func (m model) fileViewBodyItems(width int) []transcriptBodyItem {
var block string
if m.fileView.mode == fileViewFull {
block = m.renderFileViewFull(width)
} else {
block = m.renderFileViewDiff(width)
}
return []transcriptBodyItem{transcriptBlockBodyItem(transcriptBodyItemRow, -1, block)}
}
// fileViewResultRows returns the transcript's tool-result rows that touched the
// viewed file, in chronological order.
func (m model) fileViewResultRows() []transcriptRow {
var rows []transcriptRow
for _, row := range m.transcript {
if row.kind != rowToolResult {
continue
}
for _, p := range row.changedFiles {
if p == m.fileView.path {
rows = append(rows, row)
break
}
}
}
return rows
}
// renderFileViewDiff renders the session's edits to the file as its full-depth
// tool cards (bodyCap 0, the detailed-view depth), stacked chronologically —
// the same cards the chat shows, so the diffs read identically in both places.
func (m model) renderFileViewDiff(width int) string {
rows := m.fileViewResultRows()
if len(rows) == 0 {
return zeroTheme.faint.Render("No recorded edits for this file in this session.")
}
rc := buildRowContext(m.transcript)
opts := cardRenderOptions{bodyCap: 0, cwd: m.cwd}
var b strings.Builder
for i, row := range rows {
if i > 0 {
b.WriteString("\n\n")
}
if len(rows) > 1 {
b.WriteString(zeroTheme.faint.Render(fmt.Sprintf("edit %d of %d", i+1, len(rows))))
b.WriteString("\n")
}
b.WriteString(m.renderRowModeUncached(row, width, rc, opts))
}
return b.String()
}
// renderFileViewFull renders the file as it currently stands on disk, syntax
// highlighted, with a line-number gutter and an accent ▎ marker on the lines
// this session's diffs added (matched by exact text — an approximation that
// tolerates later drift; a stale marker just doesn't highlight).
func (m model) renderFileViewFull(width int) string {
target := m.fileView.path
if !filepath.IsAbs(target) {
target = filepath.Join(m.cwd, target)
}
// Stream the read and stop at the cap: os.ReadFile would load a multi-GB
// file wholesale before any truncation, which is the exact render freeze
// fileViewMaxLines exists to prevent.
file, err := os.Open(target)
if err != nil {
return zeroTheme.faint.Render("Could not read file: " + err.Error())
}
defer file.Close()
var lines []string
truncated := false
scanner := bufio.NewScanner(file)
scanner.Buffer(make([]byte, 0, 64*1024), 16*1024*1024)
for scanner.Scan() {
if len(lines) == fileViewMaxLines {
truncated = true
break
}
lines = append(lines, scanner.Text())
}
if err := scanner.Err(); err != nil {
if len(lines) == 0 {
return zeroTheme.faint.Render("Could not read file: " + err.Error())
}
truncated = true // e.g. a single over-long line mid-file: show what we have
}
changed := m.fileViewChangedLines()
gutterW := len(fmt.Sprintf("%d", len(lines)))
textBudget := maxInt(8, width-gutterW-3) // gutter + space + marker column
// Highlight with an effectively-infinite measure so the highlighter never
// wraps — output lines stay 1:1 with file lines and the gutter numbering
// can't desync. Each line is then truncated to the column budget below.
display, ok := highlightCodeForPath(lines, m.fileView.path, 1<<20, nil)
if !ok || len(display) != len(lines) {
display = lines // no lexer for this path: render plain
}
var b strings.Builder
for i, line := range display {
line = fitStyledLine(line, textBudget)
if i > 0 {
b.WriteString("\n")
}
marker := " "
if changed[strings.TrimSpace(lines[i])] {
marker = zeroTheme.accent.Render("▎")
}
b.WriteString(zeroTheme.faintest.Render(fmt.Sprintf("%*d ", gutterW, i+1)))
b.WriteString(marker)
b.WriteString(line)
}
if truncated {
// No exact remaining-line count: computing one would require reading the
// rest of the file, defeating the bounded read above.
b.WriteString("\n")
b.WriteString(zeroTheme.faint.Render(fmt.Sprintf("… more lines (file truncated at %d for display)", len(lines))))
}
return b.String()
}
// fileViewChangedLines collects the trimmed text of every line the session's
// diffs ADDED to the viewed file, for the full-mode gutter markers. Very short
// lines ("}", "return", ")") are skipped: text-matching them would mark every
// unrelated occurrence across the file, which misleads far more than a missing
// marker on a brace line ever could.
func (m model) fileViewChangedLines() map[string]bool {
changed := map[string]bool{}
for _, row := range m.fileViewResultRows() {
for _, line := range strings.Split(row.detail, "\n") {
if !strings.HasPrefix(line, "+") || strings.HasPrefix(line, "+++") {
continue
}
if text := strings.TrimSpace(strings.TrimPrefix(line, "+")); len(text) >= 4 {
changed[text] = true
}
}
}
return changed
}
+288
View File
@@ -0,0 +1,288 @@
package tui
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
tea "charm.land/bubbletea/v2"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/sessions"
"github.com/Gitlawb/zero/internal/tools"
)
// TestFileViewOpenExitRestoresScroll: opening saves the chat scroll position,
// resets it for the file body, and Esc restores it; switching files while open
// keeps the ORIGINAL saved position (not the file view's own).
func TestFileViewOpenExitRestoresScroll(t *testing.T) {
m := filesPanelTestModel()
m.chatScrollOffset = 12
m = m.openFileView("web/app.js")
if !m.fileView.active || m.fileView.mode != fileViewDiff {
t.Fatalf("open should activate in diff mode: %+v", m.fileView)
}
if m.chatScrollOffset != 0 || m.fileView.parentScrollOffset != 12 {
t.Fatalf("open should reset scroll and save the parent offset: offset=%d saved=%d", m.chatScrollOffset, m.fileView.parentScrollOffset)
}
m.chatScrollOffset = 5 // scrolled within the file body
m = m.openFileView("internal/tui/sidebar.go")
if m.fileView.parentScrollOffset != 12 {
t.Fatalf("switching files must keep the original parent offset, got %d", m.fileView.parentScrollOffset)
}
m = m.exitFileView()
if m.fileView.active || m.chatScrollOffset != 12 {
t.Fatalf("exit should restore the chat scroll: active=%v offset=%d", m.fileView.active, m.chatScrollOffset)
}
}
// TestFileViewEscAndModeKeys: Esc exits the view via the model's key handler;
// d/f switch modes while the composer is empty and never while typing.
func TestFileViewEscAndModeKeys(t *testing.T) {
m := filesPanelTestModel()
m = m.openFileView("web/app.js")
updated, _ := m.Update(tea.KeyPressMsg{Code: 'f', Text: "f"})
m = updated.(model)
if m.fileView.mode != fileViewFull {
t.Fatal("f should switch to full mode")
}
updated, _ = m.Update(tea.KeyPressMsg{Code: 'd', Text: "d"})
m = updated.(model)
if m.fileView.mode != fileViewDiff {
t.Fatal("d should switch back to diff mode")
}
// With text in the composer, d/f type as normal characters.
m.input.SetValue("say")
updated, _ = m.Update(tea.KeyPressMsg{Code: 'f', Text: "f"})
m = updated.(model)
if m.fileView.mode != fileViewDiff {
t.Fatal("f while typing must not hijack the composer")
}
m.input.SetValue("")
updated, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEsc})
m = updated.(model)
if m.fileView.active {
t.Fatal("Esc should exit the file view")
}
}
// TestFileViewDiffBody: diff mode stacks the file's edit cards chronologically
// with "edit N of M" labels; a file with no recorded edits shows the quiet
// placeholder.
func TestFileViewDiffBody(t *testing.T) {
m := filesPanelTestModel()
m = m.openFileView("internal/tui/sidebar.go")
body := plainRender(t, m.renderFileViewDiff(78))
if !strings.Contains(body, "edit 1 of 2") || !strings.Contains(body, "edit 2 of 2") {
t.Fatalf("expected chronological edit labels:\n%s", body)
}
if !strings.Contains(body, "added one") || !strings.Contains(body, "three") {
t.Errorf("expected both diffs' content:\n%s", body)
}
m.fileView.path = "never/touched.go"
if got := plainRender(t, m.renderFileViewDiff(78)); !strings.Contains(got, "No recorded edits") {
t.Errorf("untouched file should show the placeholder, got:\n%s", got)
}
}
// TestFileViewFullBody: full mode shows the on-disk content with line numbers
// and marks session-added lines with the gutter marker; a missing file degrades
// to a readable error line.
func TestFileViewFullBody(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "app.js"), []byte("let a = 1\nlet untouched = 0\n"), 0o644); err != nil {
t.Fatal(err)
}
m := filesPanelTestModel()
m.cwd = dir
m.transcript = append(m.transcript, transcriptRow{
kind: rowToolResult, tool: "write_file", id: "w9", status: tools.StatusOK,
detail: "+let a = 1",
changedFiles: []string{"app.js"},
})
m = m.openFileView("app.js")
m = m.setFileViewMode(fileViewFull)
body := m.renderFileViewFull(78)
plain := plainRender(t, body)
if !strings.Contains(plain, "1 ") || !strings.Contains(plain, "let untouched = 0") {
t.Fatalf("full view should show numbered file content:\n%s", plain)
}
lines := strings.Split(plain, "\n")
if len(lines) != 2 {
t.Fatalf("one rendered line per file line, got %d:\n%s", len(lines), plain)
}
if !strings.Contains(lines[0], "▎") {
t.Errorf("session-added line should carry the gutter marker: %q", lines[0])
}
if strings.Contains(lines[1], "▎") {
t.Errorf("untouched line must not carry the marker: %q", lines[1])
}
m.fileView.path = "gone.js"
if got := plainRender(t, m.renderFileViewFull(78)); !strings.Contains(got, "Could not read file") {
t.Errorf("missing file should degrade to an error line, got:\n%s", got)
}
}
// TestFileViewSwapsTranscriptBody: while active, transcriptBodyItems returns
// the file body (a single block) instead of the chat rows, and the pinned
// title bar swaps to the one-line nav bar — the geometry every frame consumer
// relies on.
func TestFileViewSwapsTranscriptBody(t *testing.T) {
m := filesPanelTestModel()
m = m.openFileView("internal/tui/sidebar.go")
items := m.transcriptBodyItems(m.chatColumnWidth(), "")
if len(items) != 1 {
t.Fatalf("file view should swap the body to a single block item, got %d items", len(items))
}
nav := plainRender(t, m.pinnedTitleBar(m.chatColumnWidth()))
if !strings.Contains(nav, "sidebar.go") || !strings.Contains(nav, "esc back") {
t.Fatalf("nav bar should show the path and key hints: %q", nav)
}
if lines := len(viewLines(m.fileViewNavBar(m.chatColumnWidth()))); lines != 1 {
t.Fatalf("nav bar must be exactly one line (title-bar geometry), got %d", lines)
}
// The whole view renders without panicking in both modes and shows the nav.
if view := plainRender(t, m.transcriptView()); !strings.Contains(view, "esc back") {
t.Fatal("transcript view should carry the file nav bar")
}
}
// TestSubchatEntryClosesFileView: drilling into an AGENTS row while a file view
// is open closes the file view first (the subchat owns the single-column view).
func TestSubchatEntryClosesFileView(t *testing.T) {
store := sessions.NewStore(sessions.StoreOptions{RootDir: t.TempDir()})
if _, err := store.Create(sessions.CreateInput{SessionID: "sess-1"}); err != nil {
t.Fatal(err)
}
m := filesPanelTestModel()
m.sessionStore = store
m.swarmSessionMap = map[string]string{"subagent-1": "sess-1"}
m.transcript = append(m.transcript,
transcriptRow{kind: rowToolCall, tool: "swarm_spawn", detail: "build it", runID: 1},
transcriptRow{kind: rowToolResult, tool: "swarm_spawn", detail: "Spawned subagent as task subagent-1 on team default.", runID: 1},
)
m.activeRunID = 1
m = m.openFileView("web/app.js")
width := sidebarWidth(m.width)
agents := m.sidebarAgentSelectables(width)
if len(agents) == 0 {
t.Fatal("expected a clickable agent row")
}
click := testMouseClick(tea.MouseLeft, m.chatColumnWidth()+3, agents[0].lineOffset)
updated, _, handled := m.handleTranscriptSelectionMouse(click)
if !handled {
t.Fatal("agent row click should be handled")
}
if updated.fileView.active {
t.Fatal("entering the subchat should close the file view")
}
if !updated.subchat.active {
t.Fatal("subchat should be active")
}
}
// TestChangedFilesRehydration: a persisted tool-result payload's changedFiles
// restores onto the rehydrated transcript row, so the FILES panel survives
// /resume.
func TestChangedFilesRehydration(t *testing.T) {
events := []sessions.Event{{
Type: sessions.EventToolResult,
Payload: json.RawMessage(`{"toolCallId":"t1","name":"edit_file","status":"ok","output":"+x","changedFiles":["pkg/a.go","pkg/b.go"]}`),
}}
rows := transcriptRowsFromSessionEvents(events)
if len(rows) != 1 {
t.Fatalf("expected 1 row, got %d", len(rows))
}
got := rows[0].changedFiles
if len(got) != 2 || got[0] != "pkg/a.go" || got[1] != "pkg/b.go" {
t.Fatalf("changedFiles not rehydrated: %v", got)
}
}
// TestOpenFileViewSamePathIsNoOp: re-clicking the FILES row of the file already
// being viewed must not clobber the user's mode or scroll — the old
// unconditional openFileView bounced full mode back to diff and reset scroll.
func TestOpenFileViewSamePathIsNoOp(t *testing.T) {
m := filesPanelTestModel()
m = m.openFileView("web/app.js")
m = m.setFileViewMode(fileViewFull)
m.chatScrollOffset = 7 // scrolled within the file body
m = m.openFileView("web/app.js")
if m.fileView.mode != fileViewFull {
t.Fatal("re-opening the same file must keep full mode")
}
if m.chatScrollOffset != 7 {
t.Fatalf("re-opening the same file must keep the scroll, got %d", m.chatScrollOffset)
}
// A DIFFERENT file still switches (and resets to diff mode as documented).
m = m.openFileView("internal/tui/sidebar.go")
if m.fileView.path != "internal/tui/sidebar.go" || m.fileView.mode != fileViewDiff {
t.Fatalf("opening another file should switch views: %+v", m.fileView)
}
}
// TestFileViewFullBodyTruncatesLongFile: the full view stops reading at
// fileViewMaxLines (bounded read — a giant file must not be loaded wholesale)
// and appends the truncation trailer.
func TestFileViewFullBodyTruncatesLongFile(t *testing.T) {
dir := t.TempDir()
var b strings.Builder
for i := 0; i < fileViewMaxLines+50; i++ {
fmt.Fprintf(&b, "line %d\n", i)
}
if err := os.WriteFile(filepath.Join(dir, "big.txt"), []byte(b.String()), 0o644); err != nil {
t.Fatal(err)
}
m := filesPanelTestModel()
m.cwd = dir
m.gitTouched = []gitSweepFile{{path: "big.txt"}}
m = m.openFileView("big.txt")
plain := plainRender(t, m.renderFileViewFull(80))
lines := strings.Split(plain, "\n")
if len(lines) != fileViewMaxLines+1 { // capped content + the trailer line
t.Fatalf("rendered %d lines, want %d content lines + 1 trailer", len(lines), fileViewMaxLines)
}
if !strings.Contains(lines[len(lines)-1], "truncated") {
t.Fatalf("expected the truncation trailer, got %q", lines[len(lines)-1])
}
}
// TestFileViewKeysDeferToBlockingModal: with a permission prompt up, Esc and
// the d/f shortcuts belong to the prompt — the drill-in must not swallow them
// (Esc exiting the view instead of reaching the prompt's deny handling).
func TestFileViewKeysDeferToBlockingModal(t *testing.T) {
m := filesPanelTestModel()
m = m.openFileView("web/app.js")
m.pendingPermission = &pendingPermissionPrompt{
request: agent.PermissionRequest{ToolName: "write_file"},
decide: func(agent.PermissionDecision) {},
}
updated, _ := m.Update(tea.KeyPressMsg{Code: 'f', Text: "f"})
m = updated.(model)
if m.fileView.mode != fileViewDiff {
t.Fatal("f with a permission prompt up must not switch file-view modes")
}
updated, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEsc})
m = updated.(model)
if !m.fileView.active {
t.Fatal("Esc with a permission prompt up must not exit the file view")
}
}
+237
View File
@@ -0,0 +1,237 @@
// files_git_sweep.go fills the FILES sidebar's blind spot: mutations that
// bypass the file tools entirely — bash/exec_command scaffolding (npm create,
// go generate, heredoc writes) and subagents editing the shared workspace. None
// of those produce a changedFiles-carrying tool result, so the transcript-derived
// roster (files_panel.go) never sees them. The sweep asks git instead: a
// baseline `git status --porcelain` snapshot is taken at startup (Init), and a
// re-run after each command tool result / turn end reports any NEWLY dirty
// paths, which merge into the roster with a diffstat from `git diff --numstat`.
// Pre-existing dirty state stays in the baseline and never shows; a non-git
// workspace fails the first command and the sweep silently stays off.
package tui
import (
"context"
"strconv"
"strings"
"time"
tea "charm.land/bubbletea/v2"
)
// gitSweepTimeout bounds each background git invocation so a hung index lock
// can never stall message delivery (the command runs off the update goroutine).
const gitSweepTimeout = 3 * time.Second
// gitSweepFile is one workspace file git reports as dirty: workspace-relative
// path (git's own output convention, matching changedFiles), whether it is new
// (untracked/added), and the tracked diffstat when known (0/0 for untracked).
type gitSweepFile struct {
path string
created bool
adds int
dels int
}
// gitSweepMsg carries one sweep's result. baseline marks the startup snapshot
// (recorded as "pre-existing, never show") vs a live re-check (merged into the
// roster). ok is false when git failed (not a repo, no git binary) — the
// handler then marks the sweep unavailable rather than retrying every turn.
type gitSweepMsg struct {
baseline bool
ok bool
files []gitSweepFile
}
// gitSweepCmd runs the status+numstat pair off the update goroutine. cwd is the
// workspace root the TUI resolves paths against.
func gitSweepCmd(parent context.Context, cwd string, baseline bool) tea.Cmd {
return func() tea.Msg {
if parent == nil {
parent = context.Background()
}
ctx, cancel := context.WithTimeout(parent, gitSweepTimeout)
defer cancel()
// --untracked-files=all enumerates files inside new directories; plain
// porcelain collapses them to "dir/", which is useless as a FILES row.
status, err := defaultPRCommandRunner(ctx, cwd, "git", "status", "--porcelain", "--untracked-files=all")
if err != nil {
return gitSweepMsg{baseline: baseline, ok: false}
}
files := parseGitPorcelain(status)
// Diffstat for tracked modifications; untracked files have no diff to
// stat. -z makes the output machine-parseable: NUL-terminated records, no
// path quoting, and renames as explicit preimage/postimage fields instead
// of the brace-mangled "src/{old => new}" a plain --numstat emits (which
// would never match a porcelain path). Best-effort: a failure (e.g. unborn
// HEAD in a fresh repo) keeps the file list with zero counts rather than
// dropping the sweep.
if numstat, err := defaultPRCommandRunner(ctx, cwd, "git", "diff", "HEAD", "--numstat", "-z"); err == nil {
stats := parseGitNumstat(numstat)
for i := range files {
if counts, ok := stats[files[i].path]; ok {
files[i].adds, files[i].dels = counts[0], counts[1]
}
}
}
return gitSweepMsg{baseline: baseline, ok: true, files: files}
}
}
// parseGitPorcelain parses `git status --porcelain` v1 output into sweep files.
// Format: two status columns, a space, then the path ("XY path"); renames show
// "old -> new" (keep the new side); untracked entries are "?? path".
func parseGitPorcelain(out string) []gitSweepFile {
var files []gitSweepFile
for _, line := range strings.Split(out, "\n") {
if len(line) < 4 {
continue
}
code, path := line[:2], strings.TrimSpace(line[3:])
if to, _, found := cutRename(path); found {
path = to
}
path = unquoteGitPath(path)
if path == "" {
continue
}
files = append(files, gitSweepFile{
path: path,
created: code == "??" || strings.Contains(code, "A"),
})
}
return files
}
// cutRename splits a porcelain rename value "old -> new", returning the new
// path. found is false for ordinary (non-rename) paths.
func cutRename(path string) (string, string, bool) {
if idx := strings.Index(path, " -> "); idx >= 0 {
return path[idx+4:], path[:idx], true
}
return "", "", false
}
// unquoteGitPath strips the quotes git wraps around paths with special
// characters ("web/my file.js"). Escapes inside are left as-is — such a path
// still identifies the file well enough for a sidebar row.
func unquoteGitPath(path string) string {
if len(path) >= 2 && strings.HasPrefix(path, `"`) && strings.HasSuffix(path, `"`) {
return path[1 : len(path)-1]
}
return path
}
// parseGitNumstat parses `git diff --numstat -z` output into path → [added,
// deleted]. -z records are NUL-terminated "added\tdeleted\tpath"; a rename/copy
// instead ends the counts record with an EMPTY path ("added\tdeleted\t") and
// appends two more NUL-terminated fields, preimage then postimage — the stats
// key is the postimage (the path porcelain/changedFiles report). Paths are
// never quoted or brace-mangled in -z mode. Binary files report "-" counts and
// are skipped.
func parseGitNumstat(out string) map[string][2]int {
stats := map[string][2]int{}
fields := strings.Split(out, "\x00")
for i := 0; i < len(fields); i++ {
parts := strings.SplitN(fields[i], "\t", 3)
if len(parts) != 3 {
continue
}
adds, errA := strconv.Atoi(parts[0])
dels, errD := strconv.Atoi(parts[1])
if errA != nil || errD != nil {
continue
}
path := parts[2]
if path == "" {
// Rename/copy record: the next two fields are preimage, postimage.
if i+2 >= len(fields) {
break // truncated output — drop the partial record
}
path = fields[i+2]
i += 2
}
if path != "" {
stats[path] = [2]int{adds, dels}
}
}
return stats
}
// handleGitSweepMsg folds a sweep result into the model: the baseline snapshot
// records what was already dirty before this TUI session (those paths never
// show), a live sweep upserts newly dirty paths into gitTouched in first-seen
// order. A failed sweep marks git unavailable so no further sweeps are issued.
func (m model) handleGitSweepMsg(msg gitSweepMsg) model {
m.gitSweepInFlight = false
if !msg.ok {
m.gitSweepUnavailable = true
if m.gitFileBaseline == nil {
m.gitFileBaseline = map[string]bool{}
}
return m
}
if msg.baseline {
baseline := make(map[string]bool, len(msg.files))
for _, f := range msg.files {
baseline[f.path] = true
}
m.gitFileBaseline = baseline
return m
}
for _, f := range msg.files {
if m.gitFileBaseline[f.path] {
continue
}
found := false
for i := range m.gitTouched {
if m.gitTouched[i].path == f.path {
m.gitTouched[i] = f
found = true
break
}
}
if !found {
// Copy-on-append: model copies share the backing array, so an in-place
// append from two update branches could alias. Rebuilding is cheap at
// sidebar scale.
m.gitTouched = append(append([]gitSweepFile(nil), m.gitTouched...), f)
}
}
return m
}
// maybeGitSweep issues a live sweep when one is useful and none is running:
// the baseline exists (Init's snapshot answered), git works here, and the
// workspace is known. Returns the (possibly nil) command to batch.
func (m model) maybeGitSweep() (model, tea.Cmd) {
if m.gitSweepInFlight || m.gitSweepUnavailable || m.gitFileBaseline == nil || strings.TrimSpace(m.cwd) == "" {
return m, nil
}
m.gitSweepInFlight = true
return m, gitSweepCmd(m.ctx, m.cwd, false)
}
// gitTouchedFiles adapts the sweep results to the roster's touchedFile shape,
// for merging under the transcript-derived entries (files_panel.go), newest
// detection first — gitTouched upserts in first-seen order, so iterating it in
// reverse keeps the merged FILES tail consistent with the roster's
// newest-first ordering. No transcript row backs them (lastRowIndex -1), so
// selecting one skips the scroll/tint and the drill-in opens on the full file.
func (m model) gitTouchedFiles() []touchedFile {
if len(m.gitTouched) == 0 {
return nil
}
files := make([]touchedFile, 0, len(m.gitTouched))
for i := len(m.gitTouched) - 1; i >= 0; i-- {
f := m.gitTouched[i]
files = append(files, touchedFile{
path: f.path,
created: f.created,
adds: f.adds,
dels: f.dels,
lastRowIndex: -1,
})
}
return files
}
+243
View File
@@ -0,0 +1,243 @@
package tui
import (
"os"
"os/exec"
"path/filepath"
"testing"
)
func TestParseGitPorcelain(t *testing.T) {
out := " M web/app.js\n?? web/new.css\nA docs/added.md\nR old.go -> pkg/new.go\n?? \"web/my file.js\"\n\n"
files := parseGitPorcelain(out)
if len(files) != 5 {
t.Fatalf("expected 5 entries, got %d: %+v", len(files), files)
}
byPath := map[string]gitSweepFile{}
for _, f := range files {
byPath[f.path] = f
}
if f := byPath["web/app.js"]; f.created {
t.Error("modified file must not read as created")
}
if f := byPath["web/new.css"]; !f.created {
t.Error("untracked file should read as created")
}
if f := byPath["docs/added.md"]; !f.created {
t.Error("index-added file should read as created")
}
if _, ok := byPath["pkg/new.go"]; !ok {
t.Errorf("rename should keep the new path: %+v", files)
}
if _, ok := byPath["web/my file.js"]; !ok {
t.Errorf("quoted path should unquote: %+v", files)
}
}
// TestParseGitNumstat feeds the exact `git diff --numstat -z` record shapes:
// plain "added\tdeleted\tpath\0", binary "-\t-\tpath\0", and the rename form
// "added\tdeleted\t\0preimage\0postimage\0" — the stats must key the postimage
// (the path porcelain/changedFiles report), which the old newline parser lost
// (plain --numstat brace-mangles renames, so they never matched a roster path).
func TestParseGitNumstat(t *testing.T) {
out := "12\t3\tweb/app.js\x00" +
"-\t-\tassets/logo.png\x00" +
"4\t0\t\x00old.go\x00pkg/new.go\x00" +
"1\t1\tweb/my file.js\x00" // -z paths are never quoted
stats := parseGitNumstat(out)
if got := stats["web/app.js"]; got != [2]int{12, 3} {
t.Errorf("web/app.js = %v, want [12 3]", got)
}
if _, ok := stats["assets/logo.png"]; ok {
t.Error("binary '-' entries must be skipped")
}
if got := stats["pkg/new.go"]; got != [2]int{4, 0} {
t.Errorf("renamed file must key the postimage path = %v, want [4 0]", got)
}
if _, ok := stats["old.go"]; ok {
t.Error("the rename preimage must not appear as its own entry")
}
if got := stats["web/my file.js"]; got != [2]int{1, 1} {
t.Errorf("path with spaces = %v, want [1 1] (no quoting in -z mode)", got)
}
// Truncated rename record (missing postimage) must not panic or mis-key.
if got := parseGitNumstat("4\t0\t\x00old.go"); len(got) != 0 {
t.Errorf("truncated rename record should be dropped, got %v", got)
}
}
// TestGitSweepMergeAndBaseline: the baseline snapshot hides pre-existing dirty
// paths; live sweeps upsert only new ones; a failed sweep latches unavailable.
func TestGitSweepMergeAndBaseline(t *testing.T) {
m := model{}
m = m.handleGitSweepMsg(gitSweepMsg{baseline: true, ok: true, files: []gitSweepFile{{path: "dirty-before.go"}}})
if !m.gitFileBaseline["dirty-before.go"] {
t.Fatal("baseline should record pre-existing dirty paths")
}
m = m.handleGitSweepMsg(gitSweepMsg{ok: true, files: []gitSweepFile{
{path: "dirty-before.go", adds: 9},
{path: "kanban/board.tsx", created: true, adds: 120},
}})
if len(m.gitTouched) != 1 || m.gitTouched[0].path != "kanban/board.tsx" {
t.Fatalf("only newly dirty paths should merge: %+v", m.gitTouched)
}
// Re-sweep updates stats in place, no duplicate.
m = m.handleGitSweepMsg(gitSweepMsg{ok: true, files: []gitSweepFile{{path: "kanban/board.tsx", created: true, adds: 150, dels: 2}}})
if len(m.gitTouched) != 1 || m.gitTouched[0].adds != 150 {
t.Fatalf("re-sweep should upsert stats: %+v", m.gitTouched)
}
failed := model{}
failed = failed.handleGitSweepMsg(gitSweepMsg{baseline: true, ok: false})
if !failed.gitSweepUnavailable {
t.Fatal("a failed sweep should latch unavailable")
}
if _, cmd := failed.maybeGitSweep(); cmd != nil {
t.Fatal("no further sweeps once unavailable")
}
}
// TestMaybeGitSweepGating: no sweep before the baseline answers, none while one
// is in flight, and the in-flight flag sets when one is issued.
func TestMaybeGitSweepGating(t *testing.T) {
m := model{cwd: "/tmp"}
if _, cmd := m.maybeGitSweep(); cmd != nil {
t.Fatal("no baseline yet: sweep must not run")
}
m.gitFileBaseline = map[string]bool{}
next, cmd := m.maybeGitSweep()
if cmd == nil || !next.gitSweepInFlight {
t.Fatal("with a baseline, a sweep should be issued and marked in flight")
}
if _, again := next.maybeGitSweep(); again != nil {
t.Fatal("single-flight: no second sweep while one runs")
}
}
// TestTouchedFilesMergesGitSweep: git-discovered files append below the
// transcript-derived entries, deduped by path (a tool-result entry wins).
func TestTouchedFilesMergesGitSweep(t *testing.T) {
m := filesPanelTestModel()
m.gitTouched = []gitSweepFile{
{path: "kanban/board.tsx", created: true, adds: 120},
{path: "web/app.js", adds: 999}, // duplicate of a transcript entry
}
files := m.touchedFiles()
var kanban *touchedFile
appCount := 0
for i := range files {
if files[i].path == "kanban/board.tsx" {
kanban = &files[i]
}
if files[i].path == "web/app.js" {
appCount++
if files[i].adds == 999 {
t.Error("transcript-derived entry should win over the git duplicate")
}
}
}
if kanban == nil || !kanban.created || kanban.lastRowIndex != -1 {
t.Fatalf("git-only file should merge with created badge and no row anchor: %+v", files)
}
if appCount != 1 {
t.Fatalf("web/app.js should appear exactly once, got %d", appCount)
}
}
// TestOpenFileViewGitOnlyFallsBackToFull: a file with no edit cards (git sweep
// discovery) opens straight on the full-file view instead of an empty diff.
func TestOpenFileViewGitOnlyFallsBackToFull(t *testing.T) {
m := filesPanelTestModel()
m.gitTouched = []gitSweepFile{{path: "kanban/board.tsx", created: true}}
m = m.openFileView("kanban/board.tsx")
if m.fileView.mode != fileViewFull {
t.Fatal("git-only file should open in full mode")
}
m = m.exitFileView()
m = m.openFileView("web/app.js")
if m.fileView.mode != fileViewDiff {
t.Fatal("a file with edit cards still opens in diff mode")
}
}
// TestGitSweepCmdAgainstRealRepo: end-to-end against a real git repo — the
// baseline sees pre-existing dirt, a post-mutation sweep reports the new file
// as created and the modified file with its numstat.
func TestGitSweepCmdAgainstRealRepo(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git not installed")
}
dir := t.TempDir()
run := func(args ...string) {
t.Helper()
cmd := exec.Command("git", args...)
cmd.Dir = dir
cmd.Env = append(os.Environ(),
"GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@t", "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@t")
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git %v: %v\n%s", args, err, out)
}
}
write := func(name, content string) {
t.Helper()
if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
run("init", "-q")
write("tracked.txt", "one\ntwo\n")
run("add", ".")
run("commit", "-q", "-m", "seed")
write("pre-existing.txt", "dirt\n") // dirty BEFORE the TUI "opens"
baseline := gitSweepCmd(nil, dir, true)().(gitSweepMsg)
if !baseline.ok || len(baseline.files) != 1 || baseline.files[0].path != "pre-existing.txt" {
t.Fatalf("baseline should see only the pre-existing dirt: %+v", baseline)
}
// The "agent" now scaffolds via the shell.
write("scaffolded.txt", "hello\n")
write("tracked.txt", "one\ntwo\nthree\nfour\n")
sweep := gitSweepCmd(nil, dir, false)().(gitSweepMsg)
if !sweep.ok {
t.Fatal("sweep failed")
}
byPath := map[string]gitSweepFile{}
for _, f := range sweep.files {
byPath[f.path] = f
}
if f, ok := byPath["scaffolded.txt"]; !ok || !f.created {
t.Fatalf("scaffolded file should report created: %+v", sweep.files)
}
if f := byPath["tracked.txt"]; f.adds != 2 || f.dels != 0 {
t.Fatalf("tracked.txt numstat = +%d −%d, want +2 −0", f.adds, f.dels)
}
// A rename WITH edits: porcelain reports the new path, and the -z numstat
// rename record must resolve to the same (postimage) path so the diffstat
// actually attaches — the plain --numstat form brace-mangles the path and
// the counts were silently lost.
run("add", ".")
run("commit", "-q", "-m", "second")
run("mv", "tracked.txt", "renamed.txt")
write("renamed.txt", "one\ntwo\nthree\nfour\nfive\n")
renameSweep := gitSweepCmd(nil, dir, false)().(gitSweepMsg)
if !renameSweep.ok {
t.Fatal("rename sweep failed")
}
renamed := map[string]gitSweepFile{}
for _, f := range renameSweep.files {
renamed[f.path] = f
}
if f, ok := renamed["renamed.txt"]; !ok || f.adds != 1 {
t.Fatalf("renamed file should carry its numstat under the new path, got %+v", renameSweep.files)
}
// Not-a-repo → ok=false (the sweep latches off).
if msg := gitSweepCmd(nil, t.TempDir(), false)().(gitSweepMsg); msg.ok {
t.Fatal("a non-repo should report ok=false")
}
}
+416
View File
@@ -0,0 +1,416 @@
// files_panel.go renders the FILES section of the right context sidebar: the
// workspace files this session has touched, newest first, with an A/M badge and
// a +added/−removed diffstat per file, plus a pulsing row for the file whose
// write is streaming right now. Like the swarm roster (sidebar.go), the touched
// set is not separate model state — it is recovered on demand from the
// transcript's tool-result rows (their changedFiles), so it survives resume for
// free and can never drift from what the chat shows.
//
// Interaction (see handleTranscriptSelectionMouse): the first click on a row
// SELECTS the file — its edit cards tint in the chat and the transcript scrolls
// to the most recent one; a second click (or a click while the drill-in is
// already open) opens the file view (file_view.go). Esc clears the selection.
package tui
import (
"fmt"
"sort"
"strings"
tea "charm.land/bubbletea/v2"
"github.com/Gitlawb/zero/internal/tools"
)
// maxSidebarFiles caps the FILES rows so the section stays a glanceable set,
// not a scrolling log; older files collapse into a "+N more" trailer.
const maxSidebarFiles = 6
// touchedFile is one workspace file the session has mutated, aggregated across
// every tool-result row that touched it.
type touchedFile struct {
path string
adds int // total added lines across its diffs
dels int // total removed lines across its diffs
edits int // number of mutations
created bool // first touch created the file (write_file "Created …")
failed bool // the latest touch errored
lastRowIndex int // transcript index of the most recent result touching it
}
// touchedFiles recovers the session's touched-file roster from the transcript,
// most recently touched first. Recomputed on demand (the value-receiver model
// can't persist a registry from View), mirroring swarmSpawnedAgents; the scan is
// a single pass over the transcript per render, same order as the sidebar's
// other sections.
func (m model) touchedFiles() []touchedFile {
var files []touchedFile
index := map[string]int{}
for i, row := range m.transcript {
if row.kind != rowToolResult || len(row.changedFiles) == 0 {
continue
}
adds, dels := planDiffStat(row.detail)
// A multi-file result (apply_patch spanning several files) must not charge
// the WHOLE patch's totals to every file it touched — split the diff by its
// per-file headers and attribute each file its own counts.
var perFile map[string][2]int
if len(row.changedFiles) > 1 {
perFile = perFileDiffStats(row.detail)
}
for _, path := range row.changedFiles {
if path == "" {
continue
}
at, seen := index[path]
if !seen {
index[path] = len(files)
files = append(files, touchedFile{
path: path,
created: resultRowCreatedFile(row),
lastRowIndex: i,
})
at = len(files) - 1
}
fileAdds, fileDels := adds, dels
if perFile != nil {
// Headers were found: use this file's own counts. A path the split
// couldn't attribute shows 0/0 rather than the inflated patch totals.
counts := perFile[path]
fileAdds, fileDels = counts[0], counts[1]
}
files[at].adds += fileAdds
files[at].dels += fileDels
files[at].edits++
files[at].failed = row.status == tools.StatusError
files[at].lastRowIndex = i
}
}
// Most recently touched first: the file being worked on now belongs at the
// top, like the sidebar's ACTIVITY feed. Sorted by last touch (not reversed
// first-seen order): a file touched early and touched again last must list
// first. Stable, so same-row files keep their changedFiles order.
sort.SliceStable(files, func(i, j int) bool {
return files[i].lastRowIndex > files[j].lastRowIndex
})
// Merge the git sweep's discoveries (bash/subagent mutations that carry no
// changedFiles — files_git_sweep.go) below the transcript-derived entries,
// skipping paths a tool result already reported.
for _, f := range m.gitTouchedFiles() {
if _, seen := index[f.path]; seen {
continue
}
files = append(files, f)
}
return files
}
// perFileDiffStats splits a (possibly multi-file) unified diff into per-path
// +added/−removed counts, keyed by the workspace-relative path from each
// section's "+++ b/…" header (the "--- a/…" old side when the new side is
// /dev/null, i.e. a deletion). Returns nil when the detail carries no file
// headers at all (write_file/edit_file previews may be bare hunks) — the caller
// then falls back to whole-detail totals, which are correct for a single file.
func perFileDiffStats(detail string) map[string][2]int {
stats := map[string][2]int{}
current, oldPath := "", ""
normalize := func(line string, prefix string) string {
path := strings.TrimSpace(line[4:])
if tab := strings.IndexByte(path, '\t'); tab >= 0 {
path = path[:tab] // unified-diff convention: optional timestamp after a tab
}
path = unquoteGitPath(path)
if path == "/dev/null" {
return ""
}
return strings.TrimPrefix(path, prefix)
}
for _, line := range strings.Split(detail, "\n") {
switch {
case strings.HasPrefix(line, "--- "):
oldPath = normalize(line, "a/")
case strings.HasPrefix(line, "+++ "):
current = normalize(line, "b/")
if current == "" {
current = oldPath // deletion: charge the removed lines to the old path
}
case current == "":
// Lines before the first header (or in an unattributable section).
case strings.HasPrefix(line, "+"):
counts := stats[current]
counts[0]++
stats[current] = counts
case strings.HasPrefix(line, "-"):
counts := stats[current]
counts[1]++
stats[current] = counts
}
}
if len(stats) == 0 {
return nil
}
return stats
}
// resultRowCreatedFile reports whether a tool-result row reads as a file
// creation ("tool result: write_file ok Created x (…)"), so the FILES row can
// badge it A rather than M. Heuristic on the confirmation text write_file
// already emits; a miss just shows M, never breaks anything.
func resultRowCreatedFile(row transcriptRow) bool {
return strings.Contains(row.text, " Created ")
}
// liveEditingPath is the path of the file whose mutating tool call is streaming
// its arguments RIGHT NOW (the same live write streamingToolCallView previews),
// or "" when no mutation is in flight. It renders as a pulsing row pinned atop
// the FILES section.
func (m model) liveEditingPath() string {
if m.streamCallDecoder == nil {
return ""
}
switch m.streamCallName {
case "write_file", "edit_file", "apply_patch":
return m.streamCallDecoder.path
}
return ""
}
// fileHit marks a rendered FILES row (by its ABSOLUTE index inside the rendered
// sidebar) that is clickable, carrying the file it refers to.
type fileHit struct {
lineOffset int
path string
}
// sidebarFilesHeader renders the FILES section header with the touched count.
func (m model) sidebarFilesHeader(width int) string {
n := len(m.touchedFiles())
if n == 0 {
return sidebarHeader("FILES", width)
}
return sidebarHeaderWithCount("FILES", fmt.Sprintf("%d", n), zeroTheme.muted, width)
}
// sidebarFileLines renders the FILES section body: the live "writing" pulse row
// first, then the touched files newest-first, capped at maxSidebarFiles with a
// "+N more" trailer. Returns nil when the session has touched nothing (the
// caller shows a placeholder). The paired hits slice records each clickable
// row's index WITHIN the returned lines.
func (m model) sidebarFileLines(width int) ([]string, []fileHit) {
files := m.touchedFiles()
live := m.liveEditingPath()
if len(files) == 0 && live == "" {
return nil, nil
}
room := maxInt(4, width-3)
var lines []string
var hits []fileHit
// The file being written this instant: an accent pulse, not yet clickable
// (its result row — the diff — doesn't exist until the call completes).
if live != "" {
lines = append(lines, " "+zeroTheme.accent.Render("●")+" "+
zeroTheme.ink.Render(truncatePathLeft(live, room)))
}
// Filter the live row out FIRST so the "+N more" trailer counts only rows
// that could actually render — counting the skipped live entry would inflate
// the overflow by one.
visible := files[:0:0]
for _, f := range files {
if f.path != live {
visible = append(visible, f)
}
}
shown := 0
for _, f := range visible {
if shown >= maxSidebarFiles {
lines = append(lines, " "+zeroTheme.faint.Render(fmt.Sprintf("+%d more", len(visible)-shown)))
break
}
shown++
hits = append(hits, fileHit{lineOffset: len(lines), path: f.path})
lines = append(lines, m.renderFileRow(f, room))
}
return lines, hits
}
// renderFileRow renders one touched file: status badge, left-truncated path,
// and the +/− diffstat, with the selected file carrying an accent marker so the
// selection reads in the sidebar as well as in the chat tint.
func (m model) renderFileRow(f touchedFile, room int) string {
badge := zeroTheme.muted.Render("M")
switch {
case f.failed:
badge = zeroTheme.red.Render("✗")
case f.created:
badge = zeroTheme.green.Render("A")
}
// Reserve the diffstat's width so the path truncates around it.
stat := ""
if f.adds > 0 || f.dels > 0 {
stat = fmt.Sprintf(" +%d −%d", f.adds, f.dels)
}
pathRoom := maxInt(4, room-len(stat))
pathStyle := zeroTheme.muted
lead := " "
if m.selectedFile == f.path {
lead = zeroTheme.accent.Render("▸")
pathStyle = zeroTheme.ink
}
line := lead + badge + " " + pathStyle.Render(truncatePathLeft(f.path, pathRoom))
if stat != "" {
line += zeroTheme.faintest.Render(stat)
}
return line
}
// sidebarFileSelectables returns the clickable FILES rows with their ABSOLUTE
// index inside the rendered sidebar. The FILES section renders after PLAN in
// renderContextSidebar: AGENTS header + body, blank + PLAN header + body, then
// blank + FILES header, then the file rows. The offset accounting mirrors
// sidebarPlanSelectables exactly, extended one section down.
func (m model) sidebarFileSelectables(width int) []fileHit {
lines, hits := m.sidebarFileLines(width)
if len(lines) == 0 {
return nil
}
agentBody := len(m.sidebarAgentLines(width))
if agentBody == 0 {
agentBody = 1 // the "no agents spawned" placeholder occupies one line
}
planBody := len(m.sidebarPlanLines(width))
if planBody == 0 {
planBody = 1 // the "no active plan" placeholder occupies one line
}
base := 1 + agentBody + 2 + planBody + 2 // sections above + (blank + FILES header)
for i := range hits {
hits[i].lineOffset += base
}
return hits
}
// fileRowAtMouse maps a left-click in the context sidebar to a touched file,
// mirroring planStepAtMouse's column/x gate. Rows truncated away by the
// sidebar's height budget (or colliding with the token floor line) never match.
func (m model) fileRowAtMouse(msg tea.MouseMsg) (string, bool) {
if !m.sidebarActive() {
return "", false
}
if m.setup.visible || m.providerWizard != nil || m.mcpAddWizard != nil || m.mcpManager != nil || m.picker != nil || m.suggestionsActive() {
return "", false
}
sidebarW := sidebarWidth(m.width)
if sidebarW <= 0 {
return "", false
}
x0 := m.chatColumnWidth() + 3 // " │ " divider between the columns
x, y := mouseX(msg), mouseY(msg)
if x < x0 || x >= x0+sidebarW {
return "", false
}
for _, hit := range m.sidebarFileSelectables(sidebarW) {
if hit.lineOffset == y && hit.lineOffset < m.height-1 && hit.path != "" {
return hit.path, true
}
}
return "", false
}
// rowTouchesSelectedFile reports whether a transcript row's mutation touched
// the currently selected file, so its card renders with the selection tint.
func (m model) rowTouchesSelectedFile(row transcriptRow) bool {
if m.selectedFile == "" || row.kind != rowToolResult {
return false
}
for _, path := range row.changedFiles {
if path == m.selectedFile {
return true
}
}
return false
}
// lastRowIndexForFile returns the transcript index of the most recent
// tool-result row that touched path, or -1.
func (m model) lastRowIndexForFile(path string) int {
for i := len(m.transcript) - 1; i >= 0; i-- {
row := m.transcript[i]
if row.kind != rowToolResult {
continue
}
for _, p := range row.changedFiles {
if p == path {
return i
}
}
}
return -1
}
// selectFile marks path as the selected file and scrolls the transcript so its
// most recent edit card is in view; the card tint comes from the renderers
// reading selectedFile (rowTouchesSelectedFile).
func (m model) selectFile(path string) model {
m.selectedFile = path
if offset, ok := m.scrollOffsetForTranscriptRow(m.lastRowIndexForFile(path)); ok {
m.chatScrollOffset = offset
if offset == 0 {
m.chatBodyLines = 0
}
}
return m
}
// scrollOffsetForTranscriptRow computes the chatScrollOffset that places the
// given transcript row's first rendered line at the top of the viewport (with
// one line of breathing room), using the same layout metrics the scroll engine
// itself uses. ok is false outside alt-screen or when the row isn't rendered
// (e.g. skipped/collapsed).
func (m model) scrollOffsetForTranscriptRow(rowIndex int) (int, bool) {
if rowIndex < 0 || !m.altScreen || m.height <= 0 {
return 0, false
}
width := m.chatColumnWidth()
items := m.transcriptBodyItems(width, "")
metrics := measureTranscriptBodyItems(items, m.transcriptBodyHeights)
startY := -1
for _, span := range metrics.spans {
if span.kind == transcriptBodyItemRow && span.rowIndex == rowIndex {
startY = span.startY
break
}
}
if startY < 0 {
return 0, false
}
frame := m.scrollableTranscriptFrame(m.pinnedTitleBar(width), m.footerView(width))
viewport := transcriptViewportForLayout(metrics, frame, m.chatScrollOffset)
// The offset counts lines below the fold: window.start == total-height-offset,
// so placing startY at the window top (minus one context line) means
// offset = total - height - (startY - 1), clamped to the scroll range.
offset := metrics.totalLines() - frame.bodyRect.height - (startY - 1)
return clampInt(offset, 0, viewport.maxOffset()), true
}
// truncatePathLeft fits a path into room cells by dropping leading components
// ("internal/tui/sidebar.go" → "…/tui/sidebar.go"), keeping the filename — the
// part that identifies the file — intact for as long as possible.
func truncatePathLeft(path string, room int) string {
if room <= 0 {
return ""
}
if len(path) <= room {
return path
}
const ellipsis = "…/"
parts := strings.Split(path, "/")
for start := 1; start < len(parts); start++ {
candidate := ellipsis + strings.Join(parts[start:], "/")
if len(candidate) <= room {
return candidate
}
}
// Even "…/name" overflows: fall back to a right-truncated basename.
return truncateRunes(ellipsis+parts[len(parts)-1], room)
}
+317
View File
@@ -0,0 +1,317 @@
package tui
import (
"strings"
"testing"
tea "charm.land/bubbletea/v2"
"github.com/Gitlawb/zero/internal/tools"
)
// filesPanelTestModel is sidebarTestModel plus a couple of touched files: one
// created, one edited twice (the second touch most recent).
func filesPanelTestModel() model {
m := sidebarTestModel()
m.transcript = append(m.transcript,
transcriptRow{kind: rowToolResult, tool: "write_file", id: "f1", status: tools.StatusOK,
text: "tool result: write_file ok Created web/app.js (10 lines).",
detail: "+let a = 1\n+let b = 2",
changedFiles: []string{"web/app.js"}},
transcriptRow{kind: rowToolResult, tool: "edit_file", id: "f2", status: tools.StatusOK,
text: "tool result: edit_file ok Edited internal/tui/sidebar.go.",
detail: "+added one\n-removed one",
changedFiles: []string{"internal/tui/sidebar.go"}},
transcriptRow{kind: rowToolResult, tool: "edit_file", id: "f3", status: tools.StatusOK,
text: "tool result: edit_file ok Edited internal/tui/sidebar.go.",
detail: "+two\n+three\n-gone",
changedFiles: []string{"internal/tui/sidebar.go"}},
)
return m
}
// TestTouchedFilesAggregates: the roster is recovered from tool-result rows,
// newest-touch first, with per-file diffstat totals, edit counts, and the
// created badge from write_file's confirmation text.
func TestTouchedFilesAggregates(t *testing.T) {
m := filesPanelTestModel()
files := m.touchedFiles()
if len(files) != 2 {
t.Fatalf("expected 2 touched files, got %d: %+v", len(files), files)
}
// sidebar.go was touched last, so it lists first.
if files[0].path != "internal/tui/sidebar.go" {
t.Fatalf("most recently touched file should list first, got %q", files[0].path)
}
if files[0].adds != 3 || files[0].dels != 2 || files[0].edits != 2 {
t.Errorf("sidebar.go diffstat = +%d −%d over %d edits, want +3 −2 over 2", files[0].adds, files[0].dels, files[0].edits)
}
if files[0].created {
t.Error("edited file must not read as created")
}
if !files[1].created {
t.Error("write_file 'Created …' result should mark the file created")
}
if files[0].lastRowIndex != len(m.transcript)-1 {
t.Errorf("lastRowIndex = %d, want the final transcript row", files[0].lastRowIndex)
}
}
// TestSidebarFileLinesRenderAndOverflow: rows carry the badge + left-truncated
// path + diffstat; beyond maxSidebarFiles the tail collapses into "+N more";
// hits index only real file rows.
func TestSidebarFileLinesRenderAndOverflow(t *testing.T) {
m := filesPanelTestModel()
lines, hits := m.sidebarFileLines(34)
joined := plainRender(t, strings.Join(lines, "\n"))
if !strings.Contains(joined, "sidebar.go") || !strings.Contains(joined, "app.js") {
t.Fatalf("file rows missing paths:\n%s", joined)
}
if !strings.Contains(joined, "+3 −2") {
t.Errorf("expected aggregated diffstat +3 −2:\n%s", joined)
}
if !strings.Contains(joined, "A") || !strings.Contains(joined, "M") {
t.Errorf("expected A and M badges:\n%s", joined)
}
if len(hits) != 2 {
t.Fatalf("expected 2 clickable hits, got %d", len(hits))
}
if hits[0].path != "internal/tui/sidebar.go" {
t.Errorf("first hit should be the newest file, got %q", hits[0].path)
}
// Overflow: 8 distinct files -> maxSidebarFiles rows + a "+2 more" trailer.
over := sidebarTestModel()
for _, name := range []string{"a", "b", "c", "d", "e", "f", "g", "h"} {
over.transcript = append(over.transcript, transcriptRow{
kind: rowToolResult, tool: "edit_file", id: name, status: tools.StatusOK,
changedFiles: []string{name + ".go"},
})
}
overLines, overHits := over.sidebarFileLines(34)
if len(overHits) != maxSidebarFiles {
t.Fatalf("expected %d clickable rows, got %d", maxSidebarFiles, len(overHits))
}
if got := plainRender(t, overLines[len(overLines)-1]); !strings.Contains(got, "+2 more") {
t.Errorf("expected '+2 more' trailer, got %q", got)
}
}
// TestSidebarFilesLivePulseRow: while a mutating tool call streams its args,
// the file it is writing shows as a pinned (unclickable) pulse row.
func TestSidebarFilesLivePulseRow(t *testing.T) {
m := filesPanelTestModel()
m.streamCallName = "write_file"
m.streamCallDecoder = newStreamingDecoder()
m.streamCallDecoder.feed(`{"path":"web/new.css","content":"body{}`)
lines, hits := m.sidebarFileLines(34)
if got := plainRender(t, lines[0]); !strings.Contains(got, "web/new.css") {
t.Fatalf("live write should pin its path atop FILES, got %q", got)
}
for _, hit := range hits {
if hit.path == "web/new.css" {
t.Error("the live row must not be clickable (no result exists yet)")
}
}
// A non-mutating streaming call shows no pulse row.
m.streamCallName = "read_file"
if m.liveEditingPath() != "" {
t.Error("read_file must not read as a live edit")
}
}
// TestSidebarFileSelectablesMatchRenderedRows: the hit offsets computed by
// sidebarFileSelectables must land exactly on the rendered FILES rows in
// renderContextSidebar's output — the same invariant the mouse hit-test relies
// on. Asserted against the real render, not a re-derivation of the math.
func TestSidebarFileSelectablesMatchRenderedRows(t *testing.T) {
m := filesPanelTestModel()
width := sidebarWidth(m.width)
rendered := m.renderContextSidebar(width, m.height)
hits := m.sidebarFileSelectables(width)
if len(hits) == 0 {
t.Fatal("expected clickable FILES rows")
}
for _, hit := range hits {
if hit.lineOffset >= len(rendered) {
t.Fatalf("hit offset %d beyond rendered sidebar (%d lines)", hit.lineOffset, len(rendered))
}
line := plainRender(t, rendered[hit.lineOffset])
base := hit.path[strings.LastIndex(hit.path, "/")+1:]
if !strings.Contains(line, base) {
t.Errorf("hit for %q points at line %d which reads %q", hit.path, hit.lineOffset, line)
}
}
}
// TestFileRowClickSelectsThenOpens: the first sidebar click on a file selects
// it (tint + scroll state, no view swap); the second click on the same file
// opens the drill-in; clicking another file while the view is open switches it.
func TestFileRowClickSelectsThenOpens(t *testing.T) {
m := filesPanelTestModel()
width := sidebarWidth(m.width)
hits := m.sidebarFileSelectables(width)
if len(hits) < 2 {
t.Fatal("expected two clickable FILES rows")
}
x := m.chatColumnWidth() + 3
click := func(hit fileHit) tea.MouseMsg { return testMouseClick(tea.MouseLeft, x, hit.lineOffset) }
m1, _, handled := m.handleTranscriptSelectionMouse(click(hits[0]))
if !handled {
t.Fatal("FILES row click should be handled")
}
if m1.selectedFile != hits[0].path || m1.fileView.active {
t.Fatalf("first click should select (not open): selected=%q active=%v", m1.selectedFile, m1.fileView.active)
}
m2, _, _ := m1.handleTranscriptSelectionMouse(click(hits[0]))
if !m2.fileView.active || m2.fileView.path != hits[0].path {
t.Fatalf("second click should open the file view: %+v", m2.fileView)
}
m3, _, _ := m2.handleTranscriptSelectionMouse(click(hits[1]))
if !m3.fileView.active || m3.fileView.path != hits[1].path {
t.Fatalf("clicking another file with the view open should switch it: %+v", m3.fileView)
}
if m3.selectedFile != hits[1].path {
t.Errorf("switching the view should move the selection too, got %q", m3.selectedFile)
}
}
// TestRowTouchesSelectedFileTint: only tool-result rows whose changedFiles
// carry the selected path tint, and the render cache key differs across
// selection states so the tint can't be served stale.
func TestRowTouchesSelectedFileTint(t *testing.T) {
m := filesPanelTestModel()
row := m.transcript[len(m.transcript)-1]
if m.rowTouchesSelectedFile(row) {
t.Fatal("nothing selected: no row should tint")
}
m.selectedFile = "internal/tui/sidebar.go"
if !m.rowTouchesSelectedFile(row) {
t.Fatal("selected file's edit row should tint")
}
other := m.transcript[len(m.transcript)-3] // web/app.js
if m.rowTouchesSelectedFile(other) {
t.Fatal("an unrelated file's row must not tint")
}
rc := buildRowContext(m.transcript)
opts := cardRenderOptions{bodyCap: cardBodyMaxLines, cwd: m.cwd}
selectedKey, _ := m.renderRowCacheKey(row, 80, rc, opts, false)
m.selectedFile = ""
plainKey, _ := m.renderRowCacheKey(row, 80, rc, opts, false)
if selectedKey == plainKey {
t.Error("render cache key must change with the selection state")
}
}
// TestEscClearsFileSelectionThenNothing: Esc drops an active file selection
// (before any run-level action) and is consumed doing so.
func TestEscClearsFileSelectionThenNothing(t *testing.T) {
m := filesPanelTestModel()
m.selectedFile = "web/app.js"
updated, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyEsc})
if got := updated.(model).selectedFile; got != "" {
t.Fatalf("Esc should clear the file selection, still %q", got)
}
}
func TestTruncatePathLeft(t *testing.T) {
if got := truncatePathLeft("internal/tui/sidebar.go", 40); got != "internal/tui/sidebar.go" {
t.Errorf("short path should pass through, got %q", got)
}
if got := truncatePathLeft("internal/tui/sidebar.go", 18); got != "…/tui/sidebar.go" {
t.Errorf("want …/tui/sidebar.go, got %q", got)
}
if got := truncatePathLeft("internal/tui/sidebar.go", 14); got != "…/sidebar.go" {
t.Errorf("want …/sidebar.go, got %q", got)
}
}
// TestTouchedFilesPerFileDiffStat: a multi-file result (apply_patch spanning
// several files) must charge each file its OWN +/- counts from the per-file
// diff sections — not the whole patch's totals to every file it touched.
func TestTouchedFilesPerFileDiffStat(t *testing.T) {
m := sidebarTestModel()
patch := "diff --git a/web/one.js b/web/one.js\n" +
"--- a/web/one.js\n+++ b/web/one.js\n@@ -1,1 +1,2 @@\n+added one\n+added two\n" +
"diff --git a/web/two.js b/web/two.js\n" +
"--- a/web/two.js\n+++ b/web/two.js\n@@ -1,2 +1,1 @@\n-gone one\n-gone two\n-gone three\n"
m.transcript = append(m.transcript, transcriptRow{
kind: rowToolResult, tool: "apply_patch", id: "p1", status: tools.StatusOK,
detail: patch,
changedFiles: []string{"web/one.js", "web/two.js"},
})
byPath := map[string]touchedFile{}
for _, f := range m.touchedFiles() {
byPath[f.path] = f
}
if f := byPath["web/one.js"]; f.adds != 2 || f.dels != 0 {
t.Errorf("web/one.js = +%d −%d, want its own +2 −0 (not the patch totals)", f.adds, f.dels)
}
if f := byPath["web/two.js"]; f.adds != 0 || f.dels != 3 {
t.Errorf("web/two.js = +%d −%d, want its own +0 −3 (not the patch totals)", f.adds, f.dels)
}
}
// TestTouchedFilesRetouchOrdersByLastTouch: touch a, then b, then a again — a
// must list first. The old "reverse first-seen order" put b first because a's
// slot kept its original position.
func TestTouchedFilesRetouchOrdersByLastTouch(t *testing.T) {
m := sidebarTestModel()
for _, step := range []struct{ id, path string }{
{"r1", "a.go"}, {"r2", "b.go"}, {"r3", "a.go"},
} {
m.transcript = append(m.transcript, transcriptRow{
kind: rowToolResult, tool: "edit_file", id: step.id, status: tools.StatusOK,
changedFiles: []string{step.path},
})
}
files := m.touchedFiles()
if len(files) != 2 || files[0].path != "a.go" {
t.Fatalf("the re-touched file must list first, got %+v", files)
}
}
// TestSidebarFileLinesOverflowExcludesLiveRow: when the live-writing file is
// also a touched file, it renders as the pulse row and is skipped from the
// list — the "+N more" trailer must not count that skipped entry.
func TestSidebarFileLinesOverflowExcludesLiveRow(t *testing.T) {
m := sidebarTestModel()
for _, name := range []string{"a", "b", "c", "d", "e", "f", "g", "h"} {
m.transcript = append(m.transcript, transcriptRow{
kind: rowToolResult, tool: "edit_file", id: name, status: tools.StatusOK,
changedFiles: []string{name + ".go"},
})
}
// h.go (a touched file) is ALSO the live write: 7 listable files remain,
// 6 render, so the trailer is +1 — not +2 (which counted the skipped live row).
m.streamCallName = "edit_file"
m.streamCallDecoder = newStreamingDecoder()
m.streamCallDecoder.feed(`{"path":"h.go","content":"x`)
lines, _ := m.sidebarFileLines(34)
trailer := plainRender(t, lines[len(lines)-1])
if !strings.Contains(trailer, "+1 more") {
t.Fatalf("overflow must exclude the live row from the count, got %q", trailer)
}
}
// TestSidebarHasContentForLiveWrite: the sidebar counts an in-flight first
// write as content, so the FILES pulse is visible before any result row lands.
func TestSidebarHasContentForLiveWrite(t *testing.T) {
m := sidebarTestModel()
m.plan.steps = nil // drop the helper's seeded plan: no agents/plan/files now
if m.sidebarHasContent() {
t.Fatal("session without agents/plan/files should have no sidebar content")
}
m.streamCallName = "write_file"
m.streamCallDecoder = newStreamingDecoder()
m.streamCallDecoder.feed(`{"path":"web/new.css","content":"body{}`)
if !m.sidebarHasContent() {
t.Fatal("a live in-flight write must count as sidebar content")
}
}
+2
View File
@@ -122,6 +122,8 @@ func TestEscCancellationLeavesVisibleMarker(t *testing.T) {
updated, _ := m.Update(testKey(tea.KeyEsc))
next := updated.(model)
updated, _ = next.Update(testKey(tea.KeyEsc))
next = updated.(model)
if !transcriptContains(next.transcript, "Run cancelled.") {
t.Fatalf("expected visible cancellation marker, got %#v", next.transcript)
}
+7
View File
@@ -15,6 +15,9 @@ const (
// hoverPlanStep: a plan step row in the sidebar's PLAN section, identified by
// step index.
hoverPlanStep
// hoverFileRow: a touched-file row in the sidebar's FILES section, identified
// by path.
hoverFileRow
)
// hoverTarget identifies the single clickable row (if any) currently under the
@@ -34,6 +37,7 @@ type hoverTarget struct {
bodyY int // hoverTranscript
sessionID string // hoverSidebarAgent
stepIndex int // hoverPlanStep
filePath string // hoverFileRow
}
// mouseHover reports whether msg is a plain cursor-movement event with NO button
@@ -59,6 +63,9 @@ func (m model) updateHoverTarget(msg tea.MouseMsg) model {
if stepIndex, ok := m.planStepAtMouse(msg); ok {
return m.withHover(hoverTarget{kind: hoverPlanStep, stepIndex: stepIndex})
}
if path, ok := m.fileRowAtMouse(msg); ok {
return m.withHover(hoverTarget{kind: hoverFileRow, filePath: path})
}
if line, ok := m.transcriptLineAtMouse(msg); ok {
// A permission option reuses its OWN existing keyboard-cursor highlight
// (see hoverPermissionOption) rather than the m.hover mechanism, so there's
+59
View File
@@ -0,0 +1,59 @@
package tui
import (
"context"
"reflect"
"testing"
tea "charm.land/bubbletea/v2"
)
// TestInitRequestsWindowSize: Bubble Tea documents an initial WindowSizeMsg as
// delivered automatically on program start, but that's the terminal
// proactively pushing a size — if it's ever missed (a slow/unusual terminal,
// a multiplexer, a startup race), nothing else asks again, m.height stays 0
// forever, and transcriptView's `if m.altScreen && m.height > 0` gate falls
// back to the unpadded, non-fullscreen render path for the whole session (the
// alt-screen viewport never gets filled below the actual content). Init must
// explicitly request it too, the same way it already explicitly requests the
// background color rather than relying solely on an unprompted push.
func TestInitRequestsWindowSize(t *testing.T) {
m := newModel(context.Background(), Options{ModelName: "gpt-4"})
cmd := m.Init()
if cmd == nil {
t.Fatal("Init() must return a non-nil command")
}
want := reflect.ValueOf(tea.RequestWindowSize).Pointer()
if !batchContainsCmd(t, cmd, want) {
t.Error("Init() must request the window size (tea.RequestWindowSize), not rely solely on the terminal's unprompted initial WindowSizeMsg")
}
}
// batchContainsCmd reports whether cmd (Init()'s single top-level
// tea.Batch(...)) contains a sub-command matching want, compared by pointer
// (the same technique TestThemeAutoReProbesBackground uses for
// tea.RequestBackgroundColor — reliable for named top-level functions like
// tea.RequestWindowSize, not closures). Invokes cmd itself once to unpack the
// tea.BatchMsg, but only pointer-compares the sub-commands — never calls
// them, since Init()'s batch also carries real closures (e.g. provider model
// discovery) that make live network calls and must not fire in a unit test.
func batchContainsCmd(t *testing.T, cmd tea.Cmd, want uintptr) bool {
t.Helper()
if cmd == nil {
return false
}
if reflect.ValueOf(cmd).Pointer() == want {
return true
}
batch, ok := cmd().(tea.BatchMsg)
if !ok {
return false
}
for _, sub := range batch {
if sub != nil && reflect.ValueOf(sub).Pointer() == want {
return true
}
}
return false
}
+4 -4
View File
@@ -31,7 +31,7 @@ var keybindingGroups = []keybindingGroup{
bindings: []keybinding{
{"Enter", "send the message"},
{"Alt+Enter", "insert a newline (multi-line compose)"},
{"Esc", "cancel the run / dismiss a popup / clear the input"},
{"Esc (×2)", "cancel the run / dismiss a popup / clear the input"},
{"Ctrl+C", "cancel the run, then quit"},
{"?", "show this help (on an empty input)"},
},
@@ -124,9 +124,9 @@ func formatKeybindingLine(binding keybinding, keyColumn int, innerWidth int) str
return " " + keyCell + " " + desc
}
// renderKeybindingHelpOverlay renders the framed, centered `?` help overlay for
// the given terminal dimensions.
func (m model) renderKeybindingHelpOverlay(width int, height int) string {
// the given terminal width. Vertical centering is handled by the caller's
// overlay compositing pipeline (overlayViewportLines in transcriptView).
func (m model) renderKeybindingHelpOverlay(width int) string {
overlayWidth := keybindingHelpOverlayWidth(width)
lines := m.renderKeybindingHelpLines(overlayWidth - 4)
block := styledBlockFillTitle(overlayWidth, "Keyboard Shortcuts", lines, zeroTheme.line, zeroTheme.panel)
+52
View File
@@ -105,3 +105,55 @@ func TestKeybindingGroupsAreWellFormed(t *testing.T) {
}
}
}
// #419: the `?` help overlay must render ON TOP of the chat (like the model
// picker), not REPLACE the whole screen. The old full-screen replace produced
// only the centered shortcut block on a blank canvas — no title bar, no
// composer. With the overlay open, surrounding chat chrome that is NOT covered
// by the centered box (the model title bar at top, the composer at bottom) must
// still be present alongside "Keyboard Shortcuts".
func TestHelpOverlayCompositesOverChatNotReplacingIt(t *testing.T) {
m := newModel(context.Background(), Options{ModelName: "gpt-4o"})
m.width = 100
m.height = 40
m.altScreen = true
base := plainRender(t, m.View()) // no overlay: baseline chrome
m.helpOverlay = true
over := plainRender(t, m.View())
if !strings.Contains(over, "Keyboard Shortcuts") {
t.Fatalf("help overlay not rendered:\n%s", over)
}
// Chrome that renders in the baseline (and sits outside the centered overlay
// box) must survive behind the overlay. The full-screen replace showed none.
for _, marker := range []string{"gpt-4o", "describe a task"} {
if !strings.Contains(base, marker) {
t.Fatalf("precondition: baseline chat should contain %q:\n%s", marker, base)
}
if !strings.Contains(over, marker) {
t.Fatalf("#419: help replaced the chat instead of overlaying it; %q is gone:\n%s", marker, over)
}
}
}
// A populated transcript row also survives behind the overlay (peeking out to
// the left of the centered box), proving the chat body — not just the chrome —
// is composited under the overlay rather than discarded.
func TestHelpOverlayKeepsTranscriptBodyBehindIt(t *testing.T) {
m := newModel(context.Background(), Options{ModelName: "gpt-4o"})
m.width = 120
m.height = 40
m.altScreen = true
m.transcript = appendTranscriptRow(m.transcript, transcriptRow{kind: rowUser, text: "hello there this is a chat line"})
m.helpOverlay = true
view := plainRender(t, m.View())
if !strings.Contains(view, "Keyboard Shortcuts") {
t.Fatalf("help overlay not rendered:\n%s", view)
}
// The start of the transcript line peeks to the left of the centered box.
if !strings.Contains(view, "hello") {
t.Fatalf("#419: transcript body was replaced by the help overlay:\n%s", view)
}
}
+343 -112
View File
@@ -25,6 +25,7 @@ import (
"github.com/Gitlawb/zero/internal/notify"
"github.com/Gitlawb/zero/internal/providerhealth"
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
"github.com/Gitlawb/zero/internal/providers/providerio"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/sessions"
"github.com/Gitlawb/zero/internal/streamjson"
@@ -40,6 +41,15 @@ const chatWheelScrollLines = 5
const ctrlCExitConfirmDuration = 3 * time.Second
const ctrlCExitConfirmText = "Press Ctrl+C again to exit"
// escCancelConfirmDuration/escCancelConfirmText guard Esc cancelling a running
// turn, mirroring ctrlCExitConfirmDuration/ctrlCExitConfirmText's pattern (same
// window, same amber footer treatment) so the two "stop it" keybinds feel
// consistent. Without this, a single stray Esc — e.g. meant to dismiss a
// suggestion overlay that had already closed — silently threw away an
// in-progress run with no chance to reconsider.
const escCancelConfirmDuration = 3 * time.Second
const escCancelConfirmText = "Press Esc again to cancel"
// dragEdgeScrollInterval/dragEdgeScrollStep drive the smooth-glide auto-scroll
// while a drag holds past the transcript edge (see edgeScrollDelta). A small step
// on a short, steady cadence reads as a smooth continuous scroll; the wheel-scroll
@@ -48,40 +58,41 @@ const dragEdgeScrollInterval = 70 * time.Millisecond
const dragEdgeScrollStep = 1
type model struct {
ctx context.Context
cwd string
userCommands []usercommands.Command // file-sourced /commands (.zero/commands)
userConfigPath string
doctorUserConfigPath string
projectConfigPath string
gitBranch string
providerName string
modelName string
providerProfile config.ProviderProfile
savedProviders []config.ProviderProfile
provider zeroruntime.Provider
newProvider func(config.ProviderProfile) (zeroruntime.Provider, error)
probeProviderHealth func(context.Context, providerhealth.Options) providerhealth.Result
discoverProviderModels func(context.Context, config.ProviderProfile) ([]providermodeldiscovery.Model, error)
registry *tools.Registry
sessionStore *sessions.Store
sandboxStore *sandbox.GrantStore
mcpConfig config.MCPConfig
mcpPermissionStore *internalmcp.PermissionStore
mcpTokenStore *internalmcp.TokenStore
mcpCommand func(context.Context, []string) MCPCommandResult
sandboxSetupCommand func(context.Context) SandboxSetupCommandResult
mcpViewStateCache MCPViewState
mcpViewStateReady bool
mcpCommandSeq int
mcpCommandCancel context.CancelFunc
sandboxSetupSeq int
sandboxSetupInFlight bool
doctorCommandSeq int
doctorInFlight bool
doctorFrame int
activeSession sessions.Metadata
sessionEvents []sessions.Event
ctx context.Context
cwd string
userCommands []usercommands.Command // file-sourced /commands (.zero/commands)
userConfigPath string
doctorUserConfigPath string
projectConfigPath string
gitBranch string
providerName string
modelName string
providerProfile config.ProviderProfile
savedProviders []config.ProviderProfile
provider zeroruntime.Provider
newProvider func(config.ProviderProfile) (zeroruntime.Provider, error)
probeProviderHealth func(context.Context, providerhealth.Options) providerhealth.Result
discoverProviderModels func(context.Context, config.ProviderProfile) ([]providermodeldiscovery.Model, error)
discoverOllamaContextWindow func(ctx context.Context, baseURL string, model string) (int, error)
registry *tools.Registry
sessionStore *sessions.Store
sandboxStore *sandbox.GrantStore
mcpConfig config.MCPConfig
mcpPermissionStore *internalmcp.PermissionStore
mcpTokenStore *internalmcp.TokenStore
mcpCommand func(context.Context, []string) MCPCommandResult
sandboxSetupCommand func(context.Context) SandboxSetupCommandResult
mcpViewStateCache MCPViewState
mcpViewStateReady bool
mcpCommandSeq int
mcpCommandCancel context.CancelFunc
sandboxSetupSeq int
sandboxSetupInFlight bool
doctorCommandSeq int
doctorInFlight bool
doctorFrame int
activeSession sessions.Metadata
sessionEvents []sessions.Event
// titledSessions records session ids for which a model-generated title has
// already been attempted this process, so a finished turn re-fires the title
// generator at most once per session (even before its async result lands).
@@ -200,6 +211,22 @@ type model struct {
// sidebar; when set, the chat reflows to full width. Distinct from the
// availability conditions in sidebarAvailable (geometry / mode / overlays).
sidebarHidden bool
// selectedFile is the touched file selected by clicking its FILES sidebar
// row: its edit cards tint in the chat (rowTouchesSelectedFile) and a second
// click opens the drill-in file view. "" when nothing is selected; Esc clears.
selectedFile string
// fileView is the drill-in view for a touched file (file_view.go): while
// active the chat column's body shows the file's diff/content instead of the
// transcript, mirroring the subchat drill-in.
fileView fileViewState
// Git-sweep state (files_git_sweep.go): the startup snapshot of already-dirty
// paths (nil until Init's sweep answers), the newly dirty files discovered by
// live sweeps (bash/subagent mutations that carry no changedFiles), the
// single-flight guard, and the "not a git repo / no git" latch.
gitFileBaseline map[string]bool
gitTouched []gitSweepFile
gitSweepInFlight bool
gitSweepUnavailable bool
// swarmDoneAt records when each swarm member was first seen finished (done/
// failed) in a swarm_status report, so the sidebar can linger it briefly with a
// fading ✓ before dropping it (a smooth exit, not an abrupt pop). Stamped in the
@@ -296,6 +323,14 @@ type model struct {
copyStatusSeq int
exitConfirmActive bool
exitConfirmSeq int
// cancelConfirmActive/cancelConfirmSeq mirror exitConfirmActive/exitConfirmSeq
// (same seq-gated tea.Tick pattern) but guard a DIFFERENT action: Esc
// cancelling a running turn. The two are deliberately separate state (not a
// shared flag) since they're different actions with different consequences
// (quit the app vs. cancel the current run) that are armed by different
// keys — Ctrl+C and Esc respectively.
cancelConfirmActive bool
cancelConfirmSeq int
// edgeScrollDelta drives the smooth-glide auto-scroll while a drag holds past
// the transcript's top/bottom edge: 0 when idle, else the signed per-tick step
// (matches transcriptEdgeScrollDelta's sign convention). A self-scheduling
@@ -330,6 +365,12 @@ type model struct {
// catalog descriptor ID), so /model shows each provider's real current models —
// the same list the provider-setup wizard discovers — not the static catalog.
modelPickerLiveByProvider map[string][]providermodeldiscovery.Model
// ollamaContextWindowByModel holds context-window sizes fetched from a local
// Ollama daemon's native /api/show endpoint (keyed by model name), for
// custom/local models that have no curated-catalog entry and whose
// OpenAI-compatible /v1/models listing doesn't carry that metadata at all —
// see modelContextWindow.
ollamaContextWindowByModel map[string]int
// pendingImages holds image attachments staged by /image for the next user
// turn; pendingImageLabels are their display names (base(path)) for the chip
@@ -358,6 +399,12 @@ type exitConfirmExpiredMsg struct {
seq int
}
// cancelConfirmExpiredMsg mirrors exitConfirmExpiredMsg for the Esc
// cancel-a-run confirmation (see cancelConfirmActive).
type cancelConfirmExpiredMsg struct {
seq int
}
// dragEdgeScrollTickMsg advances the smooth-glide auto-scroll one step (see
// edgeScrollDelta). seq must match m.edgeScrollSeq or the tick is stale (the
// drag moved back into the body, released, or was otherwise stopped since this
@@ -588,7 +635,7 @@ func newModel(ctx context.Context, options Options) model {
}
}
userConfigDir, _ := os.UserConfigDir()
userConfigDir, _ := config.UserConfigDir()
loadedUserCommands := usercommands.Load(usercommands.DefaultPaths(cwd, userConfigDir))
registry := options.Registry
@@ -645,56 +692,57 @@ func newModel(ctx context.Context, options Options) model {
notifier.SetFocused(true)
m := model{
ctx: ctx,
cwd: cwd,
swarmDoneAt: map[string]time.Time{},
userCommands: loadedUserCommands,
composerCursorVisible: true,
userConfigPath: options.UserConfigPath,
doctorUserConfigPath: doctorUserConfigPath,
projectConfigPath: options.ProjectConfigPath,
savedProviders: options.SavedProviders,
gitBranch: gitBranch(cwd),
providerName: options.ProviderName,
modelName: options.ModelName,
providerProfile: options.ProviderProfile,
favoriteModels: favoriteModelSet(options.FavoriteModels),
recapsEnabled: options.RecapsEnabled,
provider: options.Provider,
newProvider: options.NewProvider,
probeProviderHealth: options.ProbeProviderHealth,
discoverProviderModels: options.DiscoverProviderModels,
registry: registry,
sessionStore: sessionStore,
sandboxStore: sandboxStore,
mcpConfig: options.MCPConfig,
mcpPermissionStore: options.MCPPermissionStore,
mcpTokenStore: options.MCPTokenStore,
mcpCommand: options.MCPCommand,
sandboxSetupCommand: options.SandboxSetupCommand,
agentOptions: options.AgentOptions,
sessionCompactor: options.SessionCompactor,
runtimeMessageSink: options.RuntimeMessageSink,
permissionMode: permissionMode,
reasoningEffort: options.ReasoningEffort,
responseStyle: defaultedResponseStyle(options.ResponseStyle),
themeMode: resolveThemeMode(options.Theme, os.Getenv("ZERO_THEME"), options.SavedTheme),
hasDarkBg: true,
userAgent: options.UserAgent,
usageTracker: usageTracker,
transcript: initialTranscript(),
transcriptBodyHeights: newTranscriptBodyHeightCache(defaultTranscriptBodyHeightCacheMaxEntries),
prService: prService,
prState: prService.GetState(),
input: input,
spinner: runSpinner,
now: time.Now,
notifier: notifier,
altScreen: options.AltScreen,
liveUsageCounts: map[int]int{},
swarmSessionMap: map[string]string{},
setup: newSetupState(options.Setup),
setupSave: options.Setup.Save,
ctx: ctx,
cwd: cwd,
swarmDoneAt: map[string]time.Time{},
userCommands: loadedUserCommands,
composerCursorVisible: true,
userConfigPath: options.UserConfigPath,
doctorUserConfigPath: doctorUserConfigPath,
projectConfigPath: options.ProjectConfigPath,
savedProviders: options.SavedProviders,
gitBranch: gitBranch(cwd),
providerName: options.ProviderName,
modelName: options.ModelName,
providerProfile: options.ProviderProfile,
favoriteModels: favoriteModelSet(options.FavoriteModels),
recapsEnabled: options.RecapsEnabled,
provider: options.Provider,
newProvider: options.NewProvider,
probeProviderHealth: options.ProbeProviderHealth,
discoverProviderModels: options.DiscoverProviderModels,
discoverOllamaContextWindow: options.DiscoverOllamaContextWindow,
registry: registry,
sessionStore: sessionStore,
sandboxStore: sandboxStore,
mcpConfig: options.MCPConfig,
mcpPermissionStore: options.MCPPermissionStore,
mcpTokenStore: options.MCPTokenStore,
mcpCommand: options.MCPCommand,
sandboxSetupCommand: options.SandboxSetupCommand,
agentOptions: options.AgentOptions,
sessionCompactor: options.SessionCompactor,
runtimeMessageSink: options.RuntimeMessageSink,
permissionMode: permissionMode,
reasoningEffort: options.ReasoningEffort,
responseStyle: defaultedResponseStyle(options.ResponseStyle),
themeMode: resolveThemeMode(options.Theme, os.Getenv("ZERO_THEME"), options.SavedTheme),
hasDarkBg: true,
userAgent: options.UserAgent,
usageTracker: usageTracker,
transcript: initialTranscript(),
transcriptBodyHeights: newTranscriptBodyHeightCache(defaultTranscriptBodyHeightCacheMaxEntries),
prService: prService,
prState: prService.GetState(),
input: input,
spinner: runSpinner,
now: time.Now,
notifier: notifier,
altScreen: options.AltScreen,
liveUsageCounts: map[int]int{},
swarmSessionMap: map[string]string{},
setup: newSetupState(options.Setup),
setupSave: options.Setup.Save,
}
// Apply an explicit theme immediately; auto stays on the dark default until
// Init's terminal background probe resolves it (see Init / BackgroundColorMsg).
@@ -759,6 +807,24 @@ func composerBlinkCmd() tea.Cmd {
func (m model) Init() tea.Cmd {
cmds := []tea.Cmd{textinput.Blink, composerBlinkCmd()}
// Bubble Tea documents an initial WindowSizeMsg as delivered automatically
// on program start, so m.height/m.width are normally set before the first
// render. But that's the terminal proactively pushing a size — if it's
// ever missed (a slow/unusual terminal, a multiplexer, a startup race),
// nothing else ever asks again: m.height stays its zero value forever,
// `if m.altScreen && m.height > 0` (transcriptView) falls back to the
// unpadded, non-fullscreen render path for the rest of the session, and
// the alt-screen viewport never gets filled below the actual content.
// Explicitly requesting it here means Zero doesn't depend solely on the
// terminal's unprompted push — mirrors the RequestBackgroundColor request
// below for the same reason.
cmds = append(cmds, tea.RequestWindowSize)
// Baseline git snapshot for the FILES sidebar sweep: whatever is already
// dirty when the TUI opens is pre-existing state, not this session's work
// (files_git_sweep.go). Async; a non-git workspace just disables the sweep.
if strings.TrimSpace(m.cwd) != "" {
cmds = append(cmds, gitSweepCmd(m.ctx, m.cwd, true))
}
// In auto mode, ask the terminal for its background color; the reply arrives
// as tea.BackgroundColorMsg and selects light vs dark (see updateModel).
if m.themeMode == themeAuto {
@@ -773,6 +839,13 @@ func (m model) Init() tea.Cmd {
if cmd := m.modelPickerProviderDiscoveryCmd(descriptor, m.providerProfile); cmd != nil {
cmds = append(cmds, cmd)
}
// The generic discovery above has no source for a local Ollama model's
// context window (see ollamaContextWindowDiscoveryCmd); probe its
// native /api/show separately so the gauge works for custom/local
// Ollama models too, not just ones in the curated catalog.
if cmd := m.ollamaContextWindowDiscoveryCmd(descriptor, m.providerProfile.BaseURL, m.modelName); cmd != nil {
cmds = append(cmds, cmd)
}
}
if m.prService != nil && m.runtimeMessageSink != nil {
service := m.prService
@@ -849,6 +922,14 @@ func (m model) disarmExitConfirmation() model {
return m
}
func (m model) disarmCancelConfirmation() model {
if m.cancelConfirmActive {
m.cancelConfirmActive = false
m.cancelConfirmSeq++
}
return m
}
// Update routes every message through updateModel, then advances the flush
// frontier for inline rendering. Alt-screen runs keep rows in the managed view
// instead of printing into terminal scrollback (see flush.go).
@@ -927,6 +1008,11 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
m.exitConfirmActive = false
}
return m, nil
case cancelConfirmExpiredMsg:
if msg.seq == m.cancelConfirmSeq {
m.cancelConfirmActive = false
}
return m, nil
case dragEdgeScrollTickMsg:
if msg.seq != m.edgeScrollSeq || m.edgeScrollDelta == 0 || !m.transcriptSelection.active {
return m, nil // stale, or the chain was stopped since this tick was scheduled
@@ -974,6 +1060,13 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
if !keyCtrl(msg, 'c') {
m = m.disarmExitConfirmation()
}
// Mirrors the exit-confirmation reset above: any key that isn't itself
// the confirming Esc means the user moved on to something else, so a
// later, unrelated Esc must arm a fresh confirmation rather than
// silently cancel off a stale press from seconds ago.
if !keyIs(msg, tea.KeyEsc) {
m = m.disarmCancelConfirmation()
}
// The `?` help overlay is modal: `?`, Esc, q, or Enter close it; every
// other key is swallowed so nothing types into the hidden composer.
if m.helpOverlay {
@@ -987,6 +1080,14 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
return m.handleCtrlC()
case keyCtrl(msg, 'o'):
return m.toggleDetailedTranscript(), nil
case m.fileView.active && m.noBlockingModal() && m.composerValue() == "" && (keyText(msg) == "d" || keyText(msg) == "f"):
// Mode toggle for the file drill-in, only while the composer is empty
// (so mid-sentence typing is never hijacked) and no modal is up (so a
// permission prompt / ask-user / wizard keeps its own key handling).
if keyText(msg) == "f" {
return m.setFileViewMode(fileViewFull), nil
}
return m.setFileViewMode(fileViewDiff), nil
case keyCtrl(msg, 'e'):
// Release/recapture the mouse so the user can drag-select and copy text
// natively (mouse capture otherwise intercepts terminal selection).
@@ -996,12 +1097,29 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
}
return m.appendSystemNotice("Mouse interaction re-enabled."), nil
case keyIs(msg, tea.KeyEsc):
// Esc is heavily overloaded below (subchat exit, MCP cancel, ask-user,
// permission deny, wizard/picker/suggestions dismiss, ...) before ever
// reaching the run-cancel fallback. Capture whether this press really
// is the confirming second Esc BEFORE any of those branches can fire,
// then disarm unconditionally: an Esc that gets consumed by one of
// them wasn't a confirm, so it must not leave cancelConfirmActive
// armed for some later, unrelated Esc to silently act on.
wasConfirmingCancel := m.pending && m.cancelConfirmActive
m = m.disarmCancelConfirmation()
// Subchat view exits on Esc (returns to main chat).
if m.subchat.active {
m.chatScrollOffset = m.subchat.exit()
m = m.clearHover() // bodyY numbering differs between subchat and the parent transcript
return m, nil
}
// File drill-in exits on Esc (returns to the chat at its saved scroll
// position); the file stays selected so a second Esc clears that. Only
// with no blocking modal up: Esc on a permission prompt / ask-user /
// wizard must reach THAT surface's deny/cancel handling below, not
// silently close the drill-in behind it.
if m.fileView.active && m.noBlockingModal() {
return m.exitFileView(), nil
}
if m.mcpCommandCancel != nil {
m.cancelMCPCommand()
if m.mcpAddWizard != nil {
@@ -1058,14 +1176,34 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.suggestionsActive() {
return m.dismissSuggestions(), nil
}
// A selected FILES row clears before anything run-related: the
// selection is a passive highlight, so Esc dropping it is cheap and
// expected (mirrors how editors clear selection on Esc).
if m.selectedFile != "" {
m.selectedFile = ""
return m, nil
}
if m.hasQueuedMessage() {
return m.clearQueuedMessage(), nil
}
m.clearComposer()
m.clearSuggestions()
if m.pending {
m.cancelRun()
if wasConfirmingCancel {
m.clearComposer()
m.cancelRun()
return m, nil
}
// First Esc only arms the confirmation — preserve whatever
// draft the user has typed, since nothing has actually been
// cancelled yet and they may not press Esc again.
m.cancelConfirmActive = true
m.cancelConfirmSeq++
seq := m.cancelConfirmSeq
return m, tea.Tick(escCancelConfirmDuration, func(time.Time) tea.Msg {
return cancelConfirmExpiredMsg{seq: seq}
})
}
m.clearComposer()
return m, nil
case keyIs(msg, tea.KeyEnter):
if m.transcriptDetailed {
@@ -1531,17 +1669,38 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
// when the loop is already running or there is nothing to animate.
return m, m.ensureSpinnerTick()
case permissionRequestMsg:
// The agent goroutine that raised this request is BLOCKED waiting on the
// decision callback, so every branch below must resolve it exactly once —
// or store it in pendingPermission, which resolves it on the user's reply.
// Dropping a request without resolving parks the run forever (the reported
// "stuck for 33 minutes" deadlock): the agent waits on a decision channel
// nothing will ever signal, with no visible prompt and no network activity.
if msg.runID != m.activeRunID {
// A superseded/stale run: unblock its parked goroutine now rather than
// relying on that run's context cancel to fire first.
if msg.decide != nil {
msg.decide(agent.PermissionDecision{Action: agent.PermissionDecisionCancel, Reason: "run superseded"})
}
return m, nil
}
if msg.request.Action != agent.PermissionActionPrompt {
// Not a user-facing prompt (e.g. a sandbox-allowed command that still
// blocked because it requested additional permissions). The UI has
// nothing to ask, so resolve immediately and FAIL CLOSED — never
// silently grant access that was never surfaced to the user. (The agent
// now marks such elevation requests as prompts, so in practice this is a
// defensive backstop; matches the ACP handler's fail-closed contract.)
if msg.decide != nil {
msg.decide(autoResolvedPermissionDecision(msg.request.Action))
}
return m, nil
}
promptRow := permissionTranscriptRow(permissionEventFromRequest(msg.request))
promptRow.runID = msg.runID
m.transcript = appendTranscriptRow(m.transcript, promptRow)
if msg.request.Action == agent.PermissionActionPrompt {
m.pendingPermission = &pendingPermissionPrompt{
request: msg.request,
decide: msg.decide,
}
m.pendingPermission = &pendingPermissionPrompt{
request: msg.request,
decide: msg.decide,
}
return m, nil
case askUserRequestMsg:
@@ -1621,6 +1780,7 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
}
m.clearStreamingToolCall() // active run finished — drop any lingering "writing" block
m.pending = false
m = m.disarmCancelConfirmation() // the run finished on its own — nothing left to confirm cancelling
// Fully reset the fade state at stream end. The next render
// emits the final row in solid ink (no settling animation), and
// the pending streamingFadeTickMsg that lands after this point
@@ -1718,8 +1878,13 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
}
m, recapCmd = m.maybeRecapTurn(msg.runID, finalAnswer)
}
// End-of-turn git sweep: catch file mutations the tool stream couldn't
// report (bash scaffolding, subagent edits) so the FILES sidebar is
// complete once the turn settles.
var sweepCmd tea.Cmd
m, sweepCmd = m.maybeGitSweep()
next, queuedCmd := m.launchQueuedMessageIfReady()
return next, tea.Batch(titleCmd, recapCmd, queuedCmd)
return next, tea.Batch(titleCmd, recapCmd, sweepCmd, queuedCmd)
case sessionTitleGeneratedMsg:
return m.handleSessionTitleGenerated(msg)
case recapGeneratedMsg:
@@ -1870,6 +2035,14 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
m.transcript = collapseRepeatedStatusCard(m.transcript, msg.row)
m.transcript = appendTranscriptRow(m.transcript, msg.row)
m = m.captureStepWork(msg.row)
// A finished command tool may have mutated files git can see but no
// changedFiles reports (npm create, heredoc writes, subagent edits) —
// re-sweep so the FILES sidebar picks them up mid-turn.
if msg.row.kind == rowToolResult && isPlanCommandTool(msg.row.tool) {
var sweep tea.Cmd
m, sweep = m.maybeGitSweep()
return m, sweep
}
return m, nil
case swarmSessionsMsg:
// Merge completed swarm members' session ids so their AGENTS sidebar rows
@@ -1900,6 +2073,8 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
case prStateMsg:
m.prState = msg.state
return m, nil
case gitSweepMsg:
return m.handleGitSweepMsg(msg), nil
case prWatcherStartedMsg:
if msg.stop == nil {
return m, nil
@@ -1922,6 +2097,14 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
return m.applySetupOAuthDeviceCode(msg)
case modelPickerModelsDiscoveredMsg:
return m.applyModelPickerModelsDiscovered(msg), nil
case ollamaContextWindowDiscoveredMsg:
if msg.err == nil && msg.contextWindow > 0 {
if m.ollamaContextWindowByModel == nil {
m.ollamaContextWindowByModel = map[string]int{}
}
m.ollamaContextWindowByModel[msg.modelName] = msg.contextWindow
}
return m, nil
case mcpCommandResultMsg:
return m.applyMCPCommandResultMessage(msg), nil
}
@@ -1935,12 +2118,13 @@ func (m model) View() tea.View {
var content string
if m.setup.visible {
content = m.setupView(chatWidth(m.width))
} else if m.helpOverlay {
content = m.renderKeybindingHelpOverlay(chatWidth(m.width), m.height)
} else if m.transcriptDetailed {
content = m.detailedTranscriptView()
} else {
} else if m.helpOverlay || !m.transcriptDetailed {
// When helpOverlay is active the help panel is composited into the normal
// transcript view as a true overlay (scrim + vertical centering), matching
// how the suggestion picker / provider wizard / pickers are drawn.
content = m.transcriptView()
} else {
content = m.detailedTranscriptView()
}
view := tea.NewView(content)
@@ -2011,6 +2195,11 @@ func (m model) transcriptView() string {
return body + footer
}
helpOverlayContent := ""
if m.helpOverlay {
helpOverlayContent = m.renderKeybindingHelpOverlay(width)
}
suggestionOverlay := m.suggestionOverlay(width)
providerOverlay := m.providerWizardOverlay(width)
mcpAddOverlay := m.mcpAddWizardOverlay(width)
@@ -2018,6 +2207,8 @@ func (m model) transcriptView() string {
pickerOverlay := m.pickerOverlay(width)
viewportOverlay := ""
switch {
case helpOverlayContent != "":
viewportOverlay = helpOverlayContent
case providerOverlay != "":
viewportOverlay = providerOverlay
case mcpAddOverlay != "":
@@ -2096,6 +2287,12 @@ func (m model) pinnedTitleBar(width int) string {
if !m.altScreen || m.height <= 0 {
return ""
}
// The file drill-in replaces the title bar with its nav line (path + key
// hints). Both are exactly one line, and every frame computation routes
// through here, so the swap never desyncs the viewport geometry.
if m.fileView.active {
return m.fileViewNavBar(width)
}
return m.titleBar(width)
}
@@ -2133,15 +2330,18 @@ func (m model) footerView(width int) string {
footer.WriteString(fitStyledLine(joinHeaderLine(" "+left, right, width), width))
}
footer.WriteString("\n")
// A message typed while a run is active is queued for the next turn; show its
// preview directly ABOVE the input box (not below), so it reads as "waiting to
// send" sitting on top of what you're currently typing.
if queued := renderQueuedMessagePreview(m.queuedMessage, width); queued != "" {
footer.WriteString(queued)
footer.WriteString("\n")
}
footer.WriteString(m.composerBox(width))
if hint := m.composerDescriptionHint(width); hint != "" {
footer.WriteString("\n")
footer.WriteString(hint)
}
if queued := renderQueuedMessagePreview(m.queuedMessage, width); queued != "" {
footer.WriteString("\n")
footer.WriteString(queued)
}
footer.WriteString("\n")
footer.WriteString(m.statusLine(width))
return footer.String()
@@ -2717,6 +2917,15 @@ const quietWorkingHint = 8 * time.Second
// quietGenerationHint returns a "still generating…" cue with an advancing
// quiet-timer when the active turn has produced no streamed output for a while,
// else "". The advancing number is itself the liveness signal.
//
// Past half the provider's idle timeout, the cue escalates to name what's
// actually happening and when Zero will act on its own: a heartbeating-but-
// silent stream (observed on chatgpt/gpt-5.x and ollama reasoning models,
// see providerio.ErrStreamStalled) is bounded by the content-stall watchdog at
// providerio.ContentStallTimeout(idle), but until it fires this exact same
// plain "still generating… Xs" text is indistinguishable from a genuine hang —
// the ticking number was the only signal, and it looks identical whether real
// (if slow) content is coming or nothing ever will.
func (m model) quietGenerationHint() string {
if m.activeRunID == 0 {
return ""
@@ -2732,6 +2941,10 @@ func (m model) quietGenerationHint() string {
if quiet < quietWorkingHint {
return ""
}
if idleTimeout := providerio.ResolveStreamIdleTimeout(0); idleTimeout > 0 && quiet >= idleTimeout/2 {
ceiling := providerio.ContentStallTimeout(idleTimeout)
return fmt.Sprintf("still generating… %s — unusually quiet, Zero will auto-recover by ~%s if it doesn't resume", formatWorkingElapsed(quiet), formatWorkingElapsed(ceiling))
}
return "still generating… " + formatWorkingElapsed(quiet)
}
@@ -3294,12 +3507,13 @@ func (m model) choosePicker() (tea.Model, tea.Cmd) {
}
return m, nil
}
var cmd tea.Cmd
switch picker.kind {
case pickerModel:
text := ""
if owner := strings.TrimSpace(item.OwnerProvider); owner != "" && !strings.EqualFold(owner, strings.TrimSpace(m.providerName)) {
// A model from another saved provider: switch provider + model together.
m, text = m.switchProviderModel(owner, item.Value)
m, text, cmd = m.switchProviderModel(owner, item.Value)
} else {
m, text = m.handleModelCommand(item.Value)
}
@@ -3329,7 +3543,7 @@ func (m model) choosePicker() (tea.Model, tea.Cmd) {
return m, tea.RequestBackgroundColor
}
}
return m, nil
return m, cmd
}
func (m model) chooseSuggestion() (tea.Model, tea.Cmd) {
@@ -3853,7 +4067,8 @@ func (m *model) cancelRun() {
m.pending = false
m.runCancel = nil
m.activeRunID = 0
m.plan.frozenAt = m.now() // freeze the plan clock while idle (no run in flight)
m.cancelConfirmActive = false // whatever path got here, there's nothing left to confirm cancelling
m.plan.frozenAt = m.now() // freeze the plan clock while idle (no run in flight)
m.pendingPermission = nil
m.pendingAskUser = nil
// The interim block renders streamingText live; a cancelled run's partial
@@ -4178,13 +4393,14 @@ func (m model) runAgentWithOptions(runID int, runCtx context.Context, prompt str
}
}
row := transcriptRow{
kind: rowToolResult,
id: effectiveToolRowID(result.ToolCallID, callSeq[result.ToolCallID]),
text: toolResultRowText(result),
tool: result.Name,
status: result.Status,
detail: toolResultDetail(result),
runID: runID,
kind: rowToolResult,
id: effectiveToolRowID(result.ToolCallID, callSeq[result.ToolCallID]),
text: toolResultRowText(result),
tool: result.Name,
status: result.Status,
detail: toolResultDetail(result),
runID: runID,
changedFiles: result.ChangedFiles,
}
// A Task result is shown by the specialist card, and update_plan by the
// plan panel/sidebar, so skip both redundant transcript rows.
@@ -4338,6 +4554,21 @@ func (m model) sendPermissionRequest(runID int, request agent.PermissionRequest,
m.runtimeMessageSink(permissionRequestMsg{runID: runID, request: request, decide: decide})
}
// autoResolvedPermissionDecision resolves a permission request the TUI cannot
// turn into a user prompt (Action != prompt). The agent is blocked awaiting a
// decision, so one must ALWAYS be produced. Only an explicit Cancel is honored
// as such; every other non-prompt action — including allow — is DENIED, so the
// UI never silently grants access it did not surface for approval.
func autoResolvedPermissionDecision(action agent.PermissionAction) agent.PermissionDecision {
if action == agent.PermissionActionCancel {
return agent.PermissionDecision{Action: agent.PermissionDecisionCancel, Reason: "run cancelled"}
}
return agent.PermissionDecision{
Action: agent.PermissionDecisionDeny,
Reason: "permission request could not be surfaced for approval",
}
}
func (m model) sendAskUserRequest(runID int, request agent.AskUserRequest, answer func([]string)) {
if m.runtimeMessageSink == nil {
return
+7
View File
@@ -84,6 +84,13 @@ func (m model) modelContextWindow(modelName string) int {
return window
}
}
// A custom/local Ollama model tag has no curated-catalog entry, and its
// OpenAI-compatible /v1/models listing (the discoveredContextWindow source
// above) doesn't carry context-window metadata at all — see
// ollamaContextWindowDiscoveryCmd, the only other source for this.
if window := m.ollamaContextWindowByModel[trimmed]; window > 0 {
return window
}
// Unknown: report 0 so display gauges show no denominator. Compaction enablement
// applies its own positive fallback via modelregistry.AgentContextWindow.
return 0
+57
View File
@@ -0,0 +1,57 @@
package tui
import "testing"
// The model picker must title each row with the model NAME, not the models.dev
// marketing description. Regression: Ollama Cloud models resolved full-sentence
// descriptions from the remote catalog, so rows showed sentences and several
// models sharing one description looked like exact duplicates (issue: /model
// list rendered descriptions as titles).
func TestModelPickerDisplayNamePrefersModelName(t *testing.T) {
sentence := "DeepSeek chat model for instruction following, coding, and analysis"
cases := []struct {
id string
description string
want string
}{
{"deepseek-v3.2", sentence, "Deepseek V3.2"},
{"deepseek-v3.2-thinking", sentence, "Deepseek V3.2 Thinking"},
{"glm-5.2", "GLM flagship model", "GLM 5.2"},
{"qwen3-coder:480b", "Qwen coding agent model for repository tasks", "Qwen3 Coder 480b"},
{"anthropic/claude-sonnet-4.6", "some blurb", "Claude Sonnet 4.6"},
}
for _, tc := range cases {
if got := modelPickerDisplayName(tc.id, tc.description); got != tc.want {
t.Errorf("modelPickerDisplayName(%q, %q) = %q, want %q", tc.id, tc.description, got, tc.want)
}
}
}
// Two distinct model ids that share the same catalog description must render as
// distinct rows (the "duplicate-looking rows" symptom), never as the shared
// sentence.
func TestModelPickerDisplayNameDistinctForSharedDescription(t *testing.T) {
shared := "Mistral coding agent model for repository tasks and software engineering"
a := modelPickerDisplayName("codestral-2", shared)
b := modelPickerDisplayName("devstral-2", shared)
if a == b {
t.Fatalf("shared description collapsed rows to the same title: %q", a)
}
if a == shared || b == shared {
t.Fatalf("row title used the description sentence: a=%q b=%q", a, b)
}
}
// With no id to name the row, a non-generic description is still an acceptable
// fallback; a generic placeholder (or nothing) falls back to "model".
func TestModelPickerDisplayNameFallsBackToDescriptionOnlyWithoutID(t *testing.T) {
if got := modelPickerDisplayName("", "Friendly Name"); got != "Friendly Name" {
t.Fatalf("empty id should fall back to the description, got %q", got)
}
if got := modelPickerDisplayName("", "catalog default"); got != "model" {
t.Fatalf("generic description with no id should yield %q, got %q", "model", got)
}
if got := modelPickerDisplayName("", ""); got != "model" {
t.Fatalf("empty id and description should yield %q, got %q", "model", got)
}
}
+203 -7
View File
@@ -1108,7 +1108,87 @@ func TestPromptSubmitDoesNotStartAnotherRunWhilePending(t *testing.T) {
}
}
func TestEscCancelsPendingRun(t *testing.T) {
func TestEscRequiresSecondPressToCancelPendingRun(t *testing.T) {
m := newModel(context.Background(), Options{})
cancelled := false
m.pending = true
m.activeRunID = 1
m.runCancel = func() { cancelled = true }
updated, cmd := m.Update(testKey(tea.KeyEsc))
next := updated.(model)
if cancelled {
t.Fatal("first Esc should not cancel the pending run")
}
if !next.pending {
t.Fatal("first Esc should leave the run pending")
}
if !next.cancelConfirmActive {
t.Fatal("first Esc should arm cancel confirmation")
}
if cmd == nil {
t.Fatal("first Esc should schedule confirmation expiry")
}
status := plainRender(t, next.statusLine(80))
if !strings.Contains(status, escCancelConfirmText) {
t.Fatalf("status line = %q, want cancel confirmation", status)
}
updated, _ = next.Update(testKey(tea.KeyEsc))
next = updated.(model)
if !cancelled {
t.Fatal("second Esc should cancel pending run")
}
if next.pending {
t.Fatal("expected second Esc to clear pending state")
}
if next.activeRunID != 0 || next.runCancel != nil {
t.Fatalf("expected active run state to clear, got id=%d cancel=%v", next.activeRunID, next.runCancel)
}
if next.cancelConfirmActive {
t.Fatal("cancelling should clear cancel confirmation")
}
}
// TestEscArmingCancelConfirmationPreservesComposerDraft: the first Esc only
// arms the confirmation — nothing has actually been cancelled yet, so it
// must not destroy a draft the user is still typing. Only the confirming
// second Esc (the one that actually cancels the run) clears the composer.
func TestEscArmingCancelConfirmationPreservesComposerDraft(t *testing.T) {
m := newModel(context.Background(), Options{})
m.pending = true
m.activeRunID = 1
m.runCancel = func() {}
m.input.SetValue("draft prompt")
updated, _ := m.Update(testKey(tea.KeyEsc))
next := updated.(model)
if !next.cancelConfirmActive {
t.Fatal("first Esc should arm cancel confirmation")
}
if next.composerValue() != "draft prompt" {
t.Fatalf("first Esc should preserve the draft, got %q", next.composerValue())
}
updated, _ = next.Update(testKey(tea.KeyEsc))
next = updated.(model)
if next.pending {
t.Fatal("second Esc should cancel the pending run")
}
if next.composerValue() != "" {
t.Fatalf("the confirming second Esc should clear the draft, got %q", next.composerValue())
}
}
// TestPasteDisarmsCancelConfirmation: a paste isn't a keypress, so it never
// went through the generic "any non-Esc key disarms it" hook. Pasting is a
// deliberate action just like typing or clicking — it must disarm a stale
// confirmation too, or a later, unrelated Esc could silently cancel the run.
func TestPasteDisarmsCancelConfirmation(t *testing.T) {
m := newModel(context.Background(), Options{})
cancelled := false
m.pending = true
@@ -1117,15 +1197,131 @@ func TestEscCancelsPendingRun(t *testing.T) {
updated, _ := m.Update(testKey(tea.KeyEsc))
next := updated.(model)
if !next.cancelConfirmActive {
t.Fatal("first Esc should arm cancel confirmation")
}
if !cancelled {
t.Fatal("expected Esc to cancel pending run")
updated, _ = next.Update(testPaste("pasted text"))
next = updated.(model)
if next.cancelConfirmActive {
t.Fatal("a paste should disarm the stale cancel confirmation")
}
if next.pending {
t.Fatal("expected Esc to clear pending state")
updated, _ = next.Update(testKey(tea.KeyEsc))
next = updated.(model)
if cancelled {
t.Fatal("Esc after a paste should re-arm, not immediately cancel")
}
if next.activeRunID != 0 || next.runCancel != nil {
t.Fatalf("expected active run state to clear, got id=%d cancel=%v", next.activeRunID, next.runCancel)
if !next.cancelConfirmActive {
t.Fatal("Esc after a paste should arm a fresh confirmation")
}
}
func TestEscCancelConfirmationExpires(t *testing.T) {
m := newModel(context.Background(), Options{})
m.pending = true
m.activeRunID = 1
m.runCancel = func() {}
updated, _ := m.Update(testKey(tea.KeyEsc))
next := updated.(model)
seq := next.cancelConfirmSeq
updated, _ = next.Update(cancelConfirmExpiredMsg{seq: seq - 1})
next = updated.(model)
if !next.cancelConfirmActive {
t.Fatal("stale expiry should not clear active cancel confirmation")
}
updated, _ = next.Update(cancelConfirmExpiredMsg{seq: seq})
next = updated.(model)
if next.cancelConfirmActive {
t.Fatal("matching expiry should clear cancel confirmation")
}
if !next.pending {
t.Fatal("expiring the confirmation should not cancel the run")
}
}
// TestEscCancelConfirmationDisarmsOnInterveningKey mirrors
// TestCtrlCExitConfirmationDisarmsOnInterveningKey: an intervening key other
// than Esc (typing, Ctrl+O, etc.) means the user moved on to something else,
// so a later, unrelated Esc must arm a fresh confirmation instead of
// silently cancelling off a stale press from seconds ago.
func TestEscCancelConfirmationDisarmsOnInterveningKey(t *testing.T) {
m := newModel(context.Background(), Options{})
cancelled := false
m.pending = true
m.activeRunID = 1
m.runCancel = func() { cancelled = true }
updated, _ := m.Update(testKey(tea.KeyEsc))
next := updated.(model)
if !next.cancelConfirmActive {
t.Fatal("first Esc should arm cancel confirmation")
}
seq := next.cancelConfirmSeq
updated, _ = next.Update(testKey('h'))
next = updated.(model)
if next.cancelConfirmActive {
t.Fatal("an intervening non-Esc key should disarm cancel confirmation")
}
if next.cancelConfirmSeq == seq {
t.Fatal("disarming should advance the sequence so a stale expiry tick is ignored")
}
updated, _ = next.Update(testKey(tea.KeyEsc))
next = updated.(model)
if cancelled {
t.Fatal("Esc after an intervening key should re-arm, not cancel")
}
if !next.cancelConfirmActive {
t.Fatal("Esc after an intervening key should arm a fresh confirmation")
}
}
// TestEscCancelConfirmationDisarmsWhenStolenByAskUser: a mid-turn ask_user
// prompt lands between the two Esc presses. The user's second Esc denies the
// questionnaire (an earlier branch in the Esc handler), not a confirm, so it
// must not leave cancelConfirmActive armed for a later, unrelated Esc to
// silently cancel the run.
func TestEscCancelConfirmationDisarmsWhenStolenByAskUser(t *testing.T) {
m := newModel(context.Background(), Options{})
cancelled := false
m.pending = true
m.activeRunID = 1
m.runCancel = func() { cancelled = true }
updated, _ := m.Update(testKey(tea.KeyEsc))
next := updated.(model)
if !next.cancelConfirmActive {
t.Fatal("first Esc should arm cancel confirmation")
}
request := agent.AskUserRequest{Questions: []agent.AskUserQuestion{{Question: "name?"}}}
next.pendingAskUser = &pendingAskUserPrompt{
request: request,
answer: func([]string) {},
states: newAskUserStates(request.Questions),
}
updated, _ = next.Update(testKey(tea.KeyEsc))
next = updated.(model)
if cancelled {
t.Fatal("an Esc consumed by the ask-user prompt must not cancel the run")
}
if next.cancelConfirmActive {
t.Fatal("an Esc consumed by the ask-user prompt must disarm the stale cancel confirmation")
}
updated, _ = next.Update(testKey(tea.KeyEsc))
next = updated.(model)
if cancelled {
t.Fatal("the next Esc should arm a fresh confirmation, not immediately cancel")
}
if !next.cancelConfirmActive {
t.Fatal("the next Esc should arm a fresh confirmation")
}
}
+9
View File
@@ -40,6 +40,15 @@ func (m model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
if m.providerWizard != nil && m.providerWizard.oauthPending {
return m, nil
}
// A click is a deliberate action, same as a keypress — it means the user
// moved on to something else, so it disarms a stale Esc cancel-confirmation
// rather than leaving it armed for some later, unrelated Esc to act on.
// Scoped to actual clicks (not hover/motion/wheel), since motion events fire
// continuously while the mouse merely sits over the terminal and would
// otherwise defeat the confirmation window on every render tick.
if mouseLeftPress(msg) || mouseRightPress(msg) {
m = m.disarmCancelConfirmation()
}
// A right-click pastes the clipboard straight into the focused field — no
// menu. pasteFromClipboardCmd reads the clipboard off the Update goroutine; the
// clipboardReadMsg result is routed by routePaste to wherever input is focused
Loaded 100 of 122 files, more files were not shown because too many files have changed in this diff. Show more