#1193 changed httpStatusError on main: a response to a request that
carried the injected AI/ML API key drops its body, and a 401 from that
server gets an authentication hint. Both are kept, with this branch's
truncation marking. The hint now goes before the body rather than after
it, so the retained body stays at the end of the message, which is the
only place the display repairs a cut credential. A new unit test pins
that order.
Two tests moved with main:
- #1189 made configured secrets match across separator bytes, so a
credential split by a bare ESC is now redacted with the reason kept
instead of withheld. The escape test expects that, and its real-HTTP
case now uses a CSI that eats a credential byte, which still withholds.
- #1193 added the opt-in aimlapi default, which sorts first and is
disabled, so the startup-split collision test skips disabled defaults.
* feat(aimlapi): shared service key and attribution for aimlapi.com services
Adds the pieces the Toolbox and MCP integrations share:
- internal/aimlapi: X-AIMLAPI-Source (agent/zero), the fixed Toolbox and
MCP URLs, and an exact-hostname check (https, default port, no
userinfo) for api/tools/mcp.aimlapi.com before the key or attribution
headers are attached. The partner id still comes from ResolvePartnerID.
- internal/config: AIMLAPIServiceKey picks the key from the aimlapi
profile (or AIMLAPI_API_KEY without one), reading the credential
store, and withholds it when any aimlapi-keyed profile points at a
proxy base URL or AIMLAPI_INFERENCE_URL is overridden. Keys that are
not a single token are refused.
* feat(mcp): AI/ML API MCP server as an opt-in built-in default
Seeds `aimlapi` (https://mcp.aimlapi.com/mcp, streamable HTTP) next to
Exa as a built-in MCP server that ships disabled: its tool runs and
generations are billed to the user's AI/ML API balance. The disable is
marked as explicitly set, so a project config cannot lift it; the user
turns it on with `zero mcp enable aimlapi`, which now writes
`"disabled": false` for a default that ships off (and `zero mcp add
aimlapi` does the same). `zero doctor` reports the untouched default as
a pass rather than a warning.
The MCP client attaches the AI/ML API key and the X-AIMLAPI-Source /
X-AIMLAPI-Partner-ID headers in one place (internal/mcp/aimlapi.go),
called from the three header-application points. Both apply only to a
request whose own URL is exactly https://mcp.aimlapi.com on the default
port, whatever the server is named. The key is resolved for every
request through config.LoadAIMLAPIServiceKey, so switching the aimlapi
provider to a proxy, or a key that is not a single token, means no
Authorization header on the next request. It is never put in config,
the server identity used for OAuth token binding, list output or errors.
A user Authorization header, `"auth": "oauth"`, or a project-touched
server goes without it. A 401 from that host explains how to add a key
or sign in with `zero mcp oauth login`.
* fix(aimlapi): validate whitespace and preserve store errors
* fix(mcp): tailor unauthorized hints to credential policy
* docs(mcp): clarify automatic credential eligibility
* test(mcp): cover edited disabled built-in defaults
* fix(aimlapi): report store reads after proxy eligibility checks
* docs(aimlapi): document MCP key precedence and clarify 401 hints
Operator copy now matches normalizeProvider resolution (inline key,
apiKeyEnv, then credential store) and warns that AIMLAPI_API_KEY can
bill MCP before a stored profile key. SECURITY and service-key godoc
stay aligned. 401 hints no longer imply a proxy-only failure mode.
Adds a regression test that resolved profile keys win over the store.
Co-authored-by: Cursor <[email protected]>
* fix(mcp): keep the aimlapi key off redirects and error text, persist enable
Redirect hops: net/http copies the first request's headers onto every
redirect hop without calling the header hooks, so the automatic AI/ML API
key and attribution followed a same-origin hop that fails the exact-URL
rule (userinfo) and an eligible hop reused the first hop's key. The
redirect guard now restores the server's own values for those headers and
applies the per-request rule again.
Error text: httpStatusError copied up to 1 KiB of the response body into
the error, so a body echoing the injected key (or its prefix, when cut by
the read limit) reached CLI output. When the request carried the automatic
key, the body is now omitted instead of matched.
Enable persistence: MCPServerConfig had no serializer and Disabled is
omitempty, so any read-modify-write of config.json (SetTheme,
SetActiveProvider, ...) dropped `"disabled": false` and the shipped
disable turned aimlapi off again. MarshalJSON now writes the flag whenever
it was explicitly set; `zero mcp add` keeps writing only a disable.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Stan <[email protected]>
Co-authored-by: Hugo <[email protected]>
Co-authored-by: Cursor <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
* feat(web-fetch): AI/ML API Toolbox as an opt-in web_fetch backend
With ZERO_WEBFETCH_PROVIDER=aimlapi, web_fetch fetches the page as Markdown
through the Toolbox tool search1api/crawl instead of a direct GET. A key
alone never selects it; unset or unknown values keep the direct fetch.
- The URL checks (pre-permission and resolved host) run before the URL is
sent, so loopback, private and metadata URLs never reach the crawler.
- The key is resolved on every call through LoadAIMLAPIServiceKey; a
missing, proxied or invalid key is an error with no request and no
fallback. A failed run (is_error, failed status, no content) is also an
error.
- Requests go only to aimlapi.ToolboxBaseURL, checked with
IsAttributedURL before the key and attribution headers are attached;
redirects are refused. The client uses the default transport, so the
proxy environment applies and the target-site SSRF pin does not.
- Every run carries max_cost_usd 0.005, wait_ms 30000 and a fresh
idempotency key. 202 runs are polled (hint clamped to 250 ms..5 s,
120 s overall). 429/502/503/504 are retried with the identical body,
honouring Retry-After (capped at 10 s); max_cost_exceeded is not.
- Output keeps the result shape with a Backend line, respects max_bytes,
refuses format raw, and scrubs the key and redacts all Toolbox text.
* fix(web-fetch): honor poll retries and reject raw before permission
* docs(web-fetch): explain the bounded run response
* test(web-fetch): rename the Toolbox fake that collides with web_search tests
#1189 added a fakeToolbox type with a recorded method to the tools test
package. The web_fetch Toolbox tests declared their own fakeToolbox with a
different shape, so the package's tests no longer compiled once both were on
main. Rename the web_fetch one to webFetchFakeToolbox, matching the file's
webFetch prefix. Test-only; no behavior change.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* fix(web-fetch): describe the aimlapi backend in the tool schema
With ZERO_WEBFETCH_PROVIDER=aimlapi the max_bytes and format descriptions
still promised direct-fetch behavior: raw body bytes before conversion and
HTML conversion with a raw option. The Toolbox backend returns crawled
Markdown, applies max_bytes to that Markdown and refuses format "raw", as
the README says. Say so in both descriptions so the model sees the same
contract.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* fix(web-fetch): keep the AI/ML API key out of Toolbox errors and output
The Toolbox backend scrubbed the key with a literal string replace. A key
echoed back split by an invisible character (NUL, ZWSP) passed it on every
path the model sees: error bodies, run errors, page content and the run_id
meta field. scrubWebFetchKey now uses scrubAIMLAPIResult, the same
split-aware redaction the web_search backend uses.
A failed Toolbox request also returned the *url.Error from client.Do. That
error quotes the request URL, and a polling URL carries the run id
percent-escaped, a form no key scrubbing can match. send now reports the
cause without the URL, as web_search does.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* docs(web-fetch): state only established billing for Toolbox runs
The README said every fetch is a billed Toolbox run, while the next
sentence says a run over max_cost_usd is refused and not charged, and a
code comment said a 404 comes back as an unbilled is_error run. The
AI/ML API Toolbox documentation says you pay for runs but not whether a
failed run is billed. Both now say only that Toolbox runs are billed.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Stan <[email protected]>
Co-authored-by: Hugo <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
* feat(aimlapi): shared service key and attribution for aimlapi.com services
Adds the pieces the Toolbox and MCP integrations share:
- internal/aimlapi: X-AIMLAPI-Source (agent/zero), the fixed Toolbox and
MCP URLs, and an exact-hostname check (https, default port, no
userinfo) for api/tools/mcp.aimlapi.com before the key or attribution
headers are attached. The partner id still comes from ResolvePartnerID.
- internal/config: AIMLAPIServiceKey picks the key from the aimlapi
profile (or AIMLAPI_API_KEY without one), reading the credential
store, and withholds it when any aimlapi-keyed profile points at a
proxy base URL or AIMLAPI_INFERENCE_URL is overridden. Keys that are
not a single token are refused.
* feat(web-search): AI/ML API Toolbox as an opt-in web_search backend
ZERO_WEBSEARCH_PROVIDER=aimlapi runs the Toolbox tool web/search at the
fixed https://tools.aimlapi.com/v1/tools URL. It is selected only by that
setting; an AI/ML API key alone never enables it or registers web_search.
- The key comes from config.LoadAIMLAPIServiceKey on every search; a
missing, proxy-profile or invalid key fails before any request.
ZERO_WEBSEARCH_BASE_URL and ZERO_WEBSEARCH_API_KEY are ignored.
- The key and attribution headers are attached only after
aimlapi.IsAttributedURL accepts the URL, and redirects are refused.
- Every run carries max_cost_usd 0.025 and a fresh idempotency key;
limit maps to max_results and domains to include_domains, with the
local domain filter kept.
- A queued run (202) is polled until it ends, honouring
next_poll_hint_ms clamped to 250 ms..5 s. This backend gets a 30 s
budget with wait_ms 20 s; the generic backends keep 10 s.
- Non-2xx responses, is_error runs, failed/stopped/timed_out runs and
malformed bodies are errors carrying the error code and request id,
with the key scrubbed.
* fix(aimlapi): preserve store errors and isolate Windows config tests
* fix(web-search): redact results and preserve domain errors
* docs(web-search): clarify minimum search charge
* fix(aimlapi): reject Unicode whitespace in service keys
* fix(aimlapi): report store reads after proxy eligibility checks
* fix(web-search): keep plain no-results for unscoped backends
Only a backend that applied the domain allowlist itself (the AI/ML API
Toolbox) should turn an empty result into the "no results matched
domains" error. A generic backend never saw the allowlist, so an empty
result from it is an ordinary "No results for query", as before this
branch.
Also clear ZERO_WEBSEARCH_PROVIDER in the unconfigured registry test:
ZERO_WEBSEARCH_PROVIDER=aimlapi alone now registers web_search, so an
exported value flipped the test's oracle.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* fix(aimlapi): resolve the Toolbox key without an active provider
A user config that lists several providers but marks none active (or
names a removed one) makes Resolve return ErrNoActiveProvider together
with the normalized profiles. The app keeps running on that partial
config, but LoadAIMLAPIServiceKey stopped on the error, so web_search
failed even with an aimlapi profile or AIMLAPI_API_KEY set. Look the
key up in the partial config instead; the proxy check still scans every
resolved profile, and all other resolve errors are still returned.
Also state the key rules in the README as implemented: user config
only (project .zero providers are not used), which aimlapi profile is
used, and that any aimlapi profile or any profile reading
AIMLAPI_API_KEY on a non-canonical base URL withholds the key.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* fix(aimlapi): withhold the Toolbox key for dropped proxy profiles
Resolve drops a non-active profile it cannot normalize (an unknown or
unsupported catalog entry, a missing base URL, ...), so the proxy check
never saw it. A dropped profile that reads AIMLAPI_API_KEY from a proxy
still shows the key belongs to that proxy, yet the key was sent to
tools.aimlapi.com. LoadAIMLAPIServiceKey now also checks the configured
user profiles, merged the same way Resolve merges them, and withholds
the key when a dropped profile carries it and is not explicitly on the
canonical base URL.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* test(web-search): pin the generic backend route in BASE_URL registration tests
defaultSearchBackend now checks ZERO_WEBSEARCH_PROVIDER=aimlapi before
ZERO_WEBSEARCH_BASE_URL. TestWebSearchRegisteredInCoreNetworkTools and
TestCoreNetworkToolsExposeSafetyMetadata set only the base URL and inherit
the provider from the process environment, so with an exported
ZERO_WEBSEARCH_PROVIDER=aimlapi they took the Toolbox route and still passed
even if generic base-URL registration broke. Clear the provider so they
exercise the generic route they describe, matching
TestCoreNetworkToolsOmitWebSearchWhenUnconfigured.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* fix(redaction): redact caller-supplied secrets split by invisible characters
RedactString replaced Options.ExtraSecretValues only as contiguous text,
and the gap-tolerant pass from #1067 covers only the built-in credential
shapes. An opaque key such as the AI/ML API service key, echoed by a
service with a NUL or zero-width space inside it, matched neither pass.
Both fragments then reached Toolbox web_search errors (before clipping)
and successful results sent to the model and transcript.
Match each caller-supplied secret with runs of the same invisible
separators allowed between its characters, right after the contiguous
replacement. Separators inside the match are covered; those beside it
stay. Text without a separator takes the old path unchanged.
Regressions: split explicit values in the redaction package, and split
keys in the Toolbox HTTP error, run error and successful-result paths.
Raised by Copilot on #1189.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* fix(redaction): claim each caller secret whole or split before shorter ones
RedactString replaced every ExtraSecretValues entry contiguously, longest
first, and only then ran the split match. A shorter secret inside a
longer one that was split by an invisible character was replaced first,
so "abc\x00def" with secrets abcdef and abc became "[REDACTED]\x00def"
and the rest of the longer secret stayed visible.
Run the contiguous and split match together per value, longest first,
so a longer value is claimed in either form before any shorter one.
Regressions cover a shorter secret at the start, end and middle of a
split longer one, and alongside it. Raised by CodeRabbit on #1189.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* fix(redaction): merge overlapping caller-secret occurrences before replacing
ExtraSecretValues were replaced one occurrence at a time, so each
replacement could consume bytes another occurrence needed. With secret
abcabc, "abc\x00abcabc" became "abc\x00[REDACTED]". A key whose first and
last characters match lost all but one character the same way
("xY7q[REDACTED]"), and two partly overlapping values left the
rest of one visible. Reordering the passes cannot fix this: the
contiguous and regexp replacements are both non-overlapping.
Find every occurrence of every value, whole or split by invisible
separators, in one separator-free copy of the text (linear KMP, all
overlapping starts), map the spans back, merge overlapping ones, and
replace each merged span once. Occurrences that only touch stay
separate replacements; separators beside a match stay; tab, newline
and carriage return are still not gaps. This honours the existing
TestRedactString_OverlappingExtraSecrets contract for both forms.
Raised by CodeRabbit on #1189.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* fix(web-search): keep the request URL out of Toolbox transport errors
When http.Client.Do failed, the backend scrubbed err.Error() from the
*url.Error, which quotes the request URL. A polling URL carries the run
id the Toolbox returned, percent-escaped, so a key echoed in that id
appeared as "Lp9%00Xr4" or "Lp9%E2%80%8BXr4". The key scrubber matches
the key whole or split by raw invisible separators, not percent-escapes,
so the key stayed recoverable in the web_search error.
Report only the cause inside the *url.Error. The message prefix, the
redirect refusal text and key scrubbing are unchanged. The regression
fails a polling GET after a queued run whose id carries a NUL- or
ZWSP-split key and checks the output has neither the polling URL nor
the key. Raised by CodeRabbit on #1189.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* fix(redaction): always redact a caller secret as written
Once the text held any invisible separator, redactExtraSecretValues
matched each caller secret only with separators removed from both. That
replaced the contiguous match instead of adding to it: a secret made
only of separators compacted to nothing and was skipped, and separators
at either end of a secret fell outside its match. RedactString("a\x00b")
with secret "\x00" returned the text unchanged, where main replaced it.
In the separator path, also record every occurrence of the secret as
written, then merge spans as before. Text without a separator takes the
unchanged path. Raised by CodeRabbit on #1189.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Stan <[email protected]>
Co-authored-by: Hugo <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Sandboxed tool runs in the agent tests keep runtime state and lease files
under os.UserCacheDir, so go test ./internal/agent/ left
zero/runtime/v1/*.lease files in the developer's real cache. TestMain
now points every per-user root at its temp dir via
testutil.IsolateUserDirs, as internal/tui does, and a guard test pins it.
Refs #1103
Signed-off-by: ShryukGrandhi <[email protected]>
* fix(agent): mark headless guardrail stops incomplete
The empty-turn and repeated-tool-failure guards returned the run as a
successful final answer even with RequireCompletionSignal enabled, so
zero exec reported exit 0 and a success run_end for work it had just
abandoned.
Set Result.Incomplete and a reason at both stops when the headless gate
is on. Thresholds, stop answers, retry behavior, interactive runs, and
--no-completion-gate are unchanged.
Fixes#1154
* fix(agent): count consecutive empty turns in stop reason
The headless no-output stop reason used result.Turns, which counts every
turn in the run, so a run with an earlier tool-call turn claimed "stopped
after 4 turns with no output" for a 3-turn empty streak. Use the guard's
emptyTurns counter and say "consecutive".
Pinned by a case with a successful tool turn before the empty streak.
* fix(chatgpt): refresh subscription defaults and compatibility version
Replace retired preset suggestions with vendor-documented Sol, Terra, Astra, and Luna IDs. Update the Codex discovery compatibility floor for Terra and verify that saved model selections are retained.
Refs Twigpine/zero#1177 (defaults portion)
* test(chatgpt): assert discovered model in compatibility probe
Verify the live response contains exactly gpt-future-coder in addition to checking the Codex compatibility query value.
Refs Twigpine/zero#1177
* docs(chatgpt): align Terra description with vendor guidance
TestScanSSEDataWithContextContentResetsOnData wrote a data line every 30ms against a 100ms idle timeout, so a ~70ms scheduling stall on a shared runner tripped the idle watchdog. Use a 500ms idle timeout (600ms content window) and 25 writes (~750ms), which keeps every gap far under the idle timeout while the run still outlasts the content window.
Signed-off-by: ShryukGrandhi <[email protected]>
* fix(sessions): capture freeform apply_patch checkpoints
Freeform apply_patch arguments are raw patch text, but both checkpoint
paths passed them to json.Unmarshal and dropped the error, so they found
no mutation targets and recorded no checkpoint. The tool still executed,
leaving rewind unable to restore the edit.
Resolve targets through a shared agent.CheckpointTargets helper that
prepares freeform input on the registered built-in apply_patch, exactly
as executeToolCall does. This keeps absolute patch headers
workspace-relative (rewriting them and setting cwd) instead of handing
absolute paths to the checkpoint layer. Non-freeform calls now use the
same tolerant decoder as execution, so the two cannot disagree.
Covers the CLI, spec-draft, and TUI capture paths.
Fixes#1162
* test(sessions): canonicalize freeform absolute-header fixtures
t.TempDir returns the symlinked /var spelling on macOS and short names on
Windows, while the apply_patch tool resolves the physical root. The
absolute-header cases must build the header from the resolved root, as the
repo's cross-platform test guidance requires.
A finishing member's task reads Done before its slot is freed: the
supervisor completes the task, removes the member and only then frees
the slot in afterExit. The test waited for Done == 5 and checked the
running count straight after, so a busy runner could see one slot still
held. It now waits for the drain itself.
- Redact what Login stores for refresh: the registered client secret,
and the stored token endpoint, taken apart by the same URL rules as
configured endpoints. Both now feed the credential fingerprint on the
capture and display sides, so a rotated secret or a changed endpoint
withholds an older reason.
- Redact credential-named variables a stdio child inherits from this
process, with the readability floor, since a name match in the
inherited environment is as often a switch as a secret.
- Classify each query value by its decoded key and decode the pairs one
at a time, so an escaped short credential keeps its classification
and a value beside an unparsable pair keeps its decoded spelling.
- Treat a failed body read as a cut, so a server that drops the
connection part way through a credential no longer leaves an
unmarked prefix.
- Withhold the reason when a terminal escape would eat part of a
credential, which left every other byte of it on screen.
- Give the startup wiring test its own token store, so it no longer
migrates or creates the user's token files.
Reported by @jatmn.
The commands printed under each server in the /mcp transcript were built
from the trimmed display name, while check, enable, disable and remove
look the server up by its exact config key. For a padded key such as
" docs " the hint reported "not configured", and for an alias pair it
addressed the other entry. The manager overlay already dispatches with
ConfigKey; the transcript hints now use it too.
A key with space padding is double-quoted, which keeps the spaces in
POSIX shells, PowerShell, cmd and /mcp. Padding that cannot be typed back
from a terminal line, such as a tab, gets no key-addressed hint. oauth
login keeps the trimmed name, because it matches the normalized server
list rather than the config map.
TestExecCommandTTYSkipsInteractiveBlock runs ssh to a bogus host under
tty with a 250ms yield. When ssh is still resolving the host after the
yield, the session keeps running in the test's directory and nothing
stops it, so on Windows t.TempDir's cleanup fails with a sharing
violation. It failed Smoke (windows-latest) on a README-only PR.
Stop the session in a cleanup, and build the tool on execTestRoot,
which retries the removal while Windows still holds the directory right
after the stop, as the other exec tests that leave a session do.
Adds Vercel beside GMI Cloud in the Partners table of both READMEs,
with Vercel's official logotype in light and dark variants, taken
unchanged from its brand page apart from a trailing newline.
* docs: add Vercel OSS Program badge
* docs: add the Vercel OSS Program badge to README_ZH too
The two READMEs carry the same badge row, so the Chinese one gets the
badge as well. Both now list alt before src, like the other badges in
the row.
* feat(skills): install a skill's bundled files and point the skill tool at them
A skill was only its SKILL.md: install copied that one file, and the skill
tool returned only the markdown body, so a skill whose instructions run its
own scripts was broken by installing it, and a hand-placed skill directory
gave the model no way to find the files beside it.
Install now copies the whole skill directory, leaving out hidden entries
(.git, .env) and symlinks, which it neither follows nor copies but reports.
A script stays executable; everything else is installed 0644. A tree over
1,000 files or 32 MiB is refused. skills.lock records a treeHash beside the
SKILL.md hash; skill info reports drift in bundled files, and an entry from
before this is still checked on SKILL.md alone. A reinstall that changed
only a bundled file is reported as an update.
Both skill tools end their output with the skill's directory and a list of
up to 50 bundled files when there are any; a SKILL.md-only skill is
returned unchanged.
Closes#584
* fix(skills): compare a reinstall over an old lock entry with SKILL.md alone
A lock entry written before tree hashes has no treeHash, so the reinstall
compared the new tree hash with an empty string and always counted as an
update. Re-adding an unchanged skill that is only a SKILL.md printed
"Updated" and "the files bundled with it changed" when there were none.
That entry comes from an install that copied SKILL.md alone, so compare
with that: it is an update when SKILL.md changed or the reinstall now
brings bundled files. InstallResult gains previousTreeHash, and the CLI
uses it to say the bundled files are now installed rather than changed
on the first reinstall over such an entry.
* fix(skills): match the tree hash when recovering an interrupted install
lockReconciler matched both trees of an interrupted workspace on the
SKILL.md hash alone. A reinstall that changes only a bundled file keeps
SKILL.md, so a kill between the tree swap and the lockfile publish left
two trees carrying the recorded hash, and recovery kept the new one and
retired the backup the lockfile still described.
A tree now matches an entry with a tree hash only when both hashes
match. An entry from before tree hashes is still matched on SKILL.md
alone. A tree hash that cannot be computed is returned as an error, the
way an unreadable SKILL.md already is, so recovery decides nothing about
a tree it could not read.
Also bring the package comment up to date: install copies the skill
directory, not SKILL.md alone.
* test(tui): pin the cwd in tests that assert text is absent from the view
Nine tests built their model with an empty Options.Cwd, so newModel took
the package directory and the title bar showed the developer's real
branch and checkout path. Each then asserted that some text was absent
from the whole view, and failed wherever that branch or path happened to
contain it: TestSuggestionOverlayRenders on a branch with /model in its
name, TestSuggestionOverlayCapsRowsWithoutMoreText on a branch or path
containing "more" or "compact", the provider wizard tests on "offline",
"chatgpt" or "gpt-4.1", and so on.
They now pin Options.Cwd to isolatedCwd, a path that does not exist, so
no branch is read and the path is the same on every machine. A guard
test checks that isolatedCwd stays outside any git checkout.
* test(tui): make isolatedCwd fully qualified and absent by construction
The fixed /workspace/zero path could exist on some machine, a dev
container for one, and newModel would then read whatever is there. On
Windows it was also only drive-relative: \workspace\zero is not a fully
qualified path.
isolatedCwd is now a child of the home TestMain isolates that nothing
creates, so it is fully qualified, absent and outside any checkout on
every platform, and the title bar shows ~/not-a-checkout everywhere. A
t.TempDir path would have put the user name and the test name into the
view. The guard test checks all three properties.
* test(tui): clear the branch in view-test models instead of trusting the cwd
isolatedCwd is a child of the isolated home, which lives under the
temporary directory. With TMPDIR inside a checkout, gitBranch walks up
from that absent path to the checkout and puts its branch in the title
bar, so the guard failed on an ordinary branch and a colliding branch
broke the nine view tests again.
newViewTestModel now builds those models: it sets the isolated cwd and
clears the branch after construction, since no path can promise there
is no checkout above it. The guard keeps only what a path can promise
(fully qualified and absent), and a new test puts the home inside a
checkout on feat/model-picker and checks that neither that branch nor
the real checkout path reaches the view.
* test(tui): assert no checkout path reaches the view in the enclosing-checkout test
The test checked the branch strings and that the isolated cwd shows,
but a view could show both the isolated cwd and a real checkout path
without failing it. It now also asserts that neither the enclosing
checkout nor the directory the tests run from appears in the view.
The variable turns off both the models.dev fetch and the cached overlay,
and four tests in modelsdev_test.go inherited it from the shell. With it
set, TestRefreshModelsDevCacheRejectsBadBodyWithoutClobbering and
TestDefaultModelEntriesAppliesFreshCache fail, and
TestCachedModelsDevProvidersIgnoresStaleCache and
TestCachedModelsDevProvidersRequiresOptIn pass without reaching the
check they are named for: the nil they expect comes from the variable.
Clear it in each of them, as the two sibling refresh tests already do.
GHSA-5r4p-p66f-jhc7 (moderate): hono's serveStatic decodes the request
path a second time, so middleware on static paths can be bypassed.
Versions 4.12.4 to 4.13.10 are affected and main pins 4.13.9 through
tuistory, which release packaging installs as a local-control helper.
npm update takes hono to 4.13.13, inside every range that asks for it.
package.json is unchanged and nothing else in the lock moves.
* fix(oauth): use the crash-safe secret store and sync token publications
The encrypted-file OAuth backend carried its own AES-GCM secret implementation that had drifted from internal/securefile: a crash during first login left <secret>.lock behind and every later Save timed out instead of reclaiming it, and the secret and token blobs were published without fsync.\n\nUse securefile.Crypter with an opt-in publication directory so the secret still publishes through the sandbox-denied .secret.publish directory, add file and directory barriers to the encrypted token blob, and extend securefile with the publication directory plus destination-directory sync.\n\nMove the concurrent-creation and wrong-sized-secret coverage to securefile, add a stale-lock regression through the OAuth store, and test publication-dir use, sync ordering, sync-failure preservation, and retry after a failed secret sync. The stale-lock test fails on the previous code.
* test(oauth): cover post-rename directory-sync failures
The existing failure tests inject a file sync error before the rename. Add coverage for a destination-directory barrier failure after the token or secret has been atomically replaced: the write must report the error and the newly published bytes must remain readable. Also cover the corresponding securefile secret path, including its expected size.
* fix(securefile): never clobber a newer lock when restoring a reclaimed one
reclaimStaleLock could rename a lock that had just been replaced, then restore the quarantined copy with os.Rename, replacing a third holder's new lock. Both holders could then publish different secrets for the same file. Restore with O_CREATE|O_EXCL and discard the quarantined copy when the destination is already held.
Extend the directory-sync failure test to prove a later caller actually loads the published secret, not just its size. Add restore tests for the free and already-held cases.
govulncheck now reports GO-2026-6809 (crypto/x509 distrusts compromised
intermediate certificates) against Go 1.26.9, reachable through
daemon.WriteFrame, so Security & Code Health fails on main and on every
open PR. It is fixed in 1.26.10.
CI and release builds read the toolchain from go.mod. The stated
source-build minimum in README.md, README_ZH.md and docs/INSTALL.md
moves with it.
runExec starts a background models.dev download unless
ZERO_DISABLE_MODELS_FETCH is set. TestRunExecImageAttachmentHistory moved
the user directories into a TempDir by hand but left the fetch on, so a
5.7 MB download landed in the TempDir while its cleanup ran, and the
macOS runner failed with "unlinkat ... directory not empty". The two
older image tests had no isolation and fetched into the real user cache.
Use isolateCLIUserState in all three, which also points the cache path
into the fixture and turns the fetch off.
* fix(tools): grep stops where a file's line breaks stop
scanGrepFile treated EOF after a trailing line break - and an empty file -
as the START of another line, and handed the matcher an empty line one
number past the real content. Every pattern that matches an empty string
then reported a match on a line no reader sees:
a.txt holds "x\n"; grep pattern "^$"
-> a.txt:2: (grep -c '' GNU-style reports no such line)
same file, pattern ".*", count mode
-> "2 matches found" (one real line, one phantom)
an empty file, pattern "^$"
-> a.txt:1: (the file has no lines at all)
read_file and the file tracker's countLines already state the rule the
scan violated: "the trailing newline does not open a line", and an empty
file has no lines. The scan now stops there too, so anchored and
empty-matching patterns can only match the lines the content carries.
Real matches, numbering, count-per-hit accounting for non-empty patterns,
file mode, glob and symlink handling are unchanged.
* test(tools): make the grep line-number parity test catch the phantom
The parity test matched only "needle", a pattern that cannot match the
empty line the old EOF branch invented, so its absence-of-line-4
assertion passed on the code being fixed. The pattern now also matches an
empty line, and the expected output is asserted exactly: on the pre-fix
scanner the same run reported
grep = "a.txt:2: needle two\na.txt:4: ",
want exactly the line read_file numbers 2
so the phantom is visible right beside the real line, and the test is
about the numbering claim it states. Also gofmt-aligns the case table
that made the CI formatting check fail.
Two credentials glued end to end share one recovering property: the second
one's trigger prefix is where redaction can re-anchor. For fixed-length
bodies that anchor is exact (AKIA{16} stops at the next key), which is the
case the anchored end-chaining always covered. For unbounded bodies it
isn't: github's [A-Za-z0-9]{36,} body eats the literal "ghp" of the token
glued behind it, so the first match ends PAST the second trigger, the scan
that re-anchors at the span end finds nothing there, and the second
credential leaves the output as +_bbbb... / .eyJzdWIi... plaintext:
RedactString("ghp_aaa..36a..ghp_bbb..36b..")
-> "[REDACTED]_bbbb......" (second token's body printed in full)
RedactString(jwtA+jwtB)
-> "[REDACTED].eyJzdWIiOiI5ODc2...SA" (second token's payload+sig)
The same holds across shapes (a github key directly feeding "sk-ant-..."
through its body) and for three tokens in one run.
redactAdjacent now follows the run a match starts: at each boundary it
re-binds the match at the EARLIEST trigger occurrence inside the span that
stills validates as its shape (the head before the trigger must match the
anchored pattern exactly, and the run chained from the trigger must reach
past the span, so a mid-body lookalike and a mid-span cut that recovers
nothing leave the span alone), and continues under whichever shape matched
the swallowed trigger. Candidate examination is capped at
maxTriggerOccurrences per span so body spam of trigger lookalikes costs
what the span costs, and the first validating cut wins.
End-chaining for fixed-length bodies, single-credential shapes, custom
replacement templates, and the split path's announced no-op-for-contiguous
text guarantee are all unchanged; the regression tests pin the glued pairs
that previously leaked plus the AWS chaining and lone-token cases.
TestShortConfiguredValuesDoNotShredTheMessage checked only that
"connection refused" survived, so it passed just as well with the 1 and
the true redacted, which are the values it exists to protect. It now
compares the whole message.
The C1 control fixture held a raw U+0090, which staticcheck reports as
ST1018. It is written as an escape now; the input is the same.
#1149 was opened before #1105 moved the module to github.com/Twigpine/zero
and merged right after it, so its two imports of internal/gitsafe still
used the old path. Nothing provides github.com/Gitlawb/zero any more, so
main stopped building at 2ffe1831.
* fix(sessions): preserve image attachment summaries on resume
Persist TUI-compatible attachment counts for ACP and exec user events.
Restore readable markers in live history, session replay and resumed
prompts without storing or resending historical image data.
Cover image-only and mixed prompts, restart recovery, malformed metadata
and the aborted-turn boundary with regression tests.
Fixes#1135
* fix(workspaceindex): follow the current module path
Use github.com/Twigpine/zero/internal/gitsafe in the ignore scanner and its
tests. The upstream module rename preceded the scanner's gitsafe change,
leaving these two imports on the old path and preventing the rebased tree
from compiling.
* fix(sessions): count only images accepted by the model
Take ACP and exec attachment counts after the vision capability gate,
matching the TUI's persisted attachment semantics. Update the ignored-image
regressions to require zero saved images and no historical image marker.
Refs #1135
main moved the module to github.com/Twigpine/zero in #1105. Rename this
branch's imports to match, including the two in internal/workspaceindex
that main itself still carries until #1153 lands.
* fix(workspaceindex): run Scan's git calls through gitsafe
Scan asks git for ignored paths on every turn with its own gitCommand, which
turned off core.fsmonitor and dropped the repository-location variables but
still let a clean filter or diff driver from the repository's own config run
during status. It now goes through gitsafe, like the TUI and the scratch-file
warning: hooks are off as well, and a repository whose config defines a filter
or diff command takes the non-git path. The status call also passes
--ignore-submodules=dirty.
Scan makes two git calls, so gitsafe gains Check, which reads the repository
config once and builds several commands from it. Command is now Check plus one
command, and each command gets its own copy of the environment.
* test(workspaceindex): pin Scan's fallback when gitsafe refuses the repository
A repository whose own config defines a filter or diff command is refused by
gitsafe, and Scan then takes the non-git path. Scan reaches gitsafe.Check
through a package variable now, so a test can stand in for such a repository
without configuring one that names a command, and
TestScanFallsBackWhenGitsafeRefusesTheRepository checks both sides: with git
consulted, .gitignore decides and the fixed list does not apply; refused, the
fixed list applies and .gitignore is not read.
Update the Go module path, imports, install scripts, docs, workflows,
packaging and action defaults from Gitlawb/zero to Twigpine/zero. The npm
scope (already @twigpine/zero on main, with its @gitlawb/zero migration
notes), LICENSE, action author, the OpenGateway provider and historical
CHANGELOG links are unchanged.
The X-AIMLAPI-Integration-Repo header keeps Gitlawb/zero: it is what
AIMLAPI sees for attribution, so changing it needs a maintainer's yes.
docs/INSTALL.md keeps `go install github.com/Gitlawb/zero/...`, because
every tagged release so far declares that module path. The Homebrew
formula comment names the Twigpine/homebrew-tap tap.
Co-authored-by: Claude Opus 5.5 <[email protected]>
* feat(sandbox): support self-exec pattern to eliminate separate helper binary
Linux sandboxing previously required distributing a separate 'zero-linux-sandbox'
binary and looking it up on PATH.
This wires an internal '__sandbox-helper' entrypoint directly in cmd/zero/main.go
and configures findLinuxSandboxHelperCommand to fallback to os.Executable(),
enabling single-binary zero distributions with fully functional sandboxing.
* fix(sandbox): prefer PATH helper before self-exec fallback
Colocated zero-linux-sandbox stays first. A standalone helper on PATH
is used next. Self-exec of the main zero binary is only the last
fallback, so an installed helper is not ignored.
* feat(sandbox): clean formatting in main.go and add self-exec unit tests
* feat(sandbox): insert __sandbox-helper verb in bwrap plan for self-exec binary
* fix(sandbox): drive Linux selection from the self-exec helper resolver
Availability and command-plan construction now share the same helper
resolution and keep the __sandbox-helper prefix on the backend, so a
main-only install with bwrap reaches native sandboxing.
* fix(sandbox): self-exec only the zero main binary, not test binaries
* fix(sandbox): carry standalone vs self-exec identity into the inner bwrap plan
A versioned physical helper (zero-linux-sandbox-v1) must not receive
__sandbox-helper. The inner plan uses an explicit SelfExec flag, threaded
from argv, instead of guessing from filepath.Base.
* docs(install): describe self-exec as the last helper fallback
Self-execution runs only when neither a colocated nor a PATH
zero-linux-sandbox is available.
* docs(install): state colocated, PATH, then self-exec helper order
---------
Co-authored-by: cl-ment <[email protected]>
* perf(redaction): add fast-path byte checks to eliminate regex allocations on clean strings
Evaluating cascading regular expressions across every log line, tool output,
and session event incurred ~23 µs and 51 heap allocations even on strings with
zero secrets.
This introduces fast-path substring checks before invoking expensive regex
substitutions, yielding a 70x speedup and 0 B/op heap allocation on clean
strings.
* test(redaction): lengthen ghp_ fixtures and cover conditional gates
GitHub classic tokens need 36 characters after ghp_. Benchmarks and
shape tests now use a matching fixture. A table-driven test checks each
fast-path gate, including near-misses and case-insensitive Authorization.
* perf(redaction): structure triggered patterns and avoid allocation in header case checks
* test(redaction): cover secret-header and URL-password gates
Keep the redaction marker literal after url.URL rewrites userinfo, so
callers still see [REDACTED] instead of a percent-encoded form.
* fix(redaction): unescape only the generated URL password
A path or query that already contains the encoded redaction marker
must stay encoded. Only the userinfo password is rewritten in the
clear [REDACTED] form.
* fix(redaction): re-escape URL username after parse
Username() is decoded. Re-encoding with url.User keeps control bytes
out of the redacted URL so a %0AAuthorization userinfo cannot inject
a header after the header pass.
* fix(redaction): fail closed on password URLs without a host
* test(redaction): isolate query-gate coverage from assign and text secrets
* fix(redaction): fold Unicode U+017F and U+212A in containsCaseInsensitive
Go (?i) regular expressions apply Unicode simple case folding, where U+017F (ſ) folds to 's' and U+212A (K) folds to 'k'. Teach containsCaseInsensitive to fold these runes to ASCII lowercase with zero allocations so case-insensitive header gates cannot be bypassed by Unicode-folded equivalents.
---------
Co-authored-by: cl-ment <[email protected]>
* 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.
* 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.
* 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.
* 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.
* 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.
* 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.
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.
* 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.
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.
* 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
* 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
* 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.
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
* 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]>
* 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]>
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]>
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]>
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.
* 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.
FixesGitlawb/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]>
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.
* 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]>
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.
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]>
* 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]>
* 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.
* 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.
* 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.
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]>
* 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.
FixesGitlawb/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]>
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]>
* 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.
* 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.
FixesGitlawb/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]>
* 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
* 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.
FixesGitlawb/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]>
* 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.
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.
* 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.
* 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.
* 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.
* 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.
* 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.
`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.
* 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.
* 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.
A server that normalizes base64 before echoing drops the padding, so the
message carries the body while exact-value redaction only knows the padded
spelling. The whole value already had its unpadded form; the forms a known
value is taken apart into did not, and "Bearer <base64>==" is the usual shape.
Known forms get it at any length, ambiguous ones only when what is left still
passes the readability floor, and stored tokens are treated like the rest.
Seven gaps from review, in two contracts.
Credential material through the failure display:
- One parser finds where a flag ends and its value begins, before anything
inside the value is read, and the failure collector, the Target row and the
URL collector all use it. A packed "--api-key <b64>==" or an attached
"-HName: <b64>==" was cut at its own padding, so the collector kept "=" and
the Target row printed the body.
- A positional endpoint is read as a URL. Cut at its first "=", the text in
front of it named a credential, so one reader printed the userinfo and the
other printed the whole element and masked the next one.
- The tail of a known authentication value keeps its provenance. "Bearer
s3cr3t" lost its six-byte token to the floor meant for ambiguous values.
- The escaped userinfo password is known, like the decoded one.
- Fragment values are collected, classified the way the Target row does.
- The HTTP client reports when it cut a response body, and the display repairs
a partial credential at a cut it did not make.
Configuration keys and runtime names:
- mcp check matches the registration outcome by the normalized runtime name, so
a padded key no longer reports a failed server as reachable.
- mcp enable validates the prospective state with the shared active-name rule
before writing. Disable and remove are never refused.
Regressions run the real producers (HTTP 500 through the network client, a
stdio child that echoes its argument) and check both /mcp surfaces.
sensitiveMCPArgValues collects by flag name, so a stdio bridge handed its
authenticated endpoint positionally ("mcp-remote https://...") or packed into
a value ("--url=https://...") contributed no redaction candidate. Those URLs
carry credentials exactly as raw.URL does, in a query key the operator named
or in userinfo, and when the child rejects its own invocation connectStdio
appends that stderr to the registration error this panel renders.
The display side already recognised all three shapes through
looksLikeMCPDisplayURLValue, which is how the two surfaces came to disagree:
the Target row replaced the opaque value while the failure reason directly
above it printed it intact, and both are persisted to the transcript.
mcpArgURLCandidates mirrors that recognition rather than inventing a second
policy, and its output goes through the existing mcpURLSecretValues with the
same known/ambiguous split raw.URL uses. Sharing the predicate is the contract:
a value the Target row treats as a URL is now necessarily a collection
candidate.
Reported by @jatmn.
refuseColliding compared key spellings and rejected every trimmed collision. That
is a second and weaker implementation of a rule the read and startup paths
already own: ValidateUniqueNames skips disabled entries, because registration
skips them and a disabled server claims no runtime identity.
So the write side refused combinations the running configuration accepts. With a
disabled " docs" configured, `zero mcp add docs ...` failed, and even a plain
update of the enabled "docs" was refused while that alias existed. It also could
not decide the inverse case at all, because it never received whether the
incoming server was itself disabled.
Build the prospective combined configuration and hand it to the shared rule,
rather than teaching the local copy the same exceptions. The invariant is then
identical on both sides by construction: two enabled keys resolving to one
canonical name are refused, and a disabled entry on either side does not block
the active server.
Covered in both directions, including updating the enabled entry while its
disabled alias remains, which is the case the old rule got wrong most quietly.
MCPServerView carried only the canonical runtime name. That is the right
spelling for display, for joining against skipped-failure records and for tool
counts, and it is deliberately not unique: ValidateUniqueNames accepts an
enabled "docs" beside a disabled " docs" because a disabled entry claims no
runtime identity.
The manager copied that name into its item, looked the selected server up again
by it, and passed it to check, enable, disable and remove. Those address the
configuration map by exact key. So a single padded key such as " docs "
rendered correctly and sent every action to a key that does not exist, and with
an alias pair both rows rendered as "docs", detail lookup returned whichever
came first, and an action chosen on the disabled row could operate on the
enabled entry.
One string was doing three jobs: display label, runtime join key, and
persistence identity. The first two are the same and stay on Name; the third is
now ConfigKey, the exact map key, and every action and lookup uses it.
The existing view expectations gained the new field rather than being loosened:
the value they now assert is the exact key each fixture is configured under.
The input cap returned the whole value and nothing else once a configured value
crossed 8 KiB, which made a work bound into a change of security semantics. An
Authorization value of "Bearer <opaque token>" longer than the cap stopped
offering the bare token as a candidate, so a failed server echoing only the
token matched nothing; an opaque token has no shape for the fallback redactor to
catch either, and the first 400 characters reached both /mcp render paths and
the persisted transcript.
Measured before the fix: an 8205-byte value yields 1 candidate with the bare
token absent, while the same shape at 71 bytes yields 2 with it present. The
threshold alone decided whether the credential was redactable.
Bound where a separator is LOOKED FOR instead of how long a value may be. Every
convention this walks, "<scheme> <credential>" and
"<header>: <scheme> <credential>", puts its separators in a short prefix, so
scanning that prefix keeps the cost fixed while the tails survive at any length.
The candidate count stays capped, and the delimiter-heavy cost regression that
motivated the original bound still passes.
The old oversized test asserted the defect as the contract: it required exactly
one candidate for a long value. It is replaced by tests that a long credential
still yields its token, that the header form yields both tails, and that the
scan boundary is where it says it is.
dropTrailingSecretPrefix runs on every /mcp state rebuild, once per configured
or stored credential, and longestPrefixSuffix built pattern+sentinel+text with
an []int over the whole thing. The candidate's length therefore drove the work,
and configured headers, env values, URL components, OAuth fields and token-store
values have no size limit. Three 2 MiB candidates measured 54.5 MiB and 33ms on
every rebuild, against a render budget that is nominally fixed. The raw-error
cap bounds the server-controlled message but not this pass, so a bound sized to
the attacker's input was no bound.
The longest prefix of the candidate that is a suffix of the text can be at most
len(text) long, so any longer prefix is unreachable and scanning it changes no
answer. Truncate the pattern to the text length before the search. The same
three candidates now measure under 1 MiB. A differential test checks the
truncated search against the definition across many inputs so the bound cannot
change a result, and it catches a one-byte-wrong bound.
ValidateUniqueNames deliberately accepts an enabled "docs" alongside a disabled
" docs": a disabled entry claims no runtime identity during registration.
canonicalMCPServers copied both into a map keyed by the trimmed name, so they
collided, and Go randomises map iteration. The before and after snapshots each
picked a winner independently, so an unrelated /mcp operation that left the
enabled entry untouched could compare it against the disabled alias, find them
different, and discard the failure. A server that was still unavailable then
showed as fine, on roughly one run in five.
Exclude disabled entries, the way NormalizeConfig does when it decides what to
register, so aging operates on the same active-server set. A canonical name
claimed by two ENABLED entries is ambiguous rather than arbitrary:
ValidateUniqueNames rejects that config, but if it arrives there is no way to
say which entry an observation was about, so the name is dropped and the
observation ages out with it.
Optional MCP registration runs on its own goroutine so a slow server cannot
delay the first response, which means its result arrives with no user input
behind it. Bubble Tea renders only in response to a message, so reporting late
failures through a getter was not enough on its own: a manager opened while an
optional server was still connecting kept showing the configuration-derived
enabled state until unrelated input or a resize happened to redraw it.
The startup's completion channel is now surfaced to the model, Init schedules a
wait on it, and the resulting message rebuilds the MCP view state. Startup stays
non-blocking, tool readiness is untouched, and unconfigured built-in defaults
still produce no startup warning.
The regression asserts on the rebuilt cache rather than on a render. Rendering
calls mcpViewState, which invalidates on demand, so an overlay drawn after the
message looks correct even when the handler does nothing: the first version of
this test passed with the rebuild deleted, which is the failure mode it exists
to catch.
Startup separates unconfigured built-in defaults from the servers the user asked
for, and the two halves are normalized by separate calls. A collision that
straddles the split is invisible to both: a user-configured " exa" is critical
while the built-in "exa" is optional, and they are one runtime server with two
panel rows sharing a failure, a tool count, and each other's redaction context.
The check moves out of NormalizeConfig into ValidateUniqueNames, which
NormalizeConfig still calls, and startup runs it on the merged configuration
before splitting.
Two structural problems behind the panel's redaction, both about identity.
The runtime name is an identity, so it has to be unique. Registration trims the
config key, so "docs" and " docs" were two configured entries and one runtime
server: they shared a tool count and a failure, map iteration decided which
configuration survived, and each row redacted that shared error with its own
candidate set, so the row that did not fail could print the other's credential.
NormalizeConfig now refuses two names that resolve to one identity, and the
config writer refuses a key that collides with an existing one, since validation
on the way in only sees the incoming server and cannot detect the collision. A
single padded name still works; trimming was never the problem.
The context that makes an error safe has to be recorded where the error is
produced. A skipped entry keeps the raw failure and is redacted at display time
against whatever the token store holds then, so the surface needs to know
whether that set is still the one that was hiding the credential. It was sampled
when the surface was built, which is after registration and after anything in
between could have rotated the store, and a 401 during connect refreshes the
bearer that the same attempt's error text quotes. SkippedServer now carries a
fingerprint sampled before connecting, and the panel prefers it over its own
sample.
Also: optional servers register on a background goroutine, and the runtime
wrapper returned nil for its skipped list unconditionally. Moving them off the
critical path is a scheduling decision, not a visibility one, so every one of
them rendered from configuration alone: enabled, unexplained, for a server that
never connected. The panel now pulls those failures and refreshes when one
arrives.
Startup now splits MCP into a critical set registered before the TUI launches
and an optional set, the unconfigured built-in defaults, registered on a
background goroutine. The critical branch only runs when something is
configured, and this test stubbed registration without configuring any server,
so after the rebase the stub was never called and the assertion failed against
a nil list.
It stubs resolveMCPConfig with a configured server now, which is what puts the
failure in the half startup registers synchronously and hands to the TUI.
Worth recording what this does NOT yet cover: an unconfigured default that
fails is registered asynchronously, so its skipped entry does not exist when
the model is constructed and never reaches the panel. That is the same
observation-timing boundary as the outstanding review finding about binding the
redaction context at capture, and it is fixed there rather than here.
Three findings with one shape: a classification made after the evidence for it
had already been discarded.
Header, environment and query values were flattened into bare strings before
the readability heuristic ran, so a value under a key that names it as a
credential went through the floor that exists to keep mode=sse and v=1 readable.
API_KEY=s3cr3t contributed no exact candidate, and a child echoing the value on
its own reached the panel and the transcript where generic shape matching has
nothing to recognise. Keys now travel with their values to the decision, and the
endpoint parser returns the key-classified parts separately from the ambiguous
ones. The userinfo password is classified by position, since no key names it.
The Target row recognised a flag packed with its value as sensitive, printed the
whole element verbatim, and redacted the NEXT argument instead, so the row under
the redacted reason carried the credential and blanked an unrelated flag. The
display now parses the packed form the way the collector already did.
And raw.Auth was being treated as credential material although it is the public
authentication MODE selector, whose only accepted value is the word the panel
itself displays. Every failure from the OAuth stack lost the token naming the
subsystem: "oauth: fetch authorization server metadata" became "[REDACTED]:
fetch authorization server metadata". That was invisible while ambiguous values
ran through the length floor, which discarded a five-character string on its
own; removing the floor for known provenance is what surfaced it, which is the
tell that the field was miscategorised rather than the floor load-bearing.
Five leaks, four of them the same mistake in different places: a rule that was
sized to a constant, or to a heuristic, instead of to the thing it was guarding.
The tail repair inspected a fixed 4 KiB at the end of the rendered text, so a
credential beginning before that window could never be matched: the inspected
span starts partway through it, and a middle is not a prefix. Measured here, a
6000-byte value positioned across the cut left 5000 of its bytes on the panel.
The search is now sized to the credential and answered in one KMP pass, so the
work is linear in the operator's own configured value rather than in anything
the remote server sent. The flat eight-byte floor went with it: seven bytes of
an eight-byte credential is the credential, so the rule is proportional as well
as absolute.
Values already known by provenance to be secret, an OAuth client secret and the
value of a credential-bearing flag, were routed through the ambiguity heuristic
that exists to keep v=1 and mode=sse readable, and were discarded for being
short. They skip it now; genuinely ambiguous values still do not.
The OAuth endpoints were outside the candidate set entirely, although a refresh
posts to TokenEndpoint during startup and a dial failure comes back wrapped in
a url.Error that keeps the path and query.
The collector and the target row each derived the accepted header spellings
separately and neither recognised the conventional attached form, so the value
was missing from the redaction set and printed verbatim one row below. Both go
through one parser now.
And the retained startup failure kept the raw error, re-redacted on every render
from whatever the token store held at that moment, so logging out deleted the
bearer that was hiding itself and the next render wrote it into the panel and
the transcript. The observation now carries a fingerprint of the material that
made it safe, and withholds the reason rather than re-deriving a weaker one. A
fingerprint, not a copy: a second plaintext store would be its own problem.
Three things, all in the failure-display pipeline.
The bound cuts the raw error before redaction, and redaction matches whole
values, so a credential the cut sliced in half matched nothing and its surviving
prefix was ordinary text. The fixed overlap made that need a secret longer than
the window, and nothing caps a configured header, URL, environment or stored
token value, so it was a configuration away rather than impossible. A server can
also spend the raw budget on control sequences that later vanish, putting the
start of the credential right at the cut and its prefix at the top of the panel.
Only the final cut can split anything, so the fix looks at the tail alone and
drops any run that begins a configured secret. It costs one comparison per
secret against a bounded window and does not care how long the secret is, which
is the property a wider overlap could never give.
credentialCandidates walked every suffix after every space or colon and kept
them all, so a delimiter-heavy value produced thousands of candidates and
RedactString ran a replacement pass for each. That expansion is on the config
side, outside the raw-error bound, so the cost did not depend on the server's
error being long: a value with 4000 delimiters yielded 7999 candidates however
short the failure was. Input size and candidate count are bounded now. The value
itself is still redacted whole; only the suffix enumeration is dropped, and the
tails exist for one narrow case, a header configured as "Bearer <token>" whose
server echoes only the token.
And the path was treated as an identifier while query and userinfo were treated
as secret-bearing. The configuration contract accepts an arbitrary HTTP or SSE
path and opaque path-segment credentials are an ordinary endpoint convention.
This needs no crafted response body: a failing http.Client.Do returns a
*url.Error carrying the request URL, which the failed-server path wraps and
renders, so the token reached the reason, the panel and the transcript, with the
target row showing it too. Opaque segments are collected for redaction and
replaced in the displayed target, by the same length floor used elsewhere, so a
route like /v1/sse survives and the operator can still tell what failed.
One note on the first test I wrote for the oversized case: it used an
"sk-live-" prefix, which the generic patterns catch whatever the bound does, so
it passed with the fix removed and proved nothing. The fixture is opaque now and
fails with a 2800-character prefix reaching the panel.
The existing regressions call redactMCPFailureReason directly, which proves the
helper and nothing else. BuildMCPViewState is the path the panel and the
transcript actually take, and it is where a second surface could reintroduce
either problem: the row renderer inspects the raw query field separately from
the error pipeline.
Four cases through the entry point: an arbitrary percent-encoded query key,
percent-encoded userinfo, a multi-megabyte failure, and a multi-megabyte
configured secret. Each one fails without its fix. Reverting the fixed window
reproduces the 65546-byte retained error against a 20480-byte budget, and
reverting the ingress bound puts state building back over six seconds.
One honest note on coverage: the percent-encoded userinfo PASSWORD case passes
either way, because generic URL redaction already covered passwords. The query
key and the username are the two that were actually leaking.
Two problems, both in the path that turns a failed MCP server's error into
panel text.
The raw bound was outside redaction.ErrorMessage rather than around the error
going into it, so the whole server-controlled string was redacted, walked rune
by rune, redacted again, and only then cut. An eight megabyte reason took 2.2s
and allocated 286MB for a panel that shows 400 runes; it is now 9ms and 1.5MB,
flat across input sizes.
The lookahead margin past the cut was sized to the longest configured secret,
which made the real limit "the cap plus whatever the other side configured".
A two megabyte credential raised the retained error to 65546 bytes against a
nominal cap of 16384. It is a fixed 4KB now. A credential longer than that can
still straddle the cut and leave a prefix, which is a stated limit rather than
an oversight, and a far smaller one than an unbounded margin.
Credential collection only ever saw decoded values. url.Parse and
url.ParseQuery decode, so a token configured as opaque%2Dworkspace%2Dtoken was
collected as opaque-workspace-token and matched nothing when the server echoed
back the escaped spelling it was given. parsed.User.String() is not a way out
either: it re-escapes by Go's rules and leaves unreserved characters alone, so
%2D comes back as a hyphen. Both forms are collected now, the raw one taken
from RawQuery and from the original string's userinfo.
Five findings from review, plus one the review did not raise.
Header flags were classified by their NAME. isSensitiveMCPDisplayKey matches
token/secret/auth/credential and friends against the flag, and neither "header"
nor "H" is any of those, so a credential riding in a header whose name the
operator chose was never collected. All seven forms leaked once the header name
itself carried no matching word: separated, equals and packed, long and short,
and with no space after the colon. A stdio child that rejects its invocation
echoes it into captured stderr, which this panel renders and the transcript
keeps. The short form is matched case-sensitively on purpose, because -h is help
and folding case there would put the next argument into the redaction set.
The endpoint was never examined at all. HTTP and SSE send the configured URL
verbatim and it accepts userinfo and arbitrary query keys, so ?workspace=<token>
walked through the generic query redaction, which only recognises conventional
key names. Userinfo passwords and conventionally-named parameters were already
covered; the arbitrary name and the userinfo username were not. Every query
value is collected now, with the existing length floor keeping v=1 and mode=sse
readable.
WHAT THE REVIEW DID NOT RAISE, found while verifying the first two: the Target
row prints the same credential verbatim, one line under the Error row that is
correctly redacted, on the same panel and into the same transcript. Fixing the
error alone would have handed it straight back. The display path now redacts
header values while keeping the header name, and long query values while keeping
the host and path.
The raw bound sat at the very end, inside sanitizeTerminalReason, so the whole
server-controlled string was redacted, walked rune by rune into a fresh builder
and a fresh []rune, and redacted again before being cut, for a panel that shows
at most 400 runes. It is applied at ingress now, with a lookahead margin sized to
the longest secret so a credential straddling the cut cannot lose its tail and
leave a matching prefix visible.
Failures were matched against raw config-map keys while registration records the
trimmed name, so a server configured as " docs " that failed to start rendered as
enabled, and lost its tool count the same way. One canonical identity now, and it
is the registry's.
And a skipped entry is an observation about a server, not about a name. The
startup snapshot was never invalidated, so removing a failed endpoint and adding
a different one under the same name made the replacement inherit the dead
endpoint's error and failed state, in the panel and in the command transcript.
Observations are dropped when their subject is removed or changed.
Adversarial review of the previous commit found three holes in it.
Invisible Unicode rejoins like a control byte, and more comfortably. A
zero-width space, soft hyphen, word joiner, bidi control or byte order
mark inside an echoed credential is not equal to the configured value, so
redaction misses it, and the reader sees an unbroken secret because the
character renders as nothing. The whole Cf category is now dropped
alongside the control bytes. Combining marks are deliberately kept: they
are ordinary content in most of the world's scripts, and deleting them to
close a redaction hole would corrupt error messages written in those
languages.
The configured value is usually not the credential. Zero's own documented
config spells an authenticated server as "Authorization": "Bearer <token>",
so a server quoting back only the token, without the scheme word, matched
nothing. Same shape through a composite --header argument, which carries a
header name too. Each tail after a space or colon is now offered as its
own candidate, which covers both without needing to know the scheme
vocabulary, and the length floor keeps "Bearer" and the header name out of
the set so those words are not blanked out of unrelated text.
Collecting argument values over-reached. isSensitiveMCPDisplayFlag strips
leading dashes before matching, so it says yes to a bare positional word,
and the documented GitHub server config passes the env var NAME
positionally: the docker image name went into the redaction set and the
pull failure lost the one string that explained it. A positional argument
is not a flag and no longer introduces a value.
Two of the tests for this were vacuous when first written and were
rewritten after reverting the fix did not fail them. The Unicode one
asserted on bytes, but these characters rejoin in the reader's eye rather
than in the string, so it now asserts on the perceived text. The scheme
one used an sk- style value that the shape patterns already caught, so it
proved the pattern list rather than the fix; it now uses an opaque
credential only equality can match.
The reason is written by the server that failed, and it is both rendered
and persisted to the transcript, so it is untrusted text that can carry
back whatever Zero sent. Four separate ways a credential survived it.
Redaction ran before the display sanitizer, and matched configured values
literally. A server echoing a secret with a control byte pushed into the
middle of it matched nothing, and the sanitizer then removed that byte
WITHOUT leaving a gap and reassembled the intact credential on screen.
Every control byte it drops rejoins the same way, so this was never
specific to ANSI. The reason is now normalized to what the reader will
see and redacted again against that. The stripping half is split out of
sanitizeTerminalReason as stripTerminalRejoiners so redaction does not
inherit the display truncation, which would cut a secret in half and
leave the head of it unmatched.
Only the OAuth client secret was redacted, not the bearer that is
actually sent. A failed OAuth server can echo an opaque token in its
error body, and no pattern can recognize one by shape.
TokenStore.SecretValues reads that material. It enumerates rather than
looking up by server name, because the login path saves identity-bound
and a per-name Load finds nothing for exactly the servers holding a real
bearer. It is read only: LoadForServer, which the runtime bearer path
uses, migrates a legacy entry as a side effect, and opening a panel must
not rewrite the token store.
Args were not collected at all, though a stdio child that rejects its own
invocation prints it back and connectStdio appends that stderr to the
error. sensitiveMCPArgValues collects the values behind a sensitive flag,
sharing the predicates with the display pass so the two cannot drift, and
handling the shapes the display pass gets wrong or would answer with an
already-redacted string.
Extra secret values were replaced in slice order, so a secret that is a
prefix of another consumed its head and left the tail of a real
credential printed as [REDACTED]XYZ. The partial replacement also
destroyed the token shape, so the pattern passes could not recover it.
Callers collect from maps and Go randomizes iteration, so which happened
was decided per run. RedactString now applies values longest-first and
deduped, which fixes every caller rather than this one.
Each fix is falsified independently by its regression: reverting the
ordering prints [REDACTED]XYZ, reverting the normalization reassembles
wk-live-4f9c2b7ae1d8 for display, reverting the arg collection prints the
argument secret, and reverting the token read prints the stored bearer.
Reported by jatmn. BuildMCPViewState passed redaction.Options{} when rendering a
failed server's startup error, so only the generic patterns applied.
Those patterns match shapes they recognise. A remote MCP server is configured
with ARBITRARY headers, so a credential can sit under a name nobody can predict:
X-Workspace-Credential matches nothing. A server that echoes the request it
failed on puts that value into MCPServerView.Error, which this PR newly renders
in both /mcp surfaces AND the session transcript. So the exposure is one this
change introduces rather than one it inherits.
The configured values are now passed as ExtraSecretValues, which redacts by
equality instead of by shape, so the header name does not have to be guessable.
That also drops the dependence on the syntactic Authorization: matcher, which
terminal control bytes can split before the later sanitizer strips them.
Env values are included for the same reason on the stdio path: the child is
launched with them and a failure to exec commonly reports the environment it was
given. Auth and an OAuth client secret are included too.
Values shorter than eight characters are skipped. A configured "1" or "true" is
not a credential, and redacting it by equality would punch holes through
unrelated text, which is its own way of making an error useless. There is a test
for that, because the fix would otherwise be free to shred the message.
Tests cover an echoed custom header, an echoed env secret, the short-value case,
and a healthy server carrying no error text at all. Verified by mutation:
dropping the options renders the credential verbatim.
An empty /mcp opens the manager overlay, and it reported the state without the
reason. The recorded "why" was rendered only by mcpManagerServerLines inside
renderMCPView, which serves /mcp list and the transcript, so the panel said
"failed" and stopped exactly where someone goes to find out why after the
startup warning has scrolled away.
The reason now sits in the selection detail, directly under the header and above
the target, through the same sanitizeTerminalReason path the transcript uses.
TestModelMCPPanelReportsStartupFailures drives m.mcpText() and passes with or
without this, which is how the gap survived review. The new tests drive
openMCPManager().mcpManagerOverlay() instead. Mutation-verified: feeding the
sanitizer an empty reason fails the first one.
The sanitization test deliberately does not search for a bare escape byte. The
overlay is lipgloss-styled and therefore full of escape sequences it wrote
itself, so the assertion is that the SERVER's payload did not survive: no
clear-screen sequence, and no row carrying the forged text on its own. The
sanitizer collapses the newline, so the forged text stays inert on the reason
line rather than becoming an entry of its own.
Reported by jatmn on #835.
The display cap runs at the end, so the sanitizer walked the whole
server-authored string first. Escape sequences are consumed without
producing output, so they spend input against a budget that never fills:
64KB of "\x1b[2J" was walked in full and the text after it still
rendered. Nothing upstream bounds the handshake error, and the panel
re-runs this on every redraw.
Cap the raw input at 16KB before the walk, well above the 400 rune
display cap so a long error is still truncated by display rules. Trim
back a character the cut splits so the panel never renders a replacement
character it produced itself.
The failure reason is the only value on the /mcp panel that the MCP server
writes itself, and it went to the terminal with nothing but TrimSpace.
redaction.ErrorMessage strips credentials, not control bytes, so a hostile
handshake error could clear the screen, move the cursor, or embed a newline
followed by text shaped like a real entry and forge a row for a server that
does not exist.
Reproduced with @anandh8x's payload from the review. Before the fix the
rendered panel was:
> evil . failed . http
connection refused\x1b[2J
> forged . enabled
actions: zero mcp check evil | ...
The escape sequence and the forged row both survived intact.
sanitizeTerminalReason consumes escape sequences whole rather than dropping
ESC alone, since removing the ESC and leaving "[2J" behind would print visible
junk and an abandoned OSC payload can still smuggle a title-set or hyperlink.
CSI runs to its final byte, OSC to BEL or ST. Newlines and tabs collapse to
spaces so the reason stays on the single row the panel counted for it, other
control bytes are dropped, and the result is capped at 400 runes so one
verbose server cannot push the panel off screen. Truncation is by rune, not
byte, so a multi-byte character is never cut in half.
Two regressions cover it. The injection test asserts no escape byte survives,
no rendered line carries its own newline, the forged text never begins a row,
and the real reason is still shown. The cap test drives 5000 characters
through and asserts the rendered line stays bounded. Both fail on the code
before this commit.
The panel derived every server's state from config alone: `disabled` if the
user turned it off, `enabled` otherwise. MCP registration is best-effort —
a server that cannot be reached is recorded and startup continues — so a
server that never connected was listed as enabled with its tools silently
missing and nothing in the panel to explain it.
Startup already knows: it prints a warning per skipped server to stderr.
That scrolls away behind the first screen of output, and /mcp is where a
user goes afterwards to ask what is actually running.
Thread the skipped set from the MCP runtime through to the panel and render
a third state, `failed`, with the recorded reason underneath the server:
› docs · failed · stdio
exec: "docs-mcp": executable file not found in $PATH
The reason comes from the server, so it goes through redaction — a
handshake error that echoes back the Authorization header would otherwise
print the token into the transcript. Disabled still wins over failed: the
user turned that one off, so it was never expected to connect.
The stderr warning is unchanged; the panel is an addition to it.
Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-12 12:09:00 +05:30
876 changed files with 38052 additions and 3316 deletions
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)."
* **providers:** add MiniMax-M3.1-Flash to catalog ([#1180](https://github.com/Twigpine/zero/issues/1180)) ([d99f92b](https://github.com/Twigpine/zero/commit/d99f92b81a671f186cede81614c89b8e8a7f9451))
* **sandbox:** support self-exec pattern to eliminate separate helper binary ([#946](https://github.com/Twigpine/zero/issues/946)) ([8c4e759](https://github.com/Twigpine/zero/commit/8c4e7597989ef9b3ca67b7703dfd15d92372aed2))
* **skills:** install a skill's bundled files and point the skill tool at them ([#1167](https://github.com/Twigpine/zero/issues/1167)) ([1641506](https://github.com/Twigpine/zero/commit/164150633a8b2e6ae864ae02fd7105b201eeb68e))
### Bug Fixes
* **agent:** mark headless guardrail stops incomplete ([#1182](https://github.com/Twigpine/zero/issues/1182)) ([5aa54fe](https://github.com/Twigpine/zero/commit/5aa54fe1d2b419c9bbef73605a77919c9007cf7b))
* bump Go to 1.26.10 for GO-2026-6809 ([#1168](https://github.com/Twigpine/zero/issues/1168)) ([6393a90](https://github.com/Twigpine/zero/commit/6393a90ed618a3536e09c02852f4ec829b97545b))
* **chatgpt:** refresh subscription defaults and compatibility version ([#1179](https://github.com/Twigpine/zero/issues/1179)) ([f912f80](https://github.com/Twigpine/zero/commit/f912f80d002602e11ba0ef8548ecee58442874aa))
* **deps:** refresh the lockfile to hono 4.13.13 ([#1171](https://github.com/Twigpine/zero/issues/1171)) ([de1443b](https://github.com/Twigpine/zero/commit/de1443b2edb44fd3747cc77200d54dc8cffcd0a7))
* **oauth:** reuse the crash-safe secret store and sync encrypted publications ([#1165](https://github.com/Twigpine/zero/issues/1165)) ([84c1b15](https://github.com/Twigpine/zero/commit/84c1b1554076679a342e9cac61f8f8cdbaf3a429))
* **tools:** grep stops where a file's line breaks stop ([#1160](https://github.com/Twigpine/zero/issues/1160)) ([aa9ba84](https://github.com/Twigpine/zero/commit/aa9ba8412eb8917daa2ca5cb3fdbd0a39276fa88))
* **workspaceindex:** import gitsafe under the Twigpine module path ([#1153](https://github.com/Twigpine/zero/issues/1153)) ([ef4da13](https://github.com/Twigpine/zero/commit/ef4da1323f1097863ae278224b5f304c2fff909a))
* **workspaceindex:** run Scan's git calls through gitsafe ([#1149](https://github.com/Twigpine/zero/issues/1149)) ([2ffe183](https://github.com/Twigpine/zero/commit/2ffe1831b0950b1463ec46d487d03a5fcb679cc6))
* **cli:** let providers subcommands run with no active provider ([#1143](https://github.com/Twigpine/zero/issues/1143)) ([d1a0d04](https://github.com/Twigpine/zero/commit/d1a0d048ee2c1e7cbfb75160ec4c9f099f3c4472))
* **npm:** wait for npm to apply each publish, and drop the one-time @gitlawb/zero steps ([#1145](https://github.com/Twigpine/zero/issues/1145)) ([8e363fc](https://github.com/Twigpine/zero/commit/8e363fcaa5d51bf5b5b244692dc61bf8d4ae8799))
* **tui,cli:** stop a repository's git config from running programs in Zero's own git calls ([#1133](https://github.com/Twigpine/zero/issues/1133)) ([640d936](https://github.com/Twigpine/zero/commit/640d936bfbe00c4cd3017ca35fb7b5c15d91a1c5))
* **workspaceindex:** skip git-ignored and build-cache dirs in Scan only ([#1117](https://github.com/Twigpine/zero/issues/1117)) ([c88c77b](https://github.com/Twigpine/zero/commit/c88c77bf701039dca01d9786bf5648d779c949b3))
### Performance Improvements
* **redaction:** add fast-path byte checks to eliminate regex allocations on clean strings ([#945](https://github.com/Twigpine/zero/issues/945)) ([ce2fcff](https://github.com/Twigpine/zero/commit/ce2fcffaf10d8e8c8c5a13e06fd7809f09fc9867))
* **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))
* **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)
* **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))
* **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))
@@ -110,6 +110,8 @@ Discovery roots (earlier wins on name collisions):
A missing directory is fine — Zero just omits it. Management commands (`zero skill add` / `zero skill remove`) always write to the primary Zero dir only; `list` / `info` search primary + `~/.agents/skills`. `skill info` reports lock source/hash when present and whether the on-disk `SKILL.md` has drifted from the recorded hash.
For a skill installed since Zero started installing bundled files, `skill info` also reports drift in any file installed with it.
@@ -140,6 +142,15 @@ Only `name` and `description` are recognized in the frontmatter today. The `name
The `skill` tool lets the agent load any discovered skill by name (primary, agents, or plugin).
A skill can ship files beside its `SKILL.md`, in the [agentskills.io](https://agentskills.io/specification) layout: `scripts/`, `references/`, `assets/` or anything else the instructions refer to. `zero skill add` installs the whole skill directory, leaving out hidden entries such as `.git` and not following or copying symlinks (it lists any it skipped). A skill larger than 1,000 files or 32 MiB is refused. When the `skill` tool loads a skill that has such files, its output ends with the skill's directory and a list of the files, so instructions like "run `scripts/fill.py`" can be followed. Running a bundled script goes through the normal tool permissions; installing or loading a skill never runs anything.
```text
pdf/
SKILL.md
scripts/fill.py
references/form-fields.md
```
## 4. Hooks
Hooks fire shell commands on lifecycle events. Configure them in JSON:
@@ -246,6 +257,52 @@ or use a new server name. OAuth tokens are bound to the resolved server identity
so changing a server target can require `zero mcp oauth login <server>` again.
OAuth server names ending in `.<32 hex chars>` are reserved for token storage.
#### Built-in servers
Zero seeds two MCP servers before your config is merged. Both show up in
`zero mcp list`, and any field can be overridden in your user config.
-`exa` (`https://mcp.exa.ai/mcp`) is on by default for keyless web search.
`zero mcp disable exa` turns it off.
-`aimlapi` (`https://mcp.aimlapi.com/mcp`, the AI/ML API Toolbox, model
discovery, image/video/audio generation, chat, embeddings and balance tools)
is **off by default**, because its tool runs and generations are billed to
your AI/ML API balance. Turn it on with `zero mcp enable aimlapi`, which
writes only `"disabled": false` to your user config, and off again with
`zero mcp disable aimlapi`. A project config cannot turn it on.
When `aimlapi` connects, Zero sends your AI/ML API key as
`Authorization: Bearer <key>`. The key is resolved again on every request and
is never written to `config.json`. With an aimlapi.com provider profile
(`zero providers setup aimlapi`), the key is chosen in this order:
1. An inline key on the profile (if set in config).
2. Otherwise the environment variable named by the profile's `apiKeyEnv`
(the catalog default is `AIMLAPI_API_KEY`).
3. Otherwise the key held in the credential store when the profile uses
`apiKeyStored`.
When there is no aimlapi profile, Zero uses `AIMLAPI_API_KEY` alone. If both
`AIMLAPI_API_KEY` and a stored profile key exist, step 2 wins over the store
whenever the inline key is empty — MCP requests may bill the environment key's
account, not the stored key.
No automatic key is sent when an aimlapi profile, or any profile reading
`AIMLAPI_API_KEY`, uses a base URL other than `https://api.aimlapi.com/v1`, or
when `AIMLAPI_INFERENCE_URL` is overridden, because that credential may belong
to a proxy. No key is sent when the resolved value is not a single token (for
example a comma- or newline-separated pool), when you set your own
non-empty `Authorization` header, when the server uses `"auth": "oauth"`, or
when project config touched the server. Without a key the server answers 401.
To sign in with your aimlapi.com account instead, set `"auth": "oauth"` on the
server and run `zero mcp oauth login aimlapi`.
Every request to exactly `https://mcp.aimlapi.com` (https, default port,
whatever the server is named, including one you add by hand) also carries the
attribution headers `X-AIMLAPI-Source: agent/zero` and `X-AIMLAPI-Partner-ID`,
and gets the key under the rules above. Other MCP servers never receive these
headers or the key.
### As a server — expose Zero's tools to another agent
iferr:=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)
}
iferr:=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)
constmaxTurnsAnswer="Agent reached maximum number of turns without a final answer."
constmaxTurnsFinalAnswerPrompt="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).
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.",
Loaded 100 of 876 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.