Compare commits

..
Author SHA1 Message Date
Vasanthdev2004 f5557a955d fix(deps): refresh the lockfile to patched shell-quote
shell-quote 1.9.0 is inside the range GHSA-pqg4-j6r4-53mv covers
(>= 1.8.4, < 1.11.0). It reaches the lock through tuistory,
@opentui/react and react-devtools-core, which asks for ^1.6.1, so
refreshing the lock moves it to 1.12.0 without touching package.json.
Release packaging installs the local-control helpers from this lock,
so the old version shipped in the helper tree.
2026-10-14 15:50:24 +05:30
Kevin Codex 4dc603f622 refactor(config): drop the unreachable SetNotify wrapper (#1128)
SetNotify was kept in #1001 as a thin replace-form wrapper over
UpdateNotify for existing tests, but every product surface (the CLI
`zero config notify` and the TUI /notify picker) calls UpdateNotify's
merge form directly, so the advisory deadcode job reports it as
unreachable. Move the replace helper into writer_test.go as the
unexported setNotify, keep every test it served unchanged in behaviour,
and point the two CLI doc comments at UpdateNotify.

`make deadcode` no longer lists internal/config; the other advisory
findings in that job are pre-existing and untouched.
2026-10-14 16:45:00 +08:00
4d2e9f0d08 fix(config): sync atomic config replacements (#1093)
* fix(config): sync atomic config replacements

Persist temporary config contents before rename, then sync the parent directory on platforms that support it. Return persistence failures and cover ordering, cleanup, and pre/post-replacement failure states.

Fixes Gitlawb/zero#1087

Amp-Thread-ID: https://ampcode.com/threads/T-01a0dcd7-47fc-76eb-82d3-3f6c25384912
Co-authored-by: Pierre Bruno <[email protected]>

* fix(config): sync new config directories and relax unopenable dir sync

Sync each directory entry created by MkdirAll into its parent so a crash
cannot lose the config directory after a successful first write.

Treat a config directory that cannot be opened as best-effort durable,
matching the sessions store's syncDir; only a failed Sync or Close is
reported, since the rename has already replaced the file.

Check the temp file is closed with Seek instead of Stat: on Windows,
File.Stat on a closed file returns "The handle is invalid" rather than
os.ErrClosed.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* fix(config): report directory-open failures except for write-only directories

syncConfigDir treated every os.Open failure as success, so a caller could
acknowledge a save whose directory barrier was never reached (Copilot
review, PR #1093). Keep the maintainer-requested best effort for a
directory the user can write but not read, as in the sessions store, and
return every other open failure.

TestSyncConfigDir now expects os.ErrNotExist for a missing directory on
non-Windows hosts; it fails on the previous code with
"missing directory: error = <nil>, want file does not exist".

* fix(config): create the config directory durably on the lock path and roll back failed barriers

Every exported mutator acquires the config lock before it writes, and the
lock file is a sibling of the config, so lockConfigFile's MkdirAll was what
created the config directory on a first write. writeConfigData then found
nothing missing and never synced the new entries into their parents, so a
locking mutator could report success without the directory creation being
durable (CodeRabbit review, PR #1093).

A failed parent sync also left the newly created directories in place: a
retry found nothing missing, skipped the barrier, and reported success.

Route both creation sites through one helper, ensureConfigDir. It creates
each missing component with os.Mkdir so it knows exactly what it created,
syncs each new entry into its parent deepest first, and on any barrier
failure removes only the directories this call created, deepest first,
with os.Remove so a directory another process has populated survives. A
failed rollback is joined into the error, never swallowed. A component
that appeared between the lookup and the Mkdir is not rolled back but its
parent is still synced.

Regression coverage:
- TestLockingMutatorSyncsCreatedDirectories drives UpdateNotify on a
  missing nested directory, asserts the parent syncs happen before the
  lock file exists, that a failed barrier rolls the directories back, and
  that the retry re-runs the barrier. On the previous code it fails with
  "error = <nil>, want injected sync failure" because the lock path
  bypassed the barrier entirely.
- TestWriteConfigDataRollsBackOnlyCreatedDirectories asserts a failed
  barrier removes the created directories but keeps a pre-existing
  directory and its file, and that a directory populated by another
  process is kept and the failed rollback reported. On the previous code
  it fails with "must be removed after a failed parent sync: <nil>".

---------

Co-authored-by: Amp <[email protected]>
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
Co-authored-by: Kevin Codex <[email protected]>
2026-10-14 16:39:10 +08:00
Kevin Codex 1335ca6671 feat(providers): add GMI Cloud provider preset and partner listing (#1127)
Register GMI Cloud (https://api.gmi-serving.com/v1) as an
OpenAI-compatible catalog provider and add it to the README Partners
section.

- The catalog id `gmicloud` matches the models.dev provider id, so the
  remote model catalog resolves without a mapping. `gmi-cloud`,
  `GMI Cloud` and `gmi` resolve to it.
- Reads GMI_API_KEY (the name in GMI's docs) and GMICLOUD_API_KEY (the
  name models.dev uses).
- Defaults to Qwen/Qwen3.8-Max. The curated list is Qwen 3.8 Max and
  Flash (GMI's partner models), then DeepSeek V4 Pro and Flash. All four
  are served by GMI's /v1/models.
- README and README_ZH: key line, setup command, and the partner logo in
  light and dark variants under docs/assets/.

Verified live with a GMI key: `zero providers check gmicloud
--connectivity` passes, and `zero exec` completes a read-then-write task
on both Qwen 3.8 models.
2026-10-14 12:55:29 +08:00
Fabio Tavares LeitãoandClaude Sonnet 5.5 ddc45f2c81 feat(providers): opt-in ZERO_RESPONSE_HEADER_TIMEOUT override for slow first-byte deployments (#1106)
* feat(providers): opt-in ZERO_RESPONSE_HEADER_TIMEOUT override for slow first-byte deployments

The shared HTTP transport waits at most 120s for a response header. A local
model server that has to load a large model can take longer than that to
produce the first byte (measured 1-5 minutes on a throttled local Ollama), so
an otherwise-alive request is aborted.

Add ZERO_RESPONSE_HEADER_TIMEOUT, mirroring ZERO_STREAM_IDLE_TIMEOUT: a Go
duration or a bare number of seconds; "0", "off", "none" or "disabled" remove
the limit; an unparseable or non-positive value falls back to the default
instead of removing the limit. The default stays 120s, so nothing changes for
anyone who does not set the variable. The constant and the resolver sit next to
the idle-timeout equivalents in providerio.

Checked against a throttled local Ollama: ZERO_RESPONSE_HEADER_TIMEOUT=5s
fails at ~6.4s, =300s succeeds at ~223s (a request that would have hit the
120s ceiling).

Refs #1038

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_0142QEjA7eEFcdXTpcTmcAZk

* fix(providers): keep the 120s header timeout when bare seconds overflow

A value such as 36028797018963968 passes strconv.Atoi and wraps to zero
when multiplied by time.Second, which would disable the default. Values
that do not fit in time.Duration stay on the default.

* fix(providers): keep timeout defaults when bare seconds overflow

ResolveStreamIdleTimeout multiplied bare seconds by time.Second the same way the header timeout did, so a large count wrapped to zero and disabled the watchdog. Both resolvers now share a bounds-checked conversion.

* fix(providers): apply the header timeout when the shared client is taken

The stall-hardened transport baked ResponseHeaderTimeout in at package init, so a later ZERO_RESPONSE_HEADER_TIMEOUT never reached the real client and the 120s check depended on the env at init. Each resolved value now keeps its own transport, and a slow header proves that transport enforces it.

---------

Co-authored-by: Claude Sonnet 5.5 <[email protected]>
2026-10-13 12:49:55 +08:00
Lortu | AEGIS Core eebb384676 feat(runtime): add process-local per-model RPM admission limits (#1116)
* feat(runtime): add process-local per-model RPM admission limits

* fix(config): preserve model RPM through provider fallback

* test(config): pin provider-command RPM cap precedence

* style(config): gofmt provider-command RPM test
2026-10-13 10:15:11 +08:00
Kevin Codex 39d6560137 docs(readme): add Trendshift badges and Partners section (#1126)
Add three Trendshift badges under the tagline (#1 Go repository of the
day, #3 Go repository of the week, #3 on GitHub Trending) and a Partners
section listing Twigpine, Xiaomi MiMo, Novita AI, Concentrate, and Exa,
following the layout used in the OpenClaude README. README_ZH.md gets
the same badge row and a matching section.

The Novita, Concentrate, and Exa logos are vendored under docs/assets
with light and dark variants. The Twigpine and Xiaomi MiMo logos are
loaded from their own sites, as OpenClaude does; Twigpine switches to
its dark variant under a dark color scheme.
2026-10-13 09:40:10 +08:00
PierrunoYTandAmp bb3346a828 fix(execution): reserve process capacity before launching (#1092)
Reject starts when all slots are live or reserved, prune only completed history, and release reservations on launch failure. Keep live processes tracked even after failed termination. Add concurrent admission and real-process regressions for #1085.

Amp-Thread-ID: https://ampcode.com/threads/T-01a0dcd5-eb93-74c9-a1ac-5b140edaf973

Co-authored-by: Amp <[email protected]>
2026-10-12 15:03:04 +08:00
HazyLabandhazyhaar ac5fe0d218 fix(security): harden shell flag parsing, write roots, and formatters (#1066)
* fix(security): harden shell flag parsing, write roots, and formatters

- sandbox: recognize POSIX short-option clusters in shellCommandFlag (ZERO-ESC-02)
  Extract payload commands behind combined short flags (-ec, -lc, -xc) and long
  --command in dashCPayload and shellDashCPayload, preventing destructive
  subcommands from bypassing recursive AST inspection and auto-allowing execution.
- tools: anchor write_file and edit_file to *os.Root descriptor (ZERO-TRAV-01)
  Use openScopedWriteRoot and commitRootedFileContents to traverse parents relative
  to the root descriptor handle, eliminating residual TOCTOU directory swaps.
- tools: harden format_on_write against untrusted execution and secret leakage
  Pass --no-config to prettier to disable dynamic JS config execution, reject
  formatter binaries resolved inside the workspace (formatterBinaryInWriteRoots),
  and scrub credential-bearing environment variables before running formatters.

* fix(security): stop shell flag parsing at dash-dash and verify root descriptor identity

Stop recursing into shell `-c` payloads for tokens that follow the `--`
separator, which marks positional operands rather than options, in both
shellDashCPayload (text detector) and dashCPayload (AST analyzer). This
prevents `bash -- -ec '...'` from being classified on a command the shell
never runs as a command string.

Verify the identity of the *os.Root descriptor returned by openScopedWriteRoot
against a pre-open os.Stat of the resolved root, closing the check-to-use
window opened by os.OpenRoot re-resolving the path. A root substituted for a
symlink or a different directory is refused and the descriptor closed.

Regression tests fail on the unfixed code:
- TestShellDashCPayloadStopsAtDashDash, TestDetectInteractiveCommandAllowsNonInteractive
- TestAnalyzeCommand (`bash -- -ec ...` must stay non-interactive/non-destructive)
- TestOpenScopedWriteRootRejectsSubstitutedRoot

Bureau: desk-pr-1066

* fix(tools): relax symlink resolution error handling on host formatters and clean up test directory

* fix(security): anchor write preimage on root descriptor and report skipped formatters

---------

Co-authored-by: hazyhaar <[email protected]>
2026-10-12 15:02:17 +08:00
zzucs 0c6f179a84 fix(tools): hint when Windows resolves POSIX paths under root (#1063)
* fix(tools): hint when Windows resolves POSIX paths under the workspace root

On Windows a leading-slash POSIX path is not absolute, so read tools joined
it under the workspace root and surfaced a raw GetFileAttributesEx error.
Append a Windows-only hint naming the host OS and the workspace root when such
a path is missing, so the model switches to a workspace-relative path instead
of retrying the same shape.

Guidance only: which path is opened, confinement errors, and write-path
resolution are unchanged. All read tools share the annotated resolver.

Refs #972

* test(tools): keep the Windows path hint wiring test hermetic

The cross-platform wiring test used a fixed "/home/..." request, but
resolveScopedReadPathForGOOS injects only the annotation GOOS: on Unix the
request is host-absolute, so root was ignored and the test read state it did
not own. Build the request from t.TempDir() instead - joined under root on
Windows, anchored in root on Unix - and resolve an existing file under that
tree to prove the resolution stayed in test-owned storage. The native Windows
end-to-end test is unchanged.

Addresses the P2 finding on #1063.

* fix(tools): reject mixed-separator UNC paths in the Windows hint

Windows treats both separators as valid, so a second leading separator in
either position (/\server\share, /\?\C:...) is the same UNC or device-style
prefix as //. looksLikePosixAbsolutePath excluded only the literal "//", so a
missing mixed-separator target was misreported as a POSIX path resolved under
the workspace root. Exclude either separator byte and cover both mixed forms
in the shape and annotation tables.

Addresses the P3 finding on #1063.
2026-10-12 15:01:44 +08:00
feb992757c fix(tui): bound live dictation regions (#985)
* fix(tui): bound live dictation regions

Amp-Thread-ID: https://ampcode.com/threads/T-01a0448f-5860-721c-8a47-5119fc57f685
Co-authored-by: Amp <[email protected]>

* fix(tui): preserve user edits across dictation partials

* fix(tui): reanchor stale dictation regions

Amp-Thread-ID: https://ampcode.com/threads/T-01a04c92-2d1d-7508-91bc-416341b7e8b0
Co-authored-by: Amp <[email protected]>

* fix(tui): bound live dictation regions

Amp-Thread-ID: https://ampcode.com/threads/T-01a0448f-5860-721c-8a47-5119fc57f685
Co-authored-by: Amp <[email protected]>
Co-authored-by: Pierre Bruno <[email protected]>

* fix(tui): preserve user edits across dictation partials

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

* fix(tui): reanchor stale dictation regions

Amp-Thread-ID: https://ampcode.com/threads/T-01a04c92-2d1d-7508-91bc-416341b7e8b0
Co-authored-by: Amp <[email protected]>
Co-authored-by: Pierre Bruno <[email protected]>

* fix(tui): preserve dictation separators after reanchoring

Amp-Thread-ID: https://ampcode.com/threads/T-01a07cfc-4f4e-773c-b42e-6229ef568a01
Co-authored-by: Pierre Bruno <[email protected]>

* fix(acp): reject Unicode whitespace in browser title origins

Cover encoded whitespace before permission translation and through wire payloads. Pin parent reasoning-effort forwarding at the specialist resume call site.

Amp-Thread-ID: https://ampcode.com/threads/T-01a097c8-242d-732f-bf6e-7321d8cf732c
Co-authored-by: Pierre Bruno <[email protected]>

---------

Co-authored-by: Amp <[email protected]>
Co-authored-by: Anandan <[email protected]>
2026-10-12 15:01:09 +08:00
Vasanth T 23c27e0778 feat(sessions): add zero sessions prune, which never removes a session another process has open (#1089)
* feat(sessions): prune old sessions, never one another process has open

Store.Prune removes sessions last updated before a cutoff of at least a
day, and reports what it removed, what it kept and why, and what failed.
A dry run makes the same decisions and removes nothing.

A session holds no lock between writes, so nothing told a prune in one
terminal that another terminal still had the session open. Each session
now has a lease file: a process that writes a session, or loads it
through the rehydrated reads the TUI, exec --resume and ACP use to
continue one, holds a shared lock on it for its lifetime, and prune
takes it exclusively and leaves alone any session it cannot get. On
Windows the lease file is opened with FILE_SHARE_DELETE so a held lease
never blocks removing the directory.

Prune also keeps every ancestor of a session that stays, since Lineage
and Tree fail on a missing ancestor, removes descendants before their
ancestors, re-checks each session under its lease and write lock, and
removes the metadata first so a removal that fails part way leaves
nothing that looks like a session.

Part of #971.

* feat(cli): add zero sessions prune, with a retention setting read from user config only

`zero sessions prune --older-than 30d` removes sessions last updated
before the cutoff, `--dry-run` lists what it would remove and why it
keeps the rest, and `--json` prints the report. The cutoff is days or a
Go duration and never under a day. Without --older-than, prune uses
sessions.retentionDays; with neither it refuses and says how to set one,
so nothing is removed unless asked for.

sessions.retentionDays is read straight from the user config file.
Sessions belong to the user rather than to a repository, so a project's
.zero/config.json cannot choose what prune deletes, and no resolver
merge copies the setting from anywhere.

Closes #971.

* test(sessions): a dry run reports a session another process has open as kept

* test(sessions): pin the dry run for undated sessions and the lease a new session takes

* test(sessions): report every failure when a session is written while pruning

* docs(readme): list sessions prune in the Chinese README as well

* fix(sessions): a fork or child holds its parent, and is refused while prune holds it

A lease is taken best effort, so a fork whose parent prune held at that
moment went ahead: the rehydrated read in exec --fork could not hold the
parent, Fork read it anyway, and the fork was created under a parent
prune was removing. Fork and CreateChild now take the parent's lease
themselves before reading it, and refuse when prune holds it, so the
parent of a session being created is never removed under it.

* docs(sessions): say that nothing calls Release yet, and what it is for

* fix(sessions): let go of the lease before removing a pruned session's directory

lease.lock is deleted with the rest of the session while prune still
holds it. Where a delete only takes effect once the last handle closes
(Windows without POSIX delete semantics: older builds, or FAT and exFAT
volumes), prune's own handle kept the directory from being empty, so
the removal failed and left an empty directory behind. Prune now closes
the lease after session.lock is gone and before the directory goes.

* test(cli): an empty --older-than is a usage error, not the retention setting

* fix(sessions): refuse to continue a session prune holds, rather than read it without the lease

A resume that started while prune held the session could not take the
lease, read the session anyway, and went on to use one prune was free to
remove: a read changes nothing prune checks again. The rehydrated read
behind the TUI resume, exec --resume and --fork, and ACP session/load
and session/resume now fails with ErrPruning at that moment, and none of
the three falls back to a raw read on it. ACP load stays best effort
about a history it cannot read, but not about this one. Writes are
unchanged: a write lands under session.lock, where prune checks the
session again.

Prune takes the lease through the new Store.HoldExclusive, which the
TUI and ACP tests use to hold a session the way prune does.

* test(sessions): a lease that cannot be taken keeps the session, and does not stop a resume

Prune fails closed when it cannot take a session's lease at all, so a
process whose own hold fails the same way can carry on: prune cannot
remove the session either. Refusing there instead would stop resume
and fork wherever locking does not work, for people who never prune.

* fix(sessions): keep a parent forked after the plan, and refuse sessions removed under a caller

Three gaps jatmn found in the prune lease rules.

A fork made after prune's plan, by a process that has since exited,
was not in the plan, so its old parent was removed and the fork was
left under a missing ancestor. Once prune holds a parent exclusively it
now looks for sessions the plan did not see that name it as parent,
and keeps it if there is one.

Store.Create with a ParentSessionID, which exec --calling-session-id
and spec implementations use, did not hold the parent at all. It now
holds it the way Fork and CreateChild do, and all three refuse a
parent whose directory is still there without its metadata: prune
removes the metadata first and unlinks lease.lock after it, so a lease
taken at that moment lands on a fresh lease file and proves nothing.

A resume, exec --resume or --fork, or ACP load that picked a session a
moment before prune removed it could take a fresh lease and read the
session as empty. HoldToContinue now holds the picked session and
requires its metadata, and those callers go through it. The
rehydrated read on its own still reads a missing session as empty.

* fix(sessions): make a dry run decide at removal time like a real prune

A dry run appended every planned session to Removed without calling
pruneSession, so none of the checks made once a session is held for removal
ran: the second lease check, the re-read of the last update, and the scan for
a child created after the plan. On the same disk, a dry run could list a
session that a real run keeps.

pruneSession now takes a dry-run flag, makes every one of those checks under
the same locks, and stops before removing anything. The prune command reports
a session a dry run could not check as such, not as one it could not remove.
2026-10-12 14:26:29 +08:00
Kevin Codex 2c418fc8ff fix: unbreak main CI (cron claim tests, Go 1.26.9) (#1124)
* test(cron): pass the history limit in the claim tests

#1094 added a limit argument to cron.Store.Runs. #1090 merged shortly
after from a branch that predated it, so its two new tests in
cron_claim_test.go still called Runs with only the job id and the
internal/cli test package stopped compiling on main:

    internal/cli/cron_claim_test.go:111:38: not enough arguments in call to stores[0].Runs
        have (string)
        want (string, int)

That failed vet and every job that runs go test. Pass 0, which selects
the default MaxRunHistory cap, as the other callers in this package do.

* fix: bump Go to 1.26.9 for stdlib vulnerability fixes

govulncheck reports ten reachable standard-library vulnerabilities on
Go 1.26.6, all fixed in 1.26.9: GO-2026-6603, 6605, 6610, 6611, 6612,
6613 and 6617 in net/http, GO-2026-6608 in net/textproto, GO-2026-6607
in crypto/tls, and GO-2026-6604 in os (Windows only).

CI and release workflows read the toolchain from go.mod, so the go
directive is the only version to change. Update the stated source-build
minimum in the READMEs and install guide to match.
2026-10-12 10:46:27 +08:00
PierrunoYTandClaude Sonnet 5.5 571af701bd fix(mcp): refresh OAuth tokens for discovered endpoints and registered clients (#1113)
Login discovers the token endpoint (RFC 9728 / 8414) and may obtain a
client_id/client_secret through dynamic registration, but persisted none
of it. Refresh read only the config-file OAuthConfig, which is empty for
the standard `auth: oauth` setup, so every expired access token failed
with "no token endpoint configured for refresh" and required a new
`zero mcp oauth login`.

Store the discovered token endpoint (and whether it was server-advertised)
and the dynamically registered client on the token, and fall back to them
in Refresh. Explicit config still wins, and values already in config are
not copied into the store. A stored endpoint is re-validated before use:
the shared https/loopback rule always, and for a server-advertised
endpoint the same public-network policy and pinned client Login used. The
refreshed token keeps the stored fields so later refreshes keep working.

Fixes #1101

Co-authored-by: Claude Sonnet 5.5 <[email protected]>
2026-10-12 09:58:49 +08:00
2eb9d94fac fix(cron): preserve newer claims when overlapping runs complete (#1090)
* fix(cron): preserve newer claims when overlapping runs complete

Let claimFire own schedule state and increment the current fire count under the store lock. Abort completion writes on read errors rather than restoring a stale snapshot.

Cover adjacent slots in both completion orders using independent stores, including failed executions, duplicate claim rejection, and unreadable completion state.

Fixes Gitlawb/zero#962

Amp-Thread-ID: https://ampcode.com/threads/T-01a0dc9c-8bc2-72cc-ad0b-6c64e3578df8
Co-authored-by: Pierre Bruno <[email protected]>

* fix(cron): avoid stale next-run summary on persist failure

Report the next run as unknown when completion state cannot be persisted, as requested in review. Extend the existing corrupt-state regression to verify the summary.

Amp-Thread-ID: https://ampcode.com/threads/T-01a0e862-0c9c-753c-9afc-45f9c1082c17
Co-authored-by: Pierre Bruno <[email protected]>

---------

Co-authored-by: Amp <[email protected]>
2026-10-12 09:56:39 +08:00
PierrunoYTandClaude Sonnet 5.5 eefa978f61 fix(daemon): do not retry workers that finished with a final exit code (#1112)
The pool stopped retrying only on exit 0 or ExitPermanent (76), so the
other codes `zero exec` uses to say a run is over (2 usage, 3 provider,
4 incomplete, 130 interrupted) were treated as crashes and retried up to
MaxAttempts with backoff. Exit 4 means the agent already ran, so each
retry repeated its side effects, re-billed the provider and appended a
second run to the same session stream.

Treat those four codes as final: return the worker's exit code with
ErrPermanent. Crash-type exits (1, signals) and launch/read errors still
retry.

Fixes #1100

Co-authored-by: Claude Sonnet 5.5 <[email protected]>
2026-10-12 09:55:55 +08:00
Shubham bea2230633 chore: remove unused test exports and dead shell-highlight/theme/upstream helpers (#1096) 2026-10-12 09:55:24 +08:00
Marlowe df67edec13 fix(redaction): redact adjacent AWS keys glued end to end (#1095)
* fix(redaction): redact adjacent AWS keys glued end to end

Two AWS keys with no separator between them (AKIA...AKIA...) only had the
first redacted: the leading \b in the secret patterns sees a word character
on the left of the second key and misses it.

Apply each textSecretPattern via a helper that also retries at every redacted
span end with a \b-stripped, start-anchored variant of the pattern, so a
credential starting exactly where the previous one ended is still caught.
Chained runs (three or more glued credentials) are followed to the end. A
mid-word occurrence that does not abut a redacted span still does not match.

Extracted from #1075 per Vasanthdev2004's suggestion on #969; #1067 remains
the fix for the RedactString leak.

* Address CodeRabbit critical: bound redactAdjacent span-chaining loop

The outer loop re-read len(spans) while the inner loop appended chained
spans to the same slice, so appended spans were re-chained and the span
count grew exponentially on long glued-key runs (RedactString handles
untrusted content). Bound the loop to the original match count and add a
regression test with 64 glued AWS keys asserting 64 markers promptly.
2026-10-12 09:54:52 +08:00
42eb6ab5fb fix(cron): bound run history and read recent records from the tail (#1094)
* fix(cron): bound run history and read recent records from the tail

Retain the newest 1,000 outcomes under the job lock and publish complete history snapshots. Add bounded reverse reads and regression coverage for legacy logs, failures, and concurrent stores.

Fixes Gitlawb/zero#1086

Amp-Thread-ID: https://ampcode.com/threads/T-01a0dcd6-ad63-74d8-8396-45573c57dcec
Co-authored-by: Pierre Bruno <[email protected]>

* fix(cron): skip oversized legacy history records

Discard entire oversized lines during bounded tail reads so compaction can record new outcomes without weakening atomic publication. Cover exact size boundaries and oversized first, middle, and unterminated final records.

Amp-Thread-ID: https://ampcode.com/threads/T-01a0e861-ebee-7764-be1d-e67517cd914c
Co-authored-by: Pierre Bruno <[email protected]>

* fix(cron): append the outcome when the history log cannot be replaced

On Windows the atomic replace of runs.jsonl fails while another process
holds the log open, which dropped every outcome for as long as it stayed
open. Keep the replace, and only when it fails append the record on a
fresh line. The leading newline terminates any unterminated tail; the
blank line it may leave is already skipped by readRuns. Zero's own
readers take the job lock, so they never observe a partial append.

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

* fix(cron): preserve the history DACL and append only on sharing violations

Compacting runs.jsonl renamed a temporary file over it, which on Windows
replaced a DACL applied to the log with the job directory's inherited
one. Publish through ReplaceWithRetry so the log keeps its descriptor.

Fall back to appending only when the replace fails with a Windows
sharing or lock violation, the error a reader holding the log open
produces. Any other failure, such as a persistent access denial, is
returned so a log that can never be replaced cannot grow past the
retention limit. fsutil.IsSharingOrLockViolation exposes the existing
classifier, gated to Windows because errno 32 and 33 mean EPIPE and EDOM
elsewhere.

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

---------

Co-authored-by: Amp <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 09:45:29 +08:00
PierrunoYTandAmp 8a68660161 fix(daemon): bound pending sessions and prune on completion (#1091)
Reject excess unfinished sessions before registration and dispatch, independently of retained history. Release admission and prune on every terminal path.

Amp-Thread-ID: https://ampcode.com/threads/T-01a0dcbe-87ec-74e6-bdd0-6341b0c26d9a

Co-authored-by: Amp <[email protected]>
2026-10-12 09:40:41 +08:00
Vasanth T ab018b3098 fix(daemon): stop worker process groups by their launch-time identity (#1088)
* test(daemon): stop what a worker left running after it was reaped

A daemon worker is launched into its own process group, but Kill and the
Cancel hook pass its PID to background.TerminateProcess, which looks the
group up again with Getpgid when it signals. Once the worker has exited
and been waited, that lookup fails, only the dead PID is signalled, and a
child the worker left running survives. This pins the case with a shell
worker that backgrounds a sleep and exits: after the wait, Kill and the
Cancel hook must still stop the sleep.

Committed ahead of the fix so CI shows it failing on the current
behaviour on Linux and macOS.

* fix(daemon): stop worker process groups by their launch-time identity

Kill and the Cancel hook now go through background.TerminateCommandGroup,
which signals the group the worker was launched into (ConfigureChild-
ProcessGroup's Setpgid) instead of rediscovering it from the PID, so a
worker that has exited, or on Darwin one that is still a zombie, no longer
leaves its children running. It is TerminateCommand without the reap:
the pool waits on its workers itself and exec.Cmd calls Cancel from
inside Wait, so reaping there would be a second Wait.

On Windows it is the same tree kill, with the process handle pinned while
it runs. A command that has already been waited cannot be pinned; that
now reports os.ErrProcessDone, which exec.Cmd's Cancel contract treats
as nothing to cancel, instead of the unexported "already released" error.

Fixes #861
2026-10-12 09:38:26 +08:00
1697b7a5ce fix(tui): show provider ownership in Recent and Favorites (#1083)
* fix(tui): show provider ownership in recent and favorite models

Display saved provider names in mixed-provider picker groups and include them in row sizing. Preserve provider-grouped rows and selection values.

Fixes Gitlawb/zero#840

Amp-Thread-ID: https://ampcode.com/threads/T-01a0d8e9-d346-71f9-bdac-4ac762d810a5
Co-authored-by: Pierre Bruno <[email protected]>

* fix(tui): label mixed-group rows with the provider Enter uses

Recent and Favorites rows labeled their owner from recent history, but
selection falls back to the active provider when that owner was renamed
or removed, so the label could name a provider that would not be used.
Derive the label from the same decision selection makes
(modelPickerSwitchOwner) and store it on the row as OwnerLabel.

Cap the owner prefix at 20 cells so an unbounded profile name cannot
consume the model name once the overlay reaches its 76-column maximum.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* fix(tui): distinguish abbreviated model picker owners

Budget owner cues against the rendered row width, preserve model names, and distinguish abbreviated saved profiles with numbered suffix cues. Show the highlighted full owner as a wrapped key before selection. Cover shared-prefix and shared-suffix owners, narrow terminals, Favorites, and Enter routing.

Amp-Thread-ID: https://ampcode.com/threads/T-01a0e862-13e1-756e-9608-58d5fed96f05
Co-authored-by: Pierre Bruno <[email protected]>

* fix(tui): preserve model labels when owner cues cannot fit

Split owner cues onto a second line when the model needs the row width. Check the actually rendered row before suppressing the full owner key, so owner text hidden inside a clipped model label cannot hide that key.

Amp-Thread-ID: https://ampcode.com/threads/T-01a0e862-13e1-756e-9608-58d5fed96f05
Co-authored-by: Pierre Bruno <[email protected]>

* fix(tui): fit multiline model rows and mouse targets

Fit the model picker item window to its rendered viewport height and share that window with mouse hit-testing. Account for group headers and two-line rows so clicking either line selects the correct model and the keyboard-selected item remains visible.

Amp-Thread-ID: https://ampcode.com/threads/T-01a0e862-13e1-756e-9608-58d5fed96f05
Co-authored-by: Pierre Bruno <[email protected]>

---------

Co-authored-by: Amp <[email protected]>
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 09:37:30 +08:00
Vasanth T 6b5852dc18 fix(sandbox): say when a session's sandbox enforcement is degraded (#1082)
* fix(sandbox): say when a session's sandbox enforcement is degraded

Only `zero doctor` and `zero sandbox policy` reported a degraded sandbox, so
a TUI session or a `zero exec` run went ahead with the native sandbox
effectively off and nobody saw it. Both now say so once, before the first
prompt or tool call, naming the downgrade reason: exec on stderr next to
the grant migration notice, the TUI as a system notice when it opens.

The line comes from the session's own engine through Backend.BuildPlan,
the plan `zero sandbox policy` prints, so the notice and the command that
explains it cannot describe two different sandboxes. Only the degraded
level speaks: a disabled sandbox is the user's choice, and the unelevated
Windows tier still enforces the write jail.

Exec tests that expect a quiet stderr now compare against the notice this
host prints under the default policy, since they run on the host's own
backend and a Linux runner without the helper is degraded while macOS and
Windows are not.

Fixes #1041

* fix(sandbox): say so when the nesting markers skip wrapping

With ZERO_SANDBOXED and ZERO_SANDBOX_BACKEND set, BuildCommandPlan passes
every command through unwrapped on the word of an outer sandbox nothing
verifies (#727). The notice came from Backend.BuildPlan, which does not
model that path, so a native backend reported full enforcement and a
session started with the markers ran every command unwrapped in silence.
The engine's notice now mirrors the runner and names the markers and the
unverified outer sandbox. A disabled sandbox still says nothing.

hostSandboxNotice builds its expectation through the engine too, and the
tests that pin the notice clear the markers so a suite run inside a Zero
sandbox does not change their answer.

* fix(tui): say the startup notices again when a session is resumed

/resume rebuilds the transcript from an empty one, so the startup notices
went with the old transcript and the session it switched to ran under a
degraded sandbox without a word. The model keeps the notices and puts
them back first, before the resume summary. Rewind and compaction stay in
the same session and keep the notice in the scrollback above their
divider.

Also pins why the notice can be decided once, at startup: grants made
during a session only open the network or add allow and deny paths,
never the mode, and every mode but disabled already needs the platform
sandbox, so no grant reaches a degraded command plan the startup notice
missed, and a deny path the backend cannot enforce is refused instead.

* fix(tui): say the startup notices again when /new opens a session

/new cleared the transcript and added only its own note, so the new
session started under a degraded sandbox without a word, the same gap
/resume had. It now puts the notices back first, the way /resume does.
/clear stays in the same session and, like rewind and compaction, keeps
the notice in the scrollback above its divider.

* fix(modelregistry): start no background refresh when the fetch is turned off

The TUI and exec each started a goroutine that called
RefreshModelsDevCache, which reads ZERO_DISABLE_MODELS_FETCH and the
cache path when it runs. A test that turned the fetch off through
t.Setenv could finish and have the setting restored before that
goroutine ran, and the refresh would then fetch into the developer's
real cache. StartModelsDevRefresh reads the setting before starting
anything, so with the fetch off no refresh exists to outlive the test.

* test(cli): keep the degraded-notice tests out of the developer's user state

runExecWithSandbox and the TUI launch test left the sandbox grant store
and the models.dev cache at their defaults, so they read the
developer's sandbox-grants.json and could migrate it, and could refresh
the real models cache. Both now run under isolateCLIUserState, and both
join TestCLILaunchHelpersLeaveUserStateAlone so the isolation is pinned
for each of them.

* fix(cli): stop exec when the degraded-sandbox notice cannot be written

The notice goes out before any tool runs, and before MCP servers and
plugins start through the same sandbox, but a failed write to stderr
was ignored, so the run went ahead with reduced isolation and no
warning delivered. It now stops with exitCrash, the way the image and
reasoning-effort notices in the same function already do.
2026-10-12 09:36:52 +08:00
Vasanth T aa52040498 fix(deps): refresh the lockfile to patched hono and @hono/node-server (#1080)
The lock pinned hono 4.12.27 and @hono/node-server 1.19.14 through
tuistory 0.10.0, and npm audit reports advisories against both. tuistory
already declares ^4.11.7 and ^1.19.9, so the patched releases fit the
existing ranges: hono 4.13.9 and @hono/node-server 1.19.17. package.json
is unchanged.

npm also re-syncs the lock's root entry with package.json, which main had
already changed without regenerating the lock: license MIT, android in
os, and no install script.
2026-10-12 09:33:58 +08:00
Vasanth T 547330197b fix(oauth): give every loopback callback server the bounded shutdown (#1079)
* fix(oauth): give every loopback callback server the bounded shutdown

The loopback OAuth callback server was written out three times: the shared
oauth.LoopbackListener, MCP's Login and OpenRouter's login. Each ended with
Shutdown under a one-second deadline, and Shutdown only closes idle
connections. A client that has sent part of a request header is not idle, so it
survived the deadline, and its goroutine and the Serve goroutine stayed alive
after the flow returned (#1028).

#1051 fixed that for LoopbackListener (a header read bound, force-close when
Shutdown runs out of time, and a join on Serve) and left the other two exactly
as they were, because each was its own copy.

The lifecycle now lives once, in oauth.CallbackServer, with #1051's behavior
unchanged: ReadHeaderTimeout of 5s, Shutdown within 1s, Close() on a missed
deadline, and Close returning only after Serve has. LoopbackListener uses it
through an unexported constructor that keeps its ConnState test hook, and MCP's
Login and OpenRouterLogin use StartCallbackServer, which both packages could
already import.

Each call site gets a regression test that stalls a raw client mid-header ahead
of the real callback and then requires the stalled connection to be closed once
the login returns. The ordering does the synchronising: the stalled client is
dialled before the real callback, Serve accepts in order, and the login cannot
finish until the real callback has been served, so no sleep is involved.

Fixes #1028

* test(oauth): pin the callback server's header bound and its join on Serve

Two of the three things CallbackServer does had no test that noticed their
removal, the same as when #1051 introduced them on LoopbackListener: the
five-second header read bound, and Close waiting for the Serve goroutine.

The header test stalls a client mid-header and never calls Close, so it can
only pass on ReadHeaderTimeout; the bound is a package var so the test can
shorten it. It synchronises on StateNew. Go reports StateActive only after a
request read returns, and a stalled header read returns only when the header
timeout fires, so a helper waiting for StateActive depended on the bound under
test and turned its removal into a SETUP INVALID instead of a failure.

The join test uses the case where the join matters: with no connections open,
Shutdown closes the listener and returns at once, so without the join Close can
return before Serve does. Without it the test failed 10 runs out of 10.
2026-10-12 09:33:10 +08:00
Vasanth T 7f11491078 fix(acp): publish sub-run sessions loaded over ACP as view-only (#1078)
* fix(acp): publish sub-run sessions loaded over ACP as view-only

session/load registered a fully promptable session for any session kind,
including the child, side and spec sub-runs that every other resume path
refuses through sessions.IsResumableKind: the TUI, exec sessions, the exec CLI,
and ACP's own session/resume. So a sub-run could be opened and continued as a
standalone conversation through ACP and nowhere else, with whatever its parent
run relied on about that sub-run's lifecycle no longer true.

Load has a real render-only use, a client rebuilding a transcript it can scroll,
so it still loads those sessions and still replays their history. What changes
is that the session it publishes for a non-resumable kind is marked read-only,
and session/prompt refuses it with an invalid-params error that says why.
runTurn is reached only from session/prompt, so that one gate covers every way a
turn can run; set_mode and the config options only change settings.

The flag is decided at first registration and never changes. Resume refuses a
non-resumable kind before it registers anything, and session/new always creates
a standalone session, so the only path that can publish one of these ids is
load, which now publishes it read-only.

TestACPResumeAppliesStandaloneSessionKindPolicy claimed "session/load should
retain render-only access", but its prompt check ran before the load, so it only
proved the refused resume never registered the session. It now prompts after the
load as well. TestACPLoadPublishesSubRunsReadOnly checks every kind in both
directions, so a gate that refused everything would fail on the resumable kinds,
with a setup guard that the table agrees with IsResumableKind.

Fixes #1045

* test(acp): require resumable sessions to be promptable after load, not merely unrefused

TestACPLoadPublishesSubRunsReadOnly decided the outcome from the error text:
"refused as view-only" was false for any error other than the view-only one, so
a regular or fork session whose prompt failed for some other reason still
passed. With every writable prompt made to fail with an internal error, the old
test stayed green. Raised by CodeRabbit on the PR.

Resumable sessions now have to prompt successfully. For the sub-run kinds the
decision is the RPC code, and the message is checked only to tell the view-only
refusal apart from other invalid-params errors such as an unknown session id.
2026-10-12 09:32:40 +08:00
Marlowe 0324c1d410 fix(mcp): forward image tool results on the existing image channel (#1077)
* fix(mcp): forward image tool results on the existing image channel

Recreated from Gitlawb/zero#1012 (approved but unmerged).
Original: euxaristia/zero:feat/823-forward-mcp-images

* test(mcp): pin the pre-decode encoded cap and MIME sniffing guards

Adds the two reviewer-requested tests for the untrusted-input boundary:
- TestOverCapEncodedPayloadIsRejectedBeforeDecoding: a payload one byte
  over EncodedLen(MaxImageBytes) is rejected and the decoder is never
  called, so a hostile server cannot force arbitrary decode work.
- TestDeclaredMimeTypeDisagreeingWithSniffedBytesIsDropped: an HTML
  payload declared as image/png is sniffed from its bytes and dropped
  rather than forwarded to the provider as an image.

Both mutations (removing the cap, trusting the declared mimeType) were
verified to turn their test red locally.
2026-10-12 09:31:17 +08:00
Marlowe b9ef1d271a fix(agent): retry mid-stream transport aborts before tool dispatch (#1076)
* fix(agent): retry mid-stream transport aborts before tool dispatch

Recreated from Gitlawb/zero#1014 (approved but unmerged).
Original: euxaristia/zero:fix/973-mid-stream-transport-retry

* Address CodeRabbit nit: add Unix ECONNABORTED needle to shouldReconnect

midStreamAbortNeedles claims to be a subset of shouldReconnect but listed
'software caused connection abort' (the Unix/macOS ECONNABORTED text),
which shouldReconnect did not match. Add the needle alongside the other
abort phrases, and cover it in TestShouldReconnectClassification.
2026-10-12 09:30:19 +08:00
Vasanth T 9520f2bad7 fix(search): redact every text field of session metadata, not ten of thirty (#1073)
* fix(search): redact every text field of session metadata, not ten of thirty

`zero search --json` prints Hit.Session straight to stdout and redactMetadata
covered ten of its string fields. Filling every string-kinded field of
sessions.Metadata with a credential and walking the result names twenty that
came back carrying it: SessionKind, Tag, RootSessionID, AgentName, TaskID,
SpawnedFromEventID, SpecID, SpecFilePath, SpecStatus, SpecDraftModelID,
SpecDraftReasoning, SpecUserComment, SpecRejectReason, SpecSourceSessionID,
SpecImplSessionID, and the five on the Goal (Objective, Status, StatusReason,
CreatedAt, UpdatedAt).

The ones carrying the most free text are the ones a hand-kept list is least
likely to reach: SpecUserComment and SpecRejectReason are review text the user
typed, SpecDraftReasoning is the model's own prose, and Goal.Objective and
Goal.StatusReason are however the goal was phrased.

Goal is redacted through a copy. Metadata is passed by value but Goal is a
pointer inside it, so redacting in place would edit the session the caller still
holds, and a search would quietly rewrite the live record it was only asked to
report on.

TestRedactMetadataCoversEveryTextField walks the struct rather than repeating
the list, so a string field added to Metadata or Goal later fails on the day it
lands. It carries a SETUP INVALID guard that the chosen secret is a shape
RedactString actually recognizes, since otherwise every leg would pass
vacuously, and a second guard that the walk visited any fields at all.

* test(search): make the metadata coverage guard recurse, so nesting cannot slip past it

The first version of the guard walked only the direct string fields of
sessions.Metadata and hand-allocated the Goal beside it. That left it with the
blind spot it exists to close: a nested struct or pointer field added later
would arrive empty, the walk would find nothing in it, and the test would pass
while the new field went out unredacted. Raised by CodeRabbit on the PR.

Filling is now recursive through structs, pointers and slices, allocating what
it finds nil, and the walk is its mirror. The test compares the two counts, so a
shape one traversal understands and the other does not fails as SETUP INVALID
rather than passing quietly.

Both helpers fail rather than skip on a kind they do not understand. A field the
guard cannot populate is a field it is not guarding, and saying so at the point
it is added is the whole value of having it.
2026-10-12 09:29:36 +08:00
Vasanth T e2d18c50f0 test(config,tui): stop two tests reading the developer's environment and checkout path (#1072)
`go test ./...` is red on a developer machine and green in CI, for two tests
that take an input from the machine rather than from the test.

TestResolveReportsExplicitMaxTurns left ResolveOptions.Env nil on four of its
five cases, and a nil Env means "read the real process environment" (envValue).
Any one of the ten variables the resolver reads is enough to break it. With
ANTHROPIC_BASE_URL exported, applyProviderEnv synthesizes a second, keyless
"anthropic" profile from a base URL on its own, the single-provider shortcut in
normalizeProviders no longer applies because there are now two, and Resolve
returns `no active provider configured: active provider "" not found` before the
test reaches its assertion. Measured one variable at a time against a clean
environment, all ten fail the same test: ANTHROPIC_BASE_URL, ANTHROPIC_API_KEY,
ANTHROPIC_MODEL, OPENAI_API_KEY, OPENAI_BASE_URL, OPENAI_MODEL, GEMINI_API_KEY,
GOOGLE_API_KEY, ZERO_PROVIDER and ZERO_MAX_TURNS.

The cases now state an empty environment instead of no environment, which is
what they meant, and the test exports three hostile values itself so it proves
that rather than relying on the machine it runs on being clean. Reverting either
changed case fails the test on a clean environment too, so this cannot regress
silently on CI.

TestAltScreenTranscriptScrollKeepsFooterFixed asserted the branch survives in
the title bar at width 90 while leaving Options.Cwd empty, so newModel took the
real working directory. The outcome is not a monotonic function of that path's
length, which is why this took a measurement rather than a guess. The title bar
picks the widest candidate that fits: workspace carries branch and path, cwdOnly
carries the path alone, compactLeft carries the branch alone. At width 90 with
this branch, a cwd of up to 52 bytes keeps both, 53 to 74 falls to path-only and
drops the branch, and 75 or more falls again to branch-only and restores it. The
test failed from a checkout whose package path is 74 bytes and passed from one
at 80; CI sits at 40. Cwd is now pinned, which also stops newModel reading the
real repository for a branch the test overwrites anyway.

No production code changes. The resolver's nil-Env contract and the header's
candidate ladder are both deliberate; only the tests were leaning on them.
2026-10-12 09:28:11 +08:00
Vasanth T e20b762426 fix(redaction): redact credentials split by an invisible character (#1067)
* fix(redaction): redact credentials split by an invisible character

Every shape matcher in RedactString describes a contiguous run of body
characters, and none of their classes admits a control or format character.
One NUL or ESC dropped into the body of a key therefore ended the match and
both fragments were emitted untouched. Whoever read that output next saw the
original key back, because nothing about those characters was ever displayed:
a terminal eats the escape, a log viewer or JSON consumer strips the control
byte, and a copy-paste drops both.

The matchers now also run over a copy with those characters removed, and the
spans they find are mapped back so the replacement covers the original bytes,
separators included. Emitting the compacted copy would hand the caller a
different string than the one they passed in, and leaving a separator behind
would read as two secrets where there was one.

Written against the character classes rather than the two bytes in the report,
because every other Cc and Cf character behaves the same way: BEL, backspace,
DEL, the C1 controls, zero-width space and joiner, word joiner, BOM and soft
hyphen all split a key on main. Tab, newline and carriage return are
deliberately excluded and a test says so: they are real text structure, a
credential broken across a line is visible to whoever reads it rather than
hidden from them, and stripping them would let one match span a line break and
replace unrelated lines.

Text with no such character takes exactly the path it took before, decided by
a byte scan that allocates nothing, so the common case is unchanged at 51
allocations. Text that carries control characters and no credential pays for
one compacted copy and a second pattern pass.

Fixes #969

* test(redaction): assert the exact output around a malformed byte

The malformed-UTF8 legs only asked whether the key was gone, so a decoder that measured an invalid byte as the three bytes of U+FFFD still passed: the replacement covered the secret and moved its edges onto the bytes beside it. They now assert the whole string. One leg also records that an invalid byte is not one of the invisible separators, on the same ground as tab and newline: it decodes to U+FFFD and is drawn.

* fix(redaction): keep credential boundaries while matching across invisible characters

The split-credential pass compacted every Cc and Cf character out of the whole
string before matching, which removed delimiters as well as filler. Two things
followed. A whole credential written after a NUL lost the word boundary the
shape matchers open with, so "prefix<NUL>AKIA...<ESC>..." stopped redacting even
though the unsplit "prefix<NUL>AKIA..." has always redacted. And with the
delimiter between two credentials gone, the unbounded JWT shapes ran out of the
first key and through the header of the next, replacing both with one marker and
exposing the second token's payload and signature. The same reach consumed
ordinary prose written after a key.

Three changes, one rule: a separator is filler inside a credential and a
delimiter outside one, and the difference comes from the source rather than from
the compacted copy.

The pass now runs after the contiguous matchers instead of before them, over
what they left, and never across a replacement. A whole credential is claimed by
its own strict match, so neighbours stay separate and the delimiter between them
survives. The shape matchers used here drop their leading word boundary, and the
byte before the match's original start is what has to be a non-word byte, so a
key after a NUL redacts and a key glued to the end of a word still does not.

The offset table is gone with them. It carried a start and an end for every
source byte, which is sixteen bytes per byte of input on a 64-bit build, and
RedactString is handed whole command output and notification bodies with no
bound in front of it. Only the endpoints of actual matches are resolved now, in
one walk. On a 4 MiB input with a split key, the split path allocated 223.6 MB
before this change and 154.9 MB after, against 146.3 MB for the same text with
no separator in it.

Tests cover a delimiter in front of a split key against its unsplit verdict at
every kind of leading byte, each supported shape placed in front of a JWT with
each class of separator, text immediately behind a key, a caller-supplied
replacement made of word characters, and the allocation ceiling.

* fix(redaction): match split credentials with gap-tolerant shapes on the original text

CodeRabbit found a bypass in the compaction approach:
"xAKIA<NUL>AKIAIOSF<SOH>ODNN7EXAMPLE" leaked while its unsplit counterpart
redacts. With the separators removed first, the leftmost AWS match began at the
decoy AKIA, was then rejected for the "x" in front of it, and had already
consumed the start of the real key, so the real key was never considered. The
ordinary-prose version is any word ending in "sk" followed by a hyphen, "ask-"
or "task-", ahead of a split OpenAI key.

Resuming the scan one byte after each rejection would close that but is
quadratic for the greedy shapes on text like "ask-ask-ask-..." with one NUL in
it. So the boundary goes back inside the pattern instead. Each shape is rewritten
through regexp/syntax so a run of separators may sit between any two characters
it consumes, but never before the first, and matched against the ORIGINAL text
with its leading \b intact. RE2 then judges the word boundary against the real
neighbour in one linear pass, and there is no compaction and no offset mapping.
The separator class is generated from the same Unicode tables as
splitSecretSeparator.

Two further cases came out of rerunning the seeded differential against main:

- The shapes are matched independently and the union of their spans replaced.
  Applied one after another, a split key's unbounded body ran on across a
  separator into a following JWT, stopped at its first dot, and left the JWT
  shape nothing to match, exposing its payload and signature.
- A second pass, ungated, catches a credential glued onto a split one, which
  gains its word boundary only once the first is replaced. It is often whole
  itself, so its region can hold no separator by then.

On 6,000 control-free inputs the output is byte-for-byte main's. On 6,000 with
invisible characters, 147 outputs still hold a known credential once those are
removed, against 2112 on main; 140 of the 147 leak on main too when the input is
unsplit, and the remaining 7 are the one limit now written into the header
comment: a split key whose first fragment is a complete credential on its own,
with another credential glued onto its tail.

* fix(redaction): keep two split credentials of one shape apart

Matches of one pattern never overlap, so when a split credential's body ran
on through the separator after it, a following credential of the same shape
could not start a match of its own. A JWT body stops at the next token's
first dot and a classic GitHub body at its underscore, so two split JWTs
side by side left the second one's payload and signature in the clear.
A match the OpenAI filter rejects spans what it ran into as well, so a
split key after kebab-case prose was rejected with the prose and printed.

When a match runs through a separator run into a position where the same
shape matches again and reaches at least as far, that credential is taken
on its own, and the first is cut at the separators when it is a complete
match by itself. Each piece is filtered on its own. Candidates are tried
latest first and only where the shape's literal prefix follows the
separators, at most eight per match, so the pass stays linear.

The header comment now says plainly that the rest of a key whose front
part is complete alone stays visible, and drops a stale sentence from
before the shapes were matched independently.

* fix(redaction): recognise the next credential when its prefix is split too

The run-on search looked for a shape's literal prefix contiguously after
the separators, so a token split inside its first few characters was never
seen as a new start and its payload leaked again. The prefix is now matched
with gaps allowed between its characters, the way the shapes are. The new
test splits the second token's header at every character, which also
pins that the bounded search is spent only where a credential can begin.

* fix(redaction): take one run-on step per match, not the whole chain

Following a run-on from the second credential into a third, and on to the
end of the chain, did that walk again from every match inside the chain,
which is quadratic: a megabyte of split JWTs back to back allocated about
seventeen gigabytes and ran for ten minutes. Each match now separates only
the credential it ran into. The scan's own next match starts inside that
credential and takes the following step, so a chain is still covered, and
in chains of three or more the markers after the first pair can join.

A test bounds the allocation on a chain of split JWTs; the linear pass uses
about forty times the input and the walk used far more.
2026-10-12 09:24:15 +08:00
Gaurav Bhatia 7f8e5f08bb feat(notify): make permission-prompt alerts on by default and discoverable (#1001)
* feat(notify): make permission-prompt alerts on by default and discoverable

The notify system existed but was silent unless the user hand-edited
config.json, with no UI surface to discover or change it.

- resolver: fall back to mode=both, focusMode=unfocused when the notify
  block is missing or empty (Fixes #579)
- tui: add /notify slash command with popup picker, mirroring /theme;
  explicit choices persist via config.SetNotify
- cli: add `zero config notify` to read/update/reset the preference
  (--mode, --focus, --reset, --json)
- config: add SetNotify writer using the existing atomic-write helper,
  validating against the same vocab the resolver accepts

The TUI effectiveTUINotifyMode default (empty -> both) now matches the
resolver. exec_test.go seeds notify.mode=off where a test asserted
silent stderr, which the old empty-default implicitly provided.

* fix(notify): apply review feedback from CodeRabbit

- cli: omitted --mode/--focus flags now preserve the current resolved
  value instead of wiping it (--reset remains the only clearing path);
  aligns the CLI with the TUI's mode-only preservation behavior
- tui: reject /notify inputs with more than two tokens instead of
  silently accepting them
- tui: apply /notify choices to the live notifier via the new
  notify.Notifier.Configure, so the change takes effect on the next
  permission prompt in the same session (the previous message claimed
  this but only the persisted value was updated)
- notify: add Notifier.Configure (mutex-guarded policy swap that
  preserves sinks, focus state, and the writer)
- tests: mode-only/focus-only CLI preservation, live-notifier apply,
  trailing-argument rejection, Configure immediate-effect + sink
  retention

* fix(notify): read cfg under the lock in Notify

Configure (334c688) made cfg mutable at runtime, but Notify still read
n.cfg.Mode before acquiring n.mu — a data race with a concurrent
Configure. Move the mode check inside the critical section and copy cfg
to a local for all reads.

Regression test TestConfigureConcurrentWithNotify runs Configure
concurrently with Notify; verified it reports DATA RACE on the unfixed
code and passes after the fix (go test -race -count=5).

* fix(notify): keep defaults out of the resolver; preserve the user's own values

Addresses the maintainer review (Vasanthdev2004) on PR #1001. All three
findings share one root cause: the resolved config was treated as if it
were the user's choice.

- resolver: no longer defaults notify.mode/focusMode. The TUI's
  effectiveTUINotifyMode already maps empty -> both on its own, so the
  permission-prompt alert still works out of the box, while headless
  `zero exec` stays byte-identical to base (no BEL/OSC-9 on stderr under
  -o json), the exec empty-stderr fixture is restored, and the
  ZERO_NOTIFY_WEBHOOK_URL sink is not armed by an implicit default.
- cli: `zero config notify` seeds omitted fields from the user's own
  file (new config.UserNotify), never from the resolved view — a
  project config's mode:off can no longer be copied into the user's
  global config, and blank stays blank instead of pinning today's
  default as an explicit choice. --reset remains the only clearing path.
- tui: /notify mode-only changes preserve the focus stored in the
  user's own file (blank stays blank); the /notify picker enumerates the
  full 4x3 mode x focus space so every valid pair is a row, the current
  pair is always preselected, and Enter can never commit a setting the
  user did not choose. State view reads the stored pair.
- tests: the three regressions from the review — exec writes nothing to
  stderr on a clean run (fixture restored + resolver empty-default
  test), a ProjectConfigPath test proving project notify cannot leak
  into the user file, and Enter on an open picker from a pair outside
  the old curated list (off, always) keeps the setting unchanged.

* fix(cli): zero config notify no longer requires a configured provider

The command resolved the full config (providers included) before
touching notification settings, so a fresh user with no provider hit
ErrNoActiveProvider and could not read, set, or reset their notify
preference — the exact first-run user this feature targets (CodeRabbit
review, PR #1001).

The command manages a user preference, so it now talks only to the
user's own config file (config.UserNotify / config.SetNotify) and never
runs config resolution. Display reports the user's stored values — a
project config that overrides notify for one repo is not shown here,
matching the write path's source-of-truth from the maintainer review.

* fix(notify): separate live session policy from persisted user preference

Addresses the review from jatmn (PR #1001). The three notification
states — stored user file, resolved startup pair, live session — are
distinct contracts and must not be conflated:

- handleNotifyCommand now keeps two focus values per update: the LIVE
  focus (in-session, initialized from the resolved pair, so a project's
  focus rule keeps applying) feeds the notifier's Configure and the
  status line; the PERSISTED focus seeds from the user's own file
  (config.UserNotify), so a mode-only /notify writes the mode while the
  user's focus value — blank included — is preserved and project values
  never leak into the global file.
- /notify list reports the live pair (model fields), not the stored
  file, which can legitimately disagree under a project override.
- the /notify picker preselects the live pair; committing a row is an
  explicit choice, so Enter on the preselected row keeps the setting.
- /notify list requires exactly one token, matching /theme, /effort,
  and /style (rejects "/notify list junk").
- shell completions: config gained a notify child with its flags
  (--mode/--focus/--reset/--json), plus a completion-tree assertion for
  both contexts so new CLI surfaces cannot go stale.
- help/copy pass: the CLI help now describes the stored global
  notification preference controlling BOTH the completion and
  needs-input alerts, --reset as returning the TUI to its effective
  default (headless unconfigured stays silent), lists all three focus
  values in /notify usage, and drops the stale "resolver default"
  wording from effectiveTUINotifyMode and the command doc.
- tests: contract-matrix coverage with the three states deliberately
  disagreeing (blank user file + project-resolved off/focused) asserting
  list/picker/preselection/Enter, mode-only and explicit-pair updates on
  both live and persisted sides, and restart precedence; the SA4006
  lint hits are resolved by asserting the returned live model.

lint-static: 0 issues. Affected suites green under -race; the only
remaining local failures are the known environment flakes
(TestRunAuthOpenRouterSavesMintedKey on macOS keychain under a
sanitized env, and TestLoadProviderCommand* 5s subprocess timeouts
under full-suite load — both pass in isolation and fail identically on
clean origin/main here).

* fix(notify): close the remaining review findings (isolation, races, fidelity)

Addresses the third review round (PR #1001) as one boundary pass:

- BTW isolation: /notify mutations and the picker are now behind the
  existing btwCommandUnavailable guard (like /theme), because the side
  conversation shares the parent's notifier pointer — an unblocked
  /notify off inside BTW reconfigured the parent and wrote the global
  file while only the side's display changed. /notify list stays
  available. Regression: side-conversation attempt then return, checking
  the parent's actual notification output and the stored preference.
- Serialization: new UpdateNotify runs read-merge-write as ONE
  transaction under a cross-process advisory lock (flock/LockFileEx on a
  .lock file beside the config, credstore's pattern), so two concurrent
  partial updates (--mode off vs --focus always) cannot interleave and
  silently undo each other's explicit change. The CLI and the TUI's
  mode-only persistence both go through it; the TUI's stale pre-read of
  the stored focus is gone (the merge reads under the lock). Verified
  0/30 lost updates driving the real binary with two concurrent
  processes per round; a goroutine-level regression test asserts both
  explicit fields survive.
- Fidelity: notification writes now edit ONLY the notify member's bytes
  (setNotifyJSONObject, on the existing byte-preserving JSON editor),
  so unrelated values survive with their explicit presence intact —
  including tools.deferThreshold: 0 and MCP disabled: false, which the
  typed serializer's omitempty cannot round-trip, and keys FileConfig
  does not model at all. A reset removes the notify member instead of
  leaving a {} husk.
- Startup recovery: Resolve's ErrNoActiveProvider partial result now
  carries the parsed non-provider fields (notify, sandbox, tools, ...),
  and the setup-recovery branch that clears the resolved config keeps
  the notify block — a stored explicit mode:off no longer resurrects
  alerts through the provider-recovery path. Regression: stale
  activeProvider + stored off/always through recovery asserts the
  launched TUI receives the opt-out, not the both/unfocused default.
- docs nits: zero config --help now describes both alert classes; the
  two remaining "resolver default" wordings updated.

lint-static: 0 issues. Affected packages green under -race (the only
local failure is the known TestRunAuthOpenRouterSavesMintedKey macOS
keychain flake, which fails identically on clean origin/main here).

* fix(config): notify reset removes every duplicate notify member

A hand-edited config may contain duplicate notify members (JSON decoders
tolerate them; the last occurrence wins). The reset path removed only
the final member, so an earlier block survived and became the effective
preference on the next decode — `zero config notify --reset` reported
success while the old value still applied (CodeRabbit review, PR #1001).

Loop the removal until no notify member remains, mirroring
setPetPreferenceJSON's duplicate handling. The replace path still edits
the LAST member, which is the one a decoder treats as effective, so an
earlier duplicate stays inert there.

Regression TestSetNotifyResetRemovesEveryDuplicateNotifyMember verifies
the reset-orphaned failure output on the pre-fix code and passes after;
it also pins the no-op reset and the last-member-replace behavior.

* fix(config): join UpdateNotify release error; restore main's test surface

CodeRabbit pass on the rebased head, three findings:

- UpdateNotify discarded the advisory-lock release error while every
  other writer joins it into the result (errors.Join). A failed unlock
  would leave later config updates blocked while the caller is told
  success. Now named-result + deferred Join, matching SetPet et al.
- The rebase had resolved model_test.go by taking the pre-rebase file,
  which silently dropped every test upstream added or changed since the
  fork point (composer blink/cursor suite, beginRun run-details,
  scrim-preserves-semantic-colors, session payload, Termux burst, ...).
  Rebuilt the file from origin/main plus only this branch's own additions
  (TestEffectiveTUINotifyMode), so main's newer assertions are back:
  the stale scrim assertion now expects semantic ANSI preservation, and
  the removed TestBeginRunResetsSidebarHidden stays removed exactly as
  upstream had already replaced it with TestBeginRunKeepsRunDetailsClosed.

* fix(tui): restore pickerTheme's transcript append; pin the picker failure path

Rebase regression (Vasanthdev2004, PR #1001): resolving the choosePicker
conflict attached the shared transcript-append line to the new
pickerNotify case, so the pickerTheme case lost it — a theme choice
whose preference could not be saved fell out with the session theme
changed and nothing reported, while the /theme text path kept its line,
so no test went red. Restore main's line in the pickerTheme case.

New regression TestThemePickerAppendNoteOnFailedSave drives the exact
reported shape: UserConfigPath under a regular file (the write must
fail), a pickerTheme item, choosePicker, and the could-not-save note
asserted in the transcript. Verified it fails on the pre-fix code with
the reviewer's own repro output and passes after.

Also restores the TestEffectiveTUINotifyMode comment to the post-review
wording (the default lives in the TUI, not config.Resolve, so headless
runs stay silent); the model_test.go rebuild had pulled the older
pre-notify phrasing back in.

* test(tui): restore the default palette after the picker regression test

Vasanthdev2004 follow-up (PR #1001): handleThemeCommand applies its
theme to the package-level zeroTheme, so TestThemePickerAppendNoteOnFailedSave
left dracula installed and the next palette-sensitive test
(TestFocusedPermissionSelectedRowUsesSelectionTintNotBrandChip) read
dracula's selection tint instead of the default. One line, the package's
existing convention: defer applyTheme(themeDark, true), as every other
theme-committing test already does. Verified the tint test fails in
sequence before the defer and the full internal/tui suite passes under
-race after.

* fix(tui): label the effective-default notify mode as "(default)"

jatmn round (PR #1001): the TUI printed the effective default mode as a
bare "both" while the new CLI labeled the same configuration "mode:
(default)" — the two first-party surfaces contradicted each other, and a
user could not tell whether "both" was stored or effective. Focus
already labeled its built-in default ("unfocused (default)"); mode now
mirrors it.

The model keeps the RAW configured mode (notifyConfiguredMode) alongside
the effective one, so state output can distinguish "both because the user
chose both" from "both because nothing is configured" — clearing to the
raw value when an explicit choice is made. effectiveModeLabel renders the
(default) tag on the /notify list card and the /notify change line, the
same wording the CLI read path uses. No defaults are persisted; resolver
and headless behavior untouched.

Regression TestNotifyStateLabelsEffectiveDefaultMode pins the contract:
unconfigured shows "both (default)", explicit stored "both" renders bare,
an explicit /notify bell choice renders bare in both the change line and
later state. Verified it fails on the pre-fix code and passes after.

* fix(config): one lock authority; full-replace across duplicate notify keys

jatmn round (PR #1001), two findings plus one documented precedence:

- UpdateNotify took a SECOND lock primitive over the same config.json.lock.
  On Windows its LockFileEx range did not overlap lockutil's, so notify
  writes could race provider/theme/credential/MCP writes and silently
  discard them; on every platform it also bypassed the established
  no-follow path checks and the bounded wait. Delete filelock_unix.go /
  filelock_windows.go entirely and use the existing lockConfigFileFn
  authority every other mutator already shares. New regression
  TestUpdateNotifyRacesExistingConfigMutatorWithoutLoss holds notify vs
  SetTheme and requires both acknowledged edits to survive; the real
  binary also races 30/30 clean cross-process on the shared lock.

- setNotifyJSONObject replaced only the last duplicate notify member, but
  Go's decoder MERGES the fields of duplicate members into one struct, so
  a replacement omitting a field inherited it from an earlier duplicate
  ({"notify":{"mode":"off"},"notify":{"focusMode":"focused"}} +
  SetNotify({FocusMode:"always"}) left mode "off" effective). The
  nonempty path now removes every duplicate and inserts the replacement
  fresh — full replace semantics, nothing inherited. UpdateNotify's
  partial merge is unaffected (it seeds omitted fields before the edit).
  Regression TestSetNotifyReplacementInheritsNoFieldFromEarlierDuplicate,
  verified failing on the pre-fix editor and passing after.

- zero config notify --reset combined with --mode/--focus now documents
  its precedence: --reset wins, explicit values are ignored (the help
  previously did not define the combination).

* fix(config): replace single notify member in place; reserve wipe for duplicates

Vasanthdev2004 review note (PR #1001, non-blocking): the duplicate-key fix
ran remove-and-reinsert for EVERY notify write, including the ordinary
single-member file — insertJSONMember appends after the last member, so
every /notify toggle moved the notify key to the end of the config object
and collapsed its formatting while everything around kept theirs.
Nothing was lost (member order carries no meaning), but a byte-preserving
write should not reformat what it need not touch.

setNotifyJSONObject now counts notify members first: exactly one is a
positional value replace (key stays where the user put it, surrounding
bytes untouched); two or more keeps the remove-every-duplicate-and-insert
path, where Go's field-merging decode would otherwise let an earlier
block leak fields into the replacement. Regression
TestSetNotifySingleMemberReplacedInPlace asserts exact bytes through an
in-place replace, verified failing on the over-eager version and passing
after; the duplicate regression and the whole config suite stay green.
2026-10-12 09:23:37 +08:00
Anandan 99721c762f fix(providers): send stable session headers to OpenCode (#1060) 2026-09-16 07:58:40 +08:00
Fayupable dcf229fecd refactor(tests): replace duplicated wait-for helpers with testutil.WaitFor (#1039)
Fixes #1023
2026-09-15 13:16:54 +08:00
github-actions[bot] 3fae0de2ea chore(main): release 0.9.0 (#1059)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-09-15 13:05:02 +08:00
github-actions[bot] 4ba112a0eb chore(main): release 0.9.0 (#958)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-09-15 12:59:18 +08:00
euxaristiaandeuxaristia b76c764b77 fix(providerio): close idle connections periodically to prevent HTTP/2 PROTOCOL_ERROR (#1058)
* fix(providerio): close idle connections periodically to prevent HTTP/2 PROTOCOL_ERROR

OpenRouter and other HTTP/2 backends can silently reset or drop connections
that sit idle in Go's client connection pool across user turns. When a
subsequent turn reuses the stale connection, it fails on stream connect with
PROTOCOL_ERROR.

Launch a background idle connection closer on non-darwin platforms to evict
idle pooled connections on a 30-second interval, forcing fresh handshakes
after idle pauses while preserving connection reuse across fast agent turns.

Refs #1057

* test(providerio): poll for idle connection eviction with bounded deadline

Replace the fixed 60ms sleep in TestStartIdleConnCloserClosesIdleConnections
with a 2-second bounded polling loop to avoid test flakes under heavy CI
load or under the race detector.

Refs #1057

---------

Co-authored-by: euxaristia <[email protected]>
2026-09-15 12:58:58 +08:00
Anandan d73ec81e2d feat(tui): interactive terminal attach for tty exec sessions (#1055)
* feat(execution): add a stdin-only write for interactive processes

* feat(tui): attach overlay for interactive terminal sessions

* feat(tui): add /attach for interactive terminal sessions

* feat(tools): point the model at tty sessions for interactive prompts

* feat(tui): open the terminal automatically for tty exec sessions

* feat(tools): tell the model to keep polling while the user types

* feat(tui): give attached terminal sessions the full viewport

* feat(tools): route sudo through an escalated tty session

* fix(tui): route the auto-attach tick to its run

* fix(tui): defer auto-attach while another view owns the screen

* fix(tui): keep bracketed-paste framing for attached sessions

* fix(tools): let tty sessions run interactive commands

* fix(tui): capture the auto-attach baseline before the exec starts
2026-09-15 08:40:47 +08:00
Vladimir Babin 2479d946c1 fix(mcp): reject a server-initiated request as a streamable-HTTP response (#1054)
* fix(mcp): reject a server-initiated request as a streamable-HTTP response

networkClient.request matched the decoded POST body to the in-flight call on
id alone, so a server that answered tools/call with a method-bearing frame
produced an empty, error-free result. Apply the same isRequestOrNotification
guard the SSE paths already use and return a protocol error naming the method.

Fixes #1050

Signed-off-by: Vladimir Babin <[email protected]>

* fix(mcp): describe the unexpected frame by method, not as a request

A method-bearing frame without an id is a notification, so the error
should not call it a request.

Signed-off-by: Vladimir Babin <[email protected]>

---------

Signed-off-by: Vladimir Babin <[email protected]>
2026-09-15 08:40:10 +08:00
Pujitha PaladuguandPujitha Paladugu d76f7f6931 ci: run the action-summary test suite in zero-action-smoke (#1053)
Motivation: scripts/action-summary.mjs is invoked by the composite
action in action.yml on every action run, and ships with a 12-test
suite in scripts/action-summary.test.mjs. Neither the action-smoke
workflow nor the main Go-focused ci.yml executed that suite, and the
action-smoke workflow's paths filters did not even include those two
script files, so a change to the helper or its tests triggered no CI
job at all.

Approach: add scripts/action-summary.mjs and
scripts/action-summary.test.mjs to both the pull_request and push
paths filters in .github/workflows/zero-action-smoke.yml, and add a
Setup Node (actions/setup-node, node-version 24, matching the pin
already used in release-artifacts.yml) step followed by a
`node --test scripts/action-summary.test.mjs` step in the validate
job, ahead of the existing action.yml validation steps.

Validation:
- `node --test scripts/action-summary.test.mjs` passes locally: 12/12
  tests pass (Node v22.23.1; repo engines field requires >=18, CI pins
  24).
- Broke an assertion in a scratch copy of the test file outside the
  repo and reran `node --test`: 1 test failed and the process exited
  non-zero, confirming the new CI step actually gates on failures as
  the issue requires.
- `python3 -c "import yaml; yaml.safe_load(...)"` confirms the edited
  workflow YAML still parses.
- Confirmed .github/workflows/ci.yml has no Node step (Go-only
  pipeline), so zero-action-smoke.yml is the correct integration
  point.
- Base branch CI (upstream/main) is currently green.

Report: https://github.com/Gitlawb/zero/issues/1030

Assisted-by: claude-sonnet-5 (via Claude Code)

Signed-off-by: Pujitha Paladugu <[email protected]>
Co-authored-by: Pujitha Paladugu <[email protected]>
2026-09-15 08:39:40 +08:00
Pujitha PaladuguandPujitha Paladugu 37d69c96ce ci: disable persisted checkout credentials on release workflows (#1052)
Motivation:
release-artifacts.yml's three actions/checkout steps (ordinary checkout,
workflow-dispatch requested-ref checkout, and the npm-publish job
checkout) and pr-auto-review.yml's single checkout step all leave
persist-credentials at its default, so the workflow token stays in the
job's local git config for every later step, including build/test/package
steps that never need it. ci.yml and zero-action-smoke.yml already
disable this. Neither modified file runs git push/commit/tag: the GitHub
Release publish step supplies its own explicit GH_TOKEN, and npm
publishing uses OIDC trusted publishing, so removing the persisted
credential changes no behavior.

Approach:
Add `persist-credentials: false` to each of the four checkout steps,
matching the exact convention already used in ci.yml/zero-action-smoke.yml.

Validation:
- go build ./... and go vet ./... pass.
- gofmt -l . reports nothing.
- go test ./internal/installtest/... passes.
- actionlint on both changed files reports only two pre-existing shellcheck
  warnings in release-artifacts.yml (SC2016, SC2034) that are unrelated to
  this diff and were confirmed present before the change as well (verified
  by stashing and re-running actionlint against the original files).
- A live GitHub Actions run of the release/publish workflows was not
  reproducible locally; this change only disables a default the workflows
  never rely on, so no functional change is expected.

Report: https://github.com/Gitlawb/zero/issues/1029

Assisted-by: claude-sonnet-5 (via Claude Code)

Signed-off-by: Pujitha Paladugu <[email protected]>
Co-authored-by: Pujitha Paladugu <[email protected]>
2026-09-15 08:39:05 +08:00
Pujitha PaladuguandPujitha Paladugu 176f5a242b fix(oauth): force-close loopback callback connections past the shutdown deadline (#1051)
* fix(oauth): force-close loopback callback connections past the shutdown deadline

Motivation:
LoopbackListener.Close shuts the callback server down with a 1-second
context and discards the result. http.Server.Shutdown only closes idle
connections, so a client that sends a partial request header and never
finishes it counts as active; Shutdown blocks out the full deadline,
times out, and Close returns while that connection and its Serve
goroutine remain open indefinitely. This is a loopback-only (127.0.0.1),
short-lived, single-use server, so the impact is a resource leak past
the end of the flow, not a remote vulnerability or crash.

Approach:
- add a ReadHeaderTimeout to the server so a stalled request header is
  bounded on its own;
- follow a timed-out or otherwise failed Shutdown with server.Close()
  to forcibly tear down any connection still open past the deadline;
- join the Serve goroutine (via a done channel) before Close returns,
  so its completion is observable instead of left dangling.

internal/mcp/oauth.go and internal/provideroauth/openrouter.go build
their own loopback servers with the same pattern; they are left
untouched here to keep this change scoped to the file the issue's
reproduction demonstrates.

Validation:
- go build ./internal/oauth/... and go vet ./internal/oauth/...: clean.
- gofmt -l internal/oauth/: clean (make fmt-check clean repo-wide).
- go test ./internal/oauth/... -race -v -count=1: all pass, including
  three new tests (TestLoopbackCloseForceClosesSlowHeaderConnection,
  TestLoopbackCloseIsIdempotent,
  TestLoopbackCloseReturnsPromptlyAfterSuccessfulCallback).
- Confirmed TestLoopbackCloseForceClosesSlowHeaderConnection fails
  against the pre-fix loopback.go (with the new test kept) and passes
  with the fix applied.
- Full repo go build ./..., go vet ./..., and go test ./... pass.
- Could not run make lint-static (golangci-lint) locally: it needs a
  very large dependency download and the local environment ran out of
  disk space fetching it. That check is continue-on-error (advisory)
  in .github/workflows/ci.yml, not a required gate.

Report: https://github.com/Gitlawb/zero/issues/1028
Signed-off-by: Pujitha Paladugu <[email protected]>
Assisted-by: claude-sonnet-5 (via Claude Code)

* test(oauth): synchronize loopback regression tests instead of sleeping

TestLoopbackCloseForceClosesSlowHeaderConnection relied on a fixed
100ms sleep to let Serve accept a connection before sending a stalled
header; under load this could fail or silently skip the force-close
path it's meant to exercise. Use an http.Server.ConnState hook (via a
new unexported newLoopbackListener, set before Serve starts to avoid
a race) to wait for the real StateNew signal instead.

TestLoopbackCloseReturnsPromptlyAfterSuccessfulCallback measured
Close's elapsed time without joining its http.Get goroutine or
draining/closing the response body; join it first so the timing
starts from a connection that's actually back to idle.

Also register t.Cleanup(l.Close) in both tests so a Fatalf between
construction and the existing explicit Close doesn't leak the
listener's Serve goroutine.

Signed-off-by: Pujitha Paladugu <[email protected]>

---------

Signed-off-by: Pujitha Paladugu <[email protected]>
Co-authored-by: Pujitha Paladugu <[email protected]>
2026-09-15 08:37:58 +08:00
Anandan 9e609c5242 feat(exec): default headless runs to the turn-budget ceiling (#1049)
* feat(config): report whether the tool-turn budget was explicitly set

ResolvedConfig.MaxTurns is ambiguous today: a configured 80 and the
built-in default 80 are indistinguishable, so a caller that wants a
different default cannot tell "user chose this" from "nobody set it".
FileConfig now records at each merge gate (config files, provider
command, ZERO_MAX_TURNS, CLI overrides) when a source supplies a
positive maxTurns, and Resolve surfaces it as MaxTurnsSet.

* feat(exec): default headless runs to the turn-budget ceiling

The shared 80-turn default is sized for the TUI, where the user can
say "continue" and the run resumes. Headless exec has nobody to do
that, so benchmark evals truncated at 80 turns mid-implementation.
An unset budget now resolves to MaxTurnsCeiling for exec only; any
explicit source (flag, mode preset, ZERO_MAX_TURNS, config file) and
exec-profile budgets keep their precedence. The TUI default is
unchanged.

* test(cli,config): cover the headless turn-budget default

* refactor(cli): extract the headless turn-budget decision
2026-09-15 08:37:09 +08:00
Anandan 2f531178f6 feat(tui): richer /resume session browser (#1047)
* feat(tui): support an optional detail line on picker rows

Add a faint second line under picker items so richer surfaces can show
secondary metadata (project, model, size) without crowding the label.
The line renders on the selection band, joins the search haystack, and
only appears at medium width and above; detail rows halve the visible
window so the overlay keeps its height, and mouse hit-testing maps a
click on either line to the same item.

* feat(tui): show project, model, and size under /resume rows

Each session row now carries a detail line with the project directory
(~-contracted), the model that ran it, and its event count, plus a
status chip for forks and tagged sessions. The line also joins the
filter haystack, so searching by project path or model name works.
Narrow terminals keep the existing single-line list.

* feat(tui): show relative last-activity age in /resume rows

Replace the absolute timestamp column with relative age ("2h ago",
"12d ago") so recency reads at a glance; distant history still shows
the calendar date. Also adds coverage for the detail line: rendering
at wide vs narrow tiers, status chips, and filtering by model.

* refactor(tui): drop the event count from /resume detail rows

Event count is an internal metric — the detail line now carries just
project, model, and the status chip, which are the fields that
actually discriminate sessions.

* fix(tui): sanitize session metadata before it reaches picker rows

Persisted titles (--session-title, /rename) and other metadata can
legally contain newlines; a stray line would break the picker's fixed
row geometry and misalign mouse hit-testing. Reuse sanitizeCardField
for the label and detail line, and cover the detail-line click path.

* fix(tui): strip carriage returns from rendered session metadata

A \r in a persisted title or model id rewinds the cursor to column 0
and overwrites the row. Fold it into sanitizeCardField alongside the
existing newline handling, and cover it in the picker test.

* fix(tui): pad picker detail lines on the selection band

The detail line only painted its text, so the selection highlight
stopped at the end of the text instead of spanning the row like the
label line above it. Pad the remainder with the row surface.

* test(tui): cover carriage returns in sanitizeCardField

* fix(tui): drop all control runes from rendered session metadata

The picker detail line newly renders cwd, model id, and tag, and any
of them can legally carry ESC/CSI/OSC/BEL/BS/VT/NEL bytes — a project
directory name containing \x1b[2J would repaint the terminal when the
picker opens. sanitizeCardField now maps line separators and tabs to
spaces and drops every remaining control rune, matching the package's
other sanitizers; this also closes the same hole for titles, which
predates the detail line.

* fix(tui): include exactly-30-day sessions in the relative-age branch
2026-09-15 08:36:37 +08:00
Vasanth T 3b43c43eca fix(sandbox): keep a derived ACL target inside the write root it came from (#1040)
* fix(sandbox): keep a derived ACL target inside the write root it came from

FILE_FLAG_OPEN_REPARSE_POINT refuses a reparse point at the final path
component and resolves every component above it, which is right for a path
the operator named and not enough for one this package derived. The
write-root carveouts are derived: <root>/.git/hooks and <root>/.git/config
are constructed from a root, and <root>/.git is a name an unprivileged
workspace writer can create before setup runs. mklink /J needs no
privilege, so a junction there had the apply open <junction-target>/hooks,
an ordinary directory with nothing wrong about its final component, and
zero sandbox setup wrote a deny ACE on it as Administrator, outside the
workspace.

A derived entry now carries the root it came from, and the apply requires
the object it finally holds to still live under it. The check is on the
handle rather than the name: GetFinalPathNameByHandle answers where the
open object actually is, and both sides go through it so the spelling
normalizes the same way. Materialization is checked before it creates,
since os.MkdirAll follows the same reparse points.

Deliberately not a refusal of every reparse point on the path. Above the
write root the path is the operator's, who may keep a workspace under a
junction or a mapped directory; only the tail the sandbox appended is held
strict.

Closes #1024

* fix(sandbox): reject the reparse ancestor itself before materializing

The pre-create check asked where the deepest existing ancestor lives, and
a junction answers with its own path: the open does not follow a
final-component reparse point, so <root>/.git came back as <root>/.git and
matched. os.MkdirAll does follow it. What disqualifies that ancestor is
that it IS a reparse point, not where it reports living.

Found by reverting the check and watching the test still pass, which said
the guard was doing nothing rather than that the test was weak. Pinned
against the function now, because through the whole apply the create is
made and then removed on the failure path, so the filesystem afterwards
looks identical either way. The plan wiring gets its own test for the same
reason: every apply-level case here hands the group an anchor directly, so
none of them would notice the builder never setting one.

* fix(sandbox): anchor only the carveouts that are under the root, and put the DACL back

Two from review, both right.

ReadOnlySubpaths is a profile field an operator can set to any path, and one
placed outside the write root is a configuration that works today; anchoring
it unconditionally turned that into a containment refusal. Only paths
lexically under the root are anchored now, which is where the derived
carveouts are anyway, and anything else keeps the final-component guard it
always had.

The successful-apply tests left their deny ACE in place, so t.TempDir could
fail to remove the tree. Restoring the snapshot exposed the sharper half:
the ACE denied the group the test runs as, which revoked its own WRITE_DAC
and left the rollback unable to reopen the target. The tests deny a group
this process is not a member of instead, which is what a capability SID is
in production, and roll back afterwards.

* test(sandbox): check the plan anchoring on every platform, out-of-root case included

The plan builder is cross-platform and its anchor tests were in a Windows-only
file, so nothing checked the wiring on Linux or macOS. Moved beside the other
BuildWindowsACLPlan tests, with the case review raised: an out-of-root
ReadOnlySubpath stays unanchored, and an in-root one is still held to its
write root.

* test(sandbox): give the cross-platform anchor tests native paths

Moving these beside the other BuildWindowsACLPlan tests made them run on
Linux and macOS, where their Windows path literals stop meaning what they
say: pathWithinRoot is filepath.Rel underneath, a backslash is an ordinary
character off Windows, and C:\workspace\.git\hooks is then one component
that is not under C:\workspace, so every anchor came back empty. The
neighbouring tests get away with such literals because they only compare
strings they built the same way and never ask whether one contains another.

Paths are built with filepath.Join from temp roots now, so the containment
question is asked in the separator the running platform actually uses.
2026-09-15 08:35:54 +08:00
KRATOS faec8ee201 feat(acp): emit structured file changes in tool updates (#999)
* feat(tools): retain bounded structured file diffs

* feat(acp): emit structured file changes in tool updates

* fix(acp): harden structured file diff transport

* fix(tools): reject unsafe rich diff previews

* fix(acp): preserve safe rich diff semantics

* fix(tools): bind structured diffs to committed changes

* fix(acp): preserve structured diff identity

* fix(tools): close rich diff race gaps

* fix(tools): report partial patch paths from workspace

* fix(tools): finalize structured diff evidence

* fix(tools): separate unverified patch writes

* test(tools): cover failed formatter recovery

* fix(acp): serialize file deletions as nullable diffs

* fix(tools): recreate files after formatter failure

* test(acp): require nullable diff fields on wire
2026-09-15 08:31:39 +08:00
KRATOS c1937dfac7 feat(acp): add standard session list and resume (#914)
* feat(acp): add standard session list and resume

Origin-Session: local-abff1c | Claude Code | 7 prompts
Origin-Snapshot: b7d0806d49f9
Origin-Session: local-13d543 | Claude Code | 5 prompts
Origin-Snapshot: 6b5eb8ba4e5b
Origin-Session: local-c962d7 | Claude Code | 3 prompts
Origin-Snapshot: d2b8f44a9abc

* fix(acp): preserve notification wire order

Origin-Session: local-abff1c | Claude Code | 7 prompts
Origin-Snapshot: b7d0806d49f9
Origin-Session: local-13d543 | Claude Code | 5 prompts
Origin-Snapshot: 6b5eb8ba4e5b
Origin-Session: local-c962d7 | Claude Code | 3 prompts
Origin-Snapshot: d2b8f44a9abc

* fix(acp): bind sessions to persisted workspaces

Origin-Session: local-abff1c | Claude Code | 7 prompts
Origin-Snapshot: b7d0806d49f9
Origin-Session: local-13d543 | Claude Code | 5 prompts
Origin-Snapshot: 6b5eb8ba4e5b
Origin-Session: local-c962d7 | Claude Code | 3 prompts
Origin-Snapshot: d2b8f44a9abc

* fix(acp): compare workspaces by filesystem identity, not by spelling

@Vasanthdev2004's three findings, all reproduced before changing anything.

TWO SPELLINGS OF ONE DIRECTORY WERE TWO WORKSPACES. Both comparisons were string
equality on ResolveWorkspaceRoot output, and that resolver is abs plus
filepath.Clean plus a stat — it does not fold case and does not resolve
junctions. A session persisted from the TUI was unresumable from an editor
holding a different spelling of the same project folder, and session/list
filtered by the other spelling returned nothing, which makes it an invisible
failure rather than a reported one. It lands on exactly the case this feature
exists for, and on the two processes most likely to disagree about spelling.

os.SameFile asks the filesystem which directories these are, which is the
question. filepath.EvalSymlinks is NOT the fix on Windows — it normalises a
drive letter and returns a junction path unchanged, so the alias survives it,
and junctions need no privilege. String equality stays as the fast path, and a
stat failure falls back to it rather than widening the match: this gate refuses
access to another workspace's files and configuration, so an unanswerable
comparison denies.

THE TEST COULD NOT SEE ANY OF IT. testDeps resolves with the identity function,
so the guard degenerated to "are these two strings different" fed two unrelated
temp directories — it could only ever answer yes. The rejection direction was
pinned and the acceptance direction was asserted nowhere. The new test uses a
resolver reproducing the production normalisation and drives the ACCEPTANCE
direction through an alias, skipping if the filesystem folds the alias away so
it never passes vacuously. Reverting to string equality fails it three ways:
load, resume, and a list that returns zero.

THE WIRE KEYS ARE AN EXTERNAL CONTRACT. Nothing pinned sessionId, cwd, title,
updatedAt, _meta, modelId, createdAt, sessions, nextCursor, cursor, loadSession,
promptCapabilities, sessionCapabilities, list or resume, so renaming a Go field
would break every client and leave the suite green. Renaming modelId to model_id
now fails.

Origin-Session: local-79d7a0 | Claude Code | 5 prompts
Origin-Snapshot: c175cabb9d50
Origin-Session: local-13d543 | Claude Code | 5 prompts
Origin-Snapshot: 6b5eb8ba4e5b
Origin-Session: local-c962d7 | Claude Code | 3 prompts
Origin-Snapshot: d2b8f44a9abc

* fix(acp): test the alias guard where the bug lives, and stop listing unusable sessions

THE TEST COULD NOT RUN ON THE PLATFORM THE BUG IS FROM. @Vasanthdev2004's point,
and he is right that it is not a nit. The alias test built its second name with
os.Symlink, which needs a privilege an ordinary Windows session does not hold, so
it SKIPPED there — and Windows is where junctions exist and where this defect came
from. The identity guard was verified by CI on the two platforms that never had
the problem. It now builds the alias with mklink /J on Windows, which needs no
privilege and is how he found the defect in the first place, and keeps the symlink
arm elsewhere.

This is the second time in this series a correct fix shipped with a test that
could not exercise it: the same helper shape was added to internal/memory for the
same reason a day earlier.

A SESSION WITH NO PERSISTED WORKSPACE IS NOT RESUMABLE, SO IT IS NOT LISTED.
CodeRabbit's finding. activatePersistedSession refuses an empty Cwd, but the
listing advertised it anyway — a menu entry that only fails when taken. The test
asserts both halves, because the listing is only correct relative to what resume
will accept: the omitted session is checked to really fail on resume, and a usable
session is checked to survive the filter.

Mutation-checked: removing the filter advertises the unusable session again.
Origin-Session: local-13d543 | Claude Code | 5 prompts
Origin-Snapshot: 6b5eb8ba4e5b
Origin-Session: local-c962d7 | Claude Code | 3 prompts
Origin-Snapshot: d2b8f44a9abc

* fix(acp): resolve every listed workspace, not only when a filter was supplied

@anandh8x's P1, both halves reproduced before changing anything.

The loop resolved item.Cwd only when a cwd filter was present, and skipped only a
blank one. Two shapes stayed on the menu that session/resume then refuses:

  - a session whose persisted workspace has since been deleted, advertised as
    resumable
  - a legacy entry holding a relative path, reported as cwd "." although ACP
    requires SessionInfo.cwd to be absolute

Every entry is now resolved unconditionally, anything that cannot resolve is
omitted, and the RESOLVED root is what SessionInfo carries — absolute as the
contract requires, and the same value the client hands back on resume. The
optional identity filter then applies to the resolved roots, which is also where
it belonged.

Listing is a menu: activatePersistedSession resolves and refuses what it cannot
reach, so anything this loop cannot resolve is something a client would be
offered and then denied.

Mutation-checked: restoring the resolve-only-when-filtered shape re-advertises
the deleted workspace and re-emits the relative cwd.

Origin-Session: local-76c8d7 | Claude Code | 6 prompts
Origin-Snapshot: 259b715cf0fd
Origin-Session: local-13d543 | Claude Code | 5 prompts
Origin-Snapshot: 6b5eb8ba4e5b
Origin-Session: local-c962d7 | Claude Code | 3 prompts
Origin-Snapshot: d2b8f44a9abc

* test(acp): assert the relative entry is retained, not merely absolute

CodeRabbit's catch, and the test was genuinely weaker than it looked. It checked
that the deleted workspace was gone, the live one kept, and every listed cwd
absolute — all of which a "fix" that simply DISCARDED any non-absolute entry
would satisfy, while losing a resumable session.

Presence is now asserted separately from spelling: the relative entry must still
be listed, and listed with an absolute path.

Mutation-checked: skipping non-absolute entries instead of resolving them now
fails with "a session with a resolvable relative workspace was dropped rather
than normalised". The first attempt at that mutation did not compile, so it
proved nothing until it was rewritten — worth saying, because a mutation that
fails to build looks exactly like a test that passes.

Origin-Session: local-13d543 | Claude Code | 5 prompts
Origin-Snapshot: 6b5eb8ba4e5b
Origin-Session: local-c962d7 | Claude Code | 3 prompts
Origin-Snapshot: d2b8f44a9abc

* fix(acp): a relative persisted workspace is omitted, not rebased

Reported by @anandh8x. Resolving a stored relative cwd does not recover the
session's workspace, it invents one: ResolveWorkspaceRoot joins it against
whatever directory the ACP server happens to be running in, and that invented
absolute path was then advertised as the session's workspace and accepted as
its home on resume.

Reproduced on f2c6fc9a, a session persisted with cwd ".":

  LISTED legacy-rel as cwd="/Users/kratos/dev/f914/internal/acp"
  resume with an UNRELATED workspace -> err=... cwd does not match its persisted workspace
  resume with NO cwd (falls back to ".") -> err=<nil>

The mismatch check does its job when the client names a workspace, so the only
opening was the fallback path, where the rebased value was compared against
itself and always agreed. A conversation created for one project could be
resumed against another project's files, configuration and tools.

Both doors now take the same guard: handleSessionList omits an entry whose
persisted cwd is not absolute, and activatePersistedSession refuses one rather
than resolving it. The original base is not knowable from the metadata, so
guessing at it is not an option a fix can take.

This reverses an earlier assertion in TestSessionListResolvesEveryWorkspace,
which expected the relative entry to be normalised and retained. That was
requested in review on the grounds that dropping it loses a resumable session.
It does, but the entry was never resumable into its own workspace, only into
this process's. The test now asserts it is dropped, and a new
TestResumeRefusesARelativePersistedWorkspace covers the fallback path that the
listing filter alone leaves open.

Both guards mutation-checked: removing either one fails its test.
Pre-existing on this branch and on its merge-base, unrelated to this change:
TestRunDoctorFormatsRedactedProviderDiagnostics and
TestRunDoctorConnectivityProbesProvider both exit 3 in this environment.

Origin-Session: local-8cd239 | Claude Code | 11 prompts
Origin-Snapshot: 365efe3045f2
Origin-Session: local-13d543 | Claude Code | 5 prompts
Origin-Snapshot: 6b5eb8ba4e5b
Origin-Session: local-c962d7 | Claude Code | 3 prompts
Origin-Snapshot: d2b8f44a9abc

* test(acp): the resume guard is asserted through both activating methods

Raised by CodeRabbit: the test is named for resume and called session/load.

session/load and session/resume are separate entry points that today share
activatePersistedSession, so an assertion through either one passes while the
guard holds — but the name promised a surface it was not touching. Both are now
named explicitly, which keeps that true: if resume is ever given its own path,
this fails rather than quietly covering half of what it claims to.

With the guard removed, both methods accept a relative persisted workspace on
the no-cwd fallback. The named-workspace case was already refused by the
existing mismatch check; the fallback was the only door open, and it is open on
both.

Not taken in this PR, from the same review: threading a rooted directory handle
through ResolveWorkspaceRoot and workspace construction so a root rename or link
swap cannot redirect later file operations. That is a real question and a
pre-existing one — this change adds a refusal and no path handling — but it is a
capability refactor across workspace and tool access with its own race test, not
something to fold into a session-list fix. Worth its own issue.

Origin-Session: local-8cd239 | Claude Code | 11 prompts
Origin-Snapshot: 365efe3045f2
Origin-Session: local-13d543 | Claude Code | 5 prompts
Origin-Snapshot: 6b5eb8ba4e5b
Origin-Session: local-c962d7 | Claude Code | 3 prompts
Origin-Snapshot: d2b8f44a9abc

* fix(acp): activation requires an absolute cwd in the REQUEST, not just the record

Reported by @jatmn.

ResumeSessionParams is a type alias for LoadSessionParams, so JSON decoding turns
an OMITTED resume cwd into an empty string. That blank reached the shared
activation path, whose blank-cwd fallback substitutes meta.Cwd — so
{"sessionId":"known"} activated a persisted session, even though ACP v1 requires
session/resume to carry an absolute working directory.

This is a different hole from the persisted-cwd one fixed earlier on this branch.
That guard asks whether the STORED workspace is identifiable; this asks whether
the CALLER named one at all. The earlier fix does not cover it, because a stored
absolute cwd passes that check and the blank request then silently inherits it.

requestedWorkspace validates the request's own cwd before anything reaches the
fallback: absent, empty and whitespace-only are all invalid params, and so is a
relative path. Applied to both activating methods rather than to resume alone —
load's omitted-cwd fallback was inheriting the same way, and leaving one door
open is how this class survived the last fix.

Mutation-checked at the wire, which is where the defect lives: making the blank
case return no error compiles and fails the regression on four separate requests
— session/load and session/resume, each with cwd omitted and with cwd blank. A
Go-level test would not have caught it, since the defect is in decoding an absent
field.

Two notes from a verification pass, neither a defect:

Error precedence changed: a blank cwd with an UNKNOWN session id now reports the
cwd problem instead of "session not found". Kept deliberately and pinned — it
stops the server confirming whether a session exists to a request that named no
workspace.

AdditionalDirectories is declared on two params structs and consumed nowhere in
the repo. Not a hole today, but it is the same shape — client-supplied paths with
no absoluteness rule — so the field now carries a note saying it must go through
requestedWorkspace when wired up.

Rebased onto ad34dc8d. go test -race ./internal/acp/ -count=5: clean.
Pre-existing here and on main: TestRunDoctorFormatsRedactedProviderDiagnostics
and TestRunDoctorConnectivityProbesProvider exit 3 in this environment.

Origin-Session: local-c962d7 | Claude Code | 3 prompts
Origin-Snapshot: d2b8f44a9abc

* fix(acp): resume the conversation a session became, and fail closed when it cannot

Three defects in ACP session restoration, all reported by @jatmn.

loadHistory read the raw event log and kept only EventMessage. A compacted
session stores its original prefix alongside an EventCompaction naming the
events it replaced and carrying their summary, so restoring from the raw log
replayed superseded turns AND dropped the summary that replaced them. It now
reads the same rehydrated view the TUI and exec paths use, and explicitly
projects the compaction summary -- switching readers alone would still drop it,
because rehydration substitutes the compaction event in place of what it
replaced.

historyErr only suppressed replay and raised a warning: the session was
registered and reported ready regardless, so an unreadable events file left the
caller holding a live, promptable session ID whose next prompt ran as a fresh
conversation under the old identity. Resume now fails. Load keeps the
best-effort policy deliberately rather than by inheriting the shared helper.

Tool calls and their results were dropped from session/load, so a restored
transcript showed prose asserting edits with no record that any tool ran. They
now replay through the same toolCallStart/toolCallResult mapping a live turn
uses, keyed on the stored toolCallId so results pair with their calls. They do
not enter turnRecord, so load and resume still consume the same effective
history. Resume stays replay-free.

Also asserts that an unset sessionCapabilities is omitted rather than
serialized as null, raised by CodeRabbit.

* fix(acp): persist tool activity for session replay

* test(acp): bound replay notification waits

* fix(acp): preserve session restore invariants

* fix(acp): restore session lifecycle boundaries

* fix(acp): preserve replay and restore identity

* fix(acp): commit a completed turn as one store batch

A completed turn was buffered until its outcome was known and then persisted
one event per call. Each call took and released the store's session lock, so a
second ACP instance holding the same session -- its turnMu is its own, only the
store is shared -- could append its whole turn between two of them: user A,
user B, answer B, answer A. Every write was individually locked and the log
was valid, but a fresh load pairs prose by order and reconstructed three wrong
turns from two right ones. Before this branch the user and assistant events
went to AppendEvents together; buffering until the outcome is known was the
right change and splitting the commit into singleton writes was not.

The whole buffer now goes through one Store.AppendEvents call, which writes the
batch under one lock and one file write. That is the contiguity being restored:
not a disk transaction, and not a promise about a crash mid-fsync. The outcome
gate still runs first; a hard failure still commits nothing; cancellation still
commits through the same boundary. A persistence failure is now the failure of
the whole batch, so there is no dependent suffix to protect and the rewritten
regression asserts that nothing of the turn is durable, the warning is raised,
and a fresh load replays none of it.

The two-instance regression holds both writers at the commit boundary until
each has arrived and then delegates to the real batch API under the real lock;
it observes what production hands it rather than batching anything itself, so
reverting to singleton writes fails it before any interleaving has to be
provoked. The restored prompt keeps both prose pairs and no historical tool
output.

Also normalizes the expected workspace in the listing test through
filepath.Clean, matching the resolver's output contract, so a TMPDIR spelled
with a ".." component no longer fails a correct result.

Rebased onto main to pick up the ACP browser metadata, specialist model
restoration and worktree pointer protection changes. Both reported by @jatmn.
2026-09-12 18:18:39 +05:30
KRATOS 90014210d2 fix(daemon): publish status files atomically (#949)
* fix(daemon): publish status files atomically

* fix(daemon): bind status publication to directory handle

* fix(daemon): validate status directory access

* fix(daemon): accept current token directory owner

* fix(daemon): migrate owned runtime directories safely

* fix(daemon): open Windows security handle relatively

* fix(daemon): use current NT directory object

* fix(observability): harden existing crash directories

* fix(security): bind crash report creation to private root

* fix(observability): atomically publish crash reports

* fix(observability): preserve committed crash reports

* fix(observability): revalidate committed crash path

* fix(daemon): secure runtime lifecycle boundaries

* fix(daemon): bind status ownership through cleanup

* fix(daemon): preserve Windows status security

* fix(daemon): retain trusted runtime root for lifecycle

* fix(daemon): roll back redirected socket binds

* fix(daemon): address status publication review
2026-09-12 18:18:12 +05:30
Vasanth T 6937a309cf fix(tools): say when format-on-write ran out of time (#1044)
* fix(tools): say when format-on-write ran out of time

The 10s bound exists so a wedged formatter cannot hang a tool call, which
is right, and on expiry the unformatted write stood with nothing said. Every
other fallback here is a standing fact about the environment (toggle off, no
formatter for the extension, binary not installed) and is silent because
nothing is wrong. A deadline firing is not that: formatting was configured,
available and expected, and the file was written unformatted because the
machine was slow. Left silent, the caller believes it wrote canonical style
and finds out from a CI format check it cannot see, which is the failure
this feature exists to prevent.

The timeout is now reported on the write and edit summaries, naming the file
and the formatter. The caller cancelling and the formatter exiting non-zero
are deliberately not reported: the first is already surfaced as a cancelled
run, and the second usually means content the formatter could not parse,
which a write does not promise to fix.

The bound stays 10s rather than being raised. Raising it makes a wedged
formatter block longer, which is the thing the bound is for; what was
missing was knowing when it fired.

Closes #1022

* fix(tools): put the written bytes back when the formatter fails

These formatters edit in place, so one killed by the deadline, killed by
the caller, or exiting partway through its own rewrite can leave the target
truncated: neither the input nor the output. Returning the written bytes on
top of that left the tracker baseline and the diff preview describing a
file that is not on disk, so the next edit would compare against content the
file does not have. That is a worse failure than the missing formatting the
previous commit set out to disclose.

The failure paths write the bytes back, and when even that fails the user
hears about it, since it is their file. Written back unconditionally on that
path rather than only when the bytes differ: comparing first means reading
the file to find out, and a read that fails leaves the same ambiguity this
exists to remove.

The fixture is proven to damage the file on a throwaway copy, because a
successful restore erases the evidence from the real one. My first attempt
asserted that on the target itself, where it could only pass vacuously or
report the opposite of what it saw.
2026-09-12 08:54:58 +08:00
Vasanth T da244bcbfc fix(sessions): render resume context fields in key order (#1043)
* fix(sessions): render resume context fields in key order

extractText walked a map[string]any directly and Go randomizes map
iteration, so the same session put the same fields in a different order in
every process: one run rendered a tool call as c1 read_file {"path":...},
the next as {"path":...} c1 read_file. That block is part of the user
prompt, so the prompt itself changed run to run, which leaves a provider
nothing stable to prefix-cache for it and makes two resumes of one session
impossible to diff while debugging.

Only the ordering is decided here. The summary short-circuit is untouched,
a list keeps the order it was written in, and which fields survive at all
stays the caller's projection.

Pinned as exact rendered strings rather than as two renders agreeing: a
same-process repeat passes whenever the random order happens to repeat,
which for a small map is often. The raw-JSON case proved it while being
written, matching on the first five attempts before diverging on the sixth.

Closes #1020

* test(sessions): name the expected context block instead of the first render

Comparing later renders against the first only asks whether the renderer is
stable, and a renderer that is stably wrong passes it: any fixed field order
satisfies it, including the unsorted one this change replaces.

It also hid something. The events were tool calls, which
promptContextEvents filters out of the resume context on this branch, so
the block being compared was a single message line and nothing said so. The
test now names the block it expects, on events that survive the filter, and
both failures are visible.
2026-09-12 08:54:33 +08:00
Vasanth T e34c8d2252 test(cli): skip the serve-scope symlink test when the platform refuses the link (#1042)
Creating a symlink on Windows needs SeCreateSymbolicLinkPrivilege, which an
ordinary developer account does not hold, so this reported a failure with
nothing wrong in the code: "A required privilege is not held by the client".
CI runners are elevated, so it only ever appeared locally, where it cost a
round of triage on #1003 before being ruled environmental and would do the
same to any contributor on a stock Windows machine.

The two other symlink tests in this package, and most of the rest of the
repo, already answer this way. Swept the remaining os.Symlink-then-Fatal
sites rather than assuming: internal/release passes, the one in
internal/agent is never reached, and the unix-only files cannot hit it. This
was the outlier.

Closes #1021
2026-09-12 08:53:51 +08:00
Vasanth T 91d921f4c5 feat(tui): offer mid-run model escalation behind --allow-escalation (#1019)
* feat(tui): offer mid-run model escalation behind --allow-escalation

Mid-run escalation existed but only exec offered it. The TUI already handled
every consequence of a switch, re-deriving the compaction threshold "after a
mid-run escalate_model switch" and resolving the summarizer against the active
profile, while nothing on that surface could cause one: escalate_model was
registered by exec alone. This closes that, opt-in, the same conservative way
exec answers the question.

Opt-in rather than default because escalation moves a run onto a different model,
which changes what it costs and which provider sees the conversation. That is the
operator's call, and the exec flag already answers it.

The switchers are built per turn from m.providerProfile rather than once at
launch. A TUI session can change models with /model, so a closure capturing the
startup profile would escalate from whatever the session began with instead of
what is in force now, and would carry that stale profile's base URL and
credential with it. exec has no such problem because its profile cannot change
mid-run, which is why the wiring lives in different places on the two surfaces.

The nil-contract logic is now shared rather than copied. providers.
EscalationSwitchers is the single implementation both surfaces call: the loop
swaps only on a non-nil provider, so a (nil, nil) return has to leave everything
untouched including the caller's usage attribution, and the session switcher is
installed only when the run STARTED optimized so escalation cannot change the
transport underneath a session. A second copy of that would only ever have been
exercised on one surface. exec's behaviour is unchanged: its six existing
escalation tests pass against the shared builder.

The tool and the switchers ride on one flag and a test asserts they cannot be
separated. Registering escalate_model without wiring a switcher ships a tool the
loop silently ignores, so the model reports an escalation that never happened;
that is the specific failure worth a guard rather than a comment.

Note this does not answer all of #554. Assigning models per phase, plan with one
and execute with another, is still not built. This makes the mechanism reachable
from the interactive surface, which is the part that was one flag away.

Refs #554

* fix(tui): attribute usage after an escalation to the model in force

exec reassigns currentModel when the escalation switcher fires; the TUI
captured usageModelID once per run and handed the switcher no callback,
so every usage event after a mid-run escalation was billed to the model
the run started on. The switcher now updates usageModelID, and the final
response carries the model per usage event so the batch fallback does
not swing the other way and bill the events before the switch to the
escalated model. Covered by a run through a scripted escalation that
checks both the live and the batch attribution.

* fix(cli): accept the root flags in any order

Each leading-flag splitter stops at the first token it does not own, so
running them once in a fixed sequence made the order load-bearing:
`zero --allow-escalation --theme auto` stranded the theme as an unknown
command and exited with an argument error instead of launching, and the
same happened after --skip-permissions-unsafe. The three splitters now
run until they make no progress, at the root and after the unsafe flag,
so every ordering reaches the TUI with every flag applied. A --theme
written before --skip-permissions-unsafe was dropped on that path; it is
kept now, with a later one winning as the last occurrence does.

* fix(cli): forward a root --allow-escalation into exec, reject it elsewhere

The root flag was stripped before dispatch, so `zero --allow-escalation
exec ...` and `zero --allow-escalation -p ...` ran without the opt-in the
help text promises, and `zero --allow-escalation version` accepted a flag
it could only discard. It now follows --add-dir: re-synthesised into the
exec argument list for the exec and -p shapes, so exec parses it as its
own flag, and rejected loudly for every other command. The ordering test
now generates every permutation of the three root flags, with
--skip-permissions-unsafe absent or at any position.

* fix(tui): record the model on an escalated run persisted usage events

The live usage record carried the model in force; the persisted session
payload did not. The usage report rebuilds cost from that payload and
falls back to the session-wide model when an event does not name one, so
an escalated interactive run was priced end to end at the model it started
on. Written only under escalation, matching what exec records under the
same flag, so an ordinary run persists the same compact payload as before.

* test(cli): isolate the new launch helpers from real user state

A temporary working directory isolates project files and nothing else.
runWithDeps fills the dependencies a test leaves out with production ones,
and the interactive launch path reads user config, opens stores, refreshes
the models.dev cache and migrates any inline plaintext API key into the
credential store before it reaches an injected runTUI callback. Running
these tests on a developer machine therefore rewrote that developer's
config.json and wrote a credential file beside it, and the results depended
on whatever providers, MCP servers and plugins the machine had.

Both new helpers now point every per-user base directory at a throwaway
root, pin the models.dev cache path, disable its background fetch, and
force the file credential backend so nothing reaches the host keyring. A
new test seeds a config with an inline key outside those fixture roots and
fails if either helper rewrites it or leaves a file beside it; without the
isolation it reports the rewritten config and the credentials.enc files.

* test(cli): check each launch helper isolation on its own

t.Setenv restores at the end of the test, not when the helper returns, so
one test exercising both helpers let the first isolation cover for the
second. Each helper now seeds its own emulated user config in a subtest,
and removing the isolation from either one fails that subtest.

* test(cli): pin the isolation itself, not only what this platform writes

The exec helper writes its models.dev cache from a background goroutine,
so a footprint check could not prove its isolation deterministically. Each
subtest now also asserts the helper moved the per-user paths off the
seeded root and disabled the fetch and the keyring backend, which fails
for either helper when the isolation call is removed.
2026-09-12 08:51:42 +08:00
Vasanth T 6ae8396f02 fix(sessions): carry an interrupted turn's work into the resume prompt (#1016)
* fix(sessions): carry an interrupted turn's work into the resume prompt

A turn that read six files and then died on a provider error left this behind
for the next turn:

  - #1 message: add retries to the http client
  - #6 error: provider error: upstream timeout

Nothing named the files, so the next turn re-read them from scratch (#913).

The work was never lost from the session: tool calls and results are recorded as
they happen, and the error path carries them out with everything else.
promptContextEvents then dropped both types on the way into the prompt, so the
record existed and nothing read it.

Filtering them was deliberate (#460) and is right for work an answer already
describes: forty read_file results add length and no information next to the
assistant message explaining them. It is wrong only for work with no answer
after it, and that is exactly what an interruption leaves. A turn that ends
normally was survivable for that reason, which is why this went unnoticed: the
answer was doing the remembering.

So the events after the last assistant message come along and the rest stays
filtered. The tail gets its own allowance rather than sharing the conversation
budget, so a tool-heavy interrupted turn still cannot push an earlier message
out of the context. That is the property #460 added the filter for and it is
asserted directly.

This changes a tested contract. TestFormatExecPromptKeepsConversationMessagesWhenNoisyEventsFollow
asserted that tool results are omitted, and its fixture is an interrupted turn:
43 results after an assistant answer with nothing speaking for them. That is the
#913 shape, so no rule satisfies both and the contract had to move. The test
keeps the guarantee it was written for, that conversation survives a noisy turn,
and a counterpart now pins the other half: work an answer already covers is
still filtered.

Not fixed here, and worth saying plainly: this carries what was DONE, not what
was READ. Zero has no conversation-history parameter at all, agent.Run seeds
from the system prompt and the user prompt every time, so file contents cannot
survive a turn boundary today. The next turn learns it already read a path and
can decide, rather than starting blind.

* fix(sessions): carry the tool outcome, not the tool output

The tail was carrying whole tool-result events, so up to 500 bytes of raw tool
output rode into a later turn prompt. Nothing redacts on the way in, and the
prompt renderer truncates rather than filters, so the only thing standing between
a file read and a later prompt was a length limit.

It bought almost nothing. The tail exists so a resumed turn knows what the
interrupted one did, and the CALL already says that: the tool name and its
arguments, which is the path for a read. The result body is the one part of the
pair that can carry file contents.

The status is kept rather than dropping the result entirely. A bare call reads as
work that succeeded, so a failed read would come back as a file the next turn
believes it already has.

Raised by CodeRabbit on the PR, and right.

* test(sessions): scope the outcome assertion to the tool_result lines

The status check searched the whole prompt for "error", which the provider error
message in the same fixture also contains. Blanking the status out left the test
passing, so it was asserting the presence of an unrelated line.

Scoped to the tool_result lines, it fails on that mutation naming the outcome it
lost.

* test(sessions): pin the whole of every tool line, not a search for one string

Two assertions here were weaker than what they claimed.

Searching the prompt for the fixture's whole secret only rejects that exact
string, so a change that carried a truncated or partly redacted prefix of the
output into the prompt would have passed. And asking whether "ok" and "error"
each appear somewhere among the result lines passes just as well with the two
statuses swapped, or both hung off the wrong call.

Both tool calls and both results are now pinned outright, which says what the
trim actually promises: nothing but the tool name and the outcome survives a
result, and the line order pairs each outcome with its own call. Compared as a
set of fields per line rather than as a string, because the renderer walks the
payload map and Go randomizes that order, so the same events come out in a
different field order on every run.

Reported by CodeRabbit on this PR.

* fix(sessions): keep a tool call's identity and drop its payload in resume context

toolResultOutcome strips a result down to name and status, and calls were left
verbatim, so an interrupted write_file put its content into the next turn's
prompt while the matching result body did not. Same secret, one door left open.
Admitting calls is the #913 fix and has to stay: a resumed turn knowing which
file was read is the whole point. What had to change is the symmetry.

toolCallIdentity is the call-side half of toolResultOutcome. It decodes the
arguments, keeps the fields that say what a call was about, and drops the ones
that carry what it was about to write, run or search for. Paths, directories,
urls, names, patterns and a read window survive; write_file's content, edit_file's
old and new strings, apply_patch's hunks and a shell command line do not.

An allow-list rather than a deny-list, so an argument this file has never seen is
dropped rather than replayed. A new tool with a new body field then shows up as a
missing path in a resume prompt, which is visible, instead of a new leak, which
is not. Keyed by argument name rather than tool name, so an MCP tool whose
argument is a url keeps it without being enumerated. Arguments that do not decode
as an object are removed outright: text that could not be read is text that
cannot be checked.

The tail budget gets a comment rather than a change. Tail slots exist only when
the conversation uses fewer than the 80-event cap, so a session already at the
cap carries no interrupted tool work, which is what every session did before
tool events were admitted and what the #460 cap is there to hold. Reserving a
minimum tail by trimming conversation further is a product decision, and it is
not this one.

Reported by jatmn.

* fix(sessions): scrub credentials out of retained tool-call values

An allow-listed key is not a safe value. web_fetch accepts a credential in
the query string and redacts the URL it reports back, so an interrupted
fetch had its token dropped from the result and kept verbatim in the call,
and this projection then carried it into the next resume or fork prompt.
Retained string values now go through the same redaction web_fetch uses on
its own returned URL: host, path and ordinary query survive, the token does
not, and non-string values are untouched.

* fix(sessions): project a tool call's top-level fields instead of patching one

toolResultOutcome builds its output from the fields it keeps, so a field it
has never heard of cannot reach a prompt through it. The call side edited
arguments in place and returned every sibling key, and returned the payload
untouched when there were no arguments at all, so a producer recording one
more top-level field would put it into the next turn. Both halves now name
what survives and drop the rest.

* fix(sessions): name the value shapes a projected argument may keep, and scope the ambiguous aliases

Two halves of the same gap: the projection decided which KEYS survive and
said nothing about the values under them or about which tool gave them
meaning.

Values. Strings were scrubbed and everything else passed through, which
reads as "a number is nothing to scrub" and is true of a number and not of
an object. MCP schemas allow object and array properties, so
{"query":{"api_key":"...","content":"private body"}} arrived under a
permitted key and carried a whole payload into the next prompt. Redacting a
serialized object would not have helped, since ordinary private text has no
credential shape to match. The retained shapes are named now: a scrubbed
string, and the scalars a read window is made of. A container is dropped and
the call keeps its name and id, so a resumed turn still knows which tool ran.

Meaning. grep accepts "search" for its pattern and edit_file accepts the
same word for the text being replaced, so admitting it globally would have
replayed file contents. It is scoped to grep, and the rule is kept as a
test: an argument name any mutating tool accepts for body content belongs in
the per-tool table or nowhere. The unambiguous supported forms are added to
the shared table: glob's match, and read_file's start_line, end_line,
max_lines, byte_offset and byte_limit.

Driven through the store on both resume and fork, including a call the tool
would have rejected, since the agent records OnToolCall before execution. The
persisted events are asserted unchanged: this is a view for the prompt, not
an edit to the record.

* test(sessions): assert the window key with its value, not the number alone

strings.Contains(out, "40") finds it inside "4096", so the start_line
assertion passed whether or not start_line survived the projection, which
is the opposite of what it claimed. The rendered arguments carry the pairs,
so the assertions name them.
2026-09-12 08:51:10 +08:00
beardthelion 2837f5efb3 fix(installtxn): put back an install a killed commit left behind (#997)
* fix(installtxn): put back an install a killed commit left behind

CommitDir publishes by two renames: the live target moves into a workspace
backup, then the staged copy is renamed into place. Neither is journaled, so a
process killed between them left the target absent with the install's only copy
retained in a workspace nothing ever read. plugins.Load and skills.Load
enumerate directories, so the extension simply disappeared, while the lockfile
went on listing it. Recovering needed one fact the transaction never wrote
down: which install a backup belonged to. CommitDir now records that before it
moves anything, and Recover puts the backup back when the target is absent.

Recovery is a second data-loss surface, so it is bounded on every side. It
refuses a name that is not a single element inside the install root, never
replaces a live target, identifies a workspace by the name StageDir gives one
rather than by contents alone, and leaves intact anything it cannot attribute.

Every path that takes the install lock recovers, not just the two that install.
Recovering on the install path alone is worse than not recovering: a removal
takes the not-present branch, drops the lockfile entry and reports success
while the backup it never looked at stays on disk, and the next install
republishes it, reinstating an extension the user deleted. Removal and the
terminalpet install hold the same lock and now do the same thing first.

Recovery stays an explicit call rather than a side effect of Lock, matching how
the other staged-swap transactions here invoke their repair pass and keeping a
filesystem mutation visible at the sites that cause it.

* fix(installtxn): retire a backup the publish rename already superseded

Recover skipped any workspace whose target was occupied, which left the backup
of a commit that was killed after its publish rename but before its cleanup.
Removing that install then deleted the live target and its lockfile entry but
not the skipped backup, and the next recovery read the absent target as an
interrupted swap and published the stale tree again. plugins.Load and
skills.Load enumerate directories, so the removed extension was loadable again
with nothing in the lockfile naming it.

The target already records how far the commit got, so recovery reads it as the
phase rather than carrying a phase file. Absent means the swap never finished
and the backup is put back as before. Present means the publish rename
committed, so the backup beside it is superseded and its workspace is retired.
The live install is never replaced or removed either way, and the guards that
skip a workspace with no backup, an unreadable or missing marker, or a recorded
name that is not a single element inside the install root are unchanged. The
retire path removes the workspace directly rather than through
cleanupWorkspace, which refuses one holding a previous precisely because it
cannot tell a superseded backup from one still owed a restore.

Reading the target that way is only safe once nothing can leave a partial tree
there. rollback deleted the failed install in place before restoring, so a
process killed partway through that delete left a husk at the target while the
backup was still the only complete copy, and recovery would have taken the husk
for a committed publish and deleted the last good tree. The renames are now
ordered so the target is never partial: the failed install moves aside into the
workspace, the backup moves back to the target, and only then is the set aside
tree removed. A first install has no backup to protect and recorded no target,
so it still deletes in place.

The move aside can fail too, and then the failed install stays live at the
target with the backup still the only copy of what it replaced. Rollback drops
the workspace marker on that path, which leaves a workspace nothing can
attribute, and recovery already leaves those alone.

* fix(installtxn): reconcile both halves of an interrupted commit

Recovery inferred three facts from incidental filesystem shape: target
presence stood in for the transaction phase, a public dot prefix plus two
ordinary filenames stood in for ownership, and a void best effort call stood
in for both "nothing to recover" and "recovery failed".

CommitDir swaps the trees and only then publishes the lockfile, so a kill
between the two leaves exactly what a kill after both leaves. A phase bit
written after the publish only inverts which side of the write the ambiguity
falls on, so Recover now asks the caller which of the two trees its published
metadata records. plugins and skills answer from the recorded hash; the
terminalpet install publishes no metadata and passes nil, where a live target
is the committed one. A backup the metadata still records goes back over the
target through the same move aside ordering rollback uses.

Recover returns an error, and every caller that takes the install lock aborts
on it before reading the lockfile, inspecting the target, installing over it,
or reporting a successful removal. Malformed, legacy, and unattributable
workspaces are still skipped without error; a workspace we own and could not
resolve is reported, and every workspace is processed before returning.

Ownership is now proven rather than inferred. The workspace marker carries a
magic and version first line, so a user authored skill directory that happens
to be named with the workspace prefix and to hold a previous directory is left
alone. A recorded name carrying the workspace prefix is refused too, since it
names another transaction rather than an install. Retirement clears the backup
before the workspace around it: os.RemoveAll unlinks the marker first, so a
failure inside the backup used to cost the workspace the attribution that gets
it reported at all.

* fix(installtxn): do not read a target we could not probe as absent

recoverWorkspace read any os.Lstat failure on the target as nothing being
there, which enters the restore branch: the one branch that moves a tree,
taken on the strength of a question that never got an answer. Only a
not-exist error means the swap did not finish; every other probe failure
leaves the phase unknown, so it is reported and the workspace is left as it
was.

The same distinction the reconcilers already draw between a missing file and
a file that could not be read, applied to the probe that decides which branch
recovery takes at all.

* fix(installtxn): ask the reconciler about the name that resolved the path

recoverableTarget trims the recorded name before resolving the path, but the
raw line went to the reconciler, so the two halves of one decision ran on two
different values. A marker recording "demo " resolves dir/demo and then asks
the lockfile about "demo ". The lookup misses, a miss proves the publish never
ran, and the committed target is replaced with the tree it superseded. Found
by CodeRabbit on the round-2 push; the test observes that replacement before
the fix.

Also fixes the two CI failures on the new tests, neither of which was a
product defect:

macOS and Windows: the skills recovery matrix compared the lock source against
the path the test handed the installer, while Install records
canonicalSource(source). t.TempDir returns /var on macOS where the recorded
value is /private/var, and an 8.3 short name on Windows where it is the long
one. The expectation now comes from the same resolver the installer uses.

Windows: the two plugins failure-injection tests make a directory unwritable to
force a rename or removal to fail. Windows does not block either that way and
root ignores the bits, so the injected failure never fired and the tests
asserted against a success they never meant to produce. They now skip there, as
the installtxn and skills equivalents already did.

* fix(installtxn): stop recovery acting on trees the lockfile never described

An adversarial pass over the recovery protocol, driven end to end through the
built binary rather than package tests, found that the protocol still acted on
evidence it did not have.

The one that loses data: a missing lock entry was read as proof the publish
never ran, so recovery replaced whatever was at the target with the retained
backup. A missing entry proves no such thing. The entry can be lost after a
publish that did run, since a truncated or deleted lockfile reads as an empty
one, and a directory with no entry is a state these packages already support
(Load returns it, Remove handles it). Worse, the tree at the target may have
nothing to do with the transaction: `zero tools make foo` scaffolds into the
plugins root, and a hand-written skill directory is an ordinary thing to find
in the skills root. Both were destroyed and replaced by a stale backup, and the
install reported success. Reproduced through the CLI both ways before the fix.
A missing entry is now PhaseUnknown, which stops every caller and touches
nothing.

The rest are the same class as the target probe fixed in 005fff72, a probe
whose failure was read as a fact:

- A marker that could not be read was skipped as somebody else's content, so
  the only copy of an install stayed stranded while the caller was told there
  was nothing to do. Present but unreadable is now reported.
- The retained backup was probed with Stat, which follows symlinks and never
  checked the type, so a symlink there published content from outside the
  install root and a regular file published a file where a directory belongs.
  Only a directory is a tree this package set aside.
- An entry at the target was read as an install at the target. A dangling
  symlink satisfies Lstat, and treating it as live retired the only real copy.
- Two workspaces claiming one install were acted on in turn, which published
  one over the other and deleted the rest. Nothing ranks two claims, so both
  are reported.
- A stale `failed` tree from an earlier rollback made the restore rename fail
  for good, and since every caller aborts on a recovery error that wedged every
  install and removal in the root. The workspace is proven ours, so the stale
  tree is cleared first.
- A recorded name carrying a NUL or longer than a path element can be reached
  Lstat and came back with an error that is not not-exist, reported on every
  pass forever with no way for a caller to clear it. Such a name is not an
  install to recover and is skipped.

Unresolved-transaction errors now name the workspace directory, because the
only remedy is to delete it by hand and nothing in the CLI can reach it.

Left alone deliberately: an interrupted RemoveDir still writes no marker, which
the PR body already discloses as a pre-existing residual.
2026-09-12 08:46:32 +08:00
HazyLab 08fbea7a32 fix(redaction): split camelCase keys in normalizeKey (#994)
* fix(redaction): split camelCase keys in normalizeKey

A lower-to-upper boundary now inserts an underscore, so accessToken
and refreshToken match the registry instead of leaking into logs.
Token-count fields such as promptTokens stay unredacted.

Fixes #922

* fix(config): replace deprecated reflect.Ptr with reflect.Pointer

* fix(doctor): report auth presence under a non-sensitive key

camelCase normalization treats credentialConfigured as a credential
segment, so check() redacted the set/not-set indicator. authConfigured
does not match the registry.
2026-09-12 08:45:58 +08:00
cairn-internandcairn-intern 82ae3a8809 docs: name the five supported targets and drop Plan 9 as a review gate (#986)
* docs: name the five supported targets and drop Plan 9 as a review gate

Plan 9 has never compiled and is not a release or CI target. Reviews still
asked for plan9 shims. Document the five release platforms as the supported
set so unsupported GOOS is not a review criterion.

Fixes Gitlawb/zero#817

* docs: exclude unsupported GOOS/GOARCH from review criteria

CONTRIBUTING.md names five exact release combinations. Align AGENTS.md and
.coderabbit.yaml so other GOOS/GOARCH pairs are not a review criterion.
Unsupported targets such as plan9 remain allowed as fixtures.

* docs: retain Android support in target policy

---------

Co-authored-by: cairn-intern <[email protected]>
2026-09-12 08:45:36 +08:00
488def68dc fix(config): preserve unknown top-level fields (#984)
* fix(config): preserve unknown top-level fields

Amp-Thread-ID: https://ampcode.com/threads/T-01a0448f-5860-721c-8a47-5119fc57f685
Co-authored-by: Amp <[email protected]>

* fix(config): match JSON Unicode case folding

Amp-Thread-ID: https://ampcode.com/threads/T-01a044e2-92ad-774b-9a86-094e2e5293bf
Co-authored-by: Pierre Bruno <[email protected]>

---------

Co-authored-by: Amp <[email protected]>
2026-09-12 08:45:10 +08:00
b04fe96e9e fix(config): lock config read-modify-write across processes (#960)
* fix(config): lock config read-modify-write across processes

Every config mutator loads the whole document, edits independent fields, and
publishes a complete replacement by rename. The rename is atomic, so a reader
never sees partial JSON — but two processes that loaded the same revision each
write a full document, and the second rename silently discards the first one's
acknowledged update. The result is valid JSON with one update missing and no
error anywhere.

lockConfigFile takes a cross-process advisory lock through lockutil, using the
retry-with-deadline idiom cron, hooks and oauth already share (10s timeout, 20ms
retry). Callers acquire BEFORE their first read, so the lock spans load,
mutation, validation and publication and the read inside it is authoritative —
holding it only around the write would still let both processes start from the
same stale revision.

The lock file is a sibling (config.json.lock), never the config itself: an
advisory lock is held against an inode, and publishing by rename installs a new
one, so locking the config directly would leave each process holding a
different inode.

Covered: all 14 mutators in writer.go, plus ClearProviderKeyStored and
MigratePlaintextProviderKeys in credentials.go. The migration matters most — it
rewrites the config on every startup, so it is the likeliest writer to collide
with an interactive mutation in another Zero. The SetProviderDescription test
seam locks too, so it cannot stand in as the one unsynchronized writer.

Two shapes needed care:

- The lock is not reentrant. EnsureCatalogProvider scans for an existing
  profile and then upserts, so UpsertProvider is split into a locking wrapper
  and upsertProviderLocked; one lock now spans the scan and the upsert, which
  also closes the window where two processes could both create the same
  catalog profile.
- SetPet edits raw bytes to preserve unknown members and formatting rather than
  round-tripping the struct. It takes the same lock, so it neither clobbers nor
  is clobbered by the struct writers.

Regression tests, each verified to FAIL with the lock disabled:

- TestConcurrentMutationsDoNotLoseUpdates — theme, pet, recaps, favorites and a
  provider mutated at once; all five are independent fields, so a lost update
  shows up as a zero value in exactly one of them.
- TestConcurrentProviderUpsertsAllSurvive — 16 distinct providers added
  concurrently, all must be present.
- TestConcurrentSameFieldMutationsSerialize — 24 writers contending on one
  field; every call succeeds and the document stays readable.
- TestCrossProcessMutationExcludesAndPreserves — the coordinated two-process
  case. Goroutines share this process's descriptors, so only a second OS
  process shows the lock is kernel-held. The child announces itself, then the
  parent asserts its write cannot land while the lock is held elsewhere, does
  its own mutation, releases, and requires both updates to survive.

Fixes #832

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01U389qmQUhoZB3YtXkFmiTq

* fix(cli): take the config lock for MCP config edits

The previous commit locked internal/config's mutators, but that is not every
writer of the user config document. internal/cli's MCP editor reads the SAME
file, edits it, and republishes it with the identical temp-file+rename shape,
at three sites (add/update, remove, disable/enable). Locking only one package
left `zero mcp add` free to clobber a concurrent provider or preference write —
and to be clobbered by one — with the file still valid JSON afterwards, which
is the same silent lost update issue #832 reports.

config.LockFile exports the existing helper so the lock is one authority across
packages rather than a private detail of internal/config. A second, adjacent
implementation would drift from the first exactly the way this writer already
drifted from the mutators.

TestRunMCPAddParticipatesInConfigLock is the regression. Racing the two writers
and waiting to observe a lost update is NOT reliable — the losing interleaving
is narrow, and a straightforward concurrent version of this test passed five
consecutive runs against the unlocked code, so it would have shipped as
reassurance that proved nothing. It instead asserts the deterministic property:
while the config lock is held elsewhere, the MCP writer's update cannot land,
and it completes once the lock is released. That fails immediately against the
unlocked code and passes with it.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01U389qmQUhoZB3YtXkFmiTq

* fix(config): report config unlock failures instead of discarding them

AGENTS.md requires that a mutator never report success when unlock failed, and
the release error was being dropped on the floor. A failed Release can leave the
lock held for the rest of the process, so returning (cfg, nil) after one claims
a state the next mutation cannot reproduce — it will block for the full lock
timeout and then fail.

lockConfigFile now returns lock.Release directly, and every caller joins it into
its own result with the idiom credstore already uses:

	defer func() { err = errors.Join(err, unlock()) }()

Joined, not chosen between: a release failure annotates the result rather than
masking the mutation error that explains what actually went wrong. That covers
all 16 mutators in writer.go, both in credentials.go, and the test seam. The
three MCP sites return an exit code rather than an error, so they convert a
release failure into a crash exit and a stderr message, without overwriting a
failure the command had already reported.

A release failure cannot be provoked through the public API — lockutil.Release
is idempotent and reports nil once released — so the mutators call through a
lockConfigFileFn seam that a test can substitute. My first attempt at this test
could only ever t.Skip, which asserts nothing; the seam is what turns it into a
real assertion.

- TestMutationReportsUnlockFailure: SetTheme surfaces the release error AND the
  mutation is still published, since a release failure annotates the result
  rather than undoing the write.
- TestMutationErrorSurvivesUnlockFailure: an unknown-provider error and the
  release failure are both present in the joined error.

Both verified to fail when the defer discards the release error.

Note for review: cron, hooks, oauth and swarm all still discard their
lockutil.Release error, so this makes internal/config stricter than its
siblings. Worth deciding whether the guideline should be applied to them too —
out of scope here.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01U389qmQUhoZB3YtXkFmiTq

* fix(cli): complete MCP config lock handling

Amp-Thread-ID: https://ampcode.com/threads/T-01a043da-1703-70c5-9d8d-904cd8fd964b
Co-authored-by: Pierre Bruno <[email protected]>

* test(cli): synchronize MCP config lock assertion

Amp-Thread-ID: https://ampcode.com/threads/T-01a0448f-5860-721c-8a47-5119fc57f685
Co-authored-by: Amp <[email protected]>

* fix(tui): persist provider model selection atomically

Amp-Thread-ID: https://ampcode.com/threads/T-01a04c92-2d1d-7508-91bc-416341b7e8b0
Co-authored-by: Amp <[email protected]>

* fix(tui): surface unsaved model selections on every switch path

choosePicker replaces handleModelCommand's status text with its own
applied-notice, so the "· not saved (…)" qualifier that text carried was
dropped and the selection reported an unqualified success. The user only
found out at the next launch, when the old persisted model came back.
dispatchCommand drops the same text on a committed switch, so typed
/model had the identical hole — the cross-provider path already returned
the outcome as a value precisely because display copy is not a contract.

handleModelCommand now returns that outcome too, and one
modelAppliedNoticeFor decides how a committed switch is announced for
both entry points, so neither can regain the gap independently.

Recent-model history is written under the same lock, so a selection that
already lost the lock would pay a SECOND full timeout for one keystroke
and add a redundant save error. It now falls back to the in-session
updateRecentModels for exactly that case, keyed on a new
config.ErrLockUnavailable rather than on any persistence failure: a
missing config file also fails the lookup, and that write still has its
own reason to be attempted.

Regressions cover both entry points. The picker test holds the config
lock — the case the review asked for — and bounds elapsed time, which is
what proves one keystroke costs one wait; it fails at 20s against the
previous code. The typed test uses an unwritable config path so it
proves the same plumbing without a second ten-second timeout.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01REzorhNj3F1DGPXyn5Uq7j

---------

Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
Co-authored-by: Amp <[email protected]>
2026-09-12 08:44:43 +08:00
79fa1422ef fix(providers): register Atomic Chat as local OpenAI-compatible runtime (#550)
* fix(providers): register Atomic Chat as local OpenAI-compatible runtime

Point atomic-chat at the default local API (http://127.0.0.1:1337/v1),
mark it Local with no API key, and auto-detect it alongside Ollama and
LM Studio. Keep opencode-go-anthropic-compatible in the provider catalog
and curated model list after rebasing onto latest main.

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

* docs(zh): sync Atomic Chat local runtime into README_ZH

The English README lists Atomic Chat in the provider list and tells
local-model users to run its desktop app on http://127.0.0.1:1337/v1,
but the Chinese README still mentioned only Ollama and LM Studio.

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

* fix(providers): give the local Atomic Chat runtime its own catalog ID

Repurposing the existing atomic-chat ID from the hosted, key-gated preset
to a keyless local one silently downgraded credential checks for profiles
already saved against the remote endpoint: MissingCredentialEnv resolves
RequiresAuth from the catalog descriptor, so those profiles were reported
as ready and only failed later with an unauthenticated call to
api.atomic.chat.

Keep the hosted atomic-chat descriptor as it was and register the local
runtime under a separate atomic-chat-local ID, mirroring the existing
ollama-cloud/ollama split. Stored profiles keep their identity and no
migration is needed.

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

* test(onboarding): assert the full Atomic Chat local endpoint and default model

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

* fix(models): drop the placeholder curated list for atomic-chat-local

Atomic Chat's model catalog is a Hugging Face scrape refreshed every 12h
plus arbitrary owner/repo lookups, so the served model ID is whichever
model the user loaded and no hardcoded list stays accurate.

The single local-model entry short-circuited Models() without adding
anything: the descriptor is openai-compatible, so modelMatchesProvider
never matches a registry entry and the fallback already yields the
descriptor's DefaultModel. Output is unchanged; the difference is that
local-model is no longer presented as a curated contract.

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

* fix(onboarding): pin the probed model in the local-runtime adopt command

probeLocalRuntime already reads the runtime's /v1/models, but SetupAction
dropped the result and emitted 'zero providers add <id> --set-active'
with no --model. The add path then persisted the catalog DefaultModel,
which for a local runtime is the placeholder 'local-model', and the first
completion failed with an unknown-model response.

SetupAction now pins the probed id via SetupCommandWithModel. The catalog
default wins when the server actually serves it, so an existing Ollama
setup does not switch models; otherwise the first advertised id is used.
An empty probe result leaves the command on the catalog default rather
than inventing an id.

Covers a server whose returned id is not 'local-model'.

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

* fix(onboarding): shell-safe adopt command + reject atomic-chat-local placeholder model

commandArg now POSIX single-quotes rendered args instead of strconv.Quote, so a model id from an untrusted /v1/models response cannot expand when the adopt command is pasted into a shell.

providerProfileForAdd now errors when atomic-chat-local is added without --model instead of persisting the local-model placeholder that fails the first completion.

Adds regression tests for command substitution, backticks, and the require-model guard.

* fix(onboarding): finish Atomic Chat setup across supported shells

Rebase on current main and keep the hosted Atomic Chat preset intact.
Only emit setup arguments that are portable across POSIX, cmd.exe and
PowerShell; show interactive guidance for other model IDs. Reject the
Atomic Chat placeholder before both CLI and TUI persistence, and stop
advertising adoption when discovery finds no usable model.

Validation: full Go test suite, focused race suite, vet, release build,
smoke, formatting, advisory lint and vulncheck pass with isolated config
and inherited Anthropic overrides removed. Onboarding, CLI and TUI test
packages cross-compile for Windows and Linux. Regression tests fail on
the rebased pre-fix code and pass with these changes.

Refs Gitlawb/zero#550 and Gitlawb/zero#592.

* fix(onboarding): preserve option-looking model IDs in adopt commands

Use the existing --model=value grammar when a discovered model begins
with a hyphen. Validate the model separately from the fixed separator so
shell safety remains unchanged and '=' is not added to the allowlist.

Exercise generated detection actions through shell tokenization, the actual
add parser, and profile construction for Atomic Chat, LM Studio and Ollama.
Cover leading hyphens, flag-looking IDs, ordinary IDs, spaces, and unsafe
inline values. Before the fix, the new regression failed with:
  generated adoption command rejected by add parser: --model requires a value

Validation: full Go suite, focused race tests, formatting, vet, release
build, smoke, lint-static and vulncheck passed. Affected test packages
cross-compiled for Windows and Linux.

Refs Gitlawb/zero#550.

* fix(onboarding): select eligible chat models and preserve Ollama defaults

P2: apply the existing known-noncoding exclusion before default preference
or first-model fallback. When a nonempty list has no eligible model, offer
commandless guidance instead of persisting the rejected/default model.

P3: recognize an Ollama untagged default's :latest spelling, preserving the
advertised ID. Prefer exact matches and leave other tags/providers alone.

Add selector tests plus a mock-server integration test through discovery,
the generated action, the real add parser, profile persistence, a fresh
process, and the chat completion request. Before this fix, that test failed:
  fresh process sent model "text-embedding-local", want eligible model "-loaded chat model"
The Ollama alias case also failed before the fix:
  AdoptModel() = "qwen3:8b", want "llama3.1:latest"

Explicit model choices, shell checks, hosted/local provider identities and
Atomic placeholder guards retain their existing behavior.

Validation: full Go suite, focused race tests, formatting, vet, release
build, smoke, lint-static and vulncheck passed. Affected test packages
cross-compiled for Windows and Linux. Integration evidence uses a mock
server; native Windows shell and actual runtime behavior are not claimed.

Refs Gitlawb/zero#550 and Gitlawb/zero#592.

---------

Co-authored-by: Cursor <[email protected]>
Co-authored-by: Claude Opus 4.8 <[email protected]>
Co-authored-by: alex <[email protected]>
2026-09-12 08:43:46 +08:00
cairn-internandcairn-intern 1aacf38b72 fix(background): treat POSIX zombie leader as already-exited (#981)
* fix(background): treat POSIX zombie leader as already-exited

terminateOwnedProcess always returned leaderAlreadyExited=false, so
TerminateCommand surfaced "did not exit after SIGKILL" when ps was
unavailable and the leader was already a waitable zombie. Probe the
leader the way Windows uses GetExitCodeProcess, then still signal the
group so live descendants are not skipped.

* fix(background): preserve live-group termination failures

---------

Co-authored-by: cairn-intern <[email protected]>
2026-09-12 08:41:52 +08:00
HazyLabandcl-ment 3063593129 ci: harden GitHub Actions permissions, add job timeouts, concurrency and coverage reporting (#951)
* ci: harden workflow permissions, add timeouts, concurrency and coverage report

* ci: reuse make fmt-check and refine coverage table formatting

* ci: scan Windows build graph via vulncheck-windows and collapse coverage summary to single-pass awk

* ci: invoke the pinned Windows govulncheck via a local GOBIN

go install writes the binary to GOBIN, which is not necessarily on PATH.
Install into .cache/gobin and run that executable. GOOS=windows applies
only to the scan so the scanner stays a host binary.

* ci: declare contents:read on the action smoke workflow

Pull-request workflows inherit the default GITHUB_TOKEN unless they
set an explicit permission block. The smoke workflow is now covered
by the same least-privilege policy as ci.yml.

* ci: parse workflow permissions structurally and quote the Windows scanner path

The pull-request token check now ignores commented lookalikes and accepts
an inline on: [pull_request] trigger. vulncheck-windows keeps the cache
path as one shell word.

* ci: drop -race from this PR so issue 939 stays with PR 940

Coverage profile remains. The dedicated race-detector job is owned by
the open race PR.

* ci: parse workflow permissions as YAML and scope GOWORK=off

* ci: label the non-race smoke test explicitly

---------

Co-authored-by: cl-ment <[email protected]>
2026-09-12 08:41:14 +08:00
HazyLabandcl-ment d6b25fc303 perf(providers): parallelize isolated provider unit tests and shrink retry backoffs (#955)
* perf(providers): parallelize isolated provider unit tests and shrink retry backoffs

Enable t.Parallel() on independent provider test cases and shrink default
retry backoffs from seconds to 1ms during test runs, reducing provider test
suite duration from ~300s to ~2.4s without flakiness.

* test(providerio): add unit coverage for ShrinkBackoffForTest and restoration

* test(openai): keep Codex idle-timeout acceptance test serial

StreamIdleTimeout arms a wall-clock watchdog. Parallel scheduling can
lose the 50ms race against response.completed even though the test
only checks that the option is accepted.

* fix(config): replace deprecated reflect.Ptr with reflect.Pointer

---------

Co-authored-by: cl-ment <[email protected]>
2026-09-12 08:40:36 +08:00
7c50a9fb98 security(mcp): prevent server requests from matching client pending responses (#942)
* security(mcp): prevent server requests from matching client pending responses

* fix(mcp): send courtesy -32601 reply asynchronously off read loop

* fix(mcp): restore jsonRPCIDEchoable and validate request IDs before responding

* fix(mcp): queue and drop courtesy replies without blocking client callers

* fix(mcp): drop canceled queued requests

* fix(mcp): preserve valid numeric request IDs

* fix(mcp): treat JSON method member presence as the request discriminator

An explicit empty method string is a request, not a response.
Courtesy -32601 is still dropped when the write queue is full: failAll
on that path stopped the reader and blocked inbound pipe writes
(TestStdioClientUndrainedServerDoesNotBlockCallerWithDeadline).

* fix(mcp): count dropped courtesy replies when the write queue is full

The inbound reader must not stop. An empty select default is now an
atomic counter rather than a silent discard.

* fix(mcp): retain method presence, writer lifecycle, and original JSON-RPC IDs

* fix(mcp): bound courtesy overflow, pin writer queue, match exponent IDs

* fix(mcp): skip SSE frames that carry a method member

Treat method presence the same on stdio and SSE so empty or null method
never completes a pending client request.

---------

Co-authored-by: cl-ment <[email protected]>
Co-authored-by: KRATOS <[email protected]>
2026-09-12 08:34:42 +08:00
euxaristiaandeuxaristia 275e248669 fix(oauth): Split an oversized keyring token blob across entries (#1007)
* fix(oauth): split an oversized keyring token blob across entries

Store every provider and MCP token in one keyring entry and the store stops
working once the logins outgrow it. On macOS the secret rides inside a
`security -i` command line capped at 4095 bytes, which leaves 3027 bytes of
JSON for all logins combined, so a second OIDC login fails to save and every
write after it fails too.

Split a blob that does not fit across numbered entries and put a manifest in
the anchor account. Chunks live in two alternating generations: a write fills
the one that is not live, then replaces the manifest, so that single write is
the commit point and a crash partway through still reads the previous
generation. `zc1:` cannot prefix base64, so an entry written by an existing
build is still recognised and read without a migration step.

Reserve the range a write will occupy before occupying it. Without that, a
write interrupted while filling a longer generation leaves chunks above the
count the manifest records, and no later cleanup knows to delete them: a
fragment of a token blob would stay in the keychain for good.

Expose the per-entry budget from internal/keyring rather than hardcoding the
macOS figure in the oauth store, sharing one line builder with Set so the
budget and the boundary it describes cannot drift. Backends with no limit
report so and keep the single-entry layout, so Linux is untouched.

Refs #937

* fix(oauth): lock keyring reads, validate manifest digest, and harden chunk tests

* test(oauth): assert Status blocks during keyring lock regression test

* fix(oauth): sweep both chunk generations when a store regrows

A shrink writes the blob back under the anchor, which replaces the manifest
and takes the per-generation chunk counts with it. From then on nothing can
name the chunks a failed cleanup left behind, so the growth branch's sweep of
the target generation is the only one that will ever reach them — and it only
ever targets family A. A keychain that refused one delete during the shrink
therefore kept a superseded generation of access, ID and refresh tokens
indefinitely, with no way for the user to know.

Sweep the other generation alongside the target, and document that the
reclaim waits for the next growth: a store that shrinks once and never grows
again keeps the residue, which sweeping on every whole write would close at a
cost of 128 `security` invocations per save on macOS.

Also record why Load and Status take the cross-process lock, and give the
missing-chunk error the same "log in again" advice the digest failure carries.

Refs #937

* fix(oauth): serialize keyring operations under user-level lock

Derive the keyring backend lock file path from the user's home directory rather than file store configuration, ensuring processes with distinct store paths share the same lock domain for the OS keychain.

Refs #938

* Address review feedback on chunked keyring token store and recovery

Refs #938

* Refine auth reset message and chunk format notation

Refs #938

* Harden OAuth store reset lifecycle, keyring migration recovery, and completions.

Refs #938

* Validate keyring cleanup markers, propagate lock resolution errors, and test reader concurrency.

Refs #938

* Preserve live OAuth credentials when stale cleanup markers survive migration.

Refs #937

* Verify JSON output from OAuth reset to cover its recovery response.

Refs #937

* Verify that JSON OAuth reset actually removes stored logins.

Refs #937

* Require confirmation before resetting shared OAuth credentials.

Refs #937

---------

Co-authored-by: euxaristia <[email protected]>
2026-09-12 08:33:44 +08:00
0207f2b269 fix(sandbox): keep Windows restricted-token SIDs narrow and fail closed on DenyRead (#1006)
* fix(sandbox): keep Windows restricted-token SIDs narrow and fail closed on DenyRead

* fix(sandbox): address CodeRabbit review feedback on PR 640

* refactor(sandbox): Address CodeRabbit nitpicks on PR 640.

Co-Authored-By: cairn-code <[email protected]>

* fix(sandbox): skip inherited ACEs and reject DenyRead upfront during setup

Skip inherited ACEs when preserving read deny entries, correct syntax in the command runner error message, and evaluate unsupported DenyRead profiles before the elevation check in Windows sandbox setup.

Refs #640

* Reject unsupported DenyRead profiles upfront during sandbox setup

Refs #640

* Migrate legacy Windows DenyWrite ACEs, randomize smoke probes, and clean dead descendant machinery.

Refs #640

* Use /d /c for denied-write probe command and verify ownership on cleanup.

Refs #640

* Honor NoInherit in legacy ACL migration and verify probe ownership.

Refs #639

* Preserve DenyRead bits when migrating combined legacy deny ACEs.

Refs #639

---------

Co-authored-by: euxaristia <[email protected]>
Co-authored-by: cairn-code <[email protected]>
2026-09-12 08:33:06 +08:00
PierrunoYTandAmp 8b3d1b1597 fix(tools): reject fuzzy edits with drifted content (#983)
Amp-Thread-ID: https://ampcode.com/threads/T-01a0448f-5860-721c-8a47-5119fc57f685

Co-authored-by: Amp <[email protected]>
2026-09-12 08:32:36 +08:00
cairn-internandcairn-intern a3852bac59 fix(update): keep Windows failed-restore state in trusted recovery records (#979)
* fix(update): keep Windows failed-restore state in trusted recovery records

A failed restore currently records the last verified binary only as a
.deletable .keep marker in the install directory. Write an unresolved
identity-bound record to the existing per-user update-recovery store on
that failure path too, and consult it during preflight so deleting the
marker cannot silence the tamper refusal.

Fixes #868

* fix(update): keep a fail-closed signal if unresolved recovery records cannot be written

Ignoring a failed appendUnresolvedRecoveryRecord left only the sibling
.keep marker. An install-directory writer can delete that marker, after
which preflight finds no unresolved state and a retry can overwrite the
last verified recovery copy. Relocate that copy to a .recovery name when
the per-user record cannot be persisted so promotion still refuses.

Fixes #868

* fix(update): do not relocate the live binary when failed-restore compensation cannot record

persistFailedRestoreSignal relocated through the handle when the per-user
unresolved record could not be written, without checking that the object
was still the aside copy. A restore that had already put the verified
bytes back at targetPath then had that binary moved out of the executable
path. Verify identity with verifyPromotedTarget before relocating.

Unreadable recovery-state JSON now fails closed instead of looking like
an empty queue. The per-user vs per-machine split is documented: a second
account still relies on the install-directory .keep marker.

Fixes #868

* fix(update): durably commit recovery state on Windows

---------

Co-authored-by: cairn-intern <[email protected]>
2026-09-12 08:29:11 +08:00
euxaristiaandeuxaristia a3e7446681 chore(tui): remove unused test exports GetLocalDiffStats and WatchPRState (#975)
Refs #904

Co-authored-by: euxaristia <[email protected]>
2026-09-12 08:28:31 +08:00
KRATOS 0021475d4c fix(swarm): stop original member before handoff (#957)
* fix(swarm): stop source before handoff

* fix(swarm): make handoff recovery fail closed

* test(swarm): remove handoff timing assumptions
2026-09-12 08:28:01 +08:00
HazyLabandcl-ment deba3adfca security(update): prevent tar extraction from traversing escaping symlinks (#943)
* security(update): prevent tar extraction from traversing escaping symlinks (fixes #920)

When extracting tar archives, lexical path cleaning alone does not prevent
subsequent file entries from being written through a previously extracted
directory symlink that resolves outside destDir (Zip/Tar Slip).

This introduces verifyNoSymlinkEscape in safeExtractPath, which recursively
inspects existing path components between destDir and target using os.Lstat
and filepath.EvalSymlinks to reject entries attempting to write through escaping
symlinks.

* test(update): exercise chained symlink escape during file extract

The previous case was a single escaping symlink already rejected by the
existing Linkname check. Seed a mid -> outside chain under destDir and
extract a regular file through it so verifyNoSymlinkEscape is what fails,
with no write outside destDir.

* fix(update): resolve destDir symlinks and permit valid dangling symlinks in archive

* docs(update): document POSIX-only scope for verifyNoSymlinkEscape

* internal/update: test extracting files through directory symlinks

* fix(update): reject dangling symlinks instead of lexical fallback

EvalSymlinks failure used filepath.Clean on the link text, which does
not preserve already-traversed links. A dangling member is now an
error.

* fix(update): resolve dangling symlink chains from the physical parent

* fix(update): authorize tar paths through OpenRoot and reject outbound symlink entries

* fix(update): resolve intermediate symlink parents and cap Root link depth

Walk each path component from the physical parent, reject a ninth link,
and look up release binaries through OpenRoot as regular files only.

---------

Co-authored-by: cl-ment <[email protected]>
2026-09-12 08:27:31 +08:00
euxaristiaandeuxaristia 501f32160f ci: Run the race detector on pull requests (#940)
* ci: run the race detector on pull requests

The Makefile declares `go test ./... -race -count=1` as the project's test
command, but no workflow invokes that target. ci.yml and pr-auto-review.yml both
run plain `go test ./...`, and `-race` appears nowhere under .github/workflows,
so the detector has never run on a pull request. The concurrent paths where a
race is easiest to introduce and hardest to see in review, the turn loop,
streaming provider I/O, the session store, cron, swarm mailboxes, and the
cross-process lock paths in the credential and OAuth stores, are all unchecked.

Add a dedicated job rather than putting -race on the existing matrix step. A
race run is typically several times a plain one, and the matrix Test step is
followed by build and smoke, so folding it in would serialize behind those and
make that job the new long pole. On its own it runs alongside the Windows job,
which is the current long pole at 9m29s, and a failure reads as a race rather
than as a smoke failure.

Run `make test` instead of repeating its flags, so the project's declared test
command and what CI executes cannot drift apart again.

Refs #939

* ci: add permissions to race detector job

---------

Co-authored-by: euxaristia <[email protected]>
2026-09-12 08:24:18 +08:00
Vasanth Tandbaoyu0 1e4db7c664 fix: let the configured forward proxy through the loopback guard (#1025)
* fix(providerhealth,tools): let the configured forward proxy through the loopback guard

With HTTPS_PROXY pointing at a local forward proxy, the transport dials the
proxy first and tunnels the request through it. The SSRF-guarded dialer in
providerhealth refused that dial as a loopback address, so
`zero doctor --connectivity` failed with "proxyconnect tcp: provider
connectivity URL is unsafe: loopback hosts are blocked" for a public
provider that had already been validated, and web_fetch had no proxy
support at all (#569).

Each dialer is now handed its transport's own Proxy function and lets a dial
through unpinned only when the address is exactly the proxy endpoint that
function names for the request scheme: same host, same port, with a missing
proxy port taken as the scheme default. A sibling loopback port is still
refused. The shared check lives in internal/proxydial since providerhealth
and tools otherwise share nothing.

web_fetch honours http.ProxyFromEnvironment again. The guarded fetch had set
Proxy to nil as part of its SSRF hardening; the target is still validated
before the request, and what a forward proxy removes is only the dial-time
rebind pin, which is inherent to using any forward proxy. The pre-permission
URL validation stays in place.

Started from #594 by baoyu0. This version keeps the pre-permission check,
matches the proxy port exactly instead of any port when the proxy URL has
none, and injects the transport's Proxy function instead of reading the
environment a second time.

Closes #569

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

* fix(proxydial): have the dialer ask its transport for the proxy at dial time

Copying transport.Proxy into the dialer at construction left the wiring
between the two unpinned: a dialer handed nil would pass every unit test
while a proxied request still died as loopback. The dialer now consults
the transport it is installed on, and each package drives a real request
through a loopback listener acting as the proxy and checks that the
CONNECT arrives. The proxy is substituted on the transport rather than
through HTTPS_PROXY, since http.ProxyFromEnvironment reads the
environment once per process and an env-based test would depend on
test order.

* fix(proxydial): bind the proxy exemption to the route the transport chose

Asking the Proxy function a question of our own invention answered for a
request the transport never made. With NO_PROXY excluding a host, the real
request to that host goes direct while a synthetic probe still comes back
proxied, so a direct dial whose address happened to equal the configured
proxy endpoint skipped the guard and reached whatever the name resolved to
at connect time. A probe on the previous head dials rebind.example:80 with
zero safety lookups; the guard now runs and refuses its private answer.
Host-dependent proxy callbacks make the mismatch general, and no spelling
of the probe host closes it, because the two calls are about different
requests.

The decision is now recorded where it is made. proxydial.Transport wraps
the transport, asks its Proxy function about the actual request, and puts
the answer in that request's context; the transport derives the dial
context from the request context, so the dialer reads back the route this
very connection is being opened for. Per-request, so concurrent requests
cannot overwrite each other, and the context key is unexported so nothing
outside the package can plant one.

Address matching is gone with it. A transport opens one connection dial,
and when a proxy is selected that dial goes to the proxy, so the recorded
route already identifies the dial. Comparing the address on top could only
disagree with the transport, which is what a Unicode proxy hostname did:
Go dials xn--bro-hoa.example while the URL still spells it as configured,
and the proxy was refused as an ordinary private address. Both spellings
are now one endpoint with no comparison to get wrong.

Documents where destination enforcement ends: with a proxy configured, the
target hostname is sent to the proxy and the proxy resolves it, so the
local pre-request checks no longer bind the address that is reached. A
forward proxy already sees and can rewrite every request through it, so
that trust comes with configuring one; the alternative is refusing to use
proxies at all, which is the outage in #569.

---------

Co-authored-by: baoyu0 <[email protected]>
2026-09-10 21:50:26 +05:30
Vasanth T f9092668a8 fix(agent): stop the tool-schema ratchet meaning a different thing per host (#1017)
* fix(agent): stop the tool-schema ratchet meaning a different thing per host

TestEagerToolSchemaTokenBudget counted exec_command shell guidance along with
the schemas, and that guidance is host-specific. The same tree measured 3498 on
Linux, about 3600 on a Windows host with pwsh 7, and 3653 on Windows PowerShell
5.1, because shellGuidanceForRuntime appends a longer block for 5.1 (it has to
explain that && and || do not work there).

5.1 is stock Windows, and that case has been over the 3650 ceiling since
view_image landed. So `go test ./...` failed for anyone on a stock Windows box
while CI stayed green: the runners have pwsh 7 and take the cheaper branch. The
workflow does run the tests on windows-latest, so this was never a coverage gap,
just a ceiling sized against whichever shell the runner happened to have.

The two halves are now charged separately. The schema ceiling measures the
schemas alone and reads 3498 on Linux and on Windows PowerShell 5.1, so it
ratchets the same thing everywhere. The host guidance gets its own ceiling at
200, above the worst case rather than above this machine: 5.1 is 155 tokens,
pwsh 7 is 110, cmd.exe is 87, POSIX is 0.

No guidance text was trimmed. It is what stops the model writing bash on Windows,
and the 5.1 note is the reason a chained command does not silently do half its
work. Paying 155 tokens for it is the right trade; measuring it as if it were a
schema was not.

HostExecCommandShellGuidance is exported for the budget, which is production
surface added for a test. It is one accessor over existing logic, and the
alternative is a ratchet that cannot tell the two costs apart. A guard in
internal/tools pins that the guidance really is the tail of the description, so
the subtraction cannot silently start measuring the wrong thing.

* test(tools,agent): make the guidance contract checkable off Windows

The ratchet separates the fixed tool schemas from this host's exec_command
shell guidance, and both halves of that separation were only being checked on
Windows.

The description test returned early off Windows once the accessor reported no
guidance, so it never looked at the description. Dropping the GOOS condition in
the constructor would have a POSIX host append its own guidance (about 150
characters on darwin) with the accessor still reporting none and the ratchet
charging every byte of it to the schemas, and nothing would fail. Both the
description and the accessor now take goos, so the contract is checked for all
three platforms from any one of them.

The budget also went from subtracting a separately rounded guidance estimate to
removing the guidance from the description before estimating. ApproxTextTokens
is an integer division, so floor((schema+guidance)/4) - floor(guidance/4) is one
token high whenever the two remainders add past four, and which side of that a
host lands on depends on the length of its guidance. That is the same
host-dependence the ratchet was being fixed for, one token smaller.

No behaviour change: exec_command's description is byte for byte what it was on
every platform.
2026-09-10 21:43:58 +05:30
KRATOS 7f5e5af7bb fix(acp): make configured MCP tools callable (#1003)
* fix(acp): register configured MCP tools per turn

* fix(acp): address MCP review findings
2026-09-10 20:17:26 +05:30
381 changed files with 42636 additions and 2095 deletions

No files matched your search

+3
View File
@@ -29,6 +29,9 @@ reviews:
- path: "tests/**/*.ts"
instructions: |
Review tests for Bun test idioms, deterministic behavior, and meaningful coverage of safety decisions, provider/runtime flows, session events, and TUI command behavior.
- path: "**/*.go"
instructions: |
Do not request Plan 9 or other unsupported GOOS/GOARCH shims. The five native prebuilt release targets are linux-x64, linux-arm64, macos-arm64, macos-x64, and windows-x64. Android/Termux is also a supported runtime through the documented android-arm64 source build and Linux artifact fallback. Other GOOS/GOARCH combinations are not a review criterion.
tools:
github-checks:
timeout_ms: 300000
+153 -75
View File
@@ -6,10 +6,109 @@ on:
branches:
- main
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
permissions:
contents: read
jobs:
quality:
name: Code Quality & Lint
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.2.2
with:
persist-credentials: false
- name: Setup Go
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.3.0
with:
go-version-file: go.mod
cache: true
- name: Check formatting
run: make fmt-check
- name: Vet
run: go vet ./...
- name: deadcode (advisory)
continue-on-error: true
run: make deadcode
- name: golangci-lint (advisory)
continue-on-error: true
run: make lint-static
test:
name: Unit Tests & Coverage
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.2.2
with:
persist-credentials: false
- name: Setup Go
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.3.0
with:
go-version-file: go.mod
cache: true
- name: Test with coverage
run: |
go test -count=1 -coverprofile=coverage.out -covermode=atomic ./...
- name: Publish coverage summary
if: always()
shell: bash
run: |
if [ -f coverage.out ]; then
go tool cover -func=coverage.out | awk '
BEGIN {
total = "unknown"
rows = ""
}
$1 == "total:" {
total = $3
next
}
{
rows = rows sprintf("| `%s` | `%s` | **%s** |\n", $1, $2, $3)
}
END {
print "### 📊 Test Coverage Summary: **" total "**"
print "<details><summary>Coverage per function</summary>\n"
print "| File | Function | Coverage |"
print "| :--- | :--- | :--- |"
printf "%s", rows
print "</details>"
}' >> "$GITHUB_STEP_SUMMARY"
fi
- name: Upload coverage artifact
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: zero-coverage-report
path: coverage.out
if-no-files-found: warn
smoke:
name: Smoke (${{ matrix.os }})
runs-on: ${{ matrix.os }}
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
@@ -18,64 +117,22 @@ jobs:
- macos-latest
- windows-latest
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.2.2
with:
persist-credentials: false
- name: Setup Go
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.3.0
with:
go-version-file: go.mod
cache: true
- name: Install Make on Windows
if: matrix.os == 'windows-latest'
shell: pwsh
run: choco install make --no-progress -y
- name: Validate quality targets with stock macOS Make
if: matrix.os == 'macos-latest'
env:
GIT_CONFIG_COUNT: 1
GIT_CONFIG_KEY_0: grep.patternType
GIT_CONFIG_VALUE_0: fixed
run: make -n vulncheck
- name: Check vulnerabilities through native Windows Make
if: matrix.os == 'windows-latest'
shell: cmd
run: make vulncheck
- name: Check dead code through native Windows Make
if: matrix.os == 'windows-latest'
continue-on-error: true
shell: cmd
run: make deadcode
- name: Run static lint through native Windows Make
if: matrix.os == 'windows-latest'
continue-on-error: true
shell: cmd
run: make lint-static
- name: Check formatting
if: matrix.os == 'ubuntu-latest'
shell: bash
run: |
unformatted="$(gofmt -l .)"
if [ -n "$unformatted" ]; then
echo "gofmt needed on:" >&2
echo "$unformatted" >&2
exit 1
fi
- name: Vet
if: matrix.os == 'ubuntu-latest'
run: go vet ./...
- name: Test
- name: Test (Fast Non-Race)
run: go test ./...
- name: Build binary
@@ -84,9 +141,15 @@ jobs:
- name: Smoke binary
run: go run ./cmd/zero-release smoke
performance:
name: Performance Smoke
race:
name: Race Detector
runs-on: ubuntu-latest
permissions:
contents: read
# A race the detector newly reports can present as a deadlock rather than a
# failure, and the default job timeout is six hours. This job is expected to
# be the longest in the workflow, so the ceiling is generous but finite.
timeout-minutes: 30
steps:
- name: Checkout
@@ -100,55 +163,70 @@ jobs:
go-version-file: go.mod
cache: true
# Invoke the Makefile target rather than repeating its flags. `make test`
# is already `go test ./... -race -count=1`, so running it here is what
# stops the project's declared test command and what CI actually runs
# from drifting apart again. The race detector needs cgo, which the
# hosted ubuntu runner supplies without an extra setup step.
- name: Test with the race detector
run: make test
performance:
name: Performance Smoke
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.2.2
with:
persist-credentials: false
- name: Setup Go
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.3.0
with:
go-version-file: go.mod
cache: true
- name: Build binary
run: go run ./cmd/zero-release build
- name: Performance smoke
run: go run ./cmd/zero-perf-bench --output dist/perf/perf-bench.json --ci
run: |
mkdir -p dist/perf
go run ./cmd/zero-perf-bench --output dist/perf/perf-bench.json --ci
- name: Upload performance report
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: zero-performance-smoke
path: dist/perf/perf-bench.json
if-no-files-found: warn
security:
name: Security & code health
name: Security & Code Health
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.2.2
with:
persist-credentials: false
- name: Setup Go
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.3.0
with:
go-version-file: go.mod
cache: true
# Hard gate: fails the build when code reaches a known vulnerability. A stdlib
# CVE is cleared by a toolchain bump (see go.mod). May also flag a newly
# published advisory on an unrelated PR — intentional: do not ship known vulns.
- name: govulncheck
- name: govulncheck (linux)
run: make vulncheck
# Advisory: reports functions unreachable from any cmd/* main so dormant
# code is visible in CI. Non-blocking while the dormant subsystems are
# still being wired or removed.
- name: deadcode (advisory)
continue-on-error: true
run: make deadcode
# 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: make lint-static
- name: govulncheck (windows)
run: make vulncheck-windows
+1
View File
@@ -24,6 +24,7 @@ jobs:
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 0
persist-credentials: false
- name: Setup Go
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff
+5
View File
@@ -34,12 +34,15 @@ jobs:
- name: Checkout
if: ${{ github.event_name != 'workflow_dispatch' || inputs.ref == '' }}
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
persist-credentials: false
- name: Checkout requested ref
if: ${{ github.event_name == 'workflow_dispatch' && inputs.ref != '' }}
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
ref: ${{ inputs.ref }}
persist-credentials: false
- name: Setup Go
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff
@@ -129,6 +132,8 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
persist-credentials: false
# No registry-url here on purpose: setup-node's registry-url writes an
# .npmrc line referencing $NODE_AUTH_TOKEN, and npm hard-fails on the
+15
View File
@@ -5,6 +5,8 @@ on:
paths:
- action.yml
- docs/GITHUB_ACTION.md
- scripts/action-summary.mjs
- scripts/action-summary.test.mjs
- .github/workflows/zero-action-smoke.yml
push:
branches:
@@ -12,8 +14,13 @@ on:
paths:
- action.yml
- docs/GITHUB_ACTION.md
- scripts/action-summary.mjs
- scripts/action-summary.test.mjs
- .github/workflows/zero-action-smoke.yml
permissions:
contents: read
jobs:
validate:
name: Validate action.yml
@@ -24,6 +31,14 @@ jobs:
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 24
- name: Run action-summary test suite
run: node --test scripts/action-summary.test.mjs
- name: Parse action.yml and assert documented inputs
shell: bash
run: |
+1 -1
View File
@@ -1 +1 @@
{".":"0.8.0"}
{".":"0.9.0"}
+7
View File
@@ -81,6 +81,13 @@ These classes drive multi-round reviews. Fix them before requesting review:
assert raw `t.TempDir()` spellings (`/var` vs `/private/var` on macOS, short
paths on Windows). Prefer one cross-platform function with small `GOOS`
checks over duplicated helpers that drift.
- **Supported targets only:** CONTRIBUTING.md names the five native prebuilt
release artifacts (`linux-x64`, `linux-arm64`, `macos-arm64`, `macos-x64`,
`windows-x64`) and the additional supported Android/Termux source-build and
artifact-fallback path. Other `GOOS`/`GOARCH` combinations, including
`plan9`, are unsupported and are not a review criterion. Do not block a PR
for failing to compile on an unsupported `GOOS`/`GOARCH`. Tests may use
`plan9` as a fixture for "unsupported".
- **Security edges:** Keep secrets out of argv, env dumps, and logs. Redact
success and error paths (including stderr). Fail closed on ownership, lease,
and permission checks. Do not rely on pre-open path resolution
+55
View File
@@ -6,6 +6,61 @@ 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.9.0](https://github.com/Gitlawb/zero/compare/v0.8.0...v0.9.0) (2026-09-15)
### Features
* **acp:** add standard session list and resume ([#914](https://github.com/Gitlawb/zero/issues/914)) ([c1937df](https://github.com/Gitlawb/zero/commit/c1937dfac72e6ad0e5ade6e48e2d9c17d9c3e5d6))
* **acp:** emit structured file changes in tool updates ([#999](https://github.com/Gitlawb/zero/issues/999)) ([faec8ee](https://github.com/Gitlawb/zero/commit/faec8ee2017fdd6d0c2c347add892cb73eb413d7))
* **acp:** surface safe browser tool metadata ([#1000](https://github.com/Gitlawb/zero/issues/1000)) ([a6ec043](https://github.com/Gitlawb/zero/commit/a6ec0435e36b9bea9793b2fd97f2ecd30e3a0f9b))
* **exec:** default headless runs to the turn-budget ceiling ([#1049](https://github.com/Gitlawb/zero/issues/1049)) ([9e609c5](https://github.com/Gitlawb/zero/commit/9e609c52420fd322f047fc238c04ac81dd623fdd))
* **tui:** interactive terminal attach for tty exec sessions ([#1055](https://github.com/Gitlawb/zero/issues/1055)) ([d73ec81](https://github.com/Gitlawb/zero/commit/d73ec81e2d3facb5377bc9ad0fe2aa314628a0c0))
* **tui:** offer mid-run model escalation behind --allow-escalation ([#1019](https://github.com/Gitlawb/zero/issues/1019)) ([91d921f](https://github.com/Gitlawb/zero/commit/91d921f4c5b8289a9e437f786218f1b48f2add5a))
* **tui:** richer /resume session browser ([#1047](https://github.com/Gitlawb/zero/issues/1047)) ([2f53117](https://github.com/Gitlawb/zero/commit/2f531178f614e587b629e4f81e0470adda647fbd))
### Bug Fixes
* **acp:** make configured MCP tools callable ([#1003](https://github.com/Gitlawb/zero/issues/1003)) ([7f5e5af](https://github.com/Gitlawb/zero/commit/7f5e5af7bbdbe5bb4d91706146072d461c0234a4))
* **agent:** stop the tool-schema ratchet meaning a different thing per host ([#1017](https://github.com/Gitlawb/zero/issues/1017)) ([f909266](https://github.com/Gitlawb/zero/commit/f9092668a832ea73a69551f0ea2df4f6eaf356df))
* **background:** treat POSIX zombie leader as already-exited ([#981](https://github.com/Gitlawb/zero/issues/981)) ([1aacf38](https://github.com/Gitlawb/zero/commit/1aacf38b727d31b5ca09949316d6afa8292aec46))
* **config:** lock config read-modify-write across processes ([#960](https://github.com/Gitlawb/zero/issues/960)) ([b04fe96](https://github.com/Gitlawb/zero/commit/b04fe96e9ef3f2deda4fb2a972270300629079d2))
* **config:** preserve unknown top-level fields ([#984](https://github.com/Gitlawb/zero/issues/984)) ([488def6](https://github.com/Gitlawb/zero/commit/488def68dcc6d36701fdf171e94a74a436b090fc))
* **daemon:** publish status files atomically ([#949](https://github.com/Gitlawb/zero/issues/949)) ([9001421](https://github.com/Gitlawb/zero/commit/90014210d2ada2e3d61f4eca5087d3f35a3c4b8b))
* **installtxn:** put back an install a killed commit left behind ([#997](https://github.com/Gitlawb/zero/issues/997)) ([2837f5e](https://github.com/Gitlawb/zero/commit/2837f5efb38dcd3c69771b0ba31565f98a206120))
* let the configured forward proxy through the loopback guard ([#1025](https://github.com/Gitlawb/zero/issues/1025)) ([1e4db7c](https://github.com/Gitlawb/zero/commit/1e4db7c6643ca7e2c3d8e4b8b30371cfbe43561e))
* **lockutil:** use kernel-held advisory locks ([#950](https://github.com/Gitlawb/zero/issues/950)) ([6fe0d1e](https://github.com/Gitlawb/zero/commit/6fe0d1edcdbadea6d566800fbcca739c1cd54ea1))
* **mcp:** discover OAuth through protected resource metadata ([#991](https://github.com/Gitlawb/zero/issues/991)) ([1b5db17](https://github.com/Gitlawb/zero/commit/1b5db1765672820caac1684b168c9898b5ba3593))
* **mcp:** reject a server-initiated request as a streamable-HTTP response ([#1054](https://github.com/Gitlawb/zero/issues/1054)) ([2479d94](https://github.com/Gitlawb/zero/commit/2479d946c15dbf74ee3c04a637e97181d1786f08))
* **oauth:** force-close loopback callback connections past the shutdown deadline ([#1051](https://github.com/Gitlawb/zero/issues/1051)) ([176f5a2](https://github.com/Gitlawb/zero/commit/176f5a242b754a86ea4c7121a8dcc1c0d72cf2e4))
* **oauth:** Split an oversized keyring token blob across entries ([#1007](https://github.com/Gitlawb/zero/issues/1007)) ([275e248](https://github.com/Gitlawb/zero/commit/275e2486695338ce38193a6e3319f8b6a9a5c33c))
* **providerio:** close idle connections periodically to prevent HTTP/2 PROTOCOL_ERROR ([#1058](https://github.com/Gitlawb/zero/issues/1058)) ([b76c764](https://github.com/Gitlawb/zero/commit/b76c764b77926891aa67163e6605a2d8540aacaa))
* **providers:** register Atomic Chat as local OpenAI-compatible runtime ([#550](https://github.com/Gitlawb/zero/issues/550)) ([79fa142](https://github.com/Gitlawb/zero/commit/79fa1422ef1a3691b93342788453ebc284d57f9c))
* **redaction:** split camelCase keys in normalizeKey ([#994](https://github.com/Gitlawb/zero/issues/994)) ([08fbea7](https://github.com/Gitlawb/zero/commit/08fbea7a3221d2061d88038924f90a4cba9af1ac))
* **sandbox:** keep a derived ACL target inside the write root it came from ([#1040](https://github.com/Gitlawb/zero/issues/1040)) ([3b43c43](https://github.com/Gitlawb/zero/commit/3b43c43eca968c8619adadc81fcc1a17e220216a))
* **sandbox:** keep Windows restricted-token SIDs narrow and fail closed on DenyRead ([#1006](https://github.com/Gitlawb/zero/issues/1006)) ([0207f2b](https://github.com/Gitlawb/zero/commit/0207f2b2697149a73ecc89a5ab5db0bd665f1911))
* **sandbox:** Make WSL backend tests deterministic. ([#948](https://github.com/Gitlawb/zero/issues/948)) ([e59bf78](https://github.com/Gitlawb/zero/commit/e59bf78cabc2703678778b904c27c8bc8ef82bc8))
* **sandbox:** protect worktree git pointers ([#805](https://github.com/Gitlawb/zero/issues/805)) ([f30f550](https://github.com/Gitlawb/zero/commit/f30f550e6037c53f8521ba2d59c984ae6c574917))
* **sandbox:** refcount temporary roots so one holder's cleanup cannot revoke another's ([#911](https://github.com/Gitlawb/zero/issues/911)) ([7acac7a](https://github.com/Gitlawb/zero/commit/7acac7acee65565e25089f2a7b5813c086ce65eb))
* **sessions:** carry an interrupted turn's work into the resume prompt ([#1016](https://github.com/Gitlawb/zero/issues/1016)) ([6ae8396](https://github.com/Gitlawb/zero/commit/6ae8396f0282a5f18a431f0c0ef1a272ecc2ca2b))
* **sessions:** render resume context fields in key order ([#1043](https://github.com/Gitlawb/zero/issues/1043)) ([da244bc](https://github.com/Gitlawb/zero/commit/da244bcbfcfa05e414567ac0acc3cf8ba745ad7d))
* **specialist:** keep the pinned model when a specialist is resumed ([#1009](https://github.com/Gitlawb/zero/issues/1009)) ([aadb4a2](https://github.com/Gitlawb/zero/commit/aadb4a27e9cdb41e774e8e1a8f23cf9c2fb50db2))
* **swarm:** stop original member before handoff ([#957](https://github.com/Gitlawb/zero/issues/957)) ([0021475](https://github.com/Gitlawb/zero/commit/0021475d4c58c91911098f414795512cf60a0b56))
* **tools:** an edit keeps the reads it did not disturb ([#908](https://github.com/Gitlawb/zero/issues/908)) ([1649008](https://github.com/Gitlawb/zero/commit/1649008506da0e675431dc056251a01891b15cc8))
* **tools:** make apply_patch tolerant and re-expose edit_file ([#956](https://github.com/Gitlawb/zero/issues/956)) ([2749447](https://github.com/Gitlawb/zero/commit/2749447cdce4ae5dd333538b36fa919b80ecc66d))
* **tools:** reject fuzzy edits with drifted content ([#983](https://github.com/Gitlawb/zero/issues/983)) ([8b3d1b1](https://github.com/Gitlawb/zero/commit/8b3d1b1597b04bfa6d4bc7f912abc13ca80a6298))
* **tools:** say when format-on-write ran out of time ([#1044](https://github.com/Gitlawb/zero/issues/1044)) ([6937a30](https://github.com/Gitlawb/zero/commit/6937a309cf00825572210a7610a1f3ea8b74c2f9))
* **tui:** move voice capture to Ctrl+Space ([#968](https://github.com/Gitlawb/zero/issues/968)) ([eeea330](https://github.com/Gitlawb/zero/commit/eeea3308fed38b37b203522624babaf2f30df91f))
* **update:** keep Windows failed-restore state in trusted recovery records ([#979](https://github.com/Gitlawb/zero/issues/979)) ([a3852ba](https://github.com/Gitlawb/zero/commit/a3852bac595e06c5d3ca9da22212b281b8796802))
### Performance Improvements
* **agent:** cheap compaction — dedicated summarizer model, input cap… ([#532](https://github.com/Gitlawb/zero/issues/532)) ([6645f97](https://github.com/Gitlawb/zero/commit/6645f9744b37dda4d52ecc4631578d62cefc89de))
* **providers:** parallelize isolated provider unit tests and shrink retry backoffs ([#955](https://github.com/Gitlawb/zero/issues/955)) ([d6b25fc](https://github.com/Gitlawb/zero/commit/d6b25fc3039a6b7d72a5703a87a097449792b102))
* reduce startup and turn overhead ([#932](https://github.com/Gitlawb/zero/issues/932)) ([1fde941](https://github.com/Gitlawb/zero/commit/1fde941803c4295c11a4a14559f3c5217d1a9e69))
## [0.8.0](https://github.com/Gitlawb/zero/compare/v0.7.0...v0.8.0) (2026-08-21)
+25
View File
@@ -126,6 +126,31 @@ not enough. Dependency changes need a clear project benefit, such as fixing a
specific bug, addressing a security issue, improving compatibility, or supporting
an approved feature.
## Supported platforms
Zero publishes five native prebuilt artifacts from
`.github/workflows/release-artifacts.yml`:
- `linux-x64` (`linux/amd64`)
- `linux-arm64` (`linux/arm64`)
- `macos-arm64` (`darwin/arm64`)
- `macos-x64` (`darwin/amd64`)
- `windows-x64` (`windows/amd64`)
The shell installer and npm wrapper resolve the applicable artifacts from that
list. The PowerShell installer supports the published `windows-x64` artifact;
there is no native Windows ARM64 artifact. Windows on ARM users can run the x64
build under emulation or build from source.
Android/Termux is also supported even though it has no distinct release
artifact. The npm wrapper maps Android to the Linux artifact, and
`docs/INSTALL.md` documents the native `android/arm64` source build needed on
devices whose seccomp policy blocks the Linux build's syscall path.
Other `GOOS`/`GOARCH` values, including Plan 9, are unsupported. They are not a
review criterion. Do not block a pull request for failing to compile on an
unsupported target. Tests may use `plan9` as a fixture for an unsupported OS.
## What We Prioritize
The core team will prioritize:
+9 -3
View File
@@ -7,7 +7,7 @@ DEADCODE_VERSION := v0.46.0
GOLANGCI_LINT_VERSION := v2.12.2
GOVULNCHECK_VERSION := v1.3.0
.PHONY: build build-all test test-race vet fmt fmt-check lint lint-static deadcode vulncheck tidy clean baseline help
.PHONY: build build-all test test-race vet fmt fmt-check lint lint-static deadcode vulncheck vulncheck-windows tidy clean baseline help
# Build the main CLI binary into ./zero.
build:
@@ -44,7 +44,8 @@ lint: fmt-check vet
# the possibly stale Go toolchain or consulting a multi-module GOWORK. git grep
# works with both POSIX shells and cmd.exe, including GNU Make 3.81 on macOS.
# The target-specific export is shell-independent.
lint-static deadcode vulncheck: export GOTOOLCHAIN = $(GO_TOOLCHAIN)
lint-static deadcode vulncheck vulncheck-windows: export GOTOOLCHAIN = $(GO_TOOLCHAIN)
lint-static deadcode vulncheck vulncheck-windows: export GOWORK = off
lint-static:
go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$(GOLANGCI_LINT_VERSION) run --enable-only unused,ineffassign,staticcheck ./...
@@ -55,6 +56,11 @@ deadcode:
vulncheck:
go run golang.org/x/vuln/cmd/govulncheck@$(GOVULNCHECK_VERSION) ./...
vulncheck-windows:
mkdir -p "$(CURDIR)/.cache/gobin"
GOBIN="$(CURDIR)/.cache/gobin" go install golang.org/x/vuln/cmd/govulncheck@$(GOVULNCHECK_VERSION)
GOOS=windows "$(CURDIR)/.cache/gobin/govulncheck" ./...
tidy:
go mod tidy
@@ -77,4 +83,4 @@ baseline: build
--output internal/perfbench/reports/baseline.json
help:
@echo "Targets: build (default), build-all, test, test-quick, vet, fmt, fmt-check, lint, lint-static, deadcode, vulncheck, tidy, clean, baseline"
@echo "Targets: build (default), build-all, test, test-quick, vet, fmt, fmt-check, lint, lint-static, deadcode, vulncheck, vulncheck-windows, tidy, clean, baseline"
+94 -6
View File
@@ -4,9 +4,15 @@
<p align="center"><strong>A terminal coding agent you own.</strong></p>
<p align="center">
<a href="https://trendshift.io/repositories/70679?utm_source=trendshift-badge&amp;utm_medium=badge&amp;utm_campaign=badge-trendshift-70679" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/70679/daily?language=Go" alt="Twigpine/zero | Trendshift" width="250" height="55"/></a>
<a href="https://trendshift.io/repositories/70679?utm_source=trendshift-badge&amp;utm_medium=badge&amp;utm_campaign=badge-trendshift-70679" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/70679/weekly?language=Go" alt="Twigpine/zero | Trendshift" width="250" height="55"/></a>
<a href="https://trendshift.io/repositories/70679?utm_source=repository-badge&amp;utm_medium=badge&amp;utm_campaign=badge-repository-70679" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/repositories/70679" alt="Twigpine/zero | Trendshift" width="250" height="55"/></a>
</p>
<p align="center">
<a href="LICENSE"><img alt="license" src="https://img.shields.io/badge/license-MIT-blue"></a>
<img alt="Go 1.26.6+" src="https://img.shields.io/badge/Go-1.26.6+-00ADD8?logo=go&logoColor=white">
<img alt="Go 1.26.9+" src="https://img.shields.io/badge/Go-1.26.9+-00ADD8?logo=go&logoColor=white">
<img alt="25+ providers" src="https://img.shields.io/badge/providers-25+-34E2EA">
<a href="https://discord.gg/CaQDS6wdFn"><img alt="Discord" src="https://img.shields.io/badge/Discord-join-5865F2?logo=discord&logoColor=white"></a>
<br>
@@ -23,10 +29,74 @@ zero exec "fix the failing test in ./pkg"
zero exec --output-format stream-json < turns.jsonl
```
## Partners
<table align="center">
<tr>
<td align="center" width="150" height="80">
<a href="https://twigpine.com">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://twigpine.com/logo-dark.png">
<img src="https://twigpine.com/logo.png" alt="Twigpine logo" width="72">
</picture>
</a>
</td>
<td align="center" width="150" height="80">
<a href="https://mimo.mi.com">
<img src="https://mimo.xiaomi.com/mimo-v2-pro/assets/logo.svg" alt="Xiaomi MiMo logo" width="136">
</a>
</td>
<td align="center" width="150" height="80">
<a href="https://novita.ai/">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/assets/novita-logo-dark.svg">
<img src="docs/assets/novita-logo.svg" alt="Novita AI logo" width="136">
</picture>
</a>
</td>
<td align="center" width="150" height="80">
<a href="https://concentrate.ai/">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/assets/concentrate-logo-dark.svg">
<img src="docs/assets/concentrate-logo.svg" alt="Concentrate logo" width="64">
</picture>
</a>
</td>
<td align="center" width="150" height="80">
<a href="https://exa.ai/">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/assets/exa-logo-dark.svg">
<img src="docs/assets/exa-logo.svg" alt="Exa logo" width="110">
</picture>
</a>
</td>
</tr>
<tr>
<td align="center"><a href="https://twigpine.com"><strong>Twigpine</strong></a></td>
<td align="center"><a href="https://mimo.mi.com"><strong>Xiaomi MiMo</strong></a></td>
<td align="center"><a href="https://novita.ai/"><strong>Novita AI</strong></a></td>
<td align="center"><a href="https://concentrate.ai/"><strong>Concentrate</strong></a></td>
<td align="center"><a href="https://exa.ai/"><strong>Exa</strong></a></td>
</tr>
<tr>
<td align="center" width="150" height="80">
<a href="https://www.gmicloud.ai/">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/assets/gmi-cloud-logo-dark.svg">
<img src="docs/assets/gmi-cloud-logo.svg" alt="GMI Cloud logo" width="120">
</picture>
</a>
</td>
</tr>
<tr>
<td align="center"><a href="https://www.gmicloud.ai/"><strong>GMI Cloud</strong></a></td>
</tr>
</table>
## Why Zero
- **Use the model you want.** Bring OpenAI, Anthropic, Gemini, Groq, OpenRouter,
DeepSeek, Mistral, xAI, Qwen, Kimi, GitHub Models, Ollama, LM Studio, or any
DeepSeek, Mistral, xAI, Qwen, Kimi, GitHub Models, Ollama, LM Studio, Atomic Chat, or any
OpenAI-/Anthropic-compatible endpoint.
- **Stay in control.** File writes, shell commands, network access, and
out-of-workspace writes go through Zero's permission and sandbox policy.
@@ -76,7 +146,7 @@ irm https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.ps1 | ie
### From source
Source builds require Go 1.26.6+.
Source builds require Go 1.26.9+.
```bash
git clone https://github.com/Gitlawb/zero.git
@@ -133,6 +203,7 @@ export GEMINI_API_KEY=...
export AIMLAPI_API_KEY=...
export LONGCAT_API_KEY=...
export FIREWORKS_API_KEY=...
export GMI_API_KEY=...
export MINIMAX_API_KEY=...
export MINIMAXI_API_KEY=...
```
@@ -155,6 +226,12 @@ To configure Fireworks AI directly, run:
zero providers setup fireworks --set-active
```
To configure GMI Cloud directly, run:
```bash
zero providers setup gmicloud --set-active
```
MiniMax presets use the Anthropic-compatible endpoints for the global and China
regions:
@@ -182,8 +259,12 @@ zero providers add custom-openai-compatible \
--set-active
```
For local models, run Ollama or LM Studio and then use `zero setup` or
`zero providers detect`.
For local models, run Ollama, LM Studio, or the [Atomic Chat](https://atomic.chat)
desktop app, then use `zero setup` or `zero providers detect`. For Atomic Chat,
load a model and enable its local OpenAI-compatible API (default
`http://127.0.0.1:1337/v1`). Choose `atomic-chat-local`; detection includes the
loaded model ID in the add command. If no usable ID is discovered, load a model
and retry. Model IDs requiring shell-specific quoting use interactive setup.
## Daily Use
@@ -309,7 +390,7 @@ zero context context-budget report
zero repo-map deterministic repository map
zero repo-info local repository summary
zero search | find search local session history
zero sessions inspect, resume, fork, and rewind sessions
zero sessions inspect, resume, fork, rewind, and prune sessions
zero spec manage spec-mode drafts
zero specialist manage specialist subagents
zero skills manage markdown instruction skills
@@ -327,6 +408,13 @@ zero update --check check for newer releases
zero upgrade download, verify, and install the latest release
```
Cron keeps the newest 1,000 run outcomes per job in `runs.jsonl`. Each new run
atomically replaces the history with the retained tail; existing larger histories
are trimmed on their next run. Archive the file before that run if you need older
outcomes. This retention does not delete session directories or reset fire counts.
Malformed records and legacy lines of 1 MiB or more are skipped when reading or
compacting history; new records of that size are rejected.
## Extending Zero
### Project and personal instructions
+86 -5
View File
@@ -4,9 +4,15 @@
<p align="center"><strong>一个独属于你的终端编码智能体。</strong></p>
<p align="center">
<a href="https://trendshift.io/repositories/70679?utm_source=trendshift-badge&amp;utm_medium=badge&amp;utm_campaign=badge-trendshift-70679" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/70679/daily?language=Go" alt="Twigpine/zero | Trendshift" width="250" height="55"/></a>
<a href="https://trendshift.io/repositories/70679?utm_source=trendshift-badge&amp;utm_medium=badge&amp;utm_campaign=badge-trendshift-70679" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/70679/weekly?language=Go" alt="Twigpine/zero | Trendshift" width="250" height="55"/></a>
<a href="https://trendshift.io/repositories/70679?utm_source=repository-badge&amp;utm_medium=badge&amp;utm_campaign=badge-repository-70679" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/repositories/70679" alt="Twigpine/zero | Trendshift" width="250" height="55"/></a>
</p>
<p align="center">
<a href="LICENSE"><img alt="license" src="https://img.shields.io/badge/license-MIT-blue"></a>
<img alt="Go 1.26.6+" src="https://img.shields.io/badge/Go-1.26.6+-00ADD8?logo=go&logoColor=white">
<img alt="Go 1.26.9+" src="https://img.shields.io/badge/Go-1.26.9+-00ADD8?logo=go&logoColor=white">
<img alt="25+ providers" src="https://img.shields.io/badge/providers-25+-34E2EA">
<a href="https://discord.gg/CaQDS6wdFn"><img alt="Discord" src="https://img.shields.io/badge/Discord-join-5865F2?logo=discord&logoColor=white"></a>
<br>
@@ -21,9 +27,73 @@ zero exec "修复 ./pkg 中失败的测试"
zero exec --output-format stream-json < turns.jsonl
```
## 合作伙伴
<table align="center">
<tr>
<td align="center" width="150" height="80">
<a href="https://twigpine.com">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://twigpine.com/logo-dark.png">
<img src="https://twigpine.com/logo.png" alt="Twigpine logo" width="72">
</picture>
</a>
</td>
<td align="center" width="150" height="80">
<a href="https://mimo.mi.com">
<img src="https://mimo.xiaomi.com/mimo-v2-pro/assets/logo.svg" alt="Xiaomi MiMo logo" width="136">
</a>
</td>
<td align="center" width="150" height="80">
<a href="https://novita.ai/">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/assets/novita-logo-dark.svg">
<img src="docs/assets/novita-logo.svg" alt="Novita AI logo" width="136">
</picture>
</a>
</td>
<td align="center" width="150" height="80">
<a href="https://concentrate.ai/">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/assets/concentrate-logo-dark.svg">
<img src="docs/assets/concentrate-logo.svg" alt="Concentrate logo" width="64">
</picture>
</a>
</td>
<td align="center" width="150" height="80">
<a href="https://exa.ai/">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/assets/exa-logo-dark.svg">
<img src="docs/assets/exa-logo.svg" alt="Exa logo" width="110">
</picture>
</a>
</td>
</tr>
<tr>
<td align="center"><a href="https://twigpine.com"><strong>Twigpine</strong></a></td>
<td align="center"><a href="https://mimo.mi.com"><strong>Xiaomi MiMo</strong></a></td>
<td align="center"><a href="https://novita.ai/"><strong>Novita AI</strong></a></td>
<td align="center"><a href="https://concentrate.ai/"><strong>Concentrate</strong></a></td>
<td align="center"><a href="https://exa.ai/"><strong>Exa</strong></a></td>
</tr>
<tr>
<td align="center" width="150" height="80">
<a href="https://www.gmicloud.ai/">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/assets/gmi-cloud-logo-dark.svg">
<img src="docs/assets/gmi-cloud-logo.svg" alt="GMI Cloud logo" width="120">
</picture>
</a>
</td>
</tr>
<tr>
<td align="center"><a href="https://www.gmicloud.ai/"><strong>GMI Cloud</strong></a></td>
</tr>
</table>
## 为什么选择 Zero
- **使用你想要的模型。** 支持 OpenAI、Anthropic、Gemini、Groq、OpenRouter、DeepSeek、Mistral、xAI、Qwen、Kimi、GitHub Models、Ollama、LM Studio,或任何 OpenAI/Anthropic 兼容端点。
- **使用你想要的模型。** 支持 OpenAI、Anthropic、Gemini、Groq、OpenRouter、DeepSeek、Mistral、xAI、Qwen、Kimi、GitHub Models、Ollama、LM Studio、Atomic Chat,或任何 OpenAI/Anthropic 兼容端点。
- **保持控制权。** 文件写入、Shell 命令、网络访问和工作区外写入都经过 Zero 的权限和沙箱策略。
- **在终端中工作。** TUI 具有模型/提供商选择器、图片输入、斜杠命令、实时计划/工具渲染、回滚滚动、主题以及恢复/分叉支持。
- **无 TUI 也能工作。** `zero exec` 可脚本化,支持文本/JSON/stream-JSON I/O、隔离的工作树、规范优先运行,以及用于 CI 的有意义的退出码。
@@ -57,7 +127,7 @@ irm https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.ps1 | ie
### 从源码构建
源码构建需要 Go 1.26.6+。
源码构建需要 Go 1.26.9+。
```bash
git clone https://github.com/Gitlawb/zero.git
@@ -106,6 +176,7 @@ export OPENAI_API_KEY=sk-...
export ANTHROPIC_API_KEY=...
export GEMINI_API_KEY=...
export LONGCAT_API_KEY=...
export GMI_API_KEY=...
```
要直接配置美团 LongCat(LongCat-2.0),运行:
@@ -114,7 +185,17 @@ export LONGCAT_API_KEY=...
zero providers setup longcat --set-active
```
对于本地模型,运行 Ollama 或 LM Studio,然后使用 `zero setup` 或 `zero providers detect`。
要直接配置 GMI Cloud,运行:
```bash
zero providers setup gmicloud --set-active
```
对于本地模型,运行 Ollama、LM Studio 或 [Atomic Chat](https://atomic.chat) 桌面应用,
然后使用 `zero setup` 或 `zero providers detect`。使用 Atomic Chat 时,请先加载模型并启用
本地 OpenAI 兼容 API(默认地址为 `http://127.0.0.1:1337/v1`),再选择 `atomic-chat-local`。
检测生成的添加命令会包含已加载的模型 ID;如果未发现可用的 ID,请加载模型后重试。
对于需要特定 Shell 转义的模型 ID,请使用交互式设置。
## 日常使用
@@ -214,7 +295,7 @@ zero context 上下文预算报告
zero repo-map 确定性仓库映射
zero repo-info 本地仓库摘要
zero search | find 搜索本地会话历史
zero sessions 检查、恢复、分叉和回滚会话
zero sessions 检查、恢复、分叉、回滚和清理会话
zero spec 管理规范模式草稿
zero specialist 管理专业子智能体
zero skills 管理 Markdown 指令技能
+3 -2
View File
@@ -302,8 +302,9 @@ Conceptually, one requested tool goes through these phases:
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.
resume/history; ACP persists conversational messages and completed tool-call
starts/results for load replay. Live streaming details and permission
exchanges remain client notifications rather than durable history.
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
+2 -2
View File
@@ -153,7 +153,7 @@ describe a version that is no longer on disk. Prefer `mise upgrade`.
go install github.com/Gitlawb/zero/cmd/zero@latest
```
This builds from source, so it needs Go 1.26.6+ and it does not go through the
This builds from source, so it needs Go 1.26.9+ and it does not go through the
release archives. Two consequences worth knowing before you pick it:
- On Linux you also need the sandbox helper, which is a separate binary and is
@@ -180,7 +180,7 @@ Build a local binary:
go build -o zero ./cmd/zero
```
Source builds require Go 1.26.6+.
Source builds require Go 1.26.9+.
### Sandbox Helpers For Source Builds
+26
View File
@@ -0,0 +1,26 @@
# Local per-model completion limits
Add `modelRPM` to the existing Zero config file:
```json
{"modelRPM": {"gpt-4.1": 30, "claude-sonnet-4.5": 15}}
```
Keys resolve through the model registry; custom IDs match exactly after trimming
whitespace. Missing keys and zero mean unlimited. Negative limits and conflicting
aliases are configuration errors. Project config may add or tighten a user cap,
but cannot relax it. Changes take effect on the next Zero process start.
For `zero exec` and interactive agent turns, admission happens immediately before
the wrapped turn session's `Stream`. N admissions fit in any sliding 60-second
window; N+1 returns a local rate-limit error before entering that method. The
error reports when a slot expires. Exec uses its existing provider-error exit
code plus a local-limit hint. There is no sleep or hidden retry. Failed admitted
requests still consume a slot.
Sessions and model switches share a limiter in one process. Separate processes
have independent windows; restarting clears them. This is not an account-wide
cap. It counts Stream admissions, not all HTTP requests: setup/prewarm,
compaction, provider-internal retries, discovery and direct calls outside that
boundary are not counted. No TPM, cost budgets, persistence, cross-process
coordination or new runtime/Python dependency is introduced.
+1
View File
@@ -6,6 +6,7 @@ Zero.
## User Docs
- [Install](INSTALL.md)
- [Local per-model completion limits](MODEL_RPM.md)
- [Update flow](UPDATE.md)
- [OAuth logins and subscription-backed providers](oauth-subscriptions.md)
+13 -3
View File
@@ -93,14 +93,24 @@ binary:
| State on disk | Meaning |
|---|---|
| `<binary>.old` (or `<binary>.<suffix>.old`) plus a `.keep` marker | A previous update could not restore the original binary. The `.old` file may be the last binary the updater verified; the installed one may be unverified. |
| `<binary>.old` (or `<binary>.<suffix>.old`) plus a `.keep` marker, or a per-user update-recovery record naming that copy | A previous update could not restore the original binary. The `.old` file may be the last binary the updater verified; the installed one may be unverified. The `.keep` marker is in the installation directory and can be deleted; the identity-bound record is not, so deleting the marker alone does not end the refusal. |
| `<binary>.…old.<suffix>.recovery` | A previous update could not even write the marker, so it moved the last verified binary to that name. |
| The binary is missing and one or more `*.old` files exist | The previous attempt was interrupted between moves. |
The refusal names the paths involved and the two moves that end the state:
either move the recovery binary back over the executable path, or — if the
installed binary is the one you want — delete the `.keep` marker (or the
`.recovery` copy, once you have verified the installed binary) and update again.
installed binary is the one you want — delete the recovery copy itself (not
only the `.keep` marker) after verifying the installed binary, then update
again. Deleting the marker alone is not enough: a failed restore also records
that copy in per-user state outside the installation directory, and the next
update consults that record even if the marker is gone.
That trusted record lives under `%AppData%\zero\update-recovery` (per-user).
The recovery copy and its `.keep` marker sit in the installation directory
(per-machine). A second Windows account on the same machine does not see the
first account's unresolved record; the sibling marker is the machine-visible
signal for every account. There is no separate machine-wide store: writing one
would need a shared writable location and ACL rules the updater does not own.
This is deliberately fail-closed and differs from older releases, which deleted
`<binary>.old` and proceeded. The trade-off is that anyone who can write in the
+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80 80" width="80" height="80">
<circle cx="6.4" cy="6.4" r="0.3456" fill="#FFFFFF"/><circle cx="16" cy="6.4" r="1.3440793933950872" fill="#FFFFFF"/><circle cx="25.6" cy="6.4" r="2.03724340327418" fill="#FFFFFF"/><circle cx="35.199999999999996" cy="6.4" r="2.5983501502741575" fill="#FFFFFF"/><circle cx="44.8" cy="6.4" r="3.087883576356698" fill="#FFFFFF"/><circle cx="54.4" cy="6.4" r="3.530261898078346" fill="#FFFFFF"/><circle cx="63.99999999999999" cy="6.4" r="3.938362368365323" fill="#FFFFFF"/><circle cx="73.60000000000001" cy="6.4" r="4.32" fill="#FFFFFF"/><circle cx="6.4" cy="16" r="0.3456" fill="#FFFFFF"/><circle cx="16" cy="16" r="1.2253406720005793" fill="#FFFFFF"/><circle cx="25.6" cy="16" r="1.8572691561702617" fill="#FFFFFF"/><circle cx="35.199999999999996" cy="16" r="2.3688065860361402" fill="#FFFFFF"/><circle cx="44.8" cy="16" r="2.8150936284760535" fill="#FFFFFF"/><circle cx="54.4" cy="16" r="3.2183913448763177" fill="#FFFFFF"/><circle cx="63.99999999999999" cy="16" r="3.5904393853139163" fill="#FFFFFF"/><circle cx="73.60000000000001" cy="16" r="3.9383623683653233" fill="#FFFFFF"/><circle cx="6.4" cy="25.6" r="0.3456" fill="#FFFFFF"/><circle cx="16" cy="25.6" r="1.0983685811330859" fill="#FFFFFF"/><circle cx="25.6" cy="25.6" r="1.6648154545579388" fill="#FFFFFF"/><circle cx="35.199999999999996" cy="25.6" r="2.1233464197636582" fill="#FFFFFF"/><circle cx="44.8" cy="25.6" r="2.5233883646560065" fill="#FFFFFF"/><circle cx="54.4" cy="25.6" r="2.8848956178295655" fill="#FFFFFF"/><circle cx="63.99999999999999" cy="25.6" r="3.2183913448763177" fill="#FFFFFF"/><circle cx="73.60000000000001" cy="25.6" r="3.530261898078346" fill="#FFFFFF"/><circle cx="6.4" cy="35.199999999999996" r="0.3456" fill="#FFFFFF"/><circle cx="16" cy="35.199999999999996" r="0.9607316398574451" fill="#FFFFFF"/><circle cx="25.6" cy="35.199999999999996" r="1.456196862502631" fill="#FFFFFF"/><circle cx="35.199999999999996" cy="35.199999999999996" r="1.8572691561702614" fill="#FFFFFF"/><circle cx="44.8" cy="35.199999999999996" r="2.2071817085957024" fill="#FFFFFF"/><circle cx="54.4" cy="35.199999999999996" r="2.5233883646560065" fill="#FFFFFF"/><circle cx="63.99999999999999" cy="35.199999999999996" r="2.815093628476053" fill="#FFFFFF"/><circle cx="73.60000000000001" cy="35.199999999999996" r="3.087883576356698" fill="#FFFFFF"/><circle cx="6.4" cy="44.8" r="0.3456" fill="#FFFFFF"/><circle cx="16" cy="44.8" r="0.8084233550482692" fill="#FFFFFF"/><circle cx="25.6" cy="44.8" r="1.2253406720005793" fill="#FFFFFF"/><circle cx="35.199999999999996" cy="44.8" r="1.56282951468281" fill="#FFFFFF"/><circle cx="44.8" cy="44.8" r="1.8572691561702617" fill="#FFFFFF"/><circle cx="54.4" cy="44.8" r="2.1233464197636582" fill="#FFFFFF"/><circle cx="63.99999999999999" cy="44.8" r="2.3688065860361402" fill="#FFFFFF"/><circle cx="73.60000000000001" cy="44.8" r="2.5983501502741584" fill="#FFFFFF"/><circle cx="6.4" cy="54.4" r="0.3456" fill="#FFFFFF"/><circle cx="16" cy="54.4" r="0.633846499460857" fill="#FFFFFF"/><circle cx="25.6" cy="54.4" r="0.9607316398574451" fill="#FFFFFF"/><circle cx="35.199999999999996" cy="54.4" r="1.225340672000579" fill="#FFFFFF"/><circle cx="44.8" cy="54.4" r="1.456196862502631" fill="#FFFFFF"/><circle cx="54.4" cy="54.4" r="1.6648154545579388" fill="#FFFFFF"/><circle cx="63.99999999999999" cy="54.4" r="1.8572691561702614" fill="#FFFFFF"/><circle cx="73.60000000000001" cy="54.4" r="2.03724340327418" fill="#FFFFFF"/><circle cx="6.4" cy="63.99999999999999" r="0.3456" fill="#FFFFFF"/><circle cx="16" cy="63.99999999999999" r="0.4181827351271541" fill="#FFFFFF"/><circle cx="25.6" cy="63.99999999999999" r="0.6338464994608571" fill="#FFFFFF"/><circle cx="35.199999999999996" cy="63.99999999999999" r="0.8084233550482695" fill="#FFFFFF"/><circle cx="44.8" cy="63.99999999999999" r="0.9607316398574454" fill="#FFFFFF"/><circle cx="54.4" cy="63.99999999999999" r="1.0983685811330863" fill="#FFFFFF"/><circle cx="63.99999999999999" cy="63.99999999999999" r="1.2253406720005795" fill="#FFFFFF"/><circle cx="73.60000000000001" cy="63.99999999999999" r="1.3440793933950876" fill="#FFFFFF"/><circle cx="6.4" cy="73.60000000000001" r="0.3456" fill="#FFFFFF"/><circle cx="16" cy="73.60000000000001" r="0.3456" fill="#FFFFFF"/><circle cx="25.6" cy="73.60000000000001" r="0.3456" fill="#FFFFFF"/><circle cx="35.199999999999996" cy="73.60000000000001" r="0.3456" fill="#FFFFFF"/><circle cx="44.8" cy="73.60000000000001" r="0.3456" fill="#FFFFFF"/><circle cx="54.4" cy="73.60000000000001" r="0.3456" fill="#FFFFFF"/><circle cx="63.99999999999999" cy="73.60000000000001" r="0.3456" fill="#FFFFFF"/><circle cx="73.60000000000001" cy="73.60000000000001" r="0.3456" fill="#FFFFFF"/>
</svg>

After

Width:  |  Height:  |  Size: 4.7 KiB

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80 80" width="80" height="80">
<circle cx="6.4" cy="6.4" r="0.3456" fill="#0D0E10"/><circle cx="16" cy="6.4" r="1.3440793933950872" fill="#0D0E10"/><circle cx="25.6" cy="6.4" r="2.03724340327418" fill="#0D0E10"/><circle cx="35.199999999999996" cy="6.4" r="2.5983501502741575" fill="#0D0E10"/><circle cx="44.8" cy="6.4" r="3.087883576356698" fill="#0D0E10"/><circle cx="54.4" cy="6.4" r="3.530261898078346" fill="#0D0E10"/><circle cx="63.99999999999999" cy="6.4" r="3.938362368365323" fill="#0D0E10"/><circle cx="73.60000000000001" cy="6.4" r="4.32" fill="#0D0E10"/><circle cx="6.4" cy="16" r="0.3456" fill="#0D0E10"/><circle cx="16" cy="16" r="1.2253406720005793" fill="#0D0E10"/><circle cx="25.6" cy="16" r="1.8572691561702617" fill="#0D0E10"/><circle cx="35.199999999999996" cy="16" r="2.3688065860361402" fill="#0D0E10"/><circle cx="44.8" cy="16" r="2.8150936284760535" fill="#0D0E10"/><circle cx="54.4" cy="16" r="3.2183913448763177" fill="#0D0E10"/><circle cx="63.99999999999999" cy="16" r="3.5904393853139163" fill="#0D0E10"/><circle cx="73.60000000000001" cy="16" r="3.9383623683653233" fill="#0D0E10"/><circle cx="6.4" cy="25.6" r="0.3456" fill="#0D0E10"/><circle cx="16" cy="25.6" r="1.0983685811330859" fill="#0D0E10"/><circle cx="25.6" cy="25.6" r="1.6648154545579388" fill="#0D0E10"/><circle cx="35.199999999999996" cy="25.6" r="2.1233464197636582" fill="#0D0E10"/><circle cx="44.8" cy="25.6" r="2.5233883646560065" fill="#0D0E10"/><circle cx="54.4" cy="25.6" r="2.8848956178295655" fill="#0D0E10"/><circle cx="63.99999999999999" cy="25.6" r="3.2183913448763177" fill="#0D0E10"/><circle cx="73.60000000000001" cy="25.6" r="3.530261898078346" fill="#0D0E10"/><circle cx="6.4" cy="35.199999999999996" r="0.3456" fill="#0D0E10"/><circle cx="16" cy="35.199999999999996" r="0.9607316398574451" fill="#0D0E10"/><circle cx="25.6" cy="35.199999999999996" r="1.456196862502631" fill="#0D0E10"/><circle cx="35.199999999999996" cy="35.199999999999996" r="1.8572691561702614" fill="#0D0E10"/><circle cx="44.8" cy="35.199999999999996" r="2.2071817085957024" fill="#0D0E10"/><circle cx="54.4" cy="35.199999999999996" r="2.5233883646560065" fill="#0D0E10"/><circle cx="63.99999999999999" cy="35.199999999999996" r="2.815093628476053" fill="#0D0E10"/><circle cx="73.60000000000001" cy="35.199999999999996" r="3.087883576356698" fill="#0D0E10"/><circle cx="6.4" cy="44.8" r="0.3456" fill="#0D0E10"/><circle cx="16" cy="44.8" r="0.8084233550482692" fill="#0D0E10"/><circle cx="25.6" cy="44.8" r="1.2253406720005793" fill="#0D0E10"/><circle cx="35.199999999999996" cy="44.8" r="1.56282951468281" fill="#0D0E10"/><circle cx="44.8" cy="44.8" r="1.8572691561702617" fill="#0D0E10"/><circle cx="54.4" cy="44.8" r="2.1233464197636582" fill="#0D0E10"/><circle cx="63.99999999999999" cy="44.8" r="2.3688065860361402" fill="#0D0E10"/><circle cx="73.60000000000001" cy="44.8" r="2.5983501502741584" fill="#0D0E10"/><circle cx="6.4" cy="54.4" r="0.3456" fill="#0D0E10"/><circle cx="16" cy="54.4" r="0.633846499460857" fill="#0D0E10"/><circle cx="25.6" cy="54.4" r="0.9607316398574451" fill="#0D0E10"/><circle cx="35.199999999999996" cy="54.4" r="1.225340672000579" fill="#0D0E10"/><circle cx="44.8" cy="54.4" r="1.456196862502631" fill="#0D0E10"/><circle cx="54.4" cy="54.4" r="1.6648154545579388" fill="#0D0E10"/><circle cx="63.99999999999999" cy="54.4" r="1.8572691561702614" fill="#0D0E10"/><circle cx="73.60000000000001" cy="54.4" r="2.03724340327418" fill="#0D0E10"/><circle cx="6.4" cy="63.99999999999999" r="0.3456" fill="#0D0E10"/><circle cx="16" cy="63.99999999999999" r="0.4181827351271541" fill="#0D0E10"/><circle cx="25.6" cy="63.99999999999999" r="0.6338464994608571" fill="#0D0E10"/><circle cx="35.199999999999996" cy="63.99999999999999" r="0.8084233550482695" fill="#0D0E10"/><circle cx="44.8" cy="63.99999999999999" r="0.9607316398574454" fill="#0D0E10"/><circle cx="54.4" cy="63.99999999999999" r="1.0983685811330863" fill="#0D0E10"/><circle cx="63.99999999999999" cy="63.99999999999999" r="1.2253406720005795" fill="#0D0E10"/><circle cx="73.60000000000001" cy="63.99999999999999" r="1.3440793933950876" fill="#0D0E10"/><circle cx="6.4" cy="73.60000000000001" r="0.3456" fill="#0D0E10"/><circle cx="16" cy="73.60000000000001" r="0.3456" fill="#0D0E10"/><circle cx="25.6" cy="73.60000000000001" r="0.3456" fill="#0D0E10"/><circle cx="35.199999999999996" cy="73.60000000000001" r="0.3456" fill="#0D0E10"/><circle cx="44.8" cy="73.60000000000001" r="0.3456" fill="#0D0E10"/><circle cx="54.4" cy="73.60000000000001" r="0.3456" fill="#0D0E10"/><circle cx="63.99999999999999" cy="73.60000000000001" r="0.3456" fill="#0D0E10"/><circle cx="73.60000000000001" cy="73.60000000000001" r="0.3456" fill="#0D0E10"/>
</svg>

After

Width:  |  Height:  |  Size: 4.7 KiB

+6
View File
@@ -0,0 +1,6 @@
<svg width="128" height="40" viewBox="0 0 413 129" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M106.417 9.30741C106.417 9.57392 106.324 9.83227 106.155 10.0388L61.5395 64.5L106.155 118.96C106.324 119.166 106.417 119.425 106.417 119.691V127.843C106.417 128.482 105.897 129 105.255 129H1.16175C0.520132 129 0 128.482 0 127.843V1.15695C0 0.517982 0.520131 0 1.16174 0H105.255C105.897 0 106.417 0.517984 106.417 1.15695V9.30741ZM19.0364 116.382H87.6921L53.363 74.4795L19.0364 116.382ZM12.6699 104.192L40.1426 70.6585H12.6699V104.192ZM12.6699 58.0409H39.8964L12.6699 24.8064V58.0409ZM53.363 54.5193L87.6921 12.6176H19.0364L53.363 54.5193Z" fill="white"/>
<path d="M176.198 117.65C166.094 117.65 157.323 115.553 149.886 111.36C142.588 107.167 136.905 101.017 132.835 92.911C128.906 84.8045 126.941 75.0208 126.941 63.5599C126.941 53.3569 128.976 44.2022 133.046 36.0957C137.256 27.9892 143.01 21.6298 150.307 17.0175C157.744 12.4052 166.024 10.099 175.146 10.099C185.109 10.099 193.599 12.3353 200.616 16.8078C207.633 21.1406 212.965 27.3602 216.614 35.4667C220.263 43.4334 222.087 52.8677 222.087 63.7695V66.1766C222.087 66.8156 221.567 67.3336 220.925 67.3336H145.044C145.185 74.881 146.588 81.3802 149.254 86.8311C152.061 92.282 155.78 96.475 160.411 99.4101C165.182 102.205 170.515 103.603 176.409 103.603C183.285 103.603 189.179 102.066 194.091 98.9908C198.779 95.9225 201.741 91.008 202.977 84.2471C203.08 83.6853 203.565 83.267 204.139 83.267H219.737C220.429 83.267 220.969 83.8669 220.871 84.5494C220.038 90.3376 217.988 95.6406 214.72 100.458C211.211 105.63 206.159 109.823 199.564 113.037C193.108 116.112 185.32 117.65 176.198 117.65ZM202.721 54.5449C202.721 45.3203 200.265 37.9825 195.354 32.5316C190.582 27.0807 183.846 24.3552 175.146 24.3552C166.866 24.3552 160.06 27.2205 154.727 32.9509C149.395 38.6813 146.377 45.8794 145.676 54.5449H202.721Z" fill="white"/>
<path d="M364.585 117.65C358.13 117.65 352.236 116.392 346.903 113.876C341.571 111.36 337.361 107.796 334.273 103.184C331.186 98.4317 329.642 92.9808 329.642 86.8311C329.642 77.7462 332.73 70.688 338.904 65.6564C345.219 60.485 353.359 57.0607 363.322 55.3835L385.004 52.0291C387.67 51.6098 389.705 51.0507 391.108 50.3519C392.652 49.5133 393.704 48.465 394.266 47.2071C394.967 45.8095 395.318 44.0624 395.318 41.9659C395.318 36.515 393.424 32.2521 389.635 29.1772C385.846 25.9626 380.934 24.3552 374.9 24.3552C367.322 24.3552 361.498 26.2421 357.428 30.0158C353.595 33.4387 351.443 38.1016 350.971 44.0046C350.922 44.622 350.412 45.1106 349.79 45.1106H334.014C333.353 45.1106 332.823 44.5605 332.872 43.9032C333.312 38.0771 335.042 32.6792 338.062 27.7096C341.29 22.3985 345.921 18.1356 351.955 14.921C358.13 11.7063 365.287 10.099 373.426 10.099C381.706 10.099 388.793 11.6364 394.687 14.7113C400.721 17.7862 405.282 22.1888 408.369 27.9193C411.456 33.6497 413 40.4284 413 48.2554V113.348C413 113.987 412.48 114.505 411.838 114.505H396.901C396.259 114.505 395.739 113.987 395.739 113.348V109.473C395.739 108.076 395.739 106.678 395.739 105.28C395.739 103.743 395.739 102.275 395.739 100.878C393.213 106.189 389.214 110.312 383.741 113.247C378.408 116.182 372.023 117.65 364.585 117.65ZM367.111 103.603C372.444 103.603 377.215 102.485 381.425 100.249C385.775 97.8727 389.143 94.6581 391.529 90.6048C394.055 86.4118 395.318 81.6597 395.318 76.3486V61.4634C394.757 62.302 393.564 63.0707 391.74 63.7695C389.915 64.4684 387.389 65.1672 384.162 65.866L369.427 68.5915C362.971 69.8494 357.849 71.9459 354.06 74.881C350.271 77.8161 348.377 81.7995 348.377 86.8311C348.377 91.8627 350.131 95.916 353.639 98.9908C357.288 102.066 361.779 103.603 367.111 103.603Z" fill="white"/>
<path d="M285.673 62.9573L325.127 112.948C325.726 113.707 325.183 114.82 324.213 114.82H305.929C305.572 114.82 305.235 114.657 305.015 114.378L275.053 76.414L245.093 114.378C244.872 114.657 244.536 114.82 244.179 114.82H225.893C224.924 114.82 224.381 113.707 224.98 112.948L264.433 62.9573L227.177 15.7512C226.578 14.9921 227.121 13.8793 228.091 13.8793H246.375C246.732 13.8793 247.068 14.0423 247.289 14.3214L275.053 49.5006L302.818 14.3214C303.038 14.0423 303.375 13.8793 303.731 13.8793H322.016C322.985 13.8793 323.528 14.9921 322.929 15.7512L285.673 62.9573Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 4.2 KiB

+6
View File
@@ -0,0 +1,6 @@
<svg width="128" height="40" viewBox="0 0 413 129" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M106.417 9.30741C106.417 9.57392 106.324 9.83227 106.155 10.0388L61.5395 64.5L106.155 118.96C106.324 119.166 106.417 119.425 106.417 119.691V127.843C106.417 128.482 105.897 129 105.255 129H1.16175C0.520132 129 0 128.482 0 127.843V1.15695C0 0.517982 0.520131 0 1.16174 0H105.255C105.897 0 106.417 0.517984 106.417 1.15695V9.30741ZM19.0364 116.382H87.6921L53.363 74.4795L19.0364 116.382ZM12.6699 104.192L40.1426 70.6585H12.6699V104.192ZM12.6699 58.0409H39.8964L12.6699 24.8064V58.0409ZM53.363 54.5193L87.6921 12.6176H19.0364L53.363 54.5193Z" fill="#0143D9"/>
<path d="M176.198 117.65C166.094 117.65 157.323 115.553 149.886 111.36C142.588 107.167 136.905 101.017 132.835 92.911C128.906 84.8045 126.941 75.0208 126.941 63.5599C126.941 53.3569 128.976 44.2022 133.046 36.0957C137.256 27.9892 143.01 21.6298 150.307 17.0175C157.744 12.4052 166.024 10.099 175.146 10.099C185.109 10.099 193.599 12.3353 200.616 16.8078C207.633 21.1406 212.965 27.3602 216.614 35.4667C220.263 43.4334 222.087 52.8677 222.087 63.7695V66.1766C222.087 66.8156 221.567 67.3336 220.925 67.3336H145.044C145.185 74.881 146.588 81.3802 149.254 86.8311C152.061 92.282 155.78 96.475 160.411 99.4101C165.182 102.205 170.515 103.603 176.409 103.603C183.285 103.603 189.179 102.066 194.091 98.9908C198.779 95.9225 201.741 91.008 202.977 84.2471C203.08 83.6853 203.565 83.267 204.139 83.267H219.737C220.429 83.267 220.969 83.8669 220.871 84.5494C220.038 90.3376 217.988 95.6406 214.72 100.458C211.211 105.63 206.159 109.823 199.564 113.037C193.108 116.112 185.32 117.65 176.198 117.65ZM202.721 54.5449C202.721 45.3203 200.265 37.9825 195.354 32.5316C190.582 27.0807 183.846 24.3552 175.146 24.3552C166.866 24.3552 160.06 27.2205 154.727 32.9509C149.395 38.6813 146.377 45.8794 145.676 54.5449H202.721Z" fill="black"/>
<path d="M364.585 117.65C358.13 117.65 352.236 116.392 346.903 113.876C341.571 111.36 337.361 107.796 334.273 103.184C331.186 98.4317 329.642 92.9808 329.642 86.8311C329.642 77.7462 332.73 70.688 338.904 65.6564C345.219 60.485 353.359 57.0607 363.322 55.3835L385.004 52.0291C387.67 51.6098 389.705 51.0507 391.108 50.3519C392.652 49.5133 393.704 48.465 394.266 47.2071C394.967 45.8095 395.318 44.0624 395.318 41.9659C395.318 36.515 393.424 32.2521 389.635 29.1772C385.846 25.9626 380.934 24.3552 374.9 24.3552C367.322 24.3552 361.498 26.2421 357.428 30.0158C353.595 33.4387 351.443 38.1016 350.971 44.0046C350.922 44.622 350.412 45.1106 349.79 45.1106H334.014C333.353 45.1106 332.823 44.5605 332.872 43.9032C333.312 38.0771 335.042 32.6792 338.062 27.7096C341.29 22.3985 345.921 18.1356 351.955 14.921C358.13 11.7063 365.287 10.099 373.426 10.099C381.706 10.099 388.793 11.6364 394.687 14.7113C400.721 17.7862 405.282 22.1888 408.369 27.9193C411.456 33.6497 413 40.4284 413 48.2554V113.348C413 113.987 412.48 114.505 411.838 114.505H396.901C396.259 114.505 395.739 113.987 395.739 113.348V109.473C395.739 108.076 395.739 106.678 395.739 105.28C395.739 103.743 395.739 102.275 395.739 100.878C393.213 106.189 389.214 110.312 383.741 113.247C378.408 116.182 372.023 117.65 364.585 117.65ZM367.111 103.603C372.444 103.603 377.215 102.485 381.425 100.249C385.775 97.8727 389.143 94.6581 391.529 90.6048C394.055 86.4118 395.318 81.6597 395.318 76.3486V61.4634C394.757 62.302 393.564 63.0707 391.74 63.7695C389.915 64.4684 387.389 65.1672 384.162 65.866L369.427 68.5915C362.971 69.8494 357.849 71.9459 354.06 74.881C350.271 77.8161 348.377 81.7995 348.377 86.8311C348.377 91.8627 350.131 95.916 353.639 98.9908C357.288 102.066 361.779 103.603 367.111 103.603Z" fill="black"/>
<path d="M285.673 62.9573L325.127 112.948C325.726 113.707 325.183 114.82 324.213 114.82H305.929C305.572 114.82 305.235 114.657 305.015 114.378L275.053 76.414L245.093 114.378C244.872 114.657 244.536 114.82 244.179 114.82H225.893C224.924 114.82 224.381 113.707 224.98 112.948L264.433 62.9573L227.177 15.7512C226.578 14.9921 227.121 13.8793 228.091 13.8793H246.375C246.732 13.8793 247.068 14.0423 247.289 14.3214L275.053 49.5006L302.818 14.3214C303.038 14.0423 303.375 13.8793 303.731 13.8793H322.016C322.985 13.8793 323.528 14.9921 322.929 15.7512L285.673 62.9573Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 4.2 KiB

+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 34 718 165" width="718" height="165" fill="#fff">
<path d="M123.292 132.486h-33.1c-2.644 0-4.808-2.395-4.808-5.363v-21.436c0-2.95 2.148-5.363 4.809-5.363h33.099c2.645 0 4.808-2.395 4.808-5.363V73.525c0-2.95 2.147-5.363 4.808-5.363h33.099c2.646 0 4.809-2.395 4.809-5.363V41.363c0-2.95-2.148-5.363-4.809-5.363H90.208c-2.645 0-4.808 2.395-4.808 5.363V62.8c0 2.95-2.147 5.363-4.808 5.363h-33.1c-2.645 0-4.808 2.395-4.808 5.363v21.436c0 2.95-2.147 5.363-4.808 5.363H4.808c-2.645 0-4.808 2.395-4.808 5.363v21.436c0 2.95 2.147 5.363 4.808 5.363h33.1c2.645 0 4.808 2.395 4.808 5.363v21.436c0 2.95 2.147 5.363 4.808 5.363h33.099c2.645 0 4.808 2.395 4.808 5.363v21.436c0 2.95 2.148 5.363 4.809 5.363h75.798c2.646 0 4.809-2.395 4.809-5.363v-21.436c0-2.95-2.148-5.363-4.809-5.363H132.94c-2.646 0-4.809-2.395-4.809-5.363v-21.436c0-2.95-2.147-5.363-4.808-5.363z"/>
<path d="M251.408 100.341c2.646 0 4.809 2.395 4.809 5.363v21.436c0 2.95-2.148 5.363-4.809 5.363H218.31c-2.646 0-4.809 2.395-4.809 5.363v21.436c0 2.95-2.147 5.363-4.808 5.363h-33.099c-2.645 0-4.808-2.395-4.808-5.363v-53.58c0-2.951 2.147-5.364 4.808-5.364h75.799zM510.68 40.105h-28.795v153.33h28.795zM717.43 40.105h-28.795v153.33h28.795z"/>
<path d="M532.41 40.105h-28.83l35.391 153.33H567.8zM653.242 40.105h-28.795v153.33h28.795z"/>
<path d="M596.594 193.435h-28.795l35.39-153.33h28.795zM452.028 114.062v27.042s-.139.174-.191.261h-83.069V114.08h83.295-.035z"/>
<path d="M452.028 141.347v35.651c-17.479 12.306-39.834 19.769-62.953 19.769-59.673 0-97.858-35.981-97.858-80.362s34.54-80.362 97.858-80.362c22.511 0 45.127 7.689 62.953 20.36v35.685c-13.105-16.42-37.856-28.743-62.953-28.743-44.052 0-70.695 20.846-70.695 53.077 0 32.232 28.691 53.077 70.695 53.077 25.323 0 49.727-11.906 62.744-28.1h.191v-.035z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 34 718 165" width="718" height="165" fill="#0D0E10">
<path d="M123.292 132.486h-33.1c-2.644 0-4.808-2.395-4.808-5.363v-21.436c0-2.95 2.148-5.363 4.809-5.363h33.099c2.645 0 4.808-2.395 4.808-5.363V73.525c0-2.95 2.147-5.363 4.808-5.363h33.099c2.646 0 4.809-2.395 4.809-5.363V41.363c0-2.95-2.148-5.363-4.809-5.363H90.208c-2.645 0-4.808 2.395-4.808 5.363V62.8c0 2.95-2.147 5.363-4.808 5.363h-33.1c-2.645 0-4.808 2.395-4.808 5.363v21.436c0 2.95-2.147 5.363-4.808 5.363H4.808c-2.645 0-4.808 2.395-4.808 5.363v21.436c0 2.95 2.147 5.363 4.808 5.363h33.1c2.645 0 4.808 2.395 4.808 5.363v21.436c0 2.95 2.147 5.363 4.808 5.363h33.099c2.645 0 4.808 2.395 4.808 5.363v21.436c0 2.95 2.148 5.363 4.809 5.363h75.798c2.646 0 4.809-2.395 4.809-5.363v-21.436c0-2.95-2.148-5.363-4.809-5.363H132.94c-2.646 0-4.809-2.395-4.809-5.363v-21.436c0-2.95-2.147-5.363-4.808-5.363z"/>
<path d="M251.408 100.341c2.646 0 4.809 2.395 4.809 5.363v21.436c0 2.95-2.148 5.363-4.809 5.363H218.31c-2.646 0-4.809 2.395-4.809 5.363v21.436c0 2.95-2.147 5.363-4.808 5.363h-33.099c-2.645 0-4.808-2.395-4.808-5.363v-53.58c0-2.951 2.147-5.364 4.808-5.364h75.799zM510.68 40.105h-28.795v153.33h28.795zM717.43 40.105h-28.795v153.33h28.795z"/>
<path d="M532.41 40.105h-28.83l35.391 153.33H567.8zM653.242 40.105h-28.795v153.33h28.795z"/>
<path d="M596.594 193.435h-28.795l35.39-153.33h28.795zM452.028 114.062v27.042s-.139.174-.191.261h-83.069V114.08h83.295-.035z"/>
<path d="M452.028 141.347v35.651c-17.479 12.306-39.834 19.769-62.953 19.769-59.673 0-97.858-35.981-97.858-80.362s34.54-80.362 97.858-80.362c22.511 0 45.127 7.689 62.953 20.36v35.685c-13.105-16.42-37.856-28.743-62.953-28.743-44.052 0-70.695 20.846-70.695 53.077 0 32.232 28.691 53.077 70.695 53.077 25.323 0 49.727-11.906 62.744-28.1h.191v-.035z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

+11
View File
@@ -0,0 +1,11 @@
<svg width="151" height="30" viewBox="0 0 151 30" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_novita_dark)">
<path d="M17.2639 0.640625V12.1297L0.03125 29.363H17.2639V17.8739H17.7376L28.7529 29.363H45.9875L17.2639 0.640625Z" fill="white"/>
</g>
<path fill-rule="evenodd" clip-rule="evenodd" d="M126.329 25.5459H131.778V28.9895H127.454C124.427 28.9895 122.523 27.3247 122.523 24.2256V15.0698H119.438V11.5974H122.523L123.417 7.03393H126.3V11.5974H131.778V15.0698H126.329V25.5459ZM61.9158 6.0293L69.8449 23.7083H70.0753V6.0293H74.1983V28.9895H67.97L60.0409 11.3105H59.8105V28.9895H55.6875V6.0293H61.9158ZM84.78 29.334C79.734 29.334 76.5052 25.804 76.5052 20.3223C76.5052 14.8406 79.6764 11.2529 84.78 11.2529C89.8836 11.2529 92.9971 14.6966 92.9971 20.3223C92.9971 25.948 89.826 29.334 84.78 29.334ZM84.78 25.8904C87.0293 25.8904 88.903 25.115 88.9606 22.9053V17.7393C88.9606 15.4719 87.0581 14.6966 84.78 14.6966C82.5019 14.6966 80.5418 15.4719 80.5418 17.7393V22.9053C80.5994 25.1438 82.5307 25.8904 84.78 25.8904ZM97.4094 11.5686L101.907 25.3443H102.137L106.635 11.5686H110.787L104.587 28.9607H99.4547L93.284 11.5686H97.407H97.4094ZM112.922 11.5974H116.728V28.9895H112.922V11.5974ZM112.69 6.0293H116.957V9.30127H112.69V6.0293ZM141.12 29.3628C136.477 29.3628 133.508 25.8328 133.508 20.3223C133.508 14.8118 136.42 11.2817 141.12 11.2817C143.312 11.2817 145.099 12.1423 146.166 13.7495H146.397L147.147 11.5974H150.03V28.9895H147.147L146.397 26.8938H146.166C145.013 28.4146 143.081 29.3616 141.12 29.3616V29.3628ZM141.898 25.8904C144.176 25.8904 146.165 25.1726 146.223 22.9053V17.7393C146.223 15.4143 144.146 14.7542 141.812 14.7542C139.477 14.7542 137.545 15.4719 137.545 17.7393V22.9053C137.602 25.2014 139.592 25.8904 141.898 25.8904Z" fill="white"/>
<defs>
<clipPath id="clip0_novita_dark">
<rect width="45.9562" height="28.7223" fill="white" transform="translate(0.0292969 0.640625)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

+11
View File
@@ -0,0 +1,11 @@
<svg width="151" height="30" viewBox="0 0 151 30" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_125_11573)">
<path d="M17.2639 0.640625V12.1297L0.03125 29.363H17.2639V17.8739H17.7376L28.7529 29.363H45.9875L17.2639 0.640625Z" fill="black"/>
</g>
<path fill-rule="evenodd" clip-rule="evenodd" d="M126.329 25.5459H131.778V28.9895H127.454C124.427 28.9895 122.523 27.3247 122.523 24.2256V15.0698H119.438V11.5974H122.523L123.417 7.03393H126.3V11.5974H131.778V15.0698H126.329V25.5459ZM61.9158 6.0293L69.8449 23.7083H70.0753V6.0293H74.1983V28.9895H67.97L60.0409 11.3105H59.8105V28.9895H55.6875V6.0293H61.9158ZM84.78 29.334C79.734 29.334 76.5052 25.804 76.5052 20.3223C76.5052 14.8406 79.6764 11.2529 84.78 11.2529C89.8836 11.2529 92.9971 14.6966 92.9971 20.3223C92.9971 25.948 89.826 29.334 84.78 29.334ZM84.78 25.8904C87.0293 25.8904 88.903 25.115 88.9606 22.9053V17.7393C88.9606 15.4719 87.0581 14.6966 84.78 14.6966C82.5019 14.6966 80.5418 15.4719 80.5418 17.7393V22.9053C80.5994 25.1438 82.5307 25.8904 84.78 25.8904ZM97.4094 11.5686L101.907 25.3443H102.137L106.635 11.5686H110.787L104.587 28.9607H99.4547L93.284 11.5686H97.407H97.4094ZM112.922 11.5974H116.728V28.9895H112.922V11.5974ZM112.69 6.0293H116.957V9.30127H112.69V6.0293ZM141.12 29.3628C136.477 29.3628 133.508 25.8328 133.508 20.3223C133.508 14.8118 136.42 11.2817 141.12 11.2817C143.312 11.2817 145.099 12.1423 146.166 13.7495H146.397L147.147 11.5974H150.03V28.9895H147.147L146.397 26.8938H146.166C145.013 28.4146 143.081 29.3616 141.12 29.3616V29.3628ZM141.898 25.8904C144.176 25.8904 146.165 25.1726 146.223 22.9053V17.7393C146.223 15.4143 144.146 14.7542 141.812 14.7542C139.477 14.7542 137.545 15.4719 137.545 17.7393V22.9053C137.602 25.2014 139.592 25.8904 141.898 25.8904Z" fill="black"/>
<defs>
<clipPath id="clip0_125_11573">
<rect width="45.9562" height="28.7223" fill="white" transform="translate(0.0292969 0.640625)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

+2 -1
View File
@@ -1,6 +1,6 @@
module github.com/Gitlawb/zero
go 1.26.6
go 1.26.9
require (
charm.land/bubbles/v2 v2.1.1
@@ -18,6 +18,7 @@ require (
github.com/ledongthuc/pdf v0.0.0-20250511090121-5959a4027728
golang.org/x/image v0.45.0
golang.org/x/sys v0.47.0
gopkg.in/yaml.v3 v3.0.1
mvdan.cc/sh/v3 v3.13.1
)
+4
View File
@@ -70,5 +70,9 @@ 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=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
mvdan.cc/sh/v3 v3.13.1 h1:DP3TfgZhDkT7lerUdnp6PTGKyxxzz6T+cOlY/xEvfWk=
mvdan.cc/sh/v3 v3.13.1/go.mod h1:lXJ8SexMvEVcHCoDvAGLZgFJ9Wsm2sulmoNEXGhYZD0=
+687 -63
View File
@@ -2,18 +2,24 @@ package acp
import (
"context"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"log"
"os"
"path/filepath"
"strings"
"sync"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/modelregistry"
"github.com/Gitlawb/zero/internal/providercatalog"
"github.com/Gitlawb/zero/internal/providermodelcatalog"
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
"github.com/Gitlawb/zero/internal/redaction"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/sessions"
"github.com/Gitlawb/zero/internal/tools"
@@ -30,15 +36,42 @@ type Deps struct {
DiscoverModels func(context.Context, config.ProviderProfile) ([]providermodeldiscovery.Model, error)
NewProvider func(profile config.ProviderProfile) (zeroruntime.Provider, error)
RunAgent func(ctx context.Context, prompt string, provider zeroruntime.Provider, opts agent.Options) (agent.Result, error)
// BuildWorkspace builds the SCOPED tool registry and the sandbox engine for a
// validated workspace root, so ACP shell tools (bash/exec_command) are confined
// exactly like the exec surface — never run unconfined on the host.
BuildWorkspace func(workspaceRoot string, resolved config.ResolvedConfig) (*tools.Registry, *sandbox.Engine, error)
// BuildWorkspace creates the per-turn scoped tool workspace for a validated
// workspace root. Its Close method releases any resources the registry owns
// (notably MCP server connections) after the turn completes or is cancelled.
// Shell tools remain confined exactly like the exec surface — never unconfined
// on the host.
BuildWorkspace func(ctx context.Context, workspaceRoot string, resolved config.ResolvedConfig, mode agent.PermissionMode) (*Workspace, error)
// ResolveWorkspaceRoot validates + normalizes a client-supplied cwd (must be an
// existing directory; never the bare root). It is the file-tool confinement root.
ResolveWorkspaceRoot func(cwd string) (string, error)
Store *sessions.Store
AgentInfo Implementation
// PersistEvents is an optional test seam for injecting event-store failures.
// It receives a COMPLETED TURN as one batch, never one event at a time: the
// batch is the unit the shared store keeps contiguous (see persistTurn).
PersistEvents func(sessionID string, inputs []sessions.AppendEventInput) error
Store *sessions.Store
AgentInfo Implementation
}
// Workspace is the per-turn execution environment passed to the agent. ACP does
// not retain it across turns because MCP connections belong to the registry that
// advertised their tools; keeping a stale registry after a cancelled turn would
// leak its server process and its permission state into the next turn.
type Workspace struct {
Registry *tools.Registry
Sandbox *sandbox.Engine
DeferThreshold int
Notices []string
Cleanup func() error
}
// Close releases resources created with the workspace. A nil cleanup is valid
// for core-only workspaces.
func (w *Workspace) Close() error {
if w == nil || w.Cleanup == nil {
return nil
}
return w.Cleanup()
}
// Agent is the ACP agent server bound to one JSON-RPC connection (one editor).
@@ -74,6 +107,14 @@ type acpSession struct {
restrictModels bool
cancel context.CancelFunc
history []turnRecord
// readOnly marks a session published by session/load for a kind that is
// not resumable (a child, side or spec sub-run). Load still replays its
// transcript, which is the render-only use a client needs, but a prompt
// would continue the sub-run as a standalone conversation, which every
// other resume path refuses (sessions.IsResumableKind). Set once when the
// session is first registered and never changed, so it is read without mu.
readOnly bool
}
// NewAgent builds the ACP server and registers its method handlers on conn.
@@ -82,6 +123,8 @@ func NewAgent(conn *Conn, deps Deps) *Agent {
conn.Handle(MethodInitialize, a.handleInitialize)
conn.Handle(MethodSessionNew, a.handleSessionNew)
conn.Handle(MethodSessionLoad, a.handleSessionLoad)
conn.Handle(MethodSessionList, a.handleSessionList)
conn.Handle(MethodSessionResume, a.handleSessionResume)
conn.Handle(MethodSessionPrompt, a.handleSessionPrompt)
conn.Handle(MethodSessionSetMode, a.handleSetMode)
conn.Handle(MethodSessionSetConfigOption, a.handleSetConfigOption)
@@ -110,11 +153,11 @@ func (a *Agent) handleInitialize(_ context.Context, params json.RawMessage) (any
return InitializeResult{
ProtocolVersion: negotiated,
AgentCapabilities: AgentCapabilities{
// Only advertise what ZERO actually implements: session/load (loadSession)
// and image prompts. session/resume + the session-capability sub-object
// are intentionally omitted since there is no resume handler yet.
LoadSession: true,
PromptCapabilities: PromptCapabilities{Image: true},
// ACP v1 optional methods are advertised as empty capability objects;
// clients must gate session/list and session/resume on their presence.
LoadSession: true,
PromptCapabilities: PromptCapabilities{Image: true},
SessionCapabilities: &SessionCapabilities{List: &struct{}{}, Resume: &struct{}{}},
},
AgentInfo: &info,
// ZERO owns credentials (BYOK) and does not delegate auth to the editor.
@@ -124,12 +167,41 @@ func (a *Agent) handleInitialize(_ context.Context, params json.RawMessage) (any
// ---- session lifecycle ----
// requestedWorkspace is the single rule for a cwd that arrived from the client,
// applied before the value can reach ResolveWorkspaceRoot.
//
// THE REQUEST'S OWN CWD IS THE ONLY THING ALLOWED TO PICK A ROOT. Every params
// type spells cwd as a plain string, so an absent field and an empty one decode
// to exactly the same "", and the resolver treats "" as "use the directory this
// process was started in" and joins a relative cwd onto it. Those two defaults
// meet in the middle: `{"sessionId":"known"}` used to activate a persisted
// session, and `{}` used to create one, both rooted at wherever the editor
// happened to spawn `zero acp` — which then becomes the sandbox and file-tool
// confinement root. The client never named that directory, so it is a root the
// server invented, and ACP requires an absolute cwd on every method carrying
// one. Rejecting here is what keeps a missing field from being answered with a
// guess.
func requestedWorkspace(cwd string) (string, error) {
trimmed := strings.TrimSpace(cwd)
if trimmed == "" {
return "", RPCError(codeInvalidParams, "cwd is required and must be an absolute path")
}
if !filepath.IsAbs(trimmed) {
return "", RPCError(codeInvalidParams, "cwd must be an absolute path: "+trimmed)
}
return trimmed, nil
}
func (a *Agent) handleSessionNew(ctx context.Context, params json.RawMessage) (any, error) {
var p NewSessionParams
if err := json.Unmarshal(params, &p); err != nil {
return nil, RPCError(codeInvalidParams, "invalid session/new params")
}
root, err := a.deps.ResolveWorkspaceRoot(p.Cwd)
cwd, err := requestedWorkspace(p.Cwd)
if err != nil {
return nil, err
}
root, err := a.deps.ResolveWorkspaceRoot(cwd)
if err != nil {
return nil, RPCError(codeInvalidParams, err.Error())
}
@@ -141,7 +213,7 @@ func (a *Agent) handleSessionNew(ctx context.Context, params json.RawMessage) (a
if err != nil {
return nil, RPCError(codeInternalError, "create session: "+err.Error())
}
sess := a.registerSession(meta.SessionID, root, nil, model, models, restrictModels)
sess, _ := a.registerSession(meta.SessionID, root, nil, model, models, restrictModels, false)
return NewSessionResult{
SessionID: sess.id,
ConfigOptions: a.configOptions(sess),
@@ -154,22 +226,96 @@ func (a *Agent) handleSessionLoad(ctx context.Context, params json.RawMessage) (
if err := json.Unmarshal(params, &p); err != nil {
return nil, RPCError(codeInvalidParams, "invalid session/load params")
}
return a.activatePersistedSession(ctx, p, persistedSessionLoad)
}
func (a *Agent) handleSessionResume(ctx context.Context, params json.RawMessage) (any, error) {
var p ResumeSessionParams
if err := json.Unmarshal(params, &p); err != nil {
return nil, RPCError(codeInvalidParams, "invalid session/resume params")
}
return a.activatePersistedSession(ctx, p, persistedSessionResume)
}
type persistedSessionOperation uint8
const (
persistedSessionLoad persistedSessionOperation = iota
persistedSessionResume
)
// activatePersistedSession restores the agent's internal conversation context
// for both lifecycle methods. session/load additionally replays user-visible
// history as ordered session/update notifications; session/resume deliberately
// does not, which makes it safe for an already-rendered desktop reconnect.
func (a *Agent) activatePersistedSession(ctx context.Context, p LoadSessionParams, operation persistedSessionOperation) (any, error) {
// BOTH METHODS, BEFORE ANYTHING IS LOOKED UP. session/resume shares this
// params type with session/load, so the wire cannot tell an omitted cwd from
// an empty one; the blank case used to be answered with the persisted cwd,
// which made {"sessionId":"known"} a complete, successful activation request.
// Validating here rather than in one handler is what keeps the two entry
// points from drifting apart.
cwd, err := requestedWorkspace(p.Cwd)
if err != nil {
return nil, err
}
meta, err := a.deps.Store.Get(p.SessionID)
if err != nil || meta == nil {
return nil, RPCError(codeInvalidParams, "session not found: "+p.SessionID)
}
cwdInput := p.Cwd
if strings.TrimSpace(cwdInput) == "" {
cwdInput = meta.Cwd
if operation == persistedSessionResume && !sessions.IsResumableKind(meta.SessionKind) {
return nil, RPCError(codeInvalidParams, "session is not resumable: "+p.SessionID)
}
root, err := a.deps.ResolveWorkspaceRoot(cwdInput)
if strings.TrimSpace(meta.Cwd) == "" {
return nil, RPCError(codeInvalidParams, "session has no persisted workspace: "+p.SessionID)
}
// SAME RULE ON THE WAY IN. Omitting a relative entry from the listing is not
// enough: a stored "." still resolves against this process's directory, so a
// client that hands back that same directory as its cwd would be sold the
// invented root as a match. A workspace that cannot be identified is not one
// this session can be restored into.
if !filepath.IsAbs(meta.Cwd) {
return nil, RPCError(codeInvalidParams, "session workspace is not an absolute path, so it cannot be identified: "+p.SessionID)
}
persistedRoot, err := a.deps.ResolveWorkspaceRoot(meta.Cwd)
if err != nil {
return nil, RPCError(codeInvalidParams, "persisted session workspace is unavailable: "+err.Error())
}
root, err := a.deps.ResolveWorkspaceRoot(cwd)
if err != nil {
return nil, RPCError(codeInvalidParams, err.Error())
}
// ACP session cwd is immutable. Loading history under a different root
// would give a conversation from one workspace access to another
// workspace's configuration, files, and tools.
if !sameWorkspace(root, persistedRoot) {
return nil, RPCError(codeInvalidParams, "session cwd does not match its persisted workspace")
}
// 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, historyErr := a.loadHistory(meta.SessionID)
requireHistoryLog := operation == persistedSessionResume && meta.EventCount > 0
history, messages, historyWarning, historyErr := a.loadHistory(meta.SessionID, requireHistoryLog)
// RESUME PROMISES RESTORED CONTEXT, SO A FAILED RESTORATION IS A FAILED
// RESUME. historyErr used to do nothing but suppress replay and raise a
// warning: the session was registered and reported ready regardless, so a
// corrupt record or an unreadable events file left the caller holding a live,
// promptable session ID whose next prompt ran as a brand-new conversation
// under the old identity. Nothing in the response said so.
//
// Publication is now gated on restoration for resume. LOAD keeps the
// best-effort policy deliberately rather than by inheriting this helper: it
// replays what it has and warns about the rest, which is the right trade for
// a client rebuilding a transcript it can still scroll. Reported by @jatmn.
if operation == persistedSessionResume && historyErr != nil {
return nil, RPCError(codeInternalError, "restore session history: "+historyErr.Error())
}
// Load stays best effort about a history it cannot read, but not about one
// prune holds: publishing it would leave the client using a session prune may
// be removing, one this process could not hold open.
if errors.Is(historyErr, sessions.ErrPruning) {
return nil, RPCError(codeInternalError, historyErr.Error())
}
model, models, restrictModels, err := a.resolveModelChoices(ctx, root)
if err != nil {
return nil, RPCError(codeInternalError, "config: "+err.Error())
@@ -180,12 +326,39 @@ func (a *Agent) handleSessionLoad(ctx context.Context, params json.RawMessage) (
models = append(models, SessionConfigOptionValue{Value: persistedModel, Name: persistedModel})
}
}
sess := a.registerSession(meta.SessionID, root, history, model, models, restrictModels)
// Resume has already refused a non-resumable kind above, so only load can
// reach here with one, and it publishes that session for viewing only.
readOnly := !sessions.IsResumableKind(meta.SessionKind)
sess, existed := a.registerSession(meta.SessionID, root, history, model, models, restrictModels, readOnly)
if existed {
_, messages, historyWarning, historyErr = a.refreshSessionHistory(sess, requireHistoryLog)
if operation == persistedSessionResume && historyErr != nil {
return nil, RPCError(codeInternalError, "restore session history: "+historyErr.Error())
}
}
note := &notifier{conn: a.conn, sessionID: sess.id}
if operation == persistedSessionLoad && historyErr == nil {
for _, message := range messages {
if message.tool != nil {
note.send(*message.tool)
continue
}
note.send(replayMessageChunk(message.role, replayMessageID(message.eventID), message.content))
}
}
a.warnPersistence(
&notifier{conn: a.conn, sessionID: sess.id},
note,
"rehydrate session compaction",
"Could not apply session compaction. Raw session history was restored instead.",
historyWarning,
operation == persistedSessionLoad,
)
a.warnPersistence(
note,
"load session history",
"Could not load session history. The session is open, but earlier turns may be missing until storage recovers.",
historyErr,
operation == persistedSessionLoad,
)
return LoadSessionResult{
ConfigOptions: a.configOptions(sess),
@@ -193,6 +366,80 @@ func (a *Agent) handleSessionLoad(ctx context.Context, params json.RawMessage) (
}, nil
}
func (a *Agent) handleSessionList(_ context.Context, params json.RawMessage) (any, error) {
var p ListSessionsParams
if len(params) > 0 {
if err := json.Unmarshal(params, &p); err != nil {
return nil, RPCError(codeInvalidParams, "invalid session/list params")
}
}
if p.Cursor != "" {
return nil, RPCError(codeInvalidParams, "invalid session/list cursor")
}
items, err := a.deps.Store.ListResumable()
if err != nil {
return nil, RPCError(codeInternalError, "list sessions: "+err.Error())
}
// The filter is the one cwd ACP leaves optional, so a blank one means "no
// filter" rather than an error — but a relative one is rebased onto this
// process's directory exactly like the others, and would then silently answer
// about a workspace the client never named.
var cwd string
if strings.TrimSpace(p.Cwd) != "" {
filter, err := requestedWorkspace(p.Cwd)
if err != nil {
return nil, err
}
cwd, err = a.deps.ResolveWorkspaceRoot(filter)
if err != nil {
return nil, RPCError(codeInvalidParams, err.Error())
}
}
result := ListSessionsResult{Sessions: make([]SessionInfo, 0, len(items))}
for _, item := range items {
// EVERY ENTRY IS RESOLVED, FILTER OR NO FILTER. Listing is a menu, and
// every item on it has to be orderable: activatePersistedSession
// resolves the persisted workspace and refuses what it cannot reach, so
// anything this loop cannot resolve is something the client would be
// offered and then denied.
//
// Resolving only when a cwd filter was supplied left two shapes through —
// a session whose workspace has since been deleted, and a legacy entry
// holding a relative path, which was then reported as cwd "." even though
// ACP requires SessionInfo.cwd to be absolute.
if strings.TrimSpace(item.Cwd) == "" {
continue
}
// A RELATIVE PERSISTED CWD HAS NO RECOVERABLE IDENTITY. Resolving one
// rebases it onto wherever this ACP server happens to be running and
// advertises that invented absolute path as the session's workspace — so a
// conversation created for one project could be resumed against another
// project's configuration, files and tools. The original base is not
// knowable from the metadata, so the honest answer is to omit the entry
// rather than to guess at it.
if !filepath.IsAbs(item.Cwd) {
continue
}
itemRoot, err := a.deps.ResolveWorkspaceRoot(item.Cwd)
if err != nil {
continue
}
if cwd != "" && !sameWorkspace(itemRoot, cwd) {
continue
}
result.Sessions = append(result.Sessions, SessionInfo{
SessionID: item.SessionID,
Title: item.Title,
// The RESOLVED root, not the stored string: absolute as ACP
// requires, and the same value the client will hand back on resume.
Cwd: itemRoot,
UpdatedAt: item.UpdatedAt,
Meta: &SessionInfoMeta{ModelID: item.ModelID, CreatedAt: item.CreatedAt},
})
}
return result, nil
}
// ---- prompt turn ----
func (a *Agent) handleSessionPrompt(ctx context.Context, params json.RawMessage) (any, error) {
@@ -204,6 +451,9 @@ func (a *Agent) handleSessionPrompt(ctx context.Context, params json.RawMessage)
if sess == nil {
return nil, RPCError(codeInvalidParams, "unknown session: "+p.SessionID)
}
if sess.readOnly {
return nil, RPCError(codeInvalidParams, "session is not resumable, so it was loaded for viewing only and cannot be prompted: "+p.SessionID)
}
// Serialize turns for this session so two prompts can't interleave history or
// fight over the single cancel slot. session/cancel still works concurrently
@@ -241,13 +491,67 @@ func (a *Agent) runTurn(ctx context.Context, sess *acpSession, userText string,
if err != nil {
return "", RPCError(codeInternalError, "provider: "+err.Error())
}
mode := sess.currentMode()
// Build the SCOPED registry + sandbox engine for this session's workspace so
// shell/file tools are confined to the workspace exactly like the exec surface.
registry, sandboxEngine, err := a.deps.BuildWorkspace(sess.cwd, resolved)
// The workspace can also own MCP connections, which must be closed after every
// turn even if the agent run returns an error or the client cancels it.
workspace, err := a.deps.BuildWorkspace(ctx, sess.cwd, resolved, mode)
if err != nil {
return "", RPCError(codeInternalError, "workspace: "+err.Error())
}
if workspace == nil || workspace.Registry == nil {
if workspace != nil {
_ = workspace.Close()
}
return "", RPCError(codeInternalError, "workspace: missing tool registry")
}
defer func() {
if err := workspace.Close(); err != nil {
log.Printf("acp: close workspace: %v", err)
}
}()
registry := workspace.Registry
sandboxEngine := workspace.Sandbox
note := &notifier{conn: a.conn, sessionID: sess.id}
for _, notice := range workspace.Notices {
if text := strings.TrimSpace(redaction.RedactString(notice, redaction.Options{})); text != "" {
note.text("\n\n[zero warning] " + text + "\n")
}
}
// Buffer the complete turn until RunAgent has a valid ACP outcome. A hard run
// failure is an aborted turn: neither live history nor durable history may
// retain its user/tool prefix under an RPC error. StopCancelled is a valid
// outcome and commits through the same boundary below.
pendingEvents := make([]sessions.AppendEventInput, 0, 4)
queue := func(input sessions.AppendEventInput) {
pendingEvents = append(pendingEvents, input)
}
queue(messageEvent("user", userText))
visionCache := make(map[string]bool)
var visionCacheMu sync.Mutex
supportsVision := func(modelID string) bool {
modelID = strings.TrimSpace(modelID)
if modelID == "" {
return false
}
visionCacheMu.Lock()
defer visionCacheMu.Unlock()
if cached, ok := visionCache[modelID]; ok {
return cached
}
supported := a.modelSupportsVision(ctx, resolved.Provider, modelID)
visionCache[modelID] = supported
return supported
}
effectivePrompt := userText
if len(images) > 0 && !supportsVision(resolved.Provider.Model) {
msg := fmt.Sprintf("Model %s does not support image input; ignoring %d prompt image(s).", resolved.Provider.Model, len(images))
note.text("[zero] " + msg + "\n\n")
effectivePrompt = fmt.Sprintf("[Note: %s]\n\n%s", msg, userText)
images = nil
}
opts := agent.Options{
Cwd: sess.cwd,
@@ -256,13 +560,19 @@ func (a *Agent) runTurn(ctx context.Context, sess *acpSession, userText string,
Model: resolved.Provider.Model,
Registry: registry,
Sandbox: sandboxEngine,
PermissionMode: sess.currentMode(),
PermissionMode: mode,
DeferThreshold: workspace.DeferThreshold,
MaxTurns: resolved.MaxTurns,
Images: images,
SupportsVision: supportsVision,
OnText: note.text,
OnReasoning: note.thought,
OnToolCall: note.toolCall,
OnToolCall: func(call agent.ToolCall) {
queue(toolCallEvent(call))
note.toolCall(call)
},
OnToolResult: func(result agent.ToolResult) {
queue(toolResultEvent(result))
note.toolResult(result)
if result.Name == "update_plan" {
a.emitPlan(registry, note)
@@ -273,21 +583,38 @@ func (a *Agent) runTurn(ctx context.Context, sess *acpSession, userText string,
},
}
agentPrompt := buildPrompt(sess.snapshotHistory(), userText)
agentPrompt := buildPrompt(sess.snapshotHistory(), effectivePrompt)
result, runErr := a.deps.RunAgent(ctx, agentPrompt, provider, opts)
if result.FinalAnswer != "" {
queue(messageEvent("assistant", result.FinalAnswer))
}
reason, stopErr := stopReasonFor(result, runErr)
if stopErr != nil {
return "", RPCError(codeInternalError, stopErr.Error())
}
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,
)
}
// ONE BATCH, ONE LOCK. The outcome gate above decides WHETHER the turn is
// committed; the store batch decides WHICH RECORDS STAY TOGETHER relative to
// other writers, and these are different guarantees. Persisting the buffer
// one event per call took and released the store's session lock between
// events, so a second ACP instance holding the same session — its turnMu is
// its own, only the store is shared — could append its whole turn in the
// gap: user A, user B, answer B, answer A. Every write was individually
// locked and the log was valid, but a fresh load pairs prose by order and
// reconstructed three wrong turns from two right ones. Before this branch,
// persistTurn handed the user and assistant events to AppendEvents together;
// buffering until the outcome is known was correct, splitting the commit
// into singleton writes was not. The store writes a batch under one lock and
// one file write, which is the contiguity restored here — not a disk
// transaction, and not a promise about crashes mid-fsync. Reported by @jatmn.
persistenceErr := a.persistTurn(sess.id, pendingEvents)
sess.appendHistory(turnRecord{user: userText, assistant: result.FinalAnswer})
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.",
persistenceErr,
true,
)
return reason, nil
}
@@ -546,39 +873,170 @@ func (a *Agent) configOptions(s *acpSession) []SessionConfigOption {
// ---- persistence + continuity ----
func (a *Agent) persistTurn(sess *acpSession, user, assistant string) error {
defer sess.appendHistory(turnRecord{user: user, assistant: assistant})
// persistTurn commits a completed turn's buffered events as one store batch, so
// no other writer's records can land between them. A failure is the failure of
// the whole batch: nothing after a failed prerequisite is appended on its own.
func (a *Agent) persistTurn(sessionID string, inputs []sessions.AppendEventInput) error {
if len(inputs) == 0 {
return nil
}
if a.deps.PersistEvents != nil {
return a.deps.PersistEvents(sessionID, inputs)
}
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)
_, err := a.deps.Store.AppendEvents(sessionID, inputs)
return err
}
func (a *Agent) loadHistory(sessionID string) ([]turnRecord, error) {
if a.deps.Store == nil {
return nil, nil
func messageEvent(role, content string) sessions.AppendEventInput {
return sessions.AppendEventInput{
Type: sessions.EventMessage,
Payload: map[string]any{"role": role, "content": content},
}
events, err := a.deps.Store.ReadEvents(sessionID)
}
func toolCallEvent(call agent.ToolCall) sessions.AppendEventInput {
return sessions.AppendEventInput{
Type: sessions.EventToolCall,
Payload: map[string]any{
"toolCallId": call.ID,
"name": call.Name,
"arguments": call.Arguments,
},
}
}
func toolResultEvent(result agent.ToolResult) sessions.AppendEventInput {
return sessions.AppendEventInput{
Type: sessions.EventToolResult,
Payload: map[string]any{
"toolCallId": result.ToolCallID,
"name": result.Name,
"status": result.Status,
"output": result.Output,
"changedFiles": append([]string(nil), result.ChangedFiles...),
},
}
}
// persistedMessage is one entry of the restored transcript in stored order. It
// is either a message (role/content) or a tool-call notification (tool), never
// both: keeping them in ONE ordered slice is what preserves the interleaving a
// client needs to redraw the conversation as it originally happened.
type persistedMessage struct {
eventID string
role string
content string
// tool is set for a replayed tool call or its result, and is already in wire
// shape — it comes from the same toolCallStart/toolCallResult mapping the
// live turn uses, so a replayed tool call renders identically to a fresh one
// and there is no second mapping to drift out of sync.
tool *ToolCallUpdate
}
func (a *Agent) loadHistory(sessionID string, requireHistoryLog bool) ([]turnRecord, []persistedMessage, error, error) {
if a.deps.Store == nil {
return nil, nil, nil, nil
}
// THE EFFECTIVE CONVERSATION, NOT THE RAW LOG. A compacted session keeps its
// original prefix on disk alongside an EventCompaction that names the events
// it replaced and carries their durable summary. ReadEvents hands back both,
// so restoring from it replayed superseded turns AND dropped the summary that
// replaced them — resume then seeded the next turn with context the
// conversation had already moved past, and load visibly resurrected
// transcript entries the user had seen compacted away.
//
// ReadRehydratedEvents is the projection the TUI and exec paths already use
// (internal/tui/session.go, internal/sessions/exec_session.go), so all three
// now agree on what the conversation IS. Switching readers alone is not
// enough: rehydration substitutes the compaction event in place of the events
// it replaced, so a loop that skips everything but EventMessage would drop the
// summary exactly as before. It is projected below. Reported by @jatmn.
//
// The session was picked from its metadata a moment ago. Make sure it is
// still there, and held, before restoring it: see sessions.HoldToContinue.
if err := a.deps.Store.HoldToContinue(sessionID); err != nil {
return nil, nil, nil, err
}
events, eventLogPresent, err := a.deps.Store.ReadRehydratedEventsWithPresence(sessionID)
var rehydrateWarning error
if err != nil {
return nil, err
if errors.Is(err, sessions.ErrPruning) {
// Not a rehydration failure: the raw read would continue the session
// without holding it while zero sessions prune may be removing it.
return nil, nil, nil, err
}
rehydrateWarning = err
events, eventLogPresent, err = a.deps.Store.ReadEventsWithPresence(sessionID)
if err != nil {
return nil, nil, nil, err
}
}
if requireHistoryLog && !eventLogPresent {
return nil, nil, nil, fmt.Errorf("zero session %s event log is missing", sessionID)
}
var records []turnRecord
var messages []persistedMessage
activeToolCalls := make(map[string]string)
var pendingUser string
havePending := false
for _, e := range events {
if e.Type == sessions.EventCompaction {
// The summary stands in for the turns it replaced, so it enters the
// history as an assistant message: it is prior context the next turn
// must see, and the wire has no separate role for it.
raw, marshalErr := json.Marshal(e.Payload)
if marshalErr != nil {
continue
}
var payload struct {
Summary string `json:"summary"`
}
if json.Unmarshal(raw, &payload) != nil || strings.TrimSpace(payload.Summary) == "" {
continue
}
messages = append(messages, persistedMessage{
eventID: persistedMessageIdentity(sessionID, e),
role: "assistant",
content: payload.Summary,
})
records = append(records, turnRecord{user: pendingUser, assistant: payload.Summary})
pendingUser = ""
havePending = false
continue
}
// TOOL ACTIVITY IS PART OF THE TRANSCRIPT, NOT DECORATION. A restored
// conversation that shows only the prose reads as if the agent asserted
// its edits and command output from nowhere: the user sees "I updated
// scope.go" with no record that any tool ran. These are collected for
// replay only. They deliberately do NOT enter turnRecord, so the prompt
// context a resumed turn receives stays byte-identical to what it was —
// load and resume continue to consume the SAME effective history, and
// only the wire rendering differs. Reported by @jatmn.
if e.Type == sessions.EventToolCall || e.Type == sessions.EventToolResult {
if upd := replayToolUpdate(e); upd != nil {
rawID := upd.ToolCallID
if e.Type == sessions.EventToolResult {
replayID, ok := activeToolCalls[rawID]
if !ok {
continue
}
upd.ToolCallID = replayID
delete(activeToolCalls, rawID)
} else {
replayID := replayToolCallID(persistedMessageIdentity(sessionID, e))
activeToolCalls[rawID] = replayID
upd.ToolCallID = replayID
}
messages = append(messages, persistedMessage{
eventID: persistedMessageIdentity(sessionID, e),
tool: upd,
})
}
continue
}
if e.Type != sessions.EventMessage {
continue
}
@@ -595,12 +1053,14 @@ func (a *Agent) loadHistory(sessionID string) ([]turnRecord, error) {
}
switch msg.Role {
case "user":
messages = append(messages, persistedMessage{eventID: persistedMessageIdentity(sessionID, e), role: msg.Role, content: msg.Content})
if havePending {
records = append(records, turnRecord{user: pendingUser})
}
pendingUser = msg.Content
havePending = true
case "assistant":
messages = append(messages, persistedMessage{eventID: persistedMessageIdentity(sessionID, e), role: msg.Role, content: msg.Content})
records = append(records, turnRecord{user: pendingUser, assistant: msg.Content})
pendingUser = ""
havePending = false
@@ -609,10 +1069,89 @@ func (a *Agent) loadHistory(sessionID string) ([]turnRecord, error) {
if havePending {
records = append(records, turnRecord{user: pendingUser})
}
return records, nil
return records, messages, rehydrateWarning, nil
}
func (a *Agent) warnPersistence(note *notifier, action string, message string, err error) {
// replayToolUpdate rebuilds the ACP notification for one stored tool event.
//
// The stored toolCallId is returned here as a correlation key. loadHistory maps
// each call occurrence to a stable session-wide replay identity and applies that
// identity to its result. Older records wrote the field as "id", so both
// spellings are accepted — the TUI projection already does the same
// (internal/tui/session.go).
//
// A record that carries no usable id is skipped rather than replayed under a
// synthesized one: an unpairable update is worse than a missing row.
func replayToolUpdate(event sessions.Event) *ToolCallUpdate {
raw, err := json.Marshal(event.Payload)
if err != nil {
return nil
}
var payload struct {
Name string `json:"name"`
ToolCallID string `json:"toolCallId"`
ID string `json:"id"`
Arguments string `json:"arguments"`
Status string `json:"status"`
Output string `json:"output"`
ChangedFiles []string `json:"changedFiles"`
}
if json.Unmarshal(raw, &payload) != nil {
return nil
}
id := payload.ToolCallID
if id == "" {
id = payload.ID
}
if id == "" {
return nil
}
if event.Type == sessions.EventToolCall {
upd := toolCallStart(agent.ToolCall{ID: id, Name: payload.Name, Arguments: payload.Arguments})
return &upd
}
status := tools.Status(payload.Status)
if status == "" {
status = tools.StatusOK
}
upd := toolCallResult(agent.ToolResult{
ToolCallID: id,
Name: payload.Name,
Status: status,
Output: payload.Output,
ChangedFiles: append([]string(nil), payload.ChangedFiles...),
})
return &upd
}
func persistedMessageIdentity(sessionID string, event sessions.Event) string {
if event.ID != "" {
return event.ID
}
return fmt.Sprintf("%s:%d", sessionID, event.Sequence)
}
// replayMessageID maps ZERO's stable event identity to a standards-shaped UUID
// without leaking or parsing the event id on the wire. The same stored message
// receives the same opaque id across loads, which also gives clients an exact
// chunk boundary when two adjacent persisted messages have the same role.
func replayMessageID(eventID string) string {
return replayOpaqueID("message", eventID)
}
func replayToolCallID(eventID string) string {
return replayOpaqueID("tool-call", eventID)
}
func replayOpaqueID(kind, eventID string) string {
sum := sha256.Sum256([]byte("zero-acp-" + kind + ":" + eventID))
b := sum[:16]
b[6] = (b[6] & 0x0f) | 0x50
b[8] = (b[8] & 0x3f) | 0x80
return fmt.Sprintf("%x-%x-%x-%x-%x", b[:4], b[4:6], b[6:8], b[8:10], b[10:16])
}
func (a *Agent) warnPersistence(note *notifier, action string, message string, err error, notify bool) {
if err == nil {
return
}
@@ -621,7 +1160,7 @@ func (a *Agent) warnPersistence(note *notifier, action string, message string, e
sessionID = note.sessionID
}
log.Printf("zero acp: failed to %s for session %s: %v", action, sessionID, err)
if note != nil {
if note != nil && notify {
note.text("\n\n[zero warning] " + message + "\n")
}
}
@@ -668,18 +1207,34 @@ func promptImages(blocks []ContentBlock) []zeroruntime.ImageBlock {
// registerSession publishes a session under the agent's lock. If one is already
// registered for id (e.g. a re-load of an in-flight session) the existing live
// session is returned unchanged rather than orphaning its turn or resetting its
// mode/model. history is set BEFORE publishing so no concurrent prompt can read a
// half-initialized session.
func (a *Agent) registerSession(id, cwd string, history []turnRecord, model string, models []SessionConfigOptionValue, restrictModels bool) *acpSession {
// session is returned rather than orphaning its turn or resetting its mode/model.
// The bool reports that case so persisted activation can re-read and apply disk
// history under turnMu. history is set BEFORE first publication so no concurrent
// prompt can read a half-initialized session.
func (a *Agent) registerSession(id, cwd string, history []turnRecord, model string, models []SessionConfigOptionValue, restrictModels bool, readOnly bool) (*acpSession, bool) {
a.mu.Lock()
defer a.mu.Unlock()
if existing := a.sessions[id]; existing != nil {
return existing
return existing, true
}
sess := &acpSession{id: id, cwd: cwd, mode: agent.PermissionModeAuto, model: model, models: models, restrictModels: restrictModels, history: history}
sess := &acpSession{id: id, cwd: cwd, mode: agent.PermissionModeAuto, model: model, models: models, restrictModels: restrictModels, history: history, readOnly: readOnly}
a.sessions[id] = sess
return sess
return sess, false
}
// refreshSessionHistory is the activation apply boundary for an already-live
// session. The history is read while prompts are excluded, then applied to the
// same object the prompt path uses. Re-reading here avoids overwriting a turn
// that committed between activatePersistedSession's pre-publication read and
// discovering that another activation had already registered the session.
func (a *Agent) refreshSessionHistory(sess *acpSession, requireHistoryLog bool) ([]turnRecord, []persistedMessage, error, error) {
sess.turnMu.Lock()
defer sess.turnMu.Unlock()
history, messages, warning, err := a.loadHistory(sess.id, requireHistoryLog)
if err == nil {
sess.setHistory(history)
}
return history, messages, warning, err
}
func (a *Agent) session(id string) *acpSession {
@@ -762,8 +1317,77 @@ func (s *acpSession) appendHistory(rec turnRecord) {
s.mu.Unlock()
}
func (s *acpSession) setHistory(history []turnRecord) {
s.mu.Lock()
s.history = append([]turnRecord(nil), history...)
s.mu.Unlock()
}
func (s *acpSession) snapshotHistory() []turnRecord {
s.mu.Lock()
defer s.mu.Unlock()
return append([]turnRecord(nil), s.history...)
}
// sameWorkspace reports whether two resolved roots name the same directory.
//
// STRING EQUALITY IS NOT DIRECTORY IDENTITY. ResolveWorkspaceRoot is abs plus
// filepath.Clean plus a stat: it does not fold case and does not resolve
// junctions, so one directory reached by two spellings produces two different
// roots. A session persisted from the TUI was then unresumable from an editor
// holding a different spelling of the same project folder, and session/list
// filtered by the other spelling returned nothing — not a failed resume but an
// invisible one, on exactly the case this feature exists for.
//
// filepath.EvalSymlinks is NOT the fix on Windows: it normalises a drive letter
// but returns a junction path unchanged, so the alias survives it. Junctions need
// no privilege, so this is ordinary rather than exotic. os.SameFile compares the
// filesystem's own identity for the two directories, which is the question being
// asked.
//
// The string comparison stays as the fast path, and a stat failure falls back to
// it rather than widening the match — this gate refuses access to another
// workspace's files and configuration, so an unanswerable comparison denies.
func sameWorkspace(left, right string) bool {
if left == right {
return true
}
leftInfo, err := os.Stat(left)
if err != nil {
return false
}
rightInfo, err := os.Stat(right)
if err != nil {
return false
}
return os.SameFile(leftInfo, rightInfo)
}
func (a *Agent) modelSupportsVision(ctx context.Context, profile config.ProviderProfile, modelID string) bool {
trimmed := strings.TrimSpace(modelID)
if trimmed == "" {
return false
}
if a.deps.DiscoverModels != nil {
if discovered, err := a.deps.DiscoverModels(ctx, profile); err == nil {
for _, dm := range discovered {
if strings.EqualFold(strings.TrimSpace(dm.ID), trimmed) {
if len(dm.InputModalities) > 0 {
for _, mod := range dm.InputModalities {
if strings.EqualFold(strings.TrimSpace(mod), "image") {
return true
}
}
return false
}
break
}
}
}
}
reg, _ := modelregistry.DefaultRegistry()
if entry, known := reg.Resolve(trimmed); known {
return entry.Supports(modelregistry.ModelCapabilityVision)
}
return modelregistry.SupportsVision(reg, trimmed)
}
+2221 -17
View File
File diff suppressed because it is too large. Load diff
+43 -10
View File
@@ -89,6 +89,13 @@ type Conn struct {
handlers map[string]HandlerFunc
notifiers map[string]NotifyFunc
// Notifications for one method are an ordered stream: session/update in
// particular is stateful, so dispatching every frame in an unrelated
// goroutine can make a later chunk overtake an earlier one. Different
// methods retain independent tails, keeping session/cancel responsive while
// an update handler is busy.
notifyMu sync.Mutex
notifyTails map[string]chan struct{}
mu sync.Mutex
nextID int64
@@ -106,11 +113,12 @@ type Conn struct {
// lifetime.
func NewConn(r io.Reader, w io.Writer) *Conn {
return &Conn{
rawReader: r,
w: w,
handlers: make(map[string]HandlerFunc),
notifiers: make(map[string]NotifyFunc),
pending: make(map[int64]chan rpcMessage),
rawReader: r,
w: w,
handlers: make(map[string]HandlerFunc),
notifiers: make(map[string]NotifyFunc),
notifyTails: make(map[string]chan struct{}),
pending: make(map[int64]chan rpcMessage),
}
}
@@ -303,11 +311,7 @@ func (c *Conn) handleLine(ctx context.Context, line []byte) {
}(msg)
case msg.isNotify():
if fn := c.notifiers[msg.Method]; fn != nil {
c.wg.Add(1)
go func(m rpcMessage) {
defer c.wg.Done()
fn(ctx, m.Params)
}(msg)
c.dispatchNotification(ctx, msg, fn)
}
default:
// Malformed frame; reply only if we can identify a request id.
@@ -317,6 +321,35 @@ func (c *Conn) handleLine(ctx context.Context, line []byte) {
}
}
// dispatchNotification preserves wire order within one method without making
// unrelated notification methods wait for each other. The read loop installs
// each tail before starting its goroutine, so goroutine scheduling cannot
// reorder the chain it observes.
func (c *Conn) dispatchNotification(ctx context.Context, msg rpcMessage, fn NotifyFunc) {
c.notifyMu.Lock()
previous := c.notifyTails[msg.Method]
done := make(chan struct{})
c.notifyTails[msg.Method] = done
c.notifyMu.Unlock()
c.wg.Add(1)
go func() {
defer c.wg.Done()
defer func() {
close(done)
c.notifyMu.Lock()
if c.notifyTails[msg.Method] == done {
delete(c.notifyTails, msg.Method)
}
c.notifyMu.Unlock()
}()
if previous != nil {
<-previous
}
fn(ctx, msg.Params)
}()
}
func (c *Conn) dispatchRequest(ctx context.Context, msg rpcMessage) {
fn := c.handlers[msg.Method]
if fn == nil {
+80
View File
@@ -360,6 +360,86 @@ func TestConnNotification(t *testing.T) {
}
}
func TestConnNotificationsForOneMethodStayInWireOrder(t *testing.T) {
a, b, stop := connPair(t)
defer stop()
firstStarted := make(chan struct{})
releaseFirst := make(chan struct{})
delivered := make(chan int, 2)
b.HandleNotify("update", func(_ context.Context, params json.RawMessage) {
var in struct{ Sequence int }
_ = json.Unmarshal(params, &in)
if in.Sequence == 1 {
close(firstStarted)
<-releaseFirst
}
delivered <- in.Sequence
})
if err := a.Notify("update", map[string]int{"Sequence": 1}); err != nil {
t.Fatalf("first notify: %v", err)
}
select {
case <-firstStarted:
case <-time.After(2 * time.Second):
t.Fatal("first notification did not start")
}
if err := a.Notify("update", map[string]int{"Sequence": 2}); err != nil {
t.Fatalf("second notify: %v", err)
}
select {
case sequence := <-delivered:
t.Fatalf("notification %d overtook the blocked first notification", sequence)
case <-time.After(25 * time.Millisecond):
}
close(releaseFirst)
for want := 1; want <= 2; want++ {
select {
case got := <-delivered:
if got != want {
t.Fatalf("notification order = ...,%d; want %d", got, want)
}
case <-time.After(2 * time.Second):
t.Fatalf("notification %d was not delivered", want)
}
}
}
func TestConnDifferentNotificationMethodsStayConcurrent(t *testing.T) {
a, b, stop := connPair(t)
defer stop()
updateStarted := make(chan struct{})
releaseUpdate := make(chan struct{})
cancelDelivered := make(chan struct{})
b.HandleNotify("update", func(context.Context, json.RawMessage) {
close(updateStarted)
<-releaseUpdate
})
b.HandleNotify("cancel", func(context.Context, json.RawMessage) {
close(cancelDelivered)
})
if err := a.Notify("update", nil); err != nil {
t.Fatalf("update notify: %v", err)
}
select {
case <-updateStarted:
case <-time.After(2 * time.Second):
t.Fatal("update notification did not start")
}
if err := a.Notify("cancel", nil); err != nil {
t.Fatalf("cancel notify: %v", err)
}
select {
case <-cancelDelivered:
case <-time.After(2 * time.Second):
t.Fatal("different notification method was blocked behind update")
}
close(releaseUpdate)
}
func TestConnMethodNotFound(t *testing.T) {
a, _, stop := connPair(t)
defer stop()
+53 -7
View File
@@ -3,6 +3,7 @@ package acp
import (
"encoding/json"
"net/url"
"path/filepath"
"strings"
"unicode"
"unicode/utf8"
@@ -19,6 +20,14 @@ func agentMessageChunk(delta string) ContentChunk {
return ContentChunk{SessionUpdate: UpdateAgentMessageChunk, Content: TextBlock(delta)}
}
func replayMessageChunk(role, messageID, text string) ContentChunk {
update := UpdateAgentMessageChunk
if role == "user" {
update = UpdateUserMessageChunk
}
return ContentChunk{SessionUpdate: update, MessageID: messageID, Content: TextBlock(text)}
}
func agentThoughtChunk(delta string) ContentChunk {
return ContentChunk{SessionUpdate: UpdateAgentThoughtChunk, Content: TextBlock(delta)}
}
@@ -130,7 +139,7 @@ func browserToolTitle(command, rawArgs string) string {
}
// browserTitleTextSafe validates text after URL parsing has decoded escaped
// UTF-8 in the host. Valid UTF-8 alone is not presentation-safe: control,
// UTF-8 in the host. Valid UTF-8 alone is not presentation-safe: whitespace, control,
// format/bidi, and line/paragraph separator runes can reorder or split the
// permission label shown to a user. The execution URL remains unchanged.
func browserTitleTextSafe(text string) bool {
@@ -138,7 +147,7 @@ func browserTitleTextSafe(text string) bool {
return false
}
for _, r := range text {
if unicode.IsControl(r) || unicode.Is(unicode.Cf, r) || unicode.Is(unicode.Zl, r) || unicode.Is(unicode.Zp, r) {
if unicode.IsSpace(r) || unicode.IsControl(r) || unicode.Is(unicode.Cf, r) || unicode.Is(unicode.Zl, r) || unicode.Is(unicode.Zp, r) {
return false
}
}
@@ -238,22 +247,59 @@ func toolCallResult(result agent.ToolResult) ToolCallUpdate {
}
func toolResultContent(result agent.ToolResult) []ToolCallContent {
content := make([]ToolCallContent, 0, 1+len(result.FileDiffs))
text := strings.TrimRight(result.Output, "\n")
if text == "" {
text = result.Display.Summary
}
if text == "" {
return nil
return appendToolResultDiffs(content, result.FileDiffs)
}
return []ToolCallContent{ToolContent(TextBlock(text))}
content = append(content, ToolContent(TextBlock(text)))
return appendToolResultDiffs(content, result.FileDiffs)
}
func appendToolResultDiffs(content []ToolCallContent, diffs []tools.FileDiff) []ToolCallContent {
for _, diff := range diffs {
if !filepath.IsAbs(diff.Path) || (!diff.OldExists && !diff.NewExists) {
continue
}
var oldText *string
if diff.OldExists {
old := diff.OldText
oldText = &old
}
var newText *string
if diff.NewExists {
updated := diff.NewText
newText = &updated
}
content = append(content, ToolCallContent{Type: "diff", Path: diff.Path, OldText: oldText, NewText: newText})
}
return content
}
func toolResultLocations(result agent.ToolResult) []ToolCallLocation {
locs := make([]ToolCallLocation, 0, len(result.ChangedFiles))
for _, f := range result.ChangedFiles {
if strings.TrimSpace(f) == "" {
locs := make([]ToolCallLocation, 0, len(result.FileDiffs)+len(result.ChangedFiles))
seen := make(map[string]bool, len(result.FileDiffs)+len(result.ChangedFiles))
for _, diff := range result.FileDiffs {
path := diff.Path
if path == "" || seen[path] {
continue
}
seen[path] = true
locs = append(locs, ToolCallLocation{Path: path})
}
// FileDiff.Path is canonical absolute path data while ChangedFiles is
// normally workspace-relative. Without the trusted workspace root these
// coordinate systems cannot be correlated safely: a suffix match would let
// /workspace/sub/a.go consume the fallback for a distinct root a.go. The
// shared seen set deduplicates only identities already exactly comparable.
for _, f := range result.ChangedFiles {
if f == "" || seen[f] {
continue
}
seen[f] = true
locs = append(locs, ToolCallLocation{Path: f})
}
return locs
+199 -3
View File
@@ -2,6 +2,7 @@ package acp
import (
"encoding/json"
"path/filepath"
"strings"
"testing"
"unicode"
@@ -223,6 +224,9 @@ func TestBrowserPermissionTitlesMirrorSafeToolArguments(t *testing.T) {
func TestBrowserOpenTitlesRejectDecodedUnicodePresentationControls(t *testing.T) {
for _, rawURL := range []string{
"https://safe.example%C2%A0evil.test/path",
"https://safe.example%E2%80%83evil.test/path",
"https://safe.example%E3%80%80evil.test/path",
"https://safe.example%E2%80%AEevil.test/path",
"https://safe.example%E2%81%A6evil.test/path",
"https://safe.example%C2%85evil.test/path",
@@ -242,6 +246,9 @@ func TestBrowserOpenTitlesRejectDecodedUnicodePresentationControls(t *testing.T)
if err != nil {
t.Fatal(err)
}
if title := browserToolTitle("open", string(args)); title != "browser open" {
t.Errorf("unsafe origin accepted before permission translation: %q", title)
}
updates := []ToolCallUpdate{
toolCallStart(agent.ToolCall{ID: "start", Name: "browser_open", Arguments: string(args)}),
permissionToolCall(agent.PermissionRequest{ToolCallID: "permission", ToolName: "browser_open", Args: map[string]any{"url": rawURL}}),
@@ -300,21 +307,26 @@ func TestToolCallStart(t *testing.T) {
}
func TestToolCallResult(t *testing.T) {
path := filepath.Join(t.TempDir(), "a.go")
ok := toolCallResult(agent.ToolResult{
ToolCallID: "tc1",
Name: "edit_file",
Status: tools.StatusOK,
Output: "applied\n",
ChangedFiles: []string{"a.go", ""},
FileDiffs: []tools.FileDiff{{Path: path, OldExists: true, NewExists: true, OldText: "before\n", NewText: "after\n"}},
})
if ok.SessionUpdate != UpdateToolCallUpdate || ok.Status != ToolStatusCompleted {
t.Fatalf("unexpected ok result: %+v", ok)
}
if len(ok.Content) != 1 || ok.Content[0].Type != "content" || ok.Content[0].Content.Text != "applied" {
if len(ok.Content) != 2 || ok.Content[0].Type != "content" || ok.Content[0].Content.Text != "applied" {
t.Fatalf("unexpected content: %+v", ok.Content)
}
if len(ok.Locations) != 1 || ok.Locations[0].Path != "a.go" {
t.Fatalf("blank changed files should be dropped, got %+v", ok.Locations)
if diff := ok.Content[1]; diff.Type != "diff" || diff.Path != path || diff.OldText == nil || *diff.OldText != "before\n" || diff.NewText == nil || *diff.NewText != "after\n" {
t.Fatalf("unexpected diff content: %+v", diff)
}
if len(ok.Locations) != 2 || ok.Locations[0].Path != path || ok.Locations[1].Path != "a.go" {
t.Fatalf("unproven absolute/relative aliases must both remain visible, got %+v", ok.Locations)
}
failed := toolCallResult(agent.ToolResult{ToolCallID: "tc2", Status: tools.StatusError, Output: "boom"})
@@ -323,6 +335,190 @@ func TestToolCallResult(t *testing.T) {
}
}
func TestToolCallDiffJSONDistinguishesEmptyFilesAndDeletion(t *testing.T) {
path := filepath.Join(t.TempDir(), "empty.txt")
content := appendToolResultDiffs(nil, []tools.FileDiff{
{Path: path, OldExists: false, NewExists: true, NewText: ""},
{Path: path, OldExists: true, NewExists: true, OldText: "before", NewText: ""},
{Path: path, OldExists: true, NewExists: false, OldText: "before"},
})
if len(content) != 3 {
t.Fatalf("diff content = %#v", content)
}
for index, diff := range content {
encoded, err := json.Marshal(diff)
if err != nil {
t.Fatal(err)
}
var wire map[string]any
if err := json.Unmarshal(encoded, &wire); err != nil {
t.Fatal(err)
}
if wire["path"] != path {
t.Fatalf("wire diff %d = %s", index, encoded)
}
switch index {
case 0:
oldText, present := wire["oldText"]
if !present || oldText != nil || wire["newText"] != "" {
t.Fatalf("create diff = %s, want null oldText and empty newText", encoded)
}
case 1:
if wire["oldText"] != "before" || wire["newText"] != "" {
t.Fatalf("empty replacement diff = %s", encoded)
}
case 2:
newText, present := wire["newText"]
if wire["oldText"] != "before" || !present || newText != nil {
t.Fatalf("deletion diff = %s, want oldText and null newText", encoded)
}
}
}
}
func TestToolResultLocationsPreserveDistinctPathIdentities(t *testing.T) {
root := t.TempDir()
rootPath := filepath.Join(root, "a.go")
nestedPath := filepath.Join(root, "sub", "a.go")
diff := func(path string) tools.FileDiff {
return tools.FileDiff{Path: path, OldExists: true, NewExists: true, OldText: "before", NewText: "after"}
}
for _, tc := range []struct {
name string
diffs []tools.FileDiff
want []string
}{
{name: "both rich", diffs: []tools.FileDiff{diff(rootPath), diff(nestedPath)}, want: []string{rootPath, nestedPath, "a.go", filepath.Join("sub", "a.go")}},
{name: "root rich", diffs: []tools.FileDiff{diff(rootPath)}, want: []string{rootPath, "a.go", filepath.Join("sub", "a.go")}},
{name: "nested rich", diffs: []tools.FileDiff{diff(nestedPath)}, want: []string{nestedPath, "a.go", filepath.Join("sub", "a.go")}},
} {
t.Run(tc.name, func(t *testing.T) {
locations := toolResultLocations(agent.ToolResult{
ChangedFiles: []string{"a.go", filepath.Join("sub", "a.go")},
FileDiffs: tc.diffs,
})
if len(locations) != len(tc.want) {
t.Fatalf("locations = %#v, want %#v", locations, tc.want)
}
for index := range tc.want {
if locations[index].Path != tc.want[index] {
t.Fatalf("locations = %#v, want %#v", locations, tc.want)
}
}
})
}
}
func TestToolCallResultPreservesWhitespaceInFilePaths(t *testing.T) {
relativePath := " report.txt "
absolutePath := filepath.Join(t.TempDir(), relativePath)
update := toolCallResult(agent.ToolResult{
ChangedFiles: []string{relativePath},
FileDiffs: []tools.FileDiff{{
Path: absolutePath, OldExists: true, NewExists: true, OldText: "before", NewText: "after",
}},
})
if len(update.Content) != 1 || update.Content[0].Path != absolutePath {
t.Fatalf("diff content path = %#v, want %q", update.Content, absolutePath)
}
if len(update.Locations) != 2 || update.Locations[0].Path != absolutePath || update.Locations[1].Path != relativePath {
t.Fatalf("locations = %#v, want exact paths %q and %q", update.Locations, absolutePath, relativePath)
}
}
func TestToolResultLocationsDeduplicateOnlyExactPaths(t *testing.T) {
path := filepath.Join(t.TempDir(), "a.go")
locations := toolResultLocations(agent.ToolResult{
ChangedFiles: []string{path, path},
FileDiffs: []tools.FileDiff{{Path: path, OldExists: true, NewExists: true, OldText: "before", NewText: "after"}},
})
if len(locations) != 1 || locations[0].Path != path {
t.Fatalf("exact duplicate locations = %#v", locations)
}
}
func TestDeletedFileEmitsDiffAndKeepsLocations(t *testing.T) {
relativePath := "deleted.go"
absolutePath := filepath.Join(t.TempDir(), relativePath)
update := toolCallResult(agent.ToolResult{
ChangedFiles: []string{relativePath},
FileDiffs: []tools.FileDiff{{
Path: absolutePath, OldExists: true, NewExists: false, OldText: "before",
}},
})
if len(update.Content) != 1 || update.Content[0].OldText == nil || *update.Content[0].OldText != "before" || update.Content[0].NewText != nil {
t.Fatalf("deleted file diff = %#v, want oldText with null newText", update.Content)
}
if len(update.Locations) != 2 || update.Locations[0].Path != absolutePath || update.Locations[1].Path != relativePath {
t.Fatalf("deleted file locations = %#v", update.Locations)
}
}
func TestToolCallResultEmitsOnlyRedactedFileDiffs(t *testing.T) {
secret := "sk-proj-abcdefghijklmnopqrstuvwxyz"
path := filepath.Join(t.TempDir(), "secret.txt")
scrubbed := tools.ScrubResultSecrets(tools.Result{FileDiffs: []tools.FileDiff{{
Path: path, OldExists: true, NewExists: true, OldText: "token=" + secret, NewText: "safe",
}}})
update := toolCallResult(agent.ToolResult{ToolCallID: "call", Status: tools.StatusError, FileDiffs: scrubbed.FileDiffs})
if len(update.Content) != 1 || update.Content[0].OldText == nil || strings.Contains(*update.Content[0].OldText, secret) {
t.Fatalf("ACP content leaked unredacted diff: %#v", update.Content)
}
}
func TestToolCallResultOmitsSemanticallyUnchangedRedactedDiff(t *testing.T) {
oldSecret := "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
newSecret := "ghp_9876543210ZYXWVUTSRQPONMLKJIHGFEDCBA"
scrubbed := tools.ScrubResultSecrets(tools.Result{
ChangedFiles: []string{"credentials.txt"},
FileDiffs: []tools.FileDiff{{
Path: filepath.Join(t.TempDir(), "credentials.txt"), OldExists: true, NewExists: true,
OldText: "token=" + oldSecret, NewText: "token=" + newSecret,
}},
})
update := toolCallResult(agent.ToolResult{
ToolCallID: "call", Status: tools.StatusOK,
ChangedFiles: scrubbed.ChangedFiles, FileDiffs: scrubbed.FileDiffs,
})
if len(update.Content) != 0 {
t.Fatalf("ACP emitted semantically unchanged redacted diff: %#v", update.Content)
}
if len(update.Locations) != 1 || update.Locations[0].Path != "credentials.txt" {
t.Fatalf("ACP path fallback = %#v", update.Locations)
}
}
func TestToolCallResultOmitsDefaultIgnorableSplitSecretsOnEitherSide(t *testing.T) {
secret := "sk-ant-api03-AAAABBBBCCCCDDDDEEEEFFFFGGGG"
for name, separator := range map[string]string{
"combining grapheme joiner": "\u034f",
"variation selector": "\ufe0f",
} {
for _, side := range []string{"old", "new"} {
t.Run(name+" "+side, func(t *testing.T) {
obfuscated := secret[:20] + separator + secret[20:]
diff := tools.FileDiff{
Path: filepath.Join(t.TempDir(), "secret.txt"), OldExists: true, NewExists: true,
OldText: "safe old", NewText: "safe new",
}
if side == "old" {
diff.OldText = obfuscated
} else {
diff.NewText = obfuscated
}
scrubbed := tools.ScrubResultSecrets(tools.Result{FileDiffs: []tools.FileDiff{diff}})
if !scrubbed.Redacted || len(scrubbed.FileDiffs) != 0 {
t.Fatalf("registry boundary retained an obfuscated secret: %#v", scrubbed)
}
update := toolCallResult(agent.ToolResult{ToolCallID: "call", Status: tools.StatusOK, FileDiffs: scrubbed.FileDiffs})
if len(update.Content) != 0 {
t.Fatalf("ACP content retained an obfuscated secret: %#v", update.Content)
}
})
}
}
}
func TestPlanUpdateAndStatus(t *testing.T) {
upd := planUpdate([]tools.PlanItem{
{Content: "step a", Status: "completed"},
+78 -8
View File
@@ -12,6 +12,8 @@ const (
MethodAuthenticate = "authenticate"
MethodSessionNew = "session/new"
MethodSessionLoad = "session/load"
MethodSessionList = "session/list"
MethodSessionResume = "session/resume"
MethodSessionPrompt = "session/prompt"
MethodSessionCancel = "session/cancel" // notification
MethodSessionUpdate = "session/update" // notification (agent -> client)
@@ -62,8 +64,16 @@ type PromptCapabilities struct {
}
type AgentCapabilities struct {
LoadSession bool `json:"loadSession"`
PromptCapabilities PromptCapabilities `json:"promptCapabilities"`
LoadSession bool `json:"loadSession"`
PromptCapabilities PromptCapabilities `json:"promptCapabilities"`
SessionCapabilities *SessionCapabilities `json:"sessionCapabilities,omitempty"`
}
// Empty capability objects are presence flags in ACP v1. Pointers preserve
// the wire distinction between an advertised `{}` and an omitted capability.
type SessionCapabilities struct {
List *struct{} `json:"list,omitempty"`
Resume *struct{} `json:"resume,omitempty"`
}
type AuthMethod struct {
@@ -117,9 +127,14 @@ type McpServer struct {
}
type NewSessionParams struct {
Cwd string `json:"cwd"`
McpServers []McpServer `json:"mcpServers"`
AdditionalDirectories []string `json:"additionalDirectories,omitempty"`
Cwd string `json:"cwd"`
McpServers []McpServer `json:"mcpServers"`
// AdditionalDirectories is declared by the protocol and consumed nowhere in
// this repo yet. When it is wired up it must go through requestedWorkspace
// like Cwd does: these are client-supplied paths with no absoluteness rule of
// their own, which is the same shape as the resume-cwd hole that made
// {"sessionId":"known"} activate a session against this process's directory.
AdditionalDirectories []string `json:"additionalDirectories,omitempty"`
}
type NewSessionResult struct {
@@ -140,6 +155,39 @@ type LoadSessionResult struct {
Modes *SessionModeState `json:"modes,omitempty"`
}
// ListSessionsParams and the following types implement ACP v1 session/list.
// Transcript contents stay behind session/load; this method returns metadata
// only and leaves the optional pagination cursor opaque.
type ListSessionsParams struct {
Cwd string `json:"cwd,omitempty"`
Cursor string `json:"cursor,omitempty"`
}
type SessionInfoMeta struct {
ModelID string `json:"modelId,omitempty"`
CreatedAt string `json:"createdAt,omitempty"`
}
type SessionInfo struct {
SessionID string `json:"sessionId"`
Cwd string `json:"cwd"`
Title string `json:"title,omitempty"`
UpdatedAt string `json:"updatedAt,omitempty"`
Meta *SessionInfoMeta `json:"_meta,omitempty"`
}
type ListSessionsResult struct {
Sessions []SessionInfo `json:"sessions"`
NextCursor string `json:"nextCursor,omitempty"`
}
// session/resume takes session/load's wire shape, so its cwd is a plain string
// too and an omitted one decodes exactly like an empty one. Sharing the type is
// safe only because activatePersistedSession requires an absolute cwd from the
// request itself — nothing downstream may supply a default for either method.
type ResumeSessionParams = LoadSessionParams
type ResumeSessionResult = LoadSessionResult
// ---- prompt turn ----
type PromptParams struct {
@@ -174,6 +222,7 @@ type SessionNotification struct {
// ContentBlock under "content"; the variant is set via SessionUpdate.
type ContentChunk struct {
SessionUpdate string `json:"sessionUpdate"`
MessageID string `json:"messageId,omitempty"`
Content ContentBlock `json:"content"`
}
@@ -235,9 +284,30 @@ type ToolCallContent struct {
// type == "content"
Content *ContentBlock `json:"content,omitempty"`
// type == "diff"
Path string `json:"path,omitempty"`
OldText string `json:"oldText,omitempty"`
NewText string `json:"newText,omitempty"`
Path string `json:"path,omitempty"`
OldText *string `json:"oldText,omitempty"`
NewText *string `json:"newText,omitempty"`
}
// MarshalJSON preserves ACP's discriminated content union. A diff always has
// path, while nullable oldText/newText distinguish creation, deletion, and an
// existing file replaced with empty content. Other content variants omit all
// diff fields rather than serializing irrelevant nulls.
func (content ToolCallContent) MarshalJSON() ([]byte, error) {
if content.Type == "diff" {
return json.Marshal(struct {
Type string `json:"type"`
Path string `json:"path"`
OldText *string `json:"oldText"`
NewText *string `json:"newText"`
}{
Type: content.Type, Path: content.Path, OldText: content.OldText, NewText: content.NewText,
})
}
return json.Marshal(struct {
Type string `json:"type"`
Content *ContentBlock `json:"content,omitempty"`
}{Type: content.Type, Content: content.Content})
}
func ToolContent(block ContentBlock) ToolCallContent {
+6 -18
View File
@@ -8,6 +8,7 @@ import (
"testing"
"time"
"github.com/Gitlawb/zero/internal/testutil"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
@@ -111,7 +112,11 @@ func TestAsyncDiagnosticsReEditReplacesResult(t *testing.T) {
diagnostics := newAsyncDiagnostics(check, "/ws")
diagnostics.enqueue(context.Background(), []string{"a.go"})
waitForIdle(t, diagnostics)
testutil.WaitFor(t, "worker idle", func() bool {
diagnostics.mu.Lock()
defer diagnostics.mu.Unlock()
return diagnostics.working == nil
})
mu.Lock()
response = "ERR new"
mu.Unlock()
@@ -123,23 +128,6 @@ func TestAsyncDiagnosticsReEditReplacesResult(t *testing.T) {
}
}
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{}
+110 -31
View File
@@ -13,6 +13,7 @@ import (
"github.com/Gitlawb/zero/internal/execution"
"github.com/Gitlawb/zero/internal/hooks"
"github.com/Gitlawb/zero/internal/modelregistry"
"github.com/Gitlawb/zero/internal/redaction"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/streamjson"
@@ -25,14 +26,18 @@ const maxTurnsAnswer = "Agent reached maximum number of turns without a final an
const maxTurnsFinalAnswerPrompt = "You have reached the tool-turn limit. Do not call tools. Give a concise final answer now: summarize what you completed, what you found, and any remaining blockers."
// maxStreamStallRetries bounds how many times a turn that timed out (idle/stall)
// WITH NO OUTPUT yet is re-issued on a fresh connection before giving up. Only
// the no-output case is retried (a partial turn would duplicate), so this is a
// safe recovery for a stalled/dead pooled connection.
// OR hit a mid-stream transport abort (#973) with no answer text yet is re-issued
// on a fresh connection before giving up. Incomplete tool-call previews are
// allowed through the gate (they are never dispatched before the error return).
// The safety claim is only that visible answer prose is not duplicated on retry —
// not that every partial stream shape is excluded. Covers a stalled/dead pooled
// connection or a socket abort before tool dispatch.
//
// Set to 1 (not 2): each attempt can itself idle for the full stream timeout
// (~5min) before the stall is even detected, so 2 retries left an interactive
// session frozen for ~15min. One retry keeps the common single-hiccup recovery
// while bounding the worst case to ~2× the idle timeout.
// while bounding the worst case to ~2× the idle timeout. Mid-stream transport
// aborts share this bound (do not raise it for #973).
const maxStreamStallRetries = 1
const (
@@ -374,7 +379,11 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
rec.StampFirstToken()
}
if onText != nil {
forwardedVisibleText = true
// Zero-length chunks are not committed answer prose; counting
// them would block an eligible mid-stream abort retry.
if s != "" {
forwardedVisibleText = true
}
onText(s)
}
}
@@ -450,9 +459,14 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
result.Messages = copyMessages(messages)
return result, ctx.Err()
}
// A stream idle/stall timeout is safely re-issued when the turn committed NO
// answer text — no forwarded visible prose (forwardedVisibleText) and no
// collected final text (collected.Text). This covers two cases:
// A stream idle/stall timeout OR mid-stream transport abort (#973 — wsarecv /
// connection reset / unexpected EOF / connection closed / broken pipe) is
// safely re-issued when the turn committed NO answer text — no forwarded
// visible prose (forwardedVisibleText) and no collected final text
// (collected.Text). The abort gate matches midStreamAbortNeedles only, not
// the full shouldReconnect list: connect-phase timeouts and connection
// refused are NOT retried here (a slow healthy server must not cost a
// second prefill). This covers three cases:
// 1. Nothing streamed at all before the connection died (the original
// macOS stale-pooled-connection hang past the response-header timeout).
// 2. The model streamed transient reasoning and began a tool call (e.g. a
@@ -463,13 +477,27 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
// re-render is transient previews, not duplicated answer text. This is
// why the gate no longer excludes collected.ToolCalls: an incomplete
// tool call from a timed-out stream is discard-and-retry, not output.
// 3. Mid-stream socket abort after connect succeeded (Windows WSAECONNABORTED /
// wsarecv, connection reset by peer). Same no-tool-executed safety: the
// error returns before tool dispatch, so a bounded retry is safe.
// A turn that forwarded real prose is NOT retried (it would duplicate visible
// answer text) and falls through to the error return below. Capped +
// exponential backoff, with a user-visible notice per attempt.
for attempt := 1; attempt <= maxStreamStallRetries &&
isStreamTimeoutError(collected.Error) && !forwardedVisibleText &&
(isStreamTimeoutError(collected.Error) || isMidStreamTransportAbort(collected.Error)) &&
!forwardedVisibleText &&
collected.Text == ""; attempt++ {
if notify := stallRetryNoticeFor(options); notify != nil {
var notify reconnectNotifier
if isStreamTimeoutError(collected.Error) {
notify = stallRetryNoticeFor(options)
} else {
notify = reconnectNoticeFor(options)
// Count this post-connect reissue even when the replacement
// connect succeeds immediately. streamWithReconnect only
// increments reconnect_count after a failed connect attempt.
options.Trace.Counter(trace.CounterReconnectCount, 1)
}
if notify != nil {
notify(attempt, maxStreamStallRetries)
}
if err := sleepWithContext(ctx, backoffFor(attempt)); err != nil {
@@ -482,12 +510,22 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
retryStream, retryErr := streamWithReconnect(ctx, provider, retryRequest, reconnectNoticeFor(options))
if retryErr != nil {
result.Messages = copyMessages(messages)
if ctx.Err() != nil {
return result, ctx.Err()
}
return result, retryErr
}
stallGenSpan := options.Trace.Span(trace.SpanGeneration)
collected = zeroruntime.CollectStreamWithOptions(ctx, retryStream, forwardingOpts)
stallGenSpan.End()
}
// Recheck ctx after a retried CollectStream: helpers.go stores
// ctx.Err().Error() in collected.Error, and errors.New of that string
// drops the context.Canceled sentinel ACP branches on.
if ctx.Err() != nil {
result.Messages = copyMessages(messages)
return result, ctx.Err()
}
if collected.Error != "" {
// Route a reissued stream's non-stall error through the SAME recovery as
// the initial stream (image-rejection wrapping / context-limit compaction)
@@ -498,6 +536,10 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
result.Messages = copyMessages(messages)
return result, stop
}
if ctx.Err() != nil {
result.Messages = copyMessages(messages)
return result, ctx.Err()
}
if collected.Error != "" {
result.Messages = copyMessages(messages)
return result, errors.New(collected.Error)
@@ -646,9 +688,25 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
// recorded. They travel as user messages, and a user message between two
// tool_results breaks strict provider replay — Anthropic coalesces them
// into one user block list and requires the tool_result blocks first, so
// interleaving yields [tool_result, text, image, tool_result] and a 400.
// Same reason the self-correction feedback below is deferred.
var toolImageMessages []zeroruntime.Message
// Images ride a following USER message rather than the tool result
// above. Every provider drops images on a tool-role message —
// Anthropic's tool_result content is a string, Gemini's is a
// functionResponse, and OpenAI guards its image parts to the user role
// — so attaching them there would silently deliver nothing. A separate
// message also keeps the one-tool-result-per-tool-call pairing intact,
// which the providers validate. Vision-gate evaluation is deferred until
// after any model switch this turn resolves, so images can reach an
// escalated vision-capable model.
var toolResultsWithImages []ToolResult
buildToolImageMessages := func() []zeroruntime.Message {
var out []zeroruntime.Message
for _, tr := range toolResultsWithImages {
if imageMessage, ok := toolResultImageMessage(tr, options); ok {
out = append(out, imageMessage)
}
}
return out
}
// Parallel read-ahead state: results for calls[precomputedStart:precomputedEnd]
// executed concurrently, consumed strictly in order below.
var precomputed []precomputedToolResult
@@ -705,15 +763,8 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
IsError: toolResult.Status == tools.StatusError,
ChangedFiles: append([]string(nil), toolResult.ChangedFiles...),
})
// Images ride a following USER message rather than the tool result
// above. Every provider drops images on a tool-role message —
// Anthropic's tool_result content is a string, Gemini's is a
// functionResponse, and OpenAI guards its image parts to the user role
// — so attaching them there would silently deliver nothing. A separate
// message also keeps the one-tool-result-per-tool-call pairing intact,
// which the providers validate.
if imageMessage, ok := toolResultImageMessage(toolResult); ok {
toolImageMessages = append(toolImageMessages, imageMessage)
if len(toolResult.Images) > 0 {
toolResultsWithImages = append(toolResultsWithImages, toolResult)
}
// A tool may demand the run ABORT — a canceled/timed-out ask_user prompt
@@ -725,13 +776,13 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
}
if abortErr != nil {
messages = appendAbortedToolResults(messages, collected.ToolCalls[index+1:])
messages = append(messages, toolImageMessages...)
messages = append(messages, buildToolImageMessages()...)
result.Messages = copyMessages(messages)
return result, abortErr
}
if stopReason := stopReasonFromToolResult(toolResult); stopReason != "" {
messages = appendAbortedToolResults(messages, collected.ToolCalls[index+1:])
messages = append(messages, toolImageMessages...)
messages = append(messages, buildToolImageMessages()...)
result.FinalAnswer = toolResult.ModelOutput()
result.StopReason = stopReason
result.Messages = copyMessages(messages)
@@ -755,7 +806,7 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
// messages stay valid for a strict provider replay (Anthropic
// rejects a tool_use with no answering tool_result).
messages = appendAbortedToolResults(messages, collected.ToolCalls[index+1:])
messages = append(messages, toolImageMessages...)
messages = append(messages, buildToolImageMessages()...)
result.FinalAnswer = toolFailureStopAnswer(call.Name, outcome.Count)
result.Messages = copyMessages(messages)
return result, nil
@@ -774,10 +825,6 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
postEditDiagnostics.enqueue(ctx, toolResult.ChangedFiles)
}
}
// Every tool_result for this turn is now recorded, including aborted
// placeholders, so the images can follow without splitting them.
messages = append(messages, toolImageMessages...)
toolImageMessages = nil
// Run post-edit self-correction once over the union of files this turn
// changed, then append any feedback after every tool_result is recorded so
@@ -865,6 +912,12 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
}
}
// Every tool_result, self-correct notice, and model-switch update for this
// turn is now in place; evaluate and append image messages against the
// effective (potentially escalated) model.
messages = append(messages, buildToolImageMessages()...)
toolResultsWithImages = nil
// A turn can mix valid tool calls with a dropped (nameless) one. The valid
// calls executed above; surface the dropped call too so it is never
// silently ignored just because the turn also did real work. This is
@@ -1527,6 +1580,7 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
Images: result.Images,
Redacted: result.Redacted,
ChangedFiles: result.ChangedFiles,
FileDiffs: result.FileDiffs,
ChangeSummaries: result.ChangeSummaries,
Display: result.HumanDisplay(),
Outcome: result.Outcome,
@@ -1833,6 +1887,10 @@ func runToolForUnsandboxedRetry(ctx context.Context, registry *tools.Registry, n
}
func toolResultFromPrePermissionReject(call ToolCall, result tools.Result) ToolResult {
// PrePermissionRejecter runs before Registry.RunWithOptions, so it must
// explicitly cross the same transcript/redaction boundary before its result
// can be forwarded through ACP.
result = tools.ScrubResultSecrets(result)
output, outputRedacted := scrubInterceptedOutput(result.Output)
display := result.Display
summary, summaryRedacted := scrubInterceptedOutput(display.Summary)
@@ -1860,6 +1918,7 @@ func toolResultFromPrePermissionReject(call ToolCall, result tools.Result) ToolR
Meta: meta,
Redacted: result.Redacted || outputRedacted || summaryRedacted || metaRedacted,
ChangedFiles: result.ChangedFiles,
FileDiffs: result.FileDiffs,
ChangeSummaries: result.ChangeSummaries,
Display: display,
LoadedTools: loadedToolsFromResult(meta),
@@ -2153,6 +2212,7 @@ func askUserFallbackResult(ctx context.Context, registry *tools.Registry, call T
Meta: result.Meta,
Redacted: result.Redacted,
ChangedFiles: result.ChangedFiles,
FileDiffs: result.FileDiffs,
ChangeSummaries: result.ChangeSummaries,
Display: result.HumanDisplay(),
Outcome: result.Outcome,
@@ -3457,8 +3517,10 @@ func copyMessages(messages []Message) []Message {
//
// The text names the tool so the model can tell which call an image came from
// when several ran in one turn — the images arrive detached from their tool
// result, so nothing else associates them.
func toolResultImageMessage(result ToolResult) (zeroruntime.Message, bool) {
// result, so nothing else associates them. If the effective model cannot
// accept images, the tool's text result is left unchanged and this follow-up
// is a notice with no image parts, matching the CLI/TUI vision gate.
func toolResultImageMessage(result ToolResult, options Options) (zeroruntime.Message, bool) {
images := make([]zeroruntime.ImageBlock, 0, len(result.Images))
for _, image := range result.Images {
if len(image.Data) == 0 {
@@ -3479,9 +3541,26 @@ func toolResultImageMessage(result ToolResult) (zeroruntime.Message, bool) {
if label == "" {
label = "tool"
}
if !modelAcceptsToolImages(options) {
return zeroruntime.Message{
Role: zeroruntime.MessageRoleUser,
Content: "Image output from " + label + " was not sent because the current model does not support image input. Switch to a vision-capable model before retrying image delivery, including any images skipped by the tool's image budget.",
}, true
}
return zeroruntime.Message{
Role: zeroruntime.MessageRoleUser,
Content: "Image output from " + label + ":",
Images: images,
}, true
}
func modelAcceptsToolImages(options Options) bool {
if options.SupportsVision != nil {
return options.SupportsVision(options.Model)
}
registry, err := modelregistry.DefaultRegistry()
if err != nil {
return modelregistry.VisionCapableByName(options.Model)
}
return modelregistry.SupportsVision(registry, options.Model)
}
+17
View File
@@ -27,6 +27,23 @@ type mockProvider struct {
requests []zeroruntime.CompletionRequest
}
func TestPrePermissionRejectScrubsFileDiffs(t *testing.T) {
secret := "sk-proj-abcdefghijklmnopqrstuvwxyz"
result := toolResultFromPrePermissionReject(ToolCall{ID: "call", Name: "test"}, tools.Result{
Status: tools.StatusError,
FileDiffs: []tools.FileDiff{{
Path: filepath.Join(t.TempDir(), "secret.txt"),
OldExists: true,
NewExists: true,
OldText: "token=" + secret,
NewText: "safe",
}},
})
if len(result.FileDiffs) != 1 || strings.Contains(result.FileDiffs[0].OldText, secret) || !result.Redacted {
t.Fatalf("pre-permission FileDiff = %#v, redacted = %t", result.FileDiffs, result.Redacted)
}
}
func TestTypedExecutionOutcomeOverridesLegacySandboxHeuristics(t *testing.T) {
engine := sandbox.NewEngine(sandbox.EngineOptions{WorkspaceRoot: t.TempDir(), Policy: sandbox.DefaultPolicy()})
call := ToolCall{Name: tools.ExecCommandToolName}
+487
View File
@@ -0,0 +1,487 @@
package agent
import (
"context"
"errors"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/trace"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// midStreamAbortProvider connects successfully but emits a transport-abort
// StreamEventError on the first abortBefore calls, then succeeds with "done".
// hangOnCall, if > 0, makes that 1-based call block until ctx is done so a
// cancel during the retried CollectStream can be reproduced.
// connectFailFrom, if > 0, makes that 1-based call and later return a
// reconnectable connect error so streamWithReconnect enters its backoff.
type midStreamAbortProvider struct {
calls int32
abortBefore int32
abortError string
partialText string
emptyTextEvent bool
partialToolCall string
hangOnCall int32
connectFailFrom int32
connectErr string
started chan struct{}
}
func (p *midStreamAbortProvider) StreamCompletion(ctx context.Context, _ zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
n := atomic.AddInt32(&p.calls, 1)
if p.connectFailFrom > 0 && n >= p.connectFailFrom {
if p.started != nil {
select {
case <-p.started:
default:
close(p.started)
}
}
errMsg := p.connectErr
if errMsg == "" {
errMsg = "connection reset by peer"
}
return nil, errors.New(errMsg)
}
if p.hangOnCall > 0 && n == p.hangOnCall {
if p.started != nil {
close(p.started)
}
ch := make(chan zeroruntime.StreamEvent)
go func() {
<-ctx.Done()
close(ch)
}()
return ch, nil
}
ch := make(chan zeroruntime.StreamEvent, 5)
if n <= p.abortBefore {
if p.emptyTextEvent {
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventText, Content: ""}
}
if p.partialText != "" {
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventText, Content: p.partialText}
}
if p.partialToolCall != "" {
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "tc_1", ToolName: p.partialToolCall}
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "tc_1", ArgumentsFragment: `{"path":"x.html","content":"<!doctype`}
}
errMsg := p.abortError
if errMsg == "" {
errMsg = "provider stream error: read: connection reset by peer"
}
ch <- zeroruntime.StreamEvent{
Type: zeroruntime.StreamEventError,
Error: errMsg,
}
close(ch)
return ch, nil
}
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventText, Content: "done"}
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone}
close(ch)
return ch, nil
}
func TestRunRetriesMidStreamConnectionReset(t *testing.T) {
p := &midStreamAbortProvider{abortBefore: 1}
result, err := Run(context.Background(), "go", p, Options{Registry: tools.NewRegistry()})
if err != nil {
t.Fatalf("a mid-stream connection reset should retry to success, got %v", err)
}
if result.FinalAnswer != "done" {
t.Fatalf("final answer = %q, want %q", result.FinalAnswer, "done")
}
if got := atomic.LoadInt32(&p.calls); got != 2 {
t.Fatalf("want 2 calls (1 abort + 1 retry), got %d", got)
}
}
func TestRunRetriesMidStreamWindowsAbort(t *testing.T) {
p := &midStreamAbortProvider{
abortBefore: 1,
abortError: "provider stream error: read: wsarecv: An established connection was aborted by the software in your host machine",
}
result, err := Run(context.Background(), "go", p, Options{Registry: tools.NewRegistry()})
if err != nil {
t.Fatalf("a Windows mid-stream abort should retry to success, got %v", err)
}
if result.FinalAnswer != "done" {
t.Fatalf("final answer = %q, want %q", result.FinalAnswer, "done")
}
if got := atomic.LoadInt32(&p.calls); got != 2 {
t.Fatalf("want 2 calls (1 abort + 1 retry), got %d", got)
}
}
// wsarecv alone (no "connection was aborted" substring) must still retry, so
// deleting the wsarecv needle cannot go green.
func TestRunRetriesMidStreamWsarecvNeedle(t *testing.T) {
p := &midStreamAbortProvider{
abortBefore: 1,
abortError: "provider stream error: read: wsarecv: 10053",
}
result, err := Run(context.Background(), "go", p, Options{Registry: tools.NewRegistry()})
if err != nil {
t.Fatalf("wsarecv without 'connection was aborted' should retry to success, got %v", err)
}
if result.FinalAnswer != "done" {
t.Fatalf("final answer = %q, want %q", result.FinalAnswer, "done")
}
if got := atomic.LoadInt32(&p.calls); got != 2 {
t.Fatalf("want 2 calls (1 abort + 1 retry), got %d", got)
}
}
func TestRunDoesNotRetryMidStreamAbortAfterPartialOutput(t *testing.T) {
sawText := 0
p := &midStreamAbortProvider{abortBefore: 1, partialText: "partial"}
_, err := Run(context.Background(), "go", p, Options{
Registry: tools.NewRegistry(),
OnText: func(string) { sawText++ },
})
if err == nil {
t.Fatal("a mid-stream abort after partial output must NOT be retried; want an error")
}
if got := atomic.LoadInt32(&p.calls); got != 1 {
t.Fatalf("partial-then-abort must not retry, got %d calls", got)
}
if sawText == 0 {
t.Fatal("OnText must have fired so forwardedVisibleText is the named guard")
}
}
// A zero-length OnText chunk is not committed answer prose, so an eligible
// transport abort still retries.
func TestRunRetriesMidStreamAbortAfterEmptyTextEvent(t *testing.T) {
p := &midStreamAbortProvider{abortBefore: 1, emptyTextEvent: true}
result, err := Run(context.Background(), "go", p, Options{
Registry: tools.NewRegistry(),
OnText: func(string) {},
})
if err != nil {
t.Fatalf("empty OnText must not block mid-stream abort retry, got %v", err)
}
if result.FinalAnswer != "done" {
t.Fatalf("final answer = %q, want %q", result.FinalAnswer, "done")
}
if got := atomic.LoadInt32(&p.calls); got != 2 {
t.Fatalf("want 2 calls (1 abort + 1 retry), got %d", got)
}
}
func TestRunRetriesMidStreamAbortAfterIncompleteToolCall(t *testing.T) {
starts := 0
dispatched := 0
p := &midStreamAbortProvider{abortBefore: 1, partialToolCall: "write_file"}
result, err := Run(context.Background(), "go", p, Options{
Registry: tools.NewRegistry(),
OnToolCallStart: func(string, string) { starts++ },
OnToolCallDelta: func(string, string) {},
OnToolCall: func(ToolCall) { dispatched++ },
})
if err != nil {
t.Fatalf("a mid-stream abort mid-incomplete-tool-call should retry to success, got %v", err)
}
if result.FinalAnswer != "done" {
t.Fatalf("final answer = %q, want %q", result.FinalAnswer, "done")
}
if got := atomic.LoadInt32(&p.calls); got != 2 {
t.Fatalf("want 2 calls (1 abort + 1 retry), got %d", got)
}
if starts != 1 {
t.Fatalf("the incomplete tool call should have been forwarded once before the abort, got %d starts", starts)
}
if dispatched != 0 {
t.Fatalf("OnToolCall must never fire for an incomplete aborted tool call (no dispatch), got %d", dispatched)
}
}
func TestRunGivesUpAfterMaxMidStreamAbortRetries(t *testing.T) {
if maxStreamStallRetries != 1 {
t.Fatalf("maxStreamStallRetries = %d, want 1 (#973 must not raise this bound)", maxStreamStallRetries)
}
defer func(orig time.Duration) { streamReconnectBase = orig }(streamReconnectBase)
streamReconnectBase = time.Millisecond
p := &midStreamAbortProvider{abortBefore: 99}
_, err := Run(context.Background(), "go", p, Options{Registry: tools.NewRegistry()})
if err == nil {
t.Fatal("a persistent mid-stream abort must surface an error after exhausting retries")
}
if got := atomic.LoadInt32(&p.calls); got != 2 {
t.Fatalf("want 2 calls (1 abort + 1 retry); pin the bound at 1, got %d", got)
}
}
func TestRunMidStreamAbortUsesReconnectNotice(t *testing.T) {
defer func(orig time.Duration) { streamReconnectBase = orig }(streamReconnectBase)
streamReconnectBase = time.Millisecond
var notices string
p := &midStreamAbortProvider{abortBefore: 1}
result, err := Run(context.Background(), "go", p, Options{
Registry: tools.NewRegistry(),
OnReasoning: func(s string) { notices += s },
})
if err != nil {
t.Fatalf("retry should succeed, got %v", err)
}
if result.FinalAnswer != "done" {
t.Fatalf("final answer = %q, want %q", result.FinalAnswer, "done")
}
lower := strings.ToLower(notices)
if !strings.Contains(lower, "connection lost") || !strings.Contains(lower, "reconnecting") {
t.Fatalf("transport abort must use reconnect wording, notices = %q", notices)
}
if strings.Contains(lower, "model stalled") {
t.Fatalf("transport abort must not use stall wording, notices = %q", notices)
}
}
func TestRunCancelDuringMidStreamRetryPreservesContextCanceled(t *testing.T) {
defer func(orig time.Duration) { streamReconnectBase = orig }(streamReconnectBase)
streamReconnectBase = time.Millisecond
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
started := make(chan struct{})
p := &midStreamAbortProvider{abortBefore: 1, hangOnCall: 2, started: started}
errCh := make(chan error, 1)
go func() {
_, err := Run(ctx, "go", p, Options{Registry: tools.NewRegistry()})
errCh <- err
}()
select {
case <-started:
case <-time.After(5 * time.Second):
t.Fatal("timed out waiting for retried stream")
}
cancel()
err := <-errCh
if err == nil {
t.Fatal("want context.Canceled after cancel during retried stream")
}
if !errors.Is(err, context.Canceled) {
t.Fatalf("cancel during retried stream must keep the sentinel, got %q (errors.Is Canceled = false)", err)
}
}
func TestRunCancelDuringReplacementConnectBackoffPreservesContextCanceled(t *testing.T) {
defer func(orig time.Duration) { streamReconnectBase = orig }(streamReconnectBase)
streamReconnectBase = 200 * time.Millisecond
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
started := make(chan struct{})
p := &midStreamAbortProvider{abortBefore: 1, connectFailFrom: 2, started: started}
errCh := make(chan error, 1)
go func() {
_, err := Run(ctx, "go", p, Options{Registry: tools.NewRegistry()})
errCh <- err
}()
select {
case <-started:
case <-time.After(5 * time.Second):
t.Fatal("timed out waiting for replacement connect failure")
}
cancel()
err := <-errCh
if err == nil {
t.Fatal("want context.Canceled after cancel during replacement connect backoff")
}
if !errors.Is(err, context.Canceled) {
t.Fatalf("cancel during replacement connect backoff must keep the sentinel, got %q (errors.Is Canceled = false)", err)
}
}
func TestRunMidStreamAbortRecordsReconnectCountOnImmediateReplacement(t *testing.T) {
defer func(orig time.Duration) { streamReconnectBase = orig }(streamReconnectBase)
streamReconnectBase = time.Millisecond
rec := trace.NewRecorder("midstream-abort", "run-1", "test")
p := &midStreamAbortProvider{abortBefore: 1}
result, err := Run(context.Background(), "go", p, Options{
Registry: tools.NewRegistry(),
Trace: rec,
})
if err != nil {
t.Fatalf("retry should succeed, got %v", err)
}
if result.FinalAnswer != "done" {
t.Fatalf("final answer = %q, want %q", result.FinalAnswer, "done")
}
if got := atomic.LoadInt32(&p.calls); got != 2 {
t.Fatalf("want 2 calls (1 abort + 1 immediate replacement), got %d", got)
}
gotTrace := rec.Finish()
if got := gotTrace.Counter(trace.CounterReconnectCount); got != 1 {
t.Fatalf("reconnect_count = %d, want 1 for a successful replacement connection", got)
}
if got := gotTrace.Counter(trace.CounterModelRequests); got != 2 {
t.Fatalf("model_requests = %d, want 2 (initial + replacement)", got)
}
}
func TestRunDoesNotRetryResponseHeaderTimeout(t *testing.T) {
p := &midStreamAbortProvider{
abortBefore: 99,
abortError: "net/http: timeout awaiting response headers",
}
_, err := Run(context.Background(), "go", p, Options{Registry: tools.NewRegistry()})
if err == nil {
t.Fatal("a response-header timeout must not be retried as a mid-stream abort")
}
if got := atomic.LoadInt32(&p.calls); got != 1 {
t.Fatalf("header timeout must not retry, got %d calls", got)
}
}
func TestIsMidStreamTransportAbort(t *testing.T) {
aborts := []string{
"provider stream error: read: connection reset by peer",
"provider stream error: unexpected EOF",
"provider stream error: read: wsarecv: 10053",
"provider stream error: read: wsarecv: An established connection was aborted by the software in your host machine",
"An existing connection was forcibly closed by the remote host",
"provider stream error: read: connection closed",
"write: broken pipe",
"provider stream error: server closed the connection",
"provider stream error: software caused connection abort",
}
for _, m := range aborts {
if !isMidStreamTransportAbort(m) {
t.Fatalf("want mid-stream transport abort: %q", m)
}
}
notAborts := []string{
"",
"context length exceeded",
"rate limit error: slow down",
"model not found",
"net/http: timeout awaiting response headers",
"dial tcp 10.0.0.1:443: connect: connection refused",
"i/o timeout",
"500 Internal Server Error",
"502 Bad Gateway",
"503 Service Unavailable",
"504 Gateway Timeout",
"provider request error: request does not satisfy oneOf schema",
"provider request error: connection closed is not a supported finish reason",
"provider error: connection closed is not a supported finish reason",
"provider error: upstream connection reset by peer",
"provider error: unexpected EOF while reading upstream response",
"provider error: 502 Bad Gateway",
"auth error: connection reset by peer",
"rate limit error: server closed",
"schema error: property oneOf is invalid",
}
for _, m := range notAborts {
if isMidStreamTransportAbort(m) {
t.Fatalf("must NOT classify as mid-stream transport abort: %q", m)
}
}
}
func TestRunDoesNotRetryClassifiedProviderErrorWithSocketPhrase(t *testing.T) {
p := &midStreamAbortProvider{
abortBefore: 1,
abortError: "provider request error: connection closed is not a supported finish reason",
}
var notices string
opts := Options{
Registry: tools.NewRegistry(),
OnReasoning: func(s string) { notices += s },
}
_, err := Run(context.Background(), "go", p, opts)
if err == nil {
t.Fatal("expected application error, got nil")
}
if got := atomic.LoadInt32(&p.calls); got != 1 {
t.Fatalf("classified provider error with 'connection closed' must not retry (got %d calls, want 1)", got)
}
if notices != "" {
t.Fatalf("expected no reconnect notices, got %q", notices)
}
if !strings.Contains(err.Error(), "connection closed") {
t.Fatalf("expected original error preserved, got %v", err)
}
}
func TestRunDoesNotRetryProviderErrorWithSocketPhrase(t *testing.T) {
p := &midStreamAbortProvider{
abortBefore: 1,
abortError: "provider error: unexpected EOF while reading upstream response",
}
var notices string
opts := Options{
Registry: tools.NewRegistry(),
OnReasoning: func(s string) { notices += s },
}
_, err := Run(context.Background(), "go", p, opts)
if err == nil {
t.Fatal("expected application error, got nil")
}
if got := atomic.LoadInt32(&p.calls); got != 1 {
t.Fatalf("provider error with 'unexpected EOF' must not retry (got %d calls, want 1)", got)
}
if notices != "" {
t.Fatalf("expected no reconnect notices, got %q", notices)
}
if got := err.Error(); got != p.abortError {
t.Fatalf("error = %q, want original provider error %q", got, p.abortError)
}
}
func TestRunDoesNotRetryApplicationErrorContainingEOFSubstring(t *testing.T) {
p := &midStreamAbortProvider{
abortBefore: 1,
abortError: "provider request error: request does not satisfy oneOf schema",
}
var notices string
opts := Options{
Registry: tools.NewRegistry(),
OnReasoning: func(s string) { notices += s },
}
_, err := Run(context.Background(), "go", p, opts)
if err == nil {
t.Fatal("expected application error, got nil")
}
if got := atomic.LoadInt32(&p.calls); got != 1 {
t.Fatalf("application error with 'oneOf' must not retry (got %d calls, want 1)", got)
}
if notices != "" {
t.Fatalf("expected no reconnect notices, got %q", notices)
}
if !strings.Contains(err.Error(), "oneOf") {
t.Fatalf("expected original error preserved, got %v", err)
}
}
func TestRunRetriesMidStreamUnexpectedEOF(t *testing.T) {
defer func(orig time.Duration) { streamReconnectBase = orig }(streamReconnectBase)
streamReconnectBase = time.Millisecond
p := &midStreamAbortProvider{
abortBefore: 1,
abortError: "provider stream error: unexpected EOF",
}
var notices string
opts := Options{
Registry: tools.NewRegistry(),
OnReasoning: func(s string) { notices += s },
}
result, err := Run(context.Background(), "go", p, opts)
if err != nil {
t.Fatalf("unexpected EOF should retry to success, got %v", err)
}
if result.FinalAnswer != "done" {
t.Fatalf("final answer = %q, want %q", result.FinalAnswer, "done")
}
if got := atomic.LoadInt32(&p.calls); got != 2 {
t.Fatalf("want 2 calls (1 abort + 1 retry), got %d", got)
}
lower := strings.ToLower(notices)
if !strings.Contains(lower, "connection lost") || !strings.Contains(lower, "reconnecting") {
t.Fatalf("expected reconnect notice, got %q", notices)
}
}
+51 -2
View File
@@ -1,9 +1,11 @@
package agent
import (
"strings"
"testing"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// These ceilings are a deliberate ratchet on Zero's fixed per-turn overhead — the
@@ -33,7 +35,24 @@ const (
// The ratchet did its job: it caught the creep and forced a decision instead
// of a drift. That is the point of it, so keep raising it deliberately rather
// than reflexively.
//
// THIS COUNTS THE SCHEMAS ONLY, NOT THE HOST'S SHELL GUIDANCE. Until
// 2026-09-07 it counted both, and that made it a different measurement on
// every machine: exec_command appends shell guidance whose size depends on
// which shell was detected, so the same tree measured 3498 on Linux, ~3600 on
// a Windows host with pwsh 7, and 3653 on Windows PowerShell 5.1, which is
// stock Windows. The 5.1 case has been over this ceiling since view_image
// landed, so `go test ./...` failed for anyone on a stock Windows box while
// CI stayed green, because the runners have pwsh 7 and take the cheaper
// branch. A budget that passes or fails on which PowerShell happens to be
// installed is not ratcheting anything.
maxEagerToolSchemaTokens = 3650
// The host-specific half, ratcheted on its own so it cannot creep either.
// Measured 2026-09-07: Windows PowerShell 5.1 is the most expensive host at
// 155 tokens (722 chars), pwsh 7 is 110, cmd.exe is 87, and every POSIX host
// is 0 because no guidance is appended there. Set above the worst case, not
// above whatever this machine happens to be.
maxExecCommandShellGuidanceTokens = 200
)
func TestSystemPromptTokenBudget(t *testing.T) {
@@ -56,8 +75,35 @@ func TestEagerToolSchemaTokenBudget(t *testing.T) {
// 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))
total := estimateToolDefTokens(exposed)
// exec_command's description carries this host's shell guidance, and that
// guidance is the one part of the eager set that is not the same everywhere.
// Charged separately below rather than folded in here, or this ceiling would
// mean a different thing on every machine. See the constants above.
//
// Removed from the description BEFORE estimating rather than subtracted from
// the total afterwards. ApproxTextTokens is an integer division, so
// floor((schema+guidance)/4) - floor(guidance/4) comes out one token high
// whenever the two remainders add past four, and which side of that line a
// host lands on depends on the length of its guidance: exactly the
// host-dependence this number is supposed to be rid of.
guidance := tools.HostExecCommandShellGuidance()
schemas := make([]zeroruntime.ToolDefinition, len(exposed))
copy(schemas, exposed)
stripped := 0
for index := range schemas {
without := strings.TrimSuffix(schemas[index].Description, guidance)
if without != schemas[index].Description {
stripped++
}
schemas[index].Description = without
}
if guidance != "" && stripped != 1 {
t.Fatalf("SETUP INVALID: this host's shell guidance came off %d descriptions, want exactly exec_command's; internal/tools pins it as that description's tail", stripped)
}
got := estimateToolDefTokens(schemas)
guidanceTokens := ApproxTextTokens(guidance)
t.Logf("eager core tool schemas: %d tokens across %d tools (%d total, %d host shell guidance)", got, len(exposed), total, guidanceTokens)
if got > maxEagerToolSchemaTokens {
// NOT "defer a tool": this test pins DeferThreshold at 0, so marking a
// tool deferred leaves it exposed here and changes nothing. Deferral is
@@ -66,6 +112,9 @@ func TestEagerToolSchemaTokenBudget(t *testing.T) {
// deliberate raise.
t.Fatalf("eager tool schemas are %d tokens, over the %d ceiling — trim a schema, drop a core tool, or raise the ceiling deliberately (deferring will NOT help: this test disables deferral)", got, maxEagerToolSchemaTokens)
}
if guidanceTokens > maxExecCommandShellGuidanceTokens {
t.Fatalf("this host's exec_command shell guidance is %d tokens, over the %d ceiling — trim the guidance in internal/tools/shell_runtime.go or raise the ceiling deliberately", guidanceTokens, maxExecCommandShellGuidanceTokens)
}
}
func TestAgentAdvertisesBothEditTools(t *testing.T) {
+89
View File
@@ -144,6 +144,15 @@ func shouldReconnect(ctx context.Context, err error) bool {
"i/o timeout",
"server closed",
"unexpected end",
// Windows mid-stream aborts (WSAECONNABORTED / wsarecv) — host or AV
// forcibly closes an established socket during CollectStream.
// "software caused connection abort" is the Unix/macOS ECONNABORTED
// text; it also appears in midStreamAbortNeedles, which must stay a
// subset of this list.
"wsarecv",
"connection was aborted",
"software caused connection abort",
"forcibly closed",
} {
if strings.Contains(msg, needle) {
return true
@@ -152,6 +161,86 @@ func shouldReconnect(ctx context.Context, err error) bool {
return false
}
// midStreamAbortNeedles are the transport-abort classes retried AFTER connect
// succeeded and the stream body has started. This is a subset of shouldReconnect:
// connect-phase signals (timeout, connection refused, "temporarily unavailable")
// stay on the connect retry path, because matching them here would re-prefill a
// healthy-but-slow server (ollama cloud header timeouts) and tell the user the
// connection was lost. Issue #973 names WSAECONNABORTED / connection reset /
// unexpected stream EOF.
var midStreamAbortNeedles = []string{
"unexpected end",
"connection reset",
"broken pipe",
"connection closed",
"server closed",
"wsarecv",
"connection was aborted",
"software caused connection abort",
"forcibly closed",
}
var classifiedNonTransportPrefixes = []string{
"provider request error:",
"provider error:",
"auth error:",
"rate limit error:",
}
// isMidStreamTransportAbort reports whether a collected stream error string is a
// retryable mid-stream transport abort. It does NOT delegate to shouldReconnect:
// that list is a connect-phase argument ("no response was received"). The stream
// body has already started here, so only abort/reset/EOF/close classes retry.
//
// Used for failures DURING the stream body AFTER a successful connect and BEFORE
// tool dispatch — the incomplete turn committed no answer text and executed no
// tools, so a bounded re-issue is safe (same safety rules as the stall path).
func isMidStreamTransportAbort(message string) bool {
lowered := strings.ToLower(strings.TrimSpace(message))
if lowered == "" || isContextLimitError(lowered) {
return false
}
for _, prefix := range classifiedNonTransportPrefixes {
if strings.Contains(lowered, prefix) {
return false
}
}
if errhint.HasStatusCode(lowered, "500", "502", "503", "504") {
return false
}
if containsWordBoundary(lowered, "eof") {
return true
}
for _, needle := range midStreamAbortNeedles {
if strings.Contains(lowered, needle) {
return true
}
}
return false
}
func containsWordBoundary(text, word string) bool {
start := 0
for {
idx := strings.Index(text[start:], word)
if idx < 0 {
return false
}
pos := start + idx
endPos := pos + len(word)
leftBoundary := pos == 0 || !isAlphaNumByte(text[pos-1])
rightBoundary := endPos == len(text) || !isAlphaNumByte(text[endPos])
if leftBoundary && rightBoundary {
return true
}
start = pos + 1
}
}
func isAlphaNumByte(c byte) bool {
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_'
}
// 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 {
+6
View File
@@ -92,6 +92,12 @@ func TestShouldReconnectClassification(t *testing.T) {
disconnects := []string{
"unexpected EOF", "connection reset by peer", "broken pipe",
"i/o timeout", "server closed the connection", "connection refused",
// Windows socket aborts also remain reconnectable at connect time.
"wsarecv: An established connection was aborted by the software in your host machine",
"read: connection was aborted",
// Unix/macOS ECONNABORTED text.
"read: software caused connection abort",
"An existing connection was forcibly closed by the remote host",
}
for _, m := range disconnects {
if !shouldReconnect(ctx, errors.New(m)) {
+198 -1
View File
@@ -10,6 +10,24 @@ import (
"github.com/Gitlawb/zero/internal/zeroruntime"
)
func TestNonVisionImageNoticeQualifiesBudgetRecovery(t *testing.T) {
result := ToolResult{
Name: "mcp_screenshot",
Output: "One image exceeded this result's remaining image budget. Retrying with fewer images can recover this payload.",
Images: []zeroruntime.ImageBlock{{MediaType: "image/png", Data: []byte("image")}},
}
notice, ok := toolResultImageMessage(result, Options{
SupportsVision: func(string) bool { return false },
})
if !ok || len(notice.Images) != 0 {
t.Fatal("non-vision delivery must produce a text-only notice")
}
if !strings.Contains(notice.Content, "Switch to a vision-capable model before retrying") ||
!strings.Contains(notice.Content, "budget") {
t.Fatalf("notice does not qualify budget recovery: %s", notice.Content)
}
}
// imageTool returns a tool result carrying an image, the way a screenshot tool
// should be able to.
type imageTool struct {
@@ -62,6 +80,7 @@ func TestRunDeliversToolResultImagesToTheModel(t *testing.T) {
result, err := Run(context.Background(), "screenshot please", provider, Options{
Registry: registry,
MaxTurns: 2,
Model: "gpt-4o",
})
if err != nil {
t.Fatalf("Run: %v", err)
@@ -86,11 +105,16 @@ func TestRunDeliversToolResultImagesToTheModel(t *testing.T) {
// The tool-result pairing must be untouched: one tool result per tool call.
toolResults := 0
var toolText string
for _, message := range result.Messages {
if message.Role == zeroruntime.MessageRoleTool {
toolResults++
toolText = message.Content
}
}
if toolText != "Captured a screenshot." {
t.Fatalf("tool text = %q, want preserved", toolText)
}
if toolResults != 1 {
t.Errorf("got %d tool-result messages for 1 tool call", toolResults)
}
@@ -136,7 +160,7 @@ func TestRunKeepsToolResultsContiguousWhenAToolReturnsAnImage(t *testing.T) {
{{Type: zeroruntime.StreamEventText, Content: "done"}, {Type: zeroruntime.StreamEventDone}},
}}
result, err := Run(context.Background(), "two calls", provider, Options{Registry: registry, MaxTurns: 2})
result, err := Run(context.Background(), "two calls", provider, Options{Registry: registry, MaxTurns: 2, Model: "gpt-4o"})
if err != nil {
t.Fatalf("Run: %v", err)
}
@@ -205,3 +229,176 @@ func messageShape(messages []zeroruntime.Message) string {
}
return "[" + strings.Join(parts, " ") + "]"
}
func TestRunDropsToolResultImagesForANonVisionModel(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(imageTool{media: "image/png", data: []byte("\x89PNG\r\n\x1a\nfake")})
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call_1", ToolName: "capture"},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call_1", ArgumentsFragment: `{}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call_1"},
{Type: zeroruntime.StreamEventDone},
},
{{Type: zeroruntime.StreamEventText, Content: "ok"}, {Type: zeroruntime.StreamEventDone}},
}}
result, err := Run(context.Background(), "screenshot please", provider, Options{
Registry: registry,
MaxTurns: 2,
Model: "totally-made-up-model",
})
if err != nil {
t.Fatalf("Run: %v", err)
}
var toolText string
var noticed bool
for _, message := range result.Messages {
if message.Role == zeroruntime.MessageRoleTool {
toolText = message.Content
}
if len(message.Images) > 0 {
t.Fatalf("non-vision model received image bytes on %q: %s", message.Role, messageShape(result.Messages))
}
if strings.Contains(message.Content, "does not support image input") {
noticed = true
}
}
if toolText != "Captured a screenshot." {
t.Fatalf("tool text = %q, want preserved on the non-vision path", toolText)
}
if !noticed {
t.Fatalf("non-vision model was not told the image was dropped; recorded %s", messageShape(result.Messages))
}
}
type switchAndCaptureTool struct {
targetModel string
}
func (switchAndCaptureTool) Name() string { return "switch_and_capture" }
func (switchAndCaptureTool) Description() string { return "Switches model and captures image" }
func (switchAndCaptureTool) Parameters() tools.Schema {
return tools.Schema{Type: "object", Properties: map[string]tools.PropertySchema{}}
}
func (switchAndCaptureTool) Safety() tools.Safety {
return tools.Safety{Permission: tools.PermissionAllow}
}
func (t switchAndCaptureTool) Run(context.Context, map[string]any) tools.Result {
return tools.Result{
Status: tools.StatusOK,
Output: "[image returned by tool]",
Images: []zeroruntime.ImageBlock{{MediaType: "image/png", Data: []byte("\x89PNG\r\n\x1a\nfake")}},
Meta: map[string]string{"escalate_to_model": t.targetModel},
}
}
func TestRunToolImagesRespectsModelSwitch(t *testing.T) {
t.Run("Non-vision to vision model escalation forwards images", func(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(switchAndCaptureTool{targetModel: "gpt-4o"})
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call_1", ToolName: "switch_and_capture"},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call_1", ArgumentsFragment: `{}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call_1"},
{Type: zeroruntime.StreamEventDone},
},
{
{Type: zeroruntime.StreamEventText, Content: "I see it on gpt-4o."},
{Type: zeroruntime.StreamEventDone},
},
}}
switched := false
switcher := func(_ context.Context, modelID string) (Provider, error) {
switched = true
return provider, nil
}
result, err := Run(context.Background(), "test", provider, Options{
Registry: registry,
MaxTurns: 2,
Model: "non-vision-initial",
ModelSwitcher: switcher,
SupportsVision: func(modelID string) bool {
return modelID == "gpt-4o"
},
})
if err != nil {
t.Fatalf("Run: %v", err)
}
if !switched {
t.Fatal("expected model switch to occur")
}
var carrier *zeroruntime.Message
for index := range result.Messages {
if len(result.Messages[index].Images) > 0 {
carrier = &result.Messages[index]
}
if strings.Contains(result.Messages[index].Content, "does not support image input") {
t.Fatalf("images were unexpectedly dropped after switch to vision model: %v", result.Messages[index])
}
}
if carrier == nil {
t.Fatal("expected image carrier message after escalation to vision model")
}
})
t.Run("Vision to non-vision model switch drops images with notice", func(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(switchAndCaptureTool{targetModel: "text-only-dest"})
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call_1", ToolName: "switch_and_capture"},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call_1", ArgumentsFragment: `{}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call_1"},
{Type: zeroruntime.StreamEventDone},
},
{
{Type: zeroruntime.StreamEventText, Content: "ok."},
{Type: zeroruntime.StreamEventDone},
},
}}
switcher := func(_ context.Context, modelID string) (Provider, error) {
return provider, nil
}
result, err := Run(context.Background(), "test", provider, Options{
Registry: registry,
MaxTurns: 2,
Model: "gpt-4o",
ModelSwitcher: switcher,
SupportsVision: func(modelID string) bool {
return modelID == "gpt-4o"
},
})
if err != nil {
t.Fatalf("Run: %v", err)
}
var noticed bool
var toolOutputSeen bool
for _, m := range result.Messages {
if len(m.Images) > 0 {
t.Fatalf("image delivered to non-vision destination model: %v", m)
}
if strings.Contains(m.Content, "does not support image input") {
noticed = true
}
if strings.Contains(m.Content, "[image returned by tool]") {
toolOutputSeen = true
}
}
if !noticed {
t.Fatal("expected drop notice when switching to non-vision model")
}
if !toolOutputSeen {
t.Fatal("expected tool output preserved in message history")
}
})
}
+6
View File
@@ -84,6 +84,7 @@ type ToolResult struct {
Images []zeroruntime.ImageBlock
Redacted bool
ChangedFiles []string
FileDiffs []tools.FileDiff
// ChangeSummaries are non-selectable generated-tree summaries emitted by
// command execution; callers must not schedule per-file work from them.
ChangeSummaries []execution.Change
@@ -340,6 +341,11 @@ type Options struct {
// nil for text-only runs (the seeded message then carries no images, exactly
// as before).
Images []zeroruntime.ImageBlock
// SupportsVision, when set, reports whether the effective model accepts
// image input. Tool-produced images are dropped at the shared delivery
// boundary when this is false. nil uses the curated catalog plus the
// name heuristic via modelregistry.SupportsVision.
SupportsVision func(modelID string) bool
// ContextWindow is the model's maximum input token budget. When > 0 the agent
// loop compacts long conversations once the estimated size crosses a fraction
// of this window. 0 DISABLES compaction entirely (every existing caller/test
+26 -2
View File
@@ -3,7 +3,9 @@
package background
import (
"errors"
"os/exec"
"syscall"
"time"
"github.com/Gitlawb/zero/internal/execution"
@@ -45,6 +47,16 @@ func terminateProcess(pid int) error {
// rediscovery after an owned leader exits. Ordinary commands fall back to the
// safe PID/tree path rather than assuming their PID is also a process-group ID.
//
// The bool result is whether the leader had already exited before termination
// was attempted — the POSIX analogue of Windows GetExitCodeProcess returning a
// value other than STILL_ACTIVE. A waitable zombie is already-exited: Wait can
// collect it, but kill(0) still succeeds, and when ps is unavailable
// signalTargetRunning conservatively treats that as "still running".
// TerminateCommand uses the flag to discard the resulting spurious
// SIGKILL-timeout once the reap has independently succeeded. The flag is about
// the leader only; group/tree termination is still invoked so live descendants
// are signalled.
//
// The Setpgid && Pgid == 0 guard only recognizes ConfigureChildProcessGroup's
// own convention. A command made its own session leader via Setsid (as opposed
// to Setpgid) is also its own process-group leader in practice, but takes the
@@ -52,8 +64,20 @@ func terminateProcess(pid int) error {
// because TerminateCommand has exactly one caller in this codebase; worth
// covering explicitly if Setsid-configured commands start using this path too.
func terminateOwnedProcess(cmd *exec.Cmd) (bool, error) {
alreadyExited := leaderWaitableExited(cmd.Process.Pid)
if cmd.SysProcAttr != nil && cmd.SysProcAttr.Setpgid && cmd.SysProcAttr.Pgid == 0 {
return false, execution.TerminateProcessGroup(cmd.Process.Pid, terminationGracePeriod, terminationPollInterval)
return alreadyExited, execution.TerminateProcessGroup(cmd.Process.Pid, terminationGracePeriod, terminationPollInterval)
}
return false, terminateProcess(cmd.Process.Pid)
return alreadyExited, terminateProcess(cmd.Process.Pid)
}
// terminationTargetGoneAfterReap independently checks the exact signal target
// used for an owned command. In particular, reaping a zombie group leader does
// not make this return true while any descendant still occupies that group.
func terminationTargetGoneAfterReap(cmd *exec.Cmd) bool {
target := cmd.Process.Pid
if cmd.SysProcAttr != nil && cmd.SysProcAttr.Setpgid && cmd.SysProcAttr.Pgid == 0 {
target = -target
}
return errors.Is(syscall.Kill(target, syscall.Signal(0)), syscall.ESRCH)
}
+104
View File
@@ -6,6 +6,7 @@ import (
"bufio"
"errors"
"os/exec"
"runtime"
"strconv"
"strings"
"syscall"
@@ -220,6 +221,109 @@ func TestTerminateCommandKillsChildAfterLeaderExits(t *testing.T) {
}
}
func TestTerminateCommandZombieLeaderWithoutPS(t *testing.T) {
if runtime.GOOS != "linux" && runtime.GOOS != "darwin" {
t.Skip("waitable-exited leader probe is implemented on linux and darwin")
}
grace, poll := terminationGracePeriod, terminationPollInterval
terminationGracePeriod, terminationPollInterval = 150*time.Millisecond, 10*time.Millisecond
t.Cleanup(func() { terminationGracePeriod, terminationPollInterval = grace, poll })
// Issue #862: a zombie group leader with ps unresolvable used to burn both
// grace periods (kill(0) succeeds against a zombie) and return
// "did not exit after SIGKILL" even though cmd.Wait reaped exit status 0.
// The already-exited probe must answer without ps so TerminateCommand can
// discard that spurious timeout after a successful reap.
cmd := exec.Command("sh", "-c", "exit 0")
ConfigureChildProcessGroup(cmd)
if err := cmd.Start(); err != nil {
t.Fatalf("start: %v", err)
}
pid := cmd.Process.Pid
t.Cleanup(func() {
if cmd.ProcessState == nil {
_ = cmd.Process.Kill()
_ = cmd.Wait()
}
})
deadline := time.Now().Add(5 * time.Second)
for !leaderWaitableExited(pid) {
if time.Now().After(deadline) {
t.Fatalf("leader %d did not become waitable-exited within 5s", pid)
}
time.Sleep(10 * time.Millisecond)
}
t.Setenv("PATH", "")
if err := TerminateCommand(cmd); err != nil {
t.Fatalf("TerminateCommand: %v, want nil (zombie leader with ps unavailable must not report SIGKILL timeout)", err)
}
if cmd.ProcessState == nil {
t.Fatal("zombie leader was not reaped")
}
}
func TestTerminateCommandPreservesGroupFailureWithExitedLeaderAndLiveChild(t *testing.T) {
if runtime.GOOS != "linux" && runtime.GOOS != "darwin" {
t.Skip("waitable-exited leader probe is implemented on linux and darwin")
}
cmd := exec.Command("sh", "-c", "sleep 300 & echo $!; exit 0")
ConfigureChildProcessGroup(cmd)
stdout, err := cmd.StdoutPipe()
if err != nil {
t.Fatalf("stdout pipe: %v", err)
}
if err := cmd.Start(); err != nil {
t.Fatalf("start: %v", err)
}
line, err := bufio.NewReader(stdout).ReadString('\n')
if err != nil {
t.Fatalf("read forked child pid: %v", err)
}
childPID, err := strconv.Atoi(strings.TrimSpace(line))
if err != nil {
t.Fatalf("parse forked child pid %q: %v", line, err)
}
t.Cleanup(func() {
_ = syscall.Kill(childPID, syscall.SIGKILL)
if cmd.ProcessState == nil {
_ = cmd.Wait()
}
})
deadline := time.Now().Add(5 * time.Second)
for !leaderWaitableExited(cmd.Process.Pid) {
if time.Now().After(deadline) {
t.Fatalf("leader %d did not become waitable-exited within 5s", cmd.Process.Pid)
}
time.Sleep(10 * time.Millisecond)
}
treeErr := errors.New("process group could not be terminated")
originalTerminateOwnedProcess := terminateOwnedProcessForTest
terminateOwnedProcessForTest = func(got *exec.Cmd) (bool, error) {
if got != cmd {
t.Fatalf("terminate called with %p, want %p", got, cmd)
}
return true, treeErr
}
t.Cleanup(func() { terminateOwnedProcessForTest = originalTerminateOwnedProcess })
err = TerminateCommand(cmd)
if !errors.Is(err, treeErr) {
t.Fatalf("TerminateCommand error = %v, want live-group termination failure", err)
}
if cmd.ProcessState == nil {
t.Fatal("exited leader was not reaped")
}
if processStopped(childPID) {
t.Fatalf("child %d unexpectedly stopped; failure boundary needs a live group member", childPID)
}
}
func processStopped(pid int) bool {
if errors.Is(syscall.Kill(pid, syscall.Signal(0)), syscall.ESRCH) {
return true
@@ -0,0 +1,28 @@
//go:build darwin
package background
import "golang.org/x/sys/unix"
// szomb is sys/proc.h SZOMB: the process has exited and is waiting to be reaped.
const szomb int8 = 5
// leaderWaitableExited reports whether pid has already exited and is waiting
// to be reaped. This is the POSIX analogue of Windows GetExitCodeProcess
// returning a value other than STILL_ACTIVE: the leader is a zombie, waitable
// as exited, without collecting it. A true result does not mean the process
// group is empty — descendants may still be running.
//
// Darwin has no /proc and x/sys/unix does not wrap waitid here, so the probe
// is kern.proc.pid via sysctl. It does not use ps, which is the #862 failure
// mode when PATH cannot resolve ps.
func leaderWaitableExited(pid int) bool {
if pid <= 1 {
return false
}
kinfo, err := unix.SysctlKinfoProc("kern.proc.pid", pid)
if err != nil {
return false
}
return kinfo.Proc.P_stat == szomb
}
@@ -0,0 +1,68 @@
//go:build linux
package background
import (
"bytes"
"os"
"strconv"
"golang.org/x/sys/unix"
)
// POSIX waitid si_code values for an already-exited child (bits/waitflags.h).
const (
cldExited int32 = 1
cldKilled int32 = 2
cldDumped int32 = 3
)
// leaderWaitableExited reports whether pid has already exited and is waiting
// to be reaped. This is the POSIX analogue of Windows GetExitCodeProcess
// returning a value other than STILL_ACTIVE: the leader is waitable as exited
// (typically a zombie) without collecting it. A true result does not mean the
// process group is empty — descendants may still be running.
//
// Detection does not use ps. waitid(WNOHANG|WNOWAIT) asks the kernel whether
// an exit status is already available. /proc/<pid>/stat state Z is the
// fallback when waitid cannot answer (for example if procfs is the only
// usable source). Either path is independent of PATH, which is the #862
// failure mode: empty PATH makes ps unresolvable and kill(0) still succeeds
// against a zombie.
func leaderWaitableExited(pid int) bool {
if pid <= 1 {
return false
}
if waitidAlreadyExited(pid) {
return true
}
return procIsZombie(pid)
}
func waitidAlreadyExited(pid int) bool {
var info unix.Siginfo
err := unix.Waitid(unix.P_PID, pid, &info, unix.WEXITED|unix.WNOHANG|unix.WNOWAIT, nil)
if err != nil {
return false
}
switch info.Code {
case cldExited, cldKilled, cldDumped:
return true
}
return false
}
func procIsZombie(pid int) bool {
data, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/stat")
if err != nil {
return false
}
// /proc/<pid>/stat: pid (comm) state ... — comm may contain spaces or
// parentheses, so the state is the first field after the last ')'.
i := bytes.LastIndexByte(data, ')')
if i < 0 || i+1 >= len(data) {
return false
}
rest := bytes.TrimSpace(data[i+1:])
return len(rest) > 0 && rest[0] == 'Z'
}
@@ -0,0 +1,10 @@
//go:build !windows && !linux && !darwin
package background
// leaderWaitableExited cannot positively identify a waitable-exited leader on
// this platform without ps. Returning false keeps TerminateCommand's existing
// conservative behaviour: a termination error is not discarded after reap.
func leaderWaitableExited(pid int) bool {
return false
}
+13 -1
View File
@@ -4,6 +4,7 @@ package background
import (
"fmt"
"os"
"os/exec"
"github.com/Gitlawb/zero/internal/execution"
@@ -46,7 +47,18 @@ func terminateOwnedProcess(cmd *exec.Cmd) (bool, error) {
terminateErr = terminateProcessForTest(cmd.Process.Pid)
})
if err != nil {
return false, fmt.Errorf("pin process %d for termination: %w", cmd.Process.Pid, err)
// WithHandle refuses only a process that has already been waited or
// released, so it has finished. Say so in the form exec.Cmd understands:
// a Cancel error wrapping os.ErrProcessDone is "nothing to cancel", where
// the unexported "already released" error it would otherwise carry made
// Wait report a spurious cancel failure. A dead root has no tree left to
// find either way.
return true, fmt.Errorf("pin process %d for termination: %w: %w", cmd.Process.Pid, os.ErrProcessDone, err)
}
return alreadyExited, terminateErr
}
// Windows has no persistent process-group identity to query after a dead root
// is reaped. Preserve TerminateCommand's existing, documented dead-root
// behavior; the stronger independent target check is available on POSIX.
func terminationTargetGoneAfterReap(*exec.Cmd) bool { return true }
+46 -11
View File
@@ -9,6 +9,10 @@ import (
const commandReapTimeout = 3 * time.Second
// terminateOwnedProcessForTest is a seam for the failure-boundary tests. Tests
// replace it only while no other test in this package is running in parallel.
var terminateOwnedProcessForTest = terminateOwnedProcess
// TerminateProcess stops a background process by PID — on Windows its process
// tree; on POSIX its whole process group when the PID leads its own group (the
// invariant ConfigureChildProcessGroup establishes for processes started through
@@ -26,11 +30,15 @@ func TerminateProcess(pid int) error {
// have exclusive ownership of cmd: it must not have previously called Wait or
// Process.Release, and no goroutine may call either concurrently. On POSIX it
// stops the whole group when the command was configured as its leader; ordinary
// commands safely fall back to PID/tree discovery. On Windows, success for a
// leader that was already dead confirms only that the leader was reaped;
// descendants may survive because Windows cannot rediscover a tree from a dead
// root. `zero daemon start` needs this operation when readiness times out: it
// launched the child, so it must both stop the tree and collect the leader.
// commands safely fall back to PID/tree discovery. When POSIX observes a
// waitable-zombie leader before signalling, a termination error is discarded
// after reap only if the launch-time signal target is independently gone; the
// leader observation alone says nothing about descendants. Windows retains its
// existing behavior of ignoring the expected kill-attempt error when
// GetExitCodeProcess shows that the root was already dead. Windows cannot
// rediscover a tree from a dead root, so descendants may survive there. `zero
// daemon start` needs this operation when readiness times out: it launched the
// child, so it must both stop the tree and collect the leader.
//
// The order matters: the tree is signalled first, because Wait releases the
// leader's PID and a later group lookup could then resolve to nothing (or, worse,
@@ -44,7 +52,7 @@ func TerminateCommand(cmd *exec.Cmd) error {
if cmd == nil || cmd.Process == nil {
return errors.New("terminate command: process was never started")
}
leaderAlreadyExited, terminateErr := terminateOwnedProcess(cmd)
leaderAlreadyExited, terminateErr := terminateOwnedProcessForTest(cmd)
reapErr := waitForTerminatedCommandWithin(cmd, commandReapTimeout)
if reapErr != nil {
if terminateErr != nil {
@@ -52,15 +60,42 @@ func TerminateCommand(cmd *exec.Cmd) error {
}
return reapErr
}
if terminateErr != nil && !leaderAlreadyExited {
return terminateErr
if terminateErr != nil {
if !leaderAlreadyExited || !terminationTargetGoneAfterReap(cmd) {
return terminateErr
}
}
// Only discard a termination error when the platform demonstrated before
// attempting termination that the leader had already exited. A successful
// reap alone says nothing about whether a live descendant tree was stopped.
// On POSIX, discard a termination error only when the leader was already
// exited and the launch-time signal target is independently gone after reap.
// A successful leader reap alone says nothing about live descendants.
// Windows retains its existing dead-root semantics because it has no
// persistent group identity to query after the root exits.
return nil
}
// TerminateCommandGroup stops a started command's process group on POSIX, or its
// process tree on Windows, WITHOUT reaping it.
//
// It is TerminateCommand without the Wait, for a caller whose Wait already runs
// somewhere else: a worker pool waiting on the command in its own goroutine, or
// the command's own Cancel hook, which exec.Cmd invokes while its Wait is in
// progress. TerminateCommand would be a second Wait there. Both used to pass
// cmd.Process.Pid to TerminateProcess, which rediscovers the group at signal time
// with Getpgid, and once the leader has exited that lookup fails, so only the dead
// leader was signalled and its descendants kept running. When cmd was configured
// by ConfigureChildProcessGroup the group now comes from that launch-time
// configuration instead. On Windows it is the tree kill TerminateProcess performs,
// with the process handle pinned while it runs so the PID cannot be recycled
// underneath it; once the command has been waited that pin fails with an error
// wrapping os.ErrProcessDone.
func TerminateCommandGroup(cmd *exec.Cmd) error {
if cmd == nil || cmd.Process == nil {
return nil
}
_, err := terminateOwnedProcess(cmd)
return err
}
// classifyWaitError treats a non-zero exit status as success: a process being
// terminated is expected to report one (or a signal), so the only interesting
// failure is Wait itself not working.
@@ -0,0 +1,71 @@
package background
import (
"errors"
"os"
"os/exec"
"runtime"
"testing"
"time"
)
// TerminateCommandGroup LEAVES THE WAIT TO ITS CALLER. Its callers already have a
// Wait running or about to run: the daemon pool waits on its workers, and
// exec.Cmd calls the Cancel hook from inside Wait. Reaping here, the way
// TerminateCommand does, would make their Wait fail with "Wait was already
// called" instead of reporting how the command ended.
func TestTerminateCommandGroupLeavesTheWaitToTheCaller(t *testing.T) {
cmd := exec.Command("sleep", "30")
if runtime.GOOS == "windows" {
cmd = exec.Command("powershell.exe", "-NoProfile", "-NonInteractive", "-Command", "Start-Sleep -Seconds 30")
}
ConfigureChildProcessGroup(cmd)
if err := cmd.Start(); err != nil {
t.Fatalf("start: %v", err)
}
if err := TerminateCommandGroup(cmd); err != nil {
_ = cmd.Process.Kill()
_ = cmd.Wait()
t.Fatalf("TerminateCommandGroup: %v", err)
}
waited := make(chan error, 1)
go func() { waited <- cmd.Wait() }()
select {
case err := <-waited:
var exitErr *exec.ExitError
if err != nil && !errors.As(err, &exitErr) {
t.Fatalf("the caller's own Wait failed after TerminateCommandGroup: %v", err)
}
case <-time.After(10 * time.Second):
_ = cmd.Process.Kill()
t.Fatal("the command was still running 10s after TerminateCommandGroup")
}
}
// A command that was never started, or a nil one, is nothing to stop.
func TestTerminateCommandGroupWithNoProcessIsANoOp(t *testing.T) {
if err := TerminateCommandGroup(nil); err != nil {
t.Fatalf("nil command: %v", err)
}
if err := TerminateCommandGroup(exec.Command("sleep", "1")); err != nil {
t.Fatalf("unstarted command: %v", err)
}
}
// After the caller has waited, Windows cannot pin the process any more. The error
// must still read as "already finished", which is what exec.Cmd's Cancel contract
// and the pool both treat as nothing wrong.
func TestTerminateCommandGroupAfterWaitReportsProcessDone(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("POSIX signals the group, which is simply gone after the wait")
}
cmd := exec.Command("cmd.exe", "/c", "exit", "0")
ConfigureChildProcessGroup(cmd)
if err := cmd.Run(); err != nil {
t.Fatalf("run: %v", err)
}
if err := TerminateCommandGroup(cmd); err != nil && !errors.Is(err, os.ErrProcessDone) {
t.Fatalf("after Wait the error is %v, want nil or one wrapping os.ErrProcessDone", err)
}
}
+75 -15
View File
@@ -10,9 +10,11 @@ import (
"github.com/Gitlawb/zero/internal/acp"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/execution"
"github.com/Gitlawb/zero/internal/mcp"
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
"github.com/Gitlawb/zero/internal/redaction"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/tools"
)
const acpUsage = `zero acp — serve the Agent Client Protocol (ACP) over stdio
@@ -57,20 +59,8 @@ func runACP(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int
// surface — no ACP-specific credential handling needed.
NewProvider: deps.newProvider,
RunAgent: agent.Run,
// Build the SCOPED registry + sandbox engine per workspace, exactly like the
// exec surface, so ACP shell/file tools are confined — never run unconfined.
BuildWorkspace: func(workspaceRoot string, resolved config.ResolvedConfig) (*tools.Registry, *sandbox.Engine, error) {
scope, err := sandbox.NewScope(workspaceRoot, resolved.Sandbox.AdditionalWriteRoots)
if err != nil {
return nil, nil, err
}
engine, err := buildExecSandboxEngine(workspaceRoot, resolved, deps, scope)
if err != nil {
return nil, nil, err
}
registry := newCoreRegistryScoped(workspaceRoot, scope)
registerLocalControlTools(registry, workspaceRoot, resolved.LocalControl)
return registry, engine, nil
BuildWorkspace: func(ctx context.Context, workspaceRoot string, resolved config.ResolvedConfig, mode agent.PermissionMode) (*acp.Workspace, error) {
return buildACPWorkspace(ctx, workspaceRoot, resolved, mode, deps)
},
ResolveWorkspaceRoot: acpWorkspaceRootResolver(deps),
Store: deps.newSessionStore(),
@@ -85,6 +75,76 @@ func runACP(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int
return exitSuccess
}
// buildACPWorkspace matches exec's registry construction for one ACP turn. MCP
// servers use the same sandbox-prepared execution runner, project configuration is
// gated by the validated workspace's trust state, and their runtime is released
// when acp.Agent completes the turn. ACP deliberately stays at low autonomy: an
// editor connection never upgrades MCP permissions beyond an interactive prompt.
func buildACPWorkspace(ctx context.Context, workspaceRoot string, resolved config.ResolvedConfig, mode agent.PermissionMode, deps appDeps) (*acp.Workspace, error) {
scope, err := sandbox.NewScope(workspaceRoot, resolved.Sandbox.AdditionalWriteRoots)
if err != nil {
return nil, err
}
engine, err := buildExecSandboxEngine(workspaceRoot, resolved, deps, scope)
if err != nil {
return nil, err
}
registry := newCoreRegistryScoped(workspaceRoot, scope)
workspace := &acp.Workspace{
Registry: registry, Sandbox: engine,
DeferThreshold: resolved.Tools.DeferThreshold,
}
if mode != agent.PermissionModePlan {
// MCP stdio servers are subprocesses. Passing the engine to their runner
// keeps them inside the exact sandbox / lifecycle path used by zero exec.
runtime, trustSkip, err := registerMCPToolsForWorkspaceWithOptions(
ctx, workspaceRoot, registry, deps, mcp.AutonomyLow, workspaceRoot,
mcp.RegisterOptions{Execution: execution.NewRunner(engine), AdvertiseInAuto: true},
)
if err != nil {
// RegisterTools may have connected an earlier server before reporting a
// later failure, so do not orphan a partial runtime on this error path.
if runtime != nil {
_ = runtime.Close()
}
return nil, err
}
workspace.Cleanup = runtime.Close
workspace.Notices = acpMCPSetupNotices(trustSkip, runtime)
}
registerLocalControlTools(registry, workspaceRoot, resolved.LocalControl)
// MCP tools are deferred-eligible. Register their loader only after every
// ACP-visible tool is present, using the same mode the agent receives.
registerToolSearchIfEligible(registry, resolved.Tools.DeferThreshold, mode, nil, nil)
return workspace, nil
}
func acpMCPSetupNotices(skip trustSkip, runtime mcpToolRuntime) []string {
var notices []string
if skip.excludedProjectConfig {
if skip.trustCheckErrored {
notices = append(notices, "The workspace-trust store could not be read; project MCP servers were ignored (fail-closed). Run 'zero trust' to enable them.")
} else {
notices = append(notices, "Project MCP servers were ignored in this untrusted workspace. Run 'zero trust' to enable them.")
}
}
if runtime == nil {
return notices
}
for _, skipped := range runtime.Skipped() {
if skipped.UnconfiguredDefault {
continue
}
message := fmt.Sprintf("MCP server %s unavailable, skipped", skipped.Name)
if skipped.Err != nil {
message += ": " + redaction.ErrorMessage(skipped.Err, redaction.Options{})
}
notices = append(notices, redaction.RedactString(message, redaction.Options{}))
}
return notices
}
// acpWorkspaceRootResolver validates a client-supplied cwd into a confinement
// root. It reuses exec's resolveWorkspaceRoot (abs+clean, must be an existing
// dir) and additionally rejects the filesystem root and the home directory — an
+201
View File
@@ -5,10 +5,19 @@ import (
"context"
"errors"
"io"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/mcp"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/workspacetrust"
)
type acpTestReader func([]byte) (int, error)
@@ -108,6 +117,198 @@ func TestRunACPIdleCancellationExitsCleanly(t *testing.T) {
}
}
func TestBuildACPWorkspaceRegistersSandboxedMCPToolsAndClosesThem(t *testing.T) {
setTrustConfigRoot(t)
workspaceRoot := t.TempDir()
if err := workspacetrust.Trust(workspaceRoot); err != nil {
t.Fatalf("trust workspace: %v", err)
}
grantStore, err := sandbox.NewGrantStore(sandbox.StoreOptions{FilePath: t.TempDir() + "/grants.json"})
if err != nil {
t.Fatalf("new grant store: %v", err)
}
var gotExclude, registered, closed bool
deps := fillAppDeps(appDeps{
resolveMCPConfig: func(root string, excludeProject bool) (config.MCPConfig, error) {
if root != workspaceRoot {
t.Fatalf("MCP workspace root = %q, want %q", root, workspaceRoot)
}
gotExclude = excludeProject
return config.MCPConfig{Servers: map[string]config.MCPServerConfig{
"docs": {Type: "stdio", Command: "fake-docs"},
}}, nil
},
newMCPStore: func() (*mcp.PermissionStore, error) { return nil, nil },
newSandboxStore: func() (*sandbox.GrantStore, error) { return grantStore, nil },
registerMCPTools: func(ctx context.Context, registry *tools.Registry, cfg config.MCPConfig, options mcp.RegisterOptions) (mcpToolRuntime, error) {
if ctx == nil {
t.Fatal("MCP registration received nil context")
}
if len(cfg.Servers) != 1 || cfg.Servers["docs"].Command != "fake-docs" {
t.Fatalf("MCP config = %+v", cfg)
}
if options.Autonomy != mcp.AutonomyLow {
t.Fatalf("MCP autonomy = %q, want low", options.Autonomy)
}
if options.Execution == nil {
t.Fatal("MCP stdio server was not given the sandbox execution runner")
}
if options.WorkspaceRoot != workspaceRoot {
t.Fatalf("MCP execution workspace = %q, want %q", options.WorkspaceRoot, workspaceRoot)
}
if !options.AdvertiseInAuto {
t.Fatal("ACP MCP tools must be advertised in auto mode without being pre-approved")
}
registered = true
registry.Register(cliFakeDeferredTool{
name: "mcp_docs_search", permission: tools.PermissionPrompt,
advertiseInAuto: options.AdvertiseInAuto,
})
return closeFunc(func() error {
closed = true
return nil
}), nil
},
})
workspace, err := buildACPWorkspace(context.Background(), workspaceRoot, config.ResolvedConfig{
Tools: config.ToolsConfig{DeferThreshold: 1},
}, agent.PermissionModeAuto, deps)
if err != nil {
t.Fatalf("buildACPWorkspace: %v", err)
}
if gotExclude {
t.Fatal("trusted ACP workspace excluded project MCP configuration")
}
if !registered {
t.Fatal("ACP workspace did not register configured MCP tools")
}
if _, ok := workspace.Registry.Get("mcp_docs_search"); !ok {
t.Fatal("MCP tool is absent from ACP agent registry")
}
if _, ok := workspace.Registry.Get(tools.ToolSearchToolName); !ok {
t.Fatal("ACP registry is missing tool_search for deferred MCP tools")
}
if workspace.DeferThreshold != 1 {
t.Fatalf("workspace defer threshold = %d, want 1", workspace.DeferThreshold)
}
if err := workspace.Close(); err != nil {
t.Fatalf("close ACP workspace: %v", err)
}
if !closed {
t.Fatal("ACP workspace did not close its MCP runtime")
}
}
func TestBuildACPWorkspacePreservesRedactedMCPSetupNotices(t *testing.T) {
setTrustConfigRoot(t)
workspaceRoot := t.TempDir()
if err := os.MkdirAll(filepath.Join(workspaceRoot, ".zero"), 0o755); err != nil {
t.Fatal(err)
}
projectConfig := `{"mcp":{"servers":{"project-docs":{"type":"stdio","command":"project-docs"}}}}`
if err := os.WriteFile(filepath.Join(workspaceRoot, ".zero", "config.json"), []byte(projectConfig), 0o600); err != nil {
t.Fatal(err)
}
secret := "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
grantStore, err := sandbox.NewGrantStore(sandbox.StoreOptions{FilePath: filepath.Join(t.TempDir(), "grants.json")})
if err != nil {
t.Fatal(err)
}
deps := fillAppDeps(appDeps{
resolveMCPConfig: func(string, bool) (config.MCPConfig, error) {
return config.MCPConfig{Servers: map[string]config.MCPServerConfig{
"docs": {Type: "stdio", Command: "fake-docs"},
}}, nil
},
newMCPStore: func() (*mcp.PermissionStore, error) { return nil, nil },
newSandboxStore: func() (*sandbox.GrantStore, error) { return grantStore, nil },
registerMCPTools: func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) {
return fakeMCPRuntimeWithSkips{skipped: []mcp.SkippedServer{{
Name: "docs", Err: errors.New("token=" + secret),
}}}, nil
},
})
workspace, err := buildACPWorkspace(context.Background(), workspaceRoot, config.ResolvedConfig{}, agent.PermissionModeAuto, deps)
if err != nil {
t.Fatal(err)
}
defer workspace.Close()
joined := strings.Join(workspace.Notices, "\n")
if !strings.Contains(joined, "untrusted workspace") || !strings.Contains(joined, "MCP server docs unavailable") {
t.Fatalf("workspace notices = %#v", workspace.Notices)
}
if strings.Contains(joined, secret) || !strings.Contains(joined, "[REDACTED]") {
t.Fatalf("workspace notices leaked secret: %q", joined)
}
}
func TestBuildACPWorkspaceSkipsMCPInPlanMode(t *testing.T) {
grantStore, err := sandbox.NewGrantStore(sandbox.StoreOptions{FilePath: t.TempDir() + "/grants.json"})
if err != nil {
t.Fatalf("new grant store: %v", err)
}
called := false
deps := fillAppDeps(appDeps{
resolveMCPConfig: func(string, bool) (config.MCPConfig, error) {
called = true
return config.MCPConfig{}, nil
},
newSandboxStore: func() (*sandbox.GrantStore, error) { return grantStore, nil },
registerMCPTools: func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) {
called = true
return nil, nil
},
})
workspace, err := buildACPWorkspace(context.Background(), t.TempDir(), config.ResolvedConfig{}, agent.PermissionModePlan, deps)
if err != nil {
t.Fatalf("buildACPWorkspace: %v", err)
}
if called {
t.Fatal("plan-mode ACP workspace must not resolve or start MCP servers")
}
if err := workspace.Close(); err != nil {
t.Fatalf("close plan workspace: %v", err)
}
}
func TestBuildACPWorkspaceClosesPartialMCPRuntimeOnRegistrationError(t *testing.T) {
grantStore, err := sandbox.NewGrantStore(sandbox.StoreOptions{FilePath: t.TempDir() + "/grants.json"})
if err != nil {
t.Fatalf("new grant store: %v", err)
}
closed := false
deps := fillAppDeps(appDeps{
resolveMCPConfig: func(string, bool) (config.MCPConfig, error) {
return config.MCPConfig{Servers: map[string]config.MCPServerConfig{
"partial": {Type: "stdio", Command: "fake-partial"},
}}, nil
},
newMCPStore: func() (*mcp.PermissionStore, error) { return nil, nil },
newSandboxStore: func() (*sandbox.GrantStore, error) { return grantStore, nil },
registerMCPTools: func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) {
return closeFunc(func() error {
closed = true
return nil
}), errors.New("second MCP server failed")
},
})
workspace, err := buildACPWorkspace(context.Background(), t.TempDir(), config.ResolvedConfig{}, agent.PermissionModeAuto, deps)
if err == nil {
t.Fatal("buildACPWorkspace succeeded after MCP registration error")
}
if workspace != nil {
t.Fatal("buildACPWorkspace returned a workspace after MCP registration error")
}
if !closed {
t.Fatal("partial MCP runtime was not closed after registration error")
}
}
type acpNotifyingReadCloser struct {
io.ReadCloser
readStarted chan<- struct{}
+157 -54
View File
@@ -311,40 +311,35 @@ func runWithDeps(args []string, stdout io.Writer, stderr io.Writer, deps appDeps
// cache file on the machine. The refresh itself is fired in exec/TUI startup.
modelregistry.EnableModelsDevOverlay()
addDirs, args, err := splitLeadingAddDirFlags(args)
// --add-dir, --theme and --allow-escalation may be written in any order;
// see splitLeadingRootFlags for why they are split together.
root, args, err := splitLeadingRootFlags(args)
if err != nil {
return writeAppError(stderr, err.Error(), 1)
}
// --theme <name> selects the TUI palette non-interactively (auto or any registered
// theme; populates tui.Options.Theme, which resolveThemeMode prefers over
// ZERO_THEME). Re-split --add-dir afterward so it may appear on either side of --theme.
theme, args, err := splitLeadingThemeFlag(args)
if err != nil {
return writeAppError(stderr, err.Error(), 1)
}
moreDirs, args, err := splitLeadingAddDirFlags(args)
if err != nil {
return writeAppError(stderr, err.Error(), 1)
}
addDirs = append(addDirs, moreDirs...)
addDirs, theme, allowEscalation := root.addDirs, root.theme, root.allowEscalation
if len(args) == 0 {
return runInteractiveTUI(stderr, deps, agent.PermissionModeAsk, addDirs, theme)
return runInteractiveTUI(stderr, deps, agent.PermissionModeAsk, addDirs, theme, allowEscalation)
}
// --add-dir grants an extra write root, and only the interactive TUI and
// exec dispatch paths consume one. Fail loud everywhere else rather than
// silently discarding an explicit grant — including help/version, which
// run no agent and could only ignore it. The allowlist names exactly the
// cases below that forward addDirs; a future subcommand is rejected by
// default until it opts in here.
if len(addDirs) > 0 {
switch args[0] {
case "--skip-permissions-unsafe", "-p", "--prompt", "exec":
// Forwarded by the matching case below.
default:
return writeAppError(stderr, "--add-dir is only supported for the interactive TUI and exec", 1)
}
// --add-dir grants an extra write root and --allow-escalation opts a run
// into mid-run model escalation; only the interactive TUI and exec consume
// either. Both are forwarded to exec below and rejected loudly everywhere
// else rather than silently discarded, including help/version, which run
// no agent and could only ignore them. The allowlist names exactly the
// cases below that forward; a future subcommand is rejected by default
// until it opts in here.
forwardsRootFlags := false
switch args[0] {
case "--skip-permissions-unsafe", "-p", "--prompt", "exec":
forwardsRootFlags = true
}
if len(addDirs) > 0 && !forwardsRootFlags {
return writeAppError(stderr, "--add-dir is only supported for the interactive TUI and exec", 1)
}
if allowEscalation && !forwardsRootFlags {
return writeAppError(stderr, "--allow-escalation is only supported for the interactive TUI and exec", 1)
}
switch args[0] {
@@ -354,26 +349,24 @@ func runWithDeps(args []string, stdout io.Writer, stderr io.Writer, deps appDeps
// reach unsafe mode in the shell — and the "!" shell escape (which is
// gated behind unsafe) was therefore unreachable.
//
// --add-dir may legally appear on either side of the flag, so re-split
// the remaining args and merge with the dirs already collected. Any
// trailing non-flag args were ignored on this path before --add-dir
// existed and still are — but an --add-dir hidden BEHIND one would be
// silently dropped with them, so reject that misplacement loudly.
moreDirs, rest, err := splitLeadingAddDirFlags(args[1:])
// The root flags may legally appear on either side of this flag, so
// split them again from what follows it and merge with what the root
// already took. Any trailing non-flag args were ignored on this path
// before --add-dir existed and still are, but an --add-dir hidden
// BEHIND one would be silently dropped with them, so reject that
// misplacement loudly below.
more, rest, err := splitLeadingRootFlags(args[1:])
if err != nil {
return writeAppError(stderr, err.Error(), 1)
}
// --theme may appear here too; extract it before the stray-arg checks so it is
// not rejected as an unexpected positional, then re-split --add-dir after it.
skipTheme, rest, err := splitLeadingThemeFlag(rest)
if err != nil {
return writeAppError(stderr, err.Error(), 1)
moreDirs := more.addDirs
// A --theme written before the flag was taken at the root and used to be
// dropped here; one written after it wins, as the last occurrence does.
skipTheme := theme
if more.theme != "" {
skipTheme = more.theme
}
evenMoreDirs, rest, err := splitLeadingAddDirFlags(rest)
if err != nil {
return writeAppError(stderr, err.Error(), 1)
}
moreDirs = append(moreDirs, evenMoreDirs...)
skipAllowEscalation := more.allowEscalation
// A misplaced --add-dir anywhere in the remainder is the more specific error,
// so check for it across all of rest before rejecting stray args.
for _, arg := range rest {
@@ -390,7 +383,7 @@ func runWithDeps(args []string, stdout io.Writer, stderr io.Writer, deps appDeps
return writeAppError(stderr, "--skip-permissions-unsafe launches the interactive TUI and takes no prompt or subcommand; for a one-shot unsafe run use `zero exec --skip-permissions-unsafe -p \"...\"`", 1)
}
}
return runInteractiveTUI(stderr, deps, agent.PermissionModeUnsafe, append(append([]string{}, addDirs...), moreDirs...), skipTheme)
return runInteractiveTUI(stderr, deps, agent.PermissionModeUnsafe, append(append([]string{}, addDirs...), moreDirs...), skipTheme, allowEscalation || skipAllowEscalation)
case "-h", "--help", "help":
if err := writeHelp(stdout); err != nil {
return 1
@@ -423,16 +416,16 @@ func runWithDeps(args []string, stdout io.Writer, stderr io.Writer, deps appDeps
if len(args) < 2 {
return writePromptRequired(stderr)
}
// Forward leading --add-dir occurrences so exec's own parser collects them.
// Forward the root flags exec consumes so its own parser collects them.
// Use the inline --prompt=<value> form so a prompt whose first character is a
// dash (e.g. `zero -p "-foo"`) is taken verbatim instead of being mistaken for
// a flag and rejected with "--prompt requires a value" (matches the cron path).
execArgs := append(addDirFlagArgs(addDirs), "--prompt="+args[1])
execArgs := append(rootFlagArgs(addDirs, allowEscalation), "--prompt="+args[1])
execArgs = append(execArgs, args[2:]...)
return runExec(execArgs, stdout, stderr, deps)
case "exec":
// Forward leading --add-dir occurrences so exec's own parser collects them.
return runExec(append(addDirFlagArgs(addDirs), args[1:]...), stdout, stderr, deps)
// Forward the root flags exec consumes so its own parser collects them.
return runExec(append(rootFlagArgs(addDirs, allowEscalation), args[1:]...), stdout, stderr, deps)
case "completions":
return runCompletions(args[1:], stdout, stderr)
case "daemon":
@@ -694,15 +687,15 @@ func fillAppDeps(deps appDeps) appDeps {
return deps
}
func runInteractiveTUI(stderr io.Writer, deps appDeps, permissionMode agent.PermissionMode, addDirs []string, theme string) int {
return runInteractiveTUIWithSetup(stderr, deps, permissionMode, addDirs, theme, false)
func runInteractiveTUI(stderr io.Writer, deps appDeps, permissionMode agent.PermissionMode, addDirs []string, theme string, allowEscalation bool) int {
return runInteractiveTUIWithSetup(stderr, deps, permissionMode, addDirs, theme, false, allowEscalation)
}
func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode agent.PermissionMode, addDirs []string, theme string, forceSetup bool) int {
func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode agent.PermissionMode, addDirs []string, theme string, forceSetup bool, allowEscalation 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()) }()
modelregistry.StartModelsDevRefresh()
workspaceRoot, err := deps.getwd()
if err != nil {
@@ -734,7 +727,13 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
resolved.Provider = usable
resolved.ActiveProvider = usable.Name
} else {
resolved = config.ResolvedConfig{}
// Fresh-onboarding reset, but the user's notification preference is
// NOT config-to-redo: clearing it here would surface as an empty
// Options.Notify, and the TUI's unconfigured default (both/unfocused)
// would then resurrect alerts a user explicitly turned off while the
// setup wizard runs (maintainer review, PR #1001). Carry the stored
// block through; other wizard-visible state starts clean as before.
resolved = config.ResolvedConfig{Notify: resolved.Notify}
forceSetup = true
}
}
@@ -790,6 +789,14 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
registry := newCoreRegistryScoped(workspaceRoot, scope)
registerLocalControlTools(registry, workspaceRoot, resolved.LocalControl)
// Mid-run model escalation is opt-in on this surface too. The tool is present
// only when the operator asked for it with --allow-escalation, and the
// switchers that make it do anything ride on the same flag through
// Options.AllowEscalation below. Registering one without the other ships a
// tool the loop will never act on, which looks like a feature and is not.
if allowEscalation {
registry.Register(tools.NewEscalateModelTool())
}
executionRunner := execution.NewRunner(nil)
sandboxStore, err := deps.newSandboxStore()
if err != nil {
@@ -958,7 +965,12 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
// notice when project hooks/plugins were dropped for an untrusted workspace.
hookDispatcher, hookSkip := newHookDispatcherWithExtra(workspaceRoot, pluginActivation.hooks, trustRoot, executionRunner)
emitTrustNotice(stderr, hookSkip, pluginActivation.trustSkip, mcpSkip)
// From the engine the session's commands run through, so the notice
// describes the sandbox they actually get.
startupNotices := []string{sandboxEngine.DegradedNotice()}
modelRPM := zeroruntime.NewModelRPMLimiter(resolved.ModelRPM)
return deps.runTUI(context.Background(), tui.Options{
ModelRPM: modelRPM,
Cwd: workspaceRoot,
Version: version,
Theme: theme,
@@ -981,7 +993,7 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
if provider == nil {
return nil
}
if optimized, ok := providers.OptimizedTurnSessions(profile, provider, providers.Options{}); ok {
if optimized, ok := providers.ConfiguredTurnSessions(profile, provider, providers.Options{ModelRPM: modelRPM}); ok {
return optimized
}
return providers.DefaultTurnSessions(profile, provider, providers.Options{})
@@ -1033,6 +1045,7 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
Specialists: specialistRuntime.specialists,
Skills: pluginActivation.skillInfos(deps.skillsDir()),
},
AllowEscalation: allowEscalation,
// 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
@@ -1044,6 +1057,7 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
PermissionMode: permissionMode,
Notify: resolved.Notify,
KeyBindings: resolved.KeyBindings,
StartupNotices: startupNotices,
STT: resolved.STT,
BuildDictationTranscriber: newDictationTranscriberFactory(resolved, userConfigPath, sttServerManager),
ShutdownDictationServer: sttServerManager.Shutdown,
@@ -1378,6 +1392,7 @@ Flags:
-v, --version Print version
-p, --prompt Run a one-shot prompt
--add-dir <path> Allow writes in an extra directory (repeatable)
--allow-escalation Let the agent escalate to a stronger model mid-run via escalate_model
--skip-permissions-unsafe Launch the interactive shell in unsafe mode (enables the ! shell escape)
`)
return err
@@ -1394,6 +1409,17 @@ func addDirFlagArgs(addDirs []string) []string {
return flags
}
// rootFlagArgs re-synthesises the root flags exec consumes, in the spelling
// its own parser accepts, so a flag written before the subcommand reaches
// the run exactly as one written after it would.
func rootFlagArgs(addDirs []string, allowEscalation bool) []string {
flags := addDirFlagArgs(addDirs)
if allowEscalation {
flags = append(flags, "--allow-escalation")
}
return flags
}
// splitLeadingAddDirFlags strips leading --add-dir flags from the root
// argument list (zero --add-dir <path> [--add-dir <path>] [subcommand …]).
// Subcommands like exec parse their own --add-dir occurrences.
@@ -1434,6 +1460,83 @@ func splitLeadingAddDirFlags(args []string) ([]string, []string, error) {
return addDirs, args, nil
}
// rootFlags is what the leading root flags amount to once every one of them
// has been stripped from the front of the argument list.
type rootFlags struct {
addDirs []string
theme string
allowEscalation bool
}
// splitLeadingRootFlags strips --add-dir, --theme and --allow-escalation from
// the front of args in whatever order they were written, stopping at the
// first token none of them claims.
//
// EACH SPLITTER STOPS AT THE FIRST TOKEN IT DOES NOT OWN, so running them once
// in a fixed sequence made the order the operator wrote them in load-bearing:
// a flag handled late in the sequence stranded every flag written after it as
// an unknown command, and `zero --allow-escalation --theme auto` exited with
// an argument error instead of launching. Running the sequence until it makes
// no progress accepts every ordering, including a flag repeated on both sides
// of another.
func splitLeadingRootFlags(args []string) (rootFlags, []string, error) {
var flags rootFlags
for {
before := len(args)
addDirs, rest, err := splitLeadingAddDirFlags(args)
if err != nil {
return rootFlags{}, nil, err
}
flags.addDirs = append(flags.addDirs, addDirs...)
theme, rest, err := splitLeadingThemeFlag(rest)
if err != nil {
return rootFlags{}, nil, err
}
if theme != "" {
// The last occurrence wins across passes, as it does within one.
flags.theme = theme
}
allowEscalation, rest, err := splitLeadingAllowEscalationFlag(rest)
if err != nil {
return rootFlags{}, nil, err
}
flags.allowEscalation = flags.allowEscalation || allowEscalation
args = rest
if len(args) == before {
return flags, args, nil
}
}
}
// splitLeadingAllowEscalationFlag strips a leading --allow-escalation from the
// root argument list, opting the interactive session into mid-run model
// escalation.
//
// OPT-IN, THE SAME WAY exec IS. Escalation moves a run onto a different model,
// which changes what the run costs and which provider sees the conversation, so
// it is a decision the operator makes rather than a default. The exec flag
// already answers this conservatively and the interactive surface should not
// answer it differently.
//
// Bare flag only: repeating it is harmless, and an =value form is rejected so a
// mistyped --allow-escalation=false is a loud error instead of silently enabling
// the thing it was trying to turn off.
func splitLeadingAllowEscalationFlag(args []string) (bool, []string, error) {
allow := false
for len(args) > 0 {
switch {
case args[0] == "--allow-escalation":
allow = true
args = args[1:]
case strings.HasPrefix(args[0], "--allow-escalation="):
return false, nil, errors.New("--allow-escalation takes no value; pass it bare to enable mid-run model escalation, or omit it")
default:
return allow, args, nil
}
}
return allow, args, nil
}
// splitLeadingThemeFlag strips a leading --theme <auto|theme-name> (space or =form)
// from the root argument list and validates it against the registered themes. The
// last occurrence wins. A value outside the allowed set is a loud error rather than
+75 -2
View File
@@ -343,8 +343,10 @@ func TestRunNoArgsFallsBackToUsableProviderWhenNoneMarkedActive(t *testing.T) {
// 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, "")
return config.ResolvedConfig{
Providers: []config.ProviderProfile{usable},
ModelRPM: map[string]int{"gpt-test": 1},
}, fmt.Errorf("%w: active provider %q not found", config.ErrNoActiveProvider, "")
},
newProvider: func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
providerProfile = profile
@@ -374,6 +376,77 @@ func TestRunNoArgsFallsBackToUsableProviderWhenNoneMarkedActive(t *testing.T) {
}
if providerProfile.Name != "work" {
t.Fatalf("provider used = %q, want fallback to the usable saved provider %q", providerProfile.Name, "work")
}
if launchedOptions.ModelRPM == nil {
t.Fatal("ModelRPM = nil, want limiter preserved through provider fallback")
}
}
// Maintainer regression (PR #1001): a stored explicit notify opt-out must
// survive the provider-recovery startup path. With a stale activeProvider and
// another usable saved provider, Resolve returns the partial config alongside
// ErrNoActiveProvider and the recovery branch forwards it — dropping the
// notify policy there would surface an empty Options.Notify, and the TUI's
// unconfigured default (both) would resurrect alerts the user explicitly
// turned off. The same recovery branch that clears the resolved config for
// the setup wizard carries the stored notify block through.
func TestRunNoArgsPreservesNotifyOptOutThroughProviderRecovery(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
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) {
// Stale activeProvider + usable saved provider, and the user's
// config.json explicitly opts out of notifications. The fixed
// resolver carries the parsed notify block through the error path.
return config.ResolvedConfig{
Providers: []config.ProviderProfile{usable},
Notify: config.NotifyConfig{Mode: "off", FocusMode: "always"},
}, fmt.Errorf("%w: active provider %q not found", config.ErrNoActiveProvider, "ghost")
},
newProvider: func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
return &cliFakeProvider{}, 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")
}
// The explicit opt-out reaches the TUI intact — NOT the both/unfocused
// unconfigured default.
if launchedOptions.Notify.Mode != "off" || launchedOptions.Notify.FocusMode != "always" {
t.Fatalf("Options.Notify = %+v, want the stored off/always opt-out preserved through recovery", launchedOptions.Notify)
}
}
+48 -1
View File
@@ -74,6 +74,8 @@ func runAuth(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
return runAuthStatus(args[1:], stdout, stderr, deps)
case "refresh":
return runAuthRefresh(args[1:], stdout, stderr, deps)
case "reset":
return runAuthReset(args[1:], stdout, stderr, deps)
case "openrouter":
return runAuthOpenRouter(args[1:], stdout, stderr, deps)
case "chatgpt":
@@ -261,6 +263,7 @@ type authArgs struct {
json bool
device bool
watch bool
confirm bool
scopes []string
help bool
}
@@ -286,6 +289,8 @@ func parseAuthArgs(sub string, args []string) (authArgs, error) {
parsed.device = true
case arg == "--watch":
parsed.watch = true
case arg == "--confirm":
parsed.confirm = true
case arg == "--scope":
if i+1 >= len(args) {
return authArgs{}, fmt.Errorf("--scope requires a value")
@@ -321,6 +326,7 @@ func validateAuthFlags(sub string, a authArgs) error {
"logout": {"json": true},
"status": {"json": true},
"refresh": {"watch": true},
"reset": {"json": true, "confirm": true},
}[sub]
bad := func(name string) error { return fmt.Errorf("zero auth %s does not accept %s", sub, name) }
if a.json && !allowed["json"] {
@@ -332,6 +338,9 @@ func validateAuthFlags(sub string, a authArgs) error {
if a.watch && !allowed["watch"] {
return bad("--watch")
}
if a.confirm && !allowed["confirm"] {
return bad("--confirm")
}
if len(a.scopes) > 0 && !allowed["scope"] {
return bad("--scope")
}
@@ -569,6 +578,43 @@ func runAuthRefreshWatch(manager *oauth.Manager, key, provider string, stdout io
return exitSuccess
}
func runAuthReset(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
parsed, err := parseAuthArgs("reset", args)
if err != nil {
return writeExecUsageError(stderr, err.Error())
}
if parsed.help {
_ = writeAuthHelp(stdout)
return exitSuccess
}
if len(parsed.positional) > 0 {
return writeExecUsageError(stderr, fmt.Sprintf("zero auth reset takes no arguments (got %q)", parsed.positional[0]))
}
if !parsed.confirm {
return writeExecUsageError(stderr, "zero auth reset clears all stored OAuth logins and shared MCP tokens; rerun with --confirm to reset")
}
manager, err := newAuthManager(deps, stdout)
if err != nil {
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
}
if err := manager.Reset(); err != nil {
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
}
if parsed.json {
payload := struct {
Reset bool `json:"reset"`
}{Reset: true}
if err := writePrettyJSON(stdout, payload); err != nil {
return exitCrash
}
return exitSuccess
}
if _, err := fmt.Fprintln(stdout, "Reset OAuth token store."); err != nil {
return exitCrash
}
return exitSuccess
}
func filterAuthStatuses(statuses []oauth.Status, provider string) []oauth.Status {
want := oauth.ProviderKey(provider)
filtered := make([]oauth.Status, 0, 1)
@@ -589,6 +635,7 @@ Commands:
logout <provider> Delete a provider's stored login
status [provider] Show login presence/expiry (never the token)
refresh <provider> [--watch] Force a token refresh (--watch keeps it fresh)
reset --confirm [--json] Clear all OAuth logins and shared MCP tokens (also repairs corrupted stores)
openrouter Log in to OpenRouter in the browser; mints an API key
chatgpt Log in to ChatGPT in the browser (Codex backend, ChatGPT Plus/Pro)
@@ -615,7 +662,7 @@ Flags:
--device Use the device-code flow (headless/SSH; no browser)
--scope Add an OAuth scope (repeatable)
--watch Keep the token fresh in the foreground (refresh only)
--json Print result as JSON (status/logout)
--json Print result as JSON (status/logout/reset)
-h, --help Show this help
`)
return err
+92 -1
View File
@@ -129,6 +129,8 @@ func TestRunAuthRejectsWrongFlags(t *testing.T) {
{"auth", "logout", "demo", "--scope", "x"}, // scope is login-only
{"auth", "refresh", "demo", "--json"}, // json not for refresh
{"auth", "login", "demo", "--scope", ""}, // empty scope rejected
{"auth", "status", "--confirm"}, // confirm is reset-only
{"auth", "reset", "--confirm", "--watch"}, // confirmation does not allow unrelated flags
}
for _, args := range cases {
var stdout, stderr bytes.Buffer
@@ -196,13 +198,102 @@ func TestRunAuthHelp(t *testing.T) {
if code := runWithDeps([]string{"auth", "--help"}, &stdout, &stderr, appDeps{}); code != exitSuccess {
t.Fatalf("exit = %d", code)
}
for _, want := range []string{"zero auth", "login", "logout", "status", "refresh", "--device"} {
for _, want := range []string{"zero auth", "login", "logout", "status", "refresh", "reset --confirm", "--device"} {
if !strings.Contains(stdout.String(), want) {
t.Fatalf("help missing %q:\n%s", want, stdout.String())
}
}
}
func TestRunAuthResetRequiresConfirmation(t *testing.T) {
for _, args := range [][]string{{}, {"--json"}} {
t.Run(strings.Join(args, " "), func(t *testing.T) {
path := withAuthStore(t)
store, err := oauth.NewStore(oauth.StoreOptions{FilePath: path})
if err != nil {
t.Fatal(err)
}
if err := store.Save(oauth.ProviderKey("demo"), oauth.Token{AccessToken: "synthetic-reset-test-token"}); err != nil {
t.Fatal(err)
}
before, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
var stdout, stderr bytes.Buffer
code := runAuthReset(args, &stdout, &stderr, appDeps{})
after, readErr := os.ReadFile(path)
if readErr != nil || !bytes.Equal(before, after) {
t.Fatalf("unconfirmed reset changed the store: %v", readErr)
}
if code == exitSuccess || !strings.Contains(stderr.String(), "--confirm") || stdout.Len() != 0 {
t.Fatalf("unconfirmed reset: exit=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
}
})
}
}
func TestRunAuthResetJSON(t *testing.T) {
path := withAuthStore(t)
store, err := oauth.NewStore(oauth.StoreOptions{FilePath: path})
if err != nil {
t.Fatalf("NewStore: %v", err)
}
if err := store.Save(oauth.ProviderKey("demo"), oauth.Token{AccessToken: "synthetic-reset-test-token"}); err != nil {
t.Fatalf("Save: %v", err)
}
var stdout, stderr bytes.Buffer
if code := runWithDeps([]string{"auth", "reset", "--confirm", "--json"}, &stdout, &stderr, appDeps{}); code != exitSuccess {
t.Fatalf("exit = %d stderr=%s", code, stderr.String())
}
var payload struct {
Reset bool `json:"reset"`
}
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("decode JSON: %v (stdout=%q)", err, stdout.String())
}
if !payload.Reset {
t.Fatalf("payload = %+v, want reset=true", payload)
}
stdout.Reset()
stderr.Reset()
if code := runWithDeps([]string{"auth", "status"}, &stdout, &stderr, appDeps{}); code != exitSuccess {
t.Fatalf("status exit = %d stderr=%s", code, stderr.String())
}
if !strings.Contains(stdout.String(), "No OAuth provider logins are stored.") {
t.Fatalf("expected empty store after JSON reset, got: %q", stdout.String())
}
}
func TestRunAuthReset(t *testing.T) {
path := withAuthStore(t)
store, err := oauth.NewStore(oauth.StoreOptions{FilePath: path})
if err != nil {
t.Fatalf("NewStore: %v", err)
}
if err := store.Save(oauth.ProviderKey("demo"), oauth.Token{AccessToken: "secret"}); err != nil {
t.Fatalf("Save: %v", err)
}
var stdout, stderr bytes.Buffer
if code := runWithDeps([]string{"auth", "reset", "--confirm"}, &stdout, &stderr, appDeps{}); code != exitSuccess {
t.Fatalf("exit = %d stderr=%s", code, stderr.String())
}
if !strings.Contains(stdout.String(), "Reset OAuth token store") {
t.Fatalf("stdout = %q", stdout.String())
}
// Verify store is now empty.
stdout.Reset()
stderr.Reset()
if code := runWithDeps([]string{"auth", "status"}, &stdout, &stderr, appDeps{}); code != exitSuccess {
t.Fatalf("status exit = %d", code)
}
if !strings.Contains(stdout.String(), "No OAuth provider logins are stored.") {
t.Fatalf("expected empty store after reset, got: %q", stdout.String())
}
}
// 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
+35 -3
View File
@@ -26,6 +26,30 @@ type modelSummary = zerocommands.ModelSnapshot
type providerCatalogSummary = zerocommands.ProviderCatalogSnapshot
func runConfig(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
// The first non-flag argument is a subcommand. With no positional argument
// (or only flag arguments), the read-only summary path runs — this keeps
// `zero config` and `zero config --json` working unchanged.
command := "summary"
rest := args
if len(args) > 0 && !strings.HasPrefix(args[0], "-") {
command = strings.ToLower(strings.TrimSpace(args[0]))
rest = args[1:]
}
switch command {
case "summary":
return runConfigSummary(rest, stdout, stderr, deps)
case "notify":
return runConfigNotify(rest, stdout, stderr, deps)
case "help":
if err := writeConfigHelp(stdout); err != nil {
return exitCrash
}
return exitSuccess
}
return writeExecUsageError(stderr, fmt.Sprintf("unknown config command %q", command))
}
func runConfigSummary(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
options, help, err := parseCommandCenterArgs(args, false, false)
if err != nil {
return writeExecUsageError(stderr, err.Error())
@@ -407,7 +431,11 @@ func formatProviderCatalogLine(provider providerCatalogSummary) string {
provider.RuntimeSupported,
))
if provider.RuntimeSupported {
lines = append(lines, " setup: zero providers setup "+displayCLIValue(provider.ID, "unknown")+" --set-active")
setup := " setup: zero providers setup " + displayCLIValue(provider.ID, "unknown") + " --set-active"
if provider.ID == "atomic-chat-local" {
setup = " setup: run zero setup to select a loaded model, or zero providers detect to get an add command"
}
lines = append(lines, setup)
} else {
lines = append(lines, " unsupported: "+displayCLIValue(provider.RuntimeUnsupportedReason, "unknown"))
}
@@ -462,8 +490,12 @@ func formatProviderCatalogValue(value string, fallback string) string {
func writeConfigHelp(w io.Writer) error {
_, err := fmt.Fprint(w, `Usage:
zero config [flags]
zero config notify [flags]
Inspects resolved Go configuration without printing secrets.
Inspects resolved Go configuration without printing secrets. The notify
subcommand reads or updates the stored global notification preference,
which controls both the completion and needs-input alerts —
run "zero config notify --help" for details.
Flags:
--json Print JSON summary
@@ -502,7 +534,7 @@ func writeProvidersHelp(w io.Writer) error {
zero providers models [name] [flags]
Inspects resolved provider profiles and provider catalog descriptors without printing secrets.
Detect probes for running local runtimes (Ollama, LM Studio) and prints adopt commands plus per-provider next steps.
Detect probes for running local runtimes (Ollama, LM Studio, Atomic Chat) and prints adopt commands plus per-provider next steps.
Models probes a provider's live model-listing endpoint (e.g. an OpenAI-compatible /v1/models) and lists the models it serves — including custom OpenAI-/Anthropic-compatible endpoints — so a self-hosted provider needs no per-model config.
Flags:
+7
View File
@@ -967,3 +967,10 @@ func TestProvidersListMarksOAuthLoginProviders(t *testing.T) {
t.Fatalf("list should render the oauth login state, got:\n%s", rendered)
}
}
func TestAtomicCatalogSetupRequiresLoadedModel(t *testing.T) {
out := formatProviderCatalogLine(providerCatalogSummary{ID: "atomic-chat-local", RuntimeSupported: true})
if strings.Contains(out, "zero providers setup atomic-chat-local --set-active") || !strings.Contains(out, "zero setup") {
t.Fatalf("catalog advertises a failing setup command: %s", out)
}
}
+8 -3
View File
@@ -20,7 +20,7 @@ type completionContext struct {
}
var completionRoot = completionNode{
flags: []string{"-h", "--help", "-v", "--version", "-p", "--prompt", "--add-dir", "--theme", "--skip-permissions-unsafe"},
flags: []string{"-h", "--help", "-v", "--version", "-p", "--prompt", "--add-dir", "--allow-escalation", "--theme", "--skip-permissions-unsafe"},
children: []completionNode{
{names: []string{"exec"}, flags: []string{
"-h", "--help", "-f", "--file", "--image", "--add-dir", "--mode", "-m", "--model",
@@ -40,7 +40,11 @@ var completionRoot = completionNode{
},
{names: []string{"daemon"}, children: leafNodes("start", "stop", "status", "run", "attach", "serve-remote", "link")},
{names: []string{"setup"}},
{names: []string{"config"}},
{names: []string{"config"}, flags: []string{"-h", "--help", "--json"}, children: []completionNode{
{names: []string{"notify"}, flags: []string{
"-h", "--help", "--mode", "--focus", "--reset", "--json",
}},
}},
{names: []string{"models"}, children: []completionNode{{names: []string{"list", "ls"}}}},
{names: []string{"providers"}, children: []completionNode{
{names: []string{"current"}}, {names: []string{"list"}}, {names: []string{"catalog"}},
@@ -78,7 +82,8 @@ var completionRoot = completionNode{
{names: []string{"tools"}, children: leafNodes("list")},
{names: []string{"oauth"}, children: leafNodes("login", "logout", "status")},
}},
{names: []string{"auth"}, children: leafNodes("openrouter", "chatgpt", "login", "logout", "status", "refresh")},
{names: []string{"auth"}, children: append(leafNodes("openrouter", "chatgpt", "login", "logout", "status", "refresh"),
completionNode{names: []string{"reset"}, flags: []string{"--confirm", "--json", "--help"}})},
{names: []string{"sandbox"}, children: []completionNode{
{names: []string{"policy"}}, {names: []string{"setup"}}, {names: []string{"check"}},
{names: []string{"grants"}, children: leafNodes("list", "allow", "deny", "revoke", "clear")},
+7
View File
@@ -159,10 +159,17 @@ func TestCompletionTreeCoversAliasesNestingAndCommonFlags(t *testing.T) {
assertCandidates(t, byPath["worktree"], "prepare", "release")
assertCandidates(t, byPath["daemon"], "start", "stop", "status", "run", "attach")
assertCandidates(t, byPath["mcp oauth"], "login", "logout", "status")
assertCandidates(t, byPath["auth"], "openrouter", "chatgpt", "login", "logout", "status", "refresh", "reset")
assertCandidates(t, byPath["auth reset"], "--confirm", "--json", "--help")
assertCandidates(t, byPath["sandbox grants"], "list", "allow", "deny", "revoke", "clear")
assertCandidates(t, byPath["completions"], "bash", "zsh", "fish", "powershell", "elvish")
assertCandidates(t, byPath["plugins"], "list", "add", "info", "remove", "rm")
assertCandidates(t, byPath["plugin"], "list", "add", "info", "remove", "rm")
// `config` gained the notify subcommand; the completion tree must expose
// it (and its flags) so shell completion cannot go stale for new CLI
// surfaces (maintainer review, PR #1001).
assertCandidates(t, byPath["config"], "notify", "--json", "--help")
assertCandidates(t, byPath["config notify"], "--mode", "--focus", "--reset", "--json", "--help")
}
func assertCandidates(t *testing.T, got []string, wants ...string) {
+177
View File
@@ -0,0 +1,177 @@
package cli
import (
"fmt"
"io"
"strings"
"github.com/Gitlawb/zero/internal/config"
)
// runConfigNotify implements `zero config notify`: with no flags it prints the
// stored mode/focusMode; --mode/--focus update them via the same
// config.UpdateNotify writer the TUI /notify command uses, so all surfaces stay
// in lockstep; --reset blanks both fields so the TUI's effective default
// applies again (an unconfigured headless run stays silent).
//
// The command manages a user preference, so it talks ONLY to the user's own
// config file (config.UserNotify / config.UpdateNotify) and never runs the full
// config resolution: resolving providers would fail with ErrNoActiveProvider
// for a brand-new user, locking them out of setting notifications before they
// have even configured a provider (CodeRabbit review, PR #1001). The display
// therefore reports the USER'S stored values — a project config that
// overrides notify for one repo is not shown here, by the same logic the
// maintainer applied to the write path.
func runConfigNotify(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
options, help, err := parseConfigNotifyArgs(args)
if err != nil {
return writeExecUsageError(stderr, err.Error())
}
if help {
if err := writeConfigNotifyHelp(stdout); err != nil {
return exitCrash
}
return exitSuccess
}
configPath, err := deps.userConfigPath()
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
if options.mode != "" || options.focus != "" || options.reset {
// One serialized read-merge-write transaction: the lock covers
// reading the stored block, applying only the explicit fields, and
// replacing the file, so two concurrent partial updates (e.g.
// --mode off and --focus always from two terminals) cannot lose
// each other's change (maintainer review, PR #1001). Omitted
// fields preserve the values stored in the user's OWN file — blank
// stays blank; --reset is the only path that clears both fields.
_, err := config.UpdateNotify(configPath, func(current config.NotifyConfig) config.NotifyConfig {
if options.reset {
return config.NotifyConfig{}
}
if options.mode != "" {
current.Mode = options.mode
}
if options.focus != "" {
current.FocusMode = options.focus
}
return current
})
if err != nil {
return writeAppError(stderr, err.Error(), exitUsage)
}
}
// Report the stored values (blank renders as "(default)").
current, err := config.UserNotify(configPath)
if err != nil {
return writeAppError(stderr, err.Error(), exitUsage)
}
if options.json {
if err := writePrettyJSON(stdout, map[string]any{
"mode": current.Mode,
"focusMode": current.FocusMode,
}); err != nil {
return exitCrash
}
return exitSuccess
}
lines := []string{
"Notify",
"mode: " + displayCLIValue(current.Mode, "(default)"),
"focusMode: " + displayCLIValue(current.FocusMode, "(default)"),
}
if _, err := fmt.Fprintln(stdout, strings.Join(lines, "\n")); err != nil {
return exitCrash
}
return exitSuccess
}
type configNotifyOptions struct {
mode string
focus string
reset bool
json bool
}
func parseConfigNotifyArgs(args []string) (configNotifyOptions, bool, error) {
options := configNotifyOptions{}
for index := 0; index < len(args); index++ {
arg := args[index]
switch {
case arg == "-h" || arg == "--help" || arg == "help":
return options, true, nil
case arg == "--json":
options.json = true
case arg == "--reset":
options.reset = true
case arg == "--mode":
value, next, err := nextFlagValue(args, index, arg)
if err != nil {
return options, false, err
}
options.mode = value
index = next
case strings.HasPrefix(arg, "--mode="):
value, err := requiredInlineFlagValue(arg, "--mode")
if err != nil {
return options, false, err
}
options.mode = value
case arg == "--focus":
value, next, err := nextFlagValue(args, index, arg)
if err != nil {
return options, false, err
}
options.focus = value
index = next
case strings.HasPrefix(arg, "--focus="):
value, err := requiredInlineFlagValue(arg, "--focus")
if err != nil {
return options, false, err
}
options.focus = value
case strings.HasPrefix(arg, "-"):
return options, false, execUsageError{fmt.Sprintf("unknown flag %q", arg)}
default:
return options, false, execUsageError{fmt.Sprintf("unexpected argument %q", arg)}
}
}
return options, false, nil
}
func writeConfigNotifyHelp(w io.Writer) error {
_, err := fmt.Fprint(w, "Usage:\n"+
" zero config notify [flags]\n"+
"\n"+
"Print or update the stored global notification preference.\n"+
"\n"+
"The preference controls BOTH notification kinds: the turn-completion\n"+
"(\"Zero: ready\") alert and the needs-input alert. mode off silences both;\n"+
"the focus mode (unfocused, always, focused) applies to both.\n"+
"\n"+
"When run with no flag, prints the stored mode and focusMode; a field you\n"+
"never set shows as (default) — the TUI alerts with bell + notification,\n"+
"firing only when the terminal is unfocused, while an unconfigured\n"+
"headless run stays silent. Omitted flags preserve the values stored in\n"+
"YOUR config file; --reset clears both so the TUI's effective default\n"+
"applies again. If --reset is combined with --mode/--focus, --reset wins\n"+
"and the explicit values are ignored.\n"+
"\n"+
"Examples:\n"+
" zero config notify\n"+
" zero config notify --json\n"+
" zero config notify --mode both --focus unfocused\n"+
" zero config notify --mode off\n"+
" zero config notify --reset # clear the stored preference\n"+
"\n"+
"Flags:\n"+
" --mode <off|bell|notify|both> Notification mechanism (both kinds)\n"+
" --focus <unfocused|always|focused> When the alert fires\n"+
" --reset Clear the stored preference so the TUI effective default applies\n"+
" --json Machine-readable output\n"+
" -h, --help Show this help\n")
return err
}
+481
View File
@@ -0,0 +1,481 @@
package cli
import (
"bytes"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/config"
)
// `zero config notify` with no flag and a fresh config: nothing is configured,
// the resolver leaves the fields blank (defaults deliberately live in the TUI,
// not the resolver — maintainer review, PR #1001), and the command reports
// "(default)" for both.
func TestRunConfigNotifyPrintsUnconfiguredAsDefault(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
// A valid openai profile so the resolver does not error with
// ErrNoActiveProvider. The notify defaults are applied independently of
// the provider resolution path.
seed := `{
"activeProvider": "openai",
"providers": [{
"name": "openai",
"providerKind": "openai",
"baseUrl": "https://api.openai.com/v1",
"model": "gpt-4.1",
"apiKeyEnv": "OPENAI_API_KEY"
}]
}`
if err := os.WriteFile(configPath, []byte(seed), 0o600); err != nil {
t.Fatalf("seed config: %v", err)
}
deps := commandCenterDeps(t)
deps.userConfigPath = func() (string, error) { return configPath, nil }
deps.resolveConfig = func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
return config.Resolve(config.ResolveOptions{UserConfigPath: configPath, Env: map[string]string{"OPENAI_API_KEY": "sk-test"}})
}
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"config", "notify"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("exit = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
}
if !strings.Contains(stdout.String(), "mode: (default)") {
t.Errorf("stdout should show unconfigured mode as (default), got: %s", stdout.String())
}
if !strings.Contains(stdout.String(), "focusMode: (default)") {
t.Errorf("stdout should show unconfigured focus as (default), got: %s", stdout.String())
}
}
// `zero config notify --json` emits a machine-readable payload. A configured
// pair round-trips; unconfigured fields are empty strings (never defaults
// filled in), so scripts can distinguish "user chose" from "default".
func TestRunConfigNotifyPrintsJSON(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
seed := `{
"activeProvider": "openai",
"providers": [{
"name": "openai",
"providerKind": "openai",
"baseUrl": "https://api.openai.com/v1",
"model": "gpt-4.1",
"apiKeyEnv": "OPENAI_API_KEY"
}],
"notify": {"mode": "bell", "focusMode": "always"}
}`
if err := os.WriteFile(configPath, []byte(seed), 0o600); err != nil {
t.Fatalf("seed config: %v", err)
}
deps := commandCenterDeps(t)
deps.userConfigPath = func() (string, error) { return configPath, nil }
deps.resolveConfig = func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
return config.Resolve(config.ResolveOptions{UserConfigPath: configPath, Env: map[string]string{"OPENAI_API_KEY": "sk-test"}})
}
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"config", "notify", "--json"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("exit = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
}
var payload map[string]any
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("output is not valid JSON: %v\n%s", err, stdout.String())
}
if payload["mode"] != "bell" {
t.Errorf("mode = %v, want bell", payload["mode"])
}
if payload["focusMode"] != "always" {
t.Errorf("focusMode = %v, want always", payload["focusMode"])
}
}
// The unconfigured JSON shape: fields are empty strings, never defaults
// filled in, so scripts can tell "user chose" from "default".
func TestRunConfigNotifyPrintsJSONUnconfigured(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
seed := `{
"activeProvider": "openai",
"providers": [{
"name": "openai",
"providerKind": "openai",
"baseUrl": "https://api.openai.com/v1",
"model": "gpt-4.1",
"apiKeyEnv": "OPENAI_API_KEY"
}]
}`
if err := os.WriteFile(configPath, []byte(seed), 0o600); err != nil {
t.Fatalf("seed config: %v", err)
}
deps := commandCenterDeps(t)
deps.userConfigPath = func() (string, error) { return configPath, nil }
deps.resolveConfig = func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
return config.Resolve(config.ResolveOptions{UserConfigPath: configPath, Env: map[string]string{"OPENAI_API_KEY": "sk-test"}})
}
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"config", "notify", "--json"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("exit = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
}
var payload map[string]any
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("output is not valid JSON: %v\n%s", err, stdout.String())
}
if payload["mode"] != "" {
t.Errorf("mode = %v, want empty (unconfigured)", payload["mode"])
}
if payload["focusMode"] != "" {
t.Errorf("focusMode = %v, want empty (unconfigured)", payload["focusMode"])
}
}
// `zero config notify --mode off` writes the new value to disk and prints
// confirmation. The user can read it back by running the command again.
func TestRunConfigNotifyWritesModeChange(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
seed := `{
"activeProvider": "openai",
"providers": [{
"name": "openai",
"providerKind": "openai",
"baseUrl": "https://api.openai.com/v1",
"model": "gpt-4.1",
"apiKeyEnv": "OPENAI_API_KEY"
}],
"notify": {"mode": "both", "focusMode": "always"}
}`
if err := os.WriteFile(configPath, []byte(seed), 0o600); err != nil {
t.Fatalf("seed config: %v", err)
}
deps := commandCenterDeps(t)
deps.userConfigPath = func() (string, error) { return configPath, nil }
deps.resolveConfig = func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
return config.Resolve(config.ResolveOptions{UserConfigPath: configPath, Env: map[string]string{"OPENAI_API_KEY": "sk-test"}})
}
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"config", "notify", "--mode", "off"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("exit = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
}
cfg := readFileConfig(t, configPath)
if cfg.Notify.Mode != "off" {
t.Errorf("Notify.Mode = %q, want off", cfg.Notify.Mode)
}
// A mode-only update must preserve the configured focusMode, not wipe it.
if cfg.Notify.FocusMode != "always" {
t.Errorf("Notify.FocusMode = %q, want preserved %q", cfg.Notify.FocusMode, "always")
}
if !strings.Contains(stdout.String(), "mode: off") {
t.Errorf("stdout should confirm the change, got: %s", stdout.String())
}
}
// A focus-only update preserves the configured mode.
func TestRunConfigNotifyFocusOnlyPreservesMode(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
seed := `{
"activeProvider": "openai",
"providers": [{
"name": "openai",
"providerKind": "openai",
"baseUrl": "https://api.openai.com/v1",
"model": "gpt-4.1",
"apiKeyEnv": "OPENAI_API_KEY"
}],
"notify": {"mode": "bell", "focusMode": "unfocused"}
}`
if err := os.WriteFile(configPath, []byte(seed), 0o600); err != nil {
t.Fatalf("seed config: %v", err)
}
deps := commandCenterDeps(t)
deps.userConfigPath = func() (string, error) { return configPath, nil }
deps.resolveConfig = func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
return config.Resolve(config.ResolveOptions{UserConfigPath: configPath, Env: map[string]string{"OPENAI_API_KEY": "sk-test"}})
}
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"config", "notify", "--focus", "always"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("exit = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
}
cfg := readFileConfig(t, configPath)
if cfg.Notify.Mode != "bell" {
t.Errorf("Notify.Mode = %q, want preserved %q", cfg.Notify.Mode, "bell")
}
if cfg.Notify.FocusMode != "always" {
t.Errorf("Notify.FocusMode = %q, want always", cfg.Notify.FocusMode)
}
}
// Maintainer regression (PR #1001): a partial update must seed from the USER'S
// OWN file, never from the resolved view. A project .zero/config.json setting
// mode=off resolves into the session, but `--focus always` inside that repo
// must NOT copy the project's off into the user's global config.
func TestRunConfigNotifyDoesNotCopyProjectNotifyIntoUserConfig(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "user.json")
projectPath := filepath.Join(t.TempDir(), "project.json")
if err := os.WriteFile(configPath, []byte(`{"activeProvider": "openai"}`), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(projectPath, []byte(`{"notify": {"mode": "off"}}`), 0o600); err != nil {
t.Fatal(err)
}
deps := commandCenterDeps(t)
deps.userConfigPath = func() (string, error) { return configPath, nil }
// Resolve EXACTLY the way production does: user config + project config
// merged, so resolved.Notify.Mode is the project's "off".
deps.resolveConfig = func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
return config.Resolve(config.ResolveOptions{
UserConfigPath: configPath,
ProjectConfigPath: projectPath,
Env: map[string]string{"OPENAI_API_KEY": "sk-test"},
})
}
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"config", "notify", "--focus", "always"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("exit = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
}
cfg := readFileConfig(t, configPath)
// The user's file must keep an unspecified mode unspecified — the
// project's "off" stays in the project file where it belongs.
if cfg.Notify.Mode != "" {
t.Errorf("user Notify.Mode = %q, want blank (project's off must not leak into the user file)", cfg.Notify.Mode)
}
if cfg.Notify.FocusMode != "always" {
t.Errorf("Notify.FocusMode = %q, want always", cfg.Notify.FocusMode)
}
// The project file is untouched.
project := readFileConfig(t, projectPath)
if project.Notify.Mode != "off" {
t.Errorf("project Notify.Mode = %q, want untouched off", project.Notify.Mode)
}
}
// Maintainer regression (PR #1001): with a clean config, `--mode off` must not
// also pin focusMode as an explicit choice — blank means "use the built-in
// default", and a partial update keeps it that way.
func TestRunConfigNotifyDoesNotPinDefaultsAsExplicitChoices(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(configPath, []byte(`{"activeProvider": "openai"}`), 0o600); err != nil {
t.Fatal(err)
}
deps := commandCenterDeps(t)
deps.userConfigPath = func() (string, error) { return configPath, nil }
deps.resolveConfig = func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
return config.Resolve(config.ResolveOptions{UserConfigPath: configPath, Env: map[string]string{"OPENAI_API_KEY": "sk-test"}})
}
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"config", "notify", "--mode", "off"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("exit = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
}
cfg := readFileConfig(t, configPath)
if cfg.Notify.Mode != "off" {
t.Errorf("Notify.Mode = %q, want off", cfg.Notify.Mode)
}
if cfg.Notify.FocusMode != "" {
t.Errorf("Notify.FocusMode = %q, want blank (unspecified stays unspecified; the default must not be pinned)", cfg.Notify.FocusMode)
}
}
// `--mode` and `--focus` together update both fields in one call.
func TestRunConfigNotifyWritesModeAndFocus(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
seed := `{
"activeProvider": "openai",
"providers": [{
"name": "openai",
"providerKind": "openai",
"baseUrl": "https://api.openai.com/v1",
"model": "gpt-4.1",
"apiKeyEnv": "OPENAI_API_KEY"
}]
}`
if err := os.WriteFile(configPath, []byte(seed), 0o600); err != nil {
t.Fatalf("seed config: %v", err)
}
deps := commandCenterDeps(t)
deps.userConfigPath = func() (string, error) { return configPath, nil }
deps.resolveConfig = func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
return config.Resolve(config.ResolveOptions{UserConfigPath: configPath, Env: map[string]string{"OPENAI_API_KEY": "sk-test"}})
}
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"config", "notify", "--mode", "both", "--focus", "unfocused"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("exit = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
}
cfg := readFileConfig(t, configPath)
if cfg.Notify.Mode != "both" || cfg.Notify.FocusMode != "unfocused" {
t.Errorf("Notify = %+v, want mode=both focusMode=unfocused", cfg.Notify)
}
}
// `--mode loud` is a usage error. The config must not be mutated.
func TestRunConfigNotifyRejectsInvalidMode(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
seed := `{
"activeProvider": "openai",
"providers": [{
"name": "openai",
"providerKind": "openai",
"baseUrl": "https://api.openai.com/v1",
"model": "gpt-4.1",
"apiKeyEnv": "OPENAI_API_KEY"
}],
"notify": {"mode": "off"}
}`
if err := os.WriteFile(configPath, []byte(seed), 0o600); err != nil {
t.Fatalf("seed config: %v", err)
}
deps := commandCenterDeps(t)
deps.userConfigPath = func() (string, error) { return configPath, nil }
deps.resolveConfig = func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
return config.Resolve(config.ResolveOptions{UserConfigPath: configPath, Env: map[string]string{"OPENAI_API_KEY": "sk-test"}})
}
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"config", "notify", "--mode", "loud"}, &stdout, &stderr, deps)
if exitCode == exitSuccess {
t.Fatalf("expected failure for invalid mode, got success; stdout=%s", stdout.String())
}
cfg := readFileConfig(t, configPath)
if cfg.Notify.Mode != "off" {
t.Errorf("Notify.Mode = %q, want off (unchanged after failed write)", cfg.Notify.Mode)
}
}
// `--reset` blanks both fields so the TUI's effective default applies again
// (an unconfigured headless run stays silent). Useful for "go back to the
// recommended setup" after a custom value.
func TestRunConfigNotifyResetClearsStoredValues(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
seed := `{
"activeProvider": "openai",
"providers": [{
"name": "openai",
"providerKind": "openai",
"baseUrl": "https://api.openai.com/v1",
"model": "gpt-4.1",
"apiKeyEnv": "OPENAI_API_KEY"
}],
"notify": {"mode": "off", "focusMode": "always"}
}`
if err := os.WriteFile(configPath, []byte(seed), 0o600); err != nil {
t.Fatalf("seed config: %v", err)
}
deps := commandCenterDeps(t)
deps.userConfigPath = func() (string, error) { return configPath, nil }
deps.resolveConfig = func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
return config.Resolve(config.ResolveOptions{UserConfigPath: configPath, Env: map[string]string{"OPENAI_API_KEY": "sk-test"}})
}
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"config", "notify", "--reset"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("exit = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
}
cfg := readFileConfig(t, configPath)
if cfg.Notify.Mode != "" || cfg.Notify.FocusMode != "" {
t.Errorf("Notify after reset = %+v, want empty (defaults apply)", cfg.Notify)
}
}
// CodeRabbit regression (PR #1001): the command manages a user preference and
// must not require provider resolution. A brand-new user with NO provider
// configured (the resolver would return ErrNoActiveProvider) can still read,
// set, and reset their notification preference.
func TestRunConfigNotifyWorksWithoutAnyProviderConfigured(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(configPath, []byte(`{}`), 0o600); err != nil {
t.Fatal(err)
}
// resolveConfig fails the way the real resolver does for a fresh user —
// the command must never call it, so a panic-free stub is enough to prove
// the point; use the failing resolver to catch any regression to the
// resolve-first shape.
deps := commandCenterDeps(t)
deps.userConfigPath = func() (string, error) { return configPath, nil }
deps.resolveConfig = func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
return config.Resolve(config.ResolveOptions{UserConfigPath: configPath, Env: map[string]string{}})
}
// Read works and shows defaults.
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"config", "notify"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("read: exit = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
}
if !strings.Contains(stdout.String(), "mode: (default)") {
t.Errorf("read should show (default), got: %s", stdout.String())
}
// Write works.
stdout.Reset()
exitCode = runWithDeps([]string{"config", "notify", "--mode", "bell", "--focus", "always"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("write: exit = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
}
cfg := readFileConfig(t, configPath)
if cfg.Notify.Mode != "bell" || cfg.Notify.FocusMode != "always" {
t.Fatalf("write: Notify = %+v, want bell/always", cfg.Notify)
}
// JSON read reflects the stored pair.
stdout.Reset()
exitCode = runWithDeps([]string{"config", "notify", "--json"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("json: exit = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
}
var payload map[string]any
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("json output invalid: %v\n%s", err, stdout.String())
}
if payload["mode"] != "bell" || payload["focusMode"] != "always" {
t.Errorf("json = mode %v focus %v, want bell/always", payload["mode"], payload["focusMode"])
}
// Reset works.
stdout.Reset()
exitCode = runWithDeps([]string{"config", "notify", "--reset"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("reset: exit = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
}
cfg = readFileConfig(t, configPath)
if cfg.Notify.Mode != "" || cfg.Notify.FocusMode != "" {
t.Errorf("reset: Notify = %+v, want empty", cfg.Notify)
}
}
// `zero config` (no subcommand) still works after the dispatch change.
func TestRunConfigSummaryStillWorks(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"config"}, &stdout, &stderr, commandCenterDeps(t))
if exitCode != exitSuccess {
t.Fatalf("exit = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
}
if !strings.Contains(stdout.String(), "Config") {
t.Errorf("stdout should show the config summary, got: %s", stdout.String())
}
}
+116
View File
@@ -2,6 +2,10 @@ package cli
import (
"bytes"
"io"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
@@ -39,3 +43,115 @@ func TestFireJobClaimPreventsDoubleFire(t *testing.T) {
t.Fatalf("NextRunAt must advance past now, got %s", d.NextRunAt)
}
}
func TestFireJobOverlappingAdjacentSlots(t *testing.T) {
for _, olderFirst := range []bool{false, true} {
name := "newer completes first"
if olderFirst {
name = "older completes first"
}
t.Run(name, func(t *testing.T) {
root := t.TempDir()
stores := []*cron.Store{
cron.NewStore(cron.StoreOptions{RootDir: root}),
cron.NewStore(cron.StoreOptions{RootDir: root}),
}
now := time.Date(2026, 6, 9, 9, 0, 0, 0, time.UTC)
job, err := stores[0].Add(cron.Job{Expr: "* * * * *", Prompt: "fire me", NextRunAt: now, FireCount: 7})
if err != nil {
t.Fatal(err)
}
var release, done [2]chan struct{}
for i := range stores {
release[i], done[i] = make(chan struct{}), make(chan struct{})
// Always release blocked runners, including on assertion failures.
defer func() {
select {
case <-release[i]:
default:
close(release[i])
}
<-done[i]
}()
started := make(chan struct{})
go func() {
defer close(done[i])
fireJob(stores[i], func() time.Time { return now.Add(time.Duration(i) * time.Minute) }, job, io.Discard, io.Discard,
func(_ []string, _, _ io.Writer) int {
close(started)
<-release[i]
return i // Include a failed execution in the accounting.
})
}()
select {
case <-started:
case <-done[i]:
t.Fatal("runner did not claim its slot")
}
}
order := []int{1, 0}
if olderFirst {
order = []int{0, 1}
}
for n, i := range order {
close(release[i])
<-done[i]
current, err := stores[0].Get(job.ID)
if err != nil {
t.Fatal(err)
}
if current.FireCount != 8+n || !current.NextRunAt.Equal(now.Add(2*time.Minute)) {
t.Fatalf("after completion %d: FireCount=%d NextRunAt=%s; want count %d and next %s", n+1, current.FireCount, current.NextRunAt, 8+n, now.Add(2*time.Minute))
}
claimed, err := claimFire(stores[1], now.Add(time.Minute), job.ID)
if err != nil || claimed {
t.Fatalf("already claimed slot became available: claimed=%v err=%v", claimed, err)
}
}
runs, err := stores[0].Runs(job.ID, 0)
if err != nil || len(runs) != 2 {
t.Fatalf("runs=%v err=%v", runs, err)
}
for n, i := range order {
if !runs[n].At.Equal(now.Add(time.Duration(i)*time.Minute)) || runs[n].ExitCode != i {
t.Fatalf("unexpected run record: %+v", runs[n])
}
}
})
}
}
func TestFireJobCompletionReadErrorDoesNotOverwriteState(t *testing.T) {
root := t.TempDir()
store := cron.NewStore(cron.StoreOptions{RootDir: root})
now := time.Date(2026, 6, 9, 9, 0, 0, 0, time.UTC)
job, err := store.Add(cron.Job{Expr: "* * * * *", Prompt: "fire me", NextRunAt: now})
if err != nil {
t.Fatal(err)
}
metadata := filepath.Join(root, job.ID, "metadata.json")
var stdout, stderr bytes.Buffer
fireJob(store, func() time.Time { return now }, job, &stdout, &stderr,
func(_ []string, _, _ io.Writer) int {
// A corrupt read must not cause completion to replace unknown newer state
// with the stale pre-execution job. This works without permission tricks.
if err := os.WriteFile(metadata, []byte("unreadable job"), 0o600); err != nil {
t.Fatal(err)
}
return 0
})
data, err := os.ReadFile(metadata)
if err != nil || string(data) != "unreadable job" {
t.Fatalf("completion overwrote unreadable state: %q, err=%v", data, err)
}
if !strings.Contains(stderr.String(), "failed to persist job state") {
t.Fatalf("missing persistence warning: %s", stderr.String())
}
if want := "fired " + job.ID + " -> exit 0 (next: unknown)\n"; stdout.String() != want {
t.Fatalf("completion summary = %q; want %q", stdout.String(), want)
}
runs, err := store.Runs(job.ID, 0)
if err != nil || len(runs) != 1 || runs[0].ExitCode != 0 {
t.Fatalf("execution should still be recorded: runs=%v err=%v", runs, err)
}
}
+19 -38
View File
@@ -129,17 +129,15 @@ func reconcileOverdue(store *cron.Store, now func() time.Time, ids []string, std
}
}
// fireJob runs one job via the exec runner, records the outcome, advances the
// schedule, and persists. The foreground loop is single-goroutine, so the
// previous fire has already returned before the next tick — no overlap.
// fireJob claims one job, runs it via the exec runner, and records the outcome.
// Separate scheduler processes can have adjacent slots executing concurrently.
func fireJob(store *cron.Store, now func() time.Time, job cron.Job, stdout io.Writer, stderr io.Writer, exec execRunner) {
fired := now()
// Atomically claim this fire before running it so concurrent schedulers (or
// --once overlapping the forever loop) can't both execute the same due job: the
// winner advances NextRunAt under the per-job lock, and a loser sees the
// advanced time and skips. The post-exec advance recomputes the same
// NextRunAt (sched.Next(fired) is deterministic), so the claim serializes the
// fire decision without changing any persisted schedule state (M9).
// advanced time and skips. Only the claim writes schedule state; completion
// must not overwrite a later slot's claim with this run's stale job copy.
claimed, claimErr := claimFire(store, fired, job.ID)
if claimErr != nil {
if errors.Is(claimErr, cron.ErrJobNotFound) {
@@ -178,44 +176,25 @@ func fireJob(store *cron.Store, now func() time.Time, job cron.Job, stdout io.Wr
rec.Error = cronTruncate(detail, 500)
}
job.FireCount++
// Advance the schedule. If the expression can no longer produce a future run
// (became invalid, or is an impossible spec whose Next is zero), pause the job
// so it cannot re-fire on every tick.
// Explain schedules that claimFire paused because they cannot advance.
if sched, perr := cron.Parse(job.Expr); perr != nil {
job.Status = cron.StatusPaused
if rec.Error == "" {
rec.Error = "invalid schedule; job paused: " + perr.Error()
}
} else if nxt := sched.Next(fired.Truncate(time.Minute)); nxt.IsZero() {
job.Status = cron.StatusPaused
if rec.Error == "" {
rec.Error = "schedule no longer fires; job paused"
}
} else {
// Minute-aligned input keeps this advance identical to claimFire's and lets
// the DST fall-back collapse guard engage (AUDIT-M4).
job.NextRunAt = nxt
}
// Re-read and persist the advanced state ATOMICALLY under the per-job lock. The
// job may have been paused or removed while it executed, and this in-memory copy
// is stale from tick start. Mutate closes the read-modify-write window a single
// scheduler alone could only narrow (Store.Mutate took a cross-process lock): a
// concurrent scheduler or an external pause/remove landing here can no longer be
// clobbered.
// Count this completion against current state under the cross-process lock.
// The claim already advanced or paused the schedule. Leave that state alone:
// another scheduler may have claimed a later slot or a user may have edited it.
persisted, err := store.Mutate(job.ID, func(current cron.Job, readErr error) (cron.Job, error) {
if readErr != nil {
// A transient read failure (IO/permission) is NOT removal — persist the
// computed next state anyway so the schedule advances and the job does not
// re-fire next tick.
fmt.Fprintf(stderr, "warning: could not re-read job %s before persist: %v\n", job.ID, readErr)
return job, nil
}
current.FireCount = job.FireCount
current.NextRunAt = job.NextRunAt
if current.Status != cron.StatusPaused {
current.Status = job.Status
// Never replace unknown newer state with the pre-execution snapshot.
return current, readErr
}
current.FireCount++
return current, nil
})
if errors.Is(err, cron.ErrJobNotFound) {
@@ -229,19 +208,21 @@ func fireJob(store *cron.Store, now func() time.Time, job cron.Job, stdout io.Wr
if aerr := store.AppendRun(job.ID, rec); aerr != nil {
fmt.Fprintf(stderr, "warning: failed to record run for %s: %v\n", job.ID, aerr)
}
next := "unknown"
if err != nil {
fmt.Fprintf(stderr, "warning: failed to persist job state for %s: %v\n", job.ID, err)
persisted = job
} else {
next = formatCronTime(persisted.NextRunAt)
}
fmt.Fprintf(stdout, "fired %s -> exit %d (next: %s)\n", job.ID, code, formatCronTime(persisted.NextRunAt))
fmt.Fprintf(stdout, "fired %s -> exit %d (next: %s)\n", job.ID, code, next)
}
// claimFire atomically claims a due job's fire under the per-job lock and reports
// whether THIS caller should run it. It returns false only when the job was paused
// externally or another scheduler already advanced NextRunAt past `fired` (so a
// valid recurring job never double-fires). A valid due job has its NextRunAt
// advanced here to claim the slot; an invalid or exhausted schedule is left for the
// caller to fire-once and pause (preserving existing single-scheduler behavior).
// advanced here to claim the slot; an invalid or exhausted schedule is paused
// here and the caller still fires once (preserving single-scheduler behavior).
func claimFire(store *cron.Store, fired time.Time, id string) (bool, error) {
claimed := true
_, err := store.Mutate(id, func(current cron.Job, readErr error) (cron.Job, error) {
@@ -268,8 +249,8 @@ func claimFire(store *cron.Store, fired time.Time, id string) (bool, error) {
}
// Unparseable or unadvanceable schedule (an impossible spec whose Next is
// zero): pause the job inside this same locked claim so a concurrent scheduler
// sees a non-active job and does NOT also fire it. The winner still fires once
// and fireJob's post-exec keeps it paused. Without this, both callers leave
// sees a non-active job and does NOT also fire it. The winner still fires once.
// Without this, both callers leave
// NextRunAt unchanged, both see the job due, and both fire the same slot
// (AUDIT-M5).
current.Status = cron.StatusPaused
+2 -2
View File
@@ -53,7 +53,7 @@ func TestCronRunOnceFiresDueJobs(t *testing.T) {
if d.FireCount != 1 || !d.NextRunAt.After(now) {
t.Fatalf("due job not advanced: %+v", d)
}
runs, _ := store.Runs(due.ID)
runs, _ := store.Runs(due.ID, 0)
if len(runs) != 1 {
t.Fatalf("expected 1 run record, got %d", len(runs))
}
@@ -146,7 +146,7 @@ func TestCronRunPausesUnadvanceableJob(t *testing.T) {
if d.Status != cron.StatusPaused {
t.Fatalf("unadvanceable job must be paused, got status=%q", d.Status)
}
runs, _ := store.Runs(job.ID)
runs, _ := store.Runs(job.ID, 0)
if len(runs) != 1 || runs[0].Error == "" {
t.Fatalf("expected one run record with an error, got %+v", runs)
}
+1 -6
View File
@@ -7,7 +7,6 @@ import (
"os"
"os/exec"
"os/signal"
"path/filepath"
"strings"
"syscall"
"time"
@@ -147,11 +146,7 @@ func runDaemonStartDetached(paths daemon.Paths, stdout io.Writer, stderr io.Writ
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
if err := os.MkdirAll(filepath.Dir(paths.Socket), 0o700); err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
logPath := filepath.Join(filepath.Dir(paths.Socket), "daemon.log")
logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
logFile, logPath, err := daemon.OpenRuntimeLog(paths)
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
+8 -2
View File
@@ -18,14 +18,20 @@ import (
// cliFakeDeferredTool is deferred-eligible (implements Deferred() bool), mirroring
// an MCP registry tool, so it counts toward the deferral threshold.
type cliFakeDeferredTool struct {
name string
name string
permission tools.Permission
advertiseInAuto bool
}
func (t cliFakeDeferredTool) Name() string { return t.name }
func (t cliFakeDeferredTool) Description() string { return "fake deferred tool" }
func (t cliFakeDeferredTool) Parameters() tools.Schema { return tools.Schema{Type: "object"} }
func (t cliFakeDeferredTool) Safety() tools.Safety {
return tools.Safety{SideEffect: tools.SideEffectNetwork, Permission: tools.PermissionAllow}
permission := t.permission
if permission == "" {
permission = tools.PermissionAllow
}
return tools.Safety{SideEffect: tools.SideEffectNetwork, Permission: permission, AdvertiseInAuto: t.advertiseInAuto}
}
func (t cliFakeDeferredTool) Run(context.Context, map[string]any) tools.Result {
return tools.Result{Status: tools.StatusOK, Output: "ok"}
+52 -50
View File
@@ -49,6 +49,14 @@ const (
exitInterrupted = 130
)
// defaultExecMaxTurns is the headless tool-turn budget when no source sets one.
// An interactive run can be continued past the shared interactive default by the
// user; an exec run has nobody to unstick it, so evals and automation would
// truncate mid-task at 80 turns. The ceiling is the already-justified bound —
// still finite against a genuinely runaway loop, which the loop's empty-turn
// guard also cuts independently of this budget.
const defaultExecMaxTurns = config.MaxTurnsCeiling
type execOutputFormat string
type execInputFormat string
@@ -173,7 +181,7 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
// 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()) }()
modelregistry.StartModelsDevRefresh()
// A mode seeds model/effort/max-turns/tool filters as a preset. Expand it up
// front — before tool-filter validation and the --list-tools branch — so a
@@ -320,6 +328,7 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
}
return writeExecProviderError(stdout, stderr, options.outputFormat, "provider_error", err.Error())
}
resolved.MaxTurns = execTurnBudget(resolved)
var displacedMaxTurns int
resolved.MaxTurns, displacedMaxTurns = applyProfileTurnBudget(execProfile, options.maxTurns, resolved.MaxTurns)
execScope, err := sandbox.NewScope(workspaceRoot, append(append([]string{}, resolved.Sandbox.AdditionalWriteRoots...), options.addDirs...))
@@ -338,6 +347,17 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
} else if notice != "" {
_, _ = fmt.Fprintln(stderr, "[zero] "+notice)
}
// Before any tool runs, and before the MCP servers and plugins below start
// through the same sandbox, so a run that goes ahead with reduced isolation
// says so where its output starts, not only when someone runs `zero doctor`.
// A notice that cannot be written stops the run, as the image and effort
// notices further down do: going ahead would mean reduced isolation and no
// warning delivered.
if notice := sandboxEngine.DegradedNotice(); notice != "" {
if _, err := fmt.Fprintln(stderr, "[zero] "+notice); err != nil {
return exitCrash
}
}
executionRunner.SetPreparer(sandboxEngine)
if permissionMode != agent.PermissionModePlan {
mcpRuntime, mcpSkip, err = registerMCPToolsForWorkspace(context.Background(), workspaceRoot, registry, deps, execMCPAutonomy(options), trustRoot, executionRunner)
@@ -442,57 +462,22 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
// the resolved model and is reassigned by the model switcher on a mid-run
// escalation so post-switch turns are attributed to the escalated model.
currentModel := resolved.Provider.Model
// Optimized OpenAI turn sessions (ZERO_OPENAI_TURN_SESSION, default off). nil
// when gated off or ineligible, unless a configured RPM limiter needs to
// wrap the default adapter. The limiter is shared across model switches.
sessionOptions := providers.Options{ModelRPM: zeroruntime.NewModelRPMLimiter(resolved.ModelRPM)}
turnSessions, _ := providers.ConfiguredTurnSessions(resolved.Provider, provider, sessionOptions)
// Both switchers come from one shared builder so exec and the interactive TUI
// cannot drift on the nil contracts the agent loop depends on. The session
// switcher preserves the starting transport and shares the RPM limiter.
var modelSwitcher func(context.Context, string) (agent.Provider, error)
if options.allowEscalation {
modelSwitcher = func(_ context.Context, modelID string) (agent.Provider, error) {
// deps.newProvider is wrapped (fillAppDeps) to apply the stored key, so
// the escalated provider is authenticated even though resolved.Provider
// is the pure profile — no per-site key handling here.
switchedProfile := resolved.Provider
switchedProfile.Model = modelID
switchedProvider, err := deps.newProvider(switchedProfile)
if err != nil {
return nil, err
}
// Mirror the agent loop's switch guard (it only reassigns the provider
// when newProvider != nil). Updating currentModel only on a non-nil
// provider keeps usage attribution consistent with whether the loop
// actually switched — a (nil, nil) return leaves both untouched.
if switchedProvider != nil {
currentModel = modelID
}
return switchedProvider, nil
}
}
// Optimized OpenAI turn sessions (ZERO_OPENAI_TURN_SESSION, default off).
// nil when gated off or the profile is ineligible: agent.Run then wraps the
// provider in its default adapter — the exact code path of today. The
// session switcher is installed only when the run START is optimized, so
// the legacy ModelSwitcher path above stays untouched otherwise.
turnSessions, _ := providers.OptimizedTurnSessions(resolved.Provider, provider, providers.Options{})
var modelSessionSwitcher func(context.Context, string) (zeroruntime.TurnSessionProvider, error)
if options.allowEscalation && turnSessions != nil {
modelSessionSwitcher = func(_ context.Context, modelID string) (zeroruntime.TurnSessionProvider, error) {
switchedProfile := resolved.Provider
switchedProfile.Model = modelID
switchedProvider, err := deps.newProvider(switchedProfile)
if err != nil {
return nil, err
}
if switchedProvider == nil {
// The loop treats a nil session source as "no swap" — mirror the
// legacy closure's (nil, nil) contract.
return nil, nil
}
currentModel = modelID
if optimized, ok := providers.OptimizedTurnSessions(switchedProfile, switchedProvider, providers.Options{}); ok {
return optimized, nil
}
// Ineligible switch target: default adapter, but with the switched
// model's resolved capability projection preserved.
return providers.DefaultTurnSessions(switchedProfile, switchedProvider, providers.Options{}), nil
}
if options.allowEscalation {
modelSwitcher, modelSessionSwitcher = providers.EscalationSwitchers(
resolved.Provider, provider, deps.newProvider,
func(modelID string) { currentModel = modelID },
sessionOptions,
)
}
runMetadata, err := resolveExecRunMetadata(resolved.Provider)
@@ -686,6 +671,9 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
ContextWindowFor: func(modelID string) int {
return modelregistry.AgentContextWindow(modelContextWindow(modelRegistry, modelID))
},
SupportsVision: func(modelID string) bool {
return modelregistry.SupportsVision(modelRegistry, modelID)
},
ReasoningEffort: forwardEffort,
Trace: traceRecorder,
Cwd: workspaceRoot,
@@ -1257,6 +1245,20 @@ func specProfileEffortFilled(effortFilled bool, specReasoningEffort string) bool
return effortFilled && strings.TrimSpace(specReasoningEffort) == ""
}
// execTurnBudget applies the headless default to the resolved turn budget: an
// interactive run can be continued past the shared default by the user, but an
// exec run has nobody to unstick it, so an unconfigured budget starts at the
// documented ceiling instead of truncating mid-task. Explicit sources
// (--max-turns, mode presets, ZERO_MAX_TURNS, config files) resolve as
// MaxTurnsSet and pass through; an exec-profile budget still wins via
// applyProfileTurnBudget afterward.
func execTurnBudget(resolved config.ResolvedConfig) int {
if resolved.MaxTurnsSet {
return resolved.MaxTurns
}
return defaultExecMaxTurns
}
// applyProfileTurnBudget decides the run's turn budget once config is resolved.
// The profile displaces the RESOLVED value, not the flag: a pinned budget
// always wins and the profile backs off entirely, while an env/config budget
+11 -11
View File
@@ -70,8 +70,8 @@ func TestRunExecListsFilteredToolsWithoutPromptOrProvider(t *testing.T) {
if exitCode != exitSuccess {
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
}
if stderr.Len() != 0 {
t.Fatalf("expected empty stderr, got %q", stderr.String())
if stderr.String() != hostSandboxNotice(t) {
t.Fatalf("expected no stderr beyond this host's sandbox notice, got %q", stderr.String())
}
if providerBuilt {
t.Fatal("provider should not be constructed for --list-tools")
@@ -108,8 +108,8 @@ func TestRunExecListsToolsAsStreamJSONWhenRequested(t *testing.T) {
if exitCode != exitSuccess {
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
}
if stderr.Len() != 0 {
t.Fatalf("expected empty stderr, got %q", stderr.String())
if stderr.String() != hostSandboxNotice(t) {
t.Fatalf("expected no stderr beyond this host's sandbox notice, got %q", stderr.String())
}
if providerBuilt {
t.Fatal("provider should not be constructed for --list-tools")
@@ -176,8 +176,8 @@ func TestRunExecListsMCPToolsWithoutProviderConstruction(t *testing.T) {
if !closed {
t.Fatal("MCP runtime was not closed after --list-tools")
}
if stderr.Len() != 0 {
t.Fatalf("expected empty stderr, got %q", stderr.String())
if stderr.String() != hostSandboxNotice(t) {
t.Fatalf("expected no stderr beyond this host's sandbox notice, got %q", stderr.String())
}
if !strings.Contains(stdout.String(), "mcp_docs_lookup") || !strings.Contains(stdout.String(), "Lookup documentation") {
t.Fatalf("expected MCP tool in list output, got %q", stdout.String())
@@ -323,8 +323,8 @@ func TestRunExecStreamJSONOutputsRunEndAndRecordsSession(t *testing.T) {
if exitCode != exitSuccess {
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
}
if stderr.Len() != 0 {
t.Fatalf("expected empty stderr, got %q", stderr.String())
if stderr.String() != hostSandboxNotice(t) {
t.Fatalf("expected no stderr beyond this host's sandbox notice, got %q", stderr.String())
}
events := decodeJSONLines(t, stdout.String())
@@ -388,8 +388,8 @@ func TestRunExecStreamJSONEmitsAndRecordsPermissionEvents(t *testing.T) {
if exitCode != exitSuccess {
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
}
if stderr.Len() != 0 {
t.Fatalf("expected empty stderr, got %q", stderr.String())
if stderr.String() != hostSandboxNotice(t) {
t.Fatalf("expected no stderr beyond this host's sandbox notice, got %q", stderr.String())
}
events := decodeJSONLines(t, stdout.String())
@@ -460,7 +460,7 @@ func TestRunExecStreamJSONEmitsAndRecordsPermissionEvents(t *testing.T) {
if exitCode != exitSuccess {
t.Fatalf("approved exec exit = %d, stderr = %q", exitCode, stderr.String())
}
if stderr.String() != "" {
if stderr.String() != hostSandboxNotice(t) {
t.Fatalf("approved exec wrote stderr = %q", stderr.String())
}
approvedEvents := decodeJSONLines(t, stdout.String())
+9 -6
View File
@@ -118,12 +118,15 @@ func runExecSpecDraft(run execSpecDraftRun) int {
hookDispatcher, hookSkip := newHookDispatcher(run.workspaceRoot, run.trustRoot, execution.NewRunner(run.sandboxEngine))
emitTrustNotice(run.stderr, hookSkip, run.mcpSkip)
result, err := agent.Run(runCtx, run.prompt, run.provider, agent.Options{
MaxTurns: run.resolved.MaxTurns,
ContextWindow: resolveAgentContextWindow(runCtx, run.modelRegistry, run.resolved.Provider),
SessionID: draftSession.SessionID,
SessionTitle: run.sessionTitle,
ProviderName: run.resolved.Provider.Name,
Model: run.resolved.Provider.Model,
MaxTurns: run.resolved.MaxTurns,
ContextWindow: resolveAgentContextWindow(runCtx, run.modelRegistry, run.resolved.Provider),
SessionID: draftSession.SessionID,
SessionTitle: run.sessionTitle,
ProviderName: run.resolved.Provider.Name,
Model: run.resolved.Provider.Model,
SupportsVision: func(modelID string) bool {
return modelregistry.SupportsVision(run.modelRegistry, modelID)
},
ReasoningEffort: run.reasoningEffort,
Profile: run.profilePolicy,
Cwd: run.workspaceRoot,
+40 -20
View File
@@ -568,8 +568,8 @@ func TestRunExecAcceptsLegacyModelProfileFlags(t *testing.T) {
if !strings.Contains(stdout, "hello") {
t.Fatalf("expected prompt output, got %q", stdout)
}
if stderr != "" {
t.Fatalf("expected empty stderr, got %q", stderr)
if stderr != hostSandboxNotice(t) {
t.Fatalf("expected no stderr beyond this host's sandbox notice, got %q", stderr)
}
}
@@ -696,8 +696,8 @@ func TestRunExecAssemblesInlineAndFilePromptRelativeToCwd(t *testing.T) {
if !strings.Contains(stdout, "inline prompt\n\nfile prompt") {
t.Fatalf("expected inline and file prompt joined by blank line, got %q", stdout)
}
if stderr != "" {
t.Fatalf("expected empty stderr, got %q", stderr)
if stderr != hostSandboxNotice(t) {
t.Fatalf("expected no stderr beyond this host's sandbox notice, got %q", stderr)
}
}
@@ -715,8 +715,8 @@ func TestRunExecAcceptsFileOnlyPrompt(t *testing.T) {
if !strings.Contains(stdout, "file only prompt") {
t.Fatalf("expected file prompt output, got %q", stdout)
}
if stderr != "" {
t.Fatalf("expected empty stderr, got %q", stderr)
if stderr != hostSandboxNotice(t) {
t.Fatalf("expected no stderr beyond this host's sandbox notice, got %q", stderr)
}
}
@@ -798,8 +798,8 @@ func TestRunExecJSONOutputsNDJSONEvents(t *testing.T) {
if exitCode != 0 {
t.Fatalf("expected exit code 0, got %d: %s", exitCode, stderr)
}
if stderr != "" {
t.Fatalf("expected empty stderr, got %q", stderr)
if stderr != hostSandboxNotice(t) {
t.Fatalf("expected no stderr beyond this host's sandbox notice, got %q", stderr)
}
events := decodeJSONLines(t, stdout)
@@ -833,8 +833,8 @@ func TestRunExecResolvesCanonicalModelAlias(t *testing.T) {
if exitCode != 0 {
t.Fatalf("expected exit code 0, got %d: %s", exitCode, stderr)
}
if stderr != "" {
t.Fatalf("expected empty stderr for active model, got %q", stderr)
if stderr != hostSandboxNotice(t) {
t.Fatalf("expected no stderr beyond this host's sandbox notice for active model, got %q", stderr)
}
events := decodeJSONLines(t, stdout)
if got := events[0]["model"]; got != "gpt-4.1" {
@@ -926,8 +926,8 @@ func TestRunExecJSONUnsafeOutputsWarningEvent(t *testing.T) {
if exitCode != 0 {
t.Fatalf("expected exit code 0, got %d: %s", exitCode, stderr)
}
if stderr != "" {
t.Fatalf("expected empty stderr, got %q", stderr)
if stderr != hostSandboxNotice(t) {
t.Fatalf("expected no stderr beyond this host's sandbox notice, got %q", stderr)
}
events := decodeJSONLines(t, stdout)
@@ -970,10 +970,10 @@ func TestRunExecUsesProjectConfigAndOpenAICompatibleProvider(t *testing.T) {
"name": "local",
"provider_kind": "openai-compatible",
"base_url": "` + server.URL + `",
"api_key": "sk-local",
"model": "local-model"
}]
}`
"api_key": "sk-local",
"model": "local-model"
}]
}`
if err := os.WriteFile(filepath.Join(configDir, "config.json"), []byte(writeConfig), 0o600); err != nil {
t.Fatal(err)
}
@@ -988,8 +988,8 @@ func TestRunExecUsesProjectConfigAndOpenAICompatibleProvider(t *testing.T) {
if strings.TrimSpace(stdout.String()) != "provider ok" {
t.Fatalf("stdout = %q, want provider response", stdout.String())
}
if stderr.Len() != 0 {
t.Fatalf("expected empty stderr, got %q", stderr.String())
if stderr.String() != hostSandboxNotice(t) {
t.Fatalf("expected no stderr beyond this host's sandbox notice, got %q", stderr.String())
}
if gotAuth != "Bearer sk-local" {
t.Fatalf("Authorization = %q, want project config token", gotAuth)
@@ -1204,8 +1204,8 @@ func TestRunExecAutoHighEmitsUnsafeWarning(t *testing.T) {
if exitCode != exitSuccess {
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr)
}
if stderr != "" {
t.Fatalf("expected empty stderr, got %q", stderr)
if stderr != hostSandboxNotice(t) {
t.Fatalf("expected no stderr beyond this host's sandbox notice, got %q", stderr)
}
events := decodeJSONLines(t, stdout)
@@ -1975,3 +1975,23 @@ func TestRunExecTopTierDeclineNoSwitch(t *testing.T) {
t.Fatalf("provider model = %q, want claude-opus-4.1", providerModels[0])
}
}
func TestExecTurnBudgetDefaultsToCeilingUnlessSet(t *testing.T) {
cases := []struct {
name string
resolved config.ResolvedConfig
want int
}{
// A headless run has no user to continue past the shared interactive
// default, so an unconfigured budget starts at the ceiling.
{"unconfigured default", config.ResolvedConfig{MaxTurns: 80, MaxTurnsSet: false}, config.MaxTurnsCeiling},
// A user who configured 80 on purpose (e.g. cost control in CI) keeps 80.
{"explicit default value", config.ResolvedConfig{MaxTurns: 80, MaxTurnsSet: true}, 80},
{"configured value passes through", config.ResolvedConfig{MaxTurns: 42, MaxTurnsSet: true}, 42},
}
for _, tc := range cases {
if got := execTurnBudget(tc.resolved); got != tc.want {
t.Fatalf("%s: execTurnBudget = %d, want %d", tc.name, got, tc.want)
}
}
}
+1 -1
View File
@@ -69,7 +69,7 @@ func TestRunExecListToolsHonorsDisabledLocalControlConfig(t *testing.T) {
if exitCode != exitSuccess {
t.Fatalf("exitCode = %d stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String())
}
if stderr.Len() != 0 {
if stderr.String() != hostSandboxNotice(t) {
t.Fatalf("stderr = %q, want empty", stderr.String())
}
var payload struct {
+77 -3
View File
@@ -29,6 +29,8 @@ type mcpWritableConfig struct {
serverRaw map[string]json.RawMessage
}
var lockMCPConfigFile = config.LockFile
// projectMCPConfigExists reports whether the workspace's project ./.zero/config.json
// declares any MCP servers, so the trust notice fires only when project MCP config was
// actually skipped (mirroring projectHooksFileExists / projectPluginsDirExists). A
@@ -50,7 +52,7 @@ func projectMCPConfigExists(workspaceRoot string) bool {
return len(fc.MCP.Servers) > 0
}
func runMCPAdd(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
func runMCPAdd(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) (exitCode int) {
options, help, err := parseMCPAddArgs(args)
if err != nil {
return writeExecUsageError(stderr, err.Error())
@@ -66,6 +68,26 @@ func runMCPAdd(args []string, stdout io.Writer, stderr io.Writer, deps appDeps)
if err != nil {
return writeAppError(stderr, "failed to resolve user config: "+err.Error(), exitCrash)
}
// This edits the same user config document the config package mutates, with
// the same read-modify-write + rename shape, so it takes the same
// cross-process lock. Without it, an MCP config edit racing a provider or
// preference write would silently drop whichever landed first (issue #832).
unlock, err := lockMCPConfigFile(configPath)
if err != nil {
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
}
released := false
defer func() {
if released {
return
}
// A failed release leaves the lock held for the rest of the process, so
// exiting success here would claim a state the next config write cannot
// reproduce. It must not mask a failure this command already reported.
if releaseErr := unlock(); releaseErr != nil && exitCode == exitSuccess {
exitCode = writeAppError(stderr, redaction.ErrorMessage(releaseErr, redaction.Options{}), exitCrash)
}
}()
cfg, err := readMCPWritableConfig(configPath)
if err != nil {
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
@@ -80,6 +102,10 @@ func runMCPAdd(args []string, stdout io.Writer, stderr io.Writer, deps appDeps)
if err := writeMCPWritableConfig(configPath, cfg); err != nil {
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
}
if err := unlock(); err != nil {
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
}
released = true
if options.json {
payload := struct {
@@ -109,7 +135,7 @@ func runMCPAdd(args []string, stdout io.Writer, stderr io.Writer, deps appDeps)
return exitSuccess
}
func runMCPRemove(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
func runMCPRemove(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) (exitCode int) {
options, positional, help, err := parseMCPConfigPositionalCommand(args, "remove")
if err != nil {
return writeExecUsageError(stderr, err.Error())
@@ -132,6 +158,26 @@ func runMCPRemove(args []string, stdout io.Writer, stderr io.Writer, deps appDep
if err != nil {
return writeAppError(stderr, "failed to resolve user config: "+err.Error(), exitCrash)
}
// This edits the same user config document the config package mutates, with
// the same read-modify-write + rename shape, so it takes the same
// cross-process lock. Without it, an MCP config edit racing a provider or
// preference write would silently drop whichever landed first (issue #832).
unlock, err := lockMCPConfigFile(configPath)
if err != nil {
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
}
released := false
defer func() {
if released {
return
}
// A failed release leaves the lock held for the rest of the process, so
// exiting success here would claim a state the next config write cannot
// reproduce. It must not mask a failure this command already reported.
if releaseErr := unlock(); releaseErr != nil && exitCode == exitSuccess {
exitCode = writeAppError(stderr, redaction.ErrorMessage(releaseErr, redaction.Options{}), exitCrash)
}
}()
cfg, err := readMCPWritableConfig(configPath)
if err != nil {
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
@@ -145,6 +191,10 @@ func runMCPRemove(args []string, stdout io.Writer, stderr io.Writer, deps appDep
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
}
}
if err := unlock(); err != nil {
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
}
released = true
if options.json {
payload := struct {
@@ -167,7 +217,7 @@ func runMCPRemove(args []string, stdout io.Writer, stderr io.Writer, deps appDep
return exitSuccess
}
func runMCPToggle(args []string, stdout io.Writer, stderr io.Writer, deps appDeps, disabled bool) int {
func runMCPToggle(args []string, stdout io.Writer, stderr io.Writer, deps appDeps, disabled bool) (exitCode int) {
commandName := "enable"
if disabled {
commandName = "disable"
@@ -194,6 +244,26 @@ func runMCPToggle(args []string, stdout io.Writer, stderr io.Writer, deps appDep
if err != nil {
return writeAppError(stderr, "failed to resolve user config: "+err.Error(), exitCrash)
}
// This edits the same user config document the config package mutates, with
// the same read-modify-write + rename shape, so it takes the same
// cross-process lock. Without it, an MCP config edit racing a provider or
// preference write would silently drop whichever landed first (issue #832).
unlock, err := lockMCPConfigFile(configPath)
if err != nil {
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
}
released := false
defer func() {
if released {
return
}
// A failed release leaves the lock held for the rest of the process, so
// exiting success here would claim a state the next config write cannot
// reproduce. It must not mask a failure this command already reported.
if releaseErr := unlock(); releaseErr != nil && exitCode == exitSuccess {
exitCode = writeAppError(stderr, redaction.ErrorMessage(releaseErr, redaction.Options{}), exitCrash)
}
}()
cfg, err := readMCPWritableConfig(configPath)
if err != nil {
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
@@ -210,6 +280,10 @@ func runMCPToggle(args []string, stdout io.Writer, stderr io.Writer, deps appDep
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
}
}
if err := unlock(); err != nil {
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
}
released = true
if options.json {
payload := struct {
+221
View File
@@ -0,0 +1,221 @@
package cli
import (
"bytes"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/Gitlawb/zero/internal/config"
)
type mcpConfigLockCase struct {
name string
args []string
disabled bool
server bool
}
func mcpConfigLockCases() []mcpConfigLockCase {
return []mcpConfigLockCase{
{name: "add", args: []string{"mcp", "add", "docs", "--", "docs-mcp"}},
{name: "remove", args: []string{"mcp", "remove", "docs"}, server: true},
{name: "enable", args: []string{"mcp", "enable", "docs"}, server: true, disabled: true},
{name: "disable", args: []string{"mcp", "disable", "docs"}, server: true},
}
}
func seedMCPConfigLockCase(t *testing.T, path string, testCase mcpConfigLockCase) {
t.Helper()
servers := map[string]config.MCPServerConfig{}
if testCase.server {
servers["docs"] = config.MCPServerConfig{Type: "stdio", Command: "docs-mcp", Disabled: testCase.disabled}
}
writeMCPCommandConfig(t, path, config.FileConfig{
ActiveProvider: "seed",
Providers: []config.ProviderProfile{{Name: "seed", Model: "seed-model"}},
MCP: config.MCPConfig{Servers: servers},
})
}
func assertMCPConfigLockMutation(t *testing.T, path string, testCase mcpConfigLockCase) {
t.Helper()
cfg := readMCPCommandConfig(t, path)
server, exists := cfg.MCP.Servers["docs"]
switch testCase.name {
case "add":
if !exists {
t.Fatal("mcp add did not persist the server")
}
case "remove":
if exists {
t.Fatal("mcp remove left the server configured")
}
case "enable":
if !exists || server.Disabled {
t.Fatalf("mcp enable result = %+v, exists=%v", server, exists)
}
case "disable":
if !exists || !server.Disabled {
t.Fatalf("mcp disable result = %+v, exists=%v", server, exists)
}
}
if cfg.Preferences.Theme != "dracula" {
t.Errorf("theme update was lost: theme = %q, want dracula", cfg.Preferences.Theme)
}
if cfg.ActiveProvider != "seed" || len(cfg.Providers) != 1 || cfg.Providers[0].Name != "seed" {
t.Errorf("seeded provider was lost: active=%q providers=%#v", cfg.ActiveProvider, cfg.Providers)
}
}
// TestRunMCPConfigCommandsParticipateInConfigLock covers the half of issue #832
// that lives outside internal/config. MCP config commands read the SAME user
// config document, edit it, and republish it with the same temp-file+rename
// shape as the config package's mutators. Locking only the config package would
// leave these writers free to clobber a concurrent provider or preference
// update, and be clobbered by one, with the file still valid JSON afterwards.
//
// Racing the two writers and hoping to observe a lost update is unreliable —
// the interleaving that loses one is narrow, and the test passed consistently
// against the unlocked code. So this asserts the property that actually matters
// and is deterministic: while the config lock is held elsewhere, the MCP
// writer's update CANNOT land. Once released it completes, and both updates
// survive.
func TestRunMCPConfigCommandsParticipateInConfigLock(t *testing.T) {
for _, testCase := range mcpConfigLockCases() {
t.Run(testCase.name, func(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "zero", "config.json")
seedMCPConfigLockCase(t, configPath, testCase)
before, err := os.ReadFile(configPath)
if err != nil {
t.Fatal(err)
}
unlock, err := config.LockFile(configPath)
if err != nil {
t.Fatalf("acquire config lock: %v", err)
}
released := false
release := func() {
if !released {
released = true
if err := unlock(); err != nil {
t.Fatalf("release config lock: %v", err)
}
}
}
defer release()
original := lockMCPConfigFile
lockAttempted := make(chan struct{})
lockMCPConfigFile = func(path string) (func() error, error) {
close(lockAttempted)
return original(path)
}
t.Cleanup(func() { lockMCPConfigFile = original })
var stdout, stderr bytes.Buffer
done := make(chan int, 1)
go func() {
done <- runWithDeps(testCase.args, &stdout, &stderr, appDeps{
userConfigPath: func() (string, error) { return configPath, nil },
})
}()
select {
case <-lockAttempted:
case <-time.After(30 * time.Second):
t.Fatalf("mcp %s did not attempt config lock acquisition", testCase.name)
}
for range 20 {
after, err := os.ReadFile(configPath)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(after, before) {
t.Fatalf("mcp %s changed config while its lock was held", testCase.name)
}
select {
case exitCode := <-done:
t.Fatalf("mcp %s completed (exit %d) while its lock was held", testCase.name, exitCode)
default:
}
time.Sleep(5 * time.Millisecond)
}
release()
select {
case exitCode := <-done:
if exitCode != exitSuccess {
t.Fatalf("mcp %s exitCode = %d stderr=%s", testCase.name, exitCode, stderr.String())
}
case <-time.After(30 * time.Second):
t.Fatalf("mcp %s did not finish after the config lock was released", testCase.name)
}
if _, err := config.SetTheme(configPath, "dracula"); err != nil {
t.Fatalf("SetTheme: %v", err)
}
assertMCPConfigLockMutation(t, configPath, testCase)
})
}
}
func TestRunMCPConfigCommandsReportLockAcquisitionFailure(t *testing.T) {
sentinel := errors.New("injected lock acquisition failure")
original := lockMCPConfigFile
lockMCPConfigFile = func(string) (func() error, error) { return nil, sentinel }
t.Cleanup(func() { lockMCPConfigFile = original })
for _, testCase := range mcpConfigLockCases() {
t.Run(testCase.name, func(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "zero", "config.json")
seedMCPConfigLockCase(t, configPath, testCase)
before, err := os.ReadFile(configPath)
if err != nil {
t.Fatal(err)
}
var stdout, stderr bytes.Buffer
exitCode := runWithDeps(testCase.args, &stdout, &stderr, appDeps{
userConfigPath: func() (string, error) { return configPath, nil },
})
if exitCode != exitCrash || stdout.Len() != 0 || !strings.Contains(stderr.String(), sentinel.Error()) {
t.Fatalf("exit=%d stdout=%q stderr=%q, want lock error without success output", exitCode, stdout.String(), stderr.String())
}
after, err := os.ReadFile(configPath)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(after, before) {
t.Fatal("command changed config after lock acquisition failed")
}
})
}
}
func TestRunMCPConfigCommandsReleaseBeforeSuccessOutput(t *testing.T) {
sentinel := errors.New("injected lock release failure")
original := lockMCPConfigFile
lockMCPConfigFile = func(string) (func() error, error) {
return func() error { return sentinel }, nil
}
t.Cleanup(func() { lockMCPConfigFile = original })
for _, testCase := range mcpConfigLockCases() {
t.Run(testCase.name, func(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "zero", "config.json")
seedMCPConfigLockCase(t, configPath, testCase)
var stdout, stderr bytes.Buffer
exitCode := runWithDeps(testCase.args, &stdout, &stderr, appDeps{
userConfigPath: func() (string, error) { return configPath, nil },
})
if exitCode != exitCrash || stdout.Len() != 0 || !strings.Contains(stderr.String(), sentinel.Error()) {
t.Fatalf("exit=%d stdout=%q stderr=%q, want unlock error before success output", exitCode, stdout.String(), stderr.String())
}
})
}
}
+12 -10
View File
@@ -31,6 +31,14 @@ type mcpToolListItem struct {
// into the one-line trust notice (mirroring the hooks and plugins chokepoints);
// otherwise a workspace whose only project config is MCP would be gated silently.
func registerMCPToolsForWorkspace(ctx context.Context, workspaceRoot string, registry *tools.Registry, deps appDeps, autonomy mcp.PermissionAutonomy, trustRoot string, runners ...*execution.Runner) (mcpToolRuntime, trustSkip, error) {
options := mcp.RegisterOptions{}
if len(runners) > 0 {
options.Execution = runners[0]
}
return registerMCPToolsForWorkspaceWithOptions(ctx, workspaceRoot, registry, deps, autonomy, trustRoot, options)
}
func registerMCPToolsForWorkspaceWithOptions(ctx context.Context, workspaceRoot string, registry *tools.Registry, deps appDeps, autonomy mcp.PermissionAutonomy, trustRoot string, options mcp.RegisterOptions) (mcpToolRuntime, trustSkip, error) {
excludeProject, trustCheckErrored := resolveTrust(trustRoot)
skip := trustSkip{
excludedProjectConfig: excludeProject && projectMCPConfigExists(workspaceRoot),
@@ -47,16 +55,10 @@ func registerMCPToolsForWorkspace(ctx context.Context, workspaceRoot string, reg
if err != nil {
return nil, skip, err
}
var runner *execution.Runner
if len(runners) > 0 {
runner = runners[0]
}
runtime, err := deps.registerMCPTools(ctx, registry, cfg, mcp.RegisterOptions{
PermissionStore: store,
Autonomy: autonomy,
Execution: runner,
WorkspaceRoot: workspaceRoot,
})
options.PermissionStore = store
options.Autonomy = autonomy
options.WorkspaceRoot = workspaceRoot
runtime, err := deps.registerMCPTools(ctx, registry, cfg, options)
return runtime, skip, err
}
+83
View File
@@ -0,0 +1,83 @@
package cli
import (
"bytes"
"context"
"errors"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/mcp"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/tui"
"github.com/Gitlawb/zero/internal/zeroruntime"
"strings"
"testing"
)
type rpmExecProvider struct{ calls int }
func (p *rpmExecProvider) StreamCompletion(context.Context, zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
p.calls++
ch := make(chan zeroruntime.StreamEvent, 4)
if p.calls == 1 {
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "fixture", ToolName: "read_file"}
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "fixture", ArgumentsFragment: `{"path":"missing-fixture.txt"}`}
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "fixture"}
} else {
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventText, Content: "unexpected second completion"}
}
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone}
close(ch)
return ch, nil
}
func rpmTestDeps(t *testing.T, p *rpmExecProvider) appDeps {
t.Helper()
root := t.TempDir()
for _, k := range []string{"HOME", "USERPROFILE", "APPDATA", "LOCALAPPDATA", "XDG_CONFIG_HOME", "XDG_CACHE_HOME", "XDG_DATA_HOME", "XDG_STATE_HOME"} {
t.Setenv(k, root)
}
return appDeps{getwd: func() (string, error) { return root, nil }, resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
c := execResolvedConfig()
c.ModelRPM = map[string]int{c.Provider.Model: 1}
return c, nil
}, newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) { return p, nil }, registerMCPTools: func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) {
return noopMCPRuntime{}, nil
}}
}
func TestRunExecRPMRefusesSecondCompletion(t *testing.T) {
p := &rpmExecProvider{}
deps := rpmTestDeps(t, p)
var out, err bytes.Buffer
code := runWithDeps([]string{"exec", "fixture"}, &out, &err, deps)
if code != exitProvider || p.calls != 1 || !strings.Contains(err.String(), "Local modelRPM cap reached") {
t.Fatalf("exit=%d calls=%d stdout=%s stderr=%s", code, p.calls, out.String(), err.String())
}
}
func TestInteractiveRPMFactorySharesWindow(t *testing.T) {
p := &rpmExecProvider{}
deps := rpmTestDeps(t, p)
launched := false
deps.runTUI = func(ctx context.Context, o tui.Options) int {
launched = true
if o.ModelRPM == nil {
t.Fatal("TUI escalation lacks limiter")
}
for i := 0; i < 2; i++ {
ss := o.NewTurnSessionProvider(o.ProviderProfile, p)
s, e := ss.OpenTurnSession(ctx)
if e != nil {
t.Fatal(e)
}
_, e = s.Stream(ctx, zeroruntime.CompletionRequest{})
var hit *zeroruntime.RPMLimitError
if (i == 0 && e != nil) || (i == 1 && !errors.As(e, &hit)) {
t.Fatalf("run=%d err=%v", i, e)
}
}
return exitSuccess
}
var out, err bytes.Buffer
code := runWithDeps(nil, &out, &err, deps)
if !launched || code != exitSuccess || p.calls != 1 {
t.Fatalf("launched=%v exit=%d calls=%d stderr=%s", launched, code, p.calls, err.String())
}
}
+117
View File
@@ -0,0 +1,117 @@
package cli
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"github.com/Gitlawb/zero/internal/provideronboarding"
"mvdan.cc/sh/v3/shell"
)
func TestLocalAdoptionSavesAndReloadsEligibleModel(t *testing.T) {
const wantModel = "-loaded chat model"
requests := make(chan string, 4)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/v1/models":
w.Header().Set("Content-Type", "application/json")
_, _ = fmt.Fprintf(w, `{"data":[{"id":"text-embedding-local"},{"id":%q}]}`, wantModel)
case "/v1/chat/completions":
var body struct {
Model string `json:"model"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Errorf("decode completion request: %v", err)
w.WriteHeader(http.StatusBadRequest)
return
}
if r.Header.Get("Authorization") != "" {
t.Error("local adoption unexpectedly sent authorization")
}
select {
case requests <- body.Model:
default:
t.Error("unexpected extra completion requests")
}
w.Header().Set("Content-Type", "text/event-stream")
_, _ = fmt.Fprint(w, "data: {\"choices\":[{\"delta\":{\"content\":\"adoption ok\"},\"finish_reason\":\"stop\"}]}\n\ndata: [DONE]\n\n")
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer server.Close()
detected := provideronboarding.DetectLocalRuntimes(context.Background(), provideronboarding.LocalDetectOptions{
HTTPClient: server.Client(),
Candidates: []provideronboarding.LocalRuntime{{CatalogID: "atomic-chat-local", Name: "Atomic Chat Local", DefaultModel: "local-model", BaseURL: server.URL + "/v1"}},
})
if len(detected) != 1 {
t.Fatalf("expected one detected runtime: %+v", detected)
}
args, err := shell.Fields(detected[0].SetupAction().Command, func(string) string { return "" })
if err != nil || len(args) < 4 {
t.Fatalf("invalid adoption command: args=%q err=%v", args, err)
}
root := t.TempDir()
run := func(args ...string) string {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
childArgs := append([]string{"-test.run=^TestLocalAdoptionCLIProcess$", "--"}, args...)
cmd := exec.CommandContext(ctx, os.Args[0], childArgs...)
cmd.Dir = root
cmd.Env = []string{
"ZERO_TEST_LOCAL_ADOPTION=1", "ZERO_CRED_STORAGE=encrypted-file",
"HOME=" + root, "USERPROFILE=" + root,
"XDG_CONFIG_HOME=" + filepath.Join(root, "config"),
"XDG_CACHE_HOME=" + filepath.Join(root, "cache"),
"XDG_STATE_HOME=" + filepath.Join(root, "state"),
"APPDATA=" + filepath.Join(root, "config"),
"LOCALAPPDATA=" + filepath.Join(root, "cache"),
}
for _, key := range []string{"PATH", "SystemRoot", "WINDIR", "TMPDIR", "TEMP", "TMP"} {
if value, ok := os.LookupEnv(key); ok {
cmd.Env = append(cmd.Env, key+"="+value)
}
}
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("CLI %q failed: %v\n%s", args, err, output)
}
return string(output)
}
// The mock uses a random port; only override the endpoint, retaining all
// generated arguments for the real add parser and persistence path.
run(append(args[1:], "--base-url", server.URL+"/v1")...)
if output := run("exec", "--cwd", root, "Reply with adoption ok"); !strings.Contains(output, "adoption ok") {
t.Fatalf("completion output = %q", output)
}
select {
case model := <-requests:
if model != wantModel {
t.Fatalf("fresh process sent model %q, want eligible model %q", model, wantModel)
}
default:
t.Fatal("fresh process did not send a completion request")
}
}
func TestLocalAdoptionCLIProcess(t *testing.T) {
if os.Getenv("ZERO_TEST_LOCAL_ADOPTION") != "1" {
return
}
for i, arg := range os.Args {
if arg == "--" {
os.Exit(Run(os.Args[i+1:], os.Stdout, os.Stderr))
}
}
t.Fatal("missing helper argument separator")
}
@@ -20,6 +20,7 @@ func TestProviderCatalogRuntimeProvidersShowOnboardingSetupHints(t *testing.T) {
{id: "groq", name: "Groq", transport: "openai-compatible", defaultModel: "llama-3.3-70b-versatile"},
{id: "longcat", name: "LongCat", transport: "openai-compatible", defaultModel: "LongCat-2.0"},
{id: "fireworks", name: "Fireworks AI", transport: "openai-compatible", defaultModel: "accounts/fireworks/models/kimi-k2p7-code"},
{id: "gmicloud", name: "GMI Cloud", transport: "openai-compatible", defaultModel: "Qwen/Qwen3.8-Max"},
{id: "ollama-cloud", name: "Ollama Cloud", transport: "openai-compatible", defaultModel: "qwen3-coder:480b"},
{id: "ollama", name: "Ollama Local", transport: "openai-compatible", defaultModel: "llama3.1"},
}
+3 -1
View File
@@ -40,7 +40,7 @@ type providerDetectReport struct {
}
// runProvidersDetect probes the machine for running local, OpenAI-compatible
// model runtimes (Ollama, LM Studio) and prints a no-key adopt command for each
// model runtimes (Ollama, LM Studio, Atomic Chat) and prints a no-key adopt command for each
// one it finds, followed by the next-step actions for every already-configured
// provider. It is the onboarding-advice surface — "what can I do right now?" —
// and never errors on a machine with nothing running locally (it just reports an
@@ -146,6 +146,8 @@ func formatProviderDetectReport(report providerDetectReport) string {
}
if command := strings.TrimSpace(runtime.Action.Command); command != "" {
lines = append(lines, " "+runtime.Action.Label+": "+command)
} else if detail := strings.TrimSpace(runtime.Action.Detail); detail != "" {
lines = append(lines, " "+runtime.Action.Label+": "+detail)
}
}
+10
View File
@@ -105,3 +105,13 @@ func TestRunProvidersDetectJSONNoRuntimesActiveProvider(t *testing.T) {
t.Fatalf("expected only a Check action for an active keyed provider, got %#v", payload.Providers[0].Actions)
}
}
func TestProviderDetectShowsGuidanceWithoutCommand(t *testing.T) {
for _, models := range [][]string{nil, {"local-model"}, {"x&calc"}} {
runtime := provideronboarding.DetectedLocalRuntime{LocalRuntime: provideronboarding.LocalRuntime{CatalogID: "atomic-chat-local", Name: "Atomic Chat Local", DefaultModel: "local-model"}, Models: models}
out := formatProviderDetectReport(buildProviderDetectReport(config.ResolvedConfig{}, []provideronboarding.DetectedLocalRuntime{runtime}))
if strings.Contains(out, "providers add") || !strings.Contains(out, runtime.SetupAction().Detail) {
t.Fatalf("missing actionable guidance: %s", out)
}
}
}
+8
View File
@@ -434,6 +434,14 @@ func providerProfileForAdd(options providerAddOptions) (config.ProviderProfile,
catalogHeaders = aimlapi.WithResolvedPartnerHeader(catalogHeaders)
}
}
// Discovery fallback can pass the nonempty catalog placeholder through
// either CLI setup path. Never persist it as an Atomic Chat model.
if descriptor.ID == "atomic-chat-local" {
model := strings.TrimSpace(options.model)
if model == "" || model == "local-model" {
return config.ProviderProfile{}, fmt.Errorf("provider %q serves a locally loaded model; pass --model <id> (run `zero providers detect` to see the served model)", descriptor.ID)
}
}
profile := config.ProviderProfile{
Name: name,
ProviderKind: providerKindForDescriptor(descriptor),
+58
View File
@@ -1,9 +1,12 @@
package cli
import (
"strings"
"testing"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/provideronboarding"
"mvdan.cc/sh/v3/shell"
)
// Regression for issue #555's follow-up: `zero providers check` must not
@@ -66,3 +69,58 @@ func TestValidateProviderRuntimeReadyCustomEndpoint(t *testing.T) {
})
}
}
// atomic-chat-local without --model would persist the catalog placeholder
// "local-model", which the Atomic Chat server never serves, so the first
// completion fails. Adding it must require a real model instead.
func TestProviderProfileForAddRequiresModelForAtomicChatLocal(t *testing.T) {
if _, err := providerProfileForAdd(providerAddOptions{catalogID: "atomic-chat-local"}); err == nil {
t.Fatalf("providerProfileForAdd(atomic-chat-local, no --model) = nil error, want a require-model error")
} else if !strings.Contains(err.Error(), "--model") {
t.Fatalf("error should tell the user to pass --model, got %v", err)
}
// The interactive wizards and the no-id detect fallback resolve the model to
// the catalog DefaultModel and pass it through as a non-empty value, so the
// placeholder itself must be rejected, not just an empty --model.
if _, err := providerProfileForAdd(providerAddOptions{catalogID: "atomic-chat-local", model: "local-model"}); err == nil {
t.Fatalf("providerProfileForAdd(atomic-chat-local, --model local-model) = nil error, want reject of the catalog placeholder")
}
profile, err := providerProfileForAdd(providerAddOptions{catalogID: "atomic-chat-local", model: "unsloth/gemma-4-E2B-it-GGUF"})
if err != nil {
t.Fatalf("providerProfileForAdd(atomic-chat-local, --model) returned error: %v", err)
}
if profile.Model != "unsloth/gemma-4-E2B-it-GGUF" {
t.Fatalf("profile.Model = %q, want the explicit model", profile.Model)
}
if profile.Model == "local-model" {
t.Fatalf("profile persisted the catalog placeholder")
}
}
func TestDetectedModelActionSurvivesAddParser(t *testing.T) {
for _, catalogID := range []string{"atomic-chat-local", "lmstudio", "ollama"} {
for _, modelID := range []string{"-loaded-model", "--set-active", "--model", "-loaded model", "ordinary/model", "model with spaces"} {
t.Run(catalogID+"/"+modelID, func(t *testing.T) {
detected := provideronboarding.DetectedLocalRuntime{
LocalRuntime: provideronboarding.LocalRuntime{CatalogID: catalogID, Name: "Local Runtime", DefaultModel: "local-model"},
Models: []string{modelID},
}
command := detected.SetupAction().Command
args, err := shell.Fields(command, func(string) string { return "" })
if err != nil || len(args) < 4 {
t.Fatalf("invalid adoption command %q: %v", command, err)
}
options, help, err := parseProviderAddArgs(args[3:])
if err != nil || help {
t.Fatalf("generated adoption command rejected by add parser: %q: %v", command, err)
}
profile, err := providerProfileForAdd(options)
if err != nil || profile.Model != modelID || !options.setActive || profile.Name != "Local Runtime" {
t.Fatalf("adoption changed model or options: model=%q name=%q active=%v err=%v", profile.Model, profile.Name, options.setActive, err)
}
})
}
}
}
+211
View File
@@ -0,0 +1,211 @@
package cli
import (
"bytes"
"context"
"path/filepath"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/tui"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// rootFlagOrderings is every ordering of the given flag groups, each group
// kept intact so a flag stays next to its value.
func rootFlagOrderings(groups [][]string) [][]string {
if len(groups) == 0 {
return [][]string{{}}
}
var orderings [][]string
for index, group := range groups {
rest := append(append([][]string{}, groups[:index]...), groups[index+1:]...)
for _, tail := range rootFlagOrderings(rest) {
orderings = append(orderings, append(append([]string{}, group...), tail...))
}
}
return orderings
}
// ROOT FLAGS COMPOSE IN ANY ORDER. Each leading-flag splitter stops at the first
// token it does not own, so running them once in a fixed sequence made the
// order the operator wrote them in load-bearing: `zero --allow-escalation
// --theme auto` stranded `--theme auto` as an unknown command and exited with
// an argument error instead of launching. Every ordering of the three root
// flags, with --skip-permissions-unsafe absent or at any position, has to reach
// the TUI with all three applied.
func TestRootFlagsComposeInAnyOrder(t *testing.T) {
extra := t.TempDir()
resolvedExtra, err := filepath.EvalSymlinks(extra)
if err != nil {
t.Fatal(err)
}
groups := [][]string{{"--add-dir", extra}, {"--theme", "auto"}, {"--allow-escalation"}}
cases := rootFlagOrderings(groups)
cases = append(cases, rootFlagOrderings(append(groups, []string{"--skip-permissions-unsafe"}))...)
if len(cases) != 6+24 {
t.Fatalf("SETUP INVALID: %d orderings, want 30", len(cases))
}
for _, args := range cases {
t.Run(strings.Join(args, " "), func(t *testing.T) {
options := captureTUIOptions(t, args...)
wantMode := agent.PermissionModeAsk
for _, arg := range args {
if arg == "--skip-permissions-unsafe" {
wantMode = agent.PermissionModeUnsafe
}
}
if options.PermissionMode != wantMode {
t.Errorf("PermissionMode = %q, want %q", options.PermissionMode, wantMode)
}
if !options.AllowEscalation {
t.Error("escalation not enabled")
}
if options.Theme != "auto" {
t.Errorf("Theme = %q, want auto", options.Theme)
}
if options.AgentOptions.Sandbox == nil {
t.Fatal("no sandbox engine on the launched options")
}
roots := options.AgentOptions.Sandbox.Scope().Roots()
found := false
for _, root := range roots {
if root == resolvedExtra {
found = true
}
}
if !found {
t.Errorf("scope roots = %v, want the --add-dir root %q", roots, resolvedExtra)
}
})
}
}
// The last --theme wins even with another root flag between two of them, and a
// --theme written before --skip-permissions-unsafe reaches the TUI: the unsafe
// path used to launch with only the theme written after the flag, dropping one
// written before it.
func TestRootThemeLastOccurrenceWinsAcrossOtherFlags(t *testing.T) {
for _, testCase := range []struct {
args []string
want string
}{
{[]string{"--theme", "light", "--allow-escalation", "--theme", "auto"}, "auto"},
{[]string{"--theme", "auto", "--skip-permissions-unsafe"}, "auto"},
{[]string{"--theme", "light", "--skip-permissions-unsafe", "--theme", "auto"}, "auto"},
} {
t.Run(strings.Join(testCase.args, " "), func(t *testing.T) {
options := captureTUIOptions(t, testCase.args...)
if options.Theme != testCase.want {
t.Fatalf("Theme = %q, want %q", options.Theme, testCase.want)
}
})
}
}
// THE ROOT FLAG REACHES EXEC. A --allow-escalation written before the
// subcommand is forwarded the way --add-dir is, so `zero --allow-escalation -p
// "..."` runs with the opt-in the help text promises instead of silently
// without it. The negative control is the same run without the flag, which
// must not advertise escalate_model.
func TestRootAllowEscalationForwardsIntoExec(t *testing.T) {
for _, testCase := range []struct {
name string
args []string
}{
{"exec subcommand", []string{"--allow-escalation", "exec", "say hi"}},
{"prompt flag", []string{"--allow-escalation", "-p", "say hi"}},
} {
t.Run(testCase.name, func(t *testing.T) {
if !execAdvertisesEscalateModel(t, testCase.args) {
t.Fatalf("%v ran without escalate_model advertised: the root flag was dropped before exec", testCase.args)
}
if execAdvertisesEscalateModel(t, testCase.args[1:]) {
t.Fatalf("%v advertised escalate_model without the flag", testCase.args[1:])
}
})
}
}
// Everywhere else the flag is rejected loudly rather than discarded, the way
// --add-dir is: help and version run no agent and could only ignore it.
func TestRootAllowEscalationIsRejectedWhereNothingConsumesIt(t *testing.T) {
for _, args := range [][]string{
{"--allow-escalation", "version"},
{"--allow-escalation", "help"},
{"--allow-escalation", "completions", "bash"},
} {
t.Run(strings.Join(args, " "), func(t *testing.T) {
var stdout, stderr bytes.Buffer
launched := false
exitCode := runWithDeps(args, &stdout, &stderr, appDeps{
getwd: func() (string, error) { return t.TempDir(), nil },
runTUI: func(context.Context, tui.Options) int { launched = true; return exitSuccess },
})
if exitCode == exitSuccess || launched {
t.Fatalf("exit = %d launched = %v: the flag was discarded instead of rejected", exitCode, launched)
}
if !strings.Contains(stderr.String(), "--allow-escalation is only supported for the interactive TUI and exec") {
t.Fatalf("stderr does not name the flag: %s", stderr.String())
}
})
}
}
// execAdvertisesEscalateModel dispatches args through the root command with a
// provider that records the tools advertised on the first request, and reports
// whether escalate_model was among them.
func execAdvertisesEscalateModel(t *testing.T, args []string) bool {
t.Helper()
isolateCLIUserState(t)
provider := &toolListingProvider{}
var stdout, stderr bytes.Buffer
exitCode := runWithDeps(args, &stdout, &stderr, appDeps{
getwd: func() (string, error) { return t.TempDir(), nil },
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
return execResolvedConfig(), nil
},
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
return provider, nil
},
newSandboxStore: func() (*sandbox.GrantStore, error) {
return sandbox.NewGrantStore(sandbox.StoreOptions{FilePath: filepath.Join(t.TempDir(), "sandbox-grants.json")})
},
})
if exitCode != exitSuccess {
t.Fatalf("%v: exit = %d, stderr = %s", args, exitCode, stderr.String())
}
if provider.requests == 0 {
t.Fatalf("SETUP INVALID: %v never reached the provider", args)
}
for _, name := range provider.toolNames {
if name == "escalate_model" {
return true
}
}
return false
}
// toolListingProvider answers every request with a one-line text reply and
// keeps the tool names advertised on the first one.
type toolListingProvider struct {
requests int
toolNames []string
}
func (provider *toolListingProvider) StreamCompletion(_ context.Context, request zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
provider.requests++
if provider.requests == 1 {
for _, tool := range request.Tools {
provider.toolNames = append(provider.toolNames, tool.Name)
}
}
ch := make(chan zeroruntime.StreamEvent, 2)
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventText, Content: "hi"}
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone}
close(ch)
return ch, nil
}
@@ -0,0 +1,237 @@
package cli
import (
"bytes"
"context"
"encoding/json"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/mcp"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/tui"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// hostSandboxNotice is the degraded-sandbox line exec prints on THIS machine for
// a run under the default sandbox policy, or "" where the sandbox is not
// degraded. Exec tests that expect a quiet stderr compare against it rather than
// against "": unless a test pins a backend, the run gets the host's own, and a
// Linux runner without the sandbox helper is degraded where macOS and Windows
// are not.
func hostSandboxNotice(t *testing.T) string {
t.Helper()
engine := sandbox.NewEngine(sandbox.EngineOptions{
WorkspaceRoot: t.TempDir(),
Policy: sandbox.DefaultPolicy(),
Backend: sandbox.SelectBackend(sandbox.BackendOptions{}),
})
notice := engine.DegradedNotice()
if notice == "" {
return ""
}
return "[zero] " + notice + "\n"
}
// clearSandboxNestingMarkers keeps a test that pins the notice from inheriting
// the nesting markers when the suite itself runs inside a Zero sandbox.
func clearSandboxNestingMarkers(t *testing.T) {
t.Helper()
t.Setenv(sandbox.EnvSandboxed, "")
t.Setenv(sandbox.EnvSandboxBackend, "")
}
// unavailableTestSandbox is the backend a Linux box without the helper gets,
// pinned so a test sees the degraded case on every platform.
func unavailableTestSandbox(sandbox.BackendOptions) sandbox.Backend {
return sandbox.Backend{Name: sandbox.BackendUnavailable, Platform: "linux", Fallback: true, Message: "Linux sandbox helper is not available"}
}
const unavailableTestSandboxNotice = "[zero] Sandbox enforcement is degraded: Linux sandbox helper is not available. See `zero sandbox policy --effective`.\n"
func runExecWithSandbox(t *testing.T, args []string, backend func(sandbox.BackendOptions) sandbox.Backend, sandboxConfig config.SandboxConfig) (int, string, string) {
t.Helper()
isolateCLIUserState(t)
var stdout, stderr bytes.Buffer
cwd := t.TempDir()
exitCode := runWithDeps(args, &stdout, &stderr, appDeps{
getwd: func() (string, error) { return cwd, nil },
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
return config.ResolvedConfig{
ActiveProvider: "echo",
Provider: config.ProviderProfile{
Name: "echo",
ProviderKind: config.ProviderKindOpenAICompatible,
BaseURL: "http://127.0.0.1/v1",
Model: "echo-model",
},
MaxTurns: 3,
Sandbox: sandboxConfig,
}, nil
},
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
return echoExecProvider{}, nil
},
selectSandboxBackend: backend,
})
return exitCode, stdout.String(), stderr.String()
}
// A RUN THAT GOES AHEAD WITH REDUCED ISOLATION SAYS SO. Before this only `zero
// doctor` and `zero sandbox policy` did, so `zero exec` ran every tool with the
// native sandbox effectively off and printed nothing about it. #1041.
func TestExecSaysOnStderrWhenTheSandboxIsDegraded(t *testing.T) {
clearSandboxNestingMarkers(t)
exitCode, _, stderr := runExecWithSandbox(t, []string{"exec", "hello"}, unavailableTestSandbox, config.SandboxConfig{})
if exitCode != 0 {
t.Fatalf("exit code = %d, stderr %q", exitCode, stderr)
}
if stderr != unavailableTestSandboxNotice {
t.Fatalf("stderr = %q, want exactly the degraded notice %q", stderr, unavailableTestSandboxNotice)
}
}
// The notice goes to stderr in the JSON modes as well, so stdout stays one JSON
// object per line for whatever reads it.
func TestExecKeepsTheDegradedNoticeOffStdoutInJSONMode(t *testing.T) {
clearSandboxNestingMarkers(t)
exitCode, stdout, stderr := runExecWithSandbox(t, []string{"exec", "--output-format", "json", "hello"}, unavailableTestSandbox, config.SandboxConfig{})
if exitCode != 0 {
t.Fatalf("exit code = %d, stderr %q", exitCode, stderr)
}
if stderr != unavailableTestSandboxNotice {
t.Fatalf("stderr = %q, want exactly the degraded notice", stderr)
}
for _, line := range strings.Split(strings.TrimSpace(stdout), "\n") {
var event map[string]any
if err := json.Unmarshal([]byte(line), &event); err != nil {
t.Fatalf("stdout carries a line that is not JSON: %q (%v)", line, err)
}
}
}
// A sandbox the user turned off is their choice, not a degradation.
func TestExecSaysNothingWhenTheSandboxIsTurnedOff(t *testing.T) {
clearSandboxNestingMarkers(t)
off := false
exitCode, _, stderr := runExecWithSandbox(t, []string{"exec", "hello"}, unavailableTestSandbox, config.SandboxConfig{Enabled: &off})
if exitCode != 0 {
t.Fatalf("exit code = %d, stderr %q", exitCode, stderr)
}
if stderr != "" {
t.Fatalf("stderr = %q, want nothing for a disabled sandbox", stderr)
}
}
// countingExecProvider is echoExecProvider, counting the completions it streams.
type countingExecProvider struct {
echoExecProvider
streamed *int
}
func (provider countingExecProvider) StreamCompletion(ctx context.Context, request zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
*provider.streamed++
return provider.echoExecProvider.StreamCompletion(ctx, request)
}
// A degraded notice that cannot be written stops the run before the model is
// asked for anything, as the image and effort notices do, instead of going ahead
// with reduced isolation and no warning delivered.
func TestExecStopsWhenTheDegradedNoticeCannotBeWritten(t *testing.T) {
clearSandboxNestingMarkers(t)
isolateCLIUserState(t)
var stdout bytes.Buffer
cwd := t.TempDir()
streamed := 0
exitCode := runWithDeps([]string{"exec", "hello"}, &stdout, failingWriter{}, appDeps{
getwd: func() (string, error) { return cwd, nil },
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
return config.ResolvedConfig{
ActiveProvider: "echo",
Provider: config.ProviderProfile{
Name: "echo",
ProviderKind: config.ProviderKindOpenAICompatible,
BaseURL: "http://127.0.0.1/v1",
Model: "echo-model",
},
MaxTurns: 3,
}, nil
},
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
return countingExecProvider{streamed: &streamed}, nil
},
selectSandboxBackend: unavailableTestSandbox,
})
if exitCode != exitCrash {
t.Fatalf("exit code = %d, want %d when the degraded notice cannot be written", exitCode, exitCrash)
}
if streamed != 0 {
t.Fatalf("the model was asked %d times: the run went ahead without its warning", streamed)
}
}
// launchTUIWithSandbox runs the root command the way a bare `zero` does, with
// the sandbox backend pinned to the unavailable one, and returns the options the
// TUI would have started with.
func launchTUIWithSandbox(t *testing.T, sandboxConfig config.SandboxConfig) tui.Options {
t.Helper()
isolateCLIUserState(t)
var stdout, stderr bytes.Buffer
cwd := t.TempDir()
var launched tui.Options
exitCode := runWithDeps([]string{}, &stdout, &stderr, appDeps{
getwd: func() (string, error) { return cwd, nil },
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
return config.ResolvedConfig{MaxTurns: 12, Sandbox: sandboxConfig}, nil
},
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
t.Fatal("newProvider should not be called without a resolved provider")
return nil, nil
},
registerMCPTools: func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) {
return noopMCPRuntime{}, nil
},
selectSandboxBackend: unavailableTestSandbox,
runTUI: func(_ context.Context, options tui.Options) int {
launched = options
return 0
},
})
if exitCode != 0 {
t.Fatalf("exit code = %d, stderr %q", exitCode, stderr.String())
}
return launched
}
// The TUI shows the same notice when the session opens, taken from the engine
// its commands run through.
func TestTUILaunchCarriesTheDegradedNotice(t *testing.T) {
clearSandboxNestingMarkers(t)
for _, testCase := range []struct {
name string
sandbox config.SandboxConfig
want bool
}{
{name: "degraded", want: true},
{name: "turned off", sandbox: config.SandboxConfig{Enabled: func() *bool { off := false; return &off }()}},
} {
t.Run(testCase.name, func(t *testing.T) {
launched := launchTUIWithSandbox(t, testCase.sandbox)
var notices []string
for _, notice := range launched.StartupNotices {
if strings.TrimSpace(notice) != "" {
notices = append(notices, notice)
}
}
want := strings.TrimSuffix(strings.TrimPrefix(unavailableTestSandboxNotice, "[zero] "), "\n")
if testCase.want && (len(notices) != 1 || notices[0] != want) {
t.Fatalf("StartupNotices = %q, want exactly %q", notices, want)
}
if !testCase.want && len(notices) != 0 {
t.Fatalf("StartupNotices = %q, want none", notices)
}
})
}
}
+9 -2
View File
@@ -168,11 +168,18 @@ func TestBuildServeScopeKeepsLexicalPaths(t *testing.T) {
}
linkWorkspace := filepath.Join(base, "link-workspace")
linkExtra := filepath.Join(base, "link-extra")
// SKIPPED, NOT FAILED, WHEN THE PLATFORM WILL NOT MAKE THE LINK. Creating a
// symlink on Windows needs SeCreateSymbolicLinkPrivilege, which an ordinary
// developer account does not hold, so this reported a failure with nothing
// wrong in the code: "A required privilege is not held by the client". CI
// runners are elevated, so it only ever showed up locally, where it cost a
// round of triage before being ruled environmental. The sibling tests that
// need a link already answer this way.
if err := os.Symlink(realWorkspace, linkWorkspace); err != nil {
t.Fatal(err)
t.Skipf("symlink unavailable: %v", err)
}
if err := os.Symlink(realExtra, linkExtra); err != nil {
t.Fatal(err)
t.Skipf("symlink unavailable: %v", err)
}
scope, err := buildServeScope(linkWorkspace, []string{linkExtra})
+35 -1
View File
@@ -18,6 +18,8 @@ type sessionCommandOptions struct {
excludeTarget bool
preserveLast int
maxPromptChars int
olderThan string
dryRun bool
}
func runSessions(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
@@ -72,6 +74,11 @@ func runSessions(args []string, stdout io.Writer, stderr io.Writer, deps appDeps
return writeExecUsageError(stderr, "sessions compact-plan requires a session id")
}
return runSessionsCompactPlan(store, remaining[0], options, stdout, stderr)
case "prune":
if len(remaining) != 0 {
return writeExecUsageError(stderr, "sessions prune does not accept positional arguments")
}
return runSessionsPrune(store, options, stdout, stderr, deps)
default:
return writeExecUsageError(stderr, fmt.Sprintf("unknown sessions command %q", command))
}
@@ -91,6 +98,8 @@ func parseSessionsArgs(args []string) (string, []string, sessionCommandOptions,
options.json = true
case "--exclude-target":
options.excludeTarget = true
case "--dry-run":
options.dryRun = true
case "--kind":
value, next, err := nextFlagValue(args, index, arg)
if err != nil {
@@ -168,6 +177,21 @@ func parseSessionsArgs(args []string) (string, []string, sessionCommandOptions,
}
options.preserveLast = preserveLast
continue
case arg == "--older-than":
value, next, err := nextFlagValue(args, index, arg)
if err != nil {
return command, remaining, options, false, err
}
options.olderThan = value
index = next
continue
case strings.HasPrefix(arg, "--older-than="):
value, err := parseNonEmptySessionsFlag("--older-than", strings.TrimPrefix(arg, "--older-than="))
if err != nil {
return command, remaining, options, false, err
}
options.olderThan = value
continue
case arg == "--max-prompt-chars":
value, next, err := nextFlagValue(args, index, arg)
if err != nil {
@@ -225,7 +249,7 @@ func parseSessionKindFlag(value string) (sessions.SessionKind, error) {
func isSessionsCommand(command string) bool {
switch command {
case "list", "children", "lineage", "tree", "rewind-plan", "rewind", "compact-plan":
case "list", "children", "lineage", "tree", "rewind-plan", "rewind", "compact-plan", "prune":
return true
default:
return false
@@ -244,6 +268,9 @@ func validateSessionCommandFlags(command string, options sessionCommandOptions)
if hasCompactionFlag && command != "compact-plan" {
return execUsageError{"--preserve-last and --max-prompt-chars are only valid for sessions compact-plan"}
}
if (options.olderThan != "" || options.dryRun) && command != "prune" {
return execUsageError{"--older-than and --dry-run are only valid for sessions prune"}
}
return nil
}
@@ -532,6 +559,7 @@ Commands:
rewind-plan <id> Preview events kept and dropped by a rewind
rewind <id> Restore workspace files and truncate the log to a checkpoint
compact-plan <id> Preview events compacted and preserved by compaction
prune Remove sessions last updated before a cutoff
Flags:
--json Print JSON output
@@ -541,7 +569,13 @@ Flags:
--exclude-target Drop the target event (rewind-plan, rewind)
--preserve-last <n> Keep recent events in compact-plan
--max-prompt-chars <n> Limit compact-plan summary prompt
--older-than <age> Prune cutoff, as days (30d) or a duration (720h); at least 1d.
Without it, prune uses sessions.retentionDays from your user config.
--dry-run List what prune would remove, and why it keeps the rest
-h, --help Show this help
prune only runs when you run it. It never removes a session another Zero process
has open, or an ancestor of a session it keeps, and --dry-run shows why.
`)
return err
}
+171
View File
@@ -0,0 +1,171 @@
package cli
import (
"fmt"
"io"
"strconv"
"strings"
"time"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/redaction"
"github.com/Gitlawb/zero/internal/sessions"
)
// maxPruneDays keeps a day count from overflowing time.Duration.
const maxPruneDays = 36500
// runSessionsPrune removes sessions last updated before a cutoff, or with
// --dry-run lists what would go and why. It runs only when asked (#971).
func runSessionsPrune(store *sessions.Store, options sessionCommandOptions, stdout io.Writer, stderr io.Writer, deps appDeps) int {
olderThan, err := pruneCutoff(options, deps)
if err != nil {
return writeExecUsageError(stderr, err.Error())
}
report, err := store.Prune(sessions.PruneOptions{OlderThan: olderThan, DryRun: options.dryRun})
if err != nil {
return writeSessionCommandError(stderr, err)
}
if options.json {
if err := writePrettyJSON(stdout, redaction.RedactValue(report, redaction.Options{})); err != nil {
return exitCrash
}
} else if _, err := fmt.Fprint(stdout, formatPruneReport(report)); err != nil {
return exitCrash
}
if len(report.Failed) > 0 {
return exitCrash
}
return exitSuccess
}
// pruneCutoff is --older-than, or else sessions.retentionDays from the user's
// own config, and never below sessions.MinimumPruneAge. Project config has no
// say in it: see config.SessionsConfig.
func pruneCutoff(options sessionCommandOptions, deps appDeps) (time.Duration, error) {
var age time.Duration
if options.olderThan != "" {
parsed, err := parsePruneAge(options.olderThan)
if err != nil {
return 0, err
}
age = parsed
} else {
days := 0
if deps.userConfigPath != nil {
path, err := deps.userConfigPath()
if err != nil {
return 0, fmt.Errorf("sessions prune could not find your config: %w", err)
}
settings, err := config.ReadSessionsConfig(path)
if err != nil {
return 0, fmt.Errorf("sessions prune could not read sessions.retentionDays: %w", err)
}
days = settings.RetentionDays
}
if days == 0 {
return 0, execUsageError{"sessions prune needs a cutoff: pass --older-than (for example --older-than 30d) or set sessions.retentionDays in your user config"}
}
if days > maxPruneDays {
return 0, execUsageError{fmt.Sprintf("sessions.retentionDays %d is more than %d", days, maxPruneDays)}
}
age = time.Duration(days) * 24 * time.Hour
}
if age < sessions.MinimumPruneAge {
return 0, execUsageError{fmt.Sprintf("sessions prune does not remove anything updated in the last %d hours; use --older-than 1d or more", int(sessions.MinimumPruneAge.Hours()))}
}
return age, nil
}
// parsePruneAge accepts a whole number of days ("30d") or a Go duration
// ("720h").
func parsePruneAge(value string) (time.Duration, error) {
invalid := execUsageError{fmt.Sprintf("invalid --older-than %q: expected days like 30d or a duration like 720h", value)}
trimmed := strings.TrimSpace(value)
if days, ok := strings.CutSuffix(trimmed, "d"); ok {
count, err := strconv.Atoi(days)
if err != nil || count <= 0 || count > maxPruneDays {
return 0, invalid
}
return time.Duration(count) * 24 * time.Hour, nil
}
age, err := time.ParseDuration(trimmed)
if err != nil || age <= 0 {
return 0, invalid
}
return age, nil
}
func formatPruneReport(report sessions.PruneReport) string {
var out strings.Builder
verb := "Removed"
if report.DryRun {
verb = "Would remove"
}
if len(report.Removed) == 0 {
fmt.Fprintf(&out, "No sessions to remove: nothing last updated before %s can go.\n", report.Cutoff)
} else {
var total int64
for _, entry := range report.Removed {
total += entry.Bytes
}
fmt.Fprintf(&out, "%s %d %s last updated before %s (%s):\n", verb, len(report.Removed), plural(len(report.Removed), "session", "sessions"), report.Cutoff, formatPruneBytes(total))
for _, entry := range report.Removed {
fmt.Fprintf(&out, " %s\n", formatPruneEntry(entry, redact(entry.Title)))
}
}
if len(report.Kept) > 0 {
fmt.Fprintf(&out, "Kept %d old enough to remove:\n", len(report.Kept))
for _, entry := range report.Kept {
fmt.Fprintf(&out, " %s\n", formatPruneEntry(entry, entry.Reason))
}
}
if len(report.Failed) > 0 {
failed := "Could not remove"
if report.DryRun {
failed = "Could not check"
}
fmt.Fprintf(&out, "%s %d:\n", failed, len(report.Failed))
for _, entry := range report.Failed {
fmt.Fprintf(&out, " %s\n", formatPruneEntry(entry, redact(entry.Reason)))
}
}
if report.DryRun {
out.WriteString("Dry run: nothing was removed.\n")
}
return out.String()
}
func formatPruneEntry(entry sessions.PruneEntry, note string) string {
updated := entry.UpdatedAt
if len(updated) >= len("2006-01-02") {
updated = updated[:len("2006-01-02")]
}
line := redact(entry.SessionID) + " " + updated
if note = strings.TrimSpace(note); note != "" {
line += " " + note
}
return line
}
func formatPruneBytes(bytes int64) string {
const unit = 1024
if bytes < unit {
return fmt.Sprintf("%d B", bytes)
}
value, suffix := float64(bytes)/unit, "KB"
for _, next := range []string{"MB", "GB", "TB"} {
if value < unit {
break
}
value, suffix = value/unit, next
}
return fmt.Sprintf("%.1f %s", value, suffix)
}
func plural(count int, one, many string) string {
if count == 1 {
return one
}
return many
}
+220
View File
@@ -0,0 +1,220 @@
package cli
import (
"bytes"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/Gitlawb/zero/internal/sessions"
)
// pruneCLIFixture is a session root holding one session last written in 2020
// and one written now, neither held open, plus deps whose user config path is
// the returned file (which does not exist until a test writes it).
func pruneCLIFixture(t *testing.T) (string, string, appDeps) {
t.Helper()
root := t.TempDir()
for _, session := range []struct {
id string
at func() time.Time
}{
{"old-session", func() time.Time { return time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC) }},
{"recent-session", time.Now},
} {
store := sessions.NewStore(sessions.StoreOptions{RootDir: root, Now: session.at})
if _, err := store.Create(sessions.CreateInput{SessionID: session.id, Title: "title of " + session.id}); err != nil {
t.Fatalf("create %s: %v", session.id, err)
}
store.Release(session.id)
}
configPath := filepath.Join(t.TempDir(), "config.json")
deps := appDeps{
newSessionStore: func() *sessions.Store { return sessions.NewStore(sessions.StoreOptions{RootDir: root}) },
userConfigPath: func() (string, error) { return configPath, nil },
}
return root, configPath, deps
}
func runPruneCLI(t *testing.T, deps appDeps, args ...string) (int, string, string) {
t.Helper()
var stdout, stderr bytes.Buffer
code := runWithDeps(append([]string{"sessions"}, args...), &stdout, &stderr, deps)
return code, stdout.String(), stderr.String()
}
func pruneSessionExists(t *testing.T, root, id string) bool {
t.Helper()
_, err := os.Stat(filepath.Join(root, id))
return err == nil
}
func TestSessionsPruneDryRunListsWithoutRemoving(t *testing.T) {
root, _, deps := pruneCLIFixture(t)
code, stdout, stderr := runPruneCLI(t, deps, "prune", "--older-than", "30d", "--dry-run")
if code != exitSuccess {
t.Fatalf("exit %d, stderr %q", code, stderr)
}
for _, want := range []string{"Would remove 1 session", "old-session", "Dry run: nothing was removed."} {
if !strings.Contains(stdout, want) {
t.Errorf("output does not contain %q:\n%s", want, stdout)
}
}
if strings.Contains(stdout, "recent-session") {
t.Errorf("the recent session was listed:\n%s", stdout)
}
if !pruneSessionExists(t, root, "old-session") {
t.Fatal("a dry run removed the session")
}
}
// A dry run removes nothing, so a session it could not check is not reported
// as one it could not remove.
func TestSessionsPruneDryRunReportsFailuresAsUnchecked(t *testing.T) {
failed := []sessions.PruneEntry{{SessionID: "old-session", UpdatedAt: "2020-01-01T00:00:00Z", Reason: "the session disappeared while pruning"}}
dryRun := formatPruneReport(sessions.PruneReport{Cutoff: "2026-08-27T00:00:00Z", DryRun: true, Failed: failed})
if !strings.Contains(dryRun, "Could not check 1:") || strings.Contains(dryRun, "Could not remove") {
t.Errorf("dry run report:\n%s", dryRun)
}
run := formatPruneReport(sessions.PruneReport{Cutoff: "2026-08-27T00:00:00Z", Failed: failed})
if !strings.Contains(run, "Could not remove 1:") {
t.Errorf("report:\n%s", run)
}
}
func TestSessionsPruneRemovesOnlyOldSessions(t *testing.T) {
root, _, deps := pruneCLIFixture(t)
code, stdout, stderr := runPruneCLI(t, deps, "prune", "--older-than=30d")
if code != exitSuccess {
t.Fatalf("exit %d, stderr %q", code, stderr)
}
if !strings.Contains(stdout, "Removed 1 session") || !strings.Contains(stdout, "old-session") {
t.Fatalf("output does not report the removal:\n%s", stdout)
}
if pruneSessionExists(t, root, "old-session") {
t.Fatal("the old session is still there")
}
if !pruneSessionExists(t, root, "recent-session") {
t.Fatal("the recent session was removed")
}
}
func TestSessionsPruneReportsJSON(t *testing.T) {
_, _, deps := pruneCLIFixture(t)
code, stdout, stderr := runPruneCLI(t, deps, "prune", "--older-than", "720h", "--json")
if code != exitSuccess {
t.Fatalf("exit %d, stderr %q", code, stderr)
}
var report sessions.PruneReport
if err := json.Unmarshal([]byte(stdout), &report); err != nil {
t.Fatalf("output is not a prune report: %v\n%s", err, stdout)
}
if len(report.Removed) != 1 || report.Removed[0].SessionID != "old-session" || report.DryRun {
t.Fatalf("report = %+v", report)
}
}
func TestSessionsPruneRefusesABadOrShortCutoff(t *testing.T) {
root, _, deps := pruneCLIFixture(t)
for _, tc := range []struct {
value string
want string
}{
{"12h", "last 24 hours"},
{"0d", "invalid --older-than"},
{"soon", "invalid --older-than"},
{"-5d", "invalid --older-than"},
} {
code, _, stderr := runPruneCLI(t, deps, "prune", "--older-than="+tc.value)
if code != exitUsage || !strings.Contains(stderr, tc.want) {
t.Errorf("--older-than %s: exit %d, stderr %q, want a usage error naming %q", tc.value, code, stderr, tc.want)
}
}
if !pruneSessionExists(t, root, "old-session") {
t.Fatal("a refused prune removed a session")
}
}
func TestSessionsPruneNeedsACutoff(t *testing.T) {
root, _, deps := pruneCLIFixture(t)
code, _, stderr := runPruneCLI(t, deps, "prune")
if code != exitUsage || !strings.Contains(stderr, "needs a cutoff") {
t.Fatalf("exit %d, stderr %q, want a usage error asking for a cutoff", code, stderr)
}
if !pruneSessionExists(t, root, "old-session") {
t.Fatal("prune without a cutoff removed a session")
}
}
func TestSessionsPruneUsesTheUserRetentionSetting(t *testing.T) {
root, configPath, deps := pruneCLIFixture(t)
if err := os.WriteFile(configPath, []byte(`{"sessions":{"retentionDays":30}}`), 0o600); err != nil {
t.Fatal(err)
}
code, stdout, stderr := runPruneCLI(t, deps, "prune")
if code != exitSuccess {
t.Fatalf("exit %d, stderr %q", code, stderr)
}
if !strings.Contains(stdout, "Removed 1 session") || pruneSessionExists(t, root, "old-session") {
t.Fatalf("the user retention setting was not applied:\n%s", stdout)
}
}
// A REPOSITORY CANNOT CHOOSE WHAT PRUNE DELETES. Sessions are the user's, not
// the project's, so sessions.retentionDays in a workspace's .zero/config.json
// is not a cutoff, even when prune runs from inside that workspace.
func TestSessionsPruneIgnoresRetentionInProjectConfig(t *testing.T) {
root, _, deps := pruneCLIFixture(t)
workspace := t.TempDir()
if err := os.MkdirAll(filepath.Join(workspace, ".zero"), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(workspace, ".zero", "config.json"), []byte(`{"sessions":{"retentionDays":1}}`), 0o600); err != nil {
t.Fatal(err)
}
deps.getwd = func() (string, error) { return workspace, nil }
t.Chdir(workspace)
code, _, stderr := runPruneCLI(t, deps, "prune")
if code != exitUsage || !strings.Contains(stderr, "needs a cutoff") {
t.Fatalf("exit %d, stderr %q: the project's retention setting was used", code, stderr)
}
if !pruneSessionExists(t, root, "old-session") {
t.Fatal("a project config setting removed a session")
}
}
func TestSessionsPruneFlagsBelongToPrune(t *testing.T) {
_, _, deps := pruneCLIFixture(t)
for _, args := range [][]string{{"list", "--dry-run"}, {"list", "--older-than", "30d"}} {
code, _, stderr := runPruneCLI(t, deps, args...)
if code != exitUsage || !strings.Contains(stderr, "only valid for sessions prune") {
t.Errorf("%v: exit %d, stderr %q", args, code, stderr)
}
}
}
// An empty cutoff is a usage error, never a fall back to sessions.retentionDays.
func TestSessionsPruneRejectsAnEmptyCutoff(t *testing.T) {
root, configPath, deps := pruneCLIFixture(t)
if err := os.WriteFile(configPath, []byte(`{"sessions":{"retentionDays":1}}`), 0o600); err != nil {
t.Fatal(err)
}
for _, args := range [][]string{
{"prune", "--older-than", ""},
{"prune", "--older-than", " "},
{"prune", "--older-than="},
{"list", "--older-than", ""},
} {
code, _, stderr := runPruneCLI(t, deps, args...)
if code != exitUsage || !strings.Contains(stderr, "--older-than requires a value") {
t.Errorf("%q: exit %d, stderr %q, want a usage error", args, code, stderr)
}
}
if !pruneSessionExists(t, root, "old-session") {
t.Fatal("an empty cutoff fell back to the retention setting and removed a session")
}
}
+1 -1
View File
@@ -42,7 +42,7 @@ func runSetup(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) i
return exitSuccess
}
if strings.TrimSpace(options.catalogID) == "" {
return runInteractiveTUIWithSetup(stderr, deps, "", nil, "", true)
return runInteractiveTUIWithSetup(stderr, deps, "", nil, "", true, false)
}
result, err := saveSetupProvider(deps, tui.SetupSelection{
+11
View File
@@ -367,3 +367,14 @@ func TestVerifySetupProviderDistinguishesMissingFromRejectedKey(t *testing.T) {
t.Fatal("a keyless local provider should still be probed")
}
}
func TestSaveSetupProviderRejectsAtomicPlaceholderBeforeConfigAccess(t *testing.T) {
for _, model := range []string{"", "local-model", " local-model "} {
accessed := false
deps := appDeps{userConfigPath: func() (string, error) { accessed = true; return filepath.Join(t.TempDir(), "config.json"), nil }}
_, err := saveSetupProvider(deps, tui.SetupSelection{CatalogID: "atomic-chat-local", Model: model}, setupSaveOptions{})
if err == nil || !strings.Contains(err.Error(), "--model") || accessed {
t.Fatalf("invalid model %q reached config access: accessed=%v err=%v", model, accessed, err)
}
}
}
+126
View File
@@ -0,0 +1,126 @@
package cli
import (
"bytes"
"context"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/tui"
)
// captureTUIOptions runs the root command with the TUI launch intercepted, and
// returns the options the interactive session would have started with.
func captureTUIOptions(t *testing.T, args ...string) tui.Options {
t.Helper()
isolateCLIUserState(t)
var captured tui.Options
var stdout, stderr bytes.Buffer
workspace := t.TempDir()
exitCode := runWithDeps(args, &stdout, &stderr, appDeps{
getwd: func() (string, error) { return workspace, nil },
runTUI: func(_ context.Context, options tui.Options) int {
captured = options
return exitSuccess
},
})
if exitCode != exitSuccess {
t.Fatalf("exitCode = %d stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String())
}
return captured
}
func hasEscalateModel(options tui.Options) bool {
if options.AgentOptions.Registry == nil {
return false
}
_, registered := options.AgentOptions.Registry.Get("escalate_model")
return registered
}
// ESCALATION IS OFF UNLESS THE OPERATOR ASKS FOR IT.
//
// Escalation moves a run onto a different model, changing what it costs and which
// provider sees the conversation, so the interactive surface answers this the same
// conservative way `zero exec --allow-escalation` already does.
func TestInteractiveTUIRegistersEscalateModelOnlyWithTheFlag(t *testing.T) {
if hasEscalateModel(captureTUIOptions(t)) {
t.Error("escalate_model was registered without --allow-escalation")
}
if !hasEscalateModel(captureTUIOptions(t, "--allow-escalation")) {
t.Error("--allow-escalation did not register escalate_model")
}
}
// THE TOOL AND THE SWITCHERS ARE ONE FEATURE, NOT TWO.
//
// This is the failure the whole change exists to avoid. The agent loop performs a
// switch only when a switcher is wired, so registering escalate_model without
// wiring one ships a tool the model can call and the loop will silently ignore:
// the run reports an escalation that never happened. The reverse is merely dead
// code. Both halves ride on the same flag and this asserts they cannot be
// separated, whichever half a future change touches.
func TestInteractiveTUIEscalationToolAndSwitchersAreWiredTogether(t *testing.T) {
for _, testCase := range []struct {
name string
args []string
want bool
}{
{name: "default", args: nil, want: false},
{name: "flagged", args: []string{"--allow-escalation"}, want: true},
} {
t.Run(testCase.name, func(t *testing.T) {
options := captureTUIOptions(t, testCase.args...)
if got := hasEscalateModel(options); got != testCase.want {
t.Fatalf("escalate_model registered = %v, want %v", got, testCase.want)
}
if options.AllowEscalation != testCase.want {
t.Errorf("AllowEscalation = %v, want %v: the tool is registered on one gate and the switchers on another, so they can drift apart",
options.AllowEscalation, testCase.want)
}
})
}
}
// The flag is accepted on either side of --skip-permissions-unsafe, like the
// other root flags that may appear there.
func TestInteractiveTUIAcceptsAllowEscalationAroundUnsafe(t *testing.T) {
for _, args := range [][]string{
{"--allow-escalation", "--skip-permissions-unsafe"},
{"--skip-permissions-unsafe", "--allow-escalation"},
} {
options := captureTUIOptions(t, args...)
if !options.AllowEscalation {
t.Errorf("%v did not enable escalation", args)
}
if !hasEscalateModel(options) {
t.Errorf("%v did not register escalate_model", args)
}
}
}
// An =value form is a loud error rather than a silent enable, so a mistyped
// --allow-escalation=false cannot turn the feature ON.
func TestAllowEscalationRejectsAValue(t *testing.T) {
var stdout, stderr bytes.Buffer
exitCode := runWithDeps([]string{"--allow-escalation=false"}, &stdout, &stderr, appDeps{
getwd: func() (string, error) { return t.TempDir(), nil },
runTUI: func(context.Context, tui.Options) int { return exitSuccess },
})
if exitCode == exitSuccess {
t.Fatal("--allow-escalation=false was accepted; a mistyped disable must not silently enable escalation")
}
if !strings.Contains(stderr.String(), "--allow-escalation takes no value") {
t.Errorf("stderr does not explain the flag: %s", stderr.String())
}
}
func TestRootHelpDocumentsAllowEscalation(t *testing.T) {
var stdout, stderr bytes.Buffer
if exitCode := runWithDeps([]string{"--help"}, &stdout, &stderr, appDeps{}); exitCode != exitSuccess {
t.Fatalf("exitCode = %d stderr=%s", exitCode, stderr.String())
}
if !strings.Contains(stdout.String(), "--allow-escalation") {
t.Error("the root help does not mention --allow-escalation, so the flag is undiscoverable")
}
}
+143
View File
@@ -0,0 +1,143 @@
package cli
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/config"
)
// userStateEnvNames is every per-user base directory the root command can
// resolve against, across platforms. One throwaway root behind all of them
// lands the resolution inside it whichever branch it takes.
var userStateEnvNames = []string{
"HOME", "USERPROFILE", "APPDATA", "LOCALAPPDATA",
"XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_CACHE_HOME", "XDG_STATE_HOME",
}
// isolateCLIUserState points every user-scoped path the root command touches at
// throwaway directories, and keeps credential work off the host keyring.
//
// A TEMPORARY WORKING DIRECTORY IS NOT ISOLATION. runWithDeps fills the
// dependencies a test leaves out with the production ones, and the launch paths
// read user config, open stores, refresh the models.dev cache and, for an
// interactive run, migrate any inline plaintext API key into the credential
// store before they ever reach an injected callback. Without this, running these
// tests on a developer machine rewrites that developer's config.json and moves
// their key into their keychain, makes a real network call, and reports results
// that depend on whatever providers, MCP servers and plugins that machine has.
func isolateCLIUserState(t *testing.T) {
t.Helper()
root := t.TempDir()
for _, name := range userStateEnvNames {
t.Setenv(name, root)
}
// The models.dev overlay cache, and the background refresh that writes it:
// a wiring test has no business making a network call or leaving a cache
// file behind.
t.Setenv("ZERO_MODELS_CACHE_PATH", filepath.Join(root, "modelsdev.json"))
t.Setenv("ZERO_DISABLE_MODELS_FETCH", "1")
// Credentials resolve keyring-first. This keeps a migrated key in a file
// under the fixture root rather than in the developer's OS keychain.
t.Setenv("ZERO_CRED_STORAGE", "encrypted-file")
}
// AND THE ISOLATION IS ITSELF PINNED, ONE HELPER AT A TIME. A seeded config
// outside the helper fixture roots stands in for a developer's real one: the
// launch helper must leave it exactly as it found it, inline API key included,
// must not write a credential file beside it, and must have moved the per-user
// paths off that root before running anything.
//
// EACH HELPER GETS ITS OWN SUBTEST because t.Setenv restores at the end of the
// test, not when the helper returns: exercising both in one test would let the
// first helper isolation cover for a second helper that had none.
func TestCLILaunchHelpersLeaveUserStateAlone(t *testing.T) {
for _, testCase := range []struct {
name string
exercise func(*testing.T)
}{
{"captureTUIOptions", func(t *testing.T) { _ = captureTUIOptions(t, "--allow-escalation") }},
{"execAdvertisesEscalateModel", func(t *testing.T) {
_ = execAdvertisesEscalateModel(t, []string{"--allow-escalation", "exec", "say hi"})
}},
{"runExecWithSandbox", func(t *testing.T) {
_, _, _ = runExecWithSandbox(t, []string{"exec", "hello"}, unavailableTestSandbox, config.SandboxConfig{})
}},
{"launchTUIWithSandbox", func(t *testing.T) { _ = launchTUIWithSandbox(t, config.SandboxConfig{}) }},
} {
t.Run(testCase.name, func(t *testing.T) {
seedRoot, configPath, seeded, before := seedEmulatedUserConfig(t)
testCase.exercise(t)
after, err := os.ReadFile(configPath)
if err != nil {
t.Fatal(err)
}
if string(after) != string(seeded) {
t.Errorf("the seeded user config was rewritten:\n before: %s\n after: %s", seeded, after)
}
entries, err := os.ReadDir(filepath.Dir(configPath))
if err != nil {
t.Fatal(err)
}
if len(entries) != len(before) {
names := []string{}
for _, entry := range entries {
names = append(names, entry.Name())
}
t.Errorf("the user config directory gained entries: %v", names)
}
// The checks above catch what this platform happens to write. This
// one catches the missing isolation itself, so a startup path that
// writes somewhere else, or only sometimes, is covered too.
resolved, err := config.DefaultUserConfigPath()
if err != nil {
t.Fatal(err)
}
if strings.HasPrefix(resolved, seedRoot) {
t.Errorf("user config still resolves inside the seeded root (%s): the helper installed no isolation", resolved)
}
if os.Getenv("ZERO_DISABLE_MODELS_FETCH") == "" {
t.Error("the helper left the background models.dev refresh enabled, so this test can make a network call and write a cache file")
}
if os.Getenv("ZERO_CRED_STORAGE") == "" {
t.Error("the helper left credential storage resolving keyring-first, so a migrated key can reach the host keychain")
}
})
}
}
// seedEmulatedUserConfig points the per-user base directories at a throwaway
// root and writes a config carrying an inline plaintext API key there, which is
// what the startup migration rewrites. It returns that root, the config path,
// its bytes, and the directory listing to compare against.
func seedEmulatedUserConfig(t *testing.T) (string, string, []byte, []os.DirEntry) {
t.Helper()
seedRoot := t.TempDir()
for _, name := range userStateEnvNames {
t.Setenv(name, seedRoot)
}
configPath, err := config.DefaultUserConfigPath()
if err != nil {
t.Fatal(err)
}
if !strings.HasPrefix(configPath, seedRoot) {
t.Fatalf("SETUP INVALID: user config resolved to %q, outside the seeded root %q", configPath, seedRoot)
}
if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
t.Fatal(err)
}
seeded := []byte(`{"providers":[{"name":"anthropic","providerKind":"anthropic","model":"claude-haiku-4.5","apiKey":"sk-seeded-plaintext-key"}]}`)
if err := os.WriteFile(configPath, seeded, 0o600); err != nil {
t.Fatal(err)
}
before, err := os.ReadDir(filepath.Dir(configPath))
if err != nil {
t.Fatal(err)
}
return seedRoot, configPath, seeded, before
}
+396
View File
@@ -0,0 +1,396 @@
package config
import (
"encoding/json"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"testing"
"time"
)
func readTestConfig(t *testing.T, path string) FileConfig {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read config: %v", err)
}
cfg := FileConfig{}
if err := json.Unmarshal(data, &cfg); err != nil {
t.Fatalf("invalid config JSON after concurrent writes: %v\n%s", err, data)
}
return cfg
}
func seedTestConfig(t *testing.T, path string) {
t.Helper()
if _, err := UpsertProvider(path, ProviderProfile{Name: "seed", Model: "seed-model"}, true); err != nil {
t.Fatalf("seed config: %v", err)
}
}
// TestConcurrentMutationsDoNotLoseUpdates is the issue #832 regression. Each
// mutator loads the whole document, edits one field, and publishes a complete
// replacement by rename. Without a lock spanning load-through-publish, two
// writers that loaded the same revision both write a full document and the
// second rename silently discards the first one's acknowledged update.
//
// Every mutation below touches an INDEPENDENT field, so a correct
// implementation ends with all of them present. A lost update shows up as a
// zero value in exactly one of the fields, with valid JSON either way — which
// is what made the bug silent.
func TestConcurrentMutationsDoNotLoseUpdates(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.json")
seedTestConfig(t, path)
mutations := []struct {
name string
apply func() error
check func(FileConfig) error
}{
{
name: "theme",
apply: func() error { _, err := SetTheme(path, "dracula"); return err },
check: func(cfg FileConfig) error {
if cfg.Preferences.Theme != "dracula" {
return fmt.Errorf("theme = %q, want dracula", cfg.Preferences.Theme)
}
return nil
},
},
{
name: "pet",
apply: func() error { _, err := SetPet(path, "otter"); return err },
check: func(cfg FileConfig) error {
if cfg.Preferences.Pet != "otter" {
return fmt.Errorf("pet = %q, want otter", cfg.Preferences.Pet)
}
return nil
},
},
{
name: "recaps",
apply: func() error { _, err := SetRecapsEnabled(path, true); return err },
check: func(cfg FileConfig) error {
if cfg.Preferences.Recaps == nil || !*cfg.Preferences.Recaps {
return fmt.Errorf("recaps = %v, want true", cfg.Preferences.Recaps)
}
return nil
},
},
{
name: "favorites",
apply: func() error { _, err := SetFavoriteModels(path, []string{"fav-model"}); return err },
check: func(cfg FileConfig) error {
if len(cfg.Preferences.FavoriteModels) != 1 || cfg.Preferences.FavoriteModels[0] != "fav-model" {
return fmt.Errorf("favorites = %v, want [fav-model]", cfg.Preferences.FavoriteModels)
}
return nil
},
},
{
name: "provider",
apply: func() error {
_, err := UpsertProvider(path, ProviderProfile{Name: "added", Model: "added-model"}, false)
return err
},
check: func(cfg FileConfig) error {
for _, provider := range cfg.Providers {
if provider.Name == "added" {
return nil
}
}
return fmt.Errorf("provider %q missing from %d providers", "added", len(cfg.Providers))
},
},
}
// SetPet edits the raw bytes rather than round-tripping the struct, so it is
// included deliberately: it must take the same lock as the struct writers or
// it would clobber, and be clobbered by, everything else.
errs := make([]error, len(mutations))
start := make(chan struct{})
var wg sync.WaitGroup
for index, mutation := range mutations {
wg.Add(1)
go func() {
defer wg.Done()
<-start
errs[index] = mutation.apply()
}()
}
close(start)
wg.Wait()
for index, err := range errs {
if err != nil {
t.Fatalf("%s mutation failed: %v", mutations[index].name, err)
}
}
cfg := readTestConfig(t, path)
for _, mutation := range mutations {
if err := mutation.check(cfg); err != nil {
t.Errorf("%s update was lost: %v", mutation.name, err)
}
}
if cfg.ActiveProvider != "seed" {
t.Errorf("activeProvider = %q, want the seeded value preserved", cfg.ActiveProvider)
}
}
// TestConcurrentSameFieldMutationsSerialize proves the lock serializes writers
// that contend on ONE field: every call must succeed and the file must end with
// exactly one of the written values, never a merged or truncated document.
func TestConcurrentSameFieldMutationsSerialize(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.json")
seedTestConfig(t, path)
const writers = 24
errs := make([]error, writers)
start := make(chan struct{})
var wg sync.WaitGroup
for index := range writers {
wg.Add(1)
go func() {
defer wg.Done()
<-start
_, errs[index] = SetTheme(path, fmt.Sprintf("theme-%02d", index))
}()
}
close(start)
wg.Wait()
for index, err := range errs {
if err != nil {
t.Fatalf("writer %d failed: %v", index, err)
}
}
cfg := readTestConfig(t, path)
if !strings.HasPrefix(cfg.Preferences.Theme, "theme-") {
t.Fatalf("theme = %q, want one of the written values", cfg.Preferences.Theme)
}
}
// TestConcurrentProviderUpsertsAllSurvive is the shape the issue describes most
// directly: N distinct providers added at once. Each add is an independent
// successful mutation, so all N must be present afterwards.
func TestConcurrentProviderUpsertsAllSurvive(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.json")
seedTestConfig(t, path)
const providers = 16
errs := make([]error, providers)
start := make(chan struct{})
var wg sync.WaitGroup
for index := range providers {
wg.Add(1)
go func() {
defer wg.Done()
<-start
_, errs[index] = UpsertProvider(path, ProviderProfile{
Name: fmt.Sprintf("provider-%02d", index),
Model: fmt.Sprintf("model-%02d", index),
}, false)
}()
}
close(start)
wg.Wait()
for index, err := range errs {
if err != nil {
t.Fatalf("upsert %d failed: %v", index, err)
}
}
cfg := readTestConfig(t, path)
present := map[string]string{}
for _, provider := range cfg.Providers {
present[provider.Name] = provider.Model
}
for index := range providers {
name := fmt.Sprintf("provider-%02d", index)
want := fmt.Sprintf("model-%02d", index)
if got, ok := present[name]; !ok {
t.Errorf("provider %s was lost (config holds %d providers)", name, len(cfg.Providers))
} else if got != want {
t.Errorf("provider %s model = %q, want %q", name, got, want)
}
}
}
// TestCrossProcessMutationExcludesAndPreserves is the coordinated two-process
// test the issue asks for. Goroutines share this process's descriptors, so only
// a second OS process shows the lock is held by the kernel rather than by
// in-process state.
//
// The child announces itself and then mutates; the parent holds the lock across
// that window and asserts the child's write CANNOT land, then does its own
// mutation and releases. Both updates must survive. The exclusion window is
// what makes this deterministic in the passing direction: with the lock held,
// the child is blocked in its retry loop and the theme provably stays unset.
func TestCrossProcessMutationExcludesAndPreserves(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.json")
seedTestConfig(t, path)
unlock, err := lockConfigFile(path)
if err != nil {
t.Fatalf("acquire lock: %v", err)
}
released := false
release := func() {
if !released {
released = true
unlock()
}
}
defer release()
child := exec.Command(os.Args[0], "-test.run=^TestConfigConcurrentHelperProcess$")
child.Env = append(os.Environ(),
"ZERO_CONFIG_CONCURRENT_HELPER=1",
"ZERO_CONFIG_CONCURRENT_PATH="+path,
)
stdout, err := child.StdoutPipe()
if err != nil {
t.Fatalf("stdout pipe: %v", err)
}
if err := child.Start(); err != nil {
t.Fatalf("start helper: %v", err)
}
defer func() {
if child.ProcessState == nil {
_ = child.Process.Kill()
_ = child.Wait()
}
}()
ready := make([]byte, len("ready\n"))
if _, err := io.ReadFull(stdout, ready); err != nil {
t.Fatalf("helper never signalled ready: %v", err)
}
// The child is now live and contending for a lock this process holds. Its
// write must not appear while we hold it.
for range 20 {
if theme := readTestConfig(t, path).Preferences.Theme; theme != "" {
t.Fatalf("child wrote %q while the lock was held elsewhere; mutations are not excluded", theme)
}
time.Sleep(5 * time.Millisecond)
}
if _, err := upsertProviderLocked(path, ProviderProfile{Name: "parent", Model: "parent-model"}, false); err != nil {
t.Fatalf("parent mutation: %v", err)
}
release()
if err := child.Wait(); err != nil {
t.Fatalf("helper process failed: %v", err)
}
cfg := readTestConfig(t, path)
if cfg.Preferences.Theme != "child-theme" {
t.Errorf("child update was lost: theme = %q, want child-theme", cfg.Preferences.Theme)
}
found := false
for _, provider := range cfg.Providers {
if provider.Name == "parent" {
found = true
}
}
if !found {
t.Errorf("parent update was lost: provider %q missing from %d providers", "parent", len(cfg.Providers))
}
}
// TestConfigConcurrentHelperProcess is the child half of the cross-process
// test. It is skipped unless the parent selected it through the environment.
func TestConfigConcurrentHelperProcess(t *testing.T) {
if os.Getenv("ZERO_CONFIG_CONCURRENT_HELPER") == "" {
t.Skip("helper process for TestCrossProcessMutationExcludesAndPreserves")
}
path := os.Getenv("ZERO_CONFIG_CONCURRENT_PATH")
if path == "" {
t.Fatal("ZERO_CONFIG_CONCURRENT_PATH is required")
}
// Announce BEFORE contending, so the parent's exclusion window starts with
// this process already running rather than still being forked.
fmt.Println("ready")
if _, err := SetTheme(path, "child-theme"); err != nil {
t.Fatalf("child mutation: %v", err)
}
}
// TestMutationReportsUnlockFailure pins the AGENTS.md rule that a mutator must
// never report success when unlock failed. A failed release can leave the lock
// held for the rest of the process, so returning (cfg, nil) after one would
// claim a state the next mutation cannot reproduce.
//
// lockutil.Release is idempotent and reports nil once released, so a real
// release failure cannot be provoked through the public API — hence the seam.
// The mutation itself must still have been published: the release failure
// annotates the result, it does not undo the write.
func TestMutationReportsUnlockFailure(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.json")
seedTestConfig(t, path)
releaseErr := errors.New("release exploded")
original := lockConfigFileFn
t.Cleanup(func() { lockConfigFileFn = original })
lockConfigFileFn = func(p string) (func() error, error) {
unlock, err := original(p)
if err != nil {
return nil, err
}
return func() error {
_ = unlock()
return releaseErr
}, nil
}
cfg, err := SetTheme(path, "dracula")
if !errors.Is(err, releaseErr) {
t.Fatalf("SetTheme err = %v, want it to carry the release failure", err)
}
if cfg.Preferences.Theme != "dracula" {
t.Errorf("returned config lost the mutation: theme = %q", cfg.Preferences.Theme)
}
if got := readTestConfig(t, path).Preferences.Theme; got != "dracula" {
t.Errorf("mutation was not published despite only the release failing: theme = %q", got)
}
}
// TestMutationErrorSurvivesUnlockFailure proves the two errors are joined
// rather than chosen between: a release failure must not mask the mutation
// error that actually explains what went wrong.
func TestMutationErrorSurvivesUnlockFailure(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.json")
seedTestConfig(t, path)
releaseErr := errors.New("release exploded")
original := lockConfigFileFn
t.Cleanup(func() { lockConfigFileFn = original })
lockConfigFileFn = func(p string) (func() error, error) {
unlock, err := original(p)
if err != nil {
return nil, err
}
return func() error {
_ = unlock()
return releaseErr
}, nil
}
// An unknown provider is the mutation error; the release failure is joined
// onto it, and neither one disappears.
_, err := SetActiveProvider(path, "definitely-not-configured")
if !errors.Is(err, releaseErr) {
t.Fatalf("err = %v, want it to carry the release failure", err)
}
if !strings.Contains(err.Error(), "definitely-not-configured") {
t.Fatalf("err = %v, want it to still name the missing provider", err)
}
}
+23 -2
View File
@@ -2,6 +2,7 @@ package config
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
@@ -80,12 +81,21 @@ func ForgetProviderKey(provider string) (bool, error) {
// config at path, so credential checks no longer claim a stored key after one is
// removed. No-op when path/provider is empty, the config is absent, or the marker
// is already unset.
func ClearProviderKeyStored(path, provider string) (bool, error) {
func ClearProviderKeyStored(path, provider string) (cleared bool, err error) {
path = strings.TrimSpace(path)
provider = strings.TrimSpace(provider)
if path == "" || provider == "" {
return false, nil
}
unlock, err := lockConfigFileFn(path)
if err != nil {
return false, err
}
// Joined, not chosen between: a release failure annotates the result
// instead of masking the mutation error that actually explains what went
// wrong. Reporting success after a failed unlock would claim a state the
// next mutation cannot reproduce.
defer func() { err = errors.Join(err, unlock()) }()
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
@@ -116,11 +126,22 @@ func ClearProviderKeyStored(path, provider string) (bool, error) {
// leaves the plaintext key in place and never strands a credential. Returns how
// many keys were migrated; a no-op (0, nil) when path is empty/absent or nothing
// needs migrating. Safe to run on every startup (idempotent).
func MigratePlaintextProviderKeys(path string, store APIKeySetter) (int, error) {
func MigratePlaintextProviderKeys(path string, store APIKeySetter) (count int, err error) {
path = strings.TrimSpace(path)
if path == "" || store == nil {
return 0, nil
}
// This runs on every startup, so it is the most likely writer to be racing
// an interactive mutation in another Zero process.
unlock, err := lockConfigFileFn(path)
if err != nil {
return 0, err
}
// Joined, not chosen between: a release failure annotates the result
// instead of masking the mutation error that actually explains what went
// wrong. Reporting success after a failed unlock would claim a state the
// next mutation cannot reproduce.
defer func() { err = errors.Join(err, unlock()) }()
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
+15
View File
@@ -257,6 +257,21 @@ func TestProviderProfileMissingCredentialEnv(t *testing.T) {
profile: ProviderProfile{Name: "local", CatalogID: "ollama"},
want: false,
},
{
// A profile saved against the hosted atomic-chat preset keeps its
// remote base URL and key, so it must still be reported as missing a
// credential. The keyless local runtime is a separate catalog ID
// (atomic-chat-local) precisely so this identity is never repurposed.
name: "hosted atomic-chat profile still requires its key",
profile: ProviderProfile{Name: "atomic-chat", CatalogID: "atomic-chat", BaseURL: "https://api.atomic.chat/v1"},
wantEnv: "ATOMIC_CHAT_API_KEY",
want: true,
},
{
name: "local atomic chat runtime needs no credential",
profile: ProviderProfile{Name: "atomic-local", CatalogID: "atomic-chat-local"},
want: false,
},
{
name: "credential resolved via inline key",
profile: ProviderProfile{Name: "openai", ProviderKind: ProviderKindOpenAI, APIKey: "sk-test"},
+13 -1
View File
@@ -3,6 +3,7 @@ package config
import (
"encoding/json"
"errors"
"fmt"
"os"
"strings"
@@ -41,7 +42,7 @@ func ValidateFile(path string) (FileConfig, []Issue) {
// SetProviderDescription sets a provider's description VERBATIM — including to
// empty. The generic UpsertProvider merge treats empty fields as "leave
// unchanged", so clearing a description needs this dedicated setter.
func SetProviderDescription(path string, name string, description string) (FileConfig, error) {
func SetProviderDescription(path string, name string, description string) (result FileConfig, err error) {
path = strings.TrimSpace(path)
if path == "" {
return FileConfig{}, fmt.Errorf("config path is required")
@@ -50,6 +51,17 @@ func SetProviderDescription(path string, name string, description string) (FileC
if name == "" {
return FileConfig{}, fmt.Errorf("provider name is required")
}
// Locks like the production mutators so this seam can stand in for one in a
// concurrency test rather than being the one unsynchronized writer.
unlock, err := lockConfigFileFn(path)
if err != nil {
return FileConfig{}, err
}
// Joined, not chosen between: a release failure annotates the result
// instead of masking the mutation error that actually explains what went
// wrong. Reporting success after a failed unlock would claim a state the
// next mutation cannot reproduce.
defer func() { err = errors.Join(err, unlock()) }()
data, err := os.ReadFile(path)
if err != nil {
+80
View File
@@ -244,6 +244,86 @@ func petJSONObject(encodedPet []byte) []byte {
return append(result, '}')
}
// setNotifyJSONObject replaces only the notify member's value, keeping the
// original bytes of every unrelated member so a notification save cannot
// reorder, reformat, or drop the user's other settings — including values whose
// EXPLICIT presence matters (tools.deferThreshold: 0, mcp servers'
// disabled: false) and keys the typed FileConfig does not model at all. The
// typed serializer cannot round-trip explicit zeros (omitempty drops them), so
// notification writes go through this byte-preserving editor instead
// (maintainer review, PR #1001). An empty notify block removes the member
// entirely so a reset leaves no `"notify": {}` husk behind.
//
// A hand-edited file may contain DUPLICATE notify members. Go's decoder
// MERGES the fields of duplicate object members into the same struct, so an
// earlier duplicate's field survives a replacement that omits it — e.g.
// {"notify":{"mode":"off"},"notify":{"focusMode":"focused"}} + a
// replacement {"focusMode":"always"} would leave mode "off" effective.
// The nonempty path therefore removes EVERY duplicate and inserts the
// replacement fresh (full-replace semantics: nothing inherited), and the
// reset path removes every member the same way. UpdateNotify's partial
// merge is unaffected: it seeds omitted fields from the stored value BEFORE
// calling this editor, so the value it passes is already complete.
// Mirrors setPetPreferenceJSON's duplicate handling.
func setNotifyJSONObject(data []byte, notify NotifyConfig) ([]byte, error) {
encoded, err := json.Marshal(notify)
if err != nil {
return nil, fmt.Errorf("encode notify preference: %w", err)
}
rootStart := skipJSONSpace(data, 0)
root, err := parseJSONObject(data, rootStart)
if err != nil {
return nil, err
}
notifyIndex := lastJSONMember(root.members, "notify")
// Count duplicates: the ordinary file has exactly one notify member, and
// for it a positional replace keeps the key where the user put it and
// preserves their formatting (byte-preserving contract). The
// remove-all-and-reinsert path below is ONLY for files that actually
// contain duplicates, where Go's field-merging decode would otherwise let
// an earlier block's fields leak into the replacement.
duplicates := 0
for _, member := range root.members {
if member.key == "notify" {
duplicates++
}
}
if string(encoded) == "{}" {
for notifyIndex >= 0 {
data = removeJSONMember(data, root, notifyIndex)
rootStart = skipJSONSpace(data, 0)
root, err = parseJSONObject(data, rootStart)
if err != nil {
return nil, err
}
notifyIndex = lastJSONMember(root.members, "notify")
}
return data, nil
}
if duplicates <= 1 {
if notifyIndex < 0 {
return insertJSONMember(data, root, "notify", encoded), nil
}
// Single member: replace its value in place — no key movement, no
// reformatting of the surrounding bytes.
return replaceJSONRange(data, root.members[notifyIndex].valueStart, root.members[notifyIndex].valueEnd, encoded), nil
}
// Multiple duplicates: Go merges their fields at decode time, so the
// replacement must not inherit anything — remove every duplicate and
// insert the complete value fresh.
for notifyIndex >= 0 {
data = removeJSONMember(data, root, notifyIndex)
rootStart = skipJSONSpace(data, 0)
root, err = parseJSONObject(data, rootStart)
if err != nil {
return nil, err
}
notifyIndex = lastJSONMember(root.members, "notify")
}
return insertJSONMember(data, root, "notify", encoded), nil
}
func replaceJSONRange(data []byte, start, end int, replacement []byte) []byte {
result := make([]byte, 0, len(data)-(end-start)+len(replacement))
result = append(result, data[:start]...)
+95
View File
@@ -0,0 +1,95 @@
package config
import (
"errors"
"fmt"
"path/filepath"
"time"
"github.com/Gitlawb/zero/internal/lockutil"
)
// ErrLockUnavailable reports that the config lock was still held by another
// process (or goroutine) when configLockTimeout expired. A caller that would
// otherwise make a SECOND config write after a failed one branches on this: the
// lock has not been released, so the second attempt can only spend another full
// timeout to fail the same way, turning one keystroke into two stalls.
var ErrLockUnavailable = errors.New("config lock unavailable")
const (
configLockTimeout = 10 * time.Second
configLockRetryDelay = 20 * time.Millisecond
)
// lockConfigFile serializes a config read-modify-write across processes.
//
// Every mutator in this package loads the whole document, edits independent
// fields, and publishes a complete replacement by rename. The rename is atomic,
// so a reader never sees partial JSON — but two processes that both loaded the
// same revision each write a full document, and the second rename silently
// discards the first one's acknowledged update (issue #832). Startup work makes
// this easy to hit: MigratePlaintextProviderKeys rewrites the config on every
// launch, so it collides with any concurrent mutation from another Zero.
//
// The lock must therefore span load, mutation, validation AND publication:
// holding it only around the write would still let both processes read the same
// stale revision first. Callers acquire before their first read, which is what
// makes the re-read inside the lock authoritative.
//
// The lock file is a sibling of the config, never the config itself. The kernel
// holds an advisory lock against an inode, and publishing the config by rename
// installs a NEW inode — locking the config directly would leave each process
// holding a different one. lockutil keeps the sibling's path stable for the
// same reason, and never removes it.
//
// The returned release function reports its own failure rather than swallowing
// it: a failed Release can leave the lock held for the rest of the process, so
// a mutator that returned success after it would be reporting a state the next
// mutation cannot reproduce. Callers join it into their result (AGENTS.md:
// "never report success when cleanup or unlock failed").
//
// This also serializes goroutines within one process: each acquisition opens
// its own file description, so a second in-process attempt contends exactly as
// another process would. The lock is NOT reentrant, so an exported mutator that
// needs another mutator's work calls the unexported *Locked form instead of
// re-entering through the public function.
// LockFile exposes lockConfigFile to packages that edit the SAME user config
// document without going through this package's mutators — internal/cli's MCP
// editor reads, edits and republishes it with the identical temp-file+rename
// shape. A writer that skipped this lock would reintroduce the lost update for
// every field, so the lock has to be one authority across packages rather than
// a private detail of this one.
func LockFile(path string) (func() error, error) {
return lockConfigFileFn(path)
}
// lockConfigFileFn is the seam the mutators call. Release failures cannot be
// provoked through the public API — lockutil.Release is idempotent and reports
// nil once released — so substituting this is the only way to assert that a
// failed unlock actually reaches the caller instead of being swallowed.
var lockConfigFileFn = lockConfigFile
func lockConfigFile(path string) (func() error, error) {
lockPath := path + ".lock"
// Every mutator locks before it writes, so this is where the config
// directory is normally created. ensureConfigDir also syncs each new entry
// into its parent before the lock file exists; writeConfigData would find
// nothing missing by then and could not run that barrier itself.
if err := ensureConfigDir(filepath.Dir(lockPath)); err != nil {
return nil, err
}
deadline := time.Now().Add(configLockTimeout)
for {
lock, err := lockutil.TryAcquireFileLock(lockPath)
if err == nil {
return lock.Release, nil
}
if !errors.Is(err, lockutil.ErrLockHeld) {
return nil, fmt.Errorf("config: acquire config lock: %w", err)
}
if !time.Now().Before(deadline) {
return nil, fmt.Errorf("config: timed out acquiring config lock for %s: %w", path, ErrLockUnavailable)
}
time.Sleep(configLockRetryDelay)
}
}
+48
View File
@@ -0,0 +1,48 @@
package config
import (
"fmt"
"github.com/Gitlawb/zero/internal/modelregistry"
"strings"
)
func normalizeModelRPM(limits map[string]int) (map[string]int, error) {
if len(limits) == 0 {
return nil, nil
}
registry, err := modelregistry.DefaultRegistry()
if err != nil {
return nil, err
}
out := make(map[string]int, len(limits))
for key, n := range limits {
model := strings.TrimSpace(key)
if model == "" || n < 0 {
return nil, fmt.Errorf("invalid modelRPM entry %q: model must be non-empty and limit >= 0", key)
}
if id, ok := registry.ResolveID(model); ok {
model = id
}
if old, exists := out[model]; exists && old != n {
return nil, fmt.Errorf("conflicting modelRPM entries resolve to %q", model)
}
out[model] = n
}
return out, nil
}
// Project config can tighten a user cap but cannot relax it.
func mergeModelRPM(dst *map[string]int, src map[string]int, tighten bool) {
if len(src) == 0 {
return
}
if *dst == nil {
*dst = make(map[string]int)
}
for model, n := range src {
old := (*dst)[model]
if !tighten || old == 0 || (n > 0 && n < old) {
(*dst)[model] = n
}
}
}
Loaded 100 of 381 files, more files were not shown because too many files have changed in this diff. Show more