Compare commits

...
Author SHA1 Message Date
Kevin Codex 628345e560 test(providers): prove the Concentrate default check runs on the live list
The regression test passed whenever the default model was in the result,
and the curated fallback also contains it. Assert that the mock served
/v1/models and that the result includes claude-fable-5, a model only the
live response lists, so a silent fall back to the curated catalog fails.
2026-10-15 21:06:16 +08:00
Kevin Codex 7c01b4b5d6 feat(providers): add Concentrate provider preset
Register Concentrate (https://api.concentrate.ai/v1) as an
OpenAI-compatible catalog provider. Concentrate is already listed in the
README Partners section; this adds the provider itself.

- models.dev has no Concentrate provider, so the id `concentrate` and the
  key name CONCENTRATE_API_KEY match OpenClaude's Concentrate
  integration. `Concentrate AI`, `concentrate.ai` and `concentrate-ai`
  resolve to it.
- Default model deepseek-v4-flash-0731: the dated id behind the
  deepseek-v4-flash alias that OpenClaude uses. Concentrate's /v1/models
  keeps the rolling name only in `aliases`, and onboarding keeps the
  default selected only when it matches a live id, so an alias default
  made setup land on the first model in the list (claude-fable-5).
- Curated models: deepseek-v4-flash-0731 (default), deepseek-v4-pro,
  kimi-k3 and glm-5.3. Context windows (1,048,576 each), tool calling and
  thinking support come from Concentrate's public /v1/models.
- Regression test: a Concentrate-shaped live list must contain the
  preset's default model.
- README and README_ZH: key line and setup command.
2026-10-15 16:43:08 +08:00
fchengyan d1a0d048ee fix(cli): let providers subcommands run with no active provider (#1143)
* fix(cli): let providers subcommands run with no active provider

`zero providers list`, `use`, `remove` and `rename` exited 3 with
`no active provider configured: active provider "" not found` as soon as the
environment supplied more than one provider and config.json had no active
provider: normalizeProvidersWithOptions only elects an active provider by
itself when there is exactly one, and resolveCommandCenterConfig treated the
resulting error as fatal, so the command stopped before doing anything. A
base URL without a key counts as a second provider too (applyProviderEnv
builds a profile from a base URL alone).

The resolver already returns the successfully normalized list alongside
ErrNoActiveProvider, and the interactive TUI already falls back to a usable
entry from it in runInteractiveTUIWithSetup. Do the same here instead of
failing, so these subcommands can answer for providers that only come from
the environment.

Also pin the four env-derived provider tests to a fixed environment and a
temporary user config. They set OPENAI_API_KEY but resolved through the real
resolver against the developer's own config.json and ambient provider
variables, so they failed on any machine that also exported another provider
variable and whose user config had no active provider.

Adds regression coverage for the two-env-provider case: `providers list`
must report every detected provider and `use`/`remove`/`rename` must give
their usual env-derived answer. Verified against the fix (pass) and with the
fix reverted (exit 3, `active provider "" not found`).

Fixes #1130

* refactor(cli): report the configured active provider, not an elected one

Provider-management subcommands answer for the SAVED configuration, while
the TUI's recovery path answers "what can I run with". Adopting
firstUsableProvider in resolveCommandCenterConfig conflated the two: with
two env-derived providers it made `providers current`/`list` present a
provider that `zero exec` would still refuse to run with, and a
base-URL-only profile failed `providers list` because the election demands
a credential. Return the resolver's partial config untouched instead, so
nothing is reported as active when nothing is selected.

Also removes the OAuth store read this path picked up with firstUsableProvider.

Adds assertions that `current` shows nothing selected, `list` marks nothing
active, `use --json` leaves activeProvider empty, plus a base-URL-only case.

* fix(cli): scope the no-active-provider exception to provider management

Returning the resolver's partial config made `zero config` exit 0 with a
partial summary, contradicting the resolver's documented headless contract:
`zero config` / `zero exec` still fail with the actionable message and only
the interactive TUI recovers from a missing active provider. Split the
helper so only issue #1130's commands — `providers list`/`current` and the
unpersisted `use`/`remove`/`rename` reporters — tolerate the state; `zero
config`, `detect`, `models` and `check` keep failing as before.

Also pin the OAuth store in providerEnvSetupDeps: summarizeConfig reads
OAuth login state through the default store, so a developer's stored login
could flip these assertions. Point ZERO_OAUTH_TOKENS_PATH at a temp file
and force ZERO_OAUTH_STORAGE=file so an ambient keyring setting cannot pull
the OS keychain in either.
2026-10-15 15:40:43 +08:00
Fabio Tavares Leitão c88c77bf70 fix(workspaceindex): skip git-ignored and build-cache dirs in Scan only (#1117)
* fix(workspaceindex): skip git-ignored and build-cache dirs in Scan only

Scan has a fixed file budget, and build output (Cargo target/, Python
virtualenvs, caches) could exhaust it before real source was reached:
repo-map reported truncation and ranked fingerprint files above code.

Inside a git work tree, Scan now asks git for the ignored set
(`git status --porcelain=v1 -z --ignored=matching .`), so the repo's own
.gitignore, nested ignore files, info/exclude and core.excludesFile all
apply, including names no fixed list anticipates (.locust_env/). Outside
a work tree, or when git is missing, older than 2.16, fails, or exceeds
5s, a fixed list of common build/cache directory names stands in.

ShouldSkipDir is unchanged: glob, grep, list_directory and path
autocomplete share it and must keep seeing target/, .venv/ and friends.
The lookup runs with GIT_OPTIONAL_LOCKS=0 so a per-turn scan never takes
.git/index.lock next to a concurrent git command.

Fixes #1108

* fix(workspaceindex): skip a rename's original-path field when parsing status

In `git status --porcelain=v1 -z`, a rename or copy record is followed by one
more NUL field holding the original path, with no XY prefix. The parser
inspected every field, so an original name beginning with "!! " read as an
ignored entry: after `git mv "!! a" b`, a tracked file "a" disappeared from
Scan.

Move the parsing into parseIgnoredStatus, which consumes that field for R or C
in either status column, and test it directly and end to end.

* fix(workspaceindex): disable repo fsmonitor and force untracked-files=normal for status

Scan's git status lookup must not run a repository's core.fsmonitor hook or fail
when status.showUntrackedFiles=no would reject --ignored=matching.

* fix(workspaceindex): ignore inherited git repository location

Git exports GIT_DIR and GIT_WORK_TREE to hooks. With those pointing at
another repository, Scan kept .locust_env/lib/x.py, which root's own
.gitignore excludes.

* fix(workspaceindex): quote the fsmonitor hook path in the regression

On Windows, sh treats an unquoted backslash path as escapes, so the hook
ran and the marker the test stats was never created. Require a status
without the override to create that marker first.

* fix(workspaceindex): pass the fsmonitor hook path to git with forward slashes

Smoke (windows-latest) failed TestGitIgnoredPathsDisablesRepoFsmonitor
because core.fsmonitor was set to a backslash script path. Git for Windows
hands that value to sh, which consumes the separators, so the hook never
runs and the marker is never created. The test now stores a quoted forward-slash
path. The product code is unchanged.
2026-10-15 15:34:28 +08:00
Vasanth T 640d936bfb fix(tui,cli): stop a repository's git config from running programs in Zero's own git calls (#1133)
* fix(tui,cli): stop a repository's git config from running programs in Zero's own git calls

Zero runs git status and git diff on its own: the TUI's files sweep at
startup and after each command, its pull request diffstat, and the
scratch-file warning after a run. A repository's own config can name
programs those commands run (core.fsmonitor on every status, hooks when
the index is rewritten, clean filters and diff drivers assigned through
.gitattributes), so a workspace unpacked from an archive ran its own code
on the first turn, although Zero refuses the same workspace's project
hooks and plugins until the user trusts it.

internal/gitsafe builds those commands with core.fsmonitor and
core.hooksPath turned off and GIT_OPTIONAL_LOCKS=0, and drops the
repository-location variables git exports to hooks, using the same
environment for its config check and for the command. A repository whose
own config, or per-worktree config when extensions.worktreeConfig is on,
defines a filter or diff driver command is refused, and the callers
already treat that like a directory that is not a repository. The status
and diff calls also pass --ignore-submodules=dirty, because a submodule's
own config is not checked.

* test(gitsafe): keep the real-git setup off an inherited GIT_DIR

The setup helper in TestCommandAgainstRealGit ran git with the inherited
environment, so from a hook, or a shell that exports GIT_DIR or
GIT_WORK_TREE, git init and git config would act on the repository those
name instead of the temporary one. It now uses the same environment as
Command, and the test sets both variables to a decoy repository and checks
after each setup step that the decoy was not touched.
2026-10-15 15:33:32 +08:00
Kevin Codex 8e363fcaa5 fix(npm): wait for npm to apply each publish, and drop the one-time @gitlawb/zero steps (#1145)
* fix(npm): wait for npm to apply each publish, and drop the one-time @gitlawb/zero steps

npm applies a publish asynchronously: `npm publish` returns once the upload
is accepted, and the version appears later. In the 0.10.0 release the
@twigpine/zero wrapper appeared 9 seconds after publish-packages.sh stopped
checking for it, and the last platform payload about ten minutes after its
upload. Two things went wrong:

- The wrapper became visible before four of its five platform payloads, so
  installs in that window resolved aliases that 404 and fell back to the
  GitHub-release download. Ordering the uploads does not order what npm
  shows.
- The final `latest` check gave up after 25 seconds and failed the job even
  though every publish landed, which also skipped the @gitlawb/zero steps
  until a re-run.

publish-packages.sh now waits until npm shows every platform version before
it publishes the wrapper, then waits until `latest` is the wrapper version.
One deadline bounds the waits (ZERO_NPM_PUBLISH_WAIT_TIMEOUT, default 30
minutes). On timeout it fails before the wrapper goes out and says what npm
never showed. These lookups treat an error as "not yet", unlike the skip
decision, which still stops on anything but E404.

The test fake can now apply a publish only after a number of later lookups,
and records which aliased versions were missing when the wrapper was
published. Against the script as released in v0.10.0, the two new tests fail
the way the release did: the job exits 1 although everything landed, and the
wrapper goes out while its platform payload is not visible.

The two release steps that published 0.10.0 once more as @gitlawb/zero have
done their job and are removed; docs/NPM_PACKAGING.md and the comments that
pointed at them now describe what happened. The Go and wrapper handling of
the old name stays, since only a build installed under it can trigger it.

* fix(npm): validate the publish wait settings, and correct the legacy publishing claim

ZERO_NPM_PUBLISH_WAIT_TIMEOUT went into Bash arithmetic as typed. A leading
zero reads as octal (0600 waited 384 seconds), and 08 is an arithmetic error
that, in a script, does not stop it: Bash prints the error, leaves the
deadline unset and carries on, so the run only died at its first wait, after
the platform payloads were published. A negative timeout ended the wait
before it started, and a bad ZERO_NPM_PUBLISH_RETRY_DELAY only failed at the
first sleep, also after publishing began.

Both settings are now checked before anything is published: whole seconds
only, converted with 10# so a leading zero is decimal, bounded before the
conversion, and capped (timeout 86400, delay 600). A bad value fails with
the variable's name.

TestPublishPackagesReadsTheWaitTimeoutAsDecimal makes the script wait, which
is where the old code broke: against the reviewed script it publishes the
platform payload and then exits 1 on "08: value too great for base".
TestPublishPackagesRejectsAnInvalidWaitSetting covers -5, abc, 1e3, an
over-range and an overflowing timeout, and a non-numeric delay; the old
script published with five of them and crashed on the sixth with
"abc: unbound variable".

docs/NPM_PACKAGING.md said nothing could publish @gitlawb/zero again.
Removing the trusted publisher stops this workflow; a maintainer could
still publish by hand, and the doc now says that.
2026-10-15 13:30:11 +08:00
github-actions[bot] 856919bcaa chore(main): release 0.10.0 (#1061)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-10-15 12:14:04 +08:00
Vasanth T 883e5bec24 fix(sessions): keep a session lease for the life of the process, not of the Store (#1144)
A session's lease file lived in the Store that took it, so once that Store
was unreachable the garbage collector closed the file, and closing it drops
the lock. That made TestForkAndChildHoldTheParentTheyAreCreatedFrom fail on
main whenever a collection ran before its dry-run prune. It can also happen
in an exec run: PrepareExec takes the calling or forked session's lease on a
Store the run never touches again, so the parent's lease can disappear part
way through and a prune in another terminal could remove it.

Leases now belong to the process, as Hold documents. Every Store holding a
session shares one lease file in a process-wide table, and the file is
unlocked and closed only when the last Store holding it releases it.
2026-10-15 12:05:04 +08:00
Kevin Codex 530563a0bb feat(npm): publish as @twigpine/zero (#1142)
* feat(npm): publish as @twigpine/zero

The npm package moves from @gitlawb/zero to @twigpine/zero, and everything
that finds Zero's GitHub repository by name now names Twigpine/zero.

Package name
- package.json, the lockfile and release-please use @twigpine/zero.
  `repository` names Twigpine/zero: npm checks provenance against it, so a
  publish from this repo with the old URL fails with E422. The homepage is
  zero.twigpine.com.
- The name lives in package.json only. build-platform-packages.mjs names the
  wrapper, platform versions, alias names and alias targets from it, and
  bin/zero.js reads its own package.json for the alias it resolves and for
  every reinstall hint. The Go constant npmPackageName is held equal to
  package.json by a test.

Repository defaults
- The update check (DefaultRepository), install.sh, install.ps1, the npm
  wrapper's fallback downloader, and the GitHub Action's zero-repo default
  all used Gitlawb/zero. That name now belongs to an unrelated GitHub org, and
  these paths download and run what they find there, so they point at
  Twigpine/zero. Docs, README and issue-template links follow. The Go module
  path (and so `go install github.com/Gitlawb/zero/...`) is unchanged here.

Publishing
- The two inline publish steps become scripts/npm/publish-packages.sh, which
  reads the name from the assembled payloads, refuses to start when the
  wrapper's aliases do not match the platform payloads, skips versions
  already on the registry (so re-running a half-finished job is safe), and
  publishes with --ignore-scripts. The dist-tag rules are unchanged.
  internal/npmwrapper/publish_test.go runs it against a fake registry.

Docs explain the switch for existing installs: uninstall @gitlawb/zero first,
because both packages own the `zero` bin and npm refuses the second (EEXIST).

* feat(npm): publish 0.10.0 once more as @gitlawb/zero to point users at the new name

Deprecating @gitlawb/zero alone would strand every existing npm install.
0.9.0's `zero upgrade` runs `npm install -g @gitlawb/zero@latest`; with
nothing newer under that name npm installs nothing, prints no deprecation
warning (it only warns when it installs something), and Zero then reports
"updated via npm to 0.10.0" while staying on 0.9.0.

So this release goes out once more under the old name, as a bridge:

- The publish job assembles the same verified artifacts a second time with
  the new `build-platform-packages.mjs --package-name @gitlawb/zero` and
  publishes them with publish-packages.sh, after the @twigpine/zero publish.
  A gate runs it only while @gitlawb/zero's latest is still 0.9.0 (or is
  this version, on a re-run), so it happens once.
- The build knows its name from the package.json beside it. The Go side
  reports InstallMethodNpmLegacy: `zero upgrade` refuses with the switch
  steps before any release check (so it works offline too), and
  `zero update --check` adds them whether or not an update is out.
- bin/zero.js prints the same steps after zero exits (the TUI would hide a
  notice printed first), only when stderr is a terminal, and not for
  `update`/`upgrade`, which print it themselves.
- Updating from 0.9.0 now installs a real version, so npm also shows the
  deprecation message.

The steps say to uninstall @gitlawb/zero before installing @twigpine/zero:
both own the `zero` bin, npm refuses the second install with EEXIST, and a
forced install loses the bin when the old package is later removed (checked
against npm 11.16).

docs/NPM_PACKAGING.md has the maintainer runbook: bootstrap and trust the new
package, retarget the old one's trusted publisher for the bridge, then
deprecate @gitlawb/zero, revoke its publisher, and delete the bridge steps.

* fix(npm): only skip a publish when npm says the version is not there

publish-packages.sh treated any failed `npm view <name>@<version>` as "not
published". A transient network, auth or registry error on the re-run that the
skip exists for therefore published a version that was already out, and npm
rejected it with EPUBLISHCONFLICT; with the registry unreachable it published
blind. npm 11 reports every kind of missing (no such package, no such version,
including platform versions) as E404, so only E404 now means "not published".
Any other failure is retried three times and then stops the job before it
publishes anything, with npm's own error shown.

The test fake now answers a missing version with E404 the way npm does (it
returned an empty success before), and can fail lookups with a network error.
The two new tests fail on the old lookup: one with EPUBLISHCONFLICT after
transient failures, the other by publishing while the registry is unreachable.

Also renames the unused retry variable in the legacy-name gate step to `_`
(shellcheck SC2034), matching the script.
2026-10-15 11:05:51 +08:00
Vasanth T c56a4c4e6e fix(deps): refresh the lockfile to patched shell-quote (#1131)
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-15 10:13:27 +08:00
Fabio Tavares Leitão 300cf6275f fix(acp): persist token usage events for ACP sessions (#1137)
* fix(acp): persist token usage events for ACP sessions

ACP turns never set agent.Options.OnUsage, so provider token counts were
dropped on the floor. zero exec and the TUI persist the same counts as
session usage events, and `zero usage report` totals those events, which
left every editor-driven ACP session at zero requests and zero tokens.

runTurn now queues each report through usage.EventUsagePayload, the same
writer exec uses for a non-escalating run, and commits it in the turn's
existing buffered batch under the session id. A hard run failure still
returns before persistTurn, so that turn's usage is dropped with its
history. StopCancelled stays a successful outcome and keeps the usage
already reported. session/load and session/resume still skip event types
other than messages, compaction, and tools, so the restored prompt and
the wire replay do not gain a usage channel. ACP has no model escalation,
so the payload has no "model" key and no prompt, tool argument, or other
raw input.

This sits beside the permission-mode field persisted on the session
(#1134). It does not change the ACP wire protocol, exec, the TUI, or the
report reader.

Refs #1118

* test(acp): pin cancel, resume, and payload shape for usage events

The persistence test showed that a successful turn records token counts,
and the hard-failure test showed that an aborted turn records nothing.
Neither one locked the two edges that are easy to get backwards.

A client cancel is a StopCancelled outcome, not a hard failure, so the
tokens the provider already reported stay in the session log. session/load
and session/resume read that same log; the replay and the next prompt
must still be the conversation, with no usage payload on the wire and no
token counts folded into the resumed prompt. The persisted JSON is also
checked to be token-count keys only, so a prompt or tool argument cannot
ride along in the usage record.

Refs #1118
2026-10-15 09:44:28 +08:00
Fabio Tavares Leitão 106c0b77f9 test(cli): pin sandbox policy golden temp roots (#1139)
* test(cli): pin sandbox policy golden temp roots

TestRunSandboxPolicyJSONGoldenIncludesManagerBaselineFields compared the
policy snapshot to a fixed golden after stripping only the temp roots
NewScope keeps. NewScope drops a candidate that sits inside an earlier one,
while the JSON (built with a nil scope) still lists it, so a TMPDIR inside
/tmp — or TMP inside TEMP on Windows — survived normalization and failed
the golden. Pin the variables that path reads (TMPDIR on Unix, TEMP and TMP
on Windows) and cover a nested temp dir that is not inside the earlier
candidate, so the normalizer removes it instead of leaving it in the JSON.

Refs #1121

* test(cli): run the golden against an ambient nested temp dir

The nested golden created its temp dir in the package working directory and
failed when that path sat inside /tmp, so a checkout under /tmp could not
run it and the test wrote into the source tree. Stage the issue's shape
first — TMPDIR inside /tmp, or TMP inside TEMP — then let the pin override
those variables. Directories come from t.TempDir. If the nested shape cannot
be built, skip.

Refs #1121
2026-10-15 09:21:16 +08:00
zzucs 846d30f57f test(cli): give the local adoption probe a test-sized timeout (#1136)
* test(cli): give the local adoption probe a test-sized timeout

TestLocalAdoptionSavesAndReloadsEligibleModel relied on the 400ms
defaultLocalProbeTimeout, which treats a slow probe as "runtime not running"
and silently drops the candidate; under -race on a busy runner one slow
loopback round trip can exceed it and empty the detection result. Pass a
5-second test deadline instead. The production default is unchanged and stays
covered by TestDetectLocalRuntimesAppliesDefaultTimeout.

Refs #1132

* test(cli): correct the timeout-coverage note on the adoption probe

TestDetectLocalRuntimesAppliesDefaultTimeout pins the probe timeout with an
explicit 20ms deadline, so it covers timeout enforcement rather than the
400ms production default. Reword the comment accordingly; no assertion or
production behavior changes.

Addresses the CodeRabbit finding on #1136.
2026-10-14 22:31:06 +08:00
zzucs bb96206c27 fix(acp): persist the session permission mode across restarts (#1134)
ACP kept the permission mode in memory only: both mode doors called setMode,
registerSession always started at PermissionModeAuto, and nothing wrote the
mode to the session store, so session/load and session/resume returned a
plan/ask session as auto after a restart.

Save the mode next to the model on both doors, store first so a failed write
fails the request instead of leaving disk behind memory, and restore it through
registerSession. A session with no saved mode (written before this change)
keeps auto; a saved value outside auto/ask/plan never comes back weaker than
ask, and unsafe stays unreachable over ACP.

Refs #1125
2026-10-14 22:30:25 +08:00
PierrunoYTandClaude Sonnet 5.5 e0fb65f003 test(sessions): run the session lock test on Windows and add a cross-process one (#1115)
* test(sessions): run the session lock test on Windows and add a cross-process one

TestSessionFileLockSerializesAcrossStores was skipped on Windows with
"flock semantics differ on windows", so the LockFileEx path in
filelock_windows.go was never exercised by a test on the platform that
uses it. LockFileEx locks are per handle, which is what the test relies
on, so the skip was unnecessary: remove it.

Also add a helper-process test (same pattern as lockutil's) proving the
session lock blocks a different process and is released by the OS when
the holder exits without unlocking.

Both fail on Windows when the lock is replaced by a no-op.

Fixes #1104

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

* test(sessions): keep the session lock alive in the helper process

The helper discarded the unlock closure returned by lockSession, which is the only reference to the open lock file/handle. GC finalization could close it while the helper waits on stdin, releasing the OS lock early and making the cross-process test pass for the wrong reason. Retain the closure and runtime.KeepAlive it after io.Copy, as the lockutil helper does.

---------

Co-authored-by: Claude Sonnet 5.5 <[email protected]>
2026-10-14 22:29:36 +08:00
PierrunoYTandClaude Opus 5.5 a3d4edef8f test(tui): isolate user home, config and cache dirs for the TUI tests (#1114)
* test(tui): isolate user home, config and cache dirs for the TUI tests

newModel reads <user config>/zero/commands/*.md, and the package has 500+
newModel calls with no isolation, so a developer's own slash commands and
state leaked into the TUI tests (and tests could write to real user
directories), against the AGENTS.md "Hermetic tests" rule.

Add testutil.IsolateUserDirs, which points HOME, USERPROFILE, APPDATA,
LOCALAPPDATA and the XDG_* roots at a temp dir (covering Linux, macOS and
Windows), and call it from a TestMain in internal/tui. Per-test t.Setenv
overrides still win.

Refs #1103

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

* test(testutil): fail closed when user dir isolation cannot be applied

IsolateUserDirs now returns an error when any Setenv fails, rolling back
the variables it had already redirected, and its restore func reports
restoration failures. The TUI TestMain exits non-zero when isolation,
restoration or removal of the temp root fails, and the helper test
restores the inherited XDG_DATA_HOME via t.Setenv before clearing it.

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

* test(testutil): cover partial-redirect rollback and compare physical paths

Make Setenv injectable so a test can let HOME be redirected and fail the
next variable, proving IsolateUserDirs rolls back a variable it really
changed (the NUL-root case fails on the first call and cannot).

Add testutil.PathWithin, which resolves the existing part of each path
with EvalSymlinks before the containment check, and use it in both the
helper test and the TUI hermetic test so symlinked temp dirs and Windows
short names cannot cause false results.

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

---------

Co-authored-by: Claude Sonnet 5.5 <[email protected]>
2026-10-14 22:28:58 +08:00
PierrunoYTandClaude Sonnet 5.5 a195e8848c fix(tui): stop held peer message Allow/Deny from deadlocking Update (#1111)
The peer approval prompt's decide closure called runtimeMessageSink from
inside Update. The sink ends in program.Send, which blocks on bubbletea's
unbuffered message channel; the only reader is the event loop that is
running Update, so answering a held cross-session message hung the TUI.

Give pendingPermissionPrompt a decideCmd that returns a tea.Cmd yielding
the peerDecisionMsg, and have resolvePermissionWithReason return it
instead of calling the sink. Tests now drive the decision through the
returned command and a blocking sink, which hangs on the old code.

Fixes #1099

Co-authored-by: Claude Sonnet 5.5 <[email protected]>
2026-10-14 22:28:03 +08:00
PierrunoYTandClaude Sonnet 5.5 a0979fb756 test(tools): poll for listening address in foreground server test (#1110)
TestExecCommandForegroundServerReturnsSessionAndServesHTTP parsed the
helper's listening address from the first exec_command result only, with
a 500 ms yield. On a loaded Windows runner the helper had not printed its
address yet, so the test failed even though the session was running.

Poll the session with write_stdin until the address appears (20 s
deadline), and register the cleanup before the address check so the
server is stopped on early failure too.

Fixes #1097

Co-authored-by: Claude Sonnet 5.5 <[email protected]>
2026-10-14 22:21:12 +08:00
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
247 changed files with 20203 additions and 1267 deletions

No files matched your search

+3 -3
View File
@@ -1,11 +1,11 @@
blank_issues_enabled: false
contact_links:
- name: Questions and setup help
url: https://github.com/Gitlawb/zero/discussions/categories/q-a
url: https://github.com/Twigpine/zero/discussions/categories/q-a
about: Ask in Discussions Q&A. Include zero --version, your OS, provider/model, and zero doctor output for setup problems.
- name: Feature ideas
url: https://github.com/Gitlawb/zero/discussions/categories/ideas
url: https://github.com/Twigpine/zero/discussions/categories/ideas
about: Propose and discuss ideas in Discussions before opening a PR (Zero is in controlled development, see CONTRIBUTING.md).
- name: Security vulnerability
url: https://github.com/Gitlawb/zero/security/advisories/new
url: https://github.com/Twigpine/zero/security/advisories/new
about: Report security issues privately via GitHub Security Advisories (see SECURITY.md). Do not file a public issue.
+1 -1
View File
@@ -5,7 +5,7 @@ body:
- type: markdown
attributes:
value: |
New feature ideas start in [Discussions Ideas](https://github.com/Gitlawb/zero/discussions/categories/ideas),
New feature ideas start in [Discussions Ideas](https://github.com/Twigpine/zero/discussions/categories/ideas),
not here. This form is for the step after that: an idea that gained traction in Discussions,
or one a maintainer asked to have tracked as an issue. Please link the discussion below.
Do not begin implementation until the issue has been reviewed and marked with the
+23 -55
View File
@@ -100,19 +100,21 @@ jobs:
gh release create "$tag" --repo "$GITHUB_REPOSITORY" --title "$tag" --generate-notes 2>/dev/null || true
gh release upload "$tag" dist/release/* --repo "$GITHUB_REPOSITORY" --clobber
# Publishes @gitlawb/zero to npm: first the five platform payloads (versions
# of the SAME package at suffixed versions like X.Y.Z-linux-x64, carrying the
# native binary + vendored helpers), then the wrapper at X.Y.Z whose
# optionalDependencies alias those platform versions. Platform versions MUST
# publish first — the wrapper pins them exactly, so the reverse order would
# open a window where installs resolve aliases that 404. See
# docs/NPM_PACKAGING.md for the full contract.
# Publishes the npm package named in package.json (@twigpine/zero): first
# the five platform payloads (versions of the SAME package at suffixed
# versions like X.Y.Z-linux-x64, carrying the native binary + vendored
# helpers), then the wrapper at X.Y.Z whose optionalDependencies alias those
# platform versions. Platform versions MUST publish first — the wrapper pins
# them exactly, so the reverse order would open a window where installs
# resolve aliases that 404. See docs/NPM_PACKAGING.md for the full contract.
#
# Auth is npm OIDC trusted publishing: no NPM_TOKEN secret. The package's
# npmjs.com settings must list this repo + workflow file as a trusted
# publisher, and provenance is then generated automatically. Note npm only
# allows configuring a trusted publisher on an EXISTING package, so the very
# first @gitlawb/zero publish is a one-time manual `npm publish` bootstrap.
# npmjs.com settings must list this repo + workflow file + the npm-publish
# environment as a trusted publisher, and provenance is then generated
# automatically. npm checks provenance against package.json `repository`,
# so that must name this repository. Note npm only allows configuring a
# trusted publisher on an EXISTING package, so a new package name starts
# with a one-time manual `npm publish` of an empty 0.0.0 placeholder.
# Because the platform payloads are versions of the same package, they reuse
# that single trusted-publisher configuration.
publish-npm:
@@ -125,7 +127,7 @@ jobs:
# maintainer approves it in the Actions UI before anything reaches npm.
environment:
name: npm-publish
url: https://www.npmjs.com/package/@gitlawb/zero
url: https://www.npmjs.com/package/@twigpine/zero
permissions:
contents: read
id-token: write # OIDC token exchange for npm trusted publishing + provenance
@@ -201,47 +203,13 @@ jobs:
- name: Assemble npm packages
run: node scripts/npm/build-platform-packages.mjs --artifacts-dir dist/artifacts --out-dir dist/npm
# Platform versions are semver PRERELEASES of the wrapper version
# (X.Y.Z-linux-x64), so they must never publish under the default
# `latest` dist-tag — that would make `npm install @gitlawb/zero`
# deliver a platform payload as the CLI. Publish them under a dedicated
# tag and assert `latest` survived before the wrapper goes out.
- name: Publish platform packages to npm
# Platform payloads first under the `platform` dist-tag, then the wrapper,
# with the `latest` dist-tag checked before and after (platform versions
# are semver PRERELEASES of the wrapper version and must never become
# `latest`). npm applies publishes asynchronously, so the script waits
# until npm shows every platform version before the wrapper goes out,
# and until `latest` moves after it. Versions already on the registry
# are skipped, so "Re-run failed jobs" is safe after a partial publish.
- name: Publish npm packages
shell: bash
run: |
version="$(node -p "require('./package.json').version")"
for dir in dist/npm/platforms/*/; do
echo "publishing ${dir}"
(cd "$dir" && npm publish --access public --tag platform)
done
# Tolerate a failed lookup (registry lag, or no latest tag yet on a
# bootstrap publish): under set -e a bare failure here would abort
# AFTER the platform payloads went out, leaving no wrapper and a
# re-run that fails on republishing existing versions. An empty
# value falls through the guard below.
latest="$(npm view @gitlawb/zero dist-tags.latest || true)"
case "$latest" in
"${version}-"*)
echo "::error::dist-tag latest points at platform version ${latest} — a platform publish clobbered it. Fix the dist-tag before the wrapper publish." >&2
exit 1
;;
esac
echo "dist-tag latest is ${latest} (unchanged by platform publishes)."
- name: Publish wrapper to npm
shell: bash
run: |
version="$(node -p "require('./package.json').version")"
(cd dist/npm/wrapper && npm publish --access public)
# Confirm the wrapper owns `latest` (retry: registry metadata can lag
# a publish by a few seconds).
for attempt in 1 2 3 4 5; do
latest="$(npm view @gitlawb/zero dist-tags.latest || true)"
if [ "$latest" = "$version" ]; then
echo "dist-tag latest is ${version}."
exit 0
fi
sleep 5
done
echo "::error::dist-tag latest is ${latest}, expected ${version} after the wrapper publish." >&2
exit 1
run: scripts/npm/publish-packages.sh dist/npm
+1 -1
View File
@@ -1 +1 @@
{".":"0.9.0"}
{".":"0.10.0"}
+44
View File
@@ -6,6 +6,50 @@ 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.10.0](https://github.com/Twigpine/zero/compare/v0.9.0...v0.10.0) (2026-10-15)
### Features
* **notify:** make permission-prompt alerts on by default and discoverable ([#1001](https://github.com/Twigpine/zero/issues/1001)) ([7f8e5f0](https://github.com/Twigpine/zero/commit/7f8e5f08bb83371ce16cc54c97ab032a010026cf))
* **npm:** publish as @twigpine/zero ([#1142](https://github.com/Twigpine/zero/issues/1142)) ([530563a](https://github.com/Twigpine/zero/commit/530563a0bb58d3cbadceb97b3ee234ef763124df))
* **providers:** add GMI Cloud provider preset and partner listing ([#1127](https://github.com/Twigpine/zero/issues/1127)) ([1335ca6](https://github.com/Twigpine/zero/commit/1335ca6671023ecda81a3cfaae7e9f2ff5d6e0de))
* **providers:** opt-in ZERO_RESPONSE_HEADER_TIMEOUT override for slow first-byte deployments ([#1106](https://github.com/Twigpine/zero/issues/1106)) ([ddc45f2](https://github.com/Twigpine/zero/commit/ddc45f2c8152fdd04b45dc11ed20d246fe7740a5))
* **runtime:** add process-local per-model RPM admission limits ([#1116](https://github.com/Twigpine/zero/issues/1116)) ([eebb384](https://github.com/Twigpine/zero/commit/eebb384676fb567acc1f5a20dcbb17df8a03dc42))
* **sessions:** add zero sessions prune, which never removes a session another process has open ([#1089](https://github.com/Twigpine/zero/issues/1089)) ([23c27e0](https://github.com/Twigpine/zero/commit/23c27e0778e3af54c04b8749d1eda846d3253f4e))
### Bug Fixes
* **acp:** persist the session permission mode across restarts ([#1134](https://github.com/Twigpine/zero/issues/1134)) ([bb96206](https://github.com/Twigpine/zero/commit/bb96206c2781e846fd13437113eec5790afe9c81)), closes [#1125](https://github.com/Twigpine/zero/issues/1125)
* **acp:** persist token usage events for ACP sessions ([#1137](https://github.com/Twigpine/zero/issues/1137)) ([300cf62](https://github.com/Twigpine/zero/commit/300cf6275f8c94f06da3f00197d21cb8771842d9)), closes [#1118](https://github.com/Twigpine/zero/issues/1118)
* **acp:** publish sub-run sessions loaded over ACP as view-only ([#1078](https://github.com/Twigpine/zero/issues/1078)) ([7f11491](https://github.com/Twigpine/zero/commit/7f11491078662b2d389c278f3ce5d41d956670c4))
* **agent:** retry mid-stream transport aborts before tool dispatch ([#1076](https://github.com/Twigpine/zero/issues/1076)) ([b9ef1d2](https://github.com/Twigpine/zero/commit/b9ef1d271aa6ebc6d9adad2ef4ff0d695bb40da5))
* **config:** sync atomic config replacements ([#1093](https://github.com/Twigpine/zero/issues/1093)) ([4d2e9f0](https://github.com/Twigpine/zero/commit/4d2e9f0d08472f7304652ef7896df587e1cc6c21))
* **cron:** bound run history and read recent records from the tail ([#1094](https://github.com/Twigpine/zero/issues/1094)) ([42eb6ab](https://github.com/Twigpine/zero/commit/42eb6ab5fbe4ae9037f1f4cd42a00243c456a22e))
* **cron:** preserve newer claims when overlapping runs complete ([#1090](https://github.com/Twigpine/zero/issues/1090)) ([2eb9d94](https://github.com/Twigpine/zero/commit/2eb9d94fac56d91d749ea4f96edcf1c87f79b48c))
* **daemon:** bound pending sessions and prune on completion ([#1091](https://github.com/Twigpine/zero/issues/1091)) ([8a68660](https://github.com/Twigpine/zero/commit/8a68660161b04ce9668363faa14ff72c9fbad223))
* **daemon:** do not retry workers that finished with a final exit code ([#1112](https://github.com/Twigpine/zero/issues/1112)) ([eefa978](https://github.com/Twigpine/zero/commit/eefa978f6183332b7416c70c25242bcdfba1a75c)), closes [#1100](https://github.com/Twigpine/zero/issues/1100)
* **daemon:** stop worker process groups by their launch-time identity ([#1088](https://github.com/Twigpine/zero/issues/1088)) ([ab018b3](https://github.com/Twigpine/zero/commit/ab018b3098b5cb103cf8f90b8698d32a6687bdf7)), closes [#861](https://github.com/Twigpine/zero/issues/861)
* **deps:** refresh the lockfile to patched hono and @hono/node-server ([#1080](https://github.com/Twigpine/zero/issues/1080)) ([aa52040](https://github.com/Twigpine/zero/commit/aa52040498e012c41d5e8f6027a91c18eff861d4))
* **deps:** refresh the lockfile to patched shell-quote ([#1131](https://github.com/Twigpine/zero/issues/1131)) ([c56a4c4](https://github.com/Twigpine/zero/commit/c56a4c4e6e598504ab417e8cc4f5ab8d69f1b42f))
* **execution:** reserve process capacity before launching ([#1092](https://github.com/Twigpine/zero/issues/1092)) ([bb3346a](https://github.com/Twigpine/zero/commit/bb3346a82883c19ed8815fe1e874f54950711d69))
* **mcp:** forward image tool results on the existing image channel ([#1077](https://github.com/Twigpine/zero/issues/1077)) ([0324c1d](https://github.com/Twigpine/zero/commit/0324c1d4103328eb90cb24a0b582dba335255b5c))
* **mcp:** refresh OAuth tokens for discovered endpoints and registered clients ([#1113](https://github.com/Twigpine/zero/issues/1113)) ([571af70](https://github.com/Twigpine/zero/commit/571af701bd3d8dfaf40f87b5b112b25e7def5b29)), closes [#1101](https://github.com/Twigpine/zero/issues/1101)
* **oauth:** give every loopback callback server the bounded shutdown ([#1079](https://github.com/Twigpine/zero/issues/1079)) ([5473301](https://github.com/Twigpine/zero/commit/547330197b20ece591e077e876a4b8ffd015cb6e))
* **providers:** send stable session headers to OpenCode ([#1060](https://github.com/Twigpine/zero/issues/1060)) ([99721c7](https://github.com/Twigpine/zero/commit/99721c762f37cd43ac511007a5f51d1846df959e))
* **redaction:** redact adjacent AWS keys glued end to end ([#1095](https://github.com/Twigpine/zero/issues/1095)) ([df67ede](https://github.com/Twigpine/zero/commit/df67edec13345b73a85327adc003cacc7bdd8009))
* **redaction:** redact credentials split by an invisible character ([#1067](https://github.com/Twigpine/zero/issues/1067)) ([e20b762](https://github.com/Twigpine/zero/commit/e20b7624261cef8336a8eb0fbdd44d536ad77fc2))
* **sandbox:** say when a session's sandbox enforcement is degraded ([#1082](https://github.com/Twigpine/zero/issues/1082)) ([6b5852d](https://github.com/Twigpine/zero/commit/6b5852dc18bb256af1f25745697c2f2e3ea6f112))
* **search:** redact every text field of session metadata, not ten of thirty ([#1073](https://github.com/Twigpine/zero/issues/1073)) ([9520f2b](https://github.com/Twigpine/zero/commit/9520f2bad7e09ce4a8d0d42be7ce77d2ae928ddd))
* **security:** harden shell flag parsing, write roots, and formatters ([#1066](https://github.com/Twigpine/zero/issues/1066)) ([ac5fe0d](https://github.com/Twigpine/zero/commit/ac5fe0d21849b0f74af428dd3ca12e3b80a8b7aa))
* **sessions:** keep a session lease for the life of the process, not of the Store ([#1144](https://github.com/Twigpine/zero/issues/1144)) ([883e5be](https://github.com/Twigpine/zero/commit/883e5bec24dac791d1d60618c7f4927f4659b687))
* **tools:** hint when Windows resolves POSIX paths under root ([#1063](https://github.com/Twigpine/zero/issues/1063)) ([0c6f179](https://github.com/Twigpine/zero/commit/0c6f179a8443d4dfdfbbefe5cc6caf11c2a525f0))
* **tui:** bound live dictation regions ([#985](https://github.com/Twigpine/zero/issues/985)) ([feb9927](https://github.com/Twigpine/zero/commit/feb992757c582c68f0397e070e206d4bf07071e1))
* **tui:** show provider ownership in Recent and Favorites ([#1083](https://github.com/Twigpine/zero/issues/1083)) ([1697b7a](https://github.com/Twigpine/zero/commit/1697b7a5ce948e781877cdbe9223adac62c3fde6))
* **tui:** stop held peer message Allow/Deny from deadlocking Update ([#1111](https://github.com/Twigpine/zero/issues/1111)) ([a195e88](https://github.com/Twigpine/zero/commit/a195e8848cec1a27b4fe1789ecc572b943c32186)), closes [#1099](https://github.com/Twigpine/zero/issues/1099)
* unbreak main CI (cron claim tests, Go 1.26.9) ([#1124](https://github.com/Twigpine/zero/issues/1124)) ([2c418fc](https://github.com/Twigpine/zero/commit/2c418fc8ff6763de16703128f372fe526fddd331))
## [0.9.0](https://github.com/Gitlawb/zero/compare/v0.8.0...v0.9.0) (2026-09-15)
+5 -5
View File
@@ -9,9 +9,9 @@ reports, feature requests, reproduction details, logs, and discussion on existin
issues.
Questions and setup help belong in
[GitHub Discussions](https://github.com/Gitlawb/zero/discussions) (see
[GitHub Discussions](https://github.com/Twigpine/zero/discussions) (see
[SUPPORT.md](SUPPORT.md)), not in the issue tracker. Feature ideas start in the
[Ideas](https://github.com/Gitlawb/zero/discussions/categories/ideas) category;
[Ideas](https://github.com/Twigpine/zero/discussions/categories/ideas) category;
an idea that gains traction there is the path to an approved issue and,
eventually, an accepted PR.
@@ -41,7 +41,7 @@ There are three primary ways to contribute:
1. Open a bug report with clear reproduction steps.
2. Propose a feature in
[Discussions Ideas](https://github.com/Gitlawb/zero/discussions/categories/ideas),
[Discussions Ideas](https://github.com/Twigpine/zero/discussions/categories/ideas),
describing the problem, use case, and expected behavior. A feature-request
issue is filed only after the idea gains traction there or a maintainer asks
for one.
@@ -193,7 +193,7 @@ Clear bug reports are the most useful way to help the project during this phase.
## Submitting a Feature Request
Feature requests start as a thread in
[Discussions Ideas](https://github.com/Gitlawb/zero/discussions/categories/ideas),
[Discussions Ideas](https://github.com/Twigpine/zero/discussions/categories/ideas),
not as an issue. The feature-request issue form exists for the step AFTER that:
when an idea has gained traction in Discussions or a maintainer has asked for an
issue to track it. Whether in the discussion or the eventual issue, please
@@ -222,7 +222,7 @@ contribution phase.
General questions, setup help, and "is this a bug or my config" uncertainty all
go to
[Discussions Q&A](https://github.com/Gitlawb/zero/discussions/categories/q-a).
[Discussions Q&A](https://github.com/Twigpine/zero/discussions/categories/q-a).
If you are unsure whether a proposed code change is appropriate, raise it in
Discussions Ideas or on the relevant issue first and ask. Please keep discussion
focused, specific, and respectful of maintainer time.
+116 -13
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,6 +29,70 @@ 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,
@@ -46,7 +116,7 @@ zero exec --output-format stream-json < turns.jsonl
### npm
```bash
npm install -g @gitlawb/zero
npm install -g @twigpine/zero
zero
```
@@ -60,26 +130,38 @@ matching GitHub Release whenever it is missing. Windows on ARM runs the x64
build under emulation. See [docs/NPM_PACKAGING.md](docs/NPM_PACKAGING.md) for
how the package is put together.
Installed Zero as `@gitlawb/zero`? The package moved to `@twigpine/zero` in
0.10.0 and the old name gets no further releases. Both packages provide the
`zero` command, so quit any running `zero`, then remove the old package first:
```bash
npm uninstall -g @gitlawb/zero
npm install -g @twigpine/zero
```
Your config, sessions, and logins are kept; they live outside the package. For
Bun, pnpm, and yarn, see [docs/INSTALL.md](docs/INSTALL.md#moving-from-gitlawbzero).
### Install scripts
Linux/macOS:
```bash
curl -fsSL https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.sh | bash
curl -fsSL https://raw.githubusercontent.com/Twigpine/zero/main/scripts/install.sh | bash
```
Windows PowerShell:
```powershell
irm https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.ps1 | iex
irm https://raw.githubusercontent.com/Twigpine/zero/main/scripts/install.ps1 | iex
```
### 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
git clone https://github.com/Twigpine/zero.git
cd zero
go run ./cmd/zero
```
@@ -133,6 +215,8 @@ export GEMINI_API_KEY=...
export AIMLAPI_API_KEY=...
export LONGCAT_API_KEY=...
export FIREWORKS_API_KEY=...
export GMI_API_KEY=...
export CONCENTRATE_API_KEY=...
export MINIMAX_API_KEY=...
export MINIMAXI_API_KEY=...
```
@@ -155,6 +239,18 @@ 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
```
To configure Concentrate directly, run:
```bash
zero providers setup concentrate --set-active
```
MiniMax presets use the Anthropic-compatible endpoints for the global and China
regions:
@@ -313,7 +409,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
@@ -331,6 +427,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
@@ -442,19 +545,19 @@ go run ./cmd/zero-release build --goos windows --goarch amd64 --output dist/zero
Real-time chat happens on the [Discord server](https://discord.gg/CaQDS6wdFn).
Questions, setup help, ideas, and sharing all live in
[GitHub Discussions](https://github.com/Gitlawb/zero/discussions):
[GitHub Discussions](https://github.com/Twigpine/zero/discussions):
| Category | Use it for |
|---|---|
| [Q&A](https://github.com/Gitlawb/zero/discussions/categories/q-a) | Setup help, provider/model configuration, "how do I" questions |
| [Ideas](https://github.com/Gitlawb/zero/discussions/categories/ideas) | Feature proposals and design discussion before any PR |
| [Show and tell](https://github.com/Gitlawb/zero/discussions/categories/show-and-tell) | Your skills, plugins, MCP setups, themes, and workflows |
| [Announcements](https://github.com/Gitlawb/zero/discussions/categories/announcements) | Releases and project news from the maintainers |
| [Q&A](https://github.com/Twigpine/zero/discussions/categories/q-a) | Setup help, provider/model configuration, "how do I" questions |
| [Ideas](https://github.com/Twigpine/zero/discussions/categories/ideas) | Feature proposals and design discussion before any PR |
| [Show and tell](https://github.com/Twigpine/zero/discussions/categories/show-and-tell) | Your skills, plugins, MCP setups, themes, and workflows |
| [Announcements](https://github.com/Twigpine/zero/discussions/categories/announcements) | Releases and project news from the maintainers |
For a good Q&A answer fast, include `zero --version`, your OS and install
method, the provider/model in use, and `zero doctor` output. See
[SUPPORT.md](SUPPORT.md). Bugs belong in
[issues](https://github.com/Gitlawb/zero/issues/new/choose); security reports
[issues](https://github.com/Twigpine/zero/issues/new/choose); security reports
follow [SECURITY.md](SECURITY.md), never a public thread.
## Contributing
+105 -12
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,6 +27,70 @@ 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、Atomic Chat,或任何 OpenAI/Anthropic 兼容端点。
@@ -35,32 +105,41 @@ zero exec --output-format stream-json < turns.jsonl
### npm
```bash
npm install -g @gitlawb/zero
npm install -g @twigpine/zero
zero
```
npm 包是一个小型包装器,其平台构建(Linux 和 macOS 的 x64/arm64、Windows 的 x64,包含浏览器/终端控制辅助工具)作为可选依赖直接从 npm registry 安装——没有安装脚本,也不会从 npm 之外下载任何内容。Bun、pnpm 和 yarn 的行为完全一致,无需任何信任或批准步骤。跳过可选依赖的安装(`--omit=optional`)也能工作:只要二进制文件缺失,包装器就会从对应的 GitHub Release 下载。Windows on ARM 通过模拟运行 x64 构建。详见 [docs/NPM_PACKAGING.md](docs/NPM_PACKAGING.md)。
之前通过 `@gitlawb/zero` 安装过 Zero?从 0.10.0 起,npm 包已迁移到 `@twigpine/zero`,旧包名不再发布新版本。两个包都提供 `zero` 命令,所以请先退出正在运行的 `zero`,再卸载旧包:
```bash
npm uninstall -g @gitlawb/zero
npm install -g @twigpine/zero
```
配置、会话和登录信息都会保留,它们存放在包之外。Bun、pnpm 和 yarn 的命令见 [docs/INSTALL.md](docs/INSTALL.md#moving-from-gitlawbzero)。
### 安装脚本
Linux/macOS:
```bash
curl -fsSL https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.sh | bash
curl -fsSL https://raw.githubusercontent.com/Twigpine/zero/main/scripts/install.sh | bash
```
Windows PowerShell:
```powershell
irm https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.ps1 | iex
irm https://raw.githubusercontent.com/Twigpine/zero/main/scripts/install.ps1 | iex
```
### 从源码构建
源码构建需要 Go 1.26.6+。
源码构建需要 Go 1.26.9+。
```bash
git clone https://github.com/Gitlawb/zero.git
git clone https://github.com/Twigpine/zero.git
cd zero
go run ./cmd/zero
```
@@ -106,6 +185,8 @@ export OPENAI_API_KEY=sk-...
export ANTHROPIC_API_KEY=...
export GEMINI_API_KEY=...
export LONGCAT_API_KEY=...
export GMI_API_KEY=...
export CONCENTRATE_API_KEY=...
```
要直接配置美团 LongCat(LongCat-2.0),运行:
@@ -114,6 +195,18 @@ export LONGCAT_API_KEY=...
zero providers setup longcat --set-active
```
要直接配置 GMI Cloud,运行:
```bash
zero providers setup gmicloud --set-active
```
要直接配置 Concentrate,运行:
```bash
zero providers setup concentrate --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`。
@@ -218,7 +311,7 @@ zero context 上下文预算报告
zero repo-map 确定性仓库映射
zero repo-info 本地仓库摘要
zero search | find 搜索本地会话历史
zero sessions 检查、恢复、分叉和回滚会话
zero sessions 检查、恢复、分叉、回滚和清理会话
zero spec 管理规范模式草稿
zero specialist 管理专业子智能体
zero skills 管理 Markdown 指令技能
@@ -281,14 +374,14 @@ go run ./cmd/zero-release build --goos windows --goarch amd64 --output dist/zero
实时交流在 [Discord 服务器](https://discord.gg/CaQDS6wdFn) 进行。
提问、安装帮助、想法和分享都在
[GitHub Discussions](https://github.com/Gitlawb/zero/discussions):
[GitHub Discussions](https://github.com/Twigpine/zero/discussions):
| 分类 | 用途 |
|---|---|
| [Q&A](https://github.com/Gitlawb/zero/discussions/categories/q-a) | 安装帮助、提供商/模型配置、"如何做"类问题 |
| [Ideas](https://github.com/Gitlawb/zero/discussions/categories/ideas) | 功能提议和 PR 之前的设计讨论 |
| [Show and tell](https://github.com/Gitlawb/zero/discussions/categories/show-and-tell) | 你的技能、插件、MCP 配置、主题和工作流 |
| [Announcements](https://github.com/Gitlawb/zero/discussions/categories/announcements) | 维护者发布的版本和项目动态 |
| [Q&A](https://github.com/Twigpine/zero/discussions/categories/q-a) | 安装帮助、提供商/模型配置、"如何做"类问题 |
| [Ideas](https://github.com/Twigpine/zero/discussions/categories/ideas) | 功能提议和 PR 之前的设计讨论 |
| [Show and tell](https://github.com/Twigpine/zero/discussions/categories/show-and-tell) | 你的技能、插件、MCP 配置、主题和工作流 |
| [Announcements](https://github.com/Twigpine/zero/discussions/categories/announcements) | 维护者发布的版本和项目动态 |
## 贡献
+2 -2
View File
@@ -2,7 +2,7 @@ name: ZERO
description: >-
Run the ZERO coding agent headlessly in a GitHub workflow. ZERO is
model- and provider-agnostic: you supply the provider and API key as inputs.
author: Gitlawb
author: Twigpine
branding:
icon: terminal
@@ -110,7 +110,7 @@ inputs:
zero-repo:
description: Repository to install the ZERO release from (owner/repo).
required: false
default: Gitlawb/zero
default: Twigpine/zero
outputs:
exit-code:
+63 -13
View File
@@ -1,11 +1,63 @@
#!/usr/bin/env node
import { spawnSync } from 'node:child_process';
import { existsSync } from 'node:fs';
import { existsSync, readFileSync, writeSync } from 'node:fs';
import { createRequire } from 'node:module';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
const CURRENT_PACKAGE_NAME = '@twigpine/zero';
// The npm package moved from @gitlawb/zero to @twigpine/zero in 0.10.0. That
// release was also published once under the old name, so 0.9.0's `zero
// upgrade` and anyone still installing the old name land on a working build
// that says where the package went (docs/NPM_PACKAGING.md). Nothing is
// published under the old name after that.
const LEGACY_PACKAGE_NAME = '@gitlawb/zero';
// The npm name this wrapper was published under, read from its own
// package.json so the name lives in one place. The platform payload alias and
// every reinstall hint below derive from it.
function readPackageName(root) {
try {
const { name } = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
if (typeof name === 'string' && name !== '') return name;
} catch {
// An unreadable manifest must not stop the CLI from launching.
}
return CURRENT_PACKAGE_NAME;
}
const packageName = readPackageName(packageRoot);
// Same note as legacyPackageSwitchMessage in internal/update/installmethod.go.
// Uninstalling first is the only safe order: both packages own the `zero` bin,
// so npm refuses to install the new one over the old (EEXIST), and forcing it
// lets a later uninstall of the old package delete the shared bin.
function legacyPackageNotice() {
return (
`[zero] Zero has moved to ${CURRENT_PACKAGE_NAME} on npm, and ${LEGACY_PACKAGE_NAME} gets no further releases.\n` +
'To keep getting updates, quit zero and run:\n' +
` npm uninstall -g ${LEGACY_PACKAGE_NAME}\n` +
` npm install -g ${CURRENT_PACKAGE_NAME}\n` +
'Uninstall first: both packages provide the `zero` command. With bun, pnpm or yarn, use its global remove and add commands instead.\n'
);
}
// Printed when the wrapper exits rather than before zero starts, because the
// TUI takes over the screen and would hide anything printed first. Only on a
// terminal, so scripts that capture stderr see nothing new. `zero update` and
// `zero upgrade` print the same note themselves.
if (packageName === LEGACY_PACKAGE_NAME && !['update', 'upgrade'].includes(process.argv[2])) {
process.on('exit', () => {
if (process.stderr.isTTY || process.env.ZERO_WRAPPER_SIMULATE_TTY === '1') {
// Synchronous: an exit handler cannot wait for an asynchronous write.
writeSync(2, legacyPackageNotice());
}
});
}
function zeroBinaryName(platform = process.platform) {
return platform === 'win32' ? 'zero.exe' : 'zero';
}
@@ -81,10 +133,10 @@ function bunRecoveryParagraph() {
return (
'You installed with Bun, which does not run dependency lifecycle scripts\n' +
'by default. Trust the package to run the blocked postinstall:\n' +
' bun pm trust @gitlawb/zero (project install)\n' +
' bun pm -g trust @gitlawb/zero (global install)\n' +
` bun pm trust ${packageName} (project install)\n` +
` bun pm -g trust ${packageName} (global install)\n` +
'On Bun versions without `bun pm trust`, add\n' +
' "trustedDependencies": ["@gitlawb/zero"]\n' +
` "trustedDependencies": ["${packageName}"]\n` +
'to your project package.json and reinstall.\n' +
'\n'
);
@@ -93,14 +145,14 @@ function bunRecoveryParagraph() {
function buildFromSourceParagraph() {
return (
'If this platform has no prebuilt binary, build from source:\n' +
'https://github.com/Gitlawb/zero (go run ./cmd/zero, requires Go 1.26+).'
'https://github.com/Twigpine/zero (go run ./cmd/zero, requires Go 1.26+).'
);
}
function missingBinaryContextParagraph() {
return (
'Normally npm installs it as an optional dependency of @gitlawb/zero\n' +
`(@gitlawb/zero-${process.platform}-${process.arch}), and the wrapper can\n` +
`Normally npm installs it as an optional dependency of ${packageName}\n` +
`(${packageName}-${process.platform}-${process.arch}), and the wrapper can\n` +
'also download it from the GitHub Release when it is missing.'
);
}
@@ -113,7 +165,7 @@ function missingNativeRecoveryParagraphs(postinstallScript) {
'\n' +
'Things to try:\n' +
' - reinstall without omitting optional dependencies:\n' +
' npm install -g @gitlawb/zero\n' +
` npm install -g ${packageName}\n` +
' - run the downloader manually (needs write access to the package dir):\n' +
` node "${postinstallScript}"\n` +
'\n' +
@@ -182,12 +234,12 @@ function missingNativeDoctorTextReport(postinstallScript) {
);
}
// The platform payload is a version of @gitlawb/zero itself, installed under
// an npm: alias (see docs/NPM_PACKAGING.md). The alias name is derived from
// The platform payload is a version of this same package, installed under an
// npm: alias (see docs/NPM_PACKAGING.md). The alias name is derived from
// process.platform/process.arch, so an unsupported platform simply fails to
// resolve and we fall through to the downloader.
function platformPackageBinary() {
const aliasedName = `@gitlawb/zero-${process.platform}-${process.arch}`;
const aliasedName = `${packageName}-${process.platform}-${process.arch}`;
let manifestPath;
try {
manifestPath = createRequire(import.meta.url).resolve(`${aliasedName}/package.json`);
@@ -227,8 +279,6 @@ function downloadMissingBinary(packageRoot) {
});
}
const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
function resolveNativeBinary() {
const fromPlatformPackage = platformPackageBinary();
if (fromPlatformPackage) return fromPlatformPackage;
+5 -5
View File
@@ -28,7 +28,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: Gitlawb/zero@v1
- uses: Twigpine/zero@v1
with:
prompt: ${{ inputs.task }}
provider: openai
@@ -66,7 +66,7 @@ jobs:
| `github-token` | no | `${{ github.token }}` | Token used to post a PR comment. Requires `pull-requests: write`. |
| `working-directory` | no | `${{ github.workspace }}` | Directory to run ZERO in. |
| `zero-version` | no | (action ref → `latest`) | ZERO release version/tag to install, e.g. `v1.2.3` or `latest`. |
| `zero-repo` | no | `Gitlawb/zero` | Repository to install the ZERO release from. |
| `zero-repo` | no | `Twigpine/zero` | Repository to install the ZERO release from. |
## Outputs
@@ -101,7 +101,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: Gitlawb/zero@v1
- uses: Twigpine/zero@v1
with:
prompt: |
Investigate this issue and propose a fix.
@@ -134,7 +134,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: Gitlawb/zero@v1
- uses: Twigpine/zero@v1
id: zero
with:
prompt-file: .github/zero/upgrade-deps.md
@@ -176,7 +176,7 @@ jobs:
- **Least-privilege tokens.** Grant only the permissions the workflow needs
(`contents: write` to edit files, `pull-requests: write` to comment). The
default `GITHUB_TOKEN` is scoped to the repository.
- **Pin the action.** Reference a tag (`Gitlawb/zero@v1`) or a commit SHA so a
- **Pin the action.** Reference a tag (`Twigpine/zero@v1`) or a commit SHA so a
workflow run uses a known ZERO version. `zero-version` lets you pin the
installed binary independently of the action ref.
- **Linux and macOS runners** are supported; Windows runners are rejected with a
+53 -18
View File
@@ -5,11 +5,11 @@ blessed one.
| Method | Command | Self-update |
| --- | --- | --- |
| Install script (Linux, macOS) | `curl -fsSL https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.sh \| bash` | `zero upgrade` |
| Install script (Windows) | `irm https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.ps1 \| iex` | `zero upgrade` |
| npm | `npm install -g @gitlawb/zero` | `zero upgrade` (runs npm) |
| Release archive | download from [Releases](https://github.com/Gitlawb/zero/releases) | `zero upgrade` |
| mise | `mise use -g ubi:Gitlawb/zero` | `mise upgrade` |
| Install script (Linux, macOS) | `curl -fsSL https://raw.githubusercontent.com/Twigpine/zero/main/scripts/install.sh \| bash` | `zero upgrade` |
| Install script (Windows) | `irm https://raw.githubusercontent.com/Twigpine/zero/main/scripts/install.ps1 \| iex` | `zero upgrade` |
| npm | `npm install -g @twigpine/zero` | `zero upgrade` (runs npm) |
| Release archive | download from [Releases](https://github.com/Twigpine/zero/releases) | `zero upgrade` |
| mise | `mise use -g ubi:Twigpine/zero` | `mise upgrade` |
| `go install` | `go install github.com/Gitlawb/zero/cmd/zero@latest` | rerun the command |
| Source | `go build -o zero ./cmd/zero` | rebuild |
@@ -29,7 +29,7 @@ Release and verify its checksum.
## npm
```bash
npm install -g @gitlawb/zero
npm install -g @twigpine/zero
zero
```
@@ -56,15 +56,47 @@ cannot write to keeps failing — rerun the install or the command below with
sufficient permissions). To trigger the fetch manually:
```bash
node "$(npm root -g)/@gitlawb/zero/scripts/postinstall.mjs"
node "$(npm root -g)/@twigpine/zero/scripts/postinstall.mjs"
```
### Moving from @gitlawb/zero
Zero was published as `@gitlawb/zero` up to 0.9.0. From 0.10.0 it is
`@twigpine/zero`, and the old name gets no further releases.
Both packages provide the `zero` command, so npm refuses to install the new
one while the old one is installed (`EEXIST`). Forcing it is worse: a later
uninstall of the old package removes the shared `zero` command. Quit any
running `zero` (on Windows a running `zero.exe` cannot be removed), then
uninstall the old package before installing the new one:
```bash
npm uninstall -g @gitlawb/zero
npm install -g @twigpine/zero
```
With another package manager, the same two steps:
```bash
bun remove -g @gitlawb/zero && bun add -g @twigpine/zero
pnpm remove -g @gitlawb/zero && pnpm add -g @twigpine/zero
yarn global remove @gitlawb/zero && yarn global add @twigpine/zero
```
Config, sessions, logins, and plugins live in your user config and home
directories, not in the package, so they carry over unchanged.
0.10.0 was also published once as `@gitlawb/zero`, so an old install that
updates lands on a working build. That build prints these steps when `zero`
exits in a terminal, and `zero upgrade` and `zero update --check` print them
too.
## Linux And macOS Script
Install the latest release:
```bash
curl -fsSL https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.sh | bash
curl -fsSL https://raw.githubusercontent.com/Twigpine/zero/main/scripts/install.sh | bash
```
From a checkout:
@@ -89,7 +121,7 @@ scripts/install.sh --install-dir "$HOME/bin"
Defaults:
- Repository: `Gitlawb/zero`
- Repository: `Twigpine/zero`
- Version: latest GitHub release
- Install path: `~/.local/bin/zero`
@@ -100,7 +132,7 @@ Requirements: Bash, `curl` or `wget`, `tar`, and `shasum` or `sha256sum`.
Install the latest release:
```powershell
irm https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.ps1 | iex
irm https://raw.githubusercontent.com/Twigpine/zero/main/scripts/install.ps1 | iex
```
From a checkout:
@@ -123,7 +155,7 @@ powershell -ExecutionPolicy Bypass -File scripts/install.ps1 -InstallDir "$env:U
Defaults:
- Repository: `Gitlawb/zero`
- Repository: `Twigpine/zero`
- Version: latest GitHub release
- Install path: `%LOCALAPPDATA%\zero\bin\zero.exe`
@@ -134,13 +166,13 @@ archives through its `ubi` backend, so there is nothing extra to publish and no
registry in the middle:
```bash
mise use -g ubi:Gitlawb/zero
mise use -g ubi:Twigpine/zero
```
Pin a version the same way you would any other tool:
```bash
mise use -g ubi:Gitlawb/[email protected]
mise use -g ubi:Twigpine/[email protected]
```
Updates come from `mise upgrade`. `zero upgrade` also works, because a
@@ -153,7 +185,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
@@ -169,7 +201,7 @@ macOS and Windows need no extra helper.
## From Source
```bash
git clone https://github.com/Gitlawb/zero.git
git clone https://github.com/Twigpine/zero.git
cd zero
go run ./cmd/zero
```
@@ -180,7 +212,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
@@ -213,7 +245,7 @@ seccomp filter on Android:
pkg install golang
# Build Zero for Android
git clone https://github.com/Gitlawb/zero.git
git clone https://github.com/Twigpine/zero.git
cd zero
CGO_ENABLED=0 GOOS=android GOARCH=arm64 go build -ldflags="-s -w" -o zero ./cmd/zero
@@ -302,7 +334,10 @@ See the [update guide](UPDATE.md) for update modes, flags, and platform details.
overwriting a binary a package manager owns leaves that manager describing a
version that is no longer there:
- **npm**: runs `npm install -g @gitlawb/zero@latest` for you.
- **npm**: runs `npm install -g @twigpine/zero@latest` for you. An install of
the old `@gitlawb/zero` package refuses instead and prints the steps in
[Moving from @gitlawb/zero](#moving-from-gitlawbzero): that name gets no
further releases, so npm would only reinstall the same version.
- **Homebrew**: refuses, and tells you to run `brew upgrade zero`. Replacing the
keg binary directly would be reverted by the next `brew upgrade` or
`brew reinstall`.
+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.
+77 -18
View File
@@ -1,6 +1,6 @@
# npm packaging
How `@gitlawb/zero` is put together on npm, why it is shaped this way, and the
How `@twigpine/zero` is put together on npm, why it is shaped this way, and the
rules the release pipeline must follow. Read this before touching
`package.json`, `bin/zero.js`, `scripts/postinstall.mjs`,
`scripts/npm/build-platform-packages.mjs`, or the npm-publish steps of
@@ -8,7 +8,7 @@ rules the release pipeline must follow. Read this before touching
## Goals
A `npm install -g @gitlawb/zero` must be **silent and self-contained**:
A `npm install -g @twigpine/zero` must be **silent and self-contained**:
- No `EBADENGINE` warnings — ours or from any package in the dependency tree.
- No install scripts anywhere in the tree, so npm's `allow-scripts` gating,
@@ -25,15 +25,15 @@ The model is the one used by Codex, esbuild, and Biome: a tiny wrapper package
plus per-platform payloads carrying the native binaries.
```
@gitlawb/zero <- wrapper: bin/zero.js + optionalDependencies
├─ @gitlawb/zero-darwin-arm64 -> npm:@gitlawb/zero@{version}-darwin-arm64
├─ @gitlawb/zero-darwin-x64 -> npm:@gitlawb/zero@{version}-darwin-x64
├─ @gitlawb/zero-linux-arm64 -> npm:@gitlawb/zero@{version}-linux-arm64
├─ @gitlawb/zero-linux-x64 -> npm:@gitlawb/zero@{version}-linux-x64
└─ @gitlawb/zero-win32-x64 -> npm:@gitlawb/zero@{version}-win32-x64
@twigpine/zero <- wrapper: bin/zero.js + optionalDependencies
├─ @twigpine/zero-darwin-arm64 -> npm:@twigpine/zero@{version}-darwin-arm64
├─ @twigpine/zero-darwin-x64 -> npm:@twigpine/zero@{version}-darwin-x64
├─ @twigpine/zero-linux-arm64 -> npm:@twigpine/zero@{version}-linux-arm64
├─ @twigpine/zero-linux-x64 -> npm:@twigpine/zero@{version}-linux-x64
└─ @twigpine/zero-win32-x64 -> npm:@twigpine/zero@{version}-win32-x64
```
- The platform "packages" are **versions of the same `@gitlawb/zero` package**,
- The platform "packages" are **versions of the same `@twigpine/zero` package**,
published at suffixed versions (`0.4.0-linux-x64`) and referenced through
`npm:` aliases in `optionalDependencies`. One package name means one npm
trusted-publisher configuration — no new publish credentials per platform.
@@ -56,7 +56,7 @@ plus per-platform payloads carrying the native binaries.
### Binary resolution in `bin/zero.js`
1. Resolve `@gitlawb/zero-<platform>-<arch>` and exec the `zero` binary from
1. Resolve `@twigpine/zero-<platform>-<arch>` and exec the `zero` binary from
it. This wins over any previously downloaded copy — the platform version is
pinned to the wrapper release.
2. Fall back to a binary previously downloaded next to the wrapper.
@@ -103,10 +103,22 @@ dependencies of the wrapper:
platform binary (plus the musl variant on linux, which its launcher detects
at runtime) — about 65 MB saved per platform package.
### One package name
The npm name lives in `package.json` and nothing else repeats it:
`scripts/npm/build-platform-packages.mjs` names the wrapper, the platform
versions, the alias names, and the alias targets from it, and `bin/zero.js`
reads its own `package.json` to derive the alias it resolves. The Go side
needs the name as a constant (`npmPackageName` in
`internal/update/installmethod.go`), and a test holds it equal to
`package.json`.
## Publishing rules (release pipeline)
These are the invariants `release-artifacts.yml` must hold. Breaking the first
one is user-visible immediately.
one is user-visible immediately. `scripts/npm/publish-packages.sh` implements
them, and `internal/npmwrapper/publish_test.go` runs it against a fake
registry.
1. **Platform versions must never become `latest`.** `0.4.0-linux-x64` is a
semver *prerelease* of `0.4.0`; publishing it without an explicit
@@ -118,20 +130,67 @@ one is user-visible immediately.
bootstrap publish) — a missing value must not abort the job between the
platform and wrapper publishes, which would strand a half-published
release.
2. **Platform versions publish before the wrapper.** The wrapper's
`optionalDependencies` pin exact suffixed versions; publishing the wrapper
first would create a window where installs resolve aliases that 404.
2. **Platform versions are on the registry before the wrapper.** The
wrapper's `optionalDependencies` pin exact suffixed versions; publishing
the wrapper first would create a window where installs resolve aliases
that 404. Ordering the uploads is not enough: npm applies a publish
asynchronously, and in 0.10.0 a platform version appeared ten minutes
after its upload returned, after the wrapper. The publish script waits
until npm shows every platform version before it publishes the wrapper,
and waits the same way for `latest` afterwards (up to 30 minutes in all).
3. **Exact-version pinning.** The wrapper at `X.Y.Z` references platform
versions `X.Y.Z-<platform>-<arch>` exactly — never ranges — so a wrapper
and its binaries can never skew.
and its binaries can never skew. The publish script refuses to start when
the wrapper's aliases do not match the platform payloads beside it.
4. All publishes go through the existing npm OIDC trusted-publishing flow and
the `npm-publish` environment gate; no tokens. Because the platform
payloads are versions of the same package, they reuse the single
trusted-publisher configuration.
trusted-publisher configuration. npm checks provenance against
`package.json` `repository`, so it must name the repository the workflow
runs in.
5. **Re-runs are safe.** A version already on the registry is skipped rather
than republished, so re-running a job that failed between the platform and
wrapper publishes finishes the release. Only npm's E404 counts as "not
published"; a lookup that fails any other way is retried and then stops the
job before anything is published.
`zero update` keeps working unchanged: it detects an npm install by finding a
`package.json` named `@gitlawb/zero` next to the running binary — true inside
a platform payload too — and updates via `npm install -g @gitlawb/zero@latest`.
`package.json` named `@twigpine/zero` next to the running binary — true inside
a platform payload too — and updates via `npm install -g @twigpine/zero@latest`.
## The move from `@gitlawb/zero`
Zero was published as `@gitlawb/zero` up to 0.9.0 and as `@twigpine/zero`
from 0.10.0. There is no in-place migration: both packages own the `zero`
bin, so npm refuses to install one over the other (`EEXIST`), and forcing it
lets a later uninstall of the old package delete the shared bin. The user
has to uninstall the old package and install the new one, in that order.
Deprecating the old package alone would not have reached existing users.
0.9.0's `zero upgrade` runs `npm install -g @gitlawb/zero@latest`; with
nothing newer under that name, npm reinstalls nothing, prints no deprecation
warning (it only warns when it installs something), and Zero reports the
update as done. So 0.10.0 was published once more under the old name, as a
bridge:
- The release job assembled the same verified artifacts a second time with
`build-platform-packages.mjs --package-name @gitlawb/zero` and published
them with the same script, after the `@twigpine/zero` publish. The steps
that did this ran once and have been removed from `release-artifacts.yml`.
- The bridge build knows its name from the `package.json` beside it. The Go
binary reports it as `InstallMethodNpmLegacy`: `zero upgrade` refuses
with the switch steps instead of reinstalling it, and `zero update --check`
adds them. `bin/zero.js` prints the same steps after `zero` exits, on a
terminal only. This handling stays in the code, since only a build
installed under the old name ever triggers it.
- Updating from 0.9.0 installs that bridge build, so npm also shows the
deprecation message.
`@gitlawb/zero` is deprecated on every version and no longer has a trusted
publisher, so this release workflow cannot publish it again; another version
would take a maintainer publishing by hand. Its versions stay on the
registry: existing installs resolve their platform payload through them, so
they must never be unpublished.
## Runbook: bumping the vendored helpers
+10 -9
View File
@@ -2,7 +2,8 @@
Run this checklist when a PR changes npm distribution files such as
`package.json`, `bin/zero.js`, `scripts/npm/build-platform-packages.mjs`,
Go build or release tooling, or the npm `bin` wrapper.
`scripts/npm/publish-packages.sh`, Go build or release tooling, or the npm
`bin` wrapper.
## Required Checks
@@ -37,24 +38,24 @@ path:
```bash
# platform-package path
mkdir -p /tmp/zero-sim/node_modules/@gitlawb
cp -R dist/npm/wrapper /tmp/zero-sim/node_modules/@gitlawb/zero
mkdir -p /tmp/zero-sim/node_modules/@twigpine
cp -R dist/npm/wrapper /tmp/zero-sim/node_modules/@twigpine/zero
cp -R dist/npm/platforms/zero-<platform>-<arch> \
"/tmp/zero-sim/node_modules/@gitlawb/zero-<platform>-<arch>"
node /tmp/zero-sim/node_modules/@gitlawb/zero/bin/zero.js --version
"/tmp/zero-sim/node_modules/@twigpine/zero-<platform>-<arch>"
node /tmp/zero-sim/node_modules/@twigpine/zero/bin/zero.js --version
# first-run download fallback (delete the platform package first)
rm -rf "/tmp/zero-sim/node_modules/@gitlawb/zero-<platform>-<arch>"
node /tmp/zero-sim/node_modules/@gitlawb/zero/bin/zero.js --version
rm -rf "/tmp/zero-sim/node_modules/@twigpine/zero-<platform>-<arch>"
node /tmp/zero-sim/node_modules/@twigpine/zero/bin/zero.js --version
```
## Checklist
- `package.json` has the expected package name (`@gitlawb/zero`), version,
- `package.json` has the expected package name (`@twigpine/zero`), version,
`bin.zero` entry, and NO `scripts` entries — the published package must be
free of lifecycle scripts (see NPM_PACKAGING.md).
- `scripts/npm/build-platform-packages.mjs` emits platform payloads whose
`package.json` is `@gitlawb/zero@<version>-<platform>-<arch>` with matching
`package.json` is `@twigpine/zero@<version>-<platform>-<arch>` with matching
`os`/`cpu`, contains the executable binary and the vendored `helpers/` tree
with regular-file (non-symlink) `.bin` shims, and a wrapper payload with no
scripts, no dependencies, and the full five-alias `optionalDependencies`
+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)
+8 -4
View File
@@ -7,7 +7,7 @@ downloads, verifies, and installs it.
```bash
zero update --check
zero update --check --json
zero update --check --repo Gitlawb/zero
zero update --check --repo Twigpine/zero
zero update --check --target windows-x64
zero upgrade
@@ -28,7 +28,11 @@ them explicitly; `zero upgrade` is `zero update` with `--apply` implied.
`--apply` installs the update in place:
- npm installs delegate to `npm install -g @gitlawb/zero@latest`.
- npm installs delegate to `npm install -g @twigpine/zero@latest`. An install
of the old `@gitlawb/zero` package refuses, without a release check, and
prints the steps to switch to `@twigpine/zero` (see
[INSTALL.md](INSTALL.md#moving-from-gitlawbzero)); `--check` prints them
too.
- Standalone installs download the release archive, verify its checksum,
extract it, and atomically replace the running binary plus any installed
optional sandbox helpers.
@@ -38,7 +42,7 @@ them explicitly; `zero upgrade` is `zero update` with `--apply` implied.
- `--target` cannot be combined with `--apply`; it only applies to `--check`,
since applying always installs onto the current machine.
- `--repo` and `--endpoint` are ignored when applying to an npm-managed
install: that path delegates to `npm install -g @gitlawb/zero@latest` and
install: that path delegates to `npm install -g @twigpine/zero@latest` and
takes its release from the npm registry, not from GitHub. They still apply to
`--check` there.
- `--json` serializes Zero's final result. For npm-managed installs, npm may
@@ -65,7 +69,7 @@ Endpoint resolution order:
1. `--endpoint`
2. `ZERO_UPDATE_RELEASE_URL`
3. `--repo`
4. `https://api.github.com/repos/Gitlawb/zero/releases/latest`
4. `https://api.github.com/repos/Twigpine/zero/releases/latest`
Installer scripts download the matching release asset for the local platform and
verify its `.sha256` file. If Zero is already installed, run `zero upgrade`
+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

+1 -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
+144 -8
View File
@@ -15,6 +15,7 @@ import (
"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"
@@ -22,6 +23,7 @@ import (
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/sessions"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/usage"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
@@ -106,6 +108,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.
@@ -204,7 +214,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, agent.PermissionModeAuto)
return NewSessionResult{
SessionID: sess.id,
ConfigOptions: a.configOptions(sess),
@@ -301,6 +311,12 @@ func (a *Agent) activatePersistedSession(ctx context.Context, p LoadSessionParam
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())
@@ -311,7 +327,10 @@ func (a *Agent) activatePersistedSession(ctx context.Context, p LoadSessionParam
models = append(models, SessionConfigOptionValue{Value: persistedModel, Name: persistedModel})
}
}
sess, existed := 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, restoredPermissionMode(meta.PermissionMode))
if existed {
_, messages, historyWarning, historyErr = a.refreshSessionHistory(sess, requireHistoryLog)
if operation == persistedSessionResume && historyErr != nil {
@@ -433,6 +452,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
@@ -508,6 +530,30 @@ func (a *Agent) runTurn(ctx context.Context, sess *acpSession, userText string,
}
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,
SessionID: sess.id,
@@ -519,8 +565,15 @@ func (a *Agent) runTurn(ctx context.Context, sess *acpSession, userText string,
DeferThreshold: workspace.DeferThreshold,
MaxTurns: resolved.MaxTurns,
Images: images,
SupportsVision: supportsVision,
OnText: note.text,
OnReasoning: note.thought,
// Persist token usage the same way `zero exec` does, so `zero usage report`
// sees ACP sessions too. It joins the turn's buffered batch: a hard run
// failure that aborts the turn drops its usage together with its history.
OnUsage: func(u agent.Usage) {
queue(usageEvent(u))
},
OnToolCall: func(call agent.ToolCall) {
queue(toolCallEvent(call))
note.toolCall(call)
@@ -537,7 +590,7 @@ 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))
@@ -649,7 +702,9 @@ func (a *Agent) handleSetMode(_ context.Context, params json.RawMessage) (any, e
mode := agent.PermissionMode(p.ModeID)
switch mode {
case agent.PermissionModeAuto, agent.PermissionModeAsk, agent.PermissionModePlan:
sess.setMode(mode)
if err := a.updateMode(sess, mode); err != nil {
return nil, err
}
(&notifier{conn: a.conn, sessionID: sess.id}).currentMode(string(mode))
return SetSessionModeResult{}, nil
case agent.PermissionModeUnsafe:
@@ -685,7 +740,9 @@ func (a *Agent) handleSetConfigOption(_ context.Context, params json.RawMessage)
mode := agent.PermissionMode(p.Value)
switch mode {
case agent.PermissionModeAuto, agent.PermissionModeAsk, agent.PermissionModePlan:
sess.setMode(mode)
if err := a.updateMode(sess, mode); err != nil {
return nil, err
}
(&notifier{conn: a.conn, sessionID: sess.id}).currentMode(string(mode))
case agent.PermissionModeUnsafe:
return nil, RPCError(codeInvalidParams, "mode not permitted over ACP: "+p.Value)
@@ -727,6 +784,35 @@ func (a *Agent) updateModel(sess *acpSession, model string, restrictModels bool)
return nil
}
// updateMode persists the mode BEFORE applying it, so a failed write fails the
// request instead of leaving disk behind memory; the same order updateModel
// uses. The caller holds sess.turnMu (both mode doors take it and it is not
// reentrant, so this must not lock it again); the write under turnMu briefly
// blocks that session's prompts, which is what makes the store authoritative.
func (a *Agent) updateMode(sess *acpSession, mode agent.PermissionMode) error {
if _, err := a.deps.Store.UpdateMode(sess.id, string(mode)); err != nil {
return RPCError(codeInternalError, "save mode selection: "+err.Error())
}
sess.setMode(mode)
return nil
}
// restoredPermissionMode maps a persisted mode onto the modes ACP offers. A
// session written before modes were persisted has none and keeps the historical
// auto default. A stored value outside the three is never allowed to come back
// weaker than ask, and unsafe can never be reached because ACP refuses to set
// it in the first place.
func restoredPermissionMode(saved string) agent.PermissionMode {
switch mode := agent.PermissionMode(strings.TrimSpace(saved)); mode {
case agent.PermissionModeAuto, agent.PermissionModeAsk, agent.PermissionModePlan:
return mode
case "":
return agent.PermissionModeAuto
default:
return agent.PermissionModeAsk
}
}
func (a *Agent) handleCancel(_ context.Context, params json.RawMessage) {
var p CancelParams
if err := json.Unmarshal(params, &p); err != nil {
@@ -851,6 +937,15 @@ func messageEvent(role, content string) sessions.AppendEventInput {
}
}
// usageEvent records one provider usage report in the same persisted shape the
// exec runtime writes (usage.EventUsagePayload is the single writer).
func usageEvent(u agent.Usage) sessions.AppendEventInput {
return sessions.AppendEventInput{
Type: sessions.EventUsage,
Payload: usage.EventUsagePayload(u),
}
}
func toolCallEvent(call agent.ToolCall) sessions.AppendEventInput {
return sessions.AppendEventInput{
Type: sessions.EventToolCall,
@@ -908,9 +1003,20 @@ func (a *Agent) loadHistory(sessionID string, requireHistoryLog bool) ([]turnRec
// 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 {
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 {
@@ -1153,14 +1259,15 @@ func promptImages(blocks []ContentBlock) []zeroruntime.ImageBlock {
// 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) (*acpSession, bool) {
// prompt can read a half-initialized session. mode is the session's initial
// permission mode: auto for a new session, the restored value for load/resume.
func (a *Agent) registerSession(id, cwd string, history []turnRecord, model string, models []SessionConfigOptionValue, restrictModels bool, readOnly bool, mode agent.PermissionMode) (*acpSession, bool) {
a.mu.Lock()
defer a.mu.Unlock()
if existing := a.sessions[id]; existing != nil {
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: mode, model: model, models: models, restrictModels: restrictModels, history: history, readOnly: readOnly}
a.sessions[id] = sess
return sess, false
}
@@ -1305,3 +1412,32 @@ func sameWorkspace(left, right string) bool {
}
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)
}
+385
View File
@@ -1,7 +1,9 @@
package acp
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
@@ -847,6 +849,48 @@ func TestACPRunTurnClosesWorkspaceAfterSuccessAndFailure(t *testing.T) {
}
}
func TestACPWiresSupportsVision(t *testing.T) {
deps := testDeps(t)
var captured agent.Options
deps.RunAgent = func(_ context.Context, _ string, _ zeroruntime.Provider, opts agent.Options) (agent.Result, error) {
captured = opts
return agent.Result{FinalAnswer: "done"}, nil
}
deps.DiscoverModels = func(_ context.Context, _ config.ProviderProfile) ([]providermodeldiscovery.Model, error) {
return []providermodeldiscovery.Model{
{ID: "custom-vision", InputModalities: []string{"text", "image"}},
{ID: "custom-text", InputModalities: []string{"text"}},
}, nil
}
h := newHarness(t, deps)
defer h.stop()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var newRes NewSessionResult
if err := h.client.Call(ctx, MethodSessionNew, NewSessionParams{Cwd: t.TempDir(), McpServers: []McpServer{}}, &newRes); err != nil {
t.Fatalf("session/new: %v", err)
}
var promptRes PromptResult
if err := h.client.Call(ctx, MethodSessionPrompt, PromptParams{
SessionID: newRes.SessionID,
Prompt: []ContentBlock{
{Type: "text", Text: "hello"},
},
}, &promptRes); err != nil {
t.Fatalf("session/prompt: %v", err)
}
if captured.SupportsVision == nil {
t.Fatal("SupportsVision was not wired into agent.Options")
}
if !captured.SupportsVision("custom-vision") {
t.Fatal("SupportsVision(custom-vision) = false, want true")
}
if captured.SupportsVision("custom-text") {
t.Fatal("SupportsVision(custom-text) = true, want false")
}
}
// TestACPRejectsInvalidCwd confirms session/new fails when the workspace root
// resolver rejects the client cwd (e.g. filesystem root).
func TestACPRejectsInvalidCwd(t *testing.T) {
@@ -1251,6 +1295,85 @@ func TestACPResumeAppliesStandaloneSessionKindPolicy(t *testing.T) {
}, &LoadSessionResult{}); err != nil {
t.Fatalf("session/load should retain render-only access: %v", err)
}
// The check above only showed the refused resume never registered the
// session. Load does register it, so the prompt that matters is the one
// AFTER the load: render-only means the transcript can be read and the
// sub-run cannot be continued.
err = h.client.Call(ctx, MethodSessionPrompt, PromptParams{
SessionID: created.SessionID, Prompt: []ContentBlock{TextBlock("must stay closed after load")},
}, &PromptResult{})
if !errors.As(err, &rpcErr) || rpcErr.Code != codeInvalidParams {
t.Fatalf("session/prompt after load of a %s session = %v, want invalid params", tc.name, err)
}
})
}
}
// LOAD PUBLISHES A SUB-RUN FOR VIEWING, NOT FOR CONTINUING. Every other resume
// path (the TUI, exec sessions, the exec CLI, and ACP's own session/resume)
// refuses a kind sessions.IsResumableKind rejects. session/load has a real
// render-only use, a client rebuilding a transcript it can scroll, so it keeps
// loading those sessions; what it must not do is publish one that accepts a
// prompt, because the prompt would carry on a child, side or spec sub-run as a
// standalone conversation. Checked in both directions, so a gate that refused
// everything would fail on the resumable kinds.
func TestACPLoadPublishesSubRunsReadOnly(t *testing.T) {
for _, tc := range []struct {
name string
kind sessions.SessionKind
readOnly bool
}{
{name: "regular", kind: ""},
{name: "fork", kind: sessions.SessionKindFork},
{name: "child", kind: sessions.SessionKindChild, readOnly: true},
{name: "side", kind: sessions.SessionKindSide, readOnly: true},
{name: "spec draft", kind: sessions.SessionKindSpecDraft, readOnly: true},
{name: "spec impl", kind: sessions.SessionKindSpecImpl, readOnly: true},
} {
t.Run(tc.name, func(t *testing.T) {
if got := !sessions.IsResumableKind(tc.kind); got != tc.readOnly {
t.Fatalf("SETUP INVALID: IsResumableKind(%q) disagrees with this table (readOnly=%v), so the cases no longer mean what they say", tc.kind, tc.readOnly)
}
deps := testDeps(t)
workspace := t.TempDir()
created, err := deps.Store.Create(sessions.CreateInput{
SessionID: "load-" + strings.ReplaceAll(tc.name, " ", "-"), Cwd: workspace, SessionKind: tc.kind,
})
if err != nil {
t.Fatal(err)
}
h := newHarness(t, deps)
defer h.stop()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := h.client.Call(ctx, MethodSessionLoad, LoadSessionParams{
SessionID: created.SessionID, Cwd: workspace,
}, &LoadSessionResult{}); err != nil {
t.Fatalf("session/load of a %s session: %v", tc.name, err)
}
err = h.client.Call(ctx, MethodSessionPrompt, PromptParams{
SessionID: created.SessionID, Prompt: []ContentBlock{TextBlock("continue")},
}, &PromptResult{})
// A resumable session has to be genuinely promptable, not merely "not
// refused as view-only": any other failure would hide a broken
// writable path behind a passing test.
if !tc.readOnly {
if err != nil {
t.Fatalf("session/prompt after load of a %s session = %v, want success", tc.name, err)
}
return
}
// The decision is the RPC code; the message only distinguishes the
// view-only refusal from other invalid-params errors such as an
// unknown session id.
var rpcErr *rpcError
if !errors.As(err, &rpcErr) || rpcErr.Code != codeInvalidParams {
t.Fatalf("session/prompt after load of a %s session = %v, want invalid params", tc.name, err)
}
if !strings.Contains(rpcErr.Message, "loaded for viewing only") {
t.Fatalf("session/prompt after load of a %s session was refused for another reason: %v", tc.name, err)
}
})
}
}
@@ -2418,3 +2541,265 @@ func payloadString(payload any, key string) string {
value, _ := decoded[key].(string)
return value
}
// Load is best effort about a history it cannot read, but a session that zero
// sessions prune holds is refused by load and resume alike, and not published:
// this process could not hold it open while prune may be removing it.
func TestACPLoadAndResumeAreRefusedWhilePruneHoldsTheSession(t *testing.T) {
deps := testDeps(t)
cwd := t.TempDir()
meta, err := deps.Store.Create(sessions.CreateInput{Title: "ACP session", Cwd: cwd})
if err != nil {
t.Fatalf("create session: %v", err)
}
// Created by an earlier process, which has since exited.
deps.Store.Release(meta.SessionID)
release, locked, err := sessions.NewStore(sessions.StoreOptions{RootDir: deps.Store.RootDir}).HoldExclusive(meta.SessionID)
if err != nil || !locked {
t.Fatalf("take the lease the way prune does: locked=%v, %v", locked, err)
}
defer release()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
h := newHarness(t, deps)
defer h.stop()
if err := h.client.Call(ctx, MethodSessionLoad, LoadSessionParams{SessionID: meta.SessionID, Cwd: cwd, McpServers: []McpServer{}}, &LoadSessionResult{}); err == nil || !strings.Contains(err.Error(), "locked by zero sessions prune") {
t.Errorf("session/load while prune holds the session: err = %v, want it refused", err)
}
if err := h.client.Call(ctx, MethodSessionResume, ResumeSessionParams{SessionID: meta.SessionID, Cwd: cwd, McpServers: []McpServer{}}, &ResumeSessionResult{}); err == nil || !strings.Contains(err.Error(), "locked by zero sessions prune") {
t.Errorf("session/resume while prune holds the session: err = %v, want it refused", err)
}
if err := h.client.Call(ctx, MethodSessionPrompt, PromptParams{
SessionID: meta.SessionID,
Prompt: []ContentBlock{TextBlock("carry on")},
}, &PromptResult{}); err == nil {
t.Fatal("a session refused while prune held it was still promptable")
}
}
// Activation reads the session's metadata before it restores the history. A
// session prune removes in between is refused as removed, which activation then
// refuses to publish for load as well as resume, instead of restoring it as an
// empty conversation.
func TestACPLoadHistoryRefusesASessionRemovedAfterItWasPicked(t *testing.T) {
deps := testDeps(t)
meta, err := deps.Store.Create(sessions.CreateInput{Title: "ACP session", Cwd: t.TempDir()})
if err != nil {
t.Fatalf("create session: %v", err)
}
deps.Store.Release(meta.SessionID)
dir := filepath.Join(deps.Store.RootDir, meta.SessionID)
if err := os.Remove(filepath.Join(dir, sessions.MetadataFile)); err != nil {
t.Fatal(err)
}
a := &Agent{deps: deps}
if _, _, _, err := a.loadHistory(meta.SessionID, false); !errors.Is(err, sessions.ErrPruning) {
t.Errorf("load history of a session prune is removing: err = %v, want it refused", err)
}
if err := os.RemoveAll(dir); err != nil {
t.Fatal(err)
}
if _, _, _, err := a.loadHistory(meta.SessionID, false); !errors.Is(err, sessions.ErrPruning) {
t.Errorf("load history of a session prune removed: err = %v, want it refused", err)
}
}
func TestACPPromptUnsupportedModelDropsImagesAndNotifies(t *testing.T) {
deps := testDeps(t)
deps.DiscoverModels = func(ctx context.Context, p config.ProviderProfile) ([]providermodeldiscovery.Model, error) {
return []providermodeldiscovery.Model{
{ID: "fake-model", InputModalities: []string{"text"}},
}, nil
}
var capturedOpts agent.Options
var capturedPrompt string
deps.RunAgent = func(ctx context.Context, prompt string, provider zeroruntime.Provider, opts agent.Options) (agent.Result, error) {
capturedPrompt = prompt
capturedOpts = opts
return agent.Result{FinalAnswer: "Answered without images"}, nil
}
h := newHarness(t, deps)
defer h.stop()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var initRes InitializeResult
if err := h.client.Call(ctx, MethodInitialize, InitializeParams{ProtocolVersion: ProtocolVersion}, &initRes); err != nil {
t.Fatalf("initialize: %v", err)
}
var newRes NewSessionResult
if err := h.client.Call(ctx, MethodSessionNew, NewSessionParams{Cwd: t.TempDir(), McpServers: []McpServer{}}, &newRes); err != nil {
t.Fatalf("session/new: %v", err)
}
rawPng := "\x89PNG\r\n\x1a\nfake-image-bytes"
b64Png := base64.StdEncoding.EncodeToString([]byte(rawPng))
var promptRes PromptResult
if err := h.client.Call(ctx, MethodSessionPrompt, PromptParams{
SessionID: newRes.SessionID,
Prompt: []ContentBlock{
TextBlock("look at this image"),
ImageBlock(b64Png, "image/png"),
},
}, &promptRes); err != nil {
t.Fatalf("session/prompt: %v", err)
}
if promptRes.StopReason != StopEndTurn {
t.Fatalf("stopReason = %q, want %q", promptRes.StopReason, StopEndTurn)
}
got := drainTextUntil(t, h.updates, func(text string) bool {
return strings.Contains(text, "does not support image input")
})
if !strings.Contains(got, "Model fake-model does not support image input; ignoring 1 prompt image(s).") {
t.Fatalf("streamed text = %q, want drop notice", got)
}
if len(capturedOpts.Images) != 0 {
t.Fatalf("captured %d images, want 0 (images should be withheld)", len(capturedOpts.Images))
}
if !strings.Contains(capturedPrompt, "[Note: Model fake-model does not support image input; ignoring 1 prompt image(s).]") {
t.Fatalf("captured prompt = %q, want prompt note", capturedPrompt)
}
}
func TestACPSessionPrompt_SupportsVisionMemoizedPerRun(t *testing.T) {
deps := testDeps(t)
var discoverCount int
var discoverMu sync.Mutex
deps.DiscoverModels = func(ctx context.Context, p config.ProviderProfile) ([]providermodeldiscovery.Model, error) {
discoverMu.Lock()
discoverCount++
discoverMu.Unlock()
return []providermodeldiscovery.Model{
{ID: "fake-model", InputModalities: []string{"text", "image"}},
}, nil
}
deps.RunAgent = func(ctx context.Context, prompt string, provider zeroruntime.Provider, opts agent.Options) (agent.Result, error) {
for i := 0; i < 5; i++ {
if !opts.SupportsVision(opts.Model) {
t.Error("expected SupportsVision to report true")
}
}
return agent.Result{FinalAnswer: "Done"}, nil
}
h := newHarness(t, deps)
defer h.stop()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var initRes InitializeResult
if err := h.client.Call(ctx, MethodInitialize, InitializeParams{ProtocolVersion: ProtocolVersion}, &initRes); err != nil {
t.Fatalf("initialize: %v", err)
}
var newRes NewSessionResult
if err := h.client.Call(ctx, MethodSessionNew, NewSessionParams{Cwd: t.TempDir(), McpServers: []McpServer{}}, &newRes); err != nil {
t.Fatalf("session/new: %v", err)
}
discoverMu.Lock()
discoverCount = 0
discoverMu.Unlock()
rawPng := "\x89PNG\r\n\x1a\nfake-image-bytes"
b64Png := base64.StdEncoding.EncodeToString([]byte(rawPng))
var promptRes PromptResult
if err := h.client.Call(ctx, MethodSessionPrompt, PromptParams{
SessionID: newRes.SessionID,
Prompt: []ContentBlock{
TextBlock("look at this"),
ImageBlock(b64Png, "image/png"),
},
}, &promptRes); err != nil {
t.Fatalf("session/prompt: %v", err)
}
if promptRes.StopReason != StopEndTurn {
t.Fatalf("stopReason = %q, want %q", promptRes.StopReason, StopEndTurn)
}
discoverMu.Lock()
count := discoverCount
discoverMu.Unlock()
if count != 1 {
t.Fatalf("DiscoverModels was called %d times, want exactly 1 call (memoized per run)", count)
}
}
func TestACPEndToEndImagePromptSupportedModel(t *testing.T) {
deps := testDeps(t)
deps.DiscoverModels = func(ctx context.Context, p config.ProviderProfile) ([]providermodeldiscovery.Model, error) {
return []providermodeldiscovery.Model{
{ID: "discovered-vision-model", InputModalities: []string{"text", "image"}},
}, nil
}
deps.ResolveConfig = func(_ string, o config.Overrides) (config.ResolvedConfig, error) {
model := "discovered-vision-model"
if o.Provider.Model != "" {
model = o.Provider.Model
}
return config.ResolvedConfig{
Provider: config.ProviderProfile{Name: "fake", Model: model},
MaxTurns: 4,
}, nil
}
var capturedOpts agent.Options
var capturedPrompt string
deps.RunAgent = func(ctx context.Context, prompt string, provider zeroruntime.Provider, opts agent.Options) (agent.Result, error) {
capturedPrompt = prompt
capturedOpts = opts
return agent.Result{FinalAnswer: "I can see the image!"}, nil
}
h := newHarness(t, deps)
defer h.stop()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var initRes InitializeResult
if err := h.client.Call(ctx, MethodInitialize, InitializeParams{ProtocolVersion: ProtocolVersion}, &initRes); err != nil {
t.Fatalf("initialize: %v", err)
}
var newRes NewSessionResult
if err := h.client.Call(ctx, MethodSessionNew, NewSessionParams{Cwd: t.TempDir(), McpServers: []McpServer{}}, &newRes); err != nil {
t.Fatalf("session/new: %v", err)
}
rawPng := []byte("\x89PNG\r\n\x1a\nreal-png-bytes")
b64Png := base64.StdEncoding.EncodeToString(rawPng)
var promptRes PromptResult
if err := h.client.Call(ctx, MethodSessionPrompt, PromptParams{
SessionID: newRes.SessionID,
Prompt: []ContentBlock{
TextBlock("analyze this image"),
ImageBlock(b64Png, "image/png"),
},
}, &promptRes); err != nil {
t.Fatalf("session/prompt: %v", err)
}
if promptRes.StopReason != StopEndTurn {
t.Fatalf("stopReason = %q, want %q", promptRes.StopReason, StopEndTurn)
}
if len(capturedOpts.Images) != 1 {
t.Fatalf("captured %d images, want 1", len(capturedOpts.Images))
}
if capturedOpts.Images[0].MediaType != "image/png" {
t.Fatalf("mediaType = %q, want image/png", capturedOpts.Images[0].MediaType)
}
if !bytes.Equal(capturedOpts.Images[0].Data, rawPng) {
t.Fatalf("image bytes mismatch: got %v, want %v", capturedOpts.Images[0].Data, rawPng)
}
if strings.Contains(capturedPrompt, "does not support image input") {
t.Fatalf("prompt unexpectedly contains refusal note: %q", capturedPrompt)
}
}
+213
View File
@@ -0,0 +1,213 @@
package acp
import (
"context"
"errors"
"os"
"path/filepath"
"testing"
"time"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/sessions"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// captureAgentOptions makes every turn record the agent.Options it was built
// with, so a test can assert the permission mode the next prompt actually runs
// with rather than only what the wire reports.
func captureAgentOptions(deps *Deps) *agent.Options {
var captured agent.Options
deps.RunAgent = func(_ context.Context, _ string, _ zeroruntime.Provider, opts agent.Options) (agent.Result, error) {
captured = opts
return agent.Result{FinalAnswer: "ok"}, nil
}
return &captured
}
func newHarnessT(t *testing.T, deps Deps) *clientHarness {
t.Helper()
h := newHarness(t, deps)
t.Cleanup(h.stop)
return h
}
func mustCall(t *testing.T, ctx context.Context, h *clientHarness, method string, params any, result any) {
t.Helper()
if err := h.client.Call(ctx, method, params, result); err != nil {
t.Fatalf("%s: %v", method, err)
}
}
// restartHarness replaces the live agent with a fresh one over the same store,
// which is the process restart the persisted mode has to survive.
func restartHarness(t *testing.T, h *clientHarness, deps Deps) *clientHarness {
t.Helper()
h.stop()
return newHarnessT(t, deps)
}
func modeOptionValue(t *testing.T, options []SessionConfigOption) string {
t.Helper()
for _, option := range options {
if option.ID == configIDMode {
return option.CurrentValue
}
}
t.Fatalf("no mode config option in %+v", options)
return ""
}
func assertModeReported(t *testing.T, result LoadSessionResult, want agent.PermissionMode) {
t.Helper()
if result.Modes == nil || result.Modes.CurrentModeID != string(want) {
t.Fatalf("modes = %+v, want currentModeId %q", result.Modes, want)
}
if got := modeOptionValue(t, result.ConfigOptions); got != string(want) {
t.Fatalf("mode config option = %q, want %q", got, want)
}
}
func TestACPModePersistsAcrossRestartLoadAndResume(t *testing.T) {
deps := testDeps(t)
captured := captureAgentOptions(&deps)
h := newHarnessT(t, deps)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
workspace := t.TempDir()
var created NewSessionResult
mustCall(t, ctx, h, MethodSessionNew, NewSessionParams{Cwd: workspace, McpServers: []McpServer{}}, &created)
mustCall(t, ctx, h, MethodSessionSetMode, SetSessionModeParams{SessionID: created.SessionID, ModeID: string(agent.PermissionModePlan)}, &SetSessionModeResult{})
meta, err := deps.Store.Get(created.SessionID)
if err != nil || meta == nil {
t.Fatalf("store.Get: %v", err)
}
if meta.PermissionMode != string(agent.PermissionModePlan) {
t.Fatalf("stored mode = %q, want plan", meta.PermissionMode)
}
// LOAD after a restart: the response must report the stored mode, and the
// next prompt must actually run with it.
h = restartHarness(t, h, deps)
var loaded LoadSessionResult
mustCall(t, ctx, h, MethodSessionLoad, LoadSessionParams{SessionID: created.SessionID, Cwd: workspace, McpServers: []McpServer{}}, &loaded)
assertModeReported(t, loaded, agent.PermissionModePlan)
mustCall(t, ctx, h, MethodSessionPrompt, PromptParams{SessionID: created.SessionID, Prompt: []ContentBlock{TextBlock("continue")}}, &PromptResult{})
if captured.PermissionMode != agent.PermissionModePlan {
t.Fatalf("after load, agent.Options.PermissionMode = %q, want plan", captured.PermissionMode)
}
// RESUME after another restart reports the same.
h = restartHarness(t, h, deps)
var resumed ResumeSessionResult
mustCall(t, ctx, h, MethodSessionResume, ResumeSessionParams{SessionID: created.SessionID, Cwd: workspace, McpServers: []McpServer{}}, &resumed)
assertModeReported(t, resumed, agent.PermissionModePlan)
}
func TestACPModePersistsViaSetConfigOption(t *testing.T) {
deps := testDeps(t)
h := newHarnessT(t, deps)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
workspace := t.TempDir()
var created NewSessionResult
mustCall(t, ctx, h, MethodSessionNew, NewSessionParams{Cwd: workspace, McpServers: []McpServer{}}, &created)
var configured SetSessionConfigOptionResult
mustCall(t, ctx, h, MethodSessionSetConfigOption, SetSessionConfigOptionParams{
SessionID: created.SessionID, ConfigID: configIDMode, Value: string(agent.PermissionModeAsk),
}, &configured)
if got := modeOptionValue(t, configured.ConfigOptions); got != string(agent.PermissionModeAsk) {
t.Fatalf("configured mode = %q, want ask", got)
}
meta, err := deps.Store.Get(created.SessionID)
if err != nil || meta == nil {
t.Fatalf("store.Get: %v", err)
}
if meta.PermissionMode != string(agent.PermissionModeAsk) {
t.Fatalf("stored mode = %q, want ask", meta.PermissionMode)
}
h = restartHarness(t, h, deps)
var loaded LoadSessionResult
mustCall(t, ctx, h, MethodSessionLoad, LoadSessionParams{SessionID: created.SessionID, Cwd: workspace, McpServers: []McpServer{}}, &loaded)
assertModeReported(t, loaded, agent.PermissionModeAsk)
}
// TestACPModeRestoreFallback pins the restore boundary: no stored value keeps
// the historical auto default, and a stored value outside the three offered
// modes must never come back weaker than ask (and never unsafe).
func TestACPModeRestoreFallback(t *testing.T) {
cases := []struct {
name string
seed string // "" means metadata written before modes were persisted
want agent.PermissionMode
}{
{name: "unset keeps auto", want: agent.PermissionModeAuto},
{name: "ask preserved", seed: string(agent.PermissionModeAsk), want: agent.PermissionModeAsk},
{name: "unknown falls back to ask", seed: "bogus", want: agent.PermissionModeAsk},
{name: "unsafe never restored", seed: string(agent.PermissionModeUnsafe), want: agent.PermissionModeAsk},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
deps := testDeps(t)
captured := captureAgentOptions(&deps)
h := newHarnessT(t, deps)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
workspace := t.TempDir()
var created NewSessionResult
mustCall(t, ctx, h, MethodSessionNew, NewSessionParams{Cwd: workspace, McpServers: []McpServer{}}, &created)
if tc.seed != "" {
if _, err := deps.Store.UpdateMode(created.SessionID, tc.seed); err != nil {
t.Fatalf("seed mode: %v", err)
}
}
h = restartHarness(t, h, deps)
var loaded LoadSessionResult
mustCall(t, ctx, h, MethodSessionLoad, LoadSessionParams{SessionID: created.SessionID, Cwd: workspace, McpServers: []McpServer{}}, &loaded)
assertModeReported(t, loaded, tc.want)
mustCall(t, ctx, h, MethodSessionPrompt, PromptParams{SessionID: created.SessionID, Prompt: []ContentBlock{TextBlock("go")}}, &PromptResult{})
if captured.PermissionMode != tc.want {
t.Fatalf("next prompt PermissionMode = %q, want %q", captured.PermissionMode, tc.want)
}
})
}
}
// TestACPSetModeWriteFailureKeepsLiveMode pins the store-first order: when the
// mode cannot be saved the request fails and the live session is untouched.
func TestACPSetModeWriteFailureKeepsLiveMode(t *testing.T) {
deps := testDeps(t)
captured := captureAgentOptions(&deps)
h := newHarnessT(t, deps)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
var created NewSessionResult
mustCall(t, ctx, h, MethodSessionNew, NewSessionParams{Cwd: t.TempDir(), McpServers: []McpServer{}}, &created)
// Remove the metadata file so UpdateMode fails but the live session stays
// promptable (turn persistence only warns when the file is gone).
metadataPath := filepath.Join(deps.Store.RootDir, created.SessionID, sessions.MetadataFile)
if err := os.Remove(metadataPath); err != nil {
t.Fatalf("remove metadata: %v", err)
}
err := h.client.Call(ctx, MethodSessionSetMode, SetSessionModeParams{SessionID: created.SessionID, ModeID: string(agent.PermissionModePlan)}, &SetSessionModeResult{})
var rpcErr *rpcError
if !errors.As(err, &rpcErr) || rpcErr.Code != codeInternalError {
t.Fatalf("set_mode with an unsavable mode = %v, want internal error", err)
}
mustCall(t, ctx, h, MethodSessionPrompt, PromptParams{SessionID: created.SessionID, Prompt: []ContentBlock{TextBlock("still auto")}}, &PromptResult{})
if captured.PermissionMode != agent.PermissionModeAuto {
t.Fatalf("failed set_mode changed the live mode: PermissionMode = %q, want auto", captured.PermissionMode)
}
}
+2 -2
View File
@@ -139,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 {
@@ -147,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
}
}
+6
View File
@@ -224,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",
@@ -243,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}}),
+309
View File
@@ -0,0 +1,309 @@
package acp
import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
"time"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/sessions"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// `zero exec` persists every provider usage report as a session usage event, so
// `zero usage report --session <id>` can total a run. The ACP agent did not set
// OnUsage, which dropped the token counts of every ACP session. A turn with two
// provider requests (a tool round trip) must leave two usage events, in the shape
// the usage report reads, next to the turn's messages.
func TestACPPromptPersistsProviderUsage(t *testing.T) {
deps := testDeps(t)
deps.RunAgent = func(_ context.Context, _ string, _ zeroruntime.Provider, opts agent.Options) (agent.Result, error) {
if opts.OnUsage == nil {
t.Errorf("agent.Options.OnUsage is not wired: the ACP agent drops provider token usage")
return agent.Result{FinalAnswer: "done"}, nil
}
opts.OnUsage(agent.Usage{InputTokens: 1200, OutputTokens: 34})
opts.OnUsage(agent.Usage{InputTokens: 1300, OutputTokens: 21, CachedInputTokens: 900})
return agent.Result{FinalAnswer: "done"}, nil
}
h := newHarness(t, deps)
defer h.stop()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var created NewSessionResult
if err := h.client.Call(ctx, MethodSessionNew, NewSessionParams{Cwd: t.TempDir()}, &created); err != nil {
t.Fatal(err)
}
var result PromptResult
if err := h.client.Call(ctx, MethodSessionPrompt, PromptParams{
SessionID: created.SessionID, Prompt: []ContentBlock{TextBlock("count my tokens")},
}, &result); err != nil {
t.Fatalf("session/prompt: %v", err)
}
events, err := deps.Store.ReadEvents(created.SessionID)
if err != nil {
t.Fatal(err)
}
type usagePayload struct {
PromptTokens int `json:"promptTokens"`
CompletionTokens int `json:"completionTokens"`
TotalTokens int `json:"totalTokens"`
CachedInputTokens int `json:"cachedInputTokens"`
}
var got []usagePayload
messages := 0
for _, event := range events {
switch event.Type {
case sessions.EventUsage:
var payload usagePayload
if err := json.Unmarshal(event.Payload, &payload); err != nil {
t.Fatalf("usage payload %s: %v", event.Payload, err)
}
assertUsagePayloadIsTokenCountsOnly(t, event.Payload)
got = append(got, payload)
case sessions.EventMessage:
messages++
}
}
want := []usagePayload{
{PromptTokens: 1200, CompletionTokens: 34, TotalTokens: 1234},
{PromptTokens: 1300, CompletionTokens: 21, TotalTokens: 1321, CachedInputTokens: 900},
}
if len(got) != len(want) {
t.Fatalf("usage events = %+v, want %+v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("usage event %d = %+v, want %+v", i, got[i], want[i])
}
}
if messages != 2 {
t.Fatalf("message events = %d, want the user and assistant messages of the turn", messages)
}
}
// Usage joins the turn's buffered batch, so a hard run failure that aborts the
// turn leaves nothing durable, exactly as it does for the turn's messages.
func TestACPHardTurnFailureDropsItsUsageWithTheTurn(t *testing.T) {
deps := testDeps(t)
deps.RunAgent = func(_ context.Context, _ string, _ zeroruntime.Provider, opts agent.Options) (agent.Result, error) {
if opts.OnUsage == nil {
t.Errorf("agent.Options.OnUsage is not wired: the ACP agent drops provider token usage")
return agent.Result{}, errors.New("injected hard run failure")
}
opts.OnUsage(agent.Usage{InputTokens: 500, OutputTokens: 5})
return agent.Result{}, errors.New("injected hard run failure")
}
h := newHarness(t, deps)
defer h.stop()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var created NewSessionResult
if err := h.client.Call(ctx, MethodSessionNew, NewSessionParams{Cwd: t.TempDir()}, &created); err != nil {
t.Fatal(err)
}
if err := h.client.Call(ctx, MethodSessionPrompt, PromptParams{
SessionID: created.SessionID, Prompt: []ContentBlock{TextBlock("failing turn")},
}, &PromptResult{}); err == nil {
t.Fatal("hard RunAgent failure was reported as a successful turn")
}
events, err := deps.Store.ReadEvents(created.SessionID)
if err != nil {
t.Fatal(err)
}
if len(events) != 0 {
t.Fatalf("aborted turn left durable events: %+v", events)
}
}
// StopCancelled is a successful ACP outcome: the turn commits. Tokens the
// provider already reported were spent, so they stay in the session log even
// though the client cancelled. A hard error (the test above) is the path that
// drops them.
func TestACPCancelledTurnKeepsReportedUsage(t *testing.T) {
deps := testDeps(t)
deps.RunAgent = func(_ context.Context, _ string, _ zeroruntime.Provider, opts agent.Options) (agent.Result, error) {
if opts.OnUsage == nil {
t.Errorf("agent.Options.OnUsage is not wired: the ACP agent drops provider token usage")
return agent.Result{}, context.Canceled
}
opts.OnUsage(agent.Usage{InputTokens: 80, OutputTokens: 3})
return agent.Result{FinalAnswer: "partial"}, context.Canceled
}
h := newHarness(t, deps)
defer h.stop()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var created NewSessionResult
if err := h.client.Call(ctx, MethodSessionNew, NewSessionParams{Cwd: t.TempDir()}, &created); err != nil {
t.Fatal(err)
}
var result PromptResult
if err := h.client.Call(ctx, MethodSessionPrompt, PromptParams{
SessionID: created.SessionID, Prompt: []ContentBlock{TextBlock("cancelled turn")},
}, &result); err != nil {
t.Fatalf("cancelled turn returned an RPC error: %v", err)
}
if result.StopReason != StopCancelled {
t.Fatalf("stop reason = %q, want %q", result.StopReason, StopCancelled)
}
events, err := deps.Store.ReadEvents(created.SessionID)
if err != nil {
t.Fatal(err)
}
usageEvents := 0
for _, event := range events {
if event.Type != sessions.EventUsage {
continue
}
usageEvents++
assertUsagePayloadIsTokenCountsOnly(t, event.Payload)
var payload struct {
PromptTokens int `json:"promptTokens"`
CompletionTokens int `json:"completionTokens"`
TotalTokens int `json:"totalTokens"`
}
if err := json.Unmarshal(event.Payload, &payload); err != nil {
t.Fatal(err)
}
if payload.PromptTokens != 80 || payload.CompletionTokens != 3 || payload.TotalTokens != 83 {
t.Fatalf("cancelled-turn usage = %+v, want 80/3/83", payload)
}
}
if usageEvents != 1 {
t.Fatalf("usage events = %d, want the one report from the cancelled turn", usageEvents)
}
}
// Usage events sit in the same log session/load and session/resume read, but
// history reconstruction skips them. The wire replay stays the conversation,
// and the next turn's prompt stays the conversation: token counts never become
// context and never cross the ACP wire.
func TestACPLoadAndResumeIgnoreUsageEvents(t *testing.T) {
const userLine = "unique-acp-user-line"
const answer = "unique-acp-answer"
deps := testDeps(t)
deps.RunAgent = func(_ context.Context, _ string, _ zeroruntime.Provider, opts agent.Options) (agent.Result, error) {
if opts.OnUsage == nil {
t.Errorf("agent.Options.OnUsage is not wired: the ACP agent drops provider token usage")
return agent.Result{FinalAnswer: answer}, nil
}
opts.OnUsage(agent.Usage{InputTokens: 1200, OutputTokens: 34})
return agent.Result{FinalAnswer: answer}, nil
}
workspace := t.TempDir()
h := newHarness(t, deps)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var created NewSessionResult
if err := h.client.Call(ctx, MethodSessionNew, NewSessionParams{Cwd: workspace}, &created); err != nil {
t.Fatal(err)
}
if err := h.client.Call(ctx, MethodSessionPrompt, PromptParams{
SessionID: created.SessionID, Prompt: []ContentBlock{TextBlock(userLine)},
}, &PromptResult{}); err != nil {
t.Fatalf("session/prompt: %v", err)
}
h.stop()
events, err := deps.Store.ReadEvents(created.SessionID)
if err != nil {
t.Fatal(err)
}
sawUsage := false
for _, event := range events {
if event.Type == sessions.EventUsage {
sawUsage = true
assertUsagePayloadIsTokenCountsOnly(t, event.Payload)
}
}
if !sawUsage {
t.Fatal("session log has no usage event to ignore")
}
loader := newHarness(t, deps)
if err := loader.client.Call(ctx, MethodSessionLoad, LoadSessionParams{
SessionID: created.SessionID, Cwd: workspace, McpServers: []McpServer{},
}, &LoadSessionResult{}); err != nil {
t.Fatalf("session/load: %v", err)
}
var replayed []string
for {
select {
case update := <-loader.notifications:
replayed = append(replayed, update.Content.Text)
case <-time.After(150 * time.Millisecond):
goto drained
}
}
drained:
loader.stop()
if len(replayed) != 2 || replayed[0] != userLine || replayed[1] != answer {
t.Fatalf("session/load replay = %#v, want the user line and the answer only", replayed)
}
for _, text := range replayed {
if strings.Contains(text, "promptTokens") || strings.Contains(text, "1200") {
t.Fatalf("session/load replayed usage onto the wire: %q", text)
}
}
var gotPrompt string
deps.RunAgent = func(_ context.Context, prompt string, _ zeroruntime.Provider, _ agent.Options) (agent.Result, error) {
gotPrompt = prompt
return agent.Result{FinalAnswer: "continued"}, nil
}
resumer := newHarness(t, deps)
defer resumer.stop()
if err := resumer.client.Call(ctx, MethodSessionResume, ResumeSessionParams{
SessionID: created.SessionID, Cwd: workspace, McpServers: []McpServer{},
}, &ResumeSessionResult{}); err != nil {
t.Fatalf("session/resume: %v", err)
}
if err := resumer.client.Call(ctx, MethodSessionPrompt, PromptParams{
SessionID: created.SessionID, Prompt: []ContentBlock{TextBlock("carry on")},
}, &PromptResult{}); err != nil {
t.Fatalf("resumed session/prompt: %v", err)
}
if !strings.Contains(gotPrompt, userLine) || !strings.Contains(gotPrompt, answer) {
t.Fatalf("resumed prompt = %q, want the prior conversation", gotPrompt)
}
if strings.Contains(gotPrompt, "promptTokens") || strings.Contains(gotPrompt, "1200") {
t.Fatalf("resumed prompt contains persisted usage: %q", gotPrompt)
}
}
// usagePayloadAllowedKeys is the non-escalating EventUsagePayload shape. ACP
// has no model escalation, and the writer must not grow a prompt, tool
// argument, or other raw field.
var usagePayloadAllowedKeys = map[string]bool{
"promptTokens": true,
"completionTokens": true,
"totalTokens": true,
"cachedInputTokens": true,
"cacheWriteTokens": true,
"reasoningTokens": true,
}
func assertUsagePayloadIsTokenCountsOnly(t *testing.T, payload json.RawMessage) {
t.Helper()
var raw map[string]json.RawMessage
if err := json.Unmarshal(payload, &raw); err != nil {
t.Fatalf("usage payload %s: %v", payload, err)
}
if len(raw) == 0 {
t.Fatalf("usage payload is empty: %s", payload)
}
for key := range raw {
if !usagePayloadAllowedKeys[key] {
t.Fatalf("usage payload key %q is not a token count: %s", key, payload)
}
}
}
+103 -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
@@ -3464,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 {
@@ -3486,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)
}
+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)
}
}
+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")
}
})
}
+5
View File
@@ -341,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
+8 -1
View File
@@ -4,6 +4,7 @@ package background
import (
"fmt"
"os"
"os/exec"
"github.com/Gitlawb/zero/internal/execution"
@@ -46,7 +47,13 @@ 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
}
+23
View File
@@ -73,6 +73,29 @@ func TerminateCommand(cmd *exec.Cmd) error {
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)
}
}
+15 -3
View File
@@ -695,7 +695,7 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
// 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 {
@@ -727,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
}
}
@@ -959,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,
@@ -982,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{})
@@ -1046,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,
+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)
}
}
+64 -2
View File
@@ -1,6 +1,7 @@
package cli
import (
"errors"
"fmt"
"io"
"sort"
@@ -26,6 +27,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())
@@ -120,7 +145,7 @@ func runProviders(args []string, stdout io.Writer, stderr io.Writer, deps appDep
return exitSuccess
}
resolved, exitCode := resolveCommandCenterConfig(stderr, deps)
resolved, exitCode := resolveProviderManagementConfig(stderr, deps)
if exitCode != exitSuccess {
return exitCode
}
@@ -193,12 +218,45 @@ func runModels(args []string, stdout io.Writer, stderr io.Writer) int {
}
func resolveCommandCenterConfig(stderr io.Writer, deps appDeps) (config.ResolvedConfig, int) {
return resolveCommandCenterConfigFor(stderr, deps, false)
}
// resolveProviderManagementConfig resolves for the `providers` subcommands that
// only need the provider LIST (`list`, `current`, `use`, `remove`, `rename`).
// Those keep working when nothing is selected (issue #1130). It is deliberately
// NOT shared with the commands that REPORT the configuration — `zero config`
// above all: per the resolver's headless contract, `zero config` / `zero exec`
// still fail with the actionable resolve error, and only the interactive TUI
// recovers from a missing active provider.
func resolveProviderManagementConfig(stderr io.Writer, deps appDeps) (config.ResolvedConfig, int) {
return resolveCommandCenterConfigFor(stderr, deps, true)
}
func resolveCommandCenterConfigFor(stderr io.Writer, deps appDeps, allowMissingActiveProvider bool) (config.ResolvedConfig, int) {
workspaceRoot, err := resolveWorkspaceRoot("", deps)
if err != nil {
return config.ResolvedConfig{}, writeExecUsageError(stderr, err.Error())
}
resolved, err := deps.resolveConfig(workspaceRoot, config.Overrides{})
if err != nil {
// A missing active provider must not stop the `providers` subcommands
// that answer for profiles coming from the environment (OPENAI_API_KEY,
// GEMINI_API_KEY, ...), where an env-derived profile is never marked
// active in config.json. The resolver elects an active provider by
// itself only when there is exactly one, so two env providers (or one
// key plus one base URL) made every subcommand exit with `no active
// provider configured: active provider "" not found` (issue #1130). It
// still returns the successfully normalized list alongside the error,
// so hand that partial config back WITHOUT electing an active provider:
// these commands report what is configured, not what a run would use.
// Adopting firstUsableProvider here made `providers current`/`list`
// present a provider that `zero exec` would still refuse to run with,
// and made a base-URL-only profile (no API key) fail `providers list`
// because the election demands a credential. Electing a provider to RUN
// with stays in the interactive TUI's recovery path (app.go).
if allowMissingActiveProvider && errors.Is(err, config.ErrNoActiveProvider) {
return resolved, exitSuccess
}
return config.ResolvedConfig{}, writeAppError(stderr, err.Error(), exitProvider)
}
return resolved, exitSuccess
@@ -466,8 +524,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
+34
View File
@@ -880,6 +880,40 @@ func providerSetupDeps(configPath string) appDeps {
}
}
// providerEnvSetupDeps is providerSetupDeps plus a REAL resolver pinned to a
// temporary user config and a fixed environment. providerSetupDeps only
// redirects WRITES, so without this the `providers` subcommands resolve against
// the developer's own config.json and ambient provider variables: a test that
// exports OPENAI_API_KEY passes on a machine whose real config has an active
// provider and fails on one that also has GEMINI_API_KEY exported (issue
// #1130). Passing Env makes resolution fully hermetic — config.envValue prefers
// the map over os.Getenv whenever it is non-nil.
func providerEnvSetupDeps(t *testing.T, configPath string, env map[string]string) appDeps {
t.Helper()
cwd := t.TempDir()
deps := providerSetupDeps(configPath)
deps.getwd = func() (string, error) { return cwd, nil }
deps.resolveConfig = func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
return config.Resolve(config.ResolveOptions{
UserConfigPath: configPath,
Env: env,
Overrides: overrides,
})
}
// summarizeConfig reads OAuth LOGIN state through the default store, which
// resolves the developer's own ZERO_OAUTH_TOKENS_PATH (or XDG keyring root)
// — a machine with a stored ChatGPT login would make `providers list`
// assertions flip. Pin the file and the backend per the coding guideline
// that no test may read the developer's real config, cache or state dirs:
// forcing "file" also prevents an ambient ZERO_OAUTH_STORAGE=keyring from
// pulling the OS keychain into these tests.
tokensPath := filepath.Join(t.TempDir(), "oauth-tokens.json")
t.Setenv("ZERO_OAUTH_TOKENS_PATH", tokensPath)
t.Setenv("ZERO_OAUTH_STORAGE", "file")
return deps
}
func readFileConfig(t *testing.T, path string) config.FileConfig {
t.Helper()
+5 -1
View File
@@ -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"}},
+5
View File
@@ -165,6 +165,11 @@ func TestCompletionTreeCoversAliasesNestingAndCommonFlags(t *testing.T) {
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)
}
+21 -7
View File
@@ -181,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
@@ -347,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)
@@ -452,20 +463,20 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
// 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 the profile is ineligible: agent.Run then wraps the
// provider in its default adapter. This is the run's STARTING session provider
// and is used whether or not escalation is enabled.
turnSessions, _ := providers.OptimizedTurnSessions(resolved.Provider, provider, providers.Options{})
// 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 is nil unless this run STARTED optimized, which is what keeps a
// default-adapter run on the default adapter.
// switcher preserves the starting transport and shares the RPM limiter.
var modelSwitcher func(context.Context, string) (agent.Provider, error)
var modelSessionSwitcher func(context.Context, string) (zeroruntime.TurnSessionProvider, error)
if options.allowEscalation {
modelSwitcher, modelSessionSwitcher = providers.EscalationSwitchers(
resolved.Provider, provider, deps.newProvider,
func(modelID string) { currentModel = modelID },
sessionOptions,
)
}
@@ -660,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,
+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,
+20 -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)
+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 {
+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())
}
}
+7
View File
@@ -49,8 +49,15 @@ func TestLocalAdoptionSavesAndReloadsEligibleModel(t *testing.T) {
}
}))
defer server.Close()
// A test-sized probe deadline. DetectLocalRuntimes defaults to 400ms and
// treats a timeout as "runtime not running", so under -race on a busy runner
// one slow loopback round trip can silently drop the candidate. Timeout
// enforcement is covered separately in provideronboarding:
// TestDetectLocalRuntimesAppliesDefaultTimeout pins it with an explicit
// 20ms deadline.
detected := provideronboarding.DetectLocalRuntimes(context.Background(), provideronboarding.LocalDetectOptions{
HTTPClient: server.Client(),
Timeout: 5 * time.Second,
Candidates: []provideronboarding.LocalRuntime{{CatalogID: "atomic-chat-local", Name: "Atomic Chat Local", DefaultModel: "local-model", BaseURL: server.URL + "/v1"}},
})
if len(detected) != 1 {
@@ -20,6 +20,8 @@ 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: "concentrate", name: "Concentrate", transport: "openai-compatible", defaultModel: "deepseek-v4-flash-0731"},
{id: "ollama-cloud", name: "Ollama Cloud", transport: "openai-compatible", defaultModel: "qwen3-coder:480b"},
{id: "ollama", name: "Ollama Local", transport: "openai-compatible", defaultModel: "llama3.1"},
}
+4 -4
View File
@@ -542,9 +542,9 @@ func providerResolvedByName(providers []config.ProviderProfile, name string) boo
// reports the situation plainly instead of that confusing error (issue
// #707).
func reportUnpersistedProviderUse(stdout, stderr io.Writer, deps appDeps, options providerUseOptions, configPath string) (int, bool) {
resolved, exitCode := resolveCommandCenterConfig(stderr, deps)
resolved, exitCode := resolveProviderManagementConfig(stderr, deps)
if exitCode != exitSuccess {
// resolveCommandCenterConfig already wrote its own error to stderr;
// resolveProviderManagementConfig already wrote its own error to stderr;
// stop here instead of letting the caller try SetActiveProvider too.
return exitCode, true
}
@@ -578,7 +578,7 @@ func reportUnpersistedProviderUse(stdout, stderr io.Writer, deps appDeps, option
// name isn't resolvable at all, it returns handled=false so the caller falls
// through to RemoveProvider's real "not found" error.
func reportUnpersistedProviderRemove(stdout, stderr io.Writer, deps appDeps, name string, jsonOutput bool, configPath string) (int, bool) {
resolved, exitCode := resolveCommandCenterConfig(stderr, deps)
resolved, exitCode := resolveProviderManagementConfig(stderr, deps)
if exitCode != exitSuccess {
return exitCode, true
}
@@ -609,7 +609,7 @@ func reportUnpersistedProviderRemove(stdout, stderr io.Writer, deps appDeps, nam
}
func reportUnpersistedProviderRename(stdout, stderr io.Writer, deps appDeps, name string, jsonOutput bool, configPath string) (int, bool) {
resolved, exitCode := resolveCommandCenterConfig(stderr, deps)
resolved, exitCode := resolveProviderManagementConfig(stderr, deps)
if exitCode != exitSuccess {
return exitCode, true
}
+227 -8
View File
@@ -189,12 +189,11 @@ func TestRunProvidersUseSurfacesMalformedConfig(t *testing.T) {
}
func TestRunProvidersUseEnvDerivedJSONIncludesConfigPath(t *testing.T) {
t.Setenv("OPENAI_API_KEY", "sk-env")
var stdout, stderr bytes.Buffer
configPath := filepath.Join(t.TempDir(), "config.json")
writeProviderOnboardingConfig(t, configPath, config.FileConfig{})
exitCode := runWithDeps([]string{"providers", "use", "openai", "--json"}, &stdout, &stderr, providerSetupDeps(configPath))
exitCode := runWithDeps([]string{"providers", "use", "openai", "--json"}, &stdout, &stderr, providerEnvSetupDeps(t, configPath, map[string]string{"OPENAI_API_KEY": "sk-env"}))
if exitCode != exitSuccess {
t.Fatalf("exit code = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
@@ -212,12 +211,11 @@ func TestRunProvidersUseEnvDerivedJSONIncludesConfigPath(t *testing.T) {
}
func TestRunProvidersRemoveEnvDerivedJSONKeepsSchema(t *testing.T) {
t.Setenv("OPENAI_API_KEY", "sk-env")
var stdout, stderr bytes.Buffer
configPath := filepath.Join(t.TempDir(), "config.json")
writeProviderOnboardingConfig(t, configPath, config.FileConfig{})
exitCode := runWithDeps([]string{"providers", "remove", "openai", "--json"}, &stdout, &stderr, providerSetupDeps(configPath))
exitCode := runWithDeps([]string{"providers", "remove", "openai", "--json"}, &stdout, &stderr, providerEnvSetupDeps(t, configPath, map[string]string{"OPENAI_API_KEY": "sk-env"}))
if exitCode != exitSuccess {
t.Fatalf("exit code = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
@@ -237,12 +235,11 @@ func TestRunProvidersRemoveEnvDerivedJSONKeepsSchema(t *testing.T) {
}
func TestRunProvidersRenameEnvDerivedExplainsNoSavedProfile(t *testing.T) {
t.Setenv("OPENAI_API_KEY", "sk-env")
var stdout, stderr bytes.Buffer
configPath := filepath.Join(t.TempDir(), "config.json")
writeProviderOnboardingConfig(t, configPath, config.FileConfig{})
exitCode := runWithDeps([]string{"providers", "rename", "openai", "renamed"}, &stdout, &stderr, providerSetupDeps(configPath))
exitCode := runWithDeps([]string{"providers", "rename", "openai", "renamed"}, &stdout, &stderr, providerEnvSetupDeps(t, configPath, map[string]string{"OPENAI_API_KEY": "sk-env"}))
if exitCode != exitSuccess {
t.Fatalf("exit code = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
@@ -253,12 +250,11 @@ func TestRunProvidersRenameEnvDerivedExplainsNoSavedProfile(t *testing.T) {
}
func TestRunProvidersRenameEnvDerivedJSONKeepsSchema(t *testing.T) {
t.Setenv("OPENAI_API_KEY", "sk-env")
var stdout, stderr bytes.Buffer
configPath := filepath.Join(t.TempDir(), "config.json")
writeProviderOnboardingConfig(t, configPath, config.FileConfig{})
exitCode := runWithDeps([]string{"providers", "rename", "openai", "renamed", "--json"}, &stdout, &stderr, providerSetupDeps(configPath))
exitCode := runWithDeps([]string{"providers", "rename", "openai", "renamed", "--json"}, &stdout, &stderr, providerEnvSetupDeps(t, configPath, map[string]string{"OPENAI_API_KEY": "sk-env"}))
if exitCode != exitSuccess {
t.Fatalf("exit code = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
@@ -276,6 +272,229 @@ func TestRunProvidersRenameEnvDerivedJSONKeepsSchema(t *testing.T) {
}
}
// Two env-derived providers and no saved active provider is the case issue
// #1130 reports: the resolver only elects an active provider by itself when
// there is exactly one, so `providers list/use/remove/rename` used to exit 3
// with `active provider "" not found` before doing anything.
func TestRunProvidersSubcommandsWithMultipleEnvDerivedProviders(t *testing.T) {
env := map[string]string{
"OPENAI_API_KEY": "sk-env",
"GEMINI_API_KEY": "gem-env",
}
cases := []struct {
name string
args []string
want string
}{
{name: "list", args: []string{"providers", "list"}, want: "openai"},
{name: "list json", args: []string{"providers", "list", "--json"}, want: `"name": "openai"`},
{name: "use", args: []string{"providers", "use", "openai"}, want: "is not saved in config.json"},
{name: "remove", args: []string{"providers", "remove", "openai"}, want: "nothing to remove there"},
{name: "rename", args: []string{"providers", "rename", "openai", "renamed"}, want: "no saved profile to rename"},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
writeProviderOnboardingConfig(t, configPath, config.FileConfig{})
var stdout, stderr bytes.Buffer
exitCode := runWithDeps(tt.args, &stdout, &stderr, providerEnvSetupDeps(t, configPath, env))
if exitCode != exitSuccess {
t.Fatalf("exit code = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
}
if !strings.Contains(stdout.String(), tt.want) {
t.Fatalf("stdout = %q, want it to contain %q", stdout.String(), tt.want)
}
})
}
}
func TestRunProvidersListShowsEveryEnvDerivedProvider(t *testing.T) {
env := map[string]string{
"OPENAI_API_KEY": "sk-env",
"GEMINI_API_KEY": "gem-env",
}
configPath := filepath.Join(t.TempDir(), "config.json")
writeProviderOnboardingConfig(t, configPath, config.FileConfig{})
var stdout, stderr bytes.Buffer
exitCode := runWithDeps([]string{"providers", "list", "--json"}, &stdout, &stderr, providerEnvSetupDeps(t, configPath, env))
if exitCode != exitSuccess {
t.Fatalf("exit code = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
}
var payload struct {
Providers []struct {
Name string `json:"name"`
} `json:"providers"`
}
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("decode JSON: %v\n%s", err, stdout.String())
}
names := make(map[string]bool, len(payload.Providers))
for _, provider := range payload.Providers {
names[provider.Name] = true
}
for _, want := range []string{"openai", "google"} {
if !names[want] {
t.Fatalf("providers = %v, want %q listed", names, want)
}
}
}
// The provider-management commands report what is CONFIGURED, not what a run
// would use: with env-derived providers and no saved active provider they must
// not present any provider as selected. Electing firstUsableProvider in
// resolveCommandCenterConfig made `providers current` claim `openai` while
// `zero exec` in the same environment still exited 3 with `no active provider
// configured` — and it read the OAuth store on this path (maintainer review of
// this PR).
func TestRunProvidersSubcommandsElectNoActiveProvider(t *testing.T) {
env := map[string]string{
"OPENAI_API_KEY": "sk-env",
"GEMINI_API_KEY": "gem-env",
}
deps := func(t *testing.T) appDeps {
configPath := filepath.Join(t.TempDir(), "config.json")
writeProviderOnboardingConfig(t, configPath, config.FileConfig{})
return providerEnvSetupDeps(t, configPath, env)
}
t.Run("current shows nothing selected", func(t *testing.T) {
var stdout, stderr bytes.Buffer
exitCode := runWithDeps([]string{"providers", "current"}, &stdout, &stderr, deps(t))
if exitCode != exitSuccess {
t.Fatalf("exit code = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
}
if !strings.Contains(stdout.String(), "(none)") {
t.Fatalf("stdout = %q, want an empty selection", stdout.String())
}
if strings.Contains(stdout.String(), "openai") {
t.Fatalf("stdout = %q, want no provider elected as current", stdout.String())
}
})
t.Run("list marks nothing active", func(t *testing.T) {
var stdout, stderr bytes.Buffer
exitCode := runWithDeps([]string{"providers", "list", "--json"}, &stdout, &stderr, deps(t))
if exitCode != exitSuccess {
t.Fatalf("exit code = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
}
var payload struct {
Providers []struct {
Name string `json:"name"`
Active bool `json:"active"`
} `json:"providers"`
}
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("decode JSON: %v\n%s", err, stdout.String())
}
if len(payload.Providers) < 2 {
t.Fatalf("providers = %+v, want both env-derived profiles listed", payload.Providers)
}
for _, provider := range payload.Providers {
if provider.Active {
t.Fatalf("provider %q marked active, want nothing active", provider.Name)
}
}
})
t.Run("use json leaves activeProvider empty", func(t *testing.T) {
var stdout, stderr bytes.Buffer
exitCode := runWithDeps([]string{"providers", "use", "openai", "--json"}, &stdout, &stderr, deps(t))
if exitCode != exitSuccess {
t.Fatalf("exit code = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
}
var payload struct {
ActiveProvider string `json:"activeProvider"`
}
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("decode JSON: %v\n%s", err, stdout.String())
}
if payload.ActiveProvider != "" {
t.Fatalf("activeProvider = %q, want it empty for an unpersisted selection", payload.ActiveProvider)
}
})
}
// A base-URL-only environment (OPENAI_BASE_URL / GEMINI_BASE_URL set, no API
// keys) is the other half of issue #1130: electing firstUsableProvider made
// `providers list` exit 3 because the election demands a credential. The list
// must succeed and still mark nothing active. The resolver itself keeps only
// the keyless openai-compatible profile here (a google profile without a
// credential is dropped during normalization), so `openai` is the expected
// entry — pinning that as-is keeps this test about the COMMAND, not the
// resolver.
func TestRunProvidersListWithBaseURLOnlyProviders(t *testing.T) {
env := map[string]string{
"OPENAI_BASE_URL": "https://openai.proxy.example.com/v1",
"GEMINI_BASE_URL": "https://gemini.proxy.example.com",
}
configPath := filepath.Join(t.TempDir(), "config.json")
writeProviderOnboardingConfig(t, configPath, config.FileConfig{})
var stdout, stderr bytes.Buffer
exitCode := runWithDeps([]string{"providers", "list", "--json"}, &stdout, &stderr, providerEnvSetupDeps(t, configPath, env))
if exitCode != exitSuccess {
t.Fatalf("exit code = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
}
var payload struct {
Providers []struct {
Name string `json:"name"`
Active bool `json:"active"`
} `json:"providers"`
}
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("decode JSON: %v\n%s", err, stdout.String())
}
names := make(map[string]bool, len(payload.Providers))
for _, provider := range payload.Providers {
if provider.Active {
t.Fatalf("provider %q marked active, want nothing active", provider.Name)
}
names[provider.Name] = true
}
if !names["openai"] {
t.Fatalf("providers = %v, want the base-URL-only openai profile listed", names)
}
}
// `zero config` is NOT one of the provider-management commands: it reports the
// resolved configuration, so a missing active provider has to stay actionable
// (the headless contract the resolver documents for `zero config` / `zero
// exec`). Only the interactive TUI recovers from that state.
func TestRunConfigSummaryStillFailsWithNoActiveProvider(t *testing.T) {
env := map[string]string{
"OPENAI_API_KEY": "sk-env",
"GEMINI_API_KEY": "gem-env",
}
configPath := filepath.Join(t.TempDir(), "config.json")
writeProviderOnboardingConfig(t, configPath, config.FileConfig{})
var stdout, stderr bytes.Buffer
exitCode := runWithDeps([]string{"config"}, &stdout, &stderr, providerEnvSetupDeps(t, configPath, env))
if exitCode == exitSuccess {
t.Fatalf("exit code = %d, want the actionable resolve error: %s", exitCode, stdout.String())
}
message := stderr.String()
if message == "" {
message = stdout.String()
}
if !strings.Contains(message, "no active provider configured") {
t.Fatalf("output = %q / %q, want the actionable resolve error", stdout.String(), stderr.String())
}
}
func TestRunProvidersUseRejectsUsageErrors(t *testing.T) {
cases := []struct {
name string
@@ -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)
}
})
}
}
+88 -2
View File
@@ -485,6 +485,88 @@ func TestTUISandboxSetupCommandGatedToWindowsNativeBackend(t *testing.T) {
}
func TestRunSandboxPolicyJSONGoldenIncludesManagerBaselineFields(t *testing.T) {
// Pin before any t.TempDir so an ambient temp dir cannot reach the snapshot.
// Unix reads TMPDIR (and always considers /tmp). Windows reads TEMP and TMP.
// XDG_RUNTIME_DIR is not a temp write-root candidate.
pinSandboxPolicyGoldenTempEnv(t)
assertSandboxPolicyJSONGolden(t, "")
}
// TestRunSandboxPolicyJSONGoldenNestedTempDir stages the temp-dir shape from
// #1121 and then pins over it. On Unix that shape is a TMPDIR inside /tmp; on
// Windows it is TMP inside TEMP. NewScope drops the inner root, the policy
// JSON still lists it, and the golden normalizer leaves it behind. The pin
// replaces those variables before the snapshot, so the golden matches and the
// nested path does not survive. The directories come from t.TempDir, never
// from the package working directory, so a checkout under /tmp is not special.
func TestRunSandboxPolicyJSONGoldenNestedTempDir(t *testing.T) {
nested := stageNestedSandboxTempEnv(t)
pinSandboxPolicyGoldenTempEnv(t)
normalized := assertSandboxPolicyJSONGolden(t, "")
if strings.Contains(normalized, nested) || strings.Contains(normalized, filepath.ToSlash(nested)) {
t.Fatalf("normalized policy leaked nested temp root %q:\n%s", nested, normalized)
}
}
// stageNestedSandboxTempEnv installs the leaking temp-dir shape and returns
// the inner path. The caller pins afterward. Skip, rather than fail, when
// that shape cannot be built.
func stageNestedSandboxTempEnv(t *testing.T) string {
t.Helper()
if runtime.GOOS == "windows" {
outer := t.TempDir()
inner := filepath.Join(outer, "inner")
if err := os.MkdirAll(inner, 0o755); err != nil {
t.Skipf("create nested TEMP/TMP: %v", err)
}
t.Setenv("TEMP", outer)
t.Setenv("TMP", inner)
return inner
}
resolvedTmp, err := filepath.EvalSymlinks("/tmp")
if err != nil {
t.Skipf("resolve /tmp: %v", err)
}
info, err := os.Stat(resolvedTmp)
if err != nil || !info.IsDir() {
t.Skip("/tmp is not a directory; cannot stage a nested TMPDIR")
}
// t.TempDir is a child of os.TempDir(). A further child is the ambient
// case: TMPDIR inside the hard-coded /tmp candidate.
nested := filepath.Join(t.TempDir(), "nested")
if err := os.MkdirAll(nested, 0o755); err != nil {
t.Skipf("create nested TMPDIR: %v", err)
}
resolvedNested, err := filepath.EvalSymlinks(nested)
if err != nil {
t.Skipf("resolve nested TMPDIR: %v", err)
}
rel, err := filepath.Rel(resolvedTmp, resolvedNested)
if err != nil || rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || filepath.IsAbs(rel) {
t.Skipf("temp dir %q is not inside /tmp; cannot stage the nested TMPDIR case", resolvedNested)
}
t.Setenv("TMPDIR", resolvedNested)
return resolvedNested
}
// pinSandboxPolicyGoldenTempEnv fixes the temp variables
// defaultTempWriteRootCandidatesForGOOS reads so the golden does not depend on
// the process environment. An empty TMPDIR leaves the Unix hard-coded /tmp as
// the only candidate. TEMP and TMP name one directory so neither is nested in
// the other.
func pinSandboxPolicyGoldenTempEnv(t *testing.T) {
t.Helper()
if runtime.GOOS == "windows" {
dir := t.TempDir()
t.Setenv("TEMP", dir)
t.Setenv("TMP", dir)
return
}
t.Setenv("TMPDIR", "")
}
func assertSandboxPolicyJSONGolden(t *testing.T, mustList string) string {
t.Helper()
// Point all user-config roots at an empty directory so the platform-specific
// credential deny baseline can be asserted without leaking host paths into
// the platform-neutral golden comparison.
@@ -526,8 +608,8 @@ func TestRunSandboxPolicyJSONGoldenIncludesManagerBaselineFields(t *testing.T) {
t.Fatalf("policy exit = %d, stderr %q", exitCode, stderr.String())
}
got := stdout.String()
got = replacePathToken(got, workspace, "$WORKSPACE")
raw := stdout.String()
got := replacePathToken(raw, workspace, "$WORKSPACE")
got = replacePathToken(got, store.FilePath(), "$GRANTS")
gotBytes := normalizeSandboxPolicyGoldenTempRoots(t, []byte(got), workspace, emptyHome)
wantBytes, err := os.ReadFile(filepath.Join("testdata", "sandbox_policy_windows_unavailable.golden.json"))
@@ -537,6 +619,10 @@ func TestRunSandboxPolicyJSONGoldenIncludesManagerBaselineFields(t *testing.T) {
if !jsonValuesEqual(t, wantBytes, gotBytes) {
t.Fatalf("policy JSON golden mismatch\nwant:\n%s\ngot:\n%s", string(wantBytes), string(gotBytes))
}
if mustList != "" && !strings.Contains(raw, mustList) {
t.Fatalf("policy JSON did not list pinned temp root %q\n%s", mustList, raw)
}
return string(gotBytes)
}
func normalizeSandboxPolicyGoldenTempRoots(t *testing.T, gotBytes []byte, workspace string, emptyHome string) []byte {
+9 -1
View File
@@ -7,6 +7,8 @@ import (
"strconv"
"strings"
"time"
"github.com/Gitlawb/zero/internal/gitsafe"
)
type scratchFileBaseline map[string]bool
@@ -78,7 +80,13 @@ func gitUntrackedFiles(workspaceRoot string) ([]string, bool) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
output, err := exec.CommandContext(ctx, "git", "-C", workspaceRoot, "status", "--porcelain", "-z", "--untracked-files=all").Output()
// gitsafe keeps the workspace's own git config from running a program, and
// --ignore-submodules=dirty keeps git out of submodule work trees.
command, err := gitsafe.Command(ctx, workspaceRoot, "status", "--porcelain", "-z", "--untracked-files=all", "--ignore-submodules=dirty")
if err != nil {
return nil, false
}
output, err := command.Output()
if err != nil {
// Not a git repo (or git failed for some other reason) — nothing
// reliable to report against, so stay silent rather than guess.
+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")
}
}
@@ -62,6 +62,10 @@ func TestCLILaunchHelpersLeaveUserStateAlone(t *testing.T) {
{"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)
+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]...)
+6 -5
View File
@@ -3,7 +3,6 @@ package config
import (
"errors"
"fmt"
"os"
"path/filepath"
"time"
@@ -72,10 +71,12 @@ var lockConfigFileFn = lockConfigFile
func lockConfigFile(path string) (func() error, error) {
lockPath := path + ".lock"
if dir := filepath.Dir(lockPath); dir != "." && dir != "" {
if err := os.MkdirAll(dir, 0o700); err != nil {
return nil, fmt.Errorf("create config directory %s: %w", dir, err)
}
// 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 {
+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
}
}
}
+54
View File
@@ -0,0 +1,54 @@
package config
import (
"encoding/json"
"reflect"
"testing"
)
func TestModelRPMRoundTrip(t *testing.T) {
var c FileConfig
if e := json.Unmarshal([]byte(`{"modelRPM":{" OPENAI:GPT-4.1 ":2,"custom":0},"future":true}`), &c); e != nil {
t.Fatal(e)
}
want := map[string]int{"gpt-4.1": 2, "custom": 0}
if !reflect.DeepEqual(c.ModelRPM, want) {
t.Fatal(c.ModelRPM)
}
b, e := json.Marshal(c)
if e != nil {
t.Fatal(e)
}
var d FileConfig
if e = json.Unmarshal(b, &d); e != nil {
t.Fatal(e)
}
if !reflect.DeepEqual(d.ModelRPM, want) || string(d.Extra["future"]) != "true" {
t.Fatalf("round trip=%s", b)
}
}
func TestModelRPMInvalid(t *testing.T) {
for _, s := range []string{`{"modelRPM":{"":1}}`, `{"modelRPM":{"a":-1}}`, `{"modelRPM":{"a":1.5}}`, `{"modelRPM":{"a":"2"}}`, `{"modelRPM":{"gpt-4.1":1,"openai:gpt-4.1":2}}`} {
var c FileConfig
if e := json.Unmarshal([]byte(s), &c); e == nil {
t.Fatalf("accepted %s", s)
}
}
}
func TestModelRPMProjectCanOnlyTighten(t *testing.T) {
user := writeConfig(t, `{"modelRPM":{"gpt-4.1":2},"activeProvider":"test","providers":[{"name":"test","provider":"openai","model":"gpt-4.1"}]}`)
for _, n := range []string{"0", "1", "5"} {
project := writeConfig(t, `{"modelRPM":{"openai:gpt-4.1":`+n+`}}`)
c, e := Resolve(ResolveOptions{UserConfigPath: user, ProjectConfigPath: project, Env: map[string]string{}})
if e != nil {
t.Fatal(e)
}
want := 2
if n == "1" {
want = 1
}
if c.ModelRPM["gpt-4.1"] != want {
t.Fatal(c.ModelRPM)
}
}
}
+27 -2
View File
@@ -104,6 +104,9 @@ func Resolve(options ResolveOptions) (ResolvedConfig, error) {
// trusted global-config merge, which must keep honouring the setting.
commandConfig.Sandbox.Enabled = nil
commandConfig.CrossSessionInbound = ""
// External provider commands cannot relax the user cap.
mergeModelRPM(&cfg.ModelRPM, commandConfig.ModelRPM, true)
commandConfig.ModelRPM = nil
mergeConfig(&cfg, commandConfig)
}
@@ -157,11 +160,31 @@ func Resolve(options ResolveOptions) (ResolvedConfig, error) {
// On ErrNoActiveProvider, providers may still hold the successfully
// normalized (but active-less) profile list — keep it so a caller can fall
// back to an already-configured usable provider instead of treating this
// like a config with nothing set up at all.
return ResolvedConfig{Providers: providers}, err
// like a config with nothing set up at all. The non-provider fields parsed
// before the failure (notify, sandbox, tools, preferences, ...) are kept
// too: the TUI's provider-recovery path forwards this partial config, and
// dropping them would let the TUI's unconfigured notify default overwrite
// a stored explicit opt-out on startup (maintainer review, PR #1001).
partial := ResolvedConfig{
ModelRPM: cfg.ModelRPM,
Providers: providers,
MaxTurns: cfg.MaxTurns,
MCP: cfg.MCP,
Sandbox: cfg.Sandbox,
Notify: cfg.Notify,
Tools: cfg.Tools,
Swarm: cfg.Swarm,
Preferences: cfg.Preferences,
KeyBindings: cfg.KeyBindings,
LocalControl: cfg.LocalControl,
STT: cfg.STT,
CrossSessionInbound: cfg.CrossSessionInbound,
}
return partial, err
}
return ResolvedConfig{
ModelRPM: cfg.ModelRPM,
ActiveProvider: active.Name,
Providers: providers,
Provider: active,
@@ -230,6 +253,7 @@ func loadConfigFile(path string) (FileConfig, error) {
}
func mergeConfig(dst *FileConfig, src FileConfig) {
mergeModelRPM(&dst.ModelRPM, src.ModelRPM, false)
if activeProvider := strings.TrimSpace(src.ActiveProvider); activeProvider != "" {
dst.ActiveProvider = activeProvider
}
@@ -291,6 +315,7 @@ func mergeConfig(dst *FileConfig, src FileConfig) {
}
func mergeProjectConfig(dst *FileConfig, src FileConfig) error {
mergeModelRPM(&dst.ModelRPM, src.ModelRPM, true)
if activeProvider := strings.TrimSpace(src.ActiveProvider); activeProvider != "" {
dst.ActiveProvider = activeProvider
}
+98 -11
View File
@@ -1109,6 +1109,34 @@ func TestResolveProviderCommandOverridesEnvProviderFields(t *testing.T) {
}
}
func TestResolveProviderCommandCannotRelaxUserModelRPM(t *testing.T) {
userPath := writeConfig(t, `{
"modelRPM": {
"gpt-command": 5
}
}`)
command := writeCommand(t, commandScript{
Stdout: `{"activeProvider":"cmd","providers":[{"name":"cmd","provider":"openai","apiKey":"sk-command","model":"gpt-command"}],"modelRPM":{"gpt-command":10}}`,
})
resolved, err := Resolve(ResolveOptions{
UserConfigPath: userPath,
ProviderCommand: command,
Env: map[string]string{},
})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if got := resolved.ModelRPM["gpt-command"]; got != 5 {
t.Fatalf(
"ModelRPM[gpt-command] = %d, want user cap 5 preserved against provider-command relaxation",
got,
)
}
}
func TestResolveAllowsNoConfiguredProviders(t *testing.T) {
resolved, err := Resolve(ResolveOptions{Env: map[string]string{}})
if err != nil {
@@ -1151,8 +1179,13 @@ func TestResolveRejectsActiveProviderWithoutConfiguredProfiles(t *testing.T) {
if HasProviderProfile(resolved.Provider) {
t.Fatalf("Provider = %#v, want zero value", resolved.Provider)
}
if resolved.MaxTurns != 0 {
t.Fatalf("MaxTurns = %d, want zero on failed resolve", resolved.MaxTurns)
// The error path now carries the parsed non-provider fields through
// (maintainer review, PR #1001): the TUI's provider-recovery startup
// forwards this partial config, and a zero MaxTurns there would silently
// discard the user's turn budget. Matches the no-providers SUCCESS path,
// which has always defaulted MaxTurns.
if resolved.MaxTurns != defaultMaxTurns {
t.Fatalf("MaxTurns = %d, want default %d carried through the partial resolve", resolved.MaxTurns, defaultMaxTurns)
}
}
@@ -1161,10 +1194,13 @@ func TestResolveKeepsNormalizedProvidersWhenNoneMarkedActive(t *testing.T) {
// activeProvider is blank/stale — a caller like the interactive TUI still
// needs the normalized list to fall back to an already-usable provider
// instead of forcing a full re-onboarding wizard.
path := writeConfig(t, `{"providers":[
{"name":"work","provider_kind":"openai","apiKey":"sk-test","model":"gpt-test"},
{"name":"other","provider_kind":"openai","apiKey":"sk-other","model":"gpt-test"}
]}`)
path := writeConfig(t, `{
"modelRPM":{"gpt-test":1},
"providers":[
{"name":"work","provider_kind":"openai","apiKey":"sk-test","model":"gpt-test"},
{"name":"other","provider_kind":"openai","apiKey":"sk-other","model":"gpt-test"}
]
}`)
resolved, err := Resolve(ResolveOptions{ProjectConfigPath: path, Env: map[string]string{}})
if !errors.Is(err, ErrNoActiveProvider) {
@@ -1173,6 +1209,9 @@ func TestResolveKeepsNormalizedProvidersWhenNoneMarkedActive(t *testing.T) {
if len(resolved.Providers) != 2 {
t.Fatalf("Providers = %#v, want the 2 normalized profiles preserved despite the error", resolved.Providers)
}
if got := resolved.ModelRPM["gpt-test"]; got != 1 {
t.Fatalf("ModelRPM[gpt-test] = %d, want 1 preserved despite ErrNoActiveProvider", got)
}
}
func TestResolveTrimsProviderProfileAliasesBeforeFallback(t *testing.T) {
@@ -1864,14 +1903,43 @@ func TestResolveNotifyInvalidFocusMode(t *testing.T) {
}
}
func TestResolveNotifyDefaultEmpty(t *testing.T) {
path := writeConfig(t, `{}`)
resolved, err := Resolve(ResolveOptions{UserConfigPath: path, Env: map[string]string{}})
// An unconfigured notify block must resolve EMPTY. Defaults live in the TUI
// (effectiveTUINotifyMode) and in `zero config notify`'s display, not in the
// resolver: a filled-in default here leaks into headless `zero exec` (BEL +
// OSC-9 bytes on stderr under -o json) and into the CLI/TUI preserve paths,
// which must treat "user never chose" differently from "user chose both"
// (maintainer review, PR #1001).
func TestResolveNotifyUnconfiguredStaysEmpty(t *testing.T) {
// No config file at all.
resolved, err := Resolve(ResolveOptions{Env: map[string]string{}})
if err != nil {
t.Fatalf("Resolve: %v", err)
}
if resolved.Notify.Mode != "" || resolved.Notify.FocusMode != "" {
t.Fatalf("unset notify should be empty, got %+v", resolved.Notify)
t.Fatalf("no config file: notify = %+v, want empty (resolver must not default)", resolved.Notify)
}
for name, body := range map[string]string{
"empty config": `{}`,
"empty block": `{"notify":{}}`,
"mode only": `{"notify":{"mode":"off"}}`,
} {
t.Run(name, func(t *testing.T) {
resolved, err := Resolve(ResolveOptions{UserConfigPath: writeConfig(t, body), Env: map[string]string{}})
if err != nil {
t.Fatalf("Resolve: %v", err)
}
if name == "mode only" {
if resolved.Notify.Mode != "off" {
t.Errorf("notify.mode = %q, want preserved off", resolved.Notify.Mode)
}
} else if resolved.Notify.Mode != "" {
t.Errorf("notify.mode = %q, want empty (resolver must not default)", resolved.Notify.Mode)
}
if resolved.Notify.FocusMode != "" {
t.Errorf("notify.focusMode = %q, want empty (resolver must not default)", resolved.Notify.FocusMode)
}
})
}
}
@@ -2254,6 +2322,19 @@ func TestResolveRejectsInvalidCrossSessionInbound(t *testing.T) {
}
func TestResolveReportsExplicitMaxTurns(t *testing.T) {
// A nil ResolveOptions.Env means "read the real process environment" (see
// envValue), so a case that leaves it nil inherits whatever the developer
// happens to export. ANTHROPIC_BASE_URL alone is enough: applyProviderEnv
// synthesizes a second, keyless "anthropic" profile from a base URL on its
// own, the single-provider shortcut in normalizeProviders stops applying,
// and Resolve fails with `active provider "" not found` before this test
// reaches its assertion. Every variable the resolver reads does it, so the
// cases below state an EMPTY environment rather than no environment, and
// these three are set to a hostile value so the test proves it.
t.Setenv("ANTHROPIC_BASE_URL", "https://proxy.example/v1")
t.Setenv("OPENAI_API_KEY", "from-the-developer-shell")
t.Setenv(MaxTurnsEnv, "7")
const providerJSON = `{
"activeProvider": "p",
"providers": [{"name": "p", "provider_kind": "openai-compatible", "base_url": "https://x.example/v1", "apiKey": "k", "model": "m"}]
@@ -2266,10 +2347,15 @@ func TestResolveReportsExplicitMaxTurns(t *testing.T) {
set bool
}{
{"built-in default only", ResolveOptions{
Env: map[string]string{},
Overrides: Overrides{Provider: ProviderProfile{Name: "p", ProviderKind: "openai-compatible", BaseURL: "https://x.example/v1", APIKey: "k", Model: "m"}},
}, false},
{"user config", ResolveOptions{UserConfigPath: writeConfig(t, providerWithTurns)}, true},
{"user config", ResolveOptions{
Env: map[string]string{},
UserConfigPath: writeConfig(t, providerWithTurns),
}, true},
{"project config", ResolveOptions{
Env: map[string]string{},
UserConfigPath: writeConfig(t, providerJSON),
ProjectConfigPath: writeConfig(t, `{"maxTurns": 40}`),
}, true},
@@ -2278,6 +2364,7 @@ func TestResolveReportsExplicitMaxTurns(t *testing.T) {
Overrides: Overrides{Provider: ProviderProfile{Name: "p", ProviderKind: "openai-compatible", BaseURL: "https://x.example/v1", APIKey: "k", Model: "m"}},
}, true},
{"cli override", ResolveOptions{
Env: map[string]string{},
Overrides: Overrides{
MaxTurns: 42,
Provider: ProviderProfile{Name: "p", ProviderKind: "openai-compatible", BaseURL: "https://x.example/v1", APIKey: "k", Model: "m"},
+20
View File
@@ -0,0 +1,20 @@
package config
import (
"errors"
"os"
)
// ReadSessionsConfig reads the sessions settings from the config file at path,
// which callers pass as the user config. A missing file is an empty config. See
// SessionsConfig for why nothing else supplies these settings.
func ReadSessionsConfig(path string) (SessionsConfig, error) {
cfg, err := loadConfigFile(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return SessionsConfig{}, nil
}
return SessionsConfig{}, err
}
return cfg.Sessions, nil
}
+76
View File
@@ -0,0 +1,76 @@
package config
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
func TestSessionsConfigRoundTripsAsAKnownKey(t *testing.T) {
var cfg FileConfig
if err := json.Unmarshal([]byte(`{"sessions":{"retentionDays":14}}`), &cfg); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if cfg.Sessions.RetentionDays != 14 {
t.Fatalf("retentionDays = %d, want 14", cfg.Sessions.RetentionDays)
}
if _, stray := cfg.Extra["sessions"]; stray {
t.Fatal("sessions was kept as an unknown key as well as parsed")
}
data, err := json.Marshal(cfg)
if err != nil {
t.Fatalf("marshal: %v", err)
}
if !strings.Contains(string(data), `"sessions":{"retentionDays":14}`) {
t.Fatalf("marshalled config lost the setting: %s", data)
}
empty, err := json.Marshal(FileConfig{})
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(empty), "sessions") {
t.Fatalf("an unset sessions block was written out: %s", empty)
}
}
func TestSessionsConfigRejectsANegativeRetention(t *testing.T) {
var cfg FileConfig
err := json.Unmarshal([]byte(`{"sessions":{"retentionDays":-1}}`), &cfg)
if err == nil || !strings.Contains(err.Error(), "retentionDays") {
t.Fatalf("a negative retention was accepted: %v", err)
}
}
// A write that changes something else must not drop the setting.
func TestSessionsConfigSurvivesAnUnrelatedWrite(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(path, []byte(`{"sessions":{"retentionDays":21}}`), 0o600); err != nil {
t.Fatal(err)
}
if _, err := SetTheme(path, "dark"); err != nil {
t.Fatalf("SetTheme: %v", err)
}
settings, err := ReadSessionsConfig(path)
if err != nil {
t.Fatalf("ReadSessionsConfig: %v", err)
}
if settings.RetentionDays != 21 {
t.Fatalf("after an unrelated write retentionDays = %d, want 21", settings.RetentionDays)
}
}
func TestReadSessionsConfig(t *testing.T) {
dir := t.TempDir()
if settings, err := ReadSessionsConfig(filepath.Join(dir, "missing.json")); err != nil || settings != (SessionsConfig{}) {
t.Fatalf("a missing config = %+v, %v; want empty and no error", settings, err)
}
broken := filepath.Join(dir, "broken.json")
if err := os.WriteFile(broken, []byte(`{"sessions":`), 0o600); err != nil {
t.Fatal(err)
}
if _, err := ReadSessionsConfig(broken); err == nil {
t.Fatal("a config that does not parse was read as empty")
}
}
+33
View File
@@ -177,6 +177,19 @@ const (
STTProviderDeepgram STTProviderKind = "deepgram"
)
// SessionsConfig holds settings for Zero's saved sessions.
//
// READ FROM THE USER'S OWN CONFIG ONLY. It decides what `zero sessions prune`
// deletes, and sessions belong to the user rather than to a repository, so a
// project's .zero/config.json has no say: the resolver does not merge it, and
// the prune command reads it straight from the user config file.
type SessionsConfig struct {
// RetentionDays is the cutoff `zero sessions prune` uses when it is given no
// --older-than. Unset (0) means prune needs an explicit cutoff; nothing is
// ever removed without one being asked for.
RetentionDays int `json:"retentionDays,omitempty"`
}
// STTConfig configures speech-to-text dictation. All fields are optional; empty
// values take the documented defaults. Booleans that need a real tri-state
// (distinguishing "unset" from "false") use *bool, matching PreferencesConfig.Recaps.
@@ -351,6 +364,7 @@ func (cfg *ToolsConfig) UnmarshalJSON(data []byte) error {
}
type FileConfig struct {
ModelRPM map[string]int `json:"modelRPM,omitempty"`
ActiveProvider string `json:"activeProvider,omitempty"`
Providers []ProviderProfile `json:"providers,omitempty"`
MaxTurns int `json:"maxTurns,omitempty"`
@@ -364,6 +378,7 @@ type FileConfig struct {
LocalControl LocalControlConfig `json:"localControl,omitempty"`
STT STTConfig `json:"stt,omitempty"`
CrossSessionInbound string `json:"crossSessionInbound,omitempty"`
Sessions SessionsConfig `json:"sessions,omitempty"`
// maxTurnsSet records that some merge source supplied a positive maxTurns —
// i.e. the value is configured, not the built-in default. Unexported like
// Tools.deferThresholdSet: merge bookkeeping, not a config key.
@@ -375,6 +390,7 @@ type FileConfig struct {
func (cfg FileConfig) MarshalJSON() ([]byte, error) {
type rawConfig struct {
ModelRPM map[string]int `json:"modelRPM,omitempty"`
ActiveProvider string `json:"activeProvider,omitempty"`
Providers []ProviderProfile `json:"providers,omitempty"`
MaxTurns int `json:"maxTurns,omitempty"`
@@ -388,8 +404,10 @@ func (cfg FileConfig) MarshalJSON() ([]byte, error) {
LocalControl *LocalControlConfig `json:"localControl,omitempty"`
STT *STTConfig `json:"stt,omitempty"`
CrossSessionInbound string `json:"crossSessionInbound,omitempty"`
Sessions *SessionsConfig `json:"sessions,omitempty"`
}
raw := rawConfig{
ModelRPM: cfg.ModelRPM,
ActiveProvider: cfg.ActiveProvider,
Providers: cfg.Providers,
MaxTurns: cfg.MaxTurns,
@@ -408,6 +426,9 @@ func (cfg FileConfig) MarshalJSON() ([]byte, error) {
if !cfg.STT.Empty() {
raw.STT = &cfg.STT
}
if cfg.Sessions != (SessionsConfig{}) {
raw.Sessions = &cfg.Sessions
}
known, err := json.Marshal(raw)
if err != nil || len(cfg.Extra) == 0 {
return known, err
@@ -465,6 +486,7 @@ type Overrides struct {
}
type ResolvedConfig struct {
ModelRPM map[string]int
ActiveProvider string
Providers []ProviderProfile
Provider ProviderProfile
@@ -533,6 +555,7 @@ type MCPOAuthConfig struct {
func (cfg *FileConfig) UnmarshalJSON(data []byte) error {
type rawConfig struct {
ModelRPM map[string]int `json:"modelRPM"`
ActiveProvider string `json:"activeProvider"`
Providers []ProviderProfile `json:"providers"`
MaxTurns int `json:"maxTurns"`
@@ -546,6 +569,7 @@ func (cfg *FileConfig) UnmarshalJSON(data []byte) error {
LocalControl LocalControlConfig `json:"localControl"`
STT STTConfig `json:"stt"`
CrossSessionInbound string `json:"crossSessionInbound"`
Sessions SessionsConfig `json:"sessions"`
MCPServers map[string]MCPServerConfig `json:"mcpServers"`
MCPServersSnake map[string]MCPServerConfig `json:"mcp_servers"`
}
@@ -567,6 +591,11 @@ func (cfg *FileConfig) UnmarshalJSON(data []byte) error {
extra = nil
}
cfg.ActiveProvider = raw.ActiveProvider
limits, err := normalizeModelRPM(raw.ModelRPM)
if err != nil {
return err
}
cfg.ModelRPM = limits
cfg.Providers = raw.Providers
// A negative maxTurns is unambiguously invalid; without this it would be
// silently dropped by the `MaxTurns > 0` merge gates and fall back to the
@@ -587,6 +616,10 @@ func (cfg *FileConfig) UnmarshalJSON(data []byte) error {
cfg.LocalControl = raw.LocalControl
cfg.STT = raw.STT
cfg.CrossSessionInbound = raw.CrossSessionInbound
if raw.Sessions.RetentionDays < 0 {
return fmt.Errorf("invalid sessions.retentionDays %d: must be >= 0", raw.Sessions.RetentionDays)
}
cfg.Sessions = raw.Sessions
cfg.Extra = extra
if cfg.MCP.Servers == nil && (len(raw.MCPServers) > 0 || len(raw.MCPServersSnake) > 0) {
cfg.MCP.Servers = map[string]MCPServerConfig{}
+240 -4
View File
@@ -6,9 +6,11 @@ import (
"fmt"
"os"
"path/filepath"
"runtime"
"sort"
"strings"
"github.com/Gitlawb/zero/internal/notify"
"github.com/Gitlawb/zero/internal/providercatalog"
)
@@ -756,6 +758,128 @@ func SetTheme(path string, theme string) (result FileConfig, err error) {
return cfg, nil
}
// UserNotify returns the notify block stored in the user's own config file at
// path, trimmed. A missing file or missing/empty block returns the zero value:
// callers that need to preserve "whatever the user already chose" on a partial
// update must seed from THIS value, not from the resolved view — the resolver
// merges project config and the TUI applies its own defaults, so seeding from
// resolved copies choices the user never made into their global file (a
// project's mode: off, or a pinned default) and across every other project.
func UserNotify(path string) (NotifyConfig, error) {
path = strings.TrimSpace(path)
if path == "" {
return NotifyConfig{}, nil
}
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return NotifyConfig{}, nil
}
return NotifyConfig{}, fmt.Errorf("read config %s: %w", path, err)
}
var cfg FileConfig
if err := json.Unmarshal(data, &cfg); err != nil {
return NotifyConfig{}, fmt.Errorf("invalid config JSON %s: %w", path, err)
}
cfg.Notify.Mode = strings.TrimSpace(cfg.Notify.Mode)
cfg.Notify.FocusMode = strings.TrimSpace(cfg.Notify.FocusMode)
return cfg.Notify, nil
}
// validateNotify trims both fields and checks them against the accepted vocab
// (mode in {off,bell,notify,both}; focusMode in {unfocused,always,focused}) so
// a bad caller cannot write a value the resolver would later reject at
// startup. An empty Mode or FocusMode is stored as-is — blank means "use the
// built-in defaults" (the TUI's effectiveTUINotifyMode maps an empty mode to
// both; the notify package treats an empty focusMode as unfocused), not "off".
func validateNotify(value NotifyConfig) (NotifyConfig, error) {
value.Mode = strings.TrimSpace(value.Mode)
value.FocusMode = strings.TrimSpace(value.FocusMode)
if mode := value.Mode; mode != "" {
switch notify.Mode(mode) {
case notify.ModeOff, notify.ModeBell, notify.ModeNotify, notify.ModeBoth:
default:
return NotifyConfig{}, fmt.Errorf("invalid notify.mode %q: expected off, bell, notify, or both", mode)
}
}
if focus := value.FocusMode; focus != "" {
switch notify.FocusMode(focus) {
case notify.FocusUnfocused, notify.FocusAlways, notify.FocusFocused:
default:
return NotifyConfig{}, fmt.Errorf("invalid notify.focusMode %q: expected unfocused, always, or focused", focus)
}
}
return value, nil
}
// UpdateNotify runs one SERIALIZED read-modify-write transaction on the user's
// notify block. merge receives the currently stored block and returns the
// block to store; it must preserve any field the caller is not explicitly
// changing. The lock covers reading the stored block, applying merge, and
// replacing the file, so two concurrent partial updates (for example
// `--mode off` and `--focus always` from two terminals) cannot interleave
// their reads and writes and silently undo each other's explicit change
// (maintainer review, PR #1001 reproduced a 50/50 lost-update without it).
//
// The write replaces ONLY the notify member's bytes (setNotifyJSONObject),
// preserving every unrelated value and its explicit presence — including
// explicit zeros/falses like tools.deferThreshold: 0 or an MCP server's
// disabled: false, which the typed serializer's omitempty cannot round-trip —
// through the existing temp-file-and-rename atomic publish. Locking only the
// final write would leave the stale-field merge outside the transaction, so
// the whole sequence runs under one lock — the SAME lockConfigFileFn authority
// every other config mutator uses (provider/theme/credential/MCP writes), not
// a private lock: two authorities over the same file would not contend
// (maintainer review, PR #1001). The release error is joined into the
// result like every other writer: a failed unlock would leave the advisory
// lock held and later updates blocked while the caller is told success.
func UpdateNotify(path string, merge func(current NotifyConfig) NotifyConfig) (result NotifyConfig, err error) {
path = strings.TrimSpace(path)
if path == "" {
return NotifyConfig{}, fmt.Errorf("config path is required")
}
if merge == nil {
return NotifyConfig{}, fmt.Errorf("merge function is required")
}
release, err := lockConfigFileFn(path)
if err != nil {
return NotifyConfig{}, 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 release would claim a state the
// next update cannot reproduce.
defer func() { err = errors.Join(err, release()) }()
current, err := UserNotify(path)
if err != nil {
return NotifyConfig{}, err
}
next, err := validateNotify(merge(current))
if err != nil {
return NotifyConfig{}, err
}
// Read the raw file and edit only the notify member. A missing file starts
// from an empty object so a first-time write does not need a pre-seeded
// config.
data, err := os.ReadFile(path)
if err != nil {
if !os.IsNotExist(err) {
return NotifyConfig{}, fmt.Errorf("read config %s: %w", path, err)
}
data = []byte("{}")
}
updated, err := setNotifyJSONObject(data, next)
if err != nil {
return NotifyConfig{}, fmt.Errorf("invalid config JSON %s: %w", path, err)
}
if err := writeConfigData(path, updated); err != nil {
return NotifyConfig{}, err
}
return next, nil
}
// SetPet persists only the terminal-pet preference while preserving every
// unrelated user setting through the config writer's atomic replace path.
func SetPet(path string, pet string) (result FileConfig, err error) {
@@ -973,12 +1097,116 @@ func writeConfigFile(path string, cfg FileConfig) error {
return writeConfigData(path, data)
}
// Sync seams allow tests to inject persistence failures at each barrier.
var syncConfigFileFn = (*os.File).Sync
var syncConfigDirFn = syncConfigDir
// Windows cannot sync directory handles, so rename durability is best effort
// there, matching the sessions store. Elsewhere a directory the user can write
// but not read cannot be opened for syncing either; that is also best effort,
// because the write it follows has already succeeded. Every other failure to
// open, sync, or close the directory is reported: the barrier was requested and
// not reached, so the caller must not acknowledge the save.
func syncConfigDir(dir string) error {
if runtime.GOOS == "windows" {
return nil
}
d, err := os.Open(dir)
if err != nil {
if errors.Is(err, os.ErrPermission) {
return nil
}
return err
}
return errors.Join(d.Sync(), d.Close())
}
// ensureConfigDir is the ONE path that creates the config directory, shared by
// lockConfigFile and writeConfigData. Every exported mutator locks before it
// writes, and the lock file is a sibling of the config, so in practice the lock
// path is what first creates the directory; a barrier that lived only in
// writeConfigData would find nothing missing by then and never run.
//
// Each missing component is created with os.Mkdir rather than os.MkdirAll so
// the call knows exactly which entries it created, then each new entry is
// synced into its parent (deepest first) so a crash cannot lose the directory,
// and with it the config, after a successful write. A component that appeared
// between the lookup and the Mkdir belongs to another writer: it is not rolled
// back, but its parent is still synced, because this write depends on the
// entry being durable whoever created it.
//
// If a barrier fails, the directories this call created are removed again.
// Leaving them would let a retry find nothing missing, skip the barrier, and
// report a durability it never established. Only what this call created is
// removed, with os.Remove so a directory another process has since populated
// stays; a failed rollback is joined into the error, never swallowed.
func ensureConfigDir(dir string) error {
if dir == "." || dir == "" {
return nil
}
missing := missingConfigDirs(dir)
if len(missing) == 0 {
return nil
}
var created []string
for i := len(missing) - 1; i >= 0; i-- {
d := missing[i]
err := os.Mkdir(d, 0o700)
if errors.Is(err, os.ErrExist) {
continue
}
if err != nil {
return errors.Join(fmt.Errorf("create config directory %s: %w", d, err), removeConfigDirs(created))
}
created = append(created, d)
}
for _, d := range missing {
parent := filepath.Dir(d)
if err := syncConfigDirFn(parent); err != nil {
return errors.Join(fmt.Errorf("sync config directory %s: %w", parent, err), removeConfigDirs(created))
}
}
return nil
}
// removeConfigDirs rolls back directories ensureConfigDir created in this
// call, deepest first. os.Remove refuses a non-empty directory, so an entry
// another process already populated survives and its error is reported.
func removeConfigDirs(created []string) error {
var errs []error
for i := len(created) - 1; i >= 0; i-- {
if err := os.Remove(created[i]); err != nil {
errs = append(errs, fmt.Errorf("roll back config directory %s: %w", created[i], err))
}
}
return errors.Join(errs...)
}
// missingConfigDirs lists dir and each ancestor that does not exist yet,
// deepest first, so ensureConfigDir can create them shallowest first and sync
// each new entry into its parent deepest first.
func missingConfigDirs(dir string) []string {
var missing []string
for d := dir; ; {
if _, err := os.Lstat(d); !errors.Is(err, os.ErrNotExist) {
return missing
}
missing = append(missing, d)
parent := filepath.Dir(d)
if parent == d {
return missing
}
d = parent
}
}
func writeConfigData(path string, data []byte) error {
dir := filepath.Dir(path)
if dir != "." && dir != "" {
if err := os.MkdirAll(dir, 0o700); err != nil {
return fmt.Errorf("create config directory %s: %w", dir, err)
}
// Usually a no-op: the lock path has already created and synced the
// directory. It still matters for the rare unlocked caller and for a
// directory removed between the lock and the write.
if err := ensureConfigDir(dir); err != nil {
return err
}
if len(data) == 0 || data[len(data)-1] != '\n' {
data = append(data, '\n')
@@ -999,11 +1227,19 @@ func writeConfigData(path string, data []byte) error {
_ = tmp.Close()
return fmt.Errorf("write config %s: %w", path, err)
}
// Persist the complete contents before making the replacement visible.
if err := syncConfigFileFn(tmp); err != nil {
return fmt.Errorf("sync config %s: %w", path, errors.Join(err, tmp.Close()))
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("write config %s: %w", path, err)
}
if err := os.Rename(tmpPath, path); err != nil {
return fmt.Errorf("write config %s: %w", path, err)
}
// The replacement is already visible if this fails; do not roll it back.
if err := syncConfigDirFn(dir); err != nil {
return fmt.Errorf("sync config directory %s: %w", dir, err)
}
return nil
}
+678
View File
@@ -3,6 +3,8 @@ package config
import (
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"os"
"os/exec"
@@ -10,9 +12,299 @@ import (
"reflect"
"runtime"
"strings"
"sync"
"testing"
)
func TestWriteConfigDataSync(t *testing.T) {
failure := errors.New("injected sync failure")
for _, stage := range []string{"success", "file", "directory"} {
t.Run(stage, func(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.json")
oldData, newData := "{\"old\":true}\n", "{\"new\":true}\n"
if err := os.WriteFile(path, []byte(oldData), 0o600); err != nil {
t.Fatal(err)
}
checkData := func(path, want string) {
t.Helper()
got, err := os.ReadFile(path)
if err != nil || string(got) != want {
t.Fatalf("read %s = %q, %v; want %q", path, got, err, want)
}
}
fileSync, dirSync := syncConfigFileFn, syncConfigDirFn
t.Cleanup(func() { syncConfigFileFn, syncConfigDirFn = fileSync, dirSync })
var tmp *os.File
var calls []string
syncConfigFileFn = func(f *os.File) error {
tmp = f
calls = append(calls, "file")
checkData(f.Name(), newData)
checkData(path, oldData)
if stage == "file" {
return failure
}
return fileSync(f)
}
syncConfigDirFn = func(got string) error {
calls = append(calls, "directory")
if got != dir || tmp == nil {
t.Fatalf("directory sync = %q, temp = %v", got, tmp)
}
if _, err := tmp.Seek(0, io.SeekCurrent); !errors.Is(err, os.ErrClosed) {
t.Fatalf("temp must be closed before directory sync: %v", err)
}
checkData(path, newData)
if stage == "directory" {
return failure
}
return dirSync(got)
}
err := writeConfigData(path, []byte(strings.TrimSuffix(newData, "\n")))
if stage == "success" {
if err != nil {
t.Fatal(err)
}
} else if !errors.Is(err, failure) {
t.Fatalf("error = %v, want injected sync failure", err)
}
wantCalls := []string{"file", "directory"}
wantData := newData
if stage == "file" {
wantCalls, wantData = []string{"file"}, oldData
}
if !reflect.DeepEqual(calls, wantCalls) {
t.Fatalf("sync calls = %v, want %v", calls, wantCalls)
}
checkData(path, wantData)
if _, err := tmp.Seek(0, io.SeekCurrent); !errors.Is(err, os.ErrClosed) {
t.Fatalf("temp must be closed on return: %v", err)
}
entries, err := os.ReadDir(dir)
if err != nil || len(entries) != 1 || entries[0].Name() != "config.json" {
t.Fatalf("temporary file not cleaned up: %v, %v", entries, err)
}
})
}
}
func TestWriteConfigDataSyncsCreatedDirectories(t *testing.T) {
root := t.TempDir()
outer := filepath.Join(root, "a")
dir := filepath.Join(outer, "b")
path := filepath.Join(dir, "config.json")
failure := errors.New("injected sync failure")
dirSync := syncConfigDirFn
t.Cleanup(func() { syncConfigDirFn = dirSync })
for _, fail := range []bool{false, true} {
var calls []string
syncConfigDirFn = func(got string) error {
calls = append(calls, got)
if fail && got == root {
return failure
}
return dirSync(got)
}
if err := os.RemoveAll(outer); err != nil {
t.Fatal(err)
}
err := writeConfigData(path, []byte(`{}`))
if fail {
if !errors.Is(err, failure) {
t.Fatalf("error = %v, want injected sync failure", err)
}
if want := []string{outer, root}; !reflect.DeepEqual(calls, want) {
t.Fatalf("sync calls = %v, want %v", calls, want)
}
if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("config must not be written after a failed directory sync: %v", err)
}
continue
}
if err != nil {
t.Fatal(err)
}
// Each new directory's entry is synced into its parent, then the
// config's own directory after the rename.
if want := []string{outer, root, dir}; !reflect.DeepEqual(calls, want) {
t.Fatalf("sync calls = %v, want %v", calls, want)
}
}
}
func TestLockingMutatorSyncsCreatedDirectories(t *testing.T) {
root := t.TempDir()
outer := filepath.Join(root, "a")
dir := filepath.Join(outer, "b")
path := filepath.Join(dir, "config.json")
failure := errors.New("injected sync failure")
dirSync := syncConfigDirFn
t.Cleanup(func() { syncConfigDirFn = dirSync })
exists := func(p string) bool {
t.Helper()
_, err := os.Lstat(p)
if err != nil && !errors.Is(err, os.ErrNotExist) {
t.Fatal(err)
}
return err == nil
}
// Every exported mutator acquires the config lock before it writes, and
// the lock file is a sibling of the config, so the lock path is what first
// creates the config directory. The barrier for those new entries has to
// run there, before the lock file exists: by the time writeConfigData
// looks, nothing is missing any more.
for _, fail := range []bool{true, false} {
var calls []string
syncConfigDirFn = func(got string) error {
calls = append(calls, got)
if got == outer || got == root {
if exists(path+".lock") || exists(path) {
t.Fatalf("parent sync of %s ran after the lock file or config was created", got)
}
}
if fail && got == root {
return failure
}
return dirSync(got)
}
_, err := UpdateNotify(path, func(NotifyConfig) NotifyConfig {
return NotifyConfig{Mode: "off"}
})
if fail {
if !errors.Is(err, failure) {
t.Fatalf("error = %v, want injected sync failure", err)
}
if want := []string{outer, root}; !reflect.DeepEqual(calls, want) {
t.Fatalf("sync calls = %v, want %v", calls, want)
}
// The directories this attempt created are gone again, so the
// retry below re-creates them and re-runs the barrier instead of
// finding them present and reporting a durability it never had.
if exists(outer) {
t.Fatalf("%s must be rolled back after a failed parent sync", outer)
}
continue
}
if err != nil {
t.Fatal(err)
}
if want := []string{outer, root, dir}; !reflect.DeepEqual(calls, want) {
t.Fatalf("sync calls = %v, want %v", calls, want)
}
got, err := UserNotify(path)
if err != nil || got.Mode != "off" {
t.Fatalf("UserNotify = %+v, %v; want mode off", got, err)
}
}
}
func TestWriteConfigDataRollsBackOnlyCreatedDirectories(t *testing.T) {
root := t.TempDir()
existing := filepath.Join(root, "existing")
if err := os.Mkdir(existing, 0o700); err != nil {
t.Fatal(err)
}
keep := filepath.Join(existing, "keep")
if err := os.WriteFile(keep, []byte("keep\n"), 0o600); err != nil {
t.Fatal(err)
}
outer := filepath.Join(existing, "a")
dir := filepath.Join(outer, "b")
path := filepath.Join(dir, "config.json")
failure := errors.New("injected sync failure")
dirSync := syncConfigDirFn
t.Cleanup(func() { syncConfigDirFn = dirSync })
stat := func(p string) error {
_, err := os.Lstat(p)
return err
}
// The barrier for "a" fails: both directories this call created go, the
// pre-existing directory and its file stay.
syncConfigDirFn = func(got string) error {
if got == existing {
return failure
}
return dirSync(got)
}
if err := writeConfigData(path, []byte(`{}`)); !errors.Is(err, failure) {
t.Fatalf("error = %v, want injected sync failure", err)
}
for _, p := range []string{dir, outer} {
if err := stat(p); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("%s must be removed after a failed parent sync: %v", p, err)
}
}
for _, p := range []string{existing, keep} {
if err := stat(p); err != nil {
t.Fatalf("pre-existing %s must survive the rollback: %v", p, err)
}
}
// A directory another process populated in the meantime is not ours to
// remove: it stays, the failed rollback is reported with the barrier
// failure, and the write still does not acknowledge the save.
intruder := filepath.Join(dir, "other.lock")
syncConfigDirFn = func(got string) error {
if got == existing {
if err := os.WriteFile(intruder, nil, 0o600); err != nil {
t.Fatal(err)
}
return failure
}
return dirSync(got)
}
err := writeConfigData(path, []byte(`{}`))
if !errors.Is(err, failure) {
t.Fatalf("error = %v, want injected sync failure", err)
}
if err := stat(intruder); err != nil {
t.Fatalf("a populated directory must not be removed: %v", err)
}
if !strings.Contains(err.Error(), "roll back config directory") {
t.Fatalf("error must report the failed rollback: %v", err)
}
if err := stat(path); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("config must not be written after a failed directory sync: %v", err)
}
}
func TestSyncConfigDir(t *testing.T) {
dir := t.TempDir()
if err := syncConfigDir(dir); err != nil {
t.Fatal(err)
}
err := syncConfigDir(filepath.Join(dir, "missing"))
if runtime.GOOS == "windows" {
// Directory handles cannot be synced on Windows, so every call is a
// no-op there, including one for a directory that does not exist.
if err != nil {
t.Fatalf("syncConfigDir must be a no-op on windows: %v", err)
}
return
}
// A directory that is gone means the persistence barrier was never
// reached, so the caller must not acknowledge the save.
if !errors.Is(err, os.ErrNotExist) {
t.Fatalf("missing directory: error = %v, want %v", err, os.ErrNotExist)
}
// A directory the user can write but not read cannot be opened for
// syncing; that is best effort, as in the sessions store, because the
// write it follows has already succeeded.
unreadable := filepath.Join(dir, "unreadable")
if err := os.Mkdir(unreadable, 0o300); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chmod(unreadable, 0o700) })
if err := syncConfigDir(unreadable); err != nil {
t.Fatalf("write-only directory must be best effort: %v", err)
}
}
func TestSetActiveProviderSwitchesConfiguredProvider(t *testing.T) {
path := filepath.Join(t.TempDir(), "zero.json")
writeConfigFixture(t, path, FileConfig{
@@ -426,6 +718,286 @@ func TestSetThemePreservesUnknownTopLevelFields(t *testing.T) {
}
}
// setNotify replaces the stored notify block with value through UpdateNotify
// and returns the re-read config, so the tests below can assert on a
// FileConfig-shaped result. Every product surface uses UpdateNotify's merge
// form directly; this replace form exists only for these tests.
func setNotify(path string, value NotifyConfig) (FileConfig, error) {
next, err := UpdateNotify(path, func(NotifyConfig) NotifyConfig { return value })
if err != nil {
return FileConfig{}, err
}
cfg, err := loadConfigFile(path)
if err != nil {
return FileConfig{}, err
}
cfg.Notify = next
return cfg, nil
}
func TestSetNotifyPersistsValidValues(t *testing.T) {
path := filepath.Join(t.TempDir(), "zero.json")
writeConfigFixture(t, path, FileConfig{
ActiveProvider: "openai",
Providers: []ProviderProfile{
{Name: "openai", ProviderKind: ProviderKindOpenAI, Model: "gpt-4.1"},
},
}, 0o600)
cfg, err := setNotify(path, NotifyConfig{Mode: " both ", FocusMode: " unfocused "})
if err != nil {
t.Fatalf("setNotify() error = %v", err)
}
if cfg.Notify.Mode != "both" || cfg.Notify.FocusMode != "unfocused" {
t.Fatalf("Notify = %+v, want mode=both focusMode=unfocused (trimmed)", cfg.Notify)
}
persisted := readConfigFixture(t, path)
if persisted.Notify.Mode != "both" || persisted.Notify.FocusMode != "unfocused" {
t.Fatalf("persisted Notify = %+v, want mode=both focusMode=unfocused", persisted.Notify)
}
if persisted.ActiveProvider != "openai" || len(persisted.Providers) != 1 {
t.Fatalf("provider config was not preserved by setNotify: %#v", persisted)
}
}
func TestSetNotifyRejectsInvalidMode(t *testing.T) {
path := filepath.Join(t.TempDir(), "zero.json")
writeConfigFixture(t, path, FileConfig{ActiveProvider: "openai"}, 0o600)
if _, err := setNotify(path, NotifyConfig{Mode: "loud", FocusMode: "unfocused"}); err == nil {
t.Fatal("expected error for invalid notify.mode")
}
}
func TestSetNotifyRejectsInvalidFocusMode(t *testing.T) {
path := filepath.Join(t.TempDir(), "zero.json")
writeConfigFixture(t, path, FileConfig{ActiveProvider: "openai"}, 0o600)
if _, err := setNotify(path, NotifyConfig{Mode: "off", FocusMode: "sideways"}); err == nil {
t.Fatal("expected error for invalid notify.focusMode")
}
}
func TestSetNotifyRejectsEmptyConfigPath(t *testing.T) {
if _, err := setNotify("", NotifyConfig{Mode: "off"}); err == nil {
t.Fatal("expected error for empty config path")
}
}
func TestSetNotifyBlankValuesPreservedAsDefaults(t *testing.T) {
// An empty mode/focusMode stored on disk is a valid "use the built-in
// defaults" signal — setNotify must not reject blanks, and they must round
// trip unchanged.
path := filepath.Join(t.TempDir(), "zero.json")
writeConfigFixture(t, path, FileConfig{ActiveProvider: "openai"}, 0o600)
if _, err := setNotify(path, NotifyConfig{}); err != nil {
t.Fatalf("setNotify({}) should accept blank values, got error: %v", err)
}
persisted := readConfigFixture(t, path)
if persisted.Notify.Mode != "" || persisted.Notify.FocusMode != "" {
t.Fatalf("blank notify values should round-trip, got %+v", persisted.Notify)
}
}
// Maintainer regression (PR #1001): a notification save must preserve unrelated
// values whose EXPLICIT presence matters. The typed serializer's omitempty
// cannot round-trip tools.deferThreshold: 0 (explicit "never defer" vs unset
// "use default 3") or an MCP server's disabled: false (explicitly enabled), so
// UpdateNotify edits only the notify member's bytes instead. The
// assertions run against the RAW file, because reading through FileConfig
// would hide exactly the presence loss being tested.
func TestSetNotifyPreservesExplicitUnrelatedValues(t *testing.T) {
dir := t.TempDir()
for name, body := range map[string]string{
"explicit zero deferThreshold": `{
"tools": {"deferThreshold": 0},
"mcp": {"servers": {"firecrawl": {"command": "npx", "disabled": false}}}
}`,
"unknown keys stay untouched": `{
"activeProvider": "openai",
"customTopLevel": {"nested": [1, 2, 3]},
"notify": {"mode": "both"}
}`,
} {
t.Run(name, func(t *testing.T) {
path := filepath.Join(dir, name+".json")
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatal(err)
}
if _, err := setNotify(path, NotifyConfig{Mode: "off", FocusMode: "always"}); err != nil {
t.Fatalf("setNotify: %v", err)
}
raw, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
var obj map[string]json.RawMessage
if err := json.Unmarshal(raw, &obj); err != nil {
t.Fatalf("decode: %v", err)
}
if strings.Contains(name, "explicit zero") {
tools, ok := obj["tools"]
if !ok || !strings.Contains(string(tools), `"deferThreshold": 0`) {
t.Errorf("tools.deferThreshold: 0 lost through a notify write; tools = %s", string(tools))
}
mcp, ok := obj["mcp"]
if !ok || !strings.Contains(string(mcp), `"disabled": false`) {
t.Errorf("mcp disabled: false lost through a notify write; mcp = %s", string(mcp))
}
}
if strings.Contains(name, "unknown keys") {
if _, ok := obj["customTopLevel"]; !ok {
t.Error("unknown top-level key lost through a notify write")
}
}
})
}
}
// Maintainer regression (PR #1001, CodeRabbit follow-up): a hand-edited config
// may contain DUPLICATE notify members (JSON decoders tolerate them and the
// last occurrence wins). A reset must remove EVERY notify member — removing
// only the last leaves the earlier block as the new effective preference, so
// `zero config notify --reset` would report success while the old value still
// applies.
func TestSetNotifyResetRemovesEveryDuplicateNotifyMember(t *testing.T) {
path := filepath.Join(t.TempDir(), "zero.json")
duplicated := `{"notify":{"mode":"off"},"activeProvider":"openai","notify":{"mode":"bell"}}`
if err := os.WriteFile(path, []byte(duplicated), 0o600); err != nil {
t.Fatal(err)
}
if _, err := setNotify(path, NotifyConfig{}); err != nil {
t.Fatalf("setNotify({}) reset: %v", err)
}
raw, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
var obj map[string]json.RawMessage
if err := json.Unmarshal(raw, &obj); err != nil {
t.Fatalf("decode: %v", err)
}
if _, still := obj["notify"]; still {
t.Fatalf("reset left a notify member behind: %s", string(raw))
}
if obj["activeProvider"] == nil || string(obj["activeProvider"]) != `"openai"` {
t.Errorf("unrelated member lost through the reset: %s", string(raw))
}
// A reset on a file with NO notify member is a no-op, and a partial update
// against duplicates replaces the LAST member (the effective one under
// last-occurrence-wins decoding).
if err := os.WriteFile(path, []byte(duplicated), 0o600); err != nil {
t.Fatal(err)
}
if _, err := setNotify(path, NotifyConfig{Mode: "off", FocusMode: "always"}); err != nil {
t.Fatalf("setNotify replace: %v", err)
}
raw, err = os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
stored, err := UserNotify(path)
if err != nil {
t.Fatalf("UserNotify: %v", err)
}
if stored.Mode != "off" || stored.FocusMode != "always" {
t.Fatalf("after replace, effective notify = %+v, want off/always (last member replaced)", stored)
}
if !strings.Contains(string(raw), `"activeProvider"`) {
t.Errorf("unrelated member lost through the replace: %s", string(raw))
}
}
// Maintainer regression (PR #1001): concurrent partial updates must not lose
// each other's explicit change. Two `zero config notify` calls (--mode off and
// --focus always) racing from both/unfocused previously interleaved their
// read-merge-write and one write silently undid the other (reproduced 50/50
// without the lock). UpdateNotify serializes the whole transaction.
func TestUpdateNotifyConcurrentPartialUpdatesLoseNoField(t *testing.T) {
path := filepath.Join(t.TempDir(), "zero.json")
if err := os.WriteFile(path, []byte(`{"notify":{"mode":"both","focusMode":"unfocused"}}`), 0o600); err != nil {
t.Fatal(err)
}
var wg sync.WaitGroup
errs := make(chan error, 2)
wg.Add(2)
go func() {
defer wg.Done()
if _, err := UpdateNotify(path, func(current NotifyConfig) NotifyConfig {
current.Mode = "off"
return current
}); err != nil {
errs <- fmt.Errorf("mode update: %w", err)
}
}()
go func() {
defer wg.Done()
if _, err := UpdateNotify(path, func(current NotifyConfig) NotifyConfig {
current.FocusMode = "always"
return current
}); err != nil {
errs <- fmt.Errorf("focus update: %w", err)
}
}()
wg.Wait()
close(errs)
for err := range errs {
t.Fatalf("concurrent update failed: %v", err)
}
stored, err := UserNotify(path)
if err != nil {
t.Fatalf("read stored: %v", err)
}
// BOTH explicit changes must survive: mode from writer 1, focus from
// writer 2. The old race let the second writer's stale focus
// ("unfocused") or stale mode ("both") win.
if stored.Mode != "off" {
t.Errorf("mode = %q, want off (concurrent focus update must not clobber it)", stored.Mode)
}
if stored.FocusMode != "always" {
t.Errorf("focusMode = %q, want always (concurrent mode update must not clobber it)", stored.FocusMode)
}
}
// UserNotify reads the notify block from the user's own file. Partial updates
// seed from this value so they preserve what the USER chose (blank included)
// instead of copying a project config's setting or a pinned default into the
// global file (maintainer review, PR #1001).
func TestUserNotify(t *testing.T) {
dir := t.TempDir()
// Missing file: zero value, no error.
got, err := UserNotify(filepath.Join(dir, "missing.json"))
if err != nil {
t.Fatalf("missing file should not error: %v", err)
}
if got.Mode != "" || got.FocusMode != "" {
t.Fatalf("missing file = %+v, want zero value", got)
}
// Present block: trimmed values returned.
path := filepath.Join(dir, "zero.json")
writeConfigFixture(t, path, FileConfig{Notify: NotifyConfig{Mode: " bell ", FocusMode: " always "}}, 0o600)
got, err = UserNotify(path)
if err != nil {
t.Fatalf("UserNotify: %v", err)
}
if got.Mode != "bell" || got.FocusMode != "always" {
t.Fatalf("UserNotify = %+v, want bell/always (trimmed)", got)
}
// Blank path: zero value, no error.
if got, err = UserNotify(""); err != nil || got.Mode != "" || got.FocusMode != "" {
t.Fatalf("blank path = %+v err=%v, want zero value", got, err)
}
}
func TestRecapsPreferenceRoundTrips(t *testing.T) {
// Default (unset) is ON.
if !(PreferencesConfig{}).RecapsEnabled() {
@@ -1078,3 +1650,109 @@ func TestEditProviderRejectsCollisionAndUnknown(t *testing.T) {
t.Fatalf("config was rewritten by a rejected edit")
}
}
// Maintainer regression (PR #1001): notify writes must contend with EXISTING
// config mutators, not only each other. UpdateNotify originally took a private
// lock (acquireConfigLock); on Windows its LockFileEx range did not overlap
// lockutil's, so `zero config notify --mode off` could race a theme (or
// provider/credential/MCP) write and silently discard it — the later rename
// wins and the other acknowledged change is gone. UpdateNotify now uses the
// same lockConfigFileFn authority as every other mutator; this race holds it
// against SetTheme and requires BOTH acknowledged edits to survive.
func TestUpdateNotifyRacesExistingConfigMutatorWithoutLoss(t *testing.T) {
path := filepath.Join(t.TempDir(), "zero.json")
if err := os.WriteFile(path, []byte(`{"notify":{"mode":"both","focusMode":"unfocused"}}`), 0o600); err != nil {
t.Fatal(err)
}
var wg sync.WaitGroup
errs := make(chan error, 2)
wg.Add(2)
go func() {
defer wg.Done()
if _, err := UpdateNotify(path, func(NotifyConfig) NotifyConfig {
return NotifyConfig{Mode: "off", FocusMode: "always"}
}); err != nil {
errs <- fmt.Errorf("notify write: %w", err)
}
}()
go func() {
defer wg.Done()
if _, err := SetTheme(path, "dracula"); err != nil {
errs <- fmt.Errorf("theme write: %w", err)
}
}()
wg.Wait()
close(errs)
for err := range errs {
t.Fatal(err)
}
cfg := readConfigFixture(t, path)
if cfg.Notify.Mode != "off" || cfg.Notify.FocusMode != "always" {
t.Errorf("notify lost the race: %+v, want off/always — the two writers did not contend on one lock", cfg.Notify)
}
if cfg.Preferences.Theme != "dracula" {
t.Errorf("theme lost the race: %q, want dracula — the two writers did not contend on one lock", cfg.Preferences.Theme)
}
}
// Maintainer regression (PR #1001, jatmn review): Go's decoder MERGES the
// fields of duplicate JSON members into one struct, so a full replacement that
// omits a field could inherit it from an earlier duplicate —
// {"notify":{"mode":"off"},"notify":{"focusMode":"focused"}} +
// setNotify({FocusMode:"always"}) must yield blank/always, not off/always.
// The editor removes every duplicate and inserts the replacement fresh.
func TestSetNotifyReplacementInheritsNoFieldFromEarlierDuplicate(t *testing.T) {
path := filepath.Join(t.TempDir(), "zero.json")
duplicated := `{"notify":{"mode":"off"},"notify":{"focusMode":"focused"},"activeProvider":"openai"}`
if err := os.WriteFile(path, []byte(duplicated), 0o600); err != nil {
t.Fatal(err)
}
if _, err := setNotify(path, NotifyConfig{FocusMode: "always"}); err != nil {
t.Fatalf("setNotify: %v", err)
}
stored, err := UserNotify(path)
if err != nil {
t.Fatal(err)
}
if stored.Mode != "" || stored.FocusMode != "always" {
t.Fatalf("replacement inherited a field from an earlier duplicate: %+v, want blank/always", stored)
}
// Unrelated bytes still survive the rewrite.
raw, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(raw), `"activeProvider":"openai"`) {
t.Fatalf("unrelated member lost through the replace: %s", string(raw))
}
}
// Maintainer regression (PR #1001, Vasanthdev2004 review): the duplicate-key
// fix must not reach the ORDINARY single-member file. Removing and re-inserting
// the notify member there moved the key to the end of the object and collapsed
// its formatting, so every /notify toggle reordered the user's config — JSON
// member order carries no meaning, but a byte-preserving write should not
// reformat anything it does not have to. A single notify member is replaced
// in place: key position and surrounding formatting unchanged.
func TestSetNotifySingleMemberReplacedInPlace(t *testing.T) {
path := filepath.Join(t.TempDir(), "zero.json")
original := "{\n \"activeProvider\": \"openai\",\n \"notify\": {\"mode\": \"both\", \"focusMode\": \"unfocused\"},\n \"preferences\": {\"theme\": \"dracula\"}\n}\n"
if err := os.WriteFile(path, []byte(original), 0o600); err != nil {
t.Fatal(err)
}
if _, err := setNotify(path, NotifyConfig{Mode: "off", FocusMode: "always"}); err != nil {
t.Fatalf("setNotify: %v", err)
}
raw, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
want := "{\n \"activeProvider\": \"openai\",\n \"notify\": {\"mode\":\"off\",\"focusMode\":\"always\"},\n \"preferences\": {\"theme\": \"dracula\"}\n}\n"
if string(raw) != want {
t.Fatalf("single-member replace moved or reformatted unrelated bytes:\n got: %q\nwant: %q", string(raw), want)
}
}
+305 -1
View File
@@ -1,10 +1,61 @@
package cron
import (
"bytes"
"encoding/json"
"errors"
"os"
"path/filepath"
"runtime"
"strings"
"sync"
"syscall"
"testing"
"github.com/Gitlawb/zero/internal/fsutil"
)
func TestAppendRunRetainsNewestThousand(t *testing.T) {
store := newTestStore(t)
job, err := store.Add(Job{Expr: "* * * * *", Prompt: "x"})
if err != nil {
t.Fatal(err)
}
// Seed an oversized legacy log, then cross the boundary again after compaction.
var history bytes.Buffer
for i := 0; i < 1207; i++ {
if err := json.NewEncoder(&history).Encode(RunRecord{ExitCode: i}); err != nil {
t.Fatal(err)
}
}
path := filepath.Join(store.jobDir(job.ID), "runs.jsonl")
if err := os.WriteFile(path, history.Bytes(), 0o600); err != nil {
t.Fatal(err)
}
for _, next := range []int{1207, 1208} {
if err := store.AppendRun(job.ID, RunRecord{ExitCode: next}); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
lines := bytes.Split(bytes.TrimSpace(data), []byte("\n"))
if len(lines) != 1000 {
t.Fatalf("retained %d records, want 1000", len(lines))
}
for i, line := range lines {
var rec RunRecord
if err := json.Unmarshal(line, &rec); err != nil {
t.Fatal(err)
}
if rec.ExitCode != next-999+i {
t.Fatalf("record %d = %d, want %d", i, rec.ExitCode, next-999+i)
}
}
}
}
// AppendRun on a job removed mid-run must NOT resurrect its directory (which the
// old unconditional MkdirAll did, leaving an orphaned runs.jsonl with no
// metadata.json).
@@ -24,7 +75,7 @@ func TestAppendRunDoesNotResurrectRemovedJob(t *testing.T) {
if _, err := os.Stat(store.jobDir(job.ID)); !os.IsNotExist(err) {
t.Fatalf("AppendRun resurrected the removed job directory (stat err = %v)", err)
}
runs, err := store.Runs(job.ID)
runs, err := store.Runs(job.ID, 0)
if err != nil {
t.Fatalf("Runs: %v", err)
}
@@ -32,3 +83,256 @@ func TestAppendRunDoesNotResurrectRemovedJob(t *testing.T) {
t.Fatalf("expected no runs for a removed job, got %d", len(runs))
}
}
func TestRunsTail(t *testing.T) {
store := newTestStore(t)
job, err := store.Add(Job{Prompt: "x"})
if err != nil {
t.Fatal(err)
}
var history bytes.Buffer
// A forward scan would fail on this old oversized line. A recent-window
// reader must stop before reaching it, rather than merely cap its output.
history.WriteString(strings.Repeat("x", 1024*1024+1) + "\n")
for i := 0; i < 1103; i++ {
if err := json.NewEncoder(&history).Encode(RunRecord{ExitCode: i, SessionTitle: strings.Repeat("界", 1500)}); err != nil {
t.Fatal(err)
}
}
history.WriteString("bad json\n\n{\"exitCode\":1103}") // no final newline
path := filepath.Join(store.jobDir(job.ID), "runs.jsonl")
if err := os.WriteFile(path, history.Bytes(), 0o600); err != nil {
t.Fatal(err)
}
for _, limit := range []int{1, 7, 999, 1000, 1001, 0, -1} {
runs, err := store.Runs(job.ID, limit)
want := limit
if want <= 0 || want > 1000 {
want = 1000
}
if err != nil || len(runs) != want {
t.Fatalf("limit %d: got %d records, err %v", limit, len(runs), err)
}
for i, run := range runs {
if run.ExitCode != 1104-want+i {
t.Fatalf("limit %d, record %d = %d", limit, i, run.ExitCode)
}
if run.ExitCode != 1103 && run.SessionTitle != strings.Repeat("界", 1500) {
t.Fatal("record spanning read blocks was corrupted")
}
}
}
}
func TestAppendRunFailurePreservesHistory(t *testing.T) {
store := newTestStore(t)
job, err := store.Add(Job{Prompt: "x"})
if err != nil {
t.Fatal(err)
}
path := filepath.Join(store.jobDir(job.ID), "runs.jsonl")
for _, data := range []string{"{\"exitCode\":7}\n", strings.Repeat("x", 1024*1024)} {
if err := os.WriteFile(path, []byte(data), 0o600); err != nil {
t.Fatal(err)
}
rec := RunRecord{ExitCode: 8, Error: strings.Repeat("x", 1024*1024)}
if err := store.AppendRun(job.ID, rec); err == nil {
t.Fatal("expected oversized new record error")
}
got, err := os.ReadFile(path)
if err != nil || string(got) != data {
t.Fatalf("failed append changed history: %v", err)
}
}
}
func TestAppendRunWithOversizedHistory(t *testing.T) {
store := newTestStore(t)
job, err := store.Add(Job{Prompt: "x"})
if err != nil {
t.Fatal(err)
}
path := filepath.Join(store.jobDir(job.ID), "runs.jsonl")
// An oversized unterminated legacy record must not block future outcomes.
if err := os.WriteFile(path, []byte(strings.Repeat("x", maxRunBytes)), 0o600); err != nil {
t.Fatal(err)
}
if err := store.AppendRun(job.ID, RunRecord{ExitCode: 42}); err != nil {
t.Fatal(err)
}
runs, err := store.Runs(job.ID, 1)
if err != nil || len(runs) != 1 || runs[0].ExitCode != 42 {
t.Fatalf("new outcome lost: %+v, %v", runs, err)
}
}
func TestRunsSkipOversizedLines(t *testing.T) {
store := newTestStore(t)
job, err := store.Add(Job{Prompt: "x"})
if err != nil {
t.Fatal(err)
}
path := filepath.Join(store.jobDir(job.ID), "runs.jsonl")
for _, size := range []int{maxRunBytes - 1, maxRunBytes, maxRunBytes + 4096} {
// Valid JSON plus trailing whitespace catches accidental decoding of a
// prefix after dropping only the oversized suffix. The exact boundary
// also distinguishes an oversized line from the largest allowed line.
line := `{"exitCode":99}`
line += strings.Repeat(" ", size-len(line))
data := line + "\n{\"exitCode\":7}\n" + line + "\n{\"exitCode\":42}\n" + line
if err := os.WriteFile(path, []byte(data), 0o600); err != nil {
t.Fatal(err)
}
runs, err := store.Runs(job.ID, 10)
want := []int{7, 42}
if size < maxRunBytes {
want = []int{99, 7, 99, 42, 99}
}
if err != nil || len(runs) != len(want) {
t.Fatalf("size %d: got %+v, err %v; want %v", size, runs, err, want)
}
for i, rec := range runs {
if rec.ExitCode != want[i] {
t.Fatalf("size %d: record %d = %d, want %d", size, i, rec.ExitCode, want[i])
}
}
}
}
// An outside reader holding runs.jsonl open makes the Windows replace fail;
// the outcome must still be recorded rather than dropped.
func TestAppendRunWhileHistoryHeldOpen(t *testing.T) {
store := newTestStore(t)
job, err := store.Add(Job{Prompt: "x"})
if err != nil {
t.Fatal(err)
}
path := filepath.Join(store.jobDir(job.ID), "runs.jsonl")
if err := os.WriteFile(path, []byte("{\"exitCode\":7}\n"), 0o600); err != nil {
t.Fatal(err)
}
held, err := os.Open(path)
if err != nil {
t.Fatal(err)
}
defer held.Close()
if err := store.AppendRun(job.ID, RunRecord{ExitCode: 42}); err != nil {
t.Fatalf("AppendRun with history held open: %v", err)
}
runs, err := store.Runs(job.ID, 10)
if err != nil || len(runs) != 2 || runs[0].ExitCode != 7 || runs[1].ExitCode != 42 {
t.Fatalf("outcome lost while history held open: %+v, %v", runs, err)
}
}
// Only a sharing or lock violation falls back to appending. Any other replace
// failure, including a persistent access denial, and a committed replacement
// whose backup cleanup failed must leave the log untouched, so history can
// neither grow past retention nor record an outcome twice.
func TestAppendRunFallbackOnlyForSharingViolation(t *testing.T) {
const seed = "{\"exitCode\":7}\n"
for _, tc := range []struct {
name string
err error
append bool
}{
{"sharing violation", syscall.Errno(32), runtime.GOOS == "windows"},
{"access denied", os.ErrPermission, false},
{"other", errors.New("boom"), false},
{"committed", &fsutil.CommittedReplacementCleanupError{Cause: syscall.Errno(32)}, false},
} {
t.Run(tc.name, func(t *testing.T) {
store := newTestStore(t)
job, err := store.Add(Job{Prompt: "x"})
if err != nil {
t.Fatal(err)
}
path := filepath.Join(store.jobDir(job.ID), "runs.jsonl")
if err := os.WriteFile(path, []byte(seed), 0o600); err != nil {
t.Fatal(err)
}
store.replace = func(string, string) error { return tc.err }
err = store.AppendRun(job.ID, RunRecord{ExitCode: 42})
data, readErr := os.ReadFile(path)
if readErr != nil {
t.Fatal(readErr)
}
if tc.append {
if err != nil || !strings.HasPrefix(string(data), seed) || !strings.Contains(string(data), "\"exitCode\":42") {
t.Fatalf("sharing violation dropped the outcome: err %v, log %q", err, data)
}
return
}
if !errors.Is(err, tc.err) {
t.Fatalf("AppendRun error = %v, want %v", err, tc.err)
}
if string(data) != seed {
t.Fatalf("non-transient replace failure changed the log: %q", data)
}
})
}
}
// The fallback append must start a fresh line even when the log ends in an
// unterminated record, so neither record is corrupted.
func TestAppendRunLineAfterUnterminatedTail(t *testing.T) {
store := newTestStore(t)
job, err := store.Add(Job{Prompt: "x"})
if err != nil {
t.Fatal(err)
}
path := filepath.Join(store.jobDir(job.ID), "runs.jsonl")
if err := os.WriteFile(path, []byte("{\"exitCode\":7}"), 0o600); err != nil {
t.Fatal(err)
}
if err := appendRunLine(path, []byte("{\"exitCode\":42}")); err != nil {
t.Fatal(err)
}
runs, err := store.Runs(job.ID, 10)
if err != nil || len(runs) != 2 || runs[0].ExitCode != 7 || runs[1].ExitCode != 42 {
t.Fatalf("fallback append corrupted history: %+v, %v", runs, err)
}
}
func TestRunHistoryConcurrentStores(t *testing.T) {
store := newTestStore(t)
job, err := store.Add(Job{Prompt: "x"})
if err != nil {
t.Fatal(err)
}
var history bytes.Buffer
for i := 0; i < 1000; i++ {
if err := json.NewEncoder(&history).Encode(RunRecord{ExitCode: -1}); err != nil {
t.Fatal(err)
}
}
if err := os.WriteFile(filepath.Join(store.jobDir(job.ID), "runs.jsonl"), history.Bytes(), 0o600); err != nil {
t.Fatal(err)
}
var wg sync.WaitGroup
for i := 0; i < 12; i++ {
wg.Go(func() {
other := NewStore(StoreOptions{RootDir: store.root})
if err := other.AppendRun(job.ID, RunRecord{ExitCode: i}); err != nil {
t.Error(err)
return
}
runs, err := other.Runs(job.ID, 1000)
if err != nil || len(runs) != 1000 {
t.Errorf("inconsistent history: %d records, err %v", len(runs), err)
}
})
}
wg.Wait()
runs, err := store.Runs(job.ID, 12)
if err != nil || len(runs) != 12 {
t.Fatalf("final tail: %d records, err %v", len(runs), err)
}
seen := make(map[int]bool)
for _, run := range runs {
if run.ExitCode < 0 || run.ExitCode >= 12 || seen[run.ExitCode] {
t.Fatalf("lost or duplicate concurrent append: %+v", runs)
}
seen[run.ExitCode] = true
}
}
+127 -20
View File
@@ -7,6 +7,7 @@ import (
"fmt"
"os"
"path/filepath"
"slices"
"strings"
"time"
@@ -16,6 +17,10 @@ import (
const (
StatusActive = "active"
StatusPaused = "paused"
// MaxRunHistory is the number of recent outcomes retained per job.
MaxRunHistory = 1000
maxRunBytes = 1024 * 1024
)
// ErrJobNotFound is returned (wrapped) by Get when a job's metadata file is
@@ -56,6 +61,9 @@ type StoreOptions struct {
type Store struct {
root string
now func() time.Time
// replace overrides the run-history replacement primitive in tests; nil
// uses the platform default.
replace func(src, dst string) error
}
func NewStore(opts StoreOptions) *Store {
@@ -228,8 +236,7 @@ func (s *Store) Update(job Job) error {
// Remove). It re-reads the CURRENT on-disk job and passes it to mutate along with
// any read error; mutate returns the job to persist. A removed job aborts with
// ErrJobNotFound (no recreate). A transient read error (not removal) is surfaced
// via readErr so the caller can still persist a best-effort state — the fire path
// advances the schedule regardless, to avoid a re-fire.
// via readErr; the callback can return it to abort without overwriting state.
func (s *Store) Mutate(id string, mutate func(current Job, readErr error) (Job, error)) (Job, error) {
if !validID(id) {
return Job{}, fmt.Errorf("invalid cron job id %q", id)
@@ -309,10 +316,8 @@ func (s *Store) AppendRun(id string, rec RunRecord) error {
return err
}
defer unlock()
// Bail if the job was removed (e.g. mid-run) — otherwise the MkdirAll below
// would resurrect a deleted job's directory with an orphaned runs.jsonl and no
// metadata.json (which Runs would then still return). Under the per-job lock
// this stat-then-write is race-free against a concurrent Remove.
// Do not resurrect a job removed mid-run. The lock serializes this check
// and publication against Remove and other history writers/readers.
if _, err := os.Stat(filepath.Join(s.jobDir(id), "metadata.json")); err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil
@@ -320,30 +325,98 @@ func (s *Store) AppendRun(id string, rec RunRecord) error {
return err
}
dir := s.jobDir(id)
if err := os.MkdirAll(dir, 0o700); err != nil {
return err
}
f, err := os.OpenFile(filepath.Join(dir, "runs.jsonl"), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
if err != nil {
return err
}
line, err := json.Marshal(rec)
if err != nil {
return err
}
if len(line) >= maxRunBytes {
return fmt.Errorf("cron run record exceeds %d bytes", maxRunBytes-1)
}
runs, err := s.readRuns(id, MaxRunHistory-1)
if err != nil {
return err
}
// Publish a complete snapshot even below the retention boundary so readers
// never see a partial append. Legacy oversized logs compact on their next run.
f, err := os.CreateTemp(dir, "runs-*.tmp")
if err != nil {
return err
}
defer os.Remove(f.Name())
w := bufio.NewWriter(f)
for _, run := range runs {
if err := json.NewEncoder(w).Encode(run); err != nil {
_ = f.Close()
return err
}
}
if _, err := w.Write(append(line, '\n')); err != nil {
_ = f.Close()
return err
}
if _, err := f.Write(append(line, '\n')); err != nil {
if err := w.Flush(); err != nil {
_ = f.Close()
return err
}
// Surface a buffered-write failure that only materializes on Close.
if err := f.Close(); err != nil {
return err
}
path := filepath.Join(dir, "runs.jsonl")
// ReplaceWithRetry keeps a DACL applied to runs.jsonl itself; a rename would
// publish the temporary file's inherited directory DACL instead.
err = fsutil.ReplaceWithRetry(f.Name(), path, s.replace)
var committed *fsutil.CommittedReplacementCleanupError
if err == nil || errors.As(err, &committed) || !fsutil.IsSharingOrLockViolation(err) {
return err
}
// Windows refuses to replace a log another process holds open. Append the
// record instead so the outcome survives; compaction resumes on a later run.
// Other failures are returned so a log that can never be replaced cannot
// grow past the retention limit. The leading newline ends any unterminated
// tail, and readRuns skips the blank line it may leave. Zero's own readers
// hold the job lock.
if appendErr := appendRunLine(path, line); appendErr != nil {
return errors.Join(err, appendErr)
}
return nil
}
func appendRunLine(path string, line []byte) error {
f, err := os.OpenFile(path, os.O_WRONLY|os.O_APPEND, 0)
if err != nil {
return err
}
record := append(append([]byte{'\n'}, line...), '\n')
if _, err := f.Write(record); err != nil {
_ = f.Close()
return err
}
return f.Close()
}
func (s *Store) Runs(id string) ([]RunRecord, error) {
// Runs returns the newest limit valid records in append order (oldest first).
// Non-positive limits and limits above MaxRunHistory use MaxRunHistory. It reads
// backwards from the tail, including for legacy logs not yet compacted.
func (s *Store) Runs(id string, limit int) ([]RunRecord, error) {
if !validID(id) {
return nil, fmt.Errorf("invalid cron job id %q", id)
}
if limit <= 0 || limit > MaxRunHistory {
limit = MaxRunHistory
}
unlock, err := s.lockJob(id)
if err != nil {
return nil, err
}
defer unlock()
return s.readRuns(id, limit)
}
// readRuns requires the job lock. Memory is bounded by limit records plus one
// line. Malformed and oversized legacy lines are skipped so they cannot prevent
// recording new outcomes during compaction.
func (s *Store) readRuns(id string, limit int) ([]RunRecord, error) {
f, err := os.Open(filepath.Join(s.jobDir(id), "runs.jsonl"))
if errors.Is(err, os.ErrNotExist) {
return nil, nil
@@ -352,14 +425,48 @@ func (s *Store) Runs(id string) ([]RunRecord, error) {
return nil, err
}
defer f.Close()
info, err := f.Stat()
if err != nil {
return nil, err
}
var runs []RunRecord
scanner := bufio.NewScanner(f)
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for scanner.Scan() {
var line []byte
oversized := false
decode := func() {
slices.Reverse(line)
var rec RunRecord
if json.Unmarshal(scanner.Bytes(), &rec) == nil {
if json.Unmarshal(line, &rec) == nil {
runs = append(runs, rec)
}
line = line[:0]
}
return runs, scanner.Err()
var block [4096]byte
for end := info.Size(); end > 0 && len(runs) < limit; {
start := max(int64(0), end-int64(len(block)))
n, err := f.ReadAt(block[:end-start], start)
if err != nil {
return nil, err
}
for i := n - 1; i >= 0 && len(runs) < limit; i-- {
if block[i] == '\n' {
if !oversized {
decode()
}
oversized = false
} else if !oversized {
if len(line) >= maxRunBytes-1 {
line = line[:0]
oversized = true
continue
}
line = append(line, block[i])
}
}
end = start
}
if len(runs) < limit && len(line) > 0 {
decode()
}
slices.Reverse(runs)
return runs, nil
}
+2 -2
View File
@@ -238,7 +238,7 @@ func TestStoreAppendRun(t *testing.T) {
t.Fatalf("AppendRun: %v", err)
}
}
runs, err := s.Runs(job.ID)
runs, err := s.Runs(job.ID, 0)
if err != nil || len(runs) != 3 || runs[2].ExitCode != 2 {
t.Fatalf("Runs=%v err=%v", runs, err)
}
@@ -280,7 +280,7 @@ func TestStoreRejectsUnsafeID(t *testing.T) {
if _, err := s.Get(id); err == nil {
t.Fatalf("Get(%q) must be rejected", id)
}
if _, err := s.Runs(id); err == nil {
if _, err := s.Runs(id, 0); err == nil {
t.Fatalf("Runs(%q) must be rejected", id)
}
}
+73
View File
@@ -0,0 +1,73 @@
//go:build windows
package cron
import (
"os"
"path/filepath"
"strings"
"testing"
"golang.org/x/sys/windows"
)
// Compacting runs.jsonl publishes a temporary file carrying the job directory's
// inherited DACL. A rename would replace a restrictive DACL applied to the log
// itself, so AppendRun must keep the log's own descriptor.
func TestAppendRunPreservesHistoryDACL(t *testing.T) {
store := newTestStore(t)
job, err := store.Add(Job{Prompt: "x"})
if err != nil {
t.Fatal(err)
}
path := filepath.Join(store.jobDir(job.ID), "runs.jsonl")
if err := os.WriteFile(path, []byte("{\"exitCode\":7}\n"), 0o600); err != nil {
t.Fatal(err)
}
// A protected DACL granting only the owner: distinct from whatever the
// temporary file inherits from the directory.
restricted, err := windows.SecurityDescriptorFromString("D:P(A;;FA;;;OW)")
if err != nil {
t.Skipf("cannot build a test security descriptor: %v", err)
}
dacl, _, err := restricted.DACL()
if err != nil {
t.Skipf("cannot read the test DACL: %v", err)
}
if err := windows.SetNamedSecurityInfo(
path,
windows.SE_FILE_OBJECT,
windows.DACL_SECURITY_INFORMATION|windows.PROTECTED_DACL_SECURITY_INFORMATION,
nil, nil, dacl, nil,
); err != nil {
t.Skipf("cannot apply a restrictive DACL on this filesystem: %v", err)
}
want := describeDACL(t, path)
if inherited := describeDACL(t, store.jobDir(job.ID)); strings.Contains(inherited, want) {
t.Skip("the job directory already carries the same DACL; this filesystem cannot show the difference")
}
if err := store.AppendRun(job.ID, RunRecord{ExitCode: 42}); err != nil {
t.Fatal(err)
}
if got := describeDACL(t, path); got != want {
t.Fatalf("DACL after AppendRun = %q, want the log's own %q", got, want)
}
runs, err := store.Runs(job.ID, 10)
if err != nil || len(runs) != 2 || runs[1].ExitCode != 42 {
t.Fatalf("outcome not published by replacement: %+v, %v", runs, err)
}
}
func describeDACL(t *testing.T, path string) string {
t.Helper()
sd, err := windows.GetNamedSecurityInfo(path, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION)
if err != nil {
t.Skipf("cannot read the security descriptor of %s: %v", path, err)
}
text := sd.String()
if index := strings.Index(text, "D:"); index >= 0 {
return text[index:]
}
return text
}
+8 -9
View File
@@ -72,9 +72,10 @@ func (w *execWorker) Kill() error {
if w.cmd.Process == nil {
return nil
}
// background.TerminateProcess is the cross-platform terminate (kills the
// process group on POSIX, taskkill /T on Windows).
return background.TerminateProcess(w.cmd.Process.Pid)
// The group the worker was launched into, not one rediscovered from its PID:
// the pool may call this after the worker has exited and been waited, and its
// children can still be running. See background.TerminateCommandGroup.
return background.TerminateCommandGroup(w.cmd)
}
// readerLines adapts a bufio.Reader to the Lines interface. Unlike a capped
@@ -154,13 +155,11 @@ func NewExecLauncher(cfg ExecLauncherConfig) (Launcher, error) {
background.ConfigureChildProcessGroup(cmd)
// CommandContext's default cancel sends os.Process.Kill to the LEADER only,
// orphaning the process group we just configured (a stuck worker's children
// would survive ctx cancellation). Terminate the whole group instead — the
// same cross-platform group terminate Kill() uses (D11).
// would survive ctx cancellation). Terminate the whole group instead, the
// same launch-time group terminate Kill() uses (D11). Cancel runs while Wait
// is in progress, so it must not reap.
cmd.Cancel = func() error {
if cmd.Process == nil {
return nil
}
return background.TerminateProcess(cmd.Process.Pid)
return background.TerminateCommandGroup(cmd)
}
stdout, err := cmd.StdoutPipe()
@@ -0,0 +1,90 @@
//go:build !windows
package daemon
import (
"context"
"errors"
"os/exec"
"strconv"
"strings"
"syscall"
"testing"
"time"
)
// A WORKER'S CHILDREN ARE STOPPED THROUGH THE GROUP IT WAS LAUNCHED INTO.
//
// Kill and the Cancel hook used to hand the worker's 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 whatever the worker left running survives; on Darwin
// the lookup already fails while the exited worker is still a zombie. The worker
// here is a shell that backgrounds a sleep, prints its PID and exits, so after the
// wait the sleep is the only process left in the worker's group. Reported in #861.
func TestExecWorkerStopsWhatItLeftRunningAfterItExits(t *testing.T) {
for _, stop := range []struct {
name string
run func(*execWorker) error
}{
{"Kill", func(worker *execWorker) error { return worker.Kill() }},
{"Cancel hook", func(worker *execWorker) error { return worker.cmd.Cancel() }},
} {
t.Run(stop.name, func(t *testing.T) {
launcher, err := NewExecLauncher(ExecLauncherConfig{
Executable: "/bin/sh",
BaseArgs: []string{"-c", "sleep 300 >/dev/null 2>&1 & echo $!"},
})
if err != nil {
t.Fatalf("NewExecLauncher: %v", err)
}
handle, err := launcher(context.Background(), WorkerSpec{})
if err != nil {
t.Fatalf("launch: %v", err)
}
worker, ok := handle.(*execWorker)
if !ok {
t.Fatalf("SETUP INVALID: the launcher returned %T, not the exec worker under test", handle)
}
line, ok, err := worker.Stdout().Next()
if err != nil || !ok {
t.Fatalf("read the child's pid: ok=%v err=%v", ok, err)
}
childPID, err := strconv.Atoi(strings.TrimSpace(line))
if err != nil {
t.Fatalf("parse the child's pid %q: %v", line, err)
}
t.Cleanup(func() { _ = syscall.Kill(childPID, syscall.SIGKILL) })
if _, err := worker.Wait(); err != nil {
t.Fatalf("wait for the worker: %v", err)
}
if childStopped(childPID) {
t.Fatalf("SETUP INVALID: the worker's child %d is not running, so there is nothing left to stop", childPID)
}
// The pool ignores what Kill returns; the outcome is what matters.
_ = stop.run(worker)
deadline := time.Now().Add(5 * time.Second)
for !childStopped(childPID) {
if time.Now().After(deadline) {
t.Fatalf("the worker's child %d survived %s after the worker was reaped", childPID, stop.name)
}
time.Sleep(20 * time.Millisecond)
}
})
}
}
// childStopped reports whether pid is gone or only waiting to be reaped.
func childStopped(pid int) bool {
if errors.Is(syscall.Kill(pid, syscall.Signal(0)), syscall.ESRCH) {
return true
}
state, err := exec.Command("ps", "-o", "stat=", "-p", strconv.Itoa(pid)).Output()
if err != nil {
return errors.Is(syscall.Kill(pid, syscall.Signal(0)), syscall.ESRCH)
}
return strings.HasPrefix(strings.TrimSpace(string(state)), "Z")
}
+27 -1
View File
@@ -17,6 +17,28 @@ const (
ExitPermanent = 76
)
// Exit codes `zero exec` (internal/cli/exec.go) uses to report a run that
// finished, or was stopped on purpose, rather than crashed. They are duplicated
// here because the daemon cannot import the CLI package; keep them in sync.
const (
exitUsage = 2 // bad flags or arguments: a retry fails the same way
exitProvider = 3 // provider failure the run already surfaced
exitIncomplete = 4 // the agent ran (edits, commands) and stopped unfinished
exitInterrupted = 130 // SIGINT: someone stopped it on purpose
)
// isFinalExit reports whether a worker exit code means the run is over and must
// not be retried. Retrying would repeat the agent's side effects and re-bill the
// provider, and append a second run to the same session stream. Only crash-type
// exits (1, signals) and launch/read errors are worth another attempt.
func isFinalExit(code int) bool {
switch code {
case exitUsage, exitProvider, exitIncomplete, exitInterrupted:
return true
}
return false
}
// defaultPoolSize, defaultMaxAttempts and defaultKillTimeout are used when a
// PoolOptions field is left zero.
const (
@@ -176,7 +198,8 @@ type Sink interface {
// Run leases a worker slot and dispatches spec to a worker, streaming its
// stream-json lines to sink. It is at-least-once with bounded retries: a worker
// that crashes (non-zero, non-permanent) is retried on a fresh worker after a
// backoff; ExitPermanent or exhausting MaxAttempts returns ErrPermanent. Run
// backoff; ExitPermanent, a final `zero exec` exit (usage, provider, incomplete,
// interrupted) or exhausting MaxAttempts returns ErrPermanent. Run
// queues when all slots are busy. The returned int is the final worker exit code.
func (p *Pool) Run(ctx context.Context, spec WorkerSpec, sink Sink) (int, error) {
if err := p.acquire(ctx); err != nil {
@@ -209,6 +232,9 @@ func (p *Pool) Run(ctx context.Context, spec WorkerSpec, sink Sink) (int, error)
case code == ExitPermanent:
p.logf("worker %d exited permanently (code=%d) — not retrying", stat.id, code)
return code, ErrPermanent
case isFinalExit(code):
p.logf("worker %d finished with exit code %d — not retrying", stat.id, code)
return code, fmt.Errorf("%w: worker %d exited code=%d", ErrPermanent, stat.id, code)
case code == ExitTempfail:
lastErr = fmt.Errorf("worker %d tempfail (code=%d)", stat.id, code)
p.logf("worker %d tempfail — retry after %s", stat.id, p.opts.TempfailDelay)
+30
View File
@@ -3,6 +3,7 @@ package daemon
import (
"context"
"errors"
"fmt"
"sync"
"sync/atomic"
"testing"
@@ -148,6 +149,35 @@ func TestPoolRunPermanentStopsImmediately(t *testing.T) {
}
}
// A `zero exec` run that ended with usage (2), provider (3), incomplete (4) or
// interrupted (130) is over: retrying would repeat its side effects and merge a
// second run into the same session stream.
func TestPoolRunFinalExitCodesDoNotRetry(t *testing.T) {
for _, code := range []int{2, 3, 4, 130} {
t.Run(fmt.Sprintf("exit_%d", code), func(t *testing.T) {
launcher, calls := seqLauncher(
&fakeWorker{pid: 1, exitCode: code, out: []string{"first run"}},
&fakeWorker{pid: 2, exitCode: 0, out: []string{"second run"}},
)
pool, _ := NewPool(PoolOptions{Size: 1, Launcher: launcher, MaxAttempts: 5, Backoff: func(int) time.Duration { return 0 }})
sink := &collectSink{}
got, err := pool.Run(context.Background(), WorkerSpec{Session: "s"}, sink)
if !errors.Is(err, ErrPermanent) {
t.Fatalf("Run err = %v, want ErrPermanent", err)
}
if got != code {
t.Fatalf("Run code = %d, want the worker's exit code %d", got, code)
}
if *calls != 1 {
t.Fatalf("launcher calls = %d, want 1 (no retry)", *calls)
}
if len(sink.lines) != 1 || sink.lines[0] != "first run" {
t.Fatalf("sink lines = %v, want only the first run's output", sink.lines)
}
})
}
}
func TestPoolRunCapExhausted(t *testing.T) {
launcher, calls := seqLauncher(
&fakeWorker{pid: 1, exitCode: 1},
+31 -1
View File
@@ -32,6 +32,9 @@ const (
// ErrSessionExists is returned when starting a session ID already in use.
var ErrSessionExists = errors.New("daemon: session already exists")
// ErrSessionOverloaded means the manager's unfinished-session limit is reached.
var ErrSessionOverloaded = errors.New("daemon: pending session limit reached")
// ErrSessionNotFound is returned by Get/Attach for an unknown session ID.
var ErrSessionNotFound = errors.New("daemon: session not found")
@@ -219,6 +222,9 @@ func (s *Session) isFinished() bool {
// daemon does not accumulate finished sessions without limit.
const defaultMaxSessions = 256
// defaultMaxPending bounds queued and running sessions independently of history.
const defaultMaxPending = 256
// SessionManager owns the live session registry and routes each session to a
// pool worker. Mirrors session-manager.js + roster.js. Finished sessions are
// retained (so a late `attach` sees history) up to MaxSessions, past which the
@@ -227,8 +233,10 @@ type SessionManager struct {
pool *Pool
maxBuffer int
maxSessions int
maxPending int
mu sync.Mutex
pending int // queued + running; protected by mu
sessions map[string]*Session
order []string // creation order, for FIFO eviction of finished sessions
}
@@ -240,6 +248,9 @@ type SessionManagerOptions struct {
// MaxSessions caps the retained session registry; 0 => default. The oldest
// FINISHED sessions are evicted once the cap is exceeded.
MaxSessions int
// MaxPending caps unfinished sessions (queued plus running), independently
// of retained history. Non-positive values use the default of 256.
MaxPending int
}
// NewSessionManager builds a manager over pool.
@@ -251,10 +262,15 @@ func NewSessionManager(opts SessionManagerOptions) (*SessionManager, error) {
if maxSessions <= 0 {
maxSessions = defaultMaxSessions
}
maxPending := opts.MaxPending
if maxPending <= 0 {
maxPending = defaultMaxPending
}
return &SessionManager{
pool: opts.Pool,
maxBuffer: opts.MaxBuffer,
maxSessions: maxSessions,
maxPending: maxPending,
sessions: map[string]*Session{},
}, nil
}
@@ -262,13 +278,23 @@ func NewSessionManager(opts SessionManagerOptions) (*SessionManager, error) {
// Start creates a session for spec and dispatches it to the pool. It returns the
// Session immediately (non-blocking); the run proceeds in the background and the
// session transitions queued -> running -> done/failed. A duplicate ID is
// rejected with ErrSessionExists.
// rejected with ErrSessionExists; a full admission limit returns
// ErrSessionOverloaded without creating a session or background goroutine.
func (m *SessionManager) Start(ctx context.Context, spec WorkerSpec) (*Session, error) {
m.mu.Lock()
if _, exists := m.sessions[spec.Session]; exists {
m.mu.Unlock()
return nil, ErrSessionExists
}
if err := ctx.Err(); err != nil {
m.mu.Unlock()
return nil, err
}
if m.pending >= m.maxPending {
m.mu.Unlock()
return nil, ErrSessionOverloaded
}
m.pending++
sess := newSession(spec.Session, spec.Cwd, m.maxBuffer)
m.sessions[spec.Session] = sess
m.order = append(m.order, spec.Session)
@@ -277,7 +303,11 @@ func (m *SessionManager) Start(ctx context.Context, spec WorkerSpec) (*Session,
go func() {
code, err := m.pool.Run(ctx, spec, sess)
m.mu.Lock()
sess.finish(code, err)
m.pending--
m.pruneLocked()
m.mu.Unlock()
}()
return sess, nil
}
+126
View File
@@ -3,6 +3,8 @@ package daemon
import (
"context"
"errors"
"fmt"
"sync"
"sync/atomic"
"testing"
"time"
@@ -208,6 +210,130 @@ func TestSessionManagerKeepsRunningOverCap(t *testing.T) {
}
}
func TestSessionManagerAdmissionBound(t *testing.T) {
block := make(chan struct{})
launcher, _ := seqLauncher(&fakeWorker{pid: 1, waitCh: block})
pool, _ := NewPool(PoolOptions{Size: 1, Launcher: launcher})
mgr, _ := NewSessionManager(SessionManagerOptions{Pool: pool, MaxSessions: 2})
ctx, cancel := context.WithCancel(context.Background())
defer func() {
cancel()
close(block)
testutil.WaitFor(t, "all admitted sessions finished", func() bool {
mgr.mu.Lock()
defer mgr.mu.Unlock()
return mgr.pending == 0
})
}()
var wg sync.WaitGroup
var accepted atomic.Int32
for i := 0; i < 300; i++ {
wg.Add(1)
go func() {
defer wg.Done()
if _, err := mgr.Start(ctx, WorkerSpec{Session: fmt.Sprintf("s%d", i)}); err == nil {
accepted.Add(1)
}
}()
}
wg.Wait()
if got := accepted.Load(); got != 256 {
t.Fatalf("accepted %d unfinished sessions, want default bound 256", got)
}
if got := len(mgr.Statuses()); got != 256 {
t.Fatalf("registered %d sessions, want 256", got)
}
}
func TestSessionManagerCancellationReleasesAdmission(t *testing.T) {
block := make(chan struct{})
var release sync.Once
defer release.Do(func() { close(block) })
launcher, _ := seqLauncher(&fakeWorker{pid: 1, waitCh: block}, &fakeWorker{pid: 2, exitCode: ExitPermanent}, &fakeWorker{pid: 3})
pool, _ := NewPool(PoolOptions{Size: 1, Launcher: launcher})
mgr, _ := NewSessionManager(SessionManagerOptions{Pool: pool, MaxPending: 2, MaxSessions: 10})
first, err := mgr.Start(context.Background(), WorkerSpec{Session: "running"})
if err != nil {
t.Fatal(err)
}
testutil.WaitFor(t, "first running", func() bool { return first.State() == SessionRunning })
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
queued, err := mgr.Start(ctx, WorkerSpec{Session: "queued"})
if err != nil {
t.Fatal(err)
}
if s, err := mgr.Start(context.Background(), WorkerSpec{Session: "retry"}); s != nil || !errors.Is(err, ErrSessionOverloaded) {
t.Fatalf("overloaded Start = (%v, %v)", s, err)
}
if _, ok := mgr.Get("retry"); ok {
t.Fatal("rejected session was registered")
}
if _, err := mgr.Start(context.Background(), WorkerSpec{Session: "running"}); !errors.Is(err, ErrSessionExists) {
t.Fatalf("duplicate at capacity: %v", err)
}
cancel()
testutil.WaitFor(t, "queued cancellation", func() bool { return queued.State() == SessionFailed })
if !errors.Is(queued.Err(), context.Canceled) {
t.Fatalf("queued error = %v", queued.Err())
}
if s, err := mgr.Start(ctx, WorkerSpec{Session: "canceled"}); s != nil || !errors.Is(err, context.Canceled) {
t.Fatalf("canceled Start = (%v, %v)", s, err)
}
retry, err := mgr.Start(context.Background(), WorkerSpec{Session: "retry"})
if err != nil {
t.Fatalf("capacity not released after cancellation: %v", err)
}
release.Do(func() { close(block) })
testutil.WaitFor(t, "first completed", func() bool { return first.State() == SessionDone })
testutil.WaitFor(t, "retry failed", func() bool { return retry.State() == SessionFailed })
if !errors.Is(retry.Err(), ErrPermanent) {
t.Fatalf("retry error = %v", retry.Err())
}
last, err := mgr.Start(context.Background(), WorkerSpec{Session: "last"})
if err != nil {
t.Fatal(err)
}
testutil.WaitFor(t, "last completed", func() bool { return last.State() == SessionDone })
mgr.mu.Lock()
defer mgr.mu.Unlock()
if mgr.pending != 0 || len(mgr.sessions) != 4 {
t.Fatalf("pending=%d retained=%d, want 0 and 4", mgr.pending, len(mgr.sessions))
}
}
func TestSessionManagerPrunesOnCompletion(t *testing.T) {
block := make(chan struct{})
var release sync.Once
defer release.Do(func() { close(block) })
launcher, _ := seqLauncher(
&fakeWorker{pid: 1, waitCh: block},
&fakeWorker{pid: 2},
&fakeWorker{pid: 3, exitCode: ExitPermanent},
)
pool, _ := NewPool(PoolOptions{Size: 1, Launcher: launcher})
mgr, _ := NewSessionManager(SessionManagerOptions{Pool: pool, MaxSessions: 2})
sessions := make([]*Session, 3)
for i := range sessions {
var err error
sessions[i], err = mgr.Start(context.Background(), WorkerSpec{Session: fmt.Sprintf("s%d", i)})
if err != nil {
t.Fatal(err)
}
}
release.Do(func() { close(block) })
for _, s := range sessions {
select {
case <-s.Done():
case <-time.After(2 * time.Second):
t.Fatal("session did not finish")
}
}
if got := len(mgr.Statuses()); got != 2 {
t.Fatalf("retained %d completed sessions without another Start, want 2", got)
}
}
func TestSessionStatuses(t *testing.T) {
launcher, _ := seqLauncher(&fakeWorker{pid: 1, out: []string{"l"}, exitCode: 0})
pool, _ := NewPool(PoolOptions{Size: 1, Launcher: launcher})
+7
View File
@@ -24,6 +24,7 @@ const (
Connectivity
ModelNotFound
ContextOverflow
LocalRPM
)
// providerMarkers are the prefixes the provider layer attaches to every
@@ -59,6 +60,8 @@ func Classify(err error) Category {
return Unknown
}
switch {
case strings.Contains(m, "rate limit error: local rpm limit for"):
return LocalRPM
case containsAny(m, "auth error:", "unauthorized", "api key", "api_key", "invalid_api_key",
"authentication", "permission denied", "forbidden") || containsStatusCode(m, "401", "403"):
return Auth
@@ -89,6 +92,8 @@ func TUIHint(err error) string {
return "API key rejected — run /provider to re-check your credentials"
case RateLimit:
return "Rate limited — wait a moment, or switch model with /model"
case LocalRPM:
return "Local modelRPM cap reached — wait for the window to expire, or switch model with /model"
case Connectivity:
return "Can't reach the provider — run /doctor --connectivity"
case ModelNotFound:
@@ -109,6 +114,8 @@ func CLIHint(err error) string {
return "API key rejected — run `zero setup`, `zero auth openrouter` for OpenRouter, or set the provider's API key"
case RateLimit:
return "Rate limited — wait a moment, or switch model with --model"
case LocalRPM:
return "Local modelRPM cap reached — wait for the window to expire, or switch model with --model"
case Connectivity:
return "Can't reach the provider — run `zero doctor`"
case ModelNotFound:
+1
View File
@@ -17,6 +17,7 @@ func TestClassify(t *testing.T) {
{"raw 401", "provider request error: 401 Unauthorized", Auth},
{"invalid api key", "provider request error: invalid_api_key: incorrect key provided", Auth},
{"rate limit prefix", "rate limit error: 429 too many requests", RateLimit},
{"local RPM", `rate limit error: local RPM limit for "gpt-4.1" (2 requests/60s); retry in 12s`, LocalRPM},
{"overloaded", "provider error: model is overloaded, please retry", RateLimit},
{"resource exhausted gemini", "provider stream error: rpc error: code = ResourceExhausted desc = quota exceeded", RateLimit},
{"context length openai", "provider request error: this model's maximum context length is 128000 tokens", ContextOverflow},
+35 -17
View File
@@ -24,11 +24,15 @@ const (
var (
ErrProcessNotFound = errors.New("execution process not found")
ErrProcessStdinDisabled = errors.New("execution process does not accept stdin")
ErrProcessCapacity = errors.New("execution process capacity reached; stop a running process before starting another")
)
type ProcessManagerOptions struct {
CompletedRetention time.Duration
MaxProcesses int
// MaxProcesses bounds retained processes and in-flight launches. Completed
// history is pruned before rejecting a start; live processes are never evicted.
// Zero uses the default limit; a negative value disables the limit.
MaxProcesses int
}
// ProcessManager owns retained interactive-process identity, transport,
@@ -37,6 +41,7 @@ type ProcessManager struct {
mu sync.Mutex
nextID int
processes map[int]*managedProcess
starting int
completedRetention time.Duration
maxProcesses int
startTransport processTransportStarter
@@ -120,12 +125,21 @@ func (manager *ProcessManager) Start(ctx context.Context, input ProcessStart, wa
if input.Prepared.Command == nil {
return ProcessResult{}, errors.New("prepared execution has no command")
}
if err := manager.reserve(); err != nil {
if input.Prepared.Cleanup != nil {
input.Prepared.Cleanup()
}
return ProcessResult{}, err
}
command := input.Prepared.Command
buffer := newProcessOutputBuffer()
request := input.Request
observer := NewChangeObserver(request.WorkspaceRoots[0])
stdin, tty, transportCleanup, err := manager.startTransport(command, buffer, input.TTY)
if err != nil {
manager.mu.Lock()
manager.starting--
manager.mu.Unlock()
if input.Prepared.Cleanup != nil {
input.Prepared.Cleanup()
}
@@ -331,9 +345,13 @@ func (manager *ProcessManager) StopAll() []int {
return ids
}
// Remove forgets completed history only. A live process must remain tracked
// until completion, even if a termination attempt fails.
func (manager *ProcessManager) Remove(id int) {
manager.mu.Lock()
delete(manager.processes, id)
if process, ok := manager.processes[id]; ok && process.doneClosed() {
delete(manager.processes, id)
}
manager.mu.Unlock()
}
@@ -358,22 +376,25 @@ func (manager *ProcessManager) get(id int) (*managedProcess, bool) {
return process, ok
}
func (manager *ProcessManager) reserve() error {
manager.mu.Lock()
defer manager.mu.Unlock()
if manager.maxProcesses > 0 && len(manager.processes)+manager.starting >= manager.maxProcesses {
completed := manager.processToPruneLocked()
if completed == nil {
return ErrProcessCapacity
}
delete(manager.processes, completed.id)
}
manager.starting++
return nil
}
func (manager *ProcessManager) store(process *managedProcess) {
manager.mu.Lock()
var evicted *managedProcess
if manager.maxProcesses > 0 && len(manager.processes) >= manager.maxProcesses {
evicted = manager.processToPruneLocked()
if evicted != nil && evicted.doneClosed() {
delete(manager.processes, evicted.id)
evicted = nil
}
}
manager.starting--
manager.processes[process.id] = process
manager.mu.Unlock()
if evicted != nil {
_, _ = evicted.output.Write([]byte("[zero] session evicted: too many background terminals\n"))
evicted.terminate()
}
}
func (manager *ProcessManager) processToPruneLocked() *managedProcess {
@@ -387,9 +408,6 @@ func (manager *ProcessManager) processToPruneLocked() *managedProcess {
return process
}
}
if len(processes) > 8 {
return processes[0]
}
return nil
}
+140
View File
@@ -3,6 +3,7 @@ package execution
import (
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
@@ -23,6 +24,145 @@ func processManagerRequest(root string, command *exec.Cmd) Request {
}
}
func TestProcessManagerReservesCapacityBeforeTransport(t *testing.T) {
root := t.TempDir()
manager := NewProcessManager(ProcessManagerOptions{MaxProcesses: 1})
entered := make(chan struct{}, 20)
release := make(chan struct{})
launchErr := errors.New("transport failed")
manager.startTransport = func(*exec.Cmd, io.Writer, bool) (io.WriteCloser, bool, func(), error) {
entered <- struct{}{}
<-release
return nil, false, nil, launchErr
}
start := func() error {
command := exec.Command(os.Args[0])
cleaned := false
_, err := manager.Start(context.Background(), ProcessStart{
Prepared: PreparedCommand{Command: command, Cleanup: func() { cleaned = true }},
Request: processManagerRequest(root, command),
}, 0)
if !cleaned {
t.Error("failed admission or launch did not clean prepared resources")
}
return err
}
first := make(chan error, 1)
go func() { first <- start() }()
<-entered
results := make(chan error, 16)
for range 16 {
go func() { results <- start() }()
}
// Release blocked transports even when testing the unfixed implementation.
select {
case <-entered:
close(release)
<-first
for range 16 {
<-results
}
t.Fatal("additional transport launched while the only slot was reserved")
case err := <-results:
if err == nil || errors.Is(err, launchErr) {
t.Errorf("full manager returned %v, want capacity error", err)
}
}
for range 15 {
if err := <-results; err == nil || errors.Is(err, launchErr) {
t.Errorf("full manager returned %v, want capacity error", err)
}
}
close(release)
if err := <-first; !errors.Is(err, launchErr) {
t.Fatalf("first launch = %v", err)
}
if err := start(); !errors.Is(err, launchErr) {
t.Fatalf("failed launch did not release reservation: %v", err)
}
}
func TestProcessManagerCapacityDoesNotEvictLiveProcesses(t *testing.T) {
for _, limit := range []int{1, 9} {
t.Run(fmt.Sprint(limit), func(t *testing.T) {
root := t.TempDir()
manager := NewProcessManager(ProcessManagerOptions{MaxProcesses: limit})
kills := 0
for id := range limit {
manager.processes[id] = &managedProcess{
id: id, command: &exec.Cmd{Process: &os.Process{Pid: 123}},
done: make(chan struct{}), output: newProcessOutputBuffer(),
kill: func(int) error { kills++; return errors.New("kill failed") },
}
}
manager.Stop(0) // A failed kill must not free capacity.
manager.Remove(0) // Nor may removing a live identity bypass the limit.
launches := 0
launchErr := errors.New("unexpected launch")
manager.startTransport = func(*exec.Cmd, io.Writer, bool) (io.WriteCloser, bool, func(), error) {
launches++
return nil, false, nil, launchErr
}
command := exec.Command(os.Args[0])
input := ProcessStart{Prepared: PreparedCommand{Command: command}, Request: processManagerRequest(root, command)}
if _, err := manager.Start(context.Background(), input, 0); err == nil || errors.Is(err, launchErr) {
t.Fatalf("full manager returned %v, want capacity error before launch", err)
}
if launches != 0 || kills != 1 || manager.Len() != limit {
t.Fatalf("launches=%d kills=%d retained=%d", launches, kills, manager.Len())
}
manager.processes[0].markDone(nil, 0, AdapterReport{}, nil, nil)
if _, err := manager.Start(context.Background(), input, 0); !errors.Is(err, launchErr) {
t.Fatalf("completed history did not free capacity: %v", err)
}
if launches != 1 || manager.Len() != limit-1 {
t.Fatalf("launches=%d retained=%d after completion", launches, manager.Len())
}
})
}
}
func TestProcessManagerCapacityHelper(t *testing.T) {
if os.Getenv("ZERO_PROCESS_CAPACITY_HELPER") != "1" {
return
}
time.Sleep(time.Minute)
os.Exit(0)
}
func TestProcessManagerCapacityWithRunningProcess(t *testing.T) {
root := t.TempDir()
manager := NewProcessManager(ProcessManagerOptions{MaxProcesses: 1})
t.Cleanup(func() { manager.StopAll() })
start := func() (ProcessResult, *exec.Cmd, error) {
command := exec.Command(os.Args[0], "-test.run=^TestProcessManagerCapacityHelper$")
command.Env = append(os.Environ(), "ZERO_PROCESS_CAPACITY_HELPER=1")
result, err := manager.Start(context.Background(), ProcessStart{
Prepared: PreparedCommand{Command: command}, Request: processManagerRequest(root, command),
}, 0)
return result, command, err
}
first, _, err := start()
if err != nil || first.Exited {
t.Fatalf("first start = %+v, %v", first, err)
}
if _, command, err := start(); err == nil || command.Process != nil {
t.Fatalf("second start = %v, process=%v; want rejection before OS launch", err, command.Process)
}
if got := len(manager.List()); got != 1 {
t.Fatalf("live processes = %d, want 1", got)
}
stopped, err := manager.Continue(context.Background(), ProcessContinue{
ProcessID: first.ProcessID, Interrupt: true, Wait: 10 * time.Second,
})
if err != nil || !stopped.Exited {
t.Fatalf("stop = %+v, %v", stopped, err)
}
if next, _, err := start(); err != nil || next.Exited {
t.Fatalf("start after completion = %+v, %v", next, err)
}
}
func TestProcessManagerRetainsAndContinuesWithStableIdentity(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("test command uses a POSIX shell")
+8
View File
@@ -75,6 +75,14 @@ func RenameWithRetry(src, dst string, rename func(src, dst string) error) error
return err
}
// IsSharingOrLockViolation reports whether err is a Windows sharing or lock
// violation: another open handle on the file denies the requested access. It
// is always false on other platforms, where the same errno values mean
// unrelated errors.
func IsSharingOrLockViolation(err error) bool {
return runtime.GOOS == "windows" && isWindowsSharingOrLockViolation(err)
}
func isWindowsSharingOrLockViolation(err error) bool {
var errno syscall.Errno
if errors.As(err, &errno) {
+20
View File
@@ -2,6 +2,7 @@ package fsutil
import (
"errors"
"fmt"
"runtime"
"syscall"
"testing"
@@ -45,3 +46,22 @@ func TestRenameWithRetryNonRetryableError(t *testing.T) {
t.Errorf("expected only 1 attempt for non-retryable error, got %d", attempts)
}
}
// Errno 32 and 33 are EPIPE and EDOM on Unix, so only Windows may classify them.
func TestIsSharingOrLockViolation(t *testing.T) {
onWindows := runtime.GOOS == "windows"
for _, tc := range []struct {
err error
want bool
}{
{syscall.Errno(32), onWindows},
{fmt.Errorf("wrapped: %w", syscall.Errno(33)), onWindows},
{syscall.Errno(5), false}, // ERROR_ACCESS_DENIED
{errors.New("other"), false},
{nil, false},
} {
if got := IsSharingOrLockViolation(tc.err); got != tc.want {
t.Errorf("IsSharingOrLockViolation(%v) = %v, want %v", tc.err, got, tc.want)
}
}
}
+218
View File
@@ -0,0 +1,218 @@
package gitsafe
import (
"go/ast"
"go/parser"
"go/token"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
)
// The packages whose git calls Zero makes on its own. The CLI runs git for the
// scratch-file warning and the TUI for its files sweep and pull request
// diffstat; each call has to come through Command.
var automaticGitPackages = []string{
filepath.Join("..", "cli"),
filepath.Join("..", "tui"),
}
// parsePackage parses the non-test Go files in dir.
func parsePackage(t *testing.T, dir string) (*token.FileSet, []*ast.File) {
t.Helper()
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatalf("read %s: %v", dir, err)
}
fset := token.NewFileSet()
var files []*ast.File
for _, entry := range entries {
name := entry.Name()
if entry.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") {
continue
}
file, err := parser.ParseFile(fset, filepath.Join(dir, name), nil, 0)
if err != nil {
t.Fatalf("parse %s: %v", name, err)
}
files = append(files, file)
}
if len(files) == 0 {
t.Fatalf("no Go files in %s", dir)
}
return fset, files
}
// importName is the name file uses for the package at path, or "" when the
// file does not import it.
func importName(file *ast.File, path string) string {
for _, spec := range file.Imports {
if value, err := strconv.Unquote(spec.Path.Value); err != nil || value != path {
continue
}
if spec.Name != nil {
return spec.Name.Name
}
return filepath.Base(path)
}
return ""
}
// directGitExecs lists the exec.Command and exec.CommandContext calls in files
// whose program is the literal "git".
func directGitExecs(fset *token.FileSet, files []*ast.File) []string {
var found []string
for _, file := range files {
execName := importName(file, "os/exec")
if execName == "" {
continue
}
ast.Inspect(file, func(node ast.Node) bool {
call, ok := node.(*ast.CallExpr)
if !ok {
return true
}
selector, ok := call.Fun.(*ast.SelectorExpr)
if !ok {
return true
}
if pkg, ok := selector.X.(*ast.Ident); !ok || pkg.Name != execName {
return true
}
program := 0
switch selector.Sel.Name {
case "Command":
case "CommandContext":
program = 1
default:
return true
}
if len(call.Args) <= program {
return true
}
if literal, ok := call.Args[program].(*ast.BasicLit); ok && literal.Kind == token.STRING {
if value, err := strconv.Unquote(literal.Value); err == nil && value == "git" {
found = append(found, fset.Position(call.Pos()).String())
}
}
return true
})
}
return found
}
func TestAutomaticGitCallsDoNotExecGitDirectly(t *testing.T) {
for _, dir := range automaticGitPackages {
fset, files := parsePackage(t, dir)
for _, position := range directGitExecs(fset, files) {
t.Errorf("%s runs git directly; go through gitsafe.Command so the repository's config cannot make it run a program", position)
}
}
}
// gitArguments returns the git arguments of call when it is gitsafe.Command or a
// runner called with the program "git" (the TUI's runner(ctx, cwd, "git", ...)),
// as far as they are string literals.
func gitArguments(call *ast.CallExpr, gitsafeName string) ([]string, bool) {
start := -1
if selector, ok := call.Fun.(*ast.SelectorExpr); ok && gitsafeName != "" && selector.Sel.Name == "Command" {
if pkg, ok := selector.X.(*ast.Ident); ok && pkg.Name == gitsafeName {
start = 2
}
}
if start < 0 {
for index, argument := range call.Args {
if literal, ok := argument.(*ast.BasicLit); ok && literal.Kind == token.STRING && literal.Value == `"git"` {
start = index + 1
break
}
}
}
if start < 0 || start > len(call.Args) {
return nil, false
}
var arguments []string
for _, argument := range call.Args[start:] {
if literal, ok := argument.(*ast.BasicLit); ok && literal.Kind == token.STRING {
if value, err := strconv.Unquote(literal.Value); err == nil {
arguments = append(arguments, value)
continue
}
}
arguments = append(arguments, "")
}
return arguments, true
}
// A submodule has its own config, which Command does not check, so the
// automatic status and diff calls stay out of submodule work trees.
func TestAutomaticStatusAndDiffSkipSubmoduleWorkTrees(t *testing.T) {
checked := 0
for _, dir := range automaticGitPackages {
fset, files := parsePackage(t, dir)
for _, file := range files {
gitsafeName := importName(file, "github.com/Gitlawb/zero/internal/gitsafe")
ast.Inspect(file, func(node ast.Node) bool {
call, ok := node.(*ast.CallExpr)
if !ok {
return true
}
arguments, ok := gitArguments(call, gitsafeName)
if !ok || len(arguments) == 0 || (arguments[0] != "status" && arguments[0] != "diff") {
return true
}
checked++
for _, argument := range arguments {
if argument == "--ignore-submodules=dirty" {
return true
}
}
t.Errorf("%s runs git %s without --ignore-submodules=dirty", fset.Position(call.Pos()), arguments[0])
return true
})
}
}
// The scratch-file status, the files sweep's status and diff, and the pull
// request diffstat.
if checked != 4 {
t.Fatalf("checked %d automatic status or diff calls, want 4; update this test when they change", checked)
}
}
// The TUI runs every git command through one runner, whose program name is a
// parameter, so the check above cannot see it. The runner has to hand git to
// Command itself.
func TestTUICommandRunnerSendsGitThroughCommand(t *testing.T) {
_, files := parsePackage(t, filepath.Join("..", "tui"))
var runner *ast.FuncDecl
var runnerFile *ast.File
for _, file := range files {
for _, declaration := range file.Decls {
if function, ok := declaration.(*ast.FuncDecl); ok && function.Recv == nil && function.Name.Name == "defaultPRCommandRunner" {
runner, runnerFile = function, file
}
}
}
if runner == nil {
t.Fatal("defaultPRCommandRunner not found in internal/tui")
}
gitsafeName := importName(runnerFile, "github.com/Gitlawb/zero/internal/gitsafe")
calls := false
if gitsafeName != "" {
ast.Inspect(runner.Body, func(node ast.Node) bool {
if call, ok := node.(*ast.CallExpr); ok {
if selector, ok := call.Fun.(*ast.SelectorExpr); ok && selector.Sel.Name == "Command" {
if pkg, ok := selector.X.(*ast.Ident); ok && pkg.Name == gitsafeName {
calls = true
}
}
}
return !calls
})
}
if !calls {
t.Fatal("defaultPRCommandRunner does not call gitsafe.Command, so the TUI's git calls skip it")
}
}
+192
View File
@@ -0,0 +1,192 @@
// Package gitsafe builds the read-only git commands Zero runs on its own, as
// opposed to a git command the user or the model asked for: the status and diff
// calls behind the TUI's changed-files sweep, its pull request diffstat and the
// scratch-file warning, which run without anyone asking for git.
//
// A REPOSITORY CAN MAKE THOSE COMMANDS RUN A PROGRAM. git status and git diff read
// the repository's own .git/config, and several keys there name programs git then
// runs: core.fsmonitor on every status, a clean filter or diff driver assigned
// through .gitattributes whenever a file's content has to be read, and hooks such
// as post-index-change when the index is rewritten. A repository unpacked from an
// archive or handed over as a directory carries that config, and Zero would run it
// on the first turn, although it refuses the same workspace's project hooks and
// plugins until the user trusts it.
//
// The single-key cases are switched off on the command line. Filter and diff
// driver commands are named per driver, so they cannot be switched off without
// knowing the names; a repository whose own config defines any is skipped instead.
// Commands defined in the user's own global config, such as git-lfs, are the
// user's choice and are not affected.
package gitsafe
import (
"bytes"
"context"
"errors"
"os"
"os/exec"
"runtime"
"strings"
)
// ErrRepositoryDefinesCommands is returned for a repository whose own config
// defines a filter or diff driver command. The caller goes without the git
// information rather than run it.
var ErrRepositoryDefinesCommands = errors.New("the repository's own git config defines a filter or diff command, so Zero does not run git there on its own")
// commandKeys matches the config keys whose value is a program git can run while
// computing status or a diff, and which are named per driver. Git prints section
// and variable names in lower case, so the patterns do not need to fold case.
const commandKeys = `filter\..+\.(clean|smudge|process)|diff\..+\.(command|textconv)|diff\.external`
// worktreeConfigKey makes git also read per-worktree config (config.worktree),
// which a --local read leaves out. Without it git ignores that file.
const worktreeConfigKey = "extensions.worktreeconfig"
const (
localConfigPattern = `^(` + commandKeys + `|extensions\.worktreeconfig)$`
worktreeConfigPattern = `^(` + commandKeys + `)$`
)
// Command returns git with args, run in dir, with the programs a repository can
// configure turned off. It fails with ErrRepositoryDefinesCommands when the
// repository's own config defines a filter or diff driver command, and with git's
// own error when dir is not inside a repository.
//
// Callers that inspect a work tree that may contain submodules should also pass
// --ignore-submodules=dirty: a submodule has its own config, which this does not
// read, and git only runs commands there when it looks inside the submodule.
func Command(ctx context.Context, dir string, args ...string) (*exec.Cmd, error) {
env := environment()
defines, err := repositoryDefinesCommands(ctx, dir, env)
if err != nil {
return nil, err
}
if defines {
return nil, ErrRepositoryDefinesCommands
}
command := exec.CommandContext(ctx, "git", append(disabledPrograms(), args...)...)
command.Dir = dir
// No opportunistic index refresh: a read-only call never rewrites the index,
// so it never triggers the hooks that run when the index is written. Git
// before 2.15 does not know the variable and ignores it.
command.Env = append(env, "GIT_OPTIONAL_LOCKS=0")
return command, nil
}
// disabledPrograms turns off the single-key ways a repository runs a program
// during status or diff. Git ignores a key it does not know, so an older git
// without hooksPath (2.9) or fsmonitor (2.16) accepts these unchanged.
func disabledPrograms() []string {
return []string{"-c", "core.fsmonitor=false", "-c", "core.hooksPath=" + os.DevNull}
}
// repositoryLocationVariables make git use a given repository, work tree or
// index instead of discovering them from the working directory. Git exports
// them to hooks, so a Zero started from a hook inherits the hook's repository.
var repositoryLocationVariables = []string{
"GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_COMMON_DIR",
"GIT_OBJECT_DIRECTORY", "GIT_ALTERNATE_OBJECT_DIRECTORIES", "GIT_NAMESPACE", "GIT_PREFIX",
}
// environment is Zero's environment without the repository-location variables,
// so git finds the repository from dir. The config check and the command share
// it: checking one repository's config and then running git in another would
// let the second one's commands through.
func environment() []string {
parent := os.Environ()
env := make([]string, 0, len(parent)+1)
for _, entry := range parent {
name, _, _ := strings.Cut(entry, "=")
if !isRepositoryLocationVariable(name) {
env = append(env, entry)
}
}
return env
}
func isRepositoryLocationVariable(name string) bool {
for _, variable := range repositoryLocationVariables {
// Windows environment names are case-insensitive.
if name == variable || (runtime.GOOS == "windows" && strings.EqualFold(name, variable)) {
return true
}
}
return false
}
// repositoryDefinesCommands reports whether the repository's own config, with
// includes followed, defines a filter or diff driver command. The local config is
// read once, together with the key that turns on per-worktree config, and that
// file is read only when it is on, so most repositories cost one git process.
// Reading config runs nothing.
func repositoryDefinesCommands(ctx context.Context, dir string, env []string) (bool, error) {
entries, err := readConfig(ctx, dir, env, "--local", localConfigPattern)
if err != nil {
return false, err
}
worktreeConfig := false
for _, entry := range entries {
if entry.key != worktreeConfigKey {
return true, nil
}
worktreeConfig = !configFalse(entry.value)
}
if !worktreeConfig {
return false, nil
}
// The extension needs git 2.20, which also has --worktree, so a failure here
// is real and the caller goes without git.
entries, err = readConfig(ctx, dir, env, "--worktree", worktreeConfigPattern)
if err != nil {
return false, err
}
return len(entries) > 0, nil
}
// configFalse reports whether a config value is one of git's spellings of false.
// Anything else, including a key with no value, counts as on, so an unexpected
// spelling costs one more read rather than skipping the file.
func configFalse(value string) bool {
switch strings.ToLower(strings.TrimSpace(value)) {
case "false", "no", "off", "0":
return true
}
return false
}
type configEntry struct {
key string
value string
}
// readConfig returns the entries in one config scope whose key matches pattern.
// It is a variable so tests can answer for git.
var readConfig = func(ctx context.Context, dir string, env []string, scope string, pattern string) ([]configEntry, error) {
command := exec.CommandContext(ctx, "git", "config", "-z", scope, "--includes", "--get-regexp", pattern)
command.Dir = dir
command.Env = env
output, err := command.Output()
if err != nil {
var exit *exec.ExitError
if errors.As(err, &exit) && exit.ExitCode() == 1 {
return nil, nil // no key matched
}
return nil, err
}
return parseConfigEntries(output), nil
}
// parseConfigEntries splits git config -z output: each entry ends with a NUL,
// and a newline separates the key from its value when there is one.
func parseConfigEntries(output []byte) []configEntry {
var entries []configEntry
for _, record := range bytes.Split(output, []byte{0}) {
if len(record) == 0 {
continue
}
key, value, _ := bytes.Cut(record, []byte{'\n'})
entries = append(entries, configEntry{key: string(key), value: string(value)})
}
return entries
}
+327
View File
@@ -0,0 +1,327 @@
package gitsafe
import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"reflect"
"regexp"
"runtime"
"strings"
"testing"
)
type configCall struct {
scope string
env []string
}
// answerConfig stands in for git's config read and records each call.
func answerConfig(t *testing.T, answer func(scope string) ([]configEntry, error)) *[]configCall {
t.Helper()
var calls []configCall
previous := readConfig
readConfig = func(_ context.Context, _ string, env []string, scope string, _ string) ([]configEntry, error) {
calls = append(calls, configCall{scope: scope, env: env})
return answer(scope)
}
t.Cleanup(func() { readConfig = previous })
return &calls
}
func noEntries(string) ([]configEntry, error) { return nil, nil }
func scopes(calls *[]configCall) []string {
var read []string
for _, call := range *calls {
read = append(read, call.scope)
}
return read
}
// effectiveEnv returns the value a child process gets for name: os/exec keeps
// the last entry for a name, compared without case on Windows.
func effectiveEnv(env []string, name string) (string, bool) {
value, found := "", false
for _, entry := range env {
key, rest, ok := strings.Cut(entry, "=")
if ok && (key == name || (runtime.GOOS == "windows" && strings.EqualFold(key, name))) {
value, found = rest, true
}
}
return value, found
}
func TestCommandTurnsOffRepositoryPrograms(t *testing.T) {
answerConfig(t, noEntries)
t.Setenv("GIT_OPTIONAL_LOCKS", "1")
dir := t.TempDir()
command, err := Command(context.Background(), dir, "status", "--porcelain")
if err != nil {
t.Fatalf("Command: %v", err)
}
want := []string{"git", "-c", "core.fsmonitor=false", "-c", "core.hooksPath=" + os.DevNull, "status", "--porcelain"}
if !reflect.DeepEqual(command.Args, want) {
t.Fatalf("args = %q, want %q", command.Args, want)
}
if command.Dir != dir {
t.Fatalf("dir = %q, want %q", command.Dir, dir)
}
// An inherited GIT_OPTIONAL_LOCKS must not turn the index refresh back on.
if value, _ := effectiveEnv(command.Env, "GIT_OPTIONAL_LOCKS"); value != "0" {
t.Fatalf("effective GIT_OPTIONAL_LOCKS = %q, want 0", value)
}
}
func TestCommandDropsRepositoryLocationVariables(t *testing.T) {
// Turn per-worktree config on so both reads happen and both are checked.
calls := answerConfig(t, func(scope string) ([]configEntry, error) {
if scope == "--local" {
return []configEntry{{key: worktreeConfigKey, value: "true"}}, nil
}
return nil, nil
})
elsewhere := filepath.Join(t.TempDir(), "elsewhere")
for _, name := range repositoryLocationVariables {
t.Setenv(name, elsewhere)
}
t.Setenv("ZERO_GITSAFE_TEST_KEPT", "kept")
command, err := Command(context.Background(), t.TempDir(), "status")
if err != nil {
t.Fatalf("Command: %v", err)
}
for _, name := range repositoryLocationVariables {
if value, found := effectiveEnv(command.Env, name); found {
t.Fatalf("%s=%q reached git", name, value)
}
}
if value, _ := effectiveEnv(command.Env, "ZERO_GITSAFE_TEST_KEPT"); value != "kept" {
t.Fatalf("an unrelated variable was dropped: %q", value)
}
// The config check has to look at the repository the command runs in, so it
// gets the same environment, minus only the lock variable added afterwards.
if got := scopes(calls); !reflect.DeepEqual(got, []string{"--local", "--worktree"}) {
t.Fatalf("config reads = %q, want --local then --worktree", got)
}
for _, call := range *calls {
if !reflect.DeepEqual(call.env, command.Env[:len(command.Env)-1]) {
t.Fatalf("the %s config read ran with a different environment than the command", call.scope)
}
}
}
func TestLocationVariableNamesFoldCaseOnlyOnWindows(t *testing.T) {
if !isRepositoryLocationVariable("GIT_DIR") {
t.Fatal("GIT_DIR not recognised")
}
if got, want := isRepositoryLocationVariable("Git_Dir"), runtime.GOOS == "windows"; got != want {
t.Fatalf("Git_Dir recognised = %v, want %v on %s", got, want, runtime.GOOS)
}
for _, name := range []string{"GIT_DIRECTORY", "MY_GIT_DIR", "GIT_CONFIG_GLOBAL", ""} {
if isRepositoryLocationVariable(name) {
t.Fatalf("%q treated as a repository-location variable", name)
}
}
}
func TestCommandRefusesRepositoryThatDefinesCommands(t *testing.T) {
enabled := configEntry{key: worktreeConfigKey, value: "true"}
command := configEntry{key: "filter.x.clean", value: "x"}
cases := map[string]map[string][]configEntry{
"local": {"--local": {command}},
"local after the extension": {"--local": {enabled, command}},
"per-worktree": {"--local": {enabled}, "--worktree": {{key: "diff.x.textconv", value: "x"}}},
}
for name, answers := range cases {
t.Run(name, func(t *testing.T) {
answerConfig(t, func(scope string) ([]configEntry, error) { return answers[scope], nil })
got, err := Command(context.Background(), t.TempDir(), "status")
if !errors.Is(err, ErrRepositoryDefinesCommands) {
t.Fatalf("err = %v, want ErrRepositoryDefinesCommands", err)
}
if got != nil {
t.Fatal("a command came back for a repository that defines commands")
}
})
}
}
func TestCommandReadsPerWorktreeConfigOnlyWhenItIsOn(t *testing.T) {
cases := map[string]struct {
local []configEntry
want []string
}{
"no extension": {nil, []string{"--local"}},
"extension off": {[]configEntry{{key: worktreeConfigKey, value: "false"}}, []string{"--local"}},
"extension off as no": {[]configEntry{{key: worktreeConfigKey, value: "No"}}, []string{"--local"}},
"extension on": {[]configEntry{{key: worktreeConfigKey, value: "true"}}, []string{"--local", "--worktree"}},
"extension with no value": {[]configEntry{{key: worktreeConfigKey}}, []string{"--local", "--worktree"}},
"turned off again": {[]configEntry{{key: worktreeConfigKey, value: "true"}, {key: worktreeConfigKey, value: "off"}}, []string{"--local"}},
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
calls := answerConfig(t, func(scope string) ([]configEntry, error) {
if scope == "--local" {
return tc.local, nil
}
return nil, nil
})
if _, err := Command(context.Background(), t.TempDir(), "status"); err != nil {
t.Fatalf("Command: %v", err)
}
if got := scopes(calls); !reflect.DeepEqual(got, tc.want) {
t.Fatalf("config reads = %q, want %q", got, tc.want)
}
})
}
}
func TestCommandFailsWhenConfigCannotBeRead(t *testing.T) {
unreadable := errors.New("not a git repository")
for _, failing := range []string{"--local", "--worktree"} {
t.Run(failing, func(t *testing.T) {
answerConfig(t, func(scope string) ([]configEntry, error) {
if scope == failing {
return nil, unreadable
}
return []configEntry{{key: worktreeConfigKey, value: "true"}}, nil
})
if _, err := Command(context.Background(), t.TempDir(), "status"); !errors.Is(err, unreadable) {
t.Fatalf("err = %v, want the config read error", err)
}
})
}
}
// Go's regexp stands in for git's extended regex here; the patterns use nothing
// the two treat differently.
func TestConfigPatterns(t *testing.T) {
local := regexp.MustCompile(localConfigPattern)
worktree := regexp.MustCompile(worktreeConfigPattern)
for _, key := range []string{
"filter.lfs.clean", "filter.lfs.smudge", "filter.lfs.process", "filter.a.b.clean",
"diff.word.textconv", "diff.tool.command", "diff.external",
} {
if !local.MatchString(key) || !worktree.MatchString(key) {
t.Errorf("%s is not matched in both scopes", key)
}
}
if !local.MatchString(worktreeConfigKey) || worktree.MatchString(worktreeConfigKey) {
t.Errorf("%s belongs to the local read only", worktreeConfigKey)
}
for _, key := range []string{
"filter.lfs.required", "diff.word.cachetextconv", "diff.word.binary",
"core.fsmonitor", "diff.external.x", "diff.renames", "extensions.worktreeconfigx",
} {
if local.MatchString(key) || worktree.MatchString(key) {
t.Errorf("%s is matched", key)
}
}
}
func TestParseConfigEntries(t *testing.T) {
got := parseConfigEntries([]byte("filter.x.clean\nrun this\nand that\x00extensions.worktreeconfig\x00"))
want := []configEntry{
{key: "filter.x.clean", value: "run this\nand that"},
{key: "extensions.worktreeconfig"},
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("entries = %+v, want %+v", got, want)
}
}
// Real git: a fresh repository with nothing configured, the same repository with
// per-worktree config turned on, and a directory that is not a repository. This
// pins the output format and exit codes readConfig relies on; the command keys
// themselves are covered above.
func TestCommandAgainstRealGit(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git not available")
}
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
t.Setenv("GIT_CONFIG_GLOBAL", os.DevNull)
t.Setenv("GIT_CONFIG_NOSYSTEM", "1")
repo := t.TempDir()
outside := t.TempDir()
// Both share a parent; stop git from finding an enclosing repository there.
t.Setenv("GIT_CEILING_DIRECTORIES", filepath.Dir(repo))
// Run as if from a hook in another repository. The setup below has to reach
// repo, as Command does, and never the repository these name.
decoy := t.TempDir()
t.Setenv("GIT_DIR", filepath.Join(decoy, ".git"))
t.Setenv("GIT_WORK_TREE", decoy)
decoyUntouched := func(step string) {
t.Helper()
if _, err := os.Stat(filepath.Join(decoy, ".git")); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("%s reached the repository GIT_DIR names instead of repo: %v", step, err)
}
}
git := func(args ...string) {
t.Helper()
command := exec.Command("git", append([]string{"-C", repo}, args...)...)
command.Env = environment()
if output, err := command.CombinedOutput(); err != nil {
t.Fatalf("git %v: %v: %s", args, err, output)
}
}
git("init", "-q")
decoyUntouched("git init")
gitRead := readConfig
var read []string
readConfig = func(ctx context.Context, dir string, env []string, scope string, pattern string) ([]configEntry, error) {
read = append(read, scope)
return gitRead(ctx, dir, env, scope, pattern)
}
t.Cleanup(func() { readConfig = gitRead })
command, err := Command(context.Background(), repo, "status", "--porcelain")
if err != nil {
t.Fatalf("Command in a plain repository: %v", err)
}
if output, err := command.CombinedOutput(); err != nil {
t.Fatalf("git status: %v: %s", err, output)
}
if !reflect.DeepEqual(read, []string{"--local"}) {
t.Fatalf("config reads in a plain repository = %q, want --local only", read)
}
if !gitAtLeast(t, 2, 20) {
t.Log("skipping the per-worktree case: it needs git 2.20 for extensions.worktreeConfig")
} else {
git("config", "extensions.worktreeConfig", "true")
decoyUntouched("git config")
read = nil
if _, err := Command(context.Background(), repo, "status"); err != nil {
t.Fatalf("Command with per-worktree config on: %v", err)
}
if !reflect.DeepEqual(read, []string{"--local", "--worktree"}) {
t.Fatalf("config reads with per-worktree config on = %q, want --local then --worktree", read)
}
}
if _, err := Command(context.Background(), outside, "status"); err == nil {
t.Fatal("Command outside a repository succeeded")
}
}
// gitAtLeast reports whether the git on PATH is at least major.minor.
func gitAtLeast(t *testing.T, major, minor int) bool {
t.Helper()
output, err := exec.Command("git", "version").Output()
if err != nil {
t.Fatalf("git version: %v", err)
}
var gotMajor, gotMinor int
if _, err := fmt.Sscanf(strings.TrimPrefix(strings.TrimSpace(string(output)), "git version "), "%d.%d", &gotMajor, &gotMinor); err != nil {
t.Fatalf("parse %q: %v", output, err)
}
return gotMajor > major || (gotMajor == major && gotMinor >= minor)
}
+2 -2
View File
@@ -22,7 +22,7 @@ func TestUnixInstallerScriptMatchesReleaseContracts(t *testing.T) {
}
containsAll(t, script, []string{
"set -euo pipefail",
`ZERO_REPO="${ZERO_REPO:-Gitlawb/zero}"`,
`ZERO_REPO="${ZERO_REPO:-Twigpine/zero}"`,
`ZERO_INSTALL_DIR="${ZERO_INSTALL_DIR:-$HOME/.local/bin}"`,
`archive_name="zero-v${version}-${platform}-${arch}.tar.gz"`,
`checksum_name="${archive_name}.sha256"`,
@@ -220,7 +220,7 @@ func runUnixInstaller(t *testing.T, fixture unixInstallFixture) (string, string,
command.Env = append(os.Environ(),
"PATH="+fixture.mockBin+string(os.PathListSeparator)+os.Getenv("PATH"),
"ZERO_GITHUB_BASE_URL=https://example.test",
"ZERO_REPO=Gitlawb/zero",
"ZERO_REPO=Twigpine/zero",
)
var stdout bytes.Buffer
var stderr bytes.Buffer
Loaded 100 of 247 files, more files were not shown because too many files have changed in this diff. Show more