Compare commits

..
Author SHA1 Message Date
Gnanam 5068a3840f fix(doctor): address review — rename shadowing var, align missing-binary message
- Rename the local `runtime` (was shadowing the stdlib `runtime` package used
  elsewhere in doctor.go) to `runtimeVersion`.
- Align the unresolvable-binary FAIL message's remediation with the npm wrapper
  (reinstall, or build from source: <repo url>) so both missing-binary paths
  give consistent guidance.

Addresses CodeRabbit review on #405.
2026-07-03 16:00:59 +05:30
Gnanam 2a5b2af277 fix(doctor): make the runtime check verify the native binary, not always pass
`zero doctor`'s runtime check returned StatusPass unconditionally with a
hardcoded "go" label — it verified nothing (the running binary, a version,
anything), so it was a false-confidence green: "Zero Go runtime is available
(go)" no matter what (#405).

Make it real: resolve the running binary via os.Executable, stat it, and report
the resolved PATH plus the actual Go version — failing loudly if the path can't
be resolved, the file has vanished, or it's a directory. os.Executable/os.Stat
are injectable (Options.Executable/StatExecutable) so the check is testable
without a real on-disk layout.

Note on scope: doctor runs INSIDE the native binary, so it cannot diagnose the
binary being ABSENT at install time — that case is already caught earlier and
loudly by the npm wrapper (bin/zero.js refuses to launch, present since v0.1.0),
which is why the issue's literal "doctor passes while binary missing" repro does
not occur. The real defect this fixes is the tautological check; surfacing the
binary path is the useful part (spotting an npm-installed binary vs a stale or
shadowing copy on PATH) instead of a meaningless pass. The caller now passes the
real runtime.Version() rather than the "go" placeholder.

Tests: present-binary passes and reports the path; missing/inaccessible binary,
unresolvable os.Executable, and a directory path each FAIL (red-green — the old
code passed unconditionally); the default (uninjected) path resolves the real
running binary.

Fixes #405
2026-07-03 07:13:35 +05:30
Abdias J b5a78dca6f docs(install): document Bun install and improve missing-binary error (#418)
Bun does not run dependency lifecycle scripts by default, so the npm
wrapper's postinstall (which fetches the native Zero binary from GitHub
Releases) is silently skipped. The first run then fails with an
unhelpful "No native binary found" message that points at a source-only
build command, leaving Bun users stuck.

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

Two-part fix:

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

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

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

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

* chore(tools): clarify web_search permission reason

---------

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

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

Fixes #383

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

Review + CI fixes, all test-only:

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

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

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

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

* fix(tui): address FILES sidebar review findings

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

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

---------

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

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

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

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

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

* fix(providerio): make ContentStallTimeout overflow-safe

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix: address CodeRabbit review comments on PR #352

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

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

New tests: TestEscArmingCancelConfirmationPreservesComposerDraft,
TestPasteDisarmsCancelConfirmation.
2026-07-01 15:45:28 +05:30
KRATOS 2a8802e39c fix: cap unbounded exec_command output buffer to prevent OOM kill (#353)
* fix: cap unbounded exec_command output buffer to prevent OOM kill

execOutputBuffer.data accumulated every byte a backgrounded exec_command
session wrote, with no size cap, unlike buffer.recent which is correctly
bounded to recentExecOutputBytes for the /ps preview. drainString() only
clears data when the agent actually polls that session again — so a
long-lived background session nobody polls (e.g. a dev server left running
after the run that started it was cancelled) grows this buffer forever as
long as the process keeps writing, with no ceiling.

Traced this directly to a real crash: a session that had been running for
~5.5 hours (an earlier turn invoking swarm agents got cancelled mid-flight,
per that session's own event log) accumulated to ~42.7GB of compressed
memory, at which point macOS's memorystatus killed the process outright
("killing zero due to low-swap").

Fixed by capping data at maxExecOutputBufferBytes (2MiB), trimming from the
front like recent already does, and surfacing a one-time truncation notice
on the next drain so data loss is visible rather than silent.

* fix: pre-push review findings on the exec_command buffer-cap fix

Ran an adversarial review before opening the PR. It found the truncation
notice added alongside the buffer cap didn't actually work: prefixing
execOutputTruncatedMessage into drainString()'s text meant the notice
always sat ~2MiB before the end of the combined output, but the tool's
own downstream truncateExecOutput() keeps at most a 400KB tail (even at
the maximum allowed max_output_tokens) — so the notice was reliably
swallowed, or chopped into an unreadable fragment at smaller budgets.
Verified with direct reproductions against the real functions.

Fixed by moving the signal out of the text stream entirely:
- execOutputBuffer gains consumeTruncated() (read+reset, called once per
  collect()) and peekTruncated() (read-only, for status views) instead of
  injecting a marker into the drained bytes.
- collect() now returns (string, bool); both call sites thread the bool
  through to execToolResult, which sets meta["output_buffer_truncated"]
  and appends the notice to the response body *after* truncateExecOutput
  runs, so it can never be re-truncated or land in a discarded region.
- Also flagged: a session torn down without ever being polled again after
  truncation loses the signal entirely, since nothing drains/logs it on
  teardown. Full audit-trail logging felt like overkill for a soft
  transparency signal on top of a hard memory-safety fix, but exposed
  peekTruncated() on ExecSessionSnapshot and surfaced it in the /ps row
  (background_terminals.go) so it's visible any time a still-running
  session is checked — covers the common case (the review's own scenario:
  a long-lived dev server nobody's polling but that's still alive) even
  though a session reaped before ever being looked at is still a gap.

New tests: reworked TestExecOutputBufferCapsUndrainedData for the
consume/peek API, and TestExecToolResultSurfacesBufferTruncationOutsideByteBudget
reproducing the review's exact failure case (max_output_tokens=1) and
proving the notice now survives it.
2026-07-01 15:03:42 +05:30
KRATOS d53052071a revert(local): restore the original bright-lime accent color (#351)
Restores accent to #caff3f (the pre-PR#350 value), undoing the three-round
brightness reduction (#ade619 -> #8db620 -> #759523) merged in PR #350. Only
the accent token changes - the hover-highlight color (a separate amber
token) and every other PR #350 fix (subchat selection, drag-to-edge
auto-scroll, the dragging-flag fix) are untouched.

Full gate green. Local only - not pushed.
2026-07-01 13:55:35 +05:30
KRATOS 1c65f59be6 fix(tui): subchat mouse selection, hover highlighting, and drag-to-edge auto-scroll (#350)
* fix(local): subchat mouse selection + hidden plan panel + scroll-extend selection

Three bugs in the subagent/swarm drill-in (subchat) view and mouse text selection,
all from the same root: newer features (subchat's row-source swap, wheel-scroll)
were never integrated into the older selection/footer code.

1) Can't copy anything in the subchat view. transcriptLineAtMouse (and
   selectedTranscriptText, the copy-time text extraction) always hit-tested against
   m.transcript (the parent), never m.subchat.childRows, even while the screen was
   showing the child session. New transcriptHitTestSource() mirrors transcriptView's
   own branching (nav bar + child rows + full width vs pinned title bar + parent
   rows + sidebar-aware width) and is now the single source both functions use.

2) Plan panel shown above the composer inside the subchat view. footerView
   unconditionally rendered the PARENT run's pinned plan panel; only pendingAskUser
   was gated. Added a !m.subchat.active guard (m.plan belongs to the parent run,
   not whatever subagent/swarm session is being viewed).

3) Selection didn't extend across a scroll (reported as "stopped at the end of the
   screen"). transcriptSelection.cursor was only ever updated by a mouseMotion
   event; a wheel event is a distinct message type that never reached that code,
   so scrolling while selecting left the selection frozen at whatever line the
   mouse last physically moved over. New scrollChatExtendingSelection scrolls AND
   re-evaluates the cursor at the mouse's current position against the post-scroll
   viewport. Uses a new nearestTranscriptLineAtMouse (falls back to the closest
   visible selectable line) rather than an exact match, since the recomputed
   position can land exactly on a blank spacer row between messages after an
   odd-length scroll shifts text/spacer parity - an exact-match miss there must
   not silently no-op the extension.

Tests: subchat selection resolves the CHILD session's text, not the parent's; the
plan panel is absent from the footer during subchat; a selection's cursor bodyY
moves (extends) after a wheel-scroll instead of staying pinned. Full gate green
(73 pkg, -race).

* feat(local): highlight clickable rows on mouse hover

Moving the cursor over an interactive row now highlights it (accent underline)
before you click, across every clickable surface:
- specialist cards and collapse/expand toggle headers in the transcript
- AGENTS sidebar rows (swarm members)
- PLAN sidebar step rows
- permission-prompt options (reuses the EXISTING keyboard-cursor highlight by
  moving pendingPermission.cursor on hover, instead of new styling)

Requires switching the mouse-tracking mode from CellMotion (only reports motion
while a button is held) to AllMotion (reports idle cursor movement too) - the
existing 15ms mouse-event throttle already covers the added motion events.

hover.go: hoverTarget + mouseHover predicate + updateHoverTarget, which reuses
the SAME hit-testers and priority order as the existing click handlers so hover
and click never disagree about what's clickable.

Sidebar hover is resolved by STABLE IDENTITY (sessionID / stepIndex), not a
cached line offset: hoveredSidebarLineOffset re-matches the identity against a
freshly computed hit list on every render, so a row that's disappeared (a
swarm member's linger elapsed, a plan step completed) simply doesn't highlight
rather than a coincidentally-matching unrelated row lighting up. Transcript
hover stays bodyY-keyed (matching the existing selection-highlight convention)
with explicit clearHover() calls wherever bodyY numbering can shift without an
intervening mouse motion: wheel-scroll, PgUp/PgDn, window resize, and
transcript rebuilds (/clear, /resume, /rewind, /compact via resetFlushFrontier).

Adversarial review caught two real bugs before ship, both fixed:
- BLOCKER: plan-step hover stored the step's own index (0,1,2...) but the
  sidebar render treated it as a raw line offset into the rendered rows -
  every plan-step hover highlighted the wrong row (the AGENTS header/body).
  Fixed by resolving fresh at render time via the stable-identity redesign
  above, which also closes the related sidebar-drift issue.
- MAJOR: hover wasn't cleared on keyboard PgUp/PgDn scroll.

Tests: predicate correctness; hover resolves the right target across all 4
surfaces (specialist card, toggle row, sidebar agent, plan step, permission
option) with the correct priority order; hover changes the actual render
output (transcript + sidebar); hover clears on wheel-scroll and subchat exit;
a regression test proving hoveredSidebarLineOffset self-heals (no highlight)
when a hovered row's identity no longer resolves - the exact scenario the
blocker fix targets. Full gate green (73 pkg, -race).

* fix(local): soften hover color + auto-scroll a drag past the transcript edge

1) Hover highlight was brand-lime (bright, glaring against the near-black
   surface) and re-paints on every mouse movement. Switched to amber (an
   existing semantic token, "light orange") and dropped the underline per
   feedback.

2) Dragging a text selection past the top/bottom edge of the visible
   transcript now auto-scrolls toward that side and extends the selection to
   follow, matching the browser/editor "drag past the viewport edge" pattern.
   transcriptEdgeScrollDelta detects the drag is vertically outside the body
   rect (but still within its horizontal span, so it isn't just "off to the
   side" over the sidebar); dragToEdgeScroll scrolls one step and anchors the
   cursor to whichever line now sits at that edge of the NEW viewport -
   deliberately not a re-resolve of the drag's physical mouse position, which
   stays outside the body rect after scrolling (scrolling changes what content
   occupies a screen row, not the mouse's screen position) and would keep
   finding nothing.

   Extracted nearestTranscriptSelectableAt as the shared "closest selectable
   line to a target bodyY" core, now used by both nearestTranscriptLineAtMouse
   (target derived from a mouse position that may have landed on a spacer row)
   and dragToEdgeScroll (target derived directly from the viewport bound, no
   mouse position involved at all).

Tests: dragging past the top edge scrolls toward older content and extends the
cursor upward; the symmetric bottom-edge case scrolls toward newer content and
extends downward. Full gate green (73 pkg, -race).

* fix(local): smooth-glide the drag-to-edge auto-scroll instead of jumping

The initial edge-scroll implementation (previous commit) applied a single
chatWheelScrollLines (5-line) jump per raw mouse-motion event, which reads as
sudden/jerky since motion events during a real drag arrive in bursts, and
stops dead the instant the physical mouse holds still even while still parked
past the edge (a terminal only reports motion on actual cursor movement).

Replaced with a self-scheduling tea.Tick chain (dragEdgeScrollTickMsg, gated by
edgeScrollSeq mirroring the existing exitConfirmSeq/copyStatusSeq idiom) that
steps a small, fixed increment (dragEdgeScrollStep=1 line) on a short cadence
(dragEdgeScrollInterval=70ms) for as long as the drag holds past the edge,
independent of whether new raw motion events keep arriving. reducedMotion still
gets the original single-bigger-step-per-event behavior (no animated glide).

Adversarial review of the new self-perpetuating tick chain (the risk being an
orphaned/stuck chain) found one real bug: a keypress mid-drag clears
m.transcriptSelection.active directly (the KeyPressMsg handler in model.go)
without going through stopEdgeScroll, leaving edgeScrollDelta stale. The
in-flight tick itself terminates safely via its own liveness check - the bug
was that the STALE nonzero edgeScrollDelta then fooled startEdgeScroll's
"already running" fast path on the NEXT same-direction drag into silently doing
nothing (no immediate step, no scheduled tick) for that whole hold. Fixed at
the press site (transcript_selection.go's mouseLeftPress case): every fresh
press now resets glide state via stopEdgeScroll, so a new drag always starts
clean regardless of how the previous one ended - a single targeted fix rather
than chasing every place transcriptSelection can be cleared.

Tests: top/bottom edge glide starts and extends the cursor over several ticks
(a single small step can land on a blank spacer row between messages, same as
real usage); the chain stops when the drag returns to the body and a stale
in-flight tick after that is a safe no-op; the keypress-interrupt regression
(reproduces the adversarial review's finding, confirms the fix). Full gate
green (73 pkg, -race).

* fix(local): soften the brand-lime accent color across the whole UI

The accent color (#caff3f) was fully saturated at fairly high lightness
(HSL: L 62%, S 100%) - the kind of value that reads as neon/glaring
everywhere it filled a block or bold-text surface, not just the one spot
originally flagged: the composer's "typed" prompt glyph, the permission
popup's focused-option badge, and the text-selection highlight all use it,
plus the spinner, the brand chip, tool-card prompts, and more.

Rather than patch each of those consumers individually, softened the single
palette source (dark theme only; the light theme's accent is a separate,
already-darkened value for its own contrast needs, untouched) to L 50% / S
80% (#ade619) - still clearly on-brand lime, and contrast against both black
(onAccent text on accent fills) and the near-black panel stays far above
WCAG AA (~14:1 and ~13:1, both comfortably over the 4.5:1 floor).

Updated TestStreamingFadePaletteMonotonic's pinned RGB assertion (it checked
the fade ramp's first bucket equals the literal old accent bytes) to the new
values. Full gate green (73 pkg, -race; make lint/go vet clean except an
unrelated stray file that isn't part of this branch or git history at all).

* fix(local): darken the brand-lime accent further per live feedback

Two prior passes (#ade619 at L50/S80, #8db620 at L42/S70) were each tested
live against the original #caff3f via raw truecolor escape codes and
confirmed too subtle in turn. Settled at L36/S62 (#759523) - a noticeably
darker, more olive lime, confirmed against the original side by side.
Contrast vs black (onAccent) and the near-black panel stays comfortably
above WCAG AA (~6.1:1 and ~5.6:1). Updated the streaming-fade test's pinned
RGB assertion to match.

* fix: address PR #350 review comments and CI smoke test failures

Smoke tests (macOS + Ubuntu): the 4 new edge-glide tests implicitly relied
on m.reducedMotion being false, but mouseTestModel() inherits it from
defaultReducedMotion(), which forces true whenever colorprofile.NoTTY is
detected - true in CI's non-TTY test runners, false in a local interactive
dev shell. Set reducedMotion = false explicitly in each of the 4 tests so
they're deterministic regardless of environment. Verified by re-running
locally under ZERO_REDUCED_MOTION=1 (reproduces the CI condition) - passes.

CodeRabbit actionable comment: handleTranscriptSelectionMouse's mouseMotion
case gated only on transcriptSelection.active, which stays true through the
async copy-command grace window after release (a non-empty selection isn't
reset until transcriptCopiedMsg lands) - so a genuine hover motion arriving
in that window would be misrouted as a drag update. The suggested fix
(gate on msg's own Button field) turned out to be wrong in a way only a
full test run caught: TestTranscriptSelectionUpdatesOnGenericMotion is a
pre-existing, deliberate test - some terminals don't restate the button on
every motion event during a real drag, so trusting the per-event Button
field breaks those terminals. Fixed properly instead: added a `dragging`
field to transcriptSelectionState, tracking the app's own press/release
bracket (true at press, false at release) independent of active and
independent of any single event's Button field. mouseMotion now gates on
dragging.

CodeRabbit nitpicks: routed the two remaining raw `m.hover = hoverTarget{}`
subchat-exit assignments through the existing clearHover() helper, so hover
invalidation stays in one place. Left the third nitpick (hit-test layout
recomputed per hover-motion tick) as a documented follow-up per the
reviewer's own "trivial/poor tradeoff" framing - a proper per-frame memoized
cache needs correct invalidation on scroll/resize/content-change to avoid
reintroducing the exact staleness class of bug already fixed twice in this
branch, and isn't worth rushing.

New tests: TestTranscriptHoverAfterReleaseDoesNotMoveSelection (the actual
bug CodeRabbit found - a post-release hover must not alter the just-copied
selection). Full gate green (73 pkg, -race); TestTranscriptSelectionUpdatesOnGenericMotion
(the test the first fix attempt broke) verified still passing.
2026-07-01 12:36:16 +05:30
KRATOS 7fb0092de7 fix: agent runtime resilience — stream stalls, weak-model tool args, and sub-agent reliability (#349)
* fix(local): defeat the macOS stale-pooled-connection hang (transport + stall-retry)

Root cause (you confirmed it: both chatgpt AND ollama hung, only on macOS): providers
fell back to http.DefaultClient, whose pooled keep-alive connections get reused on a
later turn after the server/NAT silently dropped them. The model call is a POST
(non-idempotent), so Go won't auto-retry it on a fresh conn — it blocks forever.
macOS keeps dead pooled conns around longer than Linux, so it reproduced on macOS only.

1. providerio.HTTPClient now returns a shared, tuned transport (ResponseHeaderTimeout
   60s bounds a hung POST that gets no response; IdleConnTimeout 30s stops dead conns
   from lingering to be reused). Routed openai/codex through it too (anthropic/gemini
   already used HTTPClient). Slow first tokens / long reasoning are unaffected — the
   header timeout only bounds time-to-first-response-header, not the stream body.
2. Agent loop now retries a stream idle/stall timeout that produced NO output yet, on
   a fresh connection (capped + backoff). A partial-output turn is NOT retried (would
   duplicate). Recovers any stall that slips past the transport timeout.

Local only — not pushed.

* fix(local): tolerate concatenated tool-arg JSON + widen response-header timeout

1) Weak models (minimax-m3, glm) pack MULTIPLE concatenated top-level JSON objects
   into one tool-call arguments slot ({A}{B}), which json.Unmarshal rejects with
   'invalid character {' after top-level value' (~9 failed tool calls/session).
   New recoverableToolArguments returns the first JSON value ONLY when the payload is
   one value optionally followed by more WHOLE JSON values; it rejects a bad/truncated
   first value OR trailing NON-JSON garbage ({"x":1}xyz) so genuine corruption still
   errors (adversarial-review fix). decodeToolArguments uses it at the central dispatch
   (executeToolCall) so {A}{B} runs the first object; historySafeToolCalls uses the
   SAME helper so the replayed transcript records the recovered first object (not {}),
   keeping dispatch and history consistent. Truly-malformed JSON still hits the
   existing sanitize-to-{} + retry path. Trailing objects are dropped (the model
   re-issues on a later turn).

2) Raise the shared transport ResponseHeaderTimeout 60s -> 120s so a slow cloud proxy
   (ollama *:cloud) that withholds its 200 header until first token isn't wrongly
   aborted; still bounds a truly dead reused connection.

Local only — not pushed.

* feat(local): add /turns command + raise default maxTurns 30->50

Agentic multi-step runs (build + delegate + swarm + MCP + self-verify) routinely hit
the 30-turn ceiling mid-task and never reached the later steps; removing /mode earlier
also left NO interactive way to raise the budget.

- /turns [n]: per-session tool-turn budget setter (commandGroupSession), mirroring
  /selfcorrect. Empty/status shows the current budget; a positive int sets
  agentOptions.MaxTurns (read by the run at model.go); clamped to maxTurnsCeiling=500
  to guard against typos; invalid/non-positive shows usage without changing it.
- defaultMaxTurns 30 -> 50 (matches the old 'deep' preset). Raise per-run with /turns
  for long tasks (e.g. /turns 150 for a full acceptance run).

Local only — not pushed.

* fix(local): swarm reports failed members as failed + sub-agents inherit /turns budget

Issue B — a swarm member whose child exited non-zero (e.g. exit 4 / max-turns) was
recorded [done] (swarm_status '0 failed'), so the orchestrator — which can't see the
AGENTS panel — trusted incomplete work. The specialist launcher ignored
res.Result.Status; it now surfaces a StatusError result as a member FAILURE
(errors.New(report)) so the swarm marks it [failed]. Added Coordinator.FailWithSession
and switched the member watch path to it so a failed member keeps its session id
(still drillable) and its report rides along as the failure message.

Issue A — a member hit its OWN turn limit because /turns raised only the parent's
budget; children re-resolved config.json's default. New config.SetMaxTurnsEnv exports
ZERO_MAX_TURNS, read in applyEnv -> cfg.MaxTurns; /turns now exports it so spawned
sub-agents / swarm members (which inherit the env) run with the same budget. A
non-numeric/zero env value never clobbers the configured budget.

Tests: launcher non-zero-exit -> failed (+ session kept); SetMaxTurnsEnv round-trip
+ garbage-env guard; /turns exports + clamps the env. Full gate green (73 pkg).

Local only — not pushed.

* fix(local): corrective error for unknown specialist name + clamp ZERO_MAX_TURNS

1) A model that invents a specialist name (validator-runner, file-creator,
   file-writer — none registered) got an opaque 'specialist not found' and kept
   spawning doomed sub-agents. loadManifest now lists the available specialists and
   notes that 'worker' has shell/edit while explorer/code-review are read-only, so the
   model self-corrects to a capable specialist. New availableSpecialistList helper.

2) Defense-in-depth (review nit): applyEnv now clamps an inherited ZERO_MAX_TURNS to
   MaxTurnsCeiling, so a raw shell 'export ZERO_MAX_TURNS=999999' can't bypass the
   /turns ceiling. Ceiling promoted to config.MaxTurnsCeiling (single source of truth,
   referenced by the TUI /turns clamp).

Tests: unknown-name error lists worker/explorer/code-review; applyEnv clamps an
over-ceiling env value. Full gate green (73 pkg).

Local only — not pushed.

* fix(local): surface sub-agent kill signal instead of opaque exit -1

When a sub-agent child is terminated by a signal, command.Wait returns an ExitError
whose ExitCode() is -1, losing the cause. Capture ProcessState.String() (portable,
e.g. "signal: killed") into ChildRunResult.Signal and have BuildFinalResult render
it — "Subagent terminated by a signal (signal: killed) — killed before it finished.
Likely causes: the OS out-of-memory killer ... a timeout, or cancellation." — instead
of an opaque "Subagent failed (exit -1)". The message lists causes rather than
asserting OOM, since timeout/cancellation also SIGKILL the child. A captured signal
always surfaces even if a late run_end reported a clean exit. The background task
path (which can't carry the signal name) emits the same hint for a negative exit code.

* address CodeRabbit review on #349

- loop.go: gate stall-retry on a turn-level forwardedAnything flag (wrapped
  callbacks), so a retry never re-streams on top of output already forwarded earlier
  in the turn (e.g. across the reactive-compaction retry). New test covers a
  reasoning-then-stall (collected.Text empty but output forwarded).
- loop.go: route a reissued stream's non-stall error through the SAME recovery as
  the initial stream (image-rejection wrapping / context-limit compaction) via a
  shared recoverStreamError closure, instead of returning it raw.
- exec.go: drop the hardcoded worker/explorer/code-review guidance from the
  unknown-specialist error; rely on the dynamically-rendered available list (a
  custom registry may not have those names).
- streamer.go: kill-signal message lists OOM/timeout/cancellation evenhandedly
  instead of asserting OOM (the branch also covers timeouts/cancellations).
- launcher_specialist.go + exec.go: preserve the child SessionID on a post-start
  failure too, so FailWithSession keeps those failed members drillable.
- model.go: block /turns while a run is active (mid-run change would make the
  inherited ZERO_MAX_TURNS budget inconsistent for later-spawned sub-agents).
2026-07-01 09:15:08 +05:30
KRATOS 5aced721b3 fix(tui): sub-agents inherit the parent's live provider after a /model switch (#348)
* fix(tui): sub-agents inherit the parent's live provider after a /model switch

Swarm members and Task sub-agents run as child 'zero exec' processes that re-resolve
the provider from config.json themselves — they're passed --model but never the
parent's provider. After switching provider/model in the TUI via /model (an in-memory
change), the child still resolved config.json's stale activeProvider, landed on a
provider whose credentials didn't match the live model, and failed auth in ~3s
(exit 3, 0 tool calls, 'API key missing or invalid / can't reach the provider') — so
the spawned agent appeared then instantly disappeared.

Export ZERO_PROVIDER (config.SetActiveProviderEnv) to the live provider name on a
/model provider switch. Child processes inherit the environment, and config
resolution already maps ZERO_PROVIDER -> activeProvider, so the child resolves the
SAME profile and its credentials (env key / stored key / OAuth) — covering all three
auth methods, for both Task and swarm (both spawn via the specialist executor).

Startup needs no export: the parent and child both read config's default, so they
already match; only an in-memory switch diverges.

* fix(config): clear ZERO_PROVIDER when switching to an unnamed/default profile

SetActiveProviderEnv("") now unsets ZERO_PROVIDER instead of leaving the previous
value in the environment. Switching from a named provider back to an unnamed/default
profile otherwise kept exporting the stale provider into child zero exec processes,
which would resolve the old credentials. Extended the round-trip test with the
stale-env regression case: seed a value, switch to blank, assert both os.Getenv and
applyEnv no longer see it.

Addresses CodeRabbit review on #348.
2026-07-01 01:57:30 +05:30
KRATOS 02a3d12f34 fix(providerio): bound heartbeat-but-no-output streams so they can't hang forever (#347)
* fix(providerio): bound heartbeat-but-no-output streams (content-stall watchdog)

A stalled-but-alive upstream hung the agent indefinitely: SSE keep-alives reset the
idle watchdog (intentionally — a heartbeating upstream isn't 'dead', and aborting it
killed healthy long requests), so a stream that heartbeats while producing nothing
never tripped the idle timeout and there was no other ceiling. Observed on both
chatgpt/gpt-5.x (Codex) and ollama minimax-m3 — different provider code, same shared
providerio SSE loop — so the fix belongs here, once, for every provider.

Add a second 'content' watchdog alongside the idle one: keep-alives still reset idle
(no regression for heartbeating connections), but only REAL data lines reset the
content watchdog. If no data arrives for streamContentStallFactor (2) × the idle
timeout, abort with ErrStreamStalled instead of hanging. A slow-but-producing stream
(data between heartbeats) keeps resetting content and is never killed.

Contained to internal/providers/providerio — no provider/caller changes.

* fix(providerio): surface ErrStreamStalled in callers + widen flaky test margins

Address review:
- All four provider stream handlers (openai, codex, anthropic, gemini) now treat
  ErrStreamStalled in the same branch as ErrStreamIdle, via a new
  providerio.StreamTimeoutMessage helper that gives the stall a distinct, accurate
  message (it reports the content window and does NOT claim the upstream stopped
  sending data — keep-alives were still arriving). Previously a stalled stream fell
  through to the generic provider-error path.
- Widen the content-watchdog test timing margins (idle 100ms, data/keepalive every
  30ms) so CI/GC jitter can't trip the idle watchdog and mask the content path.
- Add a StreamTimeoutMessage unit test.
2026-06-30 22:19:52 +05:30
KRATOS a0548d707b test(providers): isolate the OAuth store in the chatgpt Codex routing test (#346)
TestNewRoutesChatGPTCatalogToCodexProvider asserts the chatgpt-account-id header is
empty when no OAuth login is stored, but never redirected the token store — so on a
developer machine with a real chatgpt login it read that login and failed (it still
passed in CI, where no token exists). Point ZERO_OAUTH_TOKENS_PATH at an empty temp
path, mirroring TestNewRoutesChatGPTCatalogWithStoredAccountID, so the test is
deterministic regardless of the developer's stored credentials.
2026-06-30 21:21:44 +05:30
KRATOS 83a371d669 fix: stream Codex reasoning + drop swarm agents on their own completion (#345)
Two agent-visibility fixes surfaced diagnosing a 20-minute gpt-5.5 step and an
'agents never disappear' report.

A) Codex Responses path streamed no reasoning, so a long thinking phase produced
   zero visible output and read as a hang (the activity clock never advanced).
   Root cause: the request never asked for a reasoning summary and the parser had
   no case for reasoning deltas. Now request reasoning.summary="auto" and forward
   response.reasoning_summary_text.delta as StreamEventReasoning — the existing TUI
   handler renders it live and refreshes the activity clock.

B) A finished swarm member stayed in the AGENTS panel until the whole turn ended.
   Now it drops once ITS OWN task completes (fade over the linger window, then
   remove), independent of the overall run — matching how finished specialists
   already behave. Running members and mid-flight collect are unaffected.
2026-06-30 21:21:30 +05:30
KRATOS 84823d7be1 fix(swarm): constrain agent_type to the registered roster via a dynamic enum (#344)
* fix(swarm): constrain spawn/handoff agent_type to the registered roster via enum

swarm_spawn and swarm_handoff described agent_type / to_agent_type as a free-form
string with only an 'e.g. teammate, subagent' hint, so a model could pass an
unregistered type (e.g. 'worker') and only discover it was invalid from the runtime
'unknown agent type' error — wasting tool calls, especially on weaker models.

Set the parameter's enum dynamically from the live roster (Registry.AgentTypes()),
so the schema advertises the valid set (built-ins plus any user-registered agents)
up front and an invalid type can't be sent. Falls back to a free-form string when
the roster is unavailable, so the tool never advertises an empty, un-satisfiable
enum. The runtime 'available agent types: …' error is kept as a backstop.

* test(swarm): also assert handoff to_agent_type enum picks up a custom roster type

Address review: the custom-type recheck only re-read spawnTool, so a regression
leaving handoffTool on the built-in list would have passed.
2026-06-30 20:50:11 +05:30
KRATOS cfd065b565 fix(openai): always send message content so strict OpenAI-compatible servers accept it (#343)
Some OpenAI-compatible endpoints (e.g. glm-* on Ollama-cloud) reject a request whose
message 'content' is absent or null with HTTP 400 'invalid message content type:
<nil>'. The chat-completions mapper relied on 'content,omitempty', so a message with
empty text (a tool result, an assistant turn that only makes tool calls, or a
sub-agent that failed with no output) serialized with no content field at all.

- Drop omitempty on chatMessage.Content and always set it (to "" when there is no
  text), so contentless messages serialize as "content":"" instead of being
  dropped. Standard OpenAI/xAI/groq already tolerated the omission; this makes the
  strict servers work too.
- Skip a degenerate assistant turn that has neither text nor tool calls, mirroring
  the Anthropic/Gemini mappers (which already drop empty turns).

Fixes the failure seen on glm-5.2:cloud after a sub-agent failed with empty output.
2026-06-30 11:19:06 +05:30
Anandan fb3a0bff44 Document source-build sandbox helpers (#342)
Clarify that Linux source builds need the zero-linux-sandbox helper on PATH for native sandboxing, while macOS does not need an extra helper and Windows can self-dispatch from zero.exe.

Also document optional release-style helper builds for Linux and Windows.

Tested: git diff --check
2026-06-30 11:16:52 +05:30
072f3877b5 fix(opengateway): correct gateway host, make it the recommended default (#341)
OpenGateway pointed at gateway.gitlawb.com, which does not resolve
(NXDOMAIN), so every request failed with "no such host". The live gateway
is opengateway.gitlawb.com — a flat OpenAI-compatible endpoint
(/v1/chat/completions, Authorization: Bearer ogw_live_…) that smart-routes
by model id across its upstreams (xiaomi-mimo, minimax, qwen, google,
nvidia, z-ai).

- catalog: fix base URL to https://opengateway.gitlawb.com/v1, default model
  to mimo-v2.5-pro; add a Recommended descriptor field and hoist OpenGateway
  to the top of the catalog so it is the first/badged pick everywhere.
- models: replace the curated model list with the gateway's real upstream
  models; fix the stale model-discovery fallback host.
- providers catalog: sort recommended-first (CLI + snapshot) and render the
  ★ … (recommended) badge; same badge in the TUI /provider wizard and the
  setup onboarding list.
- providerhealth: OpenGateway's flat /v1/models 404s by design, so probe its
  public /health endpoint for connectivity instead of reporting a false fail.
- tests: update catalog order golden + transport/discovery expectations; add
  coverage for the recommended provider and the health-endpoint override.

Co-authored-by: Kevin Codex <[email protected]>
Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-30 10:14:01 +08:00
Vasanth T 1dd884d6e9 chore(launch): MIT license, windows-arm64 install guard, TUI TTY guard (#340)
* chore(launch): MIT license field, windows-arm64 install guard, TUI TTY guard

Three launch-readiness fixes the docs cleanup doesn't cover:

- package.json: set "license": "MIT" (was the placeholder "SEE LICENSE IN
  LICENSE"), matching the LICENSE file and the README badge.
- scripts/postinstall.mjs: skip gracefully on windows-arm64. (win32, arm64)
  resolves to a valid platform/arch but the release matrix ships no
  windows-arm64 artifact, so the installer would download a 404 and hard-fail
  npm install. Treat it like any unsupported target (warnSkip, exit 0) and point
  to the x64 build (which runs under emulation on Windows on ARM) or source.
- internal/tui/run.go: fail fast when stdin is not a terminal. The interactive
  shell otherwise blocks forever in the Bubble Tea event loop on piped/redirected
  input (e.g. `echo "" | zero`); now it prints guidance toward `zero exec` and
  exits non-zero. Dependency-free ModeCharDevice check (no x/term added).

Tests: windows-arm64 skips with exit 0; the interactive shell returns non-zero
(not a hang) on non-TTY stdin.

* fix(tui): use term.IsTerminal for the stdin guard; pin exit code in test

Address review on the no-TTY guard:
- Replace the os.Stdin.Stat()/ModeCharDevice heuristic with term.IsTerminal
  (github.com/charmbracelet/x/term, already in the module graph via bubbletea).
  It is a true TTY check — it rejects pipes, regular files, and non-terminal
  char devices like /dev/null — and fails closed: anything not a verified
  terminal blocks the interactive shell.
- The test now asserts the documented exit code 2 from the guard, not just a
  non-zero result, so it proves the stdin guard fired.
2026-06-28 17:13:34 +05:30
Anandan 726f1512a3 Clean up public release docs (#339)
Rewrite the README around the initial public-release user flow, add the logo asset, and refresh install/update documentation.

Remove internal planning, audit, design, and work-split documents from the public docs tree.

Update stale documentation references and workspace seed expectations to point at public docs.

Tested: env GOCACHE=/tmp/zero-go-cache go test ./internal/workspaceseed

Tested: git diff --check
2026-06-28 17:13:21 +05:30
Vasanth T 8c5898d5db feat(reasoning): typed per-provider reasoning capability from a models.dev catalog (#338)
* feat(reasoning): typed per-provider reasoning capability from a models.dev catalog

Providers describe reasoning control with incompatible concepts — OpenAI a
discrete effort enum (split by minor version), Anthropic and Gemini 2.5 a
thinking-token budget, some models only a thinking on/off toggle — which a flat
effort string cannot represent and a model-name heuristic cannot detect.

Add internal/reasoning: a Capability type (a model's reasoning flag plus its
typed Controls — effort/budget_tokens/toggle) and a Catalog that looks capability
up by provider + api model id. The data is an embedded, trimmed snapshot of the
community capability database (first-party providers only; routers excluded since
they carry generic/stale option lists), so detection is offline and exact instead
of guessed.

The package depends only on the standard library, so the model registry and
provider adapters can consume it without an import cycle. This change is purely
additive — nothing is wired into the live resolution path yet.

Tests pin the per-version ground truth (gpt-5 minimal-not-none, gpt-5.1
none-not-minimal, gpt-5.1-codex-max xhigh, gpt-5-pro locked to high; Anthropic
budget-only legacy vs effort-only newer with the budget removed; Gemini 2.5
budget/toggle vs Gemini 3 effort) and that every reasoning model Zero ships today
is covered by the snapshot.

* fix(reasoning): address review — presence-aware budget bounds, deep-copy lookup, first-party-only snapshot

- Control.Min/Max are now *int so an explicit bound is distinct from an omitted
  one: Gemini's `min: 0` ("thinking can be disabled") and Anthropic's missing
  max (unbounded) are preserved instead of both collapsing to 0. Budget() becomes
  BudgetControl() returning the control so callers read the pointers.
- Catalog.Lookup returns a deep copy (clone) of the matched Capability, so a
  caller can no longer mutate the shared embedded catalog through the returned
  Controls / Values slices or budget pointers.
- Drop the google-vertex section from the snapshot: it re-hosts third-party MaaS
  models (Anthropic/Moonshot/OpenAI/DeepSeek/Qwen/Meta), violating first-party
  -only. google (AI Studio) already covers first-party Gemini, so the gemini
  provider kind resolves there. Snapshot 158 -> 123 models, 16.8 KB.

Tests pin the explicit min: 0 preservation, the nil max on budget-only Anthropic
models, and that a mutation of a Lookup result does not leak into the catalog.

* fix(reasoning): address round-2 review — accessor deep-copy, max tier, provenance

- EffortControl/BudgetControl now return a cloned Control, so a caller holding a
  Capability that shares state with the catalog cannot mutate Min/Max pointers or
  the Values slice through an accessor result (CodeRabbit). Lookup already deep
  -copies; this closes the accessor path too.
- Add modelregistry.ReasoningEffortMax ("max") + accept it in ValidReasoningEffort,
  so the snapshot's "max" tier (e.g. gpt-5.1-codex-max, newer Anthropic) has a
  constant to map to. No curated model lists it yet.
- Snapshot provenance: add a _fetched date and a regeneration recipe (documented
  in catalog.go), so refreshes are reproducible/auditable.
- Document that Lookup takes the API model id (wire name), not the friendly
  registry id, and that the match is exact (no case-fold/prefix-strip).
- Record the source-of-truth intent in the package doc: this catalog is the
  intended authoritative source; modelregistry's name-pattern inference is demoted
  to a fallback when it is wired in (a later change). Rename the `cap` builtin
  shadow to `entry`.

Tests pin the accessor deep-copy isolation.
2026-06-28 14:15:59 +05:30
KRATOS 7a09a42a17 feat(tui): remove the /mode command (superseded by /model + /effort) (#337)
The /mode presets bundled a hardcoded Anthropic/Google model + reasoning effort +
turn budget, so on other providers (Ollama-cloud, xAI, GPT-5, custom) /mode would
switch to a model the user isn't authenticated to. With /model (any provider's
models) and /effort (reasoning effort) covering the same ground flexibly, /mode is
redundant in the TUI.

Removes the /mode slash command, its handler, the mode picker, and the dispatch +
stale comments; updates affected tests. The CLI `--mode` flag and internal/
modelregistry mode presets are kept (still used by headless exec).
2026-06-28 13:41:57 +05:30
Anandan c0a0389b09 Polish TUI transcript rendering (#334)
* Polish transcript rendering

Tighten transcript spacing, collapse noisy permission/tool rows, and render tool activity with cleaner action-oriented labels.

Improve code and diff rendering with markdown highlighting, cleaner command output blocks, and grouped exploration summaries.

Tested: env GOCACHE=/tmp/zero-go-cache go test ./internal/tui

Tested: git diff --check

* Fix grep exploration rendering on Windows

Keep search query text out of path normalization so regex escapes render consistently across platforms.

Tested: env GOCACHE=/tmp/zero-go-cache go test ./internal/tui

Tested: git diff --check

* Address transcript rendering review

Cache highlighted streaming markdown so closed code blocks are not re-lexed on every live render.

Tighten bare-code detection to avoid highlighting ordinary prose, restore manual permission audit rows, and make exploration details expandable.

Also align diff content/header predicates and use display-width truncation for compact tool output.

Tested: env GOCACHE=/tmp/zero-go-cache go test ./internal/tui

Tested: env GOCACHE=/tmp/zero-go-cache go test -race ./internal/tui

Tested: env GOCACHE=/tmp/zero-go-cache go vet ./...

Tested: git diff --check
2026-06-28 13:41:00 +05:30
Vasanth T d99ac4d1e3 fix(openai): forward reasoning effort to the Codex Responses API (#336)
The Codex provider speaks the Responses API, where reasoning_effort is nested
under `reasoning.effort` rather than sent as a top-level field. buildResponsesRequest
never set it, so a user's chosen effort was silently dropped for every Codex
model — the request always ran at the model default.

Add a `reasoning` object to the Responses request and populate it from the
request's effort, reusing the chat path's openAIReasoningEffort normalizer so
only API-accepted values (minimal/low/medium/high) are sent and an empty or
unsupported effort omits the field (no empty object, no 400).
2026-06-28 13:11:04 +05:30
Vasanth T aa45edacc0 fix(modelregistry): one source of truth for a model's reasoning efforts (#335)
The /effort picker and the run-time effort resolver disagreed about which
tiers a model supports. Registry.ReasoningEfforts falls back to name-based
inference (reasoningEffortsForModelName) when the catalog lists no efforts,
so the picker shows controls for proxy-served GPT-5 / Codex / o-series
models. EffectiveReasoningEffort read model.ReasoningEfforts directly and
never saw that fallback, so it coerced the same request to "none" — the
picker advertised a tier the resolver silently dropped.

Route both through a single effectiveReasoningEfforts helper so they can
never diverge. A model the catalog enumerates with no efforts but whose
name is a known reasoning family now has its requested effort honored end
to end; non-reasoning models still resolve to "none".

Also drop the orphaned, stale NOTE above forwardedReasoningEffort claiming
effort "is not yet forwarded" (it is) and restore the reasoningEffortNotice
doc comment to its own function.
2026-06-28 13:07:14 +05:30
KRATOS ca5e0b9bbb feat: context-aware compaction + usage gauge for all models (#332)
* fix(agent): enable auto-compaction for all models, not just registered ones

Auto-compaction keys off the model's context window (enabled = ContextWindow > 0),
resolved only from the curated registry. Unregistered models — GPT-5/Codex, xAI,
Ollama-cloud, custom endpoints — resolved to 0, so both the proactive (~80%) and the
reactive post-overflow compaction were silently disabled and long sessions overflowed.

- TUI modelContextWindow now also consults live-discovered windows (accurate for
  proxy/custom models once /model has discovered them); unknown still returns 0 so
  display gauges show no guessed denominator.
- New modelregistry.AgentContextWindow applies a positive FallbackContextWindow (200k)
  ONLY on the compaction-enable path (TUI run + CLI exec/spec), so compaction is on for
  every model while display stays accurate-or-empty. Exact windows always win.

* feat(tui): show context usage (used / total + % gauge) for all models from launch

The sidebar/status-line context gauge only shows a total + fill % when the active
model's context window is known. For proxy/custom models (GPT-5/Codex, xAI, Ollama-
cloud) the window isn't in the registry, so it showed only a used-token count.

Warm model discovery for the active provider in Init() (background, non-blocking) so
the active model's window is learned at launch and the gauge renders 'used / total
tokens' + the fill % for every model — not just catalogued ones. Builds on the
discovered-window resolution from the compaction fix.

* fix(tui): address review — agent fallback on model-switch + active-provider window

- Apply modelregistry.AgentContextWindow to /model and /mode pre-switch compaction
  requests (TargetContextWindow), so the 'compaction for all models' behavior also
  covers undiscovered/custom switch targets, not just the active run.
- Scope the live-discovered context-window lookup to the active provider first
  (then others), so a model ID shared across providers resolves to the provider in
  use rather than an arbitrary match.
- Tighten the resolution test to compare against the registry's actual gpt-4.1
  window instead of != FallbackContextWindow.

* fix(cli): give provider-discovered context windows precedence in headless exec

Address the remaining review point: CLI exec/spec runs collapsed every uncatalogued
model to the 200k fallback, ignoring its real window. resolveAgentContextWindow now,
on a registry miss, queries the provider's live model list (resolving the credential
from inline/stored/env) to get the model's actual context window before falling back.
Discovery runs only on a registry miss (catalogued models pay no latency), is bounded
by a 5s timeout, and degrades to the fallback on any error so a headless run is never
blocked or failed.
2026-06-28 13:06:51 +05:30
KRATOS 653cee60c1 feat(tui): dim the transcript backdrop behind overlays for visibility (#333)
Slash-command/file palettes (and pickers/wizards) were composited directly over the
live transcript with no separation, so the chat bled through around the box and the
overlay was hard to read. Add a scrim: when an overlay is open, dim the transcript
backdrop (strip each line's colors and re-render faint) before compositing the bright
overlay on top. Header and composer render separately and stay bright. Text is
preserved (just dimmed), so context remains readable behind the palette.
2026-06-28 13:03:00 +05:30
207 changed files with 13166 additions and 11097 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"}
-1
View File
@@ -315,6 +315,5 @@ That's it. Run `zero` from the repo root and the agent has the team's full instr
- [README](README.md) — install, quickstart, command reference.
- [docs/SPECIALISTS.md](docs/SPECIALISTS.md) — full specialist manifest spec.
- [docs/PRD.md](docs/PRD.md) — product requirements, full feature surface.
- [docs/STREAM_JSON_PROTOCOL.md](docs/STREAM_JSON_PROTOCOL.md) — `zero exec` I/O contract.
- [docs/INSTALL.md](docs/INSTALL.md) — install from source or release.
+22 -3
View File
@@ -6,12 +6,32 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html) once the first release is
tagged. Until then, source builds report the version `dev`.
## 0.1.0 (2026-07-02)
### Features
* publish zero to npm via release-please ([#367](https://github.com/Gitlawb/zero/issues/367)) ([8eccc26](https://github.com/Gitlawb/zero/commit/8eccc2669887bc38d35bc16a315c888e4d9ec43a))
* **tui:** FILES sidebar panel with click-to-select and file drill-in ([#365](https://github.com/Gitlawb/zero/issues/365)) ([142c548](https://github.com/Gitlawb/zero/commit/142c548c89a8652ce300e64ddf1228ee36df7606))
### Bug Fixes
* **auth:** propagate credentials to every provider-build surface and pin children to the live provider ([#366](https://github.com/Gitlawb/zero/issues/366)) ([6e0a665](https://github.com/Gitlawb/zero/commit/6e0a665118fe0e09c4b07d482dd18f86045acd2b))
## [Unreleased]
### Added
- `SECURITY.md` with a private vulnerability-reporting path, `CODE_OF_CONDUCT.md`, this changelog, and
GitHub issue/PR templates.
- `--theme {auto|dark|light}` flag for the TUI (previously only the `ZERO_THEME` env var existed).
- Interactive `/theme` picker: bare `/theme` opens a popup that live-previews each palette as you move
and applies on select (Esc reverts).
- Ten built-in color themes alongside the `dark`/`light` built-ins — `dracula`, `nord`, `gruvbox`,
`tokyo-night`, `catppuccin`, `one-dark`, `solarized-dark`, `rose-pine`, `everforest`, and
`solarized-light` — selectable via `/theme <name>`, `--theme <name>`, or `ZERO_THEME`. Every palette
is contrast-audited to WCAG AA. The built-in light theme was reworked for legibility.
- `--theme <name>` flag for the TUI, accepting `auto` or any registered theme (previously only the
`ZERO_THEME` env var existed).
- "Accessibility / Appearance" section in the README documenting `NO_COLOR`, `ZERO_THEME`, `/theme`,
and `ZERO_NO_FADE`.
@@ -33,5 +53,4 @@ tagged. Until then, source builds report the version `dev`.
- The inert `/input-style` slash command (it had no backend).
### Fixed
- README/`go.mod` Go-version mismatch and other stale docs claims surfaced by the product/UX audit
(`docs/audit/2026-06-21-product-ux-audit.md`).
- README/`go.mod` Go-version mismatch and other stale public-release docs claims.
+256 -298
View File
@@ -1,364 +1,322 @@
<div align="center">
<p align="center">
<img src="docs/assets/zero-logo.png" alt="Zero" width="385">
</p>
```
███████╗ ███████╗ ██████╗ ██████╗
╚══███╔╝ ██╔════╝ ██╔══██╗ ██╔═══██╗
███╔╝ █████╗ ██████╔╝ ██║ ██║
███╔╝ ██╔══╝ ██╔══██╗ ██║ ██║
███████╗ ███████╗ ██║ ██║ ╚██████╔╝
╚══════╝ ╚══════╝ ╚═╝ ╚═╝ ╚═════╝
```
<p align="center"><strong>A terminal coding agent you own.</strong></p>
### The terminal coding agent you fully own.
<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">
</p>
**Any model. Any provider. Your rules.**
![go](https://img.shields.io/badge/Go-1.25-00ADD8?logo=go&logoColor=white)
![providers](https://img.shields.io/badge/providers-25%2B-34E2EA)
![tests](https://img.shields.io/badge/test%20files-200%2B-43D17A)
![status](https://img.shields.io/badge/status-active%20development-E8B84B)
[![license](https://img.shields.io/badge/license-MIT-blue)](LICENSE)
</div>
---
Zero is an AI coding agent that lives in your terminal. It runs a full agentic loop — reading, searching, editing, and executing in your repo — against **whatever model you choose**: frontier APIs, fast cloud inference, or a local model on your own machine. One interface, no vendor lock-in, no telemetry phoning home.
Zero is an AI coding agent for your local terminal. It can inspect a repository,
edit files, run commands, use browser/terminal helpers, and keep durable local
sessions while you choose the model and the permission level.
```bash
zero # interactive TUI
zero exec "fix the failing test in ./pkg" # headless one-shot
zero exec -o stream-json < turns.jsonl # programmatic, for scripts & CI
zero
zero exec "fix the failing test in ./pkg"
zero exec --output-format stream-json < turns.jsonl
```
> Zero treats the **model as a swappable, per-task choice** and **never mutates your system without a permission decision**.
## Why Zero
- 🔌 **25+ providers, one interface** — OpenAI, Anthropic, Gemini, Ollama (local & cloud), LM Studio, OpenRouter, Groq, DeepSeek, Mistral, xAI, Qwen, Kimi, GitHub Models, and any OpenAI- or Anthropic-compatible endpoint. Switch mid-session with `/model`.
- 🖥️ **A TUI that feels premium** — truecolor Bubble Tea interface with a first-run setup wizard, searchable live model picker, scrollback, themes, image input, and slash commands for everything.
- 🤖 **Headless & scriptable** — `zero exec` with `text` / `json` / `stream-json` I/O, session resume & fork, isolated `--worktree` runs, and meaningful exit codes. Built for CI.
- 🧠 **Subagents** — delegate to built-in `worker`, `explorer`, and `code-review` specialists (or generate your own) that run as real background tasks, even out-of-process.
- 📋 **Spec mode** — have the agent draft a spec first, review and approve it, *then* let it build. No more runaway sessions.
- 📈 **Mid-run model escalation** — start cheap, and let the agent request a stronger model only when it hits a wall (`--allow-escalation`).
- 🗺️ **Repo intelligence** — deterministic repo maps, workspace indexing, and context-budget reports keep the agent grounded in *your* codebase, not hallucinations.
- ⏰ **Scheduled agents** — `zero cron` runs file-backed, dependency-free agent jobs on a schedule.
- 🛡️ **Safe by default** — permission-gated mutations, autonomy ceilings, sandbox policy (writes stay inside the workspace unless you grant extra directories with `--add-dir` / `/add-dir`), and secret redaction everywhere. Unsafe mode is an explicit, loudly-labeled opt-in.
- 💾 **Durable sessions** — append-only local event store with full-text search, resume, fork, and rewind. Your history never leaves your disk.
- 🧩 **Extensible** — skills, plugins, hooks, and MCP (Zero is both an MCP client *and* an MCP server).
- **Use the model you want.** Bring OpenAI, Anthropic, Gemini, Groq, OpenRouter,
DeepSeek, Mistral, xAI, Qwen, Kimi, GitHub Models, Ollama, LM Studio, or any
OpenAI-/Anthropic-compatible endpoint.
- **Stay in control.** File writes, shell commands, network access, and
out-of-workspace writes go through Zero's permission and sandbox policy.
- **Works in the terminal.** The TUI has model/provider pickers, image input,
slash commands, live plan/tool rendering, scrollback, themes, and resume/fork
support.
- **Works without the TUI.** `zero exec` is scriptable, supports text/JSON/
stream-JSON I/O, isolated worktrees, spec-first runs, and meaningful exit
codes for CI.
- **Keeps context local.** Sessions are stored on disk, searchable, resumable,
and never uploaded as telemetry by Zero.
- **Extensible when you need it.** Use MCP servers, skills, plugins, hooks, and
specialist subagents from the same CLI.
## Quick start
## Install
```bash
# run from source (requires Go 1.25+)
go run ./cmd/zero
```
Or, once the first release is published, install the prebuilt binary via npm — the wrapper
downloads the right binary for your platform:
### npm
```bash
npm install -g @gitlawb/zero
zero
```
> **Pre-built binaries are coming soon.** The npm package and the `scripts/install.sh`
> (Linux/macOS) / `scripts/install.ps1` (Windows) installers all download GitHub Release
> assets, which require the first release to be published. Until then, build from source with
> `go run ./cmd/zero` (or `go build -o zero ./cmd/zero`).
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.
First launch opens a **guided setup wizard** — pick a provider, paste a key, choose a model, done. Or do it non-interactively:
### 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
export OPENAI_API_KEY=sk-... # or ANTHROPIC_API_KEY, GEMINI_API_KEY, GROQ_API_KEY, ...
zero setup # guided first-run provider setup
zero doctor # verify config, keys, and connectivity
# 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
```
Local models need no key at all:
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:
```bash
# Ollama or LM Studio running locally? Zero finds them.
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
```
### From source
Source builds require Go 1.25+.
```bash
git clone https://github.com/Gitlawb/zero.git
cd zero
go run ./cmd/zero
```
Release installers and the npm wrapper require published GitHub Release assets.
If you are testing before the first public release, build from source:
```bash
go build -o zero ./cmd/zero
```
On Linux, build the sandbox helper too if you want native sandboxing:
```bash
go build -o zero-linux-sandbox ./cmd/zero-linux-sandbox
go build -o zero-seccomp ./cmd/zero-seccomp # optional compatibility wrapper
```
Put `zero` and `zero-linux-sandbox` in the same directory on `PATH`
(`~/.local/bin` is a good default). macOS does not need an extra helper binary.
Windows source builds can use the main `zero.exe` as their sandbox helper; release
archives still ship standalone Windows helper executables.
More install details: [docs/INSTALL.md](docs/INSTALL.md).
## First Run
Start the TUI:
```bash
zero
```
The setup wizard helps you pick a provider and model. You can also configure
providers from the command line:
```bash
zero setup
zero providers list
zero models list
zero doctor
```
## The TUI
Type to chat, **Enter** to send. `/` opens command suggestions, **Shift+Tab** cycles permission modes, **Ctrl+C** exits.
| | |
|---|---|
| `/model` `/provider` | switch model or provider mid-session (searchable picker) |
| `/spec` `/plan` | spec-mode drafting and live plan view |
| `/image` | attach images for vision models |
| `/resume` `/rewind` | time-travel across sessions |
| `/compact` `/context` | manage the context window |
| `/permissions` `/tools` | inspect what the agent can touch |
| `/add-dir` | grant an extra write directory for the session, or list current write roots |
| `/theme` `/style` | make it yours |
| `/doctor` `/config` | health and config without leaving the chat |
Turn-completion notifications (terminal bell / OSC-9) ping you when the agent finishes or needs input — go make coffee.
## Accessibility & appearance
Zero ships sensible defaults and a few environment/command controls. Meaning never depends on color
alone — diffs carry `+`/`−` signs, permission outcomes use `✓`/`✗` glyphs plus a text `PERMISSION`
badge, and permission modes show text labels — so the UI stays readable when color is stripped.
| Control | What it does |
|---|---|
| `NO_COLOR` (any non-empty value) | Disables color, per [no-color.org](https://no-color.org). Zero honors **any** non-empty value (`NO_COLOR=1`, `yes`, `true`, …); bold/underline still render. |
| `ZERO_THEME=auto\|dark\|light` | Selects the palette at startup. `auto` (default) detects the terminal background. |
| `--theme auto\|dark\|light` | Same as `ZERO_THEME`, as a launch flag (takes precedence over the env var). |
| `/theme [auto\|dark\|light]` | Switches the palette live inside the TUI. |
| `ZERO_NO_FADE=1` | Reduce-motion opt-out: disables the streaming-text fade animation. Fade also auto-disables over SSH/tmux and on low-color/no-TTY terminals. |
## Headless `exec`
For API providers, set the matching environment variable before setup or enter
the key in the wizard:
```bash
# one-shot
zero exec "explain internal/agent/loop.go and suggest one improvement"
export OPENAI_API_KEY=sk-...
export ANTHROPIC_API_KEY=...
export GEMINI_API_KEY=...
```
# pick a model and mode preset per task
zero exec --model claude-sonnet-4.5 --mode deep "refactor the session store"
For local models, run Ollama or LM Studio and then use `zero setup` or
`zero providers detect`.
# spec-first: draft → review → approve → build
## Daily Use
### Interactive TUI
```bash
zero
```
Useful controls:
| Control | Action |
|---|---|
| `Enter` | send the prompt |
| `/` | open slash-command suggestions |
| `Shift+Tab` | cycle permission mode |
| `Ctrl+B` | show/hide the sidebar |
| `Ctrl+C` | cancel or exit |
Common slash commands:
| Command | Purpose |
|---|---|
| `/model`, `/provider` | switch the active model/provider |
| `/spec`, `/plan` | draft and review a plan before building |
| `/image` | attach an image for vision-capable models |
| `/resume`, `/rewind` | continue or roll back local sessions |
| `/compact`, `/context` | manage context usage |
| `/permissions`, `/tools` | inspect available tools and policy |
| `/add-dir` | allow an extra write directory for this session |
| `/theme`, `/doctor`, `/config` | adjust appearance and inspect setup |
### Headless `exec`
```bash
zero exec "explain internal/agent/loop.go"
zero exec --model claude-sonnet-4.5 "refactor the config loader"
zero exec --use-spec "add rate limiting to the API client"
zero exec --worktree "try the migration in an isolated worktree"
zero exec --resume
zero exec --fork <session-id> "try the other approach"
```
# run in an isolated git worktree, escalate model only if needed
zero exec -w --allow-escalation "migrate the config loader to v2"
Programmatic use:
# multi-turn programmatic I/O over stdio
```bash
zero exec --input-format stream-json --output-format stream-json < turns.jsonl
# resume or fork any previous session
zero exec --resume # latest
zero exec --fork <session-id> "now try the other approach"
```
Key flags: `-m/--model` · `--mode <smart|deep|fast|large|precise>` · `--image` · `--use-spec` · `--auto <low|medium|high>` · `--enabled-tools/--disabled-tools` · `-w/--worktree` · `--add-dir <path>` (repeatable) · `--resume/--fork` · `--allow-escalation` · `--notify` · `-o <text|json|stream-json>`.
The stream-JSON contract is documented in
[docs/STREAM_JSON_PROTOCOL.md](docs/STREAM_JSON_PROTOCOL.md).
stdout carries **only** program output; logs go to stderr. Full contract in [`docs/STREAM_JSON_PROTOCOL.md`](docs/STREAM_JSON_PROTOCOL.md).
## Safety Model
## Commands
Zero is designed to make side effects visible.
- Workspace reads are allowed by default.
- File writes are limited to the workspace unless you grant another directory.
- Shell commands, network access, destructive commands, and elevated actions are
permission-gated.
- `--add-dir <path>` and `/add-dir <path>` grant additional write roots without
giving the agent the whole filesystem.
- Unsafe/autonomous modes are explicit opt-ins.
- Secrets are redacted from tool output and logs where Zero controls the surface.
Example:
```bash
zero --add-dir ../docs-site
zero exec --add-dir ../shared "update both repos"
```
Sandbox behavior can be inspected with:
```bash
zero sandbox policy
zero sandbox grants list
```
## Web And Local Control
Zero includes local file/search/edit/shell tools, `web_fetch` for public URLs,
and MCP support for additional tools.
For local dev servers, use shell commands such as `curl` through `exec_command`
so the normal sandbox and permission policy applies. Long-running commands stay
attached to a background terminal session and can be listed or stopped from the
TUI.
The npm package also includes browser and terminal helper packages used by local
browser/terminal tools. Source builds can use the same helpers when they are on
`PATH` or configured in Zero's local-control settings.
## Common Commands
```text
zero interactive TUI
zero exec one-shot / scripted agent runs
zero setup guided first-run provider setup
zero models model registry (capabilities, context, cost)
zero providers provider profiles + 25-provider catalog
zero doctor config, key, and connectivity health checks
zero context workspace context-budget report
zero repo-map deterministic repository map for agent context
zero repo-info local (network-free) repository characterizer
zero search | find full-text search over local session history
zero sessions session lineage inspection
zero spec review & approve saved spec-mode drafts
zero specialist manage subagent profiles
zero skills markdown instruction skills
zero plugins plugin manifests
zero hooks lifecycle hook configuration
zero mcp MCP client settings
zero serve --mcp expose Zero's tools over MCP stdio
zero sandbox sandbox policy & persistent grants
zero worktrees isolated git worktrees for agent runs
zero verify detect & run local verification checks
zero changes inspect & commit local git changes
zero exec one-shot or scripted agent run
zero setup first-run provider setup
zero auth OAuth/login helpers for supported providers
zero models model registry and capabilities
zero providers provider profiles and detection
zero doctor setup, key, and connectivity checks
zero context context-budget report
zero repo-map deterministic repository map
zero repo-info local repository summary
zero search | find search local session history
zero sessions inspect, resume, fork, and rewind sessions
zero spec manage spec-mode drafts
zero specialist manage specialist subagents
zero skills manage markdown instruction skills
zero plugins manage plugins
zero hooks manage lifecycle hooks
zero mcp manage MCP servers and tools
zero serve --mcp expose Zero tools over MCP stdio
zero sandbox inspect sandbox policy and grants
zero worktrees prepare isolated git worktrees
zero verify detect and run local verification checks
zero changes inspect and commit local git changes
zero usage token usage and estimated cost
zero cron scheduled agent jobs (file-backed, dep-free)
zero cron scheduled agent jobs
zero update check for newer releases
```
## Providers
## Appearance And Accessibility
Bring your own key — or no key at all for local runtimes.
| Tier | Providers |
| Control | Effect |
|---|---|
| **Frontier APIs** | OpenAI · Anthropic · Google Gemini |
| **Fast cloud inference** | Groq · OpenRouter · Together AI · DeepSeek · Mistral · xAI · NVIDIA NIM |
| **Local — no key, no cloud** | Ollama · LM Studio |
| **More clouds** | Ollama Cloud · DashScope (Qwen) · Moonshot (Kimi) · MiniMax · Z.ai · Venice · GitHub Models · and more |
| **Enterprise (catalog)** | Amazon Bedrock · Vertex AI *(adapters in progress)* |
| **Anything else** | any OpenAI-compatible or Anthropic-compatible endpoint |
| `NO_COLOR=<anything>` | disables color output |
| `ZERO_THEME=<name>` | selects the startup theme (`auto`, `dark`, `light`, or a color theme like `dracula`, `nord`, `gruvbox`, `tokyo-night`, `catppuccin`, `one-dark`, `solarized-dark`, `rose-pine`, `everforest`, `solarized-light`) |
| `--theme <name>` | selects the TUI theme from the CLI (same names) |
| `/theme` | opens the theme picker inside the TUI (live preview; `/theme <name>` switches directly) |
| `ZERO_NO_FADE=1` | disables streaming fade animation |
The model registry tracks each model's capabilities, context window, and cost — and the live model picker discovers what your provider actually serves.
**Slow or stalling streams.** Every provider aborts a stream that goes completely silent (no data and no keep-alive) for `ZERO_STREAM_IDLE_TIMEOUT` (default **5 minutes**) so a hung connection can't block the agent forever. SSE keep-alives reset the timer, so a heartbeating-but-slow backend is never killed. If a slow cloud or reasoning model still trips it, raise the limit — `ZERO_STREAM_IDLE_TIMEOUT=10m` (accepts a Go duration or bare seconds; `0`/`off` disables the watchdog).
## Tools
| Tool | Purpose | Side effect |
|---|---|---|
| `read_file` · `list_directory` · `grep` · `glob` | explore & search | read |
| `web_fetch` | fetch docs & references | network |
| `update_plan` · `ask_user` | plan & clarify | none |
| `write_file` · `edit_file` · `apply_patch` | create & modify | write (gated) |
| `exec_command` · `write_stdin` | run shell commands, keep long-running processes alive, poll/send input | shell (gated on start/input) |
| `bash` | run one-shot legacy shell commands | shell (gated) |
| `Task` · `TaskOutput` · `TaskStop` | delegate to specialist subagents | per-tool gating |
| `GenerateSpecialist` | create new subagent manifests | write (gated) |
| `skill` | load markdown instruction skills | read |
| `tool_search` | lazily load deferred tools (large MCP sets stay cheap) | none |
| `escalate_model` | request a stronger model mid-run | gated by `--allow-escalation` |
Every mutating tool routes through the permission policy **before** any side effect.
### Web search & scraping (free, no API key)
`web_fetch` is built in and always available: it pulls a single public remote URL
into clean markdown locally, with no third party. For localhost/private dev-server
URLs, use shell commands such as `curl` through `exec_command` so sandbox network
permission applies. For local dev servers, start the server in the foreground with
`exec_command` and keep the returned `session_id`; use `write_stdin` to poll or
interrupt it. Use `/ps` to list running background terminals and `/stop` or
`/stop <session_id>` to close them. Interactive terminal-style commands can set
`tty: true` on `exec_command` so stdin works like a terminal session.
For **search**, JS-rendered scraping,
whole-site crawls, PDF→markdown, and structured extraction, Zero ships the
[Firecrawl](https://firecrawl.dev) **keyless** MCP server **enabled by default**, so
web search and scraping work out of the box with **no setup and no API key** (1,000
free credits/month). It is a normal MCP server, so you stay in control:
```bash
zero mcp tools list # see the firecrawl_* tools it exposes
zero mcp disable firecrawl # turn it off — web_fetch stays as the local floor
```
- **Privacy / disclosure:** keyless requests route through `firecrawl.dev`. If you
prefer nothing leaves your machine, `zero mcp disable firecrawl` and rely on
`web_fetch`, or self-host below.
- **Self-host (unlimited, private):** Firecrawl is open source (AGPL-3.0). Run your
own instance and override the default URL — Zero only *calls* it over the network,
so the AGPL never reaches into Zero's own code:
```jsonc
// config.json
{ "mcp": { "servers": { "firecrawl": { "type": "http", "url": "http://localhost:3002/v2/mcp" } } } }
```
- **Bring your own key (higher limits):** add an auth header over the default:
```bash
zero mcp add firecrawl --type http --url https://mcp.firecrawl.dev/v2/mcp \
--header "Authorization: Bearer fc-your-key"
```
An MCP server that can't be reached at startup (e.g. Firecrawl on an offline
machine) is **skipped with a warning, not fatal** — Zero still launches and the
rest of its tools, including `web_fetch`, keep working.
### Extra write directories (`--add-dir`)
Zero confines writes to the workspace by default. To let the agent write somewhere
else, pass the repeatable `--add-dir` flag — it works for both the interactive TUI
and `zero exec`:
```bash
zero --add-dir ~/Desktop/scratch # launch the TUI with an extra write root
zero exec --add-dir ../sibling-repo "update both repos"
```
In the TUI, `/add-dir <path>` grants a directory mid-session (session-only), and a
bare `/add-dir` lists the current write roots. To persist extra roots across
sessions, set `sandbox.additionalWriteRoots` in the **global** user config
(`~/.config/zero/config.json`); the key is deliberately ignored in project config
so a checked-out repo can't widen its own sandbox. Flag and config sources merge
as a union.
Granted roots must already exist (the filesystem root is rejected), symlinks are
resolved when the grant is made, and the same per-root symlink-traversal checks
that protect the workspace apply to each extra root. Relative paths in tool calls
still resolve against the workspace only, and network and destructive-shell policy
are unchanged. A write denied outside all roots returns an error that suggests
`/add-dir`.
Two extra hardening/diagnostic flags are available in the `sandbox` config block,
both **off by default** and safe to leave unset:
- `sandbox.blockUnixSockets` (Linux) — asks the Linux sandbox helper to install a
best-effort seccomp filter that denies `AF_UNIX` socket creation in the
sandboxed command. Linux packages also continue to ship `zero-seccomp` as a
compatibility wrapper for existing direct uses; new sandbox plans do not depend
on that external wrapper.
- `sandbox.monitorDenials` (macOS) — tails the unified log for this run's seatbelt
denials and appends them to a command's stderr as a `<sandbox_blocks>` block
so blocked operations are visible. A no-op on OS versions that do not deliver
seatbelt denials to the queryable log.
## Architecture
```
TUI (Bubble Tea) headless exec MCP server cron runner
└──────────────────────┬──────────────────────┘
surface-agnostic agent core
(loop · typed event stream · tool registry)
┌──────────┬──────────┬───────────┬───────────┬────────────┬──────────┐
providers tools sessions specialist repo intel permissions
+ catalog registry + search + background + workspace + sandbox
+ registry + rewind tasks index + redaction
```
- **Surface-agnostic core** — the agent loop streams text + tool calls and emits one typed event stream consumed identically by the TUI, `exec`, the MCP server, and cron.
- **Edges are interfaces** — `Provider`, `Tool`, `SessionStore`, and the permission policy are swappable.
- **Model is data** — capabilities, cost, and routing live in the registry, never hard-coded.
- **Pure Go** — one static binary per platform; the npm wrapper just delegates to it.
## Project layout
```
cmd/
zero/ production CLI entrypoint
zero-release/ release builder + smoke tests
zero-perf-bench/ performance benchmarks
zero-pr-review/ deterministic PR review helper
internal/
agent/ zeroruntime/ agent loop & runtime orchestration
cli/ command surface (exec, doctor, cron, ...)
tui/ Bubble Tea terminal interface
providers/ providercatalog/ providermodelcatalog/
modelregistry/ capabilities, context windows, cost
tools/ read/write/edit/bash/grep/glob/patch/...
specialist/ background/ subagents + out-of-process tasks
sessions/ search/ append-only store, full-text search
repomap/ repoinfo/ workspaceindex/ contextreport/
specmode/ cron/ skills/ plugins/ hooks/ mcp/
sandbox/ redaction/ secrets/ safety surfaces
doctor/ providerhealth/ verify/ selfverify/
docs/ PRD, protocols, install/update/perf
scripts/ installers
```
Meaning does not rely on color alone; diffs, permissions, and statuses also use
text or glyph markers.
## Development
```bash
go test ./... # full test suite (200+ test files)
go run ./cmd/zero-release build # compile the release binary
go run ./cmd/zero-release smoke # smoke-test it
go run ./cmd/zero-perf-bench # perf benchmarks (docs/PERFORMANCE.md)
go test ./...
go run ./cmd/zero-release build
go run ./cmd/zero-release smoke
go run ./cmd/zero-perf-bench
```
# cross-compile
Cross-compile examples:
```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
```
## Documentation
- [Product Requirements (PRD)](docs/PRD.md) — vision, full feature spec, roadmap
- [Stream-JSON protocol](docs/STREAM_JSON_PROTOCOL.md) — headless I/O contract
- [Specialists](docs/SPECIALISTS.md) — subagent manifests, Task tools, background state
- [Install](docs/INSTALL.md) · [Update](docs/UPDATE.md) · [Performance](docs/PERFORMANCE.md)
- [Install](docs/INSTALL.md)
- [Update flow](docs/UPDATE.md)
- [Stream-JSON protocol](docs/STREAM_JSON_PROTOCOL.md)
- [Specialists](docs/SPECIALISTS.md)
- [GitHub Action](docs/GITHUB_ACTION.md)
- [Benchmarks](docs/BENCHMARK.md)
- [Performance](docs/PERFORMANCE.md)
- [Agent evals](docs/AGENT_EVALS.md)
## Contributing
Contributions are welcome — see [CONTRIBUTING.md](CONTRIBUTING.md). Run `go test ./...` and the relevant build or smoke command before opening a PR.
Contributions are welcome. Read [CONTRIBUTING.md](CONTRIBUTING.md), run the
relevant tests, and open a focused pull request.
Security reports should follow [SECURITY.md](SECURITY.md).
## License
Zero is released under the [MIT License](LICENSE) — © 2026 Gitlawb.
---
<div align="center">
<sub><b>Zero</b> — one terminal · every model</sub>
</div>
Zero is released under the [MIT License](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);
}
+135 -36
View File
@@ -1,32 +1,68 @@
# Zero Install Scripts
# Installing Zero
> **Status: pre-release.** No GitHub release has been published yet, so the install scripts below
> (which download release assets from `Gitlawb/zero`'s Releases) will fail with a 404 until the first
> release exists. Until then, build from source: `go run ./cmd/zero` or `go build -o zero ./cmd/zero`
> (requires Go 1.25+). The instructions below describe how the scripts work once releases are published.
Zero is distributed as:
Zero release archives are published as:
- an npm package, `@gitlawb/zero`
- release archives on GitHub Releases
- source builds with Go 1.25+
- `zero-v<version>-linux-<arch>.tar.gz`
- `zero-v<version>-macos-<arch>.tar.gz`
- `zero-v<version>-windows-<arch>.zip`
The npm package and install scripts download a platform-specific release archive.
They require a published GitHub Release for the requested version.
Each archive must have a matching `.sha256` file. The install scripts download both files and verify the checksum before copying the binary.
Maintainers can verify the release directory before upload:
## npm
```bash
go run ./cmd/zero-release package
go run ./cmd/zero-release verify
npm install -g @gitlawb/zero
zero
```
`verify:release` requires every archive in `dist/release` to have a same-directory `.sha256` file whose contents are:
The package supports Linux, macOS, and Windows on x64 and arm64. It installs the
`zero` command and downloads the matching release binary during `postinstall`.
```text
<sha256> <archive-name>
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
```
## Linux And macOS
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:
```bash
curl -fsSL https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.sh | bash
```
From a checkout:
@@ -38,18 +74,14 @@ Install a specific version:
```bash
ZERO_VERSION=0.1.0 scripts/install.sh
scripts/install.sh --version 0.1.0
```
Install to a custom directory:
Install somewhere else:
```bash
ZERO_INSTALL_DIR="$HOME/bin" scripts/install.sh
```
Install from a fork or internal repository:
```bash
scripts/install.sh --repo owner/repo
scripts/install.sh --install-dir "$HOME/bin"
```
Defaults:
@@ -58,9 +90,15 @@ Defaults:
- Version: latest GitHub release
- Install path: `~/.local/bin/zero`
Requirements: `curl` or `wget`, `tar`, and `shasum` or `sha256sum`.
Requirements: Bash, `curl` or `wget`, `tar`, and `shasum` or `sha256sum`.
## Windows
## Windows PowerShell Script
Install the latest release:
```powershell
irm https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.ps1 | iex
```
From a checkout:
@@ -74,30 +112,91 @@ Install a specific version:
powershell -ExecutionPolicy Bypass -File scripts/install.ps1 -Version 0.1.0
```
Install to a custom directory:
Install somewhere else:
```powershell
powershell -ExecutionPolicy Bypass -File scripts/install.ps1 -InstallDir "$env:USERPROFILE\bin"
```
Install from a fork or internal repository:
```powershell
powershell -ExecutionPolicy Bypass -File scripts/install.ps1 -Repository owner/repo
```
Defaults:
- Repository: `Gitlawb/zero`
- Version: latest GitHub release
- Install path: `%LOCALAPPDATA%\zero\bin\zero.exe`
## From Source
```bash
git clone https://github.com/Gitlawb/zero.git
cd zero
go run ./cmd/zero
```
Build a local binary:
```bash
go build -o zero ./cmd/zero
```
Source builds require Go 1.25+.
### Sandbox Helpers For Source Builds
Release archives include the platform sandbox helpers. If you build directly
from source, build the helpers you need:
Linux:
```bash
go build -o zero ./cmd/zero
go build -o zero-linux-sandbox ./cmd/zero-linux-sandbox
go build -o zero-seccomp ./cmd/zero-seccomp
```
Put `zero` and `zero-linux-sandbox` in the same directory on `PATH`, for example
`~/.local/bin`. `zero-seccomp` is kept as a compatibility wrapper; the sandbox
helper applies the Unix-socket filter itself when that sandbox option is enabled.
Linux native sandboxing also requires Bubblewrap to be installed.
macOS uses the system sandbox and does not need an extra helper binary.
Windows source builds can use the main `zero.exe` as the command runner and setup
helper through Zero's built-in self-dispatch path. If you want a release-style
layout anyway, build the standalone helper executables next to `zero.exe`:
```powershell
go build -o zero.exe ./cmd/zero
go build -o zero-windows-command-runner.exe ./cmd/zero-windows-command-runner
go build -o zero-windows-sandbox-setup.exe ./cmd/zero-windows-sandbox-setup
```
## Release Archive Format
Release archives are named:
- `zero-v<version>-linux-<arch>.tar.gz`
- `zero-v<version>-macos-<arch>.tar.gz`
- `zero-v<version>-windows-<arch>.zip`
Supported targets:
- `linux-x64`
- `linux-arm64`
- `macos-x64`
- `macos-arm64`
- `windows-x64`
- `windows-arm64`
Each archive must have a matching `.sha256` file. The install scripts download
both files, verify the checksum, and then copy the binary into the install
directory.
## Updating
Check whether a newer release exists:
Check for a newer release:
```bash
zero update --check
```
For M2, updates are check-only. Re-run the installer to replace the local binary after reviewing the release.
Then reinstall with npm or rerun the install script for the version you want.
-87
View File
@@ -1,87 +0,0 @@
# M1 Headless Exec PRD
Status: Draft
Owner: Vasanth
Milestone: M1
Scope: `zero exec` CLI surface
## Goal
Ship the first production-shaped headless command for Zero:
```sh
zero exec "inspect this repo and summarize the next fix"
```
This command should be useful for local scripts, CI smoke runs, and later VS Code integration without pulling in the full future protocol, session, MCP, sandbox, or plugin roadmap.
## Non-Goals
- No daemon mode.
- No JSON-RPC input protocol.
- No MCP, plugin, marketplace, or subagent support.
- No session resume/fork/search.
- No new provider adapters in this slice.
- No architecture rewrite of the agent loop.
## User Stories
1. As a CLI user, I can run one prompt headlessly and receive the assistant answer on stdout.
2. As a script author, I can choose `text` or JSONL output.
3. As a reviewer, I can see tool activity without corrupting text stdout.
4. As a model tester, I can override the configured model for one run.
5. As a cautious user, prompt-gated tools stay disabled unless I pass an explicit unsafe flag.
## CLI Contract
```sh
zero exec [prompt...] [options]
```
Options for M1:
- `-f, --file <path>`: read the prompt from a file.
- `-m, --model <model>`: override the configured model for this run.
- `-C, --cwd <path>`: run from another working directory.
- `-o, --output-format <text|json>`: choose stdout format.
- `--skip-permissions-unsafe`: grant prompt-gated tools for this run.
The existing `zero -p "prompt"` shortcut remains as a compatibility path and should call the same runner.
## Output Contract
Text mode:
- stdout contains assistant text only.
- stderr contains tool rows, warnings, and errors.
JSON mode:
- stdout is newline-delimited JSON events.
- Required event types for this slice: `run_start`, `text`, `tool_call`, `tool_result`, `final`, `warning`, `error`, `done`.
## Exit Codes
- `0`: success
- `1`: unexpected crash
- `2`: usage error
- `3`: provider/config/runtime error
- `4`: reserved for future tool failure policy
- `5`: reserved for future permission-denied policy
## Permission Behavior
Default headless mode advertises only tools marked `permission: "allow"`.
`--skip-permissions-unsafe` advertises tools that are not marked `deny` and grants prompt-gated tools through `ToolRegistry.run`. It must not bypass schema validation or direct tool execution safeguards.
## Acceptance Criteria
- `go test ./...` passes.
- `go run ./cmd/zero-release build` passes.
- `go run ./cmd/zero-release smoke` passes.
- `zero exec --help` documents the M1 flags.
- Usage errors do not initialize providers.
- Provider failures return exit code `3`.
- Normal mode does not advertise `bash`, `write_file`, `edit_file`, or `apply_patch`.
- Unsafe mode still uses the registry execution path.
+1 -1
View File
@@ -1,6 +1,6 @@
# Zero Performance Benchmarks
The M2 performance harness tracks three release-facing signals:
The performance harness tracks three release-facing signals:
- Cold start: process startup time for `zero --version`.
- Binary first output: time from spawning the built `zero --version` command to
-602
View File
@@ -1,602 +0,0 @@
# Zero — Product Requirements Document (v1.0)
**Status:** DRAFT v1.0
**Target:** Compete with Claude Code + Cursor + other multi-provider terminal agents
**Timeline:** 6-12 months
**Stack:** Go native core + npm distribution wrapper
---
## 1. Vision
**Zero is a terminal-first, editor-aware, multi-provider AI coding platform.**
It combines the best of:
- **Claude Code** — terminal-native agentic coding
- **Cursor** — IDE integration + smart editing
- **Multi-provider terminal agents** — provider-agnostic + headless
**One Go-native agent core. Three surfaces. Zero lock-in.**
---
## 2. Core Surfaces
### 2.1 Terminal (TUI) — Primary
- Interactive coding agent in terminal
- Streaming, append-style (like Claude Code)
- Bubble Tea + Lip Gloss-based
- Full keyboard control
- Mouse support
### 2.2 Editor (VS Code Extension)
- Same agent core, different UI
- Inline suggestions + chat panel
- File editing with diff view
- Diagnostics integration
- LSP-aware
### 2.3 Headless (CLI/CI)
- `zero exec "prompt"` for scripts
- `zero serve` for daemon mode
- JSON-RPC over stdio/WebSocket
- MCP server support
---
## 3. Architecture
```
┌─────────────────────────────────────────────────┐
│ ZERO PLATFORM │
├─────────────────────────────────────────────────┤
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ TUI │ │ VSCode │ │ Headless │ │
│ │ Surface │ │ Surface │ │ Surface │ │
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
│ │ │ │ │
│ └─────────────┼─────────────┘ │
│ │ │
│ ┌──────▼──────┐ │
│ │ Agent Core │ │
│ │ (ReAct) │ │
│ └──────┬──────┘ │
│ │ │
│ ┌──────┬──────┬────┴────┬──────┬──────┐ │
│ │Tools │Perms │Sessions │Provi │Memory│ │
│ └──────┴──────┴─────────┴──────┴──────┘ │
│ │
└─────────────────────────────────────────────────┘
```
**Layering Rules:**
- Agent Core never imports a surface
- Surfaces are thin shells (I/O + rendering)
- All state persists to local files
- No cloud dependency
---
## 4. Functional Requirements (24 Features)
### F1 — Agent Core (P0)
- ReAct loop: think → tool → result → think
- Stream text + tool calls + thinking
- Parallel tool execution where safe
- Bound turns (max 100)
- Clean interrupt (Esc/Ctrl-C)
- Event stream: `text | tool_call | tool_result | thinking | usage | plan_update | error | turn_end`
### F2 — Tools (P0)
**Core (8):**
- `read_file` — read with line ranges, images
- `write_file` — create/overwrite
- `edit_file` — exact-string edit (uniqueness guard)
- `bash` — run shell command
- `grep` — ripgrep content search
- `glob` — file pattern matching
- `apply_patch` — multi-hunk patches
- `update_plan` — todo list
**Advanced (4):**
- `web_fetch` / `web_search` — web access
- `task` — spawn subagent
- `structured_output` — JSON-Schema validated
- `notebook_edit` — Jupyter notebooks
**Tool Requirements:**
- Go typed parameter schemas -> JSON Schema
- Side-effect class: `read | write | shell | network | out_of_workspace`
- Structured results: `{ status, output, truncated?, meta? }`
- Atomic execution
- Permission gating
### F3 — Providers & Model Registry (P0)
- OpenAI-compatible (works with OpenAI, OpenRouter, Ollama, etc.)
- Anthropic (Claude)
- Google Gemini
- Azure OpenAI
- AWS Bedrock
- xAI (Grok)
**Model Registry:**
```go
type ModelEntry struct {
ID string
Name string
Provider string
APIProviders []string
ContextLimits ContextLimits
ReasoningEffort ReasoningEffort
Capabilities ModelCapabilities
Cost ModelCost
Tier string
MatchPatterns []string
}
```
- Per-task model selection (`-m`)
- Provider rotation/failover
- Cost tracking
- "Spec model" for planning
- Fallback model on failure
### F4 — Terminal UI (P0)
- **Layout:** header (cwd · git · model) → tool rows → diff → response → input → footer
- **Streaming:** append-only transcript model with live tail updates
- **Markdown:** full CommonMark + syntax highlighting
- **Tool rows:** collapsible with one-line summaries
- **Input:** `/` commands, `@` files, `!` bash, `Esc` interrupt
- **Robustness:** resize, wide-char, ANSI, non-TTY fallback
- **Splash:** ZERO wordmark
- **Themes:** light/dark + custom
### F5 — Sessions (P0)
- Persist to `~/.local/share/zero/sessions/<id>/`
- `meta.json` + `events.jsonl` + `messages.jsonl` + `checkpoints/`
- `--resume [id]` (default: latest)
- `--fork <id>` (copy with lineage)
- Search: `zero search <query>` over messages/tools/results
- Atomic writes (write-temp-then-rename)
- Schema versioning
- Export to Markdown/JSON
### F6 — Configuration (P0)
- Layered: defaults → user → project → env → CLI
- `~/.config/zero/config.json` (user)
- `./.zero/config.json` (project)
- Runtime overlay (`--settings <path>`)
- Feature flags for staged rollout
- Validation on load
### F7 — MCP (Model Context Protocol) (P0)
- Connect to stdio/HTTP/SSE servers
- Surface MCP tools into registry
- `zero mcp add/remove/list`
- Per-server and per-tool permissions
- Persistent grants with revoke/clear
- MCP server mode (zero as MCP server)
### F8 — Headless / Programmatic (P0)
- `zero exec [prompt]` — one-shot
- `zero serve` — daemon mode
- `--input-format stream-json` — line-delimited events
- `--input-format stream-jsonrpc` — JSON-RPC protocol
- `-o text|json` output formats
- Exit codes: `0=success, 1=crash, 2=usage, 3=provider, 4=tool, 5=permission, 6=budget`
### F9 — Permissions & Autonomy (P0)
- **Side-effect classes:** read, write, shell, network, out_of_workspace
- **Autonomy levels:** low (ask), medium (smart), high (auto)
- `--skip-permissions-unsafe` (loud warning)
- TUI: inline prompts (allow/deny/always)
- Headless: default-deny unless granted
- Persistent grants with list/revoke/clear
- Audit log in `events.jsonl`
**Permission Matrix:**
| Tool Class | low | medium | high |
|------------|-----|--------|------|
| Read | allow | allow | allow |
| Workspace write | prompt | allow | allow |
| Shell | prompt | prompt | allow |
| Network | prompt | allow | allow |
| Out-of-workspace | deny | prompt | prompt |
### F10 — Observability (P0)
- Structured logging (stdout/stderr split)
- Secret redaction (API keys, tokens, passwords)
- No vendor telemetry (local only)
- `zero doctor` — health check command
- Crash reports (opt-in, local)
- Performance metrics (TTFT, tokens/sec)
### F11 — Subagents (P1)
- `task` tool spawns scoped subagent
- Built-in agents: code-review, security-review, refactor, test-gen
- Recursion depth limit
- Parent session linkage
- Shared/isolated budgets
### F12 — Plugins (P1)
- User/project-scoped plugins
- Commands, tools, prompts, hooks
- Plugin manifest: `plugin.json`
- `zero plugin install <path|url>`
- No marketplace in v1 (manual install)
### F13 — Distribution (P0)
- **Single binary** via Go cross-compilation
- npm package as a thin installer/launcher for the platform binary
- Cross-platform: Linux, macOS, Windows
- `zero update [--check]` self-update
- Checksum verification
- Rollback on failure
### F14 — VS Code Extension (P1)
- Same agent core via stream protocol
- Inline chat panel
- Diff view in editor
- Diagnostics integration
- LSP awareness
- File watching
### F15 — Memory & Context (P1)
- `AGENTS.md` hierarchical memory
- `@import` for modular rules
- Auto-generated from `init` command
- Project conventions learning
- Skills system (reusable instruction packs)
- Context budgeting (token tracking)
- Auto-compaction when full
### F16 — Hooks System (P1)
- Lifecycle hooks: `PreToolUse`, `PostToolUse`, `SessionStart`, `SessionEnd`
- User-defined shell commands
- JSON output for conditional flow
- `.zero/hooks/` directory
### F17 — Slash Commands (P0)
**Built-in (21):**
- Session: `/clear`, `/compact`, `/context`, `/add-dir`, `/rewind`, `/resume`, `/exit`
- Model: `/model`, `/effort`, `/style`, `/permissions`
- Project: `/init`, `/memory`, `/skills`
- Extensibility: `/mcp`, `/hooks`, `/agents`
- Meta: `/help`, `/config`, `/doctor`, `/feedback`
**Custom:** `./.zero/commands/*.md`
### F18 — Git Integration (P1)
- Worktree isolation per task
- Auto-commit with messages
- Branch management
- PR creation
- Diff review
- Conflict resolution
### F19 — Testing & Verification (P1)
- `run_tests` tool
- Self-verification loop
- Test runner integration
- Coverage reporting
- Diagnostics (typecheck, lint)
- LSP integration
### F20 — Web Access (P1)
- `web_fetch` — fetch URL content
- `web_search` — search engine
- Provider/permission gated
- Rate limiting
- Caching
### F21 — Sandbox (P2)
- Linux: bubblewrap
- macOS: sandbox-exec
- Windows: AppContainer
- Filesystem policies (workspace write confinement + user-granted extra roots via `--add-dir`)
- Network policies
- Unix socket controls
### F22 — Multi-modal (P2)
- Image input (screenshots, diagrams)
- PDF parsing
- Voice input (optional)
- File attachments
- Paste support
### F23 — Telemetry (P2)
- Local-only metrics
- Token usage tracking
- Cost reports
- Performance profiling
- Crash reports (opt-in)
- NO vendor telemetry
### F24 — Advanced Features (P2)
- Code review automation
- Auto-refactor
- Dependency updates
- Security scanning
- Documentation generation
---
## 5. Data Models
### 5.1 Headless Event Stream
```json
{ "type": "run_start", "runId": "r1", "sessionId": "s1", "cwd": "/repo", "model": "..." }
{ "type": "text", "delta": "..." }
{ "type": "tool_call", "id": "t1", "name": "bash", "args": {...}, "sideEffect": "shell" }
{ "type": "permission", "id": "t1", "decision": "needs_approval", "reason": "shell" }
{ "type": "tool_result", "id": "t1", "status": "ok", "output": "...", "truncated": false }
{ "type": "usage", "promptTokens": 1234, "completionTokens": 567, "costUsd": 0.012 }
{ "type": "plan_update", "plan": [{ "id": "1", "content": "...", "status": "in_progress" }] }
{ "type": "turn_end", "stopReason": "end_turn" }
{ "type": "error", "code": "rate_limit", "message": "...", "recoverable": true }
{ "type": "run_end", "status": "success", "exitCode": 0 }
```
### 5.2 Session On Disk
```
~/.local/share/zero/sessions/<id>/
meta.json # { schemaVersion, id, parentId?, title, cwd, model, createdAt, updatedAt, tokens, cost }
events.jsonl # append-only AgentEvent stream
messages.jsonl # provider-facing messages
checkpoints/ # snapshots for restore/fork/compact
```
### 5.3 Tool Contract
```go
type Tool interface {
Name() string
Description() string
Parameters() JSONSchema
SideEffect() SideEffect
Execute(ctx ToolContext, args map[string]any) ToolResult
}
type ToolResult struct {
Status string
Output string
Truncated bool
Meta map[string]any
}
```
### 5.4 Config Schema
```json
{
"model": "claude-sonnet-4-6",
"providers": {
"anthropic": { "apiKey": "env:AN..._KEY" },
"openai": { "apiKey": "env:OP..._KEY" }
},
"permissions": {
"autonomy": "medium",
"grants": [{ "tool": "bash", "pattern": "git status", "decision": "allow" }]
},
"ui": { "theme": "dark", "syntaxHighlighting": true }
}
```
---
## 6. Non-Functional Requirements
### Performance
- TTFT < 500ms after provider's first byte
- No full-transcript repaint per token
- Cold start < 300ms (warm cache)
- Tool execution < 100ms overhead
- Session resume < 1s
### Reliability
- Crash-free TUI under resize, wide-char, non-TTY
- Clean interrupt (no half-written state)
- Atomic session writes
- Recovery from malformed JSONL
- Provider failover
### Security
- No mutation without permission decision (hard invariant)
- API keys never logged
- Secret redaction everywhere
- Headless stdout free of logs
- Workspace boundary by default
- Sandboxing for untrusted code
### Portability
- Linux, macOS, Windows
- Any OpenAI-compatible endpoint
- x86_64, ARM64
- Single binary distribution
### Testability
- Mock provider for unit tests
- Fake MCP server/client
- Fake terminal/PTY
- Sandbox simulator
- Deterministic event replay
---
## 7. Milestones
### M0 — Foundation (Weeks 1-2)
- Go module setup
- Agent core skeleton
- 8 core tools (read, write, edit, bash, grep, glob, apply_patch, update_plan)
- OpenAI-compatible provider
- Basic Bubble Tea TUI
- Permission gate (read vs mutate)
- Session persistence
**Exit:** `zero` runs, can read/edit files, basic chat works.
### M1 — Multi-Provider (Weeks 3-4)
- Anthropic provider
- Gemini provider
- Model registry
- Per-task model selection
- Provider rotation
- Cost tracking
- Headless `exec` mode
**Exit:** Switch between Claude/GPT/Gemini, see cost, run headless.
### M2 — Polish (Weeks 5-8)
- Advanced TUI features
- Full markdown + syntax highlighting
- Tool result truncation/pagination
- Auto-compaction
- Context budgeting
- 20 slash commands
- Doctor command
- Search command
**Exit:** Production-ready TUI, smooth streaming, all commands work.
### M3 — Distribution (Weeks 9-10)
- Single binary build
- Self-update
- Cross-platform CI
- VS Code extension MVP
- Documentation site
**Exit:** `zero` ships as one binary, works on Win/Mac/Linux.
### M4 — Extensibility (Weeks 11-14)
- MCP client + permissions
- Hooks system
- Skills system
- Memory (AGENTS.md)
- Plugin loading
- Subagents (built-in: review, security)
**Exit:** Plugins work, MCP servers connect, memory persists.
### M5 — Advanced (Weeks 15-20)
- Web fetch/search
- Git worktrees
- Self-verification
- Diagnostics (LSP, typecheck)
- Testing integration
- Code review automation
**Exit:** Zero can review code, run tests, manage git.
### M6 — Sandbox & Security (Weeks 21-24)
- Linux sandbox (bubblewrap)
- macOS sandbox
- Windows AppContainer
- Network policies
- Filesystem policies
**Exit:** Untrusted code runs safely in sandbox.
### Beyond v1.0
- Multi-modal (images, PDFs)
- Voice input
- Cloud sync (optional)
- Team features
- Marketplace (if demand)
---
## 8. Risks & Mitigations
| # | Risk | Mitigation |
|---|------|------------|
| R1 | Scope creep | Stick to milestones, defer features |
| R2 | Provider API changes | Abstract via interface, test with mocks |
| R3 | TUI complexity | Use Bubble Tea/Lip Gloss, follow mature terminal agent UX patterns |
| R4 | Performance | Benchmark early, optimize streaming |
| R5 | Security vulnerabilities | Permission gate from day 1, security review |
| R6 | Cross-platform bugs | CI on Win/Mac/Linux from M0 |
| R7 | Breaking changes | Schema versioning, migration tools |
---
## 9. Success Metrics
### v1.0 (6 months)
- 5+ providers supported
- 12+ tools
- 100% test coverage on core
- < 300ms cold start
- Single binary < 50MB
- Works on Win/Mac/Linux
- 1000+ GitHub stars
- 50+ contributors
### v2.0 (12 months)
- VS Code extension published
- MCP server mode
- Plugin ecosystem (20+ plugins)
- 10K+ active users
- Compete with Claude Code on benchmarks
---
## 10. Out of Scope (v1.0)
- Cloud sync / hosted backend
- Relay / remote SSH execution
- Multi-agent mission mode
- Plugin marketplace
- Auto-generated wikis
- Git authorship notes
- Vendor telemetry
- Voice input
- Mobile support
---
## 11. Open Decisions
1. **TUI library:** Bubble Tea + Lip Gloss — **Decide: Go-native TUI** (single-binary friendly, mature terminal ecosystem)
2. **Storage:** JSONL files vs SQLite — **Decide: JSONL** (inspectable, simple)
3. **Package structure:** Single vs monorepo — **Decide: Single** until M4
4. **Provider abstraction:** Direct API vs wrapper — **Decide: Direct** (portability)
5. **Patch strategy:** apply_patch vs edit_file — **Decide: Both** (apply_patch for bulk, edit_file for small)
---
## 12. Team & Timeline
**Team (3 people):**
- You (lead + TUI + tools)
- Backend dev (providers + core)
- Designer (VS Code extension + docs)
**Timeline:**
- M0-M1: 4 weeks (foundation)
- M2-M3: 6 weeks (polish + distribution)
- M4-M5: 8 weeks (extensibility)
- M6: 4 weeks (sandbox)
- **Total v1.0: 6 months**
---
## 13. Why This Will Work
1. **Proven patterns** — Based on clean-room analysis of existing terminal coding-agent UX patterns
2. **Multi-provider** — Unique advantage vs Claude Code/Cursor
3. **Open source** — Community contributions
4. **Go** — Native binary, fast startup, strong concurrency, cross-platform CLI ergonomics
5. **npm distribution wrapper** — Keeps JavaScript ecosystem installation while the runtime stays native
6. **Clear milestones** — Ship early, iterate
---
**This is the real PRD. 13 sections, 24 features, 6 months to v1.0.**
+23
View File
@@ -0,0 +1,23 @@
# Zero Documentation
This directory contains public documentation for using, extending, and releasing
Zero.
## User Docs
- [Install](INSTALL.md)
- [Update flow](UPDATE.md)
- [OAuth logins and subscription-backed providers](oauth-subscriptions.md)
## Automation And Integrations
- [Stream-JSON protocol](STREAM_JSON_PROTOCOL.md)
- [GitHub Action](GITHUB_ACTION.md)
- [Specialists](SPECIALISTS.md)
## Maintainer Docs
- [npm wrapper smoke checklist](NPM_WRAPPER_SMOKE.md)
- [Benchmarks](BENCHMARK.md)
- [Performance benchmarks](PERFORMANCE.md)
- [Offline agent evals](AGENT_EVALS.md)
+27 -21
View File
@@ -1,38 +1,44 @@
# Zero Update Flow
# Update Flow
`zero update --check` checks the latest GitHub release for `Gitlawb/zero` and compares it with the local CLI version.
`zero update --check` checks the latest GitHub release and compares it with the
local CLI version.
```bash
zero update --check
zero update --check --json
zero update --check --repo Gitlawb/zero
zero update --check --endpoint https://api.github.com/repos/Gitlawb/zero/releases/latest
zero update --check --timeout 3s
zero update --check --target windows-x64
```
For M2 this command is intentionally check-only:
The command is intentionally check-only:
- It does not replace the running binary.
- It exits with code `0` when the check succeeds, even when an update is available.
- It exits with code `0` when the check succeeds, even when an update is
available.
- It exits with code `1` when the release check cannot be completed.
- `--json` prints the same result in a machine-readable format for scripts and CI.
- `--repo <owner/repo>` checks a different GitHub repository when no endpoint is provided.
- `--endpoint <url|owner/repo>` checks a specific release API URL or repository slug.
- `--timeout <duration>` overrides the default release check timeout.
- `--target <platform-arch>` validates release metadata for another supported target from the current machine.
- Release checks time out after 5 seconds by default.
- It validates that the latest release includes the expected archive and matching `.sha256` asset for the selected platform target.
- `--json` prints the same result in a machine-readable format for scripts and
CI.
Supported targets are `linux-x64`, `linux-arm64`, `macos-x64`, `macos-arm64`, `windows-x64`, and `windows-arm64`. Without `--target`, Zero checks the current platform.
Useful flags:
The release endpoint resolves in this order:
| Flag | Purpose |
|---|---|
| `--repo <owner/repo>` | Check another GitHub repository. |
| `--endpoint <url|owner/repo>` | Check a specific release API URL or repository slug. |
| `--timeout <duration>` | Override the default release check timeout. |
| `--target <platform-arch>` | Validate release metadata for another supported target. |
- `--endpoint` from the CLI, or `Options.Endpoint` when calling `update.Check` from code.
- `ZERO_UPDATE_RELEASE_URL` from the environment.
- `--repo` from the CLI, or `Options.Repository` when calling `update.Check` from code.
- `https://api.github.com/repos/Gitlawb/zero/releases/latest`.
Supported targets are `linux-x64`, `linux-arm64`, `macos-x64`, `macos-arm64`,
`windows-x64`, and `windows-arm64`. Without `--target`, Zero checks the current
platform.
`--endpoint`, `Options.Endpoint`, and `ZERO_UPDATE_RELEASE_URL` may be a full URL or an `owner/repo` slug.
Endpoint resolution order:
Installer scripts download the matching release asset for the local platform and verify its `.sha256` file. If Zero is already installed, use this command before re-running the installer.
1. `--endpoint`
2. `ZERO_UPDATE_RELEASE_URL`
3. `--repo`
4. `https://api.github.com/repos/Gitlawb/zero/releases/latest`
Installer scripts download the matching release asset for the local platform and
verify its `.sha256` file. If Zero is already installed, run `zero update --check`
before reinstalling.
-286
View File
@@ -1,286 +0,0 @@
# Zero WorkSplit PRD v3: Go-Native Product Ownership Plan
Status: Draft v3.0
Date: 2026-06-06
Timeline: Active sprint plan toward v0.1, then v1.0 hardening
Team:
- Vasanth
- Gnanam
- Anandan
## 1. Product Goal
Zero is a terminal-first AI coding agent built in Go.
The goal is not just to remove the old TypeScript runtime. The goal is to make Zero feel like a mature daily-driver CLI agent:
- fast startup
- strong command UX
- safe autonomy
- clear permission handling
- useful local tools
- reliable provider/runtime behavior
- installable, updateable, and testable on real developer machines
This document replaces the older migration-era work split. The Go runtime is now the main product baseline, so future work should focus on product depth rather than repeating the old M0 migration plan.
## 2. Current Baseline On Main
The current main branch is Go-native for the runtime and CLI.
Implemented baseline:
- `cmd/zero` is the primary CLI entrypoint.
- `cmd/zero-pr-review` exists for PR automation workflows.
- Core runtime packages live under `internal/`.
- Current CLI surface includes `exec`, `config`, `models`, `providers`, `doctor`, `search`, `sessions`, `plugins`, `hooks`, `mcp`, `sandbox`, `update`, `worktrees`, `verify`, `changes`, `serve`, `help`, and `version`.
- Core areas exist for agent flow, provider/model registry, tools, TUI, sessions, search, sandbox, self-verification, stream-json, usage, redaction, hooks, MCP, plugins, update, worktrees, and git/change handling.
- TypeScript is no longer part of the repository baseline. The npm package keeps a small Node wrapper that delegates to the Go binary.
Still needed before Zero feels production-ready:
- Permission and sandbox UX must become clearer and harder to misuse.
- TUI command flows need polish beyond basic rendering.
- Platform behavior, especially Windows sandbox/update/install paths, needs stronger product ownership.
- Review automation should produce useful, repeatable output for maintainers.
- Release trust needs checksums, install smoke tests, and update verification.
## 3. Ownership Rules
- Every feature has one DRI.
- UI, runtime, and platform work should be split by contract, not by vague file ownership.
- Every PR must expose user-runnable behavior or a concrete contract consumed by another owner.
- Docs, CI, and website work can support a product PR, but they should not be the whole lane for any owner.
- Anandan must own platform product work, not only docs/site/CI.
- Shared contracts should be agreed before large UI or integration work lands.
- Keep PRs large enough to move the product forward, but still reviewable.
## 4. Team Roles
| Owner | Primary Role | Owns | Should Not Own Alone |
| --- | --- | --- | --- |
| Vasanth | Product UX and TUI owner | Bubble Tea TUI, startup screen, command palette, slash command UX, tool rendering, permission prompts, sandbox block rendering, verification/test/git output UX, themes, daily CLI feel | Provider internals, release pipeline, platform adapters |
| Gnanam | Runtime core owner | Provider/model registry, provider factory, agent/runtime protocols, stream-json, sessions/search backends, config/doctor/usage/redaction, MCP/hooks/plugins backend, sandbox policy/grants, permission event contract | TUI composition, release packaging, platform-specific install trust |
| Anandan | Platform product owner | Install/update/release trust, binary/package smoke tests, platform sandbox adapters, Windows behavior, PR/review automation CLI, CI as product verification, performance and release checks | TUI rendering, provider semantics, model registry ownership |
## 5. Anandan Platform Product Rule
Anandan should not be assigned only docs/site/CI forever. His lane must produce platform behavior that users and maintainers can run.
Accepted Anandan work:
- `zero sandbox policy` improvements and platform-specific sandbox behavior.
- Windows sandbox adapter behavior and clear fallback reporting.
- `zero update` verification, release metadata validation, and install smoke tests.
- Package and binary checksums.
- PR automation CLI workflows that reviewers can run locally or in CI.
- CI jobs that prove real Zero commands work after installation.
Not accepted as standalone Anandan work:
- Docs-only updates.
- Website-only updates.
- Future VS Code notes with no current CLI behavior.
- CI matrix changes that do not prove a user-facing command or release flow.
- Release notes or checklists without executable validation.
Docs and CI are still useful, but they must attach to real platform product behavior.
## 6. Shared Contracts
| Contract | DRI | Consumers | Purpose |
| --- | --- | --- | --- |
| Provider stream events | Gnanam | Vasanth, Anandan | Lets TUI and automation render model output, tool calls, and errors consistently. |
| Permission and sandbox events | Gnanam | Vasanth, Anandan | Lets TUI ask for decisions and platform code enforce them without bypasses. |
| Session event schema | Gnanam | Vasanth, Anandan | Lets TUI, resume, search, and automation share one session model. |
| Command metadata registry | Vasanth | Gnanam, Anandan | Lets slash commands, help, shell completions, and docs use one command source. |
| Platform/release contract | Anandan | Vasanth, Gnanam | Defines install, update, checksum, smoke, and platform capability results. |
| Stream-json protocol | Gnanam | Anandan | Lets CI and PR automation consume Zero output without scraping text UI. |
## 7. Active Workstreams
### A. Safe Autonomy
Top priority. Zero must be able to run tools confidently while showing users what is happening and asking when risk increases.
Planned PRs:
- Vasanth: `feat/tui-sandbox-permissions`
- permission prompt UX
- allow, deny, and always-allow flows
- sandbox block rendering
- `/permissions` or equivalent permission state UI
- Gnanam: `feat/runtime-permission-events`
- structured permission request events
- sandbox grant contract
- stream-json support
- tests for denial, approval, and block cases
- Anandan: `feat/windows-sandbox-platform`
- platform sandbox capability checks
- stronger Windows behavior or explicit safe fallback
- `zero sandbox policy --json` output useful enough for CI and support
- platform tests where possible
Done when:
- High-autonomy tool attempts produce a clear prompt, decision, and result in TUI and headless modes.
- Denied commands do not run.
- Sandbox blocks are visible and actionable.
- Platform fallback is explicit, tested, and not silently unsafe.
### B. Command UX And Daily CLI
The CLI should feel like a product, not a collection of backend commands.
Planned PRs:
- Vasanth: `feat/tui-command-polish`
- `/model`, `/provider`, `/context`, `/doctor`, `/verify`, `/resume`, and `/compact` become clear interactive flows
- command results render consistently
- startup screen stays minimal until the user asks for panels
- Gnanam: `feat/runtime-command-contracts`
- stable command data APIs
- provider/model/session/config result structs
- error types suitable for TUI rendering
- Anandan: `feat/install-update-user-flow`
- `zero update` and install flows are user-runnable
- package smoke command verifies installed binary behavior
- CI proves the packaged command starts and reports version/help
Done when:
- A new user can run Zero, inspect config/provider/model state, and understand the next action without reading source.
### C. Verification And Review Automation
Zero should help maintainers verify changes and review PRs with repeatable local commands.
Planned PRs:
- Vasanth: `feat/tui-verification-results`
- render verify/test/git/review results in TUI
- show pass/fail/blocked states clearly
- Gnanam: `feat/runtime-self-verify-contract`
- structured verification results
- stable test/change event model
- redacted logs for model and automation use
- Anandan: `feat/pr-review-cli-workflow`
- make `cmd/zero-pr-review` or `zero review` useful for changed files, validations, and summary output
- support local and CI invocation
- avoid brittle text scraping where stream-json can be used
Done when:
- A maintainer can run one local command before review and get useful changed-file, validation, and risk output.
### D. Release Trust And Distribution
This is Anandan's main product lane after platform sandbox work.
Planned PRs:
- Anandan: `feat/release-trust`
- release metadata validation
- checksums
- install smoke tests
- update verification
- clear error output when release artifacts are invalid
- Vasanth:
- consume release/update results in CLI UX where needed
- Gnanam:
- ensure config, errors, and logs are structured and redacted
Done when:
- Users can install, run, verify, and update Zero with confidence.
- Maintainers can prove release artifacts work before publishing.
### E. Extensibility UX
This comes after safe autonomy and daily CLI polish.
Planned PRs:
- Vasanth:
- `/mcp`, `/hooks`, `/plugins`, and related UI flows
- Gnanam:
- harden backend contracts and lifecycle behavior
- Anandan:
- automation and docs only after executable behavior exists
Done when:
- Extensibility feels discoverable and safe from the terminal.
## 8. Immediate Next PRs
These are the next reviewable slices. Do not use the old migration-era next steps.
1. Vasanth: `feat/tui-sandbox-permissions`
2. Gnanam: `feat/runtime-permission-events`
3. Anandan: `feat/windows-sandbox-platform`
4. Anandan: `feat/update-release-verification`
5. Vasanth: `feat/tui-command-polish`
6. Gnanam: `feat/session-rewind-compaction-contract`
Recommended first parallel set:
- Vasanth starts the TUI permission prompt and block display.
- Gnanam defines the permission event/grant contract.
- Anandan owns Windows/platform sandbox reporting and tests.
## 9. Command Ownership Matrix
| Command Area | Vasanth | Gnanam | Anandan |
| --- | --- | --- | --- |
| `exec` | TUI and interactive UX | Runtime execution contract | Package smoke and platform validation |
| `sandbox` | Prompt and block UX | Policy, grants, and events | Platform adapters and Windows behavior |
| `update` | Display and user messaging | Config and redacted errors | Primary owner of update verification |
| `verify` | Result rendering | Verification backend contract | CI and release integration |
| `changes` | TUI rendering | Change detection backend | PR automation integration |
| `mcp`, `hooks`, `plugins`, `serve` | Discoverability and UI | Backend lifecycle and contracts | Automation/docs after behavior exists |
| `sessions`, `search` | Resume/search UX | Storage, query, and event model | Stream-json and automation consumers |
## 10. Definition Of Done
For every code PR:
- Add or update tests for the changed behavior.
- Run `go test ./...` when Go files changed.
- Run the relevant build command when CLI entrypoints changed.
- Run package smoke checks when install/update/npm wrapper files changed.
- Include manual CLI output or screenshots only when they prove a user-facing flow.
For docs-only PRs:
- State clearly that no runtime behavior changed.
- Do not use docs-only PRs as a substitute for platform product work.
For Anandan's PRs specifically:
- Must include one of:
- user-runnable command behavior
- install/update/release artifact verification
- platform adapter behavior
- CI that executes real installed Zero commands
- Docs/CI can support the PR, but cannot be the entire value.
## 11. Review Rotation
- Vasanth PRs:
- Gnanam reviews runtime contracts.
- Anandan reviews platform/release impact when touched.
- Gnanam PRs:
- Vasanth reviews user-facing contract impact.
- Anandan reviews platform/protocol impact when touched.
- Anandan PRs:
- Vasanth reviews user-facing CLI behavior.
- Gnanam reviews runtime contract and safety impact.
## 12. Retired Historical Plan
The old M0-M6 TypeScript-to-Go migration schedule is now historical.
Do not assign new work from the old migration next-steps section. The Go migration foundation has already landed on main. Future work should move Zero from "ported" to "mature terminal coding agent."
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

-208
View File
@@ -1,208 +0,0 @@
# Deep-audit status ledger (re-verified against HEAD on branch fix/audit-batch, 2026-06-13)
Total verified: 175 of 175 extracted findings
| Status | Count |
|---|---|
| fixed | 97 |
| open | 66 |
| partial | 12 |
| obsolete | 0 |
<!--
Counts are the post-re-verification view: of the 87 rows the body still marks
open/partial (69 open + 18 partial), the re-verification found 9 already fixed on
`main` (3 open + 6 partial; listed in 2026-06-13-reverification.md), leaving 66
open + 12 partial = 78 genuinely pending, and fixed 88 + 9 = 97. The per-finding
rows below retain their original verdicts pending a row-level reclassification pass.
-->
> **Authoritative re-verification (2026-06-13, branch `fix/audit-batch` off current `main`).**
> All 87 rows the 06-11 snapshot marked `open`/`partial` were re-checked against the ACTUAL
> current code by independent agents (the 06-11 ledger was verified on `fix/audit-critical-high`,
> which has since diverged). Every `already_fixed` verdict was adversarially re-checked by a
> second agent trying to prove the defect was still present — none were refuted.
> **Result: 78 genuinely pending** (66 still_open + 12 partial); **9 rows were already fixed on
> `main`** and are reclassified to `fixed` here. Full per-finding table:
> [`2026-06-13-reverification.md`](2026-06-13-reverification.md).
> Pending by severity: **high 2, medium 42, low 34**. Counts above reflect this re-verification
> plus this branch's own fixes (scanner.go:81, compaction.go:58, worktrees.go:247).
| Sev | Status | Location | Evidence |
|---|---|---|---|
| critical | fixed | internal/search/search.go:346 | search.go:363-428 findMatch maps lowered offsets back via lowerWithOffsets + snapRuneStart clamps context; /tmp probe of audit repro (100x'Ⱥ'+' hello') returns match {201 206}, valid UTF-8, no panic |
| critical | fixed | internal/mcp/protocol.go:69 | 82b9c8d: const maxMessageBytes=64MiB (protocol.go:18) bounds both framing paths — Content-Length values above the cap are rejected (:90-92) and the newline-delimited readLine errors past the cap (:78-80); stdio transport switched to newline-delimited JSON. Covered by protocol_test.go; go test green |
| high | fixed | internal/cli/usage.go:121 | usage.go:123-131: diff defaults to zerogit.DiffStat{}; resolveWorkspaceRoot/inspectChanges errors are ignored (degrade to net-LOC 0) instead of returning |
| high | fixed | internal/tui/transcript.go:137 | transcript.go:121-142 keys are run-scoped ("%d:%d:%s" with row.runID) plus effectiveToolRowID ordinal suffix; model.go:1501/1553/1572/1584 set runID on rows |
| high | fixed | internal/tui/model.go:853 | model.go:1034-1043 commandExit now calls cancelRun(), sets exiting, and defers tea.Quit while flushRunIDs is non-empty; deferred quit fires after flush at model.go:645-647 |
| high | fixed | internal/tui/model.go:599 | internal/tui/flush.go:11-30 settled-row flush frontier prints history to native scrollback via tea.Println; model.go transcriptView() renders only live tail from m.flushed; covered by internal/tui/flush_test.go |
| high | fixed | internal/tui/model.go:512 | model.go:614,633-637 flush goes to flushSessionID recorded at cancel time (model.go:1334) via appendSessionEventsTo (tui/session.go:76), not the active session |
| high | fixed | internal/tui/session_controls.go:176 | session_controls.go:182-184 blocks /rewind while len(m.flushRunIDs) > 0 ('cannot rewind while a cancelled run is still flushing'); covered by tui/session_test.go |
| high | fixed | internal/tui/model.go:584 | flush.go:71-120 settles rows into native scrollback via tea.Println; View()/transcriptView renders only rows from m.flushed (model.go:751-784) |
| high | fixed | internal/tui/transcript.go:137 | transcript.go:121-141 bakes runID into every key; within-run repeats get ordinal suffix via effectiveToolRowID (transcript.go:148, used at model.go:1493-1496 with callSeq) |
| high | fixed | internal/tui/rendering.go:525 | live cap 16 kept for tidy tail only; flushed scrollback cards use flushCardBodyMaxLines=400 (rendering.go:154-158) and Ctrl+O detailed view is uncapped (renderRowDetailed bodyCap:0, rendering.go:165; capCardLines treats <=0 as no cap, rendering.go:718-720) |
| high | fixed | internal/tui/transcript.go:135 | transcript.go:121-141 transcriptRowKey bakes runID into every key ("%d:%d:%s"); model.go:1501/1553 set row.runID and effectiveToolRowID suffixes within-run repeats |
| high | fixed | internal/tui/model.go:512 | model.go:614-637 — flushRunIDs maps cancelled run -> session id captured at cancel time (model.go:1334); flush goes via appendSessionEventsTo(flushSessionID,…) (session.go:76) when active session differs |
| high | fixed | internal/tui/run.go:26 | flush.go:71-120 settled-row frontier prints history to native scrollback via tea.Println; model.go:769 View renders only the unflushed live tail |
| high | fixed | internal/tui/transcript.go:139 | transcript.go:125 key is now "%d:%d:%s" (kind:runID:id) for all keyed kinds; transcript.go:148 effectiveToolRowID adds per-run ordinal suffix for within-run repeats |
| high | fixed | internal/tui/model.go:311 | model.go:355-360 Ctrl+C sets exiting and returns nil (no Quit) while flushRunIDs non-empty; deferred tea.Quit fires at model.go:645-647 once the cancelled run's session events flush |
| high | fixed | internal/tui/model.go:853 | model.go:1034-1043 commandExit now calls cancelRun(), sets m.exiting, and defers tea.Quit while len(m.flushRunIDs) > 0 — same protection as Ctrl+C |
| high | fixed | internal/cli/extensions.go:186 | internal/cli/extensions.go:191 passes Servers through redactMCPServerConfigs (:205-224) which replaces every env/header value with [REDACTED]; asserted by extensions_test.go:108 (go test ok) |
| high | fixed | internal/mcp/protocol.go:42 | 82b9c8d: read() now accepts newline-delimited JSON frames (isJSONStart→decodeMessage) and only falls back to Content-Length headers for back-compat; write() emits one JSON message per line. Covered by protocol_test.go; go test green |
| high | fixed | internal/streamjson/streamjson.go:310 | 3142de6: secretPatterns now anchored — sk keys require a word boundary plus a 16-char body and bearer requires a 16-char token — so 'task-list' and prose like 'bearer of bad news' are no longer redacted while real keys/tokens still are. Covered by streamjson_test.go |
| high | fixed | internal/cli/cron.go:112 | 7a47059: cronAdd now consumes an explicit positional expr (cron.go:113-118) BEFORE applying recipe defaults, so a schedule given alongside --recipe is honored; extra positionals error. Covered by cron_test.go |
| high | fixed | internal/providerhealth/providerhealth.go:285 | 600057d: default probe now uses newConnectivityClient — CheckRedirect re-validates every hop (capped at 5) and DialContext validates+pins the resolved IP, dialing the IP literal to close the DNS-rebinding TOCTOU. Covered by connectivity_client_test.go |
| high | fixed | internal/sessions/store.go:547 | 12a3f62 + 8c75074: ReadEvents now drops a torn final record (last non-empty line) only when the file lacks a trailing newline (tornTailPossible); a fully-flushed corrupt line, or any malformed earlier line, still fails loudly. Covered by store_test.go |
| high | partial | internal/cli/cron_run.go:173 | 7a47059: fireJob now re-reads the job before persisting and preserves an external pause/removal mid-run (cron_run.go:170-181), closing the pause-clobber. But internal/cron/store.go still has no flock/CAS, so the concurrent-scheduler race (two schedulers both seeing NextRunAt due) remains — single-scheduler is the supported model |
| high | fixed | internal/tools/bash.go:104 | 0dbb5e0: hardenProcessLifetime (bash_proc_unix.go) sets Setpgid, a Cancel that SIGKILLs the whole process group (negative pid), and a WaitDelay backstop so a backgrounded child cannot outlive the timeout or hang Run(); Windows sets WaitDelay only. Covered by bash_tool_test.go |
| high | fixed | internal/sandbox/runner.go:262 | 2eeaea7: sandbox-exec profile write rule now also allows the standard device nodes (/dev/null, …) and temp trees (/tmp, /private/var/folders, …) for parity with bubblewrap's --dev/--tmpfs; the workspace stays the only writable project location. Covered by runner_test.go |
| high | fixed | internal/tools/apply_patch.go:161 | b6182bf: patch headers are now parsed with hunk line-counting (git-apply semantics), so a hunk-body line like '-- /etc/old' is no longer mistaken for a file header and a valid patch is accepted. Covered by write_tools_test.go |
| high | fixed | internal/tools/grep.go:224 | b6182bf: glob now matches against the path relative to the SEARCH directory (ripgrep semantics) via filepath.Rel(target,path); a single explicit file matches by base name. probe path=subdir glob=*.go now matches subdir/a.go. Covered by file_tools_test.go |
| high | fixed | internal/providers/openai/types.go:3 | 46d495c (+7044d36/6664f71): chatCompletionRequest gained reasoning_effort; zeroruntime.CompletionRequest.ReasoningEffort is threaded through the agent loop and mapped per provider — OpenAI reasoning_effort, Anthropic extended thinking, Gemini thinking budget; CLI gates the value against the resolved model. Covered by openai/anthropic/gemini provider_test.go |
| high | fixed | internal/specialist/exec.go:166 | 39a9c55: BuildArgs always prefixes the session title with the specialist name and falls back to the bare name when description is empty, so AgentName is non-empty and a description-less run resumes. Covered by specialist/exec_test.go |
| high | fixed | internal/agent/loop.go:233 | 95a6a8e: Run now sets result.FinishReason from collected.FinishReason (and from the after-max-turns final answer); Result.Truncated()/TruncationNotice() drive an exec-writer warning and a TUI system row when a response was cut off. Covered by loop_test.go |
| high | partial | internal/hooks/hooks.go:372 | 17a91d9: a Dispatcher that runs configured hooks and audits each via NewAuditStore now exists and is tested (dispatch.go + dispatch_test.go), closing the 'zero non-test callers' gap for Select/audit. But it has ZERO production callers — not wired into the beforeTool/afterTool tool lifecycle — and runHooks still supports only `list` (extensions.go:94); no dispatch/edit CLI surface |
| low | fixed | internal/cli/exec_writer.go:412 | exec_writer.go:415 and :404 now call cutRuneBoundary (:420-431) which backs up to the last UTF-8 rune start before slicing; added in audit-fix commit 6fb0231 |
| low | fixed | internal/mcp/server.go:129 | server.go:129-132 adds `case "ping": return server.writeResult(message.ID, map[string]any{})` (introduced in commit 6fb0231, present at HEAD) |
| low | fixed | internal/search/search.go:309 | search.go:318-322: resolveSessions now returns fmt.Errorf("zero session not found: %s", sessionID) when store.Get returns nil instead of an empty success |
| low | fixed | internal/doctor/doctor.go:134 | doctor.go:129-141: providerConfigCheck now reports apiKey presence ("set"/"not set") based on profile.APIKey != "", with comment explaining the change; doctor tests pass |
| low | fixed | internal/tui/transcript.go:303 | truncateTUIOutput cuts via rune-boundary cutRunes (transcript.go:302,307-318); titles use cutRunes (session.go:112, spec_mode.go:305) and cutRuneBoundary (exec_sessions.go:90, exec_writer.go:420) |
| low | fixed | go.mod:10 | go.mod:7-12 direct requires are now only bubbles/bubbletea/lipgloss/x-sys; glamour is gone entirely and x/ansi (line 18) and termenv (line 31) are demoted to // indirect |
| low | fixed | internal/tui/model.go:525 | agentResponseMsg completion path now invokes m.runCancel() before nil-ing it (model.go:651-658, with explanatory comment about the per-prompt leak) |
| low | fixed | internal/imageinput/imageinput.go:58 | imageinput.go:56-66 os.Open failure now wraps real cause ("cannot open image %s: %w") and read failure "cannot read image %s: %w"; only a stat miss reports "not found" (:38) |
| low | fixed | internal/tui/session.go:93 | All three paths rune-safe: internal/tui/session.go:112 cutRunes(..., tuiSessionTitleLimit), internal/tui/spec_mode.go:305 cutRunes, internal/cli/exec_sessions.go:90 cutRuneBoundary(title, 80) |
| low | fixed | internal/cli/exec_writer.go:412 | internal/cli/exec_writer.go:404 (truncateForStatus) and :415 (truncateForStreamJSONOutput) both use cutRuneBoundary defined at exec_writer.go:420-431 (backs up to utf8.RuneStart) |
| low | fixed | internal/sessions/replay.go:237 | replay.go:245-256 cutPromptRuneBoundary (utf8.RuneStart backoff) now used at replay.go:174-177 and 238; titles rune-safe via cutRuneBoundary (cli/exec_sessions.go:90) and cutRunes (tui/session.go:112) |
| low | fixed | internal/tui/rendering.go:68 | footerText/commandFooterText/formatCommandFooterText/defaultCommandFooterText (present at b144cf9~1 rendering.go) are absent at HEAD; dead actionAppendToolCall/ToolResult enum values also removed from transcript.go |
| low | fixed | internal/tui/transcript.go:303 | cutRunes lands cuts on rune boundaries (transcript.go:305-318), used by truncateTUIOutput for tool-result text (transcript.go:302) and session titles (session.go:112, spec_mode.go:305) |
| low | fixed | internal/tui/model.go:853 | commandExit cancels the run, sets exiting, and defers quit while flushRunIDs is non-empty (model.go:1034-1043); deferred tea.Quit fires when the last flush drains (model.go:645-647), and new prompts are gated while exiting (model.go:1012-1014) |
| low | fixed | internal/tui/session.go:93 | cutRunes (transcript.go:307-318) cuts on rune boundaries; used by tuiSessionTitle (session.go:112) and truncateTUIOutput (transcript.go:302) for tool-result text |
| low | fixed | internal/tui/rendering.go:482 | rendering.go:526 — permission card action row now renders "[esc] cancel run", matching the KeyEsc fall-through to m.cancelRun() (model.go:391-393) |
| low | fixed | internal/cli/update.go:37 | internal/update/update.go:136-142: normalizeVersionTag failure now falls back to currentVersion="0.0.0" so the check proceeds to the network call (landed in #158) |
| low | fixed | internal/notify/notify.go:134 | notify.go:137 now drops `(r >= 0x80 && r <= 0x9f)` with comment that C1 includes single-byte CSI/OSC/ST introducers (landed in #158) |
| low | fixed | internal/tui/model.go:512 | model.go:616-624 the flushRunIDs (cancelled-run) branch of agentResponseMsg now records each usage event via recordUsageEvent → m.usageTracker.Record (session_controls.go:294) |
| low | fixed | internal/tui/rendering.go:490 | rendering.go:560-564 the ask card now echoes the in-progress answer: "❯ " + fill(zeroTheme.ink).Render(input) + accent cursor, appended to the card lines |
| low | fixed | internal/tui/view.go:227 | view.go:230-235 now matches `path == home` or strings.HasPrefix(path, home+os.PathSeparator) — a path-boundary check with a comment citing the /Users/alice2 sibling case |
| low | fixed | internal/sandbox/risk.go:53 | defect not present: risk.go:11-50 patterns are package-level regexp.MustCompile compiled once at init (identical at audit commit b144cf9); per call only MatchString runs |
| low | fixed | internal/modelregistry/catalog.go:36 | catalog.go:36 claude-haiku-3.5 extra capabilities now []ModelCapability{ModelCapabilityPromptCache} — ModelCapabilityVision removed (base withBaseCapabilities adds no vision), so SupportsVision is false |
| low | fixed | internal/providers/providerio/retry.go:107 | retry.go:108-110 ShouldRetryStatus now returns true for `code == 529` alongside 429/503 (doc comment lines 99-107 updated to cover Anthropic overloaded), consistent with ClassifiedError's 529 rate-limit classification at providerio.go:224 |
| low | fixed | internal/tui/spec_mode.go:301 | spec_mode.go:305 and session.go:112 now use cutRunes (transcript.go:307-318 backs off to utf8.RuneStart boundary); no byte-index title slicing remains |
| low | fixed | internal/agent/system_prompt.go:89 | system_prompt.go:102-107 now walks cut back with `for cut > 0 && !utf8.RuneStart(content[cut])` before slicing, so truncation lands on a rune boundary (matches audit doc's rune-safe truncation fix claim) |
| low | fixed | internal/config/writer.go:53 | internal/config/writer.go:55-74 — writeConfigFile now does os.CreateTemp + Chmod(0600) + write + os.Rename (atomic temp+rename, with explanatory comment at :53); go test ./internal/config -run Upsert passes |
| low | fixed | internal/cli/exec_sessions.go:88 | exec_sessions.go:87-91: title = cutRuneBoundary(title, 80); helper (exec_writer.go:420-431) verified rune-safe by /tmp probe |
| low | fixed | internal/cli/exec_writer.go:412 | exec_writer.go:404 (truncateForStatus/stderr) and :415 (truncateForStreamJSONOutput) both use cutRuneBoundary, which backs up to a utf8.RuneStart boundary |
| low | open | internal/streamjson/streamjson.go:30 | repo-wide grep finds EventRestore only at its declaration (streamjson.go:30) — no emitter and not even test references; streamjson.go untouched by 6fb0231 or later merges |
| low | open | internal/cli/cron_run.go:150 | cron_run.go:145-151: rec.Error still set only from errBuf on code!=0; outBuf (where stream-json error events land) is captured but never read |
| low | open | internal/cli/exec_sessions.go:55 | exec_sessions.go:55 still constructs sessions.NewStore(sessions.StoreOptions{}) directly, and exec.go:375-389 PrepareExecOptions still omits the Store field; deps.newSessionStore is used by spec/usage/observability but not the exec run path |
| low | fixed | internal/secrets/scanner.go:35 | [reverified 2026-06-13] already fixed on main: scanner.go:37 pattern is now `sk-[A-Za-z0-9_-]{20,}` (allows -/_ so sk-proj-/sk-svcacct- match), with comment at :35-36. Covered by scanner_test.go TestScanDetectsModernPrefixedOpenAIKeys |
| low | open | internal/redaction/redaction.go:222 | redaction.go:221-235 pointer/map ptr added to context.seen and never deleted after subtree recursion, so a second (sibling) visit returns CircularReference |
| low | open | internal/redaction/redaction.go:174 | redaction.go:174 `interface{ StackTrace() fmt.Stringer }` unchanged; grep finds no implementer in the module and go.mod has no pkg/errors or similar dep |
| low | open | internal/zerogit/zerogit.go:417 | zerogit.go:418-420 fallback EnvRunner adapter still discards env (`_ []string`), so stagedSnapshotDiff's GIT_INDEX_FILE isolation is lost with a caller-supplied Runner |
| low | open | internal/tui/commands_test.go:46 | internal/tui/commands_test.go:46 still defines commandTestStringSliceContains; repo-wide grep finds no callers, only the definition |
| low | open | internal/tui/session_test.go:215 | internal/tui/session_test.go:215 'runtimeMessages := []tea.Msg{}' appended in RuntimeMessageSink at line 226 but never read in the test body (only runtimeMessageCh is consumed) |
| low | open | internal/sessions/store.go:555 | store.go:554-556 timestamp() still time.RFC3339 (second precision); List ties broken lexically by SessionID (store.go:331-336), not actual recency, so Latest() can still pick the wrong session on same-second ties |
| low | open | internal/tui/session_controls.go:165 | session_controls.go:165 still only increments compactRequests; sole reads are its own status display (lines 266, 280) with "Backend: state: pending integration" — no compaction is ever triggered by it |
| low | open | internal/tui/model.go:837 | model.go:1012-1014 silently drops prompt submits while m.exiting (return m, nil — no feedback row); no exiting/flushRunIDs indicator anywhere in statusLine (view.go:113-139), composerLine, or any render path |
| low | open | internal/tui/command_center.go:17 | tui/command_center.go:17-21 passes only Now/Runtime/Provider (no UserConfig/ProjectConfig) vs cli/observability.go:64-71; empty paths warn at doctor.go:120 and :273 |
| low | open | internal/background/process_posix.go:38 | process_posix.go:39-47 still polls signal-0 on os.FindProcess(pid) while the Wait goroutine (specialist/exec.go:653) reaps the child; line 50 Kill() can hit a recycled pid |
| low | open | internal/tools/registry.go:108 | repo-wide grep: OnSandboxDecision only at registry.go:19,108,116 — no caller or test ever sets it |
| low | open | internal/sandbox/grants.go:146 | grants.go unchanged since #77: Lookup trims key (grants.go:146) but readState validates raw file keys via trimming ValidateToolName (grants.go:225-227,273) and keeps the raw map key, so a whitespace-padded key validates yet never matches |
| low | open | internal/tools/read_file.go:89 | probe: max_lines=5 -> Truncated=true with no marker beyond '(lines 1-5 of 20)' header (read_file.go:102-111); rune-width sub-claim not reproducible (ASCII strconv.Itoa padding) |
| low | open | internal/providers/factory.go:118 | factory.go:115 still `if !explicitProvider \|\| isImplicitOpenAI(...)`; isImplicitOpenAI (factory.go:159-164) requires TrimSpace(ProviderKind)=="" && TrimSpace(Provider)=="", which contradicts explicitProvider==true (factory.go:147-157), so it can never return true at its sole call site |
| low | open | internal/providercatalog/catalog.go:46 | catalog.go:46 Public still declared; no constructor or descriptor ever sets it (always false); the only read is the vacuous guard in catalog_test.go:105, which already existed before the audit (added in 4accec5/#141) — no production read or write |
| low | open | internal/verify/verify.go:215 | verify.go:215 RunLoop still defined, sole caller verify_test.go:207; production loop is selfverify.Run (selfverify.go:53-74) used by cli/workflows.go |
| low | open | internal/specialist/output_tool.go:244 | output_tool.go:244 formatTaskOutput has zero callers (prod or tests); only formatTaskOutputSummary is used (output_tool.go:154) |
| low | open | internal/specialist/exec.go:346 | exec.go:347-350: on SetPID error calls cleanupBackgroundPromptFile (runtime.go:73 deletes file/dir) and returns err with no recordSpecialistStop/UpdateStatus while child keeps running |
| low | open | internal/agent/loop.go:188 | helpers.go:85 sets collected.Error = ctx.Err().Error(); loop.go:189-191 returns errors.New(collected.Error) before the ctx.Err() identity return at loop.go:193-195, so errors.Is(err, context.Canceled) still fails for that path |
| low | open | internal/agent/types.go:183 | types.go:183 OnContext field and loop.go:102-103 emit site exist, but repo-wide grep finds no "OnContext:" assignment and no MeasureContext/ContextBreakdown use in cli/tui/cmd/contextreport (only tests) |
| low | open | internal/agent/loop.go:1072 | loop.go:1120-1122 and loop.go:1129-1131 both still assign schema["items"] = propertyToRuntimeMap(*property.Items) |
| low | open | internal/agent/loop.go:473 | loop.go:530-531 set requestEvent.GrantMatched/Grant but requestEvent is never read after the switch (post-run event rebuilt at loop.go:558); fallback at loop.go:510-511 unreachable since buildPermissionEvent always returns ok=true under the shouldRequestPermission gate (Prompt-permission tools, loop.go:751-758 vs 877-894) |
| low | open | internal/agent/loop.go:183 | loop.go:184-186 retry CollectStreamWithOptions still passes only OnUsage; comment at loop.go:178-183 documents the OnText omission as deliberate anti-duplication, but streaming surfaces still receive only the aborted partial text |
| low | open | internal/zeroruntime/helpers.go:104 | helpers.go:104 still does `collected.Text += event.Content` per StreamEventText (no strings.Builder); unchanged since audit commit b144cf9 |
| low | open | internal/zerocommands/sandbox_snapshots.go:104 | grep for SandboxPlan/Decision/Policy/Backend/Risk/Violation/Hook/Plugin/MCPServer snapshots finds no consumers outside internal/zerocommands + tests; `zero sandbox policy --json` serializes raw sandbox.Policy/Backend/BackendPlan instead (internal/cli/sandbox.go:90-105) |
| low | open | internal/config/contracts.go:12 | internal/config/contracts.go still defines ContractGap/DefaultContractGaps/FindContractGapsByMilestone; only reference repo-wide is internal/config/contracts_test.go |
| low | open | internal/skills/skills.go:91 | skills.Duplicates (skills.go:91) and skills.Get (skills.go:217) have zero callers outside internal/skills; cli/skills.go uses List and tools/skill.go uses Load, so collisions are still dropped without any user-facing warning (dedup is now documented/deterministic but unsurfaced) |
| low | open | internal/plugins/plugins.go:893 | internal/plugins/plugins.go:893 allows only beforeTool/afterTool/sessionStart/sessionEnd, while internal/hooks/hooks.go:812 also accepts specialistStart/specialistStop — sets still disagree |
| low | open | internal/config/resolver.go:136 | internal/config/resolver.go:143, :173 and :502 all gate on `MaxTurns > 0` with no error path, so a configured maxTurns of 0 or negative is still silently ignored (falls back to default 30) |
| low | open | internal/cli/exec.go:353 | exec.go:375 PrepareExec called without Store (defaults to NewStore in sessions/exec_session.go:58), exec_sessions.go:55 direct NewStore, exec.go:396 time.Now() instead of deps.now |
| low | open | internal/zerocommands/contracts.go:166 | contracts.go:166 APIKeySet checks only profile.APIKey, ignoring AuthHeaderValue (valid sole credential per config/types.go:49-51); rendered "not set" at cli/command_center.go:328 |
| low | partial | internal/cli/cron.go:275 | promptExcerpt fixed via cutRuneBoundary(p,47) at cron.go:275, but cronTruncate at cron_run.go:179-184 still returns s[:max]+"…" (byte slice, can split a rune in persisted run errors) |
| low | partial | internal/tui/options.go:28 | app.go:413 production caller still sets none of the three, but newModel now self-defaults a usage.Tracker when nil (model.go:239-242) so usage works; effort/style are settable at runtime via /effort and /style |
| low | partial | internal/tui/rendering.go:62 | Footer system, actionAppendToolCall/Result, and dead theme tokens (selRow/statusOk/statusErr/line2) are removed, but listCommandNames/formatCommandHelpLines remain test-only (commands.go:297,306), resumeText's args!="" branch is still unreachable (command_center.go:44-54; sole caller session.go:122 passes ""), OnContext is still wired by no production caller (agent/loop.go:102), and stale footer comments persist (commands.go:254, autocomplete.go:64) |
| low | partial | internal/zerogit/zerogit.go:206 | truncateSubject now rune-based (zerogit.go:464-472, no invalid UTF-8), but ValidateMessage still byte-based `len(firstLine) > 72` (zerogit.go:207) so valid non-ASCII subjects are rejected |
| low | partial | internal/cli/cron.go:275 | promptExcerpt fixed (cron.go:275 uses cutRuneBoundary) but error helper cronTruncate still byte-slices: internal/cli/cron_run.go:183 'return s[:max] + "…"', used for run errors at cron_run.go:150 |
| low | partial | internal/tui/command_center.go:44 | Footer helpers now render (rendering.go:690-700, bash/grep footers 892-905/969-971), but resumeText's args!="" branch (command_center.go:43-54) is still unreachable (sole caller session.go:122 passes "") and stale footer comments persist (autocomplete.go:64, commands.go:254) |
| low | partial | internal/cli/cron.go:275 | promptExcerpt fixed via cutRuneBoundary (cron.go:275), but cronTruncate still byte-slices `s[:max]` at cron_run.go:182, splitting runes in run-record error text |
| low | partial | internal/tui/rendering.go:62 | footer builders (footerText/commandFooterText/etc.) and the four theme tokens (selRow/statusOk/statusErr/line2/panel2) are fully removed (zero grep hits), but formatCommandHelpLines/listCommandNames (commands.go:297-308) remain production code referenced only by tests, and stale "footer advertises" comments persist (commands.go:254, autocomplete.go:64) |
| medium | fixed | internal/update/update.go:135 | update.go:136-142: normalizeVersionTag error now falls back to currentVersion="0.0.0" (commented for dev builds) instead of returning the error |
| medium | fixed | internal/tui/session.go:190 | session.go:219-227 rehydrates role "ask_user"/"ask_user_answers" rows via askUserRequestFromPayload (session.go:349) and askUserAnswersText (session.go:380) |
| medium | fixed | internal/tui/model.go:1067 | cancelRun appends rowSystem "Run cancelled." plus any partial streamed answer (model.go:1336-1343); asserted by flush_test.go:105 |
| medium | fixed | internal/search/search.go:345 | search.go:363-407 findMatch maps lowered offsets back via lowerWithOffsets offset table; snapRuneStart (:417) guards context slicing; tests pass |
| medium | fixed | internal/search/search.go:108 | search.go:111-118 LoadIndex error now `result.SkippedSessions++; continue` instead of returning; SkippedSessions reported in Result (:61) |
| medium | fixed | .github/workflows/ci.yml:31 | .github/workflows/ci.yml:31-44 adds 'Check formatting' (gofmt -l, fails on output) and 'Vet' (go vet ./...) steps; gofmt -l . is clean at HEAD |
| medium | fixed | internal/tui/transcript.go:303 | internal/tui/transcript.go:302 truncateTUIOutput now calls cutRunes (rune-boundary cut, transcript.go:307-318); non-ASCII test TestCutRunesNeverSplitsUTF8 at flush_test.go:158 |
| medium | fixed | internal/cli/usage.go:46 | usage.go:47-51 collectUsageData now does set.skipped++ and continue on ReadEvents failure, so a corrupt session is skipped instead of aborting |
| medium | fixed | internal/tui/model.go:1081 | cancelRun appends partial answer then rowSystem "Run cancelled." marker (model.go:1336-1343), shared by Esc (model.go:392) and Ctrl+C (model.go:355) paths |
| medium | fixed | internal/tui/model.go:227 | WindowSizeMsg sets m.input.Width = maxInt(20, chatWidth(msg.Width)-14) so long input scrolls with cursor visible (model.go:556-558) |
| medium | fixed | internal/tui/view.go:394 | view.go:412-431 now requires a hunk header match or BOTH '--- ' and '+++ ' file headers; a lone '---' line no longer triggers the diff renderer |
| medium | fixed | internal/tui/rendering.go:261 | rendering.go:286-330 preserves explicit newlines and each line's leading indent (tabs→4 spaces), continuations keep the indent; only intra-line space runs collapse |
| medium | fixed | internal/tui/model.go:456 | WindowSizeMsg resizes composer and prints title bar at real width (model.go:553-566); flush refuses to freeze history before a real width arrives (flush.go:72-77); width/tier recomputed every render (view.go:17-19) and history is native scrollback, so only the ephemeral pre-size frame uses the 96-col default |
| medium | fixed | internal/tui/session_controls.go:176 | session_controls.go:182-184 — handleRewindCommand returns "cannot rewind while a cancelled run is still flushing" when len(m.flushRunIDs) > 0 |
| medium | fixed | internal/tui/session.go:190 | model.go:1461-1476 persists ask_user question payload AND ask_user_answers; session.go:218-227 rehydrates both via askUserRequestFromPayload/askUserAnswersText (session.go:349, 380) |
| medium | fixed | internal/tui/model.go:853 | model.go:1034-1043 — commandExit calls cancelRun(), sets exiting, defers tea.Quit until flushRunIDs drains (model.go:645); spec_mode.go:25 gates /spec on m.pending \|\| m.exiting |
| medium | fixed | internal/tui/spec_mode.go:22 | spec_mode.go:25 handleSpecCommand gates on `if m.pending \|\| m.exiting` and refuses to start, with comment citing the post-Ctrl+C flush window |
| medium | fixed | internal/tui/model.go:1081 | model.go:1340-1343 cancelRun appends the partial streamed answer plus rowSystem "Run cancelled." to m.transcript (live), in addition to the session-log marker |
| medium | fixed | internal/tui/model.go:610 | model.go:769 transcriptView loops only `for index := m.flushed` (live tail; settled rows flushed once to scrollback per flush.go), and rendering.go:184-192 memoizes stable row renders in the LRU staticRenderCache (render_cache.go:19) |
| medium | fixed | internal/tui/session.go:93 | session.go:112 tuiSessionTitle uses cutRunes; transcript.go:302/307-318 truncateTUIOutput cuts on the last rune boundary at or before the byte limit |
| medium | fixed | internal/tui/model.go:227 | model.go:558 WindowSizeMsg handler sets `m.input.Width = maxInt(20, chatWidth(msg.Width)-14)` so long input scrolls horizontally with the cursor visible |
| medium | fixed | internal/providers/providerio/providerio.go:184 | providerio.go:142-154 onComment now forwards a keepAlive marker to the consumer, which calls resetIdle() at providerio.go:203 before the keepAlive continue (line 204-205); providerio tests pass |
| medium | fixed | internal/modelregistry/catalog.go:27 | catalog.go:27 gpt-4.1 entry now reads ContextLimits{ContextWindow: 1_047_576, MaxOutputTokens: 32_768}, matching mini/nano |
| medium | fixed | internal/tui/run.go:41 | run.go:42-46 now prints `zero: tui error: <err>` to os.Stderr before returning 1 |
| medium | open | internal/mcp/client.go:302 | client.go:295-317 readLoop dispatches any id-bearing message to pending[id] without checking message.Method; grep confirms no 'ping' handling anywhere in client.go |
| medium | open | internal/mcp/client.go:246 | client.go:246 `client.writer.write(...)` is a blocking pipe write executed while client.mu is held (:228-:255) with no ctx awareness; file unchanged since the audit (only the response wait, which predates the audit, is ctx-aware) |
| medium | open | internal/mcp/client.go:98 | client.go:98-99 `var stderr bytes.Buffer; cmd.Stderr = &stderr` — plain unbounded buffer attached for the process lifetime, only read on initialize failure |
| medium | open | internal/mcp/network_client.go:621 | network_client.go:603-632 decodeSSERPCMessage stops at the first non-empty 'message' event (sets found, returns false); caller at :210 errors on id mismatch instead of scanning for the matching response — unchanged since audit |
| medium | open | internal/mcp/network_client.go:637 | network_client.go:637 `scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)`; ErrTooLong propagates via readStream->failPending (:578-:592) setting a permanent streamErr with no reconnect, so request() (:234) fails forever |
| medium | open | internal/cli/cron_run.go:115 | cron_run.go:102-126 reconcileOverdue still reschedules any active job with NextRunAt before the current minute without firing; no RunNow/RunOnce sentinel exists in internal/cron, and the file is unchanged since pre-audit commit 0f8ea50 |
| medium | open | internal/sessions/lineage.go:140 | lineage.go:140 tree() still calls ListChildren per node, which calls store.List() (lineage.go:74) re-reading every metadata.json from disk (store.go:312-327, no cache); file unchanged since pre-audit commits |
| medium | open | internal/cli/exec_sessions.go:114 | exec_sessions.go:117-123 still early-returns once recorder.err is set and stores AppendEvent errors there; recorder.err is never read anywhere (exec.go/exec_spec.go check only writer.err) |
| medium | open | internal/tui/session_controls.go:117 | m.responseStyle still only displayed (command_views.go:155); /theme //input-style render "does not have a backend setting yet" (rendering.go:47); /compact shows "state: pending integration" (session_controls.go:272); all still in commandDefinitions help/autocomplete (commands.go:189-230) |
| medium | fixed | internal/tui/session_controls.go:42 | 46d495c/7044d36/6664f71: zeroruntime.CompletionRequest now carries ReasoningEffort, threaded from agent.Options through every request the loop builds and mapped per provider (OpenAI reasoning_effort, Anthropic extended thinking with thinking-block replay, Gemini thinkingBudget). Covered by provider tests |
| medium | fixed | internal/secrets/scanner.go:81 | [reverified+fixed 2026-06-13, fix/audit-batch] Redact now replaces longest matches first (copies findings, sorts by len(Match) desc) so a containing PRIVATE KEY block is redacted before any nested shorter match corrupts its exact string. Covered by scanner_test.go TestRedactNestedSecretStillRemovesWholeBlock (fails pre-fix: leaks BEGIN/END header) |
| medium | fixed | internal/redaction/redaction.go:362 | [reverified 2026-06-13] already fixed on main: urlWithCredsPattern is a package-level var (redaction.go:367) and redactURLPasswords (:369) uses it via ReplaceAllStringFunc — no per-call regexp.MustCompile remains |
| medium | open | internal/zerogit/zerogit.go:212 | zerogit.go:223 `path := strings.TrimSpace(line[3:])` — no C-quote unescaping and no `old -> new` rename split; no -z porcelain used |
| medium | fixed | internal/worktrees/worktrees.go:247 | [reverified+fixed 2026-06-13, fix/audit-batch] defaultRunGit now captures stdout/stderr into separate bytes.Buffers and returns CommandResult{Stdout, Stderr}; git warnings no longer pollute parsed rev-parse output and error messages get real stderr. Covered by worktrees_test.go TestDefaultRunGitSeparatesStdoutAndStderr |
| medium | open | internal/sessions/store.go:632 | store.go:632-635 writeMetadata still os.WriteFile temp + os.Rename with no file/dir Sync; zero Sync() calls anywhere in internal/sessions; appendEventLocked (store.go:512-517) also unsynced |
| medium | open | internal/tui/session_controls.go:235 | session_controls.go:244 returns lastCheckpoint-1 while the tool_call is persisted immediately before its checkpoint (model.go:1505 then 1527), so truncate (rewind.go:205 keeps seq<=target) retains the dangling tool_call |
| medium | open | internal/sessions/store.go:387 | store.go:387-391 Fork re-appends every parent event (no provider_usage skip) via AppendEvent which stamps a fresh CreatedAt (store.go:495-501); BuildReport has no event-id dedupe |
| medium | open | internal/usage/report.go:17 | report.go:17-21 usageEventPayload has only token fields and report.go:102 prices from session Metadata.ModelID, while exec.go:510-512 still persists payload["model"] under --allow-escalation — never read |
| medium | open | internal/cli/exec_sessions.go:115 | exec_sessions.go:117-121 append still early-returns once recorder.err is set, and grep shows sessionRecorder.err is never read in exec.go (no surfacing at run end) |
| medium | open | internal/tui/session_controls.go:117 | m.responseStyle (session_controls.go:117) is only echoed in /style and /config text (command_views.go:155); grep shows it never reaches agent.Options/system prompt (runAgentWithOptions model.go:1366-1397 sets no style) |
| medium | open | internal/cron/schedule.go:171 | Probe at HEAD: Next("30 1 * * *", 2026-11-01 01:30 EDT) = 01:30 EST same day (repeated hour); schedule.go:171-201 guards only spring-forward gaps |
| medium | open | internal/cli/cron.go:112 | cron.go:112-114 sets expr=r.Expr while expr is still ""; cron.go:123 only uses positional[0] if expr=="" — user's expr silently ignored, no warning |
| medium | open | internal/background/process_posix.go:50 | process_posix.go:29/50 signal only pid (no kill(-pid)); launchBackgroundProcess (internal/specialist/exec.go:642) sets no Setpgid; Windows still tree-kills via taskkill /T (process_windows.go:12) |
| medium | open | internal/background/manager.go:462 | manager.go:462-471 normalizeLoadedTask rewrites Status==running to error/PID=0/ExitCode=-1; loadTasks persists it to disk at manager.go:419 |
| medium | open | internal/tui/session_controls.go:117 | session_controls.go:117 still only stores m.responseStyle; sole readers are display strings (command_views.go:155, session_controls.go:120/132) — runAgentWithOptions (model.go:1366+) never threads it into agent.Options or the system prompt, so /style still changes nothing about responses |
| medium | open | internal/tools/registry.go:224 | registry.go:190-236 Core* sets still omit escalate_model; only exec --allow-escalation registers it (cli/exec.go:157, predates audit), TUI path (cli/app.go:449) never does |
| medium | open | internal/sandbox/safe_command.go:138 | file unchanged since #109; splitShellSegments (safe_command.go:431) is quote-unaware — probe: `git commit -m "use top \| less"` flagged as 'less', `echo "a; vim b"` flagged as 'vim' |
| medium | open | internal/providers/gemini/types.go:88 | gemini/types.go:88-93 usageMetadata still has only Prompt/Candidates/Thoughts/Total token counts (no cachedContentTokenCount); grep shows CachedInputTokens is set only in openai/provider.go:253 and anthropic/provider.go:274, never in gemini |
| medium | open | internal/providers/openai/tool_state.go:95 | tool_state.go:95-100 closeOpen still emits StreamEventToolCallDropped for calls with empty id (and applyDelta:58 never streams them); zeroruntime/helpers.go:141-207 empty-ID/synthetic-key collector support remains unreachable from the adapter |
| medium | open | internal/specialist/exec.go:150 | exec.go:150 and :195 still hardcode "--auto","high"; cli/exec_tools.go:84-85 maps high -> agent.PermissionModeUnsafe |
| medium | open | internal/specialist/accounting.go:72 | accounting.go:31/80 check (ReadEvents) then :47/91 append under separate store locks; concurrent callers remain at exec.go:336 (onExit) and output_tool.go:150 (TaskOutput); no serializing mutex in package |
| medium | open | internal/specialist/streamer.go:43 | streamer.go:43 still caps scanner at 1024*1024; ErrTooLong fails ParseStream -> runChildProcess err (exec.go:624-627) aborts whole run; tool_call Args still emitted untruncated (cli/exec_writer.go:99) |
| medium | open | internal/agent/loop.go:468 | loop.go:525-528 AlwaysAllow still returns deniedPermissionResult on persistPermissionGrant error; loop.go:778-779 still errors unconditionally when options.Sandbox == nil |
| medium | partial | internal/agent/compaction.go:58 | [reverified+fixed 2026-06-13, fix/audit-batch] estimateTokens now adds len(message.Images)*imageTokenEstimate (flat per-image cost, ~1k; raw byte size is a poor proxy for vision tiles) — Images are no longer uncounted. Covered by compaction_test.go TestEstimateTokensCountsImages. REMAINING: maybeCompact still has no tool-definition term in its estimate |
| medium | open | internal/agent/compaction.go:376 | compaction.go:376 summarizeMessagesOnce uses zeroruntime.CollectStream with no OnUsage; comment at compaction.go:314-315 documents OnText/OnUsage are intentionally not forwarded |
| medium | open | internal/config/resolver.go:95 | ResolvedConfig.MCP set at resolver.go:102 has no readers (grep: zero non-test uses); MCP registration uses ResolveMCP (resolver.go:109-124) which ignores options.ProviderCommand, so ZERO_PROVIDER_COMMAND MCP servers are still dropped |
| medium | open | internal/hooks/hooks.go:503 | internal/hooks/hooks.go:503 — append still calls store.ReadEvents() (full file read + JSON parse) on every append just to compute the next Sequence |
| medium | open | internal/cli/app.go:149 | app.go:177-190: comment says trailing non-flag args "still are" ignored; rest scanned only for --add-dir then dropped before runInteractiveTUI |
| medium | open | internal/cli/exec.go:500 | exec.go:524-532: interrupted branch emits errorEvent/runEnd only for execOutputStreamJSON; json path prints "Interrupted." to stderr, no terminal JSON event |
| medium | open | internal/cli/exec.go:182 | exec.go:186-193: only stream-json is special-cased; -o json falls to writeExecToolList plain text (exec_tools.go:100-110 "Tools visible to model:") |
| medium | partial | internal/tui/transcript.go:113 | Full-slice copy removed — in-place append, unkeyed rows skip the scan (transcript.go:92-101) — but hasTranscriptRow still linearly scans all rows per keyed tool/permission/ask append (transcript.go:103-114), so tool-heavy rehydration stays O(n²) |
| medium | partial | internal/tui/transcript.go:113 | O(n) full-slice copy removed (in-place append, transcript.go:96-100), but hasTranscriptRow (transcript.go:103-114) still linearly scans all rows per keyed (tool/permission) row and m.transcript is never pruned, so tool-heavy rehydration remains O(n^2) |
| medium | partial | internal/tui/model.go:569 | transcript half fixed: interim text appended as non-final assistant row on tool call/error/cancel (model.go:710-715, 681-683, 1340-1342); but session log still records only FinalAnswer (model.go:1638-1644) — interim segments never persisted, so /resume loses them |
| medium | partial | internal/tui/transcript.go:117 | re-render gone (View renders only unflushed tail, model.go:769; render_cache.go memoizes) and copy-on-append removed (transcript.go:96-100); but hasTranscriptRow still scans the whole transcript per keyed append (transcript.go:103-114) — O(n²) cumulative for tool rows |
| medium | partial | internal/tui/transcript.go:113 | Copy fixed: transcript.go:96-100 appends in place; but hasTranscriptRow (transcript.go:103-114) still linear-scans all rows per keyed (tool/permission/ask) row, so tool-heavy /resume rehydration remains O(N²) |
| medium | partial | internal/tui/transcript.go:117 | transcript.go:100 full-slice copy removed (in-place append; non-keyed rows now O(1) via empty-key fast path), but hasTranscriptRow transcript.go:108-113 still linear-scans all rows per tool/permission/ask append — tool-heavy appends/rehydration remain quadratic |
| medium | partial | internal/tui/rendering.go:261 | rendering.go:286-303 now preserves explicit newlines and each line's leading indentation (code blocks survive), but rendering.go:305 still runs strings.Fields(body) per line, collapsing intra-line space runs so mid-line column alignment is still lost |
File diff suppressed because it is too large. Load diff
-117
View File
@@ -1,117 +0,0 @@
# Audit re-verification against current `main` (2026-06-13)
Every pending (open/partial) row from `2026-06-10-deep-audit-status.md` was re-checked
against the **actual current code** by an independent agent (the ledger was verified on a
since-diverged branch). Every `already_fixed` verdict was then adversarially re-checked by a
second agent trying to prove the defect was still present; **none were refuted** (all 9 hold).
| Verdict | Count |
|---|---|
| still_open | 66 |
| partial | 12 |
| already_fixed (ledger over-counted) | 9 |
| **genuinely pending (still_open + partial)** | **78** |
Pending by severity: high 2, medium 42, low 34.
## Already fixed on `main` (reclassify to fixed) (9)
| Sev | Location (current) | Rationale |
|---|---|---|
| medium | `internal/cli/cron.go:106-128` | The original defect (recipe set `expr=r.Expr` before the positional was consumed, silently ignoring a user-supplied expr) is gone: cronAdd now consumes the positional expr first at cron.go:113-114 (`if len(positional)==1 && expr=="" { expr=positional[0] }`), and the recipe only fills expr if still empty (cron.go:122-123); extra positionals error. Confirmed by TestCronAddExplicitExprOverridesRecipe (cron_test.go:95-118). |
| low | `internal/cli/cron_run.go:198-205` | The original gap was that cronTruncate byte-sliced `s[:max]+"…"` and could split a multi-byte rune; current cronTruncate (cron_run.go:204) now returns `cutRuneBoundary(s, max) + "…"`, and cutRuneBoundary (exec_writer.go:420-431) backs up to a utf8.RuneStart boundary, so the rune-split defect is gone. promptExcerpt (cron.go:278) was already rune-safe. |
| low | `internal/cli/cron_run.go:198-205` | Duplicate of the same underlying issue: the cited byte-slice `return s[:max] + "…"` for run errors (used at cron_run.go:151) no longer exists; cronTruncate now calls cutRuneBoundary(s, max) which lands on a UTF-8 rune boundary, so persisted run-error text can no longer be split mid-rune. |
| low | `internal/cli/cron_run.go:198-205` | Duplicate of the same underlying issue: cronTruncate no longer byte-slices `s[:max]`; it uses the rune-safe cutRuneBoundary helper (cron_run.go:204), with a comment explaining the cut lands on a UTF-8 rune boundary, closing the run-record invalid-UTF-8 gap. |
| low | `internal/redaction/redaction.go:217-231 (pointer), :232-252 (map)` | The pointer case now does context.seen[ptr]=struct{}{} (line 225) then delete(context.seen, ptr) after redactReflect returns (line 230); the map case mirrors this (line 240 add, line 251 delete). Comments at lines 226-228 explicitly state the pointer is dropped after recursion so a shared non-cyclic reference reached via a sibling branch is not mistaken for a cycle, exactly the originally-described defect, which is gone. |
| low | `internal/sandbox/grants.go:392-406` | readState now rebuilds a new `normalized` map keyed by `key := strings.TrimSpace(name)` (grants.go:394, stored at :403) instead of retaining the raw padded file key, and Lookup indexes with `strings.TrimSpace(toolName)` (grants.go:172), so a whitespace-padded file key both validates AND matches. The ledger's premise ('unchanged since #77') is stale — this normalization was added in commit 3f3de1a (#170). |
| low | `internal/tui/session_controls.go:196-241` | handleCompactCommand no longer just increments compactRequests: when sessionCompactor or hasSessionBackedCompactor() is available it sets compactInFlight and returns tea.Batch(m.runCompact(), ...), and runCompact()->compactActiveSession() actually calls sessionStore.PlanCompaction/RecordCompaction (replay.go:145/181). The 'pending integration' status string is gone (only a negative-assertion test references it), and sessionStore is always non-nil (model.go:272-274), so real compaction is triggered. |
| low | `internal/zerocommands/contracts.go:169` | ProviderSnapshotFromProfile now sets APIKeySet via `strings.TrimSpace(profile.APIKey) != "" \|\| strings.TrimSpace(profile.AuthHeaderValue) != ""` (line 169) with a comment (lines 166-168) explaining auth-header-only profiles must not render as 'not set'; config/types.go:51 confirms AuthHeaderValue is a valid sole credential, and command_center.go:317/334 render apiKeyState(provider.APIKeySet) off this fixed boolean. |
| low | `internal/zerogit/zerogit.go:209` | The remaining gap in the partial finding (ValidateMessage still byte-based `len(firstLine) > 72`) is gone: line 209 now uses `utf8.RuneCountInString(firstLine) > 72` with an explanatory comment, and truncateSubject (line 466) is already rune-based, so valid non-ASCII subjects are no longer rejected. |
## Genuinely partial (a real gap remains) (12)
| Sev | Location (current) | Rationale |
|---|---|---|
| high | `internal/cli/cron_run.go:176-188 (re-read fix) + internal/cron/store.go:170-178 (Update, no lock)` | fireJob does re-read via store.Get and preserves an external pause (current.Status == StatusPaused at cron_run.go:186-187) and handles ErrJobNotFound/transient errors, closing the pause-clobber; but cron/store.go Update still just calls writeJob (temp+rename) with no flock/CAS anywhere in the package, so the concurrent-scheduler read-modify-write race remains exactly as the ledger noted. |
| high | `internal/hooks/dispatch.go:112 (Dispatch); wired at internal/agent/loop.go:584,620 and constructed at internal/cli/hook_dispatch.go:51` | The primary gap is closed: the Dispatcher now has production callers and IS wired into the tool lifecycle - constructed in app.go/exec.go/exec_spec.go, assigned to agent Options.Hooks, and invoked at loop.go:584 (beforeTool, with veto via blockedByHookResult) and loop.go:620 (afterTool feedback). But the original finding's CLI-surface gap remains: runHooks in internal/cli/extensions.go:88-135 still supports only `list`/help with no dispatch/edit subcommand. |
| medium | `internal/agent/compaction.go:280 (maybeCompact) / 83-96 (estimateTokens)` | The image-counting fix is present (estimateTokens line 92 adds len(message.Images)*imageTokenEstimate), but the remaining gap stands: maybeCompact's trigger uses estimateTokens(messages), which has no tool-definition term, and is called at loop.go:85 with no tools (the exposed tool defs are built afterward at loop.go:87). The tool-definition estimator estimateToolTokens exists only in the separate MeasureContext reporting path (context_measurement.go:53), not in the compaction trigger. |
| medium | `internal/tui/rendering.go:292-336 (gap at :311)` | wrapPlainText now preserves explicit newlines (split on \n, :297) and each line's leading indentation (TrimLeft + reconstructed indent, :302-305), so code blocks survive. But the word loop at :311 still iterates strings.Fields(body), which collapses intra-line space runs, so mid-line column alignment within a line is still lost exactly as the finding describes. |
| medium | `internal/tui/transcript.go:103-114` | The full-slice copy is genuinely gone (in-place append at transcript.go:100 with a comment about the old O(n²)), but hasTranscriptRow (transcript.go:108-112) still linear-scans every existing row computing transcriptRowKey per keyed tool/permission/ask append, and m.transcript is never pruned, so rehydration via appendTranscriptRow (session.go:145-146) remains O(n²). |
| medium | `internal/tui/transcript.go:103-114` | Duplicate of the same finding: copy-on-append removed (transcript.go:96-100) but hasTranscriptRow (transcript.go:108-112) still linearly scans all rows per keyed row and the transcript is never bounded, so tool-heavy rehydration stays O(n²). |
| medium | `internal/tui/transcript.go:103-114` | Duplicate of the transcript.go:113 finding: append is in-place (transcript.go:100), but hasTranscriptRow (transcript.go:108-112) still linear-scans all rows per keyed (tool/permission/ask) row, so tool-heavy /resume rehydration remains O(n²). |
| medium | `internal/tui/transcript.go:103-114` | View re-render and copy-on-append are fixed (in-place append at transcript.go:100, empty-key fast path at transcript.go:105-107), but hasTranscriptRow (transcript.go:108-112) still scans the whole transcript per keyed append — O(n²) cumulative for tool rows during rehydration. |
| medium | `internal/tui/transcript.go:103-114` | Duplicate of the transcript.go:117 finding: the full-slice copy is removed and non-keyed rows are O(1) via the empty-key fast path (transcript.go:105-107), but hasTranscriptRow (transcript.go:108-112) still linear-scans all rows per tool/permission/ask append, so those appends/rehydration remain quadratic. |
| low | `internal/agent/loop.go:553-580, 611-616` | The main "requestEvent never read after the switch" claim is now fixed (requestEvent is passed to emitDeniedPermission/deniedPermissionResult on the denial paths at 571-572,577-578), but requestEvent.GrantMatched/Grant set at 574-575 in the AlwaysAllow success case remain dead writes (the post-run event at 611-616 is rebuilt fresh) and the fallbackPermissionEvent at 555 is still unreachable because shouldRequestPermission only fires for PermissionPrompt tools, for which buildPermissionEvent never returns ok=false (false only at 1071/1076 for non-Prompt/Allow). |
| low | `internal/tui/commands.go:297,306; internal/tui/command_center.go:43-55; internal/agent/loop.go:105-106; internal/tui/commands.go:254` | The main fix landed: footer builders, theme tokens (selRow/statusOk/statusErr/line2/panel2), and actionAppendToolCall/Result are all gone (zero grep hits). But the cited gaps remain: listCommandNames (commands.go:297) and formatCommandHelpLines (commands.go:306) are referenced only by test files; resumeText's args!="" branch (command_center.go:45-55) is still unreachable since its sole non-test caller session.go:122 passes ""; OnContext (agent/loop.go:105-106) is emitted but has no production assignment; and the stale footer comment persists at commands.go:254. |
| low | `internal/tui/commands.go:297,306; internal/tui/command_center.go:43-55; internal/agent/loop.go:105-106; internal/tui/commands.go:254` | Duplicate of the other rendering.go:62 row; same underlying code. Footer builders and the theme tokens are fully removed, but formatCommandHelpLines/listCommandNames (commands.go:297-308) remain production-defined yet referenced only by tests, and a stale 'footer advertises' comment persists at commands.go:254 (the autocomplete.go:64 stale comment cited in the older row is now gone, but commands.go:254 still has one). |
## Genuinely still open (66)
| Sev | Location (current) | Rationale |
|---|---|---|
| medium | `internal/agent/compaction.go:422 (summarizeMessagesOnce)` | summarizeMessagesOnce still calls plain zeroruntime.CollectStream (compaction.go:422), which is CollectStreamWithOptions with empty CollectOptions{} (helpers.go:78), so no OnUsage callback fires; the collected Usage is discarded and the deliberate non-forwarding is now documented at summarizeClosure (compaction.go:359-361). Compaction-call token usage is still not surfaced/tracked. |
| medium | `internal/agent/loop.go:567-573, 928-931` | The AlwaysAllow case still calls deniedPermissionResult/emitDeniedPermission when persistPermissionGrant errors (loop.go:569-573), and persistPermissionGrant (now at 928-931) still errors unconditionally when options.Sandbox == nil, so choosing AlwaysAllow without a configured sandbox denies the already-approved call; both sub-claims persist (only relocated from the stale 778-779 reference). |
| medium | `internal/background/manager.go:462-471 (and persistTaskLocked call at :419)` | normalizeLoadedTask still unconditionally rewrites task.Status==StatusRunning to StatusError, sets PID=0 and ExitCode=-1, and marks changed=true; loadTasks then persists this to disk via persistTaskLocked at line 419, with no process-liveness check or reconciliation. The defect is present essentially as the auditor described. |
| medium | `internal/background/process_posix.go:29,50` | SIGTERM (line 29) and Kill() (line 50) still target only the single pid with no kill(-pid)/negative-pid group signal, and launchBackgroundProcess (specialist/exec.go:650) sets no SysProcAttr{Setpgid:true} (grep finds zero process-group setup), while Windows still tree-kills via taskkill /T /F (process_windows.go:12). All three claims hold. |
| medium | `internal/cli/app.go:200-220` | In the `--skip-permissions-unsafe` case, the code re-splits args[1:] via splitLeadingAddDirFlags and only iterates `rest` to reject a misplaced --add-dir (lines 215-219); it then calls runInteractiveTUI with merged addDirs only, never forwarding `rest`, so any trailing non-flag args are still silently dropped. The in-code comment at lines 208-209 explicitly confirms this ('were ignored on this path... and still are'), matching the original finding verbatim. |
| medium | `internal/cli/cron_run.go:103-128` | reconcileOverdue still reschedules any strictly-overdue active job (NextRunAt before nowMin) to sched.Next without firing it (lines 116-126); grep confirms no RunNow/RunOnce/FireNow sentinel exists in internal/cron, so the only way to fire backlog is the --catch-up flag that skips reconcileOverdue entirely. |
| medium | `internal/cli/exec.go:191-199` | The --list-tools branch special-cases only execOutputStreamJSON (exec.go:192-194); -o json (execOutputJSON) falls through to writeExecToolList, which calls formatExecToolList and prints the plain-text "Tools visible to model:" listing (exec_tools.go:100-110) rather than any JSON output. |
| medium | `internal/cli/exec.go:531-545` | The interrupted branch still emits writer.errorEvent/runEnd only when outputFormat == execOutputStreamJSON (exec.go:535-540); for -o json it just does fmt.Fprintln(stderr, "Interrupted.") at exec.go:542 and returns exitInterrupted, producing no terminal JSON event on stdout (unlike the provider-error path which writes type:error/type:done for json). |
| medium | `internal/cli/exec_sessions.go:116-124` | execSessionRecorder.append still early-returns when recorder.err != nil (line 117) and latches AppendEvent failures into recorder.err (line 120); a package-wide grep shows recorder.err/sessionRecorder.err is read only inside append itself, never by exec.go or exec_spec.go (which check only writer.err), so persistence errors are silently swallowed at run end. |
| medium | `internal/cli/exec_sessions.go:116-124` | Duplicate of the same recorder.err defect: append (lines 116-124) early-returns once recorder.err is set and stores AppendEvent errors there, but sessionRecorder.err is never read in exec.go or exec_spec.go, so a failed session append is never surfaced to the user at run end. |
| medium | `internal/config/resolver.go:102, 110-124` | ResolvedConfig.MCP is still set at resolver.go:102 but has zero readers: every `.MCP` access in internal/cli is the config.MCPConfig type or the separate resolveMCPConfig/ResolveMCP path, and no resolveConfig caller reads resolved.MCP. MCP registration runs through ResolveMCP (resolver.go:110-124), which only merges config-file paths and Overrides and never calls LoadProviderCommand/touches options.ProviderCommand (asserted intentional by TestResolveMCPDoesNotRunProviderCommand), so ZERO_PROVIDER_COMMAND MCP servers are still dropped. |
| medium | `internal/cron/schedule.go:171-201` | Empirically confirmed: on the 2026-11-01 US fall-back day Next("30 1 * * *") returns 01:30 EDT and then 01:30 EST again, firing the daily job twice during the repeated wall-clock hour. The Next loop and its forward-progress guard (schedule.go:182-198) only handle spring-forward non-existent instants and contain no de-duplication for the ambiguous repeated fall-back hour. |
| medium | `internal/hooks/hooks.go:503` | AuditStore.append still calls store.ReadEvents() at line 503, which os.ReadFile's the entire JSONL audit file and json.Unmarshal's every line on every single append, solely to scan for the highest existing Sequence (lines 507-513); no cached/in-memory sequence counter was introduced, so the O(n) full re-read per append is unchanged. |
| medium | `internal/mcp/client.go:246` | client.writer.write(...) at line 246 still executes while client.mu is held (locked at :228, unlocked at :255), and writer.write (protocol.go:148-163) does a blocking Write+Flush on the stdin pipe with no ctx awareness; only the response wait at :257 is ctx-aware, unchanged from the audit. |
| medium | `internal/mcp/client.go:295-318` | readLoop still dispatches any id-bearing message to pending[id] (lines 302-316) with no check of message.Method; grep finds no 'ping' or Method handling anywhere in client.go, so server-initiated requests are still ignored/misrouted exactly as described. |
| medium | `internal/mcp/client.go:98-99` | Still 'var stderr bytes.Buffer; cmd.Stderr = &stderr' — a plain unbounded buffer (no LimitReader/cap), never reset after startup, attached for the whole process lifetime and only read on initialize failure at line 114. |
| medium | `internal/mcp/network_client.go:612-642 (decodeSSERPCMessage), caller at :219` | decodeSSERPCMessage still decodes the first non-empty 'message' SSE event, sets found=true and returns false (stops scanning) without checking the message ID; the synchronous caller networkClient.request at line 219 does `if !rpcIDMatches(message.ID, id)` and errors on mismatch instead of scanning for the matching response. Defect unchanged (caller line is now :219, was the stale :210). |
| medium | `internal/mcp/network_client.go:646 (scanSSEEvents), failPending at :587-593, request gate at :243` | scanSSEEvents still calls scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) with the 1MiB cap; a bufio.Scanner ErrTooLong flows through readStream into failPending (:593) which sets a permanent client.streamErr, and request() returns that streamErr forever (:243-246) with no reconnect path (openStream is only called once at connect, :77). |
| medium | `internal/providers/gemini/types.go:103-108` | The usageMetadata struct still declares only PromptTokenCount, CandidatesTokenCount, ThoughtsTokenCount, and TotalTokenCount with no cachedContentTokenCount field; provider.go:225-228 reads only those into input/output/reasoning tokens and emitDone (provider.go:282-286) builds TokenUsage without CachedInputTokens, which is set only in openai/provider.go:253 and anthropic/provider.go:330. |
| medium | `internal/providers/openai/tool_state.go:95-101 (and applyDelta:58)` | applyDelta still returns early when call.id=="" (line 58), and closeOpen still only emits StreamEventToolCallDropped for empty-id/empty-name calls (lines 95-99) instead of streaming them with a synthetic key; every Start/Delta/End the adapter emits carries call.id, so the zeroruntime collector's empty-ID/synthetic-key support (helpers.go start/delta/end at ~173-216) is never reached from the OpenAI adapter. The provider_test still asserts such calls are dropped and never started, confirming the behavior is unchanged, and tool_state.go's last commit (4d2ce6b) predates the 2026-06-10 audit. |
| medium | `internal/sandbox/safe_command.go:431` | splitShellSegments still uses a strings.NewReplacer that blindly turns \|, ;, &&, \|\|, $(, ), backtick into segment boundaries with zero quote awareness; a live probe confirms `git commit -m "use top \| less"` is flagged as 'less' (interactive=true) and `echo "a; vim b"` is flagged as 'vim', exactly as the original finding described. The file is unchanged since commit #109 and contains no quote-state parsing. |
| medium | `internal/sessions/lineage.go:140` | tree() (lineage.go:128-154) still calls store.ListChildren(sessionID) at lineage.go:140 once per node, and ListChildren calls store.List() at lineage.go:74; List() (store.go:312-338) re-reads the whole sessions dir via os.ReadDir+Get->readMetadata (store.go:674-684, a fresh os.ReadFile+json.Unmarshal with no cache), so an N-node tree re-reads every metadata.json N times. The defect is present exactly as described. |
| medium | `internal/sessions/store.go:428-432 (Fork copy loop), 526-567 (appendEventLocked); internal/usage/report.go:74-119 (BuildReport)` | Fork's loop at store.go:428-431 re-appends every parent event (no provider_usage/EventUsage skip) via AppendEvent, which stamps a fresh CreatedAt/ID in appendEventLocked (537-543), and BuildReport (report.go:74-119) aggregates all EventUsage events with no event-id dedupe, so forked usage events are double-counted. |
| medium | `internal/sessions/store.go:686-701 (writeMetadata), 549-559 (appendEventLocked write path)` | writeMetadata still does os.WriteFile to a temp file + os.Rename (store.go:693-696) with no file or directory fsync, and a repo-wide grep finds zero Sync() calls anywhere in internal/sessions/; appendEventLocked's append write (549-557) is likewise unsynced. |
| medium | `internal/specialist/accounting.go:80-91 (appendSpecialistUsageRollup) and :31-47 (recordSpecialistStop)` | The dedup is still a non-atomic check-then-append: specialistEventExists calls store.ReadEvents (which acquires NO lock, store.go:569) and only the later AppendEvent takes the per-session lock for its own write, so concurrent callers (exec.go:343 onExit and output_tool.go:150 TaskOutput) can both pass the existence check and each append a duplicate event. No serializing mutex spans the check and append in the specialist package, and the store's lockSession/appendEventLocked machinery is not used by the accounting path. |
| medium | `internal/specialist/exec.go:150 (and :202)` | Both BuildArgs (:150) and BuildResumeArgs (:202) still hardcode args append "--auto","high", and internal/cli/exec_tools.go:84-85 maps "high" -> agent.PermissionModeUnsafe, so specialist children always run in unsafe permission mode. |
| medium | `internal/specialist/streamer.go:43` | ParseStream still calls scanner.Buffer(make([]byte,0,64*1024), 1024*1024), so a line over 1MB yields bufio.ErrTooLong; exec.go:631-634 returns that error from runChildProcess aborting the whole run, and the producer at cli/exec_writer.go:99 (parseToolCallArgs) still emits tool_call Args with no length cap. All three elements of the original finding are unchanged. |
| medium | `internal/tools/registry.go:198-245` | The Core* tool sets (CoreReadOnlyTools/CoreWriteTools/CoreShellTools at :224/CoreNetworkTools/CoreTools) still omit escalate_model; it is registered only under exec --allow-escalation (cli/exec.go:156-157), while the TUI registry built by newCoreRegistryScoped (cli/app.go:436,531-537) never registers it — app.go only mentions escalate_model in help text at :733. |
| medium | `internal/tui/model.go:2022-2028 (session log) vs model.go:854/802/1721 (transcript)` | Interim narration is appended to the live transcript on tool call (model.go:852-857), error (801-803), and cancel (1720-1722), but the session log only persists result.FinalAnswer via EventMessage (model.go:2022-2028); onText (1781-1786) and sendAgentText (2064-2069) record no session event, so the gap the ledger flagged as 'partial' is unchanged — interim segments are still never persisted and /resume loses them. |
| medium | `internal/tui/session_controls.go:148-177; internal/tui/model.go:1746-1778` | m.responseStyle is still only stored by handleStyleCommand (session_controls.go:156) and echoed in display strings (session_controls.go:159/171, command_views.go:275); runAgentWithOptions threads ReasoningEffort/SystemPrompt/PermissionMode into options but never references responseStyle, and agent.Options has no style field (no non-display ResponseStyle reader exists), so /style still changes nothing about responses. |
| medium | `internal/tui/session_controls.go:148-177; internal/tui/model.go:1746-1778` | Duplicate of the responseStyle finding: m.responseStyle is set at session_controls.go:156 but runAgentWithOptions (model.go:1746-1778) never assigns it into agent.Options or the system prompt; the only readers are display strings (command_views.go:275, session_controls.go:159/171), so the style preference is inert. |
| medium | `internal/tui/session_controls.go:148-177; internal/tui/model.go:1746-1778` | Duplicate of the responseStyle finding verified once: handleStyleCommand only stores m.responseStyle and grep shows no non-display consumer; runAgentWithOptions sets ReasoningEffort/SystemPrompt/etc. on agent.Options but never the style, so /style remains cosmetic. |
| medium | `internal/tui/session_controls.go:318 (resolveRewindTarget); internal/sessions/rewind.go:205` | resolveRewindTarget still returns lastCheckpoint-1, while OnToolCall appends EventToolCall then the EventSessionCheckpoint right after it (model.go:1886-1911) so the tool_call gets the immediately-preceding sequence; TruncateEvents keeps ev.Sequence<=target (rewind.go:205), so the tool_call (seq==target) is retained while its checkpoint (seq>target) is dropped, leaving the dangling tool_call described in the finding. |
| medium | `internal/usage/report.go:17-21 (struct) and :102 (pricing); writer at internal/cli/exec.go:521-524` | usageEventPayload (report.go:17-21) still has only PromptTokens/CompletionTokens/TotalTokens with no model field, and BuildReport at report.go:102 still prices via modelBySession[event.SessionID] from session Metadata.ModelID; meanwhile exec.go:521-522 still persists payload["model"]=currentModel under --allow-escalation, and a repo-wide grep confirms no consumer reads that per-event model key (the only usageEventPayload reader silently drops it on unmarshal). |
| medium | `internal/zerogit/zerogit.go:225` | parseStatus still does `path := strings.TrimSpace(line[3:])` with no C-quote unescaping and no `old -> new` rename split, and the caller at line 119 runs `status --short --untracked-files=all` without `-z`/porcelain, so quoted or renamed paths are still mis-parsed exactly as described. |
| low | `internal/agent/loop.go:1305-1316` | propertyToRuntimeMap still assigns schema["items"] = propertyToRuntimeMap(*property.Items) twice for the same property.Items != nil guard (lines 1305-1307 and 1314-1316); the second assignment is a redundant duplicate writing an identical value. |
| low | `internal/agent/loop.go:189-191` | The reactive-retry CollectStreamWithOptions call still passes only OnUsage and omits OnText (comment at 183-188 documents this as deliberate anti-duplication), so streaming surfaces still receive only the aborted partial text on the retried turn exactly as the finding describes. |
| low | `internal/agent/loop.go:194-201` | When ctx is canceled, internal/zeroruntime/helpers.go:89 sets collected.Error = ctx.Err().Error(); loop.go:194-197 returns errors.New(collected.Error) before the ctx.Err() identity return at 198-201, so the returned error no longer wraps the sentinel and errors.Is(err, context.Canceled) still fails on that path. |
| low | `internal/agent/types.go:196 (field); internal/agent/loop.go:105-106 (emit site); internal/agent/context_measurement.go:31 (MeasureContext/ContextBreakdown)` | The OnContext callback field (types.go:196) and its emit site (loop.go:105-106 calling MeasureContext) still exist, but a repo-wide grep finds ZERO 'OnContext:' assignments anywhere (test or prod) and no use of MeasureContext/ContextBreakdown outside internal/agent tests; cli/tui/cmd and the contextreport package never reference them (contextreport only calls agent.BuildSystemPromptPreview), so the callback remains permanently a no-op exactly as the original finding described. |
| low | `internal/background/process_posix.go:38-50` | terminateProcess still does os.FindProcess(pid) (line 24) and polls liveness via signal-0 in processAlive (lines 39-47, 59-61), while launchBackgroundProcess (specialist/exec.go:659-673) still reaps the child in a concurrent command.Wait() goroutine, so the PID can be reaped and recycled before process.Kill() at line 50 fires. Defect unchanged. |
| low | `internal/cli/cron_run.go:146-152` | rec.Error is still set only from errBuf (cronTruncate(strings.TrimSpace(errBuf.String()),500) when code!=0); outBuf is declared and passed to exec at line 146-147 but never read anywhere in fireJob, so stream-json error events on stdout are still discarded. |
| low | `internal/cli/exec.go:383-398 (PrepareExec call), exec.go:404 (time.Now), internal/cli/exec_sessions.go:55 (direct NewStore)` | All three sub-claims hold: the sessions.PrepareExec call at exec.go:383-398 omits the Store field so PrepareExec falls back to NewStore(StoreOptions{}) at exec_session.go:58-61; preflightExecSession still does sessions.NewStore(StoreOptions{}) directly at exec_sessions.go:55; and runExec uses streamjson.CreateRunID(time.Now()) at exec.go:404 instead of the injectable deps.now (which is available and used at exec.go:142/186). |
| low | `internal/cli/exec_sessions.go:55` | preflightExecSession still calls sessions.NewStore(sessions.StoreOptions{}) directly at line 55, and exec.go's PrepareExec call (exec.go:383-397) omits the Store field so sessions/exec_session.go:58-60 defaults to NewStore again; deps.newSessionStore is used by spec/usage/observability/sessions paths but never by the exec run path in exec.go. |
| low | `internal/config/contracts.go:5,12,65` | ContractGap (line 5), DefaultContractGaps (line 12), and FindContractGapsByMilestone (line 65) are all still defined, and a repo-wide grep for these symbols (plus ContractOwnerRuntime) finds references ONLY in internal/config/contracts_test.go. No production caller exists, so the dead-code defect persists exactly as described. |
| low | `internal/config/resolver.go:144,177,506` | All three MaxTurns sites (mergeConfig :144, mergeProjectConfig :177, applyOverrides :506) still gate purely on `if src/overrides.MaxTurns > 0` with no error path, and Resolve() has no MaxTurns validation, so a configured 0 or negative maxTurns is silently dropped and falls back to defaultMaxTurns=30 (resolver.go:16,22). A negative-value error path exists for tools.deferThreshold (:57-59) but none was added for MaxTurns. |
| low | `internal/plugins/plugins.go:894` | parseHookEvent's switch (plugins.go:894) still accepts only HookBeforeTool/HookAfterTool/HookSessionStart/HookSessionEnd and the HookEvent consts (plugins.go:39-42) omit specialist events entirely, whereas hooks.go parseEvent (hooks.go:812) accepts those four plus EventSpecialistStart/EventSpecialistStop, so the two validation sets still disagree exactly as described. |
| low | `internal/providercatalog/catalog.go:46` | The `Public bool` field is still declared on Descriptor (catalog.go:46) but is never assigned anywhere in the codebase: there are zero `Public:` struct-literal assignments and zero `.Public =` writes. The only read remains the vacuous guard at catalog_test.go:106 (`if descriptor.Public && !descriptor.RequiresAuth`), which is dead code since Public is always false. |
| low | `internal/providers/factory.go:115` | At the sole call site (line 115) the `\|\|` short-circuits so isImplicitOpenAI is only evaluated when explicitProvider==true, which (per explicitProviderKind at 147-157) requires ProviderKind!="" or Provider!=""; but isImplicitOpenAI (159-164) requires both ProviderKind=="" and Provider=="", so it can never return true there — the term remains dead code exactly as described. |
| low | `internal/redaction/redaction.go:174` | Line 174 still declares `var stackTracer interface{ StackTrace() fmt.Stringer }`; a repo-wide grep finds StackTrace only at redaction.go:174 and :176 (no implementer in the module), and go.mod has no pkg/errors-style dependency, so this errors.As branch is dead code that can never match exactly as the finding described. |
| low | `internal/sessions/store.go:615-617 (timestamp), 331-336 (List), 340-346 (Latest)` | timestamp() at store.go:616 still formats with time.RFC3339 (second precision), and List() at 332-334 breaks UpdatedAt ties lexically by SessionID, so Latest() (line 345 returns sessions[0]) can still pick the wrong session on same-second ties exactly as described. |
| low | `internal/skills/skills.go:91 (Duplicates); consumers: internal/cli/skills.go:62, internal/plugins/activate.go:376` | skills.Duplicates and skills.Get still have zero callers outside internal/skills (Get has zero callers anywhere, even tests), and the duplicate-collision data is never surfaced: cli/skills.go runSkillsList calls skills.List with no warning, and the skill tool discards the dups at activate.go:376 with `merged, _ := MergedSkillsLoaded(...)`. Although plugins.mergeSkills now computes a parallel DuplicateName slice that MergedSkills returns, every call site drops it, so shadowed/dropped skills are still resolved silently with no user-facing warning, exactly the defect described. |
| low | `internal/specialist/exec.go:353-358` | On manager.SetPID failure the code only calls executor.cleanupBackgroundPromptFile and returns the error; it does not call recordSpecialistStop or manager.UpdateStatus(StatusError) while the already-launched child keeps running, unlike the launch-error path at :347-352 which does record a stop. |
| low | `internal/specialist/output_tool.go:244` | formatTaskOutput is still dead code: a codebase-wide grep finds only its own definition at :244 and no callers in prod or tests; the only used formatter is formatTaskOutputSummary, called from readOutput at :154. |
| low | `internal/streamjson/streamjson.go:30` | Repo-wide grep for EventRestore (and the literal "restore" EventType) returns only the declaration at streamjson.go:30 — no emitter and no test reference, whereas the sibling EventCheckpoint is emitted at cli/exec_writer.go:119 and referenced in streamjson_test.go:435. The constant remains dead exactly as the original finding described. |
| low | `internal/tools/read_file.go:98-121` | The truncation path is unchanged from main (empty git diff): when max_lines truncates, it sets Result.Truncated=true (line 120) but the only in-output signal is the '(lines 1-5 of 20)' range header (line 114) — no distinct truncation marker is appended to Output (line 119). The rune-width sub-claim remains non-reproducible because line-number padding uses ASCII digits via strconv.Itoa (lines 105-109), exactly as the original finding concluded. |
| low | `internal/tools/registry.go:19` | Repo-wide grep finds OnSandboxDecision only at registry.go:19 (declaration), :113 (nil check), and :121 (invocation) — zero struct-literal (OnSandboxDecision:) or dot-assignment (.OnSandboxDecision =) sites anywhere in non-test or test code, so the field is still never set by any caller. |
| low | `internal/tui/command_center.go:45-55` | resumeText's args!="" branch (command_center.go:45-55) is still dead: the only production caller, handleResumeCommand at session.go:121-122, passes "" (the non-empty case is handled separately via resolveResumeSession at session.go:125), so the branch remains unreachable; one stale 'footer advertises' comment also persists at commands.go:254 (the autocomplete.go one is gone). |
| low | `internal/tui/command_center.go:17-24` | doctorText() still constructs doctor.Run(doctor.Options{Now, Runtime:"go", Provider}) with no UserConfig/ProjectConfig (command_center.go:18-22), unlike the CLI path at observability.go:64-71; so configFilesCheck (doctor.go:111-123) gets empty paths and emits the StatusWarn 'No explicit Zero config files were inspected.' exactly as the finding describes. |
| low | `internal/tui/commands_test.go:46` | The function commandTestStringSliceContains is still defined at line 46, and a repo-wide grep across all *.go files returns only the definition itself with zero call sites, matching the original dead-helper finding exactly. |
| low | `internal/tui/model.go:1363-1365` | handleSubmit still drops a prompt while m.exiting with a bare `return m, nil` and no feedback row, and no render path surfaces an exiting/flushRunIDs indicator: statusLine (view.go:137-156) shows only provider+usage, and composerLine's hint switch (model.go:1130-1134) only shows 'esc stop' when m.pending. |
| low | `internal/tui/options.go:34,39-40 (UsageTracker/ReasoningEffort/ResponseStyle); internal/cli/app.go:484-516; internal/tui/model.go:277-279` | The exact residual gap the ledger evidence describes is still present verbatim: the production caller's tui.Options{...} block in app.go (484-516) sets none of UsageTracker, ReasoningEffort, or ResponseStyle. The mitigations cited also still hold — newModel self-defaults a usage.Tracker when options.UsageTracker is nil (model.go:277-279) and effort/style remain runtime-settable via /effort (session_controls.go:81) and /style (session_controls.go:156) — so the code matches the partial state described, with the same unset-fields gap unchanged. |
| low | `internal/tui/session_test.go:215` | The local slice `runtimeMessages := []tea.Msg{}` (line 215) is still only appended to in the RuntimeMessageSink closure (line 226) and never read or asserted anywhere in the test body; the test exclusively consumes the channel runtimeMessageCh via receiveRuntimeMessage, leaving the slice write-only exactly as the finding described. |
| low | `internal/verify/verify.go:215` | RunLoop is still defined at verify.go:215 and a repo-wide grep finds only two references: the definition and its sole caller verify_test.go:207. The production retry loop is selfverify.Run (selfverify.go:53), which re-implements the attempt loop calling verify.Run directly (selfverify.go:75) and is the one wired into cli/workflows.go, so RunLoop plus LoopOptions/LoopReport remain dead production code. |
| low | `internal/zerocommands/sandbox_snapshots.go:82-104 (SandboxPlanSnapshot/SandboxDecisionSnapshot); raw serialization at internal/cli/sandbox.go:92-94,146-148` | Grep confirms SandboxPlanSnapshot/Decision/Policy/Backend/Risk/Violation snapshots are referenced only inside internal/zerocommands and its tests (no production consumer; the only new consumers in tui/command_views.go are SandboxGrantSnapshots and ProviderSnapshot, which are different types). `zero sandbox policy --json` in internal/cli/sandbox.go still builds its payload from raw zeroSandbox.Policy/Backend/BackendPlan (lines 92-94 and 146-148) instead of the snapshot constructors, exactly as the original evidence described. |
| low | `internal/zerogit/zerogit.go:419-423` | The fallback EnvRunner adapter at lines 420-422 is still `func(ctx, dir, _ []string, args ...string)` which discards the env slice and calls `runGit(ctx, dir, args...)`, so a caller-supplied Runner with no RunGitEnv still loses stagedSnapshotDiff's GIT_INDEX_FILE isolation. |
| low | `internal/agent/loop.go:194-201 (origin internal/zeroruntime/helpers.go:89)` | helpers.go:89 still sets collected.Error = ctx.Err().Error() (stringifying the cancellation, losing identity), and loop.go:194-196 returns errors.New(collected.Error) BEFORE the identity-preserving `return result, ctx.Err()` at loop.go:198-200, so on the stream-cancellation path the returned error is a plain string error and errors.Is(err, context.Canceled) still fails. |
-649
View File
@@ -1,649 +0,0 @@
# Zero — Deep Adversarial Code Audit (2026-06-20)
- **Target:** `github.com/Gitlawb/zero` @ `origin/main` commit `bfbdbb1` (a terminal coding agent: Go, Bubble Tea v2 TUI, surface-agnostic core feeding TUI / headless `exec` / MCP server / cron).
- **Toolchain:** Go 1.25.0 (toolchain go1.26.4). 72 packages.
- **Method:** Fresh checkout of `origin/main` (the audit-batch worktree lags main). Recon → build/vet/`-race` gates → 13-subsystem deep read (each reconciling its slice of the prior audit docs) → adversarial re-verification of every finding by independent skeptics (2 for high, 1 otherwise) that re-read guards/locks/callers to try to *disprove* it; only survivors are reported. Findings are grounded in current code with `path:line` + quoted evidence; several were empirically reproduced with throwaway tests.
- **Scope note:** Audit only — no source was modified.
## 1. Executive summary
The tree is in markedly better shape than the 2026-06-10 audit: of ~150 prior findings, **64 are fully fixed and 14 partially fixed** in current `main` (atomic config write, durable session metadata, cross-process cron/hooks/oauth locks, MCP framing + alloc cap, SSRF DNS-rebind pinning, sandbox quote-aware interactive detection, FinishReason consumption, transcript O(n²) + per-frame re-render, UTF-8 rune-safe truncation across the board, secret-pattern anchoring, and more). Build and vet are clean; the race detector found **zero data races** across 67/68 packages.
The residue clusters into four recurring root causes (§5). The single most important new item is **one security finding (M10):** the provider health-probe forwards `x-api-key`/`x-goog-api-key`/custom headers across an HTTP redirect to a public host — a credential-exfil vector the recently-added DNS-rebind/internal-redirect hardening does not cover. The lone high-severity item (**H1**) is a trust/correctness gap: four of six documented, user-configurable hook events are never dispatched. The remaining mediums are durability (events.jsonl not fsync'd; PTY last-chunk drop), cron edge cases (DST double-fire, claim double-grant, `--run-now` cancelled), connection-lifecycle gaps (remote-bridge idle deadline, Shutdown can't close bridge conns, MCP SSE never reconnects), a sandbox over-block false-positive, a stream-json prose-mangling regex, and a TUI streaming O(n²).
### Severity rollup
| Severity | Count |
|---|---|
| Critical | 0 |
| High | 1 |
| Medium | 15 |
| Low | 17 |
| Info | 2 |
| **Total (surviving)** | **35** |
Prior-audit reconciliation: **64 fixed · 14 partial · 12 still-open** (§4). Two drafted findings were dropped in adversarial review (§4.4).
## 2. Build / vet / test results (verbatim)
Run in the fresh `origin/main` checkout, Go-native only (per AGENTS.md), with test isolation `HOME`/`XDG_CONFIG_HOME` pointed at temp dirs and `CI=1`.
```
$ go build ./...
(no output) — exit 0 ✅ clean
$ go vet ./...
(no output) — exit 0 ✅ clean
$ go test ./... -race -count=1
ok <67 packages> ✅
--- FAIL: TestIndependentExecCommandConstructorsShareDefaultManager (1.01s)
exec_command_test.go:43: expected shared manager to find completed session, got ... session_id:"1000" ...
--- FAIL: TestExecCommandReturnsSessionAndWriteStdinPollsCompletion (1.02s)
exec_command_test.go:82: expected exit_code 0, got ... session_id:"1000" ...
--- FAIL: TestExecCommandReturnsExitCodeWhenCommandCompletesDuringInitialYield (1.01s)
exec_command_test.go:99: completed command must not return session_id, got ... session_id:"1000" ...
FAIL github.com/Gitlawb/zero/internal/tools 13.664s
exit 1
```
**Race detector: 0 data races reported in any package.** The 3 `internal/tools` failures are **`-race`-only timing flakes, not product defects**: they pass cleanly 3× without `-race` (`go test ./internal/tools -run <them> -count=3` → `ok`), and fail under `-race` only because the test's fixed 10ms yield window is too short when the binary runs ~5–10× slower under race instrumentation — the command is still running when the window elapses, so the tool *correctly* returns a still-running `session_id` instead of an `exit_code`. This is a test-robustness gap (the suite is not `-race`-clean) recorded under Confidence notes (§7); it is the same exec-session timing family that recently churned in PRs #270/#273. (A genuine, separate `-race`-detectable data race in this package — `lastUsedAt` — is reported as **L15**, found by inspection because no test exercises it.)
## 3. Findings by severity
> Each finding was re-verified against current code by independent skeptics; the evidence below is what withstood that challenge. Locations are `path:line` relative to the repo root.
### H1 · Four of six hook events (sessionStart/sessionEnd/specialistStart/specialistStop) are validated and user-configurable but NEVER dispatched
- **Severity / category:** high / dead-inert · **Subsystem:** hooks
- **Location:** `internal/hooks/dispatch.go:112; internal/agent/loop.go:914,937`
- **Evidence:**
```
KnownEvents() returns all six (hooks.go:888) and IsValidEvent gates `zero hooks add` (hooks_manage.go:297-298) advertising all six in help (hooks_manage.go:315 "beforeTool, afterTool, sessionStart, sessionEnd, specialistStart, specialistStop"). But the only two production call sites of Dispatcher.Dispatch are loop.go:915 (`Event: hooks.EventBeforeTool`) and loop.go:938 (`Event: hooks.EventAfterTool`). grep over internal/+cmd/ for `.Dispatch(` finds exactly those two; no dispatch ever passes EventSessionStart/EventSessionEnd/EventSpecialistStart/EventSpecialistStop (the specialist lifecycle in internal/specialist/accounting.go:38,60 emits sessions.EventSpecialistStart/Stop to the SESSION store, never to hooks.Dispatcher).
```
- **Impact:** Across a whole session a user (or plugin) who configures a sessionStart/sessionEnd/specialistStart/specialistStop hook — e.g. a sessionStart hook that loads secrets/env, or a sessionEnd hook that flushes/cleans up, or a specialist audit hook — gets a green `zero hooks add` and a hook that `zero hooks list` shows as enabled, yet the command is silently never executed for the entire run. This is a correctness/trust gap: lifecycle automation users believe is wired (cleanup, audit, env priming) just does not happen, with no error surfaced. This is the same class as the prior 2026-06-10 high finding 'loaded but never executed' — the beforeTool/afterTool half was fixed; the other four events remai
- **Fix:** Either (a) actually dispatch these events from their lifecycle points: call options.Hooks.Dispatch with EventSessionStart at session/agent-run start, EventSessionEnd at run teardown, and EventSpecialistStart/Stop alongside the sessions events in internal/specialist/accounting.go; or (b) if only beforeTool/afterTool are intended to be runnable, remove the other four from KnownEvents()/IsValidEvent and the CLI help so users cannot configure dead hooks. Option (a) is preferable since the events are already a documented surface.
### M1 · streamjson api_key secret pattern still mangles ordinary prose/output in every stream-json event
- **Severity / category:** medium / robustness · **Subsystem:** cli-usage-streamjson
- **Location:** `internal/streamjson/streamjson.go:314`
- **Evidence:**
```
regexp.MustCompile(`(?i)(api[_-]?key["'=:\s]+)[^"',\s)]+`) — the ["'=:\s]+ class lets a plain space follow apikey, so the next word is captured. Verified: redactString("The function apikey: foo is documented here") -> "...[REDACTED] is documented..."; redactString("The user's apiKey value spans...") -> "...apiKey [REDACTED] spans...". The sibling sk-/bearer patterns were anchored, but this one was not.
```
- **Impact:** FormatEvent runs redactValue->redactString over EVERY string field of EVERY emitted stream-json event (text-event Delta = model output, tool_result Output, final Text, warning/error Message). Any time the model's answer, a tool's output, a diff, a log line, or documentation contains the literal token "api_key:"/"apiKey "/"api-key=" followed by a word, a chunk of that non-secret content is silently replaced with [REDACTED] in the machine-readable protocol a downstream consumer parses. Over a session that discusses configs/auth (common for a coding agent) this corrupts arbitrary output with no warning. This is the unfinished half of prior finding M48.
- **Fix:** Require an actual credential shape after the key marker rather than 'any next word': drop bare whitespace as a value delimiter (use `(api[_-]?key)\s*[=:]\s*["']?` for the prefix) and require a credential-length body, e.g. `[A-Za-z0-9._-]{12,}` (mirror the sk-/bearer anchoring already applied). Add a prose regression case ("the api_key: foo setting" must survive) to TestRedactStringDoesNotOverMatchProse.
### M2 · ResolvedConfig.MCP is computed but never read; ZERO_PROVIDER_COMMAND MCP servers are silently dropped
- **Severity / category:** medium / correctness · **Subsystem:** config-plugins-skills
- **Location:** `internal/config/resolver.go:106`
- **Evidence:**
```
Resolve() merges provider-command output (LoadProviderCommand at resolver.go:54 + mergeConfig at :58, which calls mergeMCPConfig) and sets `MCP: cfg.MCP` (resolver.go:106). But `grep -rn 'resolved.MCP' internal/cli` returns nothing — no caller reads ResolvedConfig.MCP. Runtime MCP registration goes through the separate ResolveMCP (resolver.go:115-133), which only seeds DefaultMCPServers + merges UserConfigPath/ProjectConfigPath/Overrides.MCP and never calls LoadProviderCommand or touches options.ProviderCommand.
```
- **Impact:** A provider command (ZERO_PROVIDER_COMMAND) that emits an mcp.servers block has those servers folded into Resolve()'s result but the runtime registers from ResolveMCP, so provider-command-supplied MCP servers are never registered for the whole session — the user sees no MCP tools from them and no error. ResolvedConfig.MCP is also dead weight.
- **Fix:** Have ResolveMCP also merge LoadProviderCommand(options.ProviderCommand).MCP (between the file merges and the overrides merge), or make the runtime read resolved.MCP from the single Resolve() path. Drop ResolvedConfig.MCP if it stays unread.
### M3 · Project-checked-in slash commands auto-load and expand-to-prompt with no provenance marker or confirmation; Command.Project is dead
- **Severity / category:** medium / robustness · **Subsystem:** config-plugins-skills
- **Location:** `internal/tui/user_commands.go:29`
- **Evidence:**
```
model.go:473 `usercommands.Load(usercommands.DefaultPaths(cwd, userConfigDir))` auto-loads `<cwd>/.zero/commands/*.md` at TUI startup. handleUserCommand (user_commands.go:29-33) does `prompt := usercommands.Expand(cmd.Template, args)` then `m.launchPrompt(prompt)` — no confirmation, no display of whether the command is project- or user-sourced. The provenance field exists (usercommands.go:120 `Project: project`) but `grep -rn '\.Project' internal/tui internal/usercommands` shows it is only ever WRITTEN, never read. Project commands shadow user commands of the same name (Load merges project after user, byName overwrite).
```
- **Impact:** Opening Zero inside a cloned/untrusted repo silently loads that repo's `.zero/commands/*.md`. A repo can name a command after a common one (`/test`, `/fix`, `/pr`) so the user invoking it expands attacker-authored instructions and submits them to the model verbatim, driving the tool-enabled agent — with no indication the command body came from the repo rather than the user's own config. The Project flag built to distinguish this is never surfaced.
- **Fix:** Surface provenance: in the command list/help and on first invocation of a project-sourced command, show it is repo-sourced (and ideally one-time confirm), using the already-present Command.Project flag; or scope auto-load of project commands behind a trust prompt like other workspace-trust gates.
### M4 · DST fall-back double-fire: the collapse guard is dead on the real scheduler path (sub-minute `fired`)
- **Severity / category:** medium / correctness · **Subsystem:** cron
- **Location:** `internal/cron/schedule.go:205`
- **Evidence:**
```
Guard: `if sameWallClockMinute(t, after) && after.Truncate(time.Minute).Equal(after) { t = t.Add(time.Minute) } else { return t }`. But fireJob feeds raw wall-clock time: `fired := now()` (cron_run.go:136) then `sched.Next(fired)` (cron_run.go:190), and reconcileOverdue uses `sched.Next(now())` (cron_run.go:122) — none truncate to the minute. With real time.Now() the second clause `after.Truncate(time.Minute).Equal(after)` is false, so the collapse never engages. Reproduced: Next(2026-11-01 01:30:15 EDT) for `30 1 * * *` = 2026-11-01 01:30:00 EST (06:30 UTC) — same day, one absolute hour later.
```
- **Impact:** On the annual US/EU DST fall-back day, a daily job scheduled in the repeated wall-clock hour (e.g. `30 1 * * *` in a DST zone, NextRunAt stored in that location) runs twice that day: after firing at 01:30 EDT the post-exec advance computes NextRunAt=01:30 EST one hour later, and the next tick fires it again (claimFire's `current.NextRunAt.After(fired)` does not block it because the second 01:30 EST instant is not after the stored 01:30 EST). Duplicate scheduled agent run = duplicate model-token spend. The unit tests pass because they pass a minute-aligned `after`; the production caller never does.
- **Fix:** Truncate the fire instant to the minute before computing the next slot, so the guard's precondition holds for the real caller: in fireJob set `fired := now().Truncate(time.Minute)` (or pass a truncated value into sched.Next at cron_run.go:122/190 and claimFire). Alternatively make Next collapse the fall-back repeat whenever the candidate shares wall-clock minute with `after` AND is a later absolute time, independent of after's sub-minute precision, adjusting the strictly-after contract accordingly.
### M5 · claimFire grants the slot to BOTH concurrent schedulers when the schedule cannot advance (invalid/exhausted spec)
- **Severity / category:** medium / concurrency · **Subsystem:** cron
- **Location:** `internal/cli/cron_run.go:256`
- **Evidence:**
```
claimFire only advances NextRunAt when the expr parses AND Next is non-zero: `if sched, perr := cron.Parse(current.Expr); perr == nil { if nxt := sched.Next(fired); !nxt.IsZero() { current.NextRunAt = nxt } }` but unconditionally leaves `claimed := true` for an active, due job. For an unadvanceable schedule NextRunAt is left unchanged, so a second caller still sees Status==active and `!NextRunAt.After(fired)` and also returns claimed=true. Reproduced by calling claimFire twice with no fire in between: `claim1=true claim2=true`.
```
- **Impact:** Two concurrent schedulers (the documented `cron run --once` under system cron overlapping a forever-mode `cron run`, cron_run.go:61-63) both claim and both run `zero exec` for the same fire of a job whose Expr is impossible/exhausted (e.g. `0 0 30 2 *` stored directly per TestCronRunPausesUnadvanceableJob, a hand-edited metadata.json, or a finite spec that has run out near the 9-year search cap). Both spawn an agent before either pauses the job in its post-exec Mutate. This is the exact double-fire the claim mechanism exists to prevent; the prior concurrent test masks it only because the fake exec returns instantly so the first fire pauses the job before the second claims — a real minutes-lo
- **Fix:** Make claimFire fail the claim for an unadvanceable schedule: if Parse errors or Next(fired) is zero, set `claimed = false` (let a single deterministic caller pick it up) or advance NextRunAt to a sentinel/pause it inside the same locked Mutate so the loser observes the change. The winner's post-exec block already pauses it; the claim just needs to not hand the same un-advanced slot to a second runner.
### M6 · `cron add --run-now` job is silently cancelled by forever-mode startup reconcile instead of firing
- **Severity / category:** medium / correctness · **Subsystem:** cron
- **Location:** `internal/cli/cron_run.go:118`
- **Evidence:**
```
cronAdd --run-now sets `next = now()` (cron.go:159) and prints `next run <now>`. reconcileOverdue (run at forever-startup when !catchUp, cron_run.go:71) treats any NextRunAt before the current minute as strictly overdue and reschedules without firing: `nowMin := now().Truncate(time.Minute) ... if !j.NextRunAt.Before(nowMin) { continue } ... j.NextRunAt = nxt; store.Update(j)`. The Job struct carries no run-now marker, so the immediate request is indistinguishable from an ordinary backlog. Reproduced: --run-now job added at 08:00, `cron run` started at 08:05 → NextRunAt moved to 09:00, FireCount=0, no output.
```
- **Impact:** A user who runs `zero cron add ... --run-now` and then starts the foreground scheduler more than a minute later never gets the promised immediate run; the job silently waits until its next regular slot. Contradicts both the --run-now contract and the message cronAdd printed. Still open from the prior audit (docs/audit/2026-06-10-deep-audit.md:2208).
- **Fix:** Persist a one-shot intent (e.g. a RunNow bool on Job cleared on first fire, or set NextRunAt to a future-safe sentinel that reconcile fires rather than skips). In reconcileOverdue, exempt jobs explicitly marked run-now (fire them in the first fireDue pass) instead of treating their now() NextRunAt as stale backlog.
### M7 · Remote bridge clears the connection deadline before the daemon handshake, so an authenticated-but-idle peer pins a connection slot forever
- **Severity / category:** medium / resource-perf · **Subsystem:** daemon
- **Location:** `internal/daemon/remote/bridge.go:223`
- **Evidence:**
```
// Clear the handshake deadline: a session may stream...
_ = conn.SetDeadline(time.Time{})
...
b.server.ServeConn(conn) // performs the daemon handshake + one command
--- server.go:225-231 (reached via ServeConn) ---
func (s *Server) handleConn(conn net.Conn) {
defer conn.Close()
hello, err := ReadControl(conn) // no deadline
```
- **Impact:** The bridge bounds only the auth handshake (line 181) and then fully clears the conn deadline. After a peer passes auth it is handed to ServeConn -> handleConn, which does ReadControl(conn) for the daemon hello and again for the command with NO read deadline and no per-command timeout. A peer that completes the auth handshake (valid token) but then never sends the daemon hello (or sends hello but never the command) blocks its handler goroutine indefinitely while holding one of the b.sem slots (default MaxConnections=32). 32 such stalled connections exhaust the bridge: every other remote client is refused 'at capacity' (bridge.go:164-168) until process restart. This is an authenticated-client
- **Fix:** Do not fully clear the deadline before ServeConn. Either keep a (larger) per-command deadline for the daemon hello+command exchange in the session path, or set a fresh deadline covering the daemon handshake and clear it only once streaming begins. Simplest: in bridge.go before ServeConn, set conn.SetDeadline(now+handshakeTimeout) for the session path and let streamToClient clear it, OR add a read deadline inside handleConn around the two ReadControl handshake calls.
### M8 · Server.Shutdown cannot close remote bridge connections (ServeConn bypasses trackConn), so a stalled remote handshake-read survives Shutdown
- **Severity / category:** medium / robustness · **Subsystem:** daemon
- **Location:** `internal/daemon/server.go:222`
- **Evidence:**
```
func (s *Server) ServeConn(conn net.Conn) { s.handleConn(conn) }
--- vs the local accept loop (server.go:136-141) ---
s.trackConn(conn)
s.wg.Add(1)
go func() { defer s.wg.Done(); defer s.untrackConn(conn); s.handleConn(conn) }()
--- Shutdown only closes tracked conns (server.go:181-183) ---
for c := range s.conns { _ = c.Close() }
```
- **Impact:** The D3 fix (Shutdown closes every open connection so a handler blocked on a read returns) only covers connections accepted by the LOCAL Serve loop, which calls trackConn. The remote bridge enters via ServeConn, which calls handleConn directly and never registers the conn in s.conns. The streaming phase is still interruptible (streamToClient selects on <-s.done), but a remote connection blocked in the pre-stream ReadControl handshake (combined with the no-deadline gap above) is NOT closed by Shutdown and is NOT covered by s.wg, so it leaks until process exit and keeps occupying a bridge slot through the entire graceful-drain window. The asymmetry means the carefully-built D3/D4/D5 shutdown gu
- **Fix:** Have ServeConn register the conn for shutdown closure too: call s.trackConn(conn)/defer s.untrackConn(conn) inside ServeConn (or expose a tracked variant the bridge uses), so Shutdown's conns-close loop unblocks a stalled remote handshake read as it does for local connections. Pair with a handshake read deadline (previous finding) for defense in depth.
### M9 · MCP SSE client never reconnects: any stream close permanently kills the session
- **Severity / category:** medium / robustness · **Subsystem:** mcp
- **Location:** `internal/mcp/network_client.go:517`
- **Evidence:**
```
readStream end: `client.failPending(fmt.Errorf("MCP SSE stream closed for server %s", client.server.Name))`; failPending sets `client.streamErr = err` (line 593); request() gate: `if client.streamErr != nil { ... return err }` (lines 243-246). openStream is invoked exactly once, only from connectRemoteSSE at connect (line 77) — grep finds no other caller and no reconnect loop. registry.go connects each server once at startup and reuses the client for the whole session with no reconnect-on-error.
```
- **Impact:** For an SSE-transport MCP server, the persistent GET stream closing for ANY reason during a session — clean server-side EOF (the normal `MCP SSE stream closed` path), an idle/proxy timeout, a network blip, or an over-8MiB SSE line tripping bufio.ErrTooLong — sets a permanent streamErr. Every subsequent tools/call and tools/list for that server then returns that error for the rest of the process lifetime; the only recovery is restarting Zero (or `zero mcp enable` per tui hint). A long agent session that idles past a server keep-alive window silently loses all of that server's tools.
- **Fix:** On a non-cancelled stream termination, attempt a bounded re-open: clear streamErr/endpointURL, call openStream again with backoff (e.g. mirror agent/reconnect.go's maxStreamReconnects + exponential backoff), and only set a permanent streamErr after retries are exhausted or ctx is done. Re-send `notifications/initialized` after a successful re-open so the server re-establishes session state.
### M10 · providerhealth probe re-sends x-api-key / x-goog-api-key / CustomHeaders to a cross-host redirect target (credential exfil to a public attacker host)
- **Severity / category:** medium / security · **Subsystem:** oauth-providers
- **Location:** `internal/providerhealth/providerhealth.go:430`
- **Evidence:**
```
CheckRedirect: func(req *http.Request, via []*http.Request) error { if len(via) >= maxConnectivityRedirects {...}; return validateEndpoint(req.Context(), req.URL.String(), resolver) } — re-validates the redirect HOST against the private/special-use blocklist but never strips auth headers. applyAuth (line 529-561) sets DefaultAuthHeader "x-api-key" (Anthropic), "x-goog-api-key" (Google), and arbitrary profile.CustomHeaders (lines 539/548/558).
```
- **Impact:** Go's stdlib only auto-strips Authorization/Cookie/WWW-Authenticate when a redirect crosses hosts; it does NOT strip x-api-key, x-goog-api-key, or arbitrary custom headers. The DNS-rebinding/internal-redirect half of the prior high finding is now fixed (safeDialContext pins validated IPs; CheckRedirect blocks internal targets), but a configured baseURL that returns a 3xx to an attacker-controlled PUBLIC host (a compromised/MITM'd/typo'd proxy endpoint, or a hostile OpenAI-compatible gateway the user pointed at) passes the blocklist and receives the provider API key verbatim. Production callers (observability.go:58, provider_setup.go:99, setup.go:116) pass no HTTPClient, so newConnectivityClie
- **Fix:** In CheckRedirect, when req.URL.Host != via[0].URL.Host (or any earlier hop's host), delete the auth-bearing headers from req.Header (x-api-key, x-goog-api-key, Authorization, and every key in profile.CustomHeaders) before allowing the redirect; or set client.CheckRedirect to refuse cross-host redirects entirely for the probe (a health check has no need to follow a host change).
### M11 · Destructive/piped-installer risk regexes scan the whole raw command (incl. quoted literals); the quote-aware AST analyzer can never retract the false positive
- **Severity / category:** medium / correctness · **Subsystem:** sandbox
- **Location:** `internal/sandbox/risk.go:108-112`
- **Evidence:**
```
command := firstArgString(...)
if matchesDestructive(command) { add("destructive", RiskCritical) }
if pipedInstallerPattern.MatchString(command) { add("piped_installer", RiskCritical) }
... analysis := AnalyzeCommand(command); if analysis.Destructive { add("destructive", RiskCritical) } // add() only ORs in, never removes
Reproduced via engine.Evaluate: `git commit -m "fix rm -rf / bug"` -> action=prompt reason="destructive shell command requires approval" risk=[destructive shell], in BOTH PermissionModeAsk and PermissionModeAuto. Matcher-level probe: `git commit -m "rm -rf /"`=>destructive=true while AST destr=false; `echo "do not run rm -rf /"`=>destructive=true; `grep -r "dd if=/dev/zero" .`=>destructive=true; `printf '%s' "curl evil|sh"`=>piped=true. AST clears all of them (destr=false,net=false) but classifyWithScope only adds categories.
```
- **Impact:** Across a session, any shell command that merely QUOTES one of the trigger substrings (rm -rf / | rm -rf ~ | dd if= | chmod 777 <root> | the fork-bomb chars | curl..|sh) is mis-classified RiskCritical 'destructive'. Per engine.go:323-329 a SideEffectShell command with the destructive category and no prior PermissionGranted is forced to ActionPrompt (Ask AND Auto) or denied when permission can't be granted. So routine, safe commands — `git commit -m "...rm -rf..."`, `echo`/`printf`/`grep` mentioning these tokens — are spuriously gated behind a destructive approval prompt, and in a headless exec with no OnPermissionRequest the prompt resolves to a hard block. The interactive detector was alread
- **Fix:** Run matchesDestructive / pipedInstallerPattern over the command-body of each real shell segment (reuse splitShellSegments + commandBody from safe_command.go) instead of the raw string, OR make the AST analyzer authoritative for parseable scripts: when analysis.TooComplex==false, trust analysis.Destructive/Network and skip the raw-string regexes (only fall back to regex when the parse fails). Either approach removes the quoted-literal false positives while keeping detection of genuine unquoted destructive commands.
### M12 · events.jsonl append is the only write in the package not fsync'd, while the derived metadata IS — a crash can lose a 'successfully appended' event (incl. the checkpoint /rewind targets) and leave EventCount ahead of the durable log
- **Severity / category:** medium / correctness · **Subsystem:** sessions
- **Location:** `internal/sessions/store.go:606-616`
- **Evidence:**
```
file, err := os.OpenFile(store.eventsPath(sessionID), os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0o600)
...
if _, err := file.Write(append(data, '\n')); err != nil { _ = file.Close(); ... }
if err := file.Close(); err != nil { ... } // no file.Sync(), no syncDir
...
session.EventCount = sequence
if err := store.writeMetadata(session); err != nil { ... } // writeMetadata fsyncs temp + dir
```
- **Impact:** appendEventLocked writes the event line into the page cache (no fsync, no dir sync) and THEN durably writes metadata (writeFileSync + syncDir). On power loss / OS crash after the metadata fsync but before the events.jsonl page is flushed, the persisted metadata.EventCount=N but events.jsonl is missing line N. Across a session this silently drops the last appended event even though AppendEvent returned success: a just-recorded EventSessionCheckpoint vanishes (its blob, written via writeFileAtomicSync, IS durable but is now unreferenced, so /rewind cannot target it and the file content is orphaned), and the user's last turn (message/tool_call/usage) is lost on resume. It also inverts the seque
- **Fix:** Make the append durable like every other write: after file.Write, call file.Sync() before Close (and ideally syncDir(sessionPath) on first create of events.jsonl). Cheapest: replace the open/append with an fsync'd append helper, e.g. f.Write(...); if err := f.Sync(); err != nil {...}; f.Close(). This restores the ordering guarantee that the log is at least as durable as the metadata derived from it.
### M13 · Mailbox stale-lock break compares the lock file to itself, not to the snapshot observed as stale — can delete a healthy fresh lock (split-brain writers)
- **Severity / category:** medium / concurrency · **Subsystem:** specialist-swarm
- **Location:** `internal/swarm/mailbox.go:371`
- **Evidence:**
```
if info, statErr := os.Stat(lockPath); statErr == nil && time.Since(info.ModTime()) > lockStaleAfter {
stale, _ := os.ReadFile(lockPath)
if data, rerr := os.ReadFile(lockPath); rerr == nil && string(data) == string(stale) {
os.Remove(lockPath)
}
continue
}
```
- **Impact:** The comment claims the break is 'conditional on the file's content being unchanged since we observed it,' but `stale` and `data` are two consecutive reads of the SAME file microseconds apart, so the equality check is essentially always true and provides no protection. If a holder crashes and leaves a stale lock, two (or more) waiters can each observe it stale and each call os.Remove(); meanwhile a third writer may legitimately re-create the lock via O_EXCL between the two removes. The second waiter's Remove then deletes that fresh, validly-held lock, after which both that holder and the waiter (on its next O_EXCL create) hold the lock simultaneously — two concurrent writers to the same per-a
- **Fix:** Capture the lock content at Stat time and only remove if it is byte-identical AND the ModTime is unchanged at remove time; better, make the break ownership-aware like release(): read the token, then remove via an atomic rename-then-unlink or re-stat-and-verify-mtime guard. Simplest robust fix: snapshot (content, modTime) when first seen stale, sleep a short grace, re-stat, and only Remove if both still match — so a freshly rotated lock (new mtime/content) is never deleted.
### M14 · PTY exec session can drop its final output chunk on exit (copy goroutine unsynchronized with markDone), then the session is removed
- **Severity / category:** medium / concurrency · **Subsystem:** tools
- **Location:** `internal/tools/exec_command.go:515-526 (with exec_pty_linux.go:34-37)`
- **Evidence:**
```
startSession's Wait goroutine: `err := command.Wait(); ... if session.cleanup != nil { session.cleanup() }; plan.Cleanup(); cancel(); session.markDone(...)`. For a TTY session the only thing copying PTY output into the buffer is the fire-and-forget goroutine in exec_pty_linux.go:34 `go func() { _, _ = io.Copy(output, master) }()`. command.Stdout/Stderr are the *os.File slave (exec starts NO copy goroutine and WaitDelay does not cover our own goroutine), so command.Wait() returns as soon as the process exits — before io.Copy has necessarily drained the master FD. markDone then closes `done`. collect (line 541-545) does a final drainString after doneClosed, but if the copy goroutine has not yet written the tail, that drain misses it; run()/RunWithOptions then call `tool.manager.remove(session.id)` (lines 465, 676) so the session is gone and no later poll can recover the lost tail.
```
- **Impact:** Across a session, the last lines a TTY (tty:true) command prints right before it exits can be silently dropped from the tool result the model sees — e.g. a test runner's final PASS/FAIL summary or a build's last error line — and because the exited session is immediately removed, the model cannot poll for it. Pipe-mode (tty:false) is unaffected because exec's own Wait drains its os.Pipe. Linux-only (other platforms fall back to pipe via exec_pty_fallback.go).
- **Fix:** Make the PTY copy goroutine observable: have startPTYProcess return a `copied <-chan struct{}` (closed when io.Copy returns), and in the Wait goroutine block on it (with a bounded timeout matching bashWaitDelay) AFTER cleanup() closes master and BEFORE markDone, so all buffered PTY output is in the buffer before `done` is closed and the session is eligible for removal.
### M15 · Streaming live block re-parses the entire growing answer markdown on every spinner/fade tick (cumulative O(N^2) per turn)
- **Severity / category:** medium / resource-perf · **Subsystem:** tui-render
- **Location:** `internal/tui/model.go:2079`
- **Evidence:**
```
interimBlock: `lines := renderAssistantMarkdownText(text, assistantMeasure(width), width)` where `text := strings.TrimRight(m.streamingText, "\n")`. streamingText only grows (model.go:1185 `m.streamingText += msg.delta`) and is cleared solely at turn end (1411/1522/3208). interimBlock is called from the per-frame body item closure (transcript_selection.go:245 `lines: viewLines(m.interimBlock(width))`). The spinner self-reschedules while pending (model.go:1212-1238, gated on m.pending) and the fade ticks every 150ms (streaming_fade.go:79), so the full markdown parse+wrap runs ~12x/sec.
```
- **Impact:** Settled transcript rows are memoized via defaultRenderCache (rendering.go:190-197), but the streaming block is NOT cached. For a long single answer (e.g. a model emitting a multi-hundred/thousand-line file or table as prose), each ~80-150ms tick re-runs renderAssistantMarkdownText over the whole accumulated buffer — fence scanning, table parsing, per-line inline wrap with lipgloss.Width. Cost per frame is O(current length); over the turn the work is O(N^2/delta). On a slow/large stream the TUI visibly chews CPU and the frame rate drops the longer the answer gets, exactly when responsiveness matters most.
- **Fix:** Memoize the rendered streaming lines keyed by (streamingText length or a content hash, width); only re-render when streamingText or width actually changed since the last frame. The fade is applied per-line AFTER the markdown render (styleStreamingLine, model.go:2087), so the expensive markdown step can be cached while the cheap per-line color pass still runs each tick. Alternatively bound the markdown render to the visible tail (last height lines) since the viewport clips anyway.
### L1 · Reactive-compaction retry and max-turns final-answer call bypass streamWithReconnect (no transient-disconnect retry)
- **Severity / category:** low / resource-perf · **Subsystem:** agent-loop
- **Location:** `internal/agent/loop.go:205,253,518`
- **Evidence:**
```
stream, err = provider.StreamCompletion(ctx, request) // reactive connect-failure retry
retryStream, retryStreamErr := provider.StreamCompletion(ctx, retryRequest) // reactive mid-stream retry
stream, err := provider.StreamCompletion(ctx, ...) // finalAnswerAfterMaxTurns
```
- **Impact:** The main turn connect goes through streamWithReconnect (2 retries with backoff on EOF/reset/502/503/timeout), but the post-compaction retry calls and the max-turns final-answer call call provider.StreamCompletion directly. A single transient upstream hiccup on exactly those calls (e.g. the final summary after a long autonomous/cron run, or the retried turn right after a context-limit compaction) fails the whole run with no reconnect, re-burning the entire session's tokens — the precise failure mode streamWithReconnect was added to prevent.
- **Fix:** Route these three calls through streamWithReconnect(ctx, provider, request, reconnectNoticeFor(options)) as well; they are pre-content connects so no OnText duplication risk, matching the existing reconnect contract.
### L2 · OnContext / MeasureContext / estimateToolTokens context-utilization pipeline has no production consumer
- **Severity / category:** low / dead-inert · **Subsystem:** agent-loop
- **Location:** `internal/agent/loop.go:164-166`
- **Evidence:**
```
if options.OnContext != nil {
options.OnContext(MeasureContext(messages, request.Tools, options.ContextWindow))
}
```
- **Impact:** Repo-wide grep shows zero `OnContext:` assignments outside agent tests (cli/tui/exec never set it), so MeasureContext and its private estimateToolTokens (a near-duplicate of compaction.go's estimateToolDefTokens) are computed for nobody in production and ship as permanently inert plumbing. Carries a maintenance hazard (two divergent tool-token estimators) and a tiny per-turn branch. Matches the prior low finding — still open.
- **Fix:** Either wire OnContext from the TUI/exec to drive a context-utilization bar, or delete OnContext + MeasureContext + ContextBreakdown + estimateToolTokens and keep the single estimateToolDefTokens used by compaction.
### L3 · zero --skip-permissions-unsafe still silently drops trailing positional args (e.g. a prompt)
- **Severity / category:** low / robustness · **Subsystem:** cli-usage-streamjson
- **Location:** `internal/cli/app.go:234`
- **Evidence:**
```
moreDirs, rest, err := splitLeadingAddDirFlags(args[1:]) ... for _, arg := range rest { if arg == "--add-dir"... { return error } } ... return runInteractiveTUI(stderr, deps, agent.PermissionModeUnsafe, append(append([]string{}, addDirs...), moreDirs...)). `rest` is iterated only to reject a misplaced --add-dir; every other trailing arg is discarded and the interactive TUI (which takes no positional prompt) is launched.
```
- **Impact:** `zero --skip-permissions-unsafe "fix the bug"` silently discards the prompt and drops the user into the interactive TUI with no error or warning, so a scripted/one-shot unsafe invocation appears to hang/ignore input. Documented in-code as intentional ('were ignored on this path... and still are'), but it is still a silent UX drop and unchanged since the prior audit (prior M81 / app.go:149).
- **Fix:** If `rest` contains any non-flag tokens, fail loudly ("--skip-permissions-unsafe launches the interactive TUI and takes no prompt; use `zero --skip-permissions-unsafe exec -p ...` or pipe input") instead of discarding them, matching the loud-rejection style already used for misplaced --add-dir two lines above.
### L4 · usercommands frontmatter model:/agent:/mode: are parsed and documented but never applied
- **Severity / category:** low / dead-inert · **Subsystem:** config-plugins-skills
- **Location:** `internal/usercommands/usercommands.go:125`
- **Evidence:**
```
parseCommand fills cmd.Model (usercommands.go:125) and cmd.Agent (usercommands.go:126-129 from `agent:`/`mode:`), and the package doc (lines 8-9) advertises `model:` as a real override. But the sole invocation path, handleUserCommand (tui/user_commands.go:29-33), calls launchPrompt(prompt) — and `launchPrompt(prompt string)` (model.go:3020) takes only the prompt; cmd.Model/cmd.Agent are never read by any caller (`grep` for the fields outside the struct/parse shows only the parse assignment).
```
- **Impact:** A user who writes `model: <x>` or `agent: <x>` in a `.zero/commands/foo.md` frontmatter, per the documented feature, gets it silently ignored — the command always runs on the current model/agent. The fields are inert surface that mislead users into thinking per-command model routing works.
- **Fix:** Either honor cmd.Model/cmd.Agent in handleUserCommand (route the launched prompt through the model/agent switch before launchPrompt) or remove the fields and the package-doc `model:` line.
### L5 · Runtime skill tool discards duplicate-name collisions (no user-facing warning)
- **Severity / category:** low / dead-inert · **Subsystem:** config-plugins-skills
- **Location:** `internal/plugins/activate.go:376`
- **Evidence:**
```
skillTool.Run does `merged, _ := MergedSkillsLoaded(tool.defaultDir, tool.pluginRoots)` (activate.go:376), discarding the DuplicateName slice that mergeSkills computes (activate.go:311). The CLI `zero skills list` path now DOES surface dups (cli/skills.go:69-81), but the agent-facing skill tool silently drops a shadowed same-named skill.
```
- **Impact:** When a plugin skill and a user skill (or two plugin roots) share a frontmatter name, the loser is silently dropped at agent runtime and the model/user is never told which skill body it actually got — a shadowed skill disappears with no diagnostic during a session.
- **Fix:** When the skill tool resolves a name that had a collision, append a one-line note to the tool Result (or emit a startup warning) listing the shadowed Loser path, reusing the already-returned DuplicateName slice.
### L6 · pause/resume do an unlocked Get then a locked Update (non-atomic read-modify-write)
- **Severity / category:** low / concurrency · **Subsystem:** cron
- **Location:** `internal/cli/cron.go:215`
- **Evidence:**
```
cronSetStatus: `job, err := store.Get(args[0])` (Get takes no lock, store.go:150) ... `job.Status = status; store.Update(job)` (Update takes the per-job lock, store.go:170-183). cronResume (cron.go:234-253) is the same Get-then-Update shape, overwriting the whole struct including FireCount/NextRunAt from the pre-read snapshot.
```
- **Impact:** If a fireJob post-exec Mutate lands between resume's unlocked Get and resume's locked Update, the fire's FireCount++ and advanced NextRunAt are clobbered by resume's stale snapshot (resume recomputes NextRunAt from now() so scheduling stays sane, but FireCount silently regresses). The pause race is benign because fireJob re-reads Status under its lock and honors an external pause; resume has no such re-read. Low frequency (user-driven), cosmetic FireCount loss, no double-fire.
- **Fix:** Route pause/resume through store.Mutate so the read and the field update happen under the same per-job lock: read current, set Status (and for resume recompute NextRunAt from current.Expr), preserving current.FireCount, and write — instead of Get+Update on a snapshot.
### L7 · Single-instance lock refuses startup on PID reuse (bare-PID lock file, no identity/start-time)
- **Severity / category:** low / robustness · **Subsystem:** daemon
- **Location:** `internal/daemon/lock.go:58`
- **Evidence:**
```
pid, perr := readPidFile(path)
if perr == nil && pid > 0 && isAlive(pid) {
return nil, fmt.Errorf("%w (pid %d)", ErrAlreadyRunning, pid)
}
```
- **Impact:** The lock file stores only a bare PID. If a daemon dies uncleanly and the OS later recycles its exact PID for an unrelated live process, acquireLock sees isAlive(pid)==true and refuses to start with ErrAlreadyRunning, even though no zero daemon is running. The reclaim path (reclaimStaleLock, D6) is sound for the genuinely-dead case and the rename-aside race is well handled, but it never triggers here because the recycled PID reads as alive. Recovery requires manual lock removal. Low probability (exact PID reuse) and self-inflicted only after an unclean crash, but it is a real availability edge the bare-PID format cannot disambiguate.
- **Fix:** Record more than the PID in the lock file (e.g. PID + process start time, or a daemon-specific marker / boot id) and treat a live PID as the holder only when the recorded identity matches. On mismatch, treat as stale and reclaim via the existing rename-aside path.
### L8 · Plugin hook event set disagrees with hooks package: plugins reject specialist events and cannot register them
- **Severity / category:** low / robustness · **Subsystem:** hooks
- **Location:** `internal/plugins/plugins.go:38-42,893-899; internal/plugins/activate.go:412-423`
- **Evidence:**
```
hooks.go parseEvent (hooks.go:901-911) accepts six events. plugins.go HookEvent consts (lines 38-42) declare only HookBeforeTool/HookAfterTool/HookSessionStart/HookSessionEnd, and parseHookEvent (plugins.go:893-898) switches on exactly those four, rejecting specialist* with "Expected beforeTool, afterTool, sessionStart, or sessionEnd." mapHookEvent (activate.go:412-423) likewise maps only those four and returns ok=false otherwise.
```
- **Impact:** A plugin manifest declaring a specialistStart/specialistStop hook is rejected as a schema error even though hooks.json (user/project) accepts the same value. The two config surfaces for the identical Dispatcher have divergent valid-event sets, so plugin authors hit an inconsistent contract. Low because (per the finding above) those events do not actually fire from any surface today, so the practical effect is a confusing validation error rather than lost execution. This is the still-open prior reverification low (2026-06-13 line 97).
- **Fix:** Once the dead events are resolved (finding above), make the two sets agree: drive plugin validation/mapping off hooks.KnownEvents()/IsValidEvent (or add HookSpecialistStart/Stop consts + cases) so a single source of truth defines which events are valid, eliminating the drift.
### L9 · Stdio request write blocks under client.mu with no ctx awareness
- **Severity / category:** low / robustness · **Subsystem:** mcp
- **Location:** `internal/mcp/client.go:282`
- **Evidence:**
```
request() locks client.mu at line 264 and, still holding it, calls `client.writer.write(rpcMessage{...})` at line 282 before unlocking at 291. writer.write (protocol.go:148-163) does a blocking bufio Write+Flush to the child's stdin pipe with no context. Only the response wait (select at 293) is ctx-aware.
```
- **Impact:** If a misbehaving child stops draining its stdin, the OS pipe buffer (~64 KiB) fills and write() blocks while holding client.mu, serializing and stalling every other in-flight/queued request on that server with no ctx-cancel escape. Mitigated because Close() uses a separate closeMu (not mu) and closes stdin, which unblocks the stuck write — so it is recoverable rather than a hard deadlock; impact is limited to a pathological non-reading child.
- **Fix:** Keep id allocation + pending registration under mu but perform the write outside mu (or write under a dedicated writeMu), and bound it: run the write in a goroutine and select on ctx.Done() so a stalled pipe surfaces ctx.Err() to the caller instead of blocking peers.
### L10 · Child stderr capture keeps the head, dropping the likely-relevant tail on crash
- **Severity / category:** low / robustness · **Subsystem:** mcp
- **Location:** `internal/mcp/client.go:106`
- **Evidence:**
```
boundedBuffer.Write: `if remaining := b.cap - b.buf.Len(); remaining > 0 { ... b.buf.Write(p[:remaining]) ... }` — once cap (64 KiB) is reached, all further bytes are discarded; only the earliest bytes are retained. connectStdio reads stderr only on initialize failure (lines 150-154).
```
- **Impact:** Diagnostics quality: a server that prints a verbose startup banner (>64 KiB) and then dies with an error message at the end shows only the banner in the wrapped initialize error; the actual fatal line is dropped. Not a correctness/security issue, but it can make MCP startup failures hard to diagnose.
- **Fix:** Prefer a ring buffer that retains the LAST cap bytes (or head+tail split) so the terminal error is preserved; the bound is unchanged.
### L11 · ChatGPT account-id silently omitted (no warning) when the id_token claim exists but is the wrong shape
- **Severity / category:** low / robustness · **Subsystem:** oauth-providers
- **Location:** `internal/provideroauth/chatgpt.go:227`
- **Evidence:**
```
if ns, ok := claims[chatgptAuthClaimNamespace].(map[string]any); ok { if value, ok := ns[chatgptAccountClaim].(string); ok { return strings.TrimSpace(value), nil } } ... return "", nil — a present-but-non-string nested claim (or a present namespace with a missing key) falls through to `return "", nil`, the same as 'no id_token'. The caller (line 154-162) only warns on claimErr!=nil and only sets Account when account!="".
```
- **Impact:** If OpenAI ever changes the claim's JSON shape (e.g. nests it one level deeper, or makes it a number), extraction returns ("", nil) with no error: token.Account stays empty, the chatgpt-account-id header is omitted on every Codex call (codex.go:172 sets it only when ok&&account!=""), and the user gets Cloudflare 401s on every request with zero diagnostic — neither the login warning nor a parse error fires. The current 401 is then indistinguishable from an expired token.
- **Fix:** Distinguish 'claim absent' from 'claim present but unusable': when claims[ns] exists but yields no non-empty string account id, return a non-nil error (e.g. 'account-id claim present but not a string') so the existing warning path at chatgpt.go:159 fires and the user is told to re-auth.
### L12 · Latest()/List() order by second-granularity UpdatedAt with a SessionID lexical tiebreak; same-second updates with user-supplied IDs can resume the wrong session
- **Severity / category:** low / correctness · **Subsystem:** sessions
- **Location:** `internal/sessions/store.go:332-337,708-710`
- **Evidence:**
```
func (store *Store) timestamp() string { return store.now().UTC().Format(time.RFC3339) } // second precision
...
if sessions[left].UpdatedAt == sessions[right].UpdatedAt { return sessions[left].SessionID < sessions[right].SessionID }
return sessions[left].UpdatedAt > sessions[right].UpdatedAt
```
- **Impact:** UpdatedAt is RFC3339 (1s resolution). When two sessions are touched in the same wall-clock second (rapid back-to-back exec runs, or cron firing several jobs), the tiebreak is lexical SessionID, not real recency. exec_sessions.go:76 store.Latest() (used to resume the most recent session) and session.go:170 LatestResumable() can therefore land on the wrong session. For store-generated IDs the ID embeds UnixNano so lexical order roughly tracks recency, but for caller-supplied SessionIDs (Create/Fork/Child accept an explicit SessionID) the order is arbitrary, so `--continue`-style resume can attach to a stale session. Low: narrow timing window and mostly mitigated for auto-generated IDs.
- **Fix:** Persist a higher-resolution UpdatedAt (RFC3339Nano) so same-second ties are broken by true time, and keep SessionID only as a final deterministic tiebreak; or carry a monotonically increasing per-store update counter into the sort key.
### L13 · Scheduler leaks a per-job context when a bounded job completes via MaxRuns
- **Severity / category:** low / resource-perf · **Subsystem:** specialist-swarm
- **Location:** `internal/swarm/scheduler.go:280`
- **Evidence:**
```
runs := job.incRuns()
if max := job.schedule.MaxRuns; max > 0 && runs >= max {
return
}
```
- **Impact:** Each scheduled job gets its own ctx, cancel := context.WithCancel(s.ctx) (Add, line 182). On the MaxRuns-completion path run() returns without ever calling job.cancel(); only Cancel() and Close() invoke it. The derived context (and the goroutine the runtime spawns to propagate parent cancellation) is retained until the whole scheduler/Swarm context is cancelled at session end. A long session that adds many bounded daily/interval jobs accumulates one leaked context per completed job. Bounded by session lifetime and small, but it is a real, avoidable leak the codebase elsewhere is careful about.
- **Fix:** Add `defer job.cancel()` at the top of run() (alongside the existing `defer s.wg.Done()` / `defer s.forget(job.id)`), so the job's context is released on every exit path including MaxRuns completion.
### L14 · terminateProcess returns success without killing surviving group members when the leader PID was already reaped
- **Severity / category:** low / robustness · **Subsystem:** specialist-swarm
- **Location:** `internal/background/process_posix.go:55`
- **Evidence:**
```
target := pid
if pgid, err := syscall.Getpgid(pid); err == nil {
if pgid == pid {
target = -pid
}
} else if processGoneError(err) {
return nil // already gone
}
```
- **Impact:** launchBackgroundProcess (specialist/exec.go:797) runs a command.Wait() goroutine that reaps the background specialist leader as soon as it exits. If the leader exits on its own but had forked a detached grandchild into the same process group, a later TaskStop calls terminateProcess(pid): Getpgid(reaped pid) returns ESRCH, the code treats it as 'already gone' and returns nil — never signalling the surviving group. TaskStop reports success while grandchildren (e.g. a dev server the sub-agent started and the parent zero exec exited without reaping) keep running. The full-tree kill the M6 fix added only works while the leader is still alive to identify the group.
- **Fix:** Persist the child's pgid at launch (it equals the pid under Setpgid) and, when the leader is gone, fall back to syscall.Kill(-pgid, ...) to sweep any survivors instead of returning nil immediately on a reaped leader.
### L15 · Data race on execSession.lastUsedAt: prune reads it under manager.mu while touch writes it under session.mu
- **Severity / category:** low / concurrency · **Subsystem:** tools
- **Location:** `internal/tools/exec_command.go:123 vs :278`
- **Evidence:**
```
sessionToPruneLocked (called from store under manager.mu) sorts with `sessions[i].lastUsedAt.Before(sessions[j].lastUsedAt)` (line 123), reading lastUsedAt with only manager.mu held. touch() writes it under a DIFFERENT lock: `session.mu.Lock(); session.lastUsedAt = time.Now(); session.mu.Unlock()` (lines 277-279). snapshot() correctly reads it under session.mu (line 291), but the prune path does not. The two paths use disjoint mutexes, so there is no happens-before between the write and the read.
```
- **Impact:** When a new exec_command at the session cap triggers eviction (store -> sessionToPruneLocked) concurrently with a write_stdin poll calling touch() on another live session (or with removeCompletedLater/stopAll churn), this is a genuine data race on a time.Time (a multi-word struct), which `go -race` would flag and which can yield a torn/garbage timestamp that mis-selects the prune victim. Unexercised by current tests (no test drives concurrent touch+prune), so it is latent rather than observed.
- **Fix:** Read lastUsedAt through the session lock in the prune comparator (snapshot a `[]struct{id int; last time.Time}` by calling a small locked getter per session before sorting), or store lastUsedAt as an atomic.Int64 of UnixNano accessed the same way from both touch and prune.
### L16 · truncateRunes/middleTruncate truncate by rune COUNT but are used with display-WIDTH budgets — wide/CJK content overshoots its budget
- **Severity / category:** low / correctness · **Subsystem:** tui-render
- **Location:** `internal/tui/view.go:918`
- **Evidence:**
```
truncateRunes: `runes := []rune(text); if len(runes) <= limit { return text }; ... return string(runes[:limit-1]) + "…"` — purely rune-count based. middleTruncate (startup.go:138-153) likewise slices `runes[:front]`/`runes[len-back:]`. Both are called with display-width budgets: diffCardBody `truncateRunes(strings.TrimPrefix(line, "+"), textBudget)` (rendering.go:1511), then diffBodyLine pads `pad := textBudget - lipgloss.Width(text)` (rendering.go:1536); toolCardHead `truncateRunes(arg, maxInt(12, width/3))` (1336) and `middleTruncate(shown, maxInt(16, width/2))` (1328); grep/read/bash card budgets (1594/1628/1711).
```
- **Impact:** For a diff/grep/tool line of double-width (CJK/emoji) characters, truncateRunes keeps up to `limit` runes each ~2 cells wide, so the kept text can be ~2x its display budget. In diffBodyLine the band pad then computes negative and is skipped, so the add/del tint band no longer fills to the card edge and rows of CJK code render with uneven/short solid bands. Overflow past the card width is contained because toolCard re-clamps every body line with the width-aware fitStyledLine (rendering.go:1387) and the head with fitStyledLine (1380); the residual impact is cosmetic misalignment of the diff/tool band for wide-char content, not layout breakage.
- **Fix:** Add a display-width-aware truncate (loop accumulating lipgloss.Width per rune until budget, reserving 1 cell for the ellipsis) and use it where the argument is a width budget; or route these unstyled budgets through the existing width-aware fitStyledLine/truncateStyledLine primitives instead of rune-count truncateRunes.
### L17 · Cancelled run's usage events/rows are applied to whatever session is active when the goroutine returns, even after /resume switched sessions
- **Severity / category:** low / correctness · **Subsystem:** tui-render
- **Location:** `internal/tui/model.go:1319`
- **Evidence:**
```
In the agentResponseMsg `msg.runID != m.activeRunID` (flushing) branch, the session-EVENT flush is correctly routed to the recorded session (`if flushSessionID == m.activeSession.SessionID { appendSessionEvents } else { appendSessionEventsTo(flushSessionID, ...) }`, model.go:1335-1338). But the usage events just above are applied unconditionally to the current model: `for _, event := range msg.usageEvents { m, usageRows = m.recordUsageEvent(...); for _, row := range usageRows { m.transcript = appendTranscriptRow(m.transcript, row) } }` (1319-1325) — no flushSessionID gating.
```
- **Impact:** If the user cancels an in-flight run and then /resume's a DIFFERENT session before the cancelled run's goroutine returns its accumulated response, the cancelled run's usage tracker delta and any usage transcript rows land in the now-active different session's in-memory transcript/usage readout. Narrow timing window, in-memory display only (the durable session-event flush is already correctly routed), so impact is a transient mis-attributed usage line, not persisted corruption.
- **Fix:** Only record usage events and append usage rows when `flushSessionID == m.activeSession.SessionID`; when the run was recording into another session, persist its usage to that session's tracker (or drop the display rows) rather than mutating the active model.
### I1 · Local control socket handleConn has no read deadline (owner-only mitigates)
- **Severity / category:** info / robustness · **Subsystem:** daemon
- **Location:** `internal/daemon/server.go:228`
- **Evidence:**
```
hello, err := ReadControl(conn)
if err != nil { return }
...
cmd, err := ReadControl(conn) // both reads unbounded
```
- **Impact:** The locally-accepted control connection performs the hello + command reads with no deadline. Unlike the remote bridge this is gated by an owner-only Unix socket (secureSocketParent/hardenSocketFile), so the only peer able to stall it is the socket owner — the same principal that controls the daemon — making it a non-threat in practice. Noted because the same unbounded-read code is shared with the remote path (where it IS exploitable, see the medium findings); a defensive deadline here would make handleConn safe regardless of how the conn was obtained.
- **Fix:** Optionally add a modest read deadline around the two handshake ReadControl calls in handleConn so the protection is intrinsic to the function rather than relying on the caller (local socket perms / remote bridge handshake bound).
### I2 · Audit append failures are silently swallowed by the dispatcher
- **Severity / category:** info / robustness · **Subsystem:** hooks
- **Location:** `internal/hooks/dispatch.go:223-249`
- **Evidence:**
```
recordStarted (line 227 `_, _ = dispatcher.audit.AppendStarted(...)`) and recordCompleted (line 240 `_, _ = dispatcher.audit.AppendCompleted(...)`) discard the error. AppendStarted/AppendCompleted can fail on lock-acquire timeout (lock.go:86 "timed out acquiring audit lock" after 10s) or any I/O/marshal error.
```
- **Impact:** Over a session, if the cross-process audit lock is contended/stranded (a crashed holder's lock not yet 60s stale, or a permissions issue under XDG_DATA_HOME), every hook still runs and the tool proceeds normally, but the audit JSONL silently gains no record — there is no log line, metric, or diagnostic that the security/compliance audit trail dropped events. For a feature whose stated purpose is an auditable record of policy-hook executions, silent gaps undermine the trail. Informational because the dispatcher fails OPEN by design and the audit store is optional.
- **Fix:** Surface a one-time/rate-limited warning (or a diagnostic on DispatchOutcome) when an audit append errors, so an operator can tell the audit trail is incomplete rather than assuming the absence of a record means the hook did not run.
## 4. Reconciliation with prior audit docs
Reconciled against `docs/audit/2026-06-10-deep-audit.md`, `2026-06-10-deep-audit-status.md`, and `2026-06-13-reverification.md`. Each prior item for an audited area was re-checked against current code. Summary: **64 fixed, 14 partial, 12 still-open** (deduplicated across overlapping subsystem reports). The still-open and partial items are the ones that matter for remediation; the 12 still-open are all re-derived above as current findings (or noted as out-of-scope dead code).
#### Still open (12)
| Subsystem | Prior finding | Location | Evidence |
|---|---|---|---|
| agent-loop | Dead stores to requestEvent after always-allow, and unreachable fallbackPermissionEvent | `internal/agent/loop.go:473` | In the AlwaysAllow case requestEvent.GrantMatched/Grant are still set at loop.go:776-778 but the model-visible event is rebuilt fresh from the sandbox decision at 830-840, and fallbackPermissionEvent (loop.go:1694) is st |
| agent-loop | Reactive mid-stream retry never forwards retried assistant text to OnText | `internal/agent/loop.go:183` | The reactive retry still collects with only OnUsage (loop.go:264-266), deliberately omitting OnText to avoid duplicating the already-forwarded partial; documented at 258-263. Behavior unchanged (intentional trade-off). |
| agent-loop | OnContext / MeasureContext context-utilization pipeline has no consumer on any surface | `internal/agent/types.go:183` | Grep finds no `OnContext:` assignment outside agent tests; MeasureContext is only called at loop.go:165 behind the never-set OnContext guard. Reported again above as a current low finding. |
| cli-usage-streamjson | zero --skip-permissions-unsafe silently discards all trailing arguments (M81) | `internal/cli/app.go:149` | app.go:234-243 — `rest` is only scanned for misplaced --add-dir; all other trailing args are dropped and the TUI is launched. Confirmed still-open at 2026-06-13 reverification and unchanged in current code. |
| config-plugins-skills | ResolvedConfig.MCP is computed but never read; provider-command MCP servers are silently dropped | `internal/config/resolver.go:95` | resolver.go:106 still sets MCP: cfg.MCP with zero readers in internal/cli (grep 'resolved.MCP' empty); ResolveMCP (resolver.go:115-133) still seeds defaults+file+overrides only and never calls LoadProviderCommand, so ZER |
| config-plugins-skills | maxTurns<=0 is silently ignored | `internal/config/resolver.go:144,177,506` | All three merge sites still gate on `> 0` (resolver.go:152,191,539) and Resolve() still has no MaxTurns validation, unlike deferThreshold (:66-68) and maxTeamSize (:70-72); a 0/negative value is silently replaced by defa |
| config-plugins-skills | plugin vs standalone hook-event validators disagree | `internal/plugins/plugins.go:894` | plugins.parseHookEvent (plugins.go:894) + mapHookEvent (activate.go:412) still accept only the four non-specialist events; hooks.parseEvent/IsValidEvent (hooks.go:888,906) still accept six. The sets still disagree. |
| config-plugins-skills | config/contracts.go (ContractGap API) is unused dead code | `internal/config/contracts.go:12` | Per the 2026-06-13 reverification (line 95), ContractGap/DefaultContractGaps/FindContractGapsByMilestone are referenced only by contracts_test.go; no production change in this area since (outside my named focus but noted |
| cron | cron run start-up reconcile silently cancels --run-now jobs | `internal/cli/cron_run.go:115` | reconcileOverdue (cron_run.go:105-130) still reschedules any NextRunAt before nowMin without firing, with no run-now exemption, and Job has no run-now marker. Reproduced: --run-now job (NextRunAt=now at add) pushed to ne |
| hooks | Plugin/hooks event-set disagreement (plugin parseHookEvent accepts only 4 events; hooks accepts 6) | `internal/plugins/plugins.go:894 (2026-06-13 reverification l` | plugins.go:38-42 still declares only 4 HookEvent consts and parseHookEvent (plugins.go:893-898) still switches on those 4, while hooks.go parseEvent (hooks.go:901-911) accepts all 6. Re-derived above as a low finding. |
| sandbox | OnSandboxDecision callback option is set up to fire but never wired by any caller (dead code) | `internal/tools/registry.go:108` | This field lives in internal/tools/registry.go, outside the sandbox package's audited scope; the 2026-06-13 reverification (line 107) confirms repo-wide grep still finds OnSandboxDecision only at the declaration, nil-che |
| sessions | Second-granularity RFC3339 timestamps make Latest()/list ordering pick the wrong session on same-second ties | `internal/sessions/store.go:555` | store.go:709 still formats with time.RFC3339 (1s) and List tiebreak (332-336) is lexical SessionID; re-reported as the low finding above. |
#### Partially fixed (14)
| Subsystem | Prior finding | Location | Evidence |
|---|---|---|---|
| cli-usage-streamjson | streamjson secret patterns mangle ordinary text in every stream-json output event (M48) | `internal/streamjson/streamjson.go:310` | sk- and bearer patterns are now word-boundary + length anchored (streamjson.go:313,317) and TestRedactStringDoesNotOverMatchProse covers task-list/bearer; but the api[_-]?key pattern at streamjson.go:314 still over-match |
| config-plugins-skills | Hooks subsystem is loaded and listed but never executed (no event dispatch, no edit command) | `internal/hooks/hooks.go:372` | Now partially wired: beforeTool/afterTool DO dispatch from the agent loop (internal/agent/loop.go:914,937) and a CLI edit surface exists (internal/cli/hooks_manage.go `zero hooks add/...`). But the other four events (ses |
| config-plugins-skills | skills.Get/Duplicates unused; duplicate-name collisions never warned | `internal/skills/skills.go:91 + consumers` | skills.Duplicates is now consumed by the CLI list path (internal/cli/skills.go:69-81 emits per-collision warnings to stderr), so collisions ARE surfaced for `zero skills list`. But the agent-facing skill tool still drops |
| cron | Cron store has no inter-process locking: paused/edited jobs clobbered; concurrent schedulers double-fire | `internal/cli/cron_run.go:173` | A real cross-process lock now exists (internal/cron/lock.go: O_EXCL sibling <id>.lock with atomic stale reclaim) and is taken by Update/Mutate/Remove/AppendRun (store.go:174,196,219,266). fireJob now claims-before-fire u |
| cron | Cron Next double-fires wall-clock schedules on DST fall-back (repeated hour) | `internal/cron/schedule.go:171` | A fall-back collapse guard was added (schedule.go:191-209, sameWallClockMinute) and is covered by TestNextDSTFallBackDoesNotRepeatHour. But the guard only engages when `after` is exactly minute-aligned (schedule.go:205 ` |
| hooks | Hooks subsystem is loaded and listed but never executed (no event dispatch) | `internal/hooks/hooks.go:372 (2026-06-10 deep audit)` | The core gap is closed for the two tool events: Dispatcher.Dispatch is wired into the agent loop at internal/agent/loop.go:914 (beforeTool, with veto routed through blockedByHookResult at loop.go:955) and loop.go:937 (af |
| mcp | Client readLoop misroutes server-to-client requests as responses (id collision) and never answers ping | `internal/mcp/client.go:302` | client.go now has a single readLoop (331-355) that dispatches strictly by `client.pending[id]` and DROPS unmatched id-bearing messages (responses==nil) instead of corrupting a waiter, and skips id==nil notifications (338 |
| mcp | Stdio request write blocks indefinitely while holding client.mu and ignores ctx cancellation | `internal/mcp/client.go:246` | The unbounded RESPONSE wait was fixed: mu is released at client.go:291 before the ctx-aware select on the response channel (293-311), so a hung server no longer holds the lock during the wait. But the WRITE itself (line |
| mcp | 1 MiB SSE scanner token cap permanently kills the SSE session on large messages | `internal/mcp/network_client.go:637` | maxSSEEventBytes raised to 8MiB (network_client.go:658) and scanner.Buffer uses it (662), plus an aggregate multi-line data cap (707-713), so the common large-message case no longer trips ErrTooLong. But a single SSE lin |
| oauth-providers | Health probe SSRF blocklist and credentials defeated by redirects and DNS rebinding | `internal/providerhealth/providerhealth.go:285` | DNS rebinding TOCTOU is fixed: safeDialContext (providerhealth.go:442-474) re-resolves at dial time, validates every resolved addr, and dials the validated IP literal via dialValidatedAddrs. Internal-redirect escape is f |
| oauth-providers | OpenAI adapter drops tool calls lacking an id; agent loops forever on retry | `internal/providers/openai/tool_state.go:95` | tool_state.go:235-240 closeOpen now emits StreamEventToolCallDropped for an id-less/name-less call that streamed an id/name/arguments, and loop.go:300-306 converts a dropped-call-with-no-other-calls turn into a single re |
| sessions | No fsync before rename/append: metadata.json can be empty after a crash, silently hiding the session | `internal/sessions/store.go:632` | writeMetadata (store.go:779-804) now writes a temp file via writeFileSync (fsyncs data), os.Rename, then syncDir(parent) — metadata is fully crash-safe. BUT the sibling events.jsonl append (store.go:606-616) still has no |
| specialist-swarm | Post-SIGTERM grace polling can SIGKILL a recycled PID | `internal/background/process_posix.go:38-50` | terminateProcess now resolves pgid up front and signals the group (-pid) only when pid leads its own group, returning nil early if Getpgid reports the leader already gone (process_posix.go:55-61); group-leader targeting |
| tools | escalate_model tool is implemented and registry-aware but never included in any Core* tool set | `internal/tools/registry.go:224` | Still absent from CoreReadOnly/Write/Shell/Network/CoreTools sets (registry.go:204-266) and from the TUI registry (cli/app.go:660 newCoreRegistryScoped only iterates CoreToolsScoped). It IS now registered, but only in he |
#### Fixed (64)
| Subsystem | Prior finding | Location | Evidence |
|---|---|---|---|
| agent-loop | Truncated/filtered responses (FinishReason) are produced by every provider but never consumed by the agent loop | `internal/agent/loop.go:233` | FinishReason is now consumed at loop.go:284 (result.FinishReason = collected.FinishReason) and loop.go:502/539, exposed via Result.TruncationNotice() (types.go:278-292), and surfaced to users in both internal/tui/model.g |
| agent-loop | "Always allow" permission decision is converted into a denial when grant persistence fails (always, when Options.Sandbox | `internal/agent/loop.go:468` | PermissionDecisionAlwaysAllow case (loop.go:763-779) now sets permissionGranted=true unconditionally and only attempts persistPermissionGrant when options.Sandbox != nil, ignoring a persist error (the call is honored reg |
| agent-loop | estimateTokens ignores image attachments and the compaction trigger ignores tool definitions, so the context budget unde | `internal/agent/compaction.go:58` | estimateTokens adds len(message.Images)*imageTokenEstimate (compaction.go:114); maybeCompact now takes the exposed tools and counts estimateToolDefTokens(tools) into both the threshold and shrink checks (compaction.go:33 |
| agent-loop | Compaction summarizer provider calls bypass OnUsage — their token spend is invisible to usage accounting | `internal/agent/compaction.go:376` | summarizeMessagesOnce now calls CollectStreamWithOptions(ctx, stream, CollectOptions{OnUsage: onUsage}) (compaction.go:501); onUsage is threaded from options.OnUsage via newCompactionState -> summarizeClosure. |
| agent-loop | Mid-stream context cancellation is returned as a flattened errors.New string; the ctx.Err() identity check is unreachabl | `internal/agent/loop.go:188` | loop.go:272-275 now checks `if ctx.Err() != nil { return result, ctx.Err() }` BEFORE the `if collected.Error != ""` return at 276-279, so a canceled stream returns the wrapped context.Canceled and errors.Is succeeds. |
| agent-loop | Duplicate items-schema assignment in propertyToRuntimeMap | `internal/agent/loop.go:1072` | propertyToRuntimeMap (loop.go:1978-1980) assigns schema["items"] exactly once under `if property.Items != nil`. |
| agent-loop | Project-guidelines truncation can split a multibyte UTF-8 rune in the system prompt | `internal/agent/system_prompt.go:89` | projectGuidelines walks back to a rune boundary before cutting: `for cut > 0 && !utf8.RuneStart(content[cut]) { cut-- }` (system_prompt.go:255-258); compaction_preserve.go capBody does the same (304-307). |
| cli-usage-streamjson | Ctrl+C with -o json ends the JSON event stream with no error/done terminal event (M82) | `internal/cli/exec.go:500` | exec.go:567-595 emits a terminal error + done event for both execOutputStreamJSON (errorEvent+runEnd) and execOutputJSON (writeJSONLine type=error then type=done, exit_code=130) on context cancellation. |
| cli-usage-streamjson | zero exec --list-tools -o json ignores the requested JSON format and prints plain text (M83) | `internal/cli/exec.go:182` | exec.go:206-219 branches on outputFormat: stream-json -> writeExecStreamJSONFinal, json -> writeExecToolListJSON (exec_tools.go:103), else plain text. |
| cli-usage-streamjson | Per-event 'model' field persisted under --allow-escalation is never read by the usage report, mispricing escalated runs | `internal/usage/report.go:17` | report.go:138-141 prefers payload.Model (set on escalation runs) before falling back to modelBySession; exec.go:555-556 writes payload["model"]=currentModel only under options.allowEscalation, and currentModel is reassig |
| cli-usage-streamjson | One corrupt session aborts the entire zero usage report (M101) | `internal/cli/usage.go:46` | usage.go:47-51 — a ReadEvents error increments set.skipped and continues instead of returning, so a single unreadable session no longer aborts the report. (Minor: set.skipped is never surfaced to the user — info-level on |
| cli-usage-streamjson | Fork duplicates provider_usage events, double-counting usage in zero usage report (M100) | `internal/sessions/store.go:387` | store.go:431-436 — Fork explicitly skips EventUsage when copying parent events into the fork ('Do NOT copy usage accounting into the fork'), so report aggregation over parent+fork no longer double-counts. |
| cli-usage-streamjson | zero usage hard-fails outside a git repository (M72) | `internal/cli/usage.go:121` | usage.go:122-131 — net-LOC is best-effort: resolveWorkspaceRoot/inspectChanges errors are swallowed and DiffStat degrades to zero, so the token report still renders outside a git repo. |
| cli-usage-streamjson | zero mcp list --json leaks MCP server env/header secret values (M74) | `internal/cli/extensions.go:186` | extensions.go:220-223 redacts via redactMCPServerConfigs (env/header values -> [REDACTED], URL creds via redactMCPURL at 236-256) and then RedactValue before writePrettyJSON; the plain-text path also runs RedactString (2 |
| cli-usage-streamjson | redactURLPasswords recompiles its regexp on every RedactString call (M93) | `internal/redaction/redaction.go:362` | urlWithCredsPattern is now a package-level var (redaction.go:424) compiled once; redactURLPasswords (426) reuses it. |
| cli-usage-streamjson | redaction opaque/custom-scheme Authorization not redacted (M12) | `internal/redaction/redaction.go:92` | headerPattern makes the scheme optional and captures the whole value to EOL (redaction.go:92, 186-194); opaque_auth_test.go verifies opaque tokens, custom schemes, and Proxy-Authorization are redacted while a known schem |
| cli-usage-streamjson | secrets.Redact leaves a private key un-redacted when an inner pattern matches inside it (M90) | `internal/secrets/scanner.go:81` | scanner.go:91-99 replaces longest matches first (order sorted by len desc) so the whole PEM PRIVATE KEY block is redacted before any shorter nested match corrupts it. |
| cli-usage-streamjson | Secret scanner misses modern OpenAI key formats / private key block header (M158) | `internal/secrets/scanner.go:35` | scanner.go:41 openai_key body is `sk-[A-Za-z0-9_-]{20,}` (covers sk-proj-/sk-svcacct- and -/_), and scanner.go:44 matches the full BEGIN..END PRIVATE KEY block. |
| cli-usage-streamjson | Stream-json / status / tool-output truncation slices strings mid-rune (M153/M155/M165) | `internal/cli/exec_writer.go:412` | exec_writer.go:401-431 — truncateForStatus and truncateForStreamJSONOutput both call cutRuneBoundary, which walks back to the last UTF-8 rune boundary at or before n. |
| config-plugins-skills | config writer performs a non-atomic in-place write of the user config | `internal/config/writer.go:53` | writeConfigFile (writer.go:149-184) now uses os.CreateTemp in the target dir, tmp.Chmod(0o600), Write, Close, then os.Rename(tmpPath, path) with a deferred os.Remove cleanup (lines 161-182) — the documented write-to-temp |
| config-plugins-skills | parseThinkTags setting silently dropped on every profile merge (L17) | `internal/config/resolver.go (mergeProfile)` | mergeProfile now preserves it: resolver.go:420-422 `if next.ParseThinkTags != nil { base.ParseThinkTags = next.ParseThinkTags }`, and it is read end-to-end by providers/factory.go:97-98 (parseThinkTagsForProfile) into op |
| config-plugins-skills | Plugin-declared skill paths are not merged into skill lookup (inert) | `internal/skills/skills.go (Load single-root)` | Plugin skills are now merged at runtime: plugins.NewSkillTool (activate.go:340) overlays default dir + plugin SkillRoots via mergeSkills, and it is registered when SkillRoots are present (cli/plugin_activate.go:53-54), i |
| cron | cron add <expr> --recipe R silently discards the user's cron expression | `internal/cli/cron.go:112` | cronAdd now consumes the positional expression BEFORE applying recipe defaults: `if len(positional) == 1 && expr == "" { expr = positional[0] }` (cron.go:113-115) precedes the recipe block whose guard is `if expr == "" { |
| cron | cron run records lose the failure reason: exec errors go to the discarded stdout stream | `internal/cli/cron_run.go:150` | fireJob now recovers the failure detail from the stream-json error event on stdout: `if streamErr := extractStreamJSONError(outBuf.String()); streamErr != "" { detail = streamErr }` then `rec.Error = cronTruncate(detail, |
| cron | promptExcerpt/cronTruncate byte-slice strings mid-rune (invalid UTF-8) | `internal/cli/cron.go:275` | promptExcerpt now cuts on a rune boundary: `cutRuneBoundary(p, 47) + "…"` (cron.go:278), and cronTruncate does `cutRuneBoundary(s, max) + "…"` (cron_run.go:272) instead of the prior raw byte slice. |
| hooks | AuditStore.append is O(n^2): re-reads and re-parses the entire audit JSONL on every append | `internal/hooks/hooks.go:503 (2026-06-10) / reverified still-` | append() no longer calls ReadEvents(); it calls lastSequence() (hooks.go:515) which os.Open + Stat + ReadAt only the trailing 8KB window (hooks.go:550-602: `const tailWindow = 8 * 1024`, `file.ReadAt(buf, start)`), parse |
| hooks | Cross-process sequence collision on the shared audit JSONL (read-then-append not atomic across processes) | `internal/hooks/hooks.go append (implied by 2026-06-13 line 6` | append() now wraps the lastSequence()+write in a cross-process O_EXCL file lock via store.lockAudit() (hooks.go:509-513 -> lock.go:33-90), with stale-lock reclaim (lock.go:97-108) mirroring cron/oauth. TestAuditStoreSequ |
| mcp | Unbounded Content-Length allocation lets a peer crash the whole process | `internal/mcp/protocol.go:69` | protocol.go:17 defines maxMessageBytes=64MiB; readHeaderFramed caps Content-Length (lines 121-123: `if parsed > maxMessageBytes { return ... exceeds ... limit }`) before `body := make([]byte, contentLength)` (138); readL |
| mcp | MCP stdio transport uses LSP Content-Length framing instead of MCP newline-delimited JSON | `internal/mcp/protocol.go:42` | read() (protocol.go:54-76) now reads newline-delimited JSON by default (isJSONStart -> decodeMessage) and only falls back to LSP Content-Length framing when the first line is not JSON; write() (148-163) emits one JSON ob |
| mcp | zero mcp list --json leaks MCP server env/header secret values | `internal/cli/extensions.go:186` | extensions.go:222 wraps cfg.Servers in redactMCPServerConfigs (236-255), which replaces every Env and Header value with `[REDACTED]` and runs redactMCPURL on the URL (252) before writePrettyJSON; redactMCPURL (mcp_tools. |
| mcp | Child stderr captured into an unbounded bytes.Buffer for the entire server lifetime | `internal/mcp/client.go:98` | stderr is now `&boundedBuffer{cap: maxStderrCapture}` (client.go:134, cap=64KiB at line 91); boundedBuffer.Write (103-115) discards bytes past cap. (Tail-vs-head retention is a separate low finding, not unboundedness.) |
| mcp | Streamable-HTTP SSE response decoding takes the first event instead of the matching response | `internal/mcp/network_client.go:621` | decodeSSERPCMessage (network_client.go:612-652) now skips events whose decoded message has a non-empty Method (server-initiated notifications/requests) — `if candidate.Method != "" { return true }` (636-638) — and return |
| oauth-providers | Gemini adapter never reads cachedContentTokenCount; CachedInputTokens always 0 | `internal/providers/gemini/types.go:88` | gemini/types.go:107 now declares `CachedContentTokenCount int \`json:"cachedContentTokenCount"\``; provider.go:225 `state.cachedTokens = payload.UsageMetadata.CachedContentTokenCount` and provider.go:283 emits `CachedInp |
| oauth-providers | HTTP 529 classified as rate-limit but excluded from the 429/503 retry policy | `internal/providers/providerio/retry.go:107` | retry.go:108-110 `func ShouldRetryStatus(code int) bool { return code == http.StatusTooManyRequests // code == http.StatusServiceUnavailable // code == 529 }` — 529 now retried; doc comment updated to include Anthropic ' |
| sandbox | macOS sandbox-exec profile blocks /tmp and /dev/null, breaking most wrapped shell commands | `internal/sandbox/runner.go:262` | runner.go:412-436 sandboxWritableDevices (/dev/null,/dev/zero,/dev/stdin,...) and sandboxWritableSubpaths (/tmp,/private/tmp,/var/tmp,/var/folders,/dev/fd) are now emitted by seatbeltWriteRule (runner.go:559-581, AllowTe |
| sandbox | apply_patch misparses unified-diff body lines starting with '--- '/'+++ ' as file paths | `internal/tools/apply_patch.go:161` | risk.go:291-327 patchHeaderPaths now tracks hunk state (inHunk, oldRemaining/newRemaining from parsePatchHunkCounts) and decrements per body line, so '--- '/'+++ ' header parsing at risk.go:319-324 is only reached OUTSID |
| sandbox | Interactive-command detector flags pager/REPL names inside quoted arguments (splitShellSegments not quote-aware) | `internal/sandbox/safe_command.go:138` | safe_command.go:474-583 splitShellSegments is now a full quote/substitution-aware scanner (inSingle/inDouble state, substStack for $()/backtick, backslash escapes); operators inside quotes are literal. Detection also anc |
| sandbox | Grant store prefix/substring safety: Lookup trims while stored writer key may not be normalized (trim asymmetry => load | `internal/sandbox/grants.go:146` | grants.go:556-568 readState rebuilds a `normalized` map keyed by `key := strings.TrimSpace(name)` and re-keys every bucket; normalizeStoredGrant(key, grant) compares against the trimmed key (grants.go:614). Lookup/Revoke |
| sandbox | Destructive/network/installer classification recompiles N regexes per shell call | `internal/sandbox/risk.go:53` | risk.go:12-55 destructiveCommandPattern, pipedInstallerPattern, unparseableNetworkPattern, and destructiveExtraPatterns are all package-level regexp.MustCompile vars compiled once at init, not per call. |
| sessions | Torn append (crash mid-write) permanently bricks a session: ReadEvents hard-fails on any malformed JSONL line | `internal/sessions/store.go:547` | store.go:677-704 now computes tornTailPossible (trailing byte != newline) and lastNonEmpty, and breaks (drops the partial record) only when the malformed line is the last non-empty line AND the file lacks a trailing newl |
| sessions | Fork duplicates provider_usage events and rewrites their timestamps, double-counting and re-dating usage in `zero usage | `internal/sessions/store.go:387` | Fork (store.go:430-442) explicitly `if event.Type == EventUsage { continue }` so usage events are never copied into the fork; usage/report.go BuildReport iterates every session's events (cli/usage.go collectUsageData), s |
| sessions | `zero sessions tree` is O(nodes x sessions) disk reads (full store re-list per tree node) | `internal/sessions/lineage.go:140` | Tree (lineage.go:127-167) now does a single store.List() up front, indexes byID and childrenByParent in memory, and treeFrom (169-189) recurses over the in-memory maps with no per-node disk scan. |
| sessions | Naive byte-slice truncation can split multi-byte UTF-8 runes in prompts and titles (replay) | `internal/sessions/replay.go:237` | replay.go:382-393 cutPromptRuneBoundary backs up while !utf8.RuneStart(s[n]) before slicing; it is used at the prompt-truncation sites (310-314) and payloadPreview (376), so replay/preview truncation is rune-safe. |
| sessions | execSessionRecorder.append latches AppendEvent errors into recorder.err but no caller reads it (silent session-persisten | `internal/cli/exec_sessions.go:116-124` | warnIfRecordingFailed (exec_sessions.go:128-132) now surfaces recorder.err to stderr and is deferred by both runExec (exec.go:464) and the spec-draft path (exec_spec.go:90). |
| specialist-swarm | Specialist sessions launched without a description are unresumable (AgentName never recorded / recorded as garbage) | `internal/specialist/exec.go:166` | exec.go:250-255 now always emits --session-title: when Description is empty it passes the bare manifest name (`args = append(args, "--session-title", name)`), so specialistAgentName resolves to the real specialist name a |
| specialist-swarm | Specialist children always run with --auto high (PermissionModeUnsafe): one Task approval silently grants unprompted she | `internal/specialist/exec.go:150` | specialistAutonomy() (exec.go:125-132) maps only an explicit 'unsafe' parent mode to 'high' and everything else (including empty) to 'low'; BuildArgs/BuildResumeArgs (exec.go:230,282) call it with input.PermissionMode. T |
| specialist-swarm | Race: duplicate specialist stop/usage accounting events — check-then-append dedupe is not atomic | `internal/specialist/accounting.go:72` | recordSpecialistStop and appendSpecialistUsageRollup now route through appendSpecialistEventOnce → Store.AppendEventUnlessExists (accounting.go:60,105,160-172), which performs the existence check and the append atomicall |
| specialist-swarm | Foreground specialist run fails entirely when a child stream-json line exceeds 1 MiB | `internal/specialist/streamer.go:43` | ParseStream (streamer.go:41-73) now uses bufio.NewReader + ReadString('\n') with no per-line cap, and the live foreground path runChildProcess (exec.go:735-759) likewise uses bufio.NewReader.ReadString. The 1 MiB Scanner |
| specialist-swarm | SetPID failure path deletes the prompt file under a running child and records no terminal accounting/status | `internal/specialist/exec.go:346` | runBackground SetPID-failure branch (exec.go:451-466) now terminates the orphaned child (background.TerminateProcess(pid), joining any kill error), calls manager.UpdateStatus(StatusError) and recordSpecialistStop, in add |
| specialist-swarm | POSIX background-task kill terminates only the leader PID; the specialist's subprocess tree leaks on SIGKILL escalation | `internal/background/process_posix.go:50` | launchBackgroundProcess now calls background.ConfigureChildProcessGroup(command) before Start (exec.go:791), which sets Setpgid (process_posix.go:24-29), and terminateProcess signals the negative PID (the whole group) wh |
| specialist-swarm | formatTaskOutput is dead code | `internal/specialist/output_tool.go:244` | grep across the repo finds no `formatTaskOutput(` definition or call; the only formatter in use is formatTaskOutputSummary, called from readOutput (output_tool.go:154). The dead function was removed. |
| tools | bash tool timeout does not kill the command when a child inherits the output pipes | `internal/tools/bash.go:104` | bash.go:119 now calls hardenProcessLifetime(command); bash_proc_unix.go:28-44 sets Setpgid + WaitDelay=bashWaitDelay(2s) + Cancel that SIGKILLs the whole negative-pid process group, so a backgrounded child can no longer |
| tools | grep glob filter is applied to root-relative paths, silently dropping matches under a subdirectory search | `internal/tools/grep.go:224` | grepFiles now matches the glob against the path relative to the SEARCH directory: grep.go:307-311 computes `globPath = filepath.ToSlash(rel)` via `filepath.Rel(target, path)` and matches globMatcher against that, so `glo |
| tools | OnSandboxDecision callback option is set up to fire but is never wired by any caller (dead code) | `internal/tools/registry.go:108` | registry.go no longer declares or invokes OnSandboxDecision anywhere; RunOptions (registry.go:15-43) has no such field and RunWithOptions (registry.go:93-171) propagates decisions via res.SandboxDecision instead. The dea |
| tools | read_file returns Truncated but no truncation marker in output; rune-width line numbering can misalign | `internal/tools/read_file.go:89` | read_file.go:118-123 now appends an explicit marker on truncation: `[truncated: N more line(s) in the requested range not shown; set start_line=... to continue]`. Line-number padding uses ASCII strconv.Itoa width (read_f |
| tools | lsp_navigate workspace confinement (recently fixed) — verify | `internal/tools/lsp_navigate.go` | lsp_navigate.go:85 resolves the model path via resolveScopedReadPath BEFORE reading or handing it to the LSP manager (line 90 readWorkspaceFile(absPath), line 97 req.Path=absPath), so a '..'/absolute path cannot open a f |
| tui-render | wrapPlainText collapses internal whitespace, destroying code indentation/alignment in assistant answers | `internal/tui/rendering.go:261 (also 2026-06-13 reverify rend` | rendering.go:399-409 now detects preformatted/columnar bodies (an internal run of >=2 spaces) and emits them verbatim via splitPreservingWidth (459-479) instead of strings.Fields, preserving every space; leading indent i |
| tui-render | appendTranscriptRow O(n) copy + O(n) dedup scan per row → O(n^2) append and /resume rehydration | `internal/tui/transcript.go:113/117 (multiple rows)` | appendTranscriptRow now appends in place (transcript.go:99-108, comment notes the old O(n^2) is gone). The prior reverification gap — hasTranscriptRow still linear-scanning per keyed row during rehydration — is closed fo |
| tui-render | Full transcript re-rendered from scratch every frame, including per-line regex parsing | `internal/tui/model.go:610/124` | renderRowMode/renderRowDetailed now memoize per-row output via the LRU defaultRenderCache (rendering.go:172-197) keyed on width/flush/bodyCap/cwd/row fields/rc hints (render_cache.go:126-174); only rows tied to the activ |
| tui-render | looksLikeDiff false-positives on any output containing a line starting with '---' | `internal/tui/view.go:394` | view.go:897-916 now requires a real hunk header (hunkHeaderPattern) OR BOTH '+++ ' and '--- ' file headers before returning true; a lone '---' line no longer hijacks bash/generic output into the diff renderer. |
| tui-render | truncateTUIOutput byte-slices UTF-8 mid-rune, emitting invalid UTF-8 | `internal/tui/transcript.go:303` | truncateTUIOutput (transcript.go:436-445) now calls cutRunes (449-460), which walks back to the nearest UTF-8 rune boundary via utf8.RuneStart before slicing, so the transcript/session log can no longer receive a split m |
| tui-render | Cancelled-run event flush appends to whichever session is active at flush time (cross-session contamination) | `internal/tui/model.go:512` | flushRunIDs now maps runID -> recording sessionID (model.go:161, set at cancel 3177-3180), and agentResponseMsg routes the durable flush to that session: `if flushSessionID == m.activeSession.SessionID { appendSessionEve |
| tui-render | Transcript dedupe key omits runID — repeated provider tool-call IDs silently drop later tool rows | `internal/tui/transcript.go:135/137/139` | transcriptRowKey bakes runID into every key, e.g. tool rows return fmt.Sprintf("%d:%d:%s", row.kind, row.runID, row.id) (transcript.go:157-181), with a comment explaining Gemini's repeating synthetic gemini_tool_N ids; w |
| tui-render | Resize unhandled beyond storing width/height; composer not width-bounded (blind typing) | `internal/tui/model.go:456/227` | tea.WindowSizeMsg (model.go:1249-1267) stores width/height, resets the streaming fade line-age mapping that a re-wrap would invalidate (1256-1257), sizes the composer for horizontal scroll `m.input.SetWidth(maxInt(20, ch |
### 4.4 Findings dropped in adversarial review
Two drafted findings did not survive re-verification and are explicitly withdrawn:
| Subsystem | Claimed | Why dropped |
|---|---|---|
| agent-loop | Compactor context-window budget frozen at run start, not updated on mid-run model escalation | Mechanics accurate, but the user-visible failure mode is unreachable today — the escalation path that would change the window does not feed back into the frozen budget in a way that produces wrong behavior. Not a defect. |
| config-plugins-skills | `maxTurns <= 0` silently dropped with no validation error | Disproven: a 0/negative `maxTurns` is replaced by the documented default (30) by design, not silently mis-handled; there is no incorrect behavior. (The "no explicit validation error" observation is retained only as the low-priority still-open consistency note, not as a defect.) |
## 5. Root-cause synthesis
The 35 surviving findings cluster into four underlying causes:
**A. Pattern/substring matching on un-structured text where a structure-aware path exists but isn't authoritative.** The sandbox destructive/piped-installer classifier scans the *raw* command string (M11) even though a quote-aware AST analyzer runs alongside — but the analyzer can only *add* risk categories, never retract a regex false positive. The stream-json `api_key` redactor (M1) matches "any next word" after the marker. Both over-match real content (a `git commit -m "…rm -rf…"`, prose containing `apiKey foo`). Fix posture: make the structured parser authoritative when the parse succeeds; anchor patterns to credential/command shape.
**B. "Success" returned before the durable/drain step completes.** `AppendEvent` returns success after a page-cache write with no `fsync`, while the *derived* metadata is fsync'd — so a crash can leave `EventCount` ahead of the durable log and lose the last event, including the checkpoint `/rewind` targets (M12). A PTY exec session's `command.Wait()` returns and the session is removed before the fire-and-forget copy goroutine has drained the master FD, dropping the command's final output (M14). The hook dispatcher discards audit-append errors (I2). Fix posture: tie the success signal to completion of the durable write / output drain.
**C. Surfaces shipped ahead of (or drifting from) their wiring.** Four hook events are validated, documented, and `zero hooks add`-able but never dispatched (H1); `ResolvedConfig.MCP` is computed but unread so provider-command MCP servers are dropped (M2); usercommands `model:`/`agent:` frontmatter is parsed/documented but ignored (L4); project slash-commands auto-load with a built-but-unused provenance flag (M3); `OnContext`/`MeasureContext` has no consumer (L2); plugin vs hooks event-set validators disagree (L8); the runtime skill tool drops dup-name collisions silently (L5). Fix posture: a single source of truth for valid events; either wire the surface or remove it from config/CLI/docs.
**D. Lifecycle-edge handling asymmetric with the happy path.** Idle/close/exhausted/redirect edges are under-handled: the remote bridge clears the conn deadline before the daemon handshake so an authenticated-idle peer pins a slot forever (M7) and `Shutdown` can't even close bridge conns because `ServeConn` bypasses `trackConn` (M8); the MCP SSE client never reconnects after any stream close (M9); cron double-fires on DST fall-back (M4), double-grants a claim when the schedule can't advance (M5), and silently cancels `--run-now` on reconcile (M6); the swarm scheduler leaks a per-job context on `MaxRuns` completion (L13); the mailbox stale-lock break compares the file to itself instead of the observed-stale snapshot (M13); and the health probe forwards credentials across a cross-host redirect (M10 — the security item, an edge of the same "redirect handling" family the DNS-rebind fix addressed for the internal case but not the cross-host-public-credential case).
## 6. Prioritized remediation order
1. **M10 — strip auth headers on cross-host redirect in the health probe** (only security finding; credential exfil to a public host).
2. **H1 — wire or remove the four un-dispatched hook events** (trust/correctness; silent no-op of a documented automation surface).
3. **M12 — `fsync` the events.jsonl append** (data-loss of the last event, including `/rewind` checkpoints).
4. **M11 — make the sandbox AST authoritative / segment the destructive regexes** (false-positive hard-blocks safe commands in headless exec).
5. **M14 — synchronize the PTY copy goroutine with session exit** (model loses a command's final output).
6. **M4 / M5 / M6 — cron: DST double-fire, claim double-grant on unadvanceable spec, `--run-now` reconcile** (duplicate/again missed scheduled agent runs = token spend / broken contract).
7. **M7 / M8 / M9 — connection lifecycle: bridge handshake deadline, `ServeConn` tracking for Shutdown, MCP SSE bounded reconnect** (slot exhaustion / lost MCP tools mid-session).
8. **M1 — anchor the stream-json `api_key` redactor** (corrupts machine-readable output).
9. **M2 / M3 / M13 / M15 — provider-command MCP drop, project-command provenance, mailbox stale-break, TUI streaming O(n²)**.
10. **Lows / infos** as hygiene — notably **L15** (genuine `lastUsedAt` data race; add a `-race` test), **L1** (route the three remaining stream connects through `streamWithReconnect`), and the dead/inert cleanups (L2/L4/L5/L8).
## 7. Confidence notes (what could not be fully verified)
- **`-race` suite is not green:** 3 `internal/tools` exec-session tests fail under `-race` due to fixed-timing yield-window assumptions (characterized in §2 as flakes, confirmed by passing 3× without `-race`). I could not make those tests deterministic without modifying source (audit only). No data race underlies them; the one real race in that package (**L15**) was found by inspection because no test exercises concurrent `touch()`+prune.
- **Platform-specific backends only skimmed:** Windows sandbox/process files (`*_windows.go`), `landlock_linux.go`, `seccomp_*.go`, `log_monitor_darwin.go`, and the OS keyring backends (`secret_access_*.go`) were read shallowly — outside the symlink/parsing/risk/locking focus. A dedicated Windows/Linux-LSM pass is advisable.
- **Not deep-read (out of stated focus):** `internal/tools/web_fetch.go` & `web_search.go` (network tools), Anthropic provider internals, the plugin/skill **install/checksum/lockfile** paths, and TUI overlays (`mouse.go`, picker/wizard/onboarding, `command_center.go` specifics). Build/vet cover them; behavior was not audited.
- **Daemon prior-audit reconciliation is necessarily empty:** the `D1–D11` labels referenced in current daemon code comments come from a review not present in the three on-disk `docs/audit/*.md`, so "fixed since" for the daemon is against code comments, not those docs. The daemon findings here (M7/M8/L7/I1) are re-derived against current code.
- **Reproductions:** M4, M5, M6, M11 were empirically reproduced with throwaway tests against the current tree (then removed). M10, M12, M14, M9, H1 are verified by code reading + caller/grep tracing, not a live exploit. Severity calibration for M10 is *medium* (requires a malicious/compromised/typo'd `baseURL` that 3xx-redirects to an attacker host) rather than high; if a deployment commonly points providers at untrusted gateways, treat it as high.
---
## 8. Remediation status — `fix/audit-2026-06-20`
Implemented in staged commits on branch `fix/audit-2026-06-20` (one finding/tight-cluster per commit; each gated build/vet/gofmt + `-race` on the affected package; full `-race` suite green on the final tree). Each finding was re-confirmed against current code before fixing.
### Fixed (15 findings + 1 test-robustness)
| ID | Sev | Status | Note |
|---|---|---|---|
| M10 | medium | ✅ Fixed | strip auth headers (x-api-key/x-goog-api-key/custom) on cross-host redirect in the health probe |
| M14 | medium | ✅ Fixed | join the PTY copy goroutine before markDone/remove so a command's final output isn't dropped on exit (also fixes the flaky `TestExecCommandTTYSessionAcceptsInputOnLinux`) |
| M12 | medium | ✅ Fixed | fsync events.jsonl append so the log is as durable as its metadata |
| M4 | medium | ✅ Fixed | feed `sched.Next` a minute-aligned instant so the DST fall-back collapse guard engages |
| M5 | medium | ✅ Fixed | pause an unadvanceable job inside the claim so two schedulers can't both fire it |
| M7 | medium | ✅ Fixed | bound the daemon control handshake with a read deadline (idle remote peer can't pin a slot) |
| M8 | medium | ✅ Fixed | `ServeConn` tracks the conn so `Shutdown` can close a stalled remote handshake |
| M13 | medium | ✅ Fixed | atomic rename-with-verify mailbox stale-lock break (no self-compare; no split-brain) |
| M1 | medium | ✅ Fixed | anchor the streamjson `api_key` redaction so prose isn't mangled |
| I1 | info | ✅ Fixed | local control socket handshake now deadline-bounded (same fix as M7) |
| L15 | low | ✅ Fixed | snapshot exec-session `lastUsedAt` under its lock — fixes a real `-race` data race |
| L13 | low | ✅ Fixed | `defer job.cancel()` releases the scheduler's per-job context on MaxRuns completion |
| L1 | low | ✅ Fixed | route the post-compaction / max-turns connects through `streamWithReconnect` |
| L3 | low | ✅ Fixed | `--skip-permissions-unsafe` rejects trailing positional args instead of dropping them |
| L11 | low | ✅ Fixed | error on a present-but-wrong-shape chatgpt account-id claim so the login warning fires |
| (report §2) | — | ✅ Fixed | de-flaked the 3 exec-session timing tests so `go test ./... -race` is green |
### Deferred (larger/riskier than a clean stage — rationale)
| ID | Sev | Why deferred |
|---|---|---|
| H1 | high | Wiring `sessionStart/End` + `specialistStart/Stop` correctly is a cross-surface design change: the dispatcher lives only in `agent.Options` (fires per-tool), so session boundaries differ across exec/TUI/daemon and the specialist events need the parent dispatcher threaded through the child boundary. The audit's alternative (delete the events) would disable a documented feature (against the guardrails). Needs a focused design pass, not a surgical edit. |
| M9 | medium | MCP SSE reconnect is risky concurrency (re-open + re-`initialize`d session state) and not deterministically unit-testable; warrants its own change with a fault-injection harness. |
| M11 | medium | Making the sandbox AST authoritative is security-sensitive — a wrong call under-blocks a genuinely destructive command. Needs an extensive "still blocks real destructive commands" matrix before flipping the precedence; the guardrails forbid weakening confinement to land a fix. |
| M2 | medium | Merging provider-command MCP into `ResolveMCP` is a real wiring decision (which resolve path the runtime should read); deferred to avoid guessing the intended single source of truth. |
| M3, M6 | medium | Project-command provenance marker (UX) and cron `--run-now` one-shot marker (schema field) are design choices better made deliberately. |
| M15 | medium | Streaming-block memoization is perf-only (no correctness risk) and fiddly in the TUI render hot path. |
| L2, L4, L5, L6, L7, L8, L9, L10, L12, L14, L16, L17, I2 | low/info | Lower-value hygiene / cross-cutting (dead-code removal decisions, cross-platform lock identity, RFC3339Nano format change, TUI width math, minor concurrency hardening). Batchable in a follow-up. |
### Skipped (not a real defect)
- The two findings the audit itself dropped in §4.4 (compactor frozen-budget; `maxTurns<=0`) were already withdrawn and not implemented.
-444
View File
@@ -1,444 +0,0 @@
# Zero — Product / UX / TUI Audit (2026-06-21)
- **Target:** `github.com/Gitlawb/zero` @ `origin/main` commit `f7ee189` — a terminal coding agent (Go, Bubble Tea v2 TUI; one event stream feeding TUI / headless `exec` / MCP server / cron).
- **Lens:** product, UX, TUI/visual correctness, and open-source-release readiness, judged from a **stranger who just cloned the repo**. The code/security audit is separate (`docs/audit/2026-06-20-deep-audit.md`) and is **not** repeated here.
- **Scope note:** audit only — no source changed.
## 1. Executive summary
Zero is a **functionally mature** agent with a thoughtfully-built TUI (memoized rendering, width tiers, light/dark themes, a real reduce-motion opt-out, color-independent diff/permission encoding, and a genuinely good guided setup wizard). The gap is the **release/packaging and last-mile UX layer**, and it is not yet shippable as open source.
Two **release blockers**: there is **no `LICENSE` file** (the repo is legally all-rights-reserved despite being marketed as open source), and the **headline binary-install path is dead** — `scripts/install.sh` / `install.ps1` resolve `Gitlawb/zero`'s GitHub Releases, which 404 (no release has ever been published), so the only working install is `go run` — which then trips a **Go-version mismatch** (README says 1.24, `go.mod` requires 1.25). A new user on a machine without Go 1.25 cannot get to first run.
Beyond that, the recurring themes are **docs/claims drifting from the code** (a `/usage` slash command and a `--theme` flag that don't exist; a keyless local-model on-ramp the health probe blocks), **error messages that don't guide recovery** (no-key errors dump a raw OpenAI URL instead of pointing at `zero setup`; `doctor` reports "Overall: pass" with no credential and leaks Go `map[...]` syntax), and **low-contrast theme tokens carrying real content** (faint/faintest fail WCAG while holding line numbers, diff headers, and help text).
### Severity rollup (35 findings, post-dedup)
| Severity | Count |
|---|---|
| Critical | 2 |
| High | 11 |
| Medium | 11 |
| Low | 5 |
| Info | 6 |
(Counts merge cross-area duplicates — LICENSE, install-404, Go-version, and the no-provider-setup-pointer cluster each appeared from multiple area finders.)
## 2. Environment & method
- **Build:** `go build ./cmd/zero` → clean on go1.26.4; binary run as `/tmp/zero` with **isolated config** (`HOME`/`XDG_CONFIG_HOME` → temp dirs) so findings reflect a fresh machine, not local state.
- **Ran (real output captured):** `zero --help`, `doctor`, `exec` (no provider / bad key / unreachable URL / empty prompt), `setup <provider> --verify` (no key / wrong key, openai + ollama), `providers check --connectivity`, `config`, `version`; bad-flag / bad-mode / bad-effort / malformed-config error paths; the existing TUI visual tests (`command_card_contrast`, `theme_select`/luminance, `width_tiers`, `wrap_whitespace`, `theme_reprobe`) — all pass; throwaway Go tests measuring display-cell widths of the truncation helpers with CJK input; WCAG contrast ratios computed from the theme hex tokens; a `creack/pty` capture of a monochrome onboarding frame to diff SGR codes across `NO_COLOR` values.
- **Terminal profiles:** color/`NO_COLOR`/profile behavior was checked via the `colorprofile` dependency + one pty capture; **truecolor/256/16-color and a physically light terminal were not driven frame-by-frame** (see Confidence notes).
- **Could not capture:** live interactive TUI frames — Bubble Tea v2's alternate-screen buffer yields only escape setup/teardown to `script`/`expect`. Rendered first-run wizard, resize redraw, streaming fade, and narrow-tier painting are assessed **from code + the passing visual tests** and marked unverified where relevant.
- **Adversarial pass:** every finding faced 1–2 skeptics that re-ran/re-read to disprove it; 2 were dropped (see §A.dropped). Several of my own first-pass guesses were corrected (exit codes are correct; meaning never depends on color alone; a reduce-motion opt-out does exist).
## 3. "First 5 minutes" — new-user walkthrough & verdict
1. **Clone, read README.** Strong pitch ("terminal coding agent you fully own", "no telemetry"). Two install options offered as peers: a binary one-liner and `go run ./cmd/zero`.
2. **Try the binary one-liner** (`scripts/install.sh`) — the natural choice without Go. → **Dead-ends** with a curl 404 / "could not read tag_name" (no GitHub Release exists). [C1]
3. **Fall back to `go run ./cmd/zero`** per the README ("requires Go 1.24+"). On Go 1.24 → **build error** `go.mod requires go >= 1.25.0`. [H2] (Works on 1.25+.)
4. **First launch with no key.** Headless `exec` prints a clear, exit-coded error — but it names only `OPENAI_MODEL`/`OPENAI_API_KEY` and a raw `platform.openai.com` URL, **never `zero setup` or `zero auth`** (the commands built for this moment). [H/M cluster] The guided `zero setup` wizard itself is excellent [I1] — if the user discovers it.
5. **Verify setup** (`zero setup openai --verify`) with no key yet → "**The provider rejected the API key**" — but no key was sent. [M1] Or try the README's headlined **keyless local-model path** (Ollama at localhost) and run `doctor`/`--verify` → "**localhost hosts are blocked**" + "make sure the server is running" (self-contradictory). [H1]
6. **Run `zero doctor`** to sanity-check → "**Overall: pass**" even with no provider credential [H], and an unreadable `missing: map[gopls:...]` Go-map blob [H].
**Verdict: a stranger cannot reliably reach a working first session in 5 minutes.** Not because the agent is weak — it's the install/license/version/onboarding-error plumbing around it. Once a user is past setup with a real key, the core loop and TUI are solid.
## 4. Findings by area
### A. First-run & onboarding
#### C1 · Both README binary-install paths point at a repo that 404s — `scripts/install.sh` and `install.ps1` dead-end for every new user
**Status (2026-06-21):** Fixed (in-repo) — `622e4b4`. README/INSTALL demote the binary path to "coming soon" and lead with `go run`; release-artifacts.yml now publishes a GitHub Release on a `v*` tag. Publishing the release + making the repo public are MANUAL maintainer steps (repo owner/name already matched go.mod).
- **Severity:** critical · also: G-oss-readiness
- **Where:** `README.md:55-57, docs/INSTALL.md:52, scripts/install.sh:4 (ZERO_REPO default Gitlawb/zero)`
- **Evidence:** curl -s -o /dev/null -w '%{http_code}' https://api.github.com/repos/Gitlawb/zero -> 404. /releases, /releases/latest, /tags all return {"message":"Not Found","status":"404"}. install.sh:106 resolves api_url="${ZERO_GITHUB_API%/}/repos/${ZERO_REPO}/releases/latest" then install.sh:111 `[ -n "$tag" ] || fail "could not read tag_name from $api_url"`. With curl --fail (install.sh:80) the API call itself errors out first. README presents this as the 'or install a release binary' path alongside go run.
- **Impact:** A stranger who picks the README's headline binary-install path (the natural choice on a machine without Go) hits an immediate, opaque failure ('could not read tag_name' or a curl --fail HTTP error). There is no published release, so the documented one-liner cannot succeed for anyone. Only the `go run ./cmd/zero` path works.
- **Fix:** Publish at least one GitHub release with the documented archive+.sha256 assets under the correct public repo, and fix the ZERO_REPO default / README links to that repo's real owner/name. Until releases exist, demote the binary-install path in README/INSTALL to 'coming soon' and lead with `go run ./cmd/zero`.
#### C2 · No LICENSE file; README says license 'being finalized' — OSS-release blocker (confirming established fact, still true @ f7ee189)
**Status (2026-06-21):** Skipped — license choice deferred by the maintainer ("leave the license portion now"). LICENSE / README §License / package.json license remain a MANUAL step.
- **Severity:** critical · also: G-oss-readiness
- **Where:** `README.md:330-332; repo root (no LICENSE* file)`
- **Evidence:** `ls LICENSE*` -> no matches. README.md:332: 'License is being finalized; a `LICENSE` file will be added before the public release.'
- **Impact:** A new user cloning the repo has no legal grant to use, modify, or redistribute. For an 'open-source' project this blocks adoption/redistribution and is a release blocker.
- **Fix:** Add a LICENSE file (e.g. Apache-2.0/MIT) and update README §License to name it.
#### H1 · Local-model verification/health commands fail with a misleading 'localhost hosts are blocked' error — breaks the README's headlined keyless path
**Status (2026-06-21):** Fixed — `24c8de3`. Loopback allowed only for a user-configured local base_url; redirects + other private ranges stay blocked.
- **Severity:** high
- **Where:** `internal/providerhealth/providerhealth.go:374-377 (also :81,:94 loopback prefixes); surfaced by `zero setup <local> --verify`, `zero providers check <local> --connectivity`, `zero doctor``
- **Evidence:** `zero setup ollama --verify` -> `[zero] setup verification failed: Could not reach the provider endpoint. Check the base URL ... and that the server is running. (provider connectivity URL is unsafe: localhost hosts are blocked)`. `zero providers check ollama --connectivity` -> status: fail / `provider.connectivity: provider connectivity URL is unsafe: localhost hosts are blocked` / exit 3. validateEndpoint() hard-rejects normalized=='localhost' and 127.0.0.0/8 + ::1. Guard is confined to providerhealth probe path (grep shows blockedAddrReason/endpointSafetyError only in providerhealth + unrelated web_fetch.go) so actual `zero exec` chat again
- **Impact:** README §Why Zero and Quick start headline 'Local models need no key at all' as the easiest on-ramp. A new user who runs Ollama/LM Studio at localhost:11434 and then runs setup --verify / doctor / providers check (all suggested) sees FAIL with a self-contradictory message ('make sure the server is running' + 'localhost is blocked'), and the 'next' hint tells them to re-run the exact command that will keep failing. The keyless p
- **Fix:** Allow loopback/localhost in the provider connectivity probe for explicitly user-configured local providers (the SSRF guard is meant for fetched/remote URLs, not the user's own configured base_url). At minimum, special-case local provider kinds to skip the loopback block, and drop the contradictory 'server is running' wrapper when the real cause is the loopback policy.
#### H2 · go.mod requires `go 1.25.0` (+ toolchain go1.26.4) but README badge and Quick start say Go 1.24 — a Go 1.24 user cannot build from source
**Status (2026-06-21):** Fixed — `622e4b4`. README badge + quick start now say Go 1.25+.
- **Severity:** high · also: G-oss-readiness
- **Where:** `go.mod:3 (`go 1.25.0`), go.mod:4 (`toolchain go1.26.4`); README.md:16 badge 'Go-1.24', README.md:52 'requires Go 1.24+'`
- **Evidence:** go.mod: `go 1.25.0` / `toolchain go1.26.4`. README.md:16 `![go](https://img.shields.io/badge/Go-1.24-...)`, README.md:52 `# run from source (requires Go 1.24+)`. A Go 1.24.x toolchain refuses a module declaring `go 1.25.0` unless auto-toolchain download is enabled (GOTOOLCHAIN=auto + network); offline/pinned 1.24 users get a 'go.mod requires go >= 1.25.0' build error.
- **Impact:** The README explicitly invites Go 1.24 users to `go run ./cmd/zero` (the ONLY working install path given the missing releases). They hit a build failure on the documented version, with no hint that 1.25 is actually required. Builds confirmed working only on go1.26.4 in this env.
- **Fix:** Make the docs match go.mod: change the badge and Quick-start note to 'requires Go 1.25+'. (Or, if 1.24 support is intended, lower the go.mod directive to 1.24 and verify it compiles.)
#### M1 · `zero setup <provider> --verify` with NO key set reports 'The provider rejected the API key' — but no key was ever sent
**Status (2026-06-21):** Fixed — `42b7123`. `--verify` distinguishes "no API key found" from "rejected".
- **Severity:** medium
- **Where:** `internal/cli/setup.go:59-77 (verify branch); verified via runtime`
- **Evidence:** With OPENAI_API_KEY unset: `zero setup openai --verify` -> '... setup verification failed: The provider rejected the API key. Double-check the key value (and that it is for this provider), then re-run setup. (Provider openai requires API credentials.)' — identical message to the WRONG-key case (OPENAI_API_KEY=sk-wrongkey123).
- **Impact:** Confusing for a first-timer who ran setup without exporting a key yet (a very common ordering): the tool says the key was 'rejected'/'double-check the key value' when the real problem is there is no key at all. Sends them debugging a key they never set.
- **Fix:** Distinguish the missing-credentials case from the rejected-credentials case: when no key/env is present, say 'No API key found — export OPENAI_API_KEY (or pass --api-key-env) then re-run', and reserve 'rejected the key' for actual 401s.
#### I1 · The guided setup wizard copy itself is well-written and self-explanatory (source-level; live frames unverified)
**Status (2026-06-21):** Info — no action (positive finding).
- **Severity:** info
- **Where:** `internal/tui/onboarding.go:1191-1650`
- **Evidence:** Strings include 'Welcome to Zero' / 'A terminal agent for changing real code.' (1202-1204), a Safety panel 'Zero asks before running shell commands or changing files. Unsafe mode stays off unless you explicitly enable it. Default: ask before risky work.' (1191-1196), clear step headers ('Choose a provider', 'Choose a model', 'Paste your <name> API key' with 'Saved keys stay in your user config' / 'Blank uses <ENV> from your shell'), model-ID examples (1311), and a consistent footer ('↑/↓ choose Enter continue q quit'). Non-interactive `zero setup <p>` output is also good: prints config path + concrete 'next:' steps + a runnable 'try this:'
- **Impact:** Positive: the wizard and non-interactive setup are a strong on-ramp; the onboarding problems above are in the surrounding install/license/version/verify plumbing, not the wizard copy.
- **Fix:** None — keep. (Recommend capturing real wizard frames via a pty harness in CI since alt-screen content isn't visible to script/expect, to guard the rendering that could not be verified here.)
### B. Interaction flows & UX
#### H3 · /input-style is a fully inert command — registered, in /help, autocompletes, but does nothing
**Status (2026-06-21):** Fixed — `1b2e453`. Command + kind + handler + dead helper removed.
- **Severity:** high
- **Where:** `internal/tui/commands.go:288-296 (registry) + internal/tui/model.go:2964-2969 (handler) + internal/tui/rendering.go:43-53 (output)`
- **Evidence:** Handler: `case commandInputStyle: ... text: shellOnlyCommandText(command.name)`. shellOnlyCommandText renders a warning card whose only body line is "This control is available in the TUI but does not have a backend setting yet." Captured from a test I ran: `command-card input-style / status: warning / State / This control is available in the TUI but does not have a backend setting yet. / hint: use /help to inspect active commands`. grep confirms NO other input-style logic exists: `grep -rn 'input-style|inputStyle|InputStyle' internal --include='*.go' | grep -v _test` returns only the registry entry + this handler. Yet it is listed in /help (S
- **Impact:** A new user discovers /input-style via `/` autocomplete or /help (where its description, "Show input style state", implies it surfaces real configuration), runs it, and gets a warning card admitting it's a no-op stub. This is the one remaining surfaced-but-inert slash command (the sibling stubs /style, /compact, /theme, /effort flagged in the earlier code audit are now actually wired on current main). It erodes trust in the com
- **Fix:** Remove the /input-style entry from commandDefinitions (commands.go) and drop the commandInputStyle kind + its case in model.go, so /help and autocomplete stop advertising it. Re-add it only when a backend setting exists.
#### H4 · README's TUI slash-command table advertises /usage, which is not a slash command (resolves to 'unknown command')
**Status (2026-06-21):** Fixed — `e68d6cb`. `/usage` removed from the README slash table.
- **Severity:** high
- **Where:** `README.md:89 (`| \`/doctor\` \`/usage\` \`/config\` | health, cost, and config without leaving the chat |`) under the "## The TUI" slash-command table`
- **Evidence:** The registry has no /usage (full name+alias dump from commands.go contains /doctor, /config, but no /usage). I ran a test: `parseCommand("/usage").kind == commandUnknown` (PASS), and `formatGroupedCommandHelp()` does NOT contain /usage (`HELP has ... /usage=false`). `zero --help` shows `usage Summarize token usage and estimated cost` exists ONLY as a CLI subcommand. So a user who types `/usage` in-chat (per the README) hits the commandUnknown path (model.go:2976) -> "unknown command: /usage" (unless they happen to have authored .zero/commands/usage.md, which a new user has not). The table's caption literally promises "...cost...without leavi
- **Impact:** A brand-new user reads the only slash-command reference in the README, types /usage to check token cost mid-session, and gets an error. The advertised in-chat cost readout does not exist as a slash command; the real feature is the separate `zero usage` CLI subcommand, which contradicts the table's 'without leaving the chat' promise.
- **Fix:** Either add a /usage slash command (kind+handler showing the same token/cost summary as the `zero usage` subcommand) or remove `/usage` from the README:89 table and document `zero usage` as a CLI command instead.
#### L1 · Ctrl+E in the composer is hijacked by mouse-release toggle, breaking the emacs end-of-line binding (while Ctrl+A=home still works)
**Status (2026-06-21):** Deferred — low (Ctrl+E composer binding).
- **Severity:** low
- **Where:** `internal/tui/model.go:818-825 (top-level Ctrl+E intercept) vs internal/tui/composer.go:223-225 (composer Ctrl+E = end-of-line)`
- **Evidence:** The top-level key switch handles `case keyCtrl(msg, 'e'):` and unconditionally toggles m.mouseReleased then `return`s, before the composer dispatch (model.go:1140 `applyComposerKey`) is ever reached. composer.go:223 declares `case keyIs(msg, tea.KeyEnd) || keyCtrl(msg, 'e'): state.cursor = composerLineEnd(state)` — that Ctrl+E arm is therefore unreachable from the composer. By contrast Ctrl+A/K/U/W are NOT intercepted at top level (`grep -n "keyCtrl(msg, 'a'|'e'|'k'|'u'|'w')" model.go` returns ONLY line 818 = Ctrl+E), so they reach the composer normally. The `?`-help overlay (keybinding_help.go:53) documents Ctrl+E only as 'release the mouse
- **Impact:** A user with emacs/readline muscle memory: Ctrl+A correctly jumps to line start, but Ctrl+E (expected: jump to line end) instead toggles mouse-capture mode and prints a 'Mouse released' system notice — a surprising, asymmetric behavior in the input editor.
- **Fix:** Either remove the dead Ctrl+E arm from composer.go:223 (leaving only tea.KeyEnd), or gate the top-level Ctrl+E mouse-toggle so it only fires on an empty/inactive composer (like the `?` overlay does at model.go:982) and let Ctrl+E reach the composer when editing text; then update keybinding_help.go accordingly.
#### I2 · Two permission decisions share hotkey 'y' but are mutually exclusive by tool type (no actual collision)
**Status (2026-06-21):** Info — no action (no real collision).
- **Severity:** info
- **Where:** `internal/tui/permission_prompt.go:45-48 + internal/agent/loop.go:1737-1742,1798-1805`
- **Evidence:** permissionOptions maps both AlwaysAllowPrefix and AlwaysAllow to hotkey "y". The dedup in permissionOptions is keyed by decision action, not hotkey, so if both appeared in one prompt both 'y' rows would render. However availablePermissionDecisions only appends AlwaysAllowPrefix for shell tools with a command prefix (loop.go:1738), and AlwaysAllow only when permissionSupportsPersistentDecision(toolName) is true — which returns false for `bash/exec_command/write_stdin/apply_patch` (loop.go:1800). So a shell prompt gets AlwaysAllowPrefix=y and a non-shell prompt gets AlwaysAllow=y; they never co-occur.
- **Impact:** None today — verified mutually exclusive by tool type. Noting it because the safety relies on a non-local invariant (the loop.go gating); a future tool that is both a shell tool AND persistent-decision-eligible would surface two 'y' rows where the first match wins, silently shadowing the second.
- **Fix:** Optional defensive measure: de-duplicate by hotkey in permissionOptions (or assign distinct hotkeys) so the invariant is enforced at the render layer rather than depending on the agent's decision-set gating.
### C. TUI rendering & visual correctness
#### M5 · middleTruncate (diff card file paths) overflows the budget on wide-rune paths
**Status (2026-06-21):** Deferred — explicitly out of scope for this PR (wide-rune truncation budget; outer width clip already prevents frame breakage per I4).
- **Severity:** medium
- **Where:** `internal/tui/startup.go:138 middleTruncate (used for the diff card path head at internal/tui/rendering.go:1471 with budget innerWidth/2)`
- **Evidence:** middleTruncate is also rune-count based (`if len(runes) <= limit`, then string(runes[:front])+"…"+string(runes[len-back:])). Ran the real fn: `middleTruncate(strings.Repeat("路",40), 20)` returns 39 cells for a 20-cell budget (test log: `middleTruncate(20) -> cells=39 (want <=20)`).
- **Impact:** A file path containing CJK characters in the diff card header renders ~2x its budget; the outer fitStyledLine re-clips it so the card edge survives, but the head/right-aligned counts (+N/-N) collide or the path is cut harder than intended — the path becomes harder to read for non-Latin filenames.
- **Fix:** Make middleTruncate measure with lipgloss.Width and cut front/back halves with splitAtWidth on the reversed/forward string, or fall back to the width-aware truncateRunes once that is fixed.
#### I4 · Wide-rune handling is correct in prose/markdown/table/user-prompt wrap and at the outer card fit — overflow does NOT break the card frame
**Status (2026-06-21):** Info — no action (verified good).
- **Severity:** info
- **Where:** `internal/tui/rendering.go:381 wrapPlainText / :442 splitAtWidth / :463 splitPreservingWidth; internal/tui/startup.go:213 fitStyledLine / :223 truncateStyledLine`
- **Evidence:** These all measure via lipgloss.Width / per-glyph glyphWidth (e.g. truncateStyledLine line 260 accumulates glyphWidth and also tracks OSC-8 link state so truncation can't leak a hyperlink). toolCard re-fits every body line to innerWidth (rendering.go:1387) so even the buggy truncateRunes output cannot push the card past the terminal width — verified: a 100-col CJK diff card produced all lines at exactly 100 cells. Assistant prose is also capped at 96 cols (assistantMeasureCap, rendering.go:360) for readability on wide terminals. WindowSizeMsg (model.go:1249) resizes composer and resets fade state cleanly.
- **Impact:** Positive: the layout is robust against frame-breaking; the wide-rune bug manifests as silent content loss inside the card rather than a corrupted UI. Documents why the high-severity finding is content-loss, not layout-break.
- **Fix:** No change needed; noted so the truncateRunes fix is scoped to content fidelity, and to record that interactive frames themselves remain UNVERIFIED (no pty).
### C/E. Color, theme & accessibility
#### H5 · faintest fails WCAG even the 3.0 UI floor in BOTH themes; it carries functional content (line numbers, diff @@/+++/--- headers, tool args, separators)
**Status (2026-06-21):** Fixed — `cc3b5e3`. faintest raised to WCAG AA (>=4.5) in both themes.
- **Severity:** high
- **Where:** `internal/tui/theme.go:135 (dark faintest #3a3a40), :168 (light faintest #9b9ba3); consumed as zeroTheme.faintest / diffMeta / addLineNum / delLineNum / toolArg`
- **Evidence:** Computed WCAG ratios: light faintest #9b9ba3 on panel #ececed = 2.34; dark faintest #3a3a40 on panel #0e0e10 = 1.71, and on the unpainted canvas #070708 = 1.78. AA-normal needs 4.5, the relaxed UI/large floor is 3.0 — these are below even 3.0. faintest renders diffMeta (theme.go:223: '@@ hunks, +++/--- headers') and the diff gutter line numbers (theme.go:226-227), which are functional, not decorative.
- **Impact:** On a brand-new user's terminal, diff hunk headers, line numbers, and tool argument hints are near-invisible to anyone with even mild low vision, and washed out for everyone on a bright screen. The existing test passes because TestLightPaletteContrastAndHierarchy only asserts a luminance DELTA (panelL-inkL>=0.5) and monotonic ordering — it never checks a WCAG ratio, so these sub-floor values ship green.
- **Fix:** Darken light faintest toward ~#6e6e76 (>=3.0 on #ececed) and lighten dark faintest toward ~#5a5a62 (>=3.0 on #0e0e10/#070708); then add a test asserting actual WCAG ratio >= 3.0 for every token used to carry text, not just a luminance delta.
#### H6 · faint is sub-AA in both themes yet carries instructional/navigation text (help footer, MCP wizard hints, composer placeholder, working timer)
**Status (2026-06-21):** Fixed — `cc3b5e3`. faint raised to WCAG AA in both themes.
- **Severity:** high
- **Where:** `internal/tui/theme.go:134 (dark faint #5b5b63), :167 (light faint #78787f); used at keybinding_help.go:89, mcp_add_wizard_view.go:26-160, mcp_manager.go:362/443, model.go:2111/2151/2231`
- **Evidence:** WCAG: dark faint #5b5b63 on panel = 2.87 (below the 3.0 UI floor); light faint #78787f on panel = 3.71 (below AA-normal 4.5). It renders real instructions: keybinding_help.go:70 'keybindingHelpFooter = "? or Esc to close · /help for slash commands"' shown via zeroTheme.faint (the user's only on-screen cue for how to exit the help overlay), mcp_manager.go:362 the nav legend 'type search up/down navigate Enter action Esc close', and the composer placeholder (model.go:2231).
- **Impact:** The dismiss hint, navigation legends, MCP setup guidance, and input placeholder are hard to read for low-vision users and dim for everyone — a new user trying to learn the keys is reading the help in the lowest-contrast color in the UI.
- **Fix:** Raise faint to clear 4.5 (light) / >=3.0 (dark) on its panel, e.g. dark #6f6f78, light #5f5f66. Reserve sub-AA grays strictly for non-text decoration (rules/borders), which already have separate tokens (line/line2).
#### M2 · Light-theme accent (4.34) is below AA for normal text, despite a test that claims to verify light accent contrast
**Status (2026-06-21):** Fixed — `cc3b5e3`. Light accent #4d7a08->#477006 (AA); theme test now asserts a true WCAG ratio.
- **Severity:** medium
- **Where:** `internal/tui/theme.go:169 (light accent #4d7a08); test internal/tui/theme_select_test.go:83`
- **Evidence:** WCAG: light accent #4d7a08 on panel #ececed = 4.34 (AA-normal needs 4.5). accent renders the user gutter '❯' (theme.go:38/210), bash prompt (theme.go:48/218), spinner, and focus. The test at theme_select_test.go:83 only asserts 'panelL - relLum(accent) < 0.25' (a luminance delta, not a contrast ratio), so 4.34 passes. Dark accent #caff3f is fine (16.4).
- **Impact:** On a light terminal the brand prompt glyph and bash gutter sit just under the readability bar for small text. Borderline rather than broken, but it is the most-repeated colored glyph on screen.
- **Fix:** Darken light accent slightly (e.g. #436a07 → ~4.7) to clear AA-normal, and change the test to assert a real WCAG ratio (>=4.5 for text-bearing accent) instead of a luminance delta.
#### M3 · NO_COLOR honored only for strconv.ParseBool-style values; NO_COLOR=yes / NO_COLOR=anything leaves the UI in full color, violating the no-color.org spec
**Status (2026-06-21):** Fixed — `7fc296b`. NO_COLOR with any non-empty value forces the Ascii profile.
- **Severity:** medium
- **Where:** `dependency github.com/charmbracelet/[email protected] env.go:115-117 (envNoColor uses strconv.ParseBool); zero never sets WithColorProfile (internal/tui/run.go:25-35), so it inherits this`
- **Evidence:** Drove /tmp/zero under a pty across NO_COLOR variants and diffed emitted SGR: 'NO_COLOR=1' -> colorSGR=false (306 bytes, monochrome), 'NO_COLOR=true' -> false, but 'NO_COLOR=yes' -> colorSGR=true (676 bytes, full color) and 'NO_COLOR=foo' -> colorSGR=true. Source: colorprofile env.go:116 'noColor, _ := strconv.ParseBool(env.get("NO_COLOR"))'. The no-color.org spec says NO_COLOR present with ANY non-empty value must disable color. (NO_COLOR=1 correctly produced a readable bold-only frame — color degrades cleanly when the value is recognized.)
- **Impact:** A user who follows the common convention 'export NO_COLOR=yes' (or any non-bool truthy value) still gets a full-color TUI, defeating their accessibility/preference setting. Silent and surprising.
- **Fix:** Don't rely on the dependency's ParseBool: detect NO_COLOR yourself (present and non-empty => force colorprofile.Ascii via tea.WithColorProfile) in run.go, matching no-color.org. At minimum document that only NO_COLOR=1/true work today.
#### M4 · Documented-in-code --theme flag does not exist; Options.Theme is read but never populated, so the only non-interactive theme control is the undocumented ZERO_THEME env var
**Status (2026-06-21):** Fixed — `3b73074`. Real `--theme {auto|dark|light}` flag wired to Options.Theme; stale comment corrected.
- **Severity:** medium
- **Where:** `internal/tui/theme.go:12 and theme_select.go:18 (both claim a '--theme flag'); internal/tui/options.go:52-54 (Theme field); internal/tui/model.go:559 (reads it)`
- **Evidence:** Binary: 'zero --theme light -p hi' -> 'unknown command "--theme"'. 'zero --help' global Flags list has no theme flag. grep for assignments to Options.Theme / '.Theme =' across cmd/ and internal/ returns ZERO hits — the field is only ever READ (model.go:559 resolveThemeMode(options.Theme, ...)). Yet theme_select.go:18 comments 'the --theme flag, threaded via Options.Theme' and theme.go:12 says startup selection happens via '--theme'.
- **Impact:** A user reading the code (or expecting the documented flag) cannot pick a theme at launch via --theme. They must discover ZERO_THEME (documented nowhere) or use /theme after the TUI is already up. The stale comments actively mislead.
- **Fix:** Either wire a real --theme {auto|dark|light} flag that sets Options.Theme, or delete the --theme references from theme.go:12 and theme_select.go:18 and document ZERO_THEME + /theme as the supported controls.
#### L2 · Accessibility/customization controls (NO_COLOR, ZERO_THEME, ZERO_NO_FADE, motion opt-out) are completely undocumented
**Status (2026-06-21):** Fixed — `3cd0a50`. README "Accessibility & appearance" section documents NO_COLOR/ZERO_THEME/--theme//theme/ZERO_NO_FADE.
- **Severity:** low
- **Where:** `README.md / docs/ (grep for NO_COLOR|ZERO_THEME|ZERO_NO_FADE|--theme|reduce.motion returns nothing); controls exist at streaming_fade.go:28-44 and theme_select.go:19`
- **Evidence:** grep -rni 'NO_COLOR|ZERO_THEME|ZERO_NO_FADE|--theme|reduce.motion' README.md docs/ => no matches. The controls themselves are real and tested: streaming_fade.go:28-44 honors ZERO_NO_FADE and auto-disables fade over SSH/tmux/16-color/no-TTY; /theme and ZERO_THEME switch palettes.
- **Impact:** A user who finds the streaming-text fade or color distracting (motion sensitivity) has no way to know ZERO_NO_FADE exists; a NO_COLOR/light-terminal user can't discover ZERO_THEME. The a11y story is decent in code but invisible to the new user it would help.
- **Fix:** Add a short 'Accessibility / Appearance' section to the README listing NO_COLOR (note the value caveat), ZERO_THEME=auto|dark|light, /theme, and ZERO_NO_FADE (reduce-motion opt-out).
#### I3 · Meaning does not depend on color alone, and there IS a reduce-motion opt-out — both verified
**Status (2026-06-21):** Info — no action (verified good).
- **Severity:** info
- **Where:** `internal/tui/rendering.go:1512/1516 (diff signs), :1200-1203 (permission ✓/✗), view.go:277-285 (mode labels), :996 (PERMISSION text badge); streaming_fade.go:28-44 (ZERO_NO_FADE)`
- **Evidence:** Diffs emit explicit sign columns: rendering.go:1512 diffBodyLine(...,"+",...) for adds and :1516 "−" for dels, so add/del survive color-stripping. Permission outcome uses glyphs ✓ (rendering.go:1200) / ✗ (:1203), the card is gated by a text 'PERMISSION' badge (:996), and permission MODE shows a text label 'auto-approve'/'ask'/'unsafe' (view.go:277-285) not color-only. Streaming fade has a reduce-motion path: ZERO_NO_FADE plus auto-disable on SSH/tmux/ANSI/no-TTY (streaming_fade.go:29-43); the spinner has no separate opt-out but is a single glyph, not a fade. NO_COLOR=1 pty frame rendered fully readable in bold-only.
- **Impact:** Positive: the core surfaces (diffs, permissions, modes) remain interpretable for colorblind users and under NO_COLOR. Recorded so it's not re-flagged; the only motion gap is the spinner having no independent disable.
- **Fix:** Optional: extend the reduce-motion opt-out to also still the spinner glyph when ZERO_NO_FADE (or a NO_COLOR/reduce-motion signal) is set, for full motion-sensitivity coverage.
### D. Consistency & polish
#### H7 · Bad/missing API key produces a mangled, self-contradicting error paragraph (Redact corrupts the provider's own help text)
**Status (2026-06-21):** Fixed — `8b9b479`. 401/403 lead with an actionable curated message; Redact only scrubs token-shaped Bearer words.
- **Severity:** high
- **Where:** `internal/providers/providerio/providerio.go:234-249 (Redact); surfaced via exec/-p with no/invalid key`
- **Evidence:** Live: `/tmp/zero -p "hi" --model not-a-real-model` -> `[zero] auth error: You didn't provide an API key. You need to provide your API key in an Authorization header using authorization [REDACTED] (i.e. Authorization: authorization [REDACTED] or as the password field (with blank username) if you're accessing the API from your browser and are prompted for a username and password. You can obtain an API key from https://platform.openai.com/account/api-keys.` The raw 401 prose contains the literal word 'Bearer' twice as INSTRUCTION text; Redact() (line 242-247: `if EqualFold(TrimRight(words[index],":"),"Bearer")` -> rewrite this+next word) treats
- **Impact:** A brand-new user whose only mistake is a missing/typo'd key gets an incomprehensible, grammatically-broken paragraph that (a) reads like a bug, (b) gives browser-username/password advice irrelevant to a CLI, (c) names platform.openai.com even when they aren't trying to use OpenAI, and (d) never tells them to run `zero auth` / `zero setup`. This is the single most likely error a first-run user hits.
- **Fix:** Don't pass raw upstream auth-error bodies through verbatim. For 401/403 emit a curated message: `auth error: your <provider> API key is missing or invalid. Run \`zero auth\` or set <ENV>=...`. Keep the raw body only behind a --verbose/debug flag. Also tighten Redact so the 'Bearer' heuristic only fires on token-shaped following words (e.g. matches sk-/long base64), not arbitrary prose.
#### H8 · doctor leaks raw Go map[...] syntax for ANY nested-map detail — confirmed on lsp.servers AND config.validation (shared formatDetails bug)
**Status (2026-06-21):** Fixed — `bb4b348`. formatDetailValue renders nested maps as "k: v"; no more raw map[...].
- **Severity:** high
- **Where:** `internal/doctor/doctor.go:277 (formatDetails uses %v on the value)`
- **Evidence:** lsp.servers: `missing: map[gopls:install with \`go install golang.org/x/tools/gopls@latest\` ... pyright-langserver:install with \`npm install -g pyright\` ...] | present: map[rust-analyzer:on PATH]`. config.validation (with a malformed config.json): `/tmp/zbad/.zero/config.json: map[col:4 error:invalid config JSON: invalid character 't' looking for beginning of object key string line:1]` — the actual error sentence is buried inside a Go map dump. Root cause line 277: `fmt.Sprintf("%s: %v", ..., redaction.RedactValue(value, ...))` where value is a map[string]any.
- **Impact:** Builds on the established lsp leak: this is not isolated to one check — every doctor detail whose value is a nested map renders as unreadable Go syntax. The MOST useful diagnostics (which LSP to install, what's wrong with your config + the line/col) are exactly the ones mangled into `map[...]`. New users running `zero doctor` to fix a problem get gibberish.
- **Fix:** In formatDetails, special-case map[string]any values: render them as indented `key: value` sub-lines (or `k=v, k=v`) instead of `%v`. For config.validation specifically, hoist line/col/error into a one-line human string (`line 1, col 4: invalid character 't' ...`).
#### M6 · Inconsistent validation of enum-like flags: --mode rejects bad values with a great message, --reasoning-effort silently ignores garbage
**Status (2026-06-21):** Deferred — medium not in this PR's scope (crit/high + OSS files + contrast). Tracked for a follow-up (validate --reasoning-effort like --mode).
- **Severity:** medium
- **Where:** `internal/cli/exec.go:902 (mode) vs exec.go:965-998 (reasoning effort)`
- **Evidence:** `zero -p hi --mode bogus` -> `[zero] unknown mode "bogus". Valid modes: smart, deep, fast, large, precise.` (exit 2 — exemplary microcopy). But `zero -p hi --reasoning-effort wat` -> `gpt-4.1 does not support reasoning effort; ignoring --reasoning-effort wat` then proceeds; `wat` is never validated as a value (the message only fires because gpt-4.1 lacks the capability — on a model that DOES support effort, an invalid value like `wat` would pass through unchecked).
- **Impact:** Inconsistent error contract across sibling flags. A typo in --reasoning-effort is silently swallowed instead of corrected with the valid set (low/medium/high), so users think their setting applied when it didn't.
- **Fix:** Validate --reasoning-effort against the allowed enum the same way --mode does, with a `Valid efforts: low, medium, high.` message; only fall back to the capability-skip notice when the value itself is valid.
#### L3 · Brand capitalization inconsistent across one CLI surface (ZERO / Zero / zero) and `zero version` prints no real version
**Status (2026-06-21):** Deferred — low (brand capitalization + real version string).
- **Severity:** low
- **Where:** ``zero --help` header vs body; `zero version``
- **Evidence:** `zero --help` first line: `ZERO terminal coding agent`; the 10 command descriptions all say `Zero` (e.g. 'List Zero model registry entries'); `zero version` -> `zero dev`. So one help screen shows three casings, and the release binary reports its version as the literal word `dev`.
- **Impact:** Cosmetic inconsistency a design-conscious user notices immediately; `zero dev` on a fresh clone gives no way to tell which build they have when filing issues.
- **Fix:** Pick one brand casing (recommend 'Zero') and use it everywhere including the --help header. Stamp a real version (git describe / tag) into the build via ldflags so `zero version` is meaningful.
#### I5 · Empty states and existing polish tests are genuinely clean
**Status (2026-06-21):** Info — no action (verified good).
- **Severity:** info
- **Where:** `providers/sessions/skills/plugins/usage commands; internal/tui/*_test.go`
- **Evidence:** Empty-state copy is clear and path-aware: `No Zero skills found in /tmp/.../skills.`, `No local Zero plugins loaded.`, `No Zero sessions found.`, and `usage` renders a tidy zero table with `n/a (net LOC <= 0)` guards. `providers` even flags `api key: not set`. Visual tests TestLightPaletteContrastAndHierarchy / TestWidthTierSegments / TestViewNeverExceedsTerminalWidth / TestWrapPlainTextPreservesAlignedWhitespace / TestThemeAutoReProbesBackground / TestHelpRoutesThroughStyledCard / TestStyleCommandCardContentRowTwoTonesCommands all PASS.
- **Impact:** Positive: card alignment, width tiers, light/dark contrast, whitespace-preserving wrap, and theme re-probe are regression-guarded and look correct. The polish problems are concentrated in error/warning microcopy, not layout or empty states.
- **Fix:** No change needed; keep these tests. Minor terminology nit to consider later: `config` says `active provider: none` while `providers` lists openai as the default keyless profile — reconcile the two so 'active' vs 'default' is unambiguous.
### F. Product completeness & robustness
#### H9 · `zero doctor` reports "Overall: pass" with NO provider credential — the one tool meant to verify keys gives a false all-clear
**Status (2026-06-21):** Fixed — `bb4b348`. provider.config fails (not pass) for a remote provider with no credential; keyless local stays pass.
- **Severity:** high
- **Where:** `internal/doctor/doctor.go:136-156 (providerConfigCheck) + :83-84 (report.OK only flips on StatusFail) + :188 (connectivity is warn/skipped). Reproduced via `zero doctor`.`
- **Evidence:** With unset OPENAI_API_KEY and empty config: `Overall: pass` ... `[pass] provider.config - Provider config loaded for openai.\n ... credentialConfigured: not set ... model: gpt-4.1` ... `[warn] provider.connectivity - Connectivity probe skipped. Run zero doctor --connectivity`. In code, providerConfigCheck returns StatusPass whenever the profile is non-empty (the built-in openai/gpt-4.1 default is non-empty), regardless of `credential := "not set"`; report.OK is false ONLY if some check is StatusFail (line 83-84).
- **Impact:** README says `zero doctor` will "verify config, keys, and connectivity." A brand-new user who runs it before their first prompt sees a green "Overall: pass", concludes setup is done, then `zero exec` immediately fails with a raw upstream auth error. The diagnostic actively misleads instead of catching the single most common new-user misconfiguration.
- **Fix:** In providerConfigCheck, when credential=="not set" AND the provider kind requires a key (i.e. not a local/keyless runtime like ollama/lmstudio), return StatusWarn (or StatusFail) with help text pointing to `zero setup` / `zero auth`. That makes Overall reflect "not actually usable yet" and gives the user the next action.
#### H10 · No-provider / missing-key error never names zero's own onboarding (`zero setup`/`zero auth`) — it dumps a raw OpenAI HTTP message telling the user to visit platform.openai.com
**Status (2026-06-21):** Fixed — `42b7123`. exec + TUI no-provider errors point at `zero setup` / `zero auth`.
- **Severity:** high · also: A-onboarding, D-polish
- **Where:** `internal/providers/providerio/providerio.go:218-232 (ClassifiedError) + internal/config/resolver.go:740 (silent default to openai/gpt-4.1). Reproduced via `zero exec "hello"` with no config.`
- **Evidence:** `zero exec "hello"` (no key) → stderr: `[zero] auth error: You didn't provide an API key. You need to provide your API key in an Authorization header ... You can obtain an API key from https://platform.openai.com/account/api-keys.` (exit 3). The README's literal first example is `zero exec "fix the failing test in ./pkg"`. grep for `zero setup|zero auth|no provider configured` across internal/cli, internal/agent, internal/providers error paths returns nothing in this path — the only zero-added text is the `auth error: ` prefix.
- **Impact:** An exec-first / CI user who copy-pastes the README's headline command before running the wizard gets a wall of OpenAI-branded text steering them to OpenAI's website, with zero mention that zero has a `zero setup` wizard or `zero auth`, and no hint that it silently defaulted to OpenAI. Onboarding dead-ends for anyone not using the interactive TUI.
- **Fix:** When the resolved profile has no credential (credential not set) and the request fails auth, prepend a zero-owned line before the upstream text, e.g.: `No provider credential found. Run \`zero setup\` (interactive) or set OPENAI_API_KEY / ANTHROPIC_API_KEY / GEMINI_API_KEY. Local models need no key — see \`zero providers list\`.` Detect the no-credential case at the agent/exec boundary rather than relying on the upstream 401 b
#### M7 · Provider errors (auth / rate limit) are classified but give the user no recovery action
**Status (2026-06-21):** Largely addressed by H7 (`8b9b479`) — auth errors now name a recovery action (`zero auth`). Remaining per-class recovery hints DEFERRED.
- **Severity:** medium
- **Where:** `internal/providers/providerio/providerio.go:218-231 (ClassifiedError); rendered raw in TUI via internal/tui/model.go:1403-1409 (rowError text = msg.err.Error()) and to exec stderr.`
- **Evidence:** ClassifiedError only adds a category prefix: `auth error: `, `rate limit error: `, `provider error: `. The TUI error row is `transcriptRow{kind: rowError, text: msg.err.Error(), final: true}` — the verbatim classified string, no appended next-step. Confirmed live: rate-limit/auth bodies are the raw upstream message with only the prefix.
- **Impact:** On a 429 the user sees `rate limit error: <upstream text>` with no guidance (wait? `/model` to a cheaper or different provider? it already retried?). On auth failure, no pointer to `/provider`/`zero auth`. The failure is legible as a *category* but not *recoverable* without the user already knowing zero's surface. (Reconnect for transient disconnects in reconnect.go DOES surface a `[connection lost — reconnecting N/2…]` notice
- **Fix:** Append a short, category-specific recovery hint when rendering rowError / exec error: auth → `Run \`zero auth\` or check your API key, or /provider to switch.`; rate limit → `Zero already retried with backoff; wait or use /model to switch provider.` Keep it one line; the classifier already knows the category.
#### M8 · TUI has no slash command to discover or manage subagents/cron/skills/plugins — features the README markets prominently
**Status (2026-06-21):** Deferred — larger feature (discovery slash commands for subagents/cron/skills/plugins); out of this PR's scope.
- **Severity:** medium
- **Where:** `internal/tui/commands.go (full registry: /spec and /mcp exist; no /specialist, /cron, /skills, /plugins, /subagent). CLI subcommands exist (zero specialist|cron|skills|plugins per `zero --help`).`
- **Evidence:** grep over commands.go for `spec|specialist|cron|mcp|skill|plugin|subagent` matches only `/mcp` (:174) and `/spec` (:197). README §Why Zero headlines "Subagents", "Spec mode", "Scheduled agents", and "Extensible — skills, plugins, hooks"; the TUI command table only lists /spec /plan. The ? overlay (keybinding_help.go:58-61) references specialist *cards* but only as a reactive drill-in, not a way to list/create them.
- **Impact:** A user living in the interactive TUI (the default `zero` with no args) can type `/` and will never surface cron, skills, plugins, or specialist management. These read as CLI-only features even though README presents them as first-class. Discoverability of half the headline feature set is gated on reading the README's Commands section and dropping out of the TUI.
- **Fix:** Add thin TUI slash commands that shell into the existing CLI surfaces (or at least informational `/specialist`, `/cron`, `/skills`, `/plugins` that print status + the CLI command to manage them), and list them in `/help`. Even read-only listing closes the discoverability gap.
#### I6 · Strong robustness wins worth preserving: network-down framing, mid-stream reconnect, output truncation, and width-safe rendering
**Status (2026-06-21):** Info — no action (verified good).
- **Severity:** info
- **Where:** `internal/providers (upstream-unreachable message), internal/agent/reconnect.go, internal/tools/exec_command.go:802-812, internal/tui/rendering.go:1161-1233; tests in internal/tui.`
- **Evidence:** Unreachable base URL → `[zero] upstream unreachable: the model server could not connect to 127.0.0.1:9 (connection refused). The request never reached the model — this is a network failure ... Verify the host is reachable (DNS/proxy/VPN/firewall) ...` (exit 3). reconnect.go retries connect-time disconnects (eof/reset/timeout/502/503) twice with backoff and surfaces `[connection lost — reconnecting N/2…]`. exec output truncates head+tail with `\n[zero] output truncated\n`. Tool cards cap at 16 live / 400 flushed lines and collapse noisy output. Visual tests PASS: TestViewNeverExceedsTerminalWidth, TestTinyTierSingleSegmentAndRailLessCards, Tes
- **Impact:** These are genuinely good for a new user: network failures are legible and actionable, transient hiccups self-heal, and huge tool output won't flood the transcript or overflow narrow terminals. Listed so they are not regressed by fixes to the items above.
- **Fix:** None — keep. The findings above should reuse this same quality bar (the unreachable-host message is the model for what auth/rate-limit/no-key errors should look like).
### G. Open-source readiness
#### H11 · No SECURITY.md and no private vulnerability-reporting path
**Status (2026-06-21):** Fixed — `634f23f`. SECURITY.md with a private GitHub-advisory reporting path.
- **Severity:** high
- **Where:** `/tmp/zero-ux/ (no SECURITY.md at root or .github); CONTRIBUTING.md (no disclosure section)`
- **Evidence:** find for security.md => none. grep -niE 'vulnerab|disclos|CVE|report.*privately' across CONTRIBUTING.md/README.md/INSTALL.md => only an unrelated 'security issue' mention in CONTRIBUTING:92 and a firecrawl privacy note. For a coding agent that executes shell, edits files, and handles API keys/secrets, there is no documented way to report a vuln.
- **Impact:** A security researcher who finds a sandbox-escape or secret-leak in an agent that runs arbitrary commands has no private channel and will either drop a public issue (0-day exposure) or go silent. GitHub also won't surface a 'Security policy' for the repo.
- **Fix:** Add SECURITY.md (root or .github/) with a private reporting address or GitHub Security Advisories link and a supported-versions/response-time note.
#### M9 · No issue templates and no PR template (.github/) despite CONTRIBUTING mandating their use
**Status (2026-06-21):** Fixed — `634f23f`. .github issue (bug/feature/config) + PR templates added.
- **Severity:** medium
- **Where:** `/tmp/zero-ux/.github/ (only workflows/); CONTRIBUTING.md:49 ('Open an issue using the appropriate issue template')`
- **Evidence:** find .github -type f => only the 4 workflow YAMLs. No .github/ISSUE_TEMPLATE/, no PULL_REQUEST_TEMPLATE. Yet CONTRIBUTING.md:49 says 'Open an issue using the appropriate issue template' and the whole policy hinges on an `issue-approved` label workflow.
- **Impact:** A newcomer told to 'use the appropriate issue template' finds none — bug reports arrive unstructured (the exact low-signal reports CONTRIBUTING says will be closed), and the documented contribution process is self-contradictory on first contact.
- **Fix:** Add .github/ISSUE_TEMPLATE/bug_report.yml + feature_request.yml (matching the fields CONTRIBUTING already enumerates at lines 122-145) and a PULL_REQUEST_TEMPLATE.md that prompts for the `Fixes #123` issue link.
#### M10 · No CODE_OF_CONDUCT.md
**Status (2026-06-21):** Fixed — `634f23f`. CODE_OF_CONDUCT.md (Contributor Covenant 2.1) added.
- **Severity:** medium
- **Where:** `/tmp/zero-ux/ (none at root or .github)`
- **Evidence:** find for code_of_conduct* => none.
- **Impact:** Standard OSS-readiness/community-health item missing; GitHub's community profile flags it and some users/orgs treat its absence as a maturity signal. Low functional impact but expected for a public release.
- **Fix:** Add CODE_OF_CONDUCT.md (e.g. Contributor Covenant) with a contact for enforcement.
#### M11 · No CHANGELOG and source builds report version 'dev' — `zero update` has nothing to compare against
**Status (2026-06-21):** Partially fixed — `634f23f` adds CHANGELOG.md; build-time version stamping (still `dev`) is DEFERRED.
- **Severity:** medium
- **Where:** `/tmp/zero-ux/ (no CHANGELOG); internal/update/update.go:138-141; `/tmp/zero --version` => 'zero dev'`
- **Evidence:** `/tmp/zero --version` => `zero dev`. update.go:138-141: 'Source/dev builds carry a non-semver version ("dev"); ... currentVersion = "0.0.0"'. No CHANGELOG file anywhere. README.md:147 advertises `zero update` and INSTALL.md:91-98 documents `zero update --check`.
- **Impact:** A user running a source build sees version 'dev' and `zero update` treats it as 0.0.0 (will always claim an update is available once any release exists). With no CHANGELOG, a user can't see what changed between versions before updating. Weakens the documented update flow.
- **Fix:** Add a CHANGELOG.md (Keep-a-Changelog style) and document the release/versioning process; consider stamping the real version via -ldflags in the release builder so installed binaries report a semver.
#### L4 · README has no screenshot/GIF for a TUI-first product
**Status (2026-06-21):** Deferred — MANUAL maintainer step (add a screenshot/GIF; not auto-generated).
- **Severity:** low
- **Where:** `/tmp/zero-ux/README.md:1-21 (only ASCII logo + shields.io badges)`
- **Evidence:** grep -niE '!\[|\.png|\.gif|demo|screenshot|asciinema' README.md => only the 4 shields.io badge images; no product screenshot or terminal recording. README.md:38 promises 'A TUI that feels premium'.
- **Impact:** A newcomer evaluating a 'premium TUI' coding agent on the README/repo page can't see what it looks like before investing in a build — significant conversion/first-impression cost for a terminal-UI product.
- **Fix:** Add a screenshot or asciinema/GIF of the TUI (setup wizard + a chat turn) near the top of the README.
#### L5 · npm package name is the un-scoped generic 'zero' — almost certainly unpublishable as-is
**Status (2026-06-21):** Deferred — low (npm package name).
- **Severity:** low
- **Where:** `/tmp/zero-ux/package.json:2`
- **Evidence:** package.json: `"name": "zero"`. README.md:279 calls the npm wrapper a real install path ('the npm wrapper just delegates to it'). bin/zero.js exists as the wrapper.
- **Impact:** `npm publish` of a bare common word like `zero` will collide with an existing package / be rejected, so the advertised npm distribution can't ship under this name. Minor since npm isn't the primary documented path, but it's a release blocker for that channel.
- **Fix:** Scope the package (e.g. @gitlawb/zero) or pick an available name, and document the actual npm install command in the README (currently none is shown).
### Dropped in adversarial verification
| Claim | Why dropped |
|---|---|
| Wide-rune (CJK/emoji) diff/code/grep content truncated by rune-count, "half the line silently lost" | The mechanism (rune-count budget in `truncateRunes`) is real, but the **outer `fitStyledLine(line, innerWidth)` clip** (`rendering.go:1387`) is display-width-aware and absorbs the over-selection: an end-to-end width sweep showed **zero** content loss vs a width-correct budget and the ellipsis is preserved. No user-visible harm → invalid as a UX defect (kept only as a minor `middleTruncate` budget note, M). |
| Default `firecrawl` MCP server 401s on "every first run", breaking the keyless web-search promise | **Split verdict** — one verifier reproduced an **intermittent ~20%** keyless 401 (Firecrawl-side flakiness, surfaced as a mangled Bearer-redacted warning); another got HTTP 200 + 25 tools registered and the warning not emitted by `zero setup`. Not reproducible enough to assert → recorded as Info below, not a finding. |
> **Info (intermittent, unverified):** the keyless `firecrawl` default endpoint returned a 401 on a minority of identical requests in one run; if it flakes, the warning is rendered mangled (the Bearer-redaction eats the message tail). Worth a real-world soak test of the "no API key" web-search claim before launch.
## 5. Open-source readiness checklist
| Item | Status | Note |
|---|---|---|
| **LICENSE** | ❌ FAIL | Missing; README says "being finalized." **Release blocker.** No SPDX headers, no `package.json` license field either. (C2) |
| Working install path | ❌ FAIL | Binary install (`install.sh`/`.ps1`) 404s — no GitHub Release; CI never creates one. `go run` works but needs Go 1.25. (C1) |
| README — newcomer quality | ⚠️ PARTIAL | Good what-it-is/quickstart/config prose + telemetry/privacy statement; but stale Go-version, a `/usage` slash + `--theme` flag that don't exist, and **no screenshot/GIF** for a TUI-first product. |
| CONTRIBUTING.md | ⚠️ PARTIAL | Present and detailed — but mandates issue/PR templates that don't exist. |
| CODE_OF_CONDUCT.md | ❌ FAIL | Missing. |
| SECURITY.md + vuln path | ❌ FAIL | Missing; no private disclosure channel — a baseline expectation for a tool that runs shell/edits files. (H) |
| Issue templates / PR template | ❌ FAIL | `.github/` has only workflows. |
| CHANGELOG + versioning | ❌ FAIL | No CHANGELOG; binaries report `dev` → `zero update` always claims an update. |
| CI coverage | ✅ PASS | Strong: 3-OS matrix, gofmt/vet/govulncheck/deadcode. |
| Telemetry / privacy statement | ✅ PASS | "No telemetry" stated; Firecrawl keyless-routing disclosed. |
| Dependency-license compatibility | ✅ PASS | No copyleft deps linked into the binary; Firecrawl AGPL is network-only and README's reasoning is correct. |
| Reproducible build | ⚠️ PARTIAL | `go build` reproducible on Go 1.25+; binary distribution non-functional. |
## 6. Root-cause synthesis
1. **The release/packaging layer was never finished.** The product is mature but the OSS scaffolding isn't: no LICENSE, no published release (install scripts dead-end), no SECURITY/CoC/CHANGELOG/templates, version pinned at `dev`. Code shipped ahead of distribution. → C1, C2, and most of area G.
2. **Docs/marketing copy drifted from the implementation.** README claims Go 1.24 (needs 1.25), a `/usage` slash command and a `--theme` flag that don't exist, and a keyless local-model on-ramp the SSRF probe blocks; accessibility env vars are undocumented. The pitch outran the code. → H1, H2, H4, M (theme flag), L (a11y env docs).
3. **Raw core errors reach users without a "what to do next" UX layer.** The surface-agnostic core's classified/raw errors surface verbatim: no-key dumps an OpenAI URL instead of `zero setup`; "rejected the key" when none was sent; `doctor` says "pass" with no credential and prints Go `map[...]`; provider errors carry no recovery action. → the no-provider cluster, M1, doctor findings, provider-error finding.
4. **Theme aesthetics undercut the readability bar.** `faint`/`faintest` fail WCAG yet carry functional content (line numbers, diff `@@`/`+++`/`---`, help footer, placeholders); light `accent` is sub-AA. → the contrast findings.
## 7. Prioritized fix order for the OSS launch
**MUST ship (launch blockers):**
1. **Add a `LICENSE`** and name it in README. (C2)
2. **Make an install path actually work** — publish a GitHub Release with the documented assets under the real repo (and fix `ZERO_REPO`/links), or demote binary-install to "coming soon" and lead with `go run`. (C1)
3. **Fix the Go-version claim** (one-line: README → "Go 1.25+"). (H2)
4. **Add `SECURITY.md`** with a private vuln-reporting path. (H)
5. **Stop misleading on first contact:** remove the inert `/input-style` (H3) and the README's nonexistent `/usage` slash + `--theme` flag (H4, M); or wire them.
6. **Unblock the keyless local-model on-ramp** — allow loopback in the provider connectivity probe for user-configured local providers. (H1)
7. **Onboarding errors guide recovery:** no-provider/bad-key → point to `zero setup`/`zero auth`, distinguish *missing* key from *rejected* key, and don't dump a raw OpenAI URL. (no-provider cluster, M1)
8. **`doctor` must not say "Overall: pass" with no credential**, and must stop leaking Go `map[...]`. (H, H)
**SHOULD (strongly recommended before a public launch):**
- Lift `faint`/`faintest` (and light `accent`) to meet a contrast bar — they carry real content. (H, H, M)
- Add `CODE_OF_CONDUCT.md`, `CHANGELOG.md`, issue/PR templates; honor `NO_COLOR=<any value>` per spec; document `NO_COLOR`/`ZERO_THEME`/`ZERO_NO_FADE`. (G + M/L cluster)
- Add a README screenshot/GIF (TUI-first product). (L)
- Give the TUI discoverability of cron/specialists/skills/plugins (slash commands or a `/help` pointer). (M)
- Provider errors: append a recovery action; pre-soak-test the keyless Firecrawl path.
**NICE-TO-HAVE:** scope the npm package name; fix the `Ctrl+E` composer binding shadowed by mouse-toggle; normalize brand capitalization + stamp a real `version`; `middleTruncate` wide-rune budget. (L cluster)
## 8. Confidence notes (what could not be fully verified)
- **Live interactive TUI frames are UNVERIFIED.** Bubble Tea v2's alt-screen defeats `script`/`expect` (only escape setup/teardown captured). The rendered first-run wizard, resize redraw, streaming fade, and narrow-tier painting are assessed from render code + the **passing** visual tests, not captured frames. One color check did succeed via `creack/pty` + an OSC-11 reply (monochrome onboarding frame). **Recommend a pty golden-frame harness in CI** to lock the rendering this audit could only read.
- **Contrast** ratios are computed deterministically from the theme hex tokens (solid), but "readable on a *physically* light terminal" was exercised with a dark OSC-11 reply, so the light-palette verdict rests on the hex values + the luminance tests, not a real light terminal.
- **The Go 1.24 build failure** is reasoned from the `go.mod` directive (only go1.26.4 present in this env), not reproduced on a real 1.24 toolchain.
- **Truecolor / 256 / 16-color downsampling** is library-handled (`colorprofile` writer) and verified by code, not by per-profile pty captures.
- **GitHub Release existence** for `Gitlawb/zero` was probed via the public API (404 for repo/releases/tags) but the environment has no authenticated GitHub access; the repo may be private/unpublished rather than absent — either way the documented install path cannot succeed for a public stranger today.
- **Firecrawl keyless 401** could not be settled (intermittent; see §dropped) — needs a real soak test.
-281
View File
@@ -1,281 +0,0 @@
# ZERO · Lime TUI rebuild — staged prompt
You are working in a fresh checkout of `github.com/Gitlawb/zero` (Go 1.24,
Bubble Tea v1.3.10, Lip Gloss, Bubbles v1.0.0, Glamour — all in go.mod; add NO
new dependencies).
Mission: **remove both existing TUI designs entirely** — the default cyan
shell (`internal/tui/theme.go` palette + the centered splash in `startup.go`)
AND the whole `zeroline` skin system — and replace them with ONE design:
**Lime**, the near-black, lime-accent chat surface prototyped in
`docs/design/zero_tui_lime.html` (commit the prototype there first; it is the
visual source of truth). The prototype is a web mock — this spec is the
authoritative terminal translation of it. Behavior must not regress: every
slash command, prompt flow, overlay, and safety property that works today
must work after.
This is a fresh clone: file names below were verified against a recent
snapshot, but ALWAYS recon before editing. If a path or symbol has moved,
adapt — do not blindly apply.
**Gate (run after EVERY stage, commit + tag before continuing):**
```
go vet ./...
go test ./...
go run ./cmd/zero-release build
```
Stage 4 additionally runs `go run ./cmd/zero-release smoke`. Never tag a red
tree. AGENTS.MD applies: Go-native commands only.
---
## Design spec — Lime
### Palette — `internal/tui/theme.go` becomes the ONLY place colors exist
| Token | Hex | Use |
|------------|-----------|-----|
| bg | `#070708` | canvas (terminal default bg; do not paint full-bleed) |
| panel | `#0e0e10` | card backgrounds (tool/diff/read/grep/sessions rows) |
| panel2 | `#121215` | card header rows, picker rows |
| panel3 | `#17171b` | selected/hovered row bg |
| line | `#242429` | default borders, rules, status separators |
| line2 | `#2e2e34` | emphasized borders |
| ink | `#ececee` | primary text |
| muted | `#8b8b93` | secondary text, assistant interim prose |
| faint | `#5b5b63` | hints, metadata |
| faintest | `#3a3a40` | line numbers, separators, tool args |
| accent | `#caff3f` | brand lime: prompts, badge, spinner, focus, final rail |
| green | `#5dd1a4` | success, diff add sign, ✓ |
| red | `#ff7a7a` | errors, diff del sign, ✗, deny |
| amber | `#ffc25c` | permission surfaces, warnings, auto badge |
| blue | `#7db4ff` | grep file locations, local-model dot |
| addBg | `#15201d` | diff added-line bg (solid stand-in for green @9% over panel) |
| delBg | `#241819` | diff deleted-line bg (solid stand-in for red @9%) |
| permBg | `#1c1915` | permission card bg (amber @6% over panel) |
| selBg | `#1d2114` | selected picker/suggestion row (accent @8%) |
| addInk | `#bdeed7` | added-line text |
| delInk | `#f2c4c4` | deleted-line text |
| onAccent | `#000000` | text on accent or amber fills |
Terminals cannot alpha-blend or glow — the four solid tints above ARE the
translation of the prototype's rgba/glow effects; do not attempt gradients,
shadows, or blur. Truecolor hex throughout; lipgloss downsamples on limited
terminals (keep the existing explanatory comment style in theme.go).
Keep the `tuiTheme` single-value pattern but rebuild the field set around
these tokens (style per role: badge, userPrompt, sayText, finalRail, toolName,
toolTarget, toolArg, autoTag, statusOk/Err, diffAdd/Del/Meta, permBadge,
permRisk, grepLoc, bashPrompt, stGroup, etc.). No hex outside theme.go.
### Layout — single-column chat surface (matches the existing architecture)
```
zero / ~/dev/zero anthropic/claude-sonnet-4-5 · 200K
────────────────────────────────────────────────────────────────────────
❯ add a --version flag to the cli (user)
I'll add a --version flag…▌ (interim, muted)
╭ grep internal/cli flag\.|RegisterFlag ⠋ ──────────╮
│ internal/cli/root.go:41 fs := flag.NewFlagSet("zero"… │
╰ 3 matches ───────────────────────────────────────────────────────╯
╭ edit internal/cli/root.go auto ✓ ────────────╮
│ internal/cli/root.go new file +6 −1 │
│ 41 │ fs.BoolVar(&o.Version, "version", false, … (addBg) │
╰──────────────────────────────────────────────────────────────────╯
│ Done — the CLI now prints its version. (final: accent rail)
● done · 2 tools · 8.4s (done line)
❯ describe a task for zero… (composer)
────────────────────────────────────────────────────────────────────────
● anthropic │ claude-sonnet-4-5 │ │ 12.4K tok │ interactive │ ⏵⏵ auto
```
Render order stays: title line → rule → transcript → composer → rule →
status line. NO alt-screen change, NO panes, NO mouse — keep `run.go`
exactly as-is including its no-mouse-capture comment.
### Title bar (replaces `headerBar`)
Left: badge — `lipgloss` style bg=accent fg=onAccent bold, content ` 0 ` —
then `zero` (ink, bold, lowercase), ` / ` (faintest), cwd via existing
`shortenPath` (muted), git branch (faint) when present. Right:
`provider/model` (ink) + ` · ctx` (faint) where ctx comes from
`modelregistry.ContextLimits.ContextWindow` formatted like `200K`. Reuse the
existing width-candidate fallback pattern (`startupHeaderLine`) so segments
drop gracefully: full → drop ctx → drop cwd → badge+model only. Below it,
one rule line in `line`.
### Empty state (DELETE the splash entirely)
Remove `startupView`, `startupHeader`, `commandChips`, the figlet
`zeroLogoLines`, gap math, and the `showSplash` field/branch. The app boots
into the chat surface; when the transcript is empty, the stream area shows,
vertically centered:
- a 5–6 line block-art `0` in accent (derive the glyph from the `O` columns
of the old figlet constant before deleting it),
- tagline (muted): `a std-lib-first coding agent · bring your own key · no lock-in`,
- hint (faint): `running zero against <provider/model>` with the model in ink,
- three suggestion chips, one per line: `❯` (accent bold) + suggestion (ink)
inside a `line`-bordered rounded box; pressing `1`–`3` while the composer
is empty inserts that suggestion. Define suggestions as a string slice in
the tui package (e.g. add a --version flag / explain internal/agent/loop.go /
fix the failing test in internal/tools).
### Stream blocks (restyle `transcript.go` / `rendering.go` rows)
- **user** (`rowUser`): `❯ ` accent bold + ink text. Blank line above turns.
- **assistant interim** (`rowAssistant` while streaming): muted text, wrapped
to `min(width-4, 74)` columns, trailing `▌` cursor in accent while the
turn is pending (drives off existing pending state — no new ticker).
- **final answer** (last assistant row of a turn): a `│` accent rail gutter
on every wrapped line + ink text, same 74-col measure. If distinguishing
interim vs final is not already possible from row data, mark the row at
append time — do not re-parse text.
- **done line** (end of a turn): `●` green (red on error) + faint
`done · N tools · Xs` with faintest `·` separators, derived from data the
model already has (tool rows this turn + existing usage/timing if present;
omit segments that don't exist rather than inventing counters).
- **system / error notes** (`rowSystem`/`rowError` equivalents): one-line
bordered note — system: faint on panel with `line` border; denial/error:
red text, red-tinted border. Keep content unchanged.
### Tool cards (replaces `titledCard`/tool rows for `rowToolCall`+`rowToolResult`)
Rounded border card on panel bg. Head row on panel2 with a bottom rule:
status glyph + tool name (ink bold) + target (muted, middle-truncated) +
one-line arg via the existing `argHint` (faintest, right side) + optional
`auto` tag (amber text in amber-bordered mini box) when the call was
auto-approved by mode or a stored grant. Status glyph: running =
`bubbles/spinner` MiniDot in accent · ok = `✓` green · error = `✗` red.
Border tint follows state: line → accent-ish `#5a6b2e` while running →
red-mix on error (define both as theme tokens). Bodies by result shape,
reusing the existing detection where present:
- **diff** (`looksLikeDiff`): head row = path (ink) + `NEW FILE` tag (green
on addBg) when applicable + right-aligned `+N` green / `−N` red counts;
body lines = 4-col right-aligned line number (faintest) + sign col
(green/red) + text — added rows on addBg in addInk, deleted on delBg in
delInk, context muted. Keep the 16-line cap + `… N more lines` footer.
- **read**: same gutter, muted text, head shows path + `Lstart–Lend` (faint).
- **bash**: cmd row `❯` accent bold + command (ink) above a rule; output
muted (stderr-ish in delInk), indented 2; footer `exit 0` green /
`exit N` red when an exit code is in the result.
- **grep**: rows `file:line` (blue) + text (muted, truncated); footer faint
`N matches`.
- Anything else: current generic output rendering, restyled to muted on panel.
### Permission card (restyle the existing `pendingPermission` prompt — keys unchanged)
Card with amber-mixed border on permBg. Top row: `PERMISSION` badge (onAccent
on amber, bold) + `risk: <low|medium|high>` (amber) from the request's
existing risk data. Body: `<tool>` (amber bold) + target (ink) + reason
(muted). Action row of bordered key-chips: `[a] allow once` (onAccent on
accent), `[y] always` (ink, accent border), `[d] deny` (ink, red on hover is
web-only — just red-bordered), `[esc]` hint faint. After resolution collapse
to one faint line: `allowed once · <tool>` green / `always · <tool>` green /
`denied · <tool>` red. Ask-user and spec-review prompts get the same card
language with `line` borders. Do NOT alter `handlePermissionKey`,
`nextPermissionMode`, or grant-store semantics.
### Composer + status line
Composer: `❯` accent bold + the existing `bubbles/textinput`, borderless
(remove the bordered input block), placeholder `describe a task for zero…`,
switching to `running… ctrl+c to interrupt` while pending; right-aligned
faint hint `run ↵` (idle, only when input is non-empty) / `esc stop`
(pending). Rules above composer and above status line in `line`.
Status line groups separated by ` │ ` (line color): `●` accent + provider ·
model id · flexible gap · `<X.XK> tok` (existing usage segment data,
keep cost when priced) · `interactive` · permission mode (reuse
`modeLabel()`: `⏵⏵ auto-approve` green / `ask` amber / `unsafe` red) ·
optional green `always: <n> grants` when the sandbox grant store exposes a
cheap count (omit otherwise — do not add plumbing).
### Overlays (restyle in place, identical key handling)
- Autocomplete: rows on panel, selected row on selBg with accent `❯` marker,
name ink / desc faint.
- Picker (`/model`, `/provider`, etc.): bordered panel; rows = provider dot
(accent remote, blue local) + id (ink) + right meta `ctx · KEY_ENV`
(faint/faintest) when the registry/provider catalog already exposes them;
selected row selBg. Title row + `↑/↓ · ⏎ · esc` hints faint.
- Sessions: `/resume` (alias `/sessions`) keeps its existing flow; restyle
its list as stacked cards — id (accent) + age (faint) top row, title (ink),
meta line (faint with faintest dots). No new persistence behavior.
### Explicitly OUT of scope
The prototype's `text/json` toggle visualizes the headless
`--output-format stream-json` CLI mode (see docs/STREAM_JSON_PROTOCOL.md) —
not a TUI feature. The TweaksPanel in the HTML is a design harness — ignore
it. No mouse support, no model dropdown (keyboard picker is the translation).
---
## Adaptive requirements (treat as acceptance criteria)
- Width tiers, re-evaluated on every `WindowSizeMsg`:
**≥100** full spec · **80–99** drop tool-arg column, header ctx, status
`interactive` group · **60–79** drop diff/read line-number gutters, badge
renders as `0` without padding, status keeps provider+tokens+mode only ·
**<58** (current `minStartupWidth`) single-segment header, cards lose
side borders (keep top/bottom rules), composer + mode line only.
- Text measure: say/final wrap at `min(width-4, 74)`; paths middle-truncate
(`internal/…/root.go`); never emit a styled line wider than the terminal
(extend the existing `fitStyledLine`/`truncateRunes` helpers — reuse, don't
fork).
- Height: when the terminal is shorter than the frame, transcript trims from
the top (existing inline behavior) — verify no overlap of composer/status.
- Color: nothing may rely on truecolor to be legible — check every pairing
at 256 colors mentally; the four tint tokens must remain darker than ink.
- Tests for the tier logic: table-driven across widths {58, 70, 80, 100, 120}
asserting which segments render, using the existing ANSI-stripping helpers.
---
## Stages
### Stage 0 — recon + delete the `zeroline` design system *(tag: `tui-lime-s0`)*
Recon first: `grep -rni zeroline --include='*.go' .` and read
`internal/tui/options.go`, `internal/cli/app.go`. Then remove completely:
`internal/zeroline/` (whole package + tests), `internal/tui/zeroline_view.go`
(+ test), `internal/cli/zeroline.go`, the `case "zeroline":` dispatch and the
zeroline help-text line in `internal/cli/app.go`, the skin parameter on
`runInteractiveTUIWithSkin` (rename to `runInteractiveTUI`), the `Skin`,
`ThemeVariant`, `ThemeDark` fields in `tui.Options`, the model's `skin`
field and every `m.skin == "zeroline"` branch (View dispatch, `/theme`
special-case, update paths, `picker.go`, `image_attach.go`). Update or
delete affected tests; the zeroline grep must return zero hits. Gate, tag.
### Stage 1 — Lime palette on the existing layout *(tag: `tui-lime-s1`)*
Rewrite `theme.go` to the token table. Restyle every current render site
(header, transcript rows, titledCard/diffCard, overlays, status line,
prompts) to the new tokens WITHOUT changing structure yet — this isolates
the string-assertion churn in `model_test.go` / `session_test.go` /
`command_polish_test.go` from the layout work. Prefer asserting plain
content via the ANSI-stripping helpers over styled bytes. Gate, tag.
### Stage 2 — chat-surface components *(tag: `tui-lime-s2`)*
Title bar + rule, empty state replacing the splash (splash code deleted),
stream blocks (user/interim/final/done/notes), tool cards with all four
result bodies, MiniDot spinner, composer + status line redesign. Add
rendering tests per block type with synthetic rows. Gate, tag.
### Stage 3 — interactive surfaces *(tag: `tui-lime-s3`)*
Permission card + resolved collapse + auto tag, ask-user and spec-review
cards, autocomplete/picker restyle with model-row metadata, `/resume`
sessions card list, suggestion-chip `1–3` insertion. Key handling and
semantics byte-identical to before — assert that in tests where they
already exist. Gate, tag.
### Stage 4 — adaptive polish + docs *(tag: `tui-lime-s4`)*
Implement + test the width tiers, sweep remaining render sites (spec mode,
command center, doctor output if styled) for stray old-palette styles,
update `internal/cli/app.go` help text and README's TUI section, manual run
`go run ./cmd/zero` at 58/80/100/120 cols (no wrapped rules, no orphan ANSI,
prompts render correctly). Full gate + `go run ./cmd/zero-release smoke`.
Gate, tag.
---
## Hard constraints
- No new module dependencies; charmbracelet stack only. No mouse capture.
- Zero behavior change to permissions, grants, ask-user, spec review,
sessions, slash commands, autocomplete, image attach, compaction, rewind.
- All colors in `theme.go`; renderers consume named styles only.
- Reuse existing helpers (`fitStyledLine`, `truncateRunes`, `argHint`,
`shortenPath`, header candidates) instead of writing parallel ones.
- Comment style matches the repo: explain WHY at non-obvious decisions.
## Definition of done
One design in the tree (no zeroline references, no splash, no cyan tokens),
the Lime chat surface matching this spec at ≥100 cols and degrading per the
width tiers below that, every pre-existing flow keyboard-reachable and
visually consistent, `go test ./...` + release build + smoke fully green.
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -1,304 +0,0 @@
# Agent Eval Benchmark Harness Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build a local, offline-testable benchmark harness that copies eval fixtures, runs an agent command per task, scores the resulting workspace, and reports aggregate results.
**Architecture:** Keep `internal/agenteval.Runner` as the scoring primitive. Add a `Harness` layer that materializes fixture workspaces into a work directory, initializes a clean Git baseline, invokes an injectable `AgentRunner`, then calls `Runner.Run`. The CLI exposes this as `zero eval bench`, while `zero eval run` remains the lower-level scorer for an already-mutated workspace.
**Tech Stack:** Go 1.24 standard library, existing `internal/agenteval`, existing `internal/cli`, Git CLI for workspace baselines.
---
## File Structure
- Create `internal/agenteval/materialize.go`: fixture path resolution, directory copy, Git init/baseline helpers.
- Create `internal/agenteval/materialize_test.go`: fixture copy and Git baseline tests.
- Create `internal/agenteval/agent_command.go`: command-template based agent runner with `{prompt}`, `{workspace}`, `{task_id}` placeholders.
- Create `internal/agenteval/agent_command_test.go`: placeholder substitution, cwd, exit/error behavior.
- Create `internal/agenteval/benchmark.go`: benchmark orchestration and aggregate report.
- Create `internal/agenteval/benchmark_test.go`: end-to-end harness with fake agent runner and sample fixtures.
- Modify `internal/cli/agent_eval.go`: parse `zero eval bench` and wire `Harness`.
- Modify `internal/cli/agent_eval_test.go`: bench parsing, JSON/text output, report writing.
- Modify `docs/AGENT_EVALS.md`: document `bench` mode and real local command examples.
## Task 1: Fixture Materializer
**Files:**
- Create: `internal/agenteval/materialize.go`
- Create: `internal/agenteval/materialize_test.go`
- [ ] **Step 1: Write failing tests**
```go
func TestMaterializeTaskCopiesFixtureAndInitializesGit(t *testing.T) {
suitePath := filepath.Join("testdata", "sample_suite.json")
suite, err := LoadSuite(suitePath)
if err != nil {
t.Fatal(err)
}
task := suite.Tasks[0]
workRoot := t.TempDir()
workspace, err := Materializer{}.MaterializeTask(context.Background(), suitePath, task, MaterializeInput{WorkRoot: workRoot})
if err != nil {
t.Fatalf("MaterializeTask: %v", err)
}
if _, err := os.Stat(filepath.Join(workspace.Path, "go.mod")); err != nil {
t.Fatalf("fixture was not copied: %v", err)
}
if output, err := exec.Command("git", "-C", workspace.Path, "status", "--porcelain").CombinedOutput(); err != nil || strings.TrimSpace(string(output)) != "" {
t.Fatalf("workspace baseline is dirty: err=%v output=%s", err, output)
}
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `go test ./internal/agenteval -run TestMaterializeTask -count=1`
Expected: FAIL because `Materializer` is undefined.
- [ ] **Step 3: Implement minimal materializer**
Create a `Materializer` with:
- `MaterializeTask(ctx, suitePath string, task Task, input MaterializeInput) (Workspace, error)`
- `MaterializeInput{WorkRoot string}`
- `Workspace{Path string, TaskID string, FixturePath string}`
- Resolve relative `task.WorkspaceFixture` from `filepath.Dir(suitePath)`.
- Copy directories recursively using stdlib only.
- Skip `.git` while copying.
- Run `git init`, `git add .`, and `git commit -m "baseline"` with local user config/env.
- Return clear errors for missing fixture, absolute fixture escapes, and empty work root.
- [ ] **Step 4: Verify**
Run:
```bash
go test ./internal/agenteval -run 'TestMaterializeTask|TestMaterializer' -count=1
```
## Task 2: Agent Command Runner
**Files:**
- Create: `internal/agenteval/agent_command.go`
- Create: `internal/agenteval/agent_command_test.go`
- [ ] **Step 1: Write failing tests**
```go
func TestCommandAgentRunnerExpandsPlaceholders(t *testing.T) {
dir := t.TempDir()
script := filepath.Join(dir, "agent.bat")
if err := os.WriteFile(script, []byte("@echo %1|%2|%3> out.txt\r\n"), 0o755); err != nil {
t.Fatal(err)
}
runner := CommandAgentRunner{Command: []string{script, "{task_id}", "{workspace}", "{prompt}"}}
result := runner.Run(context.Background(), AgentRunInput{TaskID: "task-a", WorkspacePath: dir, Prompt: "fix bug"})
if result.ExitCode != 0 || result.Error != "" {
t.Fatalf("Run = %#v", result)
}
data, err := os.ReadFile(filepath.Join(dir, "out.txt"))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(data), "task-a") || !strings.Contains(string(data), dir) || !strings.Contains(string(data), "fix bug") {
t.Fatalf("placeholders not expanded: %q", data)
}
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `go test ./internal/agenteval -run TestCommandAgentRunner -count=1`
Expected: FAIL because `CommandAgentRunner` is undefined.
- [ ] **Step 3: Implement runner**
Add:
- `type AgentRunInput struct { TaskID, Prompt, WorkspacePath string }`
- `type AgentRunResult struct { ExitCode int; Stdout, Stderr, Error string }`
- `type AgentRunner interface { Run(context.Context, AgentRunInput) AgentRunResult }`
- `type CommandAgentRunner struct { Command []string }`
- `Run` executes without shell interpolation, with `cmd.Dir = WorkspacePath`.
- Replace `{prompt}`, `{workspace}`, `{task_id}` in every arg.
- Empty command returns `ExitCode:-1` and `Error:"agent command is required"`.
- [ ] **Step 4: Verify**
Run: `go test ./internal/agenteval -run TestCommandAgentRunner -count=1`
## Task 3: Benchmark Harness
**Files:**
- Create: `internal/agenteval/benchmark.go`
- Create: `internal/agenteval/benchmark_test.go`
- [ ] **Step 1: Write failing tests**
```go
func TestHarnessRunsTaskFromFixtureAndScoresResult(t *testing.T) {
suitePath := filepath.Join("testdata", "sample_suite.json")
suite, err := LoadSuite(suitePath)
if err != nil {
t.Fatal(err)
}
harness := Harness{
Materializer: Materializer{},
Agent: agentRunnerFunc(func(ctx context.Context, input AgentRunInput) AgentRunResult {
target := filepath.Join(input.WorkspacePath, "docs", "STREAM_JSON_PROTOCOL.md")
err := os.WriteFile(target, []byte("updated"), 0o644)
if err != nil {
return AgentRunResult{ExitCode: -1, Error: err.Error()}
}
return AgentRunResult{ExitCode: 0}
}),
Runner: Runner{},
}
report := harness.Run(context.Background(), suitePath, suite, BenchmarkInput{
TaskID: "document-stream-json-verify-events",
WorkRoot: t.TempDir(),
})
if report.Summary.TotalTasks != 1 || report.Summary.PassedTasks != 1 || !report.OK {
t.Fatalf("report = %#v", report)
}
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `go test ./internal/agenteval -run TestHarness -count=1`
Expected: FAIL because `Harness` is undefined.
- [ ] **Step 3: Implement harness**
Add:
- `BenchmarkInput{TaskID, WorkRoot string; KeepWorkspaces bool}`
- `BenchmarkReport{Contract, SuiteID, OK, Summary, Tasks}`
- `BenchmarkTaskReport{TaskID, WorkspacePath, FixturePath, Agent AgentRunResult, Report Report}`
- `BenchmarkSummary{TotalTasks, PassedTasks, FailedTasks, BlockedTasks, ErrorTasks int}`
- `Harness{Materializer Materializer; Agent AgentRunner; Runner Runner}`
- If no `Agent`, produce blocked task reports with message `agent command is required`.
- Select one task when `TaskID` set, otherwise all tasks.
- For each task: materialize, run agent, if agent fails mark blocked; otherwise score with `Runner.Run`.
- Remove each materialized task workspace after scoring unless `BenchmarkInput.KeepWorkspaces` is true.
- [ ] **Step 4: Verify**
Run: `go test ./internal/agenteval -run 'TestHarness|TestBenchmark' -count=1`
## Task 4: CLI `zero eval bench`
**Files:**
- Modify: `internal/cli/agent_eval.go`
- Modify: `internal/cli/agent_eval_test.go`
- [ ] **Step 1: Write failing tests**
```go
func TestRunEvalBenchJSONModePassesHarnessOptions(t *testing.T) {
var stdout, stderr bytes.Buffer
exitCode := runWithDeps([]string{
"eval", "bench",
"--suite", "evals/context.json",
"--task", "edit-reader",
"--work-root", "D:\\tmp\\zero-evals",
"--agent-command", "zero", "exec", "{prompt}",
"--json",
}, &stdout, &stderr, appDeps{
runAgentEval: func(ctx context.Context, options agentEvalOptions) (agentEvalReport, error) {
if options.Mode != "bench" || options.TaskID != "edit-reader" || options.WorkRoot == "" || len(options.AgentCommand) != 3 {
t.Fatalf("unexpected bench options: %#v", options)
}
return agentEvalReport{Suite: "quality-context", Status: "pass", OK: true, Total: 1, Passed: 1}, nil
},
})
if exitCode != exitSuccess {
t.Fatalf("exit=%d stderr=%s", exitCode, stderr.String())
}
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `go test ./internal/cli -run TestRunEvalBench -count=1`
Expected: FAIL because bench mode is not parsed.
- [ ] **Step 3: Implement CLI**
Add parser support:
- Modes: `validate`, `run`, `bench`
- Bench flags: `--work-root <path>`, `--agent-command <argv...>`, `--keep-workspaces`
- `--task`, `--report-dir`, `--json` work in bench mode.
- `--workspace` remains run-only.
- Default work root for bench: temp dir with prefix `zero-eval-`.
- Default `AgentCommand` empty -> harness report blocked rather than usage error.
- Convert benchmark aggregate to existing CLI report shape.
- [ ] **Step 4: Verify**
Run:
```bash
go test ./internal/cli -run 'TestRunEvalBench|TestRunEvalRun' -count=1
```
## Task 5: Docs and Manual Examples
**Files:**
- Modify: `docs/AGENT_EVALS.md`
- [ ] **Step 1: Document modes**
Add a `bench` subsection:
- `validate`: schema only
- `run`: score an already-mutated worktree
- `bench`: copy fixture, run agent command, score result
- [ ] **Step 2: Include examples**
Document:
```bash
go run ./cmd/zero eval bench \
--suite internal/agenteval/testdata/sample_suite.json \
--task document-stream-json-verify-events \
--work-root /tmp/zero-evals \
--agent-command zero exec --cwd {workspace} {prompt}
```
Also document a deterministic fake-agent example for local testing.
- [ ] **Step 3: Verify**
Run:
```bash
go run ./cmd/zero eval --suite internal/agenteval/testdata/sample_suite.json
go test ./...
```
## Task 6: Integration Validation
**Files:**
- Modify as needed only in files above.
- [ ] **Step 1: Run focused validation**
```bash
go test -count=1 ./internal/agenteval ./internal/cli
```
- [ ] **Step 2: Run fixture validation**
```bash
go test ./...
```
from `internal/agenteval/testdata/fixtures/zero-mini`.
- [ ] **Step 3: Run full repo validation**
```bash
gofmt -l internal/agenteval internal/cli
git diff --check
go test ./...
go run ./cmd/zero-release build
go run ./cmd/zero-release smoke
```
@@ -1,181 +0,0 @@
# Agent Eval D-Level Upgrades Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Upgrade the local agent eval harness from a smoke benchmark into a quality signal that can compare models, score traces, catch context mistakes, and preserve regression artifacts.
**Architecture:** Keep the existing `internal/agenteval` harness as the core. Add small schema fields to `Task`, score them through `Score` and `Harness.Run`, then expose model matrix and full benchmark report data through `zero eval bench`. Avoid provider calls or UI work.
**Tech Stack:** Go 1.24, standard library JSON/process/git helpers, existing `go test ./...` validation.
---
### Task 1: Suite Schema And Rubric Checks
**Files:**
- Modify: `internal/agenteval/suite.go`
- Modify: `internal/agenteval/score.go`
- Test: `internal/agenteval/agenteval_test.go`
- [ ] **Step 1: Write failing schema/rubric tests**
Add tests that load and normalize `tags`, `difficulty`, and `forbiddenChangedFiles`, reject malformed forbidden paths, and fail scoring when a forbidden file is touched.
- [ ] **Step 2: Run tests to verify failure**
Run: `go test ./internal/agenteval -run "LoadSuite|Validate|Forbidden" -count=1`
- [ ] **Step 3: Implement minimal schema and scoring**
Add these fields:
```go
Tags []string `json:"tags,omitempty"`
Difficulty string `json:"difficulty,omitempty"`
ForbiddenChangedFiles []string `json:"forbiddenChangedFiles,omitempty"`
```
Normalize file lists, validate duplicate/malformed entries, and add a `forbidden_changed_files` result.
- [ ] **Step 4: Verify green**
Run: `go test ./internal/agenteval -count=1`
### Task 2: Agent Trace Scoring
**Files:**
- Create: `internal/agenteval/trace.go`
- Test: `internal/agenteval/trace_test.go`
- Modify: `internal/agenteval/benchmark.go`
- [ ] **Step 1: Write failing trace tests**
Cover JSONL events such as:
```json
{"type":"tool","name":"read_file"}
{"event":"verify","name":"go-test"}
```
Require deterministic event keys like `tool:read_file` and `verify:go-test`.
- [ ] **Step 2: Run tests to verify failure**
Run: `go test ./internal/agenteval -run Trace -count=1`
- [ ] **Step 3: Implement parser and benchmark scoring**
Parse agent stdout line by line, ignore non-JSON noise, and append a `trace` result when a task declares `requiredTraceEvents`.
- [ ] **Step 4: Verify green**
Run: `go test ./internal/agenteval -count=1`
### Task 3: Context Quality Checks
**Files:**
- Create: `internal/agenteval/context_checks.go`
- Test: `internal/agenteval/context_checks_test.go`
- Modify: `internal/agenteval/benchmark.go`
- [ ] **Step 1: Write failing context tests**
Cover a task that declares:
```json
"contextChecks": {
"requiredFiles": ["docs/STREAM_JSON_PROTOCOL.md"],
"forbiddenFiles": ["node_modules/cache.txt"]
}
```
- [ ] **Step 2: Run tests to verify failure**
Run: `go test ./internal/agenteval -run Context -count=1`
- [ ] **Step 3: Implement fixture/workspace context checks**
Check required files exist under the materialized workspace and forbidden files do not. Append a `context` result before summary finalization.
- [ ] **Step 4: Verify green**
Run: `go test ./internal/agenteval -count=1`
### Task 4: Model Matrix Benchmarking
**Files:**
- Modify: `internal/agenteval/agent_command.go`
- Modify: `internal/agenteval/benchmark.go`
- Test: `internal/agenteval/agent_command_test.go`
- Test: `internal/agenteval/benchmark_test.go`
- [ ] **Step 1: Write failing model-matrix tests**
Assert `{model}` expands in agent command argv and `BenchmarkInput{Models: []string{"a","b"}}` runs every task for each model.
- [ ] **Step 2: Run tests to verify failure**
Run: `go test ./internal/agenteval -run "Model|Harness" -count=1`
- [ ] **Step 3: Implement model propagation**
Add `Model` to `AgentRunInput` and `BenchmarkTaskReport`; add `Models []string` to `BenchmarkInput`; loop task/model pairs deterministically.
- [ ] **Step 4: Verify green**
Run: `go test ./internal/agenteval -count=1`
### Task 5: CLI Flags And Regression Artifacts
**Files:**
- Modify: `internal/cli/agent_eval.go`
- Test: `internal/cli/agent_eval_test.go`
- [ ] **Step 1: Write failing CLI tests**
Cover repeated `--model`, comma-separated `--models`, help text, and report-dir JSON containing nested benchmark detail.
- [ ] **Step 2: Run tests to verify failure**
Run: `go test ./internal/cli -run Eval -count=1`
- [ ] **Step 3: Implement CLI wiring**
Pass models into `agenteval.BenchmarkInput`, include benchmark detail in `agentEvalReport`, and prefix failure IDs with model when present.
- [ ] **Step 4: Verify green**
Run: `go test ./internal/cli -count=1`
### Task 6: Suite Expansion And Docs
**Files:**
- Modify: `internal/agenteval/testdata/sample_suite.json`
- Modify: `docs/AGENT_EVALS.md`
- [ ] **Step 1: Write failing suite expectation**
Update the sample suite test to require richer coverage count and metadata fields.
- [ ] **Step 2: Run tests to verify failure**
Run: `go test ./internal/agenteval -run SampleSuite -count=1`
- [ ] **Step 3: Expand the fixture suite and docs**
Add more tasks using the existing `zero-mini` fixture, plus examples for `--model`, `{model}`, `requiredTraceEvents`, `contextChecks`, and report artifacts.
- [ ] **Step 4: Verify green**
Run: `go test ./internal/agenteval ./internal/cli -count=1`
### Final Verification
- [ ] Run `gofmt -l internal/agenteval internal/cli`
- [ ] Run `git diff --check`
- [ ] Run `go vet ./...`
- [ ] Run `go test ./...`
- [ ] Run `go run ./cmd/zero eval --suite internal/agenteval/testdata/sample_suite.json`
- [ ] Run `go run ./cmd/zero eval bench --suite internal/agenteval/testdata/sample_suite.json --task document-stream-json-verify-events --model test-model --json --agent-command powershell -NoProfile -Command "& { param(`$ws) Set-Content -LiteralPath (Join-Path `$ws 'docs/STREAM_JSON_PROTOCOL.md') -Value updated; Write-Output '{\"type\":\"tool\",\"name\":\"read_file\"}' }" "{workspace}"`
- [ ] Run `go run ./cmd/zero-release build`
- [ ] Commit and push to `feat/agent-eval-harness`
@@ -1,175 +0,0 @@
# Additional write roots (`--add-dir` / `/add-dir`)
**Date:** 2026-06-10
**Status:** Approved
## Problem
Zero's sandbox confines all writes to the workspace root, with no escape hatch.
`EnforceWorkspace` is hardcoded `true` in `DefaultPolicy()`
(`internal/sandbox/types.go`) and `SandboxConfig` exposes only `maxAutonomy`
and `network`. When a user asks the agent to save output outside the workspace
(e.g. "save this to ~/Desktop/dev/untitled folder"), every layer denies it:
1. **Policy engine** — `Engine.Evaluate` → `validateWorkspacePaths`
(`internal/sandbox/paths.go`) denies tool requests whose paths fall outside
the workspace root.
2. **macOS seatbelt** — `sandboxExecProfile` (`internal/sandbox/runner.go`)
emits `(allow file-write* (subpath "<workspaceRoot>"))`, so shell commands
fail with `Operation not permitted`.
3. **Linux bubblewrap** — `bubblewrapCommandPlan` binds only the workspace
read-write; system paths are read-only binds.
4. **File tools** — `resolveWorkspacePath` / `recheckWorkspaceWriteTarget`
(`internal/tools/workspace.go`) confine `write_file`, `edit`, `read_file`,
`list_directory`, `glob`, `grep`, and the bash tool's `cwd`.
The agent's only recourse is workarounds (zip in workspace, ask the user to
move it), and the denial surfaces as a raw OS error with no guidance.
## Goal
Let users grant zero write access to specific directories outside the
workspace — at launch (flag/config) and mid-session (TUI command) — without
weakening the sandbox elsewhere. Decided UX: **launch flag + runtime command**
(prompt-on-denial may layer on later).
## Non-goals
- No interactive prompt-on-denial flow in this iteration.
- No persistence of `/add-dir` grants across sessions (config is the durable
surface).
- No widening of network or destructive-shell policy.
- No `enforceWorkspace: false` config knob.
## Design
### 1. Core type: `sandbox.Scope`
New concurrency-safe type in `internal/sandbox` (e.g. `scope.go`):
```go
type Scope struct {
mu sync.RWMutex
workspaceRoot string // normalized at construction
extraRoots []string // normalized, deduplicated
}
func NewScope(workspaceRoot string, extras []string) (*Scope, error)
func (s *Scope) Roots() []string // workspace first, then extras
func (s *Scope) Add(path string) (string, error) // returns normalized root
func (s *Scope) validate(path string) *pathViolation // package-internal
```
`Add` normalization and guardrails:
- Expand a leading `~` to the user home directory.
- Make absolute, `filepath.Clean`, then `filepath.EvalSymlinks`.
- The path must exist and be a directory (no auto-create).
- Reject the filesystem root `/` (and Windows volume roots) — granting it
would disable confinement entirely.
- Adding the workspace root or a path inside any existing root is a
no-op (idempotent success, not an error).
`Validate` generalizes today's `validateWorkspacePath`: a path is allowed if
it validates under **any** root, including the per-segment symlink-traversal
check against that root. The existing single-root function remains as the
per-root helper.
One `*Scope` is constructed at startup and shared by the engine, the command
runner, and the tool registry, so a mid-session `Add` is immediately visible
to all layers.
### 2. Policy engine
- `EngineOptions` gains `Scope *Scope`; `NewEngine` builds a workspace-only
scope when nil (back-compat for tests/embedders).
- `Evaluate`'s workspace check (engine.go) calls `scope.Validate` per
requested path instead of `validateWorkspacePaths(root, request)`.
- Risk classification (`risk.go`) treats paths inside any scope root as
in-workspace, so writes to granted roots are not classified
`out_of_workspace`/critical.
- Denial reason becomes actionable:
`"<path> is outside the workspace (use /add-dir or --add-dir to allow writes there)"`.
### 3. OS runners
- `sandboxExecProfile` emits one `(subpath "...")` per scope root inside the
single `(allow file-write* ...)` rule.
- `bubblewrapCommandPlan` adds `--bind <root> <root>` (read-write, real path)
for each extra root. The workspace keeps its `/workspace` remount; extra
roots keep their host paths so absolute paths in commands work unchanged.
- `resolveCommandDir` accepts a command `cwd` inside any scope root.
- Profiles are built per command plan from `scope.Roots()`, so commands run
after `/add-dir` get the widened profile with no restart.
### 4. File tools
- New narrow interface in `internal/tools`:
`type PathScope interface { Roots() []string }` (satisfied by
`*sandbox.Scope`) to avoid a hard dependency direction problem.
- `resolveWorkspacePath` and `recheckWorkspaceWriteTarget` gain scope-aware
variants: absolute paths are resolved against the first root that contains
them; **relative paths still resolve against the workspace root only**.
- Tool constructors (`CoreReadOnlyTools`, `CoreWriteTools`, `CoreShellTools`,
`CoreTools` in `registry.go`) accept the scope (workspace-only default kept
via a convenience wrapper so existing call sites/tests keep compiling).
- Result: `write_file`, `edit`, `read_file`, `list_directory`, `glob`, `grep`,
and bash `cwd` all work consistently inside extra roots. Write access
implies read access within a granted root.
### 5. Config, CLI, TUI
- **Config:** `SandboxConfig` gains
`AdditionalWriteRoots []string \`json:"additionalWriteRoots,omitempty"\``.
The key is honored from the **global user config**
(`os.UserConfigDir()/zero/config.json`) so a grant can apply to every
project, and from CLI flags/overrides. It is **deliberately excluded from
the project config allowlist** (`mergeProjectConfig`): a repo-controlled
`.zero/config.json` must not be able to widen write access outside the
workspace. Sources are merged as a union (append + dedupe), not replace.
- **CLI:** repeatable `--add-dir <path>` flag on `zero` (TUI) and `zero exec`.
Effective set = union(flag, config), normalized through `Scope` at startup;
an invalid root fails fast with a clear error.
- **TUI:** new `/add-dir` command (`internal/tui/commands.go`):
- `/add-dir` (bare) — lists current write roots.
- `/add-dir <path>` — calls `scope.Add`; on success appends a system line:
`write access added: <path> (this session only)`; on failure shows the
normalization error.
- **Observability:** `zero sandbox` status output and sandbox snapshots
(`internal/zerocommands/sandbox_snapshots.go`) list extra roots.
### 6. Security invariants
- Extra roots only widen *file-write* (and file-tool read) access under the
named directories; network and destructive-shell policy are untouched.
- Symlink-traversal protection applies per root exactly as it does for the
workspace today.
- `/` and volume roots are rejected; roots are symlink-resolved before being
trusted so a symlinked grant cannot silently point elsewhere later.
- Runtime grants are session-only; durable grants require editing config.
### 7. Testing
- **Scope unit tests:** normalization (`~`, relative, symlink), rejection of
`/`, missing dirs, files; idempotent adds; multi-root `Validate` including
symlink-traversal escapes from one root into another denied area.
- **Runner tests:** seatbelt profile string contains one `subpath` per root;
bubblewrap args contain the extra `--bind`s; `resolveCommandDir` accepts
extra-root cwd (extend `runner_test.go`).
- **Engine tests:** out-of-workspace write allowed when inside an extra root;
still denied (with the new actionable message) when outside all roots.
- **Tool tests:** `write_file`/`edit`/`read_file` in an extra root succeed;
relative paths never resolve against extra roots (extend
`file_tools_test.go`).
- **TUI tests:** `parseCommand` recognizes `/add-dir`; handler list/add/error
paths.
- **Config/CLI tests:** flag-config union, fail-fast on invalid root.
- Platform-gated integration tests follow the existing sandbox test patterns.
## Implementation order
1. `sandbox.Scope` + tests.
2. Engine + risk + runner integration (flag-less, scope seeded empty).
3. File-tool scope plumbing.
4. Config key + CLI flags.
5. TUI `/add-dir` command + status output.
@@ -1,107 +0,0 @@
# Permission-UX Phase 1 — scope-enforced grants
Status: approved (2026-06-11). Branch: `feat/permission-ux` (continues Phase 0, commit `742ff48`).
## Problem
Phase 0 derives a human-readable *scope* for a tool call (the file, directory, or
cwd it touches) and shows it on the permission card and the persisted decision
row. But the grant that an "always allow" writes is still **tool-wide**:
- `persistPermissionGrant` (agent/loop.go) stores only `ToolName`.
- the grant file is keyed by tool name: `state.Grants[toolName]`.
- enforcement (`engine.Decide` → `store.Lookup(toolName, autonomy)`, engine.go:100)
matches on tool name alone.
So clicking "always allow" on a write to `src/main.go` silently authorizes *every*
future write — the blind-yes the Phase-0 card was written to warn against.
## Goal
Persist and enforce the scope so an "always" grant covers exactly what the card
showed: that file, or that directory subtree — nothing else.
## Decisions
1. **Match semantics:** a `file` scope re-matches only its exact path; a `dir`
scope matches that directory and any descendant; an empty scope (a call with no
path-like arg, e.g. `bash` with no `cwd`) stays tool-wide. A tool-wide *request*
is **not** covered by a narrower grant — it re-prompts (fail-safe).
2. **Path anchoring:** scopes are normalized to an absolute, cleaned path at grant
time, anchored to the workspace root. Matching compares absolute paths, so
`./src/main.go` == `src/main.go` == `/proj/src/main.go`, and a grant made in
`/proj-a` never matches `/proj-b`. The card still displays the short relative form.
3. **Storage:** `grants` becomes `map[toolName][]Grant`, one record per
(scope, decision). Schema bumps `1 → 2`; a v1 file is migrated by reading each
entry as a single tool-wide grant, so existing grants keep working unchanged.
4. **Precedence:** among grants that cover a request, a covering **deny wins**
(regardless of autonomy); otherwise the most-specific covering **allow**
(file > dir > tool-wide; ties broken by longer path, then newest) whose
`MaxAutonomy` satisfies the request.
## Design
### Scope derivation — single source of truth (`internal/sandbox/grant_scope.go`)
`DeriveScope(toolName, args) (raw string, kind ScopeKind)` is the one place that
knows which args are path-like, reusing Phase 0's key priority (most specific
first): `path`,`file` → `file`; `directory`,`dir`,`cwd` → `dir`. Empty / `.` →
tool-wide. `resolveScopeAbs(raw, workspaceRoot)` cleans and absolutizes (relative
anchored to the workspace root; falls back to `filepath.Abs` when the root is
empty). `agent.permissionScope` (Phase-0 display) is refactored to call
`DeriveScope`, so the displayed scope and the stored/matched scope can never diverge.
`ScopeKind`: `""` (tool-wide) | `"file"` | `"dir"`.
### Data model (`internal/sandbox/grants.go`)
`Grant` gains `Scope` (absolute path, `omitempty`) and `ScopeKind` (`omitempty`).
`grantFile.Grants` becomes `map[string][]Grant`; `grantSchemaVersion` = 2.
`GrantInput` gains `Scope` (raw) + `ScopeKind`.
### Grant creation
The agent derives `(raw, kind)` via `DeriveScope` and passes them in `GrantInput`.
`engine.Grant` resolves `Scope` to absolute using its configured `workspaceRoot`
(the engine already holds one — engine.go:33) before delegating to `store.Grant`,
which appends to the tool's slice (replacing a same-scope entry). CLI grants
(`zero sandbox grants allow|deny <tool>`) leave the scope empty → tool-wide, as today.
### Matching (`engine.Decide` → `store.Lookup`)
`Decide` derives the request's absolute scope from `request.Args` +
`request.WorkspaceRoot` (already populated at engine.go:63) and passes it to a
scope-aware `Lookup(toolName, reqScopeAbs, autonomy)`. `grantCovers` implements the
match semantics above; `Lookup` applies the deny-wins / most-specific-allow
precedence and the per-grant autonomy check. The surrounding allow/deny/ceiling
flow (engine.go:99-126) is unchanged.
### Migration & a fixed bug
`readState` peeks at `schemaVersion`: v1 (`map[string]Grant`) entries become single
tool-wide grants; v2 reads `map[string][]Grant`; anything else still errors with
"unsupported schemaVersion". Keys are canonicalized (trimmed) on read so a
whitespace-padded tool name matches at lookup — closing audit finding #90
(grants.go:146). `writeState` always emits v2.
### CLI surface
`List()` flattens to `[]Grant` (sorted by tool, then scope). `FormatGrantList`
shows the scope (`*` for tool-wide). `Revoke(toolName)` removes all of a tool's
grants and returns the count. Per-scope revoke is out of scope.
## Testing
- `grant_scope_test` / `grants_test`: `DeriveScope` kinds; `resolveScopeAbs` (relative
anchored, `./` equivalence, absolute passthrough, cross-project non-match);
`grantCovers` matrix (tool-wide/file/dir × equal/descendant/sibling/parent/
empty-request); deny-precedence; per-grant + policy autonomy; v1→v2 migration
round-trip; whitespace-key canonicalization; `FormatGrantList` scope rendering.
- `engine_test`: covered scoped request auto-allows; uncovered sibling re-prompts;
dir deny blocks a subtree request; legacy tool-wide grant still allows.
- `agent`: `persistPermissionGrant` forwards scope+kind; `TestPermissionScope`
stays green (display unchanged).
## Out of scope
Card UI changes (Phase 0), per-scope revoke, glob/pattern scopes.
+1 -1
View File
@@ -13,6 +13,7 @@ require (
github.com/aymanbagabas/go-udiff v0.4.1
github.com/charmbracelet/colorprofile v0.4.3
github.com/charmbracelet/x/ansi v0.11.7
github.com/charmbracelet/x/term v0.2.2
github.com/ledongthuc/pdf v0.0.0-20250511090121-5959a4027728
golang.org/x/sys v0.46.0
mvdan.cc/sh/v3 v3.13.1
@@ -21,7 +22,6 @@ require (
require (
github.com/charmbracelet/ultraviolet v0.0.0-20260615092913-2399af76d5b1 // indirect
github.com/charmbracelet/x/exp/golden v0.0.0-20260615092313-b57e5e6d29bb // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/charmbracelet/x/termios v0.1.1 // indirect
github.com/charmbracelet/x/windows v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.11.0 // indirect
+19
View File
@@ -280,6 +280,25 @@ func isContextLimitError(message string) bool {
return false
}
// isStreamTimeoutError reports whether a streamed error is a provider stream
// idle/stall timeout (providerio surfaces these as "idle timeout after …" /
// "no output for …" / "stream stalled"). Such a turn produced nothing, so when
// no content was forwarded yet it can be safely re-issued on a fresh connection
// (see the stall-retry in the agent loop). Substring + case-insensitive to
// tolerate the prefixed "provider stream error: …" wrapping.
func isStreamTimeoutError(message string) bool {
lowered := strings.ToLower(strings.TrimSpace(message))
if lowered == "" {
return false
}
for _, needle := range []string{"idle timeout after", "no output for", "stream stalled"} {
if strings.Contains(lowered, needle) {
return true
}
}
return false
}
// compactionState carries the per-run state the agent loop needs to compact a
// conversation safely. It is created once per Run and is a no-op whenever
// options.ContextWindow <= 0.
+99
View File
@@ -0,0 +1,99 @@
package agent
import "testing"
// decodeToolArguments must (a) decode a normal single-object payload unchanged,
// (b) tolerate a weak model packing multiple concatenated top-level JSON objects
// into one arguments string by decoding the FIRST object and ignoring the rest
// (the minimax-m3 "invalid character '{' after top-level value" failure), and
// (c) still reject a genuinely malformed/truncated first object.
func TestDecodeToolArguments(t *testing.T) {
t.Run("single object decodes normally", func(t *testing.T) {
var a map[string]any
if err := decodeToolArguments(`{"path":"x"}`, &a); err != nil {
t.Fatalf("err = %v, want nil", err)
}
if a["path"] != "x" {
t.Fatalf("decoded %v, want path=x", a)
}
})
t.Run("concatenated objects decode the first, ignore trailing", func(t *testing.T) {
var b map[string]any
if err := decodeToolArguments(`{"path":"first"}{"path":"second"}`, &b); err != nil {
t.Fatalf("multi-object should decode the first, got err = %v", err)
}
if b["path"] != "first" {
t.Fatalf("decoded %v, want the first object (path=first)", b)
}
})
t.Run("distinct tools' args concatenated (real minimax case): first object used", func(t *testing.T) {
var c map[string]any
if err := decodeToolArguments(`{"plan":[1,2]}{"cmd":"go version"}`, &c); err != nil {
t.Fatalf("err = %v, want nil", err)
}
if _, ok := c["plan"]; !ok || c["cmd"] != nil {
t.Fatalf("decoded %v, want only the first object's keys", c)
}
})
t.Run("truncated first object still errors", func(t *testing.T) {
var d map[string]any
if err := decodeToolArguments(`{"path":`, &d); err == nil {
t.Fatal("truncated JSON must still error")
}
})
t.Run("non-JSON trailing garbage still errors (not masked)", func(t *testing.T) {
var g map[string]any
if err := decodeToolArguments(`{"x":1}xyz`, &g); err == nil {
t.Fatal("a valid first object followed by non-JSON garbage must still error")
}
})
t.Run("recoverableToolArguments: only whole-JSON trailing is tolerated", func(t *testing.T) {
cases := []struct {
in string
ok bool
first string
}{
{`{"a":1}`, true, `{"a":1}`},
{`{"a":1}{"b":2}`, true, `{"a":1}`},
{`{"a":1} {"b":2} `, true, `{"a":1}`},
{`{"a":1}xyz`, false, ""},
{`{"a":`, false, ""},
{` `, false, ""},
}
for _, c := range cases {
first, ok := recoverableToolArguments(c.in)
if ok != c.ok || (ok && first != c.first) {
t.Fatalf("recoverableToolArguments(%q) = (%q,%v), want (%q,%v)", c.in, first, ok, c.first, c.ok)
}
}
})
t.Run("historySafeToolCalls recovers the first object, not {}", func(t *testing.T) {
out := historySafeToolCalls([]ToolCall{
{ID: "c1", Name: "read_file", Arguments: `{"path":"README.md"}{"path":"AGENTS.md"}`}, // concatenated -> first
{ID: "c2", Name: "read_file", Arguments: `{"path":"x"}xyz`}, // garbage -> {}
{ID: "c3", Name: "read_file", Arguments: `{"path":"ok"}`}, // valid -> unchanged
})
if out[0].Arguments != `{"path":"README.md"}` {
t.Fatalf("concatenated args should record the first object, got %q", out[0].Arguments)
}
if out[1].Arguments != "{}" {
t.Fatalf("garbage-trailing args should record {}, got %q", out[1].Arguments)
}
if out[2].Arguments != `{"path":"ok"}` {
t.Fatalf("valid args should be unchanged, got %q", out[2].Arguments)
}
})
t.Run("whitespace-only errors (caller guards empty)", func(t *testing.T) {
var e map[string]any
if err := decodeToolArguments(" ", &e); err == nil {
t.Fatal("whitespace-only must error")
}
})
}
+178 -36
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"sort"
"strings"
"sync"
@@ -20,6 +21,12 @@ import (
const maxTurnsAnswer = "Agent reached maximum number of turns without a final answer."
const maxTurnsFinalAnswerPrompt = "You have reached the tool-turn limit. Do not call tools. Give a concise final answer now: summarize what you completed, what you found, and any remaining blockers."
// maxStreamStallRetries bounds how many times a turn that timed out (idle/stall)
// WITH NO OUTPUT yet is re-issued on a fresh connection before giving up. Only
// the no-output case is retried (a partial turn would duplicate), so this is a
// safe recovery for a stalled/dead pooled connection.
const maxStreamStallRetries = 2
const (
toolResultMetaControl = "control"
toolResultControlSpecReview = "spec_review_required"
@@ -203,55 +210,74 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
}
}
collected := zeroruntime.CollectStreamWithOptions(ctx, stream, zeroruntime.CollectOptions{
OnText: options.OnText,
OnReasoning: options.OnReasoning,
OnUsage: options.OnUsage,
OnToolCallStart: options.OnToolCallStart,
OnToolCallDelta: options.OnToolCallDelta,
})
if collected.Error != "" {
collectedErr := errors.New(collected.Error)
if isImageRejectionError(collectedErr) {
result.Messages = copyMessages(messages)
return result, fmt.Errorf("model %s rejected the image: %s. The model may not support image input — try switching to a vision-capable model (claude, gpt-4o, gemini)", options.Model, collected.Error)
// 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) { forwardedVisibleText = true; options.OnText(s) }
}
if options.OnReasoning != nil {
forwardingOpts.OnReasoning = func(s string) { options.OnReasoning(s) }
}
if options.OnToolCallStart != nil {
forwardingOpts.OnToolCallStart = func(id, name string) { options.OnToolCallStart(id, name) }
}
if options.OnToolCallDelta != nil {
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)
// stream: an image rejection gets the friendly wrapping, and a context limit
// gets one compaction + reactive reissue (omitting visible callbacks, since
// any pre-error output was already forwarded). It returns the possibly-updated
// collected and a non-nil stop error when the run must end now.
recoverStreamError := func(collected zeroruntime.CollectedStream) (zeroruntime.CollectedStream, error) {
if isImageRejectionError(errors.New(collected.Error)) {
return collected, fmt.Errorf("model %s rejected the image: %s. The model may not support image input — try switching to a vision-capable model (claude, gpt-4o, gemini)", options.Model, collected.Error)
}
// REACTIVE compaction: the streamed error may also be a context
// limit (some providers surface it mid-stream). Compact and retry
// the same turn once before giving up.
// REACTIVE compaction: the streamed error may also be a context limit
// (some providers surface it mid-stream). Compact and retry once.
if compacted, retried, retryErr := compactor.recover(ctx, provider, messages, request.Tools, collected.Error); retried {
messages = compacted
if retryErr != nil {
result.Messages = copyMessages(messages)
return result, retryErr
return collected, retryErr
}
// Reuse the SAME active-mode partition (exposed) from this
// turn rather than the bare toolDefinitions: exposed depends on
// registry+loaded (not the messages), so they stay valid after compaction.
// Routing through an empty-loaded partition here would re-hide every
// already-loaded deferred tool.
// Reuse the SAME active-mode partition (exposed) from this turn rather
// than the bare toolDefinitions: exposed depends on registry+loaded (not
// the messages), so it stays valid after compaction.
retryRequest := zeroruntime.CompletionRequest{
Messages: copyMessages(messages),
Tools: exposed,
ReasoningEffort: options.ReasoningEffort,
}
// Pre-content reconnect of a mid-stream disconnect: route the connect
// through the reconnect helper too (AUDIT-L1).
retryStream, retryStreamErr := streamWithReconnect(ctx, provider, retryRequest, reconnectNoticeFor(options))
if retryStreamErr != nil {
result.Messages = copyMessages(messages)
return result, retryStreamErr
return collected, retryStreamErr
}
// Omit OnText on the reactive retry: when the original error
// surfaced MID-stream, partial text was already forwarded to the
// user. Re-streaming the retried response on top of it would
// duplicate output. OnUsage IS kept so token telemetry/budgeting
// still counts the successful retry. The retried text is captured
// in collected.Text and becomes the turn's assistant message.
collected = zeroruntime.CollectStreamWithOptions(ctx, retryStream, zeroruntime.CollectOptions{
OnUsage: options.OnUsage,
})
}
return collected, nil
}
collected := zeroruntime.CollectStreamWithOptions(ctx, stream, forwardingOpts)
if collected.Error != "" {
updated, stop := recoverStreamError(collected)
collected = updated
if stop != nil {
result.Messages = copyMessages(messages)
return result, stop
}
}
// Check ctx first: on cancellation helpers.go sets collected.Error to
// ctx.Err().Error(), so returning errors.New(collected.Error) would lose
@@ -260,9 +286,58 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
result.Messages = copyMessages(messages)
return result, ctx.Err()
}
// 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) && !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
}
retryRequest := zeroruntime.CompletionRequest{
Messages: copyMessages(messages),
Tools: exposed,
ReasoningEffort: options.ReasoningEffort,
}
retryStream, retryErr := streamWithReconnect(ctx, provider, retryRequest, reconnectNoticeFor(options))
if retryErr != nil {
result.Messages = copyMessages(messages)
return result, retryErr
}
collected = zeroruntime.CollectStreamWithOptions(ctx, retryStream, forwardingOpts)
}
if collected.Error != "" {
result.Messages = copyMessages(messages)
return result, errors.New(collected.Error)
// Route a reissued stream's non-stall error through the SAME recovery as
// the initial stream (image-rejection wrapping / context-limit compaction)
// rather than returning it raw.
updated, stop := recoverStreamError(collected)
collected = updated
if stop != nil {
result.Messages = copyMessages(messages)
return result, stop
}
if collected.Error != "" {
result.Messages = copyMessages(messages)
return result, errors.New(collected.Error)
}
}
// Carry the turn's terminal stop reason so a final answer cut off at the
@@ -615,8 +690,20 @@ func historySafeToolCalls(calls []ToolCall) []ToolCall {
for i, call := range calls {
safe[i] = call
args := strings.TrimSpace(call.Arguments)
if args == "" || !json.Valid([]byte(args)) {
switch {
case args == "":
safe[i].Arguments = "{}"
case json.Valid([]byte(args)):
// already a single valid JSON value — keep as-is
default:
// Recover the first object for the concatenated case so the replayed
// transcript matches what executeToolCall actually ran; otherwise (real
// corruption) fall back to an empty object.
if first, ok := recoverableToolArguments(args); ok {
safe[i].Arguments = first
} else {
safe[i].Arguments = "{}"
}
}
}
return safe
@@ -641,10 +728,53 @@ func toolSchemaJSON(registry *tools.Registry, name string) string {
// is a non-nil ABORT error only when the call demands the whole run stop (a
// canceled/timed-out ask_user prompt) rather than continuing — every ordinary
// success or tool error returns a nil abort error.
// recoverableToolArguments inspects a tool call's raw JSON arguments. It returns
// the raw text of the FIRST JSON value when the payload is one value optionally
// followed by additional WHOLE JSON values (the weak-model concatenated case, e.g.
// `{A}{B}`). ok is false when the payload is genuinely malformed: a bad/truncated
// first value, OR trailing NON-JSON garbage after a valid first value (e.g.
// `{"x":1}xyz`) — that case must still error, not be silently accepted. Sharing
// this between dispatch and replay-history keeps them consistent: only a cleanly
// recoverable first object is ever used.
func recoverableToolArguments(arguments string) (first string, ok bool) {
dec := json.NewDecoder(strings.NewReader(arguments))
var head json.RawMessage
if err := dec.Decode(&head); err != nil {
return "", false
}
// The remainder must be only whole JSON values (or nothing); any decode error
// other than EOF means trailing garbage — reject so corruption still surfaces.
for {
var rest json.RawMessage
if err := dec.Decode(&rest); err != nil {
if errors.Is(err, io.EOF) {
return strings.TrimSpace(string(head)), true
}
return "", false
}
}
}
// decodeToolArguments decodes a tool call's JSON arguments into v. It tolerates a
// weaker model that packs MULTIPLE concatenated top-level JSON objects into one
// arguments string (`{A}{B}`) — the cause of "invalid character '{' after top-level
// value" failures with small models like minimax-m3 — by decoding the FIRST object
// and ignoring the trailing WHOLE JSON values, so the primary intended call runs.
// A genuinely malformed payload (bad/truncated first object, or non-JSON trailing
// garbage) still returns the real parse error; a standard single-object payload
// decodes exactly as before.
func decodeToolArguments(arguments string, v any) error {
if first, ok := recoverableToolArguments(arguments); ok {
return json.Unmarshal([]byte(first), v)
}
// Not cleanly recoverable — surface the genuine parse error.
return json.Unmarshal([]byte(arguments), v)
}
func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCall, permissionMode PermissionMode, options Options) (ToolResult, error) {
args := map[string]any{}
if call.Arguments != "" {
if err := json.Unmarshal([]byte(call.Arguments), &args); err != nil {
if err := decodeToolArguments(call.Arguments, &args); err != nil {
return ToolResult{
ToolCallID: call.ID,
Name: call.Name,
@@ -2029,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
}
+127 -2
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()
@@ -1053,7 +1128,10 @@ func TestRunSanitizesMalformedToolCallArgumentsBeforeRetry(t *testing.T) {
turns: [][]zeroruntime.StreamEvent{
{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "read_file"},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"path":"README.md"}{"path":"AGENTS.md"}`},
// Genuinely malformed (truncated) args: still error -> sanitize -> retry.
// (Concatenated multi-object args are now tolerated; see
// TestRunRecoversFirstObjectFromConcatenatedToolArgs.)
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"path":"README.md"`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
{Type: zeroruntime.StreamEventDone},
},
@@ -1103,6 +1181,53 @@ func TestRunSanitizesMalformedToolCallArgumentsBeforeRetry(t *testing.T) {
}
}
// A weak model (e.g. minimax-m3) that packs MULTIPLE concatenated JSON objects into
// one tool-call slot must RECOVER the first object and run the call, not fail with
// "invalid character '{' after top-level value". The trailing object(s) are dropped
// (the model re-issues them on a later turn if still needed).
func TestRunRecoversFirstObjectFromConcatenatedToolArgs(t *testing.T) {
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "README.md"), []byte("hello readme"), 0o644); err != nil {
t.Fatal(err)
}
registry := tools.NewRegistry()
registry.Register(tools.NewReadFileTool(root))
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "read_file"},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"path":"README.md"}{"path":"AGENTS.md"}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
{Type: zeroruntime.StreamEventDone},
},
{
{Type: zeroruntime.StreamEventText, Content: "done"},
{Type: zeroruntime.StreamEventDone},
},
},
}
result, err := Run(context.Background(), "read", provider, Options{Registry: registry, MaxTurns: 2})
if err != nil {
t.Fatal(err)
}
if result.FinalAnswer != "done" {
t.Fatalf("final answer = %q, want done", result.FinalAnswer)
}
var toolResult string
for _, message := range provider.requests[1].Messages {
if message.Role == zeroruntime.MessageRoleTool {
toolResult = message.Content
}
}
if strings.Contains(toolResult, "Failed to parse arguments") {
t.Fatalf("concatenated args should recover the first object, not fail to parse: %q", toolResult)
}
if !strings.Contains(toolResult, "hello readme") {
t.Fatalf("expected README.md contents (the first object's path), got %q", toolResult)
}
}
func TestRunDefersSelfCorrectFeedbackUntilAfterToolBatch(t *testing.T) {
// A single assistant turn with two tool calls where the first mutates and
// self-correct fails must keep both tool_results contiguous, with the feedback
@@ -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
+168
View File
@@ -0,0 +1,168 @@
package agent
import (
"context"
"sync/atomic"
"testing"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// stallProvider connects successfully (HTTP 200) but the stream emits a stall/idle
// 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
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, 5)
if n <= p.stallBefore {
if p.partialText != "" {
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventText, Content: p.partialText}
}
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 6m (the model produced nothing)",
}
close(ch)
return ch, nil
}
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventText, Content: "done"}
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone}
close(ch)
return ch, nil
}
// A no-output stall is re-issued on a fresh connection and recovers — this is the
// macOS stale-pooled-connection hang turned into an automatic recovery.
func TestRunRetriesStreamStallWithNoOutput(t *testing.T) {
p := &stallProvider{stallBefore: 1}
result, err := Run(context.Background(), "go", p, Options{Registry: tools.NewRegistry()})
if err != nil {
t.Fatalf("a no-output stall 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)
}
}
// A stall AFTER partial output must NOT be retried — re-issuing would duplicate
// the already-streamed text. It surfaces the error instead.
func TestRunDoesNotRetryStallAfterPartialOutput(t *testing.T) {
p := &stallProvider{stallBefore: 1, partialText: "partial"}
_, err := Run(context.Background(), "go", p, Options{Registry: tools.NewRegistry()})
if err == nil {
t.Fatal("a stall after partial output must NOT be retried; want an error")
}
if got := atomic.LoadInt32(&p.calls); got != 1 {
t.Fatalf("partial-then-stall must not retry, got %d calls", got)
}
}
// 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"}
result, err := Run(context.Background(), "go", p, Options{
Registry: tools.NewRegistry(),
OnReasoning: func(string) {},
})
if err != nil {
t.Fatalf("a stall after only reasoning 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)
}
}
// 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)
}
}
// A persistent stall surfaces an error after exhausting the capped retries.
func TestRunGivesUpAfterMaxStallRetries(t *testing.T) {
p := &stallProvider{stallBefore: 99}
_, err := Run(context.Background(), "go", p, Options{Registry: tools.NewRegistry()})
if err == nil {
t.Fatal("a persistent stall must surface an error after exhausting retries")
}
if got := atomic.LoadInt32(&p.calls); got != int32(1+maxStreamStallRetries) {
t.Fatalf("want %d calls (1 + %d retries), got %d", 1+maxStreamStallRetries, maxStreamStallRetries, got)
}
}
func TestIsStreamTimeoutError(t *testing.T) {
timeouts := []string{
"provider stream error: no output for 10m (the model produced nothing)",
"provider stream error: idle timeout after 5m0s (upstream stopped sending data)",
"stream stalled (upstream kept the connection alive but produced no output)",
}
for _, m := range timeouts {
if !isStreamTimeoutError(m) {
t.Fatalf("want timeout-classified: %q", m)
}
}
notTimeouts := []string{"", "context length exceeded", "rate limit error: slow down", "model not found"}
for _, m := range notTimeouts {
if isStreamTimeoutError(m) {
t.Fatalf("must NOT classify as timeout: %q", m)
}
}
}
+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) {
+136 -56
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{})
},
@@ -217,9 +231,9 @@ func runWithDeps(args []string, stdout io.Writer, stderr io.Writer, deps appDeps
if err != nil {
return writeAppError(stderr, err.Error(), 1)
}
// --theme {auto|dark|light} selects the TUI palette non-interactively (populates
// tui.Options.Theme, which resolveThemeMode prefers over ZERO_THEME). Re-split
// --add-dir afterward so it may appear on either side of --theme.
// --theme <name> selects the TUI palette non-interactively (auto or any registered
// theme; populates tui.Options.Theme, which resolveThemeMode prefers over
// ZERO_THEME). Re-split --add-dir afterward so it may appear on either side of --theme.
theme, args, err := splitLeadingThemeFlag(args)
if err != nil {
return writeAppError(stderr, err.Error(), 1)
@@ -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 {
@@ -641,6 +691,7 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
return deps.runTUI(context.Background(), tui.Options{
Cwd: workspaceRoot,
Theme: theme,
SavedTheme: resolved.Preferences.Theme,
UserConfigPath: userConfigPath,
DoctorUserConfigPath: doctorUserConfigPath,
ProjectConfigPath: projectConfigPath,
@@ -719,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 {
@@ -1033,9 +1114,10 @@ func splitLeadingAddDirFlags(args []string) ([]string, []string, error) {
return addDirs, args, nil
}
// splitLeadingThemeFlag strips a leading --theme {auto|dark|light} (space or =form)
// from the root argument list and validates it. The last occurrence wins. A value
// outside the allowed set is a loud error rather than a silent fallback.
// splitLeadingThemeFlag strips a leading --theme <auto|theme-name> (space or =form)
// from the root argument list and validates it against the registered themes. The
// last occurrence wins. A value outside the allowed set is a loud error rather than
// a silent fallback.
func splitLeadingThemeFlag(args []string) (string, []string, error) {
theme := ""
for len(args) > 0 {
@@ -1043,7 +1125,7 @@ func splitLeadingThemeFlag(args []string) (string, []string, error) {
switch {
case args[0] == "--theme":
if len(args) < 2 {
return "", nil, errors.New("--theme requires a value (auto, dark, or light)")
return "", nil, errors.New("--theme requires a value (auto or a theme name; try --theme auto)")
}
value = strings.TrimSpace(args[1])
args = args[2:]
@@ -1053,12 +1135,10 @@ func splitLeadingThemeFlag(args []string) (string, []string, error) {
default:
return theme, args, nil
}
switch strings.ToLower(value) {
case "auto", "dark", "light":
theme = strings.ToLower(value)
default:
return "", nil, fmt.Errorf("--theme must be auto, dark, or light (got %q)", value)
if !tui.ValidThemeArg(value) {
return "", nil, fmt.Errorf("--theme must be auto or a registered theme name (got %q)", value)
}
theme = strings.ToLower(value)
}
return theme, args, nil
}
+148 -1
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
@@ -1397,7 +1472,79 @@ func TestRunThemeFlagRejectsBadValue(t *testing.T) {
if launched {
t.Fatal("the TUI must not launch on a bad --theme value")
}
if !strings.Contains(stderr.String(), "--theme must be auto, dark, or light") {
if !strings.Contains(stderr.String(), "--theme must be auto or a registered theme name") {
t.Fatalf("expected a clear --theme error, got %q", stderr.String())
}
}
// A custom endpoint saved without a model previously bricked bare `zero` and
// `zero setup` — the exact commands that could have fixed it (the resolve
// error escaped before the wizard could open). The interactive TUI now treats
// the requires-model failure as "needs onboarding", same as a missing active
// provider. The error comes from a REAL Resolve over a real config file, so
// this exercises the production sentinel wrapping, not a hand-built error.
func TestRunNoArgsEntersSetupWhenActiveProviderMissesModel(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
cwd := t.TempDir()
setCLIUserConfigRoot(t)
brokenConfig := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(brokenConfig, []byte(`{
"activeProvider": "gw",
"providers": [{"name": "gw", "provider_kind": "openai-compatible", "baseURL": "https://gw.example/v1", "apiKey": "sk-x"}]
}`), 0o600); err != nil {
t.Fatal(err)
}
launched := false
var launchedOptions tui.Options
exitCode := runWithDeps([]string{}, &stdout, &stderr, appDeps{
getwd: func() (string, error) { return cwd, nil },
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
return config.Resolve(config.ResolveOptions{UserConfigPath: brokenConfig, Env: map[string]string{}})
},
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
t.Fatal("newProvider must not be called without a resolved provider")
return nil, nil
},
userConfigPath: func() (string, error) { return filepath.Join(t.TempDir(), "zero", "config.json"), nil },
registerMCPTools: func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) {
return noopMCPRuntime{}, nil
},
runTUI: func(_ context.Context, options tui.Options) int {
launched = true
launchedOptions = options
return 0
},
})
if exitCode != 0 {
t.Fatalf("exit code = %d, want 0 (setup TUI launched), stderr=%q", exitCode, stderr.String())
}
if !launched {
t.Fatal("TUI was not launched; a fixable requires-model config must fall into setup, not exit fatally")
}
if !launchedOptions.Setup.Visible || !launchedOptions.Setup.Required {
t.Fatalf("Setup = %#v, want visible required setup", launchedOptions.Setup)
}
}
// `zero login`/`zero logout` don't exist (it's `zero auth login`); first-run
// users try them (reported in the wild), so the unknown-command error points at
// the real command.
func TestRunUnknownLoginSuggestsAuthLogin(t *testing.T) {
var stdout, stderr bytes.Buffer
exitCode := runWithDeps([]string{"login"}, &stdout, &stderr, appDeps{})
if exitCode != 2 {
t.Fatalf("exit = %d, want 2 (unknown command)", exitCode)
}
if !strings.Contains(stderr.String(), `"zero auth login"`) {
t.Fatalf("stderr = %q, want a zero auth login suggestion", stderr.String())
}
// An unrelated unknown command gets no misleading suggestion.
stderr.Reset()
runWithDeps([]string{"frobnicate"}, &stdout, &stderr, appDeps{})
if strings.Contains(stderr.String(), "did you mean") {
t.Fatalf("stderr = %q, unrelated commands must not get the auth hint", stderr.String())
}
}
+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)
}
}
}
+17 -2
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))
}
@@ -277,6 +280,9 @@ func listProviderCatalogSummaries(options commandCenterOptions) ([]providerCatal
return nil, err
}
sort.SliceStable(summaries, func(i int, j int) bool {
if summaries[i].Recommended != summaries[j].Recommended {
return summaries[i].Recommended
}
return summaries[i].ID < summaries[j].ID
})
return summaries, nil
@@ -354,10 +360,17 @@ func formatProviderCatalogSummaries(providers []providerCatalogSummary) string {
}
func formatProviderCatalogLine(provider providerCatalogSummary) string {
name := formatProviderCatalogValue(provider.Name, "unknown")
prefix := " id="
if provider.Recommended {
prefix = " ★ id="
name += " (recommended)"
}
lines := []string{
fmt.Sprintf(" id=%s name=%s",
fmt.Sprintf("%s%s name=%s",
prefix,
formatProviderCatalogValue(provider.ID, "unknown"),
formatProviderCatalogValue(provider.Name, "unknown"),
name,
),
fmt.Sprintf(" transport=%s defaultModel=%s",
formatProviderCatalogValue(provider.Transport, "unknown"),
@@ -455,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
+65 -15
View File
@@ -16,6 +16,8 @@ import (
"github.com/Gitlawb/zero/internal/lsp"
"github.com/Gitlawb/zero/internal/modelregistry"
"github.com/Gitlawb/zero/internal/notify"
"github.com/Gitlawb/zero/internal/providercatalog"
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
"github.com/Gitlawb/zero/internal/providers"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/sessions"
@@ -363,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)
@@ -500,7 +505,7 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
defer lspShutdown()
result, err := agent.Run(runCtx, agentPrompt, provider, agent.Options{
MaxTurns: resolved.MaxTurns,
ContextWindow: modelContextWindow(modelRegistry, resolved.Provider.Model),
ContextWindow: resolveAgentContextWindow(runCtx, modelRegistry, resolved.Provider),
DeferThreshold: effectiveDeferThreshold,
Specialists: specialistRuntime.specialistInfos(),
SessionID: preparedSession.Session.SessionID,
@@ -998,30 +1003,71 @@ func resolveSelectedModel(registry modelregistry.Registry, input string) (string
return entry.ID, notice
}
// modelContextWindow returns the resolved model's context window (max input
// tokens) from the model registry, used to enable agent-loop compaction. An
// unknown model (e.g. a custom openai-compatible name) returns 0, which leaves
// compaction DISABLED — a safe default that never compacts unexpectedly.
// modelContextWindow returns the resolved model's exact context window (max input
// tokens) from the registry, or 0 when the model isn't catalogued. Compaction call
// sites wrap this in modelregistry.AgentContextWindow to apply a positive fallback;
// display/report sites use the raw value so an unknown model shows no denominator.
func modelContextWindow(registry modelregistry.Registry, modelID string) int {
trimmed := strings.TrimSpace(modelID)
if trimmed == "" {
return 0
}
entry, ok := registry.Resolve(trimmed)
if entry, ok := registry.Resolve(trimmed); ok {
return entry.ContextLimits.ContextWindow
}
return 0
}
// resolveAgentContextWindow returns the context window used to enable/size agent
// compaction: the exact registry value when catalogued, else a window learned from
// live provider discovery (so an uncatalogued proxy/custom model gets its real
// window instead of the generic fallback), else the positive FallbackContextWindow.
// Discovery runs only on a registry miss (catalogued models pay no latency), is
// bounded by a short timeout, and degrades to the fallback on any error — so a
// headless run is never blocked or failed by it.
func resolveAgentContextWindow(ctx context.Context, registry modelregistry.Registry, profile config.ProviderProfile) int {
if window := modelContextWindow(registry, profile.Model); window > 0 {
return window
}
if window := discoveredModelContextWindow(ctx, profile); window > 0 {
return window
}
return modelregistry.AgentContextWindow(0)
}
// discoveredModelContextWindow queries the provider's live model list for the
// active model's context window. Returns 0 when the provider isn't catalogued, the
// credential can't be resolved, discovery fails, or the model reports no window.
func discoveredModelContextWindow(ctx context.Context, profile config.ProviderProfile) int {
descriptor, ok := providercatalog.Get(strings.TrimSpace(profile.CatalogID))
if !ok {
return 0
}
return entry.ContextLimits.ContextWindow
// Authenticate discovery with the resolved key (inline, then stored, then env).
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))
}
dctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
models, err := providermodeldiscovery.DiscoverCatalog(dctx, descriptor, authed, providermodeldiscovery.Options{})
if err != nil {
return 0
}
target := strings.TrimSpace(profile.Model)
for _, model := range models {
if strings.EqualFold(strings.TrimSpace(model.ID), target) && model.ContextWindow > 0 {
return model.ContextWindow
}
}
return 0
}
// reasoningEffortNotice resolves the requested --reasoning-effort against the
// selected model's supported efforts via EffectiveReasoningEffort and returns a
// short advisory when the requested value is unsupported (and was coerced to the
// model default).
//
// NOTE: the effective effort is not yet forwarded to the provider request — the
// zeroruntime.CompletionRequest / provider wire schemas carry no effort field.
// Full provider-request propagation is deferred (see slice-3 report).
// forwardedReasoningEffort returns the effort to send on the provider request.
// It mirrors reasoningEffortNotice: a known model that does not support reasoning
// yields "" (matching the "ignoring" advisory, so the request never carries an
@@ -1044,6 +1090,10 @@ func forwardedReasoningEffort(registry modelregistry.Registry, modelID string, r
return string(effective)
}
// reasoningEffortNotice resolves the requested --reasoning-effort against the
// selected model's supported efforts via EffectiveReasoningEffort and returns a
// short advisory when the requested value is unsupported (and was coerced to the
// model default).
func reasoningEffortNotice(registry modelregistry.Registry, modelID string, requested string) string {
trimmed := strings.TrimSpace(modelID)
if trimmed == "" {
+1 -1
View File
@@ -98,7 +98,7 @@ func runExecSpecDraft(run execSpecDraftRun) int {
defer stopSignals()
result, err := agent.Run(runCtx, run.prompt, run.provider, agent.Options{
MaxTurns: run.resolved.MaxTurns,
ContextWindow: modelContextWindow(run.modelRegistry, run.resolved.Provider.Model),
ContextWindow: resolveAgentContextWindow(runCtx, run.modelRegistry, run.resolved.Provider),
SessionID: draftSession.SessionID,
SessionTitle: run.sessionTitle,
ProviderName: run.resolved.Provider.Name,
+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
}
+2 -1
View File
@@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"io"
"runtime"
"strconv"
"strings"
@@ -65,7 +66,7 @@ func runDoctor(args []string, stdout io.Writer, stderr io.Writer, deps appDeps)
report := doctor.Run(doctor.Options{
Now: deps.now,
Runtime: "go",
Runtime: runtime.Version(),
UserConfig: userConfig,
ProjectConfig: projectConfig,
Provider: provider,
+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 {
+8 -3
View File
@@ -285,6 +285,7 @@ func setupProviderOptions() []tui.SetupProviderOption {
EnvVar: setupProviderEnvVar(descriptor),
RequiresAuth: descriptor.RequiresAuth,
Local: descriptor.Local,
Recommended: descriptor.Recommended,
})
}
return options
@@ -313,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)
}
@@ -0,0 +1,49 @@
package config
import (
"os"
"testing"
)
// SetActiveProviderEnv must export the provider name so a child process (whose
// resolution reads the inherited environment via the nil-map -> os.Getenv path in
// applyEnv) resolves the SAME active provider. A blank name CLEARS it.
func TestSetActiveProviderEnvRoundTrips(t *testing.T) {
t.Setenv(ActiveProviderEnv, "") // register cleanup; restores the original after
if err := os.Unsetenv(ActiveProviderEnv); err != nil {
t.Fatalf("unset: %v", err)
}
SetActiveProviderEnv("") // blank with nothing set stays unset
if got := os.Getenv(ActiveProviderEnv); got != "" {
t.Fatalf("empty name set the env to %q, want unset", got)
}
// Regression: switching from a named provider back to an unnamed/default profile
// must CLEAR a previously exported value, not leave it stale for children.
if err := os.Setenv(ActiveProviderEnv, "stale-provider"); err != nil {
t.Fatalf("seed stale: %v", err)
}
SetActiveProviderEnv("") // blank must unset the stale value
if got := os.Getenv(ActiveProviderEnv); got != "" {
t.Fatalf("blank name left stale env %q, want cleared", got)
}
cfgStale := &FileConfig{}
applyEnv(cfgStale, nil)
if cfgStale.ActiveProvider != "" {
t.Fatalf("applyEnv still resolved stale provider %q after clear", cfgStale.ActiveProvider)
}
SetActiveProviderEnv("ollama-cloud")
if got := os.Getenv(ActiveProviderEnv); got != "ollama-cloud" {
t.Fatalf("env = %q, want ollama-cloud", got)
}
// applyEnv with a nil map is the exact path a child uses: it falls back to
// os.Getenv, so the inherited value selects the active provider.
cfg := &FileConfig{}
applyEnv(cfg, nil)
if cfg.ActiveProvider != "ollama-cloud" {
t.Fatalf("applyEnv ActiveProvider = %q, want ollama-cloud (inherited env not read)", cfg.ActiveProvider)
}
}
+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)
}
})
}
}
+56
View File
@@ -0,0 +1,56 @@
package config
import (
"os"
"testing"
)
// SetMaxTurnsEnv must export the per-run turn budget so a child process (whose
// resolution reads the inherited environment via the nil-map -> os.Getenv path in
// applyEnv) runs with the SAME budget the user set via /turns. n <= 0 is a no-op,
// and a non-numeric/zero env value must not clobber the configured budget.
func TestSetMaxTurnsEnvRoundTrips(t *testing.T) {
t.Setenv(MaxTurnsEnv, "") // register cleanup; restores the original after
if err := os.Unsetenv(MaxTurnsEnv); err != nil {
t.Fatalf("unset: %v", err)
}
SetMaxTurnsEnv(0) // non-positive must not set anything
if got := os.Getenv(MaxTurnsEnv); got != "" {
t.Fatalf("n<=0 set the env to %q, want unset", got)
}
SetMaxTurnsEnv(150)
if got := os.Getenv(MaxTurnsEnv); got != "150" {
t.Fatalf("env = %q, want 150", got)
}
// applyEnv with a nil map is the exact path a child uses: it falls back to
// os.Getenv, so the inherited value sets the budget.
cfg := &FileConfig{MaxTurns: defaultMaxTurns}
applyEnv(cfg, nil)
if cfg.MaxTurns != 150 {
t.Fatalf("applyEnv MaxTurns = %d, want 150 (inherited env not read)", cfg.MaxTurns)
}
// A garbage env value must leave the configured budget untouched.
if err := os.Setenv(MaxTurnsEnv, "not-a-number"); err != nil {
t.Fatalf("set: %v", err)
}
cfg2 := &FileConfig{MaxTurns: defaultMaxTurns}
applyEnv(cfg2, nil)
if cfg2.MaxTurns != defaultMaxTurns {
t.Fatalf("garbage env clobbered MaxTurns to %d, want default %d", cfg2.MaxTurns, defaultMaxTurns)
}
// An over-ceiling env value (e.g. a raw shell export that bypasses the /turns
// clamp) must be clamped to MaxTurnsCeiling at the read site.
if err := os.Setenv(MaxTurnsEnv, "999999"); err != nil {
t.Fatalf("set: %v", err)
}
cfg3 := &FileConfig{MaxTurns: defaultMaxTurns}
applyEnv(cfg3, nil)
if cfg3.MaxTurns != MaxTurnsCeiling {
t.Fatalf("applyEnv MaxTurns = %d, want clamped to ceiling %d", cfg3.MaxTurns, MaxTurnsCeiling)
}
}
+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)
}
}
+124 -9
View File
@@ -2,9 +2,11 @@ package config
import (
"encoding/json"
"errors"
"fmt"
"net/url"
"os"
"strconv"
"strings"
"github.com/Gitlawb/zero/internal/modelregistry"
@@ -13,7 +15,40 @@ import (
"github.com/Gitlawb/zero/internal/sandbox"
)
const defaultMaxTurns = 30
// 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.
const defaultMaxTurns = 50
// MaxTurnsCeiling caps the per-run tool-turn budget so a stray env value or typo
// can't set an absurd ceiling. Shared between applyEnv (read site) and the /turns
// command (write site) so the bound holds even if the env is set by a raw shell.
const MaxTurnsCeiling = 500
// defaultDeferThreshold is the number of deferred-eligible (MCP) tools at which
// Zero collapses their full JSON schemas into compact `tool_search` reminder
@@ -186,6 +221,9 @@ func mergeConfig(dst *FileConfig, src FileConfig) {
if src.Preferences.Recaps != nil {
dst.Preferences.Recaps = src.Preferences.Recaps
}
if strings.TrimSpace(src.Preferences.Theme) != "" {
dst.Preferences.Theme = strings.TrimSpace(src.Preferences.Theme)
}
mergeLocalControlConfig(&dst.LocalControl, src.LocalControl)
}
@@ -208,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
@@ -358,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 == "" {
@@ -431,11 +490,53 @@ func mergeProfile(base ProviderProfile, next ProviderProfile) ProviderProfile {
return base
}
// ActiveProviderEnv selects the active provider profile by name (read in applyEnv).
const ActiveProviderEnv = "ZERO_PROVIDER"
// SetActiveProviderEnv exports the active provider name to the process environment
// so a spawned child process (which inherits the environment) resolves the SAME
// provider profile — and therefore the same credentials (env key / stored key /
// OAuth) — as its parent. Without this a sub-agent re-resolves config.json's
// default provider and can land on one whose credentials don't match the parent's
// live selection, failing auth the instant it spawns. A blank name CLEARS the
// variable: switching back to an unnamed/default profile must not keep exporting a
// stale provider to children.
func SetActiveProviderEnv(name string) {
name = strings.TrimSpace(name)
if name == "" {
_ = os.Unsetenv(ActiveProviderEnv)
return
}
_ = os.Setenv(ActiveProviderEnv, name)
}
// MaxTurnsEnv overrides the per-run tool-turn budget by name (read in applyEnv).
const MaxTurnsEnv = "ZERO_MAX_TURNS"
// SetMaxTurnsEnv exports the per-run tool-turn budget to the process environment so
// a spawned child (sub-agent / swarm member, which inherits the environment) runs
// with the SAME budget the user set via /turns. Without it a child re-resolves
// config.json's default and a large delegated task can exhaust its turns mid-run
// (exit 4 / max-turns). No-op for n <= 0.
func SetMaxTurnsEnv(n int) {
if n > 0 {
_ = os.Setenv(MaxTurnsEnv, strconv.Itoa(n))
}
}
func applyEnv(cfg *FileConfig, env map[string]string) {
activeProvider := strings.TrimSpace(envValue(env, "ZERO_PROVIDER"))
activeProvider := strings.TrimSpace(envValue(env, ActiveProviderEnv))
if activeProvider != "" {
cfg.ActiveProvider = activeProvider
}
if maxTurns := strings.TrimSpace(envValue(env, MaxTurnsEnv)); maxTurns != "" {
if n, err := strconv.Atoi(maxTurns); err == nil && n > 0 {
if n > MaxTurnsCeiling {
n = MaxTurnsCeiling
}
cfg.MaxTurns = n
}
}
applyProviderEnv(cfg, ProviderKindOpenAI, envProfile{
Name: string(ProviderKindOpenAI),
@@ -677,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) {
@@ -696,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
}
@@ -728,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
@@ -778,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
@@ -795,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)
@@ -809,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)
+139 -2
View File
@@ -1,6 +1,7 @@
package config
import (
"errors"
"os"
"path/filepath"
"reflect"
@@ -883,8 +884,8 @@ func TestResolveAllowsNoConfiguredProviders(t *testing.T) {
if HasProviderProfile(resolved.Provider) {
t.Fatalf("Provider = %#v, want zero value", resolved.Provider)
}
if resolved.MaxTurns != 30 {
t.Fatalf("MaxTurns = %d, want 30", resolved.MaxTurns)
if resolved.MaxTurns != defaultMaxTurns {
t.Fatalf("MaxTurns = %d, want default %d", resolved.MaxTurns, defaultMaxTurns)
}
}
@@ -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)
}
@@ -1269,6 +1273,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 +1594,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())
}
}
+4
View File
@@ -97,6 +97,10 @@ type ToolsConfig struct {
type PreferencesConfig struct {
FavoriteModels []string `json:"favoriteModels,omitempty"`
// Theme is the persisted TUI palette preference — "auto" or a registered theme
// name (e.g. "dracula"). Applied at startup below the --theme flag and
// ZERO_THEME, so a /theme choice survives restart. Empty = unset (defaults auto).
Theme string `json:"theme,omitempty"`
// Recaps is a tri-state: nil (unset) defaults to ON; an explicit false means
// the user turned post-turn recaps off. A *bool is its own tri-state, so no
// custom unmarshal is needed (unlike ToolsConfig.DeferThreshold's int).
+28 -1
View File
@@ -66,10 +66,13 @@ func TestValidateFileRedactsSecretInIssue(t *testing.T) {
}
func TestValidateFileMissingModelWarns(t *testing.T) {
// An openai-compatible CUSTOM endpoint has no catalog default to fall back
// on, so a missing model is still a real issue — and the message must tell
// the user how to fix it.
path := writeValidateFixture(t, `{
"activeProvider": "main",
"providers": [
{"name": "main", "provider_kind": "anthropic"}
{"name": "main", "provider_kind": "openai-compatible", "baseURL": "https://gateway.example/v1"}
]
}`)
@@ -80,6 +83,30 @@ func TestValidateFileMissingModelWarns(t *testing.T) {
if !strings.Contains(issues[0].Message, "requires model") {
t.Fatalf("expected requires-model issue, got %#v", issues)
}
if !strings.Contains(issues[0].Message, "config.json") {
t.Fatalf("requires-model issue should carry an actionable hint, got %#v", issues)
}
}
func TestValidateFileDefaultsOfficialKindModels(t *testing.T) {
// Official-API kinds (anthropic/google) fall back to their catalog default
// model, so a hand-written model-less profile validates clean instead of
// bricking zero config / bare zero setup — the only commands that could
// have fixed it (the reported google case).
path := writeValidateFixture(t, `{
"activeProvider": "google",
"providers": [
{"name": "google", "provider_kind": "google", "apiKey": "AIza-x"},
{"name": "anthropic", "provider_kind": "anthropic", "apiKey": "sk-ant-x"}
]
}`)
_, issues := ValidateFile(path)
for _, issue := range issues {
if strings.Contains(issue.Message, "requires model") {
t.Fatalf("official-kind profiles must default their model, got issue %#v", issue)
}
}
}
func TestValidateFileValidConfigHasNoIssues(t *testing.T) {
+22
View File
@@ -154,6 +154,28 @@ func SetRecapsEnabled(path string, enabled bool) (FileConfig, error) {
return cfg, nil
}
// SetTheme persists the TUI theme preference, mirroring SetFavoriteModels
// (read-modify-atomic-write). A blank theme clears the stored preference.
func SetTheme(path string, theme string) (FileConfig, error) {
path = strings.TrimSpace(path)
if path == "" {
return FileConfig{}, fmt.Errorf("config path is required")
}
cfg := FileConfig{}
if data, err := os.ReadFile(path); err == nil {
if err := json.Unmarshal(data, &cfg); err != nil {
return FileConfig{}, fmt.Errorf("invalid config JSON %s: %w", path, err)
}
} else if !os.IsNotExist(err) {
return FileConfig{}, fmt.Errorf("read config %s: %w", path, err)
}
cfg.Preferences.Theme = strings.TrimSpace(theme)
if err := writeConfigFile(path, cfg); err != nil {
return FileConfig{}, err
}
return cfg, nil
}
func normalizeFavoriteModels(models []string) []string {
seen := map[string]bool{}
favorites := make([]string, 0, len(models))
+33
View File
@@ -272,6 +272,39 @@ func TestSetFavoriteModelsPersistsUserPreferences(t *testing.T) {
}
}
func TestSetThemePersistsUserPreference(t *testing.T) {
path := filepath.Join(t.TempDir(), "zero.json")
writeConfigFixture(t, path, FileConfig{
ActiveProvider: "openai",
Providers: []ProviderProfile{
{Name: "openai", ProviderKind: ProviderKindOpenAI, Model: "gpt-4.1"},
},
}, 0o600)
cfg, err := SetTheme(path, " dracula ")
if err != nil {
t.Fatalf("SetTheme() error = %v", err)
}
if cfg.Preferences.Theme != "dracula" {
t.Fatalf("Theme = %q, want dracula (trimmed)", cfg.Preferences.Theme)
}
persisted := readConfigFixture(t, path)
if persisted.Preferences.Theme != "dracula" {
t.Fatalf("persisted Theme = %q, want dracula", persisted.Preferences.Theme)
}
if persisted.ActiveProvider != "openai" || len(persisted.Providers) != 1 {
t.Fatalf("provider config was not preserved by SetTheme: %#v", persisted)
}
// A blank value clears the stored preference.
if cfg, err = SetTheme(path, ""); err != nil {
t.Fatalf("SetTheme(\"\") error = %v", err)
}
if cfg.Preferences.Theme != "" {
t.Fatalf("SetTheme(\"\") should clear the theme, got %q", cfg.Preferences.Theme)
}
}
func TestRecapsPreferenceRoundTrips(t *testing.T) {
// Default (unset) is ON.
if !(PreferencesConfig{}).RecapsEnabled() {
+47 -6
View File
@@ -57,6 +57,11 @@ type Options struct {
// LSP-server checks. Nil means exec.LookPath; tests inject a stub so the
// checks are deterministic regardless of the host's installed tooling.
LookupExecutable func(string) (string, error)
// Executable resolves the running Zero binary's path (nil => os.Executable)
// and StatExecutable stats it (nil => os.Stat). Injected so the native-binary
// runtime check is testable without depending on a real on-disk layout.
Executable func() (string, error)
StatExecutable func(string) (os.FileInfo, error)
}
func Run(options Options) Report {
@@ -65,7 +70,7 @@ func Run(options Options) Report {
now = time.Now
}
checks := []Check{
runtimeCheck(options.Runtime),
runtimeCheck(options),
configFilesCheck(options.UserConfig, options.ProjectConfig),
configValidationCheck(options.UserConfig, options.ProjectConfig),
}
@@ -114,12 +119,48 @@ func Format(report Report) string {
return strings.Join(lines, "\n")
}
func runtimeCheck(runtime string) Check {
runtime = strings.TrimSpace(runtime)
if runtime == "" {
runtime = "go"
// runtimeCheck verifies the running Zero native binary is actually present and
// runnable, and reports its resolved path — instead of the former hardcoded
// always-pass, which claimed "Go runtime is available" while checking nothing
// (#405). doctor executes INSIDE the native binary, so this only fails if
// os.Executable can't resolve or the running file has vanished; the missing-
// binary-at-install case is caught earlier by the npm wrapper, which refuses to
// launch without the platform binary. Surfacing the binary PATH is the useful
// part: it lets a user see which binary answered (e.g. an npm-installed one vs a
// stale/shadowing copy on PATH) rather than a meaningless green.
func runtimeCheck(options Options) Check {
runtimeVersion := strings.TrimSpace(options.Runtime)
if runtimeVersion == "" {
runtimeVersion = "go"
}
return check("runtime.go", "Go runtime", StatusPass, fmt.Sprintf("Zero Go runtime is available (%s).", runtime), map[string]any{"runtime": runtime})
details := map[string]any{"runtime": runtimeVersion}
executable := options.Executable
if executable == nil {
executable = os.Executable
}
path, err := executable()
if err != nil {
return check("runtime.go", "Zero native binary", StatusFail,
"Could not resolve the running Zero binary: "+err.Error(), details)
}
details["binaryPath"] = path
stat := options.StatExecutable
if stat == nil {
stat = os.Stat
}
info, err := stat(path)
if err != nil {
return check("runtime.go", "Zero native binary", StatusFail,
fmt.Sprintf("The running Zero binary is not accessible at %s: %s. Reinstall the zero package, or build from source: https://github.com/Gitlawb/zero", path, err.Error()), details)
}
if info.IsDir() {
return check("runtime.go", "Zero native binary", StatusFail,
fmt.Sprintf("The resolved Zero binary path %s is a directory, not an executable.", path), details)
}
return check("runtime.go", "Zero native binary", StatusPass,
fmt.Sprintf("Zero native binary is present and runnable at %s (%s).", path, runtimeVersion), details)
}
func configFilesCheck(userPath string, projectPath string) Check {
+88
View File
@@ -0,0 +1,88 @@
package doctor
import (
"errors"
"io/fs"
"os"
"strings"
"testing"
"time"
)
// fakeFileInfo is a minimal os.FileInfo for the runtime-check stat injection.
type fakeFileInfo struct{ dir bool }
func (f fakeFileInfo) Name() string { return "zero" }
func (f fakeFileInfo) Size() int64 { return 1 }
func (f fakeFileInfo) Mode() fs.FileMode { return 0o755 }
func (f fakeFileInfo) ModTime() time.Time { return time.Unix(0, 0) }
func (f fakeFileInfo) IsDir() bool { return f.dir }
func (f fakeFileInfo) Sys() any { return nil }
func runtimeCheckFor(t *testing.T, exe func() (string, error), stat func(string) (os.FileInfo, error)) Check {
t.Helper()
return runtimeCheck(Options{Runtime: "go1.26", Executable: exe, StatExecutable: stat})
}
// A present, runnable binary passes AND reports its resolved path — the check is
// no longer a hardcoded green; it names which binary answered.
func TestRuntimeCheckPassesAndReportsBinaryPath(t *testing.T) {
c := runtimeCheckFor(t,
func() (string, error) { return "/opt/homebrew/lib/node_modules/@gitlawb/zero/zero", nil },
func(string) (os.FileInfo, error) { return fakeFileInfo{}, nil })
if c.Status != StatusPass {
t.Fatalf("status = %s, want pass", c.Status)
}
if c.Details["binaryPath"] != "/opt/homebrew/lib/node_modules/@gitlawb/zero/zero" {
t.Fatalf("binaryPath = %v, want the resolved path in details", c.Details["binaryPath"])
}
if !strings.Contains(c.Message, "/opt/homebrew/lib/node_modules/@gitlawb/zero/zero") {
t.Fatalf("message should surface the binary path, got %q", c.Message)
}
}
// The vanished-binary case FAILS loudly instead of the old unconditional pass —
// this is the #405 regression: a broken runtime no longer gets a green check.
func TestRuntimeCheckFailsWhenBinaryMissing(t *testing.T) {
c := runtimeCheckFor(t,
func() (string, error) { return "/path/to/zero", nil },
func(string) (os.FileInfo, error) { return nil, os.ErrNotExist })
if c.Status != StatusFail {
t.Fatalf("#405: a missing/inaccessible binary must FAIL, got %s (%q)", c.Status, c.Message)
}
if !strings.Contains(c.Message, "/path/to/zero") {
t.Fatalf("failure message should name the path, got %q", c.Message)
}
}
// os.Executable resolution failure fails loudly too.
func TestRuntimeCheckFailsWhenExecutableUnresolvable(t *testing.T) {
c := runtimeCheckFor(t,
func() (string, error) { return "", errors.New("no executable path") },
func(string) (os.FileInfo, error) { return fakeFileInfo{}, nil })
if c.Status != StatusFail {
t.Fatalf("unresolvable executable must FAIL, got %s", c.Status)
}
}
// A path that resolves to a directory is not a runnable binary → FAIL.
func TestRuntimeCheckFailsWhenPathIsDirectory(t *testing.T) {
c := runtimeCheckFor(t,
func() (string, error) { return "/some/dir", nil },
func(string) (os.FileInfo, error) { return fakeFileInfo{dir: true}, nil })
if c.Status != StatusFail {
t.Fatalf("a directory path must FAIL, got %s", c.Status)
}
}
// Default injection (nil Executable/StatExecutable) resolves the real running
// test binary — proving the production default path works and passes.
func TestRuntimeCheckDefaultsToRealBinary(t *testing.T) {
c := runtimeCheck(Options{})
if c.Status != StatusPass {
t.Fatalf("default runtime check should pass for the running test binary, got %s (%q)", c.Status, c.Message)
}
if strings.TrimSpace(c.Details["binaryPath"].(string)) == "" {
t.Fatal("default runtime check should report the running binary path")
}
}
+166 -21
View File
@@ -1,16 +1,17 @@
package mcp
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
@@ -211,6 +212,85 @@ func TestHTTPClientListsAndCallsTools(t *testing.T) {
}
}
func TestHTTPClientFollowsSameOriginRedirect(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
testServer := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
if request.URL.Path == "/mcp" {
http.Redirect(response, request, "/redirected", http.StatusTemporaryRedirect)
return
}
if request.Method != http.MethodPost || request.URL.Path != "/redirected" {
t.Errorf("request = %s %s, want POST /redirected", request.Method, request.URL.Path)
http.Error(response, "bad request", http.StatusNotFound)
return
}
if got := request.Header.Get("X-Api-Key"); got != "secret" {
t.Errorf("X-Api-Key = %q, want secret", got)
http.Error(response, "missing auth", http.StatusUnauthorized)
return
}
message := readHTTPRPCMessage(t, request)
switch message.Method {
case "initialize":
writeHTTPRPCResponse(t, response, message.ID, map[string]any{"protocolVersion": "2024-11-05"})
case "notifications/initialized":
response.WriteHeader(http.StatusAccepted)
default:
writeHTTPRPCResponse(t, response, message.ID, map[string]any{})
}
}))
defer testServer.Close()
client, err := Connect(ctx, Server{
Name: "docs",
Type: ServerTypeHTTP,
URL: testServer.URL + "/mcp",
Headers: map[string]string{"X-Api-Key": "secret"},
})
if err != nil {
t.Fatalf("Connect() error = %v", err)
}
if err := client.Close(); err != nil {
t.Fatalf("Close() error = %v", err)
}
}
func TestHTTPClientRejectsCrossOriginRedirectBeforeSendingHeaders(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var targetHits int32
target := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
atomic.AddInt32(&targetHits, 1)
if got := request.Header.Get("X-Api-Key"); got != "" {
t.Errorf("redirect target received X-Api-Key = %q", got)
}
response.WriteHeader(http.StatusOK)
}))
defer target.Close()
redirector := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
http.Redirect(response, request, target.URL+"/mcp", http.StatusTemporaryRedirect)
}))
defer redirector.Close()
_, err := Connect(ctx, Server{
Name: "docs",
Type: ServerTypeHTTP,
URL: redirector.URL + "/mcp",
Headers: map[string]string{"X-Api-Key": "secret"},
})
if err == nil || !strings.Contains(err.Error(), "cross-origin redirect") {
t.Fatalf("Connect() error = %v, want cross-origin redirect error", err)
}
if got := atomic.LoadInt32(&targetHits); got != 0 {
t.Fatalf("redirect target hits = %d, want 0", got)
}
}
func TestSSEClientListsAndCallsToolsFromRemoteStream(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
@@ -340,6 +420,53 @@ func TestSSEClientListsAndCallsToolsFromRemoteStream(t *testing.T) {
}
}
func TestSSEClientRejectsCrossOriginEndpointBeforeSendingHeaders(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var targetHits int32
target := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
atomic.AddInt32(&targetHits, 1)
if got := request.Header.Get("X-Api-Key"); got != "" {
t.Errorf("SSE endpoint target received X-Api-Key = %q", got)
}
response.WriteHeader(http.StatusAccepted)
}))
defer target.Close()
stream := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
if got := request.Header.Get("X-Api-Key"); got != "secret" {
t.Errorf("X-Api-Key = %q, want secret on configured SSE server", got)
http.Error(response, "missing auth", http.StatusUnauthorized)
return
}
if request.Method != http.MethodGet || request.URL.Path != "/sse" {
t.Errorf("request = %s %s, want GET /sse", request.Method, request.URL.Path)
http.Error(response, "bad request", http.StatusNotFound)
return
}
response.Header().Set("Content-Type", "text/event-stream")
_, _ = fmt.Fprintf(response, "event: endpoint\ndata: %s/messages\n\n", target.URL)
if flusher, ok := response.(http.Flusher); ok {
flusher.Flush()
}
}))
defer stream.Close()
_, err := Connect(ctx, Server{
Name: "docs",
Type: ServerTypeSSE,
URL: stream.URL + "/sse",
Headers: map[string]string{"X-Api-Key": "secret"},
})
if err == nil || !strings.Contains(err.Error(), "endpoint origin") {
t.Fatalf("Connect() error = %v, want cross-origin endpoint error", err)
}
if got := atomic.LoadInt32(&targetHits); got != 0 {
t.Fatalf("SSE endpoint target hits = %d, want 0", got)
}
}
func TestHTTPClientReportsNonOKStatus(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
@@ -393,28 +520,43 @@ func TestConnectRejectsUnsupportedTransport(t *testing.T) {
}
func TestClientRequestWaitsForMatchingResponseID(t *testing.T) {
var incoming bytes.Buffer
incomingWriter := newMessageWriter(&incoming)
if err := incomingWriter.write(rpcMessage{Method: "notifications/progress"}); err != nil {
t.Fatal(err)
}
if err := incomingWriter.write(rpcMessage{
ID: 99,
Error: &rpcError{Code: -32000, Message: "wrong response"},
}); err != nil {
t.Fatal(err)
}
if err := incomingWriter.write(rpcMessage{
ID: 1,
Result: mustRaw(map[string]any{"value": "matched"}),
}); err != nil {
t.Fatal(err)
}
// Pipes, not a pre-filled bytes.Buffer: the fake server responds only AFTER
// reading the outgoing request, matching production ordering. With responses
// pre-loaded, the client's reader goroutine could consume the matching
// response — and hit EOF — before request() registered its pending id,
// flaking as "request() error = EOF" on fast runners.
serverReader, clientWriter := io.Pipe() // client → server
clientReader, serverWriter := io.Pipe() // server → client
t.Cleanup(func() {
_ = clientWriter.Close()
_ = serverWriter.Close()
})
serverErr := make(chan error, 1)
go func() {
requests := newMessageReader(serverReader)
request, err := requests.read()
if err != nil {
serverErr <- err
return
}
responses := newMessageWriter(serverWriter)
for _, message := range []rpcMessage{
{Method: "notifications/progress"},
{ID: 99, Error: &rpcError{Code: -32000, Message: "wrong response"}},
{ID: request.ID, Result: mustRaw(map[string]any{"value": "matched"})},
} {
if err := responses.write(message); err != nil {
serverErr <- err
return
}
}
serverErr <- nil
}()
var outgoing bytes.Buffer
client := &Client{
reader: newMessageReader(&incoming),
writer: newMessageWriter(&outgoing),
reader: newMessageReader(clientReader),
writer: newMessageWriter(clientWriter),
nextID: 1,
}
var result struct {
@@ -426,6 +568,9 @@ func TestClientRequestWaitsForMatchingResponseID(t *testing.T) {
if result.Value != "matched" {
t.Fatalf("result.Value = %q, want matched response", result.Value)
}
if err := <-serverErr; err != nil {
t.Fatalf("fake server error: %v", err)
}
}
type errorCloser struct {
+8 -4
View File
@@ -733,7 +733,11 @@ func resolveSSEEndpointURL(baseValue string, endpointValue string) (string, erro
if err != nil {
return "", fmt.Errorf("parse MCP SSE endpoint URL: %w", err)
}
return baseURL.ResolveReference(endpointURL).String(), nil
resolvedURL := baseURL.ResolveReference(endpointURL)
if !sameMCPOrigin(baseURL, resolvedURL) {
return "", fmt.Errorf("MCP SSE endpoint origin %s differs from configured server origin %s", mcpOrigin(resolvedURL), mcpOrigin(baseURL))
}
return resolvedURL.String(), nil
}
func rpcResponseKey(id any) string {
@@ -873,10 +877,10 @@ func (source *storeTokenSource) Refresh(ctx context.Context) (string, error) {
// oauthHTTPClient returns an HTTP client whose transport attaches OAuth bearer
// tokens and refreshes them on 401 for OAuth-configured servers. Servers that do
// not declare OAuth get the default client unchanged.
// not declare OAuth use the default transport with the same MCP redirect guard.
func oauthHTTPClient(server Server) (*http.Client, error) {
if !strings.EqualFold(strings.TrimSpace(server.Auth), ServerAuthOAuth) {
return http.DefaultClient, nil
return mcpHTTPClient(server, nil), nil
}
store, err := NewTokenStore(TokenStoreOptions{})
if err != nil {
@@ -888,5 +892,5 @@ func oauthHTTPClient(server Server) (*http.Client, error) {
httpClient: http.DefaultClient,
now: time.Now,
}
return &http.Client{Transport: newOAuthRoundTripper(http.DefaultTransport, source, server.Name)}, nil
return mcpHTTPClient(server, newOAuthRoundTripper(http.DefaultTransport, source, server.Name)), nil
}
+136
View File
@@ -4,6 +4,7 @@ import (
"context"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync/atomic"
"testing"
@@ -149,6 +150,141 @@ func TestOAuthRoundTripperDoesNotRetryAfterSuccessfulNon401(t *testing.T) {
}
}
func TestOAuthRoundTripperRejectsCrossOriginRedirectBeforeBearerLeak(t *testing.T) {
source := &fakeTokenSource{}
source.access.Store("access-secret")
source.refreshFunc = func() (string, error) { return "access-fresh", nil }
var targetHits int32
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&targetHits, 1)
if got := r.Header.Get("Authorization"); got != "" {
t.Errorf("redirect target received Authorization = %q", got)
}
w.WriteHeader(http.StatusOK)
}))
defer target.Close()
redirector := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, target.URL+"/mcp", http.StatusTemporaryRedirect)
}))
defer redirector.Close()
client := mcpHTTPClient(
Server{Name: "oauth-docs", Type: ServerTypeHTTP},
newOAuthRoundTripper(http.DefaultTransport, source, "oauth-docs"),
)
resp, err := client.Get(redirector.URL + "/mcp")
if resp != nil {
_ = resp.Body.Close()
}
if err == nil || !strings.Contains(err.Error(), "cross-origin redirect") {
t.Fatalf("Get() error = %v, want cross-origin redirect error", err)
}
if got := atomic.LoadInt32(&targetHits); got != 0 {
t.Fatalf("redirect target hits = %d, want 0", got)
}
}
func TestSameMCPOriginNormalizesDefaultPorts(t *testing.T) {
for _, tc := range []struct {
name string
left string
right string
want bool
}{
{
name: "https default port",
left: "https://example.test/mcp",
right: "https://EXAMPLE.test:443/other",
want: true,
},
{
name: "http default port",
left: "http://example.test/mcp",
right: "http://example.test:80/other",
want: true,
},
{
name: "different scheme",
left: "https://example.test/mcp",
right: "http://example.test:443/other",
want: false,
},
{
name: "different port",
left: "https://example.test/mcp",
right: "https://example.test:8443/other",
want: false,
},
} {
t.Run(tc.name, func(t *testing.T) {
left, err := url.Parse(tc.left)
if err != nil {
t.Fatal(err)
}
right, err := url.Parse(tc.right)
if err != nil {
t.Fatal(err)
}
if got := sameMCPOrigin(left, right); got != tc.want {
t.Fatalf("sameMCPOrigin(%q, %q) = %v, want %v", tc.left, tc.right, got, tc.want)
}
})
}
}
func TestResolveSSEEndpointURLRestrictsOrigin(t *testing.T) {
for _, tc := range []struct {
name string
base string
endpoint string
want string
wantErr string
}{
{
name: "relative endpoint",
base: "https://example.test/sse",
endpoint: "/messages",
want: "https://example.test/messages",
},
{
name: "absolute same origin",
base: "https://example.test/sse",
endpoint: "https://example.test/messages",
want: "https://example.test/messages",
},
{
name: "default port equivalent",
base: "https://example.test/sse",
endpoint: "https://example.test:443/messages",
want: "https://example.test:443/messages",
},
{
name: "cross origin",
base: "https://example.test/sse",
endpoint: "https://evil.test/messages",
wantErr: "differs from configured server origin",
},
} {
t.Run(tc.name, func(t *testing.T) {
got, err := resolveSSEEndpointURL(tc.base, tc.endpoint)
if tc.wantErr != "" {
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
t.Fatalf("resolveSSEEndpointURL() error = %v, want %q", err, tc.wantErr)
}
return
}
if err != nil {
t.Fatalf("resolveSSEEndpointURL() error = %v", err)
}
if got != tc.want {
t.Fatalf("resolveSSEEndpointURL() = %q, want %q", got, tc.want)
}
})
}
}
func TestNonOAuthServerIsUnaffected(t *testing.T) {
// Regression: a server without auth must not get a bearer header or any
// OAuth round tripper, and must connect normally.
+74
View File
@@ -0,0 +1,74 @@
package mcp
import (
"fmt"
"net"
"net/http"
"net/url"
"strings"
)
const maxMCPRedirects = 10
func mcpHTTPClient(server Server, transport http.RoundTripper) *http.Client {
return &http.Client{
Transport: transport,
CheckRedirect: checkMCPRedirect(server),
}
}
func checkMCPRedirect(server Server) func(*http.Request, []*http.Request) error {
return func(request *http.Request, via []*http.Request) error {
if len(via) >= maxMCPRedirects {
return fmt.Errorf("MCP %s server %s stopped after %d redirects", server.Type, server.Name, maxMCPRedirects)
}
if len(via) == 0 {
return nil
}
if !sameMCPOrigin(via[0].URL, request.URL) {
return fmt.Errorf("MCP %s server %s refused cross-origin redirect to %s", server.Type, server.Name, mcpOrigin(request.URL))
}
return nil
}
}
func sameMCPOrigin(left *url.URL, right *url.URL) bool {
if left == nil || right == nil {
return false
}
return strings.EqualFold(left.Scheme, right.Scheme) &&
strings.EqualFold(left.Hostname(), right.Hostname()) &&
effectiveMCPPort(left) == effectiveMCPPort(right)
}
func effectiveMCPPort(value *url.URL) string {
if value == nil {
return ""
}
if port := value.Port(); port != "" {
return port
}
switch strings.ToLower(value.Scheme) {
case "http":
return "80"
case "https":
return "443"
default:
return ""
}
}
func mcpOrigin(value *url.URL) string {
if value == nil {
return "<nil>"
}
host := value.Hostname()
port := effectiveMCPPort(value)
if port != "" {
host = net.JoinHostPort(host, port)
}
if value.Scheme == "" {
return host
}
return strings.ToLower(value.Scheme) + "://" + host
}
+8 -1
View File
@@ -370,7 +370,14 @@ func TestFullFlowStoresTokens(t *testing.T) {
redirect := parsed.Query().Get("redirect_uri")
go func() {
cbURL := redirect + "?code=auth-code&state=" + url.QueryEscape(state)
_, _ = http.Get(cbURL)
for i := 0; i < 20; i++ {
resp, err := http.Get(cbURL)
if err == nil {
resp.Body.Close()
return
}
time.Sleep(50 * time.Millisecond)
}
}()
return nil
}
+26 -6
View File
@@ -7,6 +7,27 @@ import (
const DefaultModelID = "gpt-4.1"
// FallbackContextWindow is the assumed context window (max input tokens) for a
// model that isn't in the curated registry and wasn't resolved from live provider
// discovery — e.g. proxy/custom models like the GPT-5 Codex, xAI, or Ollama-cloud
// variants. A positive value here is what enables agent-loop compaction (both the
// proactive ~80% trigger and the reactive post-overflow recovery) for those models;
// without it compaction stays disabled and long sessions overflow. It is only a
// backstop: an exact window from the registry or live discovery always wins.
const FallbackContextWindow = 200_000
// AgentContextWindow turns a resolved context window into the value used to enable
// agent-loop compaction: the resolved window when known, else FallbackContextWindow
// so compaction is enabled even for uncatalogued models. Kept separate from the
// raw resolver so display gauges still show nothing (not a guessed denominator)
// when the real window is unknown.
func AgentContextWindow(resolved int) int {
if resolved <= 0 {
return FallbackContextWindow
}
return resolved
}
const sourceLastVerified = "2026-06-04"
const (
@@ -172,13 +193,12 @@ func (registry Registry) SupportsCapability(pattern string, capability ModelCapa
}
func (registry Registry) ReasoningEfforts(pattern string) []ReasoningEffort {
if model, ok := registry.Get(pattern); ok && len(model.ReasoningEfforts) > 0 {
return append([]ReasoningEffort{}, model.ReasoningEfforts...)
if model, ok := registry.Get(pattern); ok {
return append([]ReasoningEffort{}, effectiveReasoningEfforts(model)...)
}
// Fallback for reasoning-model families that aren't enumerated in the curated
// catalog — e.g. the GPT-5 / Codex / o-series variants served via the ChatGPT
// proxy or custom OpenAI-compatible endpoints. Without this, /effort shows no
// controls for those models even though they support reasoning_effort.
// Unknown model not in the curated catalog — e.g. a GPT-5 / Codex / o-series
// variant served via the ChatGPT proxy or a custom OpenAI-compatible endpoint.
// Infer from the name so /effort still shows controls for it.
return reasoningEffortsForModelName(pattern)
}
+22 -4
View File
@@ -47,10 +47,13 @@ func (registry Registry) ResolveWithFallback(input string) (ModelEntry, string,
// EffectiveReasoningEffort returns the effort to use for a model: the requested
// value if the model supports it, otherwise the model's default (or first
// supported, or none).
// supported, or none). It resolves the supported set through
// effectiveReasoningEfforts so it sees the same name-based fallback the /effort
// picker uses — the two must never disagree about which tiers a model supports.
func EffectiveReasoningEffort(model ModelEntry, requested ReasoningEffort) ReasoningEffort {
efforts := effectiveReasoningEfforts(model)
if requested != "" {
for _, effort := range model.ReasoningEfforts {
for _, effort := range efforts {
if effort == requested {
return requested
}
@@ -59,8 +62,23 @@ func EffectiveReasoningEffort(model ModelEntry, requested ReasoningEffort) Reaso
if model.DefaultReasoningEffort != "" {
return model.DefaultReasoningEffort
}
if len(model.ReasoningEfforts) > 0 {
return model.ReasoningEfforts[0]
if len(efforts) > 0 {
return efforts[0]
}
return ReasoningEffortNone
}
// effectiveReasoningEfforts returns a model's supported reasoning efforts, falling
// back to name-based inference (reasoningEffortsForModelName) when the catalog
// entry enumerates none. Both the /effort picker (Registry.ReasoningEfforts) and
// the run-time resolver (EffectiveReasoningEffort) read efforts through this
// single helper, so the picker can never advertise a tier the resolver drops.
func effectiveReasoningEfforts(model ModelEntry) []ReasoningEffort {
if len(model.ReasoningEfforts) > 0 {
return model.ReasoningEfforts
}
if efforts := reasoningEffortsForModelName(model.ID); len(efforts) > 0 {
return efforts
}
return reasoningEffortsForModelName(model.APIModel)
}
+40
View File
@@ -79,3 +79,43 @@ func TestEffectiveReasoningEffort(t *testing.T) {
t.Errorf("empty effort should use default low, got %q", got)
}
}
// TestEffectiveReasoningEffortUsesNameFallback pins that the run-time resolver
// honors the same name-based fallback the /effort picker uses. A model that the
// catalog enumerates with no efforts but whose name is a known reasoning family
// (e.g. a GPT-5 variant served via a proxy) must have its requested effort
// honored — not silently coerced to "none" while the picker advertises controls.
func TestEffectiveReasoningEffortUsesNameFallback(t *testing.T) {
// Registered model, no explicit efforts, GPT-5 api model -> fallback infers
// {minimal, low, medium, high}.
gpt5 := ModelEntry{ID: "gpt-5-proxy", APIModel: "gpt-5", Provider: ProviderOpenAI}
if got := EffectiveReasoningEffort(gpt5, ReasoningEffortHigh); got != ReasoningEffortHigh {
t.Errorf("supported (via name fallback) effort = %q; want high", got)
}
if got := EffectiveReasoningEffort(gpt5, ReasoningEffortMinimal); got != ReasoningEffortMinimal {
t.Errorf("minimal (via name fallback) = %q; want minimal", got)
}
// xhigh is outside the inferred set and there is no declared default, so it
// coerces to the first inferred tier rather than to "none".
if got := EffectiveReasoningEffort(gpt5, ReasoningEffortXHigh); got != ReasoningEffortMinimal {
t.Errorf("unsupported effort on a fallback model = %q; want minimal (first inferred)", got)
}
// Non-reasoning model: name matches nothing, stays "none".
gpt4o := ModelEntry{ID: "gpt-4o", APIModel: "gpt-4o", Provider: ProviderOpenAI}
if got := EffectiveReasoningEffort(gpt4o, ReasoningEffortHigh); got != ReasoningEffortNone {
t.Errorf("non-reasoning model = %q; want none", got)
}
// The picker and the resolver must agree on the supported set for the same id.
reg := resolveTestRegistry(t)
picker := reg.ReasoningEfforts("gpt-5") // unknown -> name fallback
if len(picker) == 0 {
t.Fatal("picker should advertise efforts for a gpt-5 name")
}
for _, tier := range picker {
if got := EffectiveReasoningEffort(gpt5, tier); got != tier {
t.Errorf("picker advertises %q but resolver returns %q", tier, got)
}
}
}
+20
View File
@@ -140,6 +140,26 @@ func TestPostinstallSkipsUnsupportedPlatform(t *testing.T) {
}
}
func TestPostinstallSkipsWindowsArm64(t *testing.T) {
// (win32, arm64) resolves to a valid platform/arch but the release matrix has
// no windows-arm64 artifact, so the install must skip gracefully (exit 0)
// rather than hard-fail on a 404 download.
stdout, stderr, err := runPostinstall(t,
"ZERO_INSTALL_DRY_RUN=1",
"ZERO_INSTALL_PLATFORM=win32",
"ZERO_INSTALL_ARCH=arm64",
)
if err != nil {
t.Fatalf("windows-arm64 should exit 0, got err=%v stderr=%s", err, stderr)
}
if strings.TrimSpace(stdout) != "" {
t.Fatalf("windows-arm64 should not print a plan, got %q", stdout)
}
if !strings.Contains(stderr, "no prebuilt binary for windows-arm64") {
t.Fatalf("stderr=%q, want the windows-arm64 skip message", stderr)
}
}
func TestPostinstallHonorsSkipEnv(t *testing.T) {
stdout, stderr, err := runPostinstall(t, "ZERO_SKIP_DOWNLOAD=1")
if err != 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"`
+20 -1
View File
@@ -58,6 +58,11 @@ type Descriptor struct {
// OAuthDeviceFlow reports that RFC 8628 device-code login is supported (for
// headless / SSH use) in addition to the browser flow.
OAuthDeviceFlow bool
// Recommended marks the provider that should be surfaced first and badged
// (★ … (recommended)) in every catalog-ordered list and picker. At most one
// descriptor should set this.
Recommended bool
}
func RuntimeSupported(descriptor Descriptor) bool {
@@ -82,6 +87,11 @@ func RuntimeUnsupportedReason(descriptor Descriptor) string {
}
var descriptors = []Descriptor{
// GitLawb OpenGateway — the recommended default. An OpenAI-compatible gateway
// that smart-routes by model id across upstream providers (xiaomi-mimo,
// minimax, qwen, google, nvidia, z-ai). Flat /v1/chat/completions with a
// Bearer ogw_live_… key; listed first and badged in every picker.
recommended(openAICompat("gitlawb-opengateway", "GitLawb OpenGateway", "https://opengateway.gitlawb.com/v1", "mimo-v2.5-pro", []string{"GITLAWB_OPENGATEWAY_API_KEY"}, "gitlawb opengateway", "opengateway")),
openAI("openai", "OpenAI", "https://api.openai.com/v1", "gpt-4.1", []string{"OPENAI_API_KEY"}),
anthropic("anthropic", "Anthropic", "https://api.anthropic.com", "claude-sonnet-4.5", []string{"ANTHROPIC_API_KEY"}),
google("google", "Google", "https://generativelanguage.googleapis.com", "gemini-2.5-pro", []string{"GEMINI_API_KEY", "GOOGLE_API_KEY"}, "gemini"),
@@ -124,7 +134,9 @@ var descriptors = []Descriptor{
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("gitlawb-opengateway", "GitLawb OpenGateway", "https://gateway.gitlawb.com/v1", "gpt-4.1", []string{"GITLAWB_OPENGATEWAY_API_KEY"}, "gitlawb opengateway"),
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
@@ -266,6 +278,13 @@ func google(id string, name string, baseURL string, model string, env []string,
}
}
// recommended marks a descriptor as the recommended default so list/picker
// surfaces sort it first and render the ★ … (recommended) badge.
func recommended(descriptor Descriptor) Descriptor {
descriptor.Recommended = true
return descriptor
}
// oauthProvider marks a descriptor as OAuth-capable. mintsKey => the flow returns
// an API key (OpenRouter); deviceFlow => RFC 8628 device code is also supported.
func oauthProvider(descriptor Descriptor, mintsKey bool, deviceFlow bool) Descriptor {
+40 -2
View File
@@ -8,6 +8,7 @@ import (
)
var expectedCatalogIDs = []string{
"gitlawb-opengateway",
"openai",
"anthropic",
"google",
@@ -33,7 +34,9 @@ var expectedCatalogIDs = []string{
"xiaomi-mimo",
"bankr",
"zai",
"gitlawb-opengateway",
"kilocode",
"opencode",
"opencode-go",
"atomic-chat",
"chatgpt-proxy",
"custom-openai-compatible",
@@ -61,6 +64,41 @@ func TestAllHasStableUniqueIDs(t *testing.T) {
}
}
func TestRecommendedProviderIsFirstAndUnique(t *testing.T) {
descriptors := All()
if len(descriptors) == 0 {
t.Fatal("All() returned no descriptors")
}
if !descriptors[0].Recommended {
t.Fatalf("All()[0] = %q, want it to be the recommended provider", descriptors[0].ID)
}
if descriptors[0].ID != "gitlawb-opengateway" {
t.Fatalf("recommended provider = %q, want %q", descriptors[0].ID, "gitlawb-opengateway")
}
recommended := 0
for _, descriptor := range descriptors {
if descriptor.Recommended {
recommended++
}
}
if recommended != 1 {
t.Fatalf("recommended descriptor count = %d, want exactly 1", recommended)
}
}
func TestRecommendedProviderEndpoint(t *testing.T) {
descriptor, ok := Get("gitlawb-opengateway")
if !ok {
t.Fatal("gitlawb-opengateway not found in catalog")
}
if descriptor.DefaultBaseURL != "https://opengateway.gitlawb.com/v1" {
t.Fatalf("OpenGateway base URL = %q, want %q", descriptor.DefaultBaseURL, "https://opengateway.gitlawb.com/v1")
}
if descriptor.Transport != TransportOpenAICompatible {
t.Fatalf("OpenGateway transport = %q, want %q", descriptor.Transport, TransportOpenAICompatible)
}
}
func TestCatalogDescriptorsExposeRequiredDefaults(t *testing.T) {
for _, descriptor := range All() {
if descriptor.ID == "" {
@@ -229,7 +267,7 @@ func TestListByTransportPreservesCatalogOrder(t *testing.T) {
TransportBedrock: {"bedrock"},
TransportVertex: {"vertex"},
TransportAnthropicCompat: {"minimax", "custom-anthropic-compatible"},
TransportOpenAICompat: {"ollama-cloud", "ollama", "lmstudio", "openrouter", "huggingface", "chatgpt", "groq", "deepseek", "together", "dashscope", "moonshot", "nvidia-nim", "mistral", "github", "xai", "venice", "xiaomi-mimo", "bankr", "zai", "gitlawb-opengateway", "atomic-chat", "chatgpt-proxy", "custom-openai-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", "kilocode", "opencode", "opencode-go", "atomic-chat", "chatgpt-proxy", "custom-openai-compatible"},
}
for transport, wantIDs := range cases {
+19
View File
@@ -326,6 +326,9 @@ func healthRequest(ctx context.Context, profile config.ProviderProfile, kind con
return nil, false, err
}
endpoint := baseURL + healthPath(kind)
if override, ok := overrideHealthEndpoint(profile, baseURL); ok {
endpoint = override
}
// Loopback is permitted only when the user's OWN configured base_url is loopback
// (a local provider like Ollama/LM Studio) — never for a redirect target. This is
// the flag returned to the caller so the dial path applies the same policy. (AUDIT-H1)
@@ -592,6 +595,22 @@ func blockedAddrReason(addr netip.Addr) string {
return ""
}
// overrideHealthEndpoint returns a provider-specific connectivity probe URL when
// the default {baseURL}+/models path does not exist. GitLawb OpenGateway is a
// smart-routing gateway whose flat /v1/models endpoint 404s by design ("Use
// /v1/<provider>/<path>"), so probe its public /health endpoint at the host root
// instead, which reports real reachability without a per-model call.
func overrideHealthEndpoint(profile config.ProviderProfile, baseURL string) (string, bool) {
if profile.CatalogID != "gitlawb-opengateway" {
return "", false
}
parsed, err := url.Parse(baseURL)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return "", false
}
return parsed.Scheme + "://" + parsed.Host + "/health", true
}
func healthPath(kind config.ProviderKind) string {
switch kind {
case config.ProviderKindAnthropic, config.ProviderKindAnthropicCompat:
@@ -170,3 +170,20 @@ func TestProbeConnectivityRedactsProviderErrorDetails(t *testing.T) {
t.Fatalf("custom header secret leaked in connectivity check: %s", rendered)
}
}
func TestOverrideHealthEndpointOpenGateway(t *testing.T) {
got, ok := overrideHealthEndpoint(config.ProviderProfile{CatalogID: "gitlawb-opengateway"}, "https://opengateway.gitlawb.com/v1")
if !ok {
t.Fatal("expected OpenGateway to override the health endpoint")
}
if got != "https://opengateway.gitlawb.com/health" {
t.Fatalf("OpenGateway health endpoint = %q, want host-root /health", got)
}
if _, ok := overrideHealthEndpoint(config.ProviderProfile{CatalogID: "openai"}, "https://api.openai.com/v1"); ok {
t.Fatal("non-OpenGateway providers must not override the health endpoint")
}
if _, ok := overrideHealthEndpoint(config.ProviderProfile{CatalogID: "gitlawb-opengateway"}, "::not a url"); ok {
t.Fatal("an unparseable base URL must not produce an override")
}
}
+11 -3
View File
@@ -129,10 +129,18 @@ var curatedModels = map[string][]Model{
{ID: "glm-4.6", Description: "latest general model"},
{ID: "glm-z1-air", Description: "reasoning model"},
},
// 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
// expose, so users can also type an id the picker doesn't list.
"gitlawb-opengateway": {
{ID: "gpt-4.1", Description: "catalog default"},
{ID: "claude-sonnet-4.5", Description: "coding model"},
{ID: "gemini-2.5-pro", Description: "long-context model"},
{ID: "mimo-v2.5-pro", Description: "catalog default (Xiaomi MiMo)"},
{ID: "mimo-v2.5-pro-ultraspeed", Description: "fast model (Xiaomi MiMo)"},
{ID: "MiniMax-M3", Description: "MiniMax model"},
{ID: "qwen-plus", Description: "Qwen model"},
{ID: "gemini-2.5-pro", Description: "long-context model (Google)"},
{ID: "glm-4.6", Description: "Z.ai model"},
{ID: "nvidia/llama-3.1-nemotron-70b-instruct", Description: "NVIDIA NIM model"},
},
"atomic-chat": {
{ID: "gpt-4.1", Description: "catalog default"},
+1 -1
View File
@@ -54,7 +54,7 @@ func TestDefaultedOpenGatewayURL(t *testing.T) {
if got := defaultedOpenGatewayURL(providercatalog.Descriptor{DefaultBaseURL: "https://gw.example.com/v1"}, ""); got != "https://gw.example.com/zero/models.json" {
t.Fatalf("derived = %q", got)
}
if got := defaultedOpenGatewayURL(providercatalog.Descriptor{DefaultBaseURL: "::not a url"}, ""); got != "https://gateway.gitlawb.com/zero/models.json" {
if got := defaultedOpenGatewayURL(providercatalog.Descriptor{DefaultBaseURL: "::not a url"}, ""); got != "https://opengateway.gitlawb.com/zero/models.json" {
t.Fatalf("fallback = %q", got)
}
}
+1 -1
View File
@@ -223,7 +223,7 @@ func defaultedOpenGatewayURL(provider providercatalog.Descriptor, override strin
}
parsed, err := url.Parse(provider.DefaultBaseURL)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return "https://gateway.gitlawb.com/zero/models.json"
return "https://opengateway.gitlawb.com/zero/models.json"
}
return parsed.Scheme + "://" + parsed.Host + "/zero/models.json"
}
@@ -85,6 +85,104 @@ func Discover(ctx context.Context, profile config.ProviderProfile, options Optio
}
}
// DiscoverOllamaContextWindow asks a local Ollama daemon for a model's context
// length via its native /api/show endpoint. The generic /v1/models probe
// (parseModelsResponse) only ever returns id/description — OpenAI-compatible
// listings don't carry context-window metadata, and a custom/local model tag
// (e.g. a user's own Ollama pull, including a ":cloud"-tagged model proxied
// through the local daemon) has no curated-catalog entry to borrow one from
// either, so modelContextWindow has nothing to show for it without this. Only
// meaningful for the local Ollama provider (baseURL like
// http://localhost:11434/v1) — Ollama Cloud's hosted API is a different
// service and isn't assumed to expose the same endpoint.
func DiscoverOllamaContextWindow(ctx context.Context, baseURL string, model string, options Options) (int, error) {
model = strings.TrimSpace(model)
if model == "" {
return 0, fmt.Errorf("model name is required")
}
endpoint, err := ollamaShowEndpoint(baseURL)
if err != nil {
return 0, err
}
payload, err := json.Marshal(struct {
Model string `json:"model"`
}{Model: model})
if err != nil {
return 0, err
}
request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(string(payload)))
if err != nil {
return 0, err
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Accept", "application/json")
client := options.HTTPClient
if client == nil {
client = &http.Client{Timeout: 10 * time.Second}
}
response, err := client.Do(request)
if err != nil {
return 0, err
}
defer response.Body.Close()
body, err := io.ReadAll(io.LimitReader(response.Body, 1<<20))
if err != nil {
return 0, err
}
if response.StatusCode < 200 || response.StatusCode >= 300 {
return 0, fmt.Errorf("ollama show endpoint returned %s: %s", response.Status, strings.TrimSpace(string(body)))
}
return parseOllamaShowContextWindow(body)
}
// ollamaShowEndpoint derives the native Ollama API root from the
// OpenAI-compatible base URL Zero stores for the provider (".../v1") and
// appends /api/show.
func ollamaShowEndpoint(baseURL string) (string, error) {
baseURL = strings.TrimSpace(baseURL)
if baseURL == "" {
return "", fmt.Errorf("provider base URL is required for ollama model discovery")
}
parsed, err := url.Parse(baseURL)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return "", fmt.Errorf("invalid provider base URL %q", baseURL)
}
path := strings.TrimRight(parsed.Path, "/")
path = strings.TrimSuffix(path, "/v1")
parsed.Path = strings.TrimRight(path, "/") + "/api/show"
parsed.RawQuery = ""
parsed.Fragment = ""
return parsed.String(), nil
}
// parseOllamaShowContextWindow extracts the context length from an
// /api/show response. Ollama reports it under model_info with an
// architecture-prefixed key (e.g. "llama.context_length",
// "qwen2.context_length") — the prefix varies by model family, so this scans
// for any key ending in ".context_length" rather than hardcoding one.
func parseOllamaShowContextWindow(body []byte) (int, error) {
// model_info mixes value types (strings like "general.architecture", numbers
// like "*.context_length"), so decode values generically and only try to
// read a number out of the one key we actually care about.
var payload struct {
ModelInfo map[string]any `json:"model_info"`
}
if err := json.Unmarshal(body, &payload); err != nil {
return 0, fmt.Errorf("decode ollama show response: %w", err)
}
for key, value := range payload.ModelInfo {
if !strings.HasSuffix(key, ".context_length") {
continue
}
if window, ok := value.(float64); ok && window > 0 {
return int(window), nil
}
}
return 0, fmt.Errorf("ollama show response did not report a context length")
}
func discoverOpenAIModels(ctx context.Context, profile config.ProviderProfile, options Options) ([]Model, error) {
endpoint, err := modelsEndpoint(profile.BaseURL)
if err != nil {
@@ -2,6 +2,7 @@ package providermodeldiscovery
import (
"context"
"io"
"net/http"
"net/http/httptest"
"strings"
@@ -267,3 +268,61 @@ func modelIDs(models []Model) []string {
}
return ids
}
// TestDiscoverOllamaContextWindowFetchesFromNativeShowEndpoint: the generic
// /v1/models probe never carries context-window metadata (parseModelsResponse
// only extracts id/description), so a custom/local Ollama model tag with no
// curated-catalog match has no other source for it. This exercises the
// Ollama-native /api/show fallback that fills that gap.
func TestDiscoverOllamaContextWindowFetchesFromNativeShowEndpoint(t *testing.T) {
var gotPath, gotMethod, gotBody string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
gotMethod = r.Method
body, _ := io.ReadAll(r.Body)
gotBody = string(body)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"model_info": {
"general.architecture": "qwen2",
"qwen2.context_length": 131072
}
}`))
}))
defer server.Close()
window, err := DiscoverOllamaContextWindow(context.Background(), server.URL+"/v1", "kimi-k2.7-code:cloud", Options{HTTPClient: server.Client()})
if err != nil {
t.Fatalf("DiscoverOllamaContextWindow returned error: %v", err)
}
if window != 131072 {
t.Fatalf("context window = %d, want 131072", window)
}
if gotPath != "/api/show" {
t.Fatalf("requested path = %q, want /api/show (not under /v1)", gotPath)
}
if gotMethod != http.MethodPost {
t.Fatalf("method = %q, want POST", gotMethod)
}
if !strings.Contains(gotBody, `"kimi-k2.7-code:cloud"`) {
t.Fatalf("request body = %q, want it to name the model", gotBody)
}
}
func TestDiscoverOllamaContextWindowRequiresModelName(t *testing.T) {
if _, err := DiscoverOllamaContextWindow(context.Background(), "http://localhost:11434/v1", "", Options{}); err == nil {
t.Fatal("expected an error for an empty model name")
}
}
func TestDiscoverOllamaContextWindowErrorsWhenShowOmitsContextLength(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"model_info": {"general.architecture": "qwen2"}}`))
}))
defer server.Close()
if _, err := DiscoverOllamaContextWindow(context.Background(), server.URL+"/v1", "some-model", Options{HTTPClient: server.Client()}); err == nil {
t.Fatal("expected an error when no *.context_length key is present")
}
}
+2 -2
View File
@@ -211,11 +211,11 @@ func (provider *Provider) stream(ctx context.Context, body []byte, events chan<-
err = providerio.ScanSSEDataWithContext(streamCtx, cancelStream, response.Body, provider.streamIdleTimeout, func(data string) bool {
return provider.emitPayload(ctx, data, state, events)
})
if errors.Is(err, providerio.ErrStreamIdle) {
if errors.Is(err, providerio.ErrStreamIdle) || errors.Is(err, providerio.ErrStreamStalled) {
state.closeOpen(ctx, events)
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{
Type: zeroruntime.StreamEventError,
Error: provider.redact(fmt.Sprintf("provider stream error: idle timeout after %s (upstream stopped sending data)", provider.streamIdleTimeout)),
Error: provider.redact("provider stream error: " + providerio.StreamTimeoutMessage(err, provider.streamIdleTimeout)),
})
return
}
+31 -13
View File
@@ -26,6 +26,13 @@ type Options struct {
// OAuth bearer token (preferred over the API key). nil => API-key auth only.
// Applied to the OpenAI and Anthropic providers.
OAuthResolver providerio.TokenResolver
// OAuthLoginKey is the credential-store key OAuthResolver bound to (empty when
// there is no OAuth login). It is the SAME selection the bearer resolver made,
// resolved ONCE by the caller so the Codex chatgpt-account-id header reads its
// account from the exact login that issued the bearer token — never a second,
// independent lookup that could pick a different login (a backend-rejected
// mismatch).
OAuthLoginKey string
}
// New creates a runtime provider for a resolved provider profile.
@@ -251,14 +258,22 @@ func isCodexCatalog(profile config.ProviderProfile, _ resolvedProfile) bool {
// newCodexProvider builds a Codex-flavored openai provider for the chatgpt
// catalog. The Codex headers (`originator`, `chatgpt-account-id`) are
// injected by the CodexProvider wrapper. The `chatgpt-account-id` is
// resolved dynamically from the stored OAuth token's Account field on
// every request so a refresh that rotates the token (and its account
// claim) takes effect immediately — a static AccountID captured at
// construction would go stale on the first refresh.
// injected by the CodexProvider wrapper. The `chatgpt-account-id` is read from
// the stored OAuth token's Account field on every request so a refresh that
// rotates the token (and its account claim) takes effect immediately — a static
// AccountID captured at construction would go stale on the first refresh.
//
// The *login* the header reads from is options.OAuthLoginKey — the SAME key the
// bearer resolver bound, resolved ONCE by the caller. The bearer resolver
// refreshes the token in place under that key; reading the account from the same
// key keeps header and token on one login for the provider's life. Resolving the
// key independently here (a second oauth.FirstStored) could, after a transient
// per-candidate load error or a mid-session `zero auth login`, pick a different
// login than the bearer — a mismatch the backend rejects.
func newCodexProvider(profile config.ProviderProfile, resolved resolvedProfile, options Options) (zeroruntime.Provider, error) {
accountKey := options.OAuthLoginKey
resolver := openai.CodexAccountResolver(func(ctx context.Context) (string, bool, error) {
account := codexAccountFromStore(profile.Name)
account := codexAccountForKey(accountKey)
if account == "" {
return "", false, nil
}
@@ -288,17 +303,20 @@ func newCodexProvider(profile config.ProviderProfile, resolved resolvedProfile,
})
}
// codexAccountFromStore reads the chatgpt_account_id from the stored OAuth
// token for the given provider name. It is called per-request (via the
// resolver) so a refresh that rotates the token takes effect immediately
// without restarting the agent. Returns "" when no token is stored (the
// Codex provider then omits the header and the user sees a clear 401).
func codexAccountFromStore(providerName string) string {
// codexAccountForKey reads the chatgpt_account_id from the token currently
// stored under key. Called per-request (via the resolver) so a refresh that
// rotates the token — saved back in place under the same key — takes effect
// immediately without restarting the agent. Returns "" when key is empty (no
// OAuth login), or the token is absent or carries no account claim.
func codexAccountForKey(key string) string {
if key == "" {
return ""
}
store, err := oauth.NewStore(oauth.StoreOptions{})
if err != nil {
return ""
}
token, ok, err := store.Load(oauth.ProviderKey(providerName))
token, ok, err := store.Load(key)
if err != nil || !ok {
return ""
}
+71 -12
View File
@@ -264,6 +264,14 @@ func TestNewRejectsUnsupportedProviderKind(t *testing.T) {
}
func TestNewRoutesChatGPTCatalogToCodexProvider(t *testing.T) {
// Isolate the OAuth token store to an empty temp path so the factory reads no
// stored login — otherwise this test picks up the developer's real chatgpt
// 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{
responseBody: "data: [DONE]\n\n",
}
@@ -315,11 +323,10 @@ func TestNewRoutesChatGPTCatalogToCodexProvider(t *testing.T) {
func TestNewRoutesChatGPTCatalogWithStoredAccountID(t *testing.T) {
// The factory reads the stored OAuth token's Account field for the
// chatgpt-account-id header. Seed a token in a temp store and point
// ZERO_OAUTH_TOKENS_PATH at it so the factory picks it up.
dir := t.TempDir()
t.Setenv("ZERO_OAUTH_TOKENS_PATH", dir+"/tokens.json")
store, err := newOAuthStoreForTest()
// chatgpt-account-id header, from the login key the CALLER supplies in
// Options.OAuthLoginKey (the same key the bearer resolver bound). Seed a
// token in an isolated temp store, then pass that key.
store, err := newOAuthStoreForTest(t)
if err != nil {
t.Fatalf("NewStore: %v", err)
}
@@ -337,8 +344,9 @@ func TestNewRoutesChatGPTCatalogWithStoredAccountID(t *testing.T) {
CatalogID: "chatgpt",
Model: "gpt-5",
}, Options{
HTTPClient: &http.Client{Transport: transport},
UserAgent: "zero-factory-test",
HTTPClient: &http.Client{Transport: transport},
UserAgent: "zero-factory-test",
OAuthLoginKey: oauth.ProviderKey("chatgpt"),
})
if err != nil {
t.Fatalf("New() error = %v", err)
@@ -400,10 +408,61 @@ func (transport *captureTransport) body() io.Reader {
return strings.NewReader(transport.requestBody)
}
// newOAuthStoreForTest builds a Store pointed at the current
// ZERO_OAUTH_TOKENS_PATH (set by the caller). It exists so the chatgpt
// factory tests can seed a token without copying the path-handling dance
// from internal/cli.
func newOAuthStoreForTest() (*oauth.Store, error) {
// newOAuthStoreForTest pins the OAuth token store to a plain temp FILE and
// returns a Store on it. Pinning ZERO_OAUTH_STORAGE matters as much as the
// path: an inherited "keyring" value would send NewStore to the OS keychain
// and ignore ZERO_OAUTH_TOKENS_PATH entirely, making the test read/write the
// developer's real logins. Exists so the chatgpt factory tests can seed a
// token without copying the path-handling dance from internal/cli.
func newOAuthStoreForTest(t *testing.T) (*oauth.Store, error) {
t.Helper()
t.Setenv("ZERO_OAUTH_STORAGE", "file")
t.Setenv("ZERO_OAUTH_TOKENS_PATH", t.TempDir()+"/tokens.json")
return oauth.NewStore(oauth.StoreOptions{})
}
// codexAccountForKey reads the chatgpt-account-id from the token stored under a
// FIXED key — the key the caller (cli.oauthLoginForProfile) already bound for the
// bearer token, passed through providers.Options.OAuthLoginKey. The account is
// therefore always read from the same login that issued the bearer (no second,
// independent selection). It re-reads per call, so an in-place token refresh (new
// account claim under the SAME key) is picked up; an empty key (no OAuth login)
// or a missing/account-less token yields "".
func TestCodexAccountForKey(t *testing.T) {
store, err := newOAuthStoreForTest(t)
if err != nil {
t.Fatalf("NewStore: %v", err)
}
if err := store.Save(oauth.ProviderKey("chatgpt"), oauth.Token{AccessToken: "tok-1", Account: "acc-catalog-7"}); err != nil {
t.Fatalf("Save chatgpt: %v", err)
}
// A different login WITHOUT an account claim, to prove the read stays on the
// requested key and never crosses to another login's account.
if err := store.Save(oauth.ProviderKey("codex"), oauth.Token{AccessToken: "tok-codex"}); err != nil {
t.Fatalf("Save codex: %v", err)
}
if got := codexAccountForKey(oauth.ProviderKey("chatgpt")); got != "acc-catalog-7" {
t.Fatalf("account = %q, want acc-catalog-7", got)
}
// The bound key's token has no account → "", NOT the other login's account.
if got := codexAccountForKey(oauth.ProviderKey("codex")); got != "" {
t.Fatalf("account = %q, want empty (must not cross to another login's account)", got)
}
// Empty key (no OAuth login) → header omitted.
if got := codexAccountForKey(""); got != "" {
t.Fatalf("account for empty key = %q, want empty", got)
}
// Unknown key → "".
if got := codexAccountForKey(oauth.ProviderKey("nope")); got != "" {
t.Fatalf("account for unknown key = %q, want empty", got)
}
// An in-place refresh under the bound key IS reflected per request.
if err := store.Save(oauth.ProviderKey("chatgpt"), oauth.Token{AccessToken: "tok-refreshed", Account: "acc-rotated"}); err != nil {
t.Fatalf("refresh chatgpt: %v", err)
}
if got := codexAccountForKey(oauth.ProviderKey("chatgpt")); got != "acc-rotated" {
t.Fatalf("account = %q, want acc-rotated (in-place refresh must be picked up)", got)
}
}
+68 -3
View File
@@ -158,10 +158,10 @@ func (provider *Provider) stream(ctx context.Context, body []byte, events chan<-
err = providerio.ScanSSEDataWithContext(streamCtx, cancelStream, response.Body, provider.streamIdleTimeout, func(data string) bool {
return provider.emitPayload(ctx, data, &state, events)
})
if errors.Is(err, providerio.ErrStreamIdle) {
if errors.Is(err, providerio.ErrStreamIdle) || errors.Is(err, providerio.ErrStreamStalled) {
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{
Type: zeroruntime.StreamEventError,
Error: provider.redact(fmt.Sprintf("provider stream error: idle timeout after %s (upstream stopped sending data)", provider.streamIdleTimeout)),
Error: provider.redact("provider stream error: " + providerio.StreamTimeoutMessage(err, provider.streamIdleTimeout)),
})
return
}
@@ -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)
}
}
+43 -9
View File
@@ -59,6 +59,7 @@ const (
responsesEventOutputItemAdded = "response.output_item.added"
responsesEventContentPartAdded = "response.content_part.added"
responsesEventOutputTextDelta = "response.output_text.delta"
responsesEventReasoningDelta = "response.reasoning_summary_text.delta"
responsesEventOutputTextDone = "response.output_text.done"
responsesEventFunctionArgsDelta = "response.function_call_arguments.delta"
responsesEventContentPartDone = "response.content_part.done"
@@ -71,13 +72,25 @@ const (
// responsesRequest is the wire shape POSTed to {baseURL}/responses.
type responsesRequest struct {
Model string `json:"model"`
Instructions string `json:"instructions"`
Input []inputItem `json:"input"`
Stream bool `json:"stream"`
Store bool `json:"store"`
MaxOutputTokens int `json:"max_output_tokens,omitempty"`
Tools []responsesTool `json:"tools,omitempty"`
Model string `json:"model"`
Instructions string `json:"instructions"`
Input []inputItem `json:"input"`
Stream bool `json:"stream"`
Store bool `json:"store"`
MaxOutputTokens int `json:"max_output_tokens,omitempty"`
Tools []responsesTool `json:"tools,omitempty"`
Reasoning *responsesReasoning `json:"reasoning,omitempty"`
}
// responsesReasoning carries the reasoning controls for the Responses API. The
// chat-completions `reasoning_effort` field is nested under `reasoning.effort`
// here; omitted entirely when the caller requests no (or an unsupported) effort.
type responsesReasoning struct {
Effort string `json:"effort,omitempty"`
// Summary requests a streamed reasoning summary ("auto" lets the API pick a
// level). Without it the backend emits no reasoning events, so a long thinking
// phase produces zero visible output and reads as a hang in the UI.
Summary string `json:"summary,omitempty"`
}
// inputItem is one element of the Responses `input` array. The Type field
@@ -242,6 +255,16 @@ func (p *CodexProvider) buildResponsesRequest(request zeroruntime.CompletionRequ
Parameters: tool.Parameters,
})
}
// Codex / o-series reasoning models take the effort tier nested under
// `reasoning` on the Responses API (the chat-completions `reasoning_effort`
// moved here). Reuse the chat normalizer so only API-accepted values are sent
// and an empty or unsupported effort simply omits the field — without this the
// caller's chosen effort was silently dropped for every Codex model.
if effort := openAIReasoningEffort(request.ReasoningEffort); effort != "" {
// Summary "auto" makes the backend stream reasoning_summary_text deltas so a
// long thinking phase shows live progress instead of looking hung.
req.Reasoning = &responsesReasoning{Effort: effort, Summary: "auto"}
}
return req, nil
}
@@ -366,10 +389,10 @@ func (p *CodexProvider) streamResponses(
err = providerio.ScanSSEDataWithContext(streamCtx, cancelStream, response.Body, inner.streamIdleTimeout, func(data string) bool {
return p.emitResponsesEvent(ctx, data, state, events)
})
if errors.Is(err, providerio.ErrStreamIdle) {
if errors.Is(err, providerio.ErrStreamIdle) || errors.Is(err, providerio.ErrStreamStalled) {
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{
Type: zeroruntime.StreamEventError,
Error: p.redact(fmt.Sprintf("provider stream error: idle timeout after %s (upstream stopped sending data)", inner.streamIdleTimeout)),
Error: p.redact("provider stream error: " + providerio.StreamTimeoutMessage(err, inner.streamIdleTimeout)),
})
return
}
@@ -485,6 +508,17 @@ func (p *CodexProvider) emitResponsesEvent(
})
}
return true
case responsesEventReasoningDelta:
// Reasoning summary deltas: surface as live "thinking" so a long reasoning
// phase shows progress (and keeps the activity clock fresh) instead of
// looking like a hang. Requested via reasoning.summary="auto".
if event.Delta != "" {
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{
Type: zeroruntime.StreamEventReasoning,
Content: event.Delta,
})
}
return true
case responsesEventFunctionArgsDelta:
p.handleFunctionArgsDelta(ctx, &event, state, events)
return true
+113
View File
@@ -454,6 +454,119 @@ func TestCodexProviderSendsResponsesRequestShape(t *testing.T) {
}
}
func TestCodexProviderForwardsReasoningEffort(t *testing.T) {
// A reasoning effort must reach the Responses backend nested under
// `reasoning.effort` (where the chat-completions `reasoning_effort` moved).
// Without this the user's chosen effort is silently dropped for Codex models.
var rec codexRequest
srv := newCodexTestServer(t, &rec)
defer srv.Close()
provider, err := NewCodexProvider(CodexOptions{
Options: Options{APIKey: "sk-test", BaseURL: srv.URL, Model: "gpt-5-codex"},
AccountID: "acc-x",
})
if err != nil {
t.Fatalf("NewCodexProvider: %v", err)
}
stream, err := provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "hi"}},
ReasoningEffort: "high",
})
if err != nil {
t.Fatalf("StreamCompletion: %v", err)
}
drainCodexEvents(t, stream)
reasoning, ok := rec.body["reasoning"].(map[string]any)
if !ok {
t.Fatalf("body.reasoning = %#v, want an object carrying the effort", rec.body["reasoning"])
}
if reasoning["effort"] != "high" {
t.Fatalf("body.reasoning.effort = %#v, want high", reasoning["effort"])
}
// A reasoning summary must be requested so the backend streams
// reasoning_summary_text deltas — otherwise a long think shows no live output.
if reasoning["summary"] != "auto" {
t.Fatalf("body.reasoning.summary = %#v, want auto", reasoning["summary"])
}
}
func TestCodexProviderStreamsReasoningSummaryDeltas(t *testing.T) {
// reasoning_summary_text deltas must surface as StreamEventReasoning (live
// "thinking"), in order, alongside the normal text output. Without this a long
// reasoning phase produces zero visible output and reads as a hang.
var rec codexRequest
srv := newCodexResponsesServer(t, &rec,
`{"type":"response.created","response":{"id":"resp-1","status":"in_progress"}}`,
`{"type":"response.reasoning_summary_text.delta","delta":"Thinking. "}`,
`{"type":"response.reasoning_summary_text.delta","delta":"Planning."}`,
`{"type":"response.output_text.delta","delta":"done"}`,
`{"type":"response.completed","response":{"id":"resp-1","status":"completed"}}`,
)
defer srv.Close()
provider, err := NewCodexProvider(CodexOptions{
Options: Options{APIKey: "sk-test", BaseURL: srv.URL, Model: "gpt-5"},
AccountID: "acc-x",
})
if err != nil {
t.Fatalf("NewCodexProvider: %v", err)
}
stream, err := provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "solve it"}},
ReasoningEffort: "high",
})
if err != nil {
t.Fatalf("StreamCompletion: %v", err)
}
var reasoning, text []string
for ev := range stream {
switch ev.Type {
case zeroruntime.StreamEventReasoning:
reasoning = append(reasoning, ev.Content)
case zeroruntime.StreamEventText:
text = append(text, ev.Content)
case zeroruntime.StreamEventError:
t.Fatalf("unexpected error event: %q", ev.Error)
}
}
if got := strings.Join(reasoning, ""); got != "Thinking. Planning." {
t.Fatalf("reasoning deltas = %q, want %q", got, "Thinking. Planning.")
}
if got := strings.Join(text, ""); got != "done" {
t.Fatalf("text deltas = %q, want %q", got, "done")
}
}
func TestCodexProviderOmitsReasoningWhenUnset(t *testing.T) {
// No effort (and unsupported values) must omit the `reasoning` field entirely
// rather than send an empty object, which the backend would reject.
var rec codexRequest
srv := newCodexTestServer(t, &rec)
defer srv.Close()
provider, err := NewCodexProvider(CodexOptions{
Options: Options{APIKey: "sk-test", BaseURL: srv.URL, Model: "gpt-5-codex"},
AccountID: "acc-x",
})
if err != nil {
t.Fatalf("NewCodexProvider: %v", err)
}
stream, err := provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "hi"}},
ReasoningEffort: "", // auto
})
if err != nil {
t.Fatalf("StreamCompletion: %v", err)
}
drainCodexEvents(t, stream)
if _, ok := rec.body["reasoning"]; ok {
t.Fatalf("body.reasoning must be omitted when no effort is requested, got %#v", rec.body["reasoning"])
}
}
func TestCodexProviderRetriesHeadersAfter401(t *testing.T) {
var hits atomic.Int32
var rec1, rec2 codexRequest
+21 -11
View File
@@ -102,10 +102,11 @@ func New(options Options) (*Provider, error) {
endpoint = baseURL + "/chat/completions"
}
httpClient := options.HTTPClient
if httpClient == nil {
httpClient = http.DefaultClient
}
// Route through providerio.HTTPClient so a missing client gets the shared,
// stall-hardened transport (bounded response-header wait + shorter idle-conn
// reuse) rather than the raw http.DefaultClient, which hangs on a reused-dead
// pooled connection (the macOS-only "both providers stall" bug).
httpClient := providerio.HTTPClient(options.HTTPClient)
maxTokens := options.MaxTokens
if maxTokens < 0 {
@@ -218,12 +219,12 @@ func (provider *Provider) stream(ctx context.Context, body []byte, events chan<-
err = providerio.ScanSSEDataWithContext(streamCtx, cancelStream, response.Body, provider.streamIdleTimeout, func(data string) bool {
return provider.emitPayload(ctx, data, state, events)
})
if errors.Is(err, providerio.ErrStreamIdle) {
if errors.Is(err, providerio.ErrStreamIdle) || errors.Is(err, providerio.ErrStreamStalled) {
state.flushBufferedContent(events)
state.closeBufferedOpen(events)
sendEvent(ctx, events, zeroruntime.StreamEvent{
Type: zeroruntime.StreamEventError,
Error: provider.redact(fmt.Sprintf("provider stream error: idle timeout after %s (upstream stopped sending data)", provider.streamIdleTimeout)),
Error: provider.redact("provider stream error: " + providerio.StreamTimeoutMessage(err, provider.streamIdleTimeout)),
})
return
}
@@ -385,6 +386,14 @@ func sendBufferedEvent(events chan<- zeroruntime.StreamEvent, event zeroruntime.
func (provider *Provider) openAIRequest(request zeroruntime.CompletionRequest) chatCompletionRequest {
messages := make([]chatMessage, 0, len(request.Messages))
for _, message := range request.Messages {
// Drop a degenerate assistant turn that carries neither text nor tool calls
// (e.g. a sub-agent that failed with no output). The Anthropic/Gemini mappers
// already skip empty turns; without this, the contentless message reaches
// strict OpenAI-compatible servers and is rejected.
if message.Role == zeroruntime.MessageRoleAssistant &&
strings.TrimSpace(message.Content) == "" && len(message.ToolCalls) == 0 {
continue
}
messages = append(messages, mapMessage(message))
}
@@ -445,11 +454,12 @@ func mapMessage(message zeroruntime.Message) chatMessage {
// message that happens to carry Images keeps the plain string/nil content
// path (its images are simply not serialized).
if len(message.Images) == 0 || message.Role != zeroruntime.MessageRoleUser {
// Preserve the legacy behavior: only non-empty text is serialized, so
// empty content is omitted by the `omitempty` tag.
if message.Content != "" {
mapped.Content = message.Content
}
// Always set content (to "" when empty) so it serializes as `"content":""`
// rather than being dropped. Strict OpenAI-compatible servers reject a
// message with no content field; tool results and assistant-with-tool-calls
// turns must still carry an (empty) content. Truly empty assistant turns are
// dropped upstream in openAIRequest.
mapped.Content = message.Content
} else {
parts := make([]contentPart, 0, len(message.Images)+1)
if message.Content != "" {
+65 -85
View File
@@ -1000,57 +1000,6 @@ func TestStreamCompletionEmitsDroppedOnNamelessToolCall(t *testing.T) {
}
}
func TestOpenAIRequestTextOnlyOmitsEmptyContent(t *testing.T) {
provider, err := New(Options{Model: "gpt-test"})
if err != nil {
t.Fatalf("New returned error: %v", err)
}
got, err := json.Marshal(provider.openAIRequest(zeroruntime.CompletionRequest{
Messages: []zeroruntime.Message{
{Role: zeroruntime.MessageRoleSystem, Content: "system"},
{Role: zeroruntime.MessageRoleUser, Content: "hello"},
{
Role: zeroruntime.MessageRoleAssistant,
Content: "", // assistant turn that is *only* a tool call -> empty content must drop
ToolCalls: []zeroruntime.ToolCall{{
ID: "call_1",
Name: "read_file",
Arguments: `{"path":"README.md"}`,
}},
},
{Role: zeroruntime.MessageRoleTool, Content: "contents", ToolCallID: "call_1"},
},
}))
if err != nil {
t.Fatalf("marshal request: %v", err)
}
// String content round-trips as a JSON string, and empty content is omitted.
if !strings.Contains(string(got), `"content":"hello"`) {
t.Fatalf("user content not serialized as string: %s", got)
}
if strings.Contains(string(got), `"content":""`) {
t.Fatalf("empty assistant content must be omitted, got: %s", got)
}
// Decode the assistant message and assert no content key at all.
var decoded chatCompletionRequest
var raw struct {
Messages []map[string]any `json:"messages"`
}
if err := json.Unmarshal(got, &raw); err != nil {
t.Fatalf("unmarshal request: %v", err)
}
_ = decoded
if _, present := raw.Messages[2]["content"]; present {
t.Fatalf("assistant (tool-only) message must omit content key, got: %#v", raw.Messages[2])
}
if raw.Messages[1]["content"] != "hello" {
t.Fatalf("user content = %#v, want \"hello\"", raw.Messages[1]["content"])
}
}
func TestContentPartImageURLMarshalsDataURI(t *testing.T) {
parts := []contentPart{
{Type: "text", Text: "look"},
@@ -1076,38 +1025,6 @@ func TestContentPartImageURLMarshalsDataURI(t *testing.T) {
}
}
func TestOpenAIRequestEmptyContentStaysOmittedAfterAnyRefactor(t *testing.T) {
provider, err := New(Options{Model: "gpt-test"})
if err != nil {
t.Fatalf("New returned error: %v", err)
}
got, err := json.Marshal(provider.openAIRequest(zeroruntime.CompletionRequest{
Messages: []zeroruntime.Message{
{
Role: zeroruntime.MessageRoleAssistant,
Content: "",
ToolCalls: []zeroruntime.ToolCall{{
ID: "call_1",
Name: "read_file",
Arguments: `{}`,
}},
},
},
}))
if err != nil {
t.Fatalf("marshal: %v", err)
}
var raw struct {
Messages []map[string]any `json:"messages"`
}
if err := json.Unmarshal(got, &raw); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if _, present := raw.Messages[0]["content"]; present {
t.Fatalf("empty content boxed in any must stay omitted, got: %#v", raw.Messages[0])
}
}
func TestMapMessageBuildsImageURLContentParts(t *testing.T) {
msg := mapMessage(zeroruntime.Message{
Role: zeroruntime.MessageRoleUser,
@@ -1165,8 +1082,8 @@ func TestMapMessageTextOnlyKeepsStringContent(t *testing.T) {
t.Fatalf("Content = %#v, want string \"hi\"", msg.Content)
}
empty := mapMessage(zeroruntime.Message{Role: zeroruntime.MessageRoleAssistant, Content: ""})
if empty.Content != nil {
t.Fatalf("empty text content = %#v, want nil so omitempty drops it", empty.Content)
if got, ok := empty.Content.(string); !ok || got != "" {
t.Fatalf("empty text content = %#v, want \"\" so it serializes as content:\"\" (strict servers reject a missing/null content)", empty.Content)
}
}
@@ -1246,3 +1163,66 @@ func TestStreamCompletionSerializesImageContentParts(t *testing.T) {
t.Fatalf("image url = %#v, want data:image/png;base64,QUJD", imageURL["url"])
}
}
// TestOpenAIRequestEmptyContentHandling locks in the fix for strict OpenAI-
// compatible servers (e.g. glm-* on Ollama-cloud) that reject a message whose
// `content` is absent/null with "invalid message content type: <nil>": a
// contentless assistant turn with no tool calls is dropped, and every other
// message serializes an explicit `"content"` field (empty string when there is
// no text) instead of omitting it.
func TestOpenAIRequestEmptyContentHandling(t *testing.T) {
provider, err := New(Options{Model: "gpt-test"})
if err != nil {
t.Fatalf("New returned error: %v", err)
}
req := provider.openAIRequest(zeroruntime.CompletionRequest{
Messages: []zeroruntime.Message{
{Role: zeroruntime.MessageRoleUser, Content: "hi"},
// Degenerate empty assistant turn (e.g. a sub-agent that failed with no
// output): must be dropped, not sent.
{Role: zeroruntime.MessageRoleAssistant, Content: " "},
// Assistant with tool calls but no text: kept, content present as "".
{Role: zeroruntime.MessageRoleAssistant, Content: "", ToolCalls: []zeroruntime.ToolCall{{
ID: "call_1", Name: "read_file", Arguments: "{}",
}}},
// Tool result with empty content: kept, content present as "".
{Role: zeroruntime.MessageRoleTool, Content: "", ToolCallID: "call_1"},
},
})
if len(req.Messages) != 3 {
t.Fatalf("empty assistant turn should be dropped; got %d messages: %#v", len(req.Messages), req.Messages)
}
// No message may serialize with an absent/null content field.
for i, message := range req.Messages {
if message.Content == nil {
t.Fatalf("message %d has nil content (would serialize as null/omitted): %#v", i, message)
}
data, err := json.Marshal(message)
if err != nil {
t.Fatalf("marshal message %d: %v", i, err)
}
if !strings.Contains(string(data), `"content":`) {
t.Fatalf("message %d omits the content field: %s", i, data)
}
}
if req.Messages[0].Content != "hi" {
t.Fatalf("user content = %#v, want \"hi\"", req.Messages[0].Content)
}
// The kept-but-textless messages must send content as an explicit empty string.
for _, idx := range []int{1, 2} {
data, err := json.Marshal(req.Messages[idx])
if err != nil {
t.Fatalf("marshal message %d: %v", idx, err)
}
if !strings.Contains(string(data), `"content":""`) {
t.Fatalf("message %d should send content:\"\", got %s", idx, data)
}
}
if len(req.Messages[1].ToolCalls) != 1 {
t.Fatalf("assistant tool calls dropped: %#v", req.Messages[1])
}
}
+7 -2
View File
@@ -18,8 +18,13 @@ type streamOptions struct {
}
type chatMessage struct {
Role string `json:"role"`
Content any `json:"content,omitempty"`
Role string `json:"role"`
// Content has no omitempty: strict OpenAI-compatible servers (e.g. some
// Ollama-cloud models like glm-*) reject a message whose `content` is absent
// or null with "invalid message content type: <nil>". mapMessage always sets
// this (to "" when there's no text), so a contentless message serializes as
// `"content":""` rather than being dropped.
Content any `json:"content"`
ToolCalls []requestToolCall `json:"tool_calls,omitempty"`
ToolCallID string `json:"tool_call_id,omitempty"`
}
+131 -12
View File
@@ -6,10 +6,12 @@ import (
"errors"
"fmt"
"io"
"math"
"net/http"
"net/url"
"os"
"regexp"
"runtime"
"strconv"
"strings"
"time"
@@ -23,6 +25,26 @@ const maxSSELineBytes = 16 * 1024 * 1024
// closing the connection. Callers surface it as an idle-timeout error.
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 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
// error (ErrStreamIdle or ErrStreamStalled), given the configured idle timeout.
// Callers prepend their own "provider stream error: " prefix. A stalled stream
// gets a distinct, actionable message — it did NOT stop sending data (keep-alives
// kept arriving); it just produced no output, which usually means the model is
// 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)", ContentStallTimeout(idleTimeout))
}
return fmt.Sprintf("idle timeout after %s (upstream stopped sending data)", idleTimeout)
}
// DefaultStreamIdleTimeout is the single source of truth for how long every
// provider waits on a silent stream before aborting it. The watchdog only fires
// on genuine silence — SSE keep-alive comments reset it (see
@@ -33,6 +55,33 @@ var ErrStreamIdle = errors.New("idle timeout (upstream stopped sending data)")
// Override globally with ZERO_STREAM_IDLE_TIMEOUT.
const DefaultStreamIdleTimeout = 5 * time.Minute
// 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
// ("300"). A value of "0", "off", "none", or "disabled" turns the watchdog off
@@ -77,12 +126,58 @@ func NormalizeBaseURL(baseURL string, defaultBaseURL string, label string) (stri
return baseURL, nil
}
// HTTPClient returns the configured client or the process default.
// sharedHTTPClient is the process-wide client used when a provider supplies none.
// It tunes the default transport to defeat the stale-pooled-connection hang: Go
// keeps idle keep-alive connections in a pool, and a later request can reuse one
// the server/NAT has silently dropped. Because the model call is a POST (non-
// idempotent), Go will NOT auto-retry it on a fresh connection — so it blocks
// forever waiting for a response that never arrives. This is provider-agnostic
// (every provider shares the default transport), which is why it reproduced on
// BOTH chatgpt and ollama, and it surfaces on macOS far more than Linux because
// macOS keeps dead pooled connections around longer.
//
// - ResponseHeaderTimeout bounds the gap between finishing the request and the
// first response header, so a reused-dead connection fails fast (then the
// reconnect path re-dials) instead of hanging. It does NOT bound streaming
// after the 200 header arrives, so slow first tokens / long reasoning are
// 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
// 200 response header until the upstream model emits a first token, so a 60s cap
// risked aborting a legitimately-slow-but-alive request. 120s still bounds a
// truly dead reused connection (which never responds) while tolerating slow
// header delivery; slow first tokens after the header are covered by the idle +
// content-stall watchdogs.
transport.ResponseHeaderTimeout = 120 * time.Second
transport.IdleConnTimeout = 30 * time.Second
transport.DisableKeepAlives = runtime.GOOS == "darwin"
return &http.Client{Transport: transport}
}()
// HTTPClient returns the configured client or the shared, stall-hardened default.
func HTTPClient(client *http.Client) *http.Client {
if client != nil {
return client
}
return http.DefaultClient
return sharedHTTPClient
}
// SendEvent writes a provider event without blocking cancellation cleanup.
@@ -202,21 +297,36 @@ func ScanSSEDataWithContext(
// The idle watchdog is optional. When idleTimeout <= 0 it is disabled, but we
// STILL run the goroutine + select loop so ctx cancellation is always honored
// (a nil idleC channel simply never fires in the select).
var idleC <-chan time.Time
var idleC, contentC <-chan time.Time
resetIdle := func() {}
resetContent := func() {}
if idleTimeout > 0 {
// reset drains the timer's channel if it already fired before re-arming, so
// a stale tick can't trip the watchdog after activity resumed.
reset := func(t *time.Timer, d time.Duration) func() {
return func() {
if !t.Stop() {
select {
case <-t.C:
default:
}
}
t.Reset(d)
}
}
idle := time.NewTimer(idleTimeout)
defer idle.Stop()
idleC = idle.C
resetIdle = func() {
if !idle.Stop() {
select {
case <-idle.C:
default:
}
}
idle.Reset(idleTimeout)
}
resetIdle = reset(idle, idleTimeout)
// 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 := ContentStallTimeout(idleTimeout)
content := time.NewTimer(contentTimeout)
defer content.Stop()
contentC = content.C
resetContent = reset(content, contentTimeout)
}
for {
@@ -232,6 +342,12 @@ func ScanSSEDataWithContext(
// a timeout instead of blocking the agent forever.
cancel()
return ErrStreamIdle
case <-contentC:
// Upstream kept heartbeating (idle watchdog never fired) but produced
// no output for the content window. Treat as stalled and abort rather
// than hanging forever.
cancel()
return ErrStreamStalled
case item, ok := <-payloads:
if !ok {
// Reader finished: deliver its terminal status (EOF -> nil,
@@ -246,8 +362,11 @@ func ScanSSEDataWithContext(
}
resetIdle()
if item.keepAlive {
// A heartbeat keeps the connection "alive" (idle watchdog) but is
// NOT output, so it must not reset the content watchdog.
continue
}
resetContent()
if !handle(item.data) {
// The provider asked to stop (e.g. it already emitted an error
// for this payload). Abort the read and end like ScanSSEData:
Loaded 100 of 207 files, more files were not shown because too many files have changed in this diff. Show more