mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-09 18:08:31 -10:00
main
817
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
17fcd26602 |
fix(update): authenticate GitHub release checks (supersedes #504) (#1216)
* fix(update): authenticate GitHub release checks
Rebuild PatrickNoFilter's final change from Twigpine/zero#504 on current
upstream main. Prefer ZERO_GITHUB_TOKEN, fall back to GITHUB_TOKEN, and
limit credentials to HTTPS GitHub API requests with downgrade protection.
Retain the original tests and update documentation.
Original implementation, tests and documentation by PatrickNoFilter.
Source-PR: https://github.com/Twigpine/zero/pull/504
Source-Head:
|
||
|
|
15ff1704a4 |
fix(codex): forward tool input that only arrives whole on an output item (#1215)
The Codex Responses handler kept arguments that arrived only on
response.output_item.done (and custom tool input that arrived on
response.output_item.added) in the builder for history replay, but never
emitted them as tool-call deltas, so the runtime executed the call with an
empty argument string ("path is required").
Route every builder write through one forwardInput helper that also emits
the delta, so the builder holds exactly what the runtime has received. The
added handler forwards whole custom input, the delta handler forwards each
fragment, and the done handler forwards the final input only when nothing
reached the runtime yet, so streamed fragments are never duplicated.
Handler-level coverage spans function and custom calls, with and without
the added item, streamed or final-only, plus custom input on the added
item; a second test drives the same shapes through StreamCompletion and
zeroruntime.CollectStream. Against the unfixed handler the final-only and
input-on-added cases report an empty argument string at tool-call-end.
Closes #1194
Co-authored-by: oniisanlabs <[email protected]>
Co-authored-by: Claude Fable 5.1 <[email protected]>
|
||
|
|
88ec97d563 |
fix(tools): publish file writes atomically instead of truncating in place (#1214)
* fix(tools): publish file writes atomically instead of truncating in place Stage replacements before publication so failed writes preserve existing contents, successful renames detach hard-link siblings, and creates publish without replacing raced files. Preserve modes and contained symlinks, with the existing validated in-place fallback when replacement is refused. Rebuild Ayush7614's complete change from Twigpine/zero#1159 on current main. The net patch is unchanged; original authorship is retained. Original-commit: |
||
|
|
b32f7ce4d6 |
fix(agent): stop a denied tool looping past the repeated-failure halt (#866)
* fix(agent): stop a denied tool looping past the repeated-failure halt The repeated-failure guard keys its streak on the first 80 characters of the error text. A permission denial reads "Error: Permission denied for <tool>: <reason>", and reason names the path or command that was refused, so the text differs on every call while describing the same unchanging refusal. Each call therefore rebuilt the record at count 1 and toolFailureStopAt was never reached. Not hypothetical. A headless run made 384 denied calls over 26 minutes under a halt set to 6, produced no files, and reported nothing. #702 already hit this shape once and fixed it by making one error message id-invariant; that works per message and needs every future message to remember. Denials now key on their DenialCategory instead, which is a small closed enum the loop already sets on the result, so the class is fixed rather than one instance of it. Adds a second, content-blind counter beside the streak. The signature-keyed one cannot by construction see a tool that fails with a genuinely different error every time, and that is still a tool that is not working. It counts consecutive failures regardless of the error and is cleared only by a success of that same tool, so changing how a tool fails is not progress and neither is some other tool succeeding. It stops at 12 rather than 6 on purpose: a model iterating on a tricky edit legitimately fails a few times with different errors while converging, which is the same reasoning that moved toolFailureStopAt from 4 to 6. Two counters, tripping on either, is what both of the agent CLIs I compared against arrived at independently after hitting this bug — a tight bound on identical failures ORed with a looser bound that no amount of varying the error can reset. Every guard is mutation-checked. Reverting the denial re-key fails TestPermissionDenialStreakSurvivesVaryingReasonText; deleting the content-blind bound, or letting a signature change reset it, each fail TestToolFailingWithDifferentErrorsEveryTimeStillStops and TestSuccessResetsBothFailureCounters. One existing test call site gains the new parameter. * fix(agent): count denials as failures and report the bound that tripped Addresses both blocking findings from @anandh8x's review. He was right on both, and the first was fatal: the previous commit was a no-op in production. loop.go passed isRetriableToolError as the guard's `failed` flag, and that returns false for any categorized denial (a policy refusal is deliberately not retriable). observeToolResult therefore took its success branch and DELETED the record before it could key on DenialReason, so a denied tool still looped to the turn limit. The re-key was correct and unreachable. The flag is now split. `failed` counts a denial toward the streaks; `hintable` stays retriable-only, because a schema hint is the wrong response to a refusal — the call shape is fine, the answer was no. Collapsing the two is what made a caller unable to express "count this but do not coach the model about it". Second finding: outcome.Count returned the signature-keyed record.count even when the content-blind counter was what tripped the stop. With twelve distinct errors that count is 1, so the final answer told the user a tool "failed 1 time in a row with the same error". The outcome now carries the counter that actually fired plus a Varied flag, and the stop answer says "each with a different error" in that case. Every earlier test passed while the production path was broken, because they called observeToolResult directly with failed=true. So the important addition here is TestRunStopsARepeatedlyDeniedToolAtTheFailureBound, which drives Run itself: a tool that always prompts, an approver that always denies, and a different command per turn so the denial reason varies as it does in a real run. Verified by reverting the fix: the run makes 10 denied calls instead of halting at 6 and dies on the no-output guard 13 turns later, while the helper-level test stays green — which is precisely why this shipped in the first place. * fix(agent): count uncategorized policy refusals in the halt guard Reported by jatmn, and he is right that the guard missed the paths it most needed to cover. countedFailure asked `DenialReason != DenialNone`, but a category is only attached where a TYPED denial is built. A headless run leaves OnPermissionRequest nil, so the loop never reaches that branch and the registry returns a bare `Error: Permission required ...` with no category. A sandbox preflight denial on a non-shell tool loses its SandboxDecision converting to ToolResult and arrives as an uncategorized `Sandbox block`. isRetriableToolError rejects both, so both operands were false, observeToolResult took its success branch, and the record the guard accumulates was cleared. The same refused call could then repeat to MaxTurns, which is the loop this PR exists to stop. The text patterns for those outcomes already existed, enumerated inside isRetriableToolError. They simply were not reachable from the counting question. They are now a shared isPolicyRefusal predicate that both callers use, so the two questions cannot drift apart again, which is how they diverged in the first place. Also, denials no longer feed the execution-profile failure-streak trigger. That trigger restores the displaced turn budget and reasoning effort on the theory that a tool is struggling and needs room. A policy refusal is not a struggling tool, it is an answer, and spending the one-shot escalation on one contradicts the trigger's documented retriable-failure contract. Denials still count for the halt; they just no longer buy more budget. On coverage, honestly: the new tests pin the PREDICATE, including both uncategorized shapes, and I verified by mutation that removing the text branch fails them. They do NOT pin the wiring. Mutating countedFailure leaves them green, which is the same unit-versus-call-path gap that produced the original defect here. A Run-level test through the headless path is what would close it and this commit does not add one. * fix(agent): stop the halt answer claiming a pattern the counters do not track jatmn's P3. The final answer overclaimed in both directions. The content-blind bound said "each with a different error". anyErrorCount only establishes that the tool failed consecutively without a success. Five A, five B and two C reaches 12 without any signature repeating six times, and three of those errors were shared, so "each different" is false. It now says "varying errors", which is what reaching 12 without tripping the signature bound actually proves: no signature repeated six times in a row. The signature bound said "with the same error", which is false the other way for a denial streak. A denial keys on its CATEGORY precisely because the prose embeds the path or command refused and therefore differs on every call. That streak now reports as refused rather than as one repeated error, carried on a Refused flag derived from the signature prefix. The one claim that IS justified is kept: an error-signature streak really did repeat the same signature, so that wording stands. The existing denial test asserted the old "same error" phrasing, so it was describing the very defect this fixes; it now expects the refusal wording. Tests: the three wordings against their counters, plus the mixed-signature regression jatmn asked for, driven through the real counter rather than asserted about the strings in isolation, so it proves the 5/5/2 run trips the content-blind bound and is not described as all-different. * fix(agent): do not classify successful tool output as a policy refusal isPolicyRefusal decides on denial category, then permission metadata, then output text. None of those questions is meaningful about a call the tool completed, and the last one is answered by content the model does not control. isRetriableToolError gated on StatusError before calling in, so the boundary held while that was the only caller. Extracting the helper and calling it from the counting path dropped the gate: an allowed bash printing "Sandbox block", or a read_file returning a document that quotes it, set policyRefusal, made countedFailure true, and recorded a failure against the tool's signature. Six such successes tripped the same-signature stop and ended a healthy run with "the `bash` tool failed 6 times in a row with the same error". The gate belongs in the classifier rather than at each caller, because the next caller will forget it too. Covered by a direct StatusOK classifier case over every signal the helper reads, and by an end-to-end run of ten successful greps whose output quotes the phrase. Both fail against the ungated helper: the run halts at 6 with the refusal answer above. * test(agent): drive the uncategorized refusal paths through Run The categorized denial was never the gap. The gap is a refusal arriving with DenialReason empty, because a category is attached only where a typed denial is built: a headless run leaves OnPermissionRequest nil and the registry gate returns a bare "Permission required for ...", and a sandbox preflight denial on a non-shell tool loses its SandboxDecision converting to ToolResult and arrives as a bare "Sandbox block". Testing that through the helper proves nothing. The first version of this fix passed every helper test while being a no-op in production, because the loop asked a different question than the tests did. Both cases here go through Run and pin what the loop does with the classification: halt at the bound, never execute the tool, and withhold the profile's one-shot failure escalation. Each half of the wiring falsifies the tests on its own. Dropping policyRefusal from countedFailure lets the headless refusal run 13 turns instead of halting at 6. Dropping the empty-outcome branch for the posture controller reports posture_escalations = 1 instead of 0. * fix(agent): classify a policy refusal from provenance, never from tool output isPolicyRefusal fell back to matching phrases in the model-visible output, and output is tool-controlled. bash preserves arbitrary stdout and stderr on a StatusError for any nonzero exit, so an allowed command running `printf 'Sandbox block\n' >&2; exit 1` had actually executed, carried no denial category, and was still classified as refused. read_file returning a document that quotes one of the phrases did the same, which is the likelier way a real session hits it. The loop then withheld the retry hint and the profile failure-streak recovery and accumulated the executed failure toward the refusal halt, so a later stop told the user a tool had been refused when it had run. The registry already had the structured facts and dropped them on the floor. Every path that returns BEFORE the tool runs now carries one marker naming which gate refused: sandbox deny, sandbox approval required, permission required, permission denied. That includes the two cases that were previously uncategorized, the headless prompt refusal and the sandbox preflight denial on a non-shell tool, neither of which builds a typed DenialReason. isPolicyRefusal reads DenialReason, permission metadata and that marker, and nothing else. markStructuredSandboxDenial already stated this rule for the sandbox adapter, "Classification is never inferred from stdout or stderr"; this carries the same guarantee across the remaining gates. Coverage runs in both directions. The existing refusal fixtures now carry the provenance their production paths attach rather than relying on their text, and there is a Run-level regression where an allowed tool that ran, failed, and printed each recognized phrase still receives the retry hint. Reverting the classifier to substrings fails all three tests, including every phrase of the Run-level one. * fix(tools): give capture_artifact's configuration refusals the same provenance as the gates capture_artifact rejects in RejectBeforePermission, which the registry returns straight back before any of the gates that attach provenance. Its valid-but-unavailable calls therefore reached the classifier with no denial category, no permission metadata and no refusal marker, so they were read as ordinary retriable failures: the model got the schema hint telling it to fix arguments that were already valid, and the call could consume the profile failure-streak escalation, for a tool that never executed and that no argument change can enable. PolicyRefusalToolNotEnabled existed for exactly this and I never wired it. The missing-artifact-directory and disabled-driver branches carry it now. The malformed-argument branch deliberately stays an ordinary error. That one IS fixable by trying again differently, which is what the hint is for, so marking every early rejection would trade one wrong answer for another. Both directions are covered. Checked the rest of the class rather than only the reported tool: web_fetch, browser_launch, browser_connect, browser_open, desktop_windows, desktop_snapshot and terminal_session all reject on arguments alone, which is correctly retriable. capture_artifact was the only one refusing on configuration. Also rebased onto current main rather than carrying the two merge commits, per the same requirement raised on #886. * fix(agent): give a marker-only refusal the same identity the guard keys on The registry marks its pre-execution refusals in metadata, and isPolicyRefusal read that marker while observeToolResult keyed on DenialReason, which those paths leave empty. The guard fell back to errorSignature(output), so two refusals of the same category with different wording looked like two different failures and the streak restarted at 1 on every call. A model alternating capture_artifact's browser_screenshot and browser_pdf against a disabled driver is refused identically each time, and never tripped the six-call refusal halt. Only the generic twelve-error fallback stopped the run, reporting varied errors rather than a repeated refusal. The category is derived once now, at the boundary where a tools.Result becomes a ToolResult, and both the classification and the streak read that one value. It had to go in twice, because a RejectBeforePermission refusal takes its own constructor, and that is the route capture_artifact actually takes. Deriving it at the producers instead would have left the same gap for the next path that returns before the gates. One behaviour change worth stating plainly rather than burying. A headless prompt refusal is marked too, so it now carries a category and the stop answer says the tool was refused rather than that it failed with the same error. The bound is unchanged, and the new wording is the accurate one: the tool never ran. The test that pinned the old wording is updated, along with the comment that explained why it was uncategorized. * fix(agent): type the refusal identity and carry the halt to terminal status Three defects with one shape: provenance was encoded in a string, recovered by inspecting that string, and then not carried far enough. The same-identity streak keyed on one string namespace holding both a normalized error signature and a synthetic "denial:<category>" key, with provenance recovered afterwards by testing for that prefix. Trusted provenance and untrusted content in one namespace is a namespace the untrusted side can write into: a command printing exactly "denial:permission_denied" and exiting non-zero acquired the identity of a real permission refusal and the run reported it as refused although it executed every time. The identity is now a (kind, key) pair, so no output can spell a refusal. The content-blind bound reported only that the failures varied, and it fires precisely when no identity repeated, so the identity present at the end says nothing about the eleven before it. Alternating two refusal categories reached twelve without either reaching six and the answer described a tool that never ran as having failed. The record now carries the aggregate, and the guard returns a typed cause instead of two overlapping booleans, with the mixed case named rather than left to whichever field a switch tested first. The halt returned straight out of the tool loop, so it never crossed the completion gate the max-turns paths go through. Under RequireCompletionSignal zero exec treats only Incomplete as exit 4, so a task denied six times came back as a successful automation result having done none of the work. Also closes two coverage gaps that made the markers untestable: reverting either the sandbox deny marker in registry.go or the disabled-driver refusal in local_capture.go left both suites green. The sandbox case now runs a real engine evaluation through Run and asserts the body was skipped; the capture case configures an artifact root with one driver enabled so it reaches the disabled-driver branch instead of returning at the missing-directory one, with a sibling case pinning that a malformed argument stays retriable. * fix(agent): close out advertised tool calls by what actually happened Parallel read-ahead broke the assumption the aborted placeholders were written under. executeParallelReadBatch runs an entire eligible run of read calls before the loop consumes any of them, so "not consumed yet" stopped meaning "not executed". Every terminal branch closed out the calls after the current index as aborted, and a sibling that had already run was recorded that way: its real result discarded, and its callbacks, trace counter, task observation, loaded tools and images lost with it. Where the sibling is a successful read, execution may already have committed file-observation credit for content the model never receives, so the authorization state disagreed with the transcript. Each remaining call is now put in the state that is true of it. A completed one is finalized exactly once with the same bookkeeping the main path performs; an unstarted one still gets a placeholder so every tool_use keeps its answering tool_result. The guard is deliberately not consulted for a drained sibling: it cannot reverse a decision already made, it is only owed an honest record. All three early returns go through one helper rather than repeating the assumption, so the next stop condition inherits the fix instead of the bug. * fix(agent): authenticate refusal provenance and separate completion from abort Two things this branch left keyed on something that could disagree with the fact it stands for. A tool that ran and failed could claim the registry refused it before it ran. IsPolicyRefusalResult trusted a metadata key, and Registry.RunWithOptions forwards an executed result and its Meta unchanged, so a tool could set it by mistake, by copying metadata forward from something it called, or on purpose. The loop then withheld the retry hint, suppressed the failure-streak recovery, counted the call in refusal accounting, and could tell the user a tool was refused when it had executed. That is the output-text trust problem one layer down. The execution boundary now strips the marker, so no value survives running, recognized or invented. Pre-execution refusals are untouched, including RejectBeforePermission, which decides before any of this. precomputedResultFor treated any batch entry carrying an abort error as unstarted. Producing a result and asking the run to stop are different facts, and executeToolCall's cancelled-permission path returns both: an earlier sibling reaching a terminal branch would discard the real cancellation result and write an aborted placeholder over it. The batch now records what it ran, where that is known, and the placeholder is reserved for entries that produced nothing. The terminal decision is unchanged; draining only makes the record honest. * test(agent): pin a drained read-ahead sibling's image through the guard halt The halt drains read-ahead siblings that already ran and records them as completed, images included. Images now reach the transcript through the turn's deferred builder, and nothing checked that a drained sibling's image still went through it: dropping it left every test green. The probe tool returns an image from the sibling and the run must carry exactly one image message. * chore: follow the Twigpine module path 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. * chore(agent): drop two unused zero-value constants failureKindNone and toolFailureCauseNone were never referenced, so the pinned unused check flagged them. The zero values stay reserved with a blank identifier. * fix(agent): report mixed guard halts as mixed and pair drained siblings - toolFailureIncompleteReason gains the mixed branch, so a headless run halted by the content-blind bound over failures and refusals says the tool failed or was refused, as the stop answer does. - closeOutRemaining sets ToolCallProviderID on a drained sibling's tool result, matching the consumption path, so a strict replay can pair it. - executedToolResult strips permission_action as well as the refusal marker. The loop reads a denied permission action as a refusal, so an executed failure carrying one was counted toward the refusal halt. The key is a tools constant now, and the loop uses it too. - The parallel read-ahead comment says what differs from sequential execution: a halt can leave up to seven sibling reads already run. Reported by @jatmn. |
||
|
|
8e9c3ec4ce |
fix(mcp): scrub the injected AI/ML API key from server responses (#1197)
* fix(mcp): scrub the injected AI/ML API key from server responses Zero injects the user's AI/ML API key into requests to mcp.aimlapi.com (#1193) and keeps it out of config, but only an HTTP error status left the server's text out. A JSON-RPC error message, the method of an unexpected message, a tool result (MCP reports a failed tool call as a result with isError, not as an error) and a legacy SSE endpoint that fails to parse all reached the caller as the server wrote them, so a server that echoed the key put it in errors, in tool output sent to the model, and in anything that prints them. A response to a request that carried the injected key now has the key replaced with [REDACTED] in its error text, its method and every string of its result before anything reads it, on streamable HTTP and on the legacy SSE transport. The SSE client scrubs both the key its stream request carried and the one on the POST, since the key is resolved per request. A user Authorization header, OAuth and project-configured servers are unchanged, since nothing was injected. The scrub uses a new redaction.RedactSecretValues: the split-aware known-value pass RedactString applies to ExtraSecretValues, without the shape patterns, which could otherwise rewrite tool output or base64 payloads that never held the key. A result without the key keeps its original bytes. * fix(mcp): scrub every key a call sent, and transport errors too Review found two gaps. The key is resolved again for each redirect hop, but the scrub used only the final hop's key, so a same-origin server answering the last hop could echo an earlier hop's key. And a legacy SSE POST that failed at the transport quoted the endpoint URL the server chose, which can hold the stream's key, with no scrub at all. A collector in the request context now records every key applyAIMLAPIRequestHeaders injects during a call. net/http gives each redirect hop the first request's context, so a later hop's key is recorded too. Both transports scrub the response and every error the call returns with those keys, the stream request's own errors included, and the SSE client adds the keys its stream request sent. A scrubbed error unwraps only to the context sentinel the original matched, so a cancellation still reads as one without the chain that held the key. Reported by @jatmn. |
||
|
|
ef1ca39b09 |
chore(main): release 0.13.0 (#1190)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>v0.13.0 |
||
|
|
bbf779e691 |
fix(tui): make reasoning stream rendering linear (coalesced deltas, O(1) append, tail-bounded preview) (#1198)
* feat(tui): coalesce reasoning deltas with kind-switch flush Extend the stream coalescer to batch agentReasoningMsg deltas alongside assistant-text at ~60fps, flushing on kind switches so interleaved prose and reasoning never reorder. Fixes the O(n^2) re-render pathology where each thinking delta re-rendered the whole accumulated block. * perf(tui): make reasoning stream append and preview rendering O(1) per delta streamingReasoning accumulated with string += delta, the same O(n^2) append pathology that streamingText had already been migrated away from for. Switch it to []byte so each reasoning delta is an amortized O(1) append, read through streamingReasoningString(). The live preview also paid per-frame costs on the growing string: reasoningPreviewLines split the entire accumulated reasoning to show the last two lines, and previewTail rune-converted whole lines. Both now walk backwards over only the tail they render, so preview cost is bounded by the visible width instead of segment length. * fix(tui): clear fired coalescer timer so later deltas re-arm and never stall * fix(tui): normalize invalid UTF-8 in truncated reasoning preview tail |
||
|
|
47f78c6624 |
feat(providers): point OpenGateway at api.twigpine.com and trust its live model list (#1208)
Rename the recommended gateway provider to "Twigpine OpenGateway" and move it to https://api.twigpine.com/v1. The catalog id (gitlawb-opengateway) and GITLAWB_OPENGATEWAY_API_KEY stay, and "twigpine" / "twigpine opengateway" are added as aliases. - Default model is now xiaomi/mimo-v2.6-pro, an exact id from the live /v1/models list, so onboarding keeps it selected after live discovery (an alias default made setup jump to the first sorted model). - The live models URL is https://api.twigpine.com/v1/models. Entries marked status "retired" are skipped in both the catalog parse and the live /models probe. - The curated list is reduced to an offline fallback (auto, xiaomi/mimo-v2.6-pro, xiaomi/mimo-v2.6-flash). The live list replaces it whenever the gateway is reachable. - Profiles saved with the old https://opengateway.gitlawb.com/v1 base URL keep resolving: the descriptor lists it in LegacyBaseURLs, and the resolver and picker treat it as the catalog endpoint, so the catalog env var binding is kept. |
||
|
|
e034a93815 |
perf(tui): bounded file read, LRU rendering cache, and size caps for full-file view (#1203)
* perf(tui): bounded file read, LRU rendering cache, and size caps for full-file view - Bound full-file view reads (4000 lines, 1 MiB total, 4 KiB per line) with async loading and an LRU render cache capped at 64 entries and 8 MiB. - Refresh both BTW surfaces when a tool result (such as write_stdin) carries both exact changed paths and an unknown-scope or aggregated directory summary outside Git. - Gate Run details FILES mouse hit-testing on Run details being the visible foreground overlay so higher-priority modals (speech API-key prompt, leader help) block clicks behind them. (cherry picked from commit |
||
|
|
2ca84cc127 |
fix(hooks): dispatch specialistStart/Stop lifecycle hooks (#1204)
* fix(hooks): dispatch specialistStart/Stop and allow matchers These events were documented and accepted by hooks add, but never fired. Wire advisory dispatch from specialist accounting and allow specialist name matchers so EXTENDING.md matches runtime behavior. * fix(hooks): unlock before specialistStop hooks and fire once Release accountingMu before running lifecycle hooks, and dispatch specialistStop only when the stop event is newly recorded so concurrent finishers cannot re-run arbitrary hook commands. * fix(hooks): fire specialistStop without relying on session append Decouple stop-hook dispatch from parent-session event append so swarm members (empty parent) and TaskOutput still get exactly one hook via a shared LifecycleHooks claim, and wire that bridge into TaskOutput. * fix(hooks): claim specialistStop durably and gate hooks on real launches Address the review findings on specialist lifecycle dispatch: - specialistStop is now claimed in the parent session's event log via a record-once specialist_stop_hook event, sharing the accounting run-ID equivalence (empty runId is a catch-all). A later reaper in another process (TaskOutput poll, recovered TaskStop, restart) no longer re-runs the hook. The in-process claim remains only as fallback for swarm members with no parent session and for session-store errors. - Recovered TaskStop records the terminal transition after Kill, so an explicitly stopped recovered task emits specialistStop exactly once. - specialistStart fires only after the child process actually started (onStarted after Start()); a launch that fails before exec records attempt accounting but no public start/stop hooks. Reapers derive the same eligibility from the durable stop event's childStarted flag. Adds a CLI startup wire-up test asserting both real entry points (interactive TUI, headless exec) attach the lifecycle bridge to the same LifecycleHooks the TaskOutput tool holds, fixes the stale import path on the rebased branch, and documents the once-per-run semantics. * fix(hooks): gate background exit accounting behind the start decision A background child that exits immediately can run its launcher exit callback before Executor.LaunchBackground returns, so the terminal accounting could claim and dispatch specialistStop before runBackground dispatched specialistStart, and the recorded stop missed the pid that SetPID had not stored yet. The exit callback now waits on a settled channel closed when runBackground returns (after the start hook and SetPID), from its own goroutine so a synchronous injected launcher cannot deadlock waiting for a signal only the launch return can close. Regression test: TestRunBackgroundDefersExitUntilStartDecisionCompletes drives a synchronous fast-exit launcher and asserts start-before-stop hook order, the recorded stop pid, and no deadlock. It fails on the previous code with hooks "specialistStop,specialistStart". * fix(hooks): record specialistStop only after the orphan kill succeeds When SetPID fails after a background child has started, runBackground kills the unrecorded child and records the stop. If TerminateProcess fails, the child may still be running, but the branch still marked the task errored, recorded the stop, took the durable specialist_stop_hook claim and fired specialistStop. The hook could then run while the sub-agent was alive, and the one-shot claim suppressed the hook on the child's real exit. Marking the task non-running also let a TaskOutput or TaskStop reap claim the stop before the launcher's Wait observed the exit. On a failed kill, surface the joined error and return without the status update or the stop. The task stays running, so reapers remain no-ops, and the launcher's exit callback marks it exited and records the single stop when the child ends. A successful kill keeps the immediate stop as before. Co-Authored-By: Claude Opus 5.5 <[email protected]> * fix(hooks): keep the prompt on a failed orphan kill and share one stop claim When SetPID fails and terminating the unrecorded child also fails, the child may still be running and the task stays running. That branch still deleted the --file prompt, so a child that had not yet read it failed at startup. The launcher's exit callback already removes the prompt once the child really exits, and Runtime.Close removes tracked prompts at shutdown, so the failed-kill branch no longer removes it. dispatchSpecialistStopHook made two independent dispatch decisions: the durable specialist_stop_hook claim, and the in-process claim used when the session store errors. A won durable claim never recorded the in-process claim, so on one bridge a transient store error before or after a durable claim dispatched the same stop twice, breaking the documented at-most-once per process. A won durable claim now also takes the in-process claim (including for an ineligible stop), and the hook runs only if both are free. Co-Authored-By: Claude Opus 5.5 <[email protected]> --------- Co-authored-by: Ayush7614 <[email protected]> Co-authored-by: Claude Opus 5.5 <[email protected]> |
||
|
|
05c1b5e160 |
feat(plugins): add CLI enable/disable for plugin.json (#1202)
* feat(plugins): add CLI enable/disable for plugin.json
Add zero plugins enable|disable so installed plugins can be toggled
without removing them, matching hooks/MCP parity and EXTENDING docs.
* fix(plugins): serialize toggles with installs and preserve manifest bytes
Address review findings on the enable/disable CLI:
- Take the same cross-process installtxn lock plugins add/remove take on
the plugin's install root before the final lookup and read, and publish
plugin.json with installtxn.WriteFileAtomically while holding it.
- Re-resolve the plugin under the lock so a concurrent install can never
be overwritten with manifest bytes read before it committed, retrying
when precedence moves to the other root; a plugin removed under the
lock reports ErrNotInstalled instead of being recreated.
- Edit the enabled value in place (a targeted token walk) so field
order, formatting, and numeric literals survive; the old
decode/re-encode dropped precision (9007199254740993 became
9007199254740992) and sorted the keys.
- Fail closed on manifest stat errors, refresh a clean plugins.lock hash
after a toggle, and leave pre-existing drift in place instead of
absorbing it into the lockfile.
- Redact the text-mode output, reject an empty plugin id as a usage
error, and document project-over-user precedence in the help text.
* fix(plugins): recover interrupted install transactions before toggling
SetEnabledByID takes the plugin root's installtxn lock but went straight
to reading and rewriting the tree. installtxn.Recover requires every
holder of that lock to resolve interrupted transactions first and to
abort on its error, as Install and Remove already do.
Without it, a toggle after an install killed between its tree swap and
its lockfile publish edited the unpublished tree, and the next install
or removal restored the retained backup over it, silently undoing the
enable/disable. A transaction recovery could not resolve also failed to
stop the toggle.
Call installtxn.Recover with the plugins reconciler right after taking
the lock, and add regression tests for every interrupted state that
leaves the plugin discoverable, the unresolved-transaction abort, and a
plugin removed while the toggle waits on the lock.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* docs(plugins): say when a plugin toggle takes effect
Zero reads plugin.json "enabled" only when it activates plugins at
startup (runExec and the interactive TUI bootstrap), and nothing reloads
them afterwards. A running interactive session therefore keeps the
tools, hooks, and skills of a plugin disabled from another terminal.
State that in the plugins docs and in the enable/disable help so the
documented activation rule matches what the command does.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* fix(plugins): pin toggled manifests, redact toggle errors, honor last enabled key
Address the review findings on the plugin enable/disable toggle.
Pin the plugin directory before editing its manifest. The install lock
only serializes cooperating installers, so swapping the plugin directory
for a link after discovery redirected the read and the atomic publish
through it, overwriting another manifest. The toggle now opens the
directory under its install root with pathjail, refuses a link or
reparse point, checks that the handle is the directory entry itself,
and reads, stats, creates the temporary file, and renames through that
handle.
Redact every stderr message the toggle writes. Parser and not-installed
usage errors echoed the raw argument, so a mistyped credential reached
stderr unredacted while the toggle's other output was redacted.
Validate only the last "enabled" value, as the loader does. The loader
keeps the last duplicate key, but the toggle rejected an earlier,
overwritten non-boolean value, so a plugin discovered as enabled could
not be toggled.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* fix(plugins): keep the toggle on the pinned plugin directory throughout
The previous fix pinned the manifest read and publish to a handle on the
plugin directory but still hashed the tree for plugins.lock by path, and
reported success without checking that the path still named the pinned
directory. If the directory was replaced after it was opened, the edit
landed in the moved-away original while the lockfile check and refresh
read the replacement, and the command reported a state the installed
plugin did not have.
Hash the tree before and after the edit through the pinned handle, and
confirm that the plugin's directory entry is still the pinned directory
before refreshing the lockfile or reporting any result, including a
no-op. hashTree now delegates to the same rooted walker, so path and
handle callers hash identical bytes.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* fix(plugins): hash plugin trees through native os.Root calls
|
||
|
|
16ccd78765 |
feat(mcp): add AI/ML API as an opt-in built-in server (#1193)
* 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]> |
||
|
|
c9f179482a |
feat(web-fetch): add an opt-in AI/ML API Toolbox scraping backend (#1192)
* 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]> |
||
|
|
98736457bd |
feat(web-search): add AI/ML API Toolbox as an opt-in backend (#1189)
* 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]> |
||
|
|
e59632406b |
test(agent): isolate user home, config and cache dirs for the agent tests (#1184)
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]> |
||
|
|
7c55a09c07 |
chore(main): release 0.12.0 (#1150)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>v0.12.0 |
||
|
|
d99f92b81a |
feat(providers): add MiniMax-M3.1-Flash to catalog (#1180)
* feat(providers): add MiniMax-M3.1-Flash to catalog * fix(providers): forward MiniMax adaptive effort --------- Co-authored-by: octo-patch <[email protected]> |
||
|
|
5aa54fe1d2 |
fix(agent): mark headless guardrail stops incomplete (#1182)
* 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. |
||
|
|
f912f80d00 |
fix(chatgpt): refresh subscription defaults and compatibility version (#1179)
* 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 |
||
|
|
9c4e3693e7 |
test(providerio): widen the idle window in the content-reset test (#1172)
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]> |
||
|
|
63962081cf |
fix(sessions): capture freeform apply_patch checkpoints (#1183)
* 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. |
||
|
|
2e10a4b60b |
test(swarm): wait for every slot to be released before checking the drain (#1181)
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. |
||
|
|
4247a07aba |
test(tools): stop the tty session before removing its root (#1175)
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. |
||
|
|
b837448dd4 |
docs: list Vercel under Partners (#1176)
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. |
||
|
|
a0b036a3c7 |
docs: add Vercel OSS Program badge (#1174)
* 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. |
||
|
|
164150633a |
feat(skills): install a skill's bundled files and point the skill tool at them (#1167)
* 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. |
||
|
|
5d080ef46b |
test(tui): pin the cwd in tests that assert text is absent from the view (#1170)
* 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. |
||
|
|
1d233c0a9b |
test(modelregistry): clear ZERO_DISABLE_MODELS_FETCH in the models.dev tests (#1169)
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. |
||
|
|
de1443b2ed |
fix(deps): refresh the lockfile to hono 4.13.13 (#1171)
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. |
||
|
|
84c1b15540 |
fix(oauth): reuse the crash-safe secret store and sync encrypted publications (#1165)
* 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. |
||
|
|
6393a90ed6 |
fix: bump Go to 1.26.10 for GO-2026-6809 (#1168)
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. |
||
|
|
cd3e0b0a9f |
test(cli): isolate the exec image tests from the models.dev refresh (#1161)
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. |
||
|
|
aa9ba8412e |
fix(tools): grep stops where a file's line breaks stop (#1160)
* 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.
|
||
|
|
a97db8832f |
fix(redaction): redact credentials whose trigger a greedy span swallowed (#1158)
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.
|
||
|
|
ef4da1323f |
fix(workspaceindex): import gitsafe under the Twigpine module path (#1153)
#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
|
||
|
|
1e848492e5 |
fix(sessions): preserve image attachment summaries on resume (#1152)
* 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 |
||
|
|
2ffe1831b0 |
fix(workspaceindex): run Scan's git calls through gitsafe (#1149)
* 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. |
||
|
|
13e2596b2e |
chore: move repository references to Twigpine/zero (#1105)
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]> |
||
|
|
8c4e759798 |
feat(sandbox): support self-exec pattern to eliminate separate helper binary (#946)
* 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]> |
||
|
|
d597f0e095 |
chore(main): release 0.11.0 (#1146)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>v0.11.0 |
||
|
|
ce2fcffaf1 |
perf(redaction): add fast-path byte checks to eliminate regex allocations on clean strings (#945)
* 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]> |
||
|
|
61b09364f0 |
feat(providers): add Concentrate provider preset (#1147)
* 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. |
||
|
|
d1a0d048ee |
fix(cli): let providers subcommands run with no active provider (#1143)
* fix(cli): let providers subcommands run with no active provider `zero providers list`, `use`, `remove` and `rename` exited 3 with `no active provider configured: active provider "" not found` as soon as the environment supplied more than one provider and config.json had no active provider: normalizeProvidersWithOptions only elects an active provider by itself when there is exactly one, and resolveCommandCenterConfig treated the resulting error as fatal, so the command stopped before doing anything. A base URL without a key counts as a second provider too (applyProviderEnv builds a profile from a base URL alone). The resolver already returns the successfully normalized list alongside ErrNoActiveProvider, and the interactive TUI already falls back to a usable entry from it in runInteractiveTUIWithSetup. Do the same here instead of failing, so these subcommands can answer for providers that only come from the environment. Also pin the four env-derived provider tests to a fixed environment and a temporary user config. They set OPENAI_API_KEY but resolved through the real resolver against the developer's own config.json and ambient provider variables, so they failed on any machine that also exported another provider variable and whose user config had no active provider. Adds regression coverage for the two-env-provider case: `providers list` must report every detected provider and `use`/`remove`/`rename` must give their usual env-derived answer. Verified against the fix (pass) and with the fix reverted (exit 3, `active provider "" not found`). Fixes #1130 * refactor(cli): report the configured active provider, not an elected one Provider-management subcommands answer for the SAVED configuration, while the TUI's recovery path answers "what can I run with". Adopting firstUsableProvider in resolveCommandCenterConfig conflated the two: with two env-derived providers it made `providers current`/`list` present a provider that `zero exec` would still refuse to run with, and a base-URL-only profile failed `providers list` because the election demands a credential. Return the resolver's partial config untouched instead, so nothing is reported as active when nothing is selected. Also removes the OAuth store read this path picked up with firstUsableProvider. Adds assertions that `current` shows nothing selected, `list` marks nothing active, `use --json` leaves activeProvider empty, plus a base-URL-only case. * fix(cli): scope the no-active-provider exception to provider management Returning the resolver's partial config made `zero config` exit 0 with a partial summary, contradicting the resolver's documented headless contract: `zero config` / `zero exec` still fail with the actionable message and only the interactive TUI recovers from a missing active provider. Split the helper so only issue #1130's commands — `providers list`/`current` and the unpersisted `use`/`remove`/`rename` reporters — tolerate the state; `zero config`, `detect`, `models` and `check` keep failing as before. Also pin the OAuth store in providerEnvSetupDeps: summarizeConfig reads OAuth login state through the default store, so a developer's stored login could flip these assertions. Point ZERO_OAUTH_TOKENS_PATH at a temp file and force ZERO_OAUTH_STORAGE=file so an ambient keyring setting cannot pull the OS keychain in either. |
||
|
|
c88c77bf70 |
fix(workspaceindex): skip git-ignored and build-cache dirs in Scan only (#1117)
* fix(workspaceindex): skip git-ignored and build-cache dirs in Scan only Scan has a fixed file budget, and build output (Cargo target/, Python virtualenvs, caches) could exhaust it before real source was reached: repo-map reported truncation and ranked fingerprint files above code. Inside a git work tree, Scan now asks git for the ignored set (`git status --porcelain=v1 -z --ignored=matching .`), so the repo's own .gitignore, nested ignore files, info/exclude and core.excludesFile all apply, including names no fixed list anticipates (.locust_env/). Outside a work tree, or when git is missing, older than 2.16, fails, or exceeds 5s, a fixed list of common build/cache directory names stands in. ShouldSkipDir is unchanged: glob, grep, list_directory and path autocomplete share it and must keep seeing target/, .venv/ and friends. The lookup runs with GIT_OPTIONAL_LOCKS=0 so a per-turn scan never takes .git/index.lock next to a concurrent git command. Fixes #1108 * fix(workspaceindex): skip a rename's original-path field when parsing status In `git status --porcelain=v1 -z`, a rename or copy record is followed by one more NUL field holding the original path, with no XY prefix. The parser inspected every field, so an original name beginning with "!! " read as an ignored entry: after `git mv "!! a" b`, a tracked file "a" disappeared from Scan. Move the parsing into parseIgnoredStatus, which consumes that field for R or C in either status column, and test it directly and end to end. * fix(workspaceindex): disable repo fsmonitor and force untracked-files=normal for status Scan's git status lookup must not run a repository's core.fsmonitor hook or fail when status.showUntrackedFiles=no would reject --ignored=matching. * fix(workspaceindex): ignore inherited git repository location Git exports GIT_DIR and GIT_WORK_TREE to hooks. With those pointing at another repository, Scan kept .locust_env/lib/x.py, which root's own .gitignore excludes. * fix(workspaceindex): quote the fsmonitor hook path in the regression On Windows, sh treats an unquoted backslash path as escapes, so the hook ran and the marker the test stats was never created. Require a status without the override to create that marker first. * fix(workspaceindex): pass the fsmonitor hook path to git with forward slashes Smoke (windows-latest) failed TestGitIgnoredPathsDisablesRepoFsmonitor because core.fsmonitor was set to a backslash script path. Git for Windows hands that value to sh, which consumes the separators, so the hook never runs and the marker is never created. The test now stores a quoted forward-slash path. The product code is unchanged. |
||
|
|
640d936bfb |
fix(tui,cli): stop a repository's git config from running programs in Zero's own git calls (#1133)
* fix(tui,cli): stop a repository's git config from running programs in Zero's own git calls Zero runs git status and git diff on its own: the TUI's files sweep at startup and after each command, its pull request diffstat, and the scratch-file warning after a run. A repository's own config can name programs those commands run (core.fsmonitor on every status, hooks when the index is rewritten, clean filters and diff drivers assigned through .gitattributes), so a workspace unpacked from an archive ran its own code on the first turn, although Zero refuses the same workspace's project hooks and plugins until the user trusts it. internal/gitsafe builds those commands with core.fsmonitor and core.hooksPath turned off and GIT_OPTIONAL_LOCKS=0, and drops the repository-location variables git exports to hooks, using the same environment for its config check and for the command. A repository whose own config, or per-worktree config when extensions.worktreeConfig is on, defines a filter or diff driver command is refused, and the callers already treat that like a directory that is not a repository. The status and diff calls also pass --ignore-submodules=dirty, because a submodule's own config is not checked. * test(gitsafe): keep the real-git setup off an inherited GIT_DIR The setup helper in TestCommandAgainstRealGit ran git with the inherited environment, so from a hook, or a shell that exports GIT_DIR or GIT_WORK_TREE, git init and git config would act on the repository those name instead of the temporary one. It now uses the same environment as Command, and the test sets both variables to a decoy repository and checks after each setup step that the decoy was not touched. |
||
|
|
8e363fcaa5 |
fix(npm): wait for npm to apply each publish, and drop the one-time @gitlawb/zero steps (#1145)
* fix(npm): wait for npm to apply each publish, and drop the one-time @gitlawb/zero steps npm applies a publish asynchronously: `npm publish` returns once the upload is accepted, and the version appears later. In the 0.10.0 release the @twigpine/zero wrapper appeared 9 seconds after publish-packages.sh stopped checking for it, and the last platform payload about ten minutes after its upload. Two things went wrong: - The wrapper became visible before four of its five platform payloads, so installs in that window resolved aliases that 404 and fell back to the GitHub-release download. Ordering the uploads does not order what npm shows. - The final `latest` check gave up after 25 seconds and failed the job even though every publish landed, which also skipped the @gitlawb/zero steps until a re-run. publish-packages.sh now waits until npm shows every platform version before it publishes the wrapper, then waits until `latest` is the wrapper version. One deadline bounds the waits (ZERO_NPM_PUBLISH_WAIT_TIMEOUT, default 30 minutes). On timeout it fails before the wrapper goes out and says what npm never showed. These lookups treat an error as "not yet", unlike the skip decision, which still stops on anything but E404. The test fake can now apply a publish only after a number of later lookups, and records which aliased versions were missing when the wrapper was published. Against the script as released in v0.10.0, the two new tests fail the way the release did: the job exits 1 although everything landed, and the wrapper goes out while its platform payload is not visible. The two release steps that published 0.10.0 once more as @gitlawb/zero have done their job and are removed; docs/NPM_PACKAGING.md and the comments that pointed at them now describe what happened. The Go and wrapper handling of the old name stays, since only a build installed under it can trigger it. * fix(npm): validate the publish wait settings, and correct the legacy publishing claim ZERO_NPM_PUBLISH_WAIT_TIMEOUT went into Bash arithmetic as typed. A leading zero reads as octal (0600 waited 384 seconds), and 08 is an arithmetic error that, in a script, does not stop it: Bash prints the error, leaves the deadline unset and carries on, so the run only died at its first wait, after the platform payloads were published. A negative timeout ended the wait before it started, and a bad ZERO_NPM_PUBLISH_RETRY_DELAY only failed at the first sleep, also after publishing began. Both settings are now checked before anything is published: whole seconds only, converted with 10# so a leading zero is decimal, bounded before the conversion, and capped (timeout 86400, delay 600). A bad value fails with the variable's name. TestPublishPackagesReadsTheWaitTimeoutAsDecimal makes the script wait, which is where the old code broke: against the reviewed script it publishes the platform payload and then exits 1 on "08: value too great for base". TestPublishPackagesRejectsAnInvalidWaitSetting covers -5, abc, 1e3, an over-range and an overflowing timeout, and a non-numeric delay; the old script published with five of them and crashed on the sixth with "abc: unbound variable". docs/NPM_PACKAGING.md said nothing could publish @gitlawb/zero again. Removing the trusted publisher stops this workflow; a maintainer could still publish by hand, and the doc now says that. |
||
|
|
856919bcaa |
chore(main): release 0.10.0 (#1061)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>v0.10.0 |
||
|
|
883e5bec24 |
fix(sessions): keep a session lease for the life of the process, not of the Store (#1144)
A session's lease file lived in the Store that took it, so once that Store was unreachable the garbage collector closed the file, and closing it drops the lock. That made TestForkAndChildHoldTheParentTheyAreCreatedFrom fail on main whenever a collection ran before its dry-run prune. It can also happen in an exec run: PrepareExec takes the calling or forked session's lease on a Store the run never touches again, so the parent's lease can disappear part way through and a prune in another terminal could remove it. Leases now belong to the process, as Hold documents. Every Store holding a session shares one lease file in a process-wide table, and the file is unlocked and closed only when the last Store holding it releases it. |
||
|
|
530563a0bb |
feat(npm): publish as @twigpine/zero (#1142)
* feat(npm): publish as @twigpine/zero The npm package moves from @gitlawb/zero to @twigpine/zero, and everything that finds Zero's GitHub repository by name now names Twigpine/zero. Package name - package.json, the lockfile and release-please use @twigpine/zero. `repository` names Twigpine/zero: npm checks provenance against it, so a publish from this repo with the old URL fails with E422. The homepage is zero.twigpine.com. - The name lives in package.json only. build-platform-packages.mjs names the wrapper, platform versions, alias names and alias targets from it, and bin/zero.js reads its own package.json for the alias it resolves and for every reinstall hint. The Go constant npmPackageName is held equal to package.json by a test. Repository defaults - The update check (DefaultRepository), install.sh, install.ps1, the npm wrapper's fallback downloader, and the GitHub Action's zero-repo default all used Gitlawb/zero. That name now belongs to an unrelated GitHub org, and these paths download and run what they find there, so they point at Twigpine/zero. Docs, README and issue-template links follow. The Go module path (and so `go install github.com/Gitlawb/zero/...`) is unchanged here. Publishing - The two inline publish steps become scripts/npm/publish-packages.sh, which reads the name from the assembled payloads, refuses to start when the wrapper's aliases do not match the platform payloads, skips versions already on the registry (so re-running a half-finished job is safe), and publishes with --ignore-scripts. The dist-tag rules are unchanged. internal/npmwrapper/publish_test.go runs it against a fake registry. Docs explain the switch for existing installs: uninstall @gitlawb/zero first, because both packages own the `zero` bin and npm refuses the second (EEXIST). * feat(npm): publish 0.10.0 once more as @gitlawb/zero to point users at the new name Deprecating @gitlawb/zero alone would strand every existing npm install. 0.9.0's `zero upgrade` runs `npm install -g @gitlawb/zero@latest`; with nothing newer under that name npm installs nothing, prints no deprecation warning (it only warns when it installs something), and Zero then reports "updated via npm to 0.10.0" while staying on 0.9.0. So this release goes out once more under the old name, as a bridge: - The publish job assembles the same verified artifacts a second time with the new `build-platform-packages.mjs --package-name @gitlawb/zero` and publishes them with publish-packages.sh, after the @twigpine/zero publish. A gate runs it only while @gitlawb/zero's latest is still 0.9.0 (or is this version, on a re-run), so it happens once. - The build knows its name from the package.json beside it. The Go side reports InstallMethodNpmLegacy: `zero upgrade` refuses with the switch steps before any release check (so it works offline too), and `zero update --check` adds them whether or not an update is out. - bin/zero.js prints the same steps after zero exits (the TUI would hide a notice printed first), only when stderr is a terminal, and not for `update`/`upgrade`, which print it themselves. - Updating from 0.9.0 now installs a real version, so npm also shows the deprecation message. The steps say to uninstall @gitlawb/zero before installing @twigpine/zero: both own the `zero` bin, npm refuses the second install with EEXIST, and a forced install loses the bin when the old package is later removed (checked against npm 11.16). docs/NPM_PACKAGING.md has the maintainer runbook: bootstrap and trust the new package, retarget the old one's trusted publisher for the bridge, then deprecate @gitlawb/zero, revoke its publisher, and delete the bridge steps. * fix(npm): only skip a publish when npm says the version is not there publish-packages.sh treated any failed `npm view <name>@<version>` as "not published". A transient network, auth or registry error on the re-run that the skip exists for therefore published a version that was already out, and npm rejected it with EPUBLISHCONFLICT; with the registry unreachable it published blind. npm 11 reports every kind of missing (no such package, no such version, including platform versions) as E404, so only E404 now means "not published". Any other failure is retried three times and then stops the job before it publishes anything, with npm's own error shown. The test fake now answers a missing version with E404 the way npm does (it returned an empty success before), and can fail lookups with a network error. The two new tests fail on the old lookup: one with EPUBLISHCONFLICT after transient failures, the other by publishing while the registry is unreachable. Also renames the unused retry variable in the legacy-name gate step to `_` (shellcheck SC2034), matching the script. |
||
|
|
c56a4c4e6e |
fix(deps): refresh the lockfile to patched shell-quote (#1131)
shell-quote 1.9.0 is inside the range GHSA-pqg4-j6r4-53mv covers (>= 1.8.4, < 1.11.0). It reaches the lock through tuistory, @opentui/react and react-devtools-core, which asks for ^1.6.1, so refreshing the lock moves it to 1.12.0 without touching package.json. Release packaging installs the local-control helpers from this lock, so the old version shipped in the helper tree. |