Compare commits

...
Author SHA1 Message Date
KRATOS 0919a848d1 fix(oauth): refuse redirects on credential POSTs (#729)
Token exchange/refresh and the device authorization/poll POSTs validated only
the initial endpoint, then let the http.Client follow a 307/308 that replays the
form body (code, PKCE verifier, refresh_token, client_secret) to an unvalidated
origin.

Add withoutRedirects(): a shallow client copy whose CheckRedirect refuses every
redirect (ErrUnsafeRedirect). Applied at PostToken (covers ExchangeCode, Refresh,
and MCP which delegate to it), RequestDeviceCode, and pollDeviceOnce. The caller's
client is never mutated; legitimate token endpoints that return 200 are unaffected.
2026-07-19 18:16:13 +05:30
KRATOSandKRATOS ce4a996ffa fix(oauth): validate discovered endpoints before merge/use (#511) (#739)
Discovery metadata was merged without the https/loopback endpoint rule that
configured endpoints already pass, letting a malicious issuer downgrade the
authorization/token/device/registration endpoint after config validation.

- Add exported ValidateEndpointURL as the single endpoint-safety rule;
  validateTokenEndpoint now delegates to it.
- Backstop in BuildAuthorizationURL (the choke point both the provider and MCP
  flows build their browser URL through).
- Validate each discovered endpoint before merge in the provider resolveEndpoints
  and validate resolved MCP metadata in both branches; fail closed on insecure.

Co-authored-by: KRATOS <[email protected]>
2026-07-19 09:56:32 +05:30
Vasanth T 378d538e24 perf(agent): execution profiles with one-shot posture escalation (PR10b+PR10c) (#740)
* perf(agent): execution profiles, --exec-profile, bench passthrough, TUI /profile (PR10b+10c)

* fix(execprofile): harden profiles against review findings

- fast escalates on critical-risk mutations, not high: the sandbox marks
  every shell command high before command analysis, so a high trigger
  spent the one-shot on the first go test of any task
- escalation can now restore the provider-default effort a profile
  displaced (PostureEscalation.RestoreDefaultEffort; a plain empty
  target means leave untouched)
- TUI /turns while a profile is active pins the budget: disarms the
  escalation turn target (mirroring exec's explicit --max-turns rule)
  and profile reverts use touched bits instead of value equality, so
  explicit choices survive even when they coincide with profile values
- spec-draft runs get the same escalation policy as the main run
  instead of the displaced budget with no safety net
- bench validates and canonicalizes --exec-profile before spawning:
  an unknown name previously produced an exit-0 report full of
  millisecond usage-error walls
- turn summary prints exec-profile alongside mode; zero exec rejects
  an inline-empty --exec-profile=; balanced trace label documented and
  pinned as deliberate attribution

* fix(execprofile): explicit effort choices disarm the escalation effort restore

An explicit effort set AFTER profile selection could be cleared by a
mid-run escalation (the effort analog of the /turns pin, introduced by
RestoreDefaultEffort): TUI /effort and Ctrl+T now disarm the restore on
a policy clone, and the spec-draft path only arms it when no explicit
--spec-reasoning-effort governs the draft.

* fix(execprofile): address review findings on the profile surfaces

- --use-spec projects away a profile's self-correct knob instead of
  silently arming an option the draft runner ignores (the explicit
  --self-correct --use-spec combination is already a parse-time error)
- RunTurnBench canonicalizes and validates ExecProfile at the library
  boundary, so a direct caller's casing/whitespace cannot fork report
  labels and an unknown name fails before anything runs
- reverting a profile whose displaced budget was zero clears
  ZERO_MAX_TURNS (SetMaxTurnsEnv ignores zero, so sub-agents would have
  kept the removed profile's budget)
- the TUI effort-disarm test fails instead of skipping when the catalog
  stops filling low effort

* fix(tui): reconcile an active profile's effort fill on model switch

The fill is per-model, not a session preference: fast selected on a
model without low fills nothing, and switching to a model that supports
it should behave like selecting the profile there; the reverse switch
must not keep sending a level the destination does not support. Both
switch paths (the /model command and the cross-provider picker) now
re-derive the fill, leaving explicitly touched efforts alone and
keeping the escalation's effort restore in step. Also: the revert-env
test asserts key removal via LookupEnv, and applyProfileTurnBudget's
pinned-budget parameter is named and documented as covering mode
presets, not just the flag.

* fix(tui): void profile effort bookkeeping when the model-switch drop erases it

The /model path's pre-existing unsupported-effort drop ran before the
profile reconciliation and left contradictory state behind: a dropped
touched override kept blocking the profile from governing, and a
dropped profile fill left a stale applied value with the effort restore
still armed. The drop itself is load-bearing (the TUI run path forwards
the effort to the provider unfiltered), so instead of preserving a
known-unsupported level, the voided choice now resets the profile's
effort bookkeeping and the reconcile re-derives from scratch. The whole
sequence lives in one ring-parameterized helper driven by the switch
target's authoritative ring, unit-tested against synthetic rings for
both the void-then-refill arc and the supported-override survival.

* fix(tui): only drop an explicit effort when the target ring is authoritative

A nil ring meant both a catalog model with no effort controls and a
live-discovered custom model the catalog cannot vouch for. The drop now
requires an authoritative ring (a resolved catalog entry, whose ring may
legitimately be empty); an unknown ring preserves an explicit preference,
matching the cross-provider picker's behavior, while the profile's own
fill still applies only where support is known.
2026-07-19 00:53:17 +05:30
Vasanth T 015452c1c9 fix(perfbench): keep the stamped answer file out of negative oracle greps (#737)
* fix(perfbench): keep the stamped answer file out of negative oracle greps

* fix(perfbench): exclude stamped answer from positive oracle greps too
2026-07-18 22:54:02 +05:30
Vasanth T af875df587 perf(agent): posture-escalation signals and controller (PR10a) (#736)
* trace(perf): add posture escalation counter

* feat(agent): typed posture-escalation policy and tool-result risk field

* feat(agent): posture controller wiring and executed-risk stamping

* test(agent): cover posture controller, escalation act path, and risk stamping

* test(agent): pin zero risk on not-executed results

* fix(agent): wire uncertain-path escalation and validate risk thresholds
2026-07-18 22:32:02 +05:30
Vasanth T dbd9443014 fix(perfbench): absolutize the bench binary and make errored tasks first-class (#730)
* fix(perfbench): absolutize the bench binary and make errored tasks first-class

* fix(perfbench): state that errored tasks count as pass-rate failures

* fix(perfbench): align errored-task wording with the actual tier accounting

* chore(perfbench): never commit generated baseline reports
2026-07-18 21:39:52 +05:30
Anandan 0fae754460 docs: refresh repository agent guidelines (#734)
* docs: refresh repository agent guidelines

* docs: make validation guidance non-mutating
2026-07-18 21:37:36 +05:30
Vasanth T 2cc6f43bf7 docs(community): wire Discussions as the front door for questions and ideas (#732)
* docs(community): wire Discussions as the front door for questions and ideas

* docs(community): restore LF endings and make feature routing coherent
2026-07-18 21:34:58 +05:30
uma-prasadandCursor 77960229b8 fix(doctor): detect missing native binary during runtime checks (#450)
* fix(doctor): report missing npm native binary in wrapper

Co-authored-by: Cursor <[email protected]>

* fix(doctor): preserve JSON output for missing native binary

* fix(doctor): preserve doctor CLI behavior in wrapper fallback

Co-authored-by: Cursor <[email protected]>

* style: gofmt npmwrapper doctor fallback tests

Co-authored-by: Cursor <[email protected]>

* fix(doctor): reuse shared Bun recovery helpers in wrapper fallback

Extract bunRecoveryParagraph and related helpers so the doctor missing-binary text path and the generic wrapper bail share the same copy. Add regression tests with ZERO_WRAPPER_SIMULATE_BUN to guard against future drift.

Co-authored-by: Cursor <[email protected]>

---------

Co-authored-by: Cursor <[email protected]>
2026-07-18 22:49:19 +08:00
Vasanth T 60dc84e7a3 perf(openai): optimized turn session — background prewarm and prefix telemetry (PR8) (#723)
* trace(perf): add provider prewarm span and prefix-stability counters

* feat(openai): optimized turn session with prewarm and prefix telemetry

* feat(providers): gate optimized OpenAI turn sessions behind env flag

* feat(cli): wire optimized turn sessions into headless exec

* docs(readme): document the optimized OpenAI turn-session toggle

* fix(openai): include tool descriptions in prefix digest, harden gate and test bounds

* fix(openai): isolate prewarm tracing, skip non-reusable transports, fingerprint wire order and cache key

* test(openai): pin keep-alive transports in probe-expecting tests

* feat(providers): preserve resolved capabilities on switched-model fallback sessions
2026-07-18 14:24:50 +05:30
Vasanth T 30e2c3f7ff perf(providers): provider capabilities and default turn-session adapter (PR7) (#720)
* perf(providers): add provider capabilities and default turn-session adapter (PR7)

* fix(providers): project effective reasoning efforts into capabilities

* feat(agent): carry optimized turn sessions across mid-run model switches

* test(agent): cover swap-time session open failure
2026-07-18 09:33:07 +05:30
Anandan 18cce358a2 Add deterministic completion policy (#719)
Extract completion decisions from the agent loop into a feature-gated typed policy with complete, incomplete, and uncertain outcomes. Preserve bounded plan-stall nudges and allow at most one semantic acceptance check for self-correcting runs.

Tested: make build

Tested: make test

Tested: go fmt ./...

Tested: go vet ./...

Tested: make lint

Tested: govulncheck ./...

Note: repository-wide pinned golangci-lint reports 36 pre-existing unrelated findings; internal/agent/... reports 0 issues.
2026-07-18 00:29:58 +05:30
KRATOSandKRATOS e5670c427f perf(output): add token-aware semantic output budgeting (PR11) (#717)
* perf(output): add token-aware budget contract

* perf(output): add semantic retention policies

* perf(output): budget results after redaction

* perf(output): propagate tool truncation metadata

* perf(output): trace semantic budget decisions

* perf(output): classify core tool output

* docs(output): describe semantic output budgeting

* test(output): make spill fallback portable on windows

* fix(output): address semantic budgeting review findings

* test(output): make hook budgeting tests portable

* fix(output): retain oversized single-line file content

* fix(output): apply semantic budgets to captured output

* fix(output): preserve hook budgets and search paths

* fix(output): preserve alias and truncation metadata

* test(trace): validate output budget metadata

---------

Co-authored-by: KRATOS <[email protected]>
2026-07-18 00:02:43 +05:30
KRATOSandKRATOS 31d45d5f14 perf(agent): concurrent read-only tool batches via capability gate (#715)
Upgrade the parallel tool planner from SideEffectRead probes to the PR5
CapabilitiesOf contract: EffectReadOnly + ThreadSafe + auto-allowed, with
resource-key conflict boundaries so same-path reads stay sequential.

Mark audited pure reads ThreadSafe (read_file, read_minified_file,
list_directory, glob, grep, skill, web_fetch). Glob/grep use scopedScan
directory keys (nil for workspace-wide "." so scans do not false-conflict).

Tests cover the capability gate, key conflicts, extendParallelRun windows,
and catalog ThreadSafe audit for concurrent-safe reads.

Co-authored-by: KRATOS <[email protected]>
2026-07-17 17:52:11 +05:30
Vasanth T 727ad4d321 perf(turn-bench): Phase 0 — strengthen oracles so pass rate can't be misread as correctness (#712)
* perf(turn-bench): Phase 0 — strengthen oracles so pass rate can't be misread as correctness

The baseline manifest's pass/fail contract had three holes that let a weak
or no-op agent read as a pass:

  - edit grep oracles were substring checks: a reworded line (e.g.
    fmt.Println("debug: starting") -> fmt.Println("starting up")) passed
    `! grep 'debug: starting'`.
  - refactor lived in the build tier with a non-positive `go build ./...`,
    so a no-op refactor passed.
  - the 24 read-only tasks (nav/longproc/longctx/parallel) had no oracle;
    a zero-exit only proved the turn ran.

This makes the tiers honest:

  - edit/refactor now carry a stamped oracle_test.go compiled by
    `go test ./...` — the Go compiler is the structural verifier, so a
    no-op refactor, a missing field, or a reworded-but-not-removed line
    fails to compile/run. The test is stamped into the fixture copy AFTER
    the agent run, so it can't interfere with the agent's own build or be
    pre-seen.
  - nav gains an answer-oracle: the harness captures the agent's final
    answer (.zero-answer.txt) and greps it for determinable facts. Count
    tasks (nav-04/05/08) use a structured `count: N` token so the oracle is
    tight rather than a loose prose-contains on "0".
  - refactor graduates from build to correctness (its oracle is now
    positive); nav graduates from latency-only to correctness. The build
    tier is empty (buildOnlyClasses: []).
  - longproc/longctx/parallel stay permanently latency-only — there is no
    deterministic oracle for "report whether the build succeeds" or
    "summarize a large file"; documented in MANIFEST.md.

Tier counts: correctness 34 (edit 10 + fix 8 + nav 10 + refactor 6),
build 0, latency 14. Total 48 unchanged.

TurnSchemaVersion 2 -> 3: the result shape is unchanged (only class-list
membership shifts) but correctnessPassRate now means something strictly
stronger, so the bump prevents a v2->v2 cross-version comparison from
misreading the jump as a model improvement.

Prerequisite fix: the mutating fixtures had no go.mod, so `go test ./...`
in the temp copy failed with "cannot find main module". Added go.mod per
fixture (edit/fix/refactor/nav) and moved copyFixture's temp dir one level
below the system temp root — Go ignores go.mod in direct children of the
temp dir (a hijack guard), which would have broken every compiler-backed
oracle. This also fixes the pre-existing fix/refactor oracles, which were
silently broken because the test suite used fake runners that never ran
go test.

Tests: stub-binary gating tests prove each oracle fails the wrong thing
(no-op refactor, missing field, wrong nav answer, reworded debug print) and
passes the right thing (real refactor, added field, correct answer); an
edit-01 test proves the scoped negative grep doesn't false-fail a correct
rename that leaves the doc comment. Aggregation, schema, and unit tests
cover the v3 tier model and the answer-capture helper.

* Address CodeRabbit review on PR #712: tighten oracles + isolation

- nav count oracles (nav-01/04/05/08): anchored line-exact
  `^count: N$` with the fixture's exact expected count, so a substring
  like "the count: 0" or a wrong count like "count: 3" fails instead
  of rubber-stamping. nav-01 added to the structured-count contract.
- stampOracleAndAnswer now errors when a task has an oracle or
  verification but no workspace fixture, so verification can't run in
  the caller's cwd with the oracle never compiled (false-pass closed).
- copyFixture uses a unique 0700 os.MkdirTemp parent under the temp
  root and nests the copy beneath it (grandchild of temp root), so the
  Go go.mod hijack guard is satisfied and cleanup removes the whole
  parent. Avoids a predictable shared world-readable dir.
- Rejection tests require a non-empty VerifyErr via a shared
  assertVerifyFailed helper, proving the failure came from
  verification rather than a harness error or a silent non-pass.
- Unify streamJSONExitCode + streamJSONFinalText behind one
  parseStreamJSON parser (behavior-preserving; scanner limits and
  malformed-event handling now live in one place).

* Address gnanam1990 review on PR #712: close count=0 gameability + stronger oracles

- nav count tasks (nav-04/05/08) had a count=0 ground truth, so an agent
  that never opened the workspace and always emitted "count: 0" passed
  three correctness tasks -- the exact pass-rate-misread-as-correctness
  failure this PR closes. nav-04 now has a real TestGreet (count 1) and
  nav-05 a real TODO (count 1); nav-01's file count moves to 5. nav-08
  stays at 0 (a real third-party import would need a network fetch and
  break the offline suite) but its oracle now requires the answer to name
  both stdlib imports (fmt from main.go, testing from main_test.go), so a
  blind count:0 or a main.go-only read fails. nav-08's prompt now asks the
  agent to enumerate and classify every import, so naming the stdlib
  imports is part of a correct answer, not a false-fail.
- refactor-05: stamp a behavioral oracle (TestGreetWrapped) so deleting
  both Wrapper and greetWrapped, or stubbing the caller to return "",
  fails to compile/run. The negative grep alone passed that wrong edit.
- refactor-04: replace the brittle exact-text shell grep with a stamped
  file-reading oracle using a spacing-tolerant regex, so a bare
  map[string]int decl under any spacing/:= variant can't dodge it, and
  the Stats type must be referenced in main.go.
- edit-05: also assert the "debug: starting" message text is absent, so
  a respell onto fmt.Printf / backticks / log.Println / println that
  still prints the message fails (the fmt.Println(" check missed those).
- v3 doc comments on ClassSummary / TurnBenchResult /
  FormatTurnBenchSummary now describe the v3 tiers (correctness =
  edit/fix/nav/refactor, build empty, latency = longproc/longctx/
  parallel) instead of the stale v2 model.
- stampOracleAndAnswer failure now mutates the existing outcome instead
  of returning a fresh one, so the already-parsed trace and trace issue
  survive a stamp error (attribution is preserved, pass/fail unchanged).
- Schema comment provenance: "PR #701" -> neutral "Phase 0 oracle
  hardening" (this PR is #712).
- New gating + satisfiable stub tests for refactor-04/05 and edit-05, and
  count-oracle zero-guess / inspection-proof tests for nav-04/05/08.

* Fix macOS CI: copyFixture isolation test was slash-sensitive

TestCopyFixtureIsolatesSourceFromMutation compared filepath.Dir(parent)
against os.TempDir() verbatim. On macOS $TMPDIR ends in a trailing slash
("/var/folders/.../T/") while filepath.Dir strips it, so the equality
false-failed even though the parent is genuinely a direct child of the
temp root. Linux (/tmp, no slash) and Windows (no slash in this Go
build) matched by accident.

The production copyFixture nesting is correct on all platforms (the
grandchild copy sits one level below the temp root, satisfying Go's
go.mod hijack guard); only the assertion was slash-sensitive. Normalize
both sides with filepath.Clean.

* Close nav-04/05 blind-count-one gameability (anandh8x review)

nav-04 and nav-05 had a count-only oracle (anchored '^count:1$'). Once the
ground truth became 1, an agent that never inspects the fixture and always
emits 'count: 1' passed both — the blind-count-one surface the prior
non-zero-ground-truth fix opened. Pair the count with a workspace-derived
named fact that proves inspection, mirroring nav-08's fmt+testing:

- nav-04 now also requires 'TestGreet' (the one test function in
  main_test.go) — a specific, non-generic name a blind agent can't produce
  without reading main.go to learn a greet function exists. Fully closes
  the blind-count-one surface.
- nav-05 now also requires 'main.go' (the file holding the one TODO).
  main.go is the conventional entrypoint and the most guessable filename, so
  this reduces rather than fully closes the surface — a blind 'count: 1, in
  main.go' can still pass. Accepted for Phase 0: the blind floor drops below
  100% (a bare 'count: 1' fails), so a model still moves the pass rate by
  inspecting. MANIFEST.md documents this residual honestly.

Two gating tests per task: RejectsBlindCountOne (bare 'count: 1' now fails)
and, for nav-05, RejectsSubstring (main.go present but unanchored count
still fails — parity with nav-04). Existing accept tests still pass (their
answers already name TestGreet / main.go).

Doc nits also fixed: MANIFEST.md #701 -> #712; refactor-05 removed from the
'no oracleTest' inventory (it now has TestGreetWrapped); the nav-01 named-
fact prose corrected to the three files the oracle actually anchors on;
test comment #701 -> #712.
2026-07-17 17:32:45 +05:30
github-actions[bot] e666395d47 chore(main): release 0.4.0 (#625)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-07-17 13:25:31 +08:00
PierrunoYTandClaude Sonnet 5 9043baedcf fix(sandbox): scrub dynamic credential env vars (#682)
* fix(sandbox): scrub dynamic credential env vars

* fix(sandbox): tolerate env assignments in configured sensitive keys

A config value mistakenly given as a full assignment (e.g.
"COMPANY_LLM_SECRET=...") would never match the real env key during
scrubbing, silently re-exposing the credential to sandboxed commands.
normalizeSensitiveEnvKeys now keeps only the name part before '=' and
re-trims it, per PR review.

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

* fix(sandbox): scrub credentials on direct/degraded command plans

directCommandPlan (used when the native sandbox backend is unavailable,
disabled, or not required, including the EnforcementDegraded fallback)
left Env nil whenever spec.Env was nil, so exec.Cmd inherited the full
caller environment unscrubbed. Configured apiKeyEnv credentials and
dynamically named ZERO_OAUTH_<NAME>_CLIENT_SECRET variables leaked
through that path even though the wrapped sandbox plans already
scrubbed them.

Route direct plans through scrubSensitiveEnv the same way wrapped
plans do, snapshotting os.Environ() when spec.Env is nil so scrubbing
still applies. Adds a regression test covering the degraded-fallback
path with both a configured and a dynamically named secret.

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

---------

Co-authored-by: Claude Fable 5 <[email protected]>
2026-07-17 13:04:12 +08:00
Ashwinhegde19 4d8c31cf16 fix(config): enforce MCP trust boundary so project config cannot override user disable (#609)
A user-level MCP server disable is now sticky. The project config
(a cloned repo's ./.zero/config.json) cannot re-enable a server the
user disabled, and cannot disable a server the user explicitly enabled.

The previous merge just assigned base.Disabled = next.Disabled, so a repo
that set disabled:true could silently defeat a user's explicit mcp enable
<server>. The guard is now symmetric via a mergeMCPServer canReenable
flag: once a higher-trust scope has explicitly set the disabled flag, a
lower-trust layer may not override that decision in either direction. Only
the CLI override scope (canReenable=true) may lift a sticky user decision.

The prior disabledSet-before-check ordering bug is also fixed: the
higher-trust scope's prior disabledSet/Disabled is captured before folding
in next's flag, so a project layer can still disable a default-enabled
server (e.g. firecrawl) rather than being silently ignored.

Ports the fix onto main's refactored MCP merge (internal/config/mcp_merge.go
and mergeProjectMCPConfig), which splits project-scope merging from the
general mergeMCPConfig path.

Fixes #512
2026-07-17 13:03:32 +08:00
PierrunoYTandClaude Sonnet 5 824ecdbcf9 fix(hooks): run sessionEnd hooks after Esc/Ctrl+C interrupts (#606)
* fix(hooks): run sessionEnd hooks after Esc/Ctrl+C interrupts

On interrupt, Run returns with ctx already canceled. The deferred
dispatchSessionEnd call passed that same ctx into Hooks.Dispatch, whose
context.WithTimeout derives an already-canceled child context, so the
hook process never actually launches (recorded as timed-out/error in
the audit log). Since most sessions end via interrupt, sessionEnd
hooks were effectively unreliable.

Detach the sessionEnd dispatch from the run context's cancellation
with context.WithoutCancel so the dispatcher's own timeout still
applies without being short-circuited by the run's cancellation.

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

* test(agent): fall back to GOROOT for hook regression

* test(agent): justify GOROOT fallback

---------

Co-authored-by: Claude Sonnet 5 <[email protected]>
2026-07-17 13:03:14 +08:00
6b9c2f0c08 feat(aimlapi): AI/ML API provider with guided onboarding (top-up + key issuance) (#655)
* feat(aimlapi): AI/ML API guided onboarding (top-up + key issuance)

Add the AI/ML API provider integration to the zero CLI: a guided TUI
onboarding sub-flow that takes an existing key or an email top-up and
writes the issued key into the provider profile, attributed to the
Gitlawb rebate partner.

- internal/aimlapi: OpenAI-compatible client, partner-checkout top-up,
  key validation, streaming, and endpoint config (Paths A/B/C).
- providercatalog / providermodelcatalog: register the aimlapi provider
  with default headers and models.
- tui: aimlapi onboarding screen wired into the provider wizard and
  onboarding flow.

Default endpoints point at production (auth/app/api/pay/aimlapi.com);
each is overridable via AIMLAPI_*_URL env vars.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

* fix(aimlapi): address review findings in onboarding flow

- topup: use errors.As for the 5xx retry check so a wrapped APIError
  still retries instead of aborting a paid checkout.
- onboarding: a Path-A top-up on an email with no account now lands on a
  single coherent "key saved, balance not topped up" screen instead of a
  green "ready" plus a red error.
- onboarding: an empty amount field is rejected instead of silently
  falling through to the $25 default.
- collapse the two identical top-up prompt constants into MsgTopUpPrompt
  and drop the dead amountPrompt() branch.
- reuse stdlib maps.Clone instead of a third bespoke header-copy helper.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

* fix(aimlapi): retry transient poll errors and sharpen amount validation

* fix(aimlapi): retry transient poll errors and surface real amount errors

* docs(aimlapi): document exported client API; drop out-of-scope gitignore entry

Addresses the two CodeRabbit PR checks on #655:

- Add doc comments to the exported aimlapi client API (Client, APIError,
  the session/checkout/onboarding types and their methods) and
  ParseAmountUSD, to satisfy the docstring-coverage check.
- Remove the unrelated /zero.exe~ .gitignore entry that an earlier
  review-fix commit added; it is out of scope for the provider work, so
  the PR no longer touches .gitignore.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

* fix(aimlapi): render aimlapi row like other recommended providers

* fix(aimlapi): resolve onboarding review findings (Path A funding, base URL, request cancellation)

* fix(aimlapi): validate pasted API keys

* test(aimlapi): clarify pasted key validation

* test(aimlapi): cover key-balance low-balance transition

* fix(aimlapi): reject non-finite top-up amounts

* fix(cli): scope and canonicalize provider headers

* fix(tui): distinguish invalid verification codes

* feat(aimlapi): resume account-bound top-ups safely

* feat(tui): offer top-up for existing AIMLAPI keys

* feat(tui): reuse AIMLAPI env key on first run

* feat(tui): reuse configured AIMLAPI profiles

* test(aimlapi): trim redundant flow coverage

* fix(tui): preserve existing AIMLAPI profile metadata

* fix(tui): clone AIMLAPI headers in env fallback

* fix(aimlapi): address remaining review findings

* fix(aimlapi): harden onboarding lifecycle

* fix(aimlapi): preserve validated checkout context

* fix(aimlapi): harden checkout retry state

* fix(tui): clarify AIMLAPI configured choices

* fix(aimlapi): make checkout retries idempotent

* fix(aimlapi): validate checkout responses

* fix(providers): reconcile aimlapi catalog after rebase

---------

Co-authored-by: Lookoff123 <[email protected]>
Co-authored-by: Claude Opus 4.8 <[email protected]>
2026-07-17 12:49:38 +08:00
KRATOSandKRATOS 8ef8576df7 perf(tools): add explicit effect metadata for safe concurrency (#705)
* perf(tools): add explicit tool effect metadata

Introduce fail-closed EffectClass and ToolCapabilities on every built-in
tool so a later concurrent read-only batch PR can decide safety from
explicit metadata rather than tool names. Plugin and MCP tools stay
EffectUnknown. No concurrent execution is enabled.

* fix(tools): address review feedback on effect metadata

Harden capability normalization and resource keys before PR6 consumes them:

- Force ThreadSafe=false for every non-ReadOnly effect at read time
- Reject duplicate builtin names in ValidateBuiltinCatalog
- Coerce numeric session ids and accept "session" for process keys
- Strip path/query before userinfo in endpoint host parsing
- Wire apply_patch keys from unified-diff paths + cwd
- Reclassify bash as WorkspaceWrite (one-shot shell mutator)
- Expand regression tests for keys, normalization, and gates

* fix(tools): keep multiFileResourceKeys used by apply_patch keys

Satisfies unused lint after switching apply_patch to applyPatchResourceKeys.

* fix(tools): resolve CodeRabbit residual capability/key issues

- Join apply_patch diff paths under cwd for conflict keys
- Validate constructor-declared capabilities before normalization
- Unify session/process ID key extraction helper

---------

Co-authored-by: KRATOS <[email protected]>
2026-07-17 12:48:36 +08:00
Anandanandanandh8x 1c5c6e78a8 feat(perf): emit prompt-prefix hash fingerprint per turn (#704)
Compute a seven-field SHA-256 fingerprint of the cacheable prompt
prefix (base instructions, confirmation policy, project context, skills,
tools, tool schemas, complete) and emit one prefix_hash event per turn
through the trace recorder. The complete hash is the SHA-256 of the
canonical join of the other six, so any sub-component drift is
observable both individually and in aggregate.

The decomposition exists so a downstream observer can detect which
sub-component of the prefix broke cache stability across turns: a
stable complete_prefix hash means the cacheable prefix is byte-
identical, and the sub-hash that changed names the culprit.

No behavior change for untraced runs. For OpenAI, the existing
prompt_cache_key (session ID) is unchanged; the fingerprint is purely
additive observability. The Anthropic cache_control breakpoint work
already in main is not touched.

Co-authored-by: anandh8x <[email protected]>
2026-07-17 12:47:53 +08:00
Leonardo Faoro 7f669f4550 feat(tui): Ctrl+X leader chords and emacs menu navigation (#699)
* feat(tui): Ctrl+X leader chords and emacs menu navigation

Add Ctrl+X leader shortcuts for common slash commands (with Ctrl+X ?
chord map), and Ctrl+P/N previous/next selection in modals and pickers.

* fix(tui): drop Ctrl+X e /edit leader chord

/edit replaces the composer and would discard an in-progress draft; leave
it unbound so leader shortcuts stay draft-preserving.

* fix(tui): consume idle Ctrl+N as a no-op

Reserve Ctrl+N for emacs menu navigation so it never falls through to
remapped global bindings when no selection surface is open.

* docs: drop manual CHANGELOG entries for TUI shortcuts
2026-07-17 12:44:00 +08:00
euxaristia 2b42cd567b fix(tui): stop the composer cursor blinking while typing or unfocused (#672)
* fix(tui): stop the composer cursor blinking while typing or unfocused

The composer's cursor blinked on a fixed timer unconditionally, including
mid-keystroke, which made typing feel janky since the cursor's job is to mark
the current position. Gate the existing blink tick on two states instead:
force solid while a keystroke landed within the last 500ms (reusing the
existing lastCharTime tracker), and hide it entirely while the terminal
window is unfocused (new terminalFocused field, driven by tea.FocusMsg/
BlurMsg). Idle and focused keeps the prior blink behavior unchanged.

Also decouples view.ReportFocus from the notifier (it was previously gated on
m.notifier != nil, which is effectively always true in production but left
cursor-focus behavior fragile if that ever changes) so focus reporting is
requested unconditionally.

* fix(tui): apply caret visibility synchronously and in the argument-hint line

- A keypress, focus, or blur now updates composerCursorVisible in its own
  message handler instead of waiting for the next 530ms composerBlinkMsg
  tick, which could render a typed character caret-less, leave a visible
  caret in an unfocused terminal, or keep it hidden after a refocus.
- commandArgumentHintComposerLine takes the caret visibility state instead
  of painting its cursor cell unconditionally, so the required-argument
  hint path follows the same focus and blink contract as the main
  composer renderer.
- New tests assert the state immediately after each message, with no blink
  tick in between, and cover the argument-hint render in both states.

* fix(tui): apply the caret state on composer paste

A paste is the same immediate-input transition as a keypress: routePaste now
refreshes lastCharTime and sets the caret visible for both the bracketed
tea.PasteMsg and right-click clipboardReadMsg paths, so a paste right after
the blink phase hid the caret renders solid immediately instead of waiting
for a tick that would toggle off a stale idle state.
2026-07-17 12:43:30 +08:00
euxaristia 665bd4b3d0 WIP: feat(tui): TUI theme presets (Dune and Neon) (#634)
* draft(tui): add theme interface and Claude/Codex presets proposal

Introduces the Theme interface alongside the initial specifications and styling models for the Claude (card-based, warm) and Codex (high-density, split-screen cyberpunk) layout presets. Detailed design plans are documented in docs/THEMES.md.

* fix(tui): implement Claude/Codex themes on the existing palette system

theme.go had replaced the tuiTheme/buildTheme/palette architecture every
renderer depends on with a standalone Theme interface using the wrong
lipgloss import, breaking the build and orphaning the existing theme
registry, /theme command, and picker.

Revert theme.go to the working implementation and add claude and codex
as real palette entries in theme_palettes.go, following the file's own
extension pattern. Both pass the WCAG AA contrast and gray-ramp
invariants asserted across the whole registry, and are picked up
automatically by --theme, ZERO_THEME, /theme, and the picker.

Rewrite docs/THEMES.md to describe the actual palette-registry
architecture instead of the removed interface.

* fix(tui): rename Claude/Codex to neutral Dune/Neon, ensure WCAG AA contrast compliance, and add tests

* style(tui): gofmt theme_select_test.go (drop trailing whitespace)

The ubuntu smoke run fails on gofmt -l; this clears the only unformatted
file in the draft so CI goes green.

* test(tui): cover the CLI/env resolution path for the dune and neon presets

TestNewThemePresetsWired only checked lookupTheme and the IsDark
grouping. Add coverage for the actual --theme/ZERO_THEME precedence
path (resolveThemeMode) and confirm applyTheme swaps zeroTheme to each
preset's own palette rather than a built-in fallback.

* fix(tui): raise Dune/Neon palette contrast and fix theme docs

Darken dune's accent, blue, and red tokens so the selected-row caret,
local-model dot, and diff deletion text clear WCAG AA against selBg and
delBg, including after ANSI-256 downsampling. Also correct THEMES.md's
description of bare /theme (it opens a live-preview picker, not a
list), and add the dune/neon names to the ZERO_THEME/--theme tables in
README.md and README_ZH.md.

* fix(tui): keep Dune and Neon readable after ANSI-256 downsampling

- Dune's accent becomes #724028: still AA on selBg and panel in truecolor
  (5.46:1 and 7.03:1), and its xterm-256 quantization (#444444) now clears
  AA on quantized selBg at 6.47:1 where the previous #7c4712 quantized to
  #875f00 at 3.81:1. Blue already survived quantization at 4.67:1.
- Neon's diff bands move to hexes that quantize to distinct green and red
  xterm entries (addBg #083c10 -> #005f00, delBg #3c0810 -> #5f0000,
  addBgWord #147828 -> #008700, delBgWord #74202e -> #870000) instead of
  collapsing to the same grays, while keeping every truecolor invariant.
- New TestExtendedThemeANSI256Contrast quantizes the palette tokens the way
  a 256-color terminal does and asserts the Dune selected-row pairs stay AA
  and the Neon bands keep their green/red identity and mutual separation.
- docs/THEMES.md is now linked from the Documentation sections of both
  READMEs, and the Unreleased changelog inventory says twelve themes and
  names dune and neon.

* fix(tui): keep Neon diff content AA after quantization, fix error border

- faintest becomes #74c468: line numbers now quantize to #87d75f, which
  clears AA on the xterm-green addBg (#005f00) where the old #58af69
  quantized to 2.95:1, while staying dimmer than faint so the gray ramp
  stays monotonic.
- addInk becomes #ecffdc, quantizing to #ffffd7 for 4.60:1 on the word
  band's #008700 (was 4.29:1). Both rendered diff-content pairs are now
  asserted in the ANSI-256 test instead of documented as omissions.
- cardErr becomes #9a4042: 3.01:1 against the panel (2.43:1 before) and
  3.65:1 quantized, meeting the 3:1 non-text component threshold, with the
  border surface asserted in both renderings.
- docs/THEMES.md no longer claims the extended invariants run against the
  whole registry: it now says new palettes must be added to the
  per-palette contrast tests or given equivalent coverage.
2026-07-17 12:38:51 +08:00
euxaristia 9acb4113cc feat(cli): show ZERO wordmark on --version (#673)
* feat(cli): show ZERO wordmark on --version

The TUI's empty-state ZERO ascii art gets exposed via a new tui.Wordmark
helper and printed above the version line for `zero -v`/`--version`.
Coloring only applies when stdout is a real TTY and NO_COLOR isn't set,
so redirected or piped output (scripts parsing the version) stays plain.

* fix(release): match --version output by suffix, not exact string

The release smoke check compared zero --version output to an exact
"zero <version>" string, which broke once --version started printing
the wordmark above the version line. Check for a "version: <version>"
suffix instead so the smoke check doesn't couple to the wordmark art.

* fix(cli,tui): keep --version machine-readable off-TTY, drop wordmark color

- When stdout is not a terminal, --version prints exactly "zero <version>"
  again: pipes, redirects, command substitutions, and the release smoke
  contract all parse that single record, so the banner is a TTY-only
  affordance now.
- The TTY path keeps the banner but restores "zero <version>" as the
  version record instead of "version: <version>".
- Wordmark renders uncolored: only newModel resolves --theme/ZERO_THEME,
  so painting the global dark palette here was unreadable on light
  terminals. The terminal's default foreground works on any background.
- smokeVersion goes back to requiring the exact "zero <version>" output
  instead of the suffix match that papered over the banner.
2026-07-16 23:02:22 +08:00
d66a9dda69 feat(providers): add AI/ML API preset (rebased onto main) (#621)
* feat(providers): add AI/ML API preset

* fix(providers): relocate aimlapi preset and drop referral header

Addresses the two review asks on #402:

- Move the AI/ML API descriptor out of catalog slot #2 (it sat above the
  first-party OpenAI/Anthropic/Google entries) down next to openrouter in
  the aggregator cluster. Re-sync expectedCatalogIDs and the
  TransportOpenAICompat order-list to match.
- Drop the always-on X-AIMLAPI-Partner-ID / Integration-Repo /
  Integration-Version attribution headers. There's no partner arrangement
  behind the preset, so a non-overridable referral tag on every user's
  request isn't something to ship on a community preset. aimlapi now rides
  through plain CopyHeaders like every other provider. Removes
  HeadersForCatalog (it existed only for this special-case), reverts the
  factory and discovery call sites to CopyHeaders, and drops the header
  test. The factory/discovery tests now assert the endpoint, auth, and the
  user's own custom headers survive while no attribution header is sent.

* fix(providers): address CodeRabbit review on #621

- Document AIMLAPIID for docstring coverage.
- Apply providerio.CopyHeaders for Anthropic and Google provider paths,
  matching the OpenAI path introduced for aimlapi header isolation.

Co-authored-by: Cursor <[email protected]>

* fix(providers): gofmt factory.go to unblock CI smoke

Remove mixed tab/space indentation in the openai.New options block so
ubuntu-latest fmt-check passes.

---------

Co-authored-by: Dmitry Tumanov <[email protected]>
Co-authored-by: Vasanthdev2004 <[email protected]>
Co-authored-by: Cursor <[email protected]>
Co-authored-by: KRATOS <[email protected]>
2026-07-16 22:57:35 +08:00
Leonardo Faoro 7d579996b4 feat(skills): discover shared ~/.agents/skills with multi-root skill loading (#696)
* feat(skills): discover shared ~/.agents/skills with multi-root skill loading

Add ~/.agents/skills as a read-only global discovery root after the
primary
Zero skills dir and before plugin roots. Unify runtime and CLI discovery
on
one multi-root path (DiscoveryRoots / LoadFromRoots / GlobalRoots) and
always
register the multi-root skill tool so bare runs and plugin runs share
the
same load surface.
Install/remove/lock still target only the primary Zero skills directory.
Fixes #610

* docs(skills): note lock stays on the primary write root

Document that install/remove/lock target only the primary Zero skills
directory, matching multi-root discovery write-root isolation.

* fix(skills): bubble primary root load errors from LoadFromRoots

Return non-missing failures from the first non-empty skills root so a
broken primary dir is not reported as an empty skill list. Keep fail-open
behavior for optional roots (~/.agents/skills, plugins).

* fix(skills): treat non-directory skills root as error on Windows

Windows maps ENOTDIR to ErrNotExist, so ReadDir on a regular file looked
like a missing skills dir. Reclassify existing non-directories and assert
the portable load behavior in tests.
2026-07-16 21:59:20 +08:00
Vasanth T abe24bcb10 Add per-turn tracing and a turn-benchmark harness (Phase 0 baseline) (#700)
* Add per-turn tracing and a turn-benchmark harness (Phase 0 baseline)

Introduce an opt-in `internal/trace` package that attributes a run's wall
time to named spans — prompt build, provider connect/queue, generation,
tool queue/execution, permission wait, verification, compaction,
persistence — plus counters for model requests, tool calls, retries,
reconnects, compactions, completion nudges, acceptance checks, model
switches, and input/cached/output tokens. The recorder is nil-safe (a nil
*Recorder is a no-op), so every stamp site can call it unguarded; an
untraced run is byte-identical to today.

The recorder threads into agent.Run via a new `Options.Trace` field and a
single `trace.WithContext` assignment, so the shared providerio seam
(connect around client.Do, queue around OAuth resolve, first token at the
first non-keepalive SSE payload) reaches it through `trace.FromContext`
without any provider-interface change. All instrumentation is opt-in: when
`Options.Trace` is nil the hot path is untouched.

Headless `zero exec` gains `--trace <path>` (and `ZERO_TRACE=<path>`),
writing an agenteval-contract-compatible NDJSON trace (`-` for stderr).
The trace is pure observation; enabling it does not change behavior.

A new per-turn benchmark harness extends `internal/perfbench` (reusing the
existing TaskSet/NumericStats/subprocess runner shapes): `RunTurnBench`
aggregates per-span median/P95 across iterations, ranks the top three
controllable latency sources by share of attributed time, rolls up per
task class, and totals tokens/counts. A checked-in 42-task manifest across
seven classes (nav, edit, fix, refactor, longproc, longctx, parallel) and
local offline fixtures back it, and `make baseline` runs it against the
built `zero`, writing `internal/perfbench/reports/baseline.json`. The
report is machine-specific and regenerated, not hand-edited.

The manifest is JSON rather than YAML to avoid adding a dependency;
per-task verification targets the specific test (`go test -run <name>`)
so fix tasks are independent. Each task is a fresh `zero exec` process, so
iterations are cold-start samples; a warm in-process path is left for
follow-up.

Verified: `go build ./...`, `go vet ./...`, `gofmt -l` clean on touched
files; `go test -race` green for internal/trace, internal/agent,
internal/perfbench, and cmd/zero-perf-bench.

* test(trace): make TestAttributionRatio clock-independent

The test recorded two synthetic 10ms spans with no real delay and asserted
the attribution ratio stayed <= 1.0. On a precise clock (Linux/macOS CI) the
wall is microseconds while attributed is 20ms, so attributed/wall blows past
1.0 and the test failed — even though the ratio legitimately exceeds 1.0
when spans overlap (parallel tool execution). On Windows the coarse timer
masked it, so the failure only surfaced in CI.

Assert the contract instead: attributed is the deterministic sum of span
durations, and the ratio equals attributed/wall (0 when wall is zero). Add a
zero-wall case for the divide-by-zero guard.

* Address review feedback on the per-turn tracer + benchmark harness

Benchmark runner (internal/perfbench):
- A nonzero agent exit code no longer counts as Passed. The runner set
  VerifyErr but then fell through to outcome.Passed = true, so a crashed
  run could be recorded as a pass. Return before the verification block
  when VerifyErr is already set.
- A task now passes only when every iteration passes. The previous
  "any iteration passes" rule let a flaky fail hide behind one good run.
- Each invocation copies its workspace fixture to a fresh temp dir and
  runs the agent + verifier there, so mutating tasks (edit/fix/refactor)
  can't dirty the checked-in fixtures or bleed into the next iteration.
- Trace load failures (missing/malformed trace file) are surfaced as
  result warnings via a new TraceIssue field instead of being swallowed.

Tracer (internal/trace):
- Finish freezes the recorder: Counter, addSpan, and the first-event
  stamps are dropped after Finish returns, so a snapshot captures exactly
  one turn and late stamps can't mutate it.
- WriteText propagates the first write error instead of silently
  swallowing sink failures.

Provider seam (internal/providers/providerio):
- FirstTokenAt is stamped only after a payload is accepted as real model
  output, not before handle() decides to drop it as an error payload.

Agent loop (internal/agent):
- The compaction counter increments only when the history actually
  shrinks, in both the proactive and reactive paths — paid no-ops no
  longer inflate the compaction total.
- Document on Options.Trace that a fresh Recorder is required per Run.

CLI (internal/cli/exec):
- Finish the trace at the agent.Run boundary and defer only the
  serialization, so the snapshot covers exactly the turn.
- Reject --trace / ZERO_TRACE for spec-draft runs with a clear error
  instead of silently accepting and writing nothing.

Manifest + fixtures:
- Strengthen the edit verifiers: rename and value-bump tasks now assert
  the old symbol is gone, and the getter task greps the method signature.
- Add the missing testdata/edit fixture (10 edit tasks referenced a
  directory that was never committed, so the whole edit class errored
  out at run time).
- The manifest test now stats each fixture so a missing dir fails at
  load time instead of at every run.
- longproc benchmark keeps Process live across the loop; README code
  fence gets a language tag and documents the per-invocation fixture copy.
- Add tests for the tracing wrapper (nil vs wired recorder, OnUsage
  forwarding, token aggregation) and the frozen-recorder contract.

* Address review feedback: exclusive-time attribution + wired spans + fix-suite compile

Rewrite the trace attribution model so concurrent/nested spans no longer
double-count. Spans record wall intervals; the recorder derives a parent for
each by interval containment and computes exclusive time (duration minus the
union of its children). AttributedDuration sums only top-level spans; Coverage
is the union of intervals over wall, capped at 1, the honest ">=95%" metric.
Top-latency ranking uses exclusive time so shares sum to ~1 instead of being
inflated by overlapping provider_connect/generation and nested
permission_wait/tool_execution.

Wire the spans that were named but never stamped: verification around the
self-correct verify pass, and StampFirstVisibleEvent on the first forwarded
text event. Drop the tool_queue/process_wait/persistence constants that had no
stamps, and rename the misnamed near-noise prompt_build span to tool_partition.
Trim RequiredEventKeys to events a healthy short run actually emits; move
conditional phases (tools, permission, compaction, verification) to
OptionalEventKeys so a short trace does not false-fail.

Make ReadNDJSON fail loudly on a corrupt file: require a type:trace header,
error on span/counter lines that appear before it, and error when a header
yields no spans or counters.

Fix the blocking fix-suite compile error: Label's %d format verb on a string
tripped go test's vet before any fix task could verify. Make it a runtime
wrong-prefix bug ("admin-" vs "user-") that still type-checks, so each fix
task is independent again.

Manifest: correct refactor-04's stats.go to main.go, reword fix-07 to the
wrong-prefix bug, add a latency-only description (read-only tasks carry no
verification oracle by design), and frame the report README on exclusive time
and coverage.

Tests updated for the occurrence/exclusive/coverage model; trace, perfbench,
and agent green under -race.

* Fix exclusive-time round-trip, identical-interval nesting, and empty-trace parse

Three correctness bugs in the attribution model found by an adversarial review
of the prior commit, all undermining the no-double-count guarantee:

1. exclusive_ms:0 was overwritten with the inclusive Duration on re-parse. A
   parent whose children tile its interval legitimately has exclusive time 0;
   WriteNDJSON emits exclusive_ms:0, but ReadNDJSON's "<= 0 means missing"
   fallback turned that back into the full duration, so every re-parsed trace
   re-introduced the double-counting the model exists to prevent (the benchmark
   harness, which ranks re-parsed traces, inherits it). Gate the fallback on
   key ABSENCE, so a written 0 is preserved.

2. deriveNesting formed a 2-cycle for two spans with identical [start, end]
   intervals: the symmetric containment check made each parent the other,
   dropping both from top-level and zeroing AttributedDuration. Break the tie
   deterministically — strict containment is an unambiguous parent; for
   identical intervals, only the lower-indexed span may parent the other, so
   one stays top-level.

3. ReadNDJSON returned a valid empty TurnTrace for empty/blank-only input, so a
   run that crashed before emitting a trace (or where --trace was not honored)
   masqueraded as a clean zero-attribution sample. Empty input now errors, so
   the harness records a TraceIssue.

Also: decode counters via json.Number so int64 values above 2^53 round-trip
exactly instead of losing precision through float64; and drop the harness's
own "exclusive <= 0 -> Duration" ranking fallback, which was the same
double-count on the re-parsed path. Added regression tests covering the
zero-exclusive round-trip, the identical-interval nesting, empty-input
rejection, and counter precision.

* perf(turn-bench): split pass/fail into oracle tiers so the pass rate can't be misread as correctness

The turn benchmark counted any exit-0 task as passed, including the 24
read-only tasks (nav/longproc/longctx/parallel) that carry no verification
oracle — so the JSON tasksPassed / "N/M passed" headline could read as a
correctness verdict it never was. Split pass/fail into three oracle tiers:

- correctness (edit grep + fix go test): the only pass rate that can move
  with model quality (tasksVerified / tasksPassed / correctnessPassRate)
- build-only (refactor go build): a non-positive oracle, reported as
  buildPassRate and excluded from correctnessPassRate; the manifest declares
  buildOnlyClasses: ["refactor"]
- latency-only (no oracle): counted in latencyOnlyTasks, never in any pass
  rate

NewTurnExecRunner no longer sets Passed=true for tasks with no
verificationCommand, and the summary headline names each tier explicitly so
an exit-0 read-only run cannot inflate a correctness number. Bumps
TurnSchemaVersion to 2. Class breakdown and tier contract documented in
internal/perfbench/MANIFEST.md and reports/README.md.

Also install the trace first-token/visible-event forwarding handlers in
agent.Run whenever options.Trace is set, not only when a UI OnText /
OnReasoning callback is set, so a headless traced run (zero exec --trace)
still captures FirstTokenAt. forwardedVisibleText stays tied to the user
callback so stall-retry semantics are unchanged. Adds a regression
assertion to the existing traced-run test.

* Address CodeRabbit review on 2bfb3406: omitempty + crash visibility

Three findings from CodeRabbit's review of the 3-tier pass-rate fix:

- BuildPassRate dropped its omitempty so an explicit 0.0 (all refactor
  tasks fail to compile) is no longer indistinguishable from 'no build
  tasks ran' in the JSON. Matches CorrectnessPassRate's always-serialize
  behavior, which is the whole point of the tier split: the report
  cannot be misread.
- A crashed sample (outcome.Err) now emits a Warning mirroring the
  TraceIssue path, so a run that died every iteration can't look like a
  normal benchmark with fewer measurements instead of a failure.
- cmd.Run()'s returned error is preserved on the no-run_end path: when
  stderr is empty and Run failed (binary missing, exec permission
  denied), the actual OS error is reported instead of collapsing to the
  generic 'missing terminal run_end event'.

go build/vet/gofmt clean; go test -race green for perfbench/agent/trace.

* Address CodeRabbit re-review: anti-averaging README + fixture-isolation test

Re-review at d0615249 asked for the pass-rate contract to be loud to
downstream consumers and for the fixture-isolation property to be asserted,
not just read from code.

- reports/README.md gains a prominent 'do not average the tier pass rates /
  do not report a single pass rate' warning. The schema offers no headline
  pass rate on purpose; a consumer must name the tier it is reporting. This
  is the durability fix for the misread the tier split exists to prevent.
- turn_bench_test.go gains TestCopyFixtureIsolatesSourceFromMutation: copies
  a fixture, mutates the copy, and asserts the source is byte-identical
  (and that a file added to the copy never appears in the source). Proves the
  'fixtures stay clean across runs' property without needing the binary.

Items the re-review also raised that were already satisfied: the
oracle-presence-over-declared-list precedence is documented on the
BuildOnlyClasses field (taskbench.go), and the PR body already says
'48-task manifest'. The Share->exclusiveShare rename is tracked in #701.

go build/vet/gofmt clean; go test -race green for perfbench.
2026-07-16 21:58:20 +08:00
Felix-Ayush 75a78e715b feat(cli): wire MCP serve WorkspaceRoot and --add-dir scope (#694)
* feat(cli): wire MCP serve WorkspaceRoot and --add-dir scope

Pass the resolved workspace into mcp.ServeOptions and honor --add-dir so
resources/list and scoped tools can expose extra roots beyond cwd.

* fix(cli): keep lexical serve --add-dir roots for path matching

Use symlink-resolved paths only for deduplication so Scope roots stay
aligned with the un-evaluated WorkspaceRoot MCP serve already passes.
2026-07-15 21:16:51 +08:00
PierrunoYT 8502449430 test(tui): isolate provider wizard credentials (#688) 2026-07-15 21:06:48 +08:00
euxaristia c0bcfdda41 chore(deps): bump go module dependencies (#669)
Updates charm.land bubbles/bubbletea/lipgloss v2, golang.org/x/sys,
golang.org/x/sync, and dlclark/regexp2/v2 to their latest patch releases.
2026-07-15 21:06:17 +08:00
euxaristia 1bb6b5745a fix(swarm): wait for job.Runs directly in scheduler skip test (#667)
The launcher recording a spawn and the job's Runs counter incrementing
are sequential but distinct steps in the scheduler goroutine
(fireIfIdle's Spawn call, then run's job.incRuns()). The test polled
only the launcher's recorded count, then asserted Runs immediately
with no wait, so a scheduler goroutine preempted between those two
steps could be observed with a stale Runs value under CI load. Wait
for Runs itself instead of assuming the launcher signal implies it.
2026-07-15 21:05:46 +08:00
euxaristia 6fc1220f6a fix(sandbox): scrub sensitive credentials from sandbox environment (#660)
* fix(windows): retry atomic file renames on Access is denied / Sharing violation

* test(swarm): add unit tests for rename retry and non-retryable errors

* test(swarm): skip TestMailboxRenameRetry on non-Windows platforms

* fix(sandbox): scrub sensitive provider credentials from sandbox env

* refactor(sandbox): unify environment creation and reduce lines of code

* fix(sandbox): forward workspaceRoot through sandboxEnvironment, gate Windows env on backend

* style(sandbox,swarm): remove stray blank lines flagged by gofmt

* refactor(fsutil): extract shared rename retry helper, derive scrub list from provider catalog

Address CodeRabbit review feedback:

- Add internal/fsutil.RenameWithRetry with an injectable rename override and
  move the four duplicated renameWithRetry/isWindowsSharingViolation copies
  (cron, sessions, sandbox, swarm) onto it. Mailbox keeps its rename test
  seam by passing its field through. Add fsutil tests for the Windows retry
  and non-retryable paths.
- Remove the orphaned metadata.json.tmp in cron writeJob when the rename
  permanently fails, matching the sibling writers.
- Derive scrubSensitiveEnv keys from providercatalog AuthEnvVars plus a
  static list of non-catalog secrets, so new providers are scrubbed
  automatically. AWS_PROFILE and GOOGLE_APPLICATION_CREDENTIALS stay
  because they are pointers, not secrets.

* fix(sandbox): deny sandboxed reads of cloud credential stores by default

Address CodeRabbit finding that scrubbed env pointers still left the
underlying credential files readable under the read-all workspace posture:

- Add credentialDenyReadPaths: default profile-level deny-read entries for
  ~/.aws, ~/.config/gcloud, ~/.azure, and the GOOGLE_APPLICATION_CREDENTIALS
  target file, wired into both permission profile builders so the seatbelt
  and Linux helper backends enforce them. Entries are stat-filtered and a
  store nested under a user AllowRead entry is dropped, keeping an explicit
  opt-out.
- Keep the defaults out of Policy.DenyRead, whose emptiness gates escalated
  execution, and skip Windows so the profile deny list does not flip the
  runner off the WRITE_RESTRICTED token path.
- Scrub GOOGLE_APPLICATION_CREDENTIALS from the sandbox env; keep AWS_PROFILE
  since the deny-read rule on ~/.aws is the real protection and the SDKs
  fall back to the default profile regardless.
- Add credential deny path tests, extend the scrub test, relax the profile
  test's exact deny count to containment.

* test(cli): pin HOME in sandbox policy golden test

The default credential-store deny-read entries depend on what exists in the
real home directory (the macOS CI image ships ~/.aws), which leaked host
paths into the policy JSON golden comparison. Point HOME at an empty temp
directory so the golden stays hermetic.

* fix(sandbox): protect GOOGLE_APPLICATION_CREDENTIALS without a home, retry lock violations

Address CodeRabbit review feedback:

- credentialDenyReadPaths no longer returns early when the home lookup
  fails; home-based candidates become conditional and the
  GOOGLE_APPLICATION_CREDENTIALS target is always considered.
- fsutil.RenameWithRetry also retries ERROR_LOCK_VIOLATION (errno 33),
  matching the mcp lock handling; helper renamed to
  isWindowsSharingOrLockViolation.
- Extend tests: homeless GOOGLE_APPLICATION_CREDENTIALS case and a lock
  violation step in the retry sequence.

* fix(sandbox): tighten env-scrub test assertion and add missing secret keys

TestScrubSensitiveEnv only checked an allowlist plus a length count, so a
duplicate allowed key silently replacing a required one would still pass.
Compare the exact expected key/value map instead.

Also add ZERO_WEBSEARCH_API_KEY and ZERO_DAEMON_REMOTE_TOKEN to
scrubSensitiveEnv's sensitiveKeys list: both are real bearer secrets read
from the environment but were missing from the scrub list, leaving them
readable by sandboxed commands when set in the parent shell.
2026-07-14 21:46:10 +08:00
euxaristia 80c39aa599 docs: add codebase minimization guideline to AGENTS.md (#661) 2026-07-14 15:15:45 +08:00
euxaristia 8bd9742fa9 fix(tools): classify silent wrapped Windows command failures as sandbox denials (#659)
* fix(tools): classify silent wrapped Windows command failures as sandbox denials

A Windows restricted-token sandbox failure is often completely silent:
when the token cannot open the target executable or its DLLs (or an
MSYS runtime dies during init), the command exits nonzero having
written zero bytes to stdout and stderr. The existing detection is
keyword-based, so these failures were never marked as sandbox denials,
the agent loop never offered the unsandboxed-retry prompt, and the
model was left staring at 'Command completed with no output' until it
gave up.

Treat a wrapped command on the Windows restricted-token or elevated
backend that exits nonzero with completely empty output as a likely
sandbox denial. A command that legitimately fails silently (findstr
with no match, for example) now costs one extra approval prompt; the
inverse misclassification silently strands the session, so the trade
goes to prompting.

* test(tools): cover the elevated Windows backend in the silent-denial test

The heuristic treats windows-elevated identically to
windows-restricted-token, but only the latter was exercised.
Parameterize the silent-failure test over both backends.
2026-07-14 15:13:05 +08:00
euxaristia 91ea6ded75 fix(keyring): pass generic password via stdin on macOS (#574)
* fix(keyring): pass generic password via stdin on macOS

Pass the generic password secret to security add-generic-password via stdin instead of passing it as a command-line argument. This prevents the secret from leaking to the local process list (visible via ps) and aligns the macOS keyring implementation with the Linux secret-tool implementation.

* fix(keyring): pipe secret twice for macOS security password+retype prompt

The macOS security add-generic-password command prompts for both a password and a retype confirmation when -w has no trailing value. The previous stdin approach only piped the secret once, causing a passwords don't match failure. Write the secret twice separated by newlines to satisfy both prompts.

* fix(keyring): reject multiline secrets on macOS instead of corrupting them

Addresses jatmn's review:
- security's -w password+retype prompt reads stdin line by line, so a
  secret containing its own newline can't be distinguished from the
  password/retype separator: the first line read back would silently
  truncate the stored value. Set now rejects a secret containing \r or
  \n on darwin before invoking security, rather than storing a
  corrupted value. Linux's secret-tool has no such restriction (reads
  the whole stdin payload), so it is unaffected.
- Updated the package doc comment, which still described the old
  argv-based (process-list-exposed) macOS path this PR replaced.

Added TestKeyringSetRejectsMultilineSecretOnDarwin and
TestKeyringSetAllowsMultilineSecretOnLinux.

* fix(keyring): drive security -i for macOS Set instead of the -w prompt

The trailing -w prompt uses getpass(3), which reads from /dev/tty whenever
the process has a controlling terminal, so the piped secret was ignored in
any interactive session, and getpass's small fixed buffer would truncate
long secrets (like the base64 OAuth blob) when the fallback did engage.

Interactive mode reads whole commands from stdin, keeping argv at just -i.
Arguments are quoted for SecurityTool's split_line parser (backslash and
double quote escaped inside double quotes). The parser is line-based with a
4096-byte buffer, so Set rejects newlines and oversized payloads up front
rather than corrupting the stored value.
2026-07-14 14:46:09 +08:00
Octopusandocto-patch fa3052a142 feat(providers): refresh MiniMax model coverage (#665)
Co-authored-by: octo-patch <[email protected]>
2026-07-13 22:33:55 +08:00
euxaristia 4c986d327b feat(tui): press up to edit queued messages (#656)
* feat(tui): press up to edit queued messages

Pressing up with a message queued pops it back into the composer for
editing, taking priority over history recall. While a message is queued
the empty composer shows a hint placeholder. A second prompt queued
during the same run now stacks under the first instead of replacing it.

* refactor(tui): remove openclaude references from queued_message comments
2026-07-13 22:18:55 +08:00
euxaristia a5d2e327c8 fix(sandbox): use WRITE_RESTRICTED token when no DenyRead paths are configured (#658)
Removing the WRITE_RESTRICTED flag in #612 made the restricted-SID check
apply to reads as well as writes. Default Windows DACLs grant
BUILTIN\Users rather than any SID in the token's restricted list
(random capability SIDs, logon SID, Everyone), so the sandboxed process
could no longer open any executable or non-KnownDlls DLL: every spawned
command failed with exit 1 and no output, including cmd builtins'
children (gh, git, where.exe). The existing
TestWindowsRestrictedTokenNestedPipeCapture smoke test reproduces the
regression.

Restore WRITE_RESTRICTED, but only when the permission profile has no
DenyRead paths. The flag makes the kernel skip restricted-SID deny ACEs
for reads, which is exactly the DenyRead bypass #612 fixed, so profiles
that configure DenyRead keep the fully restricted token and trade spawn
capability for read-deny enforcement. DenyRead is empty by default, so
the common case regains a working sandbox while #612's guarantee holds
for the profiles that rely on it.
2026-07-13 22:17:31 +08:00
euxaristia 5c4815a66e fix(sandbox): unblock git fetch/commit/add under the write-restricted sandbox (#654)
* fix(sandbox): stop blocking git fetch/commit/add by unblocking .git writes

The sandbox denied every write under .git for shell-executed commands,
so any git operation that touches its own metadata (fetch, commit, add,
pull, merge, stash) failed under the default sandbox. Narrow the block
to .git/hooks (auto-executing scripts) and .git/config (remote URLs,
credential.helper, core.hooksPath), the two subpaths that are actually
dangerous to write; the rest of .git stays writable to git subprocesses
since those writes go through git's own invariants.

The app-level auto-allow gate for direct file-tool writes (write_file,
edit_file, apply_patch) is unaffected and still treats all of .git as
protected, since hand-editing git's internals bypasses those invariants.

* test(cli): normalize Windows path separators in sandbox policy golden

The sandbox policy golden is shared across platforms, but readOnlySubpaths
carve-outs are built with filepath.Join and render as backslash paths on
Windows. normalizePortableJSONRootString only converted a bare backslash, so
the Windows smoke run diverged from the forward-slash golden. Replace all
backslashes with slashes during normalization so the shared golden passes on
every runner.
2026-07-13 21:56:10 +08:00
euxaristia 635c93af51 fix(agent): raise default and deep-mode turn budgets (#650)
* fix(agent): raise default and deep-mode turn budgets

The default per-run tool-turn budget of 50 was still too low for larger
multi-step tasks that span several files: agents hit the ceiling and
stopped with a "remaining work" summary instead of finishing. Raise the
default from 50 to 80.

The "deep" preset also carried MaxTurns: 50, identical to the default, so
its "larger turn budget" promise no longer held on the turn-budget axis.
Raise it to 160 so deep runs get a genuinely larger budget again.

Users can still override per-session with /turns or --max-turns (bounded
by MaxTurnsCeiling).

* test(cli): expect deep-mode MaxTurns=160 after budget raise
2026-07-13 21:54:48 +08:00
Ashwinhegde19 893b7b424c fix(config): surface unknown/typo'd config fields instead of silently dropping them (#645)
* fix(config): surface unknown/typo'd config fields instead of silently dropping them

json.Unmarshal ignores unknown JSON keys by default, so typos such as
"maxTurn" or "sandbox.network" were silently discarded: the user
believed a setting (e.g. a sandbox hardening option) was active while
Zero used its default. Worse, "zero doctor" reported
"config files parsed and validated successfully" even when fields were dropped.

Add a reflection-based unknown-field scanner
(internal/config/unknownfields.go) that walks the FileConfig JSON schema
and recurses into nested objects, arrays, and maps, reporting each
unknown key with its full JSON path and a near-match suggestion
("did you mean 'maxTurns'?"). The two legacy top-level aliases
(mcpServers, mcp_servers) are allowlisted so they are not flagged,
and detection only inspects json tags, so a config written by a newer
Zero with new fields still loads and merges normally — validate/doctor
just surface the unknown keys as issues rather than rejecting the file.

Wire the scanner into ValidateBytes/ValidateFile, which zero doctor
already uses, so doctor now reports the unknown fields instead of
"validation passed".

Add tests for unknown top-level keys, unknown nested keys, unknown
keys inside a provider entry, and that the legacy aliases are not
flagged.

Fixes #644

* fix(config): address review comments on unknown-field scanner

- Treat JSON keys case-insensitively (matching encoding/json), so a
  valid case variant such as "MaxTurns" is not falsely flagged as
  unknown. Canonical (correct-case) names are kept only for the
  "did you mean" hint.
- Allowlist ProviderProfile's legacy snake_case keys (providerKind,
  catalog_id, base_url, api_key, ...) that ProviderProfile.UnmarshalJSON
  accepts but which are not fields on the Go type, so existing
  configs using them are not false-positive'd in zero doctor.
- Make the near-match suggestion deterministic: sort candidates before
  picking so an edit-distance tie always resolves to the same lexical
  winner (map-range order was nondeterministic and could flake).
- Make the unknown-field test assertions order-independent (search by
  FieldPath instead of assuming issues[0]).

Add regression coverage for legacy provider keys and valid case
variants not being flagged.
2026-07-13 21:52:44 +08:00
euxaristia da41c3a75b fix(lock): prevent POSIX lock file overwrite and leak on Windows/Unix (#628)
* fix(lock): prevent POSIX lock file overwrite and leak on Windows/Unix

During lock reclamation, reclaiming a suspected stale lock renames the file aside and renames it back if not stale. On POSIX systems, the rename back silently overwrites any new lock file created in the gap. On Windows, it fails to rename back and leaks the stale file.

Introduce a cross-platform restoreLockFile helper:
- On POSIX: Use os.Link to link the file back (which atomic-fails if the target already exists), then remove the temporary link.
- On Windows: Use os.Rename (which safely fails if the target exists).
If restoring fails, delete the temporary file immediately to prevent leaks.

* fix(lockutil): fix Windows build by using golang.org/x/sys/windows

Addresses jatmn's follow-up review: the required Windows smoke check was
failing because syscall.MoveFileEx is not exported by the standard
library's syscall package on this platform, so internal/lockutil (and
everything importing it: cron, daemon, hooks, oauth) failed to compile
on Windows.

Switched to golang.org/x/sys/windows, which does export MoveFileEx with
the same signature, already a direct module dependency. Verified on a
real Windows machine that the no-flags call still fails with
ERROR_ALREADY_EXISTS when the destination exists, and that this
satisfies errors.Is(err, os.ErrExist), which cron/daemon/hooks/oauth's
reclaim paths all depend on to decide whether the sidelined lock was
genuinely superseded (safe to discard) versus some other, unrelated
failure (must be preserved).

TestRestoreLockFile already covered both the successful-restore and
fail-when-target-exists cases; it now passes on Windows.

* fix(lockutil): route lock file deletes through RemoveLockFile with Windows retry loop

* fix(lockutil): close restore split-brain and unify RemoveLockFile

RestoreLockFile could fail with errors other than os.ErrExist (hardlink-
incapable filesystems such as FAT or some FUSE/network mounts, ENOSPC,
Windows sharing violations). Callers only clean up on os.ErrExist, so any
other failure left the lock path missing with the live holder's lock
stranded in the sidelined file; the next O_EXCL create then succeeded and
two processes held the lock. RestoreLockFile now falls back to an O_EXCL
copy in that case, which preserves the no-overwrite guarantee, and a
successful restore with a failed sidelined-file cleanup reports nil
instead of a failed restore.

RemoveLockFile now has one cross-platform contract: removing an already
missing file reports nil on POSIX too, matching Windows, and daemon
release() drops its caller-side ErrNotExist filter.

Tests: TestRestoreByCopy and TestRemoveLockFile run on all platforms;
Windows-tagged tests pin the ERROR_ALREADY_EXISTS mapping to os.ErrExist
and exercise the RemoveLockFile retry loop to success and to bounded
exhaustion. The package doc comment documents the shared contract.

* refactor(swarm): extract reclaimStaleLock to mirror other lock callers

The stale-lock reclaim in acquireLock was inline, so its restore path
(a holder rotated a fresh lock in the gap between the stale check and
the rename) could not be regression-tested the way cron, daemon, hooks,
and oauth test theirs. Extract it into reclaimStaleLock with the same
shape as the other callers, behavior unchanged, and add the mirrored
regression test.

* test(hooks,oauth): add stale lock reclaim regression tests

Mirror the cron reclaim test in the two packages that lacked coverage
for reclaimStaleLock: a genuinely stale lock is reclaimed, a fresh lock
(reacquired in the gap) is restored intact rather than stolen, and a
missing lock reports no reclaim. daemon already had equivalent tests in
lock_test.go.

* fix(lock): fail closed and drop sidelined file when restore fails

A non-os.ErrExist error from RestoreLockFile means both the no-replace
restore and its O_EXCL copy fallback failed, so the lock path may be
missing while the sidelined holder is live. Callers previously swallowed
that error and their next O_EXCL create would succeed, breaking mutual
exclusion. reclaimStaleLock in cron, daemon, hooks, oauth, and swarm now
returns an error for that case and the acquire loops fail closed instead
of re-acquiring.

The sidelined .stale.* file is now removed on every restore failure, not
only os.ErrExist: once the restore has failed it has no protocol
function (release only consults the lock path), so keeping it only
leaked files.

Also replace the numeric errno literals in the Windows RemoveLockFile
retry loop with windows.ERROR_SHARING_VIOLATION and
windows.ERROR_ACCESS_DENIED.

* refactor(lockutil): extract shared ReclaimStaleLock helper

Consolidate the duplicated reclaimStaleLock functions from cron, hooks,
oauth, and swarm into lockutil.ReclaimStaleLock. Each caller now passes
an mtime-based isLive predicate to the shared helper, which owns the
rename-aside, restore-if-live, cleanup, ErrExist handling, and
fail-closed error propagation.

Daemon keeps its own reclaimStaleLock because it uses PID-based liveness
(different predicate shape and no staleAfter duration).

The per-package reclaim regression tests are removed since the shared
helper is thoroughly tested in lockutil/reclaim_test.go (dead lock
reclaimed, live lock restored, missing lock, fail-closed on restore
error, sidelined file dropped when new holder wins).

* fix(lockutil): propagate non-contention rename failures from ReclaimStaleLock

A rename-aside failure that is not a lost race previously mapped to the
benign lost-race result, so acquirers spun until their deadline and
reported a timeout instead of the real failure. Keep only a missing
source and the Windows sharing-violation/access-denied contention
errnos benign (neither Windows errno maps to os.ErrExist, and
ERROR_SHARING_VIOLATION does not map to os.ErrPermission either, so the
classification compares raw errnos) and return everything else so
callers fail closed.

Also convert daemon's private reclaimStaleLock to the shared helper so
it picks up the same contract, and reword the caller error messages
from "restore reclaimed" to "reclaim stale" since the error can now
come from the rename aside as well.

* fix(lockutil): publish restore-by-copy atomically and honor lost mailbox reclaims

restoreByCopy created the destination lock file empty before copying into
it, so a crash mid-copy could leave a live holder's lock permanently
unrepresented at the lock path. Stage the copy under a private name and
publish it with the platform's no-replace primitive instead.

swarm's mailbox lock discarded the reclaimed boolean and always retried
immediately, so a lost reclaim race (or Windows sharing-violation
contention) could hot-spin past the lock timeout instead of falling
through to the bounded wait like the other lock callers.

* fix(lockutil): shrink the reclaim/restore window with a fast replacing rename

Restoring a lock that turned out live went through the no-replace path
(plus its copy fallback on some failures) first, which is slow enough
that a fourth process's unrelated O_EXCL create could win lockPath while
it was absent for the whole call. RestoreLockFile's no-replace contract
would then report os.ErrExist and the caller silently dropped the
sidelined lock, orphaning the still-running original holder.

restoreLiveLock now tries a single replacing rename straight back first,
falling back to the no-replace path only if that fails. It shrinks the
window from a whole restore call down to roughly one syscall; it does
not close it, since the fast path can silently overwrite a competing
lock rather than detect it. A fully race-free reclaim would need an
OS-level advisory lock checked non-destructively instead of by moving
the file, which is a larger change than this.
2026-07-13 21:45:08 +08:00
euxaristia 5a5b7fd5b8 docs: add Go code quality & security checks and bump Go version to 1.26+ (#635)
* docs: add Go code quality & security checks and bump Go version to 1.26+

* docs: address CodeRabbit review comments and use pinned versions and Go 1.26.5+

* docs: remove trailing blank lines at end of AGENTS.md

* docs: add missing /v2/ segment to golangci-lint install path

The go install command pointed at the v1 module path
(golangci-lint/cmd/golangci-lint), which does not resolve for
v2.12.2. The go run invocation earlier in the same section already
had the correct v2 path.

* docs: trim AGENTS.md below 8 KiB guideline and clean up README cross-compile header

* docs: address CodeRabbit review feedback on extending guide
2026-07-12 22:37:30 +08:00
euxaristia 212734adf3 fix(securefile,credstore): call Sync on temp file before close and rename (#631)
When writing credentials or generating user secrets, files are written using a write-to-temp-then-rename pattern. However, neither package called Sync (fsync) on the temporary file before closing and renaming. If a crash or power failure occurred shortly after write, this could result in truncated or 0-byte key/credential files on disk.

Add tmp.Sync() calls to writeNewSecretFile and Store.write to guarantee durability.
2026-07-12 22:35:55 +08:00
euxaristia 2efe6d539e fix(plugins): resolve relative executable paths against plugin root (#627)
* fix(plugins): resolve relative executable paths against plugin root

When a plugin registers a tool or a hook command as a relative path (e.g. ./tools/helper.sh), running it in the caller's workspace CWD allows local execution hijacking by placing a malicious script at that path in the untrusted workspace. Update expandPluginRootPath to resolve relative executable paths containing path separators to absolute paths inside the plugin's install directory.

* fix(plugins): remove ineffectual permission initializer in toolSafety

The initial assignment to permission was always overwritten by one of
the switch arms below it, including a case that just reassigned the
same PermissionPrompt value the initializer already held. Flagged by
ineffassign in CI.
2026-07-12 22:34:34 +08:00
Kevin Codex 5e1405d0b7 feat(npm): ship the native binary as platform optionalDependencies (#626)
* feat(npm): ship the native binary as platform optionalDependencies

Replace the postinstall downloader with Codex-style platform packages:
suffixed versions of @gitlawb/zero (X.Y.Z-<platform>-<arch>) carrying the
binary, sandbox helpers, and the vendored agent-browser/tuistory tree,
referenced from the wrapper via npm: aliases in optionalDependencies.

The published wrapper has no lifecycle scripts and no dependencies, so
npm/Bun/pnpm installs are silent: no EBADENGINE from agent-browser's
node>=24 engines pin, no allow-scripts warnings, no network outside the
npm registry. The old downloader remains as a checksum-verified first-run
fallback for --omit=optional installs and unsupported platforms.

scripts/npm/build-platform-packages.mjs assembles the payloads from the
release archives at publish time: it materializes npm's symlink .bin
shims into regular sh shims (npm pack silently drops symlinks) and prunes
agent-browser's other-platform binaries (~65 MB per package). The publish
job uploads platform versions first under --tag platform, asserts the
latest dist-tag survived (platform versions are semver prereleases of the
wrapper version), then publishes the wrapper.

See docs/NPM_PACKAGING.md for the architecture and publishing rules.

* fix(npm): address PR review — LICENSE in payloads, dist-tag lookup guard, accurate fallback and platform docs

- Ship the repo LICENSE in both the wrapper and platform payloads (npm only
  includes a license file physically present in the packed directory).
- Guard the platform-publish dist-tag lookup with || true so a transient
  npm view failure (or a bootstrap publish with no latest tag) cannot abort
  the job between the platform and wrapper publishes.
- Gate the wrapper's fallback downloader on the release matrix so platforms
  with no asset (windows-arm64 and unknown combos) skip the spawn instead of
  attempting it every run; keep deliberate retry-on-failure for supported
  platforms and say so honestly in the notice and docs.
- Correct Windows support wording everywhere: x64 only, ARM runs under
  emulation (README, README_ZH, INSTALL.md incl. the release-target list).
2026-07-11 23:25:41 +08:00
KRATOSandKRATOS 1af58828eb fix(openai): omit prompt_cache_key for openai-compatible providers (#636)
Interactive TUI always has a session ID, so every completion request
forwarded OpenAI's prompt_cache_key. Strict openai-compatible gateways
(e.g. NVIDIA NIM) reject unknown fields with a 400, while plain zero
exec usually has no session and omits the field.

Disable prompt_cache_key for ProviderKindOpenAICompatible; keep it for
official OpenAI. ZERO_DISABLE_PROMPT_CACHE_KEY remains a global kill
switch.

Fixes #624

Co-authored-by: KRATOS <[email protected]>
2026-07-10 21:43:49 +08:00
euxaristia dc06fe72ca fix(hooks): fail closed on launch failures for beforeTool hooks (#629)
When executing a security-critical beforeTool hook, if the hook command fails to launch (e.g. binary not found, missing execute permission), it previously failed open and let tool execution proceed.

Update classifyResult to return AuditBlocked, true when a launch error occurs on a beforeTool hook, enforcing a fail-closed policy. Observational afterTool hooks still fail open.
2026-07-10 16:42:32 +08:00
euxaristia e4bd703cfb fix(cron): prevent cron job Mutate from clobbering concurrent updates (#630)
Inside fireJob, the Mutate transaction callback previously returned the stale in-memory job copy (read at tick start). This clobbered any concurrent updates made to the job (such asExpr, Prompt, or Cwd edits) while the job was running.

Update the callback to apply schedule and status changes directly onto the fresh current job loaded from disk, and return current.
2026-07-10 16:41:27 +08:00
euxaristia aa73a76f1b fix(tui): resolve pending askUser callbacks to prevent runner hangs (#620)
When a run is completed or canceled (agentResponseMsg) while an askUser prompt is pending, or when a stale/superseded askUserRequestMsg is received, the answer callback is now invoked with nil. This unblocks the waiting agent loop goroutine, preventing goroutine leaks and runner hangs.
2026-07-10 10:12:16 +08:00
euxaristia 5b4f48d2dc fix(cli): prevent consuming positional arguments as flag values (#619)
When parsing CLI flags, positional prompt text placed immediately after flags requiring values (e.g. --auto, --notify, --max-turns) would be greedily consumed as their value by nextFlagValue. Refine nextFlagValue to validate choices/types (autonomy levels, notify modes, integers) to detect invalid values early and fail validation rather than eating positional arguments.
2026-07-10 10:11:12 +08:00
euxaristia f78b36c770 fix(tools): preserve SysProcAttr during PTY fallback (#618)
When falling back from a failed PTY start to a pipe execution, the command attributes are reset. Previously, resetExecCommandForPipeFallback set command.SysProcAttr = nil, which stripped custom settings like Windows CmdLine quoting. Update the fallback logic to restore the original SysProcAttr instance, and add TestStartExecProcessPTYFallbackPreservesSysProcAttr to cover.
2026-07-10 10:08:32 +08:00
euxaristia 2db00ee3d5 fix(windows): resolve absolute path for taskkill to prevent hijacking (#617)
Using unqualified taskkill / taskkill.exe commands on Windows can allow binary hijacking under certain conditions. Update all process termination callers on Windows to resolve the absolute path to taskkill.exe under System32 (referencing SystemRoot/windir environment variables, falling back to C:\Windows).
2026-07-10 10:08:11 +08:00
euxaristia 8ea53841a5 fix(daemon): handle os.ErrPermission as collision during O_EXCL lock creation (#616)
On Windows, a concurrent holder's os.Remove leaves the lock file in a 'delete pending' state, causing a racing O_EXCL create to fail with ERROR_ACCESS_DENIED (mapped to os.ErrPermission). Treat os.ErrPermission as lock contention / already-existing lock, matching the collision checks in cron, hooks, oauth, and securefile.
2026-07-10 10:07:50 +08:00
euxaristia 8536cc87f7 fix(securefile): reclaim stale lock files to prevent permanent DOS (#615)
* fix(securefile): reclaim stale lock files to prevent permanent DOS

When createSecretFile fails to acquire the O_EXCL lock because it already exists, check if the file is stale (older than 10 seconds). If so, atomically reclaim it via a rename-aside check, preventing a permanent DOS after abrupt process crashes. Update the lock file to write and verify process-specific tokens, and add TestCreateSecretFileReclaimsStaleLock to cover the stale reclaim flow.

* style(securefile): gofmt const block alignment
2026-07-10 10:07:22 +08:00
euxaristia ddc4927aac fix(acp): make truncateHint rune-safe (#614)
truncateHint slices string hints using a raw byte offset (s[:max]), which can split multi-byte UTF-8 characters (like CJK characters, emojis, or accents) in half and produce invalid UTF-8 sequences. Update it to count and slice by runes to guarantee valid UTF-8, and add a unit test TestToolTitleTruncateHintRuneSafe to verify.
2026-07-10 10:05:56 +08:00
euxaristia 0184581ec2 fix(agent,tui): resolve git branch detection when starting Zero in subdirectories (#613)
Currently, both gitBranchForPrompt (agent) and gitBranch (tui) check for a .git entry directly inside the current working directory. When starting Zero in nested folders/subdirectories of a repository, the check fails and the branch segment is omitted.

Export findProjectGitRoot as FindProjectGitRoot in agent package, and use it in both functions to resolve the correct repository root before checking HEAD and resolving the branch name. Also resolve relative worktree gitdirs relative to the repository root.
2026-07-10 10:05:36 +08:00
euxaristia 3d96ac7e55 fix(sandbox): remove windowsWriteRestricted flag to fix DenyRead bypass (#612)
On Windows, creating restricted tokens with windowsWriteRestricted (0x08) instructs the kernel to skip checking the restricted SIDs list for read operations. This bypasses DenyRead path policies on NTFS. Remove the flag to enforce restricted SID constraints on both read and write operations, and add integration test assertions to verify that DenyRead paths are correctly blocked.
2026-07-10 10:05:04 +08:00
Gautam ManchandaniandGautam Manchandani fdddb05ba8 fix: harden MCP credential boundaries (#597)
Co-authored-by: Gautam Manchandani <[email protected]>
2026-07-10 09:00:17 +08:00
euxaristia b6117af86d fix(exec): stop false INCOMPLETE downgrades on conversational final messages (#608)
* fix(agent): scope self-reported incompletion to sentences about the current objective

The detector matched inability stems anywhere in the final message, so a
conversational recap of a past exchange ("You asked if I could work
autonomously ... so I couldn't actually do it at the time") downgraded a
completed run to INCOMPLETE. Match per sentence instead, drop quoted spans,
and skip sentences that retell a past exchange. The real transcript is now a
regression test, and genuine admissions still fire, both present tense and
past tense about the current task.

* feat(exec): add --no-completion-gate for conversational callers

A chat frontend driving zero exec has an operator present, so a final
message that hands the turn back ("the sandbox blocks network egress,
approve it and I'll continue") is a complete conversational answer, not an
unfinished task. The headless completion gate still stamped such runs
INCOMPLETE (exit 4) on the honest inability phrasing, and no phrase
heuristic can fix that case because the admission is true. The new flag
maps to RequireCompletionSignal=false so conversational callers can opt
out. Default behavior is unchanged.

* fix(exec): address review findings on quote stripping and --use-spec combination

stripQuoted now removes only balanced quoted spans: an opening delimiter
that never closes is restored verbatim, so a stray quote cannot swallow
the rest of the message and hide a genuine admission from the detector.
Regression tests cover dangling straight, curly, and backtick delimiters.

--no-completion-gate combined with --use-spec is now rejected at parse
time, mirroring the --self-correct check: the spec-draft path never
consults the completion gate, so the flag would be silently ignored.
2026-07-10 08:59:33 +08:00
github-actions[bot] 584ef75a46 chore(main): release 0.3.0 (#537)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-07-09 23:19:06 +08:00
beardthelionandKevin Codex a880ce80a6 feat: gate project-scoped hooks, plugins, and MCP servers behind workspace trust (#529)
* feat(workspacetrust): add exact-match trust store for project config gating

* feat(hooks): add ExcludeProject option to LoadConfig

* feat(plugins): add ExcludeProject option to Load

* feat(cli): gate project hooks and plugins behind workspace trust

Trust check lives inside newHookDispatcherWithExtra and activatePlugins
(fail-closed by construction); trustRoot is the original launch dir so a
--worktree run inherits the source repo's trust. Callers OR the skip
reports and emit one combined stderr notice.

* feat(cli): add zero trust command (trust/list/remove)

* fix(review): key spec-draft trust on the original launch dir; cover trust CLI edge branches

Spec-draft runs (zero exec --use-spec --worktree) passed the reassigned
worktree path as trustRoot, dropping a trusted repo's project hooks
(fail-closed, but breaks worktree-inheritance). Thread the captured
pre-worktree trustRoot into execSpecDraftRun. Add tests for the trust
'remove' too-many-args and --help branches, and a resolveTrust comment
noting the per-chokepoint check must not be hoisted to callers.

* test(cli): end-to-end trust-gate coverage through agent.Run and exec --worktree

Drives the real agent loop with a fake tool-calling provider so a project
beforeTool hook actually fires (or is gated away) through production code:
gate blocks a tool call via agent.Run, and --worktree / --use-spec --worktree
key trust on the original launch dir. All three are load-bearing (RED when the
gate or the trustRoot threading regresses).

* style(cli): gofmt struct alignment in exec_spec.go

* fix(workspacetrust): compare stored trust entries literally, no symlink re-resolution

External adversarial review (codex) found a false positive: IsTrusted
re-normalized stored entries on every check (Abs+EvalSymlinks), so a trusted
path later replaced by a symlink to attacker content re-resolved to the new
target and matched. Stored entries are already canonical (normalized once at
Trust time); compare them as literals and only normalize the incoming query.
Add a regression test for the symlink-retarget case.

* feat(mcp): gate project-scoped MCP servers behind workspace trust

Closes the P0 an external adversarial review (codex) found: project
./.zero/config.json stdio MCP servers spawned before the hook/plugin gate,
an ungated arbitrary-execution path that could also self-trust the workspace.
Add ExcludeProject to config.ResolveMCP (skips the project layer) and thread a
fail-closed trust check into every MCP spawn site (exec, TUI setup+refresh,
mcp tools list, mcp check); reporting sites stay ungated. Verified the sole
server-command exec (mcp/client.go) is reachable only through the now-gated
RegisterTools paths.

* test(cli): cover default-mode beforeTool firing through the trust gate

Assert a project beforeTool hook fires in the default auto and ask permission
modes (not only under unsafe), and is blocked when the workspace is untrusted,
so the gate is verified in the mode a normal run actually uses.

* docs(cli): note MCP servers in zero trust help text and doc comment

The gate covers project hooks, plugins, and MCP servers, but the help string and
runTrust doc comment listed only hooks and plugins.

* test(cli): make workspace-trust tests portable on macOS and Windows

setTrustConfigRoot set HOME on darwin, but config.UserConfigDir is XDG-first
there (=$HOME/.config), so tests wrote trust.json and user hooks.json where the
code never read them; set XDG_CONFIG_HOME on every non-Windows platform instead.

writeMarkerHook shells out to /bin/sh, absent on Windows, so its trusted-case
marker never appeared; skip it there like writeMarkerHookScript already does.

TestRunTrustAddThenList compared `trust list` output (canonicalized via
EvalSymlinks) against the raw cwd, which diverges on the Windows runner's 8.3
short names (RUNNER~1 -> runneradmin); compare the resolved path, and add
TestRunTrustListShowsCanonicalPath to exercise that via a symlink on any platform.

* feat(cli): surface the project MCP skip in the workspace-trust notice

Project MCP servers were dropped silently in an untrusted workspace: the trust
notice covered hooks and plugins but not MCP, and app.go discarded the
resolveTrust error. registerMCPToolsForWorkspace now returns a trustSkip, gated
on a new projectMCPConfigExists check plus the previously-dropped store-read
error, and emitTrustNotice is variadic so the exec, spec-draft, interactive, and
`mcp tools list` paths all fold in the MCP skip.

The skip drives the notice only; the gate stays on resolveTrust/excludeProject,
so a trusted workspace is unaffected and an untrusted one still fails closed.

* fix(cli): print zero trust --help to stdout with a success exit

zero trust -h/--help/help routed usage to stderr with the usage exit code, unlike
every other subcommand (mcp, sandbox, skills, cron, ...) which prints help to
stdout and returns success. Align it; the unknown-subcommand path still writes to
stderr with the usage exit code.

* fix(cli): gate oauth MCP login and surface mcp-check trust notice

Two project-scoped MCP activation sites were left outside the workspace
trust gate.

zero mcp oauth login loaded a project-defined OAuth server from an
untrusted workspace and ran the full discovery/registration/token-exchange
flow against its configured URL. Gate resolveOAuthServer with resolveTrust
so an untrusted clone's ./.zero/config.json OAuth server is dropped before
login, and emit the same one-line trust notice the other spawn sites use.

zero mcp check already gated the project layer but discarded the trust-check
result and never emitted the notice, so a gated project server read as a
bare "not configured". Emit the notice on the not-found branch.

The three remaining ungated resolveMCPConfig(cwd, false) sites (backends
doctor/snapshot and the extensions listing) are enumeration-only and stay
ungated.

* test(cli): adapt MCP startup-skip tests to two-arg resolveMCPConfig

---------

Co-authored-by: Kevin Codex <[email protected]>
2026-07-09 10:51:09 +08:00
Kevin Codex a7cfb99fed fix: bump Go to 1.26.5 for crypto/tls fix (GO-2026-5856) (#607) 2026-07-09 10:02:08 +08:00
PierrunoYT 1fc9b2d25c fix(update): clearer error on unsupported release platform (android/termux) (#603)
* fix(update): clearer error on unsupported release platform (android/termux)

zero update/upgrade fails on Android/Termux because GOOS "android" has
no published release archive. Replace the generic "unsupported release
platform: android" error with a clear, actionable message explaining
that no release builds are published for the platform, listing the
supported platforms, and suggesting a rebuild from source. The command
still fails gracefully (clean stderr message, exit 1, no panic/stack
trace) rather than crashing.

Fixes #598

* fix(update): address review feedback on unsupported-platform error

- Condense the explanatory comment for the unsupported GOOS branch.
- Replace the Unicode em dash with plain ASCII punctuation.
- Align internal/release.ReleasePlatform's error message with the
  updater's so both flows give identical guidance.

* fix(update): npm-based upgrade guidance for platforms without release assets

Address review feedback: the previous message implied Android/Termux was an unsupported platform and told users to rebuild from source. Termux runs zero fine via the npm wrapper; it just has no self-updating release archive. Point users at 'npm update -g @gitlawb/zero', the documented Termux install/upgrade path, instead.
2026-07-09 09:34:48 +08:00
Ashwinhegde19 66a6396414 fix(modelregistry): reject oversized models.dev cache responses (#602)
* fix(modelregistry): reject oversized models.dev cache responses

Read modelsDevFetchLimit+1 bytes instead of modelsDevFetchLimit. If the
response exceeds the 32 MiB guard, return an error and leave any
existing cache untouched.

Fixes #510

* address review: include byte count in error, make oversized test hermetic

- error message now reports actual bytes read vs the limit
- test payload is limit+1 bytes (matches the reported repro)
- test explicitly clears ZERO_DISABLE_MODELS_FETCH so it exercises
  the oversized path regardless of the outer environment
2026-07-09 09:33:52 +08:00
Gautam ManchandaniandGautam Manchandani 8a05e6486f perf(grep): stop content scan after head limit (#601)
Co-authored-by: Gautam Manchandani <[email protected]>
2026-07-09 09:33:31 +08:00
euxaristia 92a92ceb29 feat(modelregistry): infer reasoning efforts for Hunyuan and vendor-prefixed model ids (#599) 2026-07-09 09:33:12 +08:00
PierrunoYTandCopilot App 062328b632 fix: warn about untracked scratch files left behind after a run (#571)
* fix: warn about untracked scratch files left behind after a run

Fixes #551. Headless exec runs could leave behind debug/scratch files
(e.g. _fix_test.py, _debug.py) created by write_file during iteration,
which would then be silently swept up by a later git add -A.

- FileTracker now records brand-new files (RecordCreated/CreatedFiles),
  distinct from the existing version-baseline tracking used for
  conflict detection.
- write_file records a path as created only on first write, not on
  overwrite of an existing file.
- After a headless run completes, exec.go checks git status for any
  created file still present and untracked, and surfaces a warning via
  the existing writer.warning() (works across text/-o json/stream-json).
  No files are deleted automatically — a wrong guess at 'scratch' could
  destroy real work, so this only surfaces what git already knows.

Co-authored-by: Copilot App <[email protected]>

* fix: address scratch warning review comments

* test: normalize created file tracker paths

* fix: track apply_patch-created scratch files

* fix: address scratch warning review feedback

* fix: tighten scratch warning lifecycle

---------

Co-authored-by: Copilot App <[email protected]>
2026-07-09 09:25:09 +08:00
euxaristiaandVasanthdev2004 f10ed0c893 fix(tools): Windows cmd.exe quoting guidance and clipboard escaping fix (#468)
* merge: rebase onto main, combine Windows shell guidance with #476's MSYS text

#476 merged into main and rewrote the same "Shell syntax: Windows cmd.exe..."
guidance string in system_prompt.go and shellGuidanceForGOOS in
shell_runtime.go that this branch also edits (this PR's fix for the broken
single-quoted quoting examples). Combined both into one coherent string in
each spot: the double-quote/single-quote metachar rule with correct
examples, plus #476's MSYS coreutils warning and require_escalated
mention. Merged the corresponding test assertions in loop_test.go the
same way.

internal/imageinput/clipboard.go, internal/tools/bash_tool_test.go, and
internal/tools/exec_command_test.go applied cleanly with no overlap.

* test(tools): add host shell syntax description test for exec_command

* chore: retrigger CI after infra flake

* style(tools): gofmt exec_command_test.go

---------

Co-authored-by: Vasanthdev2004 <[email protected]>
2026-07-09 09:23:40 +08:00
euxaristiaandgnanam1990 8a52d98cad fix(mcp): skip RFC 8414 discovery when OAuth endpoints are preconfigured (#586)
* fix(mcp): skip RFC 8414 discovery when OAuth endpoints are preconfigured

resolveAuthorizationServer performed a real network metadata discovery call even when the MCP server config already supplied both the authorization and token endpoints. When discovery blocks (offline, unreachable issuer, or test fixtures pointing at invalid hosts) the whole Login flow hangs and never reaches the browser/loopback step, so mcp oauth login could not complete.

When both endpoints are present, return them directly and skip discovery entirely; discovery remains the fallback for any endpoint the config leaves blank. This also fixes TestRunMCPOAuthLoginStoresTokens, which timed out because the loopback callback was never driven (the URL was never printed).

Removes scratch test files left over from investigation (internal/mcp/scratch_cli_test.go, internal/cli/scratch_oauth_test.go).

* fix: trim fast-path endpoints, restore fallback test, fix picker_test compile error

- Trim AuthorizationEndpoint/TokenEndpoint/RegistrationEndpoint in the
  skip-discovery fast path to match the fallback path (P3 from @jatmn).
- Update TestResolveEndpointsFallsBackToConfig to only configure one
  endpoint so discovery still runs and fails, exercising the fallback.
- Add TestResolveEndpointsSkipsDiscoveryWhenBothConfigured to verify
  discovery is skipped when both endpoints are configured (P2 from @jatmn).
- Fix picker_test.go:771 to capture 4 return values from switchProviderModel
  (pre-existing compile error on main blocking CI smoke).

* test(mcp): prove discovery is skipped via request-failing transport

The skip-discovery test passed even without the fast path, because
discovery against the invalid URL failed instantly and the config
fallback produced identical trimmed endpoints. Use a transport that
fails the test on any outbound request so the fast path is actually
what is being verified.

---------

Co-authored-by: gnanam1990 <[email protected]>
2026-07-09 09:21:47 +08:00
euxaristia 46077c8d24 test(config): make Resolve local-control tests env-independent (#585)
* test(config): make Resolve local-control tests env-independent

Resolve reads ZERO_PROVIDER from the environment via applyEnv, then fails with "no active provider configured" when that provider is not present in the config. This made TestResolveLocalControlFromUserConfig, TestResolveLocalControlIgnoresProjectOptIn, TestLocalControlDefaultsBrowserAndTerminalOn, and TestLocalControlExplicitGlobalDisable fail whenever the process environment happened to export ZERO_PROVIDER (e.g. an agent session), even though the tests never intended to exercise provider resolution.

Pass Env: map[string]string{} to the affected Resolve calls so the tests assert only local-control resolution and are unaffected by ambient environment variables. Verified the four tests pass even with ZERO_PROVIDER=gitlawb-opengateway set.

* fix(tui): align picker_test switchProviderModel call with 4-value return
2026-07-09 09:20:45 +08:00
euxaristia 041167a272 Fix OpenGateway credential lookup, stream error code mapping, and model catalog (#580)
* Fix OpenGateway credential lookup, stream error code mapping, and model catalog

* fix(providers): map semantic OpenAI stream error codes, not just numeric

Addresses jatmn's review (following up on CodeRabbit's original request):
the streamed error classifier only recognized numeric codes ("429", 401,
403 as string/float64/int), so semantic string codes OpenAI-compatible
providers commonly send instead (rate_limit_exceeded, insufficient_quota,
invalid_api_key) fell back to a generic 500 and lost their rate-limit/auth
classification.

Unified the numeric and semantic mappings into one
openAIStreamErrorStatusByCode table so both forms of the same condition
classify identically. insufficient_quota maps to 429, matching OpenAI's
own API behavior for quota-exceeded errors.

Added TestStreamCompletionClassifiesStreamErrorCode covering both forms.

* test(providermodelcatalog): cover the new gitlawb-opengateway catalog IDs

Addresses jatmn's review (completing CodeRabbit's original request):
TestModelsAreProviderScoped only asserted mimo-v2.5-pro and tencent/hy3
for gitlawb-opengateway, missing the other newly curated IDs added in
this PR (xiaomi/mimo-v2.5-pro, minimax/minimax-m3, qwen/qwen3.7-max,
google/gemini-3.1-flash-lite, z-ai/glm-5.2,
nvidia/nemotron-3-ultra-550b-a55b:free). A typo or accidental removal
in any of them would not have failed a test.

* test: cover the last catalog ID and probeProviderHealth's stored-key wrap

Addresses jatmn's second follow-up review on PR #580:
- TestModelsAreProviderScoped still missed xiaomi/mimo-v2.5 (distinct
  from xiaomi/mimo-v2.5-pro) among the newly curated gitlawb-opengateway
  catalog entries.
- fillAppDeps wraps probeProviderHealth the same way it wraps
  newProvider, applying the stored credential-store key before
  delegating, but only newProvider's wrap had a regression test
  (TestFillAppDepsWrapsNewProviderWithStoredKey). Added the parallel
  TestFillAppDepsWrapsProbeProviderHealthWithStoredKey so the health-check
  path used by `zero providers check --connectivity`, `zero doctor
  --connectivity`, and the TUI doctor panel can't silently regress back
  to keyless probes.
2026-07-09 09:20:10 +08:00
PatrickNoFilterandPatrickNoFilter 8e9149f1a2 fix(tui): paste protection for Termux char-by-char paste (#573)
* fix(tui): paste protection for Termux char-by-char paste

Termux context menu paste injects characters one at a time, including
newlines as raw KeyEnter events. This caused every pasted multiline
text to be submitted prematurely before the user could review it.

The fix adds a timing-based paste detector that works alongside the
existing bracketed-paste handler (tea.PasteMsg):

- Three new model fields: lastCharTime, lastKeyTime, burstCount
- Timing trackers run before any early return in case tea.KeyPressMsg
- Enter handler checks burstCount > 1 (2+ chars within 100ms = paste)
- Composer check gated with burstCount > 0 to avoid infinite composer lock
- burstCount reset at every intentional action (permission, picker,
  submit, wizard, spec review, etc.) so normal typing is unaffected

The 100ms window catches all observed Termux paste speeds while
remaining under typical human inter-key intervals. The burstCount
resets at modal boundaries prevent false triggers in tests and
real usage.

* fix(tui): reset burstCount in Enter handler wizard paths

CodeRabbit review found 3 Enter-handler paths (providerWizard,
mcpAddWizard, mcpManager) that delegated without resetting burstCount.
The equivalent post-switch paths already had the reset but the
Enter-specific branches were missing it, creating a window where
stale burst state could cause false paste detection.

* fix(tui): raise paste burst threshold to >2, use injectable clock

* fix(tui): gate paste detection to Termux only, add bidirectional tests

- Paste burst detection (insert newline on rapid Enter) now only
  activates under Termux (TERMUX_VERSION env var), so fast desktop
  typing never false-triggers.
- Switched burst tracker from time.Now() to m.now() for deterministic
  testing.
- Added 3 tests:
  • TestTermuxBurstInsertsNewline  — Termux + 3 chars + Enter → newline
  • TestTermuxFastTypingSubmits   — Termux + 2 chars + Enter → submit
  • TestDesktopBurstNotAffected   — Desktop + 3 chars + Enter → submit

* test: extract burst test helper, fix assertions, add reset-after-submit test

- Extracted shared burstTestModel helper + typeKeys helper to eliminate
  duplication across the 3 tests (CodeRabbit nitpick).
- Added m.pending assertion to submit tests (cmd != nil alone is weak).
- Added TestBurstResetAfterSubmit verifying burstCount is properly
  reset after submission — 2 chars + Enter still submits.

* test: add explicit pending check and lastKeyTime reset in burst reset test

* fix(tui): align composer-multiline guard with same burstCount > 2 threshold

* test: add TestMultilineBurstSubmits for multiline composer + 2-char burst

---------

Co-authored-by: PatrickNoFilter <[email protected]>
2026-07-09 09:18:55 +08:00
euxaristia ce9cb91295 fix(update): support safe symlink extraction during updates (#575)
* fix(update): support safe symlink extraction during updates

Allow extracting symlinks from TarGz and Zip update archives to support Unix npm shim symlinks. Prevent path traversal vulnerabilities by rejecting absolute link targets and verifying that relative link targets do not escape the destination directory.

* revert(update): keep zip symlink entries rejected

Addresses jatmn's review: the stated bug (standalone update extraction
broken on Unix/macOS) is about .tar.gz archives; the zip path handles
Windows release archives. Adding zip symlink support alongside the
tar.gz fix reversed the existing zip symlink-rejection hardening
(TestExtractZipRejectsSymlinkEntry) and failed Windows CI, since
filepath.IsAbs does not reliably reject a slash-rooted target like
"/some/other/path" on Windows (which needs a drive letter or UNC
prefix to be absolute).

Reverted extractZip's symlink handling back to unconditional rejection
and removed the two zip-symlink tests that covered it. The tar.gz
symlink support and its tests (the actual fix for the reported bug)
are unchanged.
2026-07-08 23:23:40 +08:00
PierrunoYTandClaude Sonnet 5 2a062aa401 fix(auth): persist OpenRouter API key after login (#595)
* fix(auth): persist OpenRouter API key after login

zero auth openrouter minted a key but only printed it, leaving the
provider unusable unless the user manually exported it or edited
config by hand. Now the key is saved into the credential store, the
provider profile is created/activated, and stale inline/env key
fields are cleared. Auth-failure hints also now point at zero setup
and zero auth openrouter instead of the vaguer zero auth.

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

* fix(auth): sanitize minted key and roll back orphaned credential on save failure

Addresses PR review comments: trim the OpenRouter key right after
login so both the save path and the manual fallback instructions use
the sanitized value, and delete the just-written credential-store
entry if persisting the stored-key marker in config.json fails, so a
partial failure doesn't leave an orphaned secret.

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

---------

Co-authored-by: Claude Sonnet 5 <[email protected]>
2026-07-08 23:04:57 +08:00
ba4c00755d fix(sandbox): Windows-appropriate suggestions when blocking interactive commands (#414)
* fix(sandbox): give Windows-appropriate suggestions when blocking interactive commands

DetectInteractiveCommand already receives goos, but its remediation
text for pagers (less/more/most), process monitors (top/htop/btop/
btm), and `tail -f` hardcoded POSIX-only alternatives (cat, head,
tail -n N, ps aux) regardless of platform. On Windows none of those
exist either, so blocking e.g. `type file | more` correctly stopped
the interactive command but then pointed the model at three more
commands cmd.exe doesn't have — directly undermining the guidance
this check exists to give. Reported in the wild (screenshot showing
`more` blocked with a "use cat, head, or tail -n N" suggestion on a
Windows session).

Adds an optional windowsSuggestion per program/segment, used in
place of the POSIX suggestion when goos == "windows" — `type` for
pagers, `tasklist` for process monitors, the read_file tool for
tail -f. Non-Windows suggestions are unchanged.

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

* test(sandbox): cover remaining Windows suggestion overrides and fix GOOS-blind assertion

TestBashToolBlocksInteractiveCommandThroughSandbox asserted the pager
guard message always contains "cat", but on Windows it now correctly
suggests "type" — breaking Smoke (windows-latest) CI. Make the
assertion GOOS-aware. Also extend the safe_command windowsSuggestion
test table to cover most/top/btop/btm, which were missing alongside
the already-tested more/less/htop/tail -f.

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

---------

Co-authored-by: Claude Sonnet 5 <[email protected]>
Co-authored-by: Vasanthdev2004 <[email protected]>
2026-07-08 18:00:55 +08:00
euxaristia 066681855b fix(tools): block MSYS and WSL shells under the Windows sandbox (#587)
* fix(tools): block MSYS and WSL shells under the Windows sandbox

Git-for-Windows bash.exe and sh.exe are MSYS binaries: the MSYS runtime
secures its signal pipe and shared-memory sections with explicit DACLs
granting only the user, Administrators, and SYSTEM (sigproc.cc
sigproc_init -> sec_user_nih), so under the WRITE_RESTRICTED token the
second access check can never pass and startup dies with "couldn't
create signal pipe" or "CreateFileMapping <SID>.1", Win32 error 5. The
System32 WSL bash launcher fails equivalently: the restricted token
cannot connect to the WSL service (Bash/Service/CreateInstance/
E_ACCESSDENIED). Every executable a bare bash can resolve to fails, so
block bash and sh preflight like the MSYS coreutils from #476, mention
the indirect spawn paths (git hooks, git/gh credential helpers) in the
shared suggestion, detect the WSL launcher's UTF-16LE service-denied
stderr, and document the root cause in the runner's KNOWN LIMITATION
comment beside the Schannel note.

* fix(tui): align picker_test switchProviderModel call with 4-value return

* test(tools): cover sh.exe in Windows shell blocking test
2026-07-08 17:57:56 +08:00
Pengkuh Dwi Septiandi 50d4bef8e5 docs: add how zero works architecture guide (#539)
Fixes #541
2026-07-08 17:50:42 +08:00
Vasanth T 8f156506dc fix(tui): update picker_test for switchProviderModel 4-value return (#589)
#568 added a bool return to switchProviderModel (model, string, bool,
tea.Cmd) and updated every call site except picker_test.go:771, which
still assigned three values. That broke the internal/tui test build
and turned Smoke red on main. Add the missing receiver here.
2026-07-08 14:59:51 +08:00
PierrunoYT 6eb4b89f3f Update plugin skill discovery docs (#582)
* Update plugin skill discovery docs

* Clarify plugin skill precedence docs
2026-07-08 09:12:29 +08:00
PierrunoYT 6ec77d62f5 Dispatch session lifecycle hooks (#581) 2026-07-08 09:10:51 +08:00
euxaristia 25653f686c fix: gitignore Windows sandbox helpers and npm version marker (#578)
* fix: gitignore Windows sandbox helpers and npm version marker

Add three missing build artifacts to .gitignore:

- /zero-windows-command-runner.exe — helper built by cmd/zero-windows-command-runner
- /zero-windows-sandbox-setup.exe — helper built by cmd/zero-windows-sandbox-setup
- /.zero-binary-version — marker file written by scripts/postinstall.mjs during npm install

These are generated files analogous to the already-ignored /zero, /zero.exe,
zero-termux-arm64, and zero-linux-sandbox entries.

* fix: gitignore the Linux zero-seccomp helper too

Addresses jatmn's review: scripts/postinstall.mjs copies zero-seccomp
next to zero-linux-sandbox, and README.md documents building it, but
.gitignore only covered the Windows helpers and zero-linux-sandbox.
A Linux source build or npm postinstall could still leave this
generated helper visible to git status.
2026-07-08 09:10:28 +08:00
euxaristia c7346fbcf0 fix(tui): bypass toggleSidebar and toggleMouse global shortcuts when composer is non-empty (#576)
* fix(tui): bypass toggleSidebar and toggleMouse global shortcuts when composer is non-empty

Allows standard readline/Emacs cursor movement keybindings (Ctrl+B and Ctrl+E) to work inside the prompt composer when typing a prompt. This prevents these keys from being globally intercepted to toggle the sidebar or mouse mode when the composer is populated.

* fix(tui): remove trailing whitespace, scope composer guard to default chords

Addresses jatmn's review:
- Removed a trailing-whitespace-only blank line in
  keybinding_help_test.go that was failing the diff-hygiene CI check.
- The composerValue() == "" guard on toggleMouse/toggleSidebar applied
  to the whole keyMatch(...) result, so a user who remaps either
  binding to a chord that doesn't conflict with readline navigation
  (Ctrl+E/Ctrl+B) lost that shortcut entirely while typing. The guard
  now only applies when the match came through the default chord;
  a remapped binding fires regardless of composer state.

Added TestRemappedToggleBindingsIgnoreComposerGuard covering both
bindings.

* fix(tui): treat an explicit default-chord config the same as unset

Addresses jatmn's follow-up review: the prior fix checked
!b.isZero() to decide whether a binding is a genuine, non-conflicting
remap, but a user can explicitly configure toggleMouse: "ctrl+e" or
toggleSidebar: "ctrl+b" (the same chord as the built-in default).
parseBinding gives that a non-zero parsedBinding, so isZero() alone
wrongly treated it as safe to bypass the composer guard, letting
Ctrl+E/Ctrl+B hijack keystrokes mid-sentence again.

requiresEmptyComposer now compares the resolved binding against the
canonical default chord directly (parsedBinding is a plain comparable
struct), so both "unset" and "explicitly re-affirmed to the same
chord" require an empty composer; only a binding that resolves to a
genuinely different chord bypasses it.

Added TestExplicitDefaultChordConfigStillRequiresEmptyComposer.

* refactor(tui): dedupe the composer-gated toggle check

CodeRabbit nitpick: the toggleMouse/toggleSidebar dispatch cases both
repeated !requiresEmptyComposer(...) || m.composerValue() == "" inline.
Extracted canFireComposerGatedToggle so both cases share one
expression.
2026-07-08 09:10:02 +08:00
PierrunoYTandCursor d0c4e62cd4 feat(tui): remember recent provider+model selections in /model picker (#568)
* feat(tui): remember recent provider+model selections in /model picker

The /model picker's "Recent" section only ever showed the currently active
model, making it hard to switch back to a previously used provider+model pair
once you've moved on to something else (especially when the same model name
exists across multiple providers). Track a short automatic history of
provider-qualified switches instead: newest first, capped at 5, deduped by
provider+model pair (not model name alone), and selectable across providers
like any other picker row.

Closes Gitlawb/zero#562

Co-authored-by: Cursor <[email protected]>

* chore: gofmt fixes

* fix(tui): restore favorites one-row-per-model semantics in /model picker

Favorites de-dup had switched to the provider+model key, letting the same
favorited model ID surface once per provider. Revert to per-Value de-dup for
Favorites, and skip any model ID already shown under Favorites from Recent
and Catalog so favorited models don't reappear in a second group. Recent and
Catalog still de-dup among themselves by provider+model so cross-provider
rows remain distinct.

Also correct the recordRecentModel doc comment: in-memory history is updated
even without a user config path (only persistence is skipped), not a no-op.

Addresses Copilot review comments on #568.

* fix(tui): set OwnerProvider on registry catalog rows; dedupe recent-model normalization

- registryModelPickerItem now tags rows with OwnerProvider (the registry
  entry's canonical provider), fixing the plain-registry fallback path (no
  saved providers resolved any models) where two providers offering the same
  model id would collide in pickerItemDedupKey and silently drop a row.
- Export config.NormalizeRecentModels and have
  internal/tui/picker.go's normalizeRecentModelEntries delegate to it instead
  of maintaining an independent copy of the same trim/dedupe/cap logic, so the
  TUI and persisted-config normalization can never drift apart.
- Add regression tests for both.

Addresses remaining CodeRabbit review comments on #568.

* fix(tui): batch recent-model persistence into one write per switch; tighten tests

- Add recordRecentModels(pairs...), a batched form of recordRecentModel that
  normalizes and persists exactly once per switch instead of twice (outgoing
  + incoming pair each triggered their own read-modify-write to
  userConfigPath). handleModelCommand and switchProviderModel now call it
  once with both pairs.
- picker_test.go: capture and assert switchProviderModel's status return in
  TestSwitchProviderModelRecordsRecentHistory instead of discarding it, so a
  failed switch fails at the actual point of failure.
- picker_test.go: also assert the persisted config (not just in-memory
  recentModels) after the reorder/dedupe case in
  TestModelCommandRecordsAndPersistsRecentHistory.
- picker_test.go: pin down the surviving Favorites row's OwnerProvider in
  TestAssembleModelPickerItemsFavoritesDedupByModelIDAndHideFromOtherGroups,
  and drop the tautological otherRecent assertion (other-model was never
  added to recent, so it could never fail).
- resolver_test.go: add TestResolveIgnoresProjectOnlyRecentModels, covering
  the case where only project config sets preferences.recentModels and user
  config omits it — resolved value must stay empty.

Addresses remaining CodeRabbit nitpick comments on #568.

* fix(tui): route unresolvable OwnerProvider rows in-place; align recent-history keys

- choosePicker: only treat a picker row as cross-provider when its
  OwnerProvider resolves to an actual saved provider. Registry-fallback
  catalog rows (tagged with a provider *kind* string) and stale "Recent"
  rows referencing a since-removed provider now fall back to an in-place
  model switch instead of erroring "unknown provider".
- handleModelCommand/switchProviderModel: capture and record recent-history
  entries using m.providerName (the resolved value recentModelPairsForPicker
  pins the active row with) instead of the raw, possibly-empty provider
  profile Name, so a no-name provider profile can no longer produce a
  duplicate "Recent" row for the same switch.
- newModelPicker: scope the registry-fallback's active-model exclusion by
  provider too, consistent with the provider-aware de-dup used elsewhere.
- recentModelPairsForPicker: delegate to config.NormalizeRecentModels
  instead of reimplementing the same trim/dedup/cap logic.
- Remove the unused recordRecentModel wrapper.
- assembleModelPickerItems: build Favorites from recent/catalog directly
  instead of allocating a concatenated copy of both slices.

---------

Co-authored-by: Cursor <[email protected]>
2026-07-08 09:07:28 +08:00
PierrunoYTandClaude Sonnet 5 302f58bb5f fix(mcp): silence startup warning for unconfigured default servers (#563)
* fix(mcp): silence startup warning for unconfigured default servers

Firecrawl is seeded as an always-on default MCP server with no
credentials (config.DefaultMCPServers), so every fresh install prints
a loud "warning: MCP server firecrawl unavailable, skipped: ... HTTP
401" line on first launch, even though the user never configured or
touched it.

Add config.IsUnconfiguredDefault to detect a server whose resolved
config is byte-identical to its built-in default, thread it through
mcp.Server and mcp.SkippedServer, and skip the startup warning in
app.go when a skipped server is an untouched default. Servers the
user actually configured still warn loudly on failure.

Fixes #552

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

* fix(mcp): address review feedback on #563

- mergeMCPServer now marks disabledSet on the merged config whenever a
  disabled toggle was explicitly applied, so an explicit re-enable
  (e.g. `zero mcp enable firecrawl`) is no longer indistinguishable
  from an untouched default even though the resolved value matches —
  the toggle itself is the "configured" signal for IsUnconfiguredDefault.
- Tighten TestRegisterToolsFlagsUnconfiguredDefaultOnSkip to assert
  presence in the Skipped() map before checking UnconfiguredDefault,
  so a missing entry fails loudly instead of comparing zero values.

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

* fix(config): close IsUnconfiguredDefault gap for redeclared default values

IsUnconfiguredDefault compared the resolved MCPServerConfig against
DefaultMCPServers() with reflect.DeepEqual. A user who explicitly
redeclares a default's exact field values in their config JSON (e.g.
copying firecrawl's default type/url verbatim, perhaps intending to add
credentials later) produced a resolved value byte-identical to the
default, so the comparison silently treated a real, deliberate config
entry as "unconfigured" — the same trap the disabled/enabled toggle
case was already patched for, just left open for every other field.

Add an unexported `configured` field to MCPServerConfig, set whenever
MCPServerConfig.UnmarshalJSON actually runs for a server key (built-in
defaults are seeded as Go struct literals and never unmarshaled, so
they start unconfigured). IsUnconfiguredDefault now requires both
!configured and the value still matching the default — the value check
remains as a fallback for callers that construct MCPServerConfig
directly rather than through the JSON/merge pipeline, where configured
is always false.

This also lets mergeMCPServer drop the disabledSet-specific
"mark as touched" workaround added for the toggle case, since
`configured` now generalizes it.

Adds TestResolveMCPExplicitRedeclareOfDefaultValuesIsNotUnconfiguredDefault
covering the redeclared-values case.

---------

Co-authored-by: Claude Sonnet 5 <[email protected]>
2026-07-08 08:57:56 +08:00
ba99fa8d48 fix(provider): stop dropping custom no-auth providers on restart (#558)
* fix(provider): stop dropping custom no-auth providers on restart

Custom OpenAI/Anthropic-compatible catalog entries hardcode
RequiresAuth as a template default for the setup wizard, not a fact
about the user's actual endpoint. Leaving the credential step blank
(e.g. a local llama.cpp server) still stamped the saved profile with
a guessed OPENAI_API_KEY/ANTHROPIC_API_KEY env var, and that same
static RequiresAuth flag then made the profile look like it was
missing a required credential on the next run, silently filtering it
out of /model.

Add a Custom flag to the catalog descriptor so both the wizard
(write side) and setupMissingCredentialEnv (read side) judge these
profiles by their own explicit APIKeyEnv instead of the catalog's
static default.

Fixes #555

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

Co-authored-by: Pierre Bruno <[email protected]>

* fix(provider): self-heal legacy custom profiles stamped by the old wizard bug

CodeRabbit flagged that profiles saved before this fix could already
carry the phantom APIKeyEnv the old wizard stamped on every custom
provider (e.g. OPENAI_API_KEY), and the new custom-branch logic would
keep treating any non-empty APIKeyEnv as an explicit requirement —
leaving already-affected users' providers filtered out of /model even
after upgrading.

Treat an APIKeyEnv that exactly matches the catalog's own guessed
default as indistinguishable from unset, so already-broken saved
profiles self-heal without a config.json migration. A deliberately
different env var name (e.g. via --api-key-env) still means auth is
required.

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

Co-authored-by: Pierre Bruno <[email protected]>

* fix(provider): centralize custom-endpoint credential logic, fix two more surfaces

Copilot's review of the earlier fix found the same RequiresAuth-as-
authoritative bug in two more places that decide whether a saved
provider needs a credential:

- validateProviderRuntimeReady (zero providers check) would hard-error
  a no-auth custom endpoint with "requires API key".
- providerWizardCredentialLabel and providerCredentialRequired (/providers
  status, wizard summary) would still claim/warn that one was needed.

Added config.ProviderProfile.MissingCredentialEnv as the single source
of truth for this decision (custom-endpoint self-heal included), and
had setupMissingCredentialEnv and validateProviderRuntimeReady delegate
to it. providerWizardCredentialLabel and providerCredentialRequired
take the same Custom-aware fix directly, since their non-catalog
fallback path uses runtime-resolved provider-kind metadata that
shouldn't be collapsed into the canonical helper.

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

Co-authored-by: Pierre Bruno <[email protected]>

* fix(ci): request pinned Go toolchain directly

Co-authored-by: Pierre Bruno <[email protected]>

---------

Co-authored-by: Amp <[email protected]>
2026-07-08 08:54:39 +08:00
Eli Lap 1805a8ce09 Introduce opencode-go-anthropic that uses anthropic SDK for qwen and … (#544)
* Introduce opencode-go-anthropic that uses anthropic SDK for qwen and minimax models.

* Adjust naming to match convention format.

* Adjut naming to match convention.

* Update name for opencode-go-anthropic-compatible

* Update curated models for opencode anthropic and adjust test.

* Adjust filter for opencode anthropic provider and cover with tests.

* Adjust discovery model filtering - tolerate ModlIDAllowedForProvider rules.

* Change model descriptors to lowercase for minimax models.

* CLI call triggers model filtering for opencode-go-anthropic-compatible.

* feat: gate runtime model resolution on provider allowlist (PR #544 P2)

Apply ModelIDAllowedForProvider(profile.CatalogID, model) at profile
resolution and ResolveRuntimeMetadata so TUI /model and headless
--model overrides cannot escape the provider-scoped filter for
catalog-backed profiles such as opencode-go-anthropic-compatible.

resolveProfile is the single funnel for both New() and
ResolveRuntimeMetadata, so a single validation call in each return
path covers every caller (TUI command_center, exec, provider health,
context report, zerocommands). Restricted providers reject; the
default branch keeps unrestricted providers unchanged.
2026-07-07 22:43:44 +08:00
euxaristia 8b248f4e11 fix(tools): platform-specific pager suggestions, quote/caret-safe cd detection (#543)
less/more/most now suggest `type` instead of cat/head/tail on Windows,
since those tools don't exist there. Drop `more` from the MSYS sandbox
suggestion too, since more is itself blocked as an interactive pager
and would send the model straight into that guard next.

detectShellCommandIssue also strips double-quoted spans and cmd.exe
caret escapes before matching the bash-style `cd /foo` pattern, so a
cd-shaped string inside a quoted argument (e.g. a commit message) isn't
mistaken for an actual command.
2026-07-07 22:42:01 +08:00
Gautam ManchandaniandGautam Manchandani 4f1f94f26a Stream tool file IO (#565)
Co-authored-by: Gautam Manchandani <[email protected]>
2026-07-07 22:41:13 +08:00
Kevin Codex 6247ee8f9c Add Tencent HY3 to Zero OpenGateway catalog (#577) 2026-07-07 17:52:39 +08:00
Anandan 87158a1c90 feat: voice dictation (speech-to-text) (#557)
* ## Speech-to-text dictation

Adds voice dictation to Zero. Run `/voice` to enter hold-to-record mode, then hold **Space** to dictate and release to transcribe (run `/voice` again to turn it off so Space types normally). Pick a backend with `/stt-model`.

### Backends
- **Local (sherpa-onnx)** — offline, no API key. Auto-downloads a model via `/stt-model`; the engine binary is fetched once and shared across models. Family auto-detection covers the mainstream offline families (Whisper, transducer/Zipformer, Moonshine, Paraformer, SenseVoice, and the CTC families FireRedASR, NeMo, TeleSpeech, WeNet, Zipformer-CTC, Dolphin, Omnilingual — plus the encoder/decoder families Canary and FireRedASR-AED).
- **Cloud batch** — Groq and OpenAI Whisper. Keys resolve from a configured provider, the encrypted credential store, or the env var; a missing/invalid key is entered inline.
- **Streaming** — a warm local sherpa-onnx websocket server (live transcript as you speak) on desktop, or Deepgram / OpenAI Realtime.

### Highlights
- Live audio-reactive waveform while recording (real mic levels when streaming; a synthesized wave for batch).
- `/stt-model` download chooser: recommended models first, Live/Batch tag per row, size + download state, and a search filter.
- Streaming transcript is inserted live into the composer and committed on stop; batch inserts on stop. Insert-for-review by default (`stt.autoSubmit` to fire straight at the agent).
- The warm streaming server is released when `/voice` is turned off and on exit.

### Implementation notes
- No CGO and no bundled runtimes — every OS integration shells out via `exec.Command` with discrete argv (never a shell string).
- The streaming server manager mirrors the LSP manager's warm-subprocess lifecycle.
- OS-boundary code (recorders, transcribers, server) is unit-tested via injected fakes; real microphone / websocket behavior is covered by env-gated smoke tests.

### Testing
- `go build ./...`, `go vet ./...`, and the config / dictation / cli / tui suites pass.
- Verified end-to-end against real downloaded binaries + models (offline batch, streaming websocket server, and the model auto-download), including the FireRedASR-CTC and NeMo streaming transducer families.

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

* **New Features**
  * Added speech-to-text dictation with both batch and streaming modes, supporting local (sherpa-onnx) and cloud providers, including WAV/M4A detection.
  * Introduced voice dictation mode with STT model selection, local engine/model auto-download, and an inline prompt to save cloud API keys.
  * Added new TUI commands for voice/STT model selection and enabled F1–F12 key bindings.
* **Bug Fixes**
  * Improved streaming dictation to preserve accumulated transcript partials and keep the editor region consistent.
  * Refined TUI status/picker rendering, including clearer loading/search behavior and more reliable selection alignment.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

* fix(dictation): address CodeRabbit review feedback

- validateSTTConfig: add NumThreads bounds check
- downloadVerifyExtract: stage extraction into a sibling temp dir,
  atomic-rename into destDir on success so a partial extract cannot
  leave a truncated engine binary that passes fileExists() on next run
- startProcess: close pipe fds when cmd.Start() fails
- ServerManager.Shutdown: honor the caller's context deadline
- audioSpillDir: tighten any pre-existing directory to 0700
- startVariantDownload: refuse re-entry while a download is in flight
- maybeOpenSTTDownloadPicker: defer Provider=local persistence to
  applyEngineComponents (commit-on-success), matching the cloud flow
- fetchSTTModelsCmd: bound with a 30s timeout client+context
- newSTTDownloadPickerFrom.isCurrent: compare path segments via
  filepath.Base to avoid substring false-positives

* fix(dictation): harden recorder.Stop, Esc-cancel priority, streaming region

Three correctness issues from PR review:

1) recorder.Stop() panicked (nil-deref) when called on a StartStreaming
   session because StartStreaming intentionally leaves r.proc nil. Reject
   that misuse with a clear error. Termux's batch path also leaves
   r.proc nil by design — keep that path working.

2) Esc was consumed by an active dictation session before the
   confirming second Esc could cancel a pending run, so a user
   mid-recording who double-Esc's to kill the run found the first Esc
   swallowed by dictation. Gate dictation-cancel on the Esc not being
   a pending-run confirm.

3) applyStreamingText hard-reset the cursor to regionStart on every
   partial, yanking focus away from users editing elsewhere in the
   composer, and stored regionStart/End as absolute indices that became
   stale the moment the user typed or pasted before the live region.
   Preserve the cursor when it's outside the region, and snapshot the
   prefix BEFORE the region so the next partial can detect external
   edits and shift [start,end) to stay aligned.
2026-07-07 11:13:14 +08:00
Kevin Codex 16550569c1 feat(providers): OAuth login profiles and list-first /provider manager (#560)
* feat(providers): OAuth login profiles and list-first /provider manager

Make multiple providers a first-class workflow: every OAuth login is now a
visible, switchable provider, and /provider manages the whole lifecycle.

OAuth logins become provider profiles:
- zero auth chatgpt / zero auth login <preset> and all four TUI login paths
  (wizard + first-run, browser + device-code) now upsert a catalog-backed
  profile via config.EnsureCatalogProvider after storing the token — without
  stealing the active provider. Previously the token was stored invisibly:
  the provider showed up nowhere and 'providers use' failed not-found.
- Switching to an OAuth provider no longer inlines the bearer as an API key,
  which suppressed the runtime resolver (no token refresh, no Codex
  chatgpt-account-id header → guaranteed 401s). The credential gate now
  recognizes stored OAuth logins on keyless profiles.
- providers list / current report truthful credential state: stored keys
  count as configured (APIKeySet honors apiKeyStored) and token logins show
  'oauth login'; providers check accepts an OAuth login as a credential.

List-first /provider manager (TUI):
- /provider opens the saved-provider list: Enter activates via the shared
  switch path, 'a' adds (existing wizard), 'e' opens a field-level editor
  (endpoint, model, key, description, and safe rename — the stored key,
  activeProvider, and live ZERO_PROVIDER follow the new name), 'd' deletes
  with an inline confirm and stored-key cleanup. Esc walks back one level.
- Credential states resolve asynchronously so keychain reads never block
  the render loop; 'stored key missing' surfaces broken keychain entries.
- Implemented as manager steps on providerWizardState so every existing
  overlay gate, key route, and mouse hook covers it unchanged.

CLI + config parity:
- zero providers remove <name> (deletes the stored key, hands the active
  pointer off) and zero providers rename <old> <new>.
- config.RemoveProvider / RenameProvider: read-modify-atomic-write; rename
  migrates the credential-store entry write-new-then-delete-old so an
  interruption can never lose the key.

* fix(providers): address PR #560 review findings

All six review findings validated as real; fixes with regression tests:

- Persist the apiKeyStored marker when a key is captured onto an existing
  profile: mergeProfile deliberately ignores the marker (a project config
  must not claim the user's stored keys at resolve time), so the user-config
  WRITE path (UpsertProvider) now re-applies it — previously editing a key
  onto an env/no-key profile stranded the secret in the credential store
  while every ApplyStoredAPIKey gate skipped it. [jatmn P2]
- Let active-pointer recovery select OAuth-login providers:
  firstUsableProvider now applies the same providerHasOAuthLogin check as
  setupRequired/usableSavedProviders, so a stale activeProvider plus a saved
  ChatGPT/xAI login recovers instead of forcing onboarding. [jatmn P2]
- Delete stored keys from the store BESIDE the config being edited:
  providers remove and the TUI manager delete now use ProviderKeyStoreAt
  (matching setup/rename) instead of the default-path store. [jatmn P2]
- Roll back the key migration when RenameProvider's config write fails, so
  the store and config.json can't desync (key under the new name, config
  still naming the old one). [CodeRabbit]
- Clear provider descriptions for real: new SetProviderDescription writes
  the field verbatim (the upsert merge treats empty as unchanged); the
  manager's editor routes description changes through it. [jatmn P3]
- Surface stored-key deletion failures in providers remove (stderr warning
  + keyError in the JSON payload) and the TUI delete status, instead of
  letting a lingering secret read as a clean removal. [CodeRabbit]

* fix(providers): second review round — case-only rename, logout hint, test hermeticity

- Skip the credential-store migration on case-only renames (groq -> Groq):
  the store normalizes names case-insensitively, so Set(new)+Delete(old)
  target ONE entry and the delete removed the key that was just "moved".
  The existing entry already serves the new name. [CodeRabbit]
- Point the delete flow's cleanup hint at the stored login that actually
  exists: after a rename ({name:"codex", catalogID:"chatgpt"}) the token
  lives under the catalog id, and `zero auth logout codex` would delete
  nothing. oauthLoginName resolves the same FirstStored candidate the
  runtime uses. [jatmn P3]
- Lock in APIKeyEnv semantics after a key edit: the env reference survives
  as an explicit override (a SET env var wins at resolve time; the stored
  key applies when it is absent). [CodeRabbit nitpick]
- Make main's new firstUsableProvider tests hermetic: they read the REAL
  OAuth token store, so any developer with an actual xai/chatgpt login gets
  spurious failures (found because this machine has one).

Also rebased onto main; the only conflict was comment wording in
firstUsableProvider, where the audit commit (#481) independently landed the
same OAuth-login fallback check this branch added.

* fix(tui): read stored keys from the active config's credential store

The TUI's write paths (SecureProviderProfile capture, rename migration,
manager delete) all target the store beside m.userConfigPath, but the read
paths still opened the DEFAULT-path store: profileWithCredential (feeding
the /model and manager switch gate), the model picker's discovery
credential lookup, and the manager's async credential-status probe. With a
non-default config path, a provider saved with only apiKeyStored had its
key in the config-adjacent store, so the gate rejected it as having no
usable credential and the manager showed 'stored key missing' for a
healthy profile.

All TUI stored-key reads now resolve through providerKeyStoreForPath
(config-adjacent, default-path fallback for ephemeral sessions), so the UI
reads from the same store it writes to. Regression test covers both the
credential probe and the switch path with a key seeded only beside the
config.

Also consolidates the OAuth test-store seeding on withAuthStore (now pins
ZERO_OAUTH_STORAGE=file so an inherited keyring backend can't bypass the
temp path) per review nitpick.

* fix(providers): resolve self-review findings — atomic edits, key normalization, threading

Findings from a multi-agent code review of this branch (8 finder angles,
independently verified); all confirmed against the code, several with
empirical repros.

Correctness:
- config.EditProvider: one atomic read-modify-write for the manager's Save
  (rename + fields + verbatim description + stored-key marker + key
  migration with write-failure rollback). Replaces the rename→upsert→
  describe sequence that (a) turned a case-only rename (groq → Groq) into a
  DUPLICATE profile via UpsertProvider's case-sensitive merge, and (b) could
  fail halfway, leaving config.json renamed while savedProviders and
  ZERO_PROVIDER still held the old name (retry then failed 'not found').
  A rename now also migrates a freshly captured replacement key (captured
  under the current name; the migration moves it).
- savedProviders is mutated surgically on manager delete/edit instead of
  being replaced with the raw user-config list — the session list is seeded
  from the RESOLVED (project-config layered) and usability-FILTERED set, so
  wholesale replacement dropped project-contributed providers and
  resurrected filtered stubs for the rest of the session.
- oauth.ProviderKey normalizes provider names to lower case at the single
  write/lookup choke point: `zero auth login xAI` stored provider:xAI while
  every candidate lookup was case-sensitive — a successful login invisible
  to the runtime. Map-based presence checks lowercase to match.
- OAuth login persistence moved onto the Update goroutine (message handlers)
  — a background tea.Cmd (device-code polls land minutes later) doing an
  unlocked config read-modify-write raced UI-thread writes (lost updates).
  The write error is now surfaced and the in-memory mirror is skipped on
  failure, so a profile that never reached disk can't show as a working
  provider that vanishes on restart.

UX / consistency:
- Esc inside a manager-launched add flow walks back to the provider list
  from EVERY step (was: first step only; deeper steps destroyed the overlay
  and the user's place). In-flight OAuth logins are invalidated on the way
  out; footers say 'Esc back'.
- switchProviderModel returns a structured outcome; the manager branches on
  it instead of strings.Contains(text, "Switched to") — UI copy is no longer
  a control-flow contract (a provider named 'Switched to prod' could close
  the manager on a refusal).
- Manager delete's keychain subprocess + OAuth lookup run in a follow-up
  tea.Cmd (the confirm keypress no longer blocks the render loop); the
  credential probe takes ONE token-store snapshot instead of 2 reads per
  row, and resolved states carry over across reloads (no 'checking…' flash).
- Credential-state reporting converges: /provider status recognizes OAuth
  logins (was 'api key: not set' + warning for a WORKING provider), doctor
  uses the shared HasConfiguredCredential definition plus OAuth awareness
  (its 'matching ProviderSnapshot.APIKeySet' claim is true again), and the
  manager's classifier falls through a stale stored-key marker to env/OAuth
  the way the runtime does.

* Fix provider manager test formatting
2026-07-07 10:34:59 +08:00
PierrunoYTandCopilot Autofix powered by AI 008bc9b3f3 fix: address bugs found in a multi-agent codebase audit (#481)
* fix: address bugs found in a multi-agent codebase audit

Five parallel review agents each swept a different area (self-update,
TUI keybindings, agent/session handling, sandbox/shell safety, and
auth/credential storage) and surfaced nine concrete defects, all fixed
here with regression tests:

- sandbox: grant_scope path comparisons were case-sensitive, letting a
  persisted deny grant be bypassed by spelling a Windows path with
  different case
- securefile/oauth: createSecretFile's Windows ERROR_ACCESS_DENIED
  handling was only applied to the oauth token store, not the shared
  credstore backend; both also masked the real lock-contention error
  behind a misleading "file does not exist" timeout
- cli: firstUsableProvider ignored OAuth logins when picking a
  fallback provider, forcing unnecessary re-onboarding for an
  already-authenticated user
- sessions: exec prompt summarization truncated on a raw byte offset,
  which could split a multi-byte UTF-8 rune and embed invalid UTF-8
  into a resumed session's prompt
- tui: configurable keybindings had no collision detection, so a
  remapped chord could silently make a hardcoded shortcut (or another
  configured binding) permanently unreachable
- tools: the Windows shell-syntax pre-flight check wasn't quote-aware
  and could block harmless commands whose quoted arguments merely
  contained operator-like text
- update: checksum verification didn't cross-check the archive
  filename it verified against the one being extracted; extractZip
  didn't reject symlink-mode entries like extractTarGz already did;
  replaceBinary's restore-on-failure rename had no retry, risking a
  permanently missing binary on a transient Windows file lock

* fix: close remaining PR 481 review findings

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <[email protected]>

* fix(tools): drop stale ; test case after rebasing onto main's MSYS check

Rebasing fix/bug-hunt-findings onto main pulled in #476, which replaced
this file's regex-based windows_shell_syntax detection with a
segment/word-anchored check and deliberately stopped treating ; as a
command separator on Windows (cmd.exe doesn't split on ; the way bash
does). detectShellCommandIssue itself came out of the rebase unchanged
from main (our quote-masking version was fully superseded by #476's
more thorough segment-based approach), but the quoted-content test
this PR added still asserted the old bash-style semantics for
`echo "foo" ; ls -la`, which now correctly returns no issue since it's
a single echo invocation with literal arguments under cmd.exe, not a
real ls invocation.

---------

Co-authored-by: Copilot Autofix powered by AI <[email protected]>
2026-07-06 18:48:28 +08:00
Kevin Codex fd69233e33 feat(tui): show CLI version on the startup home screen (#538)
The home screen orientation line now leads with the build version
(v0.2.0 · cwd · branch · model). Release builds already inject
internal/cli.version via ldflags; dev builds show "dev".
2026-07-06 12:29:23 +08:00
Pengkuh Dwi Septiandi 3f0503bc23 fix(agent): keep tools exposed for max-turn finalization (#533)
* fix(agent): keep tools exposed for max-turn finalization

* chore(agent): rename max-turn tool definitions parameter
2026-07-06 12:07:07 +08:00
冯基魁 f951aee65d surface ACP session persistence failures (#535) 2026-07-06 12:06:38 +08:00
github-actions[bot] 82ecac5281 chore(main): release 0.2.0 (#370)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-07-06 11:51:03 +08:00
Vasanth T 387fe67ee7 feat(tui): /loop — repeat a prompt or command on an interval or self-paced (#502)
* feat(tui): /loop foundation — command parsing, loop state, safety bounds

First slice of the /loop feature (#501): the self-contained core with no TUI
wiring yet. parseLoopCommand handles the leading-interval, trailing-"every",
bare-self-paced, and list/stop forms; loopState models one session-scoped loop
with doom-loop + iteration-cap fields; interval/self-pace clamps and duration
formatting round it out, with unit tests. Because Zero drives loops off a Go
timer rather than a cron backend, arbitrary intervals (90s, 7m) are supported
with no whitelist limitation.

* feat(tui): wire /loop into the turn lifecycle — dispatch, idle tick, continuation

Second slice of #501. Adds the controller and wires it in:

- /loop command registered (commands.go) and dispatched to handleLoopCommand.
- Loop state on the model (loops, activeLoopID, loopSeq/loopTicking poll guards,
  loopNextWake/loopDone self-pace channel).
- An idle poll tick (loopTickMsg, ~1s) fires the earliest due loop only when the
  session is idle — mirrors the launchQueuedMessageIfReady guard so loops and a
  user's queued prompt never collide; a self-perpetuating tea.Tick that stops when
  no loops remain.
- The agentResponseMsg completion seam advances a loop iteration (schedule next
  wake for fixed, model delay for self-paced) with doom-loop, iteration-cap, and
  done stop conditions, restarting the ticker if other loops remain.
- /loop list|stop|stop all, footer summary + list rendering, and a guard that only
  a prompt or custom /command may loop (not a built-in).

Controller tests cover start/advance/self-paced-delay/done/doom/cap/stop/busy.

* feat(tui): /loop self-paced adaptive cadence + footer summary + /clear note

Third slice of #501.

- Self-paced loops now use an adaptive cadence (2m early -> 30m heartbeat as
  iterations accrue), with the loopNextWake channel kept as an override for a
  future explicit model signal.
- Persistent footer segment "↻ N loops · next 3:05pm" while loops are active.
- /clear notes that loops keep running (it wipes the transcript, not the session)
  so a loop never silently fires into a cleared screen.
- Tests for adaptive cadence + footer summary.

* test(tui): drive /loop end-to-end through Update (submit + stale tick)

* docs(readme): add /loop to the slash-command table

* fix(tui): stop looping on repeated failure + clear loop tag on cancel

Addresses CodeRabbit review on #502:
- advanceLoop now counts consecutive failed iterations (loopMaxFailures=3) and
  stops the loop, so a loop whose turns keep erroring no longer churns to the
  iteration cap (the doom guard only sees identical non-empty answers).
- cancelRun clears activeLoopID and re-arms the interrupted loop for its next
  cadence, so a cancelled iteration is not left running forever and the next
  unrelated turn is not misattributed as that loop's completion.

* fix(tui): finish /loop v1 — real self-paced completion, session scope, leave gate

Response to the review. The self-paced path was the real gap: it advertised
model-chosen completion but nothing ever wrote the done/next-wake fields, so it
was just adaptive polling dressed up. Replaced that with an actual control
channel — each self-paced iteration runs a short maintenance playbook and ends
its reply with a control line (LOOP: DONE / LOOP: CONTINUE [interval]) that
advanceLoop parses to stop the loop or pick the next wake. The two dead model
fields are gone; done and next-wake now come from the model's output.

Rest of the review:
- loops stop on a session switch (/new, /resume) instead of firing the previous
  conversation's prompt into the new one
- validateLoopTarget resolves custom /commands up front and rejects unknowns, so
  a mistyped `/loop 5m /nope` isn't scheduled and then re-sent as literal text
- a one-shot confirm gates /clear and /quit while loops are active
- age-based auto-expire (24h) next to the iteration cap
- the two CodeRabbit test nits (HasPrefix footer check, bare `/loop stop` on a
  single loop)

Hardening from a self-review pass over the above:
- normalize CRLF and allow a trailing note on the control line, so
  `LOOP: DONE — all clean` and \r\n replies still stop the loop
- disarm the leave-confirm before the queue/exit early returns, so an interposed
  prompt can't leave it falsely armed
- a loop no longer fires behind an open picker/modal; a run launched behind the
  /resume picker would otherwise complete into whatever session you switch to

* fix(tui): address /loop review round 2 — ticker re-arm, self-paced command, resume guard

- stopLoop re-arms the poll ticker after removing one loop, so stopping one of
  several no longer strands the rest with no tick scheduled (jatmn).
- a bare self-paced /command is rejected with a pointer to the interval form:
  self-paced drives a free-form goal via the playbook + LOOP: control line, which
  a custom command's expanded template never carries, so it would silently
  degrade to adaptive polling (jatmn).
- /resume only tears loops down on an actual session-id change; /resume latest or
  /resume <currentID> can resolve to the already-active session, whose loops are
  its own, not a "previous" session's (CodeRabbit).
- tests: skipped-behind-modal loop stays scheduled, footer carries the next-wake
  time, the confirmed /clear actually wipes the transcript, plus multi-loop stop
  keeping the ticker alive and self-paced-/command rejection.

* docs(readme): clarify /loop runs a prompt or custom command, not built-ins
2026-07-06 11:42:46 +08:00
Gautam ManchandaniandGautam Manchandani d185f3ef0d Fix action summary for structured output (#485)
* Fix action summary for structured output

* Address action summary review comments

* Keep action summary extraction best effort

---------

Co-authored-by: Gautam Manchandani <[email protected]>
2026-07-06 08:52:43 +08:00
euxaristia 8c6c49ad52 chore: fix golangci-lint findings (dead code, ineffassign, staticcheck) (#527)
- Remove unused safeRequestedPrefix, safeShellCommandTokens
  (internal/agent/command_prefix.go), and truncateHeadTail
  (internal/tools/bash.go): each was a thin wrapper with no remaining
  callers.
- Drop an ineffectual assignment in stringListArgWebSearch: every
  return path after it already sets the named return explicitly.
- Simplify a negated-and condition in
  partition_cache_stable_test.go via De Morgan's law.
- Use strings.Contains instead of strings.Index(...) < 0 in
  edit_replacers.go.
- Fix error string style (ST1005): lowercase "unsafe redirect URL"
  and drop the trailing period from webFetchPublicOnlyHint.

Skipped the errcheck findings on this pass (unchecked defer
Close()/Write() errors): those are stylistic, not correctness bugs,
and this repo's CI only runs go vet, not golangci-lint.
2026-07-06 08:09:32 +08:00
Gautam ManchandaniandGautam Manchandani 809489a6e3 Batch session event persistence (#526)
Co-authored-by: Gautam Manchandani <[email protected]>
2026-07-06 08:08:58 +08:00
euxaristia 56147a708e ci: add golangci-lint as an advisory check (#528)
go vet, gofmt, govulncheck, and deadcode don't catch unused private
functions/assignments reachable within a package but never called, or
staticcheck-style correctness/readability issues. deadcode's whole-program
reachability analysis specifically missed several of these; golangci-lint's
truncateHeadTail nitpick on #476 assumed it would fail CI, but nothing in
this repo actually ran it.

Scoped to unused, ineffassign, and staticcheck rather than the full default
battery, and continue-on-error like deadcode, so this surfaces the existing
findings across the repo (being cleaned up incrementally per #527) without
blocking in-flight PRs on a big-bang adoption.
2026-07-06 08:07:29 +08:00
euxaristiaandClaude Fable 5 81aad58d97 fix(tools): Block MSYS coreutils under Windows sandbox (#476)
* fix(tools): block MSYS coreutils under Windows sandbox

Git for Windows usr\bin coreutils (head, grep, cat, ...) are MSYS PE binaries that fail with ACCESS_DENIED and cygheap/signal-pipe errors when Zero runs them in the write-restricted Windows sandbox. Detect and block these commands before execution, surface MSYS runtime failures from stderr, steer agents toward native Zero tools or require_escalated, and exclude MSYS-prone names from known-safe command tails.

Refs #458

* fix(tools): anchor the last MSYS failure heuristic to MSYS markers

CodeRabbit review feedback: the win32-error-5 plus terminating check
had no MSYS-specific anchor, so unrelated access-denied failures could
be mislabeled as windows_msys_sandbox. Require an MSYS marker like the
neighboring checks do.

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

* fix(tools): let escalation bypass the MSYS guard and make detection quote-aware

Addresses jatmn's review on PR #476:
- bash and exec_command called detectShellCommandIssue before resolving
  sandbox_permissions into a command engine, so a require_escalated retry of
  a blocked command was still hard-blocked by the same preflight check.
  Resolve the command engine first and only bypass the windows_msys_sandbox
  issue kind when that resolves to true unsandboxed execution; other issue
  kinds (real cmd.exe syntax problems) still block regardless of escalation.
- Unify all MSYS-prone command name checks (MsysProneCommandName, the
  preflight scan, and transitively command_prefix.go's known-safe guard)
  behind one windowsMsysProneNames set. This fixes expr (in the name set but
  missing from the old regex alternations, so never proactively flagged) and
  ls (hit the older windows_shell_syntax branch first, so it got different
  metadata than every other MSYS-prone name).
- Make the preflight scan quote-aware: split the command into cmd.exe-
  operator-separated segments respecting double-quote grouping, and only
  check the first word of each segment instead of scanning raw text anywhere.
  Fixes false positives on quoted text like commit messages or PR comment
  bodies that merely mention a coreutil name.

* test(tools): decouple MSYS-guard bypass tests from real command output

TestBashToolRequireEscalatedMsysGuard and
TestExecCommandRequireEscalatedBypassesMsysGuardAfterApproval asserted
shell_issue == "" to prove the require_escalated bypass worked. Once
bypassed, "cat somefile.txt" actually runs unsandboxed, and its real,
PATH-dependent output could trip the unrelated post-execution
detectShellOutputIssue heuristic, failing the assertion for reasons
unrelated to the preflight guard under test.

Assert on the exit_code "-1" sentinel (set only by shellIssueBlockResult)
instead, which directly reflects whether the preflight guard blocked the
command.

* fix(tools): anchor MSYS path detection to command position, fix prefix approval gap

Address jatmn's latest review on PR #476:

- shell_runtime.go: the explicit MSYS binary-path check (usr\bin\,
  mingw64\bin\, msys-2.0.dll, cygwin1.dll) ran against the raw command
  string, so a quoted mention (e.g. a commit message describing a
  usr\bin\head.exe failure) was still hard-blocked. It now runs against
  the first word of each operator-separated segment, same as the
  coreutil-name check. windowsCommandSegments also stopped treating ;
  as a command separator (cmd.exe does not split on ;, unlike bash) and
  now respects cmd.exe's ^ escape character, so escaped operators like
  echo ^| head are no longer misread as a real head invocation.

- command_prefix.go: proposedCommandPrefix could offer to approve a
  single segment (e.g. "ps aux" in "ps aux | head -5") without checking
  that every other segment was independently safe. Since approving a
  prefix escalates the whole command to bypass the sandbox, an
  MSYS-prone or otherwise unsafe segment could ride along uncovered.
  It now only proposes a segment as the prefix when every other
  segment in the command is known-safe on its own.

Added regression tests for quoted MSYS path mentions, caret-escaped
operators, semicolon text, and the prefix-approval gap.

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

* fix(tools): treat redirection as a command boundary, stop using command text as output evidence

Addresses jatmn's latest review on PR #476:
- firstCommandWord only split on whitespace, so cmd.exe redirection
  attached directly to a command name (head>out.txt, cat<in.txt,
  grep>matches.txt) was read as one word and never matched against the
  MSYS-prone name set. It now also stops at < and >.
- detectShellOutputIssue scanned command+output together, so a command
  whose own argument text merely quoted an MSYS failure string (e.g. a
  gh pr comment --body describing the bug) could be mislabeled after
  running even though the real stdout/stderr was unrelated. Removed the
  command parameter entirely from detectShellOutputIssue and both call
  sites (bash.go, exec_command.go), so the command line can never be
  used as post-execution evidence again.

* fix(tools): remove unused truncateHeadTail wrapper

CodeRabbit nitpick: truncateHeadTailWithTotal is called directly
everywhere, so this wrapper is dead code that trips golangci-lint's
unused check and fails CI.

---------

Co-authored-by: Claude Fable 5 <[email protected]>
2026-07-06 08:03:32 +08:00
Kevin Codex 95ccd5bc32 perf: universal tool-output ceiling with spill + async post-edit diagnostics (#518)
* feat(tools): universal token-based output ceiling with spill-to-disk

Cap every tool result at the registry boundary (16k estimated tokens,
ZERO_TOOL_OUTPUT_CEILING_TOKENS-tunable, 0 disables) unless the tool
manages its own deliberate budget. Head+tail truncation, full output
spilled to the existing redacted spill dir and re-readable via
grep/read_file, so nothing is lost — only deferred.

This closes the three unbounded paths a single call could flood the
context window through (and then re-bill every following turn):
- web_fetch: up to 256KiB default / 2MiB max of non-HTML body
- skill: full SKILL.md returned uncapped
- MCP-served tools: no budget at all

Self-budgeting tools opt out via an unexported marker interface (an MCP
server can never exempt itself): bash, exec_command (model-raisable),
read_file, read_minified_file, grep, glob, list_directory. Browser
snapshots deliberately stay under the net.

bash itself is tightened to match the unified token model: emit budget
96KiB -> 32KiB per stream (~24k -> ~8k tokens worst case per stream),
while capture retention stays at 96KiB head+tail so the new spill file
covers 6x more of the output than the transcript shows.

* perf(agent): collect post-edit LSP diagnostics off the tool-call path

Inline post-edit diagnostics blocked every edit_file/write_file result on
the language server: a >=300ms quiet-period debounce that reset on each
publish, seconds on the first edit per language, capped at 10s — the
largest recurring latency tax in an edit-heavy session.

Diagnostics are now collected in the background: a file is enqueued the
moment a mutating tool finishes, a single worker checks it while the rest
of the batch executes, and the loop drains completed results just before
building the NEXT provider request, appending errors as a user nudge.
The model always issues another request after an edit turn (to read its
tool results), so it still sees introduced errors at the same decision
point — no tool call ever stalls on the language server.

A file re-edited before its check runs is checked once in its final
state (the checker re-reads the file); re-edited after, it is re-queued
and the newer result wins. A drain that finds the worker still busy
gives up after 3s and delivers on a later turn — results stay accurate
because any further edit re-enqueues the file.

The tools-side inline mechanism (tools.RunOptions.Diagnostics) is kept
for direct API callers; the loop just no longer wires it.

* fix: address review — max-turns diagnostics drain, capture-gap marker, test hardening

- Drain background diagnostics before the max-turns final-answer request
  so an error introduced by the LAST turn's edit is reported in the
  summary instead of silently dropped (+ regression test).
- spillBashStreams marks the head/tail junction with a capture-gap
  marker when boundedBuffer dropped the middle of a stream, so a spilled
  log never reads as contiguous when it is not; junction position is
  asserted in the test.
- Tests override TMP/TEMP alongside TMPDIR (os.TempDir reads TMP/TEMP on
  Windows) via a shared setTestTempDir helper.
- TestBudgetBashCaptureReportsTrueTotal now asserts spill_path meta and
  spill file content; exemption-list test gains read_minified_file.

* fix(test): use a real workspace root in async diagnostics tests

filepath.IsAbs("/ws") is false on Windows (no drive letter), so the
collector test's workspace-absolute assertion failed on the windows
smoke job. Root the fake workspace at t.TempDir() — absolute on every
platform — and assert against it.

* fix: pattern-scrub spill files and gate finalization on pending diagnostics

Review follow-ups (Vasanthdev2004):

- Spill files now pass the pattern-based secret scanner (AWS keys,
  GitHub/Slack/OpenAI tokens, PEM blocks, JWTs) in addition to the
  configured-key scrub. A bash spill runs before formatBashOutput's
  scan, so without this a spilled file held in cleartext exactly the
  credentials the transcript redacts. Applied centrally in
  spillTruncatedOutput, covering bash, exec_command, and ceiling spills.

- Finalization diagnostics gate: a no-tool-call final answer now drains
  pending post-edit diagnostics with the full inline-era budget (10s,
  vs the 3s per-turn wait) before the run is accepted; introduced
  errors append the nudge and give the model one more turn instead of
  being silently dropped when no later turn exists. The max-turns
  summary path uses the same finalization budget. Free for runs that
  never edited — an idle collector returns immediately.
2026-07-05 23:50:31 +08:00
KRATOS 284a6c86cb Skills: deterministic invocation — /<skill-name>, searchable /skills picker, discovery-budget fix (#520)
* feat(skills): make skill invocation deterministic and discoverable

Skills were model-pulled only: the model had to notice a skill in a
640-byte system-prompt list (~6 skills visible; the rest collapsed into
'…and N more') and call the skill tool on its own. Users could not run a
skill at all — typing /<skill-name> reported 'unknown command'. The two
halves of the fix:

User invocation
- /<skill-name> [args] now runs an installed skill directly: resolved
  after builtins and user commands, the skill body is inlined as the
  prompt with the typed args appended, and submitted through the same
  launch path user commands use.
- /skills lists installed skills with their slash form, live from disk.
  Labels are honest: a skill shadowed by a builtin, alias, user command,
  or a case-colliding sibling is listed bare with a '(via skill tool)'
  note instead of advertising a dead /name.
- The slash palette suggests skills (labeled '(skill)') read through the
  same loader dispatch uses, so mid-session installs appear and shadowed
  or case-colliding names are never advertised.
- Skill and user-command invocations now apply the plain-prompt run-state
  guards: mid-run submissions queue the EXPANDED prompt (the queue flush
  submits literal text), compaction-in-flight warns, exiting drops.
- Skills resolve through a lazy plugin-aware loader (default dir merged
  with plugin skill roots — the same set the skill tool and the system
  prompt see), TTL-cached so palette keystrokes don't re-read SKILL.md
  bodies.

Model triggering
- Raise the skills-list budget 640 -> 4096 bytes: the list is the model's
  only discovery surface, and the old cap made skills past ~6 invisible —
  invocation reliability collapsed exactly for users invested in skills.
- Raise description truncation 100 -> 200 runes: the description carries
  the trigger conditions ('use when…'); cutting them is what stopped the
  model from firing the skill.
- Strengthen the instruction line: scan the list before acting, call the
  matching skill FIRST, don't substitute your own approach.

* feat(tui): /skills opens a searchable skill picker

Replace the static /skills card with the same picker UX /model uses:
type-to-filter (ranked search), arrow-key selection, Enter runs the
chosen skill immediately. Selection is by the skill's exact name, so
skills the slash path cannot reach — names shadowed by a builtin or
user command, or names that don't fit a slash token — are still
runnable from the picker, where the choice is unambiguous. The text
card remains only as the no-skills install hint, and the picker path
applies the same run-state guards as slash invocation.

* feat(tui): ask-first guard for bare skill invocations

A skill invoked with no request submits only its body — instructions
with no target ("review the PR" — which PR?) — and the model improvises
a target instead of asking, which is especially wrong in a fresh
session. Bare invocations (slash and picker) now append a conditional
note: if the instructions need a target or details not already clear
from the conversation, ask first; self-contained skills still just run.
Invocations that carry a request are unchanged.

* fix(tui): review follow-ups — restore typed invocation on compaction guard, cap merged suggestions

- The compaction-in-flight guard for skill/user-command invocations fired
  after the dispatch path had already cleared the composer, so the typed
  "/name args" was lost and had to be retyped; thread the raw invocation
  through and restore it into the composer (the plain-prompt guard keeps
  the composer intact by firing before the clear). The picker path has no
  typed form and restores nothing.
- Cap the merged autocomplete list at the shared bound: each source
  self-caps, but three sources could stack to ~3x the palette limit.
- Tighten the shadowed-skill palette test to assert on the advertised
  Name rather than the fallback Desc wording.

* chore: ignore zero-skills local build artifact

A locally built test binary under this name was accidentally committed
(and has been scrubbed from the branch history); ignore the name so it
cannot recur.
2026-07-05 21:14:53 +05:30
8f3b8f0d13 docs: document AGENTS.md/ZERO.md and plugin manifest format (#522)
* docs: document AGENTS.md/ZERO.md and plugin manifest format

Add an "Extending Zero" section to the README covering the
AGENTS.md/ZERO.md project-instructions lookup chain and the
plugin.json manifest fields, neither of which were previously
documented outside the source.

Closes #521

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

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <[email protected]>

* docs: fix plugin path scope and add per-user ZERO.md guidelines

- Correct project-scope plugin discovery to <cwd>/.zero/plugins, not
  the repo root.
- Clarify that user-scope plugin discovery always resolves under
  ~/.config regardless of OS, separate from config.UserConfigDir()
  (which is OS-aware and used for the per-user ZERO.md file).
- Document the per-user ZERO.md guidelines file added in #475.

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

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <[email protected]>

---------

Co-authored-by: Claude Sonnet 5 <[email protected]>
Co-authored-by: Copilot Autofix powered by AI <[email protected]>
2026-07-05 22:51:20 +08:00
PatrickNoFilter 0f69d995e9 fix: Termux/Android support — PRoot scroll, SIGSYS sandbox, build docs (#509)
* fix(tui): TracerPid PRoot detection, Ctrl+U/D direction, composer guard

- Replace Termux env var detection with isRunningUnderPRoot()
  via /proc/self/status TracerPid, cached with sync.OnceValue
- Mouse mode falls back to CellMotion (not AllMotion) under PRoot
  to fix broken touch scrolling
- Add Shift+Up/Shift+Down before plain KeyDown/KeyUp for transcript
  scroll with permission/askuser cursor + composer guard
- Add Ctrl+U (scroll up) and Ctrl+D (scroll down) with proper
  direction: Ctrl+U=move up, Ctrl+D=move down
- Add guards for active modals (wizard, picker, spec review, etc.)
  to avoid intercepting their Ctrl+U/D keybindings
- Tests: TestPermissionCursorCtrlU/D (direction regression),
  TestShiftUp/DownComposerGuard (composer guard)
- References issue #507

* fix(sandbox): avoid faccessat2 for Termux/Android portability

Replace exec.LookPath with lookupExecutable that uses os.Stat
instead of the faccessat2 syscall. The faccessat2 syscall (439)
is blocked by the Samsung seccomp filter on Android, causing
SIGSYS termination. os.Stat uses the newfstatat/statx syscalls
which are universally permitted.

This is part of broader Termux/Android support for Zero.

* docs(install): add Termux/Android build and configuration guide

Document how to build Zero for native Android/Termux: GOOS=android
to avoid the SIGSYS crash, proot bind-mount for DNS resolution,
PRoot scroll fix, and free-tier provider setup.

* fix(sandbox): skip empty PATH segments, use platform-aware executable check
2026-07-05 21:48:34 +08:00
Kevin Codex 87e7e69afd feat(openai): forward prompt_cache_key for server-side prefix cache routing (#515)
* feat(openai): forward prompt_cache_key for server-side prefix cache routing

Send the session ID as OpenAI's prompt_cache_key on every completion
request so consecutive turns route to a backend replica that already
holds the conversation prefix in its prompt cache, instead of re-billing
the full ~11k-token system+tools prefix each turn on OpenAI-compatible
endpoints (which get no cache_control breakpoints, unlike Anthropic).

Keyless requests serialize byte-identically to before, and
ZERO_DISABLE_PROMPT_CACHE_KEY=1 suppresses the field for strict
endpoints that reject unknown parameters. The Codex (Responses API)
flavor is deliberately untouched: the ChatGPT backend is a separate,
stricter surface.

* fix(openai): parse prompt-cache-key kill switch as a boolean flag

ZERO_DISABLE_PROMPT_CACHE_KEY=0 or =false previously disabled forwarding
because any non-empty value tripped the check. Parse it the same way
ZERO_FORMAT_ON_WRITE parses boolean flags, so explicitly-falsy values
are a no-op and only truthy values flip the toggle.
2026-07-05 21:41:03 +08:00
Vasanth T f4c998ac30 feat(agent): curb over-engineering the solution in the editing discipline (#517)
The editing discipline already caps the diff size and scope, but a small diff can
still over-build the solution — speculative abstraction, unrequested
configurability, handling for cases that cannot occur. Add a guardrail: solve the
problem as posed, nothing the user did not ask for; if a 200-line solution could
be 50, rewrite it.

Validated with a before/after eval on five over-engineering-prone tasks: 3/5
solutions came out simpler with the guardrail, none more complex, none broke
correctness (avg 9 vs 11 non-blank lines); the baseline gold-plated on two of them
(an unrequested cache-introspection attribute and header-merge config). No effect
on trivial tasks, where the baseline is already minimal.

Closes #516
2026-07-05 21:38:42 +08:00
Alaa Elsamouly 30bff28efc Fix/detailed view scroll select (#498)
* fix(tui): strip all ANSI escapes from copied transcript text

renderAssistantMarkdownText embeds bold markers for prose and ANSI color sequences from chroma for fenced code blocks. stripMarkdownRenderControls only stripped the bold markers, leaking ANSI codes into clipboard text. Switch to ansi.Strip() to remove all escape sequences.

* refactor(tui): extract rendering dispatch and add detailed bool to transcriptBodyItems

Extract rowRenderFn and transcriptRowDispatchFn types and refactor rendering methods into *Fn variants parameterized by the render function.

Add detailed bool parameter to transcriptBodyItems to prepare for the detailed transcript viewport. All callers pass false so there is no behavior change yet.

Extract transcriptRowBodyHeightCacheKeyOpts from transcriptRowBodyHeightCacheKey so the body cap can be configured per caller.

* feat(tui): scrollable detailed transcript viewport with sidebar suppression

Enable keyboard scrolling (PgUp/PgDown/Up/Down) in detailed transcript mode by routing through scrollChat instead of swallowing the keys.

Rewrite detailedTranscriptView to use the transcriptBodyItems pipeline so the body renders through the same viewport/scroll engine as the live view.

Implement detailedTranscriptFooter with copy-status and jump-to-bottom hint integration.

Suppress the sidebar in detailed mode so the full chat width is available for reading uncapped tool output.

* test(tui): detailed transcript scroll with PgUp/Down and Up/Down arrows

TestPgUpDownScrollsDetailedTranscript verifies that PgUp scrolls toward older content and PgDown returns to the bottom in detailed mode.

TestUpDownArrowsScrollDetailedTranscript verifies that arrow keys scroll one line at a time and bottom is clamped at offset 0.

* test(tui): ANSI strip verification for markdown rendering controls and selectable text

TestStripMarkdownRenderControlsStripsAllANSI verifies bold markers, color sequences, and combined sequences are stripped.

TestSelectedTranscriptTextStripsANSIFromHighlightedCode verifies that chroma-highlighted code blocks produce clean plain text in the selectable layer.

* fix for coderabit, and better fewer code.

* fix(tui): address PR review feedback dead code, hover clear, and
rebindable key label

* format

* addressing the review issue

1. the ctrl + b in detailed mode, added the guard to not be able to do
it in detailed mode so it affect its state in the normal mode.

2. added a test coverage for keyDown after `keDown then keyUp` so it
   covers that.

* fix(tui): close file drill-in when entering detailed transcript

toggleDetailedTranscript now calls exitFileView before returning, so an active file drill-in is automatically closed when the user enters detailed transcript mode.

* test(tui): verify file drill-in closes on detailed toggle

Adds TestDetailedTranscriptClosesFileView and TestDetailedTranscriptStaysClosedOnSecondToggle to guard against the file-view bypass bug.

* fix(tui): block composer hit-test while detailed transcript is active

composerMouseSelectionBlocked and mouseOverComposer now check m.transcriptDetailed so mouse clicks and wheel events in the composer area reach the transcript body instead of being silently consumed.

* test(tui): verify composer mouse is ignored in detailed mode

Adds TestComposerMouseSelectionBlockedInDetailedMode to guard against composer hit-test swallowing mouse events during detailed transcript viewing.
2026-07-05 19:36:17 +08:00
euxaristiaandClaude Sonnet 5 7b10aab74b feat(agent): inject per-user config.UserConfigDir()/zero/ZERO.md guidelines into system prompt (#475)
* Add-user-ZERO-guidelines-to-system-prompt

* fix(agent): resolve user ZERO.md under config.UserConfigDir()/zero

Review feedback: every other user-level config (config.json, commands,
specialists) lives under config.UserConfigDir()/zero, so the per-user
guidelines file now resolves there instead of ~/.zero. The basename match
is case-insensitive to mirror the project guideline loader.

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

* refactor(agent): extract shared guideline truncation and lookup helpers

CodeRabbit review feedback: the rune-safe truncation block and the
case-insensitive basename scan were duplicated across the project and
user guideline loaders. Extract truncateGuidelineContent and
findCaseInsensitiveFile and use them in both paths.

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

* fix(agent): keep truncated guideline content within the byte limit

CodeRabbit review feedback: truncateGuidelineContent appended the
truncation marker after slicing to the limit, so the result could
exceed the requested budget by the marker length. Reserve space for
the marker before choosing the cut point.

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

* fix(agent): fix small-limit truncation, precedence, and test isolation for user guidelines

Address remaining review feedback on PR #475:

- truncateGuidelineContent still exceeded the limit when limit was
  smaller than the truncation marker itself. Fall back to a hard,
  rune-safe cut with no marker in that case so the result never
  exceeds the caller's budget.
- Reorder buildSystemPrompt so user guidelines are injected before
  workspace/project guidelines, and add an explicit precedence note
  in the user guidelines section text, so a repo's AGENTS.md/ZERO.md
  is the later, more specific instruction and wins on conflict.
- Add a TestMain in the agent package that stubs userConfigDirForPrompt
  to an empty temp directory by default, so buildSystemPrompt(Options{})
  calls in unrelated tests don't pick up a contributor's real
  ~/.config/zero/ZERO.md.
- Document the per-user ZERO.md location and its precedence relative
  to project guidelines in AGENTS.md.

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

---------

Co-authored-by: Claude Fable 5 <[email protected]>
2026-07-05 18:53:17 +08:00
Kevin Codex 3c81fea228 feat: agent quality, caching, retry, and tooling upgrades (#506)
* feat: agent quality, caching, retry, and tooling upgrades

Ten self-contained improvements to the core agent engine and tools:

- edit_file: fuzzy fallback matching (7-strategy cascade with uniqueness
  protection and a disproportion guard) when old_string does not match
  byte-for-byte; exact match remains the fast path
- edit_file/write_file: inline error-severity LSP diagnostics in tool
  output so the model sees a break it introduced in the same turn
- lsp: expand server registry from 4 to 28 languages; doctor grades only
  tier-1 servers and lists the long tail informationally
- agent: consecutive read-only tool calls execute concurrently (bounded
  at 8), consumed in original order, never spanning a mutating call
- anthropic: cache_control breakpoints on the last two conversation
  messages so the transcript prefix stays a prompt-cache hit
- providerio: retry schedule raised to 6 attempts with exponential
  2s-30s backoff when the server sends no Retry-After
- tools: truncated exec/bash output spills to a redacted temp file
  referenced in the truncation notice; bash output (previously
  unbounded) now shares exec_command's token budget
- web_fetch: dependency-free HTML-to-markdown conversion (format:
  auto|raw|markdown) and larger limits (256KiB default, 2MiB max, 30s)
- modelregistry: live models.dev overlay refreshes context limits and
  base pricing from a TTL-cached fetch; opt-in at the CLI entrypoint
- tools: opt-in format-on-write (ZERO_FORMAT_ON_WRITE=1) runs standard
  formatters across 27 extensions, ordered before the file-tracker
  re-baseline so conflict detection is preserved

* fix: address PR review feedback on fuzzy edits, diagnostics, spill

- edit_file: adapt the replacement to the fuzzy-resolved span — when the
  span is a uniformly deeper-indented copy of old_string (line-trimmed /
  indentation-flexible matches), the same indentation delta is applied to
  new_string, and a span ending in a bare CR keeps its CR so CRLF files
  never gain a mixed line ending; block-anchor matches with drifted
  interiors are deliberately left untouched
- diagnostics: render workspace-relative paths in inline post-edit
  diagnostics (base name outside the workspace) so absolute paths never
  leak the local username or directory layout into the model prompt or
  session transcript
- spill: harden the spill directory for shared temp dirs — per-uid name,
  reject symlinked or non-directory paths (MkdirAll follows symlinks),
  and on Unix require current-user ownership
- bash: the branch's output cap and truncation-flag threading are
  superseded by main's capture-time bounding (boundedBuffer +
  budgetBashCapture), which already sets Result.Truncated; this rebase
  adopts that design and keeps spill-to-disk on exec_command, where the
  full output is still buffered and a spill is genuinely complete

* fix: address review round 2 — permission-event race, fuzzy ambiguity, spill reachability

- agent: serialize OnPermission behind the same mutex as
  OnPermissionRequest in the parallel read batch. buildPermissionEvent
  fires for batched reads with a non-trivial sandbox decision (e.g. a
  grant-matched extra root), and front-end handlers append to shared
  session-recording state without their own locking — two batched reads
  emitting concurrently was a data race the pre-batch serial loop never
  allowed.
- edit_file: reject two or more DISTINCT candidate spans from one fuzzy
  strategy as ambiguous instead of silently editing the first. Duplicate
  blocks at different indentation each resolve to a unique literal, so
  the previous occurrence-uniqueness check passed and wrote the wrong
  span. A single candidate occurring at multiple positions still
  cascades to stricter strategies as before.
- tools: make spill files actually reachable by the recovery path the
  truncation notice advertises — resolveScopedReadPath now recognizes
  files inside the per-uid spill dir (containment verified after symlink
  resolution, so a planted link cannot smuggle an out-of-scope file
  through the gate); read_file/grep can follow the notice instead of
  re-running the command.

* fix: surface competing block-anchor spans; make write-barrier test observe cross-probe order

- edit_file: blockAnchorReplacer now returns every span clearing the
  similarity threshold (best first) instead of silently picking the
  single most-similar block. Hiding competitors from the cascade's
  distinct-candidate check could edit the wrong block when two blocks
  share anchors and both interiors are plausible; one clear winner still
  resolves, two plausible blocks now report ambiguity (covered by a
  two-block test that asserts the file is untouched).
- agent tests: the parallel write-barrier test now records both probes
  into one mutex-guarded shared log and asserts the cross-probe ordering
  it always claimed to (write starts after the first read batch ends;
  second read batch starts after the write ends). The previous version
  only compared indices within the read probe's own log, which a
  regression letting the write overlap a batch could still pass; the
  stray no-op mutex assignment is gone.
2026-07-05 17:25:26 +08:00
Ashwinhegde19andAshwin f6c060631e fix(openai): handle Ollama reasoning stream deltas (#486)
* fix(openai): handle reasoning alias in stream deltas

* test(cli): assert reasoning event order

---------

Co-authored-by: Ashwin <[email protected]>
2026-07-05 17:05:54 +08:00
euxaristiaandClaude Fable 5 563a6dbe91 fix(sandbox): fix nested pipe creation under the Windows restricted token (#456)
* fix(sandbox): fix nested pipe creation under the Windows restricted token

Any subprocess spawned FROM WITHIN a process already running under Zero's
write-restricted token, that captures the child's output via a pipe, failed
with ERROR_ACCESS_DENIED. Reproduces with `gh pr list` (gh shells out to git
and captures its output), with a bare Go program doing exec.Command(...).
Output(), and with cmd.exe's own FOR /F ('...') construct, which uses the
identical CreatePipe+CreateProcess pattern internally with no external tools
involved.

Root cause: a WRITE_RESTRICTED token requires a WRITE-type access check to
match BOTH the normal enabled-SID grant and a separate grant to one of the
token's restricted SIDs. CreateRestrictedToken does not modify the default
DACL it inherits from the base token, so a pipe created with a default
security descriptor (the common case; every language runtime's "spawn a
subprocess and capture output" primitive does this) only carries ACEs for
the base identity, none of which are in the restricted-SID list, so the
second check fails. This is the same failure family already documented for
Schannel/SEC_E_NO_CREDENTIALS, but broader in scope: it affects any pipe,
event, mutex, or other kernel object created with default security by a
process running under the token.

Fix: add the token's own logon SID (already unconditionally present in its
restricted-SID list) to the token's default DACL, so objects it creates
without an explicit security descriptor automatically satisfy the extra
check. This does not touch filesystem access: NTFS write-jailing is
enforced by the explicit ACL grants applied to workspace paths, created
WITH an explicit security descriptor, and the token's default DACL is only
consulted when none is supplied. Exposure is bounded to the same logon
session (only other processes running as the same signed-in user could, in
principle, open a NAMED object the sandboxed process creates with default
security; anonymous pipes have no name and are reachable only via an
inherited handle, so this is a no-op for the actual objects this fixes).

This is not specific to the unelevated tier added in #427 - the affected
token-construction code is shared by both the fully elevated and unelevated
restricted-token paths, so this predates that work.

Verified on Windows 11 (before/after, using an unfixed build for
comparison):
- Before: FOR /F fails with Win32 error 5 (ERROR_ACCESS_DENIED), masked
  behind cmd.exe's generic "not recognized" message; `gh pr list` fails
  with "failed to run git: pipe: Access is denied."; a bare Go nested
  exec.Command(...).Output() fails with "pipe: Access is denied."
- After: all three succeed; `gh pr list` returns real PR data.
- Write-jail regression check: a write outside every granted root is still
  denied by the OS after the fix (unchanged from before).

New TestWindowsRestrictedTokenNestedPipeCapture (ZERO_SANDBOX_REAL_SMOKE=1)
pins the FOR /F case using only cmd.exe, no external dependency. go vet and
the full internal/sandbox suite are clean (excluding pre-existing failures
already present identically on unmodified main, unrelated to this change).

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

* fix(sandbox): fix dangling-buffer use-after-scope in default DACL merge

Addresses the blocker from review on this PR (Vasanthdev2004, gnanam1990,
coderabbitai): windowsTokenDefaultDacl returned a *windows.ACL that points
INTO the local buf byte slice backing TOKEN_DEFAULT_DACL - Windows embeds
the ACL data in the same allocation GetTokenInformation fills. The
function's own runtime.KeepAlive(buf) only covered buf up to that
function's own return; nothing kept it alive across the caller's later use.
In broadenWindowsRestrictedTokenDefaultDacl, the returned ACL pointer was
then dereferenced by native code in setEntriesInACL after intervening
allocations (the EXPLICIT_ACCESS literal, TrusteeValueFromSID) that are
GC-safepoint-eligible - a real use-after-free window in code that
constructs part of the sandbox's own restricted token, even though Go's
current non-moving GC meant it didn't manifest in testing.

windowsTokenDefaultDacl now returns the backing buffer alongside the ACL;
the caller holds runtime.KeepAlive(oldDaclBuf) until after
SetEntriesInAclW (the last point that dereferences it) returns.

Also from review: SetEntriesInAclW reports its Win32 error directly in the
return value, not via GetLastError, so setEntriesInACL now uses that
return value directly instead of the (potentially stale/unrelated) syscall
error. And narrowed the default-DACL grant from GENERIC_ALL to GENERIC_READ
| GENERIC_WRITE - the second WRITE_RESTRICTED access check only needs a
read/write match; GENERIC_ALL also implied DELETE/WRITE_DAC/WRITE_OWNER
that the pipe/event/mutex use case never needed.

Re-verified on Windows 11 with the fixed runner: nested pipe capture still
succeeds (pure Go exec.Command(...).Output(), and TestWindowsRestrictedTokenNestedPipeCapture
via go test itself), and the write-jail regression check (write denied
outside every granted root, allowed inside) is unchanged. Full
internal/sandbox suite green.

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

---------

Co-authored-by: Claude Fable 5 <[email protected]>
2026-07-05 17:04:07 +08:00
Kunj SHAH 2312abe5dd feat: add zero changes push and pr subcommands, and extra repo-info metrics (#391)
* feat: add zero changes push and pr subcommands, and extra repo-info metrics

* fix: address review feedback on changes command and repoinfo

* style: format repoinfo.go with gofmt

* fix: address remaining review feedback — JSON tags, -- separator, PR validation, tests

* fix: address CodeRabbit + review feedback — default-branch guard, --yes flag, branch parsing, typed JSON, missing tests

- Add default-branch guard: Push rejects main/master unless --yes is passed
- Add --yes flag to changes push/pr to allow pushing to protected branches
- Fix BranchCount parsing: skip detached HEAD lines and strip worktree + prefix
- Replace ad-hoc map with typed struct for PR --json output
- Add push failure short-circuit test for pr workflow
- Add --dry-run rejection test for pr workflow
- Add CreatePR dir assertion in zerogit_test
- Add --yes validation test for commit
- Add RejectsDefaultBranch and AllowDefaultBranchWithYes tests

* test: add positive-path test for --yes flag in changes push

* fix: resolve remote HEAD dynamically instead of hardcoded main/master

* style: run gofmt on changed files

* retrigger CI

* fix: resolve syntax error in parseChangesArgs
2026-07-05 17:03:29 +08:00
Anandan afb5010a46 polish v2: smoothness & delight (#484)
* feat(tui): actionable hints on provider/model errors

Classify a failed turn's provider error into auth / rate-limit /
connectivity / model-not-found / context-overflow and render a one-line
next step below the red error row, instead of dumping an identical raw
blob for every failure mode.

The classifier lives in a new internal/errhint package so both the TUI
error row and the CLI exec path can share it (TUIHint references slash
commands, CLIHint references zero subcommands). Classification is a
conservative string heuristic keyed off providerio.ClassifiedError's
prefixes plus lower-level DNS/TLS/timeout/context-length signatures,
since the numeric HTTP status is gone by the time the error reaches a UI
surface.

* feat(tools): budget bash output with head+tail truncation

bash was the one tool that returned command output with no byte cap —
'cat large.log' or a verbose test run could dump megabytes into context
and force compaction, while every other read/search tool already applies
a budget.

Cap stdout and stderr at 96KB each, keeping the head and tail of an
oversized stream (build/test failures usually land at the tail) and
dropping the middle behind a marker that points at redirect-to-file +
read_file. Record raw_bytes/emitted_bytes/estimated_tokens/truncated in
Meta like the other tools. Sandbox-denial and shell-issue detection still
run on the full raw output before budgeting.

* fix(gemini): retry 401 with refreshed OAuth token

The Gemini provider used providerio.SendWithRetry (429/503/529 only),
so an expired OAuth token surfaced as a terminal 'API key not valid'
auth error while OpenAI and Anthropic silently recovered via
SendWithAuthRetry's force-refresh-and-replay on 401.

Plumb OAuthResolver through gemini.Options/Provider and the factory
(exactly as the other two providers already do) and switch the stream
send to SendWithAuthRetry. With a nil resolver (API-key users) behavior
is unchanged.

* fix(errhint): gate classification on provider-origin marker

Address CodeRabbit review on #1: agentResponseMsg.err can also carry
local failures (a tool's 'permission denied', a 'file does not exist',
a config error), and broad substrings would have attached a bogus
/provider or /model hint to those.

Classify now returns Unknown unless the message carries a provider marker
the provider layer always attaches (auth error: / rate limit error: /
provider error: / provider request error: / provider stream error:).
Local errors never draw a provider hint; provider errors sub-classify as
before. Added local-failure test cases proving they stay Unknown.

* feat(cli): actionable hint on text-mode exec provider errors

zero exec printed provider failures as a bare '[zero] <raw error>' with
no next step. Append a one-line hint (reusing the shared errhint
classifier) for recognized provider failures — 'run `zero auth`',
'run `zero doctor`', etc. The provider-origin gate means non-provider
codes (sandbox_error, mcp_error) and JSON/stream-json output are
untouched.

* fix(tools): set Result.Truncated for budgeted bash output

Address CodeRabbit review on #3: budgetBashOutput recorded truncation in
meta["truncated"] but never set the Result.Truncated struct field, so
consumers reading the struct (like glob/web_fetch/read_minified do) would
miss truncated bash results. Return the bool from budgetBashOutput and
set Result.Truncated on every return path.

* perf(tui): reuse one LSP manager across prompts

runAgentWithOptions built a fresh lsp.NewManager per run and shut it down
when the run returned, so gopls (and every other language server)
cold-started on the first edit of every turn — 200ms-2s of latency the
manager's own design (long-lived, reused servers) exists to avoid.

Build one manager per session in newModel (cheap: servers start lazily on
first Check) and reuse it across runs; the SelfCorrector still gets a
fresh checker wrapping the shared manager each run. Torn down with a
short deadline in quit(). Runs fall back to a per-run manager when the
session manager is absent (cwd unknown, or a directly-built test model).

* perf(tui): accumulate streamingText as []byte (O(1) append)

m.streamingText += msg.delta was O(len) per delta -> O(n²) across a long
generation (a 10k-token code gen allocates hundreds of MB of intermediate
strings and the UI gets progressively laggier). Accumulate into a []byte
with append (O(1) amortized) and read via streamingTextString().

A []byte rather than strings.Builder because the TUI model is copied by
value on every Update, which would trip strings.Builder's copy check;
[]byte is nil-safe and copies cleanly like the other slice fields.

* perf(tui): coalesce streamed text deltas to one frame

Every OnText delta was its own tea.Msg, so a fast provider (100+ tok/s)
drove 100+ full Update->View cycles per second, each re-parsing the
growing markdown — visible stutter over SSH and wasted CPU.

Batch agentTextMsg deltas at the runtime sink over a ~16ms frame and
forward them as a single message, decoupling render rate from token rate.
Any non-text message flushes pending text first so ordering with
tool-call / reasoning / row messages is preserved; the final
agentResponseMsg (a tea.Cmd return, not a sink message) is safe because
the model already drops deltas for an inactive runID.

* fix(agent): raise tool-failure stop threshold 4->6

guardrails.go halted a run after 4 consecutive same-error tool failures.
A corrective hint fires at 2, and a model iterating on a genuinely tricky
edit can legitimately fail a couple more times after the hint while
converging — stopping at 4 cut those runs short. Raise to 6; the streak
still resets the instant the tool succeeds or hits a different error, so
only true same-error loops are affected.

* fix(agent): more reconnect retries with jitter; narrow to transport errors

#19: bump maxStreamReconnects 2->4 and add up-to-50% jitter on the
exponential backoff (capped at 8s), so a multi-second network blip is
ridden out instead of killing the run on a 2s hiccup, and concurrent runs
(swarms, cron fleets) don't reconnect in lockstep.

#34: drop the '502'/'503' substring matches from shouldReconnect. 503
already exhausted providerio.SendWithRetry (retrying here is a redundant
double-retry) and 502 is non-idempotent by providerio's rule (the POST
may have been processed). Only genuine transport failures — where no
response was received — reconnect now.

* fix(agent): reduce stall retries 2->1 to bound stuck-session time

A no-output stream stall is detected only after the full stream idle
timeout (~5min) elapses, and each retry can idle again — so 2 retries
left an interactive session frozen for ~15min. Drop to 1 retry: keeps the
common single-hiccup recovery while bounding the worst case to ~2x the
idle timeout.

* fix(tui): serialize coalescer forwarding to preserve message order

Address CodeRabbit review on #7: flush() and the run-switch path drained
the text buffer under c.mu but called forward() after releasing it, so a
timer-fired text flush could race a concurrent non-text send() and land
after the tool-call/reasoning message it should precede.

Hold c.mu across drain AND forward (drainAndForwardLocked), so whoever
holds the lock delivers atomically and the other caller blocks until it
is done — text can never overtake a following non-text message. Added a
concurrency stress test (timer racing an inline send) that passes under
-race.

* feat(tui): accept /compact now

Bare /compact already triggers compaction, but /compact now — what users
reach for when the context gauge climbs — hit the usage-error branch.
Accept the 'now' keyword and advertise it in the command usage.

* feat(tui): add /retry, /edit, /copy, /export commands

/retry resends the last prompt; /edit recalls it into the composer to
tweak and resend — both read a new lastPrompt field captured verbatim
(pre-expansion) in launchPrompt. /copy puts the last answer on the
clipboard via the existing copy machinery; /export writes a plain
role-prefixed transcript to a file (timestamped default, or a given
path). Real gaps for SSH users with no mouse select.

* feat(tools): separator-insensitive tool_search matching

tool_search ranked deferred tools by exact substring, so a model that
typed 'webfetch' (dropping the underscore) matched nothing and looped.
Add a separator-squashing fallback ('web_fetch' -> 'webfetch') scored
below an exact substring match, so precise queries still rank first.

* feat(tools): signal ask_user dismissal distinctly from a blank field

Empty answers all rendered as '(no answer provided)', so the model
couldn't tell a wholesale dismissal (user closed the prompt without
answering anything) from one field left blank amid real answers — and
might invent a default. FormatAskUserAnswers now flags a full dismissal
up front as a skip and marks individual empties '(left blank)' vs
'(skipped)'.

* feat(tui): fuzzy ranking in model/session pickers

The pickers filtered by flat strings.Contains with no ranking, so
finding 'sonnet 4.5' among 50+ models meant an exact prefix or scrolling.
Rank matches (exact < prefix < contains < subsequence, reusing
fuzzySubsequenceGap) so the closest match lands on top, and 'snt45' now
matches 'Sonnet 4.5'. Groups stay contiguous — ordered by their best
match, never split — so the grouped model picker still renders one header
per provider. Covers both /model and /resume (shared commandPicker).

* perf(agent): cache per-tool schema render across turns

partitionTools re-ran the recursive schema->map conversion
(schemaToRuntimeMap) for every tool on every turn — redundant, since a
tool's advertised name/description/schema is stable for the run. Add a
per-run definition cache keyed by tool name (nil-safe; the plain
partitionTools entrypoint and tests pass nil for fresh renders).

The partitioning itself (visibility, deferral, ordering) still recomputes
every turn — it must, because a tool's deferred state can flip mid-run
(swarm tools un-defer once a swarm is active). Only the expensive schema
render is memoized. tool_search is excluded by its callers (dynamic
description) so it never poisons the cache.

* perf(agent): calibrate compaction estimate against real prompt tokens

The byte/4 heuristic (ApproxTextTokens) over-counts code-heavy content
~15-20%, so with triggerRatio=0.7 compaction fired at ~60% of true
capacity — premature summarizer calls that degrade quality. Each turn now
folds (rawEstimate, provider InputTokens) into an EMA calibration ratio,
clamped per-sample to a sane band; maybeCompact and the reactive path
scale their estimates by it. Turn 1 uses the raw estimate (no data yet);
later turns compact near real capacity.

* style: gofmt the new agent test files

The CI gofmt check flagged the #22/#25 test files (struct-field and
switch alignment). No behavior change.

* feat(agent): turn-based plan staleness reminder

The stale-plan nudge only fired after 10 tool calls since the last
update_plan, so a plan that drifts stale across many low-tool-call turns
(one call per turn) took many turns to catch — the 'plan set turn 1 stays
pending forever' case. Add a turn-count complement: after
stalePlanTurnThreshold (8) turns without an update AND with items still
pending, fire the same one-shot reminder. Gated on pending items so a
fully-completed plan is never nagged; the existing tool-call trigger is
unchanged. (The roadmap's fuzzy 'tool calls unrelated to plan items'
relevance match is deliberately omitted — too false-positive-prone.)

* feat(agent): preserve recent-edits summary across compaction

Compaction preserved the plan, loaded skills, tool schemas, and project
instructions, but not file edits — so after the editing turns were elided
the model knew it had changed a file only vaguely, and would re-read to
rediscover its own footprint. Extract write_file/edit_file targets from
the elided middle with a one-line note from each tool result, merge them
across repeated compactions (newer note per path wins), and carry them in
the preserved-state JSON (capped at 20 paths / 160 bytes per note).

* feat(tui): workspace-scoped /resume + fewer event reads

/resume listed sessions from every project globally, and read the full
event file for each of them to test for resumable content. Filter the
picker (and /resume latest) by the current workspace Cwd, checked BEFORE
the per-session read so a large global history no longer pays N full file
reads to build one workspace's list; also skip zero-event sessions via
metadata without any read. Sessions with no recorded Cwd (older runs) and
an unknown current workspace stay visible so nothing is hidden that can't
be confidently placed. Explicit /resume <id> still resolves any session.

* feat(tui): warn on model switch that drops staged images

Staged images were silently dropped at submit when the active model had
no vision support — the user only learned after sending. Chips already
render above the composer (renderAttachmentChips); add an immediate amber
warning line to the /model switch status (both same-provider and
cross-provider paths) when images are staged and the newly selected model
can't accept them, so the drop is surfaced at switch time.

* feat(tui): inline recovery affordance on setup errors

A first-run setup error rendered as a bare red 'error: <text>' with no
pointer to the fix. Append a faint, stage-tailored recovery line (edit
the endpoint/name/key above then Enter, or pick another model) that names
only keys the stage's footer confirms, so the guidance is always accurate.
Empty for stages whose footer already makes recovery obvious.

* fix: address CodeRabbit review (5 findings)

- loop.go: recompute the compaction calibration estimate at calibration
  time, not request-build time, so a reactive-compaction reissue that
  shrinks the request feeds the EMA the sample that actually completed.
- errhint: match bare HTTP status codes (401/403/429/529) only at digit
  boundaries, so an incidental number ('4290ms', 'id 14015') isn't
  mis-bucketed as auth/rate-limit.
- bash: drop the stray unmatched ']' in the output-truncation marker.
- tui /retry: apply the same exiting/compactInFlight guards commandPrompt
  has, so a retry can't race compactResultMsg's rewrite of session state.
- tui /export: write the transcript 0o600 (it may contain echoed
  secrets), not world/group-readable 0o644.

* test(tui): skip export file-mode assertion on Windows

os.WriteFile ignores Unix permission bits on Windows and Stat reports
0666, so the 0o600 assertion failed there. The production 0o600 is
honored on Linux/macOS (where it matters); guard the check with
runtime.GOOS so Windows smoke passes.

* fix(tui): align /resume latest filters + case-insensitive workspace match on Windows

Address CodeRabbit review on #21:
- latestResumableInWorkspace now applies the SAME filters as the picker
  (skip zero-event metadata and empty/failed runs), so /resume latest
  can't land on a session the picker intentionally hides.
- sessionMatchesWorkspace compares paths case-insensitively on Windows
  (its filesystem is), so the same workspace spelled with different casing
  no longer hides resumable sessions; other platforms stay case-sensitive.

* fix(tui): /retry resends the last prompt's image/PDF attachments

/retry relaunched only lastPrompt's text, but launchPrompt clears the
pending image/document queues once a turn is sent. A vision- or PDF-backed
prompt that failed would silently retry as text-only and answer a different
task. Snapshot the consumed attachments at launch and re-stage them on
/retry so launchPrompt rebuilds an identical request (document preamble,
images, and the submit-time vision re-check). startNewSession drops the
snapshot so a post-/new /retry cannot leak the previous session's
attachments. Also correct the OAuthResolver factory comment, which omitted
the Gemini provider it is now passed to.

* fix: address jatmn review (reconnect 5xx, /edit attachments, edit-cap, coalescer test)

- reconnect: exclude HTTP 5xx (500/502/503/504) before the transport
  substring match so "504 Gateway Timeout" no longer slips through on the
  generic "timeout" needle. A gateway timeout is non-idempotent — the
  completion POST may already have reached the model — so replaying the
  connect risked duplicate billable work. Reuses a digit-boundary status
  matcher exported from errhint (HasStatusCode). Test asserts 504/500 stay
  non-reconnectable.

- tui /edit: re-stage the remembered image/PDF snapshot alongside the
  recalled prompt text, mirroring the /retry fix — editing a vision- or
  document-backed prompt no longer silently resends a text-only version.

- compaction recent-edits: order edits by LAST edit, not first, and move a
  re-touched path to the newest position on merge, so the maxRecentEdits
  tail cap keeps the file the model most recently edited instead of dropping
  it. Adds mergeRecentEdits (edit-specific) and a capped-list regression.

- coalescer test: inject the frame timer via afterFunc so
  TestCoalescerBatchesDeltas proves buffering deterministically instead of
  racing the real 16ms wall-clock timer.

* fix(tools): bound bash output capture in memory, not just model-visible text

Previously stdout/stderr streamed into unbounded bytes.Buffers and were only
truncated to the 96 KiB budget AFTER the command finished, so a runaway command
(cat huge.log, yes) could grow Zero's memory until it stalled or OOMed before
budgetBashOutput ever ran. Replace the capture with boundedBuffer, an io.Writer
that keeps only the head+tail each stream will surface and discards the middle as
it arrives, while counting the true total for the truncation marker. Memory is now
bounded to ~head+2×tail per stream regardless of output size.

truncateHeadTail is refactored onto a total-aware core so the string path (and its
tests) are unchanged, and budgetBashCapture reports the true raw_bytes even though
only a bounded slice was held. Adds boundedBuffer/capture unit tests and an
end-to-end test that streams ~5× the budget and asserts bounded emission with an
accurate raw_bytes.
2026-07-05 13:15:14 +05:30
Vasanth T 30dd7c3112 fix(config): let a gateway ANTHROPIC_BASE_URL resolve as anthropic-compatible (#497)
Setting ANTHROPIC_BASE_URL to a proxy/gateway failed setup with "anthropic
provider anthropic requires official baseURL https://api.anthropic.com".
OPENAI_BASE_URL already auto-promotes a non-official env URL to the
openai-compatible kind, but Anthropic had no equivalent, so the universal
ANTHROPIC_BASE_URL proxy convention was rejected outright.

Mirror the OpenAI behaviour: a non-official ANTHROPIC_BASE_URL from the
environment promotes the profile to anthropic-compatible. This stays env-only
on purpose -- a custom baseURL in a project config.json is still rejected by
Resolve, so an untrusted project can't redirect a first-party key to another
host (TestResolveRejectsAnthropicOfficialProviderCustomBaseURL still passes).

Fixes #479
2026-07-05 11:39:22 +08:00
Gautam ManchandaniandGautam Manchandani e7d88b4b51 perf: cache TUI model registry (#496)
Co-authored-by: Gautam Manchandani <[email protected]>
2026-07-05 11:38:45 +08:00
euxaristiaandClaude Sonnet 5 177442cfe4 fix(specialist): cap max specialist nesting depth (#491)
* fix(specialist): cap max specialist nesting depth

A Task specialist can recursively spawn further specialists via --depth.
Without a ceiling, a runaway or malicious chain of Task calls can recurse
indefinitely, exhausting process and memory resources. Reject any run whose
depth exceeds a hard cap of 8.

Related to #470, not closing it: today's normal specialist/swarm path
cannot reach this recursively, since child sessions are launched with
--tag specialist and shouldRegisterExecSpecialistTools suppresses the Task
tool for that tag (see PR description for details). This is a
defense-in-depth guard, not a fix for an active exploit. Split out of #468
at reviewer request.

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

* fix(specialist): reject depth cap at the boundary, not past it

Addresses jatmn's review on PR #491:
- Executor.Run checked `CurrentDepth > maxSpecialistDepth`, but this call is
  what launches the child at CurrentDepth+1. A parent already AT the cap
  passed the check and started a child one level past it, only getting
  rejected on that child's own next call. Change to `>=` so the guard fires
  before the over-cap child is launched.
- Add TestRunRejectsDepthAtMax to cover the boundary explicitly.

* test(specialist): cover resume boundary in depth-cap guard

The pre-branch depth guard in Run sits before the fresh/resume split, but
the boundary test only exercised the fresh path, leaving a future refactor
free to move the guard without any test catching a resume regression.

---------

Co-authored-by: Claude Sonnet 5 <[email protected]>
2026-07-05 11:38:15 +08:00
euxaristiaandClaude Fable 5 190241bd59 fix(tools): fix cmd.exe /S/C corrupting commands with embedded quotes (#465)
* fix(tools): fix cmd.exe /S/C corrupting commands with embedded quotes

Any shell command whose own text contains embedded double quotes -
python -c "print(15 / 3)", git commit -m "message", node -e "..." - was
silently corrupted on Windows before reaching the target program. Reported
live: a model ran python -c "print(15 / 3)" and got back a SyntaxError
showing the code Python received as literally "print(15, truncated right
before the /.

Root cause: bash.go passes the full command text as ONE argv element to
cmd.exe /d /s /c, and Go's os/exec escapes it correctly for a standard
CommandLineToArgvW-style parser (wrapping it in quotes, backslash-escaping
the embedded ones). But cmd.exe's own /C remainder parsing does not follow
those rules once /S is present: per its documented behavior, it strips the
FIRST and LAST literal quote character in the whole remainder - not a
matched pair - before handing the (now corrupted) text along to be
re-tokenized. The escaping was correct for one layer of parsing; the
command line actually goes through two.

Fix: on Windows, write commandText to a temp .cmd script file and point
/c at the file's path instead of the raw command text. This sidesteps
cmd.exe's /C-remainder heuristic entirely, since the remainder is then
just a plain path with no quotes to mis-strip. @echo off is prepended
since cmd.exe treats a /c target ending in .cmd as a batch file and
echoes each line it runs by default, which would otherwise pollute
captured stdout (caught by the existing bash tool test suite before this
commit, not left in).

buildBashCommand now returns a cleanup func (in addition to plan.Cleanup)
for the temp file. Both callers updated: bash.go's one-shot run defers it
immediately; exec_command.go's persistent session composes it with
startExecProcess's own cleanup and runs it after the session's process
actually exits, not before.

This fix lives upstream of the sandbox wrapping, in how the Name/Args spec
is constructed before either the direct or sandboxed code path consumes
it, so both benefit without separate sandbox-specific changes - cmd.exe's
own command-line parsing does not depend on what token launched it.

New TestBashToolPreservesEmbeddedQuotesOnWindows pins the exact failure
shape via a self-dispatch test helper (a quoted argument containing a
space and a slash, matching the original bug report's truncation point),
with no dependency on python being installed. Full internal/tools suite
green, excluding 3 failures pre-existing and identical on unmodified main
in my environment (unrelated path-assumption issues under a deeply nested
working directory).

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

* fix(tools): pass the shell command line to cmd.exe raw instead of via a temp script

Review on the original fix found real problems with running commandText
from a temporary .cmd file: it silently changed cmd.exe command-line
syntax into batch-file syntax (for example, for %i loops need %%i only
inside an actual .bat/.cmd file), /s was still corrupting the script
path itself if it contained spaces, and the script leaked on sandbox
planning errors.

The actual root cause was narrower than the temp-file approach assumed.
cmd.exe's own /C remainder parsing is not CommandLineToArgvW-compatible:
it strips the first and last literal quote from the remainder whenever
it starts with a quote, without undoing backslash-escaping. Passing
commandText as a normal exec.Cmd argument makes Go wrap it in exactly
that shape (quoted because it has spaces, inner quotes escaped as \")
since it contains embedded quotes, which is what the stripping then
corrupts.

Fixes it at the source instead: commandText now reaches cmd.exe
completely unescaped (via SysProcAttr.CmdLine for the unwrapped path,
and the same treatment inside the sandboxed runner's own process
creation for the wrapped path), exactly as if it had been typed at an
interactive prompt. No outer quotes means the stripping heuristic never
triggers for a command like `python -c "print(15 / 3)"`, and a command
that legitimately starts with a quoted executable path is handled by
cmd.exe's own careful quote-preserving special case for that shape.
Also drops /s, which forced the more aggressive of cmd.exe's two
stripping behaviors. No temp file, so nothing to leak on error, and no
batch-file reinterpretation of the command text.

internal/sandbox/windows_runner.go now owns the shared shell-argv shape
(WindowsShellArgs/WindowsShellCommandLine) so internal/tools/bash.go
and the sandboxed runner's windowsCommandLine build the exact same raw
command line instead of duplicating the flag literals in two places.

* chore: retrigger CI

The Windows smoke job failed on TestLoadOrCreateSecretConcurrentConverges in internal/oauth (a lock-file access-denied flake), unrelated to this change. internal/tools and internal/sandbox, the packages this PR touches, both passed. Retriggering to confirm.

---------

Co-authored-by: Claude Fable 5 <[email protected]>
2026-07-05 11:36:00 +08:00
euxaristiaandClaude Fable 5 ba6c026469 fix(tools): make grep and glob respect run cancellation (#464)
* fix(tools): make grep and glob respect run cancellation

grep and glob's Run/RunWithSandbox discarded their context parameter
entirely (bound to _) and ran a synchronous filepath.WalkDir with no way
to know it should stop. An unscoped search over a large tree - a broad
parent directory, not just the workspace, which the model can and does
reach for - could run long enough to be effectively unkillable: the TUI's
Ctrl+C/exit path calls the stored CancelFunc and immediately marks the run
"cancelled" in the UI, but the underlying walk kept running regardless,
because nothing in it ever checked ctx.Err(). Worse, the TUI's quit path
explicitly waits for the cancelled run's own response (to drain
flushRunIDs, so /rewind checkpoints stay consistent) before it will
actually exit - so with the walk unable to hear the cancellation, neither
a second Ctrl+C nor /exit could complete. The only way out was killing the
process.

Thread ctx through Run/RunWithSandbox into grepFiles/scanGlob and check
ctx.Err() at the top of each WalkDir callback, ahead of walkErr, so a
cancelled run aborts the walk on its very next entry instead of visiting
every remaining file. Checked the rest of the tools package for the same
_ context.Context pattern: list_directory (single-level os.ReadDir, no
walk), skill, ask_user, and update_plan don't touch the filesystem, so
grep and glob were the only two tools with this gap.

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

* fix(tools): thread cancellation through grep's match-collection phase

The walk-level fix in grepFiles stopped a cancelled search from visiting
new filesystem entries, but collectGrepMatches still read and
regex-scanned every file the walk had already found, so a search that
had discovered its full file list before cancellation kept running
regardless. Adds the same per-item ctx.Err() check to that phase, plus
a deterministic test that cancels after a fixed number of files instead
of relying on timing.

* test(tools): add mid-walk cancellation coverage and shared helper

Add deterministic mid-walk tests for grepFiles and scanGlob using countingCancelContext, and extract searchCancelledResult so grep/glob share the same cancellation Result mapping.

* style(tools): gofmt search_cancel.go (add missing trailing newline)

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

---------

Co-authored-by: Claude Fable 5 <[email protected]>
2026-07-05 11:35:01 +08:00
Pengkuh Dwi Septiandi daf65e0af9 feat: support shift enter for composer newlines (#462) 2026-07-05 11:34:18 +08:00
Anandan f401c66e81 polish: cut per-turn overhead, stabilize cache, add /new (#478)
* polish: cut per-turn overhead, stabilize cache, add /new

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

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

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

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

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

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

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

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

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

* fix: remove redundant

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

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

Fixes #392

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

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

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

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

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

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

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

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

Closes #459.

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

Fixes #431

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

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

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

Fixes #430

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

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

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

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

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

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

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

* chore: retrigger CI

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

---------

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

* Use sidebarToggleAllowed. Fix fmt errors.

* Fix: combination matching superset.

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

Co-authored-by: Claude Fable 5 <[email protected]>
2026-07-03 19:34:56 +08:00
586 changed files with 67091 additions and 2382 deletions

No files matched your search

+6
View File
@@ -1,5 +1,11 @@
blank_issues_enabled: false
contact_links:
- name: Questions and setup help
url: https://github.com/Gitlawb/zero/discussions/categories/q-a
about: Ask in Discussions Q&A. Include zero --version, your OS, provider/model, and zero doctor output for setup problems.
- name: Feature ideas
url: https://github.com/Gitlawb/zero/discussions/categories/ideas
about: Propose and discuss ideas in Discussions before opening a PR (Zero is in controlled development, see CONTRIBUTING.md).
- name: Security vulnerability
url: https://github.com/Gitlawb/zero/security/advisories/new
about: Report security issues privately via GitHub Security Advisories (see SECURITY.md). Do not file a public issue.
+13 -3
View File
@@ -1,12 +1,22 @@
name: Feature request
description: Propose a new capability or change
name: Feature request (previously discussed)
description: File only after your idea gained traction in Discussions Ideas or a maintainer asked for a tracking issue
labels: ["enhancement"]
body:
- type: markdown
attributes:
value: |
Please do not begin implementation until the issue has been reviewed and marked with the
New feature ideas start in [Discussions Ideas](https://github.com/Gitlawb/zero/discussions/categories/ideas),
not here. This form is for the step after that: an idea that gained traction in Discussions,
or one a maintainer asked to have tracked as an issue. Please link the discussion below.
Do not begin implementation until the issue has been reviewed and marked with the
`issue-approved` label (see CONTRIBUTING.md).
- type: input
id: discussion
attributes:
label: Ideas discussion
description: Link to the Discussions Ideas thread (or note which maintainer asked for this issue).
validations:
required: true
- type: textarea
id: problem
attributes:
+14 -3
View File
@@ -100,9 +100,10 @@ jobs:
go-version-file: go.mod
cache: true
# govulncheck and deadcode resolve a toolchain from their own modules, which
# can drop below the version go.mod requires and then fail to load our
# packages. Pin GOTOOLCHAIN to the go.mod toolchain so both run under it.
# govulncheck, deadcode, and golangci-lint each resolve a toolchain from
# their own modules, which can drop below the version go.mod requires and
# then fail to load our packages. Pin GOTOOLCHAIN to the go.mod toolchain
# so all three run under it.
- name: Pin toolchain from go.mod
run: |
toolchain="$(awk '/^toolchain /{print $2}' go.mod)"
@@ -120,3 +121,13 @@ jobs:
- name: deadcode (advisory)
continue-on-error: true
run: go run golang.org/x/tools/cmd/[email protected] -test=false ./...
# Advisory: catches what deadcode's whole-program reachability analysis
# doesn't, unused private functions/assignments reachable within a
# package but never actually called, plus staticcheck-style correctness
# and readability issues. Scoped to a few linters rather than the full
# default battery, and non-blocking, while the existing findings across
# the repo are cleaned up incrementally (see #527).
- name: golangci-lint (advisory)
continue-on-error: true
run: go run github.com/golangci/golangci-lint/v2/cmd/[email protected] run --enable-only unused,ineffassign,staticcheck ./...
+69 -11
View File
@@ -97,16 +97,21 @@ jobs:
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.
# Publishes @gitlawb/zero to npm: first the five platform payloads (versions
# of the SAME package at suffixed versions like X.Y.Z-linux-x64, carrying the
# native binary + vendored helpers), then the wrapper at X.Y.Z whose
# optionalDependencies alias those platform versions. Platform versions MUST
# publish first — the wrapper pins them exactly, so the reverse order would
# open a window where installs resolve aliases that 404. See
# docs/NPM_PACKAGING.md for the full contract.
#
# 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.
# Because the platform payloads are versions of the same package, they reuse
# that single trusted-publisher configuration.
publish-npm:
name: Publish npm package
needs: package
@@ -156,10 +161,11 @@ jobs:
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.
# Probe the exact URLs the wrapper's first-run downloader builds
# (internal/release/release.go asset-name scheme) as an anonymous client
# — install.sh/install.ps1 and `zero update` read them too. 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: |
@@ -173,12 +179,64 @@ jobs:
"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
echo "::error::${url} is not publicly downloadable — the wrapper's fallback downloader and install.sh 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
- name: Download release artifacts
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
pattern: zero-*
path: dist/artifacts
merge-multiple: true
- name: Assemble npm packages
run: node scripts/npm/build-platform-packages.mjs --artifacts-dir dist/artifacts --out-dir dist/npm
# Platform versions are semver PRERELEASES of the wrapper version
# (X.Y.Z-linux-x64), so they must never publish under the default
# `latest` dist-tag — that would make `npm install @gitlawb/zero`
# deliver a platform payload as the CLI. Publish them under a dedicated
# tag and assert `latest` survived before the wrapper goes out.
- name: Publish platform packages to npm
shell: bash
run: |
version="$(node -p "require('./package.json').version")"
for dir in dist/npm/platforms/*/; do
echo "publishing ${dir}"
(cd "$dir" && npm publish --access public --tag platform)
done
# Tolerate a failed lookup (registry lag, or no latest tag yet on a
# bootstrap publish): under set -e a bare failure here would abort
# AFTER the platform payloads went out, leaving no wrapper and a
# re-run that fails on republishing existing versions. An empty
# value falls through the guard below.
latest="$(npm view @gitlawb/zero dist-tags.latest || true)"
case "$latest" in
"${version}-"*)
echo "::error::dist-tag latest points at platform version ${latest} — a platform publish clobbered it. Fix the dist-tag before the wrapper publish." >&2
exit 1
;;
esac
echo "dist-tag latest is ${latest} (unchanged by platform publishes)."
- name: Publish wrapper to npm
shell: bash
run: |
version="$(node -p "require('./package.json').version")"
(cd dist/npm/wrapper && npm publish --access public)
# Confirm the wrapper owns `latest` (retry: registry metadata can lag
# a publish by a few seconds).
for attempt in 1 2 3 4 5; do
latest="$(npm view @gitlawb/zero dist-tags.latest || true)"
if [ "$latest" = "$version" ]; then
echo "dist-tag latest is ${version}."
exit 0
fi
sleep 5
done
echo "::error::dist-tag latest is ${latest}, expected ${version} after the wrapper publish." >&2
exit 1
+17 -1
View File
@@ -4,12 +4,14 @@ on:
pull_request:
paths:
- action.yml
- docs/GITHUB_ACTION.md
- .github/workflows/zero-action-smoke.yml
push:
branches:
- main
paths:
- action.yml
- docs/GITHUB_ACTION.md
- .github/workflows/zero-action-smoke.yml
jobs:
@@ -29,7 +31,8 @@ jobs:
import sys, yaml
with open("action.yml", "r", encoding="utf-8") as handle:
doc = yaml.safe_load(handle)
action_raw = handle.read()
doc = yaml.safe_load(action_raw)
assert isinstance(doc, dict), "action.yml must be a mapping"
assert doc.get("name"), "action must declare a name"
@@ -65,6 +68,19 @@ jobs:
assert "--skip-permissions-unsafe" not in serialized, \
"action must never pass --skip-permissions-unsafe"
run_zero = next((step for step in steps if step.get("name") == "Run ZERO"), None)
assert run_zero is not None, "action.yml must contain a Run ZERO step"
run_zero_script = run_zero.get("run") or ""
assert 'export "${ZERO_INPUT_API_KEY_ENV}=${ZERO_INPUT_API_KEY}"' in run_zero_script, \
"Run ZERO must export the provider key for the current step"
assert "later steps inherit" not in action_raw.lower(), \
"provider keys must not be documented as inherited by later steps"
for line in run_zero_script.splitlines():
if "GITHUB_ENV" in line and (
"ZERO_INPUT_API_KEY" in line or "ZERO_INPUT_API_KEY_ENV" in line
):
raise AssertionError("provider key must not be persisted to GITHUB_ENV")
print(f"action.yml OK: {len(inputs)} inputs, {len(outputs)} outputs, {len(steps)} steps")
PY
+16
View File
@@ -27,6 +27,10 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
.cache
*.tsbuildinfo
# Go build/module caches (GOCACHE/GOMODCACHE pointed here for local runs)
.gocache/
.gomodcache/
# IntelliJ based IDEs
.idea
@@ -37,8 +41,20 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
/zero
/zero.exe
/zero-windows-command-runner.exe
/zero-windows-sandbox-setup.exe
/zero-skills
/.zero-binary-version
/snake-game.html
# Superpowers skill artifacts (brainstorm mockups, specs, plans) — local only
.superpowers/
docs/superpowers/
# Termux/Android build artifacts
zero-termux-arm64
zero-linux-sandbox
zero-seccomp
# Generated benchmark reports are evidence for one configuration, never tree state
internal/perfbench/reports/*.json
+1 -1
View File
@@ -1 +1 @@
{".":"0.1.0"}
{".":"0.4.0"}
+71
View File
@@ -0,0 +1,71 @@
# Repository Guidelines for Zero
These instructions apply to all work in this repository. For the user-facing
guide to extending Zero with specialists, hooks, plugins, MCP, and skills, see
[docs/EXTENDING.md](docs/EXTENDING.md).
## 1. Contribution and Pull Request Rules
- Before opening any pull request, **all contributors**—including maintainers,
community contributors, and coding agents—must read and follow
[CONTRIBUTING.md](CONTRIBUTING.md).
- Community pull requests require an existing parent issue with the
`issue-approved` label. Team members may open pull requests through the
internal development process described in `CONTRIBUTING.md`.
- Keep each change focused on the approved or assigned scope. Do not include
unrelated fixes, refactors, formatting churn, generated output, or existing
local changes in the same commit or pull request.
- Discuss new implementation languages, runtimes, major dependency changes,
and broad architectural rewrites with maintainers before implementation.
- Pull request descriptions must explain what changed and why, link the parent
issue when required, and list the tests or verification performed. Include
screenshots or a short recording for user-visible UI changes when practical.
## 2. Repository and Implementation Conventions
- Use the Go version declared in `go.mod`. Do not hardcode a different local
toolchain version in scripts or documentation.
- Use the repository build and release commands (`make` and
`go run ./cmd/zero-release ...`) instead of inventing parallel build flows.
- Keep tests beside their source files (`foo_test.go` next to `foo.go`). Add a
regression test for behavior changes and run affected concurrent code under
the race detector.
- Never edit files under `third_party/`; they are vendored.
- Prefer one cross-platform function with small conditional checks over
duplicated platform-specific helpers when the behavior can remain unified.
- Do not commit generated benchmark reports from
`internal/perfbench/reports/*.json`; reports are configuration-specific
evidence, not repository state.
- Preserve the user's working tree. Do not overwrite, delete, stage, or commit
unrelated tracked or untracked files.
## 3. Required Validation
Run validation from the repository root before committing, opening a pull
request, or completing an implementation task:
1. **Formatting check**: `make fmt-check`. If it fails, format with
`go fmt ./...` (or `make fmt`) and run the check again.
2. **Vet**: `go vet ./...` (or `make vet`).
3. **Tests**: `go test ./...`. Use `make test` for the full race-enabled suite,
or run focused tests with `-race`, when concurrency is affected.
4. **Build**: `go run ./cmd/zero-release build`.
5. **Smoke test**: `go run ./cmd/zero-release smoke`.
6. **Advisory lint**:
`go run github.com/golangci/golangci-lint/v2/cmd/[email protected] run --enable-only unused,ineffassign,staticcheck ./...`.
7. **Security**:
`go run golang.org/x/vuln/cmd/[email protected] ./...`.
8. **Diff hygiene**: `git diff HEAD --check` (covers staged and unstaged
tracked changes).
`make lint` currently runs the formatting check and `go vet`; it does **not**
run golangci-lint. The pinned golangci-lint job is advisory in CI while the
existing repository-wide backlog is cleaned up. Fix findings introduced by or
related to the current change. Unrelated pre-existing advisory findings do not
justify expanding a focused pull request; report them separately.
Formatting, vet, tests, build, smoke, diff hygiene, and govulncheck are hard
requirements. If a related check fails, fix it. If a required check cannot run
because of the environment or fails for an unrelated external reason, report
the exact failure and obtain maintainer direction rather than silently ignoring
it.
+168 -4
View File
@@ -6,6 +6,165 @@ 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.4.0](https://github.com/Gitlawb/zero/compare/v0.3.0...v0.4.0) (2026-07-17)
### Features
* **aimlapi:** AI/ML API provider with guided onboarding (top-up + key issuance) ([#655](https://github.com/Gitlawb/zero/issues/655)) ([6b9c2f0](https://github.com/Gitlawb/zero/commit/6b9c2f0c083e02bcd3aad68fb7af0501a9e7cd61))
* **cli:** show ZERO wordmark on --version ([#673](https://github.com/Gitlawb/zero/issues/673)) ([9acb411](https://github.com/Gitlawb/zero/commit/9acb4113cc3337b3f361a16278e9cf11ca105e34))
* **cli:** wire MCP serve WorkspaceRoot and --add-dir scope ([#694](https://github.com/Gitlawb/zero/issues/694)) ([75a78e7](https://github.com/Gitlawb/zero/commit/75a78e715bf23154f69c8c79cb58eb4b535b2a2a))
* **npm:** ship the native binary as platform optionalDependencies ([#626](https://github.com/Gitlawb/zero/issues/626)) ([5e1405d](https://github.com/Gitlawb/zero/commit/5e1405d0b7abff5b3ccb3cfdb66d64d6d3322922))
* **perf:** emit prompt-prefix hash fingerprint per turn ([#704](https://github.com/Gitlawb/zero/issues/704)) ([1c5c6e7](https://github.com/Gitlawb/zero/commit/1c5c6e78a8a0e228bdf53d7be90934fbce9d98c3))
* **providers:** add AI/ML API preset (rebased onto main) ([#621](https://github.com/Gitlawb/zero/issues/621)) ([d66a9dd](https://github.com/Gitlawb/zero/commit/d66a9dda69c32aa59f4bea903cfefe00d4b7adef))
* **providers:** refresh MiniMax model coverage ([#665](https://github.com/Gitlawb/zero/issues/665)) ([fa3052a](https://github.com/Gitlawb/zero/commit/fa3052a1422a4ad30a3a6295829564f42dee31a8))
* **skills:** discover shared ~/.agents/skills with multi-root skill loading ([#696](https://github.com/Gitlawb/zero/issues/696)) ([7d57999](https://github.com/Gitlawb/zero/commit/7d579996b43741a2e57e3e38fcdd8484fcfc34e9))
* **tui:** Ctrl+X leader chords and emacs menu navigation ([#699](https://github.com/Gitlawb/zero/issues/699)) ([7f669f4](https://github.com/Gitlawb/zero/commit/7f669f455021be51319ee3b8298cd48a17f745c7))
* **tui:** press up to edit queued messages ([#656](https://github.com/Gitlawb/zero/issues/656)) ([4c986d3](https://github.com/Gitlawb/zero/commit/4c986d327b5ed26338d295c23bea5839681db8d0))
### Bug Fixes
* **acp:** make truncateHint rune-safe ([#614](https://github.com/Gitlawb/zero/issues/614)) ([ddc4927](https://github.com/Gitlawb/zero/commit/ddc4927aac5544bf4dd2c46615aeda5a81c96576))
* **agent,tui:** resolve git branch detection when starting Zero in subdirectories ([#613](https://github.com/Gitlawb/zero/issues/613)) ([0184581](https://github.com/Gitlawb/zero/commit/0184581ec234ea414e0a47bc33e8b0f4ddfb497b))
* **agent:** raise default and deep-mode turn budgets ([#650](https://github.com/Gitlawb/zero/issues/650)) ([635c93a](https://github.com/Gitlawb/zero/commit/635c93af51ebc20f3e0917917e55a79edfe27c35))
* **cli:** prevent consuming positional arguments as flag values ([#619](https://github.com/Gitlawb/zero/issues/619)) ([5b4f48d](https://github.com/Gitlawb/zero/commit/5b4f48d2dcb66402c13bde0c3cfe9c9371da19fb))
* **config:** enforce MCP trust boundary so project config cannot override user disable ([#609](https://github.com/Gitlawb/zero/issues/609)) ([4d8c31c](https://github.com/Gitlawb/zero/commit/4d8c31cf16a3080344e3be7039408fcae71c075d)), closes [#512](https://github.com/Gitlawb/zero/issues/512)
* **config:** surface unknown/typo'd config fields instead of silently dropping them ([#645](https://github.com/Gitlawb/zero/issues/645)) ([893b7b4](https://github.com/Gitlawb/zero/commit/893b7b424cc203a2fcf92327a4e25c84286a90e0))
* **cron:** prevent cron job Mutate from clobbering concurrent updates ([#630](https://github.com/Gitlawb/zero/issues/630)) ([e4bd703](https://github.com/Gitlawb/zero/commit/e4bd703cfb28dab2dfa3c2ddba46237e1bb2e164))
* **daemon:** handle os.ErrPermission as collision during O_EXCL lock creation ([#616](https://github.com/Gitlawb/zero/issues/616)) ([8ea5384](https://github.com/Gitlawb/zero/commit/8ea53841a5d54c37a153779ae86bea010659433c))
* **exec:** stop false INCOMPLETE downgrades on conversational final messages ([#608](https://github.com/Gitlawb/zero/issues/608)) ([b6117af](https://github.com/Gitlawb/zero/commit/b6117af86d6bc87a4ee66910e99d76cb16b03fed))
* harden MCP credential boundaries ([#597](https://github.com/Gitlawb/zero/issues/597)) ([fdddb05](https://github.com/Gitlawb/zero/commit/fdddb05ba84b1600ae6c3a20028bf83afe474c44))
* **hooks:** fail closed on launch failures for beforeTool hooks ([#629](https://github.com/Gitlawb/zero/issues/629)) ([dc06fe7](https://github.com/Gitlawb/zero/commit/dc06fe72caf45f72d2cba1e8a835c0f5b405c1e8))
* **hooks:** run sessionEnd hooks after Esc/Ctrl+C interrupts ([#606](https://github.com/Gitlawb/zero/issues/606)) ([824ecdb](https://github.com/Gitlawb/zero/commit/824ecdbcf9c467c35ef4e2666770fdadcb5bf402))
* **keyring:** pass generic password via stdin on macOS ([#574](https://github.com/Gitlawb/zero/issues/574)) ([91ea6de](https://github.com/Gitlawb/zero/commit/91ea6ded7503538834a84d090f78670a363c62d3))
* **lock:** prevent POSIX lock file overwrite and leak on Windows/Unix ([#628](https://github.com/Gitlawb/zero/issues/628)) ([da41c3a](https://github.com/Gitlawb/zero/commit/da41c3a75b782d6e0836fe13346321e40a90fbb4))
* **openai:** omit prompt_cache_key for openai-compatible providers ([#636](https://github.com/Gitlawb/zero/issues/636)) ([1af5882](https://github.com/Gitlawb/zero/commit/1af58828eb3c22567599c000736c913a290959d2)), closes [#624](https://github.com/Gitlawb/zero/issues/624)
* **plugins:** resolve relative executable paths against plugin root ([#627](https://github.com/Gitlawb/zero/issues/627)) ([2efe6d5](https://github.com/Gitlawb/zero/commit/2efe6d539e29374b3ef39c2290bdea81f33a228b))
* **sandbox:** remove windowsWriteRestricted flag to fix DenyRead bypass ([#612](https://github.com/Gitlawb/zero/issues/612)) ([3d96ac7](https://github.com/Gitlawb/zero/commit/3d96ac7e55c760a97f28c0e6ceaf1ec3b4ab717a))
* **sandbox:** scrub dynamic credential env vars ([#682](https://github.com/Gitlawb/zero/issues/682)) ([9043bae](https://github.com/Gitlawb/zero/commit/9043baedcff7776c7373645b57563cac06b31847))
* **sandbox:** scrub sensitive credentials from sandbox environment ([#660](https://github.com/Gitlawb/zero/issues/660)) ([6fc1220](https://github.com/Gitlawb/zero/commit/6fc1220f6ac66fb3ae67b637cbbed7068d2213c0))
* **sandbox:** unblock git fetch/commit/add under the write-restricted sandbox ([#654](https://github.com/Gitlawb/zero/issues/654)) ([5c4815a](https://github.com/Gitlawb/zero/commit/5c4815a66ed07d9cf90b825adfd936d3ac07639d))
* **sandbox:** use WRITE_RESTRICTED token when no DenyRead paths are configured ([#658](https://github.com/Gitlawb/zero/issues/658)) ([a5d2e32](https://github.com/Gitlawb/zero/commit/a5d2e327c8681671aa8a9e5378801215b747edcf))
* **securefile,credstore:** call Sync on temp file before close and rename ([#631](https://github.com/Gitlawb/zero/issues/631)) ([212734a](https://github.com/Gitlawb/zero/commit/212734adf3b5f982e22385161205aa1afe4634fe))
* **securefile:** reclaim stale lock files to prevent permanent DOS ([#615](https://github.com/Gitlawb/zero/issues/615)) ([8536cc8](https://github.com/Gitlawb/zero/commit/8536cc87f7a885f9e436d6ef28f5f325201623dc))
* **swarm:** wait for job.Runs directly in scheduler skip test ([#667](https://github.com/Gitlawb/zero/issues/667)) ([1bb6b57](https://github.com/Gitlawb/zero/commit/1bb6b5745af90321d1e657a12a1976cded5dd1bd))
* **tools:** classify silent wrapped Windows command failures as sandbox denials ([#659](https://github.com/Gitlawb/zero/issues/659)) ([8bd9742](https://github.com/Gitlawb/zero/commit/8bd9742fa95c41b93ea4e718628aed0ff3ae9dd0))
* **tools:** preserve SysProcAttr during PTY fallback ([#618](https://github.com/Gitlawb/zero/issues/618)) ([f78b36c](https://github.com/Gitlawb/zero/commit/f78b36c770daa4577a9f99265b18a354454e36eb))
* **tui:** resolve pending askUser callbacks to prevent runner hangs ([#620](https://github.com/Gitlawb/zero/issues/620)) ([aa73a76](https://github.com/Gitlawb/zero/commit/aa73a76f1bd1b6fe97bac2fbff2d61b7474139f2))
* **tui:** stop the composer cursor blinking while typing or unfocused ([#672](https://github.com/Gitlawb/zero/issues/672)) ([2b42cd5](https://github.com/Gitlawb/zero/commit/2b42cd567b96d6f7e0818594e53ff31cce1e42e9))
* **windows:** resolve absolute path for taskkill to prevent hijacking ([#617](https://github.com/Gitlawb/zero/issues/617)) ([2db00ee](https://github.com/Gitlawb/zero/commit/2db00ee3d57db97e0fbe23cb8628e8bcb47f6f09))
### Performance Improvements
* **tools:** add explicit effect metadata for safe concurrency ([#705](https://github.com/Gitlawb/zero/issues/705)) ([8ef8576](https://github.com/Gitlawb/zero/commit/8ef8576df7d0775a5b815bc0776a794cadb75c34))
## [0.3.0](https://github.com/Gitlawb/zero/compare/v0.2.0...v0.3.0) (2026-07-09)
### Features
* gate project-scoped hooks, plugins, and MCP servers behind workspace trust ([#529](https://github.com/Gitlawb/zero/issues/529)) ([a880ce8](https://github.com/Gitlawb/zero/commit/a880ce80a6ec72da511fb9bdf6dd69291c72a64b))
* **modelregistry:** infer reasoning efforts for Hunyuan and vendor-prefixed model ids ([#599](https://github.com/Gitlawb/zero/issues/599)) ([92a92ce](https://github.com/Gitlawb/zero/commit/92a92ceb29dc63f5216ab140ef9a6dd9afe17df8))
* **providers:** OAuth login profiles and list-first /provider manager ([#560](https://github.com/Gitlawb/zero/issues/560)) ([1655056](https://github.com/Gitlawb/zero/commit/16550569c1be615cfaf244dab25909ec37f6dee6))
* **tui:** remember recent provider+model selections in /model picker ([#568](https://github.com/Gitlawb/zero/issues/568)) ([d0c4e62](https://github.com/Gitlawb/zero/commit/d0c4e62cd429a0614c15296934c740a08bc0e07b))
* **tui:** show CLI version on the startup home screen ([#538](https://github.com/Gitlawb/zero/issues/538)) ([fd69233](https://github.com/Gitlawb/zero/commit/fd69233e334f1823a06b5794085a9255b3abdfa8))
* voice dictation (speech-to-text) ([#557](https://github.com/Gitlawb/zero/issues/557)) ([87158a1](https://github.com/Gitlawb/zero/commit/87158a1c90b4f91fc5f2bb8178ebaf46d7654680))
### Bug Fixes
* address bugs found in a multi-agent codebase audit ([#481](https://github.com/Gitlawb/zero/issues/481)) ([008bc9b](https://github.com/Gitlawb/zero/commit/008bc9b3f3ba13c7d4822b9559b020f381ff555b))
* **agent:** keep tools exposed for max-turn finalization ([#533](https://github.com/Gitlawb/zero/issues/533)) ([3f0503b](https://github.com/Gitlawb/zero/commit/3f0503bc2312ae29d5ade784d8824dc9a3524958))
* **auth:** persist OpenRouter API key after login ([#595](https://github.com/Gitlawb/zero/issues/595)) ([2a062aa](https://github.com/Gitlawb/zero/commit/2a062aa4014c6cd5e20a57dad4a685f88966f109))
* bump Go to 1.26.5 for crypto/tls fix (GO-2026-5856) ([#607](https://github.com/Gitlawb/zero/issues/607)) ([a7cfb99](https://github.com/Gitlawb/zero/commit/a7cfb99fed7b88ebc09a2f251cb82864d3c2cade))
* gitignore Windows sandbox helpers and npm version marker ([#578](https://github.com/Gitlawb/zero/issues/578)) ([25653f6](https://github.com/Gitlawb/zero/commit/25653f686c016f95b81ceb7ff5d5452d37c4d4f3))
* **mcp:** silence startup warning for unconfigured default servers ([#563](https://github.com/Gitlawb/zero/issues/563)) ([302f58b](https://github.com/Gitlawb/zero/commit/302f58bb5f2a03ec7230354ed4747e4e55c16c50))
* **mcp:** skip RFC 8414 discovery when OAuth endpoints are preconfigured ([#586](https://github.com/Gitlawb/zero/issues/586)) ([8a52d98](https://github.com/Gitlawb/zero/commit/8a52d98cad7cd0086dee9aede4ce477e432bd385))
* **modelregistry:** reject oversized models.dev cache responses ([#602](https://github.com/Gitlawb/zero/issues/602)) ([66a6396](https://github.com/Gitlawb/zero/commit/66a63964149fb2f07e646e5f1987627c5cd9ac28))
* **provider:** stop dropping custom no-auth providers on restart ([#558](https://github.com/Gitlawb/zero/issues/558)) ([ba99fa8](https://github.com/Gitlawb/zero/commit/ba99fa8d487fb28f2700e0ff10b2a25c75303cf7))
* **sandbox:** Windows-appropriate suggestions when blocking interactive commands ([#414](https://github.com/Gitlawb/zero/issues/414)) ([ba4c007](https://github.com/Gitlawb/zero/commit/ba4c00755dc1c31b3dcca18e50b20f62c6bf5d1f))
* **tools:** block MSYS and WSL shells under the Windows sandbox ([#587](https://github.com/Gitlawb/zero/issues/587)) ([0666818](https://github.com/Gitlawb/zero/commit/066681855b80e3baf5d07d7397610b25f724e353))
* **tools:** platform-specific pager suggestions, quote/caret-safe cd detection ([#543](https://github.com/Gitlawb/zero/issues/543)) ([8b248f4](https://github.com/Gitlawb/zero/commit/8b248f4e1198dc86ab332a697fba4cf520823cbd))
* **tools:** Windows cmd.exe quoting guidance and clipboard escaping fix ([#468](https://github.com/Gitlawb/zero/issues/468)) ([f10ed0c](https://github.com/Gitlawb/zero/commit/f10ed0c893ce6de08923f143d681ba96f0fcfe3a))
* **tui:** bypass toggleSidebar and toggleMouse global shortcuts when composer is non-empty ([#576](https://github.com/Gitlawb/zero/issues/576)) ([c7346fb](https://github.com/Gitlawb/zero/commit/c7346fbcf01fe7e70ed2fcfccbf9965e5985727b))
* **tui:** paste protection for Termux char-by-char paste ([#573](https://github.com/Gitlawb/zero/issues/573)) ([8e9149f](https://github.com/Gitlawb/zero/commit/8e9149f1a296bebebf95cae9dd2a7c5156c9dbb6))
* **tui:** update picker_test for switchProviderModel 4-value return ([#589](https://github.com/Gitlawb/zero/issues/589)) ([8f15650](https://github.com/Gitlawb/zero/commit/8f156506dc92449bd24caa14e36f28477ba00fff))
* **update:** clearer error on unsupported release platform (android/termux) ([#603](https://github.com/Gitlawb/zero/issues/603)) ([1fc9b2d](https://github.com/Gitlawb/zero/commit/1fc9b2d25c79e089f34cb7b5b6a7f7c7b8233123))
* **update:** support safe symlink extraction during updates ([#575](https://github.com/Gitlawb/zero/issues/575)) ([ce9cb91](https://github.com/Gitlawb/zero/commit/ce9cb912958a3d9ae0b052bfebb849c28e0e719b))
* warn about untracked scratch files left behind after a run ([#571](https://github.com/Gitlawb/zero/issues/571)) ([062328b](https://github.com/Gitlawb/zero/commit/062328b632a2d27353a6d47c522bbd22d7282539))
### Performance Improvements
* **grep:** stop content scan after head limit ([#601](https://github.com/Gitlawb/zero/issues/601)) ([8a05e64](https://github.com/Gitlawb/zero/commit/8a05e6486f7a63281b869064db03dfc5531e6a04))
## [0.2.0](https://github.com/Gitlawb/zero/compare/v0.1.0...v0.2.0) (2026-07-06)
### Features
* add --auto flag for LLM-generated commit messages ([#423](https://github.com/Gitlawb/zero/issues/423)) ([b0abde7](https://github.com/Gitlawb/zero/commit/b0abde7d0697e808480cd59d69a6f4d0c6320475))
* add zero changes push and pr subcommands, and extra repo-info metrics ([#391](https://github.com/Gitlawb/zero/issues/391)) ([2312abe](https://github.com/Gitlawb/zero/commit/2312abe5ddd95f4c6ef373cfb61cc03092f48cdd))
* agent quality, caching, retry, and tooling upgrades ([#506](https://github.com/Gitlawb/zero/issues/506)) ([3c81fea](https://github.com/Gitlawb/zero/commit/3c81fea22873ee3df7fc97b10cb4f77792706c4b))
* **agent:** curb over-engineering the solution in the editing discipline ([#517](https://github.com/Gitlawb/zero/issues/517)) ([f4c998a](https://github.com/Gitlawb/zero/commit/f4c998ac30c4f07ff313a2d706791e857293be49)), closes [#516](https://github.com/Gitlawb/zero/issues/516)
* **agent:** inject per-user config.UserConfigDir()/zero/ZERO.md guidelines into system prompt ([#475](https://github.com/Gitlawb/zero/issues/475)) ([7b10aab](https://github.com/Gitlawb/zero/commit/7b10aab74bf14a01166b2cea22deab79bba9850b))
* **openai:** forward prompt_cache_key for server-side prefix cache routing ([#515](https://github.com/Gitlawb/zero/issues/515)) ([87e7e69](https://github.com/Gitlawb/zero/commit/87e7e69afd18b5539579856f3a61c6a95bc445ae))
* **providers:** add `zero providers models` to discover a provider's models ([#386](https://github.com/Gitlawb/zero/issues/386)) ([0bc8074](https://github.com/Gitlawb/zero/commit/0bc8074c97b0310e4a9d70c3f967003ee5e8a59f))
* **providers:** add KiloCode and OpenCode provider support ([#388](https://github.com/Gitlawb/zero/issues/388)) ([b1ccb6d](https://github.com/Gitlawb/zero/commit/b1ccb6d9c1875377f5e5ea81a1304edd1e41ab4f))
* **providers:** add Meituan LongCat catalog preset ([#424](https://github.com/Gitlawb/zero/issues/424)) ([b4275e3](https://github.com/Gitlawb/zero/commit/b4275e350472b2490212bf814709819d354c1216))
* **providers:** split minimax zai into intl cn ([#398](https://github.com/Gitlawb/zero/issues/398)) ([aaad4d2](https://github.com/Gitlawb/zero/commit/aaad4d271270f41af837b6f3b60ae80beba0c645))
* require manual approval before npm publish + drop release-as pin ([#369](https://github.com/Gitlawb/zero/issues/369)) ([bd89a1f](https://github.com/Gitlawb/zero/commit/bd89a1f451643c1b65ec803070abc7b116631ebe))
* **sandbox:** unelevated Windows fallback tier instead of prompts-only degrade ([#427](https://github.com/Gitlawb/zero/issues/427)) ([b9ddd6f](https://github.com/Gitlawb/zero/commit/b9ddd6f42138312a1fee8d8bb67c46c8eb1dea2f))
* support shift enter for composer newlines ([#462](https://github.com/Gitlawb/zero/issues/462)) ([daf65e0](https://github.com/Gitlawb/zero/commit/daf65e0af9a040314d4ab337b0ad59c55416b7bc))
* **tui:** /loop — repeat a prompt or command on an interval or self-paced ([#502](https://github.com/Gitlawb/zero/issues/502)) ([387fe67](https://github.com/Gitlawb/zero/commit/387fe67ee7cd81317c9c969f5906a4437080fea3))
* **tui:** add search/filter to provider picker in setup wizard ([#400](https://github.com/Gitlawb/zero/issues/400)) ([2fcea71](https://github.com/Gitlawb/zero/commit/2fcea71778d23e050c93409c471aef45b68c1621))
* **update:** add zero upgrade command to apply self-updates ([#461](https://github.com/Gitlawb/zero/issues/461)) ([5f36349](https://github.com/Gitlawb/zero/commit/5f36349c1884e81fa9bc66bb5fe813b627e897b7))
### Bug Fixes
* **action:** keep provider key scoped to zero step ([#448](https://github.com/Gitlawb/zero/issues/448)) ([407a927](https://github.com/Gitlawb/zero/commit/407a92739ff508cba32d2c12b3f36f0efcdd54c3))
* add android platform support for Termux npm install ([#455](https://github.com/Gitlawb/zero/issues/455)) ([9bd93c6](https://github.com/Gitlawb/zero/commit/9bd93c62f8d57fb74057284aa66a1b6e1429dcdd)), closes [#449](https://github.com/Gitlawb/zero/issues/449)
* **agent:** reject a malformed additional_permissions payload before prompting ([#453](https://github.com/Gitlawb/zero/issues/453)) ([e4f760e](https://github.com/Gitlawb/zero/commit/e4f760ee8bd57299cd2fcb37e8e23130037c2607))
* allow non-TLS connections to private-network provider endpoints ([#444](https://github.com/Gitlawb/zero/issues/444)) ([1d86384](https://github.com/Gitlawb/zero/commit/1d8638466ca31517eb9db2b9353d3dce1cbeeabc))
* **auth:** route zero auth login chatgpt to the dedicated ChatGPT flow ([#443](https://github.com/Gitlawb/zero/issues/443)) ([305a62c](https://github.com/Gitlawb/zero/commit/305a62c954ca6cec00bc58d5398f933415156aff))
* **config:** fall back to a usable saved provider instead of forcing full re-onboarding ([#410](https://github.com/Gitlawb/zero/issues/410)) ([c60ad87](https://github.com/Gitlawb/zero/commit/c60ad8729f79bb841114d352ee2d2fe29d5d0e41))
* **config:** let a gateway ANTHROPIC_BASE_URL resolve as anthropic-compatible ([#497](https://github.com/Gitlawb/zero/issues/497)) ([30dd7c3](https://github.com/Gitlawb/zero/commit/30dd7c3112ad22d42fa12b5addd4e38f4beda42a)), closes [#479](https://github.com/Gitlawb/zero/issues/479)
* **config:** unbrick first-run setup — default google/anthropic models, enter setup on fixable config errors ([#385](https://github.com/Gitlawb/zero/issues/385)) ([72eed06](https://github.com/Gitlawb/zero/commit/72eed06b4f94c43d75d31fe54a58d2f566de059e))
* **config:** use ~/.config on macOS and enter setup when no provider ([#371](https://github.com/Gitlawb/zero/issues/371)) ([#372](https://github.com/Gitlawb/zero/issues/372)) ([027a8f2](https://github.com/Gitlawb/zero/commit/027a8f2768b17b89f5c8270887f156e2ccda69ea))
* **docs:** rename AGENTS.MD &gt; AGENTS.md ([#438](https://github.com/Gitlawb/zero/issues/438)) ([4266baf](https://github.com/Gitlawb/zero/commit/4266baf222df583ed2078b776687f12d496475b5))
* **gemini:** strip unsupported JSON Schema fields from tool declarations ([#374](https://github.com/Gitlawb/zero/issues/374)) ([39e7100](https://github.com/Gitlawb/zero/commit/39e7100674150144a1152e3110c64c7cf0321d64)), closes [#373](https://github.com/Gitlawb/zero/issues/373)
* **install:** persist install dir to user PATH on Windows ([#407](https://github.com/Gitlawb/zero/issues/407)) ([bdb1b0e](https://github.com/Gitlawb/zero/commit/bdb1b0ecd15859b1712a6037d296dace7f9c3c3f))
* **mcp:** block cross-origin credential redirects ([#396](https://github.com/Gitlawb/zero/issues/396)) ([f915f70](https://github.com/Gitlawb/zero/commit/f915f70e5a3096e2419fa8d961a0f84a626fa4a9))
* **oauth:** treat Windows ERROR_ACCESS_DENIED as lock contention in createSecretFile ([#445](https://github.com/Gitlawb/zero/issues/445)) ([d05e914](https://github.com/Gitlawb/zero/commit/d05e9148a7f79f67d1d3c31fca2775f21fbd331e))
* **openai:** handle Ollama reasoning stream deltas ([#486](https://github.com/Gitlawb/zero/issues/486)) ([f6c0606](https://github.com/Gitlawb/zero/commit/f6c060631e18e082dda24cc4dc0903c31c2120d6))
* preserve conversation context in exec prompts ([#460](https://github.com/Gitlawb/zero/issues/460)) ([949ee43](https://github.com/Gitlawb/zero/commit/949ee43f71e5cb7fab4695c5cb7b442fe4ecfbf7))
* **provider-wizard:** allow multiple custom OpenAI-compatible providers ([#403](https://github.com/Gitlawb/zero/issues/403)) ([3fbbd28](https://github.com/Gitlawb/zero/commit/3fbbd28e4c586822cc4312c86232d94befe56e87))
* **sandbox:** fix nested pipe creation under the Windows restricted token ([#456](https://github.com/Gitlawb/zero/issues/456)) ([563a6db](https://github.com/Gitlawb/zero/commit/563a6dbe91e65d5daeefd7626e8a77e30a6d8fb2))
* **sandbox:** gate /tmp test assertions on GOOS, not path existence ([#426](https://github.com/Gitlawb/zero/issues/426)) ([f653dca](https://github.com/Gitlawb/zero/commit/f653dcac363fb69ad7be5b35e6e0fa6d2bce476d))
* **sandbox:** self-heal a corrupt unelevated setup marker ([#437](https://github.com/Gitlawb/zero/issues/437)) ([8d0c5fe](https://github.com/Gitlawb/zero/commit/8d0c5feccb8bdbfb015df0508aa6e3bcbd1fd0e8))
* **specialist:** cap max specialist nesting depth ([#491](https://github.com/Gitlawb/zero/issues/491)) ([177442c](https://github.com/Gitlawb/zero/commit/177442cfe4015bd8df04cc9894f98b468ee796d4))
* Termux/Android support — PRoot scroll, SIGSYS sandbox, build docs ([#509](https://github.com/Gitlawb/zero/issues/509)) ([0f69d99](https://github.com/Gitlawb/zero/commit/0f69d995e9b586b774f66c066b21abab5e03024a))
* **tools:** Block MSYS coreutils under Windows sandbox ([#476](https://github.com/Gitlawb/zero/issues/476)) ([81aad58](https://github.com/Gitlawb/zero/commit/81aad58d97839e51c068e2f08907618991fdc3fb))
* **tools:** CRLF line ending mismatch in edit_file tool on Windows ([#378](https://github.com/Gitlawb/zero/issues/378)) ([33dc7ae](https://github.com/Gitlawb/zero/commit/33dc7ae2cc82c5389675531e1416856dae7151ce))
* **tools:** fix cmd.exe /S/C corrupting commands with embedded quotes ([#465](https://github.com/Gitlawb/zero/issues/465)) ([190241b](https://github.com/Gitlawb/zero/commit/190241bd593f43211b766e0b13c8e89802d4bb37))
* **tools:** flag piped POSIX utilities before running on Windows ([#412](https://github.com/Gitlawb/zero/issues/412)) ([5658a36](https://github.com/Gitlawb/zero/commit/5658a366274fc59a9d5336b06a21019c9c25cbf1))
* **tools:** make grep and glob respect run cancellation ([#464](https://github.com/Gitlawb/zero/issues/464)) ([ba6c026](https://github.com/Gitlawb/zero/commit/ba6c0264697b7d7ed479f6e782fba9700a481e3d))
* **tools:** require permission before web_search requests ([#382](https://github.com/Gitlawb/zero/issues/382)) ([960db96](https://github.com/Gitlawb/zero/commit/960db9660e4e31dc588fe8f7d6f116ff5e225566))
* **tui:** compose help overlay through the viewport overlay pipeline ([#421](https://github.com/Gitlawb/zero/issues/421)) ([5b2b4de](https://github.com/Gitlawb/zero/commit/5b2b4dea1aaf9e0f68baa25e97e83296fb17b1a2))
* **tui:** keep the profile name on /model switch so the stored key resolves ([#441](https://github.com/Gitlawb/zero/issues/441)) ([9134148](https://github.com/Gitlawb/zero/commit/9134148f4df3e4e556fba6c2f8babfdf6fcfeee1)), closes [#440](https://github.com/Gitlawb/zero/issues/440)
* **tui:** resolve every permission request so the agent can't deadlock ([#397](https://github.com/Gitlawb/zero/issues/397)) ([952788f](https://github.com/Gitlawb/zero/commit/952788f72d32957659fe004521fcc8372b9ba9b4))
* **tui:** show an M suffix for million-scale token counts ([#457](https://github.com/Gitlawb/zero/issues/457)) ([0562e3b](https://github.com/Gitlawb/zero/commit/0562e3bef7df2328610a48a1e81632a8da4aec64))
* **tui:** title /model rows by model name, not the catalog description ([#395](https://github.com/Gitlawb/zero/issues/395)) ([cdf9d83](https://github.com/Gitlawb/zero/commit/cdf9d839ae57a729f292f36f7c5b0c67b41b288d))
### Performance Improvements
* cache TUI model registry ([#496](https://github.com/Gitlawb/zero/issues/496)) ([e7d88b4](https://github.com/Gitlawb/zero/commit/e7d88b4b518049733da25a8447c00144bd1da518))
* universal tool-output ceiling with spill + async post-edit diagnostics ([#518](https://github.com/Gitlawb/zero/issues/518)) ([95ccd5b](https://github.com/Gitlawb/zero/commit/95ccd5bc327f6fb464ff0239f7229de789f578dc))
## 0.1.0 (2026-07-02)
@@ -22,14 +181,19 @@ tagged. Until then, source builds report the version `dev`.
## [Unreleased]
### Added
- Shared multi-agent skills discovery: when present, `~/.agents/skills` is searched after the primary
Zero skills dir (and before plugin skill roots). `zero skills list` / `info` and the runtime `skill`
tool share one multi-root discovery path; install/remove/lock still target only the Zero skills directory.
- `SECURITY.md` with a private vulnerability-reporting path, `CODE_OF_CONDUCT.md`, this changelog, and
GitHub issue/PR templates.
- 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.
- Twelve built-in color themes alongside the `dark`/`light` built-ins — `dracula`, `nord`, `gruvbox`,
`tokyo-night`, `catppuccin`, `one-dark`, `solarized-dark`, `rose-pine`, `everforest`,
`solarized-light`, `dune`, and `neon` — selectable via `/theme <name>`, `--theme <name>`, or
`ZERO_THEME`. Every palette is contrast-audited to WCAG AA, and the new presets are additionally
audited after xterm-256 downsampling; see [docs/THEMES.md](docs/THEMES.md). 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`,
+26 -5
View File
@@ -8,6 +8,13 @@ stabilizes. Community participation is still welcome, especially through bug
reports, feature requests, reproduction details, logs, and discussion on existing
issues.
Questions and setup help belong in
[GitHub Discussions](https://github.com/Gitlawb/zero/discussions) (see
[SUPPORT.md](SUPPORT.md)), not in the issue tracker. Feature ideas start in the
[Ideas](https://github.com/Gitlawb/zero/discussions/categories/ideas) category;
an idea that gains traction there is the path to an approved issue and,
eventually, an accepted PR.
Please read this policy before opening a pull request. Pull requests that do not
follow it may be closed without review.
@@ -33,7 +40,11 @@ unless the issue has been explicitly approved for outside contribution.
There are three primary ways to contribute:
1. Open a bug report with clear reproduction steps.
2. Open a feature request describing the problem, use case, and expected behavior.
2. Propose a feature in
[Discussions Ideas](https://github.com/Gitlawb/zero/discussions/categories/ideas),
describing the problem, use case, and expected behavior. A feature-request
issue is filed only after the idea gains traction there or a maintainer asks
for one.
3. Add useful context to an existing issue, such as logs, screenshots, examples,
affected versions, or reproduction cases.
@@ -46,7 +57,8 @@ All community pull requests require an approved parent issue.
Before starting implementation work:
1. Open an issue using the appropriate issue template.
1. For a bug, open an issue using the bug-report template. For a feature, start
in Discussions Ideas; the feature-request issue comes after the discussion.
2. Describe the bug, feature request, or functional change clearly.
3. Wait for the core team to review the issue.
4. Do not open a pull request unless the issue has the `issue-approved` label.
@@ -133,7 +145,12 @@ Clear bug reports are the most useful way to help the project during this phase.
## Submitting a Feature Request
When filing a feature request, please include:
Feature requests start as a thread in
[Discussions Ideas](https://github.com/Gitlawb/zero/discussions/categories/ideas),
not as an issue. The feature-request issue form exists for the step AFTER that:
when an idea has gained traction in Discussions or a maintainer has asked for an
issue to track it. Whether in the discussion or the eventual issue, please
include:
- The problem you are trying to solve.
- The behavior or functionality you are requesting.
@@ -156,5 +173,9 @@ contribution phase.
## Getting Help
If you are unsure whether a change is appropriate, open an issue first and ask.
Please keep discussion focused, specific, and respectful of maintainer time.
General questions, setup help, and "is this a bug or my config" uncertainty all
go to
[Discussions Q&A](https://github.com/Gitlawb/zero/discussions/categories/q-a).
If you are unsure whether a proposed code change is appropriate, raise it in
Discussions Ideas or on the relevant issue first and ask. Please keep discussion
focused, specific, and respectful of maintainer time.
+17 -3
View File
@@ -1,7 +1,7 @@
# Zero build/test/lint targets. AGENTS.MD says "Build with `make`" and "Run `make
# Zero build/test/lint targets. AGENTS.md says "Build with `make`" and "Run `make
# lint` before opening a PR" — these targets back those instructions.
.DEFAULT_GOAL := build
.PHONY: build build-all test test-race vet fmt fmt-check lint tidy clean help
.PHONY: build build-all test test-race vet fmt fmt-check lint tidy clean baseline help
# Build the main CLI binary into ./zero.
build:
@@ -40,5 +40,19 @@ clean:
rm -f zero
go clean ./...
# Run the per-turn benchmark harness over the checked-in baseline manifest and
# write the JSON result to internal/perfbench/reports/baseline.json. Requires a
# built `zero` binary and a model; set ZERO_BENCH_MODEL (required) and
# ZERO_BENCH_BINARY (defaults to ./zero) to configure the run. The report is
# machine-specific and regenerated, not hand-edited.
baseline: build
@if [ -z "$(ZERO_BENCH_MODEL)" ]; then echo "Set ZERO_BENCH_MODEL (and optionally ZERO_BENCH_BINARY) before running 'make baseline'"; exit 2; fi
@ZERO_BIN="$${ZERO_BENCH_BINARY:-./zero}"; \
go run ./cmd/zero-perf-bench turn \
--suite internal/perfbench/manifests/baseline.json \
--model $(ZERO_BENCH_MODEL) \
--binary "$$ZERO_BIN" \
--output internal/perfbench/reports/baseline.json
help:
@echo "Targets: build (default), build-all, test, test-quick, vet, fmt, fmt-check, lint, tidy, clean"
@echo "Targets: build (default), build-all, test, test-quick, vet, fmt, fmt-check, lint, tidy, clean, baseline"
+129 -28
View File
@@ -6,7 +6,7 @@
<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="Go 1.26.5+" src="https://img.shields.io/badge/Go-1.26.5+-00ADD8?logo=go&logoColor=white">
<img alt="25+ providers" src="https://img.shields.io/badge/providers-25+-34E2EA">
<br>
<strong>English</strong> | <a href="README_ZH.md">中文</a>
@@ -49,30 +49,15 @@ npm install -g @gitlawb/zero
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.
### Bun
Bun does not run dependency lifecycle scripts by default, so the `postinstall`
that fetches the Zero binary is skipped and the first run fails with
`No native binary found next to the npm wrapper`.
Install with Bun, then fetch the binary in one of two ways:
```bash
# Option A: run the installer manually
bun add @gitlawb/zero
node node_modules/@gitlawb/zero/scripts/postinstall.mjs
# Option B: allow the postinstall to run on install
# add to your package.json: "trustedDependencies": ["@gitlawb/zero"]
bun add @gitlawb/zero
```
For global installs (`bun add -g @gitlawb/zero`), use Option A against the
global install path, or prefer the install scripts below.
The npm package is a small wrapper whose platform build (Linux and macOS on
x64/arm64, Windows on x64 — including the browser/terminal control helpers)
installs as an optional dependency straight from the npm registry — no install
scripts, no downloads outside npm. Bun, pnpm, and yarn work the same way with
no trust or approval steps. Installs that skip optional dependencies
(`--omit=optional`) still work: the wrapper fetches the binary from the
matching GitHub Release whenever it is missing. Windows on ARM runs the x64
build under emulation. See [docs/NPM_PACKAGING.md](docs/NPM_PACKAGING.md) for
how the package is put together.
### Install scripts
@@ -90,7 +75,7 @@ irm https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.ps1 | ie
### From source
Source builds require Go 1.25+.
Source builds require Go 1.26.5+.
```bash
git clone https://github.com/Gitlawb/zero.git
@@ -144,7 +129,16 @@ the key in the wizard:
export OPENAI_API_KEY=sk-...
export ANTHROPIC_API_KEY=...
export GEMINI_API_KEY=...
export AIMLAPI_API_KEY=...
export LONGCAT_API_KEY=...
export MINIMAX_API_KEY=...
export MINIMAXI_API_KEY=...
```
To configure AI/ML API directly, run:
```bash
zero providers setup aimlapi --set-active
```
To configure Meituan LongCat (LongCat-2.0) directly, run:
@@ -153,6 +147,33 @@ To configure Meituan LongCat (LongCat-2.0) directly, run:
zero providers setup longcat --set-active
```
MiniMax presets use the Anthropic-compatible endpoints for the global and China
regions:
```bash
zero providers add minimax --set-active
zero providers add minimaxi-cn --set-active
```
To use the OpenAI-compatible endpoints instead, add a custom compatible profile
for the required region:
```bash
zero providers add custom-openai-compatible \
--name minimax-openai \
--model MiniMax-M3 \
--base-url https://api.minimax.io/v1 \
--api-key-env MINIMAX_API_KEY \
--set-active
zero providers add custom-openai-compatible \
--name minimax-cn-openai \
--model MiniMax-M3 \
--base-url https://api.minimaxi.com/v1 \
--api-key-env MINIMAXI_API_KEY \
--set-active
```
For local models, run Ollama or LM Studio and then use `zero setup` or
`zero providers detect`.
@@ -170,6 +191,8 @@ Useful controls:
|---|---|
| `Enter` | send the prompt |
| `/` | open slash-command suggestions |
| `Ctrl+X` then letter | common slash commands (e.g. `m` → `/model`; `Ctrl+X` `?` for full list) |
| `Ctrl+P` / `Ctrl+N` | previous / next item in menus (arrows still work) |
| `Shift+Tab` | cycle permission mode |
| `Ctrl+B` | show/hide the sidebar |
| `Ctrl+C` | cancel or exit |
@@ -182,6 +205,7 @@ Common slash commands:
| `/spec`, `/plan` | draft and review a plan before building |
| `/image` | attach an image for vision-capable models |
| `/resume`, `/rewind` | continue or roll back local sessions |
| `/loop` | repeat a prompt or custom `/command` on an interval (`/loop 5m /babysit-prs`) or self-paced |
| `/compact`, `/context` | manage context usage |
| `/permissions`, `/tools` | inspect available tools and policy |
| `/add-dir` | allow an extra write directory for this session |
@@ -279,12 +303,45 @@ zero cron scheduled agent jobs
zero update check for newer releases
```
## Extending Zero
### Project and personal instructions
Zero appends project-specific guidance to the system prompt from the first
`AGENTS.md`, `ZERO.md`, or `.zero/AGENTS.md` file found in each directory from
the git root down to your current working directory (checked in that order
per directory). Files are injected general-to-specific, capped at 8 KiB per
file and 32 KiB total.
A personal `ZERO.md` under `config.UserConfigDir()/zero/ZERO.md`
(`$XDG_CONFIG_HOME/zero/ZERO.md` or `~/.config/zero/ZERO.md` on Linux/macOS,
`%AppData%\Roaming\zero\ZERO.md` on Windows) applies across every workspace, ahead of any project guidelines.
### Plugins
Plugins are discovered from `~/.config/zero/plugins/<name>/plugin.json` (user
scope — `$XDG_CONFIG_HOME` or `~/.config` on every OS, independent of the
`config.UserConfigDir()` path used above) and `<cwd>/.zero/plugins/<name>/plugin.json`
(project scope — resolved from the current working directory, not the repo
root), and managed with `zero plugins`. A manifest can declare:
- `tools` — custom tools (`command`, `args`, `inputSchema`, and a
`permission` of `prompt` or `deny`; `allow` is honored only when manifest tool
auto-approval is enabled)
- `hooks` — commands run on `beforeTool`, `afterTool`, `sessionStart`, or
`sessionEnd`
- `prompts` and `skills` — additional prompt/skill files
MCP servers (`zero mcp`) and standalone markdown skills (`zero skills`) use
the same extension points and can also be wired up outside of a plugin
manifest.
## Appearance And Accessibility
| Control | Effect |
|---|---|
| `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`) |
| `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`, `neon`, `solarized-light`, `dune`) |
| `--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 |
@@ -301,7 +358,32 @@ go run ./cmd/zero-release smoke
go run ./cmd/zero-perf-bench
```
Cross-compile examples:
Experimental: `ZERO_OPENAI_TURN_SESSION=1` enables the optimized OpenAI turn
session (background connection prewarm + request-prefix telemetry) for headless
`zero exec` runs against official OpenAI profiles. Off by default; `0`/`false`
disable. A/B-benchmark it by running the same `zero-perf-bench` suite with the
variable unset and set.
### Code Quality and Security Checks
Before committing any changes, ensure all Go code quality and security checks pass. Pinned `go run` commands matching CI constraints can be used directly without prior installation:
1. **Formatting**: Run `go fmt ./...` (or `make fmt`).
2. **Vetting**: Run `go vet ./...` (or `make vet`).
3. **Linting**: Run `go run github.com/golangci/golangci-lint/v2/cmd/[email protected] run --enable-only unused,ineffassign,staticcheck ./...`.
4. **Vulnerability Scan**: Run `go run golang.org/x/vuln/cmd/[email protected] ./...`.
If you prefer to install these tools globally on your path, you can run:
```bash
# Install golangci-lint
go install github.com/golangci/golangci-lint/v2/cmd/[email protected]
# Install govulncheck
go install golang.org/x/vuln/cmd/[email protected]
```
### Cross-Compile Examples
```bash
go run ./cmd/zero-release build --goos linux --goarch amd64
@@ -312,6 +394,7 @@ go run ./cmd/zero-release build --goos windows --goarch amd64 --output dist/zero
- [Install](docs/INSTALL.md)
- [Update flow](docs/UPDATE.md)
- [Themes](docs/THEMES.md)
- [Stream-JSON protocol](docs/STREAM_JSON_PROTOCOL.md)
- [Specialists](docs/SPECIALISTS.md)
- [GitHub Action](docs/GITHUB_ACTION.md)
@@ -319,6 +402,24 @@ go run ./cmd/zero-release build --goos windows --goarch amd64 --output dist/zero
- [Performance](docs/PERFORMANCE.md)
- [Agent evals](docs/AGENT_EVALS.md)
## Community
Questions, setup help, ideas, and sharing all live in
[GitHub Discussions](https://github.com/Gitlawb/zero/discussions):
| Category | Use it for |
|---|---|
| [Q&A](https://github.com/Gitlawb/zero/discussions/categories/q-a) | Setup help, provider/model configuration, "how do I" questions |
| [Ideas](https://github.com/Gitlawb/zero/discussions/categories/ideas) | Feature proposals and design discussion before any PR |
| [Show and tell](https://github.com/Gitlawb/zero/discussions/categories/show-and-tell) | Your skills, plugins, MCP setups, themes, and workflows |
| [Announcements](https://github.com/Gitlawb/zero/discussions/categories/announcements) | Releases and project news from the maintainers |
For a good Q&A answer fast, include `zero --version`, your OS and install
method, the provider/model in use, and `zero doctor` output. See
[SUPPORT.md](SUPPORT.md). Bugs belong in
[issues](https://github.com/Gitlawb/zero/issues/new/choose); security reports
follow [SECURITY.md](SECURITY.md), never a public thread.
## Contributing
Contributions are welcome. Read [CONTRIBUTING.md](CONTRIBUTING.md), run the
+5 -22
View File
@@ -6,7 +6,7 @@
<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="Go 1.26.5+" src="https://img.shields.io/badge/Go-1.26.5+-00ADD8?logo=go&logoColor=white">
<img alt="25+ providers" src="https://img.shields.io/badge/providers-25+-34E2EA">
<br>
<a href="README.md">English</a> | <strong>中文</strong>
@@ -38,25 +38,7 @@ npm install -g @gitlawb/zero
zero
```
npm 包安装一个小型包装器以及与你平台匹配的 Zero 二进制文件(从 GitHub Releases 获取)。支持 Linux、macOS 和 Windows 的 x64 和 arm64。
### Bun
Bun 默认不运行依赖的生命周期脚本,因此获取 Zero 二进制文件的 `postinstall` 会被跳过,首次运行会失败并显示 `No native binary found next to the npm wrapper`。
使用 Bun 安装后,通过以下两种方式之一获取二进制文件:
```bash
# 选项 A:手动运行安装程序
bun add @gitlawb/zero
node node_modules/@gitlawb/zero/scripts/postinstall.mjs
# 选项 B:允许 postinstall 在安装时运行
# 添加到你的 package.json: "trustedDependencies": ["@gitlawb/zero"]
bun add @gitlawb/zero
```
对于全局安装(`bun add -g @gitlawb/zero`),请对全局安装路径使用选项 A,或使用下面的安装脚本。
npm 包是一个小型包装器,其平台构建(Linux 和 macOS 的 x64/arm64、Windows 的 x64,包含浏览器/终端控制辅助工具)作为可选依赖直接从 npm registry 安装——没有安装脚本,也不会从 npm 之外下载任何内容。Bun、pnpm 和 yarn 的行为完全一致,无需任何信任或批准步骤。跳过可选依赖的安装(`--omit=optional`)也能工作:只要二进制文件缺失,包装器就会从对应的 GitHub Release 下载。Windows on ARM 通过模拟运行 x64 构建。详见 [docs/NPM_PACKAGING.md](docs/NPM_PACKAGING.md)。
### 安装脚本
@@ -74,7 +56,7 @@ irm https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.ps1 | ie
### 从源码构建
源码构建需要 Go 1.25+。
源码构建需要 Go 1.26.5+。
```bash
git clone https://github.com/Gitlawb/zero.git
@@ -252,7 +234,7 @@ zero update 检查更新版本
| 控制 | 效果 |
|---|---|
| `NO_COLOR=<任意值>` | 禁用颜色输出 |
| `ZERO_THEME=<名称>` | 选择启动主题(`auto`、`dark`、`light`,或颜色主题如 `dracula`、`nord`、`gruvbox`、`tokyo-night`、`catppuccin`、`one-dark`、`solarized-dark`、`rose-pine`、`everforest`、`solarized-light`) |
| `ZERO_THEME=<名称>` | 选择启动主题(`auto`、`dark`、`light`,或颜色主题如 `dracula`、`nord`、`gruvbox`、`tokyo-night`、`catppuccin`、`one-dark`、`solarized-dark`、`rose-pine`、`everforest`、`neon`、`solarized-light`、`dune`) |
| `--theme <名称>` | 从 CLI 选择 TUI 主题(相同名称) |
| `/theme` | 在 TUI 中打开主题选择器(实时预览;`/theme <名称>` 直接切换) |
| `ZERO_NO_FADE=1` | 禁用流式淡入动画 |
@@ -279,6 +261,7 @@ go run ./cmd/zero-release build --goos windows --goarch amd64 --output dist/zero
- [安装](docs/INSTALL.md)
- [更新流程](docs/UPDATE.md)
- [主题](docs/THEMES.md)
- [Stream-JSON 协议](docs/STREAM_JSON_PROTOCOL.md)
- [专家](docs/SPECIALISTS.md)
- [GitHub Action](docs/GITHUB_ACTION.md)
+36
View File
@@ -0,0 +1,36 @@
# Getting help with Zero
## Questions, setup help, configuration
Ask in [GitHub Discussions](https://github.com/Gitlawb/zero/discussions). Use
[Q&A](https://github.com/Gitlawb/zero/discussions/categories/q-a) so answers can
be marked and found by the next person.
The fastest way to a useful answer is to include:
- `zero --version` output
- your OS (Windows, macOS, Linux) and how you installed Zero (npm, install
script, source)
- the provider and model you are using (`zero providers list`)
- `zero doctor` output for setup problems
- the exact command you ran and the exact error text
## Bugs
If Zero does something wrong (crashes, wrong output, broken behavior), file a
[bug report](https://github.com/Gitlawb/zero/issues/new/choose). If you are not
sure whether it is a bug or a setup problem, start in Discussions and we will
promote it to an issue if it is one.
## Feature ideas
Start a thread in
[Ideas](https://github.com/Gitlawb/zero/discussions/categories/ideas). Zero is
in controlled development (see CONTRIBUTING.md), so discussing an idea first is
also the path to getting a PR accepted.
## Security issues
Never file a public issue or discussion. Report privately via
[GitHub Security Advisories](https://github.com/Gitlawb/zero/security/advisories/new)
(see SECURITY.md).
+8 -9
View File
@@ -27,14 +27,14 @@ inputs:
api-key:
description: >-
The provider API key. Pass it from a repository or organization secret;
it is exported into the step environment and never written to the log.
it is exported only for the ZERO step and never written to the log.
required: false
default: ""
api-key-env:
description: >-
Name of the environment variable the chosen provider reads its key from
(for example OPENAI_API_KEY or ANTHROPIC_API_KEY). When set together with
api-key, that variable is exported for the run. Leave the action
api-key, that variable is exported only for the ZERO step. Leave the action
provider-agnostic by passing the env name your provider expects rather
than hardcoding one here.
required: false
@@ -180,6 +180,7 @@ runs:
ZERO_INPUT_ADD_DIR: ${{ inputs.add-dir }}
ZERO_INPUT_WORKTREE: ${{ inputs.worktree }}
ZERO_INPUT_OUTPUT_FORMAT: ${{ inputs.output-format }}
ACTION_PATH: ${{ github.action_path }}
RUNNER_TEMP: ${{ runner.temp }}
run: |
set -euo pipefail
@@ -192,10 +193,9 @@ runs:
echo "::error::api-key-env must be a valid environment variable name." >&2
exit 2
fi
# Export in-process so the zero invocation in this same run block sees
# it, and append to GITHUB_ENV so any later steps inherit it too.
# Export in-process for this Run ZERO step so zero can read it; do
# not persist provider credentials to later job steps.
export "${ZERO_INPUT_API_KEY_ENV}=${ZERO_INPUT_API_KEY}"
printf '%s=%s\n' "${ZERO_INPUT_API_KEY_ENV}" "${ZERO_INPUT_API_KEY}" >> "${GITHUB_ENV}"
fi
if [ -n "${ZERO_INPUT_PROVIDER}" ]; then
export ZERO_PROVIDER="${ZERO_INPUT_PROVIDER}"
@@ -248,10 +248,9 @@ runs:
echo "exit-code=${code}" >> "${GITHUB_OUTPUT}"
echo "output-file=${output_file}" >> "${GITHUB_OUTPUT}"
# Best-effort one-line summary: last non-empty line of the captured output.
summary="$(grep -v '^[[:space:]]*$' "${output_file}" | tail -n 1 || true)"
# Keep the summary single-line and bounded for the step output.
summary="$(printf '%s' "${summary}" | cut -c1-280)"
# Best-effort one-line summary. Structured output ends with lifecycle
# metadata, so parse final/error events instead of tailing stdout.
summary="$(node "${ACTION_PATH}/scripts/action-summary.mjs" "${ZERO_INPUT_OUTPUT_FORMAT}" "${output_file}" || true)"
{
echo "summary<<ZERO_EOF"
printf '%s\n' "${summary}"
+223 -24
View File
@@ -2,6 +2,7 @@
import { spawnSync } from 'node:child_process';
import { existsSync } from 'node:fs';
import { createRequire } from 'node:module';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -39,6 +40,11 @@ function resolveHelper(packageRoot, name) {
return null;
}
// Legacy path: helpers installed as npm dependencies of the wrapper. Current
// installs vendor the helpers inside the platform package instead (a helpers/
// directory next to the binary, found by the Go side on its own), so this
// manifest is usually empty — it only matters for a downloaded-binary install
// whose wrapper still carries helper dependencies.
function localControlHelperManifest(packageRoot) {
const helpers = {};
for (const name of ['agent-browser', 'tuistory']) {
@@ -49,36 +55,229 @@ function localControlHelperManifest(packageRoot) {
return JSON.stringify({ version: 1, helpers });
}
const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
const nativePath = join(packageRoot, zeroBinaryName());
const localControlHelpers = localControlHelperManifest(packageRoot);
// Mirrors internal/cli/observability.go parseDoctorArgs + writeDoctorHelp so the
// wrapper's missing-binary doctor fallback matches the Go doctor CLI surface.
const DOCTOR_HELP = `Usage:
zero doctor [flags]
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.\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+).',
Runs Go backend health checks for config and provider setup.
Flags:
--json Print JSON report
--connectivity Include provider endpoint connectivity probe when available
-h, --help Show this help
`;
const EXIT_USAGE = 2;
function installedByBun() {
if (process.env.ZERO_WRAPPER_SIMULATE_BUN === '1') {
return true;
}
return process.execPath.includes('bun') || !!process.versions?.bun;
}
function bunRecoveryParagraph() {
return (
'You installed with Bun, which does not run dependency lifecycle scripts\n' +
'by default. Trust the package to run the blocked postinstall:\n' +
' bun pm trust @gitlawb/zero (project install)\n' +
' bun pm -g trust @gitlawb/zero (global install)\n' +
'On Bun versions without `bun pm trust`, add\n' +
' "trustedDependencies": ["@gitlawb/zero"]\n' +
'to your project package.json and reinstall.\n' +
'\n'
);
}
function buildFromSourceParagraph() {
return (
'If this platform has no prebuilt binary, build from source:\n' +
'https://github.com/Gitlawb/zero (go run ./cmd/zero, requires Go 1.26+).'
);
}
function missingBinaryContextParagraph() {
return (
'Normally npm installs it as an optional dependency of @gitlawb/zero\n' +
`(@gitlawb/zero-${process.platform}-${process.arch}), and the wrapper can\n` +
'also download it from the GitHub Release when it is missing.'
);
}
function missingNativeRecoveryParagraphs(postinstallScript) {
const ranByBun = installedByBun();
return (
missingBinaryContextParagraph() +
'\n' +
'\n' +
'Things to try:\n' +
' - reinstall without omitting optional dependencies:\n' +
' npm install -g @gitlawb/zero\n' +
' - run the downloader manually (needs write access to the package dir):\n' +
` node "${postinstallScript}"\n` +
'\n' +
(ranByBun ? bunRecoveryParagraph() : '') +
buildFromSourceParagraph()
);
}
function formatGenericMissingNativeBinaryMessage(postinstallScript) {
return (
'[zero] No native binary is available for this install.\n' +
missingNativeRecoveryParagraphs(postinstallScript)
);
}
function parseDoctorArgs(args) {
let json = false;
for (const arg of args) {
switch (arg) {
case '-h':
case '--help':
case 'help':
return { kind: 'help' };
case '--json':
json = true;
break;
case '--connectivity':
break;
default:
return { kind: 'error', message: `unknown doctor flag ${JSON.stringify(arg)}` };
}
}
return { kind: 'run', json };
}
function missingNativeDoctorJSONReport(postinstallScript) {
return {
generatedAt: new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'),
ok: false,
checks: [
{
id: 'runtime.go',
label: 'Go runtime',
status: 'fail',
message: 'Native zero binary is missing next to the npm wrapper.',
details: {
remedy: `node "${postinstallScript}"`,
},
},
],
};
}
function missingNativeDoctorTextReport(postinstallScript) {
return (
'Zero doctor report (' +
new Date().toISOString() +
')\n' +
'Overall: fail\n' +
'[fail] runtime.go - Native zero binary is missing next to the npm wrapper.\n' +
' remedy: node "' +
postinstallScript +
'"\n' +
'\n' +
missingNativeRecoveryParagraphs(postinstallScript)
);
}
// The platform payload is a version of @gitlawb/zero itself, installed under
// an npm: alias (see docs/NPM_PACKAGING.md). The alias name is derived from
// process.platform/process.arch, so an unsupported platform simply fails to
// resolve and we fall through to the downloader.
function platformPackageBinary() {
const aliasedName = `@gitlawb/zero-${process.platform}-${process.arch}`;
let manifestPath;
try {
manifestPath = createRequire(import.meta.url).resolve(`${aliasedName}/package.json`);
} catch {
return null;
}
const candidate = join(dirname(manifestPath), zeroBinaryName());
return existsSync(candidate) ? candidate : null;
}
// Mirrors the release matrix (internal/release/release.go): the platforms a
// GitHub Release asset exists for. android maps to the linux asset; there is
// no windows-arm64 asset (Windows on ARM runs the x64 build under emulation).
function releaseAssetAvailable() {
const platform = { linux: 'linux', android: 'linux', darwin: 'macos', win32: 'windows' }[
process.platform
];
const arch = process.arch === 'x64' || process.arch === 'arm64' ? process.arch : null;
if (!platform || !arch) return false;
return !(platform === 'windows' && arch === 'arm64');
}
// Fallback for installs without a platform package (--omit=optional, package
// managers that skip optionalDependencies): fetch the binary next to the
// wrapper with the same checksum-verified downloader that used to run as
// postinstall. Deliberately NOT cached on failure — a transient network error
// self-heals on the next run — so this retries on every invocation until a
// binary is in place. Platforms with no release asset skip the attempt.
function downloadMissingBinary(packageRoot) {
const downloadScript = join(packageRoot, 'scripts', 'postinstall.mjs');
if (!existsSync(downloadScript) || !releaseAssetAvailable()) return;
console.error(
'[zero] no platform package installed — fetching the native binary from the GitHub Release (retried on each run until it succeeds).',
);
spawnSync(process.execPath, [downloadScript], {
stdio: ['ignore', 'inherit', 'inherit'],
});
}
const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
function resolveNativeBinary() {
const fromPlatformPackage = platformPackageBinary();
if (fromPlatformPackage) return fromPlatformPackage;
const downloaded = join(packageRoot, zeroBinaryName());
if (existsSync(downloaded)) return downloaded;
downloadMissingBinary(packageRoot);
return existsSync(downloaded) ? downloaded : null;
}
const nativePath = resolveNativeBinary();
if (!nativePath) {
const postinstallScript = join(packageRoot, 'scripts', 'postinstall.mjs');
// `zero doctor` is the diagnostic command: when the native binary is missing
// it's the one invocation that must NOT bail with the generic wrapper error,
// because that's exactly the blind alley issue #405 reports. Instead, surface
// a doctor-shaped FAIL line for the runtime so the user's diagnostic finds the
// real cause. We branch on a literal 'doctor' subcommand only (matching `zero
// doctor` and `zero doctor --connectivity`), preserving the existing bail for
// every other invocation (exec, providers list, TUI, --version, etc.).
const argv = process.argv.slice(2);
const isDoctor = argv.length > 0 && argv[0] === 'doctor';
if (isDoctor) {
const parsed = parseDoctorArgs(argv.slice(1));
if (parsed.kind === 'help') {
process.stdout.write(DOCTOR_HELP);
process.exit(0);
}
if (parsed.kind === 'error') {
process.stderr.write(`[zero] ${parsed.message}\n`);
process.exit(EXIT_USAGE);
}
if (parsed.json) {
process.stdout.write(JSON.stringify(missingNativeDoctorJSONReport(postinstallScript), null, 2) + '\n');
process.exit(1);
}
process.stdout.write(missingNativeDoctorTextReport(postinstallScript) + '\n');
process.exit(1);
}
console.error(formatGenericMissingNativeBinaryMessage(postinstallScript));
process.exit(1);
}
const env = { ...process.env };
const localControlHelpers = localControlHelperManifest(packageRoot);
if (localControlHelpers) {
env.ZERO_LOCAL_CONTROL_HELPERS = localControlHelpers;
} else {
+4
View File
@@ -39,6 +39,9 @@ func run(args []string, getenv func(string) string, stdout io.Writer, stderr io.
if len(args) > 0 && args[0] == "tasks" {
return runTasksCommand(args[1:], getenv, stdout, stderr)
}
if len(args) > 0 && args[0] == "turn" {
return runTurnCommand(args[1:], getenv, stdout, stderr)
}
options, err := parseArgs(args, getenv)
if err != nil {
_, _ = fmt.Fprintln(stderr, err.Error())
@@ -206,6 +209,7 @@ func helpText() string {
return strings.Join([]string{
"Usage: zero-perf-bench [options]",
" zero-perf-bench tasks [options] (Terminal-Bench-style task harness; see `tasks --help`)",
" zero-perf-bench turn [options] (per-turn tracing benchmark; see `turn --help`)",
"",
"Options:",
" --iterations <n> Measured samples to collect (default: 5)",
+17
View File
@@ -4,6 +4,8 @@ import (
"bytes"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/perfbench"
)
func TestParseArgsUsesCliAndEnvOverrides(t *testing.T) {
@@ -92,3 +94,18 @@ func TestHelpText(t *testing.T) {
func emptyEnv(string) string {
return ""
}
func TestTurnExitCodeFailsWhenEveryTaskErrored(t *testing.T) {
var stderr bytes.Buffer
allErrored := perfbench.TurnBenchResult{TasksAttempted: 3, TasksErrored: 3}
if code := turnExitCode(allErrored, &stderr); code != 1 {
t.Fatalf("exit=%d, want 1 when every task errored", code)
}
if !strings.Contains(stderr.String(), "no accepted benchmark sample") {
t.Fatalf("stderr missing explanation: %q", stderr.String())
}
partial := perfbench.TurnBenchResult{TasksAttempted: 3, TasksErrored: 2}
if code := turnExitCode(partial, &stderr); code != 0 {
t.Fatalf("exit=%d, want 0 for partial errors (summary surfaces them)", code)
}
}
+279
View File
@@ -0,0 +1,279 @@
package main
import (
"bytes"
"context"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"github.com/Gitlawb/zero/internal/execprofile"
"github.com/Gitlawb/zero/internal/perfbench"
)
// turnOptions configures the `zero-perf-bench turn` subcommand: the per-turn
// benchmark harness that runs ZERO headlessly with --trace, parses each turn's
// NDJSON trace, and records per-span latency plus the top controllable latency
// sources — the Phase 0 baseline's "do not proceed until" criterion.
type turnOptions struct {
SuitePath string
Model string
Mode string
ExecProfile string
SelfCorrect bool
Binary string
Iterations int
Version string
Commit string
Output string
JSON bool
DryRun bool
Help bool
}
func runTurnCommand(args []string, getenv func(string) string, stdout io.Writer, stderr io.Writer) int {
options, err := parseTurnArgs(args, getenv)
if err != nil {
_, _ = fmt.Fprintln(stderr, err.Error())
return 2
}
if options.Help {
_, _ = fmt.Fprint(stdout, turnHelpText())
return 0
}
set, err := perfbench.LoadTaskSet(options.SuitePath)
if err != nil {
_, _ = fmt.Fprintln(stderr, "[zero] Turn benchmark failed: "+err.Error())
return 1
}
// The dry-run path records a zero-iteration run without a binary, so the
// manifest loads and the report path is exercised in CI without a model.
if options.DryRun {
_, _ = fmt.Fprintln(stdout, "[zero] turn benchmark: dry run (no agent invoked)")
return 0
}
binary, err := perfbench.ResolveBinary(options.Binary)
if err != nil {
_, _ = fmt.Fprintln(stderr, "[zero] Turn benchmark failed: "+err.Error())
return 2
}
result, err := perfbench.RunTurnBench(context.Background(), set, perfbench.TurnBenchConfig{
Model: options.Model,
Mode: options.Mode,
ExecProfile: options.ExecProfile,
SelfCorrect: options.SelfCorrect,
Version: options.Version,
Commit: options.Commit,
Iterations: options.Iterations,
Runner: perfbench.NewTurnExecRunner(binary),
})
if err != nil {
_, _ = fmt.Fprintln(stderr, "[zero] Turn benchmark failed: "+err.Error())
return 1
}
if options.Output != "" {
if err := writeTurnReport(options.Output, result); err != nil {
_, _ = fmt.Fprintln(stderr, "[zero] Turn benchmark failed: "+err.Error())
return 1
}
}
if options.JSON {
if err := perfbench.WriteTurnBenchJSON(stdout, result); err != nil {
_, _ = fmt.Fprintln(stderr, "[zero] Turn benchmark failed: "+err.Error())
return 1
}
return turnExitCode(result, stderr)
}
_, _ = fmt.Fprintln(stdout, perfbench.FormatTurnBenchSummary(result))
return turnExitCode(result, stderr)
}
// turnExitCode fails the command when every attempted task errored (no
// iteration produced an accepted benchmark sample): such a report measures
// nothing, and exiting 0 would let a broken configuration (missing binary, bad
// path, failing harness step) pass as a clean baseline. Partial errors keep
// exit 0 — the summary surfaces them loudly and the surviving samples are
// still valid measurements.
func turnExitCode(result perfbench.TurnBenchResult, stderr io.Writer) int {
if result.TasksAttempted > 0 && result.TasksErrored == result.TasksAttempted {
_, _ = fmt.Fprintln(stderr, "[zero] Turn benchmark failed: every task errored with no accepted benchmark sample (see warnings); the report contains no valid measurements")
return 1
}
return 0
}
func parseTurnArgs(args []string, getenv func(string) string) (turnOptions, error) {
options := turnOptions{
Iterations: 1,
Version: strings.TrimSpace(getenv("ZERO_BENCH_VERSION")),
Commit: strings.TrimSpace(getenv("ZERO_BENCH_COMMIT")),
}
for index := 0; index < len(args); index++ {
arg := args[index]
flag, inlineValue := splitFlagValue(arg)
switch flag {
case "--suite":
value, next, err := readOptionValue(args, inlineValue, index, flag)
if err != nil {
return options, err
}
options.SuitePath = value
index = next
case "--model":
value, next, err := readOptionValue(args, inlineValue, index, flag)
if err != nil {
return options, err
}
options.Model = value
index = next
case "--mode":
value, next, err := readOptionValue(args, inlineValue, index, flag)
if err != nil {
return options, err
}
options.Mode = value
index = next
case "--exec-profile":
value, next, err := readOptionValue(args, inlineValue, index, flag)
if err != nil {
return options, err
}
options.ExecProfile = value
index = next
case "--binary":
value, next, err := readOptionValue(args, inlineValue, index, flag)
if err != nil {
return options, err
}
options.Binary = value
index = next
case "--iterations":
value, next, err := readOptionValue(args, inlineValue, index, flag)
if err != nil {
return options, err
}
parsed, err := parsePositiveInteger(flag, value)
if err != nil {
return options, err
}
options.Iterations = parsed
index = next
case "--version":
value, next, err := readOptionValue(args, inlineValue, index, flag)
if err != nil {
return options, err
}
options.Version = value
index = next
case "--commit":
value, next, err := readOptionValue(args, inlineValue, index, flag)
if err != nil {
return options, err
}
options.Commit = value
index = next
case "--output":
value, next, err := readOptionValue(args, inlineValue, index, flag)
if err != nil {
return options, err
}
options.Output = value
index = next
case "--self-correct":
if strings.Contains(arg, "=") {
return options, fmt.Errorf("%s does not accept a value", flag)
}
options.SelfCorrect = true
case "--json":
if strings.Contains(arg, "=") {
return options, fmt.Errorf("%s does not accept a value", flag)
}
options.JSON = true
case "--dry-run":
if strings.Contains(arg, "=") {
return options, fmt.Errorf("%s does not accept a value", flag)
}
options.DryRun = true
case "-h", "--help":
if strings.Contains(arg, "=") {
return options, fmt.Errorf("%s does not accept a value", flag)
}
options.Help = true
default:
return options, fmt.Errorf("unknown option: %s", arg)
}
}
if options.Help {
return options, nil
}
if strings.TrimSpace(options.SuitePath) == "" {
return options, fmt.Errorf("--suite is required")
}
if strings.TrimSpace(options.Model) == "" && !options.DryRun {
return options, fmt.Errorf("--model is required (or pass --dry-run)")
}
// Validate the profile here, before anything spawns: an unknown name would
// otherwise make every child exit with a usage error in milliseconds, and
// those near-zero walls would be recorded as valid latency samples in an
// exit-0 report — exactly the misread-as-improvement trap the harness
// closed for spawn failures. Normalizing to the catalog name also keeps
// the stamped execProfile canonical (Lookup is case-insensitive), so two
// captures of the same posture always compare equal.
if raw := strings.TrimSpace(options.ExecProfile); raw != "" {
profile, ok := execprofile.Lookup(raw)
if !ok {
return options, fmt.Errorf("unknown execution profile %q for --exec-profile. Valid profiles: %s", raw, strings.Join(execprofile.Names(), ", "))
}
options.ExecProfile = profile.Name
}
return options, nil
}
func writeTurnReport(path string, result perfbench.TurnBenchResult) error {
if dir := filepath.Dir(path); dir != "" {
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
}
var buffer bytes.Buffer
if err := perfbench.WriteTurnBenchJSON(&buffer, result); err != nil {
return err
}
return os.WriteFile(path, buffer.Bytes(), 0o644)
}
func turnHelpText() string {
return strings.Join([]string{
"Usage: zero-perf-bench turn [options]",
"",
"Runs ZERO headlessly with --trace against a per-turn benchmark task set and",
"records per-span latency plus the top controllable latency sources (the",
"Phase 0 baseline's \"do not proceed until\" criterion). Each task is a fresh",
"`zero exec` process, so iterations are cold-start samples; a warm path needs",
"an in-process runner (future work).",
"",
"Options:",
" --suite <path> Task set JSON file (required)",
" --model <model> Model to run (required unless --dry-run)",
" --mode <name> Exec mode preset to apply",
" --exec-profile <name>",
" Execution profile for every task (fast|balanced|thorough);",
" forwarded to zero exec and stamped into the result",
" --self-correct Enable the post-edit verify-and-correct loop",
" --binary <path> Path to the `zero` binary (default: zero on PATH / repo root)",
" --iterations <n> Times to run each task (default: 1)",
" --version <v> Record the ZERO version (default: $ZERO_BENCH_VERSION)",
" --commit <sha> Record the ZERO commit (default: $ZERO_BENCH_COMMIT)",
" --output <path> Write the JSON result to path",
" --json Print only the JSON result",
" --dry-run Load the manifest and exit without invoking the agent",
" -h, --help Show this help",
}, "\n") + "\n"
}
+65
View File
@@ -0,0 +1,65 @@
package main
import (
"strings"
"testing"
)
func TestParseTurnArgsExecProfile(t *testing.T) {
getenv := func(string) string { return "" }
options, err := parseTurnArgs([]string{
"--suite", "suite.json",
"--model", "m",
"--exec-profile", "fast",
}, getenv)
if err != nil {
t.Fatalf("parseTurnArgs: %v", err)
}
if options.ExecProfile != "fast" {
t.Fatalf("ExecProfile = %q, want fast", options.ExecProfile)
}
// Inline form.
options, err = parseTurnArgs([]string{
"--suite=suite.json",
"--model=m",
"--exec-profile=thorough",
}, getenv)
if err != nil {
t.Fatalf("parseTurnArgs inline: %v", err)
}
if options.ExecProfile != "thorough" {
t.Fatalf("ExecProfile = %q, want thorough", options.ExecProfile)
}
}
// The bench validates the profile BEFORE spawning anything: an unknown name
// would otherwise make every child exit with a usage error in milliseconds
// and record those near-zero walls as valid latency samples in an exit-0
// report. Lookup normalization also keeps the stamped name canonical so two
// captures of the same posture compare equal.
func TestParseTurnArgsExecProfileValidatesAndNormalizes(t *testing.T) {
getenv := func(string) string { return "" }
options, err := parseTurnArgs([]string{
"--suite", "suite.json", "--model", "m", "--exec-profile", "FAST",
}, getenv)
if err != nil {
t.Fatalf("parseTurnArgs: %v", err)
}
if options.ExecProfile != "fast" {
t.Fatalf("ExecProfile = %q, want the canonical fast", options.ExecProfile)
}
_, err = parseTurnArgs([]string{
"--suite", "suite.json", "--model", "m", "--exec-profile", "blanced",
}, getenv)
if err == nil {
t.Fatal("an unknown profile must fail parse, before anything spawns")
}
for _, name := range []string{"balanced", "fast", "thorough"} {
if !strings.Contains(err.Error(), name) {
t.Fatalf("error must list %q, got %q", name, err.Error())
}
}
}
+45 -24
View File
@@ -1,14 +1,8 @@
---
description: Extending Zero — how to write AGENTS.md files, custom specialists, skills, hooks, MCP servers, and plugins for the open-source CLI coding agent.
globs: "*.go, *.js, *.md, *.json, *.toml, *.yaml, *.yml"
alwaysApply: false
---
# Extending Zero
Zero is an open-source terminal coding agent. Out of the box it does the obvious things — read, edit, run, search — but the design point of the project is that **every surface is configurable**. This document is the user-facing guide for that configuration.
If you only want to *use* Zero, the [README](README.md) is enough. This page is for the other three jobs:
If you only want to *use* Zero, the [README](../README.md) is enough. This page is for the other three jobs:
1. Tell the agent about *your* project (drop an `AGENTS.md` in your repo).
2. Add new specialist sub-agents.
@@ -26,7 +20,7 @@ Accepted file names, in priority order at each level:
| `./ZERO.md` | Brand-specific alias. Same format, lower priority. |
| `./.zero/AGENTS.md` | Project-local, hidden, gitignored. Personal notes that stay out of git. |
Matching is **case-insensitive** on the basename, so `AGENTS.md`, `Agents.md`, and `agents.md` resolve to the same file on Windows and macOS. The git-tracked filename in this repo is `AGENTS.MD` (uppercase `MD`) — keep that on case-sensitive filesystems (Linux, the WSL filesystem, or a CI runner) to match what the loader looks for.
Matching is **case-insensitive** on the basename, so `AGENTS.md`, `Agents.md`, and `agents.md` resolve to the same file on Windows and macOS. The git-tracked filename in this repo is `AGENTS.md` — keep that on case-sensitive filesystems (Linux, the WSL filesystem, or a CI runner) to match what the loader looks for.
Both files use the same format. YAML frontmatter is optional; the markdown body is loaded as instructions for the agent. Zero reads the file once at session start, so changes take effect on the next `zero` launch — not mid-session.
@@ -47,6 +41,12 @@ Tips:
- In a monorepo, drop a narrower `AGENTS.md` in each sub-tree (e.g. `services/api/AGENTS.md`). Zero picks those up automatically when you launch from inside the sub-tree.
- A YAML frontmatter block (`---\n...\n---`) at the top is preserved verbatim in the injected prompt but is not parsed for `globs:` or `alwaysApply:` scoping today — keep the body self-contained.
### Personal guidelines, across every project
For preferences that follow *you*, not a specific repo (tone, tooling habits, workflow), drop a `ZERO.md` in your user config directory: `~/.config/zero/ZERO.md` on Linux/macOS, `%AppData%\zero\ZERO.md` on Windows — the same directory as `config.json` and your personal specialists. Same format and 8 KiB cap as the project files above, and the same case-insensitive basename match.
This file is injected as its own `## User guidelines` section, before the project's `AGENTS.md`/`ZERO.md`, and is labeled as personal preference in the prompt: project guidelines are the later, more specific instruction and take precedence over it when the two conflict.
## 2. Custom specialists
Specialists are Zero's sub-agents. Three scopes, in priority order:
@@ -94,22 +94,32 @@ zero specialist delete api-reviewer --project
zero specialist path # prints the resolved specialists directory
```
The full format spec (frontmatter fields, tool scopes, prompt conventions) is in [`docs/SPECIALISTS.md`](docs/SPECIALISTS.md).
The full format spec (frontmatter fields, tool scopes, prompt conventions) is in [`docs/SPECIALISTS.md`](SPECIALISTS.md).
> **Roadmap.** An in-UI specialist manager (create / edit / delete / preview) is on the backlog. Today you use the `zero specialist` CLI subcommands above.
## 3. Skills
Skills are markdown instruction packs the agent can pull in on demand. Each skill is a directory containing a `SKILL.md`. Skills are **user-level only** in this version — there's no project-scoped skill directory yet, so anything you want shared with the team goes in `AGENTS.md` (section 1) or as a hook (section 4).
Skills are markdown instruction packs the agent can pull in on demand. Each skill is a directory containing a `SKILL.md`. Standalone project skill directories are not supported in this version (shared project-wide skills must go in `AGENTS.md` or as a hook). However, project plugins (section 6) may bundle skills, which are merged into the active run.
Discovery root: `$ZERO_SKILLS_DIR` → `$XDG_DATA_HOME/zero/skills` → `~/.local/share/zero/skills/`. A missing directory is fine — Zero just reports "no skills".
Discovery roots (earlier wins on name collisions):
```
~/.local/share/zero/skills/
1. **Primary Zero dir** — `$ZERO_SKILLS_DIR` if set, else `$XDG_DATA_HOME/zero/skills`, else `~/.local/share/zero/skills/`
2. **Shared multi-agent dir** — `~/.agents/skills/` when present (read-only discovery; never an install target)
3. **Plugin skill roots** — skills bundled by active plugins (section 6)
A missing directory is fine — Zero just omits it. Management commands (`zero skills add` / `remove` / `lock`) always write to the primary Zero dir only; `list` / `info` search primary + `~/.agents/skills`.
```text
~/.local/share/zero/skills/ # primary (install/remove/lock target)
run-benchmarks/
SKILL.md
write-changelog/
SKILL.md
~/.agents/skills/ # optional shared multi-agent root
shared-review/
SKILL.md
```
`SKILL.md` format:
@@ -126,9 +136,9 @@ description: Run the project's benchmark suite and summarize the deltas.
3. Report any regression > 5% with the function name and the previous value.
```
Only `name` and `description` are recognized in the frontmatter today. The `name` defaults to the directory name. If two skills declare the same name, the one in the lexicographically-first directory wins — duplicates are dropped silently. Plugin-declared skills (section 6) are not yet merged into the loader, so a `skills:` entry inside a plugin's `plugin.json` is not visible to the `skill` tool today.
Only `name` and `description` are recognized in the frontmatter today. The `name` defaults to the directory name. Within a single skills root, duplicate names are resolved by lexicographic directory order. Across roots, Zero loads the primary directory first, then `~/.agents/skills`, then plugin skill roots; earlier roots win name collisions. Plugin-declared skills (section 6) are merged into the active agent run at plugin activation time, so bundled skills appear in the available skills list and can be loaded with the `skill` tool.
The `skill` core tool lets the agent load any discovered skill by name.
The `skill` tool lets the agent load any discovered skill by name (primary, agents, or plugin).
## 4. Hooks
@@ -229,14 +239,24 @@ Servers are merged from user and project configs (project wins on conflicts). To
- A `--header` value produced by command substitution (`"Authorization=Bearer $(print-token)"`) in a private shell config that you keep out of git.
- A secret manager that injects the env var your MCP server reads on its own (the `command` and `args` then run inside that environment).
Project MCP config remains supported for shared team servers, but a project layer
that changes an existing server's target (`url`, `command`, or `args`) does not
inherit user credentials. Clear or replace `headers`, `env`, or `oauth` explicitly,
or use a new server name. OAuth tokens are bound to the resolved server identity,
so changing a server target can require `zero mcp oauth login <server>` again.
OAuth server names ending in `.<32 hex chars>` are reserved for token storage.
### As a server — expose Zero's tools to another agent
```bash
zero serve --mcp
zero serve --mcp -C /path/to/workspace
zero serve --mcp --add-dir /path/to/extra
```
The server speaks MCP over stdio. Configure it from the receiving side as a `stdio` server whose command is `zero serve --mcp`.
`-C/--cwd` sets the workspace root exposed as MCP resources and used by core tools. `--add-dir` (repeatable) widens that resource/tool scope beyond the workspace without granting the sandbox temp roots used by interactive runs.
## 6. Plugins
A plugin is a self-contained directory that bundles tools, hooks, and skills for one capability. Plugins live at:
@@ -276,19 +296,19 @@ A plugin is enabled by being present in the plugins directory and disabled by re
Plugin commands run with the plugin directory as their working directory. Use relative paths; the loader resolves them at activation time.
> **Roadmap.** An in-UI plugins manager (browse, install, enable / disable) is on the backlog. Today you use the `zero plugins` CLI subcommands above. Skills declared inside a plugin's `plugin.json` are not yet merged into the `skill` tool's discovery (see section 3).
> **Roadmap.** An in-UI plugins manager (browse, install, enable / disable) is on the backlog. Today you use the `zero plugins` CLI subcommands above.
## 7. Configuration locations
Three layers, applied in order (later layers override earlier ones):
Five configuration sources, in precedence order (later sources override earlier ones):
| Layer | Path | Notes |
| Source | Path / Key | Notes |
| --- | --- | --- |
| Built-in defaults | compiled in | Lowest priority. |
| User config | `~/.config/zero/config.json` | Your machine. Never committed. |
| Project config | `./.zero/config.json` | The repo. Committed (or not, your call). |
| CLI flags | `--model`, `--mode`, ... | Highest priority, per-invocation. |
| Environment | `ZERO_*` | Provider commands, secrets, skills dir override. |
| CLI flags | `--model`, `--mode`, ... | Highest priority, per-invocation. |
The user config holds things that should follow the user across projects (default provider, default model, theme). The project config holds things the team agreed on (provider catalog, sandbox policies, model restrictions).
@@ -301,19 +321,20 @@ A team that wants every contributor's Zero to behave the same way commits:
- `AGENTS.md` — project conventions, build commands, do-not-edit lists.
- `.zero/config.json` — provider catalog, default model, allowed tools.
- `.zero/specialists/api-reviewer.md` — the team's PR-review specialist.
- `.zero/hooks.json` — block `rm -rf` and `git push --force` on `beforeTool`.
- `.zero/hooks.json` — block `rm -rf` on `beforeTool`.
- `.zero/plugins/internal-tooling/` — a plugin that adds the team's internal CLI tools to the agent's toolset.
Each contributor adds only:
- `~/.config/zero/config.json` — their personal API keys, theme, default mode.
- `~/.config/zero/ZERO.md` — personal preferences that follow them across every project (see section 1).
- `~/.local/share/zero/skills/` — personal skills they keep across projects.
That's it. Run `zero` from the repo root and the agent has the team's full instruction set, every contributor's personal setup, and nothing else.
## 9. Reference
- [README](README.md) — install, quickstart, command reference.
- [docs/SPECIALISTS.md](docs/SPECIALISTS.md) — full specialist manifest spec.
- [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.
- [README](../README.md) — install, quickstart, command reference.
- [docs/SPECIALISTS.md](SPECIALISTS.md) — full specialist manifest spec.
- [docs/STREAM_JSON_PROTOCOL.md](STREAM_JSON_PROTOCOL.md) — `zero exec` I/O contract.
- [docs/INSTALL.md](INSTALL.md) — install from source or release.
+9 -7
View File
@@ -41,8 +41,9 @@ jobs:
> provider-neutral: `api-key-env` is the environment variable name the chosen
> provider reads its key from (for example `ANTHROPIC_API_KEY`, `GEMINI_API_KEY`,
> `OPENROUTER_API_KEY`), and `api-key` is the secret value. The action exports
> that variable for the run and never prints it. If your repository already
> commits a `.zero/config.json` with an active provider, you can omit `provider`.
> that variable only for the ZERO step and never prints it. If your repository
> already commits a `.zero/config.json` with an active provider, you can omit
> `provider`.
## Inputs
@@ -51,8 +52,8 @@ jobs:
| `prompt` | one of `prompt`/`prompt-file` | `""` | The instruction for ZERO to execute. |
| `prompt-file` | one of `prompt`/`prompt-file` | `""` | Path (relative to `working-directory`) to a file whose contents are the prompt. |
| `provider` | no | `""` | Provider id to activate (e.g. `openai`, `anthropic`, `gemini`, `ollama`, or any compatible endpoint). |
| `api-key` | no | `""` | The provider API key. Pass from a secret; never logged. |
| `api-key-env` | no | `""` | Env var name the provider reads its key from (e.g. `OPENAI_API_KEY`). Exported with `api-key`. |
| `api-key` | no | `""` | The provider API key. Pass from a secret; exported only for the ZERO step and never logged. |
| `api-key-env` | no | `""` | Env var name the provider reads its key from (e.g. `OPENAI_API_KEY`). Exported only for the ZERO step when used with `api-key`. |
| `model` | no | `""` | Model id. Defaults to the resolved provider's default. |
| `mode` | no | `""` | Run mode (`zero exec --mode`), e.g. `smart`, `deep`, `fast`. |
| `auto` | no | `low` | Autonomy ceiling (`zero exec --auto`): `low`, `medium`, or `high`. Conservative by default. |
@@ -162,9 +163,10 @@ jobs:
## Security notes
- **Secrets are passed as Action secrets and never logged.** The action exports
`api-key` (under `api-key-env`) and `slack-webhook-url` into the step
environment only. They are never echoed, and ZERO redacts secret-shaped
strings from its own output.
`api-key` (under `api-key-env`) only for the ZERO step. `slack-webhook-url`
is passed only to the Slack post step. They are never echoed or persisted to
later workflow steps, and ZERO redacts secret-shaped strings from its own
output.
- **The sandbox is always active.** This action never passes
`--skip-permissions-unsafe`, so writes stay inside the checked-out repository
(plus any roots you grant with `add-dir`). Unsafe mode is never enabled
+801
View File
@@ -0,0 +1,801 @@
# How Zero Works
This document explains Zero's architecture at a high level: the layers, module
boundaries, data flow, and runtime concepts behind the interactive TUI and the
headless `zero exec` mode.
It is written as a progressive map: each high-level section introduces the
concepts you need for orientation, then points to the later section that explains
the mechanics in more detail.
## Big Picture
Zero is a Go CLI application with two main user-facing surfaces:
- **Interactive TUI** (`zero`): a Bubble Tea application that renders chat,
tool cards, permission prompts, model/provider pickers, and session controls.
- **Headless exec** (`zero exec ...`): a scriptable command runner that uses the
same agent loop and tools, but writes text, JSON, or stream-JSON events.
Both surfaces converge on the same core agent runtime:
```mermaid
flowchart TD
User[User / automation] --> CLI[cmd/zero -> internal/cli]
CLI --> TUI[Interactive TUI<br/>internal/tui]
CLI --> Exec[Headless exec<br/>internal/cli/exec.go]
CLI --> ACP[ACP / editor bridge<br/>internal/acp]
TUI --> Agent[Agent loop<br/>internal/agent]
Exec --> Agent
ACP --> Agent
Agent --> Provider[Provider runtime<br/>internal/zeroruntime.Provider]
Provider --> APIs[OpenAI / Anthropic / Gemini / compatible APIs]
Agent --> Registry[Tool registry<br/>internal/tools.Registry]
Registry --> CoreTools[Core tools<br/>read/edit/shell/search/plan]
Registry --> MCP[MCP tools<br/>internal/mcp]
Registry --> Specialists[Specialist tools<br/>internal/specialist]
Registry --> Plugins[Plugin tools<br/>internal/plugins]
Registry --> LocalControl[Browser / terminal / desktop helpers]
Agent --> Sandbox[Permission + sandbox engine<br/>internal/sandbox]
Agent --> Hooks[Hooks dispatcher<br/>internal/hooks]
Agent --> Sessions[Session event store<br/>internal/sessions]
```
The important split is:
- `internal/tui` is the **presentation layer**. For the mechanics, see
[Presentation Layer: Bubble Tea TUI](#presentation-layer-bubble-tea-tui) and
[How the TUI and Agent Loop Connect](#how-the-tui-and-agent-loop-connect).
- `internal/agent` is the **reasoning/tool loop**. For the per-turn mechanics,
see [Agent Loop](#agent-loop), [Tool Execution Lifecycle](#tool-execution-lifecycle),
and [Context Budget and Compaction](#context-budget-and-compaction).
- `internal/tools`, `internal/sandbox`, and provider packages are the **runtime
capability layer**. For the details, see [Runtime Provider Layer](#runtime-provider-layer),
[Tools and Capabilities](#tools-and-capabilities), and
[Permission and Sandbox Model](#permission-and-sandbox-model).
- `internal/sessions` is the **durable event log** used for resume, fork,
rewind, sub-agents, and history. See [Session Storage and History](#session-storage-and-history).
## Architecture Spine
The easiest way to understand Zero is not as a set of independent packages, but
as one spine with several mechanisms attached to it:
```mermaid
flowchart LR
Surface[Surface<br/>TUI / exec / ACP] --> Run[Run request<br/>prompt + runtime options]
Run --> Agent[Agent loop<br/>turns, tools, recovery]
Agent --> Capabilities[Capabilities<br/>providers + tools + sandbox]
Capabilities --> Events[Runtime events<br/>text, tool calls, permissions, usage]
Events --> Surface
Events --> Sessions[Session log<br/>resume, fork, rewind, history]
Config[Config / guidelines / skills] --> Run
Extensions[MCP / specialists / plugins / hooks] --> Capabilities
```
The **TUI + agent loop** is the main story because it explains the normal user
experience: type a prompt, watch the model stream, approve tool use, see file
changes, and receive a final answer. Other mechanisms are still important, but
most of them attach to the same spine:
| Mechanism | Where it attaches | What it changes |
| --- | --- | --- |
| Config, provider profiles, model registry | Before a run starts | Which provider/model is used and what defaults apply. |
| Project/user guidelines, skills, specialists context | Prompt construction | What instructions and available capabilities the model sees. |
| Tool registry, MCP, plugins, local-control helpers | Capability layer | Which actions the model may request. |
| Permission policy, sandbox, command-prefix grants | Tool execution | Whether a requested action can run, needs approval, or is denied. |
| Hooks | Tool lifecycle | Optional before/after observers or blockers around tool calls. |
| Sessions, checkpoints, compaction metadata | Event persistence/context | What can be resumed, forked, rewound, or summarized for later turns. |
| Stream-JSON | Surface layer | How the same runtime events are emitted for automation instead of rendered in the TUI. |
So this document focuses first on the TUI/agent-loop bridge, then explains the
supporting mechanisms in terms of where they connect to that bridge.
Use this as the reading path:
| If you see this concept in a high-level flow | Read this detail section |
| --- | --- |
| Surface submits a run | [Startup Flow](#startup-flow), then [How the TUI and Agent Loop Connect](#how-the-tui-and-agent-loop-connect) |
| Agent builds context and advances turns | [Agent Loop](#agent-loop) |
| “Maybe compact context” | [Context Budget and Compaction](#context-budget-and-compaction) |
| Model requests a tool | [Tool Execution Lifecycle](#tool-execution-lifecycle), then [Tools and Capabilities](#tools-and-capabilities) |
| Tool needs approval or sandbox access | [Permission and Sandbox Model](#permission-and-sandbox-model) |
| Events appear in the UI or stream-JSON | [How the TUI and Agent Loop Connect](#how-the-tui-and-agent-loop-connect), [Stream-JSON Protocol](#stream-json-protocol) |
| Resume, fork, rewind, or sub-agent history | [Session Storage and History](#session-storage-and-history) |
## Main Packages
The sections above describe the architecture in concepts. If you want to connect
those concepts to implementation details, use this table as the code map before
reading the deeper flows below.
| Package | Role |
| --- | --- |
| `cmd/zero` | Tiny binary entrypoint; calls `internal/cli.Run`. |
| `internal/cli` | Command parsing, config resolution, provider creation, registry setup, TUI/exec/ACP launch paths. |
| `internal/tui` | Bubble Tea model/update/view code, transcript rendering, composer, prompts, slash commands, setup wizard. |
| `internal/agent` | Core loop that sends messages to the model, streams output, executes tools, handles permissions, compaction, retries, and final answers. |
| `internal/zeroruntime` | Provider-neutral message, tool-call, stream, usage, and image types. Providers implement `Provider.StreamCompletion`. |
| `internal/providers` | Factory that turns a resolved provider profile into an OpenAI, Anthropic, Gemini, Codex, or compatible provider adapter. |
| `internal/tools` | Tool interface, registry, core read/write/shell/network tools, local-control wrappers, tool safety metadata, redaction, output ceilings. |
| `internal/sandbox` | Permission policy, path/network checks, grant store, command-prefix approvals, and platform sandbox backends. |
| `internal/sessions` | Local append-only session storage: `metadata.json` + `events.jsonl`. |
| `internal/config` | User/project config resolution, active provider, preferences, sandbox/tool settings. |
| `internal/mcp` | MCP server config, client runtime, permission store, and MCP tool registration. |
| `internal/specialist` / `internal/swarm` | Sub-agent manifests and team/member orchestration exposed as tools. |
| `internal/localcontrol` / `internal/browser` | Runtime helpers used by config-gated local-control tool wrappers. |
| `internal/plugins` / `internal/skills` / `internal/hooks` | Extension surfaces loaded into the agent context, tool registry, or tool lifecycle. |
| `internal/streamjson` | Machine-readable protocol for `zero exec --input-format/--output-format stream-json`. |
## Startup Flow
### Interactive TUI (`zero`)
```mermaid
sequenceDiagram
participant Main as cmd/zero
participant CLI as internal/cli
participant Config as internal/config
participant TUI as internal/tui
participant Agent as internal/agent
Main->>CLI: Run(args)
CLI->>Config: resolve config for workspace
CLI->>CLI: build provider
CLI->>CLI: build tool registry
CLI->>CLI: register core, config-gated local-control, specialists, MCP, plugins
CLI->>CLI: create sandbox engine + session store
CLI->>TUI: run Bubble Tea with Options
TUI->>TUI: Init / Update / View event loop
TUI->>Agent: agent.Run on submitted prompt
```
The TUI owns terminal interaction. It tracks composer state, transcript rows,
scrolling, permission dialogs, model/provider pickers, setup wizard state, and
visual updates. When the user submits a prompt, the TUI builds `agent.Options`
with callbacks like `OnText`, `OnToolCall`, `OnToolResult`, `OnPermission`,
`OnAskUser`, and `OnUsage`. Those callbacks convert runtime events into visible
transcript rows and session events.
### Headless exec (`zero exec`)
```mermaid
flowchart TD
A[Parse exec flags] --> B[Resolve config + provider]
B --> C[Build tool registry]
C --> D[Resolve prompt / stream-json input / images]
D --> E[Prepare or resume session]
E --> F[Create stream-json/text/json writer]
F --> G[agent.Run]
G --> H[Write final output and exit code]
```
`zero exec` uses the same `agent.Run` loop as the TUI, but without interactive
terminal state. It can persist sessions, resume/fork existing sessions, run in
stream-JSON mode for automation, and always requires a stronger completion
signal so a no-tool assistant response is not treated as success while work is
still pending.
## Agent Loop
The core loop lives in `internal/agent.Run`. Conceptually, every turn follows
this cycle:
```mermaid
flowchart TD
Start[Build system prompt + user prompt] --> Expose[Partition visible tools]
Expose --> Compact[Maybe compact context]
Compact --> Request[Send CompletionRequest to provider]
Request --> Stream[Collect streamed text, reasoning, usage, tool calls]
Stream --> HasTools{Tool calls?}
HasTools -- No --> FinalCheck{Final answer acceptable?}
FinalCheck -- Yes --> Done[Return Result]
FinalCheck -- No --> Nudge[Append user nudge and continue]
HasTools -- Yes --> Execute[Execute tool calls]
Execute --> Permission[Permission / sandbox / hooks]
Permission --> ToolResult[Append tool result messages]
ToolResult --> Diagnostics[Post-edit diagnostics / self-correct]
Diagnostics --> Expose
```
Key responsibilities of the loop:
1. **Build prompt state** — combines the system prompt, project/user guidelines,
loaded skills/specialist instructions, the current user prompt, and images. The
resulting message list is the model-facing context that later tool results are
appended to. Resumed session history is replayed by the caller before a new run,
not passed as a separate `agent.Run` history field.
2. **Expose tools** — starts from the registry built by the CLI, then filters
tool definitions by permission mode, enabled or disabled filters, and
deferred-loading rules. Tool schemas also count toward context budget, so they
matter for compaction decisions.
3. **Call provider** — sends a provider-neutral `zeroruntime.CompletionRequest`.
4. **Stream events** — forwards text, reasoning, tool-call deltas, and usage to
the caller via callbacks.
5. **Execute tools** — decodes tool arguments, checks filters, applies
permissions and sandbox policy, dispatches hooks, runs the registered tool,
redacts output, and appends a tool message. See
[Tool Execution Lifecycle](#tool-execution-lifecycle).
6. **Recover and guard** — handles malformed tool calls, repeated failures,
empty turns, context pressure, stream reconnect/retry, and max-turn fallback.
Context pressure is handled by [Context Budget and Compaction](#context-budget-and-compaction).
7. **Finalize** — returns a final answer or a structured incomplete/stop reason.
The model never directly mutates the workspace. It asks for tool calls; Zero
validates and executes those calls through the registry, permission policy, and
sandbox engine.
## Tool Execution Lifecycle
A tool call is part of the model conversation, not a side channel that bypasses
it. The model proposes an action, Zero decides whether that action is allowed to
run, the tool returns a structured result, and that result becomes context for the
next provider request.
```mermaid
sequenceDiagram
participant Model as LLM / provider
participant Agent as agent.Run
participant Policy as filters + permissions + sandbox
participant Hooks as hooks
participant Tool as tool implementation
participant Surface as TUI / exec callbacks
participant Transcript as model conversation + session events
Agent->>Model: request with messages + available tool schemas
Model-->>Agent: assistant text and/or tool_call(name, args)
Agent-->>Surface: stream text / tool-call preview
Agent->>Agent: collect and decode tool arguments
Agent->>Policy: check tool filters, permission mode, sandbox policy
alt denied before execution
Policy-->>Agent: denied result
Agent-->>Surface: tool failed/denied event
Agent->>Transcript: append tool result explaining denial
else needs user approval
Policy-->>Surface: permission request
Surface-->>Policy: allow / deny / remember decision
Policy-->>Agent: decision
end
alt approved to execute
Agent->>Hooks: beforeTool hooks
Hooks-->>Agent: allow / block / annotate
Agent->>Tool: execute(ctx, args)
Tool-->>Agent: output, error, metadata
Agent->>Hooks: afterTool hooks
Agent->>Agent: redact secrets, apply semantic output budget, enforce byte ceiling
Agent->>Agent: classify success/failure
Agent-->>Surface: OnToolResult callback
Agent->>Transcript: append tool result message
end
Agent->>Model: next request includes prior assistant tool_call + tool result
Model-->>Agent: continue with more tools or final answer
```
Conceptually, one requested tool goes through these phases:
1. **Proposal** — the provider streams assistant content and may emit one or more
tool calls. Zero can show the partial tool arguments in the TUI while they are
still streaming, but nothing has executed yet.
2. **Decode and gate** — the agent validates JSON arguments, checks whether the
tool is enabled, applies permission mode, asks the sandbox engine about path,
network, shell, or command-prefix constraints, and may invoke permission
callbacks.
3. **Execute** — the selected registry entry runs locally, through MCP, as a
specialist/sub-agent, or through a plugin/local-control helper. Hooks can run
before or after this step.
4. **Normalize result** — Zero turns the outcome into an agent `ToolResult`: a
success, failure, denial, cancellation, timeout, or validation error with
output/metadata that may be redacted or truncated.
5. **Append to context** — the result is appended as a tool-result message paired
with the assistant tool call. The next model request includes that result, so
the model can inspect it and decide what to do next.
6. **Render and persist** — the result is surfaced to the TUI, stream-JSON
writer, or ACP client. TUI and exec record rich tool/session events for
resume/history; ACP currently persists conversational messages and streams
tool/permission updates to the client.
Tool success and failure are both informative context. A successful `read_file`
result gives the model file contents; a failed `edit_file` result gives the model
an error such as “old string not found”; a denied shell command tells the model
that the action is not currently permitted. The model is expected to use that
feedback to continue, choose another tool, ask the user, or explain the blocker.
The judgement that “information is sufficient” is made by the model, not by the
TUI. Zero controls the loop mechanics: it keeps appending messages, enforces
safety, retries or nudges around malformed/empty/incomplete turns, and stops on
hard limits. But the semantic decision to call another tool versus produce a
final answer comes from the next provider response. In other words:
```mermaid
flowchart TD
Result[Tool result appended to conversation] --> Next[Next provider request]
Next --> Decide{Model response}
Decide -- More information/action needed --> MoreTools[Request another tool]
MoreTools --> Result
Decide -- User input needed --> AskUser[Call ask_user or explain question]
AskUser --> Result
Decide -- Enough information --> Final[Final assistant answer]
Decide -- Invalid / empty / unsafe --> Guard[Agent guardrail, retry, denial, or stop reason]
Guard --> Next
```
This is why tool output is usually more than UI decoration. It is part of the
working memory for the next model turn, subject to context-window pressure and
compaction. The TUI may summarize or prettify it for the user, but the agent loop
also stores the machine-facing result that the model sees.
## Context Budget and Compaction
The `Maybe compact context` step in the agent-loop diagram is Zero's context-window
pressure valve. It is not a separate memory engine; it rewrites the current
model-facing conversation so the next provider request fits within the configured
context window.
Compaction is enabled only when the run knows a positive `ContextWindow`. When it
is enabled, the loop does this near the top of each turn:
```mermaid
flowchart TD
Tools[Partition visible tools] --> Estimate[Estimate messages + tool-definition tokens]
Estimate --> Under{Under threshold?}
Under -- Yes --> Keep[Keep context as-is]
Under -- No --> Grown{Grown past low-water mark?}
Grown -- No --> Keep
Grown -- Yes --> Prune[Prune stale large tool outputs]
Prune --> Recheck{Back under threshold?}
Recheck -- Yes --> KeepPruned[Use pruned context]
Recheck -- No --> Summarize[Summarize oldest middle]
Summarize --> Replace[system messages + summary + recent suffix]
```
The trigger is intentionally approximate:
- Zero estimates request size using message text, images, tool-call arguments, and
the tool definitions advertised on that turn.
- The proactive threshold is roughly **two-thirds of the context window**.
- Provider usage from completed turns is used to calibrate the rough estimate over
time, so the budget gauge and compaction decision track the provider more
closely after a few turns.
- A low-water mark prevents the loop from re-summarizing the same already-compacted
history on every turn.
When compaction actually runs, it tries to preserve the parts most likely to be
needed by the model:
- leading system messages stay verbatim;
- the most recent turns stay verbatim (`CompactionPreserveLast`, defaulting to the
agent's normal preserve count);
- the preserved suffix is widened to a provider-valid turn boundary so tool-result
messages are not replayed without their corresponding assistant tool call;
- the older middle is summarized into one user-role message labeled
`[Summary of earlier conversation]`;
- structured state that should not be paraphrased away, such as active plan/loaded
skill state, is preserved alongside the prose summary.
There are two compaction paths:
1. **Proactive compaction** runs before the provider call when the estimated
request is over threshold.
2. **Reactive compaction** runs after a provider returns a context-limit style
error. Zero compacts once and retries that same turn; if the provider still
rejects the request, the error is surfaced rather than looping forever.
Compaction failures are conservative. If the summarizer fails during proactive
compaction, Zero keeps the original messages and tries to continue. It does not
drop conversation history just to make room.
## Presentation Layer: Bubble Tea TUI
`internal/tui` is a Bubble Tea application. The usual Bubble Tea shape applies:
```mermaid
flowchart LR
Init[model.Init] --> Update[model.Update]
Update --> View[model.View]
View --> Terminal[Terminal frame]
Terminal --> Msg[tea.Msg: key, mouse, window, agent event]
Msg --> Update
```
The TUI is intentionally not the agent brain. It is responsible for:
- collecting user input from the composer;
- rendering assistant text, reasoning, tool cards, permissions, plans, usage,
files, and sidebars;
- handling slash commands and modal surfaces;
- converting agent callbacks into Bubble Tea messages;
- persisting visible session events;
- allowing live provider/model/permission changes.
When a prompt is submitted, the TUI starts a background command that invokes
`agent.Run`. Streaming callbacks send messages back into the Bubble Tea update
loop, so rendering remains responsive while the agent is working.
## How the TUI and Agent Loop Connect
A common source of confusion is that Zero has two event loops running at once:
1. **Bubble Tea loop** — owns terminal input, in-memory UI state, and frames.
2. **Agent loop** — owns model turns, tool execution, permissions, retries, and
stop conditions.
They are connected by `tea.Cmd` and `agent.Options` callbacks. The TUI never
runs provider/tool logic directly inside `Update`; instead, submit handling gates
and dispatches a prompt, the TUI starts a background command, and that command
calls `agent.Run` with callbacks that send runtime events back to the Bubble Tea
program. Function names in this section are illustrative code anchors, not API
boundaries.
```mermaid
sequenceDiagram
participant User
participant TUI as Bubble Tea model<br/>internal/tui
participant Cmd as tea.Cmd goroutine<br/>runAgentWithOptions
participant Agent as agent.Run<br/>internal/agent
participant Provider as Provider.StreamCompletion
participant Tools as Tool registry + sandbox
participant Store as Session store
User->>TUI: type prompt / press Enter
TUI->>TUI: validate/gate submit and append visible user row
TUI->>Cmd: launch tea.Cmd with runID + cancellable context
Cmd->>Agent: agent.Run(prompt, provider, options)
Agent->>Provider: stream completion request
Provider-->>Agent: text / reasoning / tool-call deltas
Agent-->>Cmd: OnText / OnReasoning / OnToolCallStart callbacks
Cmd-->>TUI: runtimeMessageSink(tea.Msg)
TUI->>TUI: Update appends/updates transcript rows
TUI-->>User: View renders new frame
Agent->>Tools: execute collected tool call
Tools->>Tools: permission, sandbox, hooks, redaction
Tools-->>Agent: tool result
Agent-->>Cmd: OnToolCall / OnToolResult / OnPermission / OnUsage
Cmd->>Store: batch session events for resume/history
Cmd-->>TUI: final agentResponseMsg
TUI->>TUI: mark run complete, flush rows/events, unlock composer
```
In the TUI path, the same event can have three representations:
| Runtime thing | Agent representation | TUI representation | Session representation |
| --- | --- | --- | --- |
| Assistant text | streamed provider text forwarded through `OnText` | incremental assistant row text | assistant message event |
| Reasoning preview | `OnReasoning` callback | collapsible/preview reasoning row | usually not the same as final assistant text |
| Tool call | `agent.ToolCall` from collected provider stream | tool card, specialist card, or plan panel update | `EventToolCall` |
| Tool result | `agent.ToolResult` | result card/status, changed files, plan/specialist updates | `EventToolResult` |
| Permission prompt/audit | `PermissionRequest` / `PermissionEvent` | modal prompt or permission row | permission request/decision events |
| Usage/context | provider usage + measured context | context gauge / usage metadata | usage event |
### One interactive turn, end to end
For a normal prompt in the interactive TUI:
1. Submit handling validates composer state and command gates, then dispatches
the prompt to the launch path.
2. The launch path appends immediate UI rows, creates a cancellable run context,
and starts `runAgent` as a `tea.Cmd`; run bookkeeping assigns the `runID` used
to correlate later events.
3. `runAgentWithOptions` copies the current UI-controlled runtime choices into
`agent.Options`: provider/model labels, permission mode, cwd, session ID,
images, registry, response style, context window, and self-correction hooks.
4. The command wraps agent callbacks. For example:
- `OnText` sends incremental assistant text into the TUI message sink.
- `OnToolCallStart` / `OnToolCallDelta` show live tool-argument streaming.
- `OnToolCall` creates visible tool rows and appends session events.
- `OnToolResult` creates result rows, updates plan/specialist side panels, and
records changed files.
- `OnPermissionRequest` sends a prompt to the TUI and blocks the agent loop
until the user answers or the run context is cancelled.
- `OnAskUser` does the same for model-initiated clarification questions.
5. `agent.Run` builds provider-neutral messages and tool definitions, streams a
provider response, executes any requested tools through the registry/sandbox,
appends tool results back into the model conversation, and repeats until it has
a final answer or a stop condition.
6. When `agent.Run` returns, the command emits `agentResponseMsg`. The Bubble Tea
loop marks the run complete, flushes queued rows/session events, updates usage
and file state, and re-enables the composer.
The important architectural point: **the TUI is stateful and reactive, but the
agent loop is the source of runtime truth for model/tool progress**. Presentation
state is derived from callback events and persisted session events; workspace
changes happen only through tools executed by the agent runtime.
### Feature flows use the same bridge
For one interactive turn, the supporting systems do not run beside the TUI/agent
loop; they run inside or around it:
```mermaid
flowchart TD
Submit[TUI submit] --> Options[Build agent.Options]
Options --> Context[Assemble context<br/>config, guidelines, history, skills]
Context --> ProviderCall[Provider stream]
ProviderCall --> StreamEvents[Text/reasoning/tool-call events]
StreamEvents --> Render[TUI render callbacks]
ProviderCall --> ToolCall{Tool call?}
ToolCall -- No --> Final[Final response]
ToolCall -- Yes --> ToolGate[Tool gate<br/>filters, permissions, sandbox]
ToolGate --> HooksBefore[beforeTool hooks]
HooksBefore --> Execute[Tool / MCP / specialist / plugin]
Execute --> HooksAfter[afterTool hooks]
HooksAfter --> ToolResult[Tool result message]
ToolResult --> Persist[Session events]
ToolResult --> Context
Final --> Persist
Persist --> Resume[Resume / fork / rewind / parent metadata]
```
Most interactive features are variations of that same bridge rather than separate
architectures:
- **Slash commands** either mutate TUI state directly (`/model`, `/permission`,
`/help`) or start short `tea.Cmd` jobs that later return a message to `Update`
(`/doctor`, `/search`, `/compact`, session commands).
- **Permissions** sit between a model-requested tool call and actual execution:
the agent blocks on `OnPermissionRequest`, the TUI surfaces a modal and sends
the decision back through a channel, and the session log records the
request/decision events.
- **Plans, specialists/sub-agents, and ask-user prompts** all follow the same
runtime pattern: the model requests a capability, the agent records the
call/result in its conversation, and the TUI mirrors the visible state as plan
panels, specialist cards, or question forms. Specialist session metadata can
carry parent/root and sub-agent/spec fields, with richer linkage depending on
the specialist or swarm path.
- **Streaming tool writes** start as provider tool-call deltas before execution;
the TUI shows the arguments live, then replaces/augments them with the actual
`ToolResult` after the tool runs.
- **Compaction** is an agent-loop context-management step; the TUI may show that
it happened, but the purpose is to keep the next provider request within budget.
- **Stream-JSON** is another surface over the same callbacks: `zero exec`
serializes the events as JSON protocol events instead of Bubble Tea views.
So a feature-specific deep dive should usually explain: what user event starts it,
which `tea.Msg`/callback carries it, which agent/tool event is authoritative, and
which session events make it resumable.
## Runtime Provider Layer
Providers are hidden behind this interface in `internal/zeroruntime`:
```go
type Provider interface {
StreamCompletion(ctx context.Context, request CompletionRequest) (<-chan StreamEvent, error)
}
```
The agent loop only knows about provider-neutral messages, tool definitions,
stream events, reasoning blocks, images, and token usage. Provider adapters in
`internal/providers/*` translate those neutral types to provider-specific APIs:
- OpenAI and OpenAI-compatible APIs;
- Anthropic and Anthropic-compatible APIs;
- Gemini;
- Codex / ChatGPT OAuth-backed runtime.
`internal/providers.New` resolves the configured model through the model
registry/catalog, normalizes API model IDs and base URLs, attaches API keys or
OAuth resolvers, and returns the concrete adapter.
## Tools and Capabilities
Tools implement a common interface and are registered by name in
`internal/tools.Registry`.
```mermaid
flowchart TD
AgentToolCall[Tool call from model] --> Decode[Decode JSON args]
Decode --> Filter[Enabled/disabled + permission-mode filter]
Filter --> Precheck[Pre-permission rejecters]
Precheck --> SandboxEval[Sandbox Evaluate]
SandboxEval --> Prompt{Needs user approval?}
Prompt -- Yes --> Decision[Permission callback]
Prompt -- No --> Run[Registry.RunWithOptions]
Decision --> Run
Run --> Redact[Redact secrets]
Redact --> Budget[Token-aware semantic output budget]
Budget --> Ceiling[Existing hard byte ceiling]
Ceiling --> Message[Return one tool result to model]
```
Oversized results use deterministic, provider-neutral estimated-token budgets.
Policies retain useful structure for files, search matches, tests, process logs,
diffs, and worker conclusions; tools without a declared category use a UTF-8-safe
head/tail fallback. The estimate is intentionally conservative for non-ASCII
text and is not exact provider tokenization. Existing byte ceilings remain the
authoritative safety limit. When the existing spill mechanism can persist the
complete redacted text received by the budgeting layer, the result includes its
safe spill reference. This does not imply capture of subprocess bytes already
discarded by a tool's established internal buffer.
Core tool groups include:
- **Read-only tools**: file reads, directory listing, glob, grep, LSP navigation,
skills, `ask_user`, permission requests.
- **Write tools**: `write_file`, `edit_file`, `apply_patch`, `update_plan`.
- **Shell tools**: `exec_command`, `write_stdin`, legacy `bash`.
- **Network tools**: `web_fetch` and optional web search backend.
- **Local-control tools**: config-gated wrappers for browser, terminal, desktop,
and artifact helpers backed by packages such as `internal/localcontrol` and
`internal/browser`.
- **Extension tools**: MCP tools, specialists/sub-agents, swarm tools, plugins.
Tool definitions include safety metadata: side-effect class, permission level,
reason, and whether prompt-gated tools are advertised in auto-like modes. The
agent uses this metadata plus per-turn filters and deferred-loading state to
choose which registered tools the model can see and whether a call can run
immediately.
## Permission and Sandbox Model
Zero has two related but separate checks:
1. **Tool permission metadata** says whether a tool is allowed, denied, or needs
approval.
2. **Sandbox policy** evaluates the actual request: path scope, network access,
destructive shell patterns, explicit escalation, persistent/session grants,
and platform isolation availability.
```mermaid
flowchart TD
Call[Tool call] --> Safety[Tool Safety metadata]
Call --> Scope[Workspace/additional roots scope]
Safety --> Engine[Sandbox Engine Evaluate]
Scope --> Engine
Engine --> Allow[Allow]
Engine --> Prompt[Prompt user]
Engine --> Deny[Deny]
Prompt --> Grant[Turn/session/persistent grant]
Grant --> Execute[Execute tool]
Allow --> Execute
Deny --> Result[Return denied tool result]
```
The sandbox engine can:
- auto-allow workspace writes for safe file-edit tools;
- block reads/writes outside the allowed scope;
- prompt for recoverable outside-workspace access;
- deny or prompt for network depending on policy;
- detect destructive shell commands;
- run shell commands through a platform backend when available;
- remember session or persistent grants;
- support narrow command-prefix approvals.
The user-facing TUI and `zero exec` mode both route permission requests through
callbacks, but the TUI can show interactive modals while headless mode generally
returns non-interactive fallback behavior unless a protocol/client handles it.
## Session Storage and History
Sessions live locally under Zero's data directory. A session directory contains:
- `metadata.json` — session ID, title, cwd, provider/model, parent/root IDs,
spec/sub-agent fields, timestamps, and event count.
- `events.jsonl` — append-only event log with messages, tool calls/results,
permissions, usage, checkpoints, forks, compaction, and specs. TUI and exec use
the richer event stream; ACP currently records conversational message history
while streaming tool and permission updates to the client.
```mermaid
flowchart LR
TUI[TUI callbacks] --> Store[internal/sessions.Store]
Exec[exec recorder] --> Store
Store --> Metadata[metadata.json]
Store --> Events[events.jsonl]
Events --> Resume[resume]
Events --> Fork[fork]
Events --> Rewind[rewind/checkpoints]
Events --> Children[parent/root metadata]
```
This is why sessions can be resumed, forked, rewound, and inspected without a
remote service. Zero stores the local event history; providers only receive the
messages needed for the current completion request.
## Stream-JSON Protocol
`zero exec --output-format stream-json` emits newline-delimited JSON events for
integrations and CI. The protocol is implemented in `internal/streamjson` and is
specified in [STREAM_JSON_PROTOCOL.md](STREAM_JSON_PROTOCOL.md).
Common output event types include:
- `run_start`, `text`, `reasoning`, `tool_call`, `tool_result`;
- `permission`, `permission_request`, `permission_decision`;
- `checkpoint`, `restore`, `usage`, `final`, `warning`, `error`, `run_end`.
Input stream-JSON can provide `prompt` or `message` events and image payloads.
The CLI parses those events, resolves a single prompt, validates/decodes images,
and passes the result into the same agent loop used everywhere else.
## Extensibility Flow
Zero's extension surfaces are loaded before the agent loop starts:
```mermaid
flowchart TD
Config[Config resolution] --> Registry[Tool registry]
Registry --> Core[Core tools]
Registry --> MCP[MCP server tools]
Registry --> Specialist[Specialist / Task tools]
Registry --> Plugin[Plugin tools]
Config --> Prompt[System prompt context]
Prompt --> Guidelines[AGENTS.md / ZERO.md]
Prompt --> Skills[Skills list]
Prompt --> SpecialistContext[Specialist descriptions]
ToolLifecycle[Tool lifecycle] --> Hooks[beforeTool / afterTool hooks]
```
- **Project/user guidelines** become prompt context.
- **Skills** are instruction packs the model can load on demand.
- **Specialists** are sub-agents callable through the `Task` tool.
- **MCP servers** contribute external tools.
- **Plugins** can add tools, hooks, and skill roots. Bootstrap always registers a
multi-root skill tool: primary Zero skills dir, optional `~/.agents/skills`,
then plugin skill roots (earlier wins). `internal/skills` owns that merge via
`LoadFromRoots` / `DiscoveryRoots`; single-root `Load` remains for install/write.
- **Hooks** can observe or block tool lifecycle events.
## End-to-End Data Flow
A typical interactive edit request looks like this:
```mermaid
sequenceDiagram
participant U as User
participant T as TUI
participant A as Agent loop
participant P as Provider
participant R as Tool registry
participant S as Sandbox
participant FS as Workspace/session files
U->>T: Type prompt and press Enter
T->>A: agent.Run(prompt, provider, options)
A->>P: CompletionRequest(messages, tool definitions)
P-->>A: Stream text + tool call edit_file
A->>S: Evaluate edit_file args
S-->>A: allow/prompt/deny decision
A->>T: permission callback if prompt needed
T-->>A: user decision
A->>R: Run edit_file
R->>FS: Write workspace file
R-->>A: Tool result + changed files
A->>P: Next CompletionRequest with tool result
P-->>A: Final answer
A-->>T: Result + callbacks
T->>FS: Append session events
T-->>U: Render final transcript
```
The same request in `zero exec` follows the same provider/tool/sandbox path, but
streaming events are written to stdout/stderr instead of rendered in Bubble Tea.
## Mental Model
When you work on Zero, start by locating the change on the four-layer spine from
[Architecture Spine](#architecture-spine) — Surface, Agent loop, Capabilities,
Persistence — then jump to the package that owns that boundary:
- UI behavior? Start in `internal/tui`.
- Command-line behavior? Start in `internal/cli`.
- Model/tool-turn behavior? Start in `internal/agent`.
- A new capability? Register a tool in `internal/tools` or an extension surface.
- Safety policy? Start in `internal/sandbox` and agent permission wiring.
- Resume/history behavior? Start in `internal/sessions` and session recorders.
+78 -37
View File
@@ -4,10 +4,11 @@ Zero is distributed as:
- an npm package, `@gitlawb/zero`
- release archives on GitHub Releases
- source builds with Go 1.25+
- source builds with Go 1.26.5+
The npm package and install scripts download a platform-specific release archive.
They require a published GitHub Release for the requested version.
The install scripts download a platform-specific release archive and require a
published GitHub Release for the requested version. The npm package is
self-contained: the platform binary installs from the npm registry.
## npm
@@ -16,46 +17,32 @@ npm install -g @gitlawb/zero
zero
```
The package supports Linux, macOS, and Windows on x64 and arm64. It installs the
`zero` command and downloads the matching release binary during `postinstall`.
The package supports Linux and macOS on x64 and arm64, and Windows on x64
(Windows on ARM runs the x64 build under emulation). It installs a small
`zero` wrapper plus, as an optional dependency, a platform payload with the
native binary and the bundled browser/terminal control helpers. There are no
install scripts and nothing is downloaded from outside the npm registry, so
the install is silent and works identically under npm, Bun, pnpm, and yarn —
no trust or approval steps. See [NPM_PACKAGING.md](NPM_PACKAGING.md) for the
package architecture.
Requirements:
- Node.js 18+
- network access to npm and GitHub Releases
- network access to npm
## 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:
If the install skipped optional dependencies (`npm install --omit=optional`,
or a package manager configured to do so), the wrapper falls back to
downloading the binary from the matching GitHub Release, with checksum
verification. The fetch is retried on each run until a binary is in place (a
transient network failure heals itself; an install directory the current user
cannot write to keeps failing — rerun the install or the command below with
sufficient permissions). To trigger the fetch manually:
```bash
bun add @gitlawb/zero
node node_modules/@gitlawb/zero/scripts/postinstall.mjs
node "$(npm root -g)/@gitlawb/zero/scripts/postinstall.mjs"
```
Or allow the postinstall to run by adding the package to your project's
`trustedDependencies` before installing:
```json
{
"trustedDependencies": ["@gitlawb/zero"]
}
```
```bash
bun add @gitlawb/zero
```
For global installs (`bun add -g @gitlawb/zero`), run the installer manually
against the global install path, or use the install scripts below.
Reference: <https://bun.sh/docs/pm/lifecycle>
## Linux And macOS Script
Install the latest release:
@@ -138,7 +125,7 @@ Build a local binary:
go build -o zero ./cmd/zero
```
Source builds require Go 1.25+.
Source builds require Go 1.26.5+.
### Sandbox Helpers For Source Builds
@@ -160,6 +147,61 @@ Linux native sandboxing also requires Bubblewrap to be installed.
macOS uses the system sandbox and does not need an extra helper binary.
### Termux (Android)
Zero can run natively on Android via [Termux](https://termux.dev/). Build with
`GOOS=android` to avoid the `faccessat2` syscall that is blocked by Samsung's
seccomp filter on Android:
```bash
# Install Go in Termux
pkg install golang
# Build Zero for Android
git clone https://github.com/Gitlawb/zero.git
cd zero
CGO_ENABLED=0 GOOS=android GOARCH=arm64 go build -ldflags="-s -w" -o zero ./cmd/zero
# Move into PATH
mv zero ~/.local/bin/
```
> **Why `GOOS=android`?** Go 1.26+ detects `runtime.GOOS == "android"` and skips
> the `faccessat2` syscall inside `os/exec.findExecutable`, falling back to
> permission-bit checks. Without this flag, Android's seccomp sends SIGSYS and
> kills the process whenever Zero looks up a binary on `PATH` (git, sh, etc.).
**DNS.** Android does not expose `/etc/resolv.conf`. Go's pure-Go DNS resolver
needs one. Use `proot` to bind-mount Termux's resolver config:
```bash
pkg install proot
proot -b "$PREFIX/etc/resolv.conf:/etc/resolv.conf" zero
```
Create a wrapper at `~/.local/bin/zero` to avoid typing proot every time:
```bash
#!/data/data/com.termux/files/usr/bin/bash
exec proot -b "$PREFIX/etc/resolv.conf:/etc/resolv.conf" ~/.local/bin/zero.bin "$@"
```
**Scroll.** On native Termux (not under PRoot), mouse scrolling works out of the
box. The TUI uses Bubble Tea's `AllMotion` mouse mode by default. If you run Zero
inside PRoot (e.g. through proot-distro), the scroll fix activates `CellMotion`
to avoid PRoot's ptrace interference with the 1003 escape sequence.
**Providers.** Zero works with any OpenAI-compatible provider on Termux. For
example, to use OpenCode Zen's free tier:
```bash
zero providers add opencode \
--name opencode \
--model deepseek-v4-flash-free \
--base-url https://opencode.ai/zen/v1 \
--set-active
```
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`:
@@ -184,8 +226,7 @@ Supported targets:
- `linux-arm64`
- `macos-x64`
- `macos-arm64`
- `windows-x64`
- `windows-arm64`
- `windows-x64` (Windows on ARM runs this build under emulation)
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
+162
View File
@@ -0,0 +1,162 @@
# npm packaging
How `@gitlawb/zero` is put together on npm, why it is shaped this way, and the
rules the release pipeline must follow. Read this before touching
`package.json`, `bin/zero.js`, `scripts/postinstall.mjs`,
`scripts/npm/build-platform-packages.mjs`, or the npm-publish steps of
`release-artifacts.yml`.
## Goals
A `npm install -g @gitlawb/zero` must be **silent and self-contained**:
- No `EBADENGINE` warnings — ours or from any package in the dependency tree.
- No install scripts anywhere in the tree, so npm's `allow-scripts` gating,
Bun's blocked-by-default lifecycle scripts, and pnpm's strict mode all
install zero without prompts, trust ceremonies, or broken binaries.
- No network fetches outside the npm registry at install time. GitHub being
down or rate-limited must not break `npm install`.
- Browser control (`agent-browser`) and terminal control (`tuistory`) work out
of the box.
## Architecture
The model is the one used by Codex, esbuild, and Biome: a tiny wrapper package
plus per-platform payloads carrying the native binaries.
```
@gitlawb/zero <- wrapper: bin/zero.js + optionalDependencies
├─ @gitlawb/zero-darwin-arm64 -> npm:@gitlawb/zero@{version}-darwin-arm64
├─ @gitlawb/zero-darwin-x64 -> npm:@gitlawb/zero@{version}-darwin-x64
├─ @gitlawb/zero-linux-arm64 -> npm:@gitlawb/zero@{version}-linux-arm64
├─ @gitlawb/zero-linux-x64 -> npm:@gitlawb/zero@{version}-linux-x64
└─ @gitlawb/zero-win32-x64 -> npm:@gitlawb/zero@{version}-win32-x64
```
- The platform "packages" are **versions of the same `@gitlawb/zero` package**,
published at suffixed versions (`0.4.0-linux-x64`) and referenced through
`npm:` aliases in `optionalDependencies`. One package name means one npm
trusted-publisher configuration — no new publish credentials per platform.
The alias suffixes use Node's `process.platform`/`process.arch` names so
`bin/zero.js` can derive its platform package directly.
- Each platform version sets `os` and `cpu`, so npm installs exactly one of
them and skips the rest.
- A platform payload is assembled from the platform's release archive by
`scripts/npm/build-platform-packages.mjs` and contains the `zero` binary,
the platform's sandbox helpers, and the vendored `helpers/` tree (see
below). It has no `bin`, no scripts, and no dependencies — it is inert on
its own; the wrapper execs the binary out of it.
- The published wrapper has **no scripts and no dependencies** — the
assembly script strips both from the repo `package.json` and injects the
exact-version `optionalDependencies`. (The repo `package.json` keeps
`agent-browser`/`tuistory` in `dependencies` purely as the version pins for
the vendored helpers tree; they are never installed by consumers.)
- There is **no windows-arm64 build** (matches the release matrix); Windows on
ARM runs the x64 build under emulation via the first-run fallback.
### Binary resolution in `bin/zero.js`
1. Resolve `@gitlawb/zero-<platform>-<arch>` and exec the `zero` binary from
it. This wins over any previously downloaded copy — the platform version is
pinned to the wrapper release.
2. Fall back to a binary previously downloaded next to the wrapper.
3. If neither exists (`--omit=optional`, package managers that skip optional
dependencies), run the **fallback downloader**: `scripts/postinstall.mjs`,
the exact logic that used to run as a postinstall hook (HTTPS-only, SHA-256
verified against the release's own checksum file, no zip-slip), invoked by
the wrapper itself. Failures are deliberately not cached — the fetch
retries on every run until a binary is in place, so a transient network
error self-heals. Platforms with no release asset (anything outside the
matrix above, including windows-arm64) skip the attempt entirely rather
than probing the network each run.
4. If the download is impossible too, print build-from-source guidance.
There is deliberately **no `scripts.postinstall`** in any published
`package.json`. The downloader exists only as a first-run fallback.
### Vendored helpers (`helpers/`)
`agent-browser` (Apache-2.0, vercel-labs/agent-browser) and `tuistory` are
**vendored binaries/packages inside the platform payload**, not npm
dependencies of the wrapper:
- As a dependency, agent-browser's `engines: { node: ">=24", pnpm: ">=11" }`
and postinstall script produce `EBADENGINE` and `allow-scripts` warnings for
every installer. As a vendored tree it produces none, because npm never
resolves it.
- `zero-release package` already stages the helpers tree into every release
archive (`stageLocalControlHelpers` runs `npm ci` from the repo's
`package.json` pins + lockfile on the native builder). The assembly script
reuses that staged tree, so npm installs and `install.sh` installs get
identical helpers.
- The Go binary resolves helpers from `<binary dir>/helpers/node_modules/.bin`
on its own (`internal/localcontrol/browser.go`, `adjacentHelper`) — no
wrapper involvement, no configuration.
- **Symlink materialization:** `npm pack` silently drops symlinks, and npm's
own `.bin` shims are symlinks on POSIX. The assembly script rewrites every
`.bin` symlink into a relocatable `#!/bin/sh` exec shim and dereferences any
other symlink, so the vendored tree survives publishing. (Verified
empirically; nested `node_modules` under `helpers/` IS packed — only the
package-root `node_modules` is always ignored.)
- **Binary pruning:** agent-browser ships one ~11 MB native binary per
platform (7 total). The assembly script keeps only the payload's own
platform binary (plus the musl variant on linux, which its launcher detects
at runtime) — about 65 MB saved per platform package.
## Publishing rules (release pipeline)
These are the invariants `release-artifacts.yml` must hold. Breaking the first
one is user-visible immediately.
1. **Platform versions must never become `latest`.** `0.4.0-linux-x64` is a
semver *prerelease* of `0.4.0`; publishing it without an explicit
non-`latest` dist-tag would clobber `latest` and users would install a
platform payload as the CLI. The workflow publishes platform versions with
`--tag platform` and asserts `latest` survived before the wrapper publish,
then asserts `latest` equals the wrapper version after it. Both lookups
tolerate a failed `npm view` (registry lag, or no `latest` tag yet on a
bootstrap publish) — a missing value must not abort the job between the
platform and wrapper publishes, which would strand a half-published
release.
2. **Platform versions publish before the wrapper.** The wrapper's
`optionalDependencies` pin exact suffixed versions; publishing the wrapper
first would create a window where installs resolve aliases that 404.
3. **Exact-version pinning.** The wrapper at `X.Y.Z` references platform
versions `X.Y.Z-<platform>-<arch>` exactly — never ranges — so a wrapper
and its binaries can never skew.
4. All publishes go through the existing npm OIDC trusted-publishing flow and
the `npm-publish` environment gate; no tokens. Because the platform
payloads are versions of the same package, they reuse the single
trusted-publisher configuration.
`zero update` keeps working unchanged: it detects an npm install by finding a
`package.json` named `@gitlawb/zero` next to the running binary — true inside
a platform payload too — and updates via `npm install -g @gitlawb/zero@latest`.
## Runbook: bumping the vendored helpers
The vendored helper versions are pinned by the repo `package.json`
`dependencies` + `package-lock.json`; the release build vendors exactly what
the lockfile resolves. No `^range` surprises ship: a release carries what its
lockfile pinned at build time.
- Routine bump: `npm install agent-browser@<version>` (or `tuistory@…`) in the
repo to update pin + lockfile, verify the browser/terminal tools end-to-end,
ship with the next release.
- Security response: if a vendored helper publishes a security fix, the bump
is release-worthy on its own — cut a patch release of zero carrying only the
pin change.
- Attribution: agent-browser is Apache-2.0; its LICENSE ships inside the
vendored package directory (`helpers/node_modules/agent-browser/`), which
satisfies redistribution attribution.
## History / rationale
Until v0.3.x the npm package was a wrapper with a `postinstall` downloader and
`agent-browser`/`tuistory` as regular dependencies. That produced three
warnings on every install (`EBADENGINE` from agent-browser's `node >=24`
engines pin, and `allow-scripts` warnings for both postinstall scripts), broke
silently under Bun's default script blocking, and coupled `npm install` to
GitHub Releases availability. The platform-package model removes the warnings
structurally — there is nothing left in the tree for a package manager to warn
about — rather than asking users to approve or suppress them.
+45 -10
View File
@@ -1,8 +1,8 @@
# npm Wrapper Smoke Checklist
Run this checklist when a PR changes npm distribution files such as
`package.json`, `bin/zero.js`, Go build or release tooling, or the npm `bin`
wrapper.
`package.json`, `bin/zero.js`, `scripts/npm/build-platform-packages.mjs`,
Go build or release tooling, or the npm `bin` wrapper.
## Required Checks
@@ -21,17 +21,52 @@ go run ./cmd/zero-release build
go run ./cmd/zero-release smoke
```
## End-To-End Assembly Smoke
Build a real archive for the host platform and assemble the npm payloads from
it (this is exactly what the publish job does):
```bash
go run ./cmd/zero-release package
node scripts/npm/build-platform-packages.mjs \
--artifacts-dir dist/release --out-dir dist/npm --only <platform>-<arch>
```
Then simulate an installed layout and run the wrapper through each resolution
path:
```bash
# platform-package path
mkdir -p /tmp/zero-sim/node_modules/@gitlawb
cp -R dist/npm/wrapper /tmp/zero-sim/node_modules/@gitlawb/zero
cp -R dist/npm/platforms/zero-<platform>-<arch> \
"/tmp/zero-sim/node_modules/@gitlawb/zero-<platform>-<arch>"
node /tmp/zero-sim/node_modules/@gitlawb/zero/bin/zero.js --version
# first-run download fallback (delete the platform package first)
rm -rf "/tmp/zero-sim/node_modules/@gitlawb/zero-<platform>-<arch>"
node /tmp/zero-sim/node_modules/@gitlawb/zero/bin/zero.js --version
```
## Checklist
- `package.json` has the expected package name (`@gitlawb/zero`), version,
`bin.zero` entry, and exactly one `scripts` entry — the `postinstall` hook.
- `scripts/postinstall.mjs` resolves the correct release asset name/URL per
platform (`ZERO_INSTALL_DRY_RUN=1` prints the plan), verifies the downloaded
archive's SHA-256, and extracts only the known binary basenames into place.
`ZERO_SKIP_DOWNLOAD=1` opts out cleanly (exit 0) and an unsupported
platform/arch is a non-fatal skip.
- The wrapper binary resolves through the package `bin` entry and
`node_modules/.bin` in a package-install smoke test.
`bin.zero` entry, and NO `scripts` entries — the published package must be
free of lifecycle scripts (see NPM_PACKAGING.md).
- `scripts/npm/build-platform-packages.mjs` emits platform payloads whose
`package.json` is `@gitlawb/zero@<version>-<platform>-<arch>` with matching
`os`/`cpu`, contains the executable binary and the vendored `helpers/` tree
with regular-file (non-symlink) `.bin` shims, and a wrapper payload with no
scripts, no dependencies, and the full five-alias `optionalDependencies`
matrix.
- `scripts/postinstall.mjs` (the first-run fallback) resolves the correct
release asset name/URL per platform (`ZERO_INSTALL_DRY_RUN=1` prints the
plan), verifies the downloaded archive's SHA-256, and extracts only the
known binary basenames into place. `ZERO_SKIP_DOWNLOAD=1` opts out cleanly
(exit 0) and an unsupported platform/arch is a non-fatal skip.
- The wrapper prefers the platform package binary over a previously downloaded
one, and `agent-browser`/`tuistory` run from the platform package's
`helpers/node_modules/.bin` shims.
- The built binary exits 0 for `zero --version` or `zero --help`.
- `zero --version` reports `zero <package.json version>`.
- Release packaging still emits the expected archive and checksum names when
+1
View File
@@ -17,6 +17,7 @@ Zero.
## Maintainer Docs
- [How Zero Works](HOW_ZERO_WORKS.md)
- [npm wrapper smoke checklist](NPM_WRAPPER_SMOKE.md)
- [Benchmarks](BENCHMARK.md)
- [Performance benchmarks](PERFORMANCE.md)
+5
View File
@@ -41,6 +41,7 @@ events when present.
```json
{ "schemaVersion": 2, "type": "run_start", "runId": "run_20260603_abc123", "sessionId": "zero_20260603100000_abc123", "cwd": "/repo", "provider": "openai", "model": "gpt-4.1", "apiModel": "gpt-4.1" }
{ "schemaVersion": 2, "type": "reasoning", "runId": "run_20260603_abc123", "delta": "Thinking..." }
{ "schemaVersion": 2, "type": "text", "runId": "run_20260603_abc123", "delta": "..." }
{ "schemaVersion": 2, "type": "tool_call", "runId": "run_20260603_abc123", "id": "call_1", "name": "read_file", "args": { "path": "README.md" }, "sideEffect": "read" }
{ "schemaVersion": 2, "type": "permission_request", "runId": "run_20260603_abc123", "id": "call_2", "name": "write_file", "action": "prompt", "permission": "prompt", "permissionMode": "ask", "sideEffect": "write", "reason": "Creates or overwrites files." }
@@ -51,6 +52,10 @@ events when present.
{ "schemaVersion": 2, "type": "run_end", "runId": "run_20260603_abc123", "status": "success", "exitCode": 0 }
```
`reasoning` events carry live model reasoning/status deltas for providers that
stream them separately from answer text. They are liveness/progress events only:
they are not folded into `text` or the final answer.
Permission events may include structured sandbox metadata:
```json
+39
View File
@@ -0,0 +1,39 @@
# TUI Themes
Zero's TUI ships a set of built-in color themes. Pick one with `--theme <name>`,
the `ZERO_THEME` environment variable, or the `/theme <name>` command while
running. `auto` (the default) follows the terminal's detected background.
Run `/theme` with no argument to open a picker: move through the list to
live-preview each theme, press Enter to apply the highlighted one, or Esc to
cancel and restore the previously active theme. Run `/theme list` to print
the active theme and the registered names without opening the picker.
## Dune (`dune`)
A warm sand-and-cream palette: sand/cream surface, charcoal ink, and a soft
amber accent.
## Neon (`neon`)
A neon-on-black palette: pitch-black surface, bright green ink, and a cyan accent.
## Adding a theme
Every theme is a `palette` (a table of color hex tokens) plus one entry in
`themeRegistry`, both in `internal/tui/theme_palettes.go`. `buildTheme` in
`internal/tui/theme.go` turns a `palette` into the resolved `lipgloss.Style`
set every renderer reads from the active `zeroTheme`. Adding a theme means
adding a new `palette{...}` literal, a `themeRegistry` entry, and test
coverage for the new palette (see below).
Registry-wide tests in `internal/tui/theme_select_test.go` assert the basic
WCAG AA text tokens, the gray-ramp order, the diff word-span pairs, and the
selected-row band for every entry. The rendered-surface invariants beyond
those (permission surfaces, selected-row secondary text, diff gutters, and
the xterm-256 downsampling checks) are asserted per palette, not against the
whole registry: `TestExtendedThemeContrastInvariants` and
`TestExtendedThemeANSI256Contrast` enumerate the palettes they cover. A new
theme must be added to those tests (or given equivalent palette-specific
assertions), or CI can stay green while its permission, selected-row, and
diff surfaces ship unreadable.
+9 -10
View File
@@ -1,32 +1,31 @@
module github.com/Gitlawb/zero
go 1.25.0
toolchain go1.26.4
go 1.26.5
require (
charm.land/bubbles/v2 v2.1.0
charm.land/bubbletea/v2 v2.0.7
charm.land/lipgloss/v2 v2.0.4
charm.land/bubbles/v2 v2.1.1
charm.land/bubbletea/v2 v2.0.8
charm.land/lipgloss/v2 v2.0.5
github.com/alecthomas/chroma/v2 v2.27.0
github.com/atotto/clipboard v0.1.4
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/coder/websocket v1.8.15
github.com/ledongthuc/pdf v0.0.0-20250511090121-5959a4027728
golang.org/x/sys v0.46.0
golang.org/x/sys v0.47.0
mvdan.cc/sh/v3 v3.13.1
)
require (
github.com/charmbracelet/ultraviolet v0.0.0-20260615092913-2399af76d5b1 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20260703014108-f5a850f9c2b7 // indirect
github.com/charmbracelet/x/exp/golden v0.0.0-20260615092313-b57e5e6d29bb // 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
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/dlclark/regexp2/v2 v2.2.1 // indirect
github.com/dlclark/regexp2/v2 v2.5.0 // indirect
github.com/go-quicktest/qt v1.102.0 // indirect
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
github.com/mattn/go-runewidth v0.0.24 // indirect
@@ -35,5 +34,5 @@ require (
github.com/rogpeppe/go-internal v1.15.0 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/exp v0.0.0-20260611194520-c48552f49976 // indirect
golang.org/x/sync v0.21.0 // indirect
golang.org/x/sync v0.22.0 // indirect
)
+16 -14
View File
@@ -1,9 +1,9 @@
charm.land/bubbles/v2 v2.1.0 h1:YSnNh5cPYlYjPxRrzs5VEn3vwhtEn3jVGRBT3M7/I0g=
charm.land/bubbles/v2 v2.1.0/go.mod h1:l97h4hym2hvWBVfmJDtrEHHCtkIKeTEb3TTJ4ZOB3wY=
charm.land/bubbletea/v2 v2.0.7 h1:7qw2tTAVar7m7klOPBYfTB0mniv/RuexsYwMRNxSeL0=
charm.land/bubbletea/v2 v2.0.7/go.mod h1:DGW2q8gvzHnOpMpZTORs0aySVHCox5C+2Svk0fci1qs=
charm.land/lipgloss/v2 v2.0.4 h1:lcPeVtcp23SNra7lHy8iYE4UC2aIipVQ47sbGyyxR5Q=
charm.land/lipgloss/v2 v2.0.4/go.mod h1:0653x8epbZSzdDfO/XPS1a/uYPOBeSsCssOpJOqDzik=
charm.land/bubbles/v2 v2.1.1 h1:7r55WzBxpo/R3z98hGmY7KKPd3ET6vsf0Fb9sDHOV60=
charm.land/bubbles/v2 v2.1.1/go.mod h1:GE6M31gaWZVXzGw73OeuTTgy4lX+OtkH0E5ymnNsHxo=
charm.land/bubbletea/v2 v2.0.8 h1:SxTJMhCAI3lbPmy4SgX5LWZ24AdINr4I6UEqzZvYJuY=
charm.land/bubbletea/v2 v2.0.8/go.mod h1:2SkdgoTXluXJHOUwAoRlRXF/28vklb1rFl6GcgV1/ss=
charm.land/lipgloss/v2 v2.0.5 h1:kbNxgeeUOYv5J0YdpxFjfvf3dFvqH8Aci4zB6xqFtrY=
charm.land/lipgloss/v2 v2.0.5/go.mod h1:9oqhxt4yxIMe6q5A4kHr44DremZk7J9UNh74GlWa5nc=
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/chroma/v2 v2.27.0 h1:FodwmyOBgJULFYmDqibcp9pvfDLWdtPRh9v/r5BXYZs=
@@ -16,8 +16,8 @@ github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
github.com/charmbracelet/ultraviolet v0.0.0-20260615092913-2399af76d5b1 h1:4+r3uOJ69ueRBt4okgEfWZeXs3BD36HcDBmOIAUlETk=
github.com/charmbracelet/ultraviolet v0.0.0-20260615092913-2399af76d5b1/go.mod h1:f/jRa757WUmaOZrbPspXymbg/GnbF+rwe4OLsG7aXYo=
github.com/charmbracelet/ultraviolet v0.0.0-20260703014108-f5a850f9c2b7 h1:3FmWoGNWK4STvqg0O0Aeav2T7rodWJAPeF0QpH+8gFw=
github.com/charmbracelet/ultraviolet v0.0.0-20260703014108-f5a850f9c2b7/go.mod h1:f/jRa757WUmaOZrbPspXymbg/GnbF+rwe4OLsG7aXYo=
github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI=
github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ=
github.com/charmbracelet/x/exp/golden v0.0.0-20260615092313-b57e5e6d29bb h1:m1Uub9uM7meZ8ofVyeRfK7M/knCi3sF2gKn9kldVW0I=
@@ -32,8 +32,10 @@ github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSE
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/dlclark/regexp2/v2 v2.2.1 h1:mf4KkFUj0gJuarK8P+LgiS+Lit7m9N1yAwEfPbee7R0=
github.com/dlclark/regexp2/v2 v2.2.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA=
github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/dlclark/regexp2/v2 v2.5.0 h1:liNiWIPCvCh5HBcYfsjd+P16AG79fwd6T1Toy2gOtEA=
github.com/dlclark/regexp2/v2 v2.5.0/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
github.com/go-quicktest/qt v1.102.0 h1:HSQxCeh5YZH3EL3W39ixjtyaEhcWSXQHtHnMBzSs474=
github.com/go-quicktest/qt v1.102.0/go.mod h1:p4lGIVX+8Wa6ZPNDvqcxq36XpUDLh42FLetFU7odllI=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
@@ -60,9 +62,9 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavM
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
golang.org/x/exp v0.0.0-20260611194520-c48552f49976 h1:X8Hz2ImujgbmetVuW+w2YkyZChE3cBpZi2P158rTG9M=
golang.org/x/exp v0.0.0-20260611194520-c48552f49976/go.mod h1:vnf4pv9iKZXY58sQE1L86zmNWJ4159e1RkcWiLCkeEY=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
mvdan.cc/sh/v3 v3.13.1 h1:DP3TfgZhDkT7lerUdnp6PTGKyxxzz6T+cOlY/xEvfWk=
mvdan.cc/sh/v3 v3.13.1/go.mod h1:lXJ8SexMvEVcHCoDvAGLZgFJ9Wsm2sulmoNEXGhYZD0=
+54 -21
View File
@@ -5,6 +5,7 @@ import (
"encoding/base64"
"encoding/json"
"errors"
"log"
"strings"
"sync"
@@ -159,8 +160,14 @@ func (a *Agent) handleSessionLoad(_ context.Context, params json.RawMessage) (an
// Load history BEFORE publishing the session so no concurrent prompt observes
// a half-initialized session (registerSession sets history under the lock and
// reuses an already-live session rather than orphaning its in-flight turn).
history := a.loadHistory(meta.SessionID)
history, historyErr := a.loadHistory(meta.SessionID)
sess := a.registerSession(meta.SessionID, root, history)
a.warnPersistence(
&notifier{conn: a.conn, sessionID: sess.id},
"load session history",
"Could not load session history. The session is open, but earlier turns may be missing until storage recovers.",
historyErr,
)
return LoadSessionResult{
Modes: a.modeState(sess),
}, nil
@@ -253,7 +260,14 @@ func (a *Agent) runTurn(ctx context.Context, sess *acpSession, userText string,
if stopErr != nil {
return "", RPCError(codeInternalError, stopErr.Error())
}
a.persistTurn(sess, userText, result.FinalAnswer)
if err := a.persistTurn(sess, userText, result.FinalAnswer); err != nil {
a.warnPersistence(
note,
"save session history",
"Could not save session history. This turn is available in memory, but future resume may miss it until storage recovers.",
err,
)
}
return reason, nil
}
@@ -386,29 +400,34 @@ func (a *Agent) modeState(s *acpSession) *SessionModeState {
// ---- persistence + continuity ----
func (a *Agent) persistTurn(sess *acpSession, user, assistant string) {
if a.deps.Store != nil {
_, _ = a.deps.Store.AppendEvent(sess.id, sessions.AppendEventInput{
Type: sessions.EventMessage,
Payload: map[string]any{"role": "user", "content": user},
})
if assistant != "" {
_, _ = a.deps.Store.AppendEvent(sess.id, sessions.AppendEventInput{
Type: sessions.EventMessage,
Payload: map[string]any{"role": "assistant", "content": assistant},
})
}
}
sess.appendHistory(turnRecord{user: user, assistant: assistant})
}
func (a *Agent) loadHistory(sessionID string) []turnRecord {
func (a *Agent) persistTurn(sess *acpSession, user, assistant string) error {
defer sess.appendHistory(turnRecord{user: user, assistant: assistant})
if a.deps.Store == nil {
return nil
}
events := []sessions.AppendEventInput{
{
Type: sessions.EventMessage,
Payload: map[string]any{"role": "user", "content": user},
},
}
if assistant != "" {
events = append(events, sessions.AppendEventInput{
Type: sessions.EventMessage,
Payload: map[string]any{"role": "assistant", "content": assistant},
})
}
_, err := a.deps.Store.AppendEvents(sess.id, events)
return err
}
func (a *Agent) loadHistory(sessionID string) ([]turnRecord, error) {
if a.deps.Store == nil {
return nil, nil
}
events, err := a.deps.Store.ReadEvents(sessionID)
if err != nil {
return nil
return nil, err
}
var records []turnRecord
var pendingUser string
@@ -444,7 +463,21 @@ func (a *Agent) loadHistory(sessionID string) []turnRecord {
if havePending {
records = append(records, turnRecord{user: pendingUser})
}
return records
return records, nil
}
func (a *Agent) warnPersistence(note *notifier, action string, message string, err error) {
if err == nil {
return
}
sessionID := ""
if note != nil {
sessionID = note.sessionID
}
log.Printf("zero acp: failed to %s for session %s: %v", action, sessionID, err)
if note != nil {
note.text("\n\n[zero warning] " + message + "\n")
}
}
// buildPrompt prepends prior conversation as context, since agent.Run drives a
+72 -1
View File
@@ -5,6 +5,8 @@ import (
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"testing"
"time"
@@ -242,8 +244,77 @@ func TestACPRejectsInvalidCwd(t *testing.T) {
}
}
func TestACPPromptWarnsWhenTurnPersistenceFails(t *testing.T) {
deps := testDeps(t)
h := newHarness(t, deps)
defer h.stop()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var newRes NewSessionResult
if err := h.client.Call(ctx, MethodSessionNew, NewSessionParams{Cwd: t.TempDir(), McpServers: []McpServer{}}, &newRes); err != nil {
t.Fatalf("session/new: %v", err)
}
metadataPath := filepath.Join(deps.Store.RootDir, newRes.SessionID, sessions.MetadataFile)
if err := os.Remove(metadataPath); err != nil {
t.Fatalf("remove metadata: %v", err)
}
var promptRes PromptResult
if err := h.client.Call(ctx, MethodSessionPrompt, PromptParams{
SessionID: newRes.SessionID,
Prompt: []ContentBlock{TextBlock("hi")},
}, &promptRes); err != nil {
t.Fatalf("session/prompt: %v", err)
}
if promptRes.StopReason != StopEndTurn {
t.Fatalf("stopReason = %q, want %q", promptRes.StopReason, StopEndTurn)
}
got := drainTextUntil(t, h.updates, func(text string) bool {
return strings.Contains(text, "Hello from ZERO") &&
strings.Contains(text, "Could not save session history")
})
if !strings.Contains(got, "Could not save session history") {
t.Fatalf("streamed text = %q, want persistence warning", got)
}
}
func TestACPLoadWarnsWhenHistoryReadFails(t *testing.T) {
deps := testDeps(t)
cwd := t.TempDir()
meta, err := deps.Store.Create(sessions.CreateInput{Title: "ACP session", Cwd: cwd})
if err != nil {
t.Fatalf("create session: %v", err)
}
eventsPath := filepath.Join(deps.Store.RootDir, meta.SessionID, sessions.EventsFile)
if err := os.WriteFile(eventsPath, []byte("{bad json}\n"), 0o600); err != nil {
t.Fatalf("write corrupt events: %v", err)
}
h := newHarness(t, deps)
defer h.stop()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := h.client.Call(ctx, MethodSessionLoad, LoadSessionParams{SessionID: meta.SessionID, Cwd: cwd, McpServers: []McpServer{}}, &LoadSessionResult{}); err != nil {
t.Fatalf("session/load: %v", err)
}
got := drainTextUntil(t, h.updates, func(text string) bool {
return strings.Contains(text, "Could not load session history")
})
if !strings.Contains(got, "Could not load session history") {
t.Fatalf("streamed text = %q, want load warning", got)
}
}
// drainText collects streamed chunks for a short window and concatenates them.
func drainText(t *testing.T, ch <-chan string) string {
t.Helper()
return drainTextUntil(t, ch, func(text string) bool {
return strings.Contains(text, "Hello from ZERO")
})
}
func drainTextUntil(t *testing.T, ch <-chan string, done func(string) bool) string {
t.Helper()
var b strings.Builder
deadline := time.After(2 * time.Second)
@@ -251,7 +322,7 @@ func drainText(t *testing.T, ch <-chan string) string {
select {
case s := <-ch:
b.WriteString(s)
if strings.Contains(b.String(), "Hello from ZERO") {
if done(b.String()) {
return b.String()
}
case <-deadline:
+3 -2
View File
@@ -3,6 +3,7 @@ package acp
import (
"encoding/json"
"strings"
"unicode/utf8"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/tools"
@@ -70,8 +71,8 @@ func primaryArgHint(rawArgs string) string {
func truncateHint(s string) string {
s = strings.TrimSpace(s)
const max = 60
if len(s) > max {
return s[:max] + "…"
if utf8.RuneCountInString(s) > max {
return string([]rune(s)[:max]) + "…"
}
return s
}
+34
View File
@@ -1,7 +1,9 @@
package acp
import (
"strings"
"testing"
"unicode/utf8"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/tools"
@@ -126,3 +128,35 @@ func TestPromptText(t *testing.T) {
t.Fatalf("promptText = %q", got)
}
}
func TestToolTitleTruncateHintRuneSafe(t *testing.T) {
// A 61-character string containing multi-byte UTF-8 runes (emojis / CJK characters).
// We want to verify that it is truncated without cutting any runes or producing invalid UTF-8.
longPath := "📁/项目/非常长的路径名称/测试/🚀/emoji-and-cjk-characters-which-are-very-long-and-exceed-sixty-characters"
// Create JSON args for read_file
rawArgs := `{"path":"` + longPath + `"}`
got := toolTitle("read_file", rawArgs)
expectedPrefix := "read_file "
if !strings.HasPrefix(got, expectedPrefix) {
t.Fatalf("expected title to start with %q, got %q", expectedPrefix, got)
}
hint := strings.TrimPrefix(got, expectedPrefix)
// Hint should end with the ellipsis character
if !strings.HasSuffix(hint, "…") {
t.Fatalf("expected truncated hint to end with ellipsis, got %q", hint)
}
// Check that we don't have invalid UTF-8 runes
if !utf8.ValidString(hint) {
t.Fatalf("truncated hint is not a valid UTF-8 string: %q", hint)
}
// The rune count of the hint (excluding ellipsis) should be exactly 60
runes := []rune(strings.TrimSuffix(hint, "…"))
if len(runes) != 60 {
t.Fatalf("expected exactly 60 runes before ellipsis, got %d (hint: %q)", len(runes), hint)
}
}
+20
View File
@@ -72,6 +72,26 @@ func TestSelfReportedIncompletionMatching(t *testing.T) {
{"I replaced the placeholder value with the computed key.", false},
{"The retry uses exponential backoff as a fallback.", false},
{"Without proper error handling the function would crash, so I added it.", false},
// real false positive (conversational recap): retelling a PAST exchange is
// not an admission about the current objective — the sentence carries its
// own narrative context ("You asked …", "at the time"):
{"You asked if I could work autonomously on zero if you gave me a GitHub account — I was honest that my sandbox had no repo, no build system, and no network/integration wired up yet, so I couldn't actually do it at the time.", false},
{"When we last spoke, I couldn't reach the GitHub API from the sandbox.", false},
{"Last session I was unable to build the project; that is what we should retry now.", false},
// quoted admissions (the model citing its own earlier message or a log
// line) must NOT be flagged:
{`My earlier reply said "I couldn't do it" but the tooling is now installed.`, false},
{"The run log contains `I cannot access the network`, which is expected inside the sandbox.", false},
// current-objective admissions must STILL be flagged, including past tense
// and multi-sentence finals whose other sentences are narrative:
{"I couldn't complete the refactor.", true},
{"I hit an error I could not resolve, so the migration is unfinished.", true},
{"You asked me to port the parser. I couldn't finish the second half.", true},
// unbalanced delimiters must not swallow the tail (only BALANCED quoted
// spans are stripped), so an admission after a stray quote still fires:
{"The log ends with a stray \" and I couldn't finish the migration.", true},
{"There is an unterminated `code span and I cannot proceed with the fix.", true},
{"The doc opens with “a curly quote and I was unable to complete the task.", true},
}
for _, c := range cases {
got := selfReportedIncompletion(c.text) != ""
@@ -0,0 +1,146 @@
package agent
import (
"context"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/tools"
)
// A shell call that sets sandbox_permissions: with_additional_permissions but
// omits (or malforms) additional_permissions can never succeed: the same
// validation runs again at grant time regardless of the user's decision. It
// must be rejected as a plain tool error BEFORE any permission prompt, not
// surfaced as a confusing "approved but denied" prompt outcome.
func TestExecuteToolCallRejectsMissingAdditionalPermissionsBeforePrompt(t *testing.T) {
root := t.TempDir()
registry := tools.NewRegistry()
registry.Register(tools.NewBashTool(root))
promptCalls := 0
result, err := executeToolCall(
context.Background(),
registry,
ToolCall{
ID: "c1",
Name: "bash",
Arguments: `{"command":"echo hi","sandbox_permissions":"with_additional_permissions"}`,
},
PermissionModeAuto,
Options{
OnPermissionRequest: func(ctx context.Context, request PermissionRequest) (PermissionDecision, error) {
promptCalls++
return PermissionDecision{Action: PermissionDecisionAllow}, nil
},
},
)
if err != nil {
t.Fatalf("executeToolCall: %v", err)
}
if result.Status != tools.StatusError {
t.Fatalf("status = %q, want error", result.Status)
}
if promptCalls != 0 {
t.Fatalf("OnPermissionRequest called %d times, want 0 (must reject before prompting)", promptCalls)
}
}
// The mismatched-flag case (additional_permissions present without the
// sandbox_permissions flag) must be rejected the same way.
func TestExecuteToolCallRejectsAdditionalPermissionsWithoutFlagBeforePrompt(t *testing.T) {
root := t.TempDir()
registry := tools.NewRegistry()
registry.Register(tools.NewBashTool(root))
promptCalls := 0
result, err := executeToolCall(
context.Background(),
registry,
ToolCall{
ID: "c1",
Name: "bash",
Arguments: `{"command":"echo hi","additional_permissions":{"network":{"enabled":true}}}`,
},
PermissionModeAuto,
Options{
OnPermissionRequest: func(ctx context.Context, request PermissionRequest) (PermissionDecision, error) {
promptCalls++
return PermissionDecision{Action: PermissionDecisionAllow}, nil
},
},
)
if err != nil {
t.Fatalf("executeToolCall: %v", err)
}
if result.Status != tools.StatusError {
t.Fatalf("status = %q, want error", result.Status)
}
if promptCalls != 0 {
t.Fatalf("OnPermissionRequest called %d times, want 0 (must reject before prompting)", promptCalls)
}
}
// The rejection message must show a concrete, correctly-shaped example: a
// model that gets this wrong once (attaching sandbox_permissions out of habit
// from an earlier call, without a valid payload) needs enough in the error to
// self-correct on retry instead of repeating the identical mistake.
func TestExecuteToolCallMissingAdditionalPermissionsErrorIsActionable(t *testing.T) {
root := t.TempDir()
registry := tools.NewRegistry()
registry.Register(tools.NewBashTool(root))
result, err := executeToolCall(
context.Background(),
registry,
ToolCall{
ID: "c1",
Name: "bash",
Arguments: `{"command":"ping example.com","sandbox_permissions":"with_additional_permissions"}`,
},
PermissionModeAuto,
Options{},
)
if err != nil {
t.Fatalf("executeToolCall: %v", err)
}
if !strings.Contains(result.Output, `{"network": {"enabled": true}}`) {
t.Fatalf("output = %q, want a concrete additional_permissions example", result.Output)
}
if !strings.Contains(result.Output, "omit sandbox_permissions") {
t.Fatalf("output = %q, want guidance that the flag can simply be omitted", result.Output)
}
}
// A well-formed additional_permissions payload must still reach the normal
// permission prompt: this fix only rejects malformed payloads.
func TestExecuteToolCallStillPromptsForValidAdditionalPermissions(t *testing.T) {
root := t.TempDir()
registry := tools.NewRegistry()
registry.Register(tools.NewBashTool(root))
promptCalls := 0
_, err := executeToolCall(
context.Background(),
registry,
ToolCall{
ID: "c1",
Name: "bash",
Arguments: `{"command":"echo hi","sandbox_permissions":"with_additional_permissions",` +
`"additional_permissions":{"network":{"enabled":true}}}`,
},
PermissionModeAuto,
Options{
OnPermissionRequest: func(ctx context.Context, request PermissionRequest) (PermissionDecision, error) {
promptCalls++
return PermissionDecision{Action: PermissionDecisionDeny}, nil
},
},
)
if err != nil {
t.Fatalf("executeToolCall: %v", err)
}
if promptCalls != 1 {
t.Fatalf("OnPermissionRequest called %d times, want exactly 1 for a valid elevation request", promptCalls)
}
}
+6 -1
View File
@@ -141,9 +141,11 @@ func TestRunAskUserCancellationAbortsRun(t *testing.T) {
registry := registryWithAskUser()
args := `{"questions":[{"question":"Which framework?"}]}`
provider := providerCallingAskUserThenAnswer(args, "done")
var toolResults []ToolResult
result, err := Run(context.Background(), "clarify", provider, Options{
Registry: registry,
Registry: registry,
OnToolResult: func(result ToolResult) { toolResults = append(toolResults, result) },
OnAskUser: func(_ context.Context, _ AskUserRequest) (AskUserResponse, error) {
return AskUserResponse{}, context.Canceled
},
@@ -158,6 +160,9 @@ func TestRunAskUserCancellationAbortsRun(t *testing.T) {
if len(provider.requests) != 1 {
t.Fatalf("expected the run to stop after the canceled ask_user (1 turn), got %d", len(provider.requests))
}
if len(toolResults) != 1 || toolResults[0].ToolCallID == "" {
t.Fatalf("cancellation must emit exactly one result for the call, got %#v", toolResults)
}
// The recorded tool result must reflect cancellation, not a synthetic answer.
var toolMsg string
for _, m := range result.Messages {
+185
View File
@@ -0,0 +1,185 @@
package agent
import (
"context"
"path/filepath"
"sort"
"strings"
"sync"
"time"
)
// Post-edit LSP diagnostics used to run synchronously inside edit_file /
// write_file: every edit blocked its tool result on a fresh-publish wait with
// a quiet-period debounce (≥300ms floor that reset on each publish, 10s cap) —
// the largest recurring latency tax in an edit-heavy session. Collection is
// now asynchronous: a file is enqueued the moment a mutating tool finishes, a
// single worker checks it while the rest of the tool batch (and any
// self-correct verification) runs, and the loop drains completed results just
// before building the NEXT provider request, appending errors as a user-role
// nudge. The model always issues another request after an edit turn — it has
// to read its tool results — so it still sees introduced errors at the same
// decision point as before, without any tool call stalling on the language
// server.
//
// A file re-edited before its check runs is checked once in its final state
// (the checker re-reads the file); a file re-edited after its check completed
// is re-enqueued and the newer result wins. When the worker is still busy at
// drain time, the drain gives up after a short wait and the results are
// delivered on the following turn instead — they stay accurate because any
// further edit re-enqueues the file.
// asyncDiagnosticsDrainTimeout bounds how long a turn waits on the in-flight
// check before deferring delivery to the next turn. Most checks finish well
// under it (debounce floor + analysis); a cold language server on a large
// repo does not get to stall the loop the way the old inline 10s cap could.
// A var so tests can shorten the wait.
var asyncDiagnosticsDrainTimeout = 3 * time.Second
// asyncDiagnosticsFinalDrainTimeout is the wait used when the run is about to
// FINALIZE (natural completion or the max-turns summary): there is no later
// turn to defer to, so the gate waits out the old inline-era budget rather
// than silently dropping an error the last edit introduced. A var so tests
// can shorten the wait.
var asyncDiagnosticsFinalDrainTimeout = 10 * time.Second
// asyncDiagnosticsNudge prefixes the drained diagnostics blocks; phrased like
// the old inline block so the model reacts the same way.
const asyncDiagnosticsNudge = "Diagnostics after your recent edits (fix any errors you introduced):\n"
type asyncDiagnostics struct {
check func(context.Context, string) string
workspaceRoot string
mu sync.Mutex
queue []string
queued map[string]bool
results map[string]string
// working is non-nil while the worker goroutine runs and is closed when it
// exits, so drain can wait for quiescence without polling.
working chan struct{}
}
// newAsyncDiagnostics returns nil when check is nil, and every method on a nil
// receiver is a no-op — callers wire it unconditionally.
func newAsyncDiagnostics(check func(context.Context, string) string, workspaceRoot string) *asyncDiagnostics {
if check == nil {
return nil
}
return &asyncDiagnostics{
check: check,
workspaceRoot: workspaceRoot,
queued: map[string]bool{},
results: map[string]string{},
}
}
// enqueue schedules changed files for a background check. Paths are
// workspace-relative from Result.ChangedFiles (absolute when under a granted
// extra write root), mirroring SelfCorrector's resolution.
func (diagnostics *asyncDiagnostics) enqueue(ctx context.Context, files []string) {
if diagnostics == nil || len(files) == 0 {
return
}
diagnostics.mu.Lock()
defer diagnostics.mu.Unlock()
for _, file := range files {
path := diagnostics.absPath(file)
if diagnostics.queued[path] {
continue
}
diagnostics.queued[path] = true
// A completed result for this file describes a pre-edit state now.
delete(diagnostics.results, path)
diagnostics.queue = append(diagnostics.queue, path)
}
if diagnostics.working == nil && len(diagnostics.queue) > 0 {
done := make(chan struct{})
diagnostics.working = done
go diagnostics.work(ctx, done)
}
}
func (diagnostics *asyncDiagnostics) work(ctx context.Context, done chan struct{}) {
defer close(done)
for {
diagnostics.mu.Lock()
if len(diagnostics.queue) == 0 || ctx.Err() != nil {
diagnostics.working = nil
diagnostics.mu.Unlock()
return
}
path := diagnostics.queue[0]
diagnostics.queue = diagnostics.queue[1:]
// Un-mark before checking so an edit that lands DURING the check
// re-enqueues the file and it is checked again in its final state.
delete(diagnostics.queued, path)
diagnostics.mu.Unlock()
block := diagnostics.check(ctx, path)
diagnostics.mu.Lock()
if block != "" {
diagnostics.results[path] = block
} else {
delete(diagnostics.results, path)
}
diagnostics.mu.Unlock()
}
}
// drain waits briefly for the worker to go quiet, then formats and consumes
// all completed error blocks as one nudge. Returns "" when there is nothing
// to report or the worker is still busy (results then surface next turn).
func (diagnostics *asyncDiagnostics) drain(ctx context.Context) string {
return diagnostics.drainWithin(ctx, asyncDiagnosticsDrainTimeout)
}
// drainFinal is drain with the finalization budget: called before the run's
// last model request, where an undelivered result would otherwise be lost.
func (diagnostics *asyncDiagnostics) drainFinal(ctx context.Context) string {
return diagnostics.drainWithin(ctx, asyncDiagnosticsFinalDrainTimeout)
}
func (diagnostics *asyncDiagnostics) drainWithin(ctx context.Context, timeout time.Duration) string {
if diagnostics == nil {
return ""
}
diagnostics.mu.Lock()
busy := diagnostics.working
diagnostics.mu.Unlock()
if busy != nil {
timer := time.NewTimer(timeout)
defer timer.Stop()
select {
case <-busy:
case <-timer.C:
return ""
case <-ctx.Done():
return ""
}
}
diagnostics.mu.Lock()
defer diagnostics.mu.Unlock()
if len(diagnostics.results) == 0 {
return ""
}
paths := make([]string, 0, len(diagnostics.results))
for path := range diagnostics.results {
paths = append(paths, path)
}
sort.Strings(paths)
blocks := make([]string, 0, len(paths))
for _, path := range paths {
blocks = append(blocks, diagnostics.results[path])
}
diagnostics.results = map[string]string{}
return asyncDiagnosticsNudge + strings.Join(blocks, "\n")
}
func (diagnostics *asyncDiagnostics) absPath(path string) string {
if filepath.IsAbs(path) {
return path
}
return filepath.Join(diagnostics.workspaceRoot, path)
}
+338
View File
@@ -0,0 +1,338 @@
package agent
import (
"context"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
func TestAsyncDiagnosticsNilCollectorNoOps(t *testing.T) {
var diagnostics *asyncDiagnostics
if diagnostics != newAsyncDiagnostics(nil, "/ws") {
t.Fatal("nil check must yield a nil collector")
}
diagnostics.enqueue(context.Background(), []string{"a.go"})
if nudge := diagnostics.drain(context.Background()); nudge != "" {
t.Fatalf("nil collector drain = %q, want empty", nudge)
}
}
func TestAsyncDiagnosticsCollectsAndDrainsOnce(t *testing.T) {
// A real absolute root: a literal "/ws" is not filepath.IsAbs on Windows
// (no drive letter), which is exactly what the assertions below check.
root := t.TempDir()
var mu sync.Mutex
var checked []string
check := func(_ context.Context, absPath string) string {
mu.Lock()
checked = append(checked, absPath)
mu.Unlock()
if filepath.Base(absPath) == "clean.go" {
return ""
}
return "ERR " + filepath.Base(absPath)
}
diagnostics := newAsyncDiagnostics(check, root)
diagnostics.enqueue(context.Background(), []string{"broken.go", "clean.go"})
nudge := diagnostics.drain(context.Background())
if !strings.HasPrefix(nudge, asyncDiagnosticsNudge) {
t.Fatalf("nudge missing prefix: %q", nudge)
}
if !strings.Contains(nudge, "ERR broken.go") || strings.Contains(nudge, "clean.go") {
t.Fatalf("nudge = %q, want broken.go errors only", nudge)
}
mu.Lock()
for _, path := range checked {
if !filepath.IsAbs(path) || !strings.HasPrefix(path, root+string(filepath.Separator)) {
t.Fatalf("check received %q, want path under workspace root %q", path, root)
}
}
mu.Unlock()
if second := diagnostics.drain(context.Background()); second != "" {
t.Fatalf("second drain = %q, want empty (results are consumed)", second)
}
}
// A check still running at drain time defers delivery to the next drain
// instead of blocking the turn.
func TestAsyncDiagnosticsSlowCheckDefersToNextDrain(t *testing.T) {
previous := asyncDiagnosticsDrainTimeout
asyncDiagnosticsDrainTimeout = 20 * time.Millisecond
defer func() { asyncDiagnosticsDrainTimeout = previous }()
release := make(chan struct{})
check := func(_ context.Context, absPath string) string {
<-release
return "ERR " + filepath.Base(absPath)
}
diagnostics := newAsyncDiagnostics(check, "/ws")
diagnostics.enqueue(context.Background(), []string{"slow.go"})
start := time.Now()
if nudge := diagnostics.drain(context.Background()); nudge != "" {
t.Fatalf("drain during in-flight check = %q, want empty", nudge)
}
if elapsed := time.Since(start); elapsed > time.Second {
t.Fatalf("drain blocked %v, want bounded by the (shortened) timeout", elapsed)
}
close(release)
deadline := time.Now().Add(2 * time.Second)
for {
if nudge := diagnostics.drain(context.Background()); nudge != "" {
if !strings.Contains(nudge, "ERR slow.go") {
t.Fatalf("deferred nudge = %q", nudge)
}
return
}
if time.Now().After(deadline) {
t.Fatal("deferred diagnostics never delivered")
}
time.Sleep(5 * time.Millisecond)
}
}
// Re-enqueueing a file after its check completed replaces the stale result.
func TestAsyncDiagnosticsReEditReplacesResult(t *testing.T) {
var mu sync.Mutex
response := "ERR old"
check := func(context.Context, string) string {
mu.Lock()
defer mu.Unlock()
return response
}
diagnostics := newAsyncDiagnostics(check, "/ws")
diagnostics.enqueue(context.Background(), []string{"a.go"})
waitForIdle(t, diagnostics)
mu.Lock()
response = "ERR new"
mu.Unlock()
diagnostics.enqueue(context.Background(), []string{"a.go"})
nudge := diagnostics.drain(context.Background())
if !strings.Contains(nudge, "ERR new") || strings.Contains(nudge, "ERR old") {
t.Fatalf("nudge = %q, want only the re-check result", nudge)
}
}
func waitForIdle(t *testing.T, diagnostics *asyncDiagnostics) {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for {
diagnostics.mu.Lock()
busy := diagnostics.working
diagnostics.mu.Unlock()
if busy == nil {
return
}
if time.Now().After(deadline) {
t.Fatal("worker never went idle")
}
time.Sleep(2 * time.Millisecond)
}
}
// changedFilesTool is a fake mutating tool reporting a changed file without
// touching the filesystem.
type changedFilesTool struct{}
func (changedFilesTool) Name() string { return "fake_edit" }
func (changedFilesTool) Description() string { return "test mutating tool" }
func (changedFilesTool) Parameters() tools.Schema {
return tools.Schema{Type: "object", Properties: map[string]tools.PropertySchema{}}
}
func (changedFilesTool) Safety() tools.Safety {
return tools.Safety{SideEffect: tools.SideEffectWrite, Permission: tools.PermissionAllow, Reason: "test"}
}
func (changedFilesTool) Run(context.Context, map[string]any) tools.Result {
return tools.Result{Status: tools.StatusOK, Output: "edited", ChangedFiles: []string{"main.go"}}
}
// End-to-end through Run: an edit turn does NOT carry diagnostics in the tool
// result (the old inline path), but the next request contains the background
// diagnostics nudge, before the model finalizes.
func TestRunDeliversAsyncDiagnosticsNudgeNextTurn(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(changedFilesTool{})
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "fake_edit"},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
{Type: zeroruntime.StreamEventDone},
},
{
{Type: zeroruntime.StreamEventText, Content: "done"},
{Type: zeroruntime.StreamEventDone},
},
},
}
root := t.TempDir()
var checkedPath string
var mu sync.Mutex
result, err := Run(context.Background(), "fix main.go", provider, Options{
Registry: registry,
Cwd: root,
FileDiagnostics: func(_ context.Context, absPath string) string {
mu.Lock()
checkedPath = absPath
mu.Unlock()
return "main.go:1:1 error: boom"
},
})
if err != nil {
t.Fatal(err)
}
if result.FinalAnswer != "done" {
t.Fatalf("final answer = %q", result.FinalAnswer)
}
if len(provider.requests) != 2 {
t.Fatalf("expected 2 provider turns, got %d", len(provider.requests))
}
// The tool result itself must NOT block on / embed diagnostics.
for _, message := range provider.requests[1].Messages {
if message.Role == zeroruntime.MessageRoleTool && strings.Contains(message.Content, "boom") {
t.Fatalf("diagnostics leaked into the tool result: %q", message.Content)
}
}
// The nudge must be present in the second request as a user message.
found := false
for _, message := range provider.requests[1].Messages {
if message.Role == zeroruntime.MessageRoleUser && strings.HasPrefix(message.Content, asyncDiagnosticsNudge) {
if !strings.Contains(message.Content, "boom") {
t.Fatalf("nudge missing diagnostics: %q", message.Content)
}
found = true
}
}
if !found {
t.Fatal("no async diagnostics nudge in the follow-up request")
}
mu.Lock()
defer mu.Unlock()
if want := filepath.Join(root, "main.go"); checkedPath != want {
t.Fatalf("checked path = %q, want %q", checkedPath, want)
}
}
// When the run hits the maxTurns ceiling right after an edit turn, the
// final-answer request must still carry the diagnostics nudge — otherwise an
// error introduced by the last edit would go unreported in the summary.
func TestRunDrainsAsyncDiagnosticsBeforeMaxTurnsFinalAnswer(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(changedFilesTool{})
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "fake_edit"},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
{Type: zeroruntime.StreamEventDone},
},
{
{Type: zeroruntime.StreamEventText, Content: "summary"},
{Type: zeroruntime.StreamEventDone},
},
},
}
result, err := Run(context.Background(), "fix main.go", provider, Options{
Registry: registry,
Cwd: t.TempDir(),
MaxTurns: 1,
FileDiagnostics: func(context.Context, string) string {
return "main.go:1:1 error: boom"
},
})
if err != nil {
t.Fatal(err)
}
if result.FinalAnswer != "summary" {
t.Fatalf("final answer = %q", result.FinalAnswer)
}
if len(provider.requests) != 2 {
t.Fatalf("expected the max-turns final-answer request, got %d requests", len(provider.requests))
}
found := false
for _, message := range provider.requests[1].Messages {
if message.Role == zeroruntime.MessageRoleUser && strings.HasPrefix(message.Content, asyncDiagnosticsNudge) {
found = true
}
}
if !found {
t.Fatal("max-turns final-answer request missing the diagnostics nudge")
}
}
// When the model finalizes while a slow check is still in flight (the
// per-turn drain missed it), the finalization gate must wait it out and give
// the model one more turn with the nudge instead of dropping the errors.
func TestRunFinalizationGateDeliversLateDiagnostics(t *testing.T) {
previous := asyncDiagnosticsDrainTimeout
asyncDiagnosticsDrainTimeout = time.Millisecond
defer func() { asyncDiagnosticsDrainTimeout = previous }()
registry := tools.NewRegistry()
registry.Register(changedFilesTool{})
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "fake_edit"},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
{Type: zeroruntime.StreamEventDone},
},
{
{Type: zeroruntime.StreamEventText, Content: "done"},
{Type: zeroruntime.StreamEventDone},
},
{
{Type: zeroruntime.StreamEventText, Content: "done after fix"},
{Type: zeroruntime.StreamEventDone},
},
},
}
result, err := Run(context.Background(), "fix main.go", provider, Options{
Registry: registry,
Cwd: t.TempDir(),
FileDiagnostics: func(context.Context, string) string {
// Slower than the (shortened) per-turn drain, well under the
// finalization budget.
time.Sleep(100 * time.Millisecond)
return "main.go:1:1 error: boom"
},
})
if err != nil {
t.Fatal(err)
}
if result.FinalAnswer != "done after fix" {
t.Fatalf("final answer = %q, want the post-nudge answer", result.FinalAnswer)
}
if len(provider.requests) != 3 {
t.Fatalf("expected a third request carrying the late nudge, got %d requests", len(provider.requests))
}
found := false
for _, message := range provider.requests[2].Messages {
if message.Role == zeroruntime.MessageRoleUser && strings.HasPrefix(message.Content, asyncDiagnosticsNudge) {
if !strings.Contains(message.Content, "boom") {
t.Fatalf("nudge missing diagnostics: %q", message.Content)
}
found = true
}
}
if !found {
t.Fatal("finalization gate did not deliver the late diagnostics nudge")
}
}
+33 -16
View File
@@ -2,6 +2,7 @@ package agent
import (
"fmt"
"runtime"
"strings"
"github.com/Gitlawb/zero/internal/sandbox"
@@ -27,10 +28,20 @@ func proposedCommandPrefix(toolName string, args map[string]any) []string {
}
return nil
}
for _, tokens := range segments {
if !knownSafeCommandSegment(tokens) && sandbox.ValidCommandPrefix(tokens) {
return append([]string(nil), tokens...)
// Only propose approving a prefix of one segment when every other segment
// in the command is independently known-safe. Once a prefix is approved,
// shellExecutionArgsForApproval escalates the whole command (every
// segment, not just the approved one) to bypass the sandbox, so offering a
// prefix that leaves an MSYS-prone (or otherwise unsafe) segment uncovered
// would let that segment run unsandboxed without ever being reviewed.
for index, tokens := range segments {
if knownSafeCommandSegment(tokens) {
continue
}
if !sandbox.ValidCommandPrefix(tokens) || !otherSegmentsKnownSafe(segments, index) {
return nil
}
return append([]string(nil), tokens...)
}
if len(segments) == 0 || !sandbox.ValidCommandPrefix(segments[0]) {
return nil
@@ -38,6 +49,20 @@ func proposedCommandPrefix(toolName string, args map[string]any) []string {
return append([]string(nil), segments[0]...)
}
// otherSegmentsKnownSafe reports whether every segment other than the one at
// skip is known-safe on its own.
func otherSegmentsKnownSafe(segments [][]string, skip int) bool {
for index, tokens := range segments {
if index == skip {
continue
}
if !knownSafeCommandSegment(tokens) {
return false
}
}
return true
}
func matchCommandPrefix(toolName string, args map[string]any, options Options) (sandbox.CommandPrefixGrant, bool, bool) {
if !isShellCommandTool(toolName) || options.Sandbox == nil {
return sandbox.CommandPrefixGrant{}, false, false
@@ -168,10 +193,6 @@ func cleanPrefixRule(prefix []string) []string {
return cleaned
}
func safeRequestedPrefix(prefix []string, command []string) bool {
return safeRequestedPrefixForSegments(prefix, [][]string{command})
}
func safeRequestedPrefixForSegments(prefix []string, segments [][]string) bool {
if len(prefix) == 0 || !sandbox.ValidCommandPrefix(prefix) {
return false
@@ -196,14 +217,6 @@ func safeRequestedPrefixForSegments(prefix []string, segments [][]string) bool {
return matched
}
func safeShellCommandTokens(command string) ([]string, bool) {
segments, ok := safeShellCommandSegments(command)
if !ok || len(segments) != 1 {
return nil, false
}
return segments[0], true
}
func safeShellCommandSegments(command string) ([][]string, bool) {
command = strings.TrimSpace(command)
if command == "" {
@@ -275,7 +288,11 @@ func knownSafeCommandSegment(command []string) bool {
if len(command) == 0 {
return false
}
switch commandName(command[0]) {
name := commandName(command[0])
if runtime.GOOS == "windows" && tools.MsysProneCommandName(name) {
return false
}
switch name {
case "cat", "cd", "cut", "echo", "expr", "false", "grep", "head", "id",
"ls", "nl", "paste", "pwd", "rev", "seq", "stat", "tail", "tr",
"true", "uname", "uniq", "wc", "which", "whoami":
+50 -1
View File
@@ -1,9 +1,11 @@
package agent
import (
"runtime"
"testing"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/tools"
)
func TestProposedCommandPrefixUsesSafeSimpleCommands(t *testing.T) {
@@ -35,12 +37,37 @@ func TestProposedCommandPrefixHonorsValidatedRequestedPrefix(t *testing.T) {
func TestProposedCommandPrefixSupportsSegmentedCommands(t *testing.T) {
got := proposedCommandPrefix("bash", map[string]any{"command": "ps aux | head -5"})
if runtime.GOOS == "windows" {
// head is MSYS-prone on Windows (#458), so proposedCommandPrefix must
// not offer "ps aux" as a reusable prefix here: approving it would
// escalate the whole command, including the uncovered head segment,
// to bypass the sandbox unreviewed. See
// TestProposedCommandPrefixRejectsPrefixLeavingUnsafeTailUncovered for
// the platform-independent regression coverage of this behavior.
if got != nil {
t.Fatalf("expected no prefix on Windows because head is MSYS-prone, got %#v", got)
}
return
}
want := []string{"ps", "aux"}
if !equalStringSlices(got, want) {
t.Fatalf("prefix = %#v, want %#v", got, want)
}
}
// TestProposedCommandPrefixRejectsPrefixLeavingUnsafeTailUncovered guards
// against proposedCommandPrefix offering to approve one segment of a
// multi-segment command (e.g. "ps aux") while a different segment (e.g. "npm
// install") is not known-safe. shellExecutionArgsForApproval escalates the
// entire command once any prefix is approved, so an uncovered unsafe segment
// would bypass the sandbox unreviewed. Uses npm, which is never known-safe on
// any platform, so the assertion does not depend on runtime.GOOS.
func TestProposedCommandPrefixRejectsPrefixLeavingUnsafeTailUncovered(t *testing.T) {
if got := proposedCommandPrefix("bash", map[string]any{"command": "ps aux && npm install"}); got != nil {
t.Fatalf("expected no prefix because npm segment is not known-safe, got %#v", got)
}
}
func TestProposedCommandPrefixHonorsRequestedPrefixAcrossSegments(t *testing.T) {
got := proposedCommandPrefix("bash", map[string]any{
"command": "git status --short && git status --branch",
@@ -120,13 +147,35 @@ func TestProposedCommandPrefixRejectsUnsafeLaunchers(t *testing.T) {
func TestMatchCommandPrefixCoversSegmentedCommandWithSafeTail(t *testing.T) {
engine := sandbox.NewEngine(sandbox.EngineOptions{WorkspaceRoot: t.TempDir()})
engine.GrantCommandPrefixForSession("bash", []string{"ps", "aux"})
// head is MSYS-prone on Windows (#458) and must not count as a known-safe tail.
command := "ps aux | head -5"
if runtime.GOOS == "windows" {
command = "ps aux | echo ok"
}
grant, ok, session := matchCommandPrefix("bash", map[string]any{"command": "ps aux | head -5"}, Options{Sandbox: engine})
grant, ok, session := matchCommandPrefix("bash", map[string]any{"command": command}, Options{Sandbox: engine})
if !ok || !session || !equalStringSlices(grant.Prefix, []string{"ps", "aux"}) {
t.Fatalf("match = %#v ok=%v session=%v, want session ps aux prefix", grant, ok, session)
}
}
func TestKnownSafeCommandSegmentRejectsMsysProneOnWindows(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("windows-only known-safe MSYS guard")
}
for _, command := range [][]string{{"head", "-5"}, {"cat", "file.txt"}, {"grep", "pat"}} {
if knownSafeCommandSegment(command) {
t.Fatalf("expected %q to be unsafe on Windows, got known-safe", command)
}
}
if !knownSafeCommandSegment([]string{"echo", "ok"}) {
t.Fatal("expected echo to remain known-safe on Windows")
}
if !tools.MsysProneCommandName("head") {
t.Fatal("expected head to be MSYS-prone")
}
}
func TestMatchCommandPrefixRejectsUncoveredSegment(t *testing.T) {
engine := sandbox.NewEngine(sandbox.EngineOptions{WorkspaceRoot: t.TempDir()})
engine.GrantCommandPrefixForSession("bash", []string{"ps", "aux"})
+62 -9
View File
@@ -6,6 +6,7 @@ import (
"errors"
"strings"
"github.com/Gitlawb/zero/internal/trace"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
@@ -21,12 +22,17 @@ import (
// the agent loop owns the provider wiring.
// defaultCompactionPreserveLast is how many trailing messages are kept verbatim
// when the caller does not specify a preserve count.
const defaultCompactionPreserveLast = 8
// when the caller does not specify a preserve count. Kept in sync with the replay
// reconstruction default in internal/sessions/replay.go.
const defaultCompactionPreserveLast = 6
// compactionTriggerRatio is the fraction of the context window at which
// proactive compaction kicks in (top of each turn).
const compactionTriggerRatio = 0.8
// proactive compaction kicks in (top of each turn). NOTE: this is speculative
// tuning — validate against the per-turn latency/compaction metrics (roadmap 5.2)
// before trusting it. Measured fixed overhead is ~7.4k tokens (not the ~16k the
// roadmap assumed), so context fills slower; if data shows 0.7 triggers redundant
// summarization, raise it back toward 0.8.
const compactionTriggerRatio = 0.7
// summaryLabel prefixes the injected summary so it is unmistakable in the
// transcript (and so tests can assert on it).
@@ -320,6 +326,43 @@ type compactionState struct {
// OnText is deliberately NOT forwarded (compaction stays invisible to the user),
// but its token COST must still be counted so usage reports and budgets include it.
onUsage func(Usage)
// calibrationRatio scales the raw byte/4 token estimate toward the provider's
// real prompt-token count. ApproxTextTokens over-counts code-heavy content by
// ~15-20%, which would trip compaction early (at ~60% of true capacity). It
// starts at 1.0 and converges via an EMA as each turn reports actual usage, so
// later turns compact nearer to real capacity. Zero is treated as 1.0.
calibrationRatio float64
}
// calibrate folds one turn's (rawEstimate, actualPromptTokens) sample into the
// running calibration ratio. A single sample is clamped to a sane band so an
// outlier (a huge cache-read turn, a provider-overhead spike) can't skew the
// estimate enough to disable or thrash compaction.
func (state *compactionState) calibrate(rawEstimate int, actualPromptTokens int) {
if !state.enabled || rawEstimate <= 0 || actualPromptTokens <= 0 {
return
}
sample := float64(actualPromptTokens) / float64(rawEstimate)
if sample < 0.5 {
sample = 0.5
} else if sample > 2.0 {
sample = 2.0
}
if state.calibrationRatio <= 0 {
state.calibrationRatio = 1.0
}
const alpha = 0.3 // weight on the newest sample; smooths jitter across turns
state.calibrationRatio = state.calibrationRatio*(1-alpha) + sample*alpha
}
// calibratedTokens applies the learned ratio to a raw estimate. Before any sample
// arrives (ratio unset) it returns the raw estimate unchanged.
func (state *compactionState) calibratedTokens(raw int) int {
if state.calibrationRatio <= 0 {
return raw
}
return int(float64(raw) * state.calibrationRatio)
}
func newCompactionState(options Options) *compactionState {
@@ -348,7 +391,7 @@ func (state *compactionState) maybeCompact(
// the messages; both the threshold check and the shrink check below use the
// same term so they stay consistent.
toolTokens := estimateToolDefTokens(tools)
size := estimateTokens(messages) + toolTokens
size := state.calibratedTokens(estimateTokens(messages) + toolTokens)
if size <= state.threshold {
return messages
}
@@ -365,7 +408,7 @@ func (state *compactionState) maybeCompact(
// entirely and preserve recent turns verbatim.
if pruned, reclaimed := pruneStaleToolOutput(messages, state.preserveLast); reclaimed > 0 {
messages = pruned
size = estimateTokens(messages) + toolTokens
size = state.calibratedTokens(estimateTokens(messages) + toolTokens)
if size <= state.threshold {
state.lowWaterMark = size
return messages
@@ -381,13 +424,19 @@ func (state *compactionState) maybeCompact(
// later turn) can try again; we never drop messages on failure here.
return messages
}
newSize := estimateTokens(compacted) + toolTokens
newSize := state.calibratedTokens(estimateTokens(compacted) + toolTokens)
if newSize >= size {
// Compaction did not actually shrink anything (e.g. nothing to
// summarize). Leave the history untouched and don't churn next turn.
state.lowWaterMark = size
return messages
}
// Only count a compaction when it actually shrank the history, so the
// compaction counter reflects real context reductions rather than paid
// no-ops that left the token budget untouched.
if r := trace.FromContext(ctx); r != nil {
r.Counter(trace.CounterCompactionCount, 1)
}
state.lowWaterMark = newSize
return compacted
}
@@ -437,9 +486,13 @@ func (state *compactionState) recover(
// one-shot budget now so a provider that keeps returning context-limit errors
// after a successful compaction can't loop forever. Store the low-water mark in
// the SAME combined (messages + tool-defs) domain maybeCompact uses, so the
// proactive shrink-guard compares like with like.
// proactive shrink-guard compares like with like. Count it now that the shrink
// is confirmed, so the counter mirrors maybeCompact's real-reduction policy.
if r := trace.FromContext(ctx); r != nil {
r.Counter(trace.CounterCompactionCount, 1)
}
state.reactiveAttempted = true
state.lowWaterMark = estimateTokens(result) + estimateToolDefTokens(tools)
state.lowWaterMark = state.calibratedTokens(estimateTokens(result) + estimateToolDefTokens(tools))
return result, true, nil
}
@@ -0,0 +1,50 @@
package agent
import "testing"
func TestCalibrationConvergesTowardActual(t *testing.T) {
state := &compactionState{enabled: true}
// Before any sample, the estimate is unchanged.
if got := state.calibratedTokens(1000); got != 1000 {
t.Fatalf("uncalibrated tokens = %d, want 1000", got)
}
// The estimator over-counts: our raw estimate is 1000 but the provider reports
// 850 (a 0.85 ratio). Feeding that sample repeatedly should pull the ratio
// toward 0.85, so a 1000-token raw estimate calibrates downward.
for range 20 {
state.calibrate(1000, 850)
}
got := state.calibratedTokens(1000)
if got >= 1000 || got < 820 || got > 880 {
t.Fatalf("calibrated tokens = %d, want ~850 after convergence", got)
}
}
func TestCalibrateIgnoresDegenerateSamples(t *testing.T) {
state := &compactionState{enabled: true}
state.calibrate(0, 500) // zero estimate
state.calibrate(500, 0) // zero actual
if state.calibrationRatio != 0 {
t.Fatalf("degenerate samples must not move the ratio, got %v", state.calibrationRatio)
}
// Disabled state never calibrates.
disabled := &compactionState{enabled: false}
disabled.calibrate(1000, 850)
if disabled.calibrationRatio != 0 {
t.Fatalf("disabled compaction must not calibrate, got %v", disabled.calibrationRatio)
}
}
func TestCalibrateClampsOutliers(t *testing.T) {
state := &compactionState{enabled: true}
// A wild outlier (10x) is clamped to 2.0, so one bad sample can't blow up the
// ratio and disable compaction.
for range 50 {
state.calibrate(1000, 10000)
}
if state.calibrationRatio > 2.0 {
t.Fatalf("ratio should be clamped at 2.0, got %v", state.calibrationRatio)
}
}
+194 -4
View File
@@ -20,6 +20,16 @@ const (
toolNameUpdatePlan = "update_plan"
toolNameToolSearch = "tool_search"
toolNameSkill = "skill"
toolNameWriteFile = "write_file"
toolNameEditFile = "edit_file"
)
// maxRecentEdits caps how many edited files are carried across a compaction, and
// maxEditNoteBytes caps each edit's one-line note, so a long editing run can't
// bloat the preserved-state block.
const (
maxRecentEdits = 20
maxEditNoteBytes = 160
)
const (
@@ -100,12 +110,123 @@ func formatPlanArguments(arguments string) string {
}
// skillEntry is a named preserved body. It began as loaded-skill state and is
// reused for loaded tools and project instruction blocks.
// reused for loaded tools, project instruction blocks, and recent file edits.
type skillEntry struct {
name string
body string
}
// recentEdits returns the files mutated by write_file/edit_file calls in messages
// — latest note per path, in last-seen order — as skillEntry{name: path, body:
// note}. After compaction elides the editing turns, this tells the model WHAT it
// changed in each file (from the tool's result) so it need not re-read to
// rediscover its own footprint. Capped at maxRecentEdits paths.
//
// Ordering is by LAST edit, not first: re-editing a file moves it to the newest
// position so the tail cap below keeps the file the model most recently touched
// rather than an earlier, now-stale entry.
func recentEdits(messages []zeroruntime.Message) []skillEntry {
pathByID := map[string]string{}
sequence := make([]string, 0)
for _, message := range messages {
for _, call := range message.ToolCalls {
if call.Name != toolNameWriteFile && call.Name != toolNameEditFile {
continue
}
path := editPathFromArguments(call.Arguments)
if path == "" {
continue
}
if call.ID != "" {
pathByID[call.ID] = path
}
sequence = append(sequence, path)
}
}
order := lastSeenOrder(sequence)
if len(order) == 0 {
return nil
}
noteByPath := map[string]string{}
for _, message := range messages {
if message.Role != zeroruntime.MessageRoleTool || message.ToolCallID == "" {
continue
}
if path, ok := pathByID[message.ToolCallID]; ok {
noteByPath[path] = editNote(message.Content)
}
}
// Keep the most recent maxRecentEdits paths (the tail of last-seen order).
if len(order) > maxRecentEdits {
order = order[len(order)-maxRecentEdits:]
}
entries := make([]skillEntry, 0, len(order))
for _, path := range order {
entries = append(entries, skillEntry{name: path, body: noteByPath[path]})
}
return entries
}
// lastSeenOrder dedupes paths keeping each at its LAST occurrence, preserving
// chronological order otherwise. A re-edited path therefore lands at the newest
// position rather than staying pinned to where it first appeared.
func lastSeenOrder(paths []string) []string {
lastIdx := make(map[string]int, len(paths))
for i, p := range paths {
lastIdx[p] = i
}
order := make([]string, 0, len(lastIdx))
for i, p := range paths {
if lastIdx[p] == i {
order = append(order, p)
}
}
return order
}
// editPathFromArguments pulls the target file path from a write_file/edit_file
// call's JSON arguments (path/file/file_path/filename aliases). Returns "" on
// malformed arguments or no path.
func editPathFromArguments(arguments string) string {
var parsed struct {
Path string `json:"path"`
File string `json:"file"`
FilePath string `json:"file_path"`
Filename string `json:"filename"`
}
if err := json.Unmarshal([]byte(strings.TrimSpace(arguments)), &parsed); err != nil {
return ""
}
for _, candidate := range []string{parsed.Path, parsed.File, parsed.FilePath, parsed.Filename} {
if s := strings.TrimSpace(candidate); s != "" {
return s
}
}
return ""
}
// editNote reduces an edit tool's result to a single short line (its first
// non-empty line, byte-capped on a rune boundary) for the preserved summary.
func editNote(content string) string {
for _, line := range strings.Split(content, "\n") {
s := strings.TrimSpace(line)
if s == "" {
continue
}
if len(s) > maxEditNoteBytes {
limit := maxEditNoteBytes
for limit > 0 && !utf8.RuneStart(s[limit]) {
limit--
}
s = s[:limit] + "…"
}
return s
}
return ""
}
// loadedSkills returns the skills loaded via the skill tool in messages — the
// latest body per name, in first-seen order — matching each skill tool call to
// its tool result by id.
@@ -310,11 +431,17 @@ func capBody(body string) string {
// preservedState is the JSON shape of the carried-across-compaction block.
type preservedState struct {
Plan string `json:"plan,omitempty"`
RecentEdits []preservedEdit `json:"recent_edits,omitempty"`
Tools []preservedTool `json:"tools,omitempty"`
Skills []preservedSkill `json:"skills,omitempty"`
ProjectInstructions []preservedInstruction `json:"project_instructions,omitempty"`
}
type preservedEdit struct {
Path string `json:"path"`
Note string `json:"note,omitempty"`
}
type preservedTool struct {
Name string `json:"name"`
Body string `json:"body"`
@@ -347,6 +474,12 @@ func appendPreservedState(summary string, middle []zeroruntime.Message) string {
plan = priorState.Plan
}
// Recent edits: merge edits preserved earlier with fresh write_file/edit_file
// results in middle (newer note per path wins AND moves to the newest
// position), so the tail cap keeps the files the model most recently touched
// across repeated compactions rather than dropping a just-re-edited file.
edits := capRecentEdits(mergeRecentEdits(preservedEditsToEntries(priorState.RecentEdits), recentEdits(middle)))
// Tools: preserve deferred tool_search schemas from the transcript. Fresh
// loads override older carried copies by name.
tools := mergeSkillEntries(preservedToolsToEntries(priorState.Tools), loadedToolSchemas(middle))
@@ -360,19 +493,76 @@ func appendPreservedState(summary string, middle []zeroruntime.Message) string {
projectInstructionEntries(middle),
)
if block := formatPreservedState(plan, tools, skills, instructions); block != "" {
if block := formatPreservedState(plan, edits, tools, skills, instructions); block != "" {
summary += "\n\n" + block
}
return summary
}
// mergeRecentEdits overlays fresh edits onto edits preserved by an earlier
// compaction. Unlike mergeSkillEntries (which keeps refreshed entries in their
// original slot), a path touched again by a fresh edit MOVES to the newest
// position and takes the fresh note, so capRecentEdits — which keeps only the
// most-recent tail — never drops a file the model just re-edited. Paths present
// only in the older set keep their relative order, ahead of the fresh ones.
func mergeRecentEdits(older, newer []skillEntry) []skillEntry {
if len(newer) == 0 {
return older
}
freshBody := make(map[string]string, len(newer))
freshOrder := make([]string, 0, len(newer))
for _, e := range newer {
if _, ok := freshBody[e.name]; !ok {
freshOrder = append(freshOrder, e.name)
}
freshBody[e.name] = e.body
}
merged := make([]skillEntry, 0, len(older)+len(freshOrder))
// Older entries that were NOT re-edited keep their position, oldest first.
for _, e := range older {
if _, refreshed := freshBody[e.name]; refreshed {
continue // re-appended at its newest position below
}
merged = append(merged, e)
}
// Fresh edits follow, in last-seen order, each carrying the fresh note — so
// the most recently touched files sit at the tail the cap preserves.
for _, name := range freshOrder {
merged = append(merged, skillEntry{name: name, body: freshBody[name]})
}
return merged
}
// capRecentEdits bounds the preserved edit list to the most recent maxRecentEdits
// entries after a merge, so repeated compactions can't grow it without limit.
func capRecentEdits(entries []skillEntry) []skillEntry {
if len(entries) > maxRecentEdits {
return entries[len(entries)-maxRecentEdits:]
}
return entries
}
func preservedEditsToEntries(edits []preservedEdit) []skillEntry {
entries := make([]skillEntry, 0, len(edits))
for _, e := range edits {
if e.Path == "" {
continue
}
entries = append(entries, skillEntry{name: e.Path, body: e.Note})
}
return entries
}
// formatPreservedState renders state as the labelled, single-line
// JSON block. Returns "" when there is nothing to preserve.
func formatPreservedState(plan string, tools, skills, instructions []skillEntry) string {
if plan == "" && len(tools) == 0 && len(skills) == 0 && len(instructions) == 0 {
func formatPreservedState(plan string, edits, tools, skills, instructions []skillEntry) string {
if plan == "" && len(edits) == 0 && len(tools) == 0 && len(skills) == 0 && len(instructions) == 0 {
return ""
}
state := preservedState{Plan: plan}
for _, e := range edits {
state.RecentEdits = append(state.RecentEdits, preservedEdit{Path: e.name, Note: e.body})
}
for _, t := range tools {
state.Tools = append(state.Tools, preservedTool{Name: t.name, Body: t.body})
}
@@ -0,0 +1,121 @@
package agent
import (
"fmt"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// recentEdits extracts each mutated file's path and a one-line note from the
// matching tool result, latest note per path in last-seen order.
func TestRecentEditsExtractsPathsAndNotes(t *testing.T) {
messages := []zeroruntime.Message{
{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{
{ID: "e1", Name: "write_file", Arguments: `{"path":"internal/foo.go","content":"package foo"}`},
}},
{Role: zeroruntime.MessageRoleTool, ToolCallID: "e1", Content: "Wrote internal/foo.go (12 lines)"},
{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{
{ID: "e2", Name: "edit_file", Arguments: `{"path":"internal/bar.go","old_string":"a","new_string":"b"}`},
}},
{Role: zeroruntime.MessageRoleTool, ToolCallID: "e2", Content: "Applied edit to internal/bar.go"},
}
edits := recentEdits(messages)
if len(edits) != 2 {
t.Fatalf("expected 2 edited files, got %d: %#v", len(edits), edits)
}
if edits[0].name != "internal/foo.go" || !strings.Contains(edits[0].body, "12 lines") {
t.Fatalf("first edit = %#v, want foo.go with its note", edits[0])
}
if edits[1].name != "internal/bar.go" || !strings.Contains(edits[1].body, "Applied edit") {
t.Fatalf("second edit = %#v, want bar.go with its note", edits[1])
}
}
// After compaction elides the editing turns, the preserved-state block still
// names the edited files and what changed, so the model needn't re-read them.
func TestCompactionPreservesRecentEdits(t *testing.T) {
messages := []zeroruntime.Message{
{Role: zeroruntime.MessageRoleSystem, Content: "system"},
{Role: zeroruntime.MessageRoleUser, Content: "add a flag"},
{Role: zeroruntime.MessageRoleAssistant, Content: "editing", ToolCalls: []zeroruntime.ToolCall{
{ID: "e1", Name: "write_file", Arguments: `{"path":"cmd/main.go","content":"..."}`},
}},
{Role: zeroruntime.MessageRoleTool, ToolCallID: "e1", Content: "Wrote cmd/main.go (adds --version flag)"},
{Role: zeroruntime.MessageRoleAssistant, Content: "done"},
{Role: zeroruntime.MessageRoleUser, Content: "continue"},
{Role: zeroruntime.MessageRoleAssistant, Content: "continuing"},
}
summary := compactStateConversation(t, messages)
state := parsePreservedStateBlock(summary)
if len(state.RecentEdits) != 1 {
t.Fatalf("expected 1 preserved edit, got %#v", state.RecentEdits)
}
if state.RecentEdits[0].Path != "cmd/main.go" || !strings.Contains(state.RecentEdits[0].Note, "--version") {
t.Fatalf("preserved edit = %#v, want cmd/main.go + its note", state.RecentEdits[0])
}
}
// A fresh edit note for a path overrides the one carried from an earlier
// compaction (newer wins) and moves the path to the newest position rather than
// duplicating it or leaving it pinned to its old slot.
func TestRecentEditsMergeNewerWins(t *testing.T) {
prior := preservedState{RecentEdits: []preservedEdit{{Path: "a.go", Note: "old note"}, {Path: "z.go", Note: "z"}}}
older := preservedEditsToEntries(prior.RecentEdits)
newer := []skillEntry{{name: "a.go", body: "new note"}, {name: "b.go", body: "added"}}
merged := mergeRecentEdits(older, newer)
// z.go (untouched) keeps its lead; a.go is re-edited so it takes the new note
// and moves behind z.go, ahead of the brand-new b.go: [z.go, a.go, b.go].
if len(merged) != 3 {
t.Fatalf("expected z.go + refreshed a.go + b.go, got %#v", merged)
}
if merged[0].name != "z.go" {
t.Fatalf("untouched z.go should stay first, got %#v", merged)
}
if merged[1].name != "a.go" || merged[1].body != "new note" {
t.Fatalf("re-edited a.go should move to the newest edits with the new note, got %#v", merged[1])
}
if merged[2].name != "b.go" {
t.Fatalf("brand-new b.go should be last, got %#v", merged[2])
}
}
// Regression: once more than maxRecentEdits distinct files are tracked, re-editing
// an early file must move it into the capped tail rather than leaving it to be
// dropped — otherwise the next compaction omits exactly the file the model most
// recently touched.
func TestRecentEditsCapKeepsReeditedFile(t *testing.T) {
// Earlier compaction preserved exactly maxRecentEdits files, f0 the oldest.
older := make([]skillEntry, 0, maxRecentEdits)
for i := 0; i < maxRecentEdits; i++ {
older = append(older, skillEntry{name: fmt.Sprintf("f%d.go", i), body: "old"})
}
// Fresh window re-edits the OLDEST file (f0) and adds a brand-new file, so the
// merged list exceeds the cap and something must be dropped.
newer := []skillEntry{{name: "f0.go", body: "re-edited"}, {name: "fresh.go", body: "new"}}
merged := capRecentEdits(mergeRecentEdits(older, newer))
if len(merged) != maxRecentEdits {
t.Fatalf("cap should keep %d entries, got %d", maxRecentEdits, len(merged))
}
var f0 *skillEntry
for i := range merged {
if merged[i].name == "f0.go" {
f0 = &merged[i]
}
if merged[i].name == "f1.go" {
t.Fatalf("f1.go (now the least-recently touched) should have been dropped, got %#v", merged)
}
}
if f0 == nil {
t.Fatalf("re-edited f0.go must survive the cap, got %#v", merged)
}
if f0.body != "re-edited" {
t.Fatalf("surviving f0.go should carry the fresh note, got %q", f0.body)
}
}
+86
View File
@@ -0,0 +1,86 @@
package agent
// CompletionDecision is the deterministic outcome of evaluating a text-only
// assistant turn. Uncertain means the loop needs one bounded follow-up action
// before it can finalize the run.
type CompletionDecision string
const (
CompletionUncertain CompletionDecision = "uncertain"
CompletionComplete CompletionDecision = "complete"
CompletionIncomplete CompletionDecision = "incomplete"
)
type completionAction string
const (
completionActionNone completionAction = ""
completionActionContinue completionAction = "continue"
completionActionSemanticCheck completionAction = "semantic_check"
)
type completionEvaluation struct {
Decision CompletionDecision
Action completionAction
Reason string
}
// completionPolicy owns the small amount of state needed to keep uncertain
// decisions bounded. requireSemanticCheck is set only for run profiles that
// already opted into self-correction; ordinary completion remains entirely
// local and adds no model call.
type completionPolicy struct {
continueNudges int
requireSemanticCheck bool
semanticCheckRequested bool
}
func newCompletionPolicy(requireSemanticCheck bool) *completionPolicy {
return &completionPolicy{requireSemanticCheck: requireSemanticCheck}
}
func (policy *completionPolicy) evaluate(text string, planPending bool) completionEvaluation {
// A direct admission is strong local evidence and takes precedence over all
// weaker signals, avoiding both continuation nudges and semantic checks.
if reason := selfReportedIncompletion(text); reason != "" {
return completionEvaluation{Decision: CompletionIncomplete, Reason: reason}
}
// A continuation cue is strong unfinished evidence; a pending plan is only
// weak evidence because bookkeeping can be stale. Both get a bounded chance
// to continue, but only a persisted cue is ultimately incomplete.
cue := endsWithContinuationCue(text)
if cue || planPending {
if policy.continueNudges < maxContinueNudges {
policy.continueNudges++
reason := "your message ended mid-step"
if !cue {
reason = "pending plan items remain — finish them, or mark them complete with update_plan if you are done"
}
return completionEvaluation{
Decision: CompletionUncertain,
Action: completionActionContinue,
Reason: reason,
}
}
if cue {
return completionEvaluation{
Decision: CompletionIncomplete,
Reason: "your message ended mid-step",
}
}
}
// Profiles with self-correction enabled require one task-grounded semantic
// check. It is requested at most once per run; the next locally complete turn
// is accepted without another model call.
if policy.requireSemanticCheck && !policy.semanticCheckRequested {
policy.semanticCheckRequested = true
return completionEvaluation{
Decision: CompletionUncertain,
Action: completionActionSemanticCheck,
}
}
return completionEvaluation{Decision: CompletionComplete, Action: completionActionNone}
}
+64
View File
@@ -0,0 +1,64 @@
package agent
import "testing"
func TestCompletionPolicyLocalEvidenceDecidesWithoutSemanticCheck(t *testing.T) {
policy := newCompletionPolicy(false)
complete := policy.evaluate("Done. All required checks pass.", false)
if complete.Decision != CompletionComplete {
t.Fatalf("confident completion decision = %q, want %q", complete.Decision, CompletionComplete)
}
incomplete := policy.evaluate("I couldn't verify the result, so this is my best guess.", false)
if incomplete.Decision != CompletionIncomplete {
t.Fatalf("admitted failure decision = %q, want %q", incomplete.Decision, CompletionIncomplete)
}
}
func TestCompletionPolicyPreservesBoundedPlanStallProtection(t *testing.T) {
policy := newCompletionPolicy(false)
for attempt := 0; attempt < maxContinueNudges; attempt++ {
got := policy.evaluate("Let me inspect the remaining configuration:", true)
if got.Decision != CompletionUncertain || got.Action != completionActionContinue {
t.Fatalf("attempt %d = (%q, %q), want uncertain continue", attempt+1, got.Decision, got.Action)
}
}
got := policy.evaluate("Let me inspect the remaining configuration:", true)
if got.Decision != CompletionIncomplete {
t.Fatalf("decision after nudge budget = %q, want %q", got.Decision, CompletionIncomplete)
}
}
func TestCompletionPolicyTreatsPendingPlanAsWeakEvidence(t *testing.T) {
policy := newCompletionPolicy(false)
for attempt := 0; attempt < maxContinueNudges; attempt++ {
got := policy.evaluate("All set.", true)
if got.Decision != CompletionUncertain || got.Action != completionActionContinue {
t.Fatalf("attempt %d = (%q, %q), want uncertain continue", attempt+1, got.Decision, got.Action)
}
}
got := policy.evaluate("All set.", true)
if got.Decision != CompletionComplete {
t.Fatalf("stale plan decision after nudge budget = %q, want %q", got.Decision, CompletionComplete)
}
}
func TestCompletionPolicyAllowsExactlyOneRequiredSemanticCheck(t *testing.T) {
policy := newCompletionPolicy(true)
first := policy.evaluate("Implemented and tested.", false)
if first.Decision != CompletionUncertain || first.Action != completionActionSemanticCheck {
t.Fatalf("first decision = (%q, %q), want uncertain semantic check", first.Decision, first.Action)
}
second := policy.evaluate("PASS. The result meets the task criterion.", false)
if second.Decision != CompletionComplete {
t.Fatalf("post-check decision = %q, want %q", second.Decision, CompletionComplete)
}
if second.Action == completionActionSemanticCheck {
t.Fatal("semantic check was requested more than once")
}
}
+62
View File
@@ -0,0 +1,62 @@
package agent
import (
"context"
"os"
"path/filepath"
"strings"
"time"
"github.com/Gitlawb/zero/internal/lsp"
)
// fileDiagnosticsTimeout bounds one inline post-edit diagnostics check so a
// slow or wedged language server can never hang a tool call; on timeout the
// edit simply reports without a diagnostics block.
const fileDiagnosticsTimeout = 10 * time.Second
// NewFileDiagnostics adapts an *lsp.Manager to the per-edit inline diagnostics
// callback (tools.RunOptions.Diagnostics): it reads the just-written file,
// checks it against the file's language server, and formats error-severity
// diagnostics for the model. Warnings and hints are excluded — nagging about
// style on every edit is noise, while a type error the edit just introduced is
// exactly what the model should see before its next step. Diagnostics are
// rendered with workspace-relative paths: the absolute path would put the
// local username/home directory into the model prompt and session transcript
// on every edit. Returns nil when manager is nil, disabling inline diagnostics
// entirely.
func NewFileDiagnostics(manager *lsp.Manager, workspaceRoot string) func(context.Context, string) string {
if manager == nil {
return nil
}
return func(ctx context.Context, absPath string) string {
text, err := os.ReadFile(absPath)
if err != nil {
return ""
}
checkCtx, cancel := context.WithTimeout(ctx, fileDiagnosticsTimeout)
defer cancel()
diagnostics, err := manager.Check(checkCtx, absPath, string(text))
if err != nil {
return ""
}
errors := lsp.FilterBySeverity(diagnostics, lsp.SeverityError)
if len(errors) == 0 {
return ""
}
return lsp.FormatDiagnostics(diagnosticsDisplayPath(workspaceRoot, absPath), errors)
}
}
// diagnosticsDisplayPath renders absPath relative to the workspace root for
// model-facing output, falling back to the file's base name when the path is
// outside the workspace (a bare name still identifies the file without
// exposing the directory layout).
func diagnosticsDisplayPath(workspaceRoot, absPath string) string {
if workspaceRoot != "" {
if rel, err := filepath.Rel(workspaceRoot, absPath); err == nil && !strings.HasPrefix(rel, "..") {
return rel
}
}
return filepath.Base(absPath)
}
+24
View File
@@ -0,0 +1,24 @@
package agent
import (
"path/filepath"
"testing"
)
// Diagnostics are model-facing: absolute paths would leak the local username
// and directory layout into the prompt and session transcript on every edit.
func TestDiagnosticsDisplayPath(t *testing.T) {
root := filepath.Join("/Users", "someone", "project")
cases := []struct {
root, abs, want string
}{
{root, filepath.Join(root, "internal", "a.go"), filepath.Join("internal", "a.go")},
{root, filepath.Join("/etc", "other.go"), "other.go"}, // outside root -> base name only
{"", filepath.Join("/home", "user", "x.go"), "x.go"}, // no root -> base name only
}
for _, c := range cases {
if got := diagnosticsDisplayPath(c.root, c.abs); got != c.want {
t.Errorf("diagnosticsDisplayPath(%q, %q) = %q, want %q", c.root, c.abs, got, c.want)
}
}
}
+129 -29
View File
@@ -22,6 +22,12 @@ const (
// calls have executed since the last update_plan call.
staleToolCallThreshold = 10
// stalePlanTurnThreshold injects the same one-shot reminder once this many
// turns have passed since the last update_plan while plan items are still
// pending — the turn-based complement to staleToolCallThreshold, catching a
// plan that drifts stale across many turns that each make few tool calls.
stalePlanTurnThreshold = 8
// toolOnlyProgressReminderAt injects a one-shot progress nudge after this
// many consecutive turns contain tool calls but no visible assistant text.
// It does not stop the run; it tells the model to synthesize what it already
@@ -43,8 +49,12 @@ const (
toolFailureHintAt = 2
// toolFailureStopAt halts the run after a tool fails this many times in a row
// with the same error, so NO model (weak or strong) burns turns looping on a
// bad call.
toolFailureStopAt = 4
// bad call. Set to 6 (not 4): a corrective hint fires at toolFailureHintAt (2),
// and a model iterating on a genuinely tricky edit can legitimately fail a few
// times after the hint while converging — stopping at 4 cut those runs short.
// The streak still resets the moment the tool succeeds or hits a different
// error, so this only affects true same-error loops.
toolFailureStopAt = 6
// maxContinueNudges bounds how many times the headless completion gate
// (Options.RequireCompletionSignal) re-prompts a model that stopped without a
@@ -173,35 +183,112 @@ var successNegationTails = []string{
"reproduce", "spot any", "locate any",
}
// selfReportedIncompletion returns a short reason when the model's final text
// admits it guessed or could not meet the objective, else "". Case-insensitive.
func selfReportedIncompletion(text string) string {
lower := strings.ToLower(text)
for _, phrase := range selfReportPhrases {
if strings.Contains(lower, phrase) {
return selfReportReason(phrase)
// narrativeMarkers flag a sentence as RETELLING a past exchange rather than
// reporting the outcome of the current objective, so an inability admission in
// that sentence is about THEN, not NOW. Grounded in a real false positive: a
// conversational recap ("You asked if I could work autonomously … I was honest
// that my sandbox had no repo … so I couldn't actually do it at the time") was
// downgraded to INCOMPLETE on the quoted "i couldn't " stem even though the
// current turn's objective (summarize where we left off) was fully met. Every
// marker is second-person or explicitly past-referential, so a first-person
// admission about the current task ("I couldn't complete the refactor") is
// never masked.
var narrativeMarkers = []string{
"you asked", "you said", "you wanted", "you mentioned",
"we discussed", "we talked", "we covered",
"when we last", "last time", "last session", "previous session",
"previous conversation", "earlier session", "earlier conversation",
"at the time", "back then",
}
// stripQuoted removes spans enclosed in double quotes (straight or curly) or
// backticks, so an admission the model merely QUOTES — its own earlier message,
// a log line, an error string — cannot fire the detector. Only BALANCED spans
// are removed: an opening delimiter that never closes is kept as literal text,
// so a stray quote cannot swallow the rest of the message (and with it a
// genuine admission the detector must see). Single quotes are left alone: they
// are overwhelmingly apostrophes ("couldn't"), and treating them as quote
// delimiters would swallow the text between two contractions.
func stripQuoted(s string) string {
var b strings.Builder
var span strings.Builder // pending text since the open delimiter, kept if it never closes
open := rune(0)
for _, r := range s {
switch {
case open != 0:
if (open == '"' && r == '"') || (open == '“' && r == '”') || (open == '`' && r == '`') {
open = 0
span.Reset()
continue
}
span.WriteRune(r)
case r == '"' || r == '“' || r == '`':
open = r
span.WriteRune(r)
default:
b.WriteRune(r)
}
}
for _, stem := range inabilityStems {
// Scan EVERY occurrence of the stem, not just the first: an earlier
// success-negation use ("I could not find any examples, so I could not
// implement it") must not mask a later genuine admission with the same stem.
for start := 0; ; {
rel := strings.Index(lower[start:], stem)
if rel < 0 {
break
b.WriteString(span.String()) // dangling delimiter: restore the span verbatim
return b.String()
}
// admissionSentences splits lowered text into sentence-ish fragments so the
// detector judges each claim in its own context. Newlines split too (markdown
// bullets are separate claims); the exact boundaries only need to keep an
// admission next to its own narrative/negation context, not be grammatical.
func admissionSentences(lower string) []string {
return strings.FieldsFunc(lower, func(r rune) bool {
return r == '.' || r == '!' || r == '?' || r == '\n'
})
}
// selfReportedIncompletion returns a short reason when the model's final text
// admits it guessed or could not meet the objective, else "". Case-insensitive.
// Matching is per sentence, after dropping quoted spans, and a sentence that
// retells a past exchange (narrativeMarkers) is skipped entirely — an admission
// must be the model's own report about the CURRENT objective, not general
// language that merely resembles one.
func selfReportedIncompletion(text string) string {
for _, sentence := range admissionSentences(strings.ToLower(stripQuoted(text))) {
if containsAny(sentence, narrativeMarkers) {
continue
}
for _, phrase := range selfReportPhrases {
if strings.Contains(sentence, phrase) {
return selfReportReason(phrase)
}
abs := start + rel
tail := strings.TrimSpace(lower[abs+len(stem):])
if !hasAnyPrefix(tail, successNegationTails) {
return selfReportReason(strings.TrimSpace(stem) + " …")
}
for _, stem := range inabilityStems {
// Scan EVERY occurrence of the stem, not just the first: an earlier
// success-negation use ("I could not find any examples, so I could not
// implement it") must not mask a later genuine admission with the same stem.
for start := 0; ; {
rel := strings.Index(sentence[start:], stem)
if rel < 0 {
break
}
abs := start + rel
tail := strings.TrimSpace(sentence[abs+len(stem):])
if !hasAnyPrefix(tail, successNegationTails) {
return selfReportReason(strings.TrimSpace(stem) + " …")
}
start = abs + len(stem)
}
start = abs + len(stem)
}
}
return ""
}
func containsAny(s string, subs []string) bool {
for _, sub := range subs {
if strings.Contains(s, sub) {
return true
}
}
return false
}
func selfReportReason(marker string) string {
return `the final message admits the objective was not met ("` + marker + `")`
}
@@ -354,8 +441,13 @@ type guardState struct {
emptyTurns int
totalToolCalls int
toolCallsSincePlanUpdate int
planEverCalled bool
notCalledReminderSent bool
// turnsSincePlanUpdate counts turns (not individual tool calls) since the last
// update_plan, so a plan that goes stale across many low-tool-call turns is
// still caught — the tool-call counter alone can take many turns to trip when
// the model makes only one call per turn.
turnsSincePlanUpdate int
planEverCalled bool
notCalledReminderSent bool
// staleReminderSent records whether the stale reminder has already fired for
// the current stale interval. It is cleared when a plan update opens a new
// interval, making the reminder one-shot per interval rather than per turn.
@@ -414,8 +506,9 @@ func (state *guardState) observeToolResult(name string, failed bool, output stri
func (state *guardState) observeTurn(collected zeroruntime.CollectedStream) (stop bool) {
hasToolCalls := len(collected.ToolCalls) > 0
hasVisibleText := strings.TrimSpace(collected.Text) != ""
hasReasoning := collected.HasReasoning || len(collected.ReasoningBlocks) > 0
if hasToolCalls || hasVisibleText {
if hasToolCalls || hasVisibleText || hasReasoning {
state.emptyTurns = 0
} else {
state.emptyTurns++
@@ -427,11 +520,16 @@ func (state *guardState) observeTurn(collected zeroruntime.CollectedStream) (sto
state.toolOnlyReminderSent = false
}
// One turn has passed; the plan-update below resets this to 0 when the model
// refreshes the plan this turn.
state.turnsSincePlanUpdate++
for _, call := range collected.ToolCalls {
state.totalToolCalls++
if call.Name == planToolName {
state.planEverCalled = true
state.toolCallsSincePlanUpdate = 0
state.turnsSincePlanUpdate = 0
// A fresh plan update opens a new stale interval.
state.staleReminderSent = false
// Record how many items remain so the completion gate knows whether
@@ -485,10 +583,12 @@ func (state *guardState) progressReminder() string {
// number of turns completed so far).
func (state *guardState) planReminder(turn int) string {
// STALE reminder takes priority: a long run without a plan update is the
// stronger signal. One-shot per stale interval.
if state.planEverCalled &&
!state.staleReminderSent &&
state.toolCallsSincePlanUpdate >= staleToolCallThreshold {
// stronger signal. Fires on either the tool-call streak OR a turn streak with
// pending items (so a plan drifting stale across many low-call turns is caught,
// while a fully-completed plan is left alone). One-shot per stale interval.
if state.planEverCalled && !state.staleReminderSent &&
(state.toolCallsSincePlanUpdate >= staleToolCallThreshold ||
(state.turnsSincePlanUpdate >= stalePlanTurnThreshold && state.planItemsPending > 0)) {
state.staleReminderSent = true
return planStaleReminder(state.toolCallsSincePlanUpdate)
}
+33
View File
@@ -22,6 +22,14 @@ func textTurn(content string) []zeroruntime.StreamEvent {
}
}
// reasoningTurn produces live reasoning without visible assistant text.
func reasoningTurn(content string) []zeroruntime.StreamEvent {
return []zeroruntime.StreamEvent{
{Type: zeroruntime.StreamEventReasoning, Content: content},
{Type: zeroruntime.StreamEventDone},
}
}
// toolTurn produces a turn that calls a named tool with the given args JSON.
func toolTurn(callID string, toolName string, args string) []zeroruntime.StreamEvent {
return []zeroruntime.StreamEvent{
@@ -101,6 +109,31 @@ func TestRunResetsEmptyTurnCounterOnVisibleOutput(t *testing.T) {
}
}
func TestRunResetsEmptyTurnCounterOnReasoning(t *testing.T) {
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
reasoningTurn("thinking 1"),
reasoningTurn("thinking 2"),
reasoningTurn("thinking 3"),
textTurn("done"),
},
}
result, err := Run(context.Background(), "go", provider, Options{
Registry: tools.NewRegistry(),
MaxTurns: 12,
})
if err != nil {
t.Fatal(err)
}
if result.FinalAnswer != "done" {
t.Fatalf("expected reasoning-only turns to keep the run live until final answer, got %q", result.FinalAnswer)
}
if len(provider.requests) != 4 {
t.Fatalf("expected 4 turns, got %d", len(provider.requests))
}
}
func TestRunResetsEmptyTurnCounterOnToolCall(t *testing.T) {
root := t.TempDir()
writeAgentTestFile(t, root+"/notes.txt", "alpha")
+577 -148
View File
File diff suppressed because it is too large. Load diff
+250 -6
View File
@@ -4,15 +4,19 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/hooks"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/specmode"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/trace"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
@@ -37,6 +41,140 @@ func (provider *mockProvider) StreamCompletion(ctx context.Context, request zero
return ch, nil
}
func TestRunDispatchesSessionLifecycleHooks(t *testing.T) {
audit, err := hooks.NewAuditStore(hooks.AuditStoreOptions{AuditPath: filepath.Join(t.TempDir(), "audit.jsonl")})
if err != nil {
t.Fatalf("NewAuditStore: %v", err)
}
dispatcher := hooks.NewDispatcher(hooks.DispatcherOptions{
Config: hooks.Config{
Enabled: true,
Hooks: []hooks.Definition{
{ID: "zero.session-start", Event: hooks.EventSessionStart, Command: "zero-missing-hook-command", Enabled: true},
{ID: "zero.session-end", Event: hooks.EventSessionEnd, Command: "zero-missing-hook-command", Enabled: true},
},
},
Audit: audit,
})
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{{
{Type: zeroruntime.StreamEventText, Content: "done"},
{Type: zeroruntime.StreamEventDone},
}}}
_, err = Run(context.Background(), "hi", provider, Options{
SessionID: "session-123",
Cwd: t.TempDir(),
ProviderName: "test-provider",
Model: "test-model",
Hooks: dispatcher,
})
if err != nil {
t.Fatalf("Run: %v", err)
}
events, err := audit.ReadEvents()
if err != nil {
t.Fatalf("ReadEvents: %v", err)
}
started := map[hooks.Event]int{}
completed := map[hooks.Event]int{}
for _, event := range events {
switch event.Type {
case "hook_execution_started":
started[event.Event]++
case "hook_execution_completed":
completed[event.Event]++
}
}
if started[hooks.EventSessionStart] != 1 || completed[hooks.EventSessionStart] != 1 {
t.Fatalf("sessionStart audit counts started/completed = %d/%d, events=%#v", started[hooks.EventSessionStart], completed[hooks.EventSessionStart], events)
}
if started[hooks.EventSessionEnd] != 1 || completed[hooks.EventSessionEnd] != 1 {
t.Fatalf("sessionEnd audit counts started/completed = %d/%d, events=%#v", started[hooks.EventSessionEnd], completed[hooks.EventSessionEnd], events)
}
}
// cancelingProvider simulates an Esc/Ctrl+C abort: it cancels the run's own
// context mid-stream (as the TUI's interrupt handler would) and returns no
// events, so Run observes ctx.Err() and returns early with an already-canceled
// context in scope for the deferred sessionEnd dispatch.
type cancelingProvider struct {
cancel context.CancelFunc
}
func (provider *cancelingProvider) StreamCompletion(ctx context.Context, request zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
provider.cancel()
ch := make(chan zeroruntime.StreamEvent)
close(ch)
return ch, nil
}
// TestRunDispatchesSessionEndHookAfterContextCancellation is a regression test
// for a bug where an interrupted run (Esc/Ctrl+C) canceled ctx before the
// deferred sessionEnd dispatch ran, so Hooks.Dispatch's context.WithTimeout(ctx, ...)
// derived an already-canceled context and the hook command never actually launched.
func TestRunDispatchesSessionEndHookAfterContextCancellation(t *testing.T) {
goBinary, err := exec.LookPath("go")
if err != nil {
// The test binary runs on the build machine, so its build-time GOROOT
// still identifies the toolchain that produced it.
goRoot := runtime.GOROOT() //nolint:staticcheck // Safe for this non-portable test binary.
goBinary = filepath.Join(goRoot, "bin", "go")
if runtime.GOOS == "windows" {
goBinary += ".exe"
}
if _, statErr := os.Stat(goBinary); statErr != nil {
t.Skipf("go binary unavailable on PATH or in GOROOT: %v", statErr)
}
}
audit, err := hooks.NewAuditStore(hooks.AuditStoreOptions{AuditPath: filepath.Join(t.TempDir(), "audit.jsonl")})
if err != nil {
t.Fatalf("NewAuditStore: %v", err)
}
dispatcher := hooks.NewDispatcher(hooks.DispatcherOptions{
Config: hooks.Config{
Enabled: true,
Hooks: []hooks.Definition{
{ID: "zero.session-end", Event: hooks.EventSessionEnd, Command: goBinary, Args: []string{"version"}, Enabled: true},
},
},
Audit: audit,
})
ctx, cancel := context.WithCancel(context.Background())
provider := &cancelingProvider{cancel: cancel}
_, err = Run(ctx, "hi", provider, Options{
SessionID: "session-cancel",
Cwd: t.TempDir(),
ProviderName: "test-provider",
Model: "test-model",
Hooks: dispatcher,
})
if !errors.Is(err, context.Canceled) {
t.Fatalf("Run err = %v, want context.Canceled", err)
}
events, err := audit.ReadEvents()
if err != nil {
t.Fatalf("ReadEvents: %v", err)
}
var completedStatus hooks.AuditStatus
found := false
for _, event := range events {
if event.Type == "hook_execution_completed" && event.Event == hooks.EventSessionEnd {
completedStatus = event.Status
found = true
}
}
if !found {
t.Fatalf("no sessionEnd hook_execution_completed audit event, events=%#v", events)
}
if completedStatus != hooks.AuditCompleted {
t.Fatalf("sessionEnd hook status = %q, want %q (hook must actually run despite ctx cancellation)", completedStatus, hooks.AuditCompleted)
}
}
type recordingWebSearchTool struct {
calls []map[string]any
}
@@ -1945,6 +2083,11 @@ func TestRunCommandPrefixApprovalBypassesSandboxForMatchingShellCalls(t *testing
func TestRunCommandPrefixApprovalCoversSegmentedShellWithSafeTail(t *testing.T) {
root := t.TempDir()
segmentedCommand := `ps aux | head -5`
if runtime.GOOS == "windows" {
// head is MSYS-prone on Windows (#458) and no longer counts as a known-safe tail.
segmentedCommand = `ps aux | echo ok`
}
retryTool := &sandboxDeniedRetryTool{}
registry := tools.NewRegistry()
registry.Register(retryTool)
@@ -1958,7 +2101,7 @@ func TestRunCommandPrefixApprovalCoversSegmentedShellWithSafeTail(t *testing.T)
},
{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-2", ToolName: "bash"},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-2", ArgumentsFragment: `{"command":"ps aux | head -5"}`},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-2", ArgumentsFragment: fmt.Sprintf(`{"command":%q}`, segmentedCommand)},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-2"},
{Type: zeroruntime.StreamEventDone},
},
@@ -2762,11 +2905,11 @@ func TestRunRequestsFinalAnswerAfterMaxTurns(t *testing.T) {
t.Fatalf("expected final answer from finalization turn, got %q", result.FinalAnswer)
}
if len(provider.requests) != 2 {
t.Fatalf("expected final no-tools request after max turns, got %d requests", len(provider.requests))
t.Fatalf("expected finalization request after max turns, got %d requests", len(provider.requests))
}
finalRequest := provider.requests[1]
if len(finalRequest.Tools) != 0 {
t.Fatalf("finalization request must not advertise tools, got %#v", finalRequest.Tools)
if toolDefinitionByName(finalRequest.Tools, "read_file") == nil {
t.Fatalf("finalization request must keep prior tools exposed for strict provider replay, got %#v", finalRequest.Tools)
}
lastMessage := finalRequest.Messages[len(finalRequest.Messages)-1]
if lastMessage.Role != zeroruntime.MessageRoleUser || !strings.Contains(lastMessage.Content, "tool-turn limit") {
@@ -2887,6 +3030,24 @@ func TestGitBranchForPromptResolvesRelativeWorktreeGitdir(t *testing.T) {
}
}
func TestGitBranchForPromptInSubdirectory(t *testing.T) {
root := t.TempDir()
gitdir := filepath.Join(root, ".git")
if err := os.MkdirAll(gitdir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(gitdir, "HEAD"), []byte("ref: refs/heads/main\n"), 0o644); err != nil {
t.Fatal(err)
}
subdir := filepath.Join(root, "sub", "dir")
if err := os.MkdirAll(subdir, 0o755); err != nil {
t.Fatal(err)
}
if got := gitBranchForPrompt(subdir); got != "main" {
t.Fatalf("gitBranchForPrompt in subdirectory = %q, want main", got)
}
}
func TestBuildSystemPromptInjectsWorkspaceContext(t *testing.T) {
cwd := t.TempDir()
if err := os.WriteFile(filepath.Join(cwd, "AGENTS.md"), []byte("Always run `make lint` before committing."), 0o644); err != nil {
@@ -2907,8 +3068,21 @@ func TestBuildSystemPromptInjectsHostShellContext(t *testing.T) {
t.Fatalf("expected operating system in environment block, got %q", prompt)
}
if runtime.GOOS == "windows" {
if !strings.Contains(prompt, "Windows cmd.exe syntax") || !strings.Contains(prompt, "cwd argument") {
t.Fatalf("expected Windows cmd.exe shell guidance in prompt, got %q", prompt)
for _, want := range []string{"Windows cmd.exe syntax", "cwd argument", "MSYS binaries", "grep", "require_escalated", "use double quotes around the value"} {
if !strings.Contains(prompt, want) {
t.Fatalf("expected Windows shell guidance to mention %q, got %q", want, prompt)
}
}
// The examples must themselves use the safe (double-quoted) form; a
// single-quoted example here would teach the model the exact syntax
// that fails under cmd.exe.
for _, want := range []string{`--jq ".a | b"`, `-run "A|B"`} {
if !strings.Contains(prompt, want) {
t.Fatalf("expected double-quoted example %q in Windows shell guidance, got %q", want, prompt)
}
}
if strings.Contains(prompt, `'.a | b'`) || strings.Contains(prompt, `'A|B'`) {
t.Fatalf("Windows shell guidance must not show the unsafe single-quoted form, got %q", prompt)
}
} else if !strings.Contains(prompt, "/bin/sh syntax") {
t.Fatalf("expected POSIX shell guidance in prompt, got %q", prompt)
@@ -3370,3 +3544,73 @@ func TestRunDoesNotFlagCleanToolOutput(t *testing.T) {
t.Errorf("clean output should not get a reminder, got %q", captured.Output)
}
}
// TestRunTracingWrapperStampsUsage verifies the per-turn tracing setup in Run:
// a wired recorder is Started, OnUsage is wrapped so it still forwards to the
// caller's callback AND stamps token counters, and the run completes.
func TestRunTracingWrapperStampsUsage(t *testing.T) {
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{{
{Type: zeroruntime.StreamEventUsage, Usage: zeroruntime.Usage{InputTokens: 100, CachedInputTokens: 20, OutputTokens: 40}},
{Type: zeroruntime.StreamEventText, Content: "done"},
{Type: zeroruntime.StreamEventDone},
}}}
onUsageCalls := 0
rec := trace.NewRecorder("tracing-session", "run-1", "test")
if _, err := Run(context.Background(), "hi", provider, Options{
SessionID: "tracing-session",
Cwd: t.TempDir(),
ProviderName: "test-provider",
Model: "test-model",
Trace: rec,
OnUsage: func(Usage) { onUsageCalls++ },
}); err != nil {
t.Fatalf("Run: %v", err)
}
tr := rec.Finish()
if tr.StartedAt.IsZero() {
t.Fatal("tracing wrapper did not Start the recorder")
}
// FirstTokenAt must stamp even though no OnText/OnReasoning user callback is
// set: a headless traced run (e.g. `zero exec --trace`) sets Trace but no UI
// callbacks, so the loop installs trace-only forwarding handlers to capture
// TTFT. Without them FirstTokenAt stays zero and the trace loses its signal.
if tr.FirstTokenAt.IsZero() {
t.Fatal("FirstTokenAt not stamped for a traced run with no OnText/OnReasoning callbacks")
}
if got := tr.Counter(trace.CounterInputTokens); got != 100 {
t.Fatalf("input token counter = %d, want 100", got)
}
if got := tr.Counter(trace.CounterCachedInputTokens); got != 20 {
t.Fatalf("cached input token counter = %d, want 20", got)
}
if got := tr.Counter(trace.CounterOutputTokens); got != 40 {
t.Fatalf("output token counter = %d, want 40", got)
}
if onUsageCalls == 0 {
t.Fatal("wrapped OnUsage did not forward to the caller's callback")
}
}
// TestRunNilTraceForwardsUsage verifies a nil recorder leaves the loop
// byte-identical: OnUsage is not wrapped, so the caller's callback still fires
// and nothing panics.
func TestRunNilTraceForwardsUsage(t *testing.T) {
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{{
{Type: zeroruntime.StreamEventUsage, Usage: zeroruntime.Usage{InputTokens: 7}},
{Type: zeroruntime.StreamEventText, Content: "done"},
{Type: zeroruntime.StreamEventDone},
}}}
onUsageCalls := 0
if _, err := Run(context.Background(), "hi", provider, Options{
SessionID: "nil-trace-session",
Cwd: t.TempDir(),
ProviderName: "test-provider",
Model: "test-model",
OnUsage: func(Usage) { onUsageCalls++ },
}); err != nil {
t.Fatalf("Run: %v", err)
}
if onUsageCalls == 0 {
t.Fatal("OnUsage not forwarded when Trace is nil")
}
}
@@ -0,0 +1,167 @@
package agent
import (
"context"
"os"
"strconv"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/hooks"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/trace"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
type propagationOutputTool struct {
output string
}
func TestOutputBudgetHookHelperProcess(t *testing.T) {
for index, arg := range os.Args {
if arg != "--zero-output-budget-hook" || index+1 >= len(os.Args) {
continue
}
if _, err := os.Stdout.WriteString(os.Args[index+1]); err != nil {
os.Exit(2)
}
os.Exit(0)
}
}
func largeOutputBudgetHookDispatcher() *hooks.Dispatcher {
feedback := strings.Repeat("hook feedback ", 200)
return hooks.NewDispatcher(hooks.DispatcherOptions{Config: hooks.Config{
Enabled: true,
Hooks: []hooks.Definition{{
ID: "large-feedback",
Event: hooks.EventAfterTool,
Matcher: "propagation_output",
Command: os.Args[0],
Args: []string{
"-test.run=TestOutputBudgetHookHelperProcess",
"--",
"--zero-output-budget-hook",
feedback,
},
Enabled: true,
}},
}})
}
func (tool propagationOutputTool) Name() string { return "propagation_output" }
func (tool propagationOutputTool) Description() string { return "returns output for propagation tests" }
func (tool propagationOutputTool) Parameters() tools.Schema {
return tools.Schema{Type: "object", AdditionalProperties: false}
}
func (tool propagationOutputTool) Safety() tools.Safety {
return tools.Safety{SideEffect: tools.SideEffectRead, Permission: tools.PermissionAllow, Reason: "test read"}
}
func (tool propagationOutputTool) Run(context.Context, map[string]any) tools.Result {
return tools.Result{Status: tools.StatusOK, Output: tool.output}
}
func TestExecuteToolCallPropagatesOutputTruncation(t *testing.T) {
t.Setenv("TMPDIR", t.TempDir())
t.Setenv("ZERO_TOOL_OUTPUT_CEILING_TOKENS", "80")
registry := tools.NewRegistry()
registry.Register(propagationOutputTool{output: strings.Repeat("large output\n", 1000)})
result, abortErr := executeToolCall(context.Background(), registry, ToolCall{
ID: "call-budget",
Name: "propagation_output",
Arguments: `{}`,
}, PermissionModeAuto, Options{Cwd: t.TempDir()})
if abortErr != nil {
t.Fatalf("executeToolCall abort error: %v", abortErr)
}
if !result.Truncated {
t.Fatalf("agent ToolResult lost tools.Result.Truncated: %#v", result)
}
if result.Meta["spill_path"] == "" {
t.Fatalf("agent ToolResult lost spill metadata: %#v", result.Meta)
}
}
func TestRecordOutputBudgetTraceUsesOnlyCompactMetadata(t *testing.T) {
recorder := trace.NewRecorder("session", "run", "")
recorder.Start()
recordOutputBudgetTrace(recorder, ToolResult{
Name: "grep",
Truncated: true,
Output: "SECRET OUTPUT MUST NOT ENTER TRACE",
Meta: map[string]string{
"output_budget_category": "search",
"output_budget_original_bytes": "1000",
"output_budget_retained_bytes": "100",
"output_budget_estimated_original_tokens": "250",
"output_budget_estimated_retained_tokens": "25",
"output_budget_reason": "semantic_search_budget",
"output_budget_spill_created": "true",
"spill_path": "/secret/path/not-for-trace",
},
})
events := recorder.Finish().OutputBudgets
if len(events) != 1 {
t.Fatalf("events = %#v", events)
}
event := events[0]
if event.Tool != "grep" || event.Category != "search" || event.OriginalBytes != 1000 || event.RetainedBytes != 100 || !event.SpillCreated {
t.Fatalf("unexpected trace event: %#v", event)
}
}
func TestExecuteToolCallRebudgetsOversizedAfterToolFeedback(t *testing.T) {
t.Setenv("ZERO_TOOL_OUTPUT_CEILING_TOKENS", "80")
registry := tools.NewRegistry()
registry.Register(propagationOutputTool{output: "tool output"})
dispatcher := largeOutputBudgetHookDispatcher()
result, abortErr := executeToolCall(context.Background(), registry, ToolCall{
ID: "call-hook-budget",
Name: "propagation_output",
Arguments: `{}`,
}, PermissionModeAuto, Options{Hooks: dispatcher})
if abortErr != nil {
t.Fatalf("executeToolCall abort error: %v", abortErr)
}
if !result.Truncated || len(result.Output) > 80*4 {
t.Fatalf("afterTool feedback bypassed output budget: truncated=%t bytes=%d meta=%#v", result.Truncated, len(result.Output), result.Meta)
}
if result.Meta["output_budget_category"] == "" || result.Meta["output_budget_retained_bytes"] != strconv.Itoa(len(result.Output)) {
t.Fatalf("post-hook budget metadata does not describe final output: %#v", result.Meta)
}
}
func TestRunTraceReflectsPostHookBudget(t *testing.T) {
t.Setenv("ZERO_TOOL_OUTPUT_CEILING_TOKENS", "80")
registry := tools.NewRegistry()
registry.Register(propagationOutputTool{output: "tool output"})
dispatcher := largeOutputBudgetHookDispatcher()
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-hook-trace", ToolName: "propagation_output"},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-hook-trace", ArgumentsFragment: `{}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-hook-trace"},
{Type: zeroruntime.StreamEventDone},
},
{{Type: zeroruntime.StreamEventText, Content: "done"}, {Type: zeroruntime.StreamEventDone}},
}}
recorder := trace.NewRecorder("session", "run", "")
var toolResults []ToolResult
if _, err := Run(context.Background(), "budget hook", provider, Options{
Registry: registry,
Hooks: dispatcher,
Trace: recorder,
OnToolResult: func(result ToolResult) { toolResults = append(toolResults, result) },
}); err != nil {
t.Fatalf("Run: %v", err)
}
if len(toolResults) != 1 || !toolResults[0].Truncated {
t.Fatalf("tool result = %#v, want one truncated post-hook result", toolResults)
}
events := recorder.Finish().OutputBudgets
if len(events) != 1 || !events[0].Truncated || events[0].RetainedBytes != len(toolResults[0].Output) {
t.Fatalf("trace does not describe final post-hook output: events=%#v result=%#v", events, toolResults[0])
}
}
+197
View File
@@ -0,0 +1,197 @@
package agent
import (
"context"
"sync"
"github.com/Gitlawb/zero/internal/tools"
)
// Parallel read-ahead for tool batches. When a turn requests several
// independent lookups (read_file + grep + glob is the common shape), executing
// them one after another serializes pure I/O waits. A consecutive run of
// capability-safe read-only calls is executed concurrently instead; results are
// then consumed in the original call order, so guard counters, message
// ordering, abort semantics, and the surface's call/result event pairing are
// byte-identical to sequential execution. Runs never span a mutating call: a
// read that follows a write must observe the write, so eligibility is decided
// per consecutive run, not per batch.
//
// Eligibility uses the PR5 tool-effect contract (tools.CapabilitiesOf):
//
// Effect == ReadOnly
// AND ThreadSafe == true
// AND auto-allowed (no interactive permission prompt on the hot path)
// AND no resource-key conflict with an earlier call in the same concurrent window
//
// Unknown, mutators, interactive tools, and non-thread-safe reads stay
// sequential. Empty resource keys do not conflict (ThreadSafe is the safety
// gate); shared non-empty keys force a batch boundary.
// maxParallelReadTools bounds concurrent read-only tool executions in a turn.
const maxParallelReadTools = 8
// precomputedToolResult is one parallel read-ahead execution, keyed back to
// its batch index by the caller.
type precomputedToolResult struct {
result ToolResult
abortErr error
}
// parallelSafeToolCall reports whether call may run concurrently with its
// neighbors under the PR5 capability contract. Loop-intercepted tools
// (ask_user, request_permissions) and tool_search (mutates the deferred-tool
// set) always stay sequential.
func parallelSafeToolCall(registry *tools.Registry, call ToolCall, options Options) bool {
switch call.Name {
case "ask_user", tools.RequestPermissionsToolName, tools.ToolSearchToolName:
return false
}
tool, found := registry.Get(call.Name)
if !found {
return false
}
caps := tools.CapabilitiesOf(tool)
// Fail-closed: only audited concurrent-safe pure reads.
if caps.Effect != tools.EffectReadOnly || !caps.ThreadSafe {
return false
}
args, ok := decodeCallArgs(call)
if !ok {
return false
}
return effectivePermission(tool, args) == tools.PermissionAllow
}
// decodeCallArgs decodes tool call JSON arguments. Returns false on malformed
// input so the call stays sequential (never panics into the parallel path).
func decodeCallArgs(call ToolCall) (map[string]any, bool) {
args := map[string]any{}
if call.Arguments == "" {
return args, true
}
if err := decodeToolArguments(call.Arguments, &args); err != nil {
return nil, false
}
return args, true
}
// resourceKeysForCall returns the conflict keys for a call, or nil when the
// tool has no ResourceKeys function / no keys for these args.
func resourceKeysForCall(registry *tools.Registry, call ToolCall) []string {
tool, found := registry.Get(call.Name)
if !found {
return nil
}
caps := tools.CapabilitiesOf(tool)
if caps.ResourceKeys == nil {
return nil
}
args, ok := decodeCallArgs(call)
if !ok {
return nil
}
return caps.ResourceKeys(args)
}
// resourceKeysConflict reports whether two key sets share any non-empty key.
// Empty key sets never conflict: ThreadSafe is the eligibility gate; keys only
// refine conflict detection among tools that declare specific resources.
func resourceKeysConflict(a, b []string) bool {
if len(a) == 0 || len(b) == 0 {
return false
}
seen := make(map[string]struct{}, len(a))
for _, key := range a {
if key == "" {
continue
}
seen[key] = struct{}{}
}
for _, key := range b {
if key == "" {
continue
}
if _, ok := seen[key]; ok {
return true
}
}
return false
}
// extendParallelRun returns the exclusive end index of a consecutive parallel-
// safe run starting at start. The run stops before the first call that is not
// parallel-safe or that conflicts on resource keys with any earlier call in
// the same window (so two read_file calls on the same path stay sequential).
func extendParallelRun(registry *tools.Registry, calls []ToolCall, start int, options Options) int {
if start >= len(calls) || !parallelSafeToolCall(registry, calls[start], options) {
return start
}
end := start + 1
keysWindow := [][]string{resourceKeysForCall(registry, calls[start])}
for end < len(calls) {
if !parallelSafeToolCall(registry, calls[end], options) {
break
}
nextKeys := resourceKeysForCall(registry, calls[end])
conflict := false
for _, prev := range keysWindow {
if resourceKeysConflict(prev, nextKeys) {
conflict = true
break
}
}
if conflict {
break
}
keysWindow = append(keysWindow, nextKeys)
end++
}
return end
}
// executeParallelReadBatch runs calls[start:end] concurrently (bounded by
// maxParallelReadTools) and returns results indexed relative to start. All
// execution-side callbacks that can fire inside executeToolCall are serialized
// behind one mutex: a permission prompt (a sandbox preflight can demand one
// even for an auto-allowed read) must never appear twice at once on an
// interactive front-end, and OnPermission event handlers append to shared
// session-recording state without their own locking — two batched reads under
// a granted extra root would otherwise race (the pre-batch serial loop never
// had two callbacks in flight at once).
func executeParallelReadBatch(ctx context.Context, registry *tools.Registry, calls []ToolCall, start, end int, permissionMode PermissionMode, options Options) []precomputedToolResult {
batchOptions := options
var callbackMutex sync.Mutex
if options.OnPermissionRequest != nil {
inner := options.OnPermissionRequest
batchOptions.OnPermissionRequest = func(ctx context.Context, request PermissionRequest) (PermissionDecision, error) {
callbackMutex.Lock()
defer callbackMutex.Unlock()
return inner(ctx, request)
}
}
if options.OnPermission != nil {
inner := options.OnPermission
batchOptions.OnPermission = func(event PermissionEvent) {
callbackMutex.Lock()
defer callbackMutex.Unlock()
inner(event)
}
}
results := make([]precomputedToolResult, end-start)
semaphore := make(chan struct{}, maxParallelReadTools)
var waitGroup sync.WaitGroup
for index := start; index < end; index++ {
waitGroup.Add(1)
go func(index int) {
defer waitGroup.Done()
semaphore <- struct{}{}
defer func() { <-semaphore }()
result, abortErr := executeToolCall(ctx, registry, calls[index], permissionMode, batchOptions)
results[index-start] = precomputedToolResult{result: result, abortErr: abortErr}
}(index)
}
waitGroup.Wait()
return results
}
+308
View File
@@ -0,0 +1,308 @@
package agent
import (
"context"
"fmt"
"sync"
"testing"
"time"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// probeTool records execution overlap and ordering so tests can assert what
// actually ran concurrently.
type probeTool struct {
name string
sideEffect tools.SideEffect
delay time.Duration
// shared, when set, receives this probe's start/end entries too, so a test
// can observe ordering ACROSS probes (e.g. reads vs a write barrier).
shared *probeLog
mu sync.Mutex
active int
maxActive int
log []string
}
// probeLog is a mutex-guarded event log shared between probes.
type probeLog struct {
mu sync.Mutex
entries []string
}
func (log *probeLog) append(entry string) {
log.mu.Lock()
defer log.mu.Unlock()
log.entries = append(log.entries, entry)
}
func (log *probeLog) snapshot() []string {
log.mu.Lock()
defer log.mu.Unlock()
return append([]string(nil), log.entries...)
}
func (tool *probeTool) Name() string { return tool.name }
func (tool *probeTool) Description() string { return "test probe tool" }
func (tool *probeTool) Parameters() tools.Schema {
return tools.Schema{
Type: "object",
Properties: map[string]tools.PropertySchema{"id": {Type: "string"}},
AdditionalProperties: false,
}
}
func (tool *probeTool) Safety() tools.Safety {
return tools.Safety{SideEffect: tool.sideEffect, Permission: tools.PermissionAllow, Reason: "test"}
}
// Capabilities maps the probe's SideEffect into the PR5 capability contract so
// parallelSafeToolCall (CapabilitiesOf) can classify reads vs mutators. Without
// this, probes default to EffectUnknown and never enter the concurrent path.
func (tool *probeTool) Capabilities() tools.ToolCapabilities {
switch tool.sideEffect {
case tools.SideEffectRead:
return tools.ToolCapabilities{Effect: tools.EffectReadOnly, ThreadSafe: true}
case tools.SideEffectWrite:
return tools.ToolCapabilities{Effect: tools.EffectWorkspaceWrite, ThreadSafe: false}
default:
return tools.UnknownCapabilities()
}
}
func (tool *probeTool) Run(_ context.Context, args map[string]any) tools.Result {
id, _ := args["id"].(string)
tool.mu.Lock()
tool.active++
if tool.active > tool.maxActive {
tool.maxActive = tool.active
}
tool.log = append(tool.log, "start:"+id)
tool.mu.Unlock()
if tool.shared != nil {
tool.shared.append("start:" + id)
}
time.Sleep(tool.delay)
if tool.shared != nil {
tool.shared.append("end:" + id)
}
tool.mu.Lock()
tool.active--
tool.log = append(tool.log, "end:"+id)
tool.mu.Unlock()
return tools.Result{Status: tools.StatusOK, Output: "probe " + id}
}
func probeCallEvents(callID, toolName, id string) []zeroruntime.StreamEvent {
return []zeroruntime.StreamEvent{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: callID, ToolName: toolName},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: callID, ArgumentsFragment: fmt.Sprintf(`{"id":%q}`, id)},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: callID},
}
}
func TestParallelSafeToolCall(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(&probeTool{name: "probe_read", sideEffect: tools.SideEffectRead})
registry.Register(&probeTool{name: "probe_write", sideEffect: tools.SideEffectWrite})
// Read-only but not ThreadSafe: must stay sequential under the PR5 gate.
registry.Register(&keyedProbeTool{
probeTool: probeTool{name: "probe_read_serial", sideEffect: tools.SideEffectRead},
threadSafe: false,
})
call := func(name, args string) ToolCall { return ToolCall{ID: "c", Name: name, Arguments: args} }
if !parallelSafeToolCall(registry, call("probe_read", `{"id":"a"}`), Options{}) {
t.Fatal("auto-allowed read tool must be parallel-safe")
}
if parallelSafeToolCall(registry, call("probe_write", `{"id":"a"}`), Options{}) {
t.Fatal("mutating tool must not be parallel-safe")
}
if parallelSafeToolCall(registry, call("unknown_tool", `{}`), Options{}) {
t.Fatal("unknown tool must not be parallel-safe")
}
if parallelSafeToolCall(registry, call("probe_read", `{"id":`), Options{}) {
t.Fatal("undecodable arguments must not be parallel-safe")
}
if parallelSafeToolCall(registry, call("ask_user", `{}`), Options{}) {
t.Fatal("loop-intercepted tools must stay sequential")
}
if parallelSafeToolCall(registry, call("probe_read_serial", `{"id":"a"}`), Options{}) {
t.Fatal("ReadOnly without ThreadSafe must not be parallel-safe")
}
}
func TestResourceKeysConflict(t *testing.T) {
if resourceKeysConflict(nil, []string{"file:a"}) {
t.Fatal("empty vs non-empty must not conflict")
}
if resourceKeysConflict([]string{"file:a"}, nil) {
t.Fatal("non-empty vs empty must not conflict")
}
if resourceKeysConflict([]string{"file:a"}, []string{"file:b"}) {
t.Fatal("distinct keys must not conflict")
}
if !resourceKeysConflict([]string{"file:a", "file:b"}, []string{"file:b"}) {
t.Fatal("shared key must conflict")
}
if resourceKeysConflict([]string{"", "file:a"}, []string{""}) {
t.Fatal("empty-string keys must be ignored")
}
}
func TestExtendParallelRunResourceKeyBoundary(t *testing.T) {
// Two keyed reads on the same path must not share a concurrent window;
// distinct paths may batch together.
registry := tools.NewRegistry()
registry.Register(&keyedProbeTool{
probeTool: probeTool{name: "keyed_read", sideEffect: tools.SideEffectRead},
threadSafe: true,
keys: func(args map[string]any) []string {
id, _ := args["id"].(string)
if id == "" {
return nil
}
return []string{"file:" + id}
},
})
calls := []ToolCall{
{ID: "1", Name: "keyed_read", Arguments: `{"id":"a"}`},
{ID: "2", Name: "keyed_read", Arguments: `{"id":"b"}`},
{ID: "3", Name: "keyed_read", Arguments: `{"id":"a"}`}, // conflicts with call 0
}
// start=0: a and b share no key → run covers [0,2)
if end := extendParallelRun(registry, calls, 0, Options{}); end != 2 {
t.Fatalf("extend from 0 = %d, want 2 (stop before second file:a)", end)
}
// start=2: single remaining call
if end := extendParallelRun(registry, calls, 2, Options{}); end != 3 {
t.Fatalf("extend from 2 = %d, want 3", end)
}
// Empty keys never conflict: three keyless safe reads form one window.
registry.Register(&probeTool{name: "keyless_read", sideEffect: tools.SideEffectRead})
keyless := []ToolCall{
{ID: "1", Name: "keyless_read", Arguments: `{"id":"x"}`},
{ID: "2", Name: "keyless_read", Arguments: `{"id":"y"}`},
{ID: "3", Name: "keyless_read", Arguments: `{"id":"z"}`},
}
if end := extendParallelRun(registry, keyless, 0, Options{}); end != 3 {
t.Fatalf("keyless extend = %d, want 3", end)
}
}
// keyedProbeTool is a probe with explicit ResourceKeys / ThreadSafe for
// planner unit tests.
type keyedProbeTool struct {
probeTool
threadSafe bool
keys func(args map[string]any) []string
}
func (tool *keyedProbeTool) Capabilities() tools.ToolCapabilities {
caps := tools.ToolCapabilities{
Effect: tools.EffectReadOnly,
ThreadSafe: tool.threadSafe,
}
if tool.keys != nil {
caps.ResourceKeys = tool.keys
}
// Honor mutator side effects from the embedded probe when used as write.
if tool.sideEffect == tools.SideEffectWrite {
caps.Effect = tools.EffectWorkspaceWrite
caps.ThreadSafe = false
}
return caps
}
func TestRunExecutesConsecutiveReadsConcurrently(t *testing.T) {
probe := &probeTool{name: "probe_read", sideEffect: tools.SideEffectRead, delay: 60 * time.Millisecond}
registry := tools.NewRegistry()
registry.Register(probe)
turnOne := append(probeCallEvents("call-1", "probe_read", "a"), probeCallEvents("call-2", "probe_read", "b")...)
turnOne = append(turnOne, probeCallEvents("call-3", "probe_read", "c")...)
turnOne = append(turnOne, zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone})
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
turnOne,
{{Type: zeroruntime.StreamEventText, Content: "done"}, {Type: zeroruntime.StreamEventDone}},
},
}
var results []ToolResult
_, err := Run(context.Background(), "probe", provider, Options{
Registry: registry,
OnToolResult: func(result ToolResult) { results = append(results, result) },
})
if err != nil {
t.Fatal(err)
}
if probe.maxActive < 2 {
t.Fatalf("consecutive read-only calls must overlap, max concurrency was %d", probe.maxActive)
}
// Results must still be recorded in original call order.
if len(results) != 3 || results[0].ToolCallID != "call-1" || results[1].ToolCallID != "call-2" || results[2].ToolCallID != "call-3" {
t.Fatalf("tool results out of order: %#v", results)
}
messages := provider.requests[1].Messages
var toolOrder []string
for _, message := range messages {
if message.Role == zeroruntime.MessageRoleTool {
toolOrder = append(toolOrder, message.ToolCallID)
}
}
if len(toolOrder) != 3 || toolOrder[0] != "call-1" || toolOrder[1] != "call-2" || toolOrder[2] != "call-3" {
t.Fatalf("recorded tool messages out of order: %v", toolOrder)
}
}
func TestRunParallelReadsNeverSpanMutatingCall(t *testing.T) {
shared := &probeLog{}
read := &probeTool{name: "probe_read", sideEffect: tools.SideEffectRead, delay: 30 * time.Millisecond, shared: shared}
write := &probeTool{name: "probe_write", sideEffect: tools.SideEffectWrite, shared: shared}
registry := tools.NewRegistry()
registry.Register(read)
registry.Register(write)
turnOne := append(probeCallEvents("call-1", "probe_read", "r1"), probeCallEvents("call-2", "probe_read", "r2")...)
turnOne = append(turnOne, probeCallEvents("call-3", "probe_write", "w")...)
turnOne = append(turnOne, probeCallEvents("call-4", "probe_read", "r3")...)
turnOne = append(turnOne, probeCallEvents("call-5", "probe_read", "r4")...)
turnOne = append(turnOne, zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone})
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
turnOne,
{{Type: zeroruntime.StreamEventText, Content: "done"}, {Type: zeroruntime.StreamEventDone}},
},
}
_, err := Run(context.Background(), "probe", provider, Options{Registry: registry})
if err != nil {
t.Fatal(err)
}
// Cross-probe ordering on the SHARED log: the write must start only after
// both first-batch reads finished, and both second-batch reads must start
// only after the write finished — batches never cross a mutating call.
log := shared.snapshot()
index := func(entry string) int {
for i, e := range log {
if e == entry {
return i
}
}
t.Fatalf("entry %q missing from shared log: %v", entry, log)
return -1
}
writeStart, writeEnd := index("start:w"), index("end:w")
if firstBatchMaxEnd := max(index("end:r1"), index("end:r2")); writeStart < firstBatchMaxEnd {
t.Fatalf("write started before the first read batch finished: %v", log)
}
if secondBatchMinStart := min(index("start:r3"), index("start:r4")); secondBatchMinStart < writeEnd {
t.Fatalf("second read batch started before the write finished: %v", log)
}
if read.maxActive < 2 {
t.Fatalf("reads within a batch must overlap, max concurrency was %d", read.maxActive)
}
}
@@ -0,0 +1,44 @@
package agent
import (
"testing"
"github.com/Gitlawb/zero/internal/tools"
)
func TestPartitionToolsActiveAppendsLoadedToolAfterEagerBlock(t *testing.T) {
// Cache-stability property: loading a deferred tool must APPEND it after the
// always-eager tools, not sort it into the middle of them — otherwise the tool
// definitions (the provider's cacheable prefix) shift on every load. The old
// behaviour alpha-sorted the whole array, so "mcp__srv__alpha" jumped ahead of
// "read_file".
root := t.TempDir()
registry := tools.NewRegistry()
registry.Register(tools.NewReadFileTool(root)) // non-deferred, "read_file"
registry.Register(fakeToolSearchTool{})
registry.Register(fakeDeferredTool{name: "mcp__srv__alpha", desc: "alpha"})
registry.Register(fakeDeferredTool{name: "mcp__srv__beta", desc: "beta"})
exposed, _ := partitionTools(registry, PermissionModeAuto, Options{DeferThreshold: 2}, map[string]bool{"mcp__srv__alpha": true})
pos := func(name string) int {
for i := range exposed {
if exposed[i].Name == name {
return i
}
}
return -1
}
readPos, searchPos, alphaPos := pos("read_file"), pos("tool_search"), pos("mcp__srv__alpha")
if readPos < 0 || searchPos < 0 || alphaPos < 0 {
t.Fatalf("expected read_file, tool_search and loaded alpha all exposed, got %#v", exposed)
}
// The eager tools must precede the loaded deferred tool (appended, not inserted).
if readPos >= alphaPos || searchPos >= alphaPos {
t.Fatalf("loaded deferred tool must be appended after the eager block; got read=%d search=%d alpha=%d", readPos, searchPos, alphaPos)
}
// beta was never loaded — it stays hidden.
if pos("mcp__srv__beta") != -1 {
t.Fatalf("unloaded deferred tool must stay hidden, got %#v", exposed)
}
}
+63
View File
@@ -0,0 +1,63 @@
package agent
import (
"context"
"reflect"
"testing"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// countingSchemaTool records how many times its Parameters() schema is read, so a
// test can prove the definition cache avoids re-rendering across turns.
type countingSchemaTool struct {
name string
calls *int
}
func (t countingSchemaTool) Name() string { return t.name }
func (t countingSchemaTool) Description() string { return "counts schema reads" }
func (t countingSchemaTool) Parameters() tools.Schema {
*t.calls++
return tools.Schema{Type: "object", AdditionalProperties: false, Properties: map[string]tools.PropertySchema{
"x": {Type: "string"},
}}
}
func (t countingSchemaTool) Safety() tools.Safety {
return tools.Safety{SideEffect: tools.SideEffectRead, Permission: tools.PermissionAllow}
}
func (t countingSchemaTool) Run(_ context.Context, _ map[string]any) tools.Result {
return tools.Result{Status: tools.StatusOK}
}
// The cache renders a tool's schema once and reuses it across calls, and its
// output is identical to the uncached path.
func TestPartitionToolsCacheRendersOnceAndMatchesUncached(t *testing.T) {
calls := 0
registry := tools.NewRegistry()
registry.Register(countingSchemaTool{name: "alpha", calls: &calls})
registry.Register(countingSchemaTool{name: "beta", calls: &calls})
uncached, _ := partitionTools(registry, PermissionModeAuto, Options{}, map[string]bool{})
base := calls
cache := map[string]zeroruntime.ToolDefinition{}
first, _ := partitionToolsCached(registry, PermissionModeAuto, Options{}, map[string]bool{}, cache)
rendersAfterFirst := calls - base
second, _ := partitionToolsCached(registry, PermissionModeAuto, Options{}, map[string]bool{}, cache)
rendersAfterSecond := calls - base
if rendersAfterFirst != 2 {
t.Fatalf("first cached call should render 2 tools once each, rendered %d times", rendersAfterFirst)
}
if rendersAfterSecond != 2 {
t.Fatalf("second cached call must reuse the cache (no new schema reads), total renders = %d", rendersAfterSecond)
}
if !reflect.DeepEqual(first, second) {
t.Fatal("cached calls must return identical definitions")
}
if !reflect.DeepEqual(first, uncached) {
t.Fatal("cached output must match the uncached partitionTools output")
}
}
+64
View File
@@ -0,0 +1,64 @@
package agent
import (
"strings"
"testing"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
func planUpdateTurn(status string) zeroruntime.CollectedStream {
return zeroruntime.CollectedStream{ToolCalls: []zeroruntime.ToolCall{
{Name: "update_plan", Arguments: `{"plan":[{"content":"step one","status":"` + status + `"}]}`},
}}
}
// Turn-based staleness: a plan that goes many turns without an update — while
// items are still pending — draws the stale reminder even though few tool calls
// have run (the tool-call streak alone would not have tripped).
func TestPlanReminderFiresOnTurnStaleness(t *testing.T) {
state := newGuardState()
state.observeTurn(planUpdateTurn("in_progress"))
// Text-only turns advance the turn counter without adding tool calls.
for range stalePlanTurnThreshold {
state.observeTurn(zeroruntime.CollectedStream{Text: "still working…"})
}
if state.toolCallsSincePlanUpdate >= staleToolCallThreshold {
t.Fatalf("precondition: the tool-call trigger must not fire (%d calls)", state.toolCallsSincePlanUpdate)
}
if got := state.planReminder(stalePlanTurnThreshold + 2); !strings.Contains(got, planStaleReminderMarker) {
t.Fatalf("expected a turn-based stale reminder, got %q", got)
}
}
// A fully-completed plan is NOT stale: no pending items means the turn-based
// trigger stays quiet even after many turns, so a finished plan isn't nagged.
func TestPlanReminderSkipsTurnStalenessWhenComplete(t *testing.T) {
state := newGuardState()
state.observeTurn(planUpdateTurn("completed"))
for range stalePlanTurnThreshold + 3 {
state.observeTurn(zeroruntime.CollectedStream{Text: "wrapping up…"})
}
if got := state.planReminder(stalePlanTurnThreshold + 5); got != "" {
t.Fatalf("a completed plan must not draw a stale reminder, got %q", got)
}
}
// A plan update resets the turn counter, so the stale reminder does not fire
// right after a fresh update.
func TestPlanUpdateResetsTurnStaleness(t *testing.T) {
state := newGuardState()
state.observeTurn(planUpdateTurn("in_progress"))
for range stalePlanTurnThreshold - 1 {
state.observeTurn(zeroruntime.CollectedStream{Text: "working…"})
}
// Refresh the plan — resets the turn streak.
state.observeTurn(planUpdateTurn("in_progress"))
if got := state.planReminder(stalePlanTurnThreshold + 1); got != "" {
t.Fatalf("a just-refreshed plan must not be stale, got %q", got)
}
}
+118
View File
@@ -0,0 +1,118 @@
package agent
import (
"github.com/Gitlawb/zero/internal/sandbox"
)
// profileController observes per-turn run signals and decides at most one
// posture escalation per run for the armed execution profile. A nil policy (or
// a policy without Escalate) makes every method a no-op, so runs without a
// profile are byte-identical to before — the same opt-in convention as Trace
// and SelfCorrect.
//
// The controller only ever CHANGES loop policy knobs (turn ceiling, reasoning
// effort, completion gate) through the values the loop applies at its act
// point; it never injects messages, never touches the model or session, and
// never interacts with the model-initiated escalate_model path.
type profileController struct {
policy *PostureEscalation
escalated bool
uncertainSeen int
failureTripped bool
riskTripped bool
scTripped bool
uncertainTrip bool
}
// newProfileController is nil-safe: a nil ProfilePolicy (the default for every
// existing caller) or a nil Escalate yields a controller that observes and
// decides nothing.
func newProfileController(policy *ProfilePolicy) *profileController {
if policy == nil {
return &profileController{}
}
return &profileController{policy: policy.Escalate}
}
// observeToolOutcome watches two signals from an executed tool call: the
// repeated-failure guard's streak (reusing the guard's own counting, not a
// second failure model) and the sandbox risk classification of an executed
// result.
func (c *profileController) observeToolOutcome(outcome toolFailureOutcome, result ToolResult) {
if c.policy == nil || c.escalated {
return
}
if c.policy.OnToolFailureStreak > 0 && outcome.Count >= c.policy.OnToolFailureStreak {
c.failureTripped = true
}
if threshold := riskRank(c.policy.OnRiskyMutation); threshold > 0 &&
result.DenialReason == DenialNone &&
riskRank(result.Risk.Level) >= threshold {
// The call EXECUTED (was not denied): partial failures count too, since
// the mutation ran. An unrecognized threshold ranks 0 and disables the
// signal instead of matching everything.
c.riskTripped = true
}
}
// observeUncertain counts uncertain completion evaluations (continue nudges and
// semantic checks). Headless-only by construction: the completion gate never
// runs interactively.
func (c *profileController) observeUncertain() {
if c.policy == nil || c.escalated || c.policy.OnCompletionUncertain <= 0 {
return
}
c.uncertainSeen++
if c.uncertainSeen >= c.policy.OnCompletionUncertain {
c.uncertainTrip = true
}
}
// observeSelfCorrect watches the post-edit verification outcome. Any failing
// outcome (correcting, reported, aborted) is a signal; passed and disabled are
// not.
func (c *profileController) observeSelfCorrect(outcome Outcome) {
if c.policy == nil || c.escalated || !c.policy.OnSelfCorrectFailure {
return
}
switch outcome {
case OutcomeCorrecting, OutcomeReported, OutcomeAborted:
c.scTripped = true
}
}
// maybeEscalate reports the escalation target exactly once, the first time any
// armed trigger has fired. Subsequent calls return false for the rest of the
// run: escalation is one-shot and never de-escalates, so no cooldown state is
// needed.
func (c *profileController) maybeEscalate() (PostureEscalation, bool) {
if c.policy == nil || c.escalated {
return PostureEscalation{}, false
}
if !(c.failureTripped || c.riskTripped || c.scTripped || c.uncertainTrip) {
return PostureEscalation{}, false
}
c.escalated = true
return *c.policy, true
}
// riskRank orders sandbox risk levels for threshold comparison. Unknown levels
// rank 0: an unrecognized RESULT level can never meet a valid threshold, and an
// unrecognized THRESHOLD is rejected before comparison (rank 0 disables the
// signal) so a typo in a profile can never make every result match.
func riskRank(level sandbox.RiskLevel) int {
switch level {
case sandbox.RiskLow:
return 1
case sandbox.RiskMedium:
return 2
case sandbox.RiskHigh:
return 3
case sandbox.RiskCritical:
return 4
default:
return 0
}
}
+345
View File
@@ -0,0 +1,345 @@
package agent
import (
"context"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/trace"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
func TestProfileControllerNilPolicyIsNoOp(t *testing.T) {
for _, controller := range []*profileController{
newProfileController(nil),
newProfileController(&ProfilePolicy{Name: "balanced"}), // Escalate nil
} {
controller.observeToolOutcome(toolFailureOutcome{Count: 99}, ToolResult{Status: tools.StatusOK, Risk: sandbox.Risk{Level: sandbox.RiskCritical}})
controller.observeUncertain()
controller.observeSelfCorrect(OutcomeAborted)
if _, fired := controller.maybeEscalate(); fired {
t.Fatal("nil policy must never escalate")
}
}
}
func TestProfileControllerEscalatesOnFailureStreak(t *testing.T) {
controller := newProfileController(&ProfilePolicy{Escalate: &PostureEscalation{OnToolFailureStreak: 2, MaxTurns: 80}})
controller.observeToolOutcome(toolFailureOutcome{Count: 1}, ToolResult{Status: tools.StatusError})
if _, fired := controller.maybeEscalate(); fired {
t.Fatal("streak below threshold must not escalate")
}
controller.observeToolOutcome(toolFailureOutcome{Count: 2}, ToolResult{Status: tools.StatusError})
target, fired := controller.maybeEscalate()
if !fired || target.MaxTurns != 80 {
t.Fatalf("expected escalation with the policy target, got fired=%t target=%+v", fired, target)
}
}
func TestProfileControllerEscalatesOnRiskyMutation(t *testing.T) {
controller := newProfileController(&ProfilePolicy{Escalate: &PostureEscalation{OnRiskyMutation: sandbox.RiskHigh}})
// Medium risk on an executed result: below threshold.
controller.observeToolOutcome(toolFailureOutcome{}, ToolResult{Status: tools.StatusOK, Risk: sandbox.Risk{Level: sandbox.RiskMedium}})
if _, fired := controller.maybeEscalate(); fired {
t.Fatal("medium risk must not trip a high threshold")
}
// Critical risk on a DENIED result: never counts (the mutation never ran).
controller.observeToolOutcome(toolFailureOutcome{}, ToolResult{Status: tools.StatusError, DenialReason: DenialSandboxBlock, Risk: sandbox.Risk{Level: sandbox.RiskCritical}})
if _, fired := controller.maybeEscalate(); fired {
t.Fatal("a denied result must not trip the risky-mutation signal")
}
// Critical risk on an executed PARTIAL FAILURE (error status, no denial):
// the mutation ran, so it counts.
controller.observeToolOutcome(toolFailureOutcome{}, ToolResult{Status: tools.StatusError, Risk: sandbox.Risk{Level: sandbox.RiskCritical}})
if _, fired := controller.maybeEscalate(); !fired {
t.Fatal("an executed partial failure at critical risk must trip a high threshold")
}
}
func TestProfileControllerIgnoresInvalidRiskThreshold(t *testing.T) {
controller := newProfileController(&ProfilePolicy{Escalate: &PostureEscalation{OnRiskyMutation: sandbox.RiskLevel("hgih")}})
controller.observeToolOutcome(toolFailureOutcome{}, ToolResult{Status: tools.StatusOK, Risk: sandbox.Risk{Level: sandbox.RiskCritical}})
if _, fired := controller.maybeEscalate(); fired {
t.Fatal("an unrecognized threshold must disable the signal, not match every result")
}
}
func TestProfileControllerEscalatesOnSelfCorrectFailure(t *testing.T) {
controller := newProfileController(&ProfilePolicy{Escalate: &PostureEscalation{OnSelfCorrectFailure: true}})
controller.observeSelfCorrect(OutcomePassed)
controller.observeSelfCorrect(OutcomeDisabled)
if _, fired := controller.maybeEscalate(); fired {
t.Fatal("passing/disabled outcomes must not escalate")
}
controller.observeSelfCorrect(OutcomeReported)
if _, fired := controller.maybeEscalate(); !fired {
t.Fatal("a failing verification outcome must escalate")
}
}
func TestProfileControllerEscalatesOnUncertainCompletion(t *testing.T) {
controller := newProfileController(&ProfilePolicy{Escalate: &PostureEscalation{OnCompletionUncertain: 2}})
controller.observeUncertain()
if _, fired := controller.maybeEscalate(); fired {
t.Fatal("first uncertain evaluation must not trip a threshold of 2")
}
controller.observeUncertain()
if _, fired := controller.maybeEscalate(); !fired {
t.Fatal("second uncertain evaluation must escalate")
}
}
func TestProfileControllerEscalatesAtMostOnce(t *testing.T) {
controller := newProfileController(&ProfilePolicy{Escalate: &PostureEscalation{OnToolFailureStreak: 1}})
controller.observeToolOutcome(toolFailureOutcome{Count: 1}, ToolResult{Status: tools.StatusError})
if _, fired := controller.maybeEscalate(); !fired {
t.Fatal("expected first escalation")
}
controller.observeToolOutcome(toolFailureOutcome{Count: 5}, ToolResult{Status: tools.StatusOK, Risk: sandbox.Risk{Level: sandbox.RiskCritical}})
controller.observeUncertain()
if _, fired := controller.maybeEscalate(); fired {
t.Fatal("escalation must be one-shot per run")
}
}
// TestPostureEscalationOnUncertainCompletion proves the uncertain-path act
// point: the completion gate's continue branch escapes the end-of-turn tail,
// so escalation must apply before the continue. A cue turn under the gate
// counts as uncertain, escalates, and the very next request carries the target
// effort.
func TestPostureEscalationOnUncertainCompletion(t *testing.T) {
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
{
{Type: zeroruntime.StreamEventText, Content: "Let me read the file:"},
{Type: zeroruntime.StreamEventDone},
},
{
{Type: zeroruntime.StreamEventText, Content: "Done. All set."},
{Type: zeroruntime.StreamEventDone},
},
}}
recorder := trace.NewRecorder("posture-session", "run-2", "fast")
result, err := Run(context.Background(), "go", provider, Options{
Registry: tools.NewRegistry(),
MaxTurns: 10,
RequireCompletionSignal: true,
Trace: recorder,
Profile: &ProfilePolicy{
Name: "fast",
Escalate: &PostureEscalation{
ReasoningEffort: "high",
OnCompletionUncertain: 1,
},
},
})
if err != nil {
t.Fatal(err)
}
if result.FinalAnswer != "Done. All set." {
t.Fatalf("expected the completed answer, got %q", result.FinalAnswer)
}
if len(provider.requests) != 2 {
t.Fatalf("expected 2 requests, got %d", len(provider.requests))
}
if provider.requests[1].ReasoningEffort != "high" {
t.Fatalf("expected the post-escalation request to carry the target effort, got %q", provider.requests[1].ReasoningEffort)
}
tr := recorder.Finish()
if got := tr.Counter(trace.CounterPostureEscalations); got != 1 {
t.Fatalf("posture_escalations = %d, want 1", got)
}
}
// A profile that filled the run's effort displaced the provider default ("");
// RestoreDefaultEffort lets escalation restore that default, which a plain ""
// ReasoningEffort target cannot express (it means "leave untouched").
func TestPostureEscalationRestoresDefaultEffort(t *testing.T) {
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
{
{Type: zeroruntime.StreamEventText, Content: "Let me read the file:"},
{Type: zeroruntime.StreamEventDone},
},
{
{Type: zeroruntime.StreamEventText, Content: "Done. All set."},
{Type: zeroruntime.StreamEventDone},
},
}}
result, err := Run(context.Background(), "go", provider, Options{
Registry: tools.NewRegistry(),
MaxTurns: 10,
ReasoningEffort: "low",
RequireCompletionSignal: true,
Profile: &ProfilePolicy{
Name: "fast",
Escalate: &PostureEscalation{
RestoreDefaultEffort: true,
OnCompletionUncertain: 1,
},
},
})
if err != nil {
t.Fatal(err)
}
if result.FinalAnswer != "Done. All set." {
t.Fatalf("expected the completed answer, got %q", result.FinalAnswer)
}
if len(provider.requests) != 2 {
t.Fatalf("expected 2 requests, got %d", len(provider.requests))
}
if provider.requests[0].ReasoningEffort != "low" {
t.Fatalf("pre-escalation request effort = %q, want the profile's low", provider.requests[0].ReasoningEffort)
}
if provider.requests[1].ReasoningEffort != "" {
t.Fatalf("post-escalation request effort = %q, want the restored provider default (empty)", provider.requests[1].ReasoningEffort)
}
}
// failingProfileTool always errors with the same signature so the repeated-
// failure guard builds a streak.
type failingProfileTool struct{}
func (failingProfileTool) Name() string { return "flaky_probe" }
func (failingProfileTool) Description() string { return "always fails identically (test)" }
func (failingProfileTool) Parameters() tools.Schema {
return tools.Schema{Type: "object", AdditionalProperties: false}
}
func (failingProfileTool) Safety() tools.Safety {
return tools.Safety{SideEffect: tools.SideEffectRead, Permission: tools.PermissionAllow}
}
func (failingProfileTool) Run(context.Context, map[string]any) tools.Result {
return tools.Result{Status: tools.StatusError, Output: "probe exploded: same signature"}
}
// TestPostureEscalationRaisesTurnCeilingMidRun proves the end-to-end act path:
// a Fast-style run with a 2-turn ceiling hits a failure streak, escalates, and
// finishes on a turn the original ceiling would have cut off, with the effort
// target applied to the post-escalation request and the counter emitted.
func TestPostureEscalationRaisesTurnCeilingMidRun(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(failingProfileTool{})
toolTurn := []zeroruntime.StreamEvent{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "c1", ToolName: "flaky_probe"},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "c1"},
{Type: zeroruntime.StreamEventDone},
}
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
toolTurn,
toolTurn,
{
{Type: zeroruntime.StreamEventText, Content: "recovered after escalation"},
{Type: zeroruntime.StreamEventDone},
},
}}
recorder := trace.NewRecorder("posture-session", "run-1", "fast")
result, err := Run(context.Background(), "go", provider, Options{
Registry: registry,
MaxTurns: 2,
Trace: recorder,
Profile: &ProfilePolicy{
Name: "fast",
Escalate: &PostureEscalation{
MaxTurns: 4,
ReasoningEffort: "high",
OnToolFailureStreak: 2,
},
},
})
if err != nil {
t.Fatal(err)
}
if result.FinalAnswer != "recovered after escalation" {
t.Fatalf("expected the run to continue past the original 2-turn ceiling, got %q (turns=%d)", result.FinalAnswer, result.Turns)
}
if len(provider.requests) != 3 {
t.Fatalf("expected 3 requests (ceiling raised from 2 to 4), got %d", len(provider.requests))
}
if provider.requests[2].ReasoningEffort != "high" {
t.Fatalf("expected the post-escalation request to carry the target effort, got %q", provider.requests[2].ReasoningEffort)
}
tr := recorder.Finish()
if got := tr.Counter(trace.CounterPostureEscalations); got != 1 {
t.Fatalf("posture_escalations = %d, want 1", got)
}
}
// TestPostureEscalationAbsentWithoutProfile pins the no-regression invariant at
// the loop level: the identical failing script without a profile ends at the
// original ceiling with the max-turns answer.
func TestPostureEscalationAbsentWithoutProfile(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(failingProfileTool{})
toolTurn := []zeroruntime.StreamEvent{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "c1", ToolName: "flaky_probe"},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "c1"},
{Type: zeroruntime.StreamEventDone},
}
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{toolTurn, toolTurn}}
result, err := Run(context.Background(), "go", provider, Options{
Registry: registry,
MaxTurns: 2,
})
if err != nil {
t.Fatal(err)
}
if len(provider.requests) > 3 {
t.Fatalf("unprofiled run must not extend the ceiling, got %d requests", len(provider.requests))
}
if !strings.Contains(result.FinalAnswer, "maximum number of turns") {
t.Fatalf("unprofiled run must end with the max-turns answer, got %q", result.FinalAnswer)
}
}
// TestExecuteToolCallClassifiesRiskWithoutSandbox verifies the executed-risk
// stamp on the unsandboxed path: a read-only tool classifies low.
func TestExecuteToolCallClassifiesRiskWithoutSandbox(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(failingProfileTool{})
result, abortErr := executeToolCall(
context.Background(),
registry,
ToolCall{ID: "c1", Name: "flaky_probe"},
PermissionModeAuto,
Options{},
)
if abortErr != nil {
t.Fatal(abortErr)
}
if result.Risk.Level == "" {
t.Fatal("executed result must carry a risk classification")
}
if riskRank(result.Risk.Level) > riskRank(sandbox.RiskMedium) {
t.Fatalf("read-only tool classified %q, want at most medium", result.Risk.Level)
}
}
// TestUnknownToolResultCarriesZeroRisk verifies a call that never executed a
// tool (unknown name, nil tool) keeps the zero risk value instead of panicking
// or inventing a classification.
func TestUnknownToolResultCarriesZeroRisk(t *testing.T) {
registry := tools.NewRegistry()
result, abortErr := executeToolCall(
context.Background(),
registry,
ToolCall{ID: "c1", Name: "no_such_tool"},
PermissionModeAuto,
Options{},
)
if abortErr != nil {
t.Fatal(abortErr)
}
if result.Status == tools.StatusOK {
t.Fatal("expected an error result for an unknown tool")
}
if result.Risk.Level != "" {
t.Fatalf("not-executed result must keep the zero risk value, got %q", result.Risk.Level)
}
}
+51
View File
@@ -0,0 +1,51 @@
package agent
import (
"testing"
"github.com/Gitlawb/zero/internal/tools"
)
// These ceilings are a deliberate ratchet on Zero's fixed per-turn overhead — the
// system prompt and the eager tool schemas that ride on EVERY request. They are
// set ~10% above the current measured cost, using ApproxTextTokens (the same
// estimate the compaction loop and /context use, ~non-whitespace-bytes/4). A change
// that pushes either past its ceiling must be justified (and the ceiling raised
// deliberately) or trimmed — the per-turn floor should not creep up silently.
//
// Measured baselines (2026-07): base system prompt ~3160 tokens; the tools a normal
// (auto permission-mode) interactive turn sends ~2430 tokens. The tool figure is
// the auto-mode advertised set, not the full core registry — higher-risk tools that
// only advertise in other modes are excluded, matching what a real turn actually
// pays.
const (
maxBaseSystemPromptTokens = 3500
maxEagerToolSchemaTokens = 2700
)
func TestSystemPromptTokenBudget(t *testing.T) {
// Minimal render: a model is set (so the session block renders) but no Cwd, so
// the workspace map, project guidelines, and repo map are excluded — this is the
// fixed base every session pays regardless of the workspace it runs in.
prompt := buildSystemPrompt(Options{Model: "claude-opus-4-8"})
got := ApproxTextTokens(prompt)
t.Logf("base system prompt: %d tokens (%d bytes)", got, len(prompt))
if got > maxBaseSystemPromptTokens {
t.Fatalf("base system prompt is %d tokens, over the %d ceiling — trim it or raise the ceiling deliberately", got, maxBaseSystemPromptTokens)
}
}
func TestEagerToolSchemaTokenBudget(t *testing.T) {
registry := tools.NewRegistry()
for _, tool := range tools.CoreToolsScoped(t.TempDir(), nil) {
registry.Register(tool)
}
// Options{} keeps DeferThreshold at 0, so deferral is inactive and every core
// tool is exposed eagerly — exactly what a plugin-free session sends each turn.
exposed, _ := partitionTools(registry, PermissionModeAuto, Options{}, map[string]bool{})
got := estimateToolDefTokens(exposed)
t.Logf("eager core tool schemas: %d tokens across %d tools", got, len(exposed))
if got > maxEagerToolSchemaTokens {
t.Fatalf("eager tool schemas are %d tokens, over the %d ceiling — defer a tool or raise the ceiling deliberately", got, maxEagerToolSchemaTokens)
}
}
+300
View File
@@ -0,0 +1,300 @@
package agent
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"sort"
"strings"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// promptSubstrings are the seven cacheable sub-components of the prompt a run
// sends to a model. Each is the literal content the sub-component would emit
// into the system prompt or tool list, hashed so a downstream observer can
// detect drift turn-over-turn. The substrings are produced by
// buildPromptSubstrings and consumed by ComputePrefixFingerprint.
type promptSubstrings struct {
baseInstructions string
confirmationPolicy string
projectContext string
skills string
tools string
schema string
}
// buildPromptSubstrings assembles the seven cacheable sub-components of the
// prompt without joining them. The system prompt sections are produced by the
// same private builders that buildSystemPrompt uses, so the substrings are
// byte-identical to what would have appeared in the joined prompt. The tools
// and schema substrings are derived from the partitioned tool list (caller-
// provided) so this helper does not need to re-run the partition.
//
// This is the seam ComputePrefixFingerprint reads from. It exists as a
// separate function (rather than returning both prompt and substrings from
// buildSystemPrompt) so existing callers of buildSystemPrompt are unaffected
// and the substrings helper is independently testable.
func buildPromptSubstrings(options Options, exposed []zeroruntime.ToolDefinition) promptSubstrings {
core := strings.TrimSpace(options.SystemPrompt)
if core == "" {
core = strings.TrimSpace(coreSystemPrompt)
}
if core == "" {
core = fallbackSystemPrompt
}
// The confirmation policy is appended unconditionally in buildSystemPrompt;
// the substring here is the same constant.
policy := strings.TrimSpace(confirmationPolicy)
// projectContext is the joined output of workspaceContext, which itself
// walks the AGENTS.md / ZERO.md / .zero/AGENTS.md chain. Hashing the
// joined string captures every guideline file's contribution and the
// ordering between them.
project := workspaceContext(options.Cwd)
// skills is the joined output of skillsContext, with each skill on its own
// line. The substring is the literal text the model would see.
skills := skillsContext(options)
// tools and schema are derived from the partitioned tool list. The list
// passed in is already in stable order (partitionToolsCached is the
// canonical stable partitioner) so concatenating in slice order is
// sufficient — we sort names again defensively in case a future caller
// hands us a list from a different partitioner.
toolsSubstr, schemaSubstr := toolSubstrings(exposed)
return promptSubstrings{
baseInstructions: core,
confirmationPolicy: policy,
projectContext: project,
skills: skills,
tools: toolsSubstr,
schema: schemaSubstr,
}
}
// toolSubstrings extracts a stable, name-sorted digest of the partitioned tool
// list. The tools substring is "name1\nname2\n..." (sorted) so a reordering of
// the partition produces a different hash. The schema substring is the
// concatenation of each tool's JSON schema in name order, so a schema change
// is detected independently of the tool ordering.
func toolSubstrings(exposed []zeroruntime.ToolDefinition) (toolsSubstr, schemaSubstr string) {
if len(exposed) == 0 {
return "", ""
}
names := make([]string, 0, len(exposed))
byName := make(map[string]zeroruntime.ToolDefinition, len(exposed))
for _, def := range exposed {
names = append(names, def.Name)
byName[def.Name] = def
}
sort.Strings(names)
var toolsSB, schemaSB strings.Builder
for i, name := range names {
if i > 0 {
toolsSB.WriteByte('\n')
}
toolsSB.WriteString(name)
def := byName[name]
// Parameters is rendered to a canonical JSON string per tool. The
// schema-render cache in internal/agent (used by partitionToolsCached)
// guarantees the same render is byte-identical across turns for the
// same tool, so this substring is a stable hash input.
schemaSB.WriteString(name)
schemaSB.WriteByte('\n')
schemaSB.WriteString(canonicalSchemaString(def.Parameters))
schemaSB.WriteByte('\n')
}
return toolsSB.String(), schemaSB.String()
}
// canonicalSchemaString renders a tool's parameter schema to a stable string.
// tools.ToolDef.Parameters is a map[string]any in the common case; Go's
// fmt.Sprintf("%v", m) iterates a map in random order, which would produce
// a different hash for the same Parameters value across calls and defeat
// the fingerprint. encoding/json marshals maps with keys sorted
// alphabetically, so json.Marshal is the primary stable render. The
// fallback for the rare non-JSON-compatible value (functions, channels,
// NaN/Inf floats, cyclic references) is a stable key-sorted stringifier
// that walks the value with sorted keys for maps and a leading
// "__non_json:" prefix so a future schema change is visible in the trace
// (a SchemaHash collision from the same value) rather than a silent hash
// drift.
//
// Note: SchemaHash is a stability signal, not a wire-identity signal. The
// bytes json.Marshal produces for a Go map[string]any may differ from
// the bytes the provider actually sends on the wire (provider encoders
// use their own JSON conventions; an Anthropic schema may serialize with
// different whitespace, key order, or number formatting). A consumer
// must NOT assume SchemaHash matches the provider's wire schema — only
// that two turns with the same Go-side Parameters produce the same
// SchemaHash. This is sufficient for the trace's purpose (drift
// detection) but not for a content-based equality check.
func canonicalSchemaString(params map[string]any) string {
if len(params) == 0 {
return ""
}
data, err := json.Marshal(params)
if err != nil {
return "__non_json:" + stableStringify(params)
}
return string(data)
}
// stableStringify renders v to a deterministic string. Maps have their keys
// sorted alphabetically; slices are walked in order; primitives use their
// natural Go format. The function is recursive but bounded by the size of
// the input, so a cyclic reference is not reachable here (the json.Marshal
// fallback is hit when json.Marshal itself returns an error, which for the
// common case is a non-JSON-compatible value, not a cycle — cycles are
// rare in tool schemas and acceptable to mis-render in the fallback path
// since the trace's contract is "the hash is stable for the same input,"
// not "the fallback is lossless"). Used only when json.Marshal fails.
func stableStringify(v any) string {
var sb strings.Builder
writeStable(&sb, v)
return sb.String()
}
func writeStable(sb *strings.Builder, v any) {
switch x := v.(type) {
case nil:
sb.WriteString("null")
case bool:
if x {
sb.WriteString("true")
} else {
sb.WriteString("false")
}
case string:
sb.WriteByte('"')
sb.WriteString(x)
sb.WriteByte('"')
case float64:
// Use %g for compact, deterministic float rendering. NaN and Inf are
// not JSON-encodable (which is why we are in the fallback path) and
// render as "NaN" / "+Inf" / "-Inf" — distinct, stable strings.
fmt.Fprintf(sb, "%g", x)
case float32:
fmt.Fprintf(sb, "%g", float64(x))
case int:
fmt.Fprintf(sb, "%d", x)
case int64:
fmt.Fprintf(sb, "%d", x)
case int32:
fmt.Fprintf(sb, "%d", x)
case uint:
fmt.Fprintf(sb, "%d", x)
case uint64:
fmt.Fprintf(sb, "%d", x)
case uint32:
fmt.Fprintf(sb, "%d", x)
case map[string]any:
sb.WriteByte('{')
keys := make([]string, 0, len(x))
for k := range x {
keys = append(keys, k)
}
sort.Strings(keys)
for i, k := range keys {
if i > 0 {
sb.WriteByte(',')
}
sb.WriteByte('"')
sb.WriteString(k)
sb.WriteString(`":`)
writeStable(sb, x[k])
}
sb.WriteByte('}')
case []any:
sb.WriteByte('[')
for i, item := range x {
if i > 0 {
sb.WriteByte(',')
}
writeStable(sb, item)
}
sb.WriteByte(']')
default:
// Last-resort: include the type so distinct values produce distinct
// strings even if their default format collides. Without the type
// tag, fmt.Sprintf("%v", x) for two different types could produce
// the same bytes (rare, but the type prefix makes it impossible).
fmt.Fprintf(sb, "<%T:%v>", x, x)
}
}
// ComputePrefixFingerprint returns a trace.PrefixHash (a 7-field fingerprint
// of the prompt prefix) for one turn of a run. The seven sub-hashes are
// independent SHA-256s of the corresponding sub-component; the complete-prefix
// hash is a SHA-256 of the canonical concatenation of the other six, so any
// sub-component drift is observable both individually and in aggregate.
//
// I/O side effects: buildPromptSubstrings calls workspaceContext, which runs
// git (gitBranchForPrompt, FindProjectGitRoot) and reads the AGENTS.md /
// ZERO.md / .zero/AGENTS.md chain plus the repo map. The same workspace-
// context work is performed separately by buildSystemPrompt a few lines
// later in the request-build path, so on every traced turn the file and
// git reads happen twice. The duplication is intentional for this PR (a
// follow-up will pass substrings through) and is a known perf cost only on
// the opt-in trace path.
//
// A stable CompletePrefixHash across turns means the four captured
// sub-components (baseInstructions, confirmationPolicy, projectContext,
// skills) are byte-identical. It does NOT rule out drift in the seven
// uncaptured sections of buildSystemPrompt (modelPromptAddendum,
// sessionRuntimeContext, approvedCommandPrefixContext, workspaceSeedContext,
// userGuidelines, specialistDelegationContext, responseStyleContext). For
// default Options the four captured substrings are the full prompt; for
// non-default Options the seven uncaptured sections may contribute, and a
// consumer correlating CompletePrefix stability with cached_input_tokens
// must cross-check the model_switches counter (modelPromptAddendum changes
// on a model switch) to disambiguate "no drift" from "drift in an
// uncaptured section."
func ComputePrefixFingerprint(options Options, exposed []zeroruntime.ToolDefinition) prefixFingerprint {
subs := buildPromptSubstrings(options, exposed)
base := sha256hex(subs.baseInstructions)
policy := sha256hex(subs.confirmationPolicy)
project := sha256hex(subs.projectContext)
skills := sha256hex(subs.skills)
toolsH := sha256hex(subs.tools)
schema := sha256hex(subs.schema)
complete := sha256hex(strings.Join([]string{
base, policy, project, skills, toolsH, schema,
}, "|"))
return prefixFingerprint{
BaseInstructionsHash: base,
ConfirmationPolicyHash: policy,
ProjectContextHash: project,
SkillsHash: skills,
ToolsHash: toolsH,
SchemaHash: schema,
CompletePrefixHash: complete,
}
}
// prefixFingerprint is the agent-side shape of a prompt-prefix fingerprint. It
// is converted to a trace.PrefixHash at the loop boundary (see EmitPrefixHash
// in loop.go) so the trace package does not need to import this one. The field
// names match the trace.PrefixHash JSON tags 1:1.
type prefixFingerprint struct {
BaseInstructionsHash string
ConfirmationPolicyHash string
ProjectContextHash string
SkillsHash string
ToolsHash string
SchemaHash string
CompletePrefixHash string
}
// sha256hex returns the hex-encoded SHA-256 of s. Empty input produces the
// hash of the empty string, which is a constant; callers that want to
// distinguish "absent" from "empty" should check s before calling.
func sha256hex(s string) string {
sum := sha256.Sum256([]byte(s))
return hex.EncodeToString(sum[:])
}
+243
View File
@@ -0,0 +1,243 @@
package agent
import (
"strings"
"testing"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// TestComputePrefixFingerprintStableAcrossCalls asserts the headline property
// the trace is meant to expose: two ComputePrefixFingerprint calls over the
// same Options and the same exposed tool list produce byte-identical hashes.
// This is the regression catch for "did anyone introduce non-determinism into
// the prompt prefix."
func TestComputePrefixFingerprintStableAcrossCalls(t *testing.T) {
opts := Options{Cwd: t.TempDir(), SystemPrompt: "core"}
exposed := []zeroruntime.ToolDefinition{
{Name: "read_file", Parameters: map[string]any{"type": "object"}},
{Name: "grep", Parameters: map[string]any{"type": "object"}},
}
first := ComputePrefixFingerprint(opts, exposed)
second := ComputePrefixFingerprint(opts, exposed)
if first != second {
t.Fatalf("fingerprint must be stable across calls with the same input:\n first=%+v\n second=%+v", first, second)
}
if first.CompletePrefixHash == "" {
t.Fatalf("CompletePrefixHash must be non-empty for a non-trivial prompt")
}
}
// TestComputePrefixFingerprintToolsHashIsNameSetSensitive asserts the
// name-set sensitivity of the ToolsHash. The test name used to read as
// "reorder changes hash" but the assertion is the opposite: the hash is
// name-set sensitive, not name-order sensitive (toolSubstrings sorts
// names before hashing). The partitioner is expected to produce a stable
// order, so a name-order change is not a hash signal — a name-set change
// is. The test asserts both: permutation of the same tools produces the
// same hash, and adding a tool produces a different hash.
func TestComputePrefixFingerprintToolsHashIsNameSetSensitive(t *testing.T) {
opts := Options{Cwd: t.TempDir(), SystemPrompt: "core"}
a := []zeroruntime.ToolDefinition{
{Name: "read_file", Parameters: map[string]any{"schema": "schema-read"}},
{Name: "grep", Parameters: map[string]any{"schema": "schema-grep"}},
}
b := []zeroruntime.ToolDefinition{
{Name: "grep", Parameters: map[string]any{"schema": "schema-grep"}},
{Name: "read_file", Parameters: map[string]any{"schema": "schema-read"}},
}
// toolSubstrings sorts names internally, so the ToolsHash is identical
// for a permutation of the same tools. We assert that here to document
// the contract: name-order independence, name-set sensitivity.
fpA := ComputePrefixFingerprint(opts, a)
fpB := ComputePrefixFingerprint(opts, b)
if fpA.ToolsHash != fpB.ToolsHash {
t.Fatalf("ToolsHash must be name-set sensitive, not name-order sensitive: a=%s b=%s", fpA.ToolsHash, fpB.ToolsHash)
}
// A different tool set must produce a different ToolsHash.
c := append([]zeroruntime.ToolDefinition{}, a...)
c = append(c, zeroruntime.ToolDefinition{Name: "bash", Parameters: map[string]any{"schema": "schema-bash"}})
fpC := ComputePrefixFingerprint(opts, c)
if fpA.ToolsHash == fpC.ToolsHash {
t.Fatalf("ToolsHash must change when the tool set changes: a=%s c=%s", fpA.ToolsHash, fpC.ToolsHash)
}
}
// TestComputePrefixFingerprintSchemaChangeChangesSchemaHash asserts that a
// change to a tool's Parameters schema is visible in the SchemaHash. A schema
// edit that should be cache-stable (e.g. field reorder, doc tweak) will move
// the hash and surface in the trace; a schema edit that legitimately
// invalidates the cache also moves the hash, which is the right behavior.
func TestComputePrefixFingerprintSchemaChangeChangesSchemaHash(t *testing.T) {
opts := Options{Cwd: t.TempDir(), SystemPrompt: "core"}
a := []zeroruntime.ToolDefinition{
{Name: "read_file", Parameters: map[string]any{"version": "v1"}},
}
b := []zeroruntime.ToolDefinition{
{Name: "read_file", Parameters: map[string]any{"version": "v2"}},
}
fpA := ComputePrefixFingerprint(opts, a)
fpB := ComputePrefixFingerprint(opts, b)
if fpA.SchemaHash == fpB.SchemaHash {
t.Fatalf("SchemaHash must change when tool Parameters change: a=%s b=%s", fpA.SchemaHash, fpB.SchemaHash)
}
}
// TestComputePrefixFingerprintSystemPromptChangeChangesBaseHash asserts the
// most common drift path: a system-prompt edit (e.g. an agent.md update, a
// model addendum change) must move the base-instructions hash, which moves
// the complete-prefix hash, which is what the trace is for.
func TestComputePrefixFingerprintSystemPromptChangeChangesBaseHash(t *testing.T) {
optsA := Options{Cwd: t.TempDir(), SystemPrompt: "core v1"}
optsB := Options{Cwd: t.TempDir(), SystemPrompt: "core v2"}
exposed := []zeroruntime.ToolDefinition{{Name: "noop", Parameters: map[string]any{}}}
fpA := ComputePrefixFingerprint(optsA, exposed)
fpB := ComputePrefixFingerprint(optsB, exposed)
if fpA.BaseInstructionsHash == fpB.BaseInstructionsHash {
t.Fatalf("BaseInstructionsHash must change when SystemPrompt changes: a=%s b=%s", fpA.BaseInstructionsHash, fpB.BaseInstructionsHash)
}
if fpA.CompletePrefixHash == fpB.CompletePrefixHash {
t.Fatalf("CompletePrefixHash must change when any sub-hash changes: a=%s b=%s", fpA.CompletePrefixHash, fpB.CompletePrefixHash)
}
}
// TestComputePrefixFingerprintCompleteIsAggregateOfSubHashes asserts the
// canonical-join property: CompletePrefixHash must depend on every sub-hash.
// This is the regression catch for "did anyone reorder or drop a sub-hash
// from the canonical join without updating CompletePrefixHash."
func TestComputePrefixFingerprintCompleteIsAggregateOfSubHashes(t *testing.T) {
opts := Options{Cwd: t.TempDir(), SystemPrompt: "core"}
exposed := []zeroruntime.ToolDefinition{{Name: "read_file", Parameters: map[string]any{"k": "v"}}}
fp := ComputePrefixFingerprint(opts, exposed)
// Manually compute what the canonical join should be.
expected := sha256hex(strings.Join([]string{
fp.BaseInstructionsHash,
fp.ConfirmationPolicyHash,
fp.ProjectContextHash,
fp.SkillsHash,
fp.ToolsHash,
fp.SchemaHash,
}, "|"))
if fp.CompletePrefixHash != expected {
t.Fatalf("CompletePrefixHash must equal sha256hex of the canonical join of the other six:\n got: %s\n expected: %s", fp.CompletePrefixHash, expected)
}
}
// TestBuildPromptSubstringsDefaultOptions asserts the invariants of the
// substrings helper for default Options: which substrings are non-empty,
// which are empty, and that the substring-to-hash round-trip is lossless.
func TestBuildPromptSubstringsDefaultOptions(t *testing.T) {
opts := Options{
Cwd: t.TempDir(),
SystemPrompt: "test core",
}
subs := buildPromptSubstrings(opts, nil)
// Invariants the trace depends on (default Options):
// 1. baseInstructions substring equals the core system prompt bytes.
// 2. confirmationPolicy substring is non-empty (the embedded policy
// is always present, post TrimSpace).
// 3. skills, tools, and schema substrings are empty (no skills or
// tools configured for default Options).
if subs.baseInstructions != "test core" {
t.Fatalf("baseInstructions substring must equal the core system prompt: got %q want %q", subs.baseInstructions, "test core")
}
if subs.confirmationPolicy == "" {
t.Fatalf("confirmationPolicy substring must be non-empty (the embedded policy is always present)")
}
if subs.skills != "" {
t.Fatalf("skills substring must be empty for default Options: got %q", subs.skills)
}
// Round-trip: the corresponding fingerprint hashes must equal
// sha256hex of the substrings, with no truncation or reformatting
// between the two layers.
fp := ComputePrefixFingerprint(opts, nil)
if fp.BaseInstructionsHash != sha256hex(subs.baseInstructions) {
t.Fatalf("BaseInstructionsHash in fingerprint must equal sha256hex of the substring")
}
if fp.ConfirmationPolicyHash != sha256hex(subs.confirmationPolicy) {
t.Fatalf("ConfirmationPolicyHash in fingerprint must equal sha256hex of the substring")
}
}
// TestCanonicalSchemaStringStableForMapParameters asserts the headline
// property the trace needs: the same map[string]any produces the same
// canonical string across calls, even though Go's fmt.Sprintf("%v", m)
// iterates a map in random order. json.Marshal sorts map keys
// alphabetically, which is the fix.
func TestCanonicalSchemaStringStableForMapParameters(t *testing.T) {
params := map[string]any{
"description": "read a file",
"properties": map[string]any{
"path": map[string]any{"type": "string"},
},
"required": []any{"path"},
"type": "object",
}
first := canonicalSchemaString(params)
second := canonicalSchemaString(params)
if first != second {
t.Fatalf("canonicalSchemaString must be stable across calls for the same Parameters:\n first=%s\n second=%s", first, second)
}
if first == "" {
t.Fatal("canonicalSchemaString must produce a non-empty string for a non-empty map")
}
// Two maps with the same key/value pairs (different declaration
// order in source) must produce identical canonical strings. This
// is the property that defeats Go's map iteration randomization.
reordered := map[string]any{
"type": "object",
"required": []any{"path"},
"properties": map[string]any{
"path": map[string]any{"type": "string"},
},
"description": "read a file",
}
if canonicalSchemaString(params) != canonicalSchemaString(reordered) {
t.Fatal("canonicalSchemaString must produce identical output for maps with the same key/value pairs in different declaration orders")
}
}
// TestCanonicalSchemaStringFallbackStableForNonJSONValue exercises the
// non-JSON fallback in canonicalSchemaString: when Parameters contains a
// value json.Marshal cannot encode (here, a channel), the function must
// fall back to a key-sorted stringification that is stable across calls
// and across map-iteration order. The previous fallback used
// fmt.Sprintf("%v", params), which iterates the map in random order and
// produced a different hash for the same value across calls — defeating
// the fingerprint. The current fallback is stable by construction.
func TestCanonicalSchemaStringFallbackStableForNonJSONValue(t *testing.T) {
ch := make(chan int) // channels are not JSON-encodable
params := map[string]any{
"description": "a tool with a non-JSON value",
"channel": ch,
"z_first": 1,
"a_second": 2,
}
first := canonicalSchemaString(params)
second := canonicalSchemaString(params)
if first == "" {
t.Fatal("canonicalSchemaString must produce a non-empty string for the fallback path")
}
if first != second {
t.Fatalf("canonicalSchemaString fallback must be stable across calls:\n first=%s\n second=%s", first, second)
}
// A re-ordered map (same key/value pairs, different declaration order)
// must produce the same fallback string. This is the property the
// key-sorted stringifier provides that fmt.Sprintf("%v", m) does not.
reordered := map[string]any{
"a_second": 2,
"z_first": 1,
"channel": ch,
"description": "a tool with a non-JSON value",
}
if canonicalSchemaString(params) != canonicalSchemaString(reordered) {
t.Fatalf("canonicalSchemaString fallback must produce identical output for maps with the same key/value pairs in different declaration orders:\n first=%s\n second=%s", first, canonicalSchemaString(reordered))
}
// The fallback must include the "__non_json:" prefix so a consumer
// can tell the bytes are not a JSON-marshaled schema (a hash collision
// between a json.Marshal result and a stableStringify result is
// possible in theory; the prefix makes the source observable).
if !strings.HasPrefix(first, "__non_json:") {
t.Fatalf("canonicalSchemaString fallback must start with the __non_json: prefix, got: %s", first)
}
}
+56 -5
View File
@@ -3,9 +3,12 @@ package agent
import (
"context"
"fmt"
"math/rand"
"strings"
"time"
"github.com/Gitlawb/zero/internal/errhint"
"github.com/Gitlawb/zero/internal/trace"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
@@ -17,10 +20,20 @@ import (
// times. We retry ONLY the connect (not a partially-consumed stream), so no
// already-forwarded OnText is ever duplicated.
const (
maxStreamReconnects = 2
streamReconnectBase = 500 * time.Millisecond
// maxStreamReconnects is how many times the connect is re-issued after a
// transient disconnect. 4 (not 2): with jittered exponential backoff this rides
// out a multi-second network blip (~0.5s + 1s + 2s + 4s worst case) instead of
// dying on a 2s hiccup and re-burning every token on a restart.
maxStreamReconnects = 4
// streamReconnectMax caps a single backoff so the tail attempts don't wait
// minutes on a long outage.
streamReconnectMax = 8 * time.Second
)
// streamReconnectBase is the first-retry delay (doubled each subsequent attempt).
// A var, not a const, so tests can shrink it to keep the exhaustion path fast.
var streamReconnectBase = 500 * time.Millisecond
// reconnectNotifier is called before each retry with the 1-based attempt number
// and the max, so the caller can surface a "Reconnecting N/max" notice. Nil is
// fine.
@@ -62,22 +75,32 @@ func stallRetryNoticeFor(options Options) reconnectNotifier {
// already past its deadline is returned immediately (no retry) — those have
// their own handling (compaction for context-limit, image-rejection, etc.).
func streamWithReconnect(ctx context.Context, provider Provider, request zeroruntime.CompletionRequest, notify reconnectNotifier) (<-chan zeroruntime.StreamEvent, error) {
recorder := trace.FromContext(ctx)
stream, err := provider.StreamCompletion(ctx, request)
if err == nil {
if recorder != nil {
recorder.Counter(trace.CounterModelRequests, 1)
}
return stream, nil
}
for attempt := 1; attempt <= maxStreamReconnects; attempt++ {
if !shouldReconnect(ctx, err) {
return nil, err
}
if recorder != nil {
recorder.Counter(trace.CounterReconnectCount, 1)
}
if notify != nil {
notify(attempt, maxStreamReconnects)
}
if waitErr := sleepWithContext(ctx, backoffFor(attempt)); waitErr != nil {
if waitErr := sleepWithContext(ctx, jitteredBackoff(attempt)); waitErr != nil {
return nil, err // ctx cancelled while waiting; surface the original error
}
stream, err = provider.StreamCompletion(ctx, request)
if err == nil {
if recorder != nil {
recorder.Counter(trace.CounterModelRequests, 1)
}
return stream, nil
}
}
@@ -96,6 +119,19 @@ func shouldReconnect(ctx context.Context, err error) bool {
if isContextLimitError(msg) || isImageRejectionError(err) {
return false
}
// HTTP 5xx statuses are non-reconnectable and must be excluded BEFORE the
// transport substring match below — otherwise "504 Gateway Timeout" would slip
// through on the generic "timeout" needle. 503 already exhausted
// providerio.SendWithRetry (retrying is a redundant double-retry); 500/502/504
// are non-idempotent by providerio's rule — the completion POST may already
// have reached the model before the upstream/gateway gave up, so replaying the
// connect risks duplicate billable work. Digit-boundary matched so an
// incidental "504" inside a latency/id number is not mistaken for a status.
if errhint.HasStatusCode(msg, "500", "502", "503", "504") {
return false
}
// Transport-level disconnects only. A genuine transport failure (EOF, reset,
// refused, timeout) means no response was received, which is safe to reconnect.
for _, needle := range []string{
"eof",
"connection reset",
@@ -106,8 +142,6 @@ func shouldReconnect(ctx context.Context, err error) bool {
"timed out",
"temporarily unavailable",
"i/o timeout",
"503",
"502",
"server closed",
"unexpected end",
} {
@@ -118,14 +152,31 @@ func shouldReconnect(ctx context.Context, err error) bool {
return false
}
// backoffFor is the deterministic exponential base delay for a 1-based attempt,
// capped at streamReconnectMax. Jitter is layered on separately (jitteredBackoff).
func backoffFor(attempt int) time.Duration {
d := streamReconnectBase
for i := 1; i < attempt; i++ {
if d >= streamReconnectMax {
return streamReconnectMax
}
d *= 2
}
if d > streamReconnectMax {
d = streamReconnectMax
}
return d
}
// jitteredBackoff adds up to 50% random jitter on top of backoffFor so concurrent
// runs (swarm members, a cron fleet) that all trip on the same outage don't
// reconnect in lockstep and hammer a recovering endpoint. Never shorter than the
// deterministic base, so backoff still grows attempt over attempt.
func jitteredBackoff(attempt int) time.Duration {
base := backoffFor(attempt)
return base + time.Duration(rand.Int63n(int64(base/2)+1))
}
func sleepWithContext(ctx context.Context, d time.Duration) error {
timer := time.NewTimer(d)
defer timer.Stop()
+30 -1
View File
@@ -44,6 +44,10 @@ func TestStreamWithReconnectRecoversFromTransientDisconnect(t *testing.T) {
}
func TestStreamWithReconnectGivesUpAfterMax(t *testing.T) {
// Shrink the backoff so exhausting all retries stays fast (real base would
// sleep ~7.5s across 4 attempts).
defer func(orig time.Duration) { streamReconnectBase = orig }(streamReconnectBase)
streamReconnectBase = time.Millisecond
// Always fails with a disconnect error → exhausts retries and returns it.
p := &flakyProvider{failBefore: 99, failErr: errors.New("connection reset by peer")}
_, err := streamWithReconnect(context.Background(), p, zeroruntime.CompletionRequest{}, nil)
@@ -87,7 +91,7 @@ func TestShouldReconnectClassification(t *testing.T) {
ctx := context.Background()
disconnects := []string{
"unexpected EOF", "connection reset by peer", "broken pipe",
"i/o timeout", "503 Service Unavailable", "server closed the connection",
"i/o timeout", "server closed the connection", "connection refused",
}
for _, m := range disconnects {
if !shouldReconnect(ctx, errors.New(m)) {
@@ -97,6 +101,13 @@ func TestShouldReconnectClassification(t *testing.T) {
notDisconnects := []string{
"context length exceeded", "invalid api key", "model not found",
"400 bad request: unsupported parameter",
// HTTP 5xx statuses are handled by providerio.SendWithRetry (503) or are
// non-idempotent (500/502/504); the reconnect path must not double-retry
// them. "504 Gateway Timeout" must NOT slip through on the generic "timeout"
// transport signal — a gateway timeout is non-idempotent, since the upstream
// may have already processed the completion POST before giving up.
"503 Service Unavailable", "502 Bad Gateway",
"504 Gateway Timeout", "500 Internal Server Error",
}
for _, m := range notDisconnects {
if shouldReconnect(ctx, errors.New(m)) {
@@ -112,6 +123,24 @@ func TestBackoffGrows(t *testing.T) {
if backoffFor(2) != 2*streamReconnectBase {
t.Fatalf("attempt 2 backoff = %v, want %v", backoffFor(2), 2*streamReconnectBase)
}
// The exponential base is capped so late attempts don't wait minutes.
if got := backoffFor(20); got != streamReconnectMax {
t.Fatalf("attempt 20 backoff = %v, want cap %v", got, streamReconnectMax)
}
}
func TestJitteredBackoffStaysInBounds(t *testing.T) {
// Jitter never drops below the deterministic base and never exceeds base*1.5,
// so backoff still grows attempt over attempt while decorrelating retries.
for attempt := 1; attempt <= 5; attempt++ {
base := backoffFor(attempt)
for i := 0; i < 200; i++ {
got := jitteredBackoff(attempt)
if got < base || got > base+base/2 {
t.Fatalf("attempt %d jittered backoff %v out of [%v, %v]", attempt, got, base, base+base/2)
}
}
}
}
func TestReconnectNoticeRoutesThroughReasoning(t *testing.T) {
+3
View File
@@ -9,6 +9,7 @@ import (
"github.com/Gitlawb/zero/internal/lsp"
"github.com/Gitlawb/zero/internal/redaction"
"github.com/Gitlawb/zero/internal/trace"
"github.com/Gitlawb/zero/internal/verify"
)
@@ -133,7 +134,9 @@ func (sc *SelfCorrector) inspect(ctx context.Context, changedFiles []string) Cor
}
if sc.cfg.IncludeTests && sc.verifier != nil {
verifySpan := trace.FromContext(ctx).Span(trace.SpanVerification)
vr, err := sc.verifier.Verify(ctx)
verifySpan.End()
if err != nil {
// "No plan / no tests" is not an error (DetectPlan returns an empty
// plan), so a non-nil error means verification could not run at all —
+88
View File
@@ -0,0 +1,88 @@
package agent
import (
"strconv"
"strings"
"testing"
)
func TestSkillsContextCapsLongList(t *testing.T) {
// 60 skills with realistic trigger-rich descriptions (~180 chars each, under
// the 200-rune truncation) total well past the 4096-byte list budget, so the
// overflow summary must kick in while the block stays bounded.
longDesc := strings.Repeat("use when the request touches deployments, release notes, or verification; ", 2) + "number "
skills := make([]SkillInfo, 0, 60)
for i := 0; i < 60; i++ {
n := strconv.Itoa(i)
skills = append(skills, SkillInfo{Name: "skill-" + n, Description: longDesc + n})
}
got := skillsContext(Options{Skills: skills})
if len(got) > skillsContextListBudget+1000 {
t.Fatalf("skills block should stay bounded near the %d-byte budget, got %d bytes:\n%s", skillsContextListBudget, len(got), got)
}
if !strings.Contains(got, "more (call skill") {
t.Fatalf("expected an overflow summary line, got:\n%s", got)
}
// The first skill is always listed regardless of budget.
if !strings.Contains(got, "- skill-0:") {
t.Fatalf("expected the first skill to always be listed, got:\n%s", got)
}
}
// A realistic mid-size skill set (20 skills, trigger-rich descriptions) must be
// listed in FULL — no overflow summary. This pins the fix for the old 640-byte
// budget, under which skills past ~#6 were invisible to the model and therefore
// never triggered.
func TestSkillsContextListsRealisticSetInFull(t *testing.T) {
desc := "Use when the user asks about deployments, release notes, or pre-merge verification runs."
skills := make([]SkillInfo, 0, 20)
for i := 0; i < 20; i++ {
skills = append(skills, SkillInfo{Name: "skill-" + strconv.Itoa(i), Description: desc})
}
got := skillsContext(Options{Skills: skills})
if strings.Contains(got, "more (call skill") {
t.Fatalf("20 described skills must all be listed without overflow, got:\n%s", got)
}
for i := 0; i < 20; i++ {
if !strings.Contains(got, "- skill-"+strconv.Itoa(i)+":") {
t.Fatalf("skill-%d missing from the list:\n%s", i, got)
}
}
}
func TestSkillsContext(t *testing.T) {
if got := skillsContext(Options{}); got != "" {
t.Fatalf("no skills should yield an empty section, got %q", got)
}
got := skillsContext(Options{Skills: []SkillInfo{
{Name: "commit-writer", Description: "Write a conventional-commit message."},
{Name: " ", Description: "nameless, should be skipped"},
{Name: "reviewer"},
}})
if !strings.Contains(got, "<available_skills>") || !strings.Contains(got, "</available_skills>") {
t.Fatalf("missing available_skills block: %q", got)
}
if !strings.Contains(got, "- commit-writer: Write a conventional-commit message.") {
t.Fatalf("missing commit-writer line: %q", got)
}
if !strings.Contains(got, "- reviewer\n") {
t.Fatalf("reviewer (no description) line missing: %q", got)
}
if strings.Contains(got, "nameless") {
t.Fatalf("nameless entry should be skipped: %q", got)
}
}
func TestSystemPromptIncludesSkillsOnlyWhenInstalled(t *testing.T) {
with := buildSystemPrompt(Options{Skills: []SkillInfo{
{Name: "commit-writer", Description: "Write a commit message."},
}})
if !strings.Contains(with, "<available_skills>") || !strings.Contains(with, "skill tool") {
t.Fatalf("expected available_skills guidance in system prompt: %q", with)
}
// Default (no skills) must reproduce the prior prompt: no skills block.
without := buildSystemPrompt(Options{})
if strings.Contains(without, "<available_skills>") {
t.Fatalf("available_skills block must not appear without skills")
}
}
+197 -31
View File
@@ -5,9 +5,11 @@ import (
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"unicode/utf8"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/repomap"
"github.com/Gitlawb/zero/internal/workspaceseed"
)
@@ -37,6 +39,14 @@ const fallbackSystemPrompt = "You are Zero, a terminal coding agent. Help with t
// general-to-specific order.
var projectContextFiles = []string{"AGENTS.md", "ZERO.md", ".zero/AGENTS.md"}
// userContextFile is the per-user instruction file, resolved under
// config.UserConfigDir()/zero/ alongside the rest of Zero's per-user config
// (config.json, commands, specialists) so users can keep personal guidance out
// of individual repositories.
const userContextFile = "ZERO.md"
var userConfigDirForPrompt = config.UserConfigDir
// maxProjectContextBytes caps how much of a single project doc is injected so
// a large guidelines file can't blow the context budget.
const maxProjectContextBytes = 8 << 10 // 8 KiB per file
@@ -80,12 +90,22 @@ func buildSystemPrompt(options Options) string {
if seed := workspaceSeedContext(options.Cwd); seed != "" {
sections = append(sections, seed)
}
// User guidelines are injected before workspace/project guidelines so the
// project's AGENTS.md/ZERO.md is the later, more specific instruction
// block. See userGuidelines for the explicit precedence note carried in
// the section text itself.
if user := userGuidelines(); user != "" {
sections = append(sections, user)
}
if ws := workspaceContext(options.Cwd); ws != "" {
sections = append(sections, ws)
}
if delegation := specialistDelegationContext(options); delegation != "" {
sections = append(sections, delegation)
}
if skillsBlock := skillsContext(options); skillsBlock != "" {
sections = append(sections, skillsBlock)
}
if style := responseStyleContext(options); style != "" {
sections = append(sections, style)
}
@@ -164,6 +184,75 @@ func specialistDelegationContext(options Options) string {
return b.String()
}
// skillsContext lists the reusable skills the model can pull in on demand via the
// skill tool, so it invokes the right one on the first try instead of guessing a
// name, failing, and reading the error. It mirrors specialistDelegationContext:
// names + one-line descriptions only (never skill bodies), and it renders nothing
// when no skills are installed, so a skill-less run reproduces the previous prompt
// byte-for-byte.
// skillsContextListBudget bounds the bytes spent listing individual skills so a
// workspace with an extreme number of them can't bloat every turn's prompt.
// Skills past the budget are summarized as a count rather than dropped — the
// model can still load any of them by name (an unknown name returns the full
// list from the skill tool).
//
// The budget is deliberately generous — roughly 18 skills with maximally long
// (200-rune, ASCII) descriptions, or 40+ with typical terser ones; non-ASCII
// descriptions consume it faster since the budget counts bytes. This list is
// the model's ONLY discovery surface, so a skill squeezed out of it effectively
// never triggers. The previous 640-byte cap silently broke invocation for
// anyone with more than ~6 skills — the model cannot match a request against a
// skill it cannot see.
const skillsContextListBudget = 4096
func skillsContext(options Options) string {
if len(options.Skills) == 0 {
return ""
}
var b strings.Builder
b.WriteString("<available_skills>\n")
b.WriteString("Reusable, on-demand instruction sets you can load with the skill tool. Before acting on a request, scan this list; when the request matches a skill's name or description, call skill with its exact name FIRST and follow the loaded guidance — do not guess names, do not skip a matching skill, and do not substitute your own approach for its instructions.\n")
listed, spent, omitted := 0, 0, 0
for _, info := range options.Skills {
name := strings.TrimSpace(info.Name)
if name == "" {
continue
}
line := "- " + name
if desc := strings.TrimSpace(info.Description); desc != "" {
line += ": " + truncateForSkillLine(desc)
}
line += "\n"
// Always list at least one skill; past the budget, summarize the remainder
// as a count instead of silently dropping it.
if listed > 0 && spent+len(line) > skillsContextListBudget {
omitted++
continue
}
b.WriteString(line)
spent += len(line)
listed++
}
if omitted > 0 {
b.WriteString("- …and " + strconv.Itoa(omitted) + " more (call skill with a name; an unknown name lists them all)\n")
}
b.WriteString("</available_skills>")
return b.String()
}
// truncateForSkillLine keeps a skill's one-line description short so a single
// verbose description can't dominate the skills-list budget. The cap is roomy on
// purpose: the description carries the skill's trigger conditions ("use when…"),
// and truncating those is what stops the model from ever invoking the skill.
func truncateForSkillLine(desc string) string {
const maxDescRunes = 200
runes := []rune(desc)
if len(runes) <= maxDescRunes {
return desc
}
return strings.TrimSpace(string(runes[:maxDescRunes])) + "…"
}
func sessionRuntimeContext(options Options) string {
provider := strings.TrimSpace(options.ProviderName)
model := strings.TrimSpace(options.Model)
@@ -197,7 +286,7 @@ func workspaceContext(cwd string) string {
b.WriteString("Working directory: " + cwd + "\n")
b.WriteString("Operating system: " + runtime.GOOS + "\n")
if runtime.GOOS == "windows" {
b.WriteString("Shell syntax: Windows cmd.exe syntax for exec_command/bash tools; prefer the workdir/cwd argument instead of cd when changing directories.\n")
b.WriteString("Shell syntax: Windows cmd.exe syntax for exec_command/bash tools. To put | & > < etc inside an arg value, use double quotes around the value, not single quotes (single quotes do not protect metachars in cmd.exe): gh --jq \".a | b\", go test -run \"A|B\". Do not pipe to or invoke POSIX coreutils from Git for Windows (usr\\bin head/grep/tail/cat/...): they are MSYS binaries and fail under the write-restricted sandbox; use native Zero tools (grep, read_file, list_directory, glob) or cmd.exe findstr/more instead, or sandbox_permissions require_escalated only when host-level execution is truly required. Prefer the workdir/cwd argument over cd when changing directories.\n")
} else {
b.WriteString("Shell syntax: /bin/sh syntax for exec_command/bash tools; prefer the workdir/cwd argument instead of cd when changing directories.\n")
}
@@ -206,7 +295,7 @@ func workspaceContext(cwd string) string {
}
b.WriteString("</environment>")
b.WriteString(projectGuidelines(cwd, findProjectGitRoot(cwd)))
b.WriteString(projectGuidelines(cwd, FindProjectGitRoot(cwd)))
if repoMap := repoMapContext(cwd); repoMap != "" {
b.WriteString("\n\n## Repo map\n\n" + repoMap)
}
@@ -250,13 +339,7 @@ func projectGuidelines(cwd, gitRoot string) string {
if remaining := maxProjectContextTotalBytes - totalUsed; remaining < limit {
limit = remaining
}
if len(content) > limit {
cut := limit
for cut > 0 && !utf8.RuneStart(content[cut]) {
cut--
}
content = content[:cut] + "\n… (truncated)"
}
content = truncateGuidelineContent(content, limit)
label := projectGuidelineLabel(match, gitRoot)
b.WriteString("\n\n## Project guidelines (" + label + ")\n\n" + content)
totalUsed += len(content)
@@ -264,6 +347,92 @@ func projectGuidelines(cwd, gitRoot string) string {
return b.String()
}
// userGuidelines returns the per-user ZERO.md instructions block, if present.
// The file lives in config.UserConfigDir()/zero/ next to Zero's other
// per-user config; the basename match is case-insensitive so a file saved as
// zero.md still resolves on case-sensitive filesystems, mirroring the project
// guideline loader. The section carries an explicit precedence note because
// this is a global, personal preferences file: it is injected earlier in the
// prompt than the project's AGENTS.md/ZERO.md (see buildSystemPrompt), and
// the note keeps that precedence unambiguous even if a model weighs later
// context more heavily than section order alone implies.
func userGuidelines() string {
configDir, err := userConfigDirForPrompt()
if err != nil {
return ""
}
configDir = strings.TrimSpace(configDir)
if configDir == "" {
return ""
}
match := findCaseInsensitiveFile(filepath.Join(configDir, "zero"), userContextFile)
if match == "" {
return ""
}
data, err := os.ReadFile(match)
if err != nil {
return ""
}
content := strings.TrimSpace(string(data))
if content == "" {
return ""
}
content = truncateGuidelineContent(content, maxProjectContextBytes)
return "## User guidelines (" + filepath.Base(match) + ")\n\n" +
"These are the operator's personal preferences, not project policy. " +
"Where they conflict with a repository's project guidelines below (AGENTS.md/ZERO.md), the project guidelines take precedence.\n\n" +
content
}
// truncationMarker is appended to guideline content that was cut short.
const truncationMarker = "\n… (truncated)"
// truncateGuidelineContent caps content at limit bytes without splitting a
// UTF-8 rune, appending a truncation marker when anything was cut. Space for
// the marker is reserved before choosing the cut point. When limit is too
// small to fit the marker itself, the marker is dropped instead, so the
// returned string never exceeds limit for any non-negative limit.
func truncateGuidelineContent(content string, limit int) string {
if len(content) <= limit {
return content
}
if limit <= 0 {
return ""
}
cut := limit - len(truncationMarker)
if cut < 0 {
cut = 0
}
for cut > 0 && !utf8.RuneStart(content[cut]) {
cut--
}
if truncated := content[:cut] + truncationMarker; len(truncated) <= limit {
return truncated
}
// limit is smaller than the marker itself: fall back to a hard,
// rune-safe cut at limit bytes with no marker rather than exceeding it.
end := limit
for end > 0 && !utf8.RuneStart(content[end]) {
end--
}
return content[:end]
}
// findCaseInsensitiveFile returns the on-disk path of the regular file in dir
// whose name matches basename case-insensitively, or "" when absent.
func findCaseInsensitiveFile(dir, basename string) string {
entries, err := os.ReadDir(dir)
if err != nil {
return ""
}
for _, e := range entries {
if !e.IsDir() && strings.EqualFold(e.Name(), basename) {
return filepath.Join(dir, e.Name())
}
}
return ""
}
// projectGuidelineDirs returns the directory chain from gitRoot to cwd
// (inclusive), in root-to-leaf order. If gitRoot is empty or unreachable
// from cwd, the chain collapses to [cwd].
@@ -310,29 +479,22 @@ func projectGuidelineLabel(match, gitRoot string) string {
// findProjectContextFile returns the first matching project guideline file
// in dir. Lookup is case-insensitive on the basename and on the actual
// filename returned, so a git-tracked file like AGENTS.MD (uppercase MD)
// still resolves to its true filename on case-sensitive filesystems — the
// returned path is what should appear in the project guidelines label.
// filename returned, so a git-tracked file like AGENTS.md still resolves to
// its true filename on case-sensitive filesystems — the returned path is what
// should appear in the project guidelines label.
// Returns "" when nothing matches.
func findProjectContextFile(dir string) string {
for _, name := range projectContextFiles {
baseLower := strings.ToLower(filepath.Base(name))
// Walk to the file's parent through dir with case-insensitive segment
// matching, then find a regular file with the same lowercased
// basename. This works on both case-sensitive and case-insensitive
// filesystems and always returns the on-disk filename.
// matching, then find a regular file with the same basename. This works
// on both case-sensitive and case-insensitive filesystems and always
// returns the on-disk filename.
parent, ok := resolveDirCaseInsensitive(filepath.Dir(filepath.Join(dir, name)), dir)
if !ok {
continue
}
entries, err := os.ReadDir(parent)
if err != nil {
continue
}
for _, e := range entries {
if !e.IsDir() && strings.ToLower(e.Name()) == baseLower {
return filepath.Join(parent, e.Name())
}
if match := findCaseInsensitiveFile(parent, filepath.Base(name)); match != "" {
return match
}
}
return ""
@@ -380,17 +542,17 @@ func resolveDirCaseInsensitive(target, anchor string) (string, bool) {
return cur, true
}
// findProjectGitRoot returns the nearest ancestor of cwd that contains a
// FindProjectGitRoot returns the nearest ancestor of cwd that contains a
// .git entry (file or directory). Returns "" when no git root is found, so
// the caller can fall back to cwd-only lookup.
func findProjectGitRoot(cwd string) string {
func FindProjectGitRoot(cwd string) string {
cwd = strings.TrimSpace(cwd)
if cwd == "" {
return ""
}
cur := cwd
for {
if hasGitMetadata(cur) {
if HasGitMetadata(cur) {
return cur
}
parent := filepath.Dir(cur)
@@ -401,7 +563,7 @@ func findProjectGitRoot(cwd string) string {
}
}
func hasGitMetadata(dir string) bool {
func HasGitMetadata(dir string) bool {
gitPath := filepath.Join(dir, ".git")
info, err := os.Stat(gitPath)
if err != nil {
@@ -457,7 +619,11 @@ func repoMapContext(cwd string) string {
// cwd, handling both a regular checkout (.git dir) and a worktree (.git file).
// Returns "" on any problem — the prompt simply omits the branch segment.
func gitBranchForPrompt(cwd string) string {
gitPath := filepath.Join(cwd, ".git")
gitRoot := FindProjectGitRoot(cwd)
if gitRoot == "" {
return ""
}
gitPath := filepath.Join(gitRoot, ".git")
info, err := os.Stat(gitPath)
if err != nil {
return ""
@@ -473,10 +639,10 @@ func gitBranchForPrompt(cwd string) string {
return ""
}
// In worktree mode the gitdir is often RELATIVE (e.g.
// "gitdir: ../.git/worktrees/<name>") — resolve it against cwd, not the
// "gitdir: ../.git/worktrees/<name>") — resolve it against the worktree root (gitRoot), not the
// process working directory, or HEAD lookup fails and we drop the branch.
if !filepath.IsAbs(dir) {
dir = filepath.Join(cwd, dir)
dir = filepath.Join(gitRoot, dir)
}
headPath = filepath.Join(dir, "HEAD")
}
+11 -9
View File
@@ -46,14 +46,10 @@ work.
formatting-only edits unless the user asked for them.
4. **Verify.** Verify after edits; see the testing gate below. This is
mandatory.
5. **Summarize.** For a substantial task, close with an ELABORATE, well-structured
summary — never a single throwaway line. Use short headings and bullets: a
one-line "what I built/changed"; **Where it lives** — the key files and how they
fit together; **How each requirement is met** — walk through each requirement
from the request and name the specifics that satisfy it; what validation ran and
what was NOT verified; and a brief "Next steps" or "Want me to…?" offer. Scale
the depth to the work — a big build earns several sections, a trivial fix earns
a line or two.
5. **Summarize.** Close with a summary scaled to the work: a trivial fix earns one
line; a substantial change earns a few short bullets covering what changed, where
it lives, and what you did and did not verify. Lead with the outcome, and never
pad — do not restate the task or narrate steps the user already watched you take.
## Editing discipline
@@ -64,7 +60,13 @@ work.
- Make one tool call per file. Do not batch multi-file writes into a single
shell or script invocation.
- For edits to existing files, prefer edit_file or apply_patch with minimal,
targeted diffs. Match the existing indentation, imports, and idioms.
targeted diffs. Match the existing indentation, imports, and idioms. Match the
file's comment density: do not add explanatory comments unless the user asks or
the code is already comment-dense.
- Solve the problem as posed, not a more general version of it. Add no
speculative abstraction, configurability, or handling for cases that cannot
occur, and nothing the user did not ask for. A small diff can still be
over-built; if a 200-line solution could be 50, rewrite it.
- Preserve behavior you were not asked to change. Do not delete or rewrite code
you did not author unless the task requires it; if you must, say so.
+10 -12
View File
@@ -6,11 +6,13 @@ import "strings"
//
// Different model families respond to different framing: the GPT family benefits
// from an explicit markdown/output spec and a strong "prefer native tools" nudge;
// Gemini benefits from explicit tool-preference and conciseness guidance; the
// Claude family is already well-aligned with the core prompt and only needs a
// light comment-discipline nudge. modelPromptAddendum returns the family-specific
// block appended after the core prompt. Classification is by model id (the agent
// only has the model string — no registry dependency).
// Gemini benefits from explicit tool-preference and conciseness guidance. The
// Claude family is already aligned with the core prompt and needs no addendum —
// comment discipline is universal in the core prompt now, so every family gets it.
// modelPromptAddendum returns the family-specific block appended after the core
// prompt, or "" when the family is unknown or needs no specialization.
// Classification is by model id (the agent only has the model string — no registry
// dependency).
const (
familyOpenAI = "openai"
@@ -45,9 +47,10 @@ func modelPromptAddendum(model string) string {
return openAIPromptAddendum
case familyGemini:
return geminiPromptAddendum
case familyAnthropic:
return anthropicPromptAddendum
default:
// Anthropic (aligned with the core prompt) and unknown families get no
// family-specific block; comment discipline now lives in the core prompt
// for every model.
return ""
}
}
@@ -70,8 +73,3 @@ const geminiPromptAddendum = `<model_guidance>
in one short clause why it is needed.
- Use update_plan for any multi-step task and keep it current.
</model_guidance>`
const anthropicPromptAddendum = `<model_guidance>
- Do not add code comments unless the user asks or the surrounding code is
comment-dense; match the file's existing comment density.
</model_guidance>`
+4 -2
View File
@@ -29,9 +29,11 @@ func TestBuildSystemPromptAppendsModelAddendum(t *testing.T) {
if got := buildSystemPrompt(Options{Model: "gpt-5"}); !strings.Contains(got, openAIPromptAddendum) {
t.Fatalf("expected the OpenAI addendum in the gpt-5 prompt")
}
// Claude is aligned with the core prompt and gets no family addendum now that
// comment discipline is universal; it must not pick up another family's block.
claude := buildSystemPrompt(Options{Model: "claude-opus-4-6"})
if !strings.Contains(claude, anthropicPromptAddendum) {
t.Fatalf("expected the Anthropic addendum in the claude prompt")
if strings.Contains(claude, "<model_guidance>") {
t.Fatalf("expected no model_guidance block for Claude (aligned with core prompt)")
}
if strings.Contains(claude, openAIPromptAddendum) {
t.Fatalf("the claude prompt must not contain the OpenAI addendum")
+130 -4
View File
@@ -9,6 +9,25 @@ import (
"github.com/Gitlawb/zero/internal/sandbox"
)
// TestMain points userConfigDirForPrompt at an empty, package-wide temp
// directory for every test in this package. Without this, buildSystemPrompt
// falls back to the real config.UserConfigDir, so any developer with a
// personal ~/.config/zero/ZERO.md would get its content folded into
// otherwise-unrelated prompt assertions, making test runs non-deterministic
// across contributor machines. Tests that specifically exercise user
// guidelines stub userConfigDirForPrompt themselves via
// withSystemPromptTestUserConfigDir(Func) and restore this default on cleanup.
func TestMain(m *testing.M) {
dir, err := os.MkdirTemp("", "zero-agent-tests-*")
if err != nil {
panic(err)
}
userConfigDirForPrompt = func() (string, error) { return dir, nil }
code := m.Run()
os.RemoveAll(dir)
os.Exit(code)
}
func TestCoreSystemPromptIncludesCodingQualityRules(t *testing.T) {
prompt := strings.ToLower(buildSystemPrompt(Options{}))
@@ -23,7 +42,8 @@ func TestCoreSystemPromptIncludesCodingQualityRules(t *testing.T) {
"avoid broad refactors",
"search the web before answering",
"do not recognize",
"how each requirement is met",
"scaled to the work",
"comment density",
} {
if !strings.Contains(prompt, want) {
t.Fatalf("expected core system prompt to include %q, got:\n%s", want, buildSystemPrompt(Options{}))
@@ -127,8 +147,90 @@ func systemPromptTestBlock(t *testing.T, prompt, start, end string) string {
return body
}
func TestBuildSystemPromptIncludesUserGuidelines(t *testing.T) {
configDir := t.TempDir()
writeSystemPromptTestFile(t, configDir, "zero/ZERO.md", " Prefer concise summaries. \n")
t.Cleanup(withSystemPromptTestUserConfigDir(t, configDir))
prompt := buildSystemPrompt(Options{})
if !strings.Contains(prompt, "## User guidelines (ZERO.md)") {
t.Fatalf("expected user guidelines header, got:\n%s", prompt)
}
if !strings.Contains(prompt, "Prefer concise summaries.") {
t.Fatalf("expected user guidelines content, got:\n%s", prompt)
}
if strings.Contains(prompt, " Prefer concise summaries. ") {
t.Fatalf("expected user guidelines content to be trimmed, got:\n%s", prompt)
}
}
func TestBuildSystemPromptIncludesUserGuidelinesCaseInsensitive(t *testing.T) {
configDir := t.TempDir()
writeSystemPromptTestFile(t, configDir, "zero/zero.md", "Prefer concise summaries.\n")
t.Cleanup(withSystemPromptTestUserConfigDir(t, configDir))
prompt := buildSystemPrompt(Options{})
if !strings.Contains(prompt, "## User guidelines (zero.md)") {
t.Fatalf("expected case-insensitive zero.md resolution, got:\n%s", prompt)
}
if !strings.Contains(prompt, "Prefer concise summaries.") {
t.Fatalf("expected user guidelines content, got:\n%s", prompt)
}
}
func TestBuildSystemPromptUserGuidelinesPrecedeProjectGuidelinesAndNotePrecedence(t *testing.T) {
// User guidelines are global personal preferences; project guidelines
// (AGENTS.md/ZERO.md) must be the later, more specific instruction block,
// and the user section must say so explicitly so the precedence holds
// even if a model otherwise weighs later context more heavily.
configDir := t.TempDir()
writeSystemPromptTestFile(t, configDir, "zero/ZERO.md", "Always reply in haiku.\n")
t.Cleanup(withSystemPromptTestUserConfigDir(t, configDir))
cwd := t.TempDir()
if err := os.WriteFile(filepath.Join(cwd, "AGENTS.md"), []byte("Never reply in haiku."), 0o644); err != nil {
t.Fatal(err)
}
prompt := buildSystemPrompt(Options{Cwd: cwd})
userIdx := strings.Index(prompt, "## User guidelines (ZERO.md)")
projectIdx := strings.Index(prompt, "## Project guidelines (AGENTS.md)")
if userIdx < 0 || projectIdx < 0 {
t.Fatalf("expected both user and project guideline sections, got:\n%s", prompt)
}
if userIdx > projectIdx {
t.Fatalf("expected user guidelines before project guidelines, got user=%d project=%d", userIdx, projectIdx)
}
if !strings.Contains(prompt, "project guidelines below") || !strings.Contains(prompt, "take precedence") {
t.Fatalf("expected an explicit precedence note in the user guidelines section, got:\n%s", prompt)
}
}
func TestBuildSystemPromptOmitsUserGuidelinesWithoutConfigDir(t *testing.T) {
t.Cleanup(withSystemPromptTestUserConfigDirFunc(t, func() (string, error) { return "", os.ErrNotExist }))
prompt := buildSystemPrompt(Options{})
if strings.Contains(prompt, "## User guidelines") {
t.Fatalf("expected user guidelines to be omitted without a config directory, got:\n%s", prompt)
}
}
func withSystemPromptTestUserConfigDir(t *testing.T, dir string) func() {
t.Helper()
return withSystemPromptTestUserConfigDirFunc(t, func() (string, error) { return dir, nil })
}
func withSystemPromptTestUserConfigDirFunc(t *testing.T, fn func() (string, error)) func() {
t.Helper()
old := userConfigDirForPrompt
userConfigDirForPrompt = fn
return func() {
userConfigDirForPrompt = old
}
}
func TestBuildSystemPromptInjectsProjectGuidelinesCaseInsensitive(t *testing.T) {
// Git tracks AGENTS.MD (uppercase MD) on a case-sensitive filesystem; the
// Git tracks AGENTS.md on a case-sensitive filesystem; the
// loader must still resolve it when the cwd lookup uses lowercase.
cwd := t.TempDir()
if err := os.WriteFile(filepath.Join(cwd, "AGENTS.MD"), []byte("Always run `make lint`."), 0o644); err != nil {
@@ -256,6 +358,30 @@ func TestBuildSystemPromptProjectGuidelinesTruncatesAtTotalCap(t *testing.T) {
}
}
func TestTruncateGuidelineContentStaysWithinLimit(t *testing.T) {
limit := 64
content := strings.Repeat("x", limit+1)
got := truncateGuidelineContent(content, limit)
if len(got) > limit {
t.Fatalf("truncated result is %d bytes, want at most %d", len(got), limit)
}
if !strings.Contains(got, "truncated") {
t.Fatalf("expected truncation marker, got %q", got)
}
}
func TestTruncateGuidelineContentStaysWithinLimitForSmallBudgets(t *testing.T) {
// Limits at or below the truncation marker's own length can't fit the
// marker at all; the helper must still never exceed the requested limit.
content := strings.Repeat("x", 100)
for _, limit := range []int{0, 1, len(truncationMarker) - 1, len(truncationMarker), len(truncationMarker) + 1} {
got := truncateGuidelineContent(content, limit)
if len(got) > limit {
t.Fatalf("truncateGuidelineContent(_, %d) = %q (%d bytes), want at most %d bytes", limit, got, len(got), limit)
}
}
}
func TestProjectGuidelineDirsOrdersRootToLeaf(t *testing.T) {
root := filepath.Join("r")
leaf := filepath.Join(root, "a", "b")
@@ -298,7 +424,7 @@ func TestFindProjectGitRootIgnoresEmptyGitDirectory(t *testing.T) {
if err := os.MkdirAll(leaf, 0o755); err != nil {
t.Fatal(err)
}
if got := findProjectGitRoot(leaf); got != "" {
t.Fatalf("findProjectGitRoot = %q, want empty for invalid .git directory", got)
if got := FindProjectGitRoot(leaf); got != "" {
t.Fatalf("FindProjectGitRoot = %q, want empty for invalid .git directory", got)
}
}
+21
View File
@@ -0,0 +1,21 @@
package agent
import (
"context"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// sessionProvider adapts a TurnSession back to the Provider interface so the
// loop's existing Provider-shaped call sites (streamWithReconnect, the
// compaction summarizer, finalAnswerAfterMaxTurns) route their stream I/O
// through the session without any signature change. For the default adapter
// this reduces to the wrapped provider's StreamCompletion, so behavior is
// byte-identical; an optimized session (PR8) takes effect here transparently.
type sessionProvider struct {
session zeroruntime.TurnSession
}
func (s sessionProvider) StreamCompletion(ctx context.Context, request zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
return s.session.Stream(ctx, request)
}
+367
View File
@@ -0,0 +1,367 @@
package agent
import (
"context"
"errors"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// fakeTurnSession wraps an inner provider for streaming while counting the
// session lifecycle, so tests can observe exactly how the loop drives it.
type fakeTurnSession struct {
inner Provider
prewarms int
streams int
closes int
closeErr error
}
func (s *fakeTurnSession) Prewarm(context.Context) error { s.prewarms++; return nil }
func (s *fakeTurnSession) Stream(ctx context.Context, request zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
s.streams++
return s.inner.StreamCompletion(ctx, request)
}
func (s *fakeTurnSession) Compact(context.Context, zeroruntime.CompletionRequest) ([]zeroruntime.Message, error) {
return nil, zeroruntime.ErrCompactionUnsupported
}
func (s *fakeTurnSession) Close() error { s.closes++; return s.closeErr }
// fakeTurnSessionProvider opens a scripted session (or fails to).
type fakeTurnSessionProvider struct {
session *fakeTurnSession
openErr error
opens int
}
func (p *fakeTurnSessionProvider) OpenTurnSession(context.Context) (zeroruntime.TurnSession, error) {
p.opens++
if p.openErr != nil {
return nil, p.openErr
}
return p.session, nil
}
func (p *fakeTurnSessionProvider) Capabilities() zeroruntime.ProviderCapabilities {
return zeroruntime.ProviderCapabilities{}
}
func singleAnswerTurns(answer string) [][]zeroruntime.StreamEvent {
return [][]zeroruntime.StreamEvent{{
{Type: zeroruntime.StreamEventText, Content: answer},
{Type: zeroruntime.StreamEventDone},
}}
}
// TestRunOpenTurnSessionFailureSurfaces verifies a failed session open is a
// clean run-start error: no panic, no stream ever issued, nothing to close.
func TestRunOpenTurnSessionFailureSurfaces(t *testing.T) {
positional := &mockProvider{turns: singleAnswerTurns("never reached")}
tsp := &fakeTurnSessionProvider{openErr: errors.New("handshake refused")}
_, err := Run(context.Background(), "go", positional, Options{
MaxTurns: 2,
TurnSessionProvider: tsp,
})
if err == nil {
t.Fatal("expected an error from a failed session open")
}
if !strings.Contains(err.Error(), "open turn session") || !strings.Contains(err.Error(), "handshake refused") {
t.Fatalf("expected a wrapped open-turn-session error, got %v", err)
}
if len(positional.requests) != 0 {
t.Fatalf("expected no provider request after a failed open, got %d", len(positional.requests))
}
if tsp.opens != 1 {
t.Fatalf("expected exactly one open attempt, got %d", tsp.opens)
}
}
// TestRunCloseFailureIsSafe verifies a Close error at teardown is swallowed:
// the run still returns its normal result.
func TestRunCloseFailureIsSafe(t *testing.T) {
inner := &mockProvider{turns: singleAnswerTurns("done")}
session := &fakeTurnSession{inner: inner, closeErr: errors.New("close blew up")}
tsp := &fakeTurnSessionProvider{session: session}
result, err := Run(context.Background(), "go", inner, Options{
MaxTurns: 2,
TurnSessionProvider: tsp,
})
if err != nil {
t.Fatalf("expected Close failure to be swallowed, got %v", err)
}
if result.FinalAnswer != "done" {
t.Fatalf("expected normal result despite Close error, got %q", result.FinalAnswer)
}
if session.closes != 1 {
t.Fatalf("expected exactly one Close at teardown, got %d", session.closes)
}
}
// TestRunStreamsThroughTurnSession verifies that when a TurnSessionProvider is
// wired, every model request of the run flows through the session (and the
// session is prewarmed once and closed once).
func TestRunStreamsThroughTurnSession(t *testing.T) {
inner := &mockProvider{turns: singleAnswerTurns("via session")}
session := &fakeTurnSession{inner: inner}
tsp := &fakeTurnSessionProvider{session: session}
// The positional provider is a DIFFERENT mock: it must stay untouched,
// proving the session (not the positional provider) carried the run.
positional := &mockProvider{turns: singleAnswerTurns("wrong path")}
result, err := Run(context.Background(), "go", positional, Options{
MaxTurns: 2,
TurnSessionProvider: tsp,
})
if err != nil {
t.Fatal(err)
}
if result.FinalAnswer != "via session" {
t.Fatalf("expected the session-backed answer, got %q", result.FinalAnswer)
}
if len(positional.requests) != 0 {
t.Fatalf("expected the positional provider to be bypassed, got %d requests", len(positional.requests))
}
if session.streams == 0 || session.streams != len(inner.requests) {
t.Fatalf("expected every request to flow through the session: streams=%d inner=%d", session.streams, len(inner.requests))
}
if session.prewarms != 1 {
t.Fatalf("expected exactly one prewarm, got %d", session.prewarms)
}
if session.closes != 1 {
t.Fatalf("expected exactly one close, got %d", session.closes)
}
}
// TestRunDefaultTurnSessionMatchesRawProvider verifies the default (nil
// TurnSessionProvider) path is behavior-identical to passing an explicit
// default adapter over the same provider: same result, same request count.
func TestRunDefaultTurnSessionMatchesRawProvider(t *testing.T) {
run := func(explicit bool) (Result, *mockProvider) {
provider := &mockProvider{turns: escalateThenAnswerTurns("identical")}
registry := tools.NewRegistry()
registry.Register(escalatingTool{target: ""})
options := Options{Registry: registry, MaxTurns: 4}
if explicit {
options.TurnSessionProvider = zeroruntime.NewProviderTurnSessionProvider(provider, zeroruntime.ProviderCapabilities{})
}
result, err := Run(context.Background(), "go", provider, options)
if err != nil {
t.Fatal(err)
}
return result, provider
}
defaultResult, defaultProvider := run(false)
explicitResult, explicitProvider := run(true)
if defaultResult.FinalAnswer != explicitResult.FinalAnswer {
t.Fatalf("final answers diverged: default=%q explicit=%q", defaultResult.FinalAnswer, explicitResult.FinalAnswer)
}
if defaultResult.Turns != explicitResult.Turns {
t.Fatalf("turn counts diverged: default=%d explicit=%d", defaultResult.Turns, explicitResult.Turns)
}
if len(defaultProvider.requests) != len(explicitProvider.requests) {
t.Fatalf("request counts diverged: default=%d explicit=%d", len(defaultProvider.requests), len(explicitProvider.requests))
}
}
// TestRunModelSwitchClosesSessionAndContinues verifies a mid-run model switch
// closes the active session exactly once and streams the rest of the run on
// the new provider.
func TestRunModelSwitchClosesSessionAndContinues(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(escalatingTool{target: "claude-opus-4.1"})
firstInner := &mockProvider{turns: escalateThenAnswerTurns("unused")}
firstSession := &fakeTurnSession{inner: firstInner}
tsp := &fakeTurnSessionProvider{session: firstSession}
secondProvider := &mockProvider{turns: singleAnswerTurns("answered after switch")}
result, err := Run(context.Background(), "go", firstInner, Options{
Registry: registry,
Model: "claude-sonnet-4.5",
MaxTurns: 4,
TurnSessionProvider: tsp,
ModelSwitcher: func(_ context.Context, _ string) (Provider, error) {
return secondProvider, nil
},
})
if err != nil {
t.Fatal(err)
}
if result.FinalAnswer != "answered after switch" {
t.Fatalf("expected the post-switch answer, got %q", result.FinalAnswer)
}
// The original session handled exactly the escalation turn, then was closed
// once by the swap (the teardown defer closes the NEW session, not this one).
if firstSession.streams != 1 {
t.Fatalf("expected the original session to stream exactly the escalation turn, got %d", firstSession.streams)
}
if firstSession.closes != 1 {
t.Fatalf("expected the swap to close the original session exactly once, got %d", firstSession.closes)
}
if len(secondProvider.requests) != 1 {
t.Fatalf("expected the post-switch turn on the new provider, got %d requests", len(secondProvider.requests))
}
}
// TestRunModelSessionSwitcherCarriesOptimizedSession verifies the target-aware
// switcher: the switched-to TurnSessionProvider's DISTINCT session receives the
// post-switch streams, is prewarmed exactly once, and is closed exactly once at
// teardown — and the legacy ModelSwitcher is never consulted when both are set.
func TestRunModelSessionSwitcherCarriesOptimizedSession(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(escalatingTool{target: "claude-opus-4.1"})
firstInner := &mockProvider{turns: escalateThenAnswerTurns("unused")}
firstSession := &fakeTurnSession{inner: firstInner}
initial := &fakeTurnSessionProvider{session: firstSession}
switchedInner := &mockProvider{turns: singleAnswerTurns("answered on the switched session")}
switchedSession := &fakeTurnSession{inner: switchedInner}
switched := &fakeTurnSessionProvider{session: switchedSession}
var switchedTo string
legacyCalled := false
result, err := Run(context.Background(), "go", firstInner, Options{
Registry: registry,
Model: "claude-sonnet-4.5",
MaxTurns: 4,
TurnSessionProvider: initial,
ModelSessionSwitcher: func(_ context.Context, modelID string) (zeroruntime.TurnSessionProvider, error) {
switchedTo = modelID
return switched, nil
},
ModelSwitcher: func(_ context.Context, _ string) (Provider, error) {
legacyCalled = true
return nil, errors.New("legacy switcher must not be consulted")
},
})
if err != nil {
t.Fatal(err)
}
if result.FinalAnswer != "answered on the switched session" {
t.Fatalf("expected the switched session's answer, got %q", result.FinalAnswer)
}
if switchedTo != "claude-opus-4.1" {
t.Fatalf("expected switch to claude-opus-4.1, got %q", switchedTo)
}
if legacyCalled {
t.Fatal("legacy ModelSwitcher was consulted despite ModelSessionSwitcher being set")
}
if switched.opens != 1 {
t.Fatalf("expected exactly one open of the switched provider, got %d", switched.opens)
}
// The switched session carries the rest of the run: it streams the answer
// turn, is prewarmed exactly once at the swap, and is closed exactly once by
// the teardown defer.
if switchedSession.streams != 1 || len(switchedInner.requests) != 1 {
t.Fatalf("expected the switched session to stream the post-switch turn, streams=%d inner=%d", switchedSession.streams, len(switchedInner.requests))
}
if switchedSession.prewarms != 1 {
t.Fatalf("expected the switched session to be prewarmed exactly once, got %d", switchedSession.prewarms)
}
if switchedSession.closes != 1 {
t.Fatalf("expected the switched session to be closed exactly once at teardown, got %d", switchedSession.closes)
}
// The original session ends with the swap: one stream, one close.
if firstSession.streams != 1 || firstSession.closes != 1 {
t.Fatalf("expected the original session to end at the swap (streams=%d closes=%d)", firstSession.streams, firstSession.closes)
}
}
// TestRunModelSessionSwitcherOpenFailureIsNonFatal verifies the swap-time open
// failure: the switcher returns a TurnSessionProvider whose OpenTurnSession
// fails. The run stays non-fatal on the ORIGINAL session (closed only at
// teardown), and the switch-failure note reaches the next request.
func TestRunModelSessionSwitcherOpenFailureIsNonFatal(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(escalatingTool{target: "claude-opus-4.1"})
inner := &mockProvider{turns: escalateThenAnswerTurns("recovered after failed open")}
session := &fakeTurnSession{inner: inner}
failingOpen := &fakeTurnSessionProvider{openErr: errors.New("switched handshake refused")}
result, err := Run(context.Background(), "go", inner, Options{
Registry: registry,
Model: "claude-sonnet-4.5",
MaxTurns: 4,
TurnSessionProvider: &fakeTurnSessionProvider{session: session},
ModelSessionSwitcher: func(_ context.Context, _ string) (zeroruntime.TurnSessionProvider, error) {
return failingOpen, nil
},
})
if err != nil {
t.Fatalf("expected a failed swap open to be non-fatal, got %v", err)
}
if result.FinalAnswer != "recovered after failed open" {
t.Fatalf("expected the run to recover on the original session, got %q", result.FinalAnswer)
}
if failingOpen.opens != 1 {
t.Fatalf("expected exactly one open attempt on the switched provider, got %d", failingOpen.opens)
}
// The original session serves both turns and is closed only at teardown —
// never by the failed swap.
if session.streams != 2 || session.closes != 1 {
t.Fatalf("expected the original session to serve both turns and close once (streams=%d closes=%d)", session.streams, session.closes)
}
var sawNote bool
for _, message := range inner.requests[1].Messages {
if message.Role == zeroruntime.MessageRoleUser && strings.Contains(strings.ToLower(message.Content), "could not switch") {
sawNote = true
}
}
if !sawNote {
t.Fatalf("expected a switch-failure note on the next turn, messages: %+v", inner.requests[1].Messages)
}
}
// TestRunModelSessionSwitcherErrorIsNonFatal verifies a ModelSessionSwitcher
// error keeps the run on the current session with a transcript note — the same
// non-fatal contract as ModelSwitcher.
func TestRunModelSessionSwitcherErrorIsNonFatal(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(escalatingTool{target: "claude-opus-4.1"})
inner := &mockProvider{turns: escalateThenAnswerTurns("recovered on original session")}
session := &fakeTurnSession{inner: inner}
result, err := Run(context.Background(), "go", inner, Options{
Registry: registry,
Model: "claude-sonnet-4.5",
MaxTurns: 4,
TurnSessionProvider: &fakeTurnSessionProvider{session: session},
ModelSessionSwitcher: func(_ context.Context, _ string) (zeroruntime.TurnSessionProvider, error) {
return nil, errors.New("session build blew up")
},
})
if err != nil {
t.Fatalf("expected session-switcher error to be non-fatal, got %v", err)
}
if result.FinalAnswer != "recovered on original session" {
t.Fatalf("expected the run to recover on the original session, got %q", result.FinalAnswer)
}
// Both turns stream through the original session; it closes once at teardown.
if session.streams != 2 || session.closes != 1 {
t.Fatalf("expected the original session to serve both turns and close once (streams=%d closes=%d)", session.streams, session.closes)
}
var sawNote bool
for _, request := range inner.requests[1].Messages {
if request.Role == zeroruntime.MessageRoleUser && strings.Contains(strings.ToLower(request.Content), "could not switch") {
sawNote = true
}
}
if !sawNote {
t.Fatalf("expected a non-fatal switch-failure note on the next turn, messages: %+v", inner.requests[1].Messages)
}
}
+122 -6
View File
@@ -7,6 +7,7 @@ import (
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/streamjson"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/trace"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
@@ -60,10 +61,13 @@ const (
)
type ToolResult struct {
ToolCallID string
Name string
Status tools.Status
Output string
ToolCallID string
Name string
Status tools.Status
Output string
// Truncated reports that the tool's model-visible output omitted content.
// The full result may be recoverable through Meta["spill_path"].
Truncated bool
Meta map[string]string
Redacted bool
ChangedFiles []string
@@ -71,6 +75,12 @@ type ToolResult struct {
// DenialReason categorizes why a tool call was blocked (empty when it ran).
// It lets a surface distinguish the cause precisely instead of parsing Output.
DenialReason DenialCategory
// Risk is the sandbox risk classification of this call, stamped for EXECUTED
// results so run-policy observers (the execution-profile controller) can see
// the risk level of an allowed mutation. It mirrors the classification the
// permission path already computes; denied or canceled results keep the zero
// value. Pure observation: nothing about permissions or sandboxing changes.
Risk sandbox.Risk
// LoadedTools carries the deferred-tool names a tool_search call asked the
// loop to expose next turn (lifted from Meta["load_tools"]). nil for every
// ordinary tool result; only tool_search populates it.
@@ -94,6 +104,54 @@ const (
DenialHookBlocked DenialCategory = "hook_blocked" // vetoed by a beforeTool hook
)
// ProfilePolicy is the loop-facing slice of a selected execution profile.
// The surface (exec flag, TUI command) resolves a named profile into this
// policy; the loop only ever sees the policy, never the catalog.
type ProfilePolicy struct {
// Name labels trace counters and diagnostics (e.g. "fast"). The trace
// recorder's profile label is set by the caller at recorder construction.
Name string
// Escalate, when non-nil, arms one-shot in-run posture escalation.
Escalate *PostureEscalation
}
// PostureEscalation describes a one-shot escalation to stricter knob values,
// applied mid-run when an armed trigger fires. Targets are the values the
// selected profile DISPLACED at run start (i.e. "restore the balanced
// posture"), so escalation can never introduce a value that was not already
// valid for this run and model. Zero-valued targets leave that knob untouched.
type PostureEscalation struct {
// MaxTurns raises the turn ceiling to this value when greater than the
// ceiling in effect. 0 leaves the ceiling untouched.
MaxTurns int
// ReasoningEffort replaces the run's effort when non-empty.
ReasoningEffort string
// RestoreDefaultEffort clears the run's effort override (back to the
// provider/model default) when true. It exists because the displaced value
// of a profile-filled effort is "" — which as a ReasoningEffort target
// means "leave untouched" — so restoring the default needs its own signal.
// Ignored when ReasoningEffort is non-empty.
RestoreDefaultEffort bool
// RestoreCompletionGate re-enables RequireCompletionSignal (headless
// completion semantics) when true.
RestoreCompletionGate bool
// Triggers. A zero value disables that signal entirely.
// OnToolFailureStreak fires when the repeated-failure guard observes a
// same-tool retriable-failure streak of at least this length.
OnToolFailureStreak int
// OnCompletionUncertain fires on the Nth uncertain completion evaluation
// (continue nudge or semantic check). Headless only: the completion gate
// never runs interactively.
OnCompletionUncertain int
// OnSelfCorrectFailure fires when a post-edit verification cycle reports a
// failing outcome (correcting, reported, or aborted).
OnSelfCorrectFailure bool
// OnRiskyMutation fires when an EXECUTED tool result carries a sandbox risk
// level at or above this threshold. Empty disables the signal.
OnRiskyMutation sandbox.RiskLevel
}
type PermissionRequest struct {
ToolCallID string `json:"toolCallId"`
ToolName string `json:"name"`
@@ -174,6 +232,16 @@ type SpecialistInfo struct {
WhenToUse string
}
// SkillInfo is a one-line summary of a reusable, on-demand skill (its name and
// frontmatter description) surfaced to the system prompt so the model can invoke
// the right skill with the skill tool on the first try instead of guessing a name
// and reading the failure. Like SpecialistInfo it is plain data, so the agent
// package needs no dependency on internal/skills.
type SkillInfo struct {
Name string
Description string
}
type Options struct {
MaxTurns int
// DeferThreshold activates deferred MCP-tool loading: when the number of
@@ -189,6 +257,12 @@ type Options struct {
// populated only where the Task tool is actually registered, so an empty slice
// (the default) reproduces the previous prompt byte-for-byte.
Specialists []SpecialistInfo
// Skills lists the reusable skills installed for this run (the default skills
// dir merged with any plugin skill roots). When non-empty the system prompt
// gains an <available_skills> block naming them so the model loads the right one
// via the skill tool on the first try. Empty (the default) reproduces the
// previous prompt byte-for-byte.
Skills []SkillInfo
// Specialist/sub-agent metadata is carried through exec now and consumed by
// the specialist runtime in later slices.
SessionID string
@@ -263,12 +337,51 @@ type Options struct {
// request), so every existing caller is unaffected. A returned error is
// non-fatal: the run continues on the current model.
ModelSwitcher func(ctx context.Context, modelID string) (Provider, error)
// TurnSessionProvider, when set, supplies the turn session the run streams
// through — the seam an optimized provider session (connection reuse,
// prewarm, native compaction) plugs into without touching the loop. nil
// keeps the default: the loop wraps the passed provider in a no-op session
// whose Stream IS provider.StreamCompletion, so behavior is byte-identical
// and every existing caller is unaffected.
TurnSessionProvider zeroruntime.TurnSessionProvider
// ModelSessionSwitcher, when set, is the target-aware escalation hook: the
// loop prefers it over ModelSwitcher, and its TurnSessionProvider keeps an
// optimized session (and its capabilities) across a mid-run model switch.
// nil falls back to ModelSwitcher, whose bare Provider is wrapped in the
// default no-op session — today's behavior, unchanged. Same non-fatal error
// contract as ModelSwitcher: a returned error records a note and the run
// continues on the current model and session.
ModelSessionSwitcher func(ctx context.Context, modelID string) (zeroruntime.TurnSessionProvider, error)
// Profile, when set, arms the execution-profile posture controller for this
// run (auto-escalation to stricter knob values on failure/uncertainty/risky
// mutation signals). nil — the default everywhere today — leaves the loop
// byte-identical: no observation, no escalation, no counters. Same opt-in
// convention as Trace and SelfCorrect.
Profile *ProfilePolicy
// Trace, when set, records per-turn timing for the run: the loop stamps
// spans (prompt build, generation, tool execution, permission wait,
// compaction, provider connect) and counters (model requests, tool calls,
// retries, tokens) into it. nil DISABLES tracing entirely — every stamp is
// nil-safe and the loop is byte-identical to an untraced run. The caller
// owns the recorder: Run stamps into it but does not Finish or emit it.
// A fresh Recorder is required per Run — reusing one across runs merges
// their spans, counters, and first-event timestamps, and Finish freezes a
// recorder so no further stamps take.
Trace *trace.Recorder
// SelfCorrect, when set, runs a post-edit verify-and-correct cycle after a
// mutating tool call: it verifies the changed files (LSP diagnostics + project
// tests) and feeds failures back to the model to fix, bounded by an attempt
// ceiling and the autonomy gate. nil disables it entirely (the loop is
// byte-identical to before). One instance per run — it holds attempt state.
SelfCorrect *SelfCorrector
// FileDiagnostics, when set, checks files changed by mutating tools for
// error-severity language diagnostics IN THE BACKGROUND and appends any
// errors as a nudge before the model's next request — the model still sees
// an error it introduced at its next decision point, but no tool call ever
// blocks on the language server (the old inline path stalled every edit on
// a ≥300ms debounce, 10s cap). Build one with NewFileDiagnostics. nil
// disables post-edit diagnostics.
FileDiagnostics func(ctx context.Context, absPath string) string
// RequireCompletionSignal gates run completion for HEADLESS exec. Without it,
// any assistant turn that produces text but no tool call is accepted as the
@@ -277,8 +390,11 @@ type Options struct {
// continuation cue ("…Let me check the config:"). The loop then nudges the
// model to continue instead, bounded by maxContinueNudges (and still by
// MaxTurns and the run deadline); if the model keeps stalling, the run
// finalizes as INCOMPLETE (Result.Incomplete) rather than success. Default
// false leaves the loop byte-identical, so the interactive TUI is unaffected.
// finalizes as INCOMPLETE (Result.Incomplete) rather than success. When the
// run's profile also enables SelfCorrect, an otherwise-complete turn gets one
// task-grounded semantic check before success; profiles without SelfCorrect
// add no model call. Default false leaves the loop byte-identical, so the
// interactive TUI is unaffected.
RequireCompletionSignal bool
runPermissions *permissionRunState
+259
View File
@@ -0,0 +1,259 @@
package aimlapi
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
// PaymentMethod selects how a partner-checkout top-up is paid for.
type PaymentMethod string
const (
PaymentMethodCard PaymentMethod = "card"
PaymentMethodCrypto PaymentMethod = "crypto"
)
// SessionStatus is the lifecycle state of a partner-checkout session, as reported
// by the aimlapi.com backend while polling for payment.
type SessionStatus string
const (
SessionStatusPendingAuth SessionStatus = "pending_auth"
SessionStatusPendingPayment SessionStatus = "pending_payment"
SessionStatusPaid SessionStatus = "paid"
SessionStatusExchanging SessionStatus = "exchanging"
SessionStatusExchanged SessionStatus = "exchanged"
SessionStatusCancelled SessionStatus = "cancelled"
SessionStatusExpired SessionStatus = "expired"
SessionStatusFailed SessionStatus = "failed"
)
// AuthResult is the bearer token minted by a passwordless sign-in / sign-up.
type AuthResult struct {
Token string `json:"token"`
Exp int64 `json:"exp"`
}
// PartnerCheckoutSession is a partner-attributed top-up session; its SessionToken
// addresses it on later pay/exchange/poll calls.
type PartnerCheckoutSession struct {
ID string `json:"id"`
SessionToken string `json:"sessionToken"`
PartnerID string `json:"partnerId"`
PartnerName *string `json:"partnerName"`
UserID *int64 `json:"userId"`
AmountUSDMinor *int64 `json:"amountUsdMinor"`
Status SessionStatus `json:"status"`
IssuedKeyID *string `json:"issuedKeyId"`
ReturnURL *string `json:"returnUrl"`
}
// PaymentSession is the hosted payment-provider checkout; PayURL is the page the
// user opens to pay.
type PaymentSession struct {
ProviderSessionID string `json:"providerSessionId"`
PayURL string `json:"payUrl"`
}
// PayResult pairs the hosted checkout with the updated partner-checkout session
// returned when a top-up payment is initiated.
type PayResult struct {
Checkout PaymentSession `json:"checkout"`
PartnerCheckout PartnerCheckoutSession `json:"partnerCheckout"`
}
// ExchangeResult holds the API key minted when a paid session is exchanged.
type ExchangeResult struct {
APIKey string `json:"apiKey"`
APIKeyID string `json:"apiKeyId"`
}
// TopUpByKeyRequest funds a partner-checkout session with the API key that owns
// the account (Path A / env key), instead of an email session.
type TopUpByKeyRequest struct {
SessionToken string
AmountUSDMinor int
PaymentSessionID string // idempotency: same id → same checkout, never a second charge
AutoTopUp bool // enroll the account in automatic top-up at checkout (card, saved off-session)
SuccessURL string
CancelURL string
}
// TopUpByKeyResult is the hosted checkout returned by TopUpByKey.
type TopUpByKeyResult struct {
Checkout PaymentSession `json:"checkout"`
}
// APIError is a non-2xx HTTP response from the aimlapi.com API. Status carries the
// code so callers can branch (e.g. 401 = bad key, 5xx = retryable).
type APIError struct {
Message string
Status int
Body string
}
// Error renders the method, endpoint, status, and (when present) the response body.
func (e APIError) Error() string {
if strings.TrimSpace(e.Body) != "" {
return fmt.Sprintf("%s: HTTP %d: %s", e.Message, e.Status, strings.TrimSpace(e.Body))
}
return fmt.Sprintf("%s: HTTP %d", e.Message, e.Status)
}
// Client talks to the aimlapi.com auth, app, and inference APIs for the onboarding
// and partner-checkout top-up flows.
type Client struct {
endpoints Endpoints
httpClient *http.Client
}
func NewClient(endpoints Endpoints, httpClient *http.Client) *Client {
if httpClient == nil {
httpClient = &http.Client{Timeout: 60 * time.Second}
}
return &Client{endpoints: endpoints, httpClient: httpClient}
}
// CreateSession opens a partner-checkout session attributed to partnerID; the
// returned SessionToken addresses it on the pay/exchange/poll calls.
func (c *Client) CreateSession(ctx context.Context, partnerID string, partnerName string, returnURL string) (PartnerCheckoutSession, error) {
body := map[string]any{"partnerId": partnerID}
if strings.TrimSpace(partnerName) != "" {
body["partnerName"] = strings.TrimSpace(partnerName)
}
if strings.TrimSpace(returnURL) != "" {
body["returnUrl"] = strings.TrimSpace(returnURL)
}
var result PartnerCheckoutSession
err := c.request(ctx, http.MethodPost, strings.TrimRight(c.endpoints.AppBaseURL, "/")+"/v3/partner-checkout/sessions", "", body, &result)
return result, err
}
// GetSession fetches the current state of a partner-checkout session; used to poll
// for payment completion.
func (c *Client) GetSession(ctx context.Context, sessionToken string) (PartnerCheckoutSession, error) {
var result PartnerCheckoutSession
err := c.request(ctx, http.MethodGet, strings.TrimRight(c.endpoints.AppBaseURL, "/")+"/v3/partner-checkout/sessions/"+urlPathEscape(sessionToken), "", nil, &result)
return result, err
}
// Pay initiates payment for a session and returns the hosted checkout URL.
// autoTopUp enrolls the account in automatic top-up at checkout time.
func (c *Client) Pay(ctx context.Context, bearer string, sessionToken string, amountUSDMinor int, paymentSessionID string, method PaymentMethod, successURL string, cancelURL string, autoTopUp bool) (PayResult, error) {
body := map[string]any{
"amountUsdMinor": amountUSDMinor,
"paymentSessionId": paymentSessionID,
"method": method,
}
if strings.TrimSpace(successURL) != "" {
body["successUrl"] = strings.TrimSpace(successURL)
}
if strings.TrimSpace(cancelURL) != "" {
body["cancelUrl"] = strings.TrimSpace(cancelURL)
}
// Only sent when enabled: enrolls the account in automatic top-up. The backend
// honours this field at checkout time.
if autoTopUp {
body["autoTopUp"] = true
}
var result PayResult
err := c.request(ctx, http.MethodPost, strings.TrimRight(c.endpoints.AppBaseURL, "/")+"/v3/partner-checkout/sessions/"+urlPathEscape(sessionToken)+"/pay", bearer, body, &result)
return result, err
}
// Exchange converts a paid session into a freshly minted API key (new-account flow).
func (c *Client) Exchange(ctx context.Context, bearer string, sessionToken string) (ExchangeResult, error) {
var result ExchangeResult
err := c.request(ctx, http.MethodPost, strings.TrimRight(c.endpoints.AppBaseURL, "/")+"/v3/partner-checkout/sessions/"+urlPathEscape(sessionToken)+"/exchange", bearer, nil, &result)
return result, err
}
// TopUpByKey funds a partner-checkout session using the raw API key that owns the
// account (Path A / env key), returning the hosted checkout. It binds the session
// to the key's account server-side, so no email session is needed and no key is
// exchanged. req.PaymentSessionID makes a retry idempotent (same id → same
// checkout, never a second charge).
func (c *Client) TopUpByKey(ctx context.Context, apiKey string, req TopUpByKeyRequest) (TopUpByKeyResult, error) {
body := map[string]any{
"sessionToken": req.SessionToken,
"amountUsdMinor": req.AmountUSDMinor,
"paymentSessionId": req.PaymentSessionID,
}
if req.AutoTopUp {
body["autoTopUp"] = true
}
if strings.TrimSpace(req.SuccessURL) != "" {
body["successUrl"] = strings.TrimSpace(req.SuccessURL)
}
if strings.TrimSpace(req.CancelURL) != "" {
body["cancelUrl"] = strings.TrimSpace(req.CancelURL)
}
var result TopUpByKeyResult
err := c.request(ctx, http.MethodPost, c.billingV2URL("/billing/topup"), apiKey, body, &result)
return result, err
}
// billingV2URL builds a v2 billing API URL from the inference base. The top-up
// endpoint lives next to the balance endpoint on the API gateway, one version up
// (".../v1" → ".../v2/billing/..."), so it re-anchors the version while keeping any
// host/prefix an override supplied.
func (c *Client) billingV2URL(path string) string {
base := strings.TrimRight(strings.TrimSpace(c.endpoints.InferenceBaseURL), "/")
base = strings.TrimSuffix(base, "/v1")
return strings.TrimRight(base, "/") + "/v2" + path
}
func (c *Client) request(ctx context.Context, method string, endpoint string, bearer string, body any, out any) error {
var reader io.Reader
if body != nil {
data, err := json.Marshal(body)
if err != nil {
return err
}
reader = bytes.NewReader(data)
}
request, err := http.NewRequestWithContext(ctx, method, endpoint, reader)
if err != nil {
return err
}
request.Header.Set("Accept", "application/json")
if body != nil {
request.Header.Set("Content-Type", "application/json")
}
if strings.TrimSpace(bearer) != "" {
request.Header.Set("Authorization", "Bearer "+strings.TrimSpace(bearer))
}
response, err := c.httpClient.Do(request)
if err != nil {
return fmt.Errorf("network request to %s failed: %w", endpoint, err)
}
defer response.Body.Close()
text, readErr := io.ReadAll(io.LimitReader(response.Body, 1<<20))
if readErr != nil {
return readErr
}
if response.StatusCode < 200 || response.StatusCode >= 300 {
return APIError{Message: method + " " + endpoint, Status: response.StatusCode, Body: string(text)}
}
if out == nil {
return nil
}
if len(bytes.TrimSpace(text)) == 0 {
return APIError{Message: method + " " + endpoint + " returned empty body", Status: response.StatusCode}
}
if err := json.Unmarshal(text, out); err != nil {
return APIError{Message: method + " " + endpoint + " returned non-JSON body", Status: response.StatusCode, Body: string(text)}
}
return nil
}
func urlPathEscape(value string) string {
return url.PathEscape(value)
}
+106
View File
@@ -0,0 +1,106 @@
package aimlapi
import (
"context"
"fmt"
"net/http"
"strings"
)
// Additional client methods backing the interactive TUI onboarding flow. They
// target the endpoints shipped in the aimlapi backend:
// - GET {inference}/billing/balance (Path A: validate key + balance)
// - PATCH{auth}/v1/auth/account (Path B: does the account exist?)
// - POST {auth}/v1/auth/sign-in/code(/verify) (Path B existing: passwordless login)
// - POST {auth}/v1/auth/account/passwordless (Path B new: email-only signup)
// - POST {app}/v1/keys (mint a key once we hold a session)
// BalanceResult is the wallet balance returned by GetBalance, with the backend's
// low-balance flag and threshold.
type BalanceResult struct {
Balance float64 `json:"balance"`
LowBalance bool `json:"lowBalance"`
LowBalanceThreshold float64 `json:"lowBalanceThreshold"`
}
// CheckResult reports whether an email already has an account, driving the
// sign-in vs sign-up branch of the onboarding flow.
type CheckResult struct {
// "sign-in" (account exists) or "sign-up" (no account yet).
Action string `json:"action"`
Provider *string `json:"provider"`
}
// CreatedKey is an API key minted by CreateKey.
type CreatedKey struct {
Key string `json:"key"`
ID string `json:"id"`
}
func (c *Client) authURL(path string) string {
return strings.TrimRight(c.endpoints.AuthBaseURL, "/") + path
}
func (c *Client) appURL(path string) string {
return strings.TrimRight(c.endpoints.AppBaseURL, "/") + path
}
func (c *Client) inferenceURL(path string) string {
return strings.TrimRight(c.endpoints.InferenceBaseURL, "/") + path
}
// GetBalance doubles as key validation: a 401 means the key is invalid, a 2xx
// returns the wallet balance. Auth is the raw API key itself.
func (c *Client) GetBalance(ctx context.Context, apiKey string) (BalanceResult, error) {
var result BalanceResult
err := c.request(ctx, http.MethodGet, c.inferenceURL("/billing/balance"), apiKey, nil, &result)
return result, err
}
// CheckAccount reports whether an email already has an aimlapi.com account
// (Action "sign-in") or not ("sign-up").
func (c *Client) CheckAccount(ctx context.Context, email string) (CheckResult, error) {
var result CheckResult
err := c.request(ctx, http.MethodPatch, c.authURL("/v1/auth/account"), "", map[string]any{"email": email}, &result)
return result, err
}
// SendSignInCode emails a 6-digit sign-in code to an existing account.
func (c *Client) SendSignInCode(ctx context.Context, email string) error {
return c.request(ctx, http.MethodPost, c.authURL("/v1/auth/sign-in/code"), "", map[string]any{"email": email}, nil)
}
// VerifySignInCode exchanges an emailed code for a session bearer token.
func (c *Client) VerifySignInCode(ctx context.Context, email string, code string) (AuthResult, error) {
var result AuthResult
err := c.request(ctx, http.MethodPost, c.authURL("/v1/auth/sign-in/code/verify"), "", map[string]any{"email": email, "code": code}, &result)
if err == nil && strings.TrimSpace(result.Token) == "" {
err = fmt.Errorf("aimlapi.com did not return an auth token")
}
return result, err
}
// CreatePasswordlessAccount registers a new email-only account and returns its
// session bearer token.
func (c *Client) CreatePasswordlessAccount(ctx context.Context, email string) (AuthResult, error) {
var result AuthResult
err := c.request(ctx, http.MethodPost, c.authURL("/v1/auth/account/passwordless"), "", map[string]any{"email": email}, &result)
if err == nil && strings.TrimSpace(result.Token) == "" {
err = fmt.Errorf("aimlapi.com did not return an auth token")
}
return result, err
}
// CreateKey mints a fresh API key for a session-holding user.
func (c *Client) CreateKey(ctx context.Context, bearer string, name string) (CreatedKey, error) {
body := map[string]any{}
if strings.TrimSpace(name) != "" {
body["name"] = strings.TrimSpace(name)
}
var result CreatedKey
err := c.request(ctx, http.MethodPost, c.appURL("/v1/keys"), bearer, body, &result)
if err == nil && strings.TrimSpace(result.Key) == "" {
err = fmt.Errorf("aimlapi.com did not return an API key")
}
return result, err
}
+159
View File
@@ -0,0 +1,159 @@
package aimlapi
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
)
func TestCreateKeySendsAuthenticatedRequestAndDecodesKey(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/v1/keys" {
t.Fatalf("request = %s %s, want POST /v1/keys", r.Method, r.URL.Path)
}
if got := r.Header.Get("Authorization"); got != "Bearer session-token" {
t.Fatalf("Authorization = %q", got)
}
var body map[string]string
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatal(err)
}
if body["name"] != "Zero CLI" {
t.Fatalf("name = %q", body["name"])
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"key":"sk-issued","id":"key-id"}`))
}))
defer server.Close()
client := NewClient(Endpoints{AppBaseURL: server.URL}, server.Client())
key, err := client.CreateKey(context.Background(), " session-token ", " Zero CLI ")
if err != nil {
t.Fatal(err)
}
if key.Key != "sk-issued" || key.ID != "key-id" {
t.Fatalf("key = %#v", key)
}
}
func TestCreateKeyRejectsEmptyKeyResponse(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"id":"key-id"}`))
}))
defer server.Close()
client := NewClient(Endpoints{AppBaseURL: server.URL}, server.Client())
if _, err := client.CreateKey(context.Background(), "session-token", "Zero"); err == nil {
t.Fatal("CreateKey accepted a response without an API key")
}
}
func TestAuthMethodsRejectEmptyTokenResponses(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"token":""}`))
}))
defer server.Close()
client := NewClient(Endpoints{AuthBaseURL: server.URL}, server.Client())
tests := []struct {
name string
call func() error
}{
{
name: "verify sign-in code",
call: func() error {
_, err := client.VerifySignInCode(context.Background(), "[email protected]", "123456")
return err
},
},
{
name: "create passwordless account",
call: func() error {
_, err := client.CreatePasswordlessAccount(context.Background(), "[email protected]")
return err
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if err := test.call(); err == nil {
t.Fatal("accepted a response without an auth token")
}
})
}
}
func TestClientReturnsTypedAPIError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
http.Error(w, "invalid key", http.StatusUnauthorized)
}))
defer server.Close()
client := NewClient(Endpoints{InferenceBaseURL: server.URL}, server.Client())
_, err := client.GetBalance(context.Background(), "bad-key")
var apiErr APIError
if !errors.As(err, &apiErr) {
t.Fatalf("error = %T %v, want APIError", err, err)
}
if apiErr.Status != http.StatusUnauthorized || apiErr.Body != "invalid key\n" {
t.Fatalf("APIError = %#v", apiErr)
}
}
func TestTypedResponseRejectsEmptySuccessBody(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
defer server.Close()
client := NewClient(Endpoints{InferenceBaseURL: server.URL}, server.Client())
if _, err := client.GetBalance(context.Background(), "key"); err == nil {
t.Fatal("GetBalance accepted an empty successful response")
}
}
func TestBodylessOperationAcceptsEmptySuccessBody(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
defer server.Close()
client := NewClient(Endpoints{AuthBaseURL: server.URL}, server.Client())
if err := client.SendSignInCode(context.Background(), "[email protected]"); err != nil {
t.Fatalf("SendSignInCode() error = %v", err)
}
}
func TestParseAmountUSD(t *testing.T) {
tests := []struct {
name string
value string
want int
wantErr bool
}{
{name: "default", want: DefaultAmountUSDMinor},
{name: "minimum", value: "20", want: 2000},
{name: "cents", value: "25.49", want: 2549},
{name: "below minimum", value: "19.99", wantErr: true},
{name: "above maximum", value: "10001", wantErr: true},
{name: "not a number", value: "twenty", wantErr: true},
{name: "nan", value: "NaN", wantErr: true},
{name: "infinity", value: "Inf", wantErr: true},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got, err := ParseAmountUSD(test.value)
if (err != nil) != test.wantErr {
t.Fatalf("ParseAmountUSD(%q) error = %v", test.value, err)
}
if got != test.want {
t.Fatalf("ParseAmountUSD(%q) = %d, want %d", test.value, got, test.want)
}
})
}
}
+113
View File
@@ -0,0 +1,113 @@
package aimlapi
import (
"net/url"
"os"
"strings"
)
type Endpoints struct {
AuthBaseURL string
AppBaseURL string
InferenceBaseURL string
PayBaseURL string
// VerificationBaseURL is the aimlapi web-app frontend base. The browser is sent
// back here after the partner checkout completes (see BuildPartnerReturnURL), so
// it must point at the same environment's front as the one the CLI is talking to
// to land the user there. Empty falls back to DefaultReturnURL.
VerificationBaseURL string
}
const (
DefaultPartnerID = "part_62yQoGYDq4Yqnrj2R1iGrDNJ"
DefaultPartnerName = "Gitlawb"
PartnerHeaderName = "X-AIMLAPI-Partner-ID"
// DefaultReturnURL is the https fallback the browser is sent to after the
// checkout / OAuth consent finishes when no frontend base is known. It must be
// a real web page the browser can open — NOT a custom scheme like
// zero://aimlapi/complete, which fails with "the scheme does not have a
// registered handler" because the CLI registers no OS protocol handler. The
// CLI learns of success by polling; this URL is purely the browser's landing.
DefaultReturnURL = "https://aimlapi.com/app"
DefaultModel = "anthropic/claude-sonnet-5"
MinAmountUSDMinor = 2000
MaxAmountUSDMinor = 1000000
DefaultAmountUSDMinor = 2500
)
func ResolveEndpoints() Endpoints {
return Endpoints{
AuthBaseURL: envOrDefault("AIMLAPI_AUTH_URL", "https://auth.aimlapi.com"),
AppBaseURL: envOrDefault("AIMLAPI_APP_URL", "https://app.aimlapi.com"),
InferenceBaseURL: envOrDefault("AIMLAPI_INFERENCE_URL", "https://api.aimlapi.com/v1"),
PayBaseURL: envOrDefault("AIMLAPI_PAY_URL", "https://pay.aimlapi.com"),
VerificationBaseURL: envOrDefault("AIMLAPI_VERIFICATION_BASE_URL", "https://aimlapi.com/app"),
}
}
// ResolvePartnerID applies the same explicit-option, environment, and default
// precedence everywhere partner attribution is used. Keeping checkout creation
// and saved inference profiles on one resolver prevents them from attributing
// the same user to different partners.
func ResolvePartnerID(explicit string) string {
if value := strings.TrimSpace(explicit); value != "" {
return value
}
return envOrDefault("AIMLAPI_PARTNER_ID", DefaultPartnerID)
}
// WithResolvedPartnerHeader returns a copy with the effective partner ID set.
// Header matching is case-insensitive so an existing spelling is replaced rather
// than duplicated. Callers remain responsible for applying catalog attribution
// only to the canonical AIMLAPI endpoint.
func WithResolvedPartnerHeader(headers map[string]string) map[string]string {
resolved := make(map[string]string, len(headers)+1)
for key, value := range headers {
if strings.EqualFold(strings.TrimSpace(key), PartnerHeaderName) {
continue
}
resolved[key] = value
}
resolved[PartnerHeaderName] = ResolvePartnerID("")
return resolved
}
func BuildPartnerCheckoutReturnURLs(appBaseURL string, sessionToken string) (successURL string, cancelURL string) {
base := strings.TrimRight(strings.TrimSpace(appBaseURL), "/")
if base == "" {
// With no base there is nothing to anchor an absolute URL to. Return empty
// so the caller omits the return URLs entirely (client.Pay skips empty ones,
// leaving the backend's generic checkout) instead of emitting a broken
// relative "/checkout?...".
return "", ""
}
token := url.QueryEscape(sessionToken)
return base + "/checkout?checkout=success&partnerCheckout=1&sessionToken=" + token,
base + "/checkout?checkout=cancel&partnerCheckout=1&sessionToken=" + token
}
// BuildPartnerReturnURL returns the https page the browser is sent back to once
// the partner checkout or OAuth consent completes — the aimlapi web app the user
// opened the flow from. frontendBaseURL is normally endpoints.VerificationBaseURL
// (the front that hosts the login/consent page), so the return follows the same
// environment. AIMLAPI_RETURN_URL overrides it outright; an empty base falls back
// to DefaultReturnURL. It is deliberately NOT a custom scheme (zero://…): no
// browser can hand a custom scheme off without an OS-registered handler, which a
// CLI does not install.
func BuildPartnerReturnURL(frontendBaseURL string) string {
if override := strings.TrimSpace(os.Getenv("AIMLAPI_RETURN_URL")); override != "" {
return override
}
if base := strings.TrimRight(strings.TrimSpace(frontendBaseURL), "/"); base != "" {
return base
}
return DefaultReturnURL
}
func envOrDefault(name string, fallback string) string {
if value := strings.TrimSpace(os.Getenv(name)); value != "" {
return value
}
return fallback
}
+55
View File
@@ -0,0 +1,55 @@
package aimlapi
import "testing"
func TestResolveEndpointsDefaults(t *testing.T) {
t.Setenv("AIMLAPI_AUTH_URL", "")
t.Setenv("AIMLAPI_APP_URL", "")
t.Setenv("AIMLAPI_INFERENCE_URL", "")
t.Setenv("AIMLAPI_PAY_URL", "")
endpoints := ResolveEndpoints()
if endpoints.AuthBaseURL != "https://auth.aimlapi.com" ||
endpoints.AppBaseURL != "https://app.aimlapi.com" ||
endpoints.InferenceBaseURL != "https://api.aimlapi.com/v1" ||
endpoints.PayBaseURL != "https://pay.aimlapi.com" {
t.Fatalf("unexpected endpoints: %#v", endpoints)
}
}
func TestResolvedPartnerHeaderUsesEnvironmentOverrideWithoutDuplicates(t *testing.T) {
t.Setenv("AIMLAPI_PARTNER_ID", "part_override")
headers := WithResolvedPartnerHeader(map[string]string{
"x-aimlapi-partner-id": "part_catalog",
"X-AIMLAPI-Integration-Repo": "Gitlawb/zero",
})
if got := headers[PartnerHeaderName]; got != "part_override" {
t.Fatalf("partner header = %q, want part_override", got)
}
if _, ok := headers["x-aimlapi-partner-id"]; ok {
t.Fatalf("case-insensitive partner header was duplicated: %#v", headers)
}
if got := headers["X-AIMLAPI-Integration-Repo"]; got != "Gitlawb/zero" {
t.Fatalf("unrelated header changed: %#v", headers)
}
}
func TestBuildPartnerReturnURL(t *testing.T) {
t.Setenv("AIMLAPI_RETURN_URL", "")
// The frontend base (the web app the flow was opened from) is returned as-is,
// trailing slash trimmed — an https page the browser can actually land on.
if got := BuildPartnerReturnURL("https://aimlapi.com/app/"); got != "https://aimlapi.com/app" {
t.Fatalf("return URL = %q, want the frontend base", got)
}
// An empty base falls back to the https default, never a custom scheme.
if got := BuildPartnerReturnURL(""); got != DefaultReturnURL {
t.Fatalf("empty base return URL = %q, want %q", got, DefaultReturnURL)
}
t.Setenv("AIMLAPI_RETURN_URL", "https://example.test/done")
if got := BuildPartnerReturnURL("https://aimlapi.com/app"); got != "https://example.test/done" {
t.Fatalf("env override return URL = %q", got)
}
}
+42
View File
@@ -0,0 +1,42 @@
package aimlapi
// UI strings shared by the first-run and provider-wizard onboarding surfaces.
const (
// Path A — existing API key. Pick-path prompt + option labels.
MsgAPIKeyInputPrompt = "Enter your aimlapi.com key."
MsgAPIKeyInvalid = "API key is invalid. Please make sure you enter a valid aimlapi.com key."
MsgPickPathPrompt = "Do you have aimlapi.com key?"
MsgPickPathHaveKey = "I already have aimlapi.com key"
MsgPickPathHaveHint = "Proceed to paste the key"
MsgPickPathNewUser = "I am a new user"
MsgPickPathNewHint = "One click set up"
// Path B — email.
MsgEnterEmail = "Enter your email.\nTo access aimlapi.com dashboard."
MsgEmailInvalid = "Email format is incorrect."
MsgAccountActionInvalid = "aimlapi.com returned an unsupported account action. Please try again."
// %s = email
MsgCodeSent = "We sent a 6-digit code to %s.\nEnter it below to continue."
MsgCodeIncorrect = "Code you've entered is incorrect."
// Balance check.
MsgLowBalance = "Your aimlapi.com balance is running low.\nIt is recommended to top up your balance."
MsgEverythingRuns = "Everything is ready."
// Low-balance choices (spec wording).
MsgLowBalanceTopUp = "Sure, let's do that"
MsgLowBalanceSkip = "I'll skip topping up the balance for now"
// Top-up workflow.
MsgTopUpPrompt = "Add credits.\nEnter an amount (min $20)."
MsgAmountRequired = "Please enter a top-up amount."
// %s = checkout URL
MsgTopUpBrowserFallback = "If the browser did not open automatically please use this link to top up your account: %s"
MsgTopUpFailed = "Top up failed. Please try again."
MsgTopUpSuccess = "Top-up successful."
// Success — key delivered.
// %s = amount in dollars (e.g. "25")
MsgTopUpAddedFmt = "$%s has been added to your balance."
// %s = email
MsgSuccessMagicLink = "We've emailed you a magic link to %s. Use it to access your aimlapi.com account and review your usage and balance."
)
+26
View File
@@ -0,0 +1,26 @@
package aimlapi
import "testing"
func TestValidEmailRejectsIncompleteDomains(t *testing.T) {
tests := []struct {
value string
valid bool
}{
{value: "[email protected]", valid: true},
{value: "[email protected]", valid: true},
{value: "123@123.", valid: false},
{value: "123@123", valid: false},
{value: "[email protected]", valid: false},
{value: "[email protected]", valid: false},
{value: "[email protected]", valid: false},
{value: "[email protected]", valid: false},
}
for _, test := range tests {
t.Run(test.value, func(t *testing.T) {
if got := ValidEmail(test.value); got != test.valid {
t.Fatalf("ValidEmail(%q) = %v, want %v", test.value, got, test.valid)
}
})
}
}
+335
View File
@@ -0,0 +1,335 @@
package aimlapi
import (
"context"
"fmt"
"net/http"
"net/url"
"strings"
)
// StreamTopUpOptions drives a session-based partner-checkout top-up for the
// interactive (TUI) onboarding. The caller already holds a user session —
// obtained from an email-code sign-in (Path B existing) or a
// passwordless new account (Path B sign-up) — so this skips authentication and
// runs create-session → pay → wait-for-payment, optionally exchanging the paid
// session into a fresh key (new accounts) versus keeping the caller's key.
type StreamTopUpOptions struct {
SessionToken string // user session bearer (required)
AmountUSD string // dollars; parsed via ParseAmountUSD (min $20)
// InferenceBaseURL pins key-bound billing and the returned provider profile to
// the endpoint that validated the credential. Empty uses ResolveEndpoints.
InferenceBaseURL string
Method PaymentMethod
AutoTopUp bool // enroll the account in automatic top-up at checkout time
Exchange bool // exchange the paid session into a new key (new accounts)
Model string
PartnerID string
PartnerName string
NoOpen bool
// ResumeSessionToken, when set, resumes that partner-checkout session instead
// of opening a new one, so a retry after an ambiguous failure (a lost Pay/
// Exchange response) can never double-charge or strand an already-paid session.
ResumeSessionToken string
// PaymentSessionID is generated once per amount/auto-top-up intent and reused
// on retry so an ambiguous Pay response cannot create a second checkout.
PaymentSessionID string
HTTPClient *http.Client
OpenBrowser func(string) error
OnStatus func(Status, string)
// OnSession reports the partner-checkout session token the moment it exists (a
// fresh CreateSession, or the resumed one), so the caller can retain it and
// resume on a later failure. It fires with "" when the session is dead and the
// retained token should be dropped (a fresh run must start over).
OnSession func(sessionToken string)
}
// topupPhase is where StreamTopUp enters its create-checkout → poll → exchange
// pipeline. A fresh run starts at phasePay; a resume skips ahead to whatever the
// existing session's status makes safe.
type topupPhase int
const (
phasePay topupPhase = iota // (re-)open the Stripe checkout, then poll + exchange
phasePoll // checkout already open; poll for payment, never re-pay
phaseExchange // already paid; exchange only (recovers a lost key)
phaseWaitExchange // an exchange claim is in flight; poll, never call Exchange twice
)
// StreamTopUp performs the browser-checkout top-up and reports progress via
// OnStatus.
func StreamTopUp(ctx context.Context, options StreamTopUpOptions) (ProvisionedKey, error) {
endpoints := ResolveEndpoints()
if baseURL := strings.TrimSpace(options.InferenceBaseURL); baseURL != "" {
endpoints.InferenceBaseURL = baseURL
}
client := NewClient(endpoints, options.HTTPClient)
partnerID := ResolvePartnerID(options.PartnerID)
partnerName := firstNonEmpty(options.PartnerName, DefaultPartnerName)
method := options.Method
if method != PaymentMethodCrypto {
method = PaymentMethodCard
}
amount, err := ParseAmountUSD(options.AmountUSD)
if err != nil {
return ProvisionedKey{}, err
}
if strings.TrimSpace(options.SessionToken) == "" {
return ProvisionedKey{}, fmt.Errorf("a session is required to top up")
}
if strings.TrimSpace(options.PaymentSessionID) == "" {
return ProvisionedKey{}, fmt.Errorf("a payment session id is required to top up")
}
status(options.OnStatus, StatusCreatingSession, "")
sessionToken, phase, err := resolveTopupSession(ctx, client, options, partnerID, partnerName, endpoints)
if err != nil {
return ProvisionedKey{}, err
}
// Retain the live token so an ambiguous failure below can resume this session
// rather than open a second checkout on the next attempt.
reportSession(options.OnSession, sessionToken)
if phase <= phasePay {
successURL, cancelURL := BuildPartnerCheckoutReturnURLs(endpoints.PayBaseURL, sessionToken)
status(options.OnStatus, StatusOpeningCheckout, "")
pay, err := client.Pay(ctx, options.SessionToken, sessionToken, amount, options.PaymentSessionID, method, successURL, cancelURL, options.AutoTopUp)
if err != nil {
return ProvisionedKey{}, err
}
checkoutURL, err := validatedCheckoutURL(pay.Checkout.PayURL)
if err != nil {
return ProvisionedKey{}, err
}
announceCheckout(options.OnStatus, options.OpenBrowser, options.NoOpen, checkoutURL)
}
paidToken := sessionToken
if phase <= phasePoll {
status(options.OnStatus, StatusWaitingPayment, "")
paid, err := pollUntilPaid(ctx, client, sessionToken, options.OnSession)
if err != nil {
return ProvisionedKey{}, err
}
paidToken = paid.SessionToken
if paid.Status == SessionStatusExchanging {
// Another/lost request claimed exchange while we were polling payment.
// Carry that fact into the provisioning branch so it follows the claim
// instead of issuing a second single-flight Exchange request.
phase = phaseWaitExchange
}
}
result := ProvisionedKey{
BaseURL: endpoints.InferenceBaseURL,
Model: firstNonEmpty(options.Model, DefaultModel),
}
if options.Exchange {
status(options.OnStatus, StatusProvisioningKey, "")
if phase == phaseWaitExchange {
return ProvisionedKey{}, pollUntilExchangeSettled(ctx, client, sessionToken, options.OnSession)
}
exchange, err := client.Exchange(ctx, options.SessionToken, paidToken)
if err != nil {
return ProvisionedKey{}, err
}
if strings.TrimSpace(exchange.APIKey) == "" {
return ProvisionedKey{}, fmt.Errorf("aimlapi.com did not return an API key")
}
result.APIKey = exchange.APIKey
result.APIKeyID = exchange.APIKeyID
}
return result, nil
}
// resolveTopupSession returns the partner-checkout session token to drive and the
// phase to enter. A fresh run opens a new session at phasePay. A resume inspects
// the existing session and enters the earliest phase that cannot re-charge:
// - pending_auth → phasePay: payment never started, so (re-)Pay is safe.
// - pending_payment → phasePoll: a checkout is already open; poll, never re-Pay.
// - paid → phaseExchange: exchange the paid session once.
// - exchanging → phaseWaitExchange: another/lost request owns the atomic
// exchange claim; poll it, never issue a second Exchange request.
// - exchanged → terminal: the one-shot raw key is gone; point at dashboard
// recovery and keep the token so a re-run repeats that guidance, not a charge.
// - cancelled/expired/failed → terminal: drop the dead token so a re-run is fresh.
func resolveTopupSession(ctx context.Context, client *Client, options StreamTopUpOptions, partnerID, partnerName string, endpoints Endpoints) (string, topupPhase, error) {
resume := strings.TrimSpace(options.ResumeSessionToken)
if resume == "" {
session, err := client.CreateSession(ctx, partnerID, partnerName, BuildPartnerReturnURL(endpoints.VerificationBaseURL))
if err != nil {
return "", phasePay, err
}
return session.SessionToken, phasePay, nil
}
session, err := client.GetSession(ctx, resume)
if err != nil {
if isTerminalSessionAPIError(err) {
reportSession(options.OnSession, "")
}
return "", phasePay, err
}
switch session.Status {
case SessionStatusPendingAuth:
return resume, phasePay, nil
case SessionStatusPendingPayment:
return resume, phasePoll, nil
case SessionStatusPaid:
return resume, phaseExchange, nil
case SessionStatusExchanging:
return resume, phaseWaitExchange, nil
case SessionStatusExchanged:
return "", phasePay, fmt.Errorf("session was already exchanged; rotate the key from the aimlapi.com dashboard")
default:
reportSession(options.OnSession, "") // drop the dead token → next run starts fresh
return "", phasePay, fmt.Errorf("payment %s; re-run the top-up to try again", session.Status)
}
}
func reportSession(callback func(string), sessionToken string) {
if callback != nil {
callback(sessionToken)
}
}
// announceCheckout opens the hosted checkout in the browser (unless suppressed)
// and reports the URL either way, so the TUI can always show the direct link.
func announceCheckout(onStatus func(Status, string), open func(string) error, noOpen bool, checkoutURL string) {
if noOpen || open == nil {
status(onStatus, StatusOpeningCheckout, checkoutURL)
return
}
if err := open(checkoutURL); err != nil {
status(onStatus, StatusOpeningCheckout, "Open manually: "+checkoutURL)
return
}
status(onStatus, StatusOpeningCheckout, checkoutURL)
}
func validatedCheckoutURL(value string) (string, error) {
checkoutURL := strings.TrimSpace(value)
parsed, err := url.Parse(checkoutURL)
if err != nil || !parsed.IsAbs() || !strings.EqualFold(parsed.Scheme, "https") || parsed.Hostname() == "" || parsed.User != nil {
return "", fmt.Errorf("payment provider did not return a valid HTTPS checkout URL")
}
return checkoutURL, nil
}
// StreamTopUpByKeyOptions drives a top-up funded with the API key that already
// owns the account (Path A pasted key, or AIMLAPI_API_KEY). Unlike StreamTopUp it
// needs no email session and never exchanges a key — the caller already holds one,
// so it only funds the wallet: create-session → pay-by-key → wait-for-payment.
type StreamTopUpByKeyOptions struct {
APIKey string // raw key that owns the account (required)
AmountUSD string // dollars; parsed via ParseAmountUSD (min $20)
InferenceBaseURL string // validated key endpoint; empty uses ResolveEndpoints
AutoTopUp bool // enroll the account in automatic top-up at checkout time
PartnerID string
PartnerName string
NoOpen bool
// ResumeSessionToken resumes an existing partner-checkout session instead of
// opening a new one. PaymentSessionID is the idempotency handle carried across
// retries so a re-issued pay returns the same checkout, never a second charge.
ResumeSessionToken string
PaymentSessionID string // required; generate once via NewPaymentSessionID, reuse on retry
HTTPClient *http.Client
OpenBrowser func(string) error
OnStatus func(Status, string)
OnSession func(sessionToken string)
}
// StreamTopUpByKey funds the account behind APIKey via the hosted checkout and
// reports progress through OnStatus. It returns an empty ProvisionedKey: the
// pasted/env key is unchanged, only the balance grows.
func StreamTopUpByKey(ctx context.Context, options StreamTopUpByKeyOptions) (ProvisionedKey, error) {
endpoints := ResolveEndpoints()
if baseURL := strings.TrimSpace(options.InferenceBaseURL); baseURL != "" {
endpoints.InferenceBaseURL = baseURL
}
client := NewClient(endpoints, options.HTTPClient)
partnerID := ResolvePartnerID(options.PartnerID)
partnerName := firstNonEmpty(options.PartnerName, DefaultPartnerName)
amount, err := ParseAmountUSD(options.AmountUSD)
if err != nil {
return ProvisionedKey{}, err
}
if strings.TrimSpace(options.APIKey) == "" {
return ProvisionedKey{}, fmt.Errorf("an API key is required to top up")
}
if strings.TrimSpace(options.PaymentSessionID) == "" {
return ProvisionedKey{}, fmt.Errorf("a payment session id is required to top up")
}
status(options.OnStatus, StatusCreatingSession, "")
sessionToken, phase, err := resolveByKeySession(ctx, client, options, partnerID, partnerName, endpoints)
if err != nil {
return ProvisionedKey{}, err
}
reportSession(options.OnSession, sessionToken)
if phase <= phasePay {
successURL, cancelURL := BuildPartnerCheckoutReturnURLs(endpoints.PayBaseURL, sessionToken)
status(options.OnStatus, StatusOpeningCheckout, "")
pay, err := client.TopUpByKey(ctx, options.APIKey, TopUpByKeyRequest{
SessionToken: sessionToken,
AmountUSDMinor: amount,
PaymentSessionID: options.PaymentSessionID,
AutoTopUp: options.AutoTopUp,
SuccessURL: successURL,
CancelURL: cancelURL,
})
if err != nil {
return ProvisionedKey{}, err
}
checkoutURL, err := validatedCheckoutURL(pay.Checkout.PayURL)
if err != nil {
return ProvisionedKey{}, err
}
announceCheckout(options.OnStatus, options.OpenBrowser, options.NoOpen, checkoutURL)
}
if phase <= phasePoll {
status(options.OnStatus, StatusWaitingPayment, "")
if _, err := pollUntilPaid(ctx, client, sessionToken, options.OnSession); err != nil {
return ProvisionedKey{}, err
}
}
// No exchange: the account is funded and the caller keeps its existing key.
return ProvisionedKey{}, nil
}
// resolveByKeySession mirrors resolveTopupSession for the by-key flow. Because
// by-key never exchanges, a paid/exchanging/exchanged session all mean "already
// funded, done" (phaseExchange skips straight past pay + poll to the empty result).
func resolveByKeySession(ctx context.Context, client *Client, options StreamTopUpByKeyOptions, partnerID, partnerName string, endpoints Endpoints) (string, topupPhase, error) {
resume := strings.TrimSpace(options.ResumeSessionToken)
if resume == "" {
session, err := client.CreateSession(ctx, partnerID, partnerName, BuildPartnerReturnURL(endpoints.VerificationBaseURL))
if err != nil {
return "", phasePay, err
}
return session.SessionToken, phasePay, nil
}
session, err := client.GetSession(ctx, resume)
if err != nil {
if isTerminalSessionAPIError(err) {
reportSession(options.OnSession, "")
}
return "", phasePay, err
}
switch session.Status {
case SessionStatusPendingAuth:
return resume, phasePay, nil
case SessionStatusPendingPayment:
return resume, phasePoll, nil
case SessionStatusPaid, SessionStatusExchanging, SessionStatusExchanged:
return resume, phaseExchange, nil
default:
reportSession(options.OnSession, "") // drop the dead token → next run starts fresh
return "", phasePay, fmt.Errorf("payment %s; re-run the top-up to try again", session.Status)
}
}
+457
View File
@@ -0,0 +1,457 @@
package aimlapi
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestStreamTopUpByKeyFundsWithoutExchange(t *testing.T) {
var topupCalls, exchangeCalls int
var topupBody map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/partner-checkout/sessions"):
_ = json.NewEncoder(w).Encode(PartnerCheckoutSession{SessionToken: "pcs_tok", Status: SessionStatusPendingAuth})
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/v2/billing/topup"):
topupCalls++
_ = json.NewDecoder(r.Body).Decode(&topupBody)
_ = json.NewEncoder(w).Encode(TopUpByKeyResult{Checkout: PaymentSession{PayURL: "https://pay/checkout"}})
case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/partner-checkout/sessions/"):
_ = json.NewEncoder(w).Encode(PartnerCheckoutSession{SessionToken: "pcs_tok", Status: SessionStatusPaid})
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/exchange"):
exchangeCalls++
t.Errorf("by-key must never exchange a key")
default:
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
}
}))
defer server.Close()
t.Setenv("AIMLAPI_APP_URL", server.URL)
t.Setenv("AIMLAPI_INFERENCE_URL", server.URL+"/v1")
var opened string
result, err := StreamTopUpByKey(context.Background(), StreamTopUpByKeyOptions{
APIKey: "sk-user",
AmountUSD: "20",
AutoTopUp: true,
PaymentSessionID: "pay-1",
OpenBrowser: func(u string) error { opened = u; return nil },
})
if err != nil {
t.Fatalf("StreamTopUpByKey = %v", err)
}
if result.APIKey != "" {
t.Fatalf("by-key must not mint a key, got %q", result.APIKey)
}
if topupCalls != 1 || exchangeCalls != 0 {
t.Fatalf("topup=%d exchange=%d, want 1/0", topupCalls, exchangeCalls)
}
if opened != "https://pay/checkout" {
t.Fatalf("opened checkout = %q", opened)
}
if topupBody["autoTopUp"] != true || topupBody["paymentSessionId"] != "pay-1" {
t.Fatalf("topup body missing autoTopUp/paymentSessionId: %#v", topupBody)
}
}
func TestValidatedCheckoutURLRequiresAbsoluteHTTPS(t *testing.T) {
tests := []struct {
name string
value string
want string
wantErr bool
}{
{name: "https", value: " https://pay.example.test/checkout?id=1 ", want: "https://pay.example.test/checkout?id=1"},
{name: "empty", wantErr: true},
{name: "relative", value: "/checkout", wantErr: true},
{name: "http", value: "http://pay.example.test/checkout", wantErr: true},
{name: "file", value: "file:///tmp/checkout", wantErr: true},
{name: "custom protocol", value: "wallet://checkout/123", wantErr: true},
{name: "userinfo", value: "https://[email protected]/checkout", wantErr: true},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got, err := validatedCheckoutURL(test.value)
if (err != nil) != test.wantErr {
t.Fatalf("validatedCheckoutURL(%q) error = %v", test.value, err)
}
if got != test.want {
t.Fatalf("validatedCheckoutURL(%q) = %q, want %q", test.value, got, test.want)
}
})
}
}
func TestStreamTopUpByKeyPinsValidatedInferenceEndpoint(t *testing.T) {
validatedCalls := 0
validated := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/partner-checkout/sessions"):
_ = json.NewEncoder(w).Encode(PartnerCheckoutSession{SessionToken: "pcs_tok", Status: SessionStatusPendingAuth})
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/v2/billing/topup"):
validatedCalls++
_ = json.NewEncoder(w).Encode(TopUpByKeyResult{Checkout: PaymentSession{PayURL: "https://pay/checkout"}})
case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/partner-checkout/sessions/"):
_ = json.NewEncoder(w).Encode(PartnerCheckoutSession{SessionToken: "pcs_tok", Status: SessionStatusPaid})
default:
t.Errorf("unexpected validated-endpoint request: %s %s", r.Method, r.URL.Path)
}
}))
defer validated.Close()
override := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Errorf("validated key leaked to process override: %s %s", r.Method, r.URL.Path)
}))
defer override.Close()
t.Setenv("AIMLAPI_APP_URL", validated.URL)
t.Setenv("AIMLAPI_INFERENCE_URL", override.URL)
_, err := StreamTopUpByKey(context.Background(), StreamTopUpByKeyOptions{
APIKey: "production-key",
AmountUSD: "20",
InferenceBaseURL: validated.URL,
PaymentSessionID: "payment-1",
OpenBrowser: func(string) error { return nil },
})
if err != nil {
t.Fatalf("StreamTopUpByKey() error = %v", err)
}
if validatedCalls != 1 {
t.Fatalf("validated endpoint top-up calls = %d, want 1", validatedCalls)
}
}
func TestStreamTopUpEmailRetrySendsStablePaymentSessionID(t *testing.T) {
getCalls := 0
var paymentSessionID string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/partner-checkout/sessions/"):
status := SessionStatusPendingAuth
if getCalls > 0 {
status = SessionStatusPaid
}
getCalls++
_ = json.NewEncoder(w).Encode(PartnerCheckoutSession{SessionToken: "pcs_retry", Status: status})
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/pay"):
var body map[string]any
_ = json.NewDecoder(r.Body).Decode(&body)
paymentSessionID, _ = body["paymentSessionId"].(string)
_ = json.NewEncoder(w).Encode(PayResult{Checkout: PaymentSession{PayURL: "https://pay/checkout"}})
default:
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
}
}))
defer server.Close()
t.Setenv("AIMLAPI_APP_URL", server.URL)
_, err := StreamTopUp(context.Background(), StreamTopUpOptions{
SessionToken: "bearer",
ResumeSessionToken: "pcs_retry",
PaymentSessionID: "payment-stable",
AmountUSD: "20",
NoOpen: true,
})
if err != nil {
t.Fatalf("StreamTopUp() error = %v", err)
}
if paymentSessionID != "payment-stable" {
t.Fatalf("paymentSessionId = %q, want stable retry id", paymentSessionID)
}
}
// resumeServer is a partner-checkout backend fake that fails the test if the
// re-charging endpoints (create-session / pay) are ever hit, and serves a
// scripted GetSession status sequence so a resume can be driven deterministically.
type resumeServer struct {
t *testing.T
statuses []string // GetSession status per call, last value repeats
getCalls int
payCalls int
createCall int
exchanged int
}
func (rs *resumeServer) handler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/partner-checkout/sessions"):
rs.createCall++
rs.t.Errorf("resume must not create a new session")
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/pay"):
rs.payCalls++
rs.t.Errorf("resume must not re-pay (double charge)")
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/exchange"):
rs.exchanged++
_ = json.NewEncoder(w).Encode(ExchangeResult{APIKey: "sk-resumed", APIKeyID: "key_1"})
case r.Method == http.MethodGet:
status := rs.statuses[len(rs.statuses)-1]
if rs.getCalls < len(rs.statuses) {
status = rs.statuses[rs.getCalls]
}
rs.getCalls++
_ = json.NewEncoder(w).Encode(PartnerCheckoutSession{SessionToken: "pc_tok", Status: SessionStatus(status)})
default:
rs.t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
}
})
}
func TestStreamTopUpResumePaidExchangesWithoutRecharge(t *testing.T) {
rs := &resumeServer{t: t, statuses: []string{"paid"}}
server := httptest.NewServer(rs.handler())
defer server.Close()
t.Setenv("AIMLAPI_APP_URL", server.URL)
var seen string
result, err := StreamTopUp(context.Background(), StreamTopUpOptions{
SessionToken: "bearer",
ResumeSessionToken: "pc_tok",
PaymentSessionID: "payment-1",
AmountUSD: "20",
Exchange: true,
NoOpen: true,
OnSession: func(token string) { seen = token },
})
if err != nil {
t.Fatalf("StreamTopUp resume = %v", err)
}
if result.APIKey != "sk-resumed" {
t.Fatalf("APIKey = %q, want recovered key", result.APIKey)
}
if rs.exchanged != 1 {
t.Fatalf("exchange calls = %d, want 1", rs.exchanged)
}
if seen != "pc_tok" {
t.Fatalf("OnSession token = %q, want retained pc_tok", seen)
}
}
func TestStreamTopUpResumePendingPaymentPollsNeverRepays(t *testing.T) {
// First GetSession (resume resolution) sees the still-open checkout; the next
// (poll) sees it settle. Pay must never be called — the fake fails the test if
// it is.
rs := &resumeServer{t: t, statuses: []string{"pending_payment", "paid"}}
server := httptest.NewServer(rs.handler())
defer server.Close()
t.Setenv("AIMLAPI_APP_URL", server.URL)
result, err := StreamTopUp(context.Background(), StreamTopUpOptions{
SessionToken: "bearer",
ResumeSessionToken: "pc_tok",
PaymentSessionID: "payment-1",
AmountUSD: "20",
Exchange: true,
NoOpen: true,
})
if err != nil {
t.Fatalf("StreamTopUp resume = %v", err)
}
if result.APIKey != "sk-resumed" {
t.Fatalf("APIKey = %q, want recovered key", result.APIKey)
}
if rs.payCalls != 0 {
t.Fatalf("pay calls = %d, want 0", rs.payCalls)
}
}
func TestStreamTopUpPollingDeadSessionClearsRetainedToken(t *testing.T) {
rs := &resumeServer{t: t, statuses: []string{"pending_payment", "expired"}}
server := httptest.NewServer(rs.handler())
defer server.Close()
t.Setenv("AIMLAPI_APP_URL", server.URL)
seen := []string{}
_, err := StreamTopUp(context.Background(), StreamTopUpOptions{
SessionToken: "bearer",
ResumeSessionToken: "pc_tok",
PaymentSessionID: "payment-1",
AmountUSD: "20",
Exchange: true,
NoOpen: true,
OnSession: func(token string) { seen = append(seen, token) },
})
if err == nil || !strings.Contains(err.Error(), "payment expired") {
t.Fatalf("err = %v, want expired payment error", err)
}
if len(seen) == 0 || seen[len(seen)-1] != "" {
t.Fatalf("OnSession events = %#v, want terminal clear", seen)
}
}
func TestPollUntilPaidTerminal4xxClearsRetainedToken(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "session rejected", http.StatusNotFound)
}))
defer server.Close()
seen := "retained"
_, err := pollUntilPaid(context.Background(), NewClient(Endpoints{AppBaseURL: server.URL}, server.Client()), "pc_missing", func(token string) {
seen = token
})
if err == nil {
t.Fatal("pollUntilPaid() error = nil, want terminal 4xx")
}
if seen != "" {
t.Fatalf("retained session = %q, want cleared", seen)
}
}
func TestPollUntilExchangeSettledClearsDeadSession(t *testing.T) {
for _, test := range []struct {
name string
statusCode int
status SessionStatus
}{
{name: "terminal API rejection", statusCode: http.StatusNotFound},
{name: "expired exchange claim", status: SessionStatusExpired},
} {
t.Run(test.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
if test.statusCode != 0 {
http.Error(w, "session rejected", test.statusCode)
return
}
_ = json.NewEncoder(w).Encode(PartnerCheckoutSession{SessionToken: "pcs_dead", Status: test.status})
}))
defer server.Close()
seen := "retained"
err := pollUntilExchangeSettled(context.Background(), NewClient(Endpoints{AppBaseURL: server.URL}, server.Client()), "pcs_dead", func(token string) {
seen = token
})
if err == nil {
t.Fatal("pollUntilExchangeSettled() error = nil")
}
if seen != "" {
t.Fatalf("retained session = %q, want cleared", seen)
}
})
}
}
func TestResumeHelpersClearOnlyRejectedSessions(t *testing.T) {
tests := []struct {
name string
status int
wantClear bool
}{
{name: "terminal 4xx", status: http.StatusNotFound, wantClear: true},
{name: "retryable 5xx", status: http.StatusServiceUnavailable, wantClear: false},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
http.Error(w, "session lookup failed", test.status)
}))
defer server.Close()
client := NewClient(Endpoints{AppBaseURL: server.URL}, server.Client())
for _, flow := range []struct {
name string
resolve func(func(string)) error
}{
{
name: "session bearer",
resolve: func(onSession func(string)) error {
_, _, err := resolveTopupSession(context.Background(), client, StreamTopUpOptions{
ResumeSessionToken: "pc_rejected", OnSession: onSession,
}, DefaultPartnerID, DefaultPartnerName, Endpoints{})
return err
},
},
{
name: "api key",
resolve: func(onSession func(string)) error {
_, _, err := resolveByKeySession(context.Background(), client, StreamTopUpByKeyOptions{
ResumeSessionToken: "pc_rejected", OnSession: onSession,
}, DefaultPartnerID, DefaultPartnerName, Endpoints{})
return err
},
},
} {
t.Run(flow.name, func(t *testing.T) {
seen := "retained"
if err := flow.resolve(func(token string) { seen = token }); err == nil {
t.Fatal("resolve error = nil")
}
if cleared := seen == ""; cleared != test.wantClear {
t.Fatalf("session cleared = %v, want %v", cleared, test.wantClear)
}
})
}
})
}
}
func TestStreamTopUpResumeExchangedReturnsRecoveryError(t *testing.T) {
rs := &resumeServer{t: t, statuses: []string{"exchanged"}}
server := httptest.NewServer(rs.handler())
defer server.Close()
t.Setenv("AIMLAPI_APP_URL", server.URL)
_, err := StreamTopUp(context.Background(), StreamTopUpOptions{
SessionToken: "bearer",
ResumeSessionToken: "pc_tok",
PaymentSessionID: "payment-1",
AmountUSD: "20",
Exchange: true,
NoOpen: true,
})
if err == nil || !strings.Contains(err.Error(), "already exchanged") {
t.Fatalf("err = %v, want an already-exchanged recovery error", err)
}
if rs.exchanged != 0 {
t.Fatalf("exchange calls = %d, want 0 (no second key)", rs.exchanged)
}
}
func TestStreamTopUpResumeExchangingPollsWithoutSecondExchange(t *testing.T) {
rs := &resumeServer{t: t, statuses: []string{"exchanging", "exchanged"}}
server := httptest.NewServer(rs.handler())
defer server.Close()
t.Setenv("AIMLAPI_APP_URL", server.URL)
_, err := StreamTopUp(context.Background(), StreamTopUpOptions{
SessionToken: "bearer",
ResumeSessionToken: "pc_tok",
PaymentSessionID: "payment-1",
AmountUSD: "20",
Exchange: true,
NoOpen: true,
})
if err == nil || !strings.Contains(err.Error(), "already exchanged") {
t.Fatalf("err = %v, want dashboard recovery guidance", err)
}
if rs.exchanged != 0 {
t.Fatalf("exchange calls = %d, want 0 while an exchange claim is in flight", rs.exchanged)
}
if rs.createCall != 0 || rs.payCalls != 0 {
t.Fatalf("resume created/paid a new checkout: create=%d pay=%d", rs.createCall, rs.payCalls)
}
}
func TestStreamTopUpPollObservesExchangingWithoutSecondExchange(t *testing.T) {
rs := &resumeServer{t: t, statuses: []string{"pending_payment", "exchanging", "exchanged"}}
server := httptest.NewServer(rs.handler())
defer server.Close()
t.Setenv("AIMLAPI_APP_URL", server.URL)
_, err := StreamTopUp(context.Background(), StreamTopUpOptions{
SessionToken: "bearer",
ResumeSessionToken: "pc_tok",
PaymentSessionID: "payment-1",
AmountUSD: "20",
Exchange: true,
NoOpen: true,
})
if err == nil || !strings.Contains(err.Error(), "already exchanged") {
t.Fatalf("err = %v, want dashboard recovery guidance", err)
}
if rs.exchanged != 0 {
t.Fatalf("exchange calls = %d, want 0 after poll observed an existing claim", rs.exchanged)
}
}
+186
View File
@@ -0,0 +1,186 @@
package aimlapi
import (
"context"
"crypto/rand"
"errors"
"fmt"
"math"
"net/http"
"strconv"
"strings"
"time"
)
// Status is a progress phase reported by StreamTopUp through its OnStatus callback.
type Status string
const (
StatusCreatingSession Status = "creating-session"
StatusOpeningCheckout Status = "opening-checkout"
StatusWaitingPayment Status = "waiting-payment"
StatusProvisioningKey Status = "provisioning-key"
)
// ProvisionedKey is the outcome of a top-up: the (optionally minted) API key plus
// the inference base URL and model to write into the provider profile.
type ProvisionedKey struct {
APIKey string
APIKeyID string
BaseURL string
Model string
}
const (
pollInterval = 3 * time.Second
pollTimeout = 20 * time.Minute
)
// ParseAmountUSD parses a dollar string into USD minor units (cents), enforcing the
// min/max top-up bounds. An empty value yields the default amount.
func ParseAmountUSD(value string) (int, error) {
value = strings.TrimSpace(value)
if value == "" {
return DefaultAmountUSDMinor, nil
}
dollars, err := strconv.ParseFloat(value, 64)
// NaN/±Inf parse without error and slip past the min/max comparisons below
// (every ordered comparison with NaN is false), so reject them explicitly
// before the cents conversion turns them into a garbage minor-unit amount.
if err != nil || math.IsNaN(dollars) || math.IsInf(dollars, 0) || dollars <= 0 {
return 0, fmt.Errorf("invalid amount %q; pass a positive USD amount", value)
}
minor := int(dollars*100 + 0.5)
if minor < MinAmountUSDMinor {
return 0, fmt.Errorf("minimum top-up is $%d", MinAmountUSDMinor/100)
}
if minor > MaxAmountUSDMinor {
return 0, fmt.Errorf("maximum top-up is $%d", MaxAmountUSDMinor/100)
}
return minor, nil
}
// NewPaymentSessionID returns a random UUIDv4-shaped idempotency id for a top-up.
// The caller generates it once per top-up intent and reuses it on retry so the
// backend (and Stripe) coalesce retries onto the same checkout instead of a
// second charge.
func NewPaymentSessionID() (string, error) {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
return "", err
}
b[6] = (b[6] & 0x0f) | 0x40 // version 4
b[8] = (b[8] & 0x3f) | 0x80 // variant 10
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]), nil
}
func pollUntilPaid(ctx context.Context, client *Client, sessionToken string, onSession func(string)) (PartnerCheckoutSession, error) {
deadline := time.Now().Add(pollTimeout)
for time.Now().Before(deadline) {
session, err := client.GetSession(ctx, sessionToken)
if err != nil {
// Context cancellation/deadline is terminal (also covers an in-flight
// request cancelled mid-poll, whose transport error wraps ctx.Err()).
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return PartnerCheckoutSession{}, err
}
// A 4xx API response is terminal. Everything else — a 5xx or a transient
// transport error (non-APIError, e.g. a dropped connection during the
// wait) — is retried until the poll deadline.
if isTerminalSessionAPIError(err) {
reportSession(onSession, "")
return PartnerCheckoutSession{}, err
}
if err := sleepContext(ctx, pollInterval); err != nil {
return PartnerCheckoutSession{}, err
}
continue
}
switch session.Status {
case SessionStatusPaid, SessionStatusExchanging:
return session, nil
case SessionStatusExchanged:
return PartnerCheckoutSession{}, fmt.Errorf("session was already exchanged; rotate the key from the aimlapi.com dashboard")
case SessionStatusCancelled, SessionStatusExpired, SessionStatusFailed:
// The checkout cannot recover from these states. Drop the retained token
// (and the TUI's paired by-key idempotency ID) now so the very next retry
// creates a fresh payment intent.
reportSession(onSession, "")
return PartnerCheckoutSession{}, fmt.Errorf("payment %s; re-run the top-up to try again", session.Status)
default:
if err := sleepContext(ctx, pollInterval); err != nil {
return PartnerCheckoutSession{}, err
}
}
}
return PartnerCheckoutSession{}, fmt.Errorf("timed out waiting for payment; re-run once the payment clears")
}
func isTerminalSessionAPIError(err error) bool {
var apiErr APIError
return errors.As(err, &apiErr) && apiErr.Status >= http.StatusBadRequest && apiErr.Status < http.StatusInternalServerError
}
// pollUntilExchangeSettled follows an exchange claim that was already in flight
// when a retry resumed the session. Exchange is single-flight and the raw key is
// returned only to the winning request, so issuing Exchange again is never safe.
// Once the claim completes, direct the user to the dashboard recovery path.
func pollUntilExchangeSettled(ctx context.Context, client *Client, sessionToken string, onSession func(string)) error {
deadline := time.Now().Add(pollTimeout)
for time.Now().Before(deadline) {
session, err := client.GetSession(ctx, sessionToken)
if err != nil {
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return err
}
if isTerminalSessionAPIError(err) {
reportSession(onSession, "")
return err
}
if err := sleepContext(ctx, pollInterval); err != nil {
return err
}
continue
}
switch session.Status {
case SessionStatusExchanged:
return fmt.Errorf("session was already exchanged; rotate the key from the aimlapi.com dashboard")
case SessionStatusExchanging:
if err := sleepContext(ctx, pollInterval); err != nil {
return err
}
case SessionStatusCancelled, SessionStatusExpired, SessionStatusFailed:
reportSession(onSession, "")
return fmt.Errorf("key provisioning %s; rotate the key from the aimlapi.com dashboard", session.Status)
default:
return fmt.Errorf("key provisioning returned to %s; re-run the top-up", session.Status)
}
}
return fmt.Errorf("timed out waiting for key provisioning; retry to check the same session")
}
func sleepContext(ctx context.Context, delay time.Duration) error {
timer := time.NewTimer(delay)
defer timer.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
return nil
}
}
func status(callback func(Status, string), value Status, detail string) {
if callback != nil {
callback(value, detail)
}
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if value = strings.TrimSpace(value); value != "" {
return value
}
}
return ""
}
+38
View File
@@ -0,0 +1,38 @@
package aimlapi
import (
"net/mail"
"strings"
)
// ValidEmail performs the lightweight validation used before starting the
// passwordless onboarding request.
func ValidEmail(value string) bool {
value = strings.TrimSpace(value)
address, err := mail.ParseAddress(value)
if err != nil || address.Address != value {
return false
}
at := strings.LastIndexByte(value, '@')
if at <= 0 || at == len(value)-1 {
return false
}
domain := value[at+1:]
if strings.HasPrefix(domain, ".") || strings.HasSuffix(domain, ".") || strings.Contains(domain, "..") {
return false
}
dot := strings.LastIndexByte(domain, '.')
if dot <= 0 || dot == len(domain)-1 {
return false
}
tld := domain[dot+1:]
if len(tld) < 2 {
return false
}
for _, char := range tld {
if char < 'A' || char > 'Z' && char < 'a' || char > 'z' {
return false
}
}
return true
}
+14 -1
View File
@@ -5,6 +5,7 @@ package background
import (
"os"
"os/exec"
"path/filepath"
"strconv"
)
@@ -14,7 +15,8 @@ import (
func ConfigureChildProcessGroup(cmd *exec.Cmd) {}
func terminateProcess(pid int) error {
if err := exec.Command("taskkill", "/T", "/F", "/PID", strconv.Itoa(pid)).Run(); err == nil {
taskkill := taskkillPath()
if err := exec.Command(taskkill, "/T", "/F", "/PID", strconv.Itoa(pid)).Run(); err == nil {
return nil
}
process, err := os.FindProcess(pid)
@@ -23,3 +25,14 @@ func terminateProcess(pid int) error {
}
return process.Kill()
}
func taskkillPath() string {
systemRoot := os.Getenv("SystemRoot")
if systemRoot == "" {
systemRoot = os.Getenv("windir")
}
if systemRoot == "" {
systemRoot = `C:\Windows`
}
return filepath.Join(systemRoot, "System32", "taskkill.exe")
}
+193 -26
View File
@@ -12,6 +12,7 @@ import (
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
"github.com/Gitlawb/zero/internal/agent"
@@ -19,10 +20,12 @@ import (
"github.com/Gitlawb/zero/internal/hooks"
"github.com/Gitlawb/zero/internal/localcontrol"
"github.com/Gitlawb/zero/internal/mcp"
"github.com/Gitlawb/zero/internal/modelregistry"
"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/provideroauth"
"github.com/Gitlawb/zero/internal/provideronboarding"
"github.com/Gitlawb/zero/internal/providers"
"github.com/Gitlawb/zero/internal/redaction"
@@ -39,6 +42,7 @@ import (
"github.com/Gitlawb/zero/internal/worktrees"
"github.com/Gitlawb/zero/internal/zerogit"
"github.com/Gitlawb/zero/internal/zeroruntime"
"github.com/charmbracelet/x/term"
)
var version = "dev"
@@ -48,7 +52,7 @@ type appDeps struct {
stdin io.Reader
userConfigPath func() (string, error)
resolveConfig func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error)
resolveMCPConfig func(workspaceRoot string) (config.MCPConfig, error)
resolveMCPConfig func(workspaceRoot string, excludeProject bool) (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
@@ -58,6 +62,7 @@ type appDeps struct {
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
openRouterLogin func(context.Context, provideroauth.OpenRouterOptions) (string, error)
newSessionStore func() *sessions.Store
loadPlugins func(plugins.LoadOptions) (plugins.LoadResult, error)
loadHooks func(hooks.LoadOptions) (hooks.LoadResult, error)
@@ -77,9 +82,12 @@ type appDeps struct {
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)
pushChanges func(context.Context, zerogit.PushOptions) (zerogit.PushResult, error)
createPR func(context.Context, zerogit.PROptions) (zerogit.PRResult, error)
runTUI func(context.Context, tui.Options) int
runEditor func(string) error
checkUpdate func(context.Context, update.Options) (update.Result, error)
applyUpdate func(context.Context, update.Options) (update.ApplyResult, error)
now func() time.Time
}
@@ -118,11 +126,12 @@ func defaultAppDeps() appDeps {
options.Overrides = overrides
return config.Resolve(options)
},
resolveMCPConfig: func(workspaceRoot string) (config.MCPConfig, error) {
resolveMCPConfig: func(workspaceRoot string, excludeProject bool) (config.MCPConfig, error) {
options, err := config.DefaultResolveOptions(workspaceRoot)
if err != nil {
return config.MCPConfig{}, err
}
options.ExcludeProject = excludeProject
return config.ResolveMCP(options)
},
newProvider: func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
@@ -139,6 +148,7 @@ func defaultAppDeps() appDeps {
probeProviderHealth: providerhealth.Probe,
discoverProviderModels: defaultDiscoverProviderModels,
detectLocalRuntimes: provideronboarding.DetectLocalRuntimes,
openRouterLogin: provideroauth.OpenRouterLogin,
newSessionStore: func() *sessions.Store {
return sessions.NewStore(sessions.StoreOptions{})
},
@@ -177,9 +187,12 @@ func defaultAppDeps() appDeps {
runAgentEval: defaultRunAgentEval,
inspectChanges: zerogit.Inspect,
commitChanges: zerogit.Commit,
pushChanges: zerogit.Push,
createPR: zerogit.CreatePR,
runTUI: tui.Run,
runEditor: openEditor,
checkUpdate: update.Check,
applyUpdate: update.Apply,
now: time.Now,
}
}
@@ -227,6 +240,12 @@ func runWithDeps(args []string, stdout io.Writer, stderr io.Writer, deps appDeps
defer observability.Recover(observability.DefaultCrashDir(), "cli", stderr, &exitCode)
deps = fillAppDeps(deps)
// CLI runs opt into the models.dev overlay (cached live context limits and
// pricing on top of the curated catalog). Explicitly enabled here — and only
// here — so library consumers and hermetic tests are never perturbed by a
// cache file on the machine. The refresh itself is fired in exec/TUI startup.
modelregistry.EnableModelsDevOverlay()
addDirs, args, err := splitLeadingAddDirFlags(args)
if err != nil {
return writeAppError(stderr, err.Error(), 1)
@@ -321,7 +340,17 @@ func runWithDeps(args []string, stdout io.Writer, stderr io.Writer, deps appDeps
return 0
}
}
if _, err := fmt.Fprintf(stdout, "zero %s\n", version); err != nil {
// Pipes and redirects keep the machine-readable contract exactly as it
// was: a single "zero <version>" line. Scripts, command substitutions,
// and the NPM wrapper smoke check all parse that record, so the banner
// is strictly a TTY affordance.
if !stdoutIsTerminal(stdout) {
if _, err := fmt.Fprintf(stdout, "zero %s\n", version); err != nil {
return 1
}
return 0
}
if _, err := fmt.Fprintf(stdout, "%s\n\nzero %s\n", tui.Wordmark(), version); err != nil {
return 1
}
return 0
@@ -383,10 +412,14 @@ func runWithDeps(args []string, stdout io.Writer, stderr io.Writer, deps appDeps
return runSandbox(args[1:], stdout, stderr, deps)
case "update":
return runUpdate(args[1:], stdout, stderr, deps)
case "upgrade":
return runUpgrade(args[1:], stdout, stderr, deps)
case "worktrees", "worktree":
return runWorktrees(args[1:], stdout, stderr, deps)
case "verify":
return runVerifyCommand(args[1:], stdout, stderr, deps)
case "trust":
return runTrust(args[1:], stdout, stderr, deps)
case "eval":
return runAgentEvalCommand(args[1:], stdout, stderr, deps)
case "changes", "change":
@@ -449,6 +482,9 @@ func fillAppDeps(deps appDeps) appDeps {
if deps.detectLocalRuntimes == nil {
deps.detectLocalRuntimes = defaults.detectLocalRuntimes
}
if deps.openRouterLogin == nil {
deps.openRouterLogin = defaults.openRouterLogin
}
if deps.newSessionStore == nil {
deps.newSessionStore = defaults.newSessionStore
}
@@ -506,6 +542,12 @@ func fillAppDeps(deps appDeps) appDeps {
if deps.commitChanges == nil {
deps.commitChanges = defaults.commitChanges
}
if deps.pushChanges == nil {
deps.pushChanges = defaults.pushChanges
}
if deps.createPR == nil {
deps.createPR = defaults.createPR
}
if deps.runTUI == nil {
deps.runTUI = defaults.runTUI
}
@@ -515,6 +557,9 @@ func fillAppDeps(deps appDeps) appDeps {
if deps.checkUpdate == nil {
deps.checkUpdate = defaults.checkUpdate
}
if deps.applyUpdate == nil {
deps.applyUpdate = defaults.applyUpdate
}
if deps.now == nil {
deps.now = defaults.now
}
@@ -529,6 +574,11 @@ func fillAppDeps(deps appDeps) appDeps {
deps.newProvider = func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
return baseNewProvider(applyStoredProviderKeyAt(profile, userConfigPath))
}
baseProbeProviderHealth := deps.probeProviderHealth
deps.probeProviderHealth = func(ctx context.Context, options providerhealth.Options) providerhealth.Result {
options.Profile = applyStoredProviderKeyAt(options.Profile, userConfigPath)
return baseProbeProviderHealth(ctx, options)
}
return deps
}
@@ -537,6 +587,11 @@ func runInteractiveTUI(stderr io.Writer, deps appDeps, permissionMode agent.Perm
}
func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode agent.PermissionMode, addDirs []string, theme string, forceSetup bool) int {
// Refresh the models.dev pricing/limits cache in the background when stale;
// the overlay is read at registry construction from the cache file, so this
// benefits the next run and never blocks or fails this one.
go func() { _ = modelregistry.RefreshModelsDevCache(context.Background()) }()
workspaceRoot, err := deps.getwd()
if err != nil {
return writeAppError(stderr, "failed to resolve workspace: "+err.Error(), 1)
@@ -557,8 +612,19 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
if !errors.Is(err, config.ErrNoActiveProvider) && !errors.Is(err, config.ErrProviderRequiresModel) {
return writeAppError(stderr, err.Error(), 1)
}
resolved = config.ResolvedConfig{}
forceSetup = true
// ErrNoActiveProvider can mean "nothing configured yet" (needs onboarding)
// or "providers ARE configured, just none marked active" (e.g. config.json's
// activeProvider is blank/stale). In the second case resolved.Providers still
// carries the already-normalized list — prefer falling back to one of those
// over wiping everything and forcing the user to re-enter credentials they
// already saved.
if usable, ok := firstUsableProvider(resolved.Providers); errors.Is(err, config.ErrNoActiveProvider) && ok {
resolved.Provider = usable
resolved.ActiveProvider = usable.Name
} else {
resolved = config.ResolvedConfig{}
forceSetup = true
}
}
userConfigPath, err := deps.userConfigPath()
if err != nil {
@@ -617,7 +683,17 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
return writeAppError(stderr, "failed to initialize specialist tools: "+err.Error(), 1)
}
defer closeSpecialistRuntime(stderr, specialistRuntime)
mcpConfig, err := deps.resolveMCPConfig(workspaceRoot)
// The TUI has no --worktree reassignment, so trustRoot == workspaceRoot here.
// Gate the project MCP layer behind the workspace-trust check (fail-closed): an
// untrusted workspace must not spawn its ./.zero/config.json stdio MCP servers.
// Keep the store-read error so the notice below can distinguish a fail-closed
// store error from a clean untrusted verdict.
mcpExcludeProject, mcpTrustErrored := resolveTrust(workspaceRoot)
mcpSkip := trustSkip{
excludedProjectConfig: mcpExcludeProject && projectMCPConfigExists(workspaceRoot),
trustCheckErrored: mcpTrustErrored,
}
mcpConfig, err := deps.resolveMCPConfig(workspaceRoot, mcpExcludeProject)
if err != nil {
return writeAppError(stderr, err.Error(), 1)
}
@@ -645,15 +721,24 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
defer closeMCPRuntime(stderr, mcpRuntime)
// A server that could not be reached or validated is skipped, not fatal (one
// bad MCP server must not abort startup) — surface each so a missing tool set is
// explained rather than silently absent.
// explained rather than silently absent. A built-in default the user never
// configured (e.g. keyless Firecrawl with no credentials) is the exception: it
// was never asked for, so its failure is not worth a startup warning — only
// servers the user actually configured warn on failure (issue #552).
for _, skipped := range mcpRuntime.Skipped() {
if skipped.UnconfiguredDefault {
continue
}
fmt.Fprintf(stderr, "warning: MCP server %s unavailable, skipped: %s\n", skipped.Name, redaction.ErrorMessage(skipped.Err, redaction.Options{}))
}
// Make local plugins live: register their declared tools into the registry and
// collect their hooks + skill roots for the dispatcher and skill tool below.
// Done after specialist + MCP registration so plugin tools are part of the
// deferral count, and it fails OPEN — a malformed plugin is warned and skipped.
pluginActivation := activatePlugins(workspaceRoot, registry, deps, stderr)
// The interactive TUI is not worktree-reassigned, so the trust root is the
// launch directory itself.
trustRoot := workspaceRoot
pluginActivation := activatePlugins(workspaceRoot, registry, deps, stderr, trustRoot)
// Ask (not Auto) is the interactive default: in Auto, ToolAdvertised exposes
// only PermissionAllow tools, so prompt-gated tools (write_file/edit_file/bash/
// apply_patch) would never be offered to the model — the TUI could neither edit
@@ -681,15 +766,34 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
}
sandboxBackend := deps.selectSandboxBackend(sandbox.BackendOptions{})
sandboxEngine := sandbox.NewEngine(sandbox.EngineOptions{
WorkspaceRoot: workspaceRoot,
Policy: applyConfiguredSandboxPolicy(sandbox.DefaultPolicy(), resolved.Sandbox),
Store: sandboxStore,
Backend: sandboxBackend,
Scope: scope,
WorkspaceRoot: workspaceRoot,
Policy: applyConfiguredSandboxPolicy(sandbox.DefaultPolicy(), resolved.Sandbox),
Store: sandboxStore,
Backend: sandboxBackend,
Scope: scope,
SensitiveEnvKeys: providerSensitiveEnvKeys(resolved),
})
lastKnownMCPConfig := mcpConfig
fileTracker := tools.NewFileTracker()
var scratchBaseline scratchFileBaseline
sttServerManager := newDictationServerManager(resolved.STT)
// Keep STT downloads in the SAME config tree the rest of the TUI uses. Deriving
// from userConfigPath (rather than config.UserConfigDir()) matters when the config
// root is overridden — e.g. in tests or a custom ZERO config dir — so the two
// don't diverge. userConfigPath points at .../zero/config.json, so its dir is the
// zero config dir.
sttDownloadRoot := ""
if userConfigPath != "" {
sttDownloadRoot = filepath.Join(filepath.Dir(userConfigPath), "stt")
}
// Build the hooks dispatcher out of the AgentOptions literal so its trust skip
// report can be combined with the plugin activation's, and emit at most one
// notice when project hooks/plugins were dropped for an untrusted workspace.
hookDispatcher, hookSkip := newHookDispatcherWithExtra(workspaceRoot, pluginActivation.hooks, trustRoot)
emitTrustNotice(stderr, hookSkip, pluginActivation.trustSkip, mcpSkip)
return deps.runTUI(context.Background(), tui.Options{
Cwd: workspaceRoot,
Version: version,
Theme: theme,
SavedTheme: resolved.Preferences.Theme,
UserConfigPath: userConfigPath,
@@ -700,17 +804,24 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
ProviderProfile: resolved.Provider,
SavedProviders: usableSavedProviders(resolved.Providers),
FavoriteModels: resolved.Preferences.FavoriteModels,
RecentModels: resolved.Preferences.RecentModels,
RecapsEnabled: resolved.Preferences.RecapsEnabled(),
Provider: provider,
NewProvider: deps.newProvider,
ProbeProviderHealth: deps.probeProviderHealth,
UserAgent: userAgent(),
Registry: registry,
SessionStore: deps.newSessionStore(),
SandboxStore: sandboxStore,
MCPConfig: mcpConfig,
MCPPermissionStore: mcpPermissionStore,
MCPTokenStore: mcpTokenStore,
PrepareRunCompletionWarning: func() {
scratchBaseline = scratchFileSnapshot(workspaceRoot)
},
RunCompletionWarning: func() string {
return scratchFileWarning(workspaceRoot, scratchBaseline)
},
Registry: registry,
SessionStore: deps.newSessionStore(),
SandboxStore: sandboxStore,
MCPConfig: mcpConfig,
MCPPermissionStore: mcpPermissionStore,
MCPTokenStore: mcpTokenStore,
MCPCommand: func(ctx context.Context, args []string) tui.MCPCommandResult {
if ctx == nil {
ctx = context.Background()
@@ -718,7 +829,7 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
var stdout, stderr bytes.Buffer
exitCode := runMCPWithContext(ctx, args, &stdout, &stderr, deps)
nextConfig := lastKnownMCPConfig
if refreshed, err := deps.resolveMCPConfig(workspaceRoot); err == nil {
if refreshed, err := deps.resolveMCPConfig(workspaceRoot, mcpExcludeProject); err == nil {
lastKnownMCPConfig = refreshed
nextConfig = refreshed
}
@@ -736,13 +847,29 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
PermissionMode: permissionMode,
Autonomy: "low",
Sandbox: sandboxEngine,
FileTracker: tools.NewFileTracker(),
Hooks: newHookDispatcherWithExtra(workspaceRoot, pluginActivation.hooks),
FileTracker: fileTracker,
Hooks: hookDispatcher,
DeferThreshold: resolved.Tools.DeferThreshold,
Specialists: specialistRuntime.specialists,
Skills: pluginActivation.skillInfos(deps.skillsDir()),
},
PermissionMode: permissionMode,
Notify: resolved.Notify,
// LoadSkills backs /skills and direct /<skill-name> invocation in the TUI.
// It resolves against the same merged set (default dir + plugin skill
// roots) as the skill tool and the system-prompt list, re-read per use so
// newly installed skills work without a restart.
LoadSkills: cachedSkillsLoader(func() []skills.Skill {
merged, _ := plugins.MergedSkillsLoaded(deps.skillsDir(), pluginActivation.skillRoots)
return merged
}),
PermissionMode: permissionMode,
Notify: resolved.Notify,
KeyBindings: resolved.KeyBindings,
STT: resolved.STT,
BuildDictationTranscriber: newDictationTranscriberFactory(resolved, userConfigPath, sttServerManager),
ShutdownDictationServer: sttServerManager.Shutdown,
STTDownloadRoot: sttDownloadRoot,
STTKeyStatus: newSTTKeyStatus(resolved, userConfigPath),
SaveSTTKey: newSaveSTTKey(userConfigPath),
Setup: tui.SetupOptions{
Visible: setupVisible,
Required: needsSetup,
@@ -1000,6 +1127,17 @@ func closeSpecialistRuntime(stderr io.Writer, runtime *agentToolRuntime) {
}
}
// stdoutIsTerminal reports whether w is an interactive terminal. Tests pass
// bytes.Buffer writers and pipes/redirects fail term.IsTerminal, so both take
// the machine-readable path.
func stdoutIsTerminal(w io.Writer) bool {
f, ok := w.(*os.File)
if !ok {
return false
}
return term.IsTerminal(f.Fd())
}
func writeAppError(stderr io.Writer, message string, exitCode int) int {
if _, err := fmt.Fprintf(stderr, "[zero] %s\n", message); err != nil {
return 1
@@ -1158,7 +1296,8 @@ func profileHasCredential(profile config.ProviderProfile) bool {
}
// baseURLIsLoopback reports whether a provider base_url points at a loopback host
// (a keyless local provider like Ollama/LM Studio), which needs no API key.
// or a private-network host (192.168.x.y / 10.x.y.z / 172.16.x.y) — a local
// provider like Ollama/LM Studio that needs no API key.
func baseURLIsLoopback(baseURL string) bool {
u := strings.TrimSpace(baseURL)
if u == "" {
@@ -1173,7 +1312,7 @@ func baseURLIsLoopback(baseURL string) bool {
return true
}
if addr, err := netip.ParseAddr(host); err == nil {
return addr.IsLoopback()
return addr.IsLoopback() || addr.IsPrivate()
}
return false
}
@@ -1202,6 +1341,9 @@ Flags:
--spec-reasoning-effort <effort>
Override draft reasoning effort when --use-spec is set
--max-turns <number> Override the maximum agent loop turns
--exec-profile <name> Apply an execution profile (balanced, fast, thorough): loop
posture only (turn budget, effort, self-correction, escalation);
composes with --mode (which picks the model) and explicit flags win
--auto <low|medium|high> Set exec autonomy; high enables unsafe tools
--enabled-tools <tools> Only expose these comma or space separated tools
--disabled-tools <tools> Hide these comma or space separated tools
@@ -1231,6 +1373,31 @@ Flags:
--notify <off|bell|notify|both>
Override notification mode for this run
--no-notify Disable notifications for this run
--no-completion-gate Accept a no-tool-call reply as final without the INCOMPLETE
downgrade (for conversational callers with an operator present)
`)
return err
}
// cachedSkillsLoader memoizes a skills loader for a short interval. The TUI
// calls the loader from autocomplete on every "/x" keystroke; without a cache
// each keystroke would re-read every SKILL.md body. Two seconds is fresh enough
// that a newly installed skill still shows up "immediately" while typing.
func cachedSkillsLoader(load func() []skills.Skill) func() []skills.Skill {
var (
mu sync.Mutex
at time.Time
cached []skills.Skill
)
const ttl = 2 * time.Second
return func() []skills.Skill {
mu.Lock()
defer mu.Unlock()
if cached != nil && time.Since(at) < ttl {
return cached
}
cached = load()
at = time.Now()
return cached
}
}
+368 -4
View File
@@ -20,6 +20,7 @@ import (
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/tui"
"github.com/Gitlawb/zero/internal/update"
"github.com/Gitlawb/zero/internal/workspacetrust"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
@@ -40,6 +41,8 @@ func TestRunPrintsVersion(t *testing.T) {
if exitCode != 0 {
t.Fatalf("expected exit code 0, got %d", exitCode)
}
// Non-terminal stdout keeps the machine-readable contract: exactly one
// "zero <version>" record, no wordmark banner, no ANSI.
if got := stdout.String(); got != "zero dev\n" {
t.Fatalf("expected version output, got %q", got)
}
@@ -48,6 +51,33 @@ func TestRunPrintsVersion(t *testing.T) {
}
}
// TestRunVersionRedirectedFile covers the `zero --version > file` path: stdout
// is a real *os.File but not a terminal, so the single-line contract must hold.
func TestRunVersionRedirectedFile(t *testing.T) {
stdout, err := os.CreateTemp(t.TempDir(), "version-stdout")
if err != nil {
t.Fatalf("create temp stdout: %v", err)
}
defer stdout.Close()
var stderr bytes.Buffer
exitCode := Run([]string{"--version"}, stdout, &stderr)
if exitCode != 0 {
t.Fatalf("expected exit code 0, got %d", exitCode)
}
content, err := os.ReadFile(stdout.Name())
if err != nil {
t.Fatalf("read temp stdout: %v", err)
}
if got := string(content); got != "zero dev\n" {
t.Fatalf("expected version output, got %q", got)
}
if stderr.Len() != 0 {
t.Fatalf("expected empty stderr, got %q", stderr.String())
}
}
func TestRunPrintsHelp(t *testing.T) {
for _, args := range [][]string{
{"--help"},
@@ -158,6 +188,86 @@ func TestRunNoArgsLaunchesSetupTUIWithNilProviderWhenNoProviderConfigured(t *tes
assertAgentOptions(t, launchedOptions, 12, agent.PermissionModeAsk)
}
type fakeMCPRuntimeWithSkips struct {
skipped []mcp.SkippedServer
}
func (r fakeMCPRuntimeWithSkips) Close() error { return nil }
func (r fakeMCPRuntimeWithSkips) Skipped() []mcp.SkippedServer { return r.skipped }
func TestTUIStartupSuppressesWarningForUnconfiguredDefaultServer(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{MaxTurns: 8}, nil
},
resolveMCPConfig: func(workspaceRoot string, excludeProject bool) (config.MCPConfig, error) {
return config.MCPConfig{Servers: map[string]config.MCPServerConfig{
"firecrawl": config.DefaultMCPServers()["firecrawl"],
}}, nil
},
registerMCPTools: func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) {
return fakeMCPRuntimeWithSkips{skipped: []mcp.SkippedServer{
{Name: "firecrawl", Err: errors.New("returned HTTP 401"), UnconfiguredDefault: true},
}}, nil
},
runTUI: func(ctx context.Context, options tui.Options) int {
return 0
},
})
if exitCode != 0 {
t.Fatalf("exitCode = %d stderr=%s", exitCode, stderr.String())
}
if strings.Contains(stderr.String(), "firecrawl") {
t.Fatalf("stderr = %q, want no warning for an unconfigured default server", stderr.String())
}
}
func TestTUIStartupWarnsForUserConfiguredServerSkip(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{MaxTurns: 8}, nil
},
resolveMCPConfig: func(workspaceRoot string, excludeProject bool) (config.MCPConfig, error) {
return config.MCPConfig{Servers: map[string]config.MCPServerConfig{
"custom": {Type: "stdio", Command: "custom-mcp"},
}}, nil
},
registerMCPTools: func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) {
return fakeMCPRuntimeWithSkips{skipped: []mcp.SkippedServer{
{Name: "custom", Err: errors.New("connect failed"), UnconfiguredDefault: false},
}}, nil
},
runTUI: func(ctx context.Context, options tui.Options) int {
return 0
},
})
if exitCode != 0 {
t.Fatalf("exitCode = %d stderr=%s", exitCode, stderr.String())
}
if !strings.Contains(stderr.String(), "warning: MCP server custom unavailable") {
t.Fatalf("stderr = %q, want a warning for a user-configured server that failed", stderr.String())
}
}
func TestRunNoArgsEntersSetupWhenResolveReportsNoActiveProvider(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
@@ -205,6 +315,67 @@ func TestRunNoArgsEntersSetupWhenResolveReportsNoActiveProvider(t *testing.T) {
}
}
func TestRunNoArgsFallsBackToUsableProviderWhenNoneMarkedActive(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
cwd := t.TempDir()
setCLIUserConfigRoot(t)
userConfigPath := filepath.Join(t.TempDir(), "zero", "config.json")
var launchedOptions tui.Options
launched := false
var providerProfile config.ProviderProfile
fake := &cliFakeProvider{}
usable := config.ProviderProfile{
Name: "work",
ProviderKind: config.ProviderKindOpenAI,
BaseURL: config.OpenAIBaseURL,
APIKey: "sk-test",
Model: "gpt-test",
}
exitCode := runWithDeps([]string{}, &stdout, &stderr, appDeps{
getwd: func() (string, error) {
return cwd, nil
},
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
// config.json has providers configured but none marked active (e.g. a
// blank/stale activeProvider field) — Resolve returns the successfully
// normalized providers list alongside ErrNoActiveProvider.
return config.ResolvedConfig{Providers: []config.ProviderProfile{usable}},
fmt.Errorf("%w: active provider %q not found", config.ErrNoActiveProvider, "")
},
newProvider: func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
providerProfile = profile
return fake, nil
},
userConfigPath: func() (string, error) {
return userConfigPath, nil
},
registerMCPTools: func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) {
return noopMCPRuntime{}, nil
},
runTUI: func(ctx context.Context, options tui.Options) int {
launched = true
launchedOptions = options
return 0
},
})
if exitCode != 0 {
t.Fatalf("exit code = %d, want 0, stderr=%q", exitCode, stderr.String())
}
if !launched {
t.Fatal("TUI was not launched")
}
if launchedOptions.Setup.Visible || launchedOptions.Setup.Required {
t.Fatalf("Setup = %#v, want no setup wizard — a usable saved provider exists", launchedOptions.Setup)
}
if providerProfile.Name != "work" {
t.Fatalf("provider used = %q, want fallback to the usable saved provider %q", providerProfile.Name, "work")
}
}
func TestRunNoArgsFailsWhenResolveErrorIsNotProviderRelated(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
@@ -263,7 +434,7 @@ func TestRunNoArgsLaunchesTUIWithMCPState(t *testing.T) {
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
return config.ResolvedConfig{MaxTurns: 8}, nil
},
resolveMCPConfig: func(workspaceRoot string) (config.MCPConfig, error) {
resolveMCPConfig: func(workspaceRoot string, _ bool) (config.MCPConfig, error) {
if workspaceRoot != cwd {
t.Fatalf("workspaceRoot = %q, want %q", workspaceRoot, cwd)
}
@@ -338,7 +509,7 @@ func TestTUIMCPCommandUsesLastGoodConfigOnRefreshError(t *testing.T) {
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
return config.ResolvedConfig{MaxTurns: 8}, nil
},
resolveMCPConfig: func(workspaceRoot string) (config.MCPConfig, error) {
resolveMCPConfig: func(workspaceRoot string, _ bool) (config.MCPConfig, error) {
resolveCalls++
switch resolveCalls {
case 1:
@@ -405,7 +576,7 @@ func TestRunNoArgsClosesPartialMCPRuntimeWhenRegistrationFails(t *testing.T) {
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
return config.ResolvedConfig{MaxTurns: 8}, nil
},
resolveMCPConfig: func(workspaceRoot string) (config.MCPConfig, error) {
resolveMCPConfig: func(workspaceRoot string, _ bool) (config.MCPConfig, error) {
return config.MCPConfig{Servers: map[string]config.MCPServerConfig{
"docs": {Type: "stdio", Command: "docs-mcp"},
}}, nil
@@ -460,7 +631,7 @@ func TestRunNoArgsSoftFailsMCPTokenStoreInit(t *testing.T) {
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
return config.ResolvedConfig{MaxTurns: 8}, nil
},
resolveMCPConfig: func(workspaceRoot string) (config.MCPConfig, error) {
resolveMCPConfig: func(workspaceRoot string, _ bool) (config.MCPConfig, error) {
return config.MCPConfig{}, nil
},
newMCPStore: func() (*mcp.PermissionStore, error) {
@@ -599,6 +770,52 @@ func TestRunNoArgsLaunchesTUIInAskPermissionMode(t *testing.T) {
}
}
// TestRunInteractiveSurfacesMCPTrustNotice executes the interactive (TUI) path's MCP
// trust notice (app.go), the third notice site alongside exec and spec-draft. An
// untrusted repo whose only project config is MCP must print the notice before runTUI;
// trusting it silences it. runTUI is stubbed so nothing renders; resolveMCPConfig
// returns no servers so nothing spawns -- the notice depends only on the trust verdict
// and the real ./.zero/config.json.
func TestRunInteractiveSurfacesMCPTrustNotice(t *testing.T) {
setTrustConfigRoot(t)
repo := t.TempDir()
if err := os.MkdirAll(filepath.Join(repo, ".zero"), 0o700); err != nil {
t.Fatal(err)
}
body := `{"mcp":{"servers":{"proj":{"type":"stdio","command":"proj-cmd"}}}}`
if err := os.WriteFile(filepath.Join(repo, ".zero", "config.json"), []byte(body), 0o600); err != nil {
t.Fatal(err)
}
run := func() string {
var stdout, stderr bytes.Buffer
code := runWithDeps([]string{}, &stdout, &stderr, appDeps{
getwd: func() (string, error) { return repo, nil },
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
return config.ResolvedConfig{MaxTurns: 3}, nil
},
resolveMCPConfig: func(string, bool) (config.MCPConfig, error) { return config.MCPConfig{}, nil },
runTUI: func(context.Context, tui.Options) int { return 0 },
})
if code != 0 {
t.Fatalf("interactive launch exit = %d, stderr=%q", code, stderr.String())
}
return stderr.String()
}
untrusted := run()
if !strings.Contains(untrusted, "MCP servers") || !strings.Contains(untrusted, "zero trust") {
t.Fatalf("untrusted interactive launch must surface the MCP trust notice, stderr=%q", untrusted)
}
if err := workspacetrust.Trust(repo); err != nil {
t.Fatal(err)
}
if trusted := run(); strings.Contains(trusted, "ignoring project") {
t.Fatalf("trusted interactive launch must not emit a trust notice, stderr=%q", trusted)
}
}
func TestRunSkipPermissionsUnsafeLaunchesTUIInUnsafeMode(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
@@ -1267,6 +1484,153 @@ func TestRunUpdateReportsUpToDate(t *testing.T) {
}
}
func TestRunUpdateApplyTextAndJSON(t *testing.T) {
applyResult := update.ApplyResult{
Result: update.Result{
CurrentVersion: "dev",
LatestVersion: "0.2.0",
UpdateAvailable: true,
},
Applied: true,
InstallMethod: update.InstallMethodStandalone,
BinaryPath: "/usr/local/bin/zero",
Message: "updated to 0.2.0",
}
var gotOptions update.Options
deps := appDeps{
applyUpdate: func(_ context.Context, options update.Options) (update.ApplyResult, error) {
gotOptions = options
return applyResult, nil
},
checkUpdate: func(context.Context, update.Options) (update.Result, error) {
t.Fatal("checkUpdate should not run for --apply")
return update.Result{}, nil
},
}
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"update", "--apply"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
}
if !strings.Contains(stdout.String(), "updated to 0.2.0") {
t.Fatalf("unexpected apply text: %q", stdout.String())
}
if gotOptions.CurrentVersion != "dev" {
t.Fatalf("unexpected options passed to applyUpdate: %#v", gotOptions)
}
stdout.Reset()
stderr.Reset()
exitCode = runWithDeps([]string{"update", "--apply", "--json"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
}
var payload struct {
Applied bool `json:"applied"`
InstallMethod string `json:"installMethod"`
BinaryPath string `json:"binaryPath"`
}
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("apply JSON did not decode: %v\n%s", err, stdout.String())
}
if !payload.Applied || payload.InstallMethod != string(update.InstallMethodStandalone) || payload.BinaryPath != applyResult.BinaryPath {
t.Fatalf("unexpected apply JSON: %#v", payload)
}
}
func TestRunUpgradeDefaultsToApply(t *testing.T) {
var applyCalled bool
deps := appDeps{
applyUpdate: func(context.Context, update.Options) (update.ApplyResult, error) {
applyCalled = true
return update.ApplyResult{Message: "already up to date"}, nil
},
checkUpdate: func(context.Context, update.Options) (update.Result, error) {
t.Fatal("checkUpdate should not run for `zero upgrade`")
return update.Result{}, nil
},
}
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"upgrade"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
}
if !applyCalled {
t.Fatal("expected `zero upgrade` to call applyUpdate")
}
}
func TestRunUpdateRejectsCheckAndApplyTogether(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"update", "--check", "--apply"}, &stdout, &stderr, appDeps{
checkUpdate: func(context.Context, update.Options) (update.Result, error) {
t.Fatal("checkUpdate should not run")
return update.Result{}, nil
},
applyUpdate: func(context.Context, update.Options) (update.ApplyResult, error) {
t.Fatal("applyUpdate should not run")
return update.ApplyResult{}, nil
},
})
if exitCode != exitUsage {
t.Fatalf("expected usage exit code, got %d", exitCode)
}
if got := stderr.String(); !strings.Contains(got, "only one of --check or --apply") {
t.Fatalf("expected --check/--apply usage error, got %q", got)
}
}
func TestRunUpdateRejectsTargetWithApply(t *testing.T) {
for _, args := range [][]string{
{"update", "--apply", "--target", "linux-arm64"},
{"upgrade", "--target", "linux-arm64"},
} {
t.Run(strings.Join(args, " "), func(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps(args, &stdout, &stderr, appDeps{
applyUpdate: func(context.Context, update.Options) (update.ApplyResult, error) {
t.Fatal("applyUpdate should not run when --target is combined with --apply")
return update.ApplyResult{}, nil
},
})
if exitCode != exitUsage {
t.Fatalf("expected usage exit code, got %d", exitCode)
}
if got := stderr.String(); !strings.Contains(got, "--target cannot be combined with --apply") {
t.Fatalf("expected --target/--apply usage error, got %q", got)
}
})
}
}
func TestRunUpdateReportsApplyError(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"update", "--apply"}, &stdout, &stderr, appDeps{
applyUpdate: func(context.Context, update.Options) (update.ApplyResult, error) {
return update.ApplyResult{}, errors.New("download failed")
},
})
if exitCode == exitSuccess {
t.Fatalf("expected non-success exit code, got %d", exitCode)
}
if got := stderr.String(); !strings.Contains(got, "download failed") {
t.Fatalf("expected apply error, got %q", got)
}
}
func assertHelpOutput(t *testing.T, args []string) {
t.Helper()
+119 -12
View File
@@ -6,15 +6,52 @@ import (
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/oauth"
"github.com/Gitlawb/zero/internal/providercatalog"
"github.com/Gitlawb/zero/internal/provideroauth"
"github.com/Gitlawb/zero/internal/redaction"
)
// ensureLoginProviderProfile makes a freshly stored OAuth login visible as a
// provider: without a profile in config.json the login shows up nowhere — not in
// `zero providers list`, not in the TUI picker — and `zero providers use <name>`
// fails "not found", so the user's working active provider looks broken while
// the new login looks lost. Returns user-facing guidance lines ("" when the
// login has no catalog entry to scaffold from). Best-effort by design: the
// token is already stored, so a profile-write failure degrades to a warning
// line instead of failing a login that succeeded.
func ensureLoginProviderProfile(deps appDeps, provider string) string {
if _, ok := providercatalog.Get(provider); !ok {
// Custom OAuth server without a catalog entry — no endpoint/model defaults
// to scaffold a profile from; the user wires their own profile.
return ""
}
configPath, err := deps.userConfigPath()
if err != nil {
return "warning: login saved, but no provider profile was written: " + err.Error()
}
ensured, err := config.EnsureCatalogProvider(configPath, provider)
if err != nil {
return "warning: login saved, but no provider profile was written: " + err.Error()
}
active := strings.EqualFold(strings.TrimSpace(ensured.Active), strings.TrimSpace(ensured.Name))
switch {
case ensured.Created && active:
return fmt.Sprintf("Added provider %q to your config and set it active.", ensured.Name)
case ensured.Created:
return fmt.Sprintf("Added provider %q to your config; the active provider is still %q.\nSwitch with: zero providers use %s", ensured.Name, ensured.Active, ensured.Name)
case active:
return fmt.Sprintf("Provider %q is configured and active.", ensured.Name)
default:
return fmt.Sprintf("Provider %q is already configured.\nSwitch with: zero providers use %s", ensured.Name, ensured.Name)
}
}
// runAuth dispatches `zero auth <command>` for provider OAuth login. It is
// additive and independent of `zero mcp oauth` (MCP server auth), which is
// unchanged.
@@ -46,11 +83,11 @@ func runAuth(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
}
}
// runAuthOpenRouter runs OpenRouter's browser PKCE login and prints the freshly
// minted API key. Unlike `auth login` (which stores an OAuth bearer token),
// OpenRouter's flow mints a normal API key; the setup wizard saves it to a
// provider profile, while this command prints it for manual configuration.
func runAuthOpenRouter(args []string, stdout io.Writer, stderr io.Writer, _ appDeps) int {
// runAuthOpenRouter runs OpenRouter's browser PKCE login and saves the freshly
// minted API key into the user's provider credential store. Unlike `auth login`
// (which stores an OAuth bearer token), OpenRouter's flow mints a normal API key;
// persist it immediately so the provider is usable after the command completes.
func runAuthOpenRouter(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
for _, a := range args {
if a == "-h" || a == "--help" || a == "help" {
_ = writeAuthHelp(stdout)
@@ -62,7 +99,7 @@ func runAuthOpenRouter(args []string, stdout io.Writer, stderr io.Writer, _ appD
if len(args) > 0 {
return writeExecUsageError(stderr, fmt.Sprintf("zero auth openrouter takes no arguments (got %q)", args[0]))
}
key, err := provideroauth.OpenRouterLogin(context.Background(), provideroauth.OpenRouterOptions{
key, err := deps.openRouterLogin(context.Background(), provideroauth.OpenRouterOptions{
Out: stdout,
HTTPClient: &http.Client{Timeout: 30 * time.Second},
// ZERO_OPENROUTER_BASE_URL overrides the endpoint (self-hosted gateway or tests).
@@ -71,12 +108,59 @@ func runAuthOpenRouter(args []string, stdout io.Writer, stderr io.Writer, _ appD
if err != nil {
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
}
if _, err := fmt.Fprintf(stdout, "\nOpenRouter login complete — new API key minted.\nUse it with zero, e.g.:\n export OPENROUTER_API_KEY=%s\n(or add it to a provider profile with catalogID \"openrouter\").\n", key); err != nil {
key = strings.TrimSpace(key)
line, err := saveOpenRouterProviderKey(deps, key)
if err != nil {
if _, writeErr := fmt.Fprintf(stdout, "\nOpenRouter login complete — new API key minted, but Zero could not save it: %s\nUse it manually, e.g.:\n export OPENROUTER_API_KEY=%s\n", err, key); writeErr != nil {
return exitCrash
}
return exitSuccess
}
if _, err := fmt.Fprintf(stdout, "\nOpenRouter login complete — new API key saved.\n%s\n", line); err != nil {
return exitCrash
}
return exitSuccess
}
func saveOpenRouterProviderKey(deps appDeps, key string) (string, error) {
key = strings.TrimSpace(key)
if key == "" {
return "", fmt.Errorf("OpenRouter returned an empty API key")
}
configPath, err := deps.userConfigPath()
if err != nil {
return "", err
}
ensured, err := config.EnsureCatalogProvider(configPath, "openrouter")
if err != nil {
return "", err
}
store, err := config.ProviderKeyStoreAt(filepath.Dir(configPath))
if err != nil {
return "", err
}
if err := store.Set(ensured.Name, key); err != nil {
return "", err
}
if err := config.MarkProviderAPIKeyStored(configPath, ensured.Name); err != nil {
// Best-effort rollback: don't leave the key orphaned in the credential
// store while config.json still says it isn't there.
_, _ = store.Delete(ensured.Name)
return "", err
}
active := strings.EqualFold(strings.TrimSpace(ensured.Active), strings.TrimSpace(ensured.Name))
switch {
case ensured.Created && active:
return fmt.Sprintf("Added provider %q to your config and set it active.", ensured.Name), nil
case ensured.Created:
return fmt.Sprintf("Added provider %q to your config; the active provider is still %q.\nSwitch with: zero providers use %s", ensured.Name, ensured.Active, ensured.Name), nil
case active:
return fmt.Sprintf("Provider %q is configured, active, and ready to use.", ensured.Name), nil
default:
return fmt.Sprintf("Provider %q is configured with the new key.\nSwitch with: zero providers use %s", ensured.Name, ensured.Name), nil
}
}
// runAuthChatGPT runs the ChatGPT (Codex) browser PKCE login, persists the
// bearer + chatgpt-account-id claim under the "chatgpt" provider key, and
// prints a status block. The bearer routes to chatgpt.com/backend-api/codex
@@ -138,8 +222,10 @@ func runAuthChatGPT(args []string, stdout io.Writer, stderr io.Writer, deps appD
if _, err := fmt.Fprint(stdout, statuses); err != nil {
return exitCrash
}
if _, err := fmt.Fprint(stdout, "\nUse it with zero, e.g.:\n zero --provider chatgpt --model gpt-5.5\n"); err != nil {
return exitCrash
if line := ensureLoginProviderProfile(deps, "chatgpt"); line != "" {
if _, err := fmt.Fprintf(stdout, "\n%s\n", line); err != nil {
return exitCrash
}
}
return exitSuccess
}
@@ -301,12 +387,27 @@ func runAuthLogin(args []string, stdout io.Writer, stderr io.Writer, deps appDep
if len(parsed.positional) != 1 {
return writeExecUsageError(stderr, "usage: zero auth login <provider> [--device] [--scope <scope>]")
}
provider := parsed.positional[0]
// ChatGPT (Codex) requires a fixed redirect_uri (http://localhost:1455/
// auth/callback) and mandatory authorize params (id_token_add_organizations,
// codex_cli_simplified_flow, originator) that the generic loopback flow
// cannot supply. Route it to the dedicated ChatGPT login so
// `zero auth login chatgpt` behaves identically to `zero auth chatgpt`.
if strings.EqualFold(provider, "chatgpt") {
if parsed.device {
return writeExecUsageError(stderr, "ChatGPT login does not support --device (it is loopback-only)")
}
if len(parsed.scopes) > 0 {
return writeExecUsageError(stderr, "ChatGPT login does not support --scope (the required scopes are fixed by the Codex client registration)")
}
return runAuthChatGPT(nil, stdout, stderr, deps)
}
manager, err := newAuthManager(deps, stdout)
if err != nil {
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
}
status, err := manager.Login(context.Background(), oauth.LoginOptions{
Provider: parsed.positional[0],
Provider: provider,
Device: parsed.device,
ExtraScopes: parsed.scopes,
})
@@ -316,6 +417,11 @@ func runAuthLogin(args []string, stdout io.Writer, stderr io.Writer, deps appDep
if _, err := fmt.Fprintf(stdout, "Logged in to %s.\n%s\n", parsed.positional[0], oauth.FormatStatuses([]oauth.Status{status})); err != nil {
return exitCrash
}
if line := ensureLoginProviderProfile(deps, provider); line != "" {
if _, err := fmt.Fprintln(stdout, line); err != nil {
return exitCrash
}
}
return exitSuccess
}
@@ -489,8 +595,9 @@ Commands:
A provider is any OAuth 2.0 / OIDC server. "openrouter" ('zero auth openrouter')
works out of the box. "xai" ('zero auth login xai') uses a built-in preset that is
off by default — enable it with ZERO_OAUTH_ALLOW_PRESETS=1, or set the
ZERO_OAUTH_XAI_* vars yourself. Any preset field is overridable via the env vars
below. For a custom provider named <name>, set:
ZERO_OAUTH_XAI_* vars yourself. "chatgpt" ('zero auth login chatgpt' or
'zero auth chatgpt') uses a fixed-port loopback flow against the Codex backend.
Any preset field is overridable via the env vars below. For a custom provider named <name>, set:
ZERO_OAUTH_<NAME>_CLIENT_ID (required)
ZERO_OAUTH_<NAME>_CLIENT_SECRET (optional)
ZERO_OAUTH_<NAME>_AUTHORIZE_URL ZERO_OAUTH_<NAME>_TOKEN_URL
+28
View File
@@ -2,12 +2,14 @@ package cli
import (
"bytes"
"context"
"errors"
"os"
"path/filepath"
"testing"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/providerhealth"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
@@ -72,6 +74,32 @@ func TestFillAppDepsWrapsNewProviderWithStoredKey(t *testing.T) {
}
}
// fillAppDeps also wraps probeProviderHealth, the code path zero providers
// check --connectivity, zero doctor --connectivity, and the TUI doctor panel
// all go through. Passing the raw resolved profile previously sent an
// unauthenticated (keyless) health probe for apiKeyStored profiles, same
// regression class as TestFillAppDepsWrapsNewProviderWithStoredKey but for
// health checks instead of the runtime provider build.
func TestFillAppDepsWrapsProbeProviderHealthWithStoredKey(t *testing.T) {
configPath := seedStoredProviderKey(t, "echo", "sk-stored-health")
var captured config.ProviderProfile
deps := fillAppDeps(appDeps{
userConfigPath: func() (string, error) { return configPath, nil },
probeProviderHealth: func(_ context.Context, options providerhealth.Options) providerhealth.Result {
captured = options.Profile
return providerhealth.Result{Status: providerhealth.StatusPass}
},
})
deps.probeProviderHealth(context.Background(), providerhealth.Options{
Profile: config.ProviderProfile{Name: "echo", APIKeyStored: true},
})
if captured.APIKey != "sk-stored-health" {
t.Fatalf("wrapped probeProviderHealth ran with APIKey = %q, want the stored key (keyless health-check 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
+166 -1
View File
@@ -2,18 +2,26 @@ package cli
import (
"bytes"
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/oauth"
"github.com/Gitlawb/zero/internal/provideroauth"
)
// withAuthStore points the provider OAuth store at a temp file for the test.
// withAuthStore points the provider OAuth store at a temp file for the test,
// pinning the file backend so an inherited ZERO_OAUTH_STORAGE=keyring can't
// ignore the temp path and hit the OS keychain.
func withAuthStore(t *testing.T) string {
t.Helper()
path := filepath.Join(t.TempDir(), "oauth-tokens.json")
t.Setenv("ZERO_OAUTH_TOKENS_PATH", path)
t.Setenv("ZERO_OAUTH_STORAGE", "file")
return path
}
@@ -145,6 +153,44 @@ func TestRunAuthOpenRouterRejectsArgs(t *testing.T) {
}
}
func TestRunAuthOpenRouterSavesMintedKey(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
var stdout, stderr bytes.Buffer
code := runWithDeps([]string{"auth", "openrouter"}, &stdout, &stderr, appDeps{
userConfigPath: func() (string, error) { return configPath, nil },
openRouterLogin: func(context.Context, provideroauth.OpenRouterOptions) (string, error) {
return "sk-openrouter-test", nil
},
})
if code != exitSuccess {
t.Fatalf("exit = %d, stderr = %q", code, stderr.String())
}
if !strings.Contains(stdout.String(), "new API key saved") {
t.Fatalf("expected saved-key confirmation, got %q", stdout.String())
}
cfg := readCLIConfigFixture(t, configPath)
if cfg.ActiveProvider != "openrouter" || len(cfg.Providers) != 1 {
t.Fatalf("config = %#v", cfg)
}
profile := cfg.Providers[0]
if profile.Name != "openrouter" || profile.CatalogID != "openrouter" || !profile.APIKeyStored || profile.APIKey != "" || profile.APIKeyEnv != "" {
t.Fatalf("provider not stored-key sanitized: %#v", profile)
}
store, err := config.ProviderKeyStoreAt(filepath.Dir(configPath))
if err != nil {
t.Fatal(err)
}
key, ok, err := store.Get("openrouter")
if err != nil {
t.Fatal(err)
}
if !ok || key != "sk-openrouter-test" {
t.Fatalf("stored key = %q, %v", key, ok)
}
}
func TestRunAuthHelp(t *testing.T) {
var stdout, stderr bytes.Buffer
if code := runWithDeps([]string{"auth", "--help"}, &stdout, &stderr, appDeps{}); code != exitSuccess {
@@ -156,3 +202,122 @@ func TestRunAuthHelp(t *testing.T) {
}
}
}
// TestRunAuthLoginChatGPTRoutesToDedicatedFlow verifies `zero auth login
// chatgpt` reaches the dedicated ChatGPT login (fixed-port loopback + mandatory
// authorize params), not the generic manager path. The generic login accepts
// --device, so a ChatGPT-specific rejection proves the routing took effect.
// See issue #430: the generic path built a random-port 127.0.0.1 redirect_uri
// without the required extra params, so OpenAI's authorize endpoint rejected it.
func TestRunAuthLoginChatGPTRoutesToDedicatedFlow(t *testing.T) {
withAuthStore(t)
var stdout, stderr bytes.Buffer
if code := runWithDeps([]string{"auth", "login", "chatgpt", "--device"}, &stdout, &stderr, appDeps{}); code == exitSuccess {
t.Fatal("auth login chatgpt --device should be rejected (ChatGPT is loopback-only)")
}
if !strings.Contains(stderr.String(), "ChatGPT login does not support --device") {
t.Fatalf("stderr = %q, want the ChatGPT-specific --device rejection", stderr.String())
}
// Case-insensitive provider name should still route.
stdout.Reset()
stderr.Reset()
if code := runWithDeps([]string{"auth", "login", "ChatGPT", "--device"}, &stdout, &stderr, appDeps{}); code == exitSuccess {
t.Fatal("auth login ChatGPT --device should be rejected")
}
if !strings.Contains(stderr.String(), "ChatGPT login does not support --device") {
t.Fatalf("stderr = %q, want the ChatGPT-specific rejection (case-insensitive)", stderr.String())
}
}
// TestRunAuthLoginChatGPTRejectsScope mirrors the --device rejection: --scope
// must not be silently dropped on the ChatGPT path. The Codex client
// registration pins a fixed scope set (incl. api.connectors.*), so custom
// scopes are rejected up front rather than plumbed through.
func TestRunAuthLoginChatGPTRejectsScope(t *testing.T) {
withAuthStore(t)
var stdout, stderr bytes.Buffer
if code := runWithDeps([]string{"auth", "login", "chatgpt", "--scope", "custom-scope"}, &stdout, &stderr, appDeps{}); code == exitSuccess {
t.Fatal("auth login chatgpt --scope should be rejected")
}
if !strings.Contains(stderr.String(), "ChatGPT login does not support --scope") {
t.Fatalf("stderr = %q, want the ChatGPT-specific --scope rejection", stderr.String())
}
}
func TestEnsureLoginProviderProfileAddsProviderWithoutStealingActive(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
seed := `{"activeProvider":"opengateway","providers":[{"name":"opengateway","provider_kind":"openai-compatible","baseURL":"https://gateway.example.com/v1","apiKeyStored":true,"model":"some-model"}]}`
if err := os.WriteFile(configPath, []byte(seed), 0o600); err != nil {
t.Fatalf("seed config: %v", err)
}
deps := appDeps{userConfigPath: func() (string, error) { return configPath, nil }}
line := ensureLoginProviderProfile(deps, "chatgpt")
if !strings.Contains(line, `Added provider "chatgpt"`) {
t.Fatalf("expected added-provider guidance, got %q", line)
}
if !strings.Contains(line, "zero providers use chatgpt") {
t.Fatalf("expected switch hint, got %q", line)
}
cfg := readCLIConfigFixture(t, configPath)
if cfg.ActiveProvider != "opengateway" {
t.Fatalf("active provider changed to %q", cfg.ActiveProvider)
}
names := make([]string, 0, len(cfg.Providers))
for _, provider := range cfg.Providers {
names = append(names, provider.Name)
}
if len(cfg.Providers) != 2 || cfg.Providers[1].CatalogID != "chatgpt" {
t.Fatalf("expected chatgpt profile appended, got %v", names)
}
// A second login must be a no-op with switch guidance, not a duplicate.
line = ensureLoginProviderProfile(deps, "chatgpt")
if !strings.Contains(line, "already configured") {
t.Fatalf("expected already-configured guidance, got %q", line)
}
cfg = readCLIConfigFixture(t, configPath)
if len(cfg.Providers) != 2 {
t.Fatalf("repeat login duplicated the profile: %d providers", len(cfg.Providers))
}
}
func TestEnsureLoginProviderProfileActivatesOnFreshConfig(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
deps := appDeps{userConfigPath: func() (string, error) { return configPath, nil }}
line := ensureLoginProviderProfile(deps, "chatgpt")
if !strings.Contains(line, "set it active") {
t.Fatalf("fresh config should adopt the login as active, got %q", line)
}
cfg := readCLIConfigFixture(t, configPath)
if cfg.ActiveProvider != "chatgpt" {
t.Fatalf("active provider = %q, want chatgpt", cfg.ActiveProvider)
}
}
func TestEnsureLoginProviderProfileSkipsNonCatalogProviders(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
deps := appDeps{userConfigPath: func() (string, error) { return configPath, nil }}
if line := ensureLoginProviderProfile(deps, "my-custom-oauth-server"); line != "" {
t.Fatalf("custom OAuth server must not scaffold a profile, got %q", line)
}
if _, err := os.Stat(configPath); err == nil {
t.Fatalf("config must not be created for a non-catalog login")
}
}
func readCLIConfigFixture(t *testing.T, path string) config.FileConfig {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read config: %v", err)
}
var cfg config.FileConfig
if err := json.Unmarshal(data, &cfg); err != nil {
t.Fatalf("decode config: %v", err)
}
return cfg
}
+6 -2
View File
@@ -125,7 +125,9 @@ func backendLifecycleSnapshot(deps appDeps) (zerocommands.BackendLifecycleSnapsh
return zerocommands.BackendLifecycleSnapshot{}, fmt.Errorf("failed to resolve workspace: %w", err)
}
cfg, err := deps.resolveMCPConfig(cwd)
// Reporting/enumeration only, never spawns a server, so it is left ungated
// (excludeProject=false) to mirror the doctor/status hooks and plugins reports.
cfg, err := deps.resolveMCPConfig(cwd, false)
if err != nil {
return zerocommands.BackendLifecycleSnapshot{}, err
}
@@ -152,7 +154,9 @@ func backendDoctorReport(deps appDeps) (zerocommands.BackendDoctorReport, error)
return zerocommands.BackendDoctorReport{}, fmt.Errorf("failed to resolve workspace: %w", err)
}
cfg, err := deps.resolveMCPConfig(cwd)
// Reporting/enumeration only, never spawns a server, so it is left ungated
// (excludeProject=false) to mirror the doctor/status hooks and plugins reports.
cfg, err := deps.resolveMCPConfig(cwd, false)
if err != nil {
return zerocommands.BackendDoctorReport{}, err
}
+4 -4
View File
@@ -26,7 +26,7 @@ func TestRunBackendsJSONUsesLifecycleSnapshotWithoutConnectingMCP(t *testing.T)
secret := "sk-proj-" + strings.Repeat("a", 24)
deps := appDeps{
getwd: func() (string, error) { return cwd, nil },
resolveMCPConfig: func(workspaceRoot string) (config.MCPConfig, error) {
resolveMCPConfig: func(workspaceRoot string, _ bool) (config.MCPConfig, error) {
if workspaceRoot != cwd {
t.Fatalf("workspaceRoot = %q, want %q", workspaceRoot, cwd)
}
@@ -123,7 +123,7 @@ func TestRunBackendsJSONUsesLifecycleSnapshotWithoutConnectingMCP(t *testing.T)
func TestRunBackendsTextAndHelp(t *testing.T) {
deps := appDeps{
getwd: func() (string, error) { return t.TempDir(), nil },
resolveMCPConfig: func(string) (config.MCPConfig, error) {
resolveMCPConfig: func(string, bool) (config.MCPConfig, error) {
return config.MCPConfig{}, nil
},
loadHooks: func(hooks.LoadOptions) (hooks.LoadResult, error) {
@@ -164,7 +164,7 @@ func TestRunBackendsDoctorJSONAndTextWithoutConnectingMCP(t *testing.T) {
secret := "sk-proj-" + strings.Repeat("b", 24)
deps := appDeps{
getwd: func() (string, error) { return cwd, nil },
resolveMCPConfig: func(workspaceRoot string) (config.MCPConfig, error) {
resolveMCPConfig: func(workspaceRoot string, _ bool) (config.MCPConfig, error) {
if workspaceRoot != cwd {
t.Fatalf("workspaceRoot = %q, want %q", workspaceRoot, cwd)
}
@@ -301,7 +301,7 @@ func TestRunBackendsDoctorDoesNotConnectOrExecuteConfiguredBackends(t *testing.T
deps := appDeps{
getwd: func() (string, error) { return cwd, nil },
resolveMCPConfig: func(string) (config.MCPConfig, error) {
resolveMCPConfig: func(string, bool) (config.MCPConfig, error) {
return config.MCPConfig{Servers: map[string]config.MCPServerConfig{
"remote": {Type: "http", URL: server.URL + "/mcp"},
}}, nil
+33 -2
View File
@@ -75,6 +75,12 @@ func runProviders(args []string, stdout io.Writer, stderr io.Writer, deps appDep
if command == "use" {
return runProvidersUse(args, stdout, stderr, deps)
}
if command == "remove" || command == "rm" {
return runProvidersRemove(args, stdout, stderr, deps)
}
if command == "rename" {
return runProvidersRename(args, stdout, stderr, deps)
}
if command == "setup" {
return runProvidersSetup(args, stdout, stderr, deps)
}
@@ -246,6 +252,20 @@ func parseCommandCenterArgs(args []string, allowModelFilters bool, allowProvider
func summarizeConfig(resolved config.ResolvedConfig) configSummary {
summary := zerocommands.ConfigSnapshotFromResolved(resolved)
// Mark keyless profiles that authenticate via a stored OAuth login (e.g.
// ChatGPT) so the list shows "oauth login" instead of "api key: not set" —
// the same candidate matching the runtime resolver uses.
logins := oauthLoggedInProviders()
if len(logins) > 0 {
for index := range summary.Providers {
for _, profile := range resolved.Providers {
if profile.Name == summary.Providers[index].Name {
summary.Providers[index].OAuthLogin = providerHasOAuthLogin(profile, logins)
break
}
}
}
}
sort.SliceStable(summary.Providers, func(i int, j int) bool {
if summary.Providers[i].Active != summary.Providers[j].Active {
return summary.Providers[i].Active
@@ -323,7 +343,7 @@ func formatProviderSummaries(command string, providers []providerSummary) string
"model: "+displayCLIValue(provider.Model, "none"),
"api model: "+displayCLIValue(provider.APIModel, "unknown"),
"base url: "+displayCLIValue(provider.BaseURL, "default"),
"api key: "+apiKeyState(provider.APIKeySet),
"api key: "+providerCredentialState(provider),
)
if provider.Message != "" {
lines = append(lines, "status: "+provider.Status+" - "+provider.Message)
@@ -340,7 +360,7 @@ func formatProviderLine(provider providerSummary) string {
if provider.Active {
marker = "*"
}
line := fmt.Sprintf("%s %s [%s] model=%s apiModel=%s api key: %s", marker, displayCLIValue(provider.Name, "none"), displayCLIValue(provider.ProviderKind, "unknown"), displayCLIValue(provider.Model, "none"), displayCLIValue(provider.APIModel, "unknown"), apiKeyState(provider.APIKeySet))
line := fmt.Sprintf("%s %s [%s] model=%s apiModel=%s api key: %s", marker, displayCLIValue(provider.Name, "none"), displayCLIValue(provider.ProviderKind, "unknown"), displayCLIValue(provider.Model, "none"), displayCLIValue(provider.APIModel, "unknown"), providerCredentialState(provider))
if provider.Message != "" {
line += " (" + provider.Status + ": " + provider.Message + ")"
}
@@ -413,6 +433,15 @@ func apiKeyState(set bool) string {
return "not set"
}
// providerCredentialState renders the credential column: a configured key wins,
// then a stored OAuth login, then "not set".
func providerCredentialState(provider providerSummary) string {
if !provider.APIKeySet && provider.OAuthLogin {
return "oauth login"
}
return apiKeyState(provider.APIKeySet)
}
func displayCLIValue(value string, fallback string) string {
if strings.TrimSpace(value) == "" {
return fallback
@@ -466,6 +495,8 @@ func writeProvidersHelp(w io.Writer) error {
zero providers add <catalog-id> [flags]
zero providers check [name] [flags]
zero providers use <name> [flags]
zero providers remove <name> [flags]
zero providers rename <old> <new> [flags]
zero providers setup <catalog-id> [flags]
zero providers detect [flags]
zero providers models [name] [flags]
+132
View File
@@ -11,6 +11,7 @@ import (
"testing"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/oauth"
"github.com/Gitlawb/zero/internal/providerhealth"
"github.com/Gitlawb/zero/internal/zerocommands"
"github.com/Gitlawb/zero/internal/zeroruntime"
@@ -347,6 +348,83 @@ func TestRunProvidersAddWritesCatalogProfile(t *testing.T) {
}
}
func TestRunProvidersAddAimlapiWritesDefaultHeaders(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
configPath := filepath.Join(t.TempDir(), "zero", "config.json")
exitCode := runWithDeps([]string{"providers", "add", "aimlapi"}, &stdout, &stderr, providerSetupDeps(configPath))
if exitCode != exitSuccess {
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
}
cfg := readFileConfig(t, configPath)
if len(cfg.Providers) != 1 {
t.Fatalf("providers = %#v, want one provider", cfg.Providers)
}
profile := cfg.Providers[0]
if profile.Name != "aimlapi" ||
profile.CatalogID != "aimlapi" ||
profile.BaseURL != "https://api.aimlapi.com/v1" ||
profile.Model != "anthropic/claude-sonnet-5" ||
profile.APIKeyEnv != "AIMLAPI_API_KEY" {
t.Fatalf("unexpected provider profile: %#v", profile)
}
if profile.CustomHeaders["X-AIMLAPI-Partner-ID"] != "part_62yQoGYDq4Yqnrj2R1iGrDNJ" ||
profile.CustomHeaders["X-AIMLAPI-Integration-Repo"] != "Gitlawb/zero" {
t.Fatalf("missing aimlapi.com default headers: %#v", profile.CustomHeaders)
}
}
func TestRunProvidersAddAimlapiMixedCaseHeaderOverride(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
configPath := filepath.Join(t.TempDir(), "zero", "config.json")
// A differently-cased override must replace the catalog header in place, not
// leave both spellings behind to race when request construction canonicalizes.
exitCode := runWithDeps([]string{
"providers", "add", "aimlapi",
"--header", "x-aimlapi-partner-id=part_override",
}, &stdout, &stderr, providerSetupDeps(configPath))
if exitCode != exitSuccess {
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
}
profile := readFileConfig(t, configPath).Providers[0]
if _, ok := profile.CustomHeaders["x-aimlapi-partner-id"]; ok {
t.Fatalf("lowercase override left a duplicate key: %#v", profile.CustomHeaders)
}
if profile.CustomHeaders["X-AIMLAPI-Partner-ID"] != "part_override" {
t.Fatalf("override did not win on the canonical key: %#v", profile.CustomHeaders)
}
}
func TestRunProvidersAddAimlapiOverrideDropsCatalogHeaders(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
configPath := filepath.Join(t.TempDir(), "zero", "config.json")
exitCode := runWithDeps([]string{
"providers", "add", "aimlapi",
"--base-url", "https://staging.example/v1",
"--header", "X-Environment=staging",
}, &stdout, &stderr, providerSetupDeps(configPath))
if exitCode != exitSuccess {
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
}
profile := readFileConfig(t, configPath).Providers[0]
if _, ok := profile.CustomHeaders["X-AIMLAPI-Partner-ID"]; ok {
t.Fatalf("partner attribution leaked to overridden endpoint: %#v", profile.CustomHeaders)
}
if _, ok := profile.CustomHeaders["X-AIMLAPI-Integration-Repo"]; ok {
t.Fatalf("integration attribution leaked to overridden endpoint: %#v", profile.CustomHeaders)
}
if profile.CustomHeaders["X-Environment"] != "staging" {
t.Fatalf("explicit user header was dropped: %#v", profile.CustomHeaders)
}
}
func TestRunProvidersAddWritesCustomHeaders(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
@@ -835,3 +913,57 @@ func (commandCenterProvider) StreamCompletion(context.Context, zeroruntime.Compl
close(ch)
return ch, nil
}
func TestProviderCredentialStateShowsOAuthLogin(t *testing.T) {
if got := providerCredentialState(providerSummary{APIKeySet: true}); got != "set" {
t.Fatalf("keyed provider = %q, want set", got)
}
if got := providerCredentialState(providerSummary{OAuthLogin: true}); got != "oauth login" {
t.Fatalf("token-login provider = %q, want oauth login", got)
}
if got := providerCredentialState(providerSummary{}); got != "not set" {
t.Fatalf("credential-less provider = %q, want not set", got)
}
}
// TestProvidersListMarksOAuthLoginProviders: a keyless profile whose credential
// is a stored OAuth login (the shape `zero auth chatgpt` now writes) must render
// as "oauth login", not as a broken "api key: not set" entry.
func TestProvidersListMarksOAuthLoginProviders(t *testing.T) {
tokensPath := filepath.Join(t.TempDir(), "oauth-tokens.json")
t.Setenv("ZERO_OAUTH_TOKENS_PATH", tokensPath)
store, err := oauth.NewStore(oauth.StoreOptions{})
if err != nil {
t.Fatalf("oauth store: %v", err)
}
if err := store.Save(oauth.ProviderKey("chatgpt"), oauth.Token{AccessToken: "bearer-123"}); err != nil {
t.Fatalf("save token: %v", err)
}
resolved := config.ResolvedConfig{
ActiveProvider: "opengateway",
Providers: []config.ProviderProfile{
{Name: "opengateway", ProviderKind: config.ProviderKindOpenAICompatible, BaseURL: "https://gateway.example.com/v1", APIKeyStored: true, Model: "some-model"},
{Name: "chatgpt", CatalogID: "chatgpt", ProviderKind: config.ProviderKindOpenAICompatible, BaseURL: "https://chatgpt.com/backend-api/codex", Model: "gpt-5.5"},
},
}
summary := summarizeConfig(resolved)
byName := map[string]providerSummary{}
for _, provider := range summary.Providers {
byName[provider.Name] = provider
}
if !byName["opengateway"].APIKeySet {
t.Fatalf("stored-key provider must report a credential: %+v", byName["opengateway"])
}
if byName["opengateway"].OAuthLogin {
t.Fatalf("key-authed provider must not claim the OAuth login: %+v", byName["opengateway"])
}
if !byName["chatgpt"].OAuthLogin || byName["chatgpt"].APIKeySet {
t.Fatalf("chatgpt must be marked oauth-login and keyless: %+v", byName["chatgpt"])
}
rendered := formatProviderSummaries("list", summary.Providers)
if !strings.Contains(rendered, "oauth login") {
t.Fatalf("list should render the oauth login state, got:\n%s", rendered)
}
}
+5 -4
View File
@@ -211,11 +211,12 @@ func fireJob(store *cron.Store, now func() time.Time, job cron.Job, stdout io.Wr
fmt.Fprintf(stderr, "warning: could not re-read job %s before persist: %v\n", job.ID, readErr)
return job, nil
}
if current.Status == cron.StatusPaused {
// Honor an external pause that landed while the job ran.
job.Status = cron.StatusPaused
current.FireCount = job.FireCount
current.NextRunAt = job.NextRunAt
if current.Status != cron.StatusPaused {
current.Status = job.Status
}
return job, nil
return current, nil
})
if errors.Is(err, cron.ErrJobNotFound) {
// Genuinely removed mid-run: don't recreate it (no run record, no persist).
+3 -3
View File
@@ -142,7 +142,7 @@ func TestRunExecListToolsAdvertisesMCPToolsWithoutToolSearch(t *testing.T) {
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
return execResolvedConfig(), nil
},
resolveMCPConfig: func(string) (config.MCPConfig, error) {
resolveMCPConfig: func(string, bool) (config.MCPConfig, error) {
return config.MCPConfig{Servers: map[string]config.MCPServerConfig{
"docs": {Type: "stdio", Command: "docs-mcp"},
}}, nil
@@ -177,7 +177,7 @@ func TestRunExecListToolsHonorsJSONFormat(t *testing.T) {
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
return execResolvedConfig(), nil
},
resolveMCPConfig: func(string) (config.MCPConfig, error) { return config.MCPConfig{}, nil },
resolveMCPConfig: func(string, bool) (config.MCPConfig, error) { return config.MCPConfig{}, nil },
newMCPStore: func() (*mcp.PermissionStore, error) { return nil, nil },
registerMCPTools: func(_ context.Context, _ *tools.Registry, _ config.MCPConfig, _ mcp.RegisterOptions) (mcpToolRuntime, error) {
return closeFunc(func() error { return nil }), nil
@@ -234,7 +234,7 @@ func TestTUIRunThreadsDeferThresholdAndRegistersToolSearch(t *testing.T) {
Tools: config.ToolsConfig{DeferThreshold: 2},
}, nil
},
resolveMCPConfig: func(string) (config.MCPConfig, error) {
resolveMCPConfig: func(string, bool) (config.MCPConfig, error) {
return config.MCPConfig{Servers: map[string]config.MCPServerConfig{
"docs": {Type: "stdio", Command: "docs-mcp"},
}}, nil
Loaded 100 of 586 files, more files were not shown because too many files have changed in this diff. Show more