Compare commits

...
Author SHA1 Message Date
JATMNandAyush7614 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: d97d1cf7ae
Original-commit: 9b994c1903
Original-commit: 48f65ca634
Original-PR: https://github.com/Twigpine/zero/pull/1159
Fixes: #1156

* fix(tools): preserve atomic publication when replacement is refused

Keep regular-file preimages and hard-link siblings intact after rename errors. Record published creates even when staging cleanup fails while preserving the cleanup error. Make the permission regression fixture independent of umask.

---------

Co-authored-by: Ayush7614 <[email protected]>
2026-11-09 12:25:24 -08:00
Vasanth T 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.
2026-11-09 08:02:41 -08:00
Vasanth T 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.
2026-11-09 07:49:09 -08:00
github-actions[bot] ef1ca39b09 chore(main): release 0.13.0 (#1190)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-11-09 14:20:51 +08:00
Victor Lowther 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
2026-11-09 13:29:28 +08:00
Kevin Codex 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.
2026-11-09 11:56:14 +08:00
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 100d847aaf)

* fix(tui): gate full-view read errors on snapshot validity

renderFileViewFull returned renderedContent whenever hasError was set,
before the snapshot-validity check that governs every other completed
snapshot. A /btw fork of a full view that had settled on a read error
invalidates the inherited snapshot and clears renderedContent, but the
error latch survived, so the side pane rendered an empty body instead of
the loading placeholder until its own load landed. Reloads of an errored
view (resize, refresh, theme) likewise kept showing the stale error.

Serve the error snapshot through the same validity predicate as content
snapshots, so a pending or invalidated load falls through to "Loading…".
Error displays still do not count as cache hits.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit a1887f4b03)

* fix(tui): keep file-view revisions monotonic and revalidate on git sweeps

Shared path revisions reset to zero on every cache purge, while each surface
kept its own required revision. Three sites could also raise that revision
past the shared one by incrementing it locally. After a purge, an exact-path
edit made on the other BTW surface could produce a shared revision no higher
than this surface's own. recoverInvalidatedFileView then saw nothing new, and
the view stayed stale. Revisions now come from one cache-wide clock that
survives purges. A purge raises the epoch floor to that clock instead of
resetting it, and each surface adopts the revision the cache returns.

Every git sweep invalidated each swept path and forced a full refresh. The
swept list is cumulative since the baseline, and each live sweep is launched
beside the tool-result, turn-end or BTW-return handling that already reloads
or rechecks the open view. The sweep that followed therefore reread and
re-highlighted an unchanged file and flashed "Loading…". A sweep now
revalidates the open view instead. It waits for an in-flight load to land,
then rereads the bounded source in the background and compares it with the
displayed snapshot's identity (hash and truncation flags). The view reloads
only if the bytes changed. A mutation the swept list omits is still caught.

Regression tests (all fail on 311290c0 and pass here):
- TestFileViewCrossSurfaceExactEditAfterPurgeReloadsOtherSurface
- TestFileViewGitSweepRevalidatesWithoutDuplicateReload
- TestFileViewGitSweepDuringLoadDefersToThatLoad
- TestFileViewStaleRevalidationDoesNotReloadNewerSnapshot
- TestFileViewCachePurgeResetsPathRevisions (now asserts monotonicity)

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

* refactor(tui): return source identity from loadAndRender directly

1ce4f96e added loadAndRenderSource and left loadAndRender as a two-value
wrapper. After that change only tests called the wrapper, so make deadcode
newly reported it. Fold the wrapper away: loadAndRender now returns the
rendered text, the source identity, and the error, and its callers take the
value they need. Behavior is unchanged.

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

* fix(tui): close three file-view staleness gaps from review

- fileViewSourceHash joined lines with separators, so an empty file (no
  lines) and a file holding one newline (one empty line) hashed alike.
  Sweep revalidation compares identities without the file size, so adding
  or removing that blank line went undetected. Every line is now
  terminated, which keeps the encoding injective.
- A load cancelled inside the bounded read returned errFileViewSuperseded
  with no lines. loadAndRender then treated it as a read error and evicted
  the path, discarding an entry a newer load had already committed. The
  sentinel now returns before the eviction branch.
- A cancelled run keeps going until it finishes, and agentRowMsg ignores its
  late tool results. Its completion only flushed session events, so a write
  that finished during cancellation left an open full-file view stale. The
  flush completion now runs the unknown-scope refresh.

Regression tests (each fails on ca1cdaf3 and passes here):
- TestFileViewGitSweepDetectsBlankLineAddedToEmptyFile
- TestFileViewSupersededReadKeepsNewerCacheEntry
- TestFileViewCancelledRunCompletionRefreshesOpenView

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

* fix(tui): gate Run details Enter on the visible overlay

The Run details Enter drill-in keyed off the raw runDetailsOpen flag,
while mouse FILES picks use runDetailsVisible(). The flag outlives the
overlay: clicking a specialist card beside Run details enters the
subchat, which hides the overlay but leaves runDetailsOpen set, and
Enter then opened the parent's file view from inside the subchat.

Use runDetailsVisible() and the shared runDetailsInnerWidth() geometry
on the keyboard path, matching the mouse path. The branch's existing
Esc/Ctrl+B and key-swallow behavior is unchanged.

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

---------

Co-authored-by: hazyhaar <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-07 10:28:29 -08:00
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]>
2026-11-07 10:19:56 -08:00
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

20b484f moved hashTree onto an os.Root handle but walked it through
handle.FS(). That adapter accepts only fs.ValidPath names, which must be
UTF-8, so a plugin holding a Linux-valid name such as byte 0xff failed
with "lstat ...: invalid argument". copyTree and the previous walker
accept those names, so Install, the toggle's lock refresh, and Info's
drift check all regressed for such plugins.

Walk the handle with its own Open/Lstat methods instead. The walk stays
pinned to the directory, and the hash bytes match the merge-base
implementation (checked on a fixture with nested and non-UTF-8 names,
an exec bit, a symlink, .git and a FIFO).

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

---------

Co-authored-by: Ayush7614 <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-07 09:56:16 -08:00
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]>
2026-11-04 20:13:53 -08:00
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]>
2026-11-04 19:20:17 -08:00
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]>
2026-11-04 18:24:09 -08:00
ShryukGrandhi 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]>
2026-11-04 12:27:48 +08:00
github-actions[bot] 7c55a09c07 chore(main): release 0.12.0 (#1150)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-11-03 08:10:03 +08:00
Octopusandocto-patch 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]>
2026-11-02 18:28:17 +08:00
zzucs 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.
2026-11-02 14:25:40 +08:00
oniisan 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
2026-11-02 14:25:00 +08:00
ShryukGrandhi 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]>
2026-11-02 14:24:24 +08:00
zzucs 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.
2026-11-02 14:23:58 +08:00
Vasanth T 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.
2026-10-29 06:30:41 -07:00
Vasanth T 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.
2026-10-27 06:25:41 -07:00
Vasanth T 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.
2026-10-27 15:29:59 +08:00
Vasanth T 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.
2026-10-27 12:30:50 +05:30
Vasanth T 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.
2026-10-25 07:24:59 -07:00
Vasanth T 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.
2026-10-22 06:52:51 -07:00
173 changed files with 22045 additions and 602 deletions

No files matched your search

+1 -1
View File
@@ -1 +1 @@
{".":"0.11.0"}
{".":"0.13.0"}
+46
View File
@@ -6,6 +6,52 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html) once the first release is
tagged. Until then, source builds report the version `dev`.
## [0.13.0](https://github.com/Twigpine/zero/compare/v0.12.0...v0.13.0) (2026-11-09)
### Features
* **mcp:** add AI/ML API as an opt-in built-in server ([#1193](https://github.com/Twigpine/zero/issues/1193)) ([16ccd78](https://github.com/Twigpine/zero/commit/16ccd78765b6a2a56b7370e3031710d718aa4757))
* **plugins:** add CLI enable/disable for plugin.json ([#1202](https://github.com/Twigpine/zero/issues/1202)) ([05c1b5e](https://github.com/Twigpine/zero/commit/05c1b5e16098e1c60212316d20ae8e055a27b5ae))
* **providers:** point OpenGateway at api.twigpine.com and trust its live model list ([#1208](https://github.com/Twigpine/zero/issues/1208)) ([47f78c6](https://github.com/Twigpine/zero/commit/47f78c66247aa96c8753d6bb95e935126ff1761e))
* **web-fetch:** add an opt-in AI/ML API Toolbox scraping backend ([#1192](https://github.com/Twigpine/zero/issues/1192)) ([c9f1794](https://github.com/Twigpine/zero/commit/c9f179482a7f6d7111ee64fa9cb8ffa2517c2397))
* **web-search:** add AI/ML API Toolbox as an opt-in backend ([#1189](https://github.com/Twigpine/zero/issues/1189)) ([9873645](https://github.com/Twigpine/zero/commit/98736457bdac6b0272e032728a5485cf557780fe))
### Bug Fixes
* **hooks:** dispatch specialistStart/Stop lifecycle hooks ([#1204](https://github.com/Twigpine/zero/issues/1204)) ([2ca84cc](https://github.com/Twigpine/zero/commit/2ca84cc127d9369765004b72cf95049fecc6c28b))
* **tui:** make reasoning stream rendering linear (coalesced deltas, O(1) append, tail-bounded preview) ([#1198](https://github.com/Twigpine/zero/issues/1198)) ([bbf779e](https://github.com/Twigpine/zero/commit/bbf779e69168d62bb1103aefdd1b10e53433209e))
### Performance Improvements
* **tui:** bounded file read, LRU rendering cache, and size caps for full-file view ([#1203](https://github.com/Twigpine/zero/issues/1203)) ([e034a93](https://github.com/Twigpine/zero/commit/e034a93815629c68976dcdab7dcc08bbe9b03693))
## [0.12.0](https://github.com/Twigpine/zero/compare/v0.11.0...v0.12.0) (2026-11-02)
### Features
* **providers:** add MiniMax-M3.1-Flash to catalog ([#1180](https://github.com/Twigpine/zero/issues/1180)) ([d99f92b](https://github.com/Twigpine/zero/commit/d99f92b81a671f186cede81614c89b8e8a7f9451))
* **sandbox:** support self-exec pattern to eliminate separate helper binary ([#946](https://github.com/Twigpine/zero/issues/946)) ([8c4e759](https://github.com/Twigpine/zero/commit/8c4e7597989ef9b3ca67b7703dfd15d92372aed2))
* **skills:** install a skill's bundled files and point the skill tool at them ([#1167](https://github.com/Twigpine/zero/issues/1167)) ([1641506](https://github.com/Twigpine/zero/commit/164150633a8b2e6ae864ae02fd7105b201eeb68e))
### Bug Fixes
* **agent:** mark headless guardrail stops incomplete ([#1182](https://github.com/Twigpine/zero/issues/1182)) ([5aa54fe](https://github.com/Twigpine/zero/commit/5aa54fe1d2b419c9bbef73605a77919c9007cf7b))
* bump Go to 1.26.10 for GO-2026-6809 ([#1168](https://github.com/Twigpine/zero/issues/1168)) ([6393a90](https://github.com/Twigpine/zero/commit/6393a90ed618a3536e09c02852f4ec829b97545b))
* **chatgpt:** refresh subscription defaults and compatibility version ([#1179](https://github.com/Twigpine/zero/issues/1179)) ([f912f80](https://github.com/Twigpine/zero/commit/f912f80d002602e11ba0ef8548ecee58442874aa))
* **deps:** refresh the lockfile to hono 4.13.13 ([#1171](https://github.com/Twigpine/zero/issues/1171)) ([de1443b](https://github.com/Twigpine/zero/commit/de1443b2edb44fd3747cc77200d54dc8cffcd0a7))
* **oauth:** reuse the crash-safe secret store and sync encrypted publications ([#1165](https://github.com/Twigpine/zero/issues/1165)) ([84c1b15](https://github.com/Twigpine/zero/commit/84c1b1554076679a342e9cac61f8f8cdbaf3a429))
* **redaction:** redact credentials whose trigger a greedy span swallowed ([#1158](https://github.com/Twigpine/zero/issues/1158)) ([a97db88](https://github.com/Twigpine/zero/commit/a97db8832fdba077f2d31bdf03ecef8d7c774fe1))
* **sessions:** capture freeform apply_patch checkpoints ([#1183](https://github.com/Twigpine/zero/issues/1183)) ([6396208](https://github.com/Twigpine/zero/commit/63962081cf130c2171407936ce3beb7021d238b8))
* **sessions:** preserve image attachment summaries on resume ([#1152](https://github.com/Twigpine/zero/issues/1152)) ([1e84849](https://github.com/Twigpine/zero/commit/1e848492e5c1919d187fb5e1eb5e7ef9bc6140dc)), closes [#1135](https://github.com/Twigpine/zero/issues/1135)
* **tools:** grep stops where a file's line breaks stop ([#1160](https://github.com/Twigpine/zero/issues/1160)) ([aa9ba84](https://github.com/Twigpine/zero/commit/aa9ba8412eb8917daa2ca5cb3fdbd0a39276fa88))
* **workspaceindex:** import gitsafe under the Twigpine module path ([#1153](https://github.com/Twigpine/zero/issues/1153)) ([ef4da13](https://github.com/Twigpine/zero/commit/ef4da1323f1097863ae278224b5f304c2fff909a))
* **workspaceindex:** run Scan's git calls through gitsafe ([#1149](https://github.com/Twigpine/zero/issues/1149)) ([2ffe183](https://github.com/Twigpine/zero/commit/2ffe1831b0950b1463ec46d487d03a5fcb679cc6))
## [0.11.0](https://github.com/Twigpine/zero/compare/v0.10.0...v0.11.0) (2026-10-15)
+80
View File
@@ -15,6 +15,7 @@
<img alt="Go 1.26.10+" src="https://img.shields.io/badge/Go-1.26.10+-00ADD8?logo=go&logoColor=white">
<img alt="25+ providers" src="https://img.shields.io/badge/providers-25+-34E2EA">
<a href="https://discord.gg/CaQDS6wdFn"><img alt="Discord" src="https://img.shields.io/badge/Discord-join-5865F2?logo=discord&logoColor=white"></a>
<a href="https://vercel.com/open-source-program"><img alt="Vercel OSS Program" src="https://vercel.com/oss/program-badge-2026.svg"></a>
<br>
<strong>English</strong> | <a href="README_ZH.md">中文</a>
</p>
@@ -87,9 +88,18 @@ zero exec --output-format stream-json < turns.jsonl
</picture>
</a>
</td>
<td align="center" width="150" height="80">
<a href="https://vercel.com/">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/assets/vercel-logo-dark.svg">
<img src="docs/assets/vercel-logo.svg" alt="Vercel logo" width="120">
</picture>
</a>
</td>
</tr>
<tr>
<td align="center"><a href="https://www.gmicloud.ai/"><strong>GMI Cloud</strong></a></td>
<td align="center"><a href="https://vercel.com/"><strong>Vercel</strong></a></td>
</tr>
</table>
@@ -387,6 +397,76 @@ and can rewrite every request through it, so this is the trust you accept by
configuring one. Leave the variables unset for the checks to be enforced end to
end.
The built-in `web_search` tool is registered only when a search backend is
configured: `ZERO_WEBSEARCH_BASE_URL` (with an optional `ZERO_WEBSEARCH_API_KEY`)
for a generic JSON endpoint, `ZERO_WEBSEARCH_PROVIDER=searxng` with a SearXNG
base URL, or the AI/ML API Toolbox:
```bash
export ZERO_WEBSEARCH_PROVIDER=aimlapi
```
The AI/ML API backend runs the Toolbox tool `web/search`, and every search is
billed to your AI/ML API balance: about $0.0013 per result, with a documented
minimum of $0.0091 per search. Each search is sent with a $0.025 spending cap,
which the Toolbox enforces. It is used only when
`ZERO_WEBSEARCH_PROVIDER` is set to `aimlapi`; an AI/ML API key alone never
enables it. The key is read from your user configuration only; provider
profiles in a project `.zero` config are not used. It is the aimlapi profile's
key (the active profile when it is aimlapi, otherwise the first aimlapi
profile), including a key in the credential store, or `AIMLAPI_API_KEY` when no
aimlapi profile exists. It is resolved again on every search;
`ZERO_WEBSEARCH_API_KEY` is not used. When any aimlapi profile, or any profile
that reads `AIMLAPI_API_KEY`, points at a base URL other than
`https://api.aimlapi.com/v1`, or `AIMLAPI_INFERENCE_URL` is overridden, the key
may belong to a proxy, so it is not sent and the search fails without a request.
Requests go only to
`https://tools.aimlapi.com` (`ZERO_WEBSEARCH_BASE_URL` is ignored and redirects
are refused) and carry `X-AIMLAPI-Source: agent/zero` and an
`X-AIMLAPI-Partner-ID` header. The `domains` argument is sent to the Toolbox as
`include_domains`, and results are still filtered locally.
### AI/ML API Toolbox backend for `web_fetch`
`web_fetch` can fetch pages through the AI/ML API Toolbox tool
`search1api/crawl` instead of a direct HTTP GET. It is off unless you choose it:
```bash
export ZERO_WEBFETCH_PROVIDER=aimlapi
```
Having an AI/ML API key never switches it on by itself, and any other value (or
none) keeps the direct fetch. The variable is read on every fetch.
- **Billing:** Toolbox runs are billed to your AI/ML API balance, about
$0.0013 per run. Each run is sent with `max_cost_usd` 0.005, so a run
priced above that is refused before it starts and not charged. Rate-limited
or gateway-failed requests are retried with the same idempotency key, so a
retry is never billed twice.
- **Key:** the key comes from your `aimlapi` provider profile (including a key
in the credential store), or from `AIMLAPI_API_KEY` when there is no such
profile. It is withheld, and `web_fetch` returns an error, when any `aimlapi`
profile or any profile using `AIMLAPI_API_KEY` points at a base URL other than
`https://api.aimlapi.com/v1`, or when `AIMLAPI_INFERENCE_URL` is overridden:
the key may then belong to a proxy.
- **Destination:** requests go only to the fixed `https://tools.aimlapi.com`
endpoint, with `Authorization: Bearer <key>` and the attribution headers
`X-AIMLAPI-Source: agent/zero` and `X-AIMLAPI-Partner-ID`. Redirects are
refused. `HTTP_PROXY`/`HTTPS_PROXY` apply to these requests as to Zero's other
aimlapi.com calls.
- **No fallback:** without a usable key, or when the run fails, `web_fetch`
returns an error and does not fetch the page directly. Unset the variable to
go back to direct fetches.
- **Remote fetch:** the page is fetched by a remote service, which may serve a
cached copy, and the full URL (query string included) is sent to it. The URL
checks above still run first, so loopback, private and metadata URLs are
never handed to it. The result is Markdown with a
`Backend: aimlapi (search1api/crawl)` line; `max_bytes` cuts the Markdown,
and `format: "raw"` is refused because the original response is not
available. The complete Toolbox run response is limited to 8 MiB before JSON
decoding; larger responses return an error even if `max_bytes` is smaller.
The remote run may already have been billed when this limit is detected.
For local dev servers, use shell commands such as `curl` through `exec_command`
so the normal sandbox and permission policy applies. Long-running commands stay
attached to a background terminal session and can be listed or stopped from the
+10
View File
@@ -15,6 +15,7 @@
<img alt="Go 1.26.10+" src="https://img.shields.io/badge/Go-1.26.10+-00ADD8?logo=go&logoColor=white">
<img alt="25+ providers" src="https://img.shields.io/badge/providers-25+-34E2EA">
<a href="https://discord.gg/CaQDS6wdFn"><img alt="Discord" src="https://img.shields.io/badge/Discord-join-5865F2?logo=discord&logoColor=white"></a>
<a href="https://vercel.com/open-source-program"><img alt="Vercel OSS Program" src="https://vercel.com/oss/program-badge-2026.svg"></a>
<br>
<a href="README.md">English</a> | <strong>中文</strong>
</p>
@@ -85,9 +86,18 @@ zero exec --output-format stream-json < turns.jsonl
</picture>
</a>
</td>
<td align="center" width="150" height="80">
<a href="https://vercel.com/">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/assets/vercel-logo-dark.svg">
<img src="docs/assets/vercel-logo.svg" alt="Vercel logo" width="120">
</picture>
</a>
</td>
</tr>
<tr>
<td align="center"><a href="https://www.gmicloud.ai/"><strong>GMI Cloud</strong></a></td>
<td align="center"><a href="https://vercel.com/"><strong>Vercel</strong></a></td>
</tr>
</table>
+13 -1
View File
@@ -33,4 +33,16 @@ Please include, where possible:
depend on a user explicitly disabling the sandbox (`--skip-permissions-unsafe`), are generally
out of scope — but please report anything you are unsure about.
- Zero sends no telemetry. Network calls go to the model/tool providers you configure (and, for
keyless web search, to Exa's hosted MCP endpoint at `https://mcp.exa.ai/mcp`).
keyless web search, to Exa's hosted MCP endpoint at `https://mcp.exa.ai/mcp`). The built-in
AI/ML API MCP server (`https://mcp.aimlapi.com/mcp`) is off until you run
`zero mcp enable aimlapi`. Once enabled, the Bearer key follows the aimlapi
profile resolution order (inline key, then the profile's `apiKeyEnv` — default
`AIMLAPI_API_KEY` — then the credential store), or `AIMLAPI_API_KEY` alone when
no aimlapi profile exists; a non-empty `AIMLAPI_API_KEY` can override a stored
profile key for MCP billing. The automatic key is withheld if an aimlapi profile
(or a profile reading `AIMLAPI_API_KEY`) uses a base URL other than
`https://api.aimlapi.com/v1`, `AIMLAPI_INFERENCE_URL` points elsewhere, a
non-empty user Authorization header is configured, OAuth is selected, project
config has touched the server, or the key is missing or not a single token
(including Unicode whitespace). See
[docs/EXTENDING.md](docs/EXTENDING.md#built-in-servers).
+69 -2
View File
@@ -110,6 +110,8 @@ Discovery roots (earlier wins on name collisions):
A missing directory is fine — Zero just omits it. Management commands (`zero skill add` / `zero skill remove`) always write to the primary Zero dir only; `list` / `info` search primary + `~/.agents/skills`. `skill info` reports lock source/hash when present and whether the on-disk `SKILL.md` has drifted from the recorded hash.
For a skill installed since Zero started installing bundled files, `skill info` also reports drift in any file installed with it.
```text
~/.local/share/zero/skills/ # primary (install/remove target)
run-benchmarks/
@@ -140,6 +142,15 @@ Only `name` and `description` are recognized in the frontmatter today. The `name
The `skill` tool lets the agent load any discovered skill by name (primary, agents, or plugin).
A skill can ship files beside its `SKILL.md`, in the [agentskills.io](https://agentskills.io/specification) layout: `scripts/`, `references/`, `assets/` or anything else the instructions refer to. `zero skill add` installs the whole skill directory, leaving out hidden entries such as `.git` and not following or copying symlinks (it lists any it skipped). A skill larger than 1,000 files or 32 MiB is refused. When the `skill` tool loads a skill that has such files, its output ends with the skill's directory and a list of the files, so instructions like "run `scripts/fill.py`" can be followed. Running a bundled script goes through the normal tool permissions; installing or loading a skill never runs anything.
```text
pdf/
SKILL.md
scripts/fill.py
references/form-fields.md
```
## 4. Hooks
Hooks fire shell commands on lifecycle events. Configure them in JSON:
@@ -192,7 +203,11 @@ Events the agent emits (in dispatch order):
| `specialistStart` | A sub-agent is spawned | yes (specialist name) |
| `specialistStop` | A sub-agent ends | yes (specialist name) |
A hook's exit code decides what happens next: `0` continues, non-zero blocks the tool call (`beforeTool`) or surfaces an error (`afterTool`). Hook execution is recorded in the audit log; the audit is reachable from the agent's view of past actions, not from a dedicated `zero doctor` check.
For swarm members the matcher subject is the **agent type** (`subagent`, `teammate`, …), not a registered specialist profile name — swarm members run from an inline manifest keyed by agent type.
`specialistStart` fires once the child process has actually started; a launch that fails before exec (missing binary, spawn error) records no public lifecycle hooks. `specialistStop` fires once per terminal stop: completion, error, explicit stop of a recovered task, and a background task reaped by a later `TaskOutput` all share one durable claim, so a hook that notifies or cleans up does not run twice for the same sub-agent run.
A hook's exit code decides what happens next: `0` continues, non-zero blocks the tool call (`beforeTool`) or surfaces an error (`afterTool`). `sessionStart` / `sessionEnd` / `specialistStart` / `specialistStop` hooks are advisory (failures are audited but do not interrupt the run). Hook execution is recorded in the audit log; the audit is reachable from the agent's view of past actions, not from a dedicated `zero doctor` check.
> **Roadmap.** An in-UI hooks manager is on the backlog. Today you edit the JSON directly.
@@ -246,6 +261,56 @@ or use a new server name. OAuth tokens are bound to the resolved server identity
so changing a server target can require `zero mcp oauth login <server>` again.
OAuth server names ending in `.<32 hex chars>` are reserved for token storage.
#### Built-in servers
Zero seeds two MCP servers before your config is merged. Both show up in
`zero mcp list`, and any field can be overridden in your user config.
- `exa` (`https://mcp.exa.ai/mcp`) is on by default for keyless web search.
`zero mcp disable exa` turns it off.
- `aimlapi` (`https://mcp.aimlapi.com/mcp`, the AI/ML API Toolbox, model
discovery, image/video/audio generation, chat, embeddings and balance tools)
is **off by default**, because its tool runs and generations are billed to
your AI/ML API balance. Turn it on with `zero mcp enable aimlapi`, which
writes only `"disabled": false` to your user config, and off again with
`zero mcp disable aimlapi`. A project config cannot turn it on.
When `aimlapi` connects, Zero sends your AI/ML API key as
`Authorization: Bearer <key>`. The key is resolved again on every request and
is never written to `config.json`. With an aimlapi.com provider profile
(`zero providers setup aimlapi`), the key is chosen in this order:
1. An inline key on the profile (if set in config).
2. Otherwise the environment variable named by the profile's `apiKeyEnv`
(the catalog default is `AIMLAPI_API_KEY`).
3. Otherwise the key held in the credential store when the profile uses
`apiKeyStored`.
When there is no aimlapi profile, Zero uses `AIMLAPI_API_KEY` alone. If both
`AIMLAPI_API_KEY` and a stored profile key exist, step 2 wins over the store
whenever the inline key is empty — MCP requests may bill the environment key's
account, not the stored key.
No automatic key is sent when an aimlapi profile, or any profile reading
`AIMLAPI_API_KEY`, uses a base URL other than `https://api.aimlapi.com/v1`, or
when `AIMLAPI_INFERENCE_URL` is overridden, because that credential may belong
to a proxy. No key is sent when the resolved value is not a single token (for
example a comma- or newline-separated pool), when you set your own
non-empty `Authorization` header, when the server uses `"auth": "oauth"`, or
when project config touched the server. Without a key the server answers 401.
To sign in with your aimlapi.com account instead, set `"auth": "oauth"` on the
server and run `zero mcp oauth login aimlapi`.
Every request to exactly `https://mcp.aimlapi.com` (https, default port,
whatever the server is named, including one you add by hand) also carries the
attribution headers `X-AIMLAPI-Source: agent/zero` and `X-AIMLAPI-Partner-ID`,
and gets the key under the rules above. Other MCP servers never receive these
headers or the key.
If the server echoes back the key Zero sent it, Zero replaces the key with
`[REDACTED]` in that server's error messages and tool results before anything
reads them, and reports an HTTP error status without the response body.
### As a server — expose Zero's tools to another agent
```bash
@@ -290,10 +355,12 @@ Install and manage:
zero plugins add ./github-pr-review # copy into ~/.config/zero/plugins/ or ./.zero/plugins/
zero plugins list
zero plugins info github-pr-review # show manifest path, enabled state, lock metadata
zero plugins disable github-pr-review # sets "enabled": false in plugin.json
zero plugins enable github-pr-review # sets "enabled": true in plugin.json
zero plugins remove github-pr-review # alias: rm
```
A plugin is enabled by being present in the plugins directory and disabled by removing it (or by the user setting `"enabled": false` in its `plugin.json`). Plugins are not enabled or disabled by a CLI subcommand today.
A plugin is active when it is present under a plugins directory and its `plugin.json` has `"enabled": true` (the default when the field is omitted). Use `zero plugins disable <id>` / `zero plugins enable <id>` to toggle that field without removing the plugin. Pass `--user` to target only user plugins and ignore `./.zero/plugins`; without it, a project plugin with the same id takes precedence. The toggle edits the `enabled` value in place — the rest of the manifest keeps its formatting, field order, and numeric literals — and serializes with `plugins add` / `plugins remove` on the same plugins root. Zero reads plugins when it starts, so the change applies to the next `zero exec` run or interactive session; an interactive session that is already running keeps the plugin tools, hooks, and skills it started with until you restart it.
Plugin commands run with the plugin directory as their working directory. Use relative paths; the loader resolves them at activation time.
+4
View File
@@ -634,6 +634,10 @@ Core tool groups include:
`HTTP_PROXY`/`HTTPS_PROXY` set, the proxy is dialed instead and resolves the
target itself, so destination enforcement ends at the proxy; see the note in
the README.
With `ZERO_WEBFETCH_PROVIDER=aimlapi` (opt-in; a key alone does not select
it), the same URL checks run and the page is then fetched as Markdown by the
billed AI/ML API Toolbox tool `search1api/crawl`, with no fallback to a
direct fetch; see the README.
- **Local-control tools**: config-gated wrappers for browser, terminal, desktop,
and artifact helpers backed by packages such as `internal/localcontrol` and
`internal/browser`.
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="261" height="52" fill="none" viewBox="0 0 261 52"><path fill="white" d="M59.8 52H0L29.9 0zm67.82-38.45q4.9 0 8.81 2.13a15.5 15.5 0 0 1 6.22 6.32q2.3 4.2 2.38 10.26v2.06h-26.35q.27 4.4 2.58 6.92 2.38 2.47 6.36 2.47a8.4 8.4 0 0 0 7.76-4.93l9.16.67q-1.68 4.98-6.29 7.99t-10.63 3q-5.53 0-9.64-2.27a16 16 0 0 1-6.43-6.46 20 20 0 0 1-2.31-9.72q0-5.52 2.3-9.72a16 16 0 0 1 6.44-6.46 20 20 0 0 1 9.64-2.26m62.55 0q4.47 0 8.18 1.66a15.3 15.3 0 0 1 6.15 4.6q2.38 3 2.87 7.05l-9.23.47a8 8 0 0 0-2.8-5 7.7 7.7 0 0 0-5.17-1.86q-4.33 0-6.7 3-2.4 3-2.39 8.52t2.38 8.52q2.37 3 6.71 3 3.15 0 5.39-1.87 2.24-1.92 2.72-5.46l9.3.4a14.7 14.7 0 0 1-2.87 7.33 16 16 0 0 1-6.15 4.86 21 21 0 0 1-8.39 1.66q-5.53 0-9.64-2.26a16 16 0 0 1-6.44-6.46 20 20 0 0 1-2.3-9.72q0-5.52 2.3-9.72a16 16 0 0 1 6.44-6.46 20 20 0 0 1 9.64-2.26m38.66 0q4.9 0 8.8 2.13a15.5 15.5 0 0 1 6.22 6.32q2.31 4.2 2.38 10.26v2.06h-26.35q.28 4.4 2.58 6.92 2.38 2.47 6.36 2.47a8.4 8.4 0 0 0 7.77-4.93l9.15.67q-1.68 5-6.29 7.99t-10.62 3q-5.53 0-9.65-2.27a16 16 0 0 1-6.43-6.46 20 20 0 0 1-2.31-9.72q0-5.52 2.3-9.72a16 16 0 0 1 6.44-6.46 20 20 0 0 1 9.64-2.26M86.9 36.69l17.24-34.33h10.8L89.96 49.63h-6.12L58.85 2.36h10.81zm71.62-15.55a11 11 0 0 1 2.47-4.48q2.28-2.31 6.38-2.31h3.4v7.26h-3.47q-2.91 0-4.79.8a5.8 5.8 0 0 0-2.77 2.5q-.9 1.72-.9 4.37v20.35h-8.89V14.35h8.33zm101.73 28.5h-8.95V2.35h8.95zM127.62 20.26q-3.7 0-6 2.2-2.32 2.2-2.87 6.2h17.05q-.48-4.34-2.72-6.33a7.8 7.8 0 0 0-5.46-2.07m101.2 0q-3.7 0-6 2.2-2.31 2.2-2.87 6.2H237q-.5-4.34-2.73-6.33a7.8 7.8 0 0 0-5.46-2.07" style="fill:white;fill-opacity:1;"/></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="261" height="52" fill="none" viewBox="0 0 261 52"><path fill="black" d="M59.8 52H0L29.9 0zm67.82-38.45q4.9 0 8.81 2.13a15.5 15.5 0 0 1 6.22 6.32q2.3 4.2 2.38 10.26v2.06h-26.35q.27 4.4 2.58 6.92 2.38 2.47 6.36 2.47a8.4 8.4 0 0 0 7.76-4.93l9.16.67q-1.68 4.98-6.29 7.99t-10.63 3q-5.53 0-9.64-2.27a16 16 0 0 1-6.43-6.46 20 20 0 0 1-2.31-9.72q0-5.52 2.3-9.72a16 16 0 0 1 6.44-6.46 20 20 0 0 1 9.64-2.26m62.55 0q4.47 0 8.18 1.66a15.3 15.3 0 0 1 6.15 4.6q2.38 3 2.87 7.05l-9.23.47a8 8 0 0 0-2.8-5 7.7 7.7 0 0 0-5.17-1.86q-4.33 0-6.7 3-2.4 3-2.39 8.52t2.38 8.52q2.37 3 6.71 3 3.15 0 5.39-1.87 2.24-1.92 2.72-5.46l9.3.4a14.7 14.7 0 0 1-2.87 7.33 16 16 0 0 1-6.15 4.86 21 21 0 0 1-8.39 1.66q-5.53 0-9.64-2.26a16 16 0 0 1-6.44-6.46 20 20 0 0 1-2.3-9.72q0-5.52 2.3-9.72a16 16 0 0 1 6.44-6.46 20 20 0 0 1 9.64-2.26m38.66 0q4.9 0 8.8 2.13a15.5 15.5 0 0 1 6.22 6.32q2.31 4.2 2.38 10.26v2.06h-26.35q.28 4.4 2.58 6.92 2.38 2.47 6.36 2.47a8.4 8.4 0 0 0 7.77-4.93l9.15.67q-1.68 5-6.29 7.99t-10.62 3q-5.53 0-9.65-2.27a16 16 0 0 1-6.43-6.46 20 20 0 0 1-2.31-9.72q0-5.52 2.3-9.72a16 16 0 0 1 6.44-6.46 20 20 0 0 1 9.64-2.26M86.9 36.69l17.24-34.33h10.8L89.96 49.63h-6.12L58.85 2.36h10.81zm71.62-15.55a11 11 0 0 1 2.47-4.48q2.28-2.31 6.38-2.31h3.4v7.26h-3.47q-2.91 0-4.79.8a5.8 5.8 0 0 0-2.77 2.5q-.9 1.72-.9 4.37v20.35h-8.89V14.35h8.33zm101.73 28.5h-8.95V2.35h8.95zM127.62 20.26q-3.7 0-6 2.2-2.32 2.2-2.87 6.2h17.05q-.48-4.34-2.72-6.33a7.8 7.8 0 0 0-5.46-2.07m101.2 0q-3.7 0-6 2.2-2.31 2.2-2.87 6.2H237q-.5-4.34-2.73-6.33a7.8 7.8 0 0 0-5.46-2.07" style="fill:black;fill-opacity:1;"/></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

@@ -0,0 +1,143 @@
package agent
import (
"context"
"strconv"
"strings"
"testing"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/zeroruntime"
)
// A CONFIGURATION REFUSAL IS NOT A RETRIABLE FAILURE, EVEN WHEN IT ARRIVES
// EARLY.
//
// 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, and were read as
// ordinary retriable failures: the model got the schema hint and the call could
// consume the profile failure-streak escalation, for a tool that never executed
// and that no argument change can enable.
func TestDisabledCaptureArtifactIsAPolicyRefusalNotARetriableFailure(t *testing.T) {
// No artifacts directory and no enabled driver: valid arguments, unavailable
// tool. This is the shape an operator produces by configuration alone.
registry := tools.NewRegistry()
for _, tool := range tools.NewLocalControlArtifactTools(tools.LocalControlArtifactOptions{}) {
registry.Register(tool)
}
result := registry.RunWithOptions(context.Background(), "capture_artifact", map[string]any{
"action": "browser_screenshot",
"name": "shot",
}, tools.RunOptions{PermissionGranted: true})
if result.Status != tools.StatusError {
t.Fatalf("SETUP INVALID: expected the disabled tool to refuse, got %s: %s", result.Status, result.Output)
}
if !tools.IsPolicyRefusalResult(result) {
t.Fatalf("the refusal carries no provenance, so nothing downstream can tell it from a failed command: %#v", result.Meta)
}
// Through the conversion the loop performs, which is where the marker has to
// survive to be worth anything.
converted := ToolResult{
Status: result.Status,
Output: result.Output,
Meta: result.Meta,
}
if !isPolicyRefusal(converted) {
t.Error("the marker did not survive into the agent-facing result")
}
if isRetriableToolError(converted) {
t.Error("a tool that never executed, and that no argument change can enable, was marked retriable: the model gets a schema hint telling it to fix arguments that were already valid")
}
}
// The malformed-argument branch must stay retriable. That one IS fixable by
// trying again differently, which is what the hint exists for, so marking every
// early rejection would trade one wrong answer for another.
func TestMalformedCaptureArtifactArgumentsStayRetriable(t *testing.T) {
registry := tools.NewRegistry()
for _, tool := range tools.NewLocalControlArtifactTools(tools.LocalControlArtifactOptions{}) {
registry.Register(tool)
}
result := registry.RunWithOptions(context.Background(), "capture_artifact", map[string]any{
"action": "not_a_real_action",
}, tools.RunOptions{PermissionGranted: true})
if result.Status != tools.StatusError {
t.Fatalf("SETUP INVALID: expected invalid arguments to fail, got %s", result.Status)
}
if tools.IsPolicyRefusalResult(result) {
t.Fatalf("a malformed-argument error was marked a policy refusal, so the model is denied the hint that would let it fix the call: %q", result.Output)
}
converted := ToolResult{Status: result.Status, Output: result.Output, Meta: result.Meta}
if !isRetriableToolError(converted) {
t.Error("invalid arguments should stay retriable")
}
}
// alwaysRefusingCaptureTool stands in for the disabled tool at Run level, so the
// loop consequence can be observed rather than inferred.
type alwaysRefusingCaptureTool struct{ ran int }
func (tool *alwaysRefusingCaptureTool) Name() string { return "capture_artifact" }
func (tool *alwaysRefusingCaptureTool) Description() string { return "test capture tool" }
func (tool *alwaysRefusingCaptureTool) Parameters() tools.Schema {
return tools.Schema{
Type: "object",
Properties: map[string]tools.PropertySchema{"action": {Type: "string"}},
Required: []string{"action"},
AdditionalProperties: false,
}
}
func (tool *alwaysRefusingCaptureTool) Safety() tools.Safety {
return tools.Safety{SideEffect: tools.SideEffectRead, Permission: tools.PermissionAllow, Reason: "captures artifacts"}
}
func (tool *alwaysRefusingCaptureTool) Run(context.Context, map[string]any) tools.Result {
tool.ran++
return tools.Result{}
}
func (tool *alwaysRefusingCaptureTool) RejectBeforePermission(map[string]any) (tools.Result, bool) {
return tools.Result{
Status: tools.StatusError,
Output: "Error: capture_artifact is disabled because no artifact directory is configured.",
Meta: map[string]string{tools.PolicyRefusalMeta: tools.PolicyRefusalToolNotEnabled},
}, true
}
// THE LOOP CONSEQUENCE. A refusal must not draw the retry hint, because the hint
// tells the model to fix arguments that were already valid and the tool will
// refuse identically next time.
func TestRunDoesNotHintARefusedCaptureArtifact(t *testing.T) {
tool := &alwaysRefusingCaptureTool{}
registry := tools.NewRegistry()
registry.Register(tool)
calls := toolFailureHintAt + 1
turns := make([][]zeroruntime.StreamEvent, 0, calls+1)
for i := range calls {
turns = append(turns, toolTurn("call-"+strconv.Itoa(i), "capture_artifact", `{"action":"browser_screenshot"}`))
}
turns = append(turns, textTurn("gave up on the screenshot"))
result, err := Run(context.Background(), "take a screenshot", &mockProvider{turns: turns}, Options{
Registry: registry,
PermissionMode: PermissionModeAsk,
MaxTurns: len(turns) + 5,
})
if err != nil {
t.Fatal(err)
}
if tool.ran != 0 {
t.Fatalf("SETUP INVALID: the tool executed %d times; it must be refused before Run", tool.ran)
}
for _, message := range result.Messages {
if strings.Contains(message.Content, toolFailureHintMarker) {
t.Fatal("a refused, never-executed tool drew the retry hint, which tells the model to fix arguments that were already valid")
}
}
}
@@ -0,0 +1,112 @@
package agent
import (
"context"
"strconv"
"strings"
"testing"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/zeroruntime"
)
// alternatingRefusalCaptureTool refuses every call with the same CATEGORY and a
// different MESSAGE, which is what a real disabled driver does: the refusal
// names the action, and the model is free to alternate valid actions.
type alternatingRefusalCaptureTool struct{ ran int }
func (tool *alternatingRefusalCaptureTool) Name() string { return "capture_artifact" }
func (tool *alternatingRefusalCaptureTool) Description() string { return "test capture tool" }
func (tool *alternatingRefusalCaptureTool) Parameters() tools.Schema {
return tools.Schema{
Type: "object",
Properties: map[string]tools.PropertySchema{"action": {Type: "string"}},
Required: []string{"action"},
AdditionalProperties: false,
}
}
func (tool *alternatingRefusalCaptureTool) Safety() tools.Safety {
return tools.Safety{SideEffect: tools.SideEffectRead, Permission: tools.PermissionAllow, Reason: "captures artifacts"}
}
func (tool *alternatingRefusalCaptureTool) Run(context.Context, map[string]any) tools.Result {
tool.ran++
return tools.Result{}
}
func (tool *alternatingRefusalCaptureTool) RejectBeforePermission(args map[string]any) (tools.Result, bool) {
action, _ := args["action"].(string)
return tools.Result{
Status: tools.StatusError,
// Action-specific wording, same category. This is the whole point: the
// prose differs on every call while the refusal never changes.
Output: "Error: Local control driver for " + action + " is disabled.",
Meta: map[string]string{tools.PolicyRefusalMeta: tools.PolicyRefusalToolNotEnabled},
}, true
}
// A REFUSAL KEYS ON ITS CATEGORY, INCLUDING WHEN THE CATEGORY IS ONLY A MARKER.
//
// The registry marks 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 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 halt. Only the generic twelve-error fallback stopped the run,
// reporting varied errors rather than a repeated refusal.
func TestAlternatingRefusedActionsStillTripTheRefusalHalt(t *testing.T) {
tool := &alternatingRefusalCaptureTool{}
registry := tools.NewRegistry()
registry.Register(tool)
// More calls than the refusal halt but FEWER than the generic any-error
// fallback, so only the category-keyed streak can stop this.
calls := toolFailureAnyErrorStopAt - 1
if calls <= toolFailureStopAt {
t.Fatalf("SETUP INVALID: %d calls cannot distinguish the refusal halt from the generic fallback", calls)
}
actions := []string{"browser_screenshot", "browser_pdf"}
turns := make([][]zeroruntime.StreamEvent, 0, calls+1)
for i := range calls {
action := actions[i%len(actions)]
turns = append(turns, toolTurn("call-"+strconv.Itoa(i), "capture_artifact", `{"action":"`+action+`"}`))
}
turns = append(turns, textTurn("gave up"))
result, err := Run(context.Background(), "capture something", &mockProvider{turns: turns}, Options{
Registry: registry,
PermissionMode: PermissionModeAsk,
MaxTurns: len(turns) + 5,
})
if err != nil {
t.Fatal(err)
}
if tool.ran != 0 {
t.Fatalf("SETUP INVALID: the tool executed %d times; it must be refused before Run", tool.ran)
}
refusals := 0
for _, message := range result.Messages {
if strings.Contains(message.Content, "is disabled") {
refusals++
}
}
if refusals > toolFailureStopAt {
t.Errorf("the run made %d refused calls; the six-call refusal halt never tripped because the streak re-keyed on each action's wording", refusals)
}
// And the halt has to read as a repeated refusal, not as varied errors.
stop := strings.ToLower(strings.Join(messageContents(result.Messages), "\n"))
if strings.Contains(stop, toolFailureHintMarker) {
t.Error("a refused, never-executed tool drew the retry hint")
}
}
func messageContents(messages []zeroruntime.Message) []string {
out := make([]string, 0, len(messages))
for _, message := range messages {
out = append(out, message.Content)
}
return out
}
@@ -0,0 +1,93 @@
package agent
import (
"context"
"testing"
"github.com/Twigpine/zero/internal/localcontrol"
"github.com/Twigpine/zero/internal/tools"
)
// THE DISABLED-DRIVER BRANCH, REACHED FOR REAL.
//
// RejectBeforePermission refuses on two separate conditions, and its neighbour
// test constructs empty options, so it returns at the FIRST one (no artifact
// directory configured) and the second is never evaluated. Reverting the
// disabled-driver branch in local_capture.go to a plain errorResult therefore
// left the whole agent and tools suites green.
//
// This is the shape an operator actually produces: an artifact root IS
// configured and one driver IS enabled, and the model asks for an action owned
// by a different, disabled driver. Without provenance that call is read as a
// retriable failure, so it collects a schema hint and can spend the profile's
// failure-streak escalation, for a tool no argument change can enable.
func TestCaptureArtifactDisabledDriverIsAPolicyRefusal(t *testing.T) {
registry := tools.NewRegistry()
options := tools.LocalControlArtifactOptions{
ArtifactsDir: t.TempDir(),
// Configured and enabled, so the tool as a whole is available and the
// missing-directory branch cannot fire.
Browser: localcontrol.BrowserOptions{Enabled: true, Driver: "test-browser", HelperPath: "browser-helper"},
// Terminal deliberately left disabled.
}
for _, tool := range tools.NewLocalControlArtifactTools(options) {
registry.Register(tool)
}
// An enabled driver's action must still be accepted, or the test below would
// pass for a tool that refuses everything.
if result := registry.RunWithOptions(context.Background(), "capture_artifact", map[string]any{
"action": "browser_screenshot", "name": "shot",
}, tools.RunOptions{PermissionGranted: true}); tools.IsPolicyRefusalResult(result) {
t.Fatalf("SETUP INVALID: the enabled browser driver was refused as policy: %s", result.Output)
}
result := registry.RunWithOptions(context.Background(), "capture_artifact", map[string]any{
"action": "terminal_snapshot", "name": "snap", "session": "test-session",
}, tools.RunOptions{PermissionGranted: true})
if result.Status != tools.StatusError {
t.Fatalf("SETUP INVALID: the disabled terminal driver did not refuse: %s / %s", result.Status, result.Output)
}
if !tools.IsPolicyRefusalResult(result) {
t.Fatalf("a driver disabled by configuration refuses without provenance, so it reads as a retriable failure: %#v", result.Meta)
}
if got := result.Meta["policy_refusal"]; got != tools.PolicyRefusalToolNotEnabled {
t.Errorf("policy_refusal = %q, want %q", got, tools.PolicyRefusalToolNotEnabled)
}
if isRetriableToolError(ToolResult{Status: result.Status, Output: result.ModelOutput(), Meta: result.Meta}) {
t.Error("the refusal is still classified as retriable, so the model gets a schema hint for a configuration decision")
}
}
// AND THE EARLY REJECTIONS MUST NOT COLLAPSE INTO ONE ANSWER.
//
// RejectBeforePermission refuses on configuration; argument validation refuses
// on a fixable mistake, and it runs FIRST, which is how the disabled-driver
// branch stayed uncovered. A malformed call must stay an ordinary retriable
// error, because trying again differently is exactly the right response to it.
func TestCaptureArtifactMalformedArgumentsStayRetriable(t *testing.T) {
registry := tools.NewRegistry()
for _, tool := range tools.NewLocalControlArtifactTools(tools.LocalControlArtifactOptions{
ArtifactsDir: t.TempDir(),
Terminal: localcontrol.TerminalOptions{Enabled: true, Driver: "test-terminal", HelperPath: "terminal-helper"},
}) {
registry.Register(tool)
}
// The driver IS enabled, so nothing here is a configuration decision. The
// call is simply missing the session the action requires.
result := registry.RunWithOptions(context.Background(), "capture_artifact", map[string]any{
"action": "terminal_snapshot", "name": "snap",
}, tools.RunOptions{PermissionGranted: true})
if result.Status != tools.StatusError {
t.Fatalf("SETUP INVALID: the malformed call did not fail: %s", result.Output)
}
if tools.IsPolicyRefusalResult(result) {
t.Errorf("a fixable argument mistake was marked a policy refusal, so the model loses the schema hint that would fix it: %s", result.Output)
}
if !isRetriableToolError(ToolResult{Status: result.Status, Output: result.ModelOutput(), Meta: result.Meta}) {
t.Errorf("a fixable argument mistake is not retriable: %s", result.Output)
}
}
+34
View File
@@ -0,0 +1,34 @@
package agent
import "github.com/Twigpine/zero/internal/tools"
// CheckpointTargets returns the workspace-relative paths a tool call will mutate,
// resolving its arguments exactly as executeToolCall does. Freeform apply_patch
// input is normalized through the registered built-in tool, so absolute headers
// are rewritten relative to the selected write root and cwd is set: checkpoint
// capture and execution therefore agree on the files at stake.
//
// Best-effort: nil when the call has no capture targets or its arguments cannot
// be resolved. Checkpoint capture never fails a run.
func CheckpointTargets(workspaceRoot string, registry *tools.Registry, call ToolCall) []string {
if registry == nil {
return nil
}
args := map[string]any{}
if call.Freeform {
tool, ok := registry.Get(call.Name)
if !ok || !tools.IsBuiltInApplyPatch(tool) {
return nil
}
prepared, err := tools.PrepareFreeformApplyPatchArguments(tool, call.Arguments)
if err != nil {
return nil
}
args = prepared
} else if call.Arguments != "" {
if err := decodeToolArguments(call.Arguments, &args); err != nil {
return nil
}
}
return tools.MutationTargets(workspaceRoot, call.Name, args)
}
+99
View File
@@ -0,0 +1,99 @@
package agent
import (
"encoding/json"
"os"
"path/filepath"
"slices"
"testing"
"github.com/Twigpine/zero/internal/tools"
)
func TestCheckpointTargets(t *testing.T) {
root := t.TempDir()
// Absolute headers must be compared against the physical root: t.TempDir
// differs from it on macOS (/var -> /private/var) and via Windows short names.
if resolved, err := filepath.EvalSymlinks(root); err == nil {
root = resolved
}
if err := os.WriteFile(filepath.Join(root, "main.txt"), []byte("before\n"), 0o644); err != nil {
t.Fatal(err)
}
relativePatch := "*** Begin Patch\n*** Update File: main.txt\n@@\n-before\n+after\n*** End Patch\n"
absolutePatch := "*** Begin Patch\n*** Update File: " + filepath.Join(root, "main.txt") + "\n@@\n-before\n+after\n*** End Patch\n"
multiFilePatch := "*** Begin Patch\n*** Update File: main.txt\n@@\n-before\n+after\n*** Add File: notes.txt\n+new\n*** End Patch\n"
jsonArgs, err := json.Marshal(map[string]any{"patch": relativePatch})
if err != nil {
t.Fatal(err)
}
registry := tools.NewRegistry()
registry.Register(tools.NewScopedApplyPatchTool(root, nil))
unbuiltinRegistry := tools.NewRegistry()
unbuiltinRegistry.Register(&customApplyPatchTool{})
for _, tc := range []struct {
name string
registry *tools.Registry
call ToolCall
want []string
}{
{
name: "freeform relative header",
call: ToolCall{Name: "apply_patch", Arguments: relativePatch, Freeform: true},
want: []string{"main.txt"},
},
{
name: "freeform absolute header stays workspace-relative",
call: ToolCall{Name: "apply_patch", Arguments: absolutePatch, Freeform: true},
want: []string{"main.txt"},
},
{
name: "freeform multi-file patch",
call: ToolCall{Name: "apply_patch", Arguments: multiFilePatch, Freeform: true},
want: []string{"main.txt", "notes.txt"},
},
{
name: "json arguments",
call: ToolCall{Name: "apply_patch", Arguments: string(jsonArgs)},
want: []string{"main.txt"},
},
{
name: "freeform traversal is rejected",
call: ToolCall{Name: "apply_patch", Arguments: "*** Begin Patch\n*** Update File: ../escape.txt\n@@\n-x\n+y\n*** End Patch\n", Freeform: true},
want: nil,
},
{
name: "freeform custom apply_patch is not prepared",
registry: unbuiltinRegistry,
call: ToolCall{Name: "apply_patch", Arguments: relativePatch, Freeform: true},
want: nil,
},
{
name: "unknown freeform tool",
call: ToolCall{Name: "read_file", Arguments: "raw", Freeform: true},
want: nil,
},
{
name: "malformed json arguments",
call: ToolCall{Name: "apply_patch", Arguments: "{"},
want: nil,
},
} {
t.Run(tc.name, func(t *testing.T) {
reg := registry
if tc.registry != nil {
reg = tc.registry
}
got := CheckpointTargets(root, reg, tc.call)
if !slices.Equal(got, tc.want) {
t.Fatalf("CheckpointTargets() = %#v, want %#v", got, tc.want)
}
})
}
if got := CheckpointTargets(root, nil, ToolCall{Name: "apply_patch", Arguments: relativePatch}); got != nil {
t.Fatalf("CheckpointTargets(nil registry) = %#v, want nil", got)
}
}
+192 -13
View File
@@ -55,6 +55,22 @@ const (
// error, so this only affects true same-error loops.
toolFailureStopAt = 6
// toolFailureAnyErrorStopAt halts a tool that keeps failing with DIFFERENT
// errors. The streak above cannot see that case by construction: a changed
// signature rebuilds the record at 1, so a tool whose error text varies every
// call never reaches toolFailureStopAt however long it loops.
//
// That is not hypothetical. A headless run made 384 denied calls over 26
// minutes without tripping a halt set to 6, because each denial named a
// different path. Keying denials on their category (see observeToolResult)
// fixes that case; this bound covers every error with no category to key on.
//
// Deliberately well above toolFailureStopAt. A model iterating on a genuinely
// tricky edit legitimately fails several times with different errors while it
// converges, and this must not cut those runs short — the same reasoning that
// moved toolFailureStopAt from 4 to 6. Both counters reset on success.
toolFailureAnyErrorStopAt = 12
// maxContinueNudges bounds how many times the headless completion gate
// (Options.RequireCompletionSignal) re-prompts a model that stopped without a
// tool call while work clearly remained. Once spent, the run finalizes as
@@ -324,15 +340,91 @@ func acceptanceVerificationNudge(objective string) string {
const toolFailureHintMarker = "kept failing with the same error"
type toolFailureRecord struct {
count int
errSig string
hintShown bool
count int
identity failureIdentity
// sawExecuted and sawRefused are the AGGREGATE over the whole streak, not
// the current identity. The content-blind bound needs them because it fires
// exactly when no identity repeated, so the last one is unrepresentative.
sawExecuted bool
sawRefused bool
hintShown bool
// anyErrorCount counts consecutive failures of this tool REGARDLESS of the
// error, and is cleared only by a success. count above restarts whenever the
// signature changes, which is exactly what a varying error message defeats;
// this one cannot be reset by changing the text.
anyErrorCount int
}
// failureKind separates a failure the TOOL produced from one POLICY produced.
//
// The two used to share one string namespace, with provenance recovered
// afterwards by testing that string for a "denial:" prefix. Trusted provenance
// and untrusted content in one namespace is a namespace the untrusted side can
// write into: a command that printed exactly "denial:permission_denied" and
// exited non-zero acquired the identity of a real permission refusal, merged
// its streak with one, and had the run report it as refused although it ran
// every time. The kind is carried as its own field so no output can spell it.
type failureKind uint8
const (
// The zero value: no failure recorded.
_ failureKind = iota
// failureKindExecuted: the tool ran and returned an error. The key is the
// normalized error signature, which is tool-controlled text.
failureKindExecuted
// failureKindRefused: policy refused the call before the tool ran. The key is
// the denial category, a small closed enum the loop sets.
failureKindRefused
)
// failureIdentity is what the same-identity streak counts. Comparing the pair
// means an executed error can never equal a refusal however it is spelled.
type failureIdentity struct {
kind failureKind
key string
}
// toolFailureCause is why the guard stopped, decided where the facts are rather
// than reconstructed by the caller from overlapping booleans.
type toolFailureCause uint8
const (
// The zero value: the guard did not stop.
_ toolFailureCause = iota
// toolFailureCauseSameError: the same executed error signature, six times.
toolFailureCauseSameError
// toolFailureCauseSameRefusal: the same denial category, six times. The prose
// differs on every call because it names what was refused, so this is NOT the
// same-error case.
toolFailureCauseSameRefusal
// toolFailureCauseVariedExecuted: the content-blind bound, and every failure
// was the tool executing and failing.
toolFailureCauseVariedExecuted
// toolFailureCauseVariedRefused: the content-blind bound, and every failure
// was a policy refusal. Alternating two categories reaches twelve without
// either reaching six, and the tool never ran once.
toolFailureCauseVariedRefused
// toolFailureCauseVariedMixed: the content-blind bound over both kinds.
// Named deliberately rather than left to whichever field a switch tested
// first.
toolFailureCauseVariedMixed
)
type toolFailureOutcome struct {
InjectHint bool
Stop bool
Count int
// Cause is set only when Stop is. It carries the aggregate fact about the
// sequence that tripped a bound, which the last record alone cannot supply:
// the content-blind bound is reached precisely when no single identity
// repeated enough, so the identity present at the end says nothing about the
// eleven before it.
Cause toolFailureCause
}
// Refused reports whether policy, rather than the tool, produced the failures.
func (cause toolFailureCause) Refused() bool {
return cause == toolFailureCauseSameRefusal || cause == toolFailureCauseVariedRefused
}
// errorSignature normalizes a tool error to a short, comparable signature so
@@ -358,12 +450,47 @@ func toolFailureHint(toolName, schemaJSON, errOutput string) string {
// toolFailureStopAnswer is the final answer when the repeated-failure guard halts
// a run.
func toolFailureStopAnswer(toolName string, count int) string {
return "Agent stopped: the `" + toolName + "` tool failed " + strconv.Itoa(count) +
" times in a row with the same error, so I halted instead of looping further. " +
func toolFailureStopAnswer(toolName string, count int, cause toolFailureCause) string {
// Each branch claims only what its counter established, and the mixed case is
// spelled out rather than decided by whichever field a switch happened to
// test first. The wording used to overclaim in both directions: the
// content-blind bound said every failure differed, which it does not track,
// and it described a run of policy refusals as the tool failing, when the
// tool had not run at all.
verb, tail := " tool failed ", " times in a row with the same error, "
switch cause {
case toolFailureCauseSameRefusal:
verb, tail = " tool was refused ", " times in a row, "
case toolFailureCauseVariedExecuted:
tail = " times in a row with varying errors, "
case toolFailureCauseVariedRefused:
verb, tail = " tool was refused ", " times in a row for different reasons, "
case toolFailureCauseVariedMixed:
verb, tail = " tool failed or was refused ", " times in a row, "
}
return "Agent stopped: the `" + toolName + "`" + verb + strconv.Itoa(count) +
tail + "so I halted instead of looping further. " +
"Please check the request or adjust the tool arguments."
}
// toolFailureIncompleteReason describes a guard halt for the headless
// completion gate. A guard halt is BY DEFINITION unfinished work: the run
// stopped because a tool would not stop failing, not because the task was done.
//
// It makes the same claim as toolFailureStopAnswer for the same halt, including
// the mixed case: a headless caller reads this reason and an interactive one
// reads the stop answer, and the two must not describe one halt differently.
// Reported by @jatmn.
func toolFailureIncompleteReason(toolName string, cause toolFailureCause) string {
switch {
case cause.Refused():
return "halted after `" + toolName + "` was refused repeatedly, without completing the task"
case cause == toolFailureCauseVariedMixed:
return "halted after `" + toolName + "` failed or was refused repeatedly, without completing the task"
}
return "halted after `" + toolName + "` failed repeatedly, without completing the task"
}
// The no-output stop answer is assembled from these fixed parts (only the turn
// count varies). IsNoProgressStop matches all three so a legitimate message that
// merely quotes the marker substring is not misclassified as a failed empty run.
@@ -472,28 +599,68 @@ func newGuardState() *guardState {
// observeToolResult tracks repeated identical failures of a tool. A successful
// result clears that tool's failure streak. Returns whether to inject a one-shot
// corrective hint and/or stop the run.
func (state *guardState) observeToolResult(name string, failed bool, output string) toolFailureOutcome {
// hintable is separate from failed on purpose. A categorized denial MUST count
// toward the streaks — that is the whole point of keying on the category — but a
// schema hint is the wrong response to a policy refusal: the call shape is fine,
// the answer was no. Collapsing the two is what made the earlier version of this
// fix a no-op, since the caller could only pass a flag that excluded denials.
func (state *guardState) observeToolResult(name string, failed bool, hintable bool, output string, denial DenialCategory) toolFailureOutcome {
if state.toolFailures == nil {
state.toolFailures = map[string]*toolFailureRecord{}
}
if !failed {
delete(state.toolFailures, name) // success resets the streak
delete(state.toolFailures, name) // success resets both counters
return toolFailureOutcome{}
}
sig := errorSignature(output)
// A denial keys on its CATEGORY, not its prose. The message embeds the path
// or command that was refused, so it differs on every call while describing
// the same unchanging refusal — which rebuilt the record at 1 each time and
// let a denied tool loop indefinitely under a halt set to 6. The category is
// a small closed enum the loop already sets on the result.
identity := failureIdentity{kind: failureKindExecuted, key: errorSignature(output)}
if denial != DenialNone {
identity = failureIdentity{kind: failureKindRefused, key: string(denial)}
}
record := state.toolFailures[name]
if record == nil || record.errSig != sig {
record = &toolFailureRecord{count: 1, errSig: sig}
if record == nil {
record = &toolFailureRecord{identity: identity}
state.toolFailures[name] = record
}
if record.identity != identity {
// A different failure restarts the same-identity streak but NOT the
// content-blind one: changing how a tool fails is not progress.
record.count = 1
record.identity = identity
record.hintShown = false
} else {
record.count++
}
record.anyErrorCount++
if identity.kind == failureKindRefused {
record.sawRefused = true
} else {
record.sawExecuted = true
}
outcome := toolFailureOutcome{Count: record.count}
if record.count >= toolFailureStopAt {
switch {
case record.count >= toolFailureStopAt:
outcome.Stop = true
outcome.Cause = toolFailureCauseSameError
if identity.kind == failureKindRefused {
outcome.Cause = toolFailureCauseSameRefusal
}
return outcome
case record.anyErrorCount >= toolFailureAnyErrorStopAt:
// Report the counter that actually tripped. record.count is the
// same-identity streak and is often 1 here, which would describe a tool
// that failed a dozen different ways as having failed once.
outcome.Stop = true
outcome.Count = record.anyErrorCount
outcome.Cause = record.variedCause()
return outcome
}
if record.count >= toolFailureHintAt && !record.hintShown {
if hintable && record.count >= toolFailureHintAt && !record.hintShown {
record.hintShown = true
outcome.InjectHint = true
}
@@ -603,3 +770,15 @@ func (state *guardState) planReminder(turn int) string {
return ""
}
// variedCause classifies a content-blind stop by what the whole streak held.
func (record *toolFailureRecord) variedCause() toolFailureCause {
switch {
case record.sawRefused && record.sawExecuted:
return toolFailureCauseVariedMixed
case record.sawRefused:
return toolFailureCauseVariedRefused
default:
return toolFailureCauseVariedExecuted
}
}
+284 -1
View File
@@ -2,6 +2,8 @@ package agent
import (
"context"
"path/filepath"
"strconv"
"strings"
"testing"
@@ -80,6 +82,9 @@ func TestRunStopsAfterConsecutiveEmptyTurns(t *testing.T) {
if result.FinalAnswer == maxTurnsAnswer {
t.Fatalf("no-output guard must stop before reaching maxTurns, got max-turns answer")
}
if result.Incomplete {
t.Fatalf("interactive run must not be marked Incomplete; reason=%q", result.IncompleteReason)
}
}
func TestRunResetsEmptyTurnCounterOnVisibleOutput(t *testing.T) {
@@ -189,7 +194,7 @@ func TestUnknownExecSessionProbingTripsFailureHalt(t *testing.T) {
var state guardState
var stoppedAt int
for i := 1; i <= toolFailureStopAt; i++ {
out := state.observeToolResult(tools.WriteStdinToolName, true, tools.UnknownExecSessionError(i))
out := state.observeToolResult(tools.WriteStdinToolName, true, true, tools.UnknownExecSessionError(i), "")
if out.Stop {
stoppedAt = i
break
@@ -200,6 +205,223 @@ func TestUnknownExecSessionProbingTripsFailureHalt(t *testing.T) {
}
}
// A permission denial repeats forever when the streak is keyed on the error
// TEXT, because the denial message embeds the path or command that varies per
// call.
//
// Observed, not theorised: a headless run made 384 denied calls over 26 minutes
// without tripping a halt that stops at 6. Every denial carried the same typed
// category and a different reason string, so errorSignature differed each time
// and the record was rebuilt at count 1 on every call.
//
// TestUnknownExecSessionErrorSignatureIsIDInvariant fixed one message this way.
// Keying on the category fixes the class, without needing every future denial
// message to remember to be invariant.
func TestPermissionDenialStreakSurvivesVaryingReasonText(t *testing.T) {
var state guardState
stoppedAt := 0
for i := 1; i <= toolFailureStopAt; i++ {
// The shape the loop actually produces: same tool, same category, a
// different path every time.
output := "Error: Permission denied for write_file: cannot write " +
filepath.Join("C:", "ws", "pkg", "file"+strconv.Itoa(i)+".go")
out := state.observeToolResult("write_file", true, true, output, DenialPermissionDenied)
if out.Stop {
stoppedAt = i
break
}
}
if stoppedAt != toolFailureStopAt {
t.Fatalf("denials with varying reason text stopped at %d, want %d", stoppedAt, toolFailureStopAt)
}
}
// The content-blind bound. A tool failing over and over with genuinely
// DIFFERENT errors and no denial category is still a tool that is not working,
// and the same-signature streak can never see it.
//
// Bounded well above toolFailureStopAt on purpose: a model iterating on a
// tricky edit legitimately fails a few times with different errors while it
// converges, and cutting that short is the regression
// TestSuccessResetsBothFailureCounters guards.
func TestToolFailingWithDifferentErrorsEveryTimeStillStops(t *testing.T) {
var state guardState
stoppedAt := 0
for i := 1; i <= toolFailureAnyErrorStopAt; i++ {
out := state.observeToolResult("bash", true, true, "distinct failure "+strconv.Itoa(i), "")
if out.Stop {
stoppedAt = i
break
}
}
if stoppedAt != toolFailureAnyErrorStopAt {
t.Fatalf("a tool failing with a new error each call stopped at %d, want %d", stoppedAt, toolFailureAnyErrorStopAt)
}
}
// Neither counter may outlive a success, or a long run that fails occasionally
// and recovers would eventually halt for no reason. This is the property that
// keeps the content-blind bound safe to add.
func TestSuccessResetsBothFailureCounters(t *testing.T) {
var state guardState
for i := 1; i < toolFailureAnyErrorStopAt; i++ {
if out := state.observeToolResult("bash", true, true, "distinct failure "+strconv.Itoa(i), ""); out.Stop {
t.Fatalf("stopped at %d before the success that should reset it", i)
}
}
state.observeToolResult("bash", false, false, "ok", "")
// Same again from zero. Reaching the bound a second time proves the counter
// restarted rather than merely paused.
stoppedAt := 0
for i := 1; i <= toolFailureAnyErrorStopAt; i++ {
if out := state.observeToolResult("bash", true, true, "later failure "+strconv.Itoa(i), ""); out.Stop {
stoppedAt = i
break
}
}
if stoppedAt != toolFailureAnyErrorStopAt {
t.Fatalf("after a success the tool stopped at %d, want a full fresh %d", stoppedAt, toolFailureAnyErrorStopAt)
}
}
// Records are keyed per tool, so ANOTHER tool succeeding in between must not
// clear the failing tool's streak.
//
// This is the realistic shape of the run that motivated the fix: the model kept
// making progress elsewhere — reading files, updating its plan — while one tool
// was refused over and over. A reset keyed on "something succeeded" rather than
// "this tool succeeded" would make the halt unreachable in exactly the runs that
// need it.
func TestAnotherToolSucceedingDoesNotClearAFailingToolsStreak(t *testing.T) {
var state guardState
stoppedAt := 0
for i := 1; i <= toolFailureStopAt; i++ {
state.observeToolResult("read_file", false, false, "ok", "")
out := state.observeToolResult("write_file", true, false,
"Error: Permission denied for write_file: "+strconv.Itoa(i), DenialPermissionDenied)
if out.Stop {
stoppedAt = i
break
}
}
if stoppedAt != toolFailureStopAt {
t.Fatalf("denials interleaved with another tool's successes stopped at %d, want %d", stoppedAt, toolFailureStopAt)
}
}
// alwaysPromptingTool is never allowed to run: it exists so a Run-level test can
// drive real permission denials through the loop.
type alwaysPromptingTool struct{ ran int }
func (tool *alwaysPromptingTool) Name() string { return "bash" }
func (tool *alwaysPromptingTool) Description() string { return "test shell tool" }
func (tool *alwaysPromptingTool) Parameters() tools.Schema {
return tools.Schema{
Type: "object",
Properties: map[string]tools.PropertySchema{"command": {Type: "string"}},
Required: []string{"command"},
AdditionalProperties: false,
}
}
func (tool *alwaysPromptingTool) Safety() tools.Safety {
return tools.Safety{SideEffect: tools.SideEffectShell, Permission: tools.PermissionPrompt, Reason: "runs shell commands"}
}
func (tool *alwaysPromptingTool) Run(context.Context, map[string]any) tools.Result {
tool.ran++
return tools.Result{Status: tools.StatusOK, Output: "should never run"}
}
// The regression for the gap this whole change existed to close, driven through
// Run rather than through the guard helper.
//
// The first version of this fix was a no-op in production and every one of its
// unit tests passed, because they called observeToolResult directly with
// failed=true. The loop passes isRetriableToolError, which returns false for a
// categorized denial, so the guard took its success branch and deleted the
// record before it could key on the category. A denied tool still looped to the
// turn limit.
//
// This drives the real path: a tool that always prompts, an approver that always
// denies, and a different command each turn so the denial REASON — and therefore
// the error text — varies exactly as it does in a real run.
func TestRunStopsARepeatedlyDeniedToolAtTheFailureBound(t *testing.T) {
tool := &alwaysPromptingTool{}
registry := tools.NewRegistry()
registry.Register(tool)
// Comfortably more turns than the bound, so reaching the bound is what stops
// the run rather than exhausting the provider or MaxTurns.
turns := make([][]zeroruntime.StreamEvent, 0, toolFailureStopAt+4)
for i := range toolFailureStopAt + 4 {
turns = append(turns, toolTurn("call-"+strconv.Itoa(i), "bash",
`{"command":"touch /etc/file`+strconv.Itoa(i)+`"}`))
}
provider := &mockProvider{turns: turns}
denials := 0
result, err := Run(context.Background(), "do the thing", provider, Options{
Registry: registry,
PermissionMode: PermissionModeAsk,
MaxTurns: len(turns) + 5,
OnPermissionRequest: func(_ context.Context, request PermissionRequest) (PermissionDecision, error) {
denials++
// The varying half. In production this is the refused path or command;
// here it is the command, which lands in the denial message the guard
// used to key on.
return PermissionDecision{
Action: PermissionDecisionDeny,
Reason: "refused " + request.ToolName + " call " + strconv.Itoa(denials),
}, nil
},
})
if err != nil {
t.Fatal(err)
}
if denials != toolFailureStopAt {
t.Errorf("the run made %d denied calls, want it halted at %d", denials, toolFailureStopAt)
}
if tool.ran != 0 {
t.Errorf("the denied tool executed %d times; the denial must precede execution", tool.ran)
}
want := toolFailureStopAnswer("bash", toolFailureStopAt, toolFailureCauseSameRefusal)
if result.FinalAnswer != want {
t.Errorf("final answer =\n %q\nwant\n %q", result.FinalAnswer, want)
}
}
// The stop message must describe the bound that actually tripped. When the
// content-blind counter is what halts the run, the same-signature streak is
// usually 1, and reporting that would tell the user a tool failed once after it
// failed a dozen different ways.
func TestVariedFailureStopAnswerReportsTheRightCounter(t *testing.T) {
var state guardState
var outcome toolFailureOutcome
for i := 1; i <= toolFailureAnyErrorStopAt; i++ {
outcome = state.observeToolResult("bash", true, true, "distinct failure "+strconv.Itoa(i), "")
if outcome.Stop {
break
}
}
if !outcome.Stop {
t.Fatal("never stopped")
}
if outcome.Cause != toolFailureCauseVariedExecuted {
t.Errorf("Cause = %v for a stop driven by the content-blind counter over executed failures", outcome.Cause)
}
if outcome.Count != toolFailureAnyErrorStopAt {
t.Errorf("Count = %d, want the counter that tripped (%d)", outcome.Count, toolFailureAnyErrorStopAt)
}
answer := toolFailureStopAnswer("bash", outcome.Count, outcome.Cause)
if !strings.Contains(answer, "varying errors") {
t.Errorf("stop answer describes the wrong cause: %q", answer)
}
if strings.Contains(answer, "with the same error") {
t.Errorf("stop answer claims a same-error loop after distinct failures: %q", answer)
}
}
func TestGuardStateResetsToolOnlyStreakOnEmptyNonToolTurn(t *testing.T) {
var state guardState
toolOnly := zeroruntime.CollectedStream{
@@ -513,6 +735,22 @@ func repeatedFlakyTurns(n int) [][]zeroruntime.StreamEvent {
return turns
}
// alwaysSucceedingTool is the successful counterpart to alwaysFailingTool, used
// to put a productive tool turn before an empty-turn streak.
type alwaysSucceedingTool struct{}
func (alwaysSucceedingTool) Name() string { return "ok" }
func (alwaysSucceedingTool) Description() string { return "always succeeds for testing" }
func (alwaysSucceedingTool) Parameters() tools.Schema {
return tools.Schema{Type: "object", AdditionalProperties: false}
}
func (alwaysSucceedingTool) Safety() tools.Safety {
return tools.Safety{SideEffect: tools.SideEffectRead, Permission: tools.PermissionAllow}
}
func (alwaysSucceedingTool) Run(context.Context, map[string]any) tools.Result {
return tools.Result{Status: tools.StatusOK, Output: "ok"}
}
func TestRunStopsAfterRepeatedToolFailures(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(alwaysFailingTool{})
@@ -529,6 +767,51 @@ func TestRunStopsAfterRepeatedToolFailures(t *testing.T) {
if len(provider.requests) != toolFailureStopAt {
t.Fatalf("expected stop at %d failures, made %d requests", toolFailureStopAt, len(provider.requests))
}
if result.Incomplete {
t.Fatalf("interactive run must not be marked Incomplete; reason=%q", result.IncompleteReason)
}
}
// Under the headless completion gate both guardrail stops are unfinished runs,
// so they must set Result.Incomplete and a reason; exec maps that to exit 4.
// Thresholds and stop answers stay pinned by the two tests above.
func TestHeadlessGuardStopsAreIncomplete(t *testing.T) {
for _, tc := range []struct {
name string
turns [][]zeroruntime.StreamEvent
wantTurns int
wantReason string
}{
{"empty_turns", [][]zeroruntime.StreamEvent{emptyTurn(), emptyTurn(), emptyTurn()}, maxEmptyTurns, "3 consecutive turns with no output"},
{"empty_turns_after_progress", [][]zeroruntime.StreamEvent{
toolTurn("ok-1", "ok", "{}"),
emptyTurn(), emptyTurn(), emptyTurn(),
}, maxEmptyTurns + 1, "3 consecutive turns with no output"},
{"repeated_tool_failures", repeatedFlakyTurns(10), toolFailureStopAt, "flaky"},
} {
t.Run(tc.name, func(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(alwaysFailingTool{})
registry.Register(alwaysSucceedingTool{})
result, err := Run(context.Background(), "go", &mockProvider{turns: tc.turns}, Options{
Registry: registry,
MaxTurns: 12,
RequireCompletionSignal: true,
})
if err != nil {
t.Fatal(err)
}
if result.Turns != tc.wantTurns {
t.Fatalf("guard did not trigger: turns=%d want=%d", result.Turns, tc.wantTurns)
}
if !result.Incomplete {
t.Fatalf("guardrail stop under the headless gate must be Incomplete; final=%q", result.FinalAnswer)
}
if !strings.Contains(result.IncompleteReason, tc.wantReason) {
t.Fatalf("IncompleteReason = %q, want it to mention %q", result.IncompleteReason, tc.wantReason)
}
})
}
}
func TestRunInjectsToolFailureHintWithSchema(t *testing.T) {
+221 -20
View File
@@ -587,6 +587,14 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
// counted toward the runaway cap so we stop before burning maxTurns.
if guards.observeTurn(collected) {
result.FinalAnswer = noOutputStopAnswer(result.Turns)
// Under the headless completion gate this stop is unfinished work,
// not a successful final answer: the guard exists precisely because
// the model made no progress. Interactive runs keep the gate off, so
// their behavior is unchanged.
if options.RequireCompletionSignal {
result.Incomplete = true
result.IncompleteReason = "stopped after " + strconv.Itoa(guards.emptyTurns) + " consecutive turns with no output"
}
result.Messages = copyMessages(messages)
return result, nil
}
@@ -711,6 +719,60 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
// executed concurrently, consumed strictly in order below.
var precomputed []precomputedToolResult
precomputedStart, precomputedEnd := 0, 0
// closeOutRemaining terminates every advertised call from `from` onward
// TRUTHFULLY, and every early return in this loop goes through it.
//
// Read-ahead broke the assumption the abort placeholders were written
// under. executeParallelReadBatch runs an entire eligible run before the
// loop consumes any of it, so "not consumed yet" stopped meaning "not
// executed": a sibling can already have run, committed its side effects
// and any authorization credit they carry, and still sit past the index
// where a terminal branch fires. Recording it as aborted threw that result
// away, so the transcript disagreed with what had actually happened, and
// its callbacks, trace counter, task observation and images went with it.
//
// So each remaining call is put in the one state that is true of it:
// completed, and finalized exactly once with the same bookkeeping the main
// path performs, or unstarted, and aborted. The guard is deliberately NOT
// consulted for a drained sibling: these results cannot reverse a stop
// decision that has already been made, they are only owed an honest record.
closeOutRemaining := func(from int) {
for next := from; next < len(collected.ToolCalls); next++ {
sibling, ran := precomputedResultFor(precomputed, precomputedStart, precomputedEnd, next)
if !ran {
messages = appendAbortedToolResults(messages, collected.ToolCalls[next:next+1])
continue
}
nextCall := collected.ToolCalls[next]
if options.OnToolCall != nil {
options.OnToolCall(nextCall)
}
options.Trace.Counter(trace.CounterToolCalls, 1)
recordOutputBudgetTrace(options.Trace, sibling)
task.observe(taskStateEvent{kind: taskStateEventToolResult, arguments: nextCall.Arguments, toolResult: sibling})
if options.OnToolResult != nil {
options.OnToolResult(sibling)
}
for _, name := range sibling.LoadedTools {
loaded[name] = true
}
// The same fields the consumption path below records, provider call
// ID included: a strict replay pairs the result to its tool_use by
// that ID, and a finalized sibling without it was the one result in
// the turn that could not be paired. Reported by @jatmn.
messages = append(messages, zeroruntime.Message{
Role: zeroruntime.MessageRoleTool,
Content: sibling.ModelOutput(),
ToolCallID: sibling.ToolCallID,
ToolCallProviderID: nextCall.ProviderCallID,
IsError: sibling.Status == tools.StatusError,
ChangedFiles: append([]string(nil), sibling.ChangedFiles...),
})
if len(sibling.Images) > 0 {
toolResultsWithImages = append(toolResultsWithImages, sibling)
}
}
}
for index, call := range collected.ToolCalls {
// When this call starts a consecutive run of >= 2 auto-allowed read-only
// calls, execute the whole run concurrently now (see parallel_tools.go).
@@ -775,13 +837,13 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
abortErr = ctx.Err()
}
if abortErr != nil {
messages = appendAbortedToolResults(messages, collected.ToolCalls[index+1:])
closeOutRemaining(index + 1)
messages = append(messages, buildToolImageMessages()...)
result.Messages = copyMessages(messages)
return result, abortErr
}
if stopReason := stopReasonFromToolResult(toolResult); stopReason != "" {
messages = appendAbortedToolResults(messages, collected.ToolCalls[index+1:])
closeOutRemaining(index + 1)
messages = append(messages, buildToolImageMessages()...)
result.FinalAnswer = toolResult.ModelOutput()
result.StopReason = stopReason
@@ -796,8 +858,29 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
// aren't fixed by reformatting the call, so a "match this schema" hint
// would misdirect the model toward JSON shape or blocked behavior.
retriableFailure := isRetriableToolError(toolResult)
outcome := guards.observeToolResult(call.Name, retriableFailure, toolResult.ModelOutput())
posture.observeToolOutcome(outcome, toolResult)
// A categorized denial is NOT retriable — retrying it verbatim is
// pointless — but it is still a failure the streaks must count, or a
// refused tool loops until the turn limit. Passing retriableFailure for
// both is what let that happen: observeToolResult took its success
// branch and deleted the record before it could key on the category.
// isPolicyRefusal rather than DenialReason alone: the categories are
// only attached where a typed denial is built, so a headless prompt
// refusal and an uncategorized sandbox block were counted as SUCCESS
// and cleared the record they were supposed to accumulate.
policyRefusal := isPolicyRefusal(toolResult)
countedFailure := retriableFailure || policyRefusal
outcome := guards.observeToolResult(call.Name, countedFailure, retriableFailure, toolResult.ModelOutput(), toolResult.DenialReason)
// The profile's failure-streak trigger is for RETRIABLE failures: it
// restores the displaced turn budget and reasoning effort on the theory
// that the 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 that trigger's own contract. Denials
// still count for the halt above; they just do not buy more budget.
if policyRefusal {
posture.observeToolOutcome(toolFailureOutcome{}, toolResult)
} else {
posture.observeToolOutcome(outcome, toolResult)
}
if outcome.Stop {
// The assistant message advertised EVERY collected tool call, but
// the guard halts mid-turn so the calls after this one never run.
@@ -805,10 +888,22 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
// every tool_use has a matching tool_result and the recorded
// messages stay valid for a strict provider replay (Anthropic
// rejects a tool_use with no answering tool_result).
messages = appendAbortedToolResults(messages, collected.ToolCalls[index+1:])
closeOutRemaining(index + 1)
messages = append(messages, buildToolImageMessages()...)
result.FinalAnswer = toolFailureStopAnswer(call.Name, outcome.Count)
result.FinalAnswer = toolFailureStopAnswer(call.Name, outcome.Count, outcome.Cause)
result.Messages = copyMessages(messages)
// THE HALT HAS TO CROSS THE TERMINAL-STATUS BOUNDARY TOO.
//
// This branch returns straight out, so it never reached the
// completion gate that the max-turns paths below go through. Under
// RequireCompletionSignal `zero exec` treats only Incomplete as exit
// 4 and otherwise emits run_end("success", 0), so a task that was
// denied six times, or failed through the twelve-call bound, became
// a successful automation result having done none of the work.
if options.RequireCompletionSignal {
result.Incomplete = true
result.IncompleteReason = toolFailureIncompleteReason(call.Name, outcome.Cause)
}
return result, nil
}
if outcome.InjectHint && failureHint == "" {
@@ -1590,9 +1685,41 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
// the Run turn loop performs the actual provider switch. Empty for every
// ordinary tool result.
RequestedModel: result.Meta["escalate_to_model"],
// ONE REFUSAL IDENTITY, derived here so classification and streak
// accounting cannot disagree.
//
// 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 therefore fell
// back to errorSignature(output), and two refusals of the same category
// with different wording looked like two different failures. 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 it,
// reporting varied errors rather than a repeated refusal.
DenialReason: denialCategoryForResult(result),
}, nil
}
// denialCategoryForResult gives every pre-execution refusal a stable category,
// whether it was built as a typed denial or only marked in metadata.
//
// The mapping lives here, at the one boundary tools.Result becomes ToolResult,
// rather than at each producer. A marker without a category is the shape that
// caused this: it classified as a refusal and keyed as an error signature.
func denialCategoryForResult(result tools.Result) DenialCategory {
switch result.Meta[tools.PolicyRefusalMeta] {
case tools.PolicyRefusalToolNotEnabled:
return DenialFiltered
case tools.PolicyRefusalPermissionDenied, tools.PolicyRefusalPermissionRequired:
return DenialPermissionDenied
case tools.PolicyRefusalSandboxDenied, tools.PolicyRefusalSandboxApproval:
return DenialSandboxBlock
default:
return DenialNone
}
}
const sandboxNamespaceLimitedReason = "sandbox output is limited to the sandbox PID namespace; host/global state requires approval"
func maybeRetryUnsandboxedAfterSandboxRestriction(ctx context.Context, registry *tools.Registry, call ToolCall, tool tools.Tool, args map[string]any, result tools.Result, permissionMode PermissionMode, options Options, progressCallback func(streamjson.Event)) (tools.Result, *ToolResult, bool, PermissionDecisionAction, string, []string, error) {
@@ -1923,6 +2050,10 @@ func toolResultFromPrePermissionReject(call ToolCall, result tools.Result) ToolR
Display: display,
LoadedTools: loadedToolsFromResult(meta),
RequestedModel: meta["escalate_to_model"],
// The SAME identity the executed path derives. This is the route a
// RejectBeforePermission refusal takes, so leaving it empty here is what
// made the marker classify as a refusal and key as an error signature.
DenialReason: denialCategoryForResult(tools.Result{Meta: meta}),
}
}
@@ -2094,21 +2225,64 @@ func isRetriableToolError(result ToolResult) bool {
// A categorized denial (filtered / permission / sandbox) is a policy decision,
// not a transient failure — never retry it. This is robust to message wording
// (the text checks below remain as a fallback for results lacking the field).
return !isPolicyRefusal(result)
}
// isPolicyRefusal reports a result the run refused on policy grounds: a
// permission gate, a filter, a sandbox preflight, or a hook.
//
// Split out of isRetriableToolError so the two questions cannot drift apart,
// which is exactly what they had done. The halt guard asked
// `DenialReason != DenialNone`, and a category is only attached on the paths
// that build a TYPED denial. A headless run leaves OnPermissionRequest nil, so
// the loop never reaches its typed-denial branch and the registry returns a bare
// `Error: Permission required ...` carrying no category. A sandbox preflight
// denial on a non-shell tool loses its SandboxDecision converting to ToolResult
// and arrives as an uncategorized `Sandbox block`.
//
// Both are refusals, and both were counted as SUCCESS, so observeToolResult
// cleared the record and the same refused call could repeat to MaxTurns. That is
// the loop this guard exists to stop. The text checks below already enumerated
// these outcomes for the retriable question; they simply were not reachable from
// the counting one.
func isPolicyRefusal(result ToolResult) bool {
// A REFUSAL IS A FAILURE. Everything below inspects metadata and then output
// text, and neither is meaningful on a result the tool completed.
//
// isRetriableToolError gates on this before it calls here, so the boundary
// held while that was the only caller. Extracting this helper and calling it
// straight from the counting path dropped the gate: an allowed `bash`
// printing "Sandbox block", or a read_file returning a document that quotes
// one of these phrases, was counted as a denial. Six such successes tripped
// the same-signature stop and ended a healthy run with a refusal answer.
//
// The gate belongs here rather than at each caller, because the next caller
// will forget it too.
if result.Status != tools.StatusError {
return false
}
if result.DenialReason != DenialNone {
return false
return true
}
if result.Meta["permission_action"] == string(PermissionActionDeny) {
return false
if result.Meta[tools.PermissionActionMeta] == string(PermissionActionDeny) {
return true
}
switch {
case strings.Contains(result.Output, "is not enabled for this run"),
strings.Contains(result.Output, "Permission denied for "),
strings.Contains(result.Output, "Permission required for "),
strings.Contains(result.Output, "Sandbox block"),
strings.Contains(result.Output, "Sandbox approval required for "):
return false
}
return true
// STRUCTURED ONLY. This used to fall back to matching phrases in Output
// ("Sandbox block", "Permission denied for ", and so on), which cannot work:
// 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` has actually executed, carries no
// DenialReason, and was still classified as refused. The loop then withheld
// the schema hint and the profile failure-streak recovery, counted the
// executed failure toward the refusal halt, and a later stop told the user a
// tool had been refused when it had run.
//
// read_file returning a document that quotes one of those phrases did the
// same thing, which is the more likely way a real session hits it.
//
// The registry now marks every path that returns BEFORE the tool runs, so
// the question is answerable from provenance. Nothing below reads Output.
return tools.IsPolicyRefusalResult(tools.Result{Meta: result.Meta})
}
// scrubInterceptedOutput mirrors the registry's scrubResultSecrets boundary for
@@ -2674,7 +2848,7 @@ func deniedPermissionResult(call ToolCall, reason string, requestEvent Permissio
Output: "Error: Permission denied for " + call.Name + ": " + reason,
DenialReason: denial,
Meta: map[string]string{
"permission_action": string(event.Action),
tools.PermissionActionMeta: string(event.Action),
},
}
}
@@ -2700,7 +2874,7 @@ func canceledPermissionResult(call ToolCall, reason string, requestEvent Permiss
Output: "Error: Permission approval cancelled for " + call.Name + ": " + reason,
DenialReason: DenialApprovalCanceled,
Meta: map[string]string{
"permission_action": string(event.Action),
tools.PermissionActionMeta: string(event.Action),
},
}
}
@@ -3554,6 +3728,33 @@ func toolResultImageMessage(result ToolResult, options Options) (zeroruntime.Mes
}, true
}
// precomputedResultFor reports the read-ahead result for call index, and whether
// that call actually produced one. A batch entry that never started is still
// unstarted as far as the transcript is concerned.
//
// PRODUCING A RESULT AND ASKING THE RUN TO STOP ARE DIFFERENT FACTS. Treating a
// non-nil abortErr as "unstarted" conflated them, and a cancelled permission
// request is exactly the case where they disagree: executeToolCall builds the
// cancellation result and returns it WITH ErrPermissionApprovalCanceled, so an
// earlier sibling reaching a terminal branch discarded a real result and wrote
// an aborted placeholder in its place. The transcript then denied that the call
// had completed permission handling, and the cancellation was missing from
// OnToolResult, the trace counters and the task observation even though the
// permission event had already happened.
//
// The terminal decision is not affected: draining such a sibling only records
// what occurred, it cannot reverse a stop already selected.
func precomputedResultFor(precomputed []precomputedToolResult, start, end, index int) (ToolResult, bool) {
if index < start || index >= end {
return ToolResult{}, false
}
entry := precomputed[index-start]
if !entry.completed {
return ToolResult{}, false
}
return entry.result, true
}
func modelAcceptsToolImages(options Options) bool {
if options.SupportsVision != nil {
return options.SupportsVision(options.Model)
+16 -4
View File
@@ -685,11 +685,23 @@ func TestIsRetriableToolError(t *testing.T) {
{"success", ToolResult{Status: tools.StatusOK}, false},
{"bad arguments", ToolResult{Status: tools.StatusError, Output: "Error: Failed to parse arguments for x: bad json"}, true},
{"execution failure", ToolResult{Status: tools.StatusError, Output: "Error: read foo.txt: no such file"}, true},
{"disabled tool", ToolResult{Status: tools.StatusError, Output: `Error: Tool "x" is not enabled for this run.`}, false},
// Each refusal carries the provenance its production path attaches: the
// disabled-tool path sets a denial category, the registry gates set the
// pre-execution marker. Text is never what decides.
{"disabled tool", ToolResult{Status: tools.StatusError, Output: `Error: Tool "x" is not enabled for this run.`, DenialReason: DenialFiltered}, false},
{"permission denied (meta)", ToolResult{Status: tools.StatusError, Output: "Error: Permission denied for x: needs approval", Meta: map[string]string{"permission_action": "deny"}}, false},
{"permission required", ToolResult{Status: tools.StatusError, Output: "Error: Permission required for x: approve first"}, false},
{"sandbox block", ToolResult{Status: tools.StatusError, Output: "Error: Sandbox block: outside_workspace"}, false},
{"sandbox approval", ToolResult{Status: tools.StatusError, Output: "Error: Sandbox approval required for x: network"}, false},
{"permission required", ToolResult{Status: tools.StatusError, Output: "Error: Permission required for x: approve first", Meta: map[string]string{tools.PolicyRefusalMeta: tools.PolicyRefusalPermissionRequired}}, false},
{"sandbox block", ToolResult{Status: tools.StatusError, Output: "Error: Sandbox block: outside_workspace", Meta: map[string]string{tools.PolicyRefusalMeta: tools.PolicyRefusalSandboxDenied}}, false},
{"sandbox approval", ToolResult{Status: tools.StatusError, Output: "Error: Sandbox approval required for x: network", Meta: map[string]string{tools.PolicyRefusalMeta: tools.PolicyRefusalSandboxApproval}}, false},
// THE INVERSE, which is the failure this classifier had. An allowed tool
// that RAN, failed, and happened to print one of those phrases must stay
// retriable: `bash` preserves arbitrary stdout and stderr on any nonzero
// exit, and read_file returns whatever the file says.
{"executed failure printing the sandbox phrase", ToolResult{Status: tools.StatusError, Output: "Sandbox block\n"}, true},
{"executed failure printing a permission denial", ToolResult{Status: tools.StatusError, Output: "cp: Permission denied for /etc/hosts"}, true},
{"executed failure printing a permission requirement", ToolResult{Status: tools.StatusError, Output: "Permission required for sudo"}, true},
{"executed failure printing the approval phrase", ToolResult{Status: tools.StatusError, Output: "Sandbox approval required for curl"}, true},
{"executed failure quoting the disabled-tool phrase", ToolResult{Status: tools.StatusError, Output: "grep: web_fetch is not enabled for this run"}, true},
// Structured denial categories are authoritative regardless of message text.
{"denial: filtered", ToolResult{Status: tools.StatusError, Output: "anything", DenialReason: DenialFiltered}, false},
{"denial: permission", ToolResult{Status: tools.StatusError, Output: "anything", DenialReason: DenialPermissionDenied}, false},
+74
View File
@@ -0,0 +1,74 @@
package agent
import (
"context"
"strconv"
"strings"
"testing"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/zeroruntime"
)
// mixedOutcomeTool needs permission on every call. The test denies every other
// request, and a call that is allowed executes and fails with an error naming
// its own item, so no single identity repeats and only the content-blind bound
// can stop the run.
type mixedOutcomeTool struct{ ran int }
func (tool *mixedOutcomeTool) Name() string { return "bash" }
func (tool *mixedOutcomeTool) Description() string { return "probe" }
func (tool *mixedOutcomeTool) Parameters() tools.Schema { return tools.Schema{Type: "object"} }
func (tool *mixedOutcomeTool) Safety() tools.Safety {
return tools.Safety{SideEffect: tools.SideEffectShell, Permission: tools.PermissionPrompt, Reason: "runs a command"}
}
func (tool *mixedOutcomeTool) Run(context.Context, map[string]any) tools.Result {
tool.ran++
return tools.Result{Status: tools.StatusError, Output: "Error: item " + strconv.Itoa(tool.ran) + " failed"}
}
// A MIXED HALT READS THE SAME HEADLESS AS INTERACTIVE.
//
// The stop answer for the content-blind bound over executed failures and
// refusals says the tool "failed or was refused". The headless reason for the
// same halt said only that it "failed repeatedly", so zero exec and the
// interactive answer disagreed about one stop. Reported by @jatmn.
func TestAMixedGuardHaltIsReportedAsMixedUnderTheCompletionGate(t *testing.T) {
tool := &mixedOutcomeTool{}
registry := tools.NewRegistry()
registry.Register(tool)
turns := make([][]zeroruntime.StreamEvent, 0, toolFailureAnyErrorStopAt+4)
for i := range toolFailureAnyErrorStopAt + 4 {
turns = append(turns, toolTurn("c"+strconv.Itoa(i), "bash", `{"item":`+strconv.Itoa(i)+`}`))
}
asked := 0
result, err := Run(context.Background(), "run them", &mockProvider{turns: turns}, Options{
Registry: registry,
PermissionMode: PermissionModeAsk,
MaxTurns: len(turns) + 5,
RequireCompletionSignal: true,
OnPermissionRequest: func(context.Context, PermissionRequest) (PermissionDecision, error) {
asked++
if asked%2 == 0 {
return PermissionDecision{Action: PermissionDecisionAllow}, nil
}
return PermissionDecision{Action: PermissionDecisionDeny}, nil
},
})
if err != nil {
t.Fatal(err)
}
if tool.ran == 0 || asked <= tool.ran {
t.Fatalf("the run did not mix executed failures with refusals (ran %d of %d asked), so this proves nothing", tool.ran, asked)
}
if !strings.Contains(result.FinalAnswer, "failed or was refused") {
t.Fatalf("the halt was not the mixed content-blind bound: %q", result.FinalAnswer)
}
if !result.Incomplete {
t.Fatal("a guard halt under the completion gate must be Incomplete")
}
if !strings.Contains(result.IncompleteReason, "failed or was refused") || !strings.Contains(result.IncompleteReason, "bash") {
t.Errorf("IncompleteReason = %q, want it to say the tool failed or was refused, as the stop answer does", result.IncompleteReason)
}
}
@@ -0,0 +1,232 @@
package agent
import (
"context"
"strconv"
"strings"
"sync"
"testing"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/zeroruntime"
)
// readAheadTool is read-only and thread-safe, so a consecutive run of its calls
// is executed as one parallel batch before the loop consumes any of it.
type readAheadTool struct {
mu sync.Mutex
ran int
}
func (t *readAheadTool) Name() string { return "read_probe" }
func (t *readAheadTool) Description() string { return "read-only probe" }
func (t *readAheadTool) Parameters() tools.Schema {
return tools.Schema{Type: "object", Properties: map[string]tools.PropertySchema{"id": {Type: "string"}}}
}
func (t *readAheadTool) Safety() tools.Safety {
return tools.Safety{SideEffect: tools.SideEffectRead, Permission: tools.PermissionAllow, Reason: "test"}
}
func (t *readAheadTool) Capabilities() tools.ToolCapabilities {
return tools.ToolCapabilities{Effect: tools.EffectReadOnly, ThreadSafe: true}
}
func (t *readAheadTool) Run(_ context.Context, args map[string]any) tools.Result {
t.mu.Lock()
t.ran++
t.mu.Unlock()
id, _ := args["id"].(string)
// Varying output, so no identity repeats and only the content-blind counter
// can halt the run.
return tools.Result{Status: tools.StatusError, Output: "failure for " + id}
}
func readCall(id string) []zeroruntime.StreamEvent {
return []zeroruntime.StreamEvent{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: id, ToolCallProviderID: "provider-" + id, ToolName: "read_probe"},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: id, ArgumentsFragment: `{"id":"` + id + `"}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: id},
}
}
// READ-AHEAD MEANS "NOT CONSUMED YET" NO LONGER MEANS "NOT EXECUTED".
//
// executeParallelReadBatch runs an entire eligible run of read calls before the
// loop consumes any of them. Every terminal branch in the consumption loop used
// to close out the calls after the current index with aborted placeholders, on
// the assumption that they had not run. A sibling that had already executed was
// therefore recorded as aborted: its real result was discarded, the transcript
// disagreed with what had happened, and its callbacks, trace counter, task
// observation and images were lost with it. Where such a sibling is a
// successful read, execution may already have committed file-observation credit
// for content the model never receives.
func TestParallelSiblingsAreFinalizedWhenTheGuardHalts(t *testing.T) {
tool := &readAheadTool{}
registry := tools.NewRegistry()
registry.Register(tool)
// Eleven single-call turns bring the content-blind counter to one below its
// bound, so the first call of the pair below trips it.
var turns [][]zeroruntime.StreamEvent
for i := 0; i < toolFailureAnyErrorStopAt-1; i++ {
id := "s" + strconv.Itoa(i)
turns = append(turns, append(readCall(id), zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone}))
}
// Then one turn advertising two parallel-eligible calls. Both execute in the
// batch; the halt fires while consuming the first.
pair := append(readCall("pA"), readCall("pB")...)
turns = append(turns, append(pair, zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone}))
turns = append(turns, append(readCall("unreached"), zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone}))
var reported []ToolResult
var announced int
result, err := Run(context.Background(), "read things", &mockProvider{turns: turns}, Options{
Registry: registry,
PermissionMode: PermissionModeAuto,
MaxTurns: len(turns) + 5,
OnToolCall: func(ToolCall) { announced++ },
OnToolResult: func(r ToolResult) { reported = append(reported, r) },
})
if err != nil {
t.Fatal(err)
}
if !strings.HasPrefix(result.FinalAnswer, "Agent stopped:") {
t.Fatalf("the guard did not halt this run: %q", result.FinalAnswer)
}
// Every execution is reported exactly once, and the announcements match.
if tool.ran != len(reported) {
t.Errorf("the tool executed %d times but %d results were reported; a completed call was recorded as aborted",
tool.ran, len(reported))
}
if announced != len(reported) {
t.Errorf("OnToolCall fired %d times against %d results; the two callbacks disagree", announced, len(reported))
}
// Strict pairing: one tool result per advertised tool call, and the drained
// sibling carries its REAL output rather than the aborted placeholder.
byID := map[string]int{}
var siblingContent string
for _, message := range result.Messages {
if message.Role != zeroruntime.MessageRoleTool {
continue
}
byID[message.ToolCallID]++
if message.ToolCallID == "pB" {
siblingContent = message.Content
}
// A strict replay pairs each result to its tool_use by the provider
// call ID, so the drained sibling needs it as much as the consumed call.
if (message.ToolCallID == "pA" || message.ToolCallID == "pB") && message.ToolCallProviderID != "provider-"+message.ToolCallID {
t.Errorf("tool result %s carries provider call ID %q, want %q", message.ToolCallID, message.ToolCallProviderID, "provider-"+message.ToolCallID)
}
}
for _, id := range []string{"pA", "pB"} {
if byID[id] != 1 {
t.Errorf("tool call %s has %d results, want exactly 1", id, byID[id])
}
}
if siblingContent == abortedToolResultNotice {
t.Error("the sibling that already ran was recorded as aborted")
}
if !strings.Contains(siblingContent, "failure for pB") {
t.Errorf("the sibling's real output is missing from the transcript: %q", siblingContent)
}
// And the drained sibling must not reverse the decision that was already
// made: the halt is still attributed to the call that tripped it.
if !strings.Contains(result.FinalAnswer, strconv.Itoa(toolFailureAnyErrorStopAt)) {
t.Errorf("the stop answer changed after draining siblings: %q", result.FinalAnswer)
}
}
// imageReadTool is readAheadTool with an image on one named call, so a drained
// sibling's image can be followed from execution to the transcript.
type imageReadTool struct {
readAheadTool
imageFor string
}
func (t *imageReadTool) Run(ctx context.Context, args map[string]any) tools.Result {
result := t.readAheadTool.Run(ctx, args)
if id, _ := args["id"].(string); id == t.imageFor {
result.Images = []zeroruntime.ImageBlock{{MediaType: "image/png", Data: []byte("\x89PNG")}}
}
return result
}
// The drained sibling's image is part of what ran, so it follows the tool
// results like any other call's image. Images reach the transcript through the
// turn's deferred builder, which the halt path calls after closing out the
// siblings; a drain that skipped the sibling's image would lose it silently.
func TestParallelSiblingImageSurvivesTheGuardHalt(t *testing.T) {
tool := &imageReadTool{imageFor: "pB"}
registry := tools.NewRegistry()
registry.Register(tool)
var turns [][]zeroruntime.StreamEvent
for i := 0; i < toolFailureAnyErrorStopAt-1; i++ {
id := "s" + strconv.Itoa(i)
turns = append(turns, append(readCall(id), zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone}))
}
pair := append(readCall("pA"), readCall("pB")...)
turns = append(turns, append(pair, zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone}))
result, err := Run(context.Background(), "read things", &mockProvider{turns: turns}, Options{
Registry: registry,
PermissionMode: PermissionModeAuto,
MaxTurns: len(turns) + 5,
SupportsVision: func(string) bool { return true },
})
if err != nil {
t.Fatal(err)
}
if !strings.HasPrefix(result.FinalAnswer, "Agent stopped:") {
t.Fatalf("the guard did not halt this run: %q", result.FinalAnswer)
}
images := 0
for _, message := range result.Messages {
if message.Role == zeroruntime.MessageRoleUser {
images += len(message.Images)
}
}
if images != 1 {
t.Errorf("the drained sibling's image reached the transcript %d times, want once", images)
}
}
// A call that genuinely never ran still gets an aborted placeholder, so the fix
// did not simply stop aborting anything.
func TestUnstartedCallsStillGetAbortedPlaceholders(t *testing.T) {
tool := &readAheadTool{}
registry := tools.NewRegistry()
registry.Register(tool)
var turns [][]zeroruntime.StreamEvent
for i := 0; i < toolFailureAnyErrorStopAt-1; i++ {
id := "s" + strconv.Itoa(i)
turns = append(turns, append(readCall(id), zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone}))
}
// One eligible pair, then a THIRD call outside the batch window is not what
// happens here: the whole run is eligible, so instead advertise a single call
// and let the halt fire on it, leaving nothing precomputed beyond it.
turns = append(turns, append(readCall("solo"), zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone}))
turns = append(turns, append(readCall("unreached"), zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone}))
result, err := Run(context.Background(), "read things", &mockProvider{turns: turns}, Options{
Registry: registry,
PermissionMode: PermissionModeAuto,
MaxTurns: len(turns) + 5,
})
if err != nil {
t.Fatal(err)
}
if !strings.HasPrefix(result.FinalAnswer, "Agent stopped:") {
t.Fatalf("the guard did not halt: %q", result.FinalAnswer)
}
if tool.ran != toolFailureAnyErrorStopAt {
t.Errorf("the tool ran %d times, want %d; nothing should execute past the halt",
tool.ran, toolFailureAnyErrorStopAt)
}
}
+30 -3
View File
@@ -2,6 +2,7 @@ package agent
import (
"context"
"strings"
"sync"
"github.com/Twigpine/zero/internal/tools"
@@ -12,8 +13,17 @@ import (
// them one after another serializes pure I/O waits. A consecutive run of
// capability-safe read-only calls is executed concurrently instead; results are
// then consumed in the original call order, so guard counters, message
// ordering, abort semantics, and the surface's call/result event pairing are
// byte-identical to sequential execution. Runs never span a mutating call: a
// ordering, abort semantics, and the surface's call/result event pairing match
// sequential execution.
//
// What can differ is how many calls RAN. A run executes before the guard
// consumes its first result, so a halt on an earlier call can leave up to
// maxParallelReadTools-1 siblings already executed. The guard bounds count
// consumed results, not executions, and the extra executions are reads by the
// eligibility rule below; closeOutRemaining records them without feeding the
// guard. Reported by @jatmn.
//
// Runs never span a mutating call: a
// read that follows a write must observe the write, so eligibility is decided
// per consecutive run, not per batch.
//
@@ -36,6 +46,15 @@ const maxParallelReadTools = 8
type precomputedToolResult struct {
result ToolResult
abortErr error
// completed records whether this call PRODUCED a result, which is a
// different question from whether it asks the enclosing run to stop.
//
// A cancelled permission request is both: executeToolCall builds the
// cancellation result, with its call ID, its message and its denial category,
// and returns it together with ErrPermissionApprovalCanceled. Reading the
// error as "never started" throws that result away, and the transcript then
// denies that permission handling happened at all.
completed bool
}
// parallelSafeToolCall reports whether call may run concurrently with its
@@ -189,7 +208,15 @@ func executeParallelReadBatch(ctx context.Context, registry *tools.Registry, cal
semaphore <- struct{}{}
defer func() { <-semaphore }()
result, abortErr := executeToolCall(ctx, registry, calls[index], permissionMode, batchOptions)
results[index-start] = precomputedToolResult{result: result, abortErr: abortErr}
// Recorded HERE, where both halves are in hand, rather than inferred later
// from the error. A populated call ID is what every path that produced a
// result sets and what a context cancellation before execution leaves
// empty.
results[index-start] = precomputedToolResult{
result: result,
abortErr: abortErr,
completed: strings.TrimSpace(result.ToolCallID) != "",
}
}(index)
}
waitGroup.Wait()
@@ -0,0 +1,116 @@
package agent
import (
"context"
"strconv"
"strings"
"testing"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/zeroruntime"
)
// forgedMarkerTool executes, fails, and claims in its metadata that the registry
// refused it before it ran.
type forgedMarkerTool struct {
ran int
key string
value string
}
func (tool *forgedMarkerTool) Name() string { return "bash" }
func (tool *forgedMarkerTool) Description() string { return "test shell tool" }
func (tool *forgedMarkerTool) Parameters() tools.Schema {
return tools.Schema{
Type: "object",
Properties: map[string]tools.PropertySchema{"command": {Type: "string"}},
Required: []string{"command"},
AdditionalProperties: false,
}
}
func (tool *forgedMarkerTool) Safety() tools.Safety {
return tools.Safety{SideEffect: tools.SideEffectRead, Permission: tools.PermissionAllow, Reason: "reads files"}
}
func (tool *forgedMarkerTool) Run(context.Context, map[string]any) tools.Result {
tool.ran++
return tools.Result{
Status: tools.StatusError,
Output: "Error: the script exited 1",
Meta: map[string]string{tool.key: tool.value},
}
}
// THE FORGED MARKER MUST NOT CHANGE THE RUN.
//
// Classifying from metadata instead of from output text is only an improvement
// while the metadata is something an executed result cannot produce. The
// registry strips the key at the execution boundary; this is the same claim
// stated where it is spent, because the unit test proves the boundary and this
// proves the loop behaves.
//
// An executed failure that is misread as a refusal loses the schema hint, stops
// the profile failure streak from recovering, and is counted toward the refusal
// halt, so the run ends early and the final answer can say a tool was refused
// when it ran every time it was asked.
func TestRunTreatsAForgedRefusalMarkerAsAnExecutedFailure(t *testing.T) {
for _, forged := range []struct{ key, value string }{
{tools.PolicyRefusalMeta, tools.PolicyRefusalSandboxDenied},
{tools.PolicyRefusalMeta, tools.PolicyRefusalPermissionDenied},
{tools.PolicyRefusalMeta, tools.PolicyRefusalToolNotEnabled},
{tools.PolicyRefusalMeta, "something-the-registry-never-emits"},
// The loop reads a denied permission action as a refusal too, so an
// executed failure carrying one is the same forgery. Reported by @jatmn.
{tools.PermissionActionMeta, string(PermissionActionDeny)},
} {
value := forged.value
t.Run(forged.key+"="+value, func(t *testing.T) {
tool := &forgedMarkerTool{key: forged.key, value: value}
registry := tools.NewRegistry()
registry.Register(tool)
// One more call than the hint threshold, so the hint has to have been
// injected by the last one, then a final text turn to end the run.
calls := toolFailureHintAt + 1
turns := make([][]zeroruntime.StreamEvent, 0, calls+1)
for i := range calls {
turns = append(turns, toolTurn("call-"+strconv.Itoa(i), "bash", `{"command":"./flaky.sh"}`))
}
turns = append(turns, textTurn("gave up on the script"))
var denials []DenialCategory
result, err := Run(context.Background(), "run the script", &mockProvider{turns: turns}, Options{
Registry: registry,
PermissionMode: PermissionModeAsk,
MaxTurns: len(turns) + 5,
OnToolResult: func(toolResult ToolResult) {
denials = append(denials, toolResult.DenialReason)
},
OnPermissionRequest: func(context.Context, PermissionRequest) (PermissionDecision, error) {
t.Error("permission was requested for an allow-safety tool; this test must exercise the executed-failure path")
return PermissionDecision{Action: PermissionDecisionDeny}, nil
},
})
if err != nil {
t.Fatal(err)
}
if tool.ran != calls {
t.Fatalf("the tool ran %d times, want %d; the run was halted by a marker it set on its own result", tool.ran, calls)
}
for index, denial := range denials {
if denial != DenialNone {
t.Errorf("result %d was recorded as denial %q; the tool executed", index, denial)
}
}
var hinted bool
for _, message := range result.Messages {
if strings.Contains(message.Content, toolFailureHintMarker) {
hinted = true
break
}
}
if !hinted {
t.Errorf("no retry hint was injected for an executed failure carrying %q, so the model got no correction", value)
}
})
}
}
@@ -0,0 +1,196 @@
package agent
import (
"context"
"strconv"
"testing"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/trace"
"github.com/Twigpine/zero/internal/zeroruntime"
)
// THE UNCATEGORIZED REFUSALS ARE THE ONES THIS CHANGE EXISTS FOR, SO THEY ARE THE
// ONES THAT MUST BE DRIVEN THROUGH Run.
//
// A categorized denial is easy to test and was never the gap. The gap is a
// refusal that arrives with DenialReason empty, because a category is attached
// only where a TYPED denial is built:
//
// - a headless run leaves OnPermissionRequest nil, so the loop never reaches
// its typed-denial branch and the registry gate returns a bare
// `Permission required for ...`
// - a sandbox preflight denial on a non-shell tool loses its SandboxDecision
// converting to ToolResult and arrives as a bare `Sandbox block`
//
// Both were counted as SUCCESS, which cleared the very record they were meant to
// accumulate, and the refused call repeated to MaxTurns.
//
// Asserting that through the helper alone would prove 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. So each case
// below goes through Run and pins the three things the loop actually does with
// the classification: halt at the bound, never execute the tool, and withhold
// the profile's one-shot failure escalation.
// headlessPromptTool needs approval and never gets it, because the run has no
// approver. The registry refuses it before Run() is reached, exactly as a
// headless run does, so nothing here fakes the refusal.
type headlessPromptTool struct{ ran int }
func (tool *headlessPromptTool) Name() string { return "bash" }
func (tool *headlessPromptTool) Description() string { return "test shell tool" }
func (tool *headlessPromptTool) Parameters() tools.Schema {
return tools.Schema{
Type: "object",
Properties: map[string]tools.PropertySchema{"command": {Type: "string"}},
Required: []string{"command"},
AdditionalProperties: false,
}
}
func (tool *headlessPromptTool) Safety() tools.Safety {
return tools.Safety{SideEffect: tools.SideEffectShell, Permission: tools.PermissionPrompt, Reason: "runs shell commands"}
}
func (tool *headlessPromptTool) Run(context.Context, map[string]any) tools.Result {
tool.ran++
return tools.Result{Status: tools.StatusOK, Output: "should never run"}
}
// A headless prompt refusal must halt at the same-signature bound rather than
// repeat to MaxTurns.
//
// It used to key on the refusal TEXT, which the registry happens to hold
// constant here, and the stop then described a repeated failure. The registry
// marks this path, so the category is now derived at the ToolResult boundary and
// the streak keys on that instead. The bound is unchanged; the wording is more
// honest, because a headless prompt refusal is a refusal and not a tool that
// keeps failing.
func TestRunStopsAnUncategorizedHeadlessRefusalAtTheFailureBound(t *testing.T) {
tool := &headlessPromptTool{}
registry := tools.NewRegistry()
registry.Register(tool)
// Comfortably more turns than the bound, so reaching the bound is what stops
// the run rather than exhausting the provider or MaxTurns.
turns := make([][]zeroruntime.StreamEvent, 0, toolFailureStopAt+4)
for i := range toolFailureStopAt + 4 {
turns = append(turns, toolTurn("call-"+strconv.Itoa(i), "bash",
`{"command":"touch /etc/file`+strconv.Itoa(i)+`"}`))
}
provider := &mockProvider{turns: turns}
recorder := trace.NewRecorder("policy-refusal-session", "run-1", "fast")
result, err := Run(context.Background(), "do the thing", provider, Options{
Registry: registry,
PermissionMode: PermissionModeAsk,
MaxTurns: len(turns) + 5,
Trace: recorder,
// Armed well below the halt bound, so a refusal counted as a retriable
// failure would visibly spend it.
Profile: &ProfilePolicy{
Name: "fast",
Escalate: &PostureEscalation{MaxTurns: 999, OnToolFailureStreak: 2},
},
// OnPermissionRequest deliberately nil: this IS the headless path.
})
if err != nil {
t.Fatal(err)
}
if len(provider.requests) != toolFailureStopAt {
t.Errorf("the run made %d turns, want it halted at %d; an uncategorized refusal is not being counted", len(provider.requests), toolFailureStopAt)
}
if tool.ran != 0 {
t.Errorf("the refused tool executed %d times; the registry gate must precede execution", tool.ran)
}
// Categorized now, so the stop answer says refused. Previously the loop could
// not tell and reported a repeated failure.
want := toolFailureStopAnswer("bash", toolFailureStopAt, toolFailureCauseSameRefusal)
if result.FinalAnswer != want {
t.Errorf("final answer =\n %q\nwant\n %q", result.FinalAnswer, want)
}
assertNoPostureEscalation(t, recorder)
}
// uncategorizedSandboxTool returns the shape a sandbox preflight denial has by
// the time the loop sees it on a non-shell tool: StatusError, `Sandbox block`
// prose, and no category, because the SandboxDecision does not survive the
// conversion to ToolResult. The path varies per call exactly as the real message
// does, since it names what was refused.
type uncategorizedSandboxTool struct{ ran int }
func (tool *uncategorizedSandboxTool) Name() string { return "write_file" }
func (tool *uncategorizedSandboxTool) Description() string { return "test write tool" }
func (tool *uncategorizedSandboxTool) Parameters() tools.Schema {
return tools.Schema{
Type: "object",
Properties: map[string]tools.PropertySchema{"path": {Type: "string"}},
Required: []string{"path"},
AdditionalProperties: false,
}
}
func (tool *uncategorizedSandboxTool) Safety() tools.Safety {
return tools.Safety{SideEffect: tools.SideEffectWrite, Permission: tools.PermissionAllow, Reason: "writes files"}
}
func (tool *uncategorizedSandboxTool) Run(context.Context, map[string]any) tools.Result {
tool.ran++
return tools.Result{
Status: tools.StatusError,
Output: "Sandbox block: write to /out-" + strconv.Itoa(tool.ran) + ".txt is outside the workspace",
}
}
// Varying text plus no category means every call restarts the same-signature
// streak at 1, so the same-signature bound is never reached and only the
// content-blind counter can halt the run. Before the fix nothing halted it at
// all: each refusal was read as a success, deleted the record, and the call
// repeated until MaxTurns.
func TestRunStopsAnUncategorizedVaryingSandboxRefusalAtTheVariedBound(t *testing.T) {
tool := &uncategorizedSandboxTool{}
registry := tools.NewRegistry()
registry.Register(tool)
turns := make([][]zeroruntime.StreamEvent, 0, toolFailureAnyErrorStopAt+4)
for i := range toolFailureAnyErrorStopAt + 4 {
turns = append(turns, toolTurn("call-"+strconv.Itoa(i), "write_file",
`{"path":"/out-`+strconv.Itoa(i)+`.txt"}`))
}
provider := &mockProvider{turns: turns}
recorder := trace.NewRecorder("policy-refusal-session", "run-2", "fast")
result, err := Run(context.Background(), "write the files", provider, Options{
Registry: registry,
PermissionMode: PermissionModeAsk,
MaxTurns: len(turns) + 5,
Trace: recorder,
Profile: &ProfilePolicy{
Name: "fast",
Escalate: &PostureEscalation{MaxTurns: 999, OnToolFailureStreak: 2},
},
})
if err != nil {
t.Fatal(err)
}
if tool.ran != toolFailureAnyErrorStopAt {
t.Errorf("the tool was called %d times, want the run halted at %d", tool.ran, toolFailureAnyErrorStopAt)
}
want := toolFailureStopAnswer("write_file", toolFailureAnyErrorStopAt, toolFailureCauseVariedExecuted)
if result.FinalAnswer != want {
t.Errorf("final answer =\n %q\nwant\n %q", result.FinalAnswer, want)
}
assertNoPostureEscalation(t, recorder)
}
// The profile's failure-streak trigger restores turn budget and reasoning effort
// on the theory that a struggling tool needs room. A refusal is not a struggling
// tool, it is an answer, and spending the one-shot escalation on one buys turns
// that will be refused identically. The loop keeps this contract by passing an
// empty outcome to the controller for a refusal, which is only observable from
// outside as the escalation never firing.
func assertNoPostureEscalation(t *testing.T, recorder *trace.Recorder) {
t.Helper()
if got := recorder.Finish().Counter(trace.CounterPostureEscalations); got != 0 {
t.Errorf("posture_escalations = %d, want 0; a policy refusal spent the one-shot failure escalation", got)
}
}
@@ -0,0 +1,265 @@
package agent
import (
"context"
"strconv"
"strings"
"testing"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/zeroruntime"
)
// A REFUSAL IS A FAILURE, AND THE CLASSIFIER MUST SAY SO BEFORE IT READS ANYTHING
// ELSE.
//
// 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:
// a `bash` that greps the sandbox docs, a read_file returning this very source
// file, any successful command whose output happens to carry one of the phrases.
//
// 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, and every successful result was put to a
// question that only failures can answer.
//
// Each case below is a SUCCESS. None may be read as a refusal.
func TestPolicyRefusalNeverClassifiesASuccessfulResult(t *testing.T) {
cases := []struct {
name string
result ToolResult
// structured marks a result carrying real provenance: a denial category,
// permission metadata, or the registry's pre-execution marker. Only those
// may be read as a refusal when they fail. The rest carry nothing but
// text, which the tool controls, so they must stay ordinary failures no
// matter which phrase they happen to contain.
structured bool
}{
{
name: "output quotes the sandbox block phrase",
result: ToolResult{Status: tools.StatusOK, Output: "grep: docs/sandbox.md: Sandbox block"},
},
{
name: "output quotes the disabled-tool phrase",
result: ToolResult{Status: tools.StatusOK, Output: "web_fetch is not enabled for this run"},
},
{
name: "output quotes a permission denial",
result: ToolResult{Status: tools.StatusOK, Output: "log line: Permission denied for bash"},
},
{
name: "output quotes a permission requirement",
result: ToolResult{Status: tools.StatusOK, Output: "log line: Permission required for bash"},
},
{
name: "output quotes a sandbox approval prompt",
result: ToolResult{Status: tools.StatusOK, Output: "Sandbox approval required for curl"},
},
{
// Metadata is no more trustworthy than text on a completed call. A
// result carrying a category with an OK status is not a denial; every
// path that attaches one fails the call.
name: "stale denial category on a completed call",
result: ToolResult{Status: tools.StatusOK, DenialReason: DenialSandboxBlock, Output: "ok"},
structured: true,
},
{
name: "stale permission metadata on a completed call",
result: ToolResult{
Status: tools.StatusOK,
Meta: map[string]string{"permission_action": string(PermissionActionDeny)},
Output: "ok",
},
structured: true,
},
{
name: "registry refusal marker on a completed call",
result: ToolResult{
Status: tools.StatusOK,
Meta: map[string]string{tools.PolicyRefusalMeta: tools.PolicyRefusalSandboxDenied},
Output: "ok",
},
structured: true,
},
}
for _, testCase := range cases {
t.Run(testCase.name, func(t *testing.T) {
if isPolicyRefusal(testCase.result) {
t.Errorf("a successful result was classified as a policy refusal, so a healthy call counts toward the denial streak: %+v", testCase.result)
}
failed := testCase.result
failed.Status = tools.StatusError
switch {
case testCase.structured && !isPolicyRefusal(failed):
t.Errorf("a failure carrying real provenance was not classified as a refusal, so the gate broke the classifier: %+v", failed)
case !testCase.structured && isPolicyRefusal(failed):
t.Errorf("a failure carrying only text was classified as a refusal; output is tool-controlled and cannot decide this: %+v", failed)
}
})
}
}
// blockTextShellTool runs freely and prints text the classifier keys on. This
// is not contrived: `Sandbox block` appears in this repo's own docs and error
// strings, so any grep, cat, or file read that crosses them produces exactly
// this result.
type blockTextShellTool struct{ ran int }
func (tool *blockTextShellTool) Name() string { return "bash" }
func (tool *blockTextShellTool) Description() string { return "test shell tool" }
func (tool *blockTextShellTool) Parameters() tools.Schema {
return tools.Schema{
Type: "object",
Properties: map[string]tools.PropertySchema{"command": {Type: "string"}},
Required: []string{"command"},
AdditionalProperties: false,
}
}
func (tool *blockTextShellTool) Safety() tools.Safety {
return tools.Safety{SideEffect: tools.SideEffectRead, Permission: tools.PermissionAllow, Reason: "reads files"}
}
func (tool *blockTextShellTool) Run(context.Context, map[string]any) tools.Result {
tool.ran++
return tools.Result{
Status: tools.StatusOK,
Output: "docs/windows-sandbox.md:41: Sandbox block (write jail) is reported as ...",
}
}
// The same gap driven through Run, because the classifier being wrong only
// matters if the loop acts on it, and it does: the counting path calls
// isPolicyRefusal directly, and a true answer records a failure against the
// tool's signature. Six identical successes then look like six identical
// denials, and the run ends on a stop answer telling the user a tool it never
// refused was refused six times.
//
// The tool succeeds every turn and the run is given more turns than the bound,
// so anything that halts before the provider's final turn is the guard.
func TestRunDoesNotHaltOnSuccessfulOutputThatQuotesARefusal(t *testing.T) {
tool := &blockTextShellTool{}
registry := tools.NewRegistry()
registry.Register(tool)
calls := toolFailureStopAt + 4
turns := make([][]zeroruntime.StreamEvent, 0, calls+1)
for i := range calls {
turns = append(turns, toolTurn("call-"+strconv.Itoa(i), "bash",
`{"command":"grep -n 'Sandbox block' docs/windows-sandbox.md"}`))
}
turns = append(turns, textTurn("found the docs"))
provider := &mockProvider{turns: turns}
result, err := Run(context.Background(), "search the docs", provider, Options{
Registry: registry,
PermissionMode: PermissionModeAsk,
MaxTurns: len(turns) + 5,
OnPermissionRequest: func(context.Context, PermissionRequest) (PermissionDecision, error) {
t.Error("the loop asked permission for an allow-safety tool; this test must exercise the success path")
return PermissionDecision{Action: PermissionDecisionDeny}, nil
},
})
if err != nil {
t.Fatal(err)
}
if tool.ran != calls {
t.Errorf("the tool ran %d times, want %d; the run was halted mid-way by its own success output", tool.ran, calls)
}
if result.FinalAnswer != "found the docs" {
t.Errorf("final answer =\n %q\nwant\n %q\n(a stop answer here means successful output was counted as a refusal)",
result.FinalAnswer, "found the docs")
}
}
// failingPhraseTool RAN and failed. Its stderr happens to contain a phrase the
// classifier used to key on, which is the everyday case: `bash` preserves
// arbitrary stdout and stderr on any nonzero exit, so
// `printf 'Sandbox block\n' >&2; exit 1` produces exactly this.
type failingPhraseTool struct {
ran int
output string
}
func (tool *failingPhraseTool) Name() string { return "bash" }
func (tool *failingPhraseTool) Description() string { return "test shell tool" }
func (tool *failingPhraseTool) Parameters() tools.Schema {
return tools.Schema{
Type: "object",
Properties: map[string]tools.PropertySchema{"command": {Type: "string"}},
Required: []string{"command"},
AdditionalProperties: false,
}
}
func (tool *failingPhraseTool) Safety() tools.Safety {
return tools.Safety{SideEffect: tools.SideEffectRead, Permission: tools.PermissionAllow, Reason: "reads files"}
}
func (tool *failingPhraseTool) Run(context.Context, map[string]any) tools.Result {
tool.ran++
return tools.Result{Status: tools.StatusError, Output: tool.output}
}
// AN EXECUTED FAILURE IS RETRIABLE, WHATEVER IT PRINTED, and the observable
// difference is the schema hint.
//
// isRetriableToolError feeds `hintable` into the guard, and the guard injects
// the retry hint at toolFailureHintAt. Classifying an executed failure as a
// policy refusal makes hintable false, so the model is told nothing and simply
// repeats the call: the run loses the one correction it had. That is on top of
// the failure being accumulated as a denial.
//
// Each phrase below is one the classifier used to match on. The tool is
// allow-safety and runs every time, so nothing here is refused by anything.
func TestRunHintsAnExecutedFailureThatPrintsARefusalPhrase(t *testing.T) {
for _, output := range []string{
"Sandbox block\n",
"cp: Permission denied for /etc/hosts",
"Permission required for sudo",
"Sandbox approval required for curl",
"grep: web_fetch is not enabled for this run",
} {
t.Run(output, func(t *testing.T) {
tool := &failingPhraseTool{output: output}
registry := tools.NewRegistry()
registry.Register(tool)
// One more call than the hint threshold, so the hint has to have been
// injected by the last one, then a final text turn to end the run.
calls := toolFailureHintAt + 1
turns := make([][]zeroruntime.StreamEvent, 0, calls+1)
for i := range calls {
turns = append(turns, toolTurn("call-"+strconv.Itoa(i), "bash", `{"command":"./flaky.sh"}`))
}
turns = append(turns, textTurn("gave up on the script"))
provider := &mockProvider{turns: turns}
result, err := Run(context.Background(), "run the script", provider, Options{
Registry: registry,
PermissionMode: PermissionModeAsk,
MaxTurns: len(turns) + 5,
OnPermissionRequest: func(context.Context, PermissionRequest) (PermissionDecision, error) {
t.Error("permission was requested for an allow-safety tool; this test must exercise the executed-failure path")
return PermissionDecision{Action: PermissionDecisionDeny}, nil
},
})
if err != nil {
t.Fatal(err)
}
if tool.ran != calls {
t.Fatalf("the tool ran %d times, want %d; the run was halted by output it merely printed", tool.ran, calls)
}
var hinted bool
for _, message := range result.Messages {
if strings.Contains(message.Content, toolFailureHintMarker) {
hinted = true
break
}
}
if !hinted {
t.Errorf("no retry hint was injected for an executed failure printing %q; it was classified as a policy refusal, so the model got no correction", output)
}
})
}
}
+84
View File
@@ -0,0 +1,84 @@
package agent
import (
"testing"
"github.com/Twigpine/zero/internal/tools"
)
// UNCATEGORIZED REFUSALS MUST STILL COUNT.
//
// The halt guard 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 in the
// conversion and arrives as an uncategorized "Sandbox block".
//
// Both were counted as SUCCESS, which cleared the very record the guard
// accumulates, so the same refused call could repeat to MaxTurns. That is the
// loop this PR exists to stop.
func TestUncategorizedPolicyRefusalsAreCounted(t *testing.T) {
for _, testCase := range []struct {
name string
result ToolResult
}{
{
// Still uncategorized in the DenialReason sense: a headless run never
// reaches the branch that builds a typed denial. It carries the
// registry's pre-execution marker instead, which is what makes it
// classifiable without reading the message.
name: "headless prompt refusal carries no denial category",
result: ToolResult{
Status: tools.StatusError,
Output: `Error: Permission required for bash: The tool is marked "prompt" and was not executed.`,
Meta: map[string]string{tools.PolicyRefusalMeta: tools.PolicyRefusalPermissionRequired},
},
},
{
// The SandboxDecision is still dropped in the conversion for a
// non-shell tool; the marker is what survives it.
name: "sandbox preflight denial loses its decision in conversion",
result: ToolResult{
Status: tools.StatusError,
Output: "Sandbox block: write outside the workspace",
Meta: map[string]string{tools.PolicyRefusalMeta: tools.PolicyRefusalSandboxDenied},
},
},
{
name: "an explicitly denied permission action",
result: ToolResult{Status: tools.StatusError, Output: "refused", Meta: map[string]string{"permission_action": string(PermissionActionDeny)}},
},
{
name: "a categorized denial still counts",
result: ToolResult{Status: tools.StatusError, Output: "denied", DenialReason: DenialFiltered},
},
} {
t.Run(testCase.name, func(t *testing.T) {
if !isPolicyRefusal(testCase.result) {
t.Fatal("refusal was treated as a success, so the guard's record is cleared and the call can repeat to MaxTurns")
}
if isRetriableToolError(testCase.result) {
t.Error("a policy refusal is not retriable: repeating it verbatim cannot help")
}
})
}
}
// An ordinary failure is still retriable and still not a refusal, or the guard
// would halt on things the model can legitimately fix by trying again.
func TestOrdinaryFailuresAreNotPolicyRefusals(t *testing.T) {
for _, output := range []string{
"Error: file not found",
"Error: invalid JSON in arguments",
"Error: connection reset",
} {
result := ToolResult{Status: tools.StatusError, Output: output}
if isPolicyRefusal(result) {
t.Errorf("%q was classified as a policy refusal", output)
}
if !isRetriableToolError(result) {
t.Errorf("%q should stay retriable", output)
}
}
}
@@ -0,0 +1,87 @@
package agent
import (
"context"
"errors"
"fmt"
"testing"
"github.com/Twigpine/zero/internal/tools"
)
// PRODUCING A RESULT AND ASKING THE RUN TO STOP ARE DIFFERENT FACTS.
//
// closeOutRemaining decides between finalizing a completed sibling and writing
// an aborted placeholder, and it used to make that decision from abortErr. A
// cancelled permission request is exactly where the two facts disagree:
// executeToolCall builds the cancellation result, with its call ID, its message
// and its denial category, and returns it TOGETHER with
// ErrPermissionApprovalCanceled. Reading the error as "never started" discards a
// real result and records the opposite of what happened, and the cancellation
// then goes missing from OnToolResult, the trace counters and the task
// observation even though the permission event already fired.
func TestABatchEntryWithAResultAndAnAbortErrorIsDrained(t *testing.T) {
canceled := canceledPermissionResult(
ToolCall{ID: "call-b", Name: "read_probe"},
"cancelled in TUI",
PermissionEvent{ToolName: "read_probe"},
)
precomputed := []precomputedToolResult{
{result: ToolResult{ToolCallID: "call-a", Name: "read_probe", Status: tools.StatusOK}, completed: true},
{
result: canceled,
abortErr: fmt.Errorf("%w for read_probe", ErrPermissionApprovalCanceled),
completed: true,
},
}
sibling, ran := precomputedResultFor(precomputed, 0, 2, 1)
if !ran {
t.Fatal("a cancelled permission result was treated as a call that never started")
}
if sibling.ToolCallID != "call-b" {
t.Errorf("ToolCallID = %q, want the cancellation result rather than an empty placeholder", sibling.ToolCallID)
}
if sibling.DenialReason != DenialApprovalCanceled {
t.Errorf("DenialReason = %q, want the cancellation preserved", sibling.DenialReason)
}
}
// An entry that never produced a result is still unstarted, or the fix would
// have stopped aborting anything.
func TestABatchEntryWithNoResultIsStillUnstarted(t *testing.T) {
precomputed := []precomputedToolResult{
{abortErr: errors.New("cancelled before the tool ran")},
}
if _, ran := precomputedResultFor(precomputed, 0, 1, 0); ran {
t.Error("an entry that produced nothing was finalized as if it had run")
}
}
// A call outside the batch window never started either.
func TestACallOutsideTheBatchWindowIsUnstarted(t *testing.T) {
precomputed := []precomputedToolResult{{result: ToolResult{ToolCallID: "call-a"}, completed: true}}
if _, ran := precomputedResultFor(precomputed, 0, 1, 1); ran {
t.Error("a call past the batch window was reported as executed")
}
}
// And the flag is recorded where the truth is known rather than inferred later,
// so a real batch marks what it ran.
func TestTheBatchRecordsWhatItRan(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(&readAheadTool{})
calls := []ToolCall{
{ID: "call-a", Name: "read_probe", Arguments: `{"id":"a"}`},
{ID: "call-b", Name: "read_probe", Arguments: `{"id":"b"}`},
}
results := executeParallelReadBatch(context.Background(), registry, calls, 0, 2, PermissionModeAuto, Options{Registry: registry})
for index, entry := range results {
if !entry.completed {
t.Errorf("entry %d executed but was not recorded as completed: %#v", index, entry)
}
if entry.result.ToolCallID != calls[index].ID {
t.Errorf("entry %d is keyed to %q, want %q", index, entry.result.ToolCallID, calls[index].ID)
}
}
}
+176
View File
@@ -0,0 +1,176 @@
package agent
import (
"context"
"strconv"
"strings"
"testing"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/zeroruntime"
)
// legacyDenialSignaturePrefix is the string provenance used to be encoded in.
// The identity was one namespace holding both a normalized error signature and
// a synthetic "denial:<category>" key, and provenance was recovered afterwards
// with strings.HasPrefix. It survives here, and only here, so the regression
// names the exact spelling that used to collide.
const legacyDenialSignaturePrefix = "denial:"
func runUntilGuardHalt(t *testing.T, gate bool) Result {
t.Helper()
tool := &uncategorizedSandboxTool{}
registry := tools.NewRegistry()
registry.Register(tool)
turns := make([][]zeroruntime.StreamEvent, 0, toolFailureAnyErrorStopAt+4)
for i := range toolFailureAnyErrorStopAt + 4 {
turns = append(turns, toolTurn("c"+strconv.Itoa(i), "write_file", `{"path":"/o-`+strconv.Itoa(i)+`.txt"}`))
}
result, err := Run(context.Background(), "write", &mockProvider{turns: turns}, Options{
Registry: registry,
PermissionMode: PermissionModeAsk,
MaxTurns: len(turns) + 5,
RequireCompletionSignal: gate,
})
if err != nil {
t.Fatal(err)
}
if !strings.HasPrefix(result.FinalAnswer, "Agent stopped:") {
t.Fatalf("the guard did not halt this run: %q", result.FinalAnswer)
}
return result
}
// A GUARD HALT IS UNFINISHED WORK, AND HEADLESS HAS TO HEAR ABOUT IT.
//
// The halt returns straight out of the tool loop, so it never reached the
// completion gate the max-turns paths go through. zero exec treats only
// Result.Incomplete as exit 4 and otherwise reports run_end success with exit
// 0, so a task denied six times, or failed through the twelve-call bound, came
// back as a successful automation result having done none of the work asked.
func TestGuardHaltIsIncompleteUnderTheCompletionGate(t *testing.T) {
headless := runUntilGuardHalt(t, true)
if !headless.Incomplete {
t.Error("a guard halt reports the run as complete; zero exec would call a run that did nothing a success")
}
if !strings.Contains(headless.IncompleteReason, "write_file") {
t.Errorf("the incomplete reason does not name the tool that halted the run: %q", headless.IncompleteReason)
}
// And the interactive default is untouched: Incomplete exists for the
// headless gate, so setting it without one would be inventing a status.
if interactive := runUntilGuardHalt(t, false); interactive.Incomplete {
t.Error("Incomplete was set without RequireCompletionSignal, changing interactive behaviour")
}
}
// executedDenialLookalikeTool runs, fails, and prints exactly the string the
// guard used to store for a real permission refusal.
type executedDenialLookalikeTool struct{ ran int }
func (t *executedDenialLookalikeTool) Name() string { return "bash" }
func (t *executedDenialLookalikeTool) Description() string { return "probe" }
func (t *executedDenialLookalikeTool) Parameters() tools.Schema { return tools.Schema{Type: "object"} }
func (t *executedDenialLookalikeTool) Safety() tools.Safety {
return tools.Safety{SideEffect: tools.SideEffectShell, Permission: tools.PermissionAllow}
}
func (t *executedDenialLookalikeTool) Run(context.Context, map[string]any) tools.Result {
t.ran++
return tools.Result{Status: tools.StatusError, Output: legacyDenialSignaturePrefix + string(DenialPermissionDenied)}
}
// TRUSTED PROVENANCE AND UNTRUSTED CONTENT MUST NOT SHARE A NAMESPACE.
//
// While the identity was a single string, a command that printed exactly
// "denial:permission_denied" and exited non-zero took on the identity of a real
// permission refusal: it merged streaks with one, and the run told the user the
// tool had been refused when it had executed every time.
func TestAnExecutedErrorCannotImpersonateARefusal(t *testing.T) {
tool := &executedDenialLookalikeTool{}
registry := tools.NewRegistry()
registry.Register(tool)
turns := make([][]zeroruntime.StreamEvent, 0, toolFailureStopAt+4)
for i := range toolFailureStopAt + 4 {
turns = append(turns, toolTurn("c"+strconv.Itoa(i), "bash", `{}`))
}
result, err := Run(context.Background(), "run it", &mockProvider{turns: turns}, Options{
Registry: registry, PermissionMode: PermissionModeAsk, MaxTurns: len(turns) + 5,
})
if err != nil {
t.Fatal(err)
}
if tool.ran == 0 {
t.Fatal("the tool never ran, so this no longer covers an executed failure")
}
if strings.Contains(result.FinalAnswer, "was refused") {
t.Errorf("the tool executed %d times and nothing refused it, but the run says it was refused: %q", tool.ran, result.FinalAnswer)
}
if !strings.Contains(result.FinalAnswer, "with the same error") {
t.Errorf("an executed failure repeating identically should read as the same error: %q", result.FinalAnswer)
}
}
// AND THE AGGREGATE IS ITS OWN FACT.
//
// The content-blind bound fires precisely when no single identity repeated
// enough, so the identity present at the end says nothing about the eleven
// before it. Alternating two refusal categories reaches twelve without either
// reaching six; the tool never ran once, and the answer used to describe that
// as the tool failing with varying errors.
func TestTheContentBlindBoundKeepsRefusalProvenance(t *testing.T) {
for _, testCase := range []struct {
name string
kinds []DenialCategory
wantCause toolFailureCause
wantAnswer string
}{
{
name: "alternating refusal categories",
kinds: []DenialCategory{DenialPermissionDenied, DenialSandboxBlock},
wantCause: toolFailureCauseVariedRefused,
wantAnswer: "was refused",
},
{
// Mixed is DECIDED, not inherited from whichever field a switch
// tested first. Neither "failed" alone nor "refused" alone is true of
// this sequence, so the wording says both.
name: "executed failures mixed with refusals",
kinds: []DenialCategory{DenialNone, DenialPermissionDenied},
wantCause: toolFailureCauseVariedMixed,
wantAnswer: "failed or was refused",
},
{
name: "executed failures only",
kinds: []DenialCategory{DenialNone, DenialNone},
wantCause: toolFailureCauseVariedExecuted,
wantAnswer: "with varying errors",
},
} {
t.Run(testCase.name, func(t *testing.T) {
state := newGuardState()
var outcome toolFailureOutcome
for index := 0; index < toolFailureAnyErrorStopAt; index++ {
// Distinct prose every call, exactly as a real refusal or error
// naming what it touched would produce.
outcome = state.observeToolResult("bash", true, false,
"failure on item "+strconv.Itoa(index), testCase.kinds[index%len(testCase.kinds)])
if outcome.Stop {
break
}
}
if !outcome.Stop {
t.Fatal("twelve consecutive failures did not trip the content-blind bound")
}
if outcome.Count != toolFailureAnyErrorStopAt {
t.Errorf("Count = %d, want the counter that tripped (%d)", outcome.Count, toolFailureAnyErrorStopAt)
}
if outcome.Cause != testCase.wantCause {
t.Errorf("Cause = %v, want %v", outcome.Cause, testCase.wantCause)
}
answer := toolFailureStopAnswer("bash", outcome.Count, outcome.Cause)
if !strings.Contains(answer, testCase.wantAnswer) {
t.Errorf("stop answer = %q, want it to contain %q", answer, testCase.wantAnswer)
}
})
}
}
@@ -0,0 +1,103 @@
package agent
import (
"context"
"path/filepath"
"strconv"
"strings"
"testing"
"github.com/Twigpine/zero/internal/sandbox"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/zeroruntime"
)
// deniedWriteTool records every execution. Its body must never run: the sandbox
// refuses the call at the registry boundary, before the tool is reached.
type deniedWriteTool struct{ ran int }
func (t *deniedWriteTool) Name() string { return "write_file" }
func (t *deniedWriteTool) Description() string { return "test write tool" }
func (t *deniedWriteTool) Parameters() tools.Schema {
return tools.Schema{
Type: "object",
Properties: map[string]tools.PropertySchema{"path": {Type: "string"}},
Required: []string{"path"},
}
}
func (t *deniedWriteTool) Safety() tools.Safety {
return tools.Safety{SideEffect: tools.SideEffectWrite, Permission: tools.PermissionAllow, Reason: "writes files"}
}
func (t *deniedWriteTool) Run(context.Context, map[string]any) tools.Result {
t.ran++
return tools.Result{Status: tools.StatusOK, Output: "wrote"}
}
// THROUGH THE REAL SANDBOX, NOT A FAKE STANDING WHERE IT WOULD BE.
//
// The neighbouring uncategorized test deliberately hand-builds the shape a
// refusal has when no category survives, which is a fact about the LOOP. This
// one covers the producer: a real sandbox.Engine evaluation denies the call,
// the registry turns that into a categorized refusal, and the guard keys on the
// category. Without it, reverting refusalResult(..., PolicyRefusalSandboxDenied)
// in registry.go to a plain errorResult left both the agent and tools suites
// green, so nothing depended on the marker at all.
//
// Every assertion here fails if a different link breaks: ran proves the body was
// skipped, the request count proves the CATEGORY bound halted it rather than the
// content-blind one, and the wording proves the provenance survived conversion.
func TestRunHaltsARealSandboxDenialOnTheCategoryBound(t *testing.T) {
workspace := t.TempDir()
forbidden := t.TempDir()
policy := sandbox.DefaultPolicy()
// Applies whenever the sandbox is enforcing, so this does not depend on the
// workspace boundary being reachable from a synthetic request.
policy.DenyWrite = []string{forbidden}
tool := &deniedWriteTool{}
registry := tools.NewRegistry()
registry.Register(tool)
// A different path every call. Note that errorSignature truncates at 80
// characters and the message opens with a fixed 40-character prefix followed
// by a long temp root, so the signatures collide here anyway: the request
// count below pins WHICH bound halted the run, but it does not by itself
// prove the streak was held by the category. The wording assertion is the one
// that discriminates, and it is the one that fails when the marker is gone.
turns := make([][]zeroruntime.StreamEvent, 0, toolFailureStopAt+4)
for i := range toolFailureStopAt + 4 {
target := filepath.Join(forbidden, "escape-"+strconv.Itoa(i)+".txt")
turns = append(turns, toolTurn("c"+strconv.Itoa(i), "write_file", `{"path":`+strconv.Quote(target)+`}`))
}
provider := &mockProvider{turns: turns}
result, err := Run(context.Background(), "write the files", provider, Options{
Registry: registry,
PermissionMode: PermissionModeAsk,
MaxTurns: len(turns) + 5,
Cwd: workspace,
Sandbox: sandbox.NewEngine(sandbox.EngineOptions{
WorkspaceRoot: workspace,
Policy: policy,
}),
})
if err != nil {
t.Fatal(err)
}
if tool.ran != 0 {
t.Errorf("the tool body ran %d times; the sandbox must refuse before execution", tool.ran)
}
if len(provider.requests) != toolFailureStopAt {
t.Errorf("the run made %d turns, want the category bound at %d; a categorized refusal whose prose varies must still halt on its category",
len(provider.requests), toolFailureStopAt)
}
if want := toolFailureStopAnswer("write_file", toolFailureStopAt, toolFailureCauseSameRefusal); result.FinalAnswer != want {
t.Errorf("final answer =\n %q\nwant\n %q", result.FinalAnswer, want)
}
if !strings.Contains(result.FinalAnswer, "was refused") {
t.Errorf("the refusal provenance did not survive to the answer: %q", result.FinalAnswer)
}
}
@@ -0,0 +1,73 @@
package agent
import (
"strings"
"testing"
)
// THE ANSWER MUST CLAIM ONLY WHAT THE COUNTER ESTABLISHED.
//
// anyErrorCount establishes that the tool failed consecutively without a
// success. It does NOT establish that every failure differed: five A, five B and
// two C reach the content-blind bound of 12 without any signature repeating six
// times, and three of those errors were shared. The old wording called that
// "each with a different error".
//
// The signature bound has the inverse problem. A denial streak keys on the
// CATEGORY, and the prose behind it embeds the path or command refused, so it
// differs on every call. Calling that "the same error" is false in the other
// direction.
func TestStopAnswerDoesNotOverclaimTheFailurePattern(t *testing.T) {
varied := toolFailureStopAnswer("bash", 12, toolFailureCauseVariedExecuted)
if strings.Contains(varied, "each with a different error") {
t.Errorf("the content-blind bound does not track that every failure differed: %q", varied)
}
if !strings.Contains(varied, "varying errors") {
t.Errorf("the varied stop should still say the errors varied: %q", varied)
}
refused := toolFailureStopAnswer("bash", 6, toolFailureCauseSameRefusal)
if strings.Contains(refused, "same error") {
t.Errorf("a denial streak covers refusals of different paths, so it is not the same error: %q", refused)
}
if !strings.Contains(refused, "refused") {
t.Errorf("a denial streak should say it was refused: %q", refused)
}
// The one claim that IS justified: a signature streak really did repeat the
// same error signature, so that wording stays.
same := toolFailureStopAnswer("bash", 6, toolFailureCauseSameError)
if !strings.Contains(same, "same error") {
t.Errorf("a signature streak may still be described as the same error: %q", same)
}
}
// The mixed-signature case jatmn asked for, driven through the real counter
// rather than asserted about the wording in isolation: a run whose errors vary
// must trip the content-blind bound and must not be described as all-different.
func TestMixedSignatureStreakTripsTheContentBlindBound(t *testing.T) {
var state guardState
var outcome toolFailureOutcome
// Five of one error, five of another, two of a third: twelve failures, no
// signature repeating six times in a row.
for index, output := range []string{
"Error: A", "Error: A", "Error: A", "Error: A", "Error: A",
"Error: B", "Error: B", "Error: B", "Error: B", "Error: B",
"Error: C", "Error: C",
} {
outcome = state.observeToolResult("bash", true, true, output, DenialNone)
if outcome.Stop && index < 11 {
t.Fatalf("halted early at failure %d", index+1)
}
}
if !outcome.Stop {
t.Fatal("twelve consecutive failures did not trip the content-blind bound")
}
if outcome.Cause != toolFailureCauseVariedExecuted {
t.Errorf("a mixed-signature streak of executed failures should report varied-executed, got %v", outcome.Cause)
}
answer := toolFailureStopAnswer("bash", outcome.Count, outcome.Cause)
if strings.Contains(answer, "each with a different error") {
t.Errorf("three of these errors were shared, so they were not each different: %q", answer)
}
}
+45
View File
@@ -1,14 +1,20 @@
package agent
import (
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"github.com/Twigpine/zero/internal/config"
"github.com/Twigpine/zero/internal/sandbox"
"github.com/Twigpine/zero/internal/testutil"
)
// isolatedUserDirsRoot is where TestMain points every per-user directory.
var isolatedUserDirsRoot string
// TestMain points userConfigDirForPrompt at an empty, package-wide temp
// directory for every test in this package. Without this, buildSystemPrompt
// falls back to the real config.UserConfigDir, so any developer with a
@@ -17,17 +23,56 @@ import (
// across contributor machines. Tests that specifically exercise user
// guidelines stub userConfigDirForPrompt themselves via
// withSystemPromptTestUserConfigDir(Func) and restore this default on cleanup.
//
// It also points every per-user directory root at the same temp directory:
// sandboxed tool runs keep their runtime state and lease files under the user
// cache dir, so without this they land in the developer's real cache.
func TestMain(m *testing.M) {
dir, err := os.MkdirTemp("", "zero-agent-tests-*")
if err != nil {
panic(err)
}
isolatedUserDirsRoot = dir
restore, err := testutil.IsolateUserDirs(dir)
if err != nil {
os.RemoveAll(dir)
panic(err)
}
userConfigDirForPrompt = func() (string, error) { return dir, nil }
code := m.Run()
if err := restore(); err != nil {
fmt.Fprintln(os.Stderr, "agent tests: restore user dirs:", err)
code = 1
}
os.RemoveAll(dir)
os.Exit(code)
}
// Guards TestMain: the per-user directories the package can read or write
// must resolve inside the isolated root on every OS.
func TestUserDirsAreIsolatedFromTheDeveloperHome(t *testing.T) {
if isolatedUserDirsRoot == "" {
t.Fatal("TestMain did not record an isolated root")
}
home, err := os.UserHomeDir()
if err != nil {
t.Fatal(err)
}
userConfig, err := config.UserConfigDir()
if err != nil {
t.Fatal(err)
}
cache, err := os.UserCacheDir()
if err != nil {
t.Fatal(err)
}
for name, dir := range map[string]string{"home": home, "config": userConfig, "cache": cache} {
if !testutil.PathWithin(isolatedUserDirsRoot, dir) {
t.Errorf("%s dir %q is outside the isolated root %q", name, dir, isolatedUserDirsRoot)
}
}
}
func TestCoreSystemPromptIncludesCodingQualityRules(t *testing.T) {
prompt := strings.Join(strings.Fields(strings.ToLower(buildSystemPrompt(Options{}))), " ")
if strings.Contains(prompt, "clickable file paths") {
+57
View File
@@ -0,0 +1,57 @@
package aimlapi
import (
"net/http"
"net/url"
"strings"
)
const (
// SourceHeaderName and SourceValue identify Zero as the calling agent on
// requests to aimlapi.com services (the Toolbox and the MCP server).
SourceHeaderName = "X-AIMLAPI-Source"
SourceValue = "agent/zero"
// CanonicalInferenceBaseURL is the catalog inference endpoint. A provider
// profile whose base URL differs is treated as a proxy: its key is never sent
// to other aimlapi.com services.
CanonicalInferenceBaseURL = "https://api.aimlapi.com/v1"
// ToolboxBaseURL is the fixed AI/ML API Toolbox REST base. It is not
// configurable, so the key and attribution can only reach tools.aimlapi.com.
ToolboxBaseURL = "https://tools.aimlapi.com/v1/tools"
// MCPServerURL is the AI/ML API MCP server (streamable HTTP).
MCPServerURL = "https://mcp.aimlapi.com/mcp"
)
// attributedHosts are the only hostnames that receive the AI/ML API key or the
// attribution headers. Matching is exact; a suffix or substring match would
// also accept hosts such as tools.aimlapi.com.example.net.
var attributedHosts = [...]string{"api.aimlapi.com", "tools.aimlapi.com", "mcp.aimlapi.com"}
// IsAttributedURL reports whether target is an https URL on one of the
// aimlapi.com service hosts, on the default port and without userinfo.
func IsAttributedURL(target *url.URL) bool {
if target == nil || !strings.EqualFold(target.Scheme, "https") || target.User != nil {
return false
}
if port := target.Port(); port != "" && port != "443" {
return false
}
host := strings.ToLower(target.Hostname())
for _, candidate := range attributedHosts {
if host == candidate {
return true
}
}
return false
}
// ApplyAttributionHeaders sets the source and partner headers on header only
// when target is an aimlapi.com service URL; any other target is left as is.
func ApplyAttributionHeaders(header http.Header, target *url.URL) {
if header == nil || !IsAttributedURL(target) {
return
}
header.Set(SourceHeaderName, SourceValue)
header.Set(PartnerHeaderName, ResolvePartnerID(""))
}
+54
View File
@@ -0,0 +1,54 @@
package aimlapi
import (
"net/http"
"net/url"
"testing"
)
func TestIsAttributedURLMatchesExactServiceHostsOnly(t *testing.T) {
cases := map[string]bool{
"https://tools.aimlapi.com/v1/tools/run": true,
"https://mcp.aimlapi.com/mcp": true,
"https://api.aimlapi.com/v1": true,
"https://TOOLS.aimlapi.com:443/v1/tools": true,
"http://tools.aimlapi.com/v1/tools/run": false,
"https://tools.aimlapi.com:8443/v1/tools": false,
"https://[email protected]/v1/tools": false,
"https://tools.aimlapi.com.example.net/run": false,
"https://eviltools.aimlapi.com/run": false,
"https://aimlapi.com/app": false,
"https://auth.aimlapi.com/": false,
"https://example.com/?h=tools.aimlapi.com": false,
}
for raw, want := range cases {
target, err := url.Parse(raw)
if err != nil {
t.Fatalf("parse %q: %v", raw, err)
}
if got := IsAttributedURL(target); got != want {
t.Errorf("IsAttributedURL(%q) = %v, want %v", raw, got, want)
}
}
if IsAttributedURL(nil) {
t.Fatal("IsAttributedURL(nil) = true")
}
}
func TestApplyAttributionHeadersSetsSourceAndPartnerOnServiceHosts(t *testing.T) {
t.Setenv("AIMLAPI_PARTNER_ID", "")
header := http.Header{}
ApplyAttributionHeaders(header, &url.URL{Scheme: "https", Host: "tools.aimlapi.com", Path: "/v1/tools/run"})
if got := header.Get(SourceHeaderName); got != "agent/zero" {
t.Fatalf("%s = %q, want agent/zero", SourceHeaderName, got)
}
if got := header.Get(PartnerHeaderName); got != DefaultPartnerID {
t.Fatalf("%s = %q, want %q", PartnerHeaderName, got, DefaultPartnerID)
}
other := http.Header{}
ApplyAttributionHeaders(other, &url.URL{Scheme: "https", Host: "tools.aimlapi.com.example.net"})
if len(other) != 0 {
t.Fatalf("attribution reached a non-aimlapi host: %#v", other)
}
}
+25 -2
View File
@@ -964,6 +964,7 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
// report can be combined with the plugin activation's, and emit at most one
// notice when project hooks/plugins were dropped for an untrusted workspace.
hookDispatcher, hookSkip := newHookDispatcherWithExtra(workspaceRoot, pluginActivation.hooks, trustRoot, executionRunner)
specialistRuntime.attachLifecycleHooks(hookDispatcher)
emitTrustNotice(stderr, hookSkip, pluginActivation.trustSkip, mcpSkip)
// From the engine the session's commands run through, so the notice
// describes the sandbox they actually get.
@@ -1221,6 +1222,9 @@ type agentToolRuntime struct {
// the orchestrator's system-prompt delegation section. Populated alongside the
// tools, so it is present exactly when the Task tool is.
specialists []agent.SpecialistInfo
// lifecycleHooks is shared with the specialist executor so the hooks
// dispatcher (built later in startup) can be attached before any specialist runs.
lifecycleHooks *specialist.LifecycleHooks
}
// specialistInfos returns the runtime's specialist summaries, nil-safe so the
@@ -1232,12 +1236,26 @@ func (r *agentToolRuntime) specialistInfos() []agent.SpecialistInfo {
return r.specialists
}
func (r *agentToolRuntime) attachLifecycleHooks(dispatcher *hooks.Dispatcher) {
if r == nil || r.lifecycleHooks == nil || dispatcher == nil {
return
}
r.lifecycleHooks.Dispatch = func(ctx context.Context, event string, specialistName string, payload map[string]any) {
dispatcher.Dispatch(ctx, hooks.DispatchInput{
Event: hooks.Event(event),
ToolName: specialistName,
Payload: payload,
})
}
}
func registerSpecialistTools(registry *tools.Registry, workspaceRoot string, maxTeamSize int) (*agentToolRuntime, error) {
paths, err := specialist.DefaultPaths(workspaceRoot)
if err != nil {
return nil, err
}
executor := specialist.Executor{Paths: paths}
lifecycleHooks := &specialist.LifecycleHooks{}
executor := specialist.Executor{Paths: paths, LifecycleHooks: lifecycleHooks}
runtime, err := specialist.RegisterTools(registry, executor)
if err != nil {
return nil, err
@@ -1256,7 +1274,12 @@ func registerSpecialistTools(registry *tools.Registry, workspaceRoot string, max
return nil, err
}
swarm.RegisterTools(registry, sw)
return &agentToolRuntime{specialist: runtime, swarm: sw, specialists: specialistSummaries(paths)}, nil
return &agentToolRuntime{
specialist: runtime,
swarm: sw,
specialists: specialistSummaries(paths),
lifecycleHooks: lifecycleHooks,
}, nil
}
// specialistSummaries loads the available specialists (built-ins + user/project
+13 -1
View File
@@ -92,13 +92,25 @@ func runSkillAdd(args []string, dir string, stdout io.Writer, stderr io.Writer)
lines := []string{}
if result.Updated {
lines = append(lines, fmt.Sprintf("Updated skill %q.", result.Name))
lines = append(lines, fmt.Sprintf(" hash: %s -> %s", shortHash(result.PreviousHash), shortHash(result.Hash)))
switch {
case result.PreviousHash != result.Hash:
lines = append(lines, fmt.Sprintf(" hash: %s -> %s", shortHash(result.PreviousHash), shortHash(result.Hash)))
case result.PreviousTreeHash == "":
// The install this replaced predates tree hashes and copied SKILL.md
// alone, so the bundled files are new rather than changed.
lines = append(lines, " SKILL.md is unchanged; the files bundled with it are now installed too")
default:
lines = append(lines, " SKILL.md is unchanged; the files bundled with it changed")
}
} else {
lines = append(lines, fmt.Sprintf("Installed skill %q.", result.Name))
lines = append(lines, " hash: "+result.Hash)
}
lines = append(lines, " source: "+result.Source)
lines = append(lines, " path: "+result.Path)
if len(result.SkippedLinks) > 0 {
lines = append(lines, " not copied (symlinks): "+strings.Join(result.SkippedLinks, ", "))
}
if _, err := fmt.Fprintln(stdout, redaction.RedactString(strings.Join(lines, "\n"), redaction.Options{})); err != nil {
return exitCrash
}
+132
View File
@@ -7,6 +7,8 @@ import (
"path/filepath"
"strings"
"testing"
"github.com/Twigpine/zero/internal/skills"
)
func writeSourceSkillDir(t *testing.T, dir string, content string) string {
@@ -391,3 +393,133 @@ func TestRunDistributionCommandsDoNotLaunchTUI(t *testing.T) {
}
}
}
// A reinstall that only changed a bundled script must still read as an update
// (#584): the SKILL.md hash is the same, so "hash: X -> X" would say nothing.
func TestRunSkillAddReportsAnUpdateToBundledFilesOnly(t *testing.T) {
skillsDir := t.TempDir()
src := writeSourceSkillDir(t, filepath.Join(t.TempDir(), "src"),
"---\nname: pdf\ndescription: Fill forms.\n---\nRun scripts/fill.py.\n")
script := filepath.Join(src, "scripts", "fill.py")
if err := os.MkdirAll(filepath.Dir(script), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(script, []byte("print('v1')\n"), 0o755); err != nil {
t.Fatal(err)
}
deps := appDeps{skillsDir: func() string { return skillsDir }}
var stdout, stderr bytes.Buffer
if exit := runWithDeps([]string{"skill", "add", src}, &stdout, &stderr, deps); exit != 0 {
t.Fatalf("first skill add exit = %d, stderr = %s", exit, stderr.String())
}
if err := os.WriteFile(script, []byte("print('v2')\n"), 0o755); err != nil {
t.Fatal(err)
}
stdout.Reset()
stderr.Reset()
if exit := runWithDeps([]string{"skill", "add", src}, &stdout, &stderr, deps); exit != 0 {
t.Fatalf("second skill add exit = %d, stderr = %s", exit, stderr.String())
}
for _, want := range []string{`Updated skill "pdf".`, "SKILL.md is unchanged; the files bundled with it changed"} {
if !strings.Contains(stdout.String(), want) {
t.Fatalf("second add output does not say %q:\n%s", want, stdout.String())
}
}
}
// An install from before tree hashes copied SKILL.md alone and recorded no tree
// hash. Adding the skill again over that entry must not claim bundled files
// changed: there are none, or they are installed for the first time.
func TestRunSkillAddOverAnEntryWithoutATreeHash(t *testing.T) {
for _, tc := range []struct {
name string
script bool
want []string
notWant []string
}{
{
name: "SKILL.md only",
want: []string{`Installed skill "pdf".`},
notWant: []string{"Updated skill", "files bundled with it"},
},
{
name: "with a bundled script",
script: true,
want: []string{`Updated skill "pdf".`, "SKILL.md is unchanged; the files bundled with it are now installed too"},
notWant: []string{"files bundled with it changed"},
},
} {
t.Run(tc.name, func(t *testing.T) {
skillsDir := t.TempDir()
src := writeSourceSkillDir(t, filepath.Join(t.TempDir(), "src"),
"---\nname: pdf\ndescription: Fill forms.\n---\nRun scripts/fill.py.\n")
if tc.script {
script := filepath.Join(src, "scripts", "fill.py")
if err := os.MkdirAll(filepath.Dir(script), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(script, []byte("print('fill')\n"), 0o755); err != nil {
t.Fatal(err)
}
}
deps := appDeps{skillsDir: func() string { return skillsDir }}
var stdout, stderr bytes.Buffer
if exit := runWithDeps([]string{"skill", "add", src}, &stdout, &stderr, deps); exit != 0 {
t.Fatalf("first skill add exit = %d, stderr = %s", exit, stderr.String())
}
lock, err := skills.ReadLock(skillsDir)
if err != nil {
t.Fatal(err)
}
entry := lock["pdf"]
entry.TreeHash = ""
lock["pdf"] = entry
data, err := json.Marshal(lock)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(skillsDir, skills.LockFileName), data, 0o644); err != nil {
t.Fatal(err)
}
stdout.Reset()
stderr.Reset()
if exit := runWithDeps([]string{"skill", "add", src}, &stdout, &stderr, deps); exit != 0 {
t.Fatalf("second skill add exit = %d, stderr = %s", exit, stderr.String())
}
for _, want := range tc.want {
if !strings.Contains(stdout.String(), want) {
t.Fatalf("second add output does not say %q:\n%s", want, stdout.String())
}
}
for _, notWant := range tc.notWant {
if strings.Contains(stdout.String(), notWant) {
t.Fatalf("second add output says %q:\n%s", notWant, stdout.String())
}
}
})
}
}
func TestRunSkillAddListsTheSymlinksItDidNotCopy(t *testing.T) {
skillsDir := t.TempDir()
src := writeSourceSkillDir(t, filepath.Join(t.TempDir(), "src"),
"---\nname: pdf\ndescription: Fill forms.\n---\nbody\n")
outside := filepath.Join(t.TempDir(), "notes.md")
if err := os.WriteFile(outside, []byte("outside\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Symlink(outside, filepath.Join(src, "notes.md")); err != nil {
t.Skipf("symlinks unavailable: %v", err)
}
var stdout, stderr bytes.Buffer
deps := appDeps{skillsDir: func() string { return skillsDir }}
if exit := runWithDeps([]string{"skill", "add", src}, &stdout, &stderr, deps); exit != 0 {
t.Fatalf("skill add exit = %d, stderr = %s", exit, stderr.String())
}
if !strings.Contains(stdout.String(), "not copied (symlinks): notes.md") {
t.Fatalf("add output does not list the skipped symlink:\n%s", stdout.String())
}
}
+2 -1
View File
@@ -655,6 +655,7 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
// can be combined with the plugin activation's, and emit at most one notice when
// project hooks/plugins were dropped for an untrusted workspace.
hookDispatcher, hookSkip := newHookDispatcherWithExtra(workspaceRoot, pluginActivation.hooks, trustRoot, executionRunner)
specialistRuntime.attachLifecycleHooks(hookDispatcher)
emitTrustNotice(stderr, hookSkip, pluginActivation.trustSkip, mcpSkip)
result, err := agent.Run(runCtx, agentPrompt, provider, agent.Options{
MaxTurns: resolved.MaxTurns,
@@ -712,7 +713,7 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
"arguments": call.Arguments,
})
// Snapshot before-state of files this call will mutate (safe rewind).
if checkpoint, ok := sessionRecorder.captureCheckpoint(workspaceRoot, call); ok {
if checkpoint, ok := sessionRecorder.captureCheckpoint(workspaceRoot, registry, call); ok {
writer.checkpoint(checkpoint)
}
},
@@ -0,0 +1,206 @@
package cli
import (
"bytes"
"context"
"encoding/json"
"os"
"path/filepath"
"testing"
"github.com/Twigpine/zero/internal/agent"
"github.com/Twigpine/zero/internal/config"
"github.com/Twigpine/zero/internal/sessions"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/zeroruntime"
)
func freeformApplyPatchArguments(root string, absolute bool) string {
target := "main.txt"
if absolute {
target = filepath.Join(root, "main.txt")
}
return "*** Begin Patch\n*** Update File: " + target + "\n@@\n-before\n+after\n*** End Patch\n"
}
// captureCheckpoint must resolve a freeform apply_patch call through the same
// preparation execution uses, so the before-state is captured and rewind can
// restore it. The JSON control pins the unchanged path.
func TestCaptureCheckpointFreeformApplyPatch(t *testing.T) {
for _, tc := range []struct {
name string
freeform bool
absolute bool
jsonArgs bool
}{
{name: "freeform", freeform: true},
{name: "freeform absolute header", freeform: true, absolute: true},
{name: "json arguments", jsonArgs: true},
} {
t.Run(tc.name, func(t *testing.T) {
t.Setenv("XDG_DATA_HOME", t.TempDir())
t.Setenv("ZERO_CHECKPOINTS", "on")
root := t.TempDir()
// The absolute-header case needs the physical root spelling the tool
// resolves (macOS /var vs /private/var, Windows short names).
if resolved, err := filepath.EvalSymlinks(root); err == nil {
root = resolved
}
if err := os.WriteFile(filepath.Join(root, "main.txt"), []byte("before\n"), 0o644); err != nil {
t.Fatal(err)
}
store := sessions.NewStore(sessions.StoreOptions{})
session, err := store.Create(sessions.CreateInput{Title: "freeform checkpoint"})
if err != nil {
t.Fatal(err)
}
if _, err := store.AppendEvent(session.SessionID, sessions.AppendEventInput{
Type: sessions.EventMessage,
Payload: map[string]string{"role": "user", "content": "apply the patch"},
}); err != nil {
t.Fatal(err)
}
recorder := &execSessionRecorder{prepared: sessions.PreparedExec{Store: store, Session: session}}
registry := tools.NewRegistry()
registry.Register(tools.NewScopedApplyPatchTool(root, nil))
call := agent.ToolCall{ID: "call", Name: "apply_patch", Freeform: tc.freeform}
if tc.jsonArgs {
encoded, err := json.Marshal(map[string]string{"patch": freeformApplyPatchArguments(root, false)})
if err != nil {
t.Fatal(err)
}
call.Arguments = string(encoded)
} else {
call.Arguments = freeformApplyPatchArguments(root, tc.absolute)
}
event, ok := recorder.captureCheckpoint(root, registry, call)
if !ok || event.Type != sessions.EventSessionCheckpoint {
t.Fatalf("captureCheckpoint() = %#v, %t; want a checkpoint event", event, ok)
}
var payload sessions.CheckpointPayload
if err := json.Unmarshal(event.Payload, &payload); err != nil {
t.Fatalf("decode checkpoint payload: %v", err)
}
if len(payload.Files) != 1 || payload.Files[0].Path != "main.txt" || payload.Files[0].Blob == "" {
t.Fatalf("checkpoint files = %#v, want main.txt with a blob", payload.Files)
}
// Simulate the tool's mutation, then rewind past it.
if err := os.WriteFile(filepath.Join(root, "main.txt"), []byte("after\n"), 0o644); err != nil {
t.Fatal(err)
}
report, err := store.ApplyRewind(session.SessionID, root, 1)
if err != nil {
t.Fatalf("ApplyRewind: %v", err)
}
if report.FilesRestored != 1 {
t.Fatalf("restore report = %#v, want one restored file", report)
}
content, err := os.ReadFile(filepath.Join(root, "main.txt"))
if err != nil {
t.Fatal(err)
}
if string(content) != "before\n" {
t.Fatalf("restored content = %q, want before newline", content)
}
})
}
}
// freeformPatchExecProvider emits one native freeform apply_patch call, then a
// final text turn.
type freeformPatchExecProvider struct {
patch string
calls int
}
func (provider *freeformPatchExecProvider) StreamCompletion(ctx context.Context, request zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
ch := make(chan zeroruntime.StreamEvent, 5)
calls := provider.calls
provider.calls++
if calls == 0 {
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call_patch", ToolName: "apply_patch", ToolCallFreeform: true}
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call_patch", ArgumentsFragment: provider.patch}
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call_patch"}
} else {
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventText, Content: "patched"}
}
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone}
close(ch)
return ch, nil
}
// End-to-end: exec captures the freeform checkpoint and `sessions rewind`
// restores the pre-patch content.
func TestRunExecFreeformApplyPatchCheckpointsForRewind(t *testing.T) {
isolateCLIUserState(t)
t.Setenv("ZERO_CHECKPOINTS", "on")
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "main.txt"), []byte("before\n"), 0o644); err != nil {
t.Fatal(err)
}
provider := &freeformPatchExecProvider{patch: freeformApplyPatchArguments(root, false)}
var stdout, stderr bytes.Buffer
exitCode := runWithDeps([]string{
"exec", "--init-session-id", "freeform_rewind", "--skip-permissions-unsafe",
"--output-format", "stream-json", "apply the patch",
}, &stdout, &stderr, appDeps{
getwd: func() (string, error) { return root, nil },
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
return execResolvedConfig(), nil
},
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
return provider, nil
},
})
if exitCode != exitSuccess {
t.Fatalf("exit = %d, want %d; stderr=%q stdout=%q", exitCode, exitSuccess, stderr.String(), stdout.String())
}
content, err := os.ReadFile(filepath.Join(root, "main.txt"))
if err != nil {
t.Fatal(err)
}
if string(content) != "after\n" {
t.Fatalf("patched content = %q, want after newline", content)
}
store := sessions.NewStore(sessions.StoreOptions{})
events, err := store.ReadEvents("freeform_rewind")
if err != nil {
t.Fatalf("ReadEvents: %v", err)
}
checkpoints := 0
for _, event := range events {
if event.Type == sessions.EventSessionCheckpoint {
checkpoints++
}
}
if checkpoints != 1 {
t.Fatalf("checkpoint events = %d, want 1; events=%v", checkpoints, sessionEventTypes(events))
}
report, err := store.ApplyRewind("freeform_rewind", root, 1)
if err != nil {
t.Fatalf("ApplyRewind: %v", err)
}
if report.FilesRestored != 1 {
t.Fatalf("restore report = %#v, want one restored file", report)
}
content, err = os.ReadFile(filepath.Join(root, "main.txt"))
if err != nil {
t.Fatal(err)
}
if string(content) != "before\n" {
t.Fatalf("rewound content = %q, want before newline", content)
}
}
func sessionEventTypes(events []sessions.Event) []string {
types := make([]string, 0, len(events))
for _, event := range events {
types = append(types, string(event.Type))
}
return types
}
@@ -0,0 +1,90 @@
package cli
import (
"bytes"
"context"
"slices"
"strings"
"testing"
"github.com/Twigpine/zero/internal/config"
"github.com/Twigpine/zero/internal/zeroruntime"
)
// guardStopExecProvider emits turns with no visible text and no tool calls, so
// the empty-turn guardrail stops the run after its consecutive-empty-turn cap.
type guardStopExecProvider struct{}
func (guardStopExecProvider) StreamCompletion(ctx context.Context, request zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
ch := make(chan zeroruntime.StreamEvent, 1)
select {
case <-ctx.Done():
close(ch)
return ch, ctx.Err()
case ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone}:
}
close(ch)
return ch, nil
}
// A guardrail stop in headless exec must use the existing INCOMPLETE contract
// (exit 4, error event, run_end status), and --no-completion-gate must keep
// reporting success. isolateCLIUserState also turns off the models.dev refresh
// that runExec starts in the background (see the #1152/#1161 flake).
func TestRunExecGuardrailStopIncompleteContract(t *testing.T) {
for _, tc := range []struct {
name string
args []string
wantExit int
wantStatus string
}{
{"gate on", []string{"exec", "--output-format", "stream-json", "complete the task"}, exitIncomplete, "incomplete"},
{"gate off", []string{"exec", "--no-completion-gate", "--output-format", "stream-json", "complete the task"}, exitSuccess, "success"},
} {
t.Run(tc.name, func(t *testing.T) {
isolateCLIUserState(t)
cwd := t.TempDir()
var stdout, stderr bytes.Buffer
exitCode := runWithDeps(tc.args, &stdout, &stderr, appDeps{
getwd: func() (string, error) { return cwd, nil },
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
return execResolvedConfig(), nil
},
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
return guardStopExecProvider{}, nil
},
})
if exitCode != tc.wantExit {
t.Fatalf("exit code = %d, want %d; stderr=%q", exitCode, tc.wantExit, stderr.String())
}
events := decodeJSONLines(t, stdout.String())
if len(events) == 0 || events[len(events)-1]["type"] != "run_end" {
t.Fatalf("expected run_end as the last event, got %#v", events)
}
runEnd := events[len(events)-1]
if runEnd["status"] != tc.wantStatus {
t.Fatalf("run_end status = %v, want %q", runEnd["status"], tc.wantStatus)
}
if runEnd["exitCode"] != float64(tc.wantExit) {
t.Fatalf("run_end exitCode = %v, want %d", runEnd["exitCode"], tc.wantExit)
}
finalEvent := findJSONEvent(t, events, "final")
if text, _ := finalEvent["text"].(string); !strings.Contains(text, "no output") {
t.Fatalf("final text = %q, want the guard stop answer", text)
}
if tc.wantStatus == "incomplete" {
errorEvent := findJSONEvent(t, events, "error")
if errorEvent["code"] != "incomplete" {
t.Fatalf("error code = %v, want incomplete", errorEvent["code"])
}
if message, _ := errorEvent["message"].(string); !strings.Contains(message, "no output") {
t.Fatalf("error message = %q, want the guard reason", message)
}
} else if slices.Contains(jsonEventTypes(events), "error") {
t.Fatalf("gate-off run must not emit an error event: %#v", events)
}
})
}
}
+2 -7
View File
@@ -1,7 +1,6 @@
package cli
import (
"encoding/json"
"fmt"
"io"
"strings"
@@ -19,15 +18,11 @@ type execSessionRecorder struct {
// captureCheckpoint snapshots the before-state of files a tool call will mutate.
// Best-effort: failures never affect the run. Returns the checkpoint event and
// true when one was recorded.
func (recorder *execSessionRecorder) captureCheckpoint(workspaceRoot string, call agent.ToolCall) (sessions.Event, bool) {
func (recorder *execSessionRecorder) captureCheckpoint(workspaceRoot string, registry *tools.Registry, call agent.ToolCall) (sessions.Event, bool) {
if recorder.prepared.Store == nil || recorder.prepared.Session.SessionID == "" {
return sessions.Event{}, false
}
var args map[string]any
if call.Arguments != "" {
_ = json.Unmarshal([]byte(call.Arguments), &args)
}
targets := tools.MutationTargets(workspaceRoot, call.Name, args)
targets := agent.CheckpointTargets(workspaceRoot, registry, call)
if len(targets) == 0 {
return sessions.Event{}, false
}
+1 -1
View File
@@ -148,7 +148,7 @@ func runExecSpecDraft(run execSpecDraftRun) int {
"name": call.Name,
"arguments": call.Arguments,
})
if checkpoint, ok := sessionRecorder.captureCheckpoint(run.workspaceRoot, call); ok {
if checkpoint, ok := sessionRecorder.captureCheckpoint(run.workspaceRoot, run.registry, call); ok {
writer.checkpoint(checkpoint)
}
},
+116
View File
@@ -2,6 +2,7 @@ package cli
import (
"context"
"errors"
"fmt"
"io"
"strings"
@@ -82,11 +83,105 @@ func runPlugins(args []string, stdout io.Writer, stderr io.Writer, deps appDeps)
return runPluginInfo(args[1:], stdout, stderr, deps)
case "remove", "rm":
return runPluginRemove(args[1:], deps.pluginsDir(), stdout, stderr)
case "enable":
return runPluginsToggle(args[1:], stdout, stderr, deps, false)
case "disable":
return runPluginsToggle(args[1:], stdout, stderr, deps, true)
default:
return writeExecUsageError(stderr, fmt.Sprintf("unknown plugins subcommand %q", args[0]))
}
}
type pluginToggleOptions struct {
json bool
user bool
}
// runPluginsToggle implements `zero plugins enable|disable <id>`.
//
// Every message it writes, including usage errors that echo the caller's
// arguments, passes through the redaction pipeline: an argument is user input
// and can be a mistyped credential.
func runPluginsToggle(args []string, stdout io.Writer, stderr io.Writer, deps appDeps, disabled bool) int {
commandName := "enable"
if disabled {
commandName = "disable"
}
usageError := func(message string) int {
return writeExecUsageError(stderr, redaction.RedactString(message, redaction.Options{}))
}
options, positional, help, err := parsePluginToggleArgs(args, commandName)
if err != nil {
return usageError(err.Error())
}
if help {
if err := writePluginsToggleHelp(stdout, commandName); err != nil {
return exitCrash
}
return exitSuccess
}
if len(positional) != 1 {
return usageError(fmt.Sprintf("usage: zero plugins %s <id> [--user] [--json]", commandName))
}
pluginID := strings.TrimSpace(positional[0])
if pluginID == "" {
return usageError(fmt.Sprintf("usage: zero plugins %s <id> [--user] [--json]", commandName))
}
cwd, err := deps.getwd()
if err != nil {
return writeAppError(stderr, "failed to resolve workspace: "+redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
}
loadOptions := plugins.LoadOptions{Cwd: cwd, ExcludeProject: options.user}
result, err := plugins.SetEnabledByID(loadOptions, pluginID, !disabled)
if err != nil {
if errors.Is(err, plugins.ErrNotInstalled) {
return usageError(err.Error())
}
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
}
state := "enabled"
if disabled {
state = "disabled"
}
if options.json {
if err := writePrettyJSON(stdout, redaction.RedactValue(result, redaction.Options{})); err != nil {
return exitCrash
}
return exitSuccess
}
message := fmt.Sprintf("Plugin %s is now %s in %s.\n", result.ID, state, result.ManifestPath)
if !result.Changed {
message = fmt.Sprintf("Plugin %s was already %s in %s.\n", result.ID, state, result.ManifestPath)
}
if _, err := fmt.Fprint(stdout, redaction.RedactString(message, redaction.Options{})); err != nil {
return exitCrash
}
return exitSuccess
}
func parsePluginToggleArgs(args []string, command string) (pluginToggleOptions, []string, bool, error) {
options := pluginToggleOptions{}
positional := []string{}
for _, arg := range args {
switch arg {
case "-h", "--help", "help":
return options, positional, true, nil
case "--json":
options.json = true
case "--user":
options.user = true
default:
if strings.HasPrefix(arg, "-") {
return options, positional, false, execUsageError{fmt.Sprintf("unknown plugins %s flag %q", command, arg)}
}
positional = append(positional, arg)
}
}
return options, positional, false, nil
}
func runHooks(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
if len(args) == 0 {
return writeExecUsageError(stderr, "hooks subcommand required. Use `zero hooks list`.")
@@ -566,10 +661,31 @@ Commands:
info <id> Show plugin details and lockfile metadata
add <git-url|path> Install a plugin (manifest-validated, pinned in plugins.lock)
remove <id> Remove an installed plugin and its lockfile entry
enable <id> Enable a plugin via its plugin.json
disable <id> Disable a plugin via its plugin.json
`)
return err
}
func writePluginsToggleHelp(w io.Writer, command string) error {
_, err := fmt.Fprintf(w, `Usage:
zero plugins %s <id> [flags]
Flags:
--user Target only user plugins (ignore ./.zero/plugins)
--json Print command result as JSON
-h, --help Show this help
Without --user, a project plugin with the same id takes precedence over a user
plugin, matching the loader's discovery order.
Zero reads plugins when it starts, so the change applies to the next zero exec
run or interactive session. A running interactive session keeps the plugins it
started with until you restart it.
`, command)
return err
}
func writePluginsListHelp(w io.Writer) error {
_, err := fmt.Fprint(w, `Usage:
zero plugins list [flags]
+232
View File
@@ -5,6 +5,7 @@ import (
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"testing"
@@ -333,3 +334,234 @@ func TestRunMCPPermissionsHelpDoesNotOpenStore(t *testing.T) {
})
}
}
func TestRunPluginsEnableDisable(t *testing.T) {
cwd := t.TempDir()
home := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", home)
t.Setenv("HOME", home)
pluginDir := filepath.Join(cwd, ".zero", "plugins", "demo")
if err := os.MkdirAll(pluginDir, 0o700); err != nil {
t.Fatal(err)
}
manifestPath := filepath.Join(pluginDir, "plugin.json")
if err := os.WriteFile(manifestPath, []byte(`{
"schemaVersion": 1,
"id": "zero.demo",
"name": "Demo",
"version": "1.0.0"
}
`), 0o600); err != nil {
t.Fatal(err)
}
deps := appDeps{getwd: func() (string, error) { return cwd, nil }}
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"plugins", "disable", "zero.demo", "--json"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("disable exit=%d stderr=%s", exitCode, stderr.String())
}
var disablePayload plugins.SetEnabledResult
if err := json.Unmarshal(stdout.Bytes(), &disablePayload); err != nil {
t.Fatalf("decode disable JSON: %v\n%s", err, stdout.String())
}
if disablePayload.ID != "zero.demo" || disablePayload.Enabled || !disablePayload.Changed {
t.Fatalf("unexpected disable payload: %#v", disablePayload)
}
raw, err := os.ReadFile(manifestPath)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(raw), `"enabled": false`) {
t.Fatalf("manifest not disabled: %s", raw)
}
stdout.Reset()
stderr.Reset()
exitCode = runWithDeps([]string{"plugins", "disable", "zero.demo"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("noop disable exit=%d stderr=%s", exitCode, stderr.String())
}
if !strings.Contains(stdout.String(), "was already disabled") {
t.Fatalf("noop disable output: %s", stdout.String())
}
stdout.Reset()
stderr.Reset()
exitCode = runWithDeps([]string{"plugins", "enable", "zero.demo"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("enable exit=%d stderr=%s", exitCode, stderr.String())
}
if !strings.Contains(stdout.String(), "is now enabled") {
t.Fatalf("enable output: %s", stdout.String())
}
stdout.Reset()
stderr.Reset()
exitCode = runWithDeps([]string{"plugins", "enable", "missing-plugin"}, &stdout, &stderr, deps)
if exitCode != exitUsage {
t.Fatalf("missing plugin exit=%d want %d stderr=%s", exitCode, exitUsage, stderr.String())
}
}
// The text-mode toggle output runs through the redaction pipeline, so a
// manifest path that contains something secret-shaped is masked.
func TestRunPluginsToggleTextOutputIsRedacted(t *testing.T) {
cwd := t.TempDir()
home := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", home)
t.Setenv("HOME", home)
secretDir := "sk-proj-abcdefghijklmnopqrstuvwxyz"
pluginDir := filepath.Join(cwd, ".zero", "plugins", secretDir)
if err := os.MkdirAll(pluginDir, 0o700); err != nil {
t.Fatal(err)
}
manifestPath := filepath.Join(pluginDir, "plugin.json")
if err := os.WriteFile(manifestPath, []byte(`{
"schemaVersion": 1,
"id": "zero.demo",
"name": "Demo",
"version": "1.0.0"
}
`), 0o600); err != nil {
t.Fatal(err)
}
deps := appDeps{getwd: func() (string, error) { return cwd, nil }}
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"plugins", "disable", "zero.demo"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("disable exit=%d stderr=%s", exitCode, stderr.String())
}
if strings.Contains(stdout.String(), secretDir) {
t.Fatalf("toggle text leaked a secret-shaped manifest path: %q", stdout.String())
}
if !strings.Contains(stdout.String(), "[REDACTED]") {
t.Fatalf("toggle text was not redacted: %q", stdout.String())
}
stdout.Reset()
stderr.Reset()
exitCode = runWithDeps([]string{"plugins", "disable", "zero.demo"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("noop disable exit=%d stderr=%s", exitCode, stderr.String())
}
if !strings.Contains(stdout.String(), "was already disabled") {
t.Fatalf("noop toggle output: %q", stdout.String())
}
if strings.Contains(stdout.String(), secretDir) {
t.Fatalf("noop toggle text leaked a secret-shaped manifest path: %q", stdout.String())
}
}
func TestRunPluginsToggleRejectsEmptyID(t *testing.T) {
deps := appDeps{getwd: func() (string, error) { return t.TempDir(), nil }}
for _, subcommand := range []string{"enable", "disable"} {
t.Run(subcommand, func(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"plugins", subcommand, ""}, &stdout, &stderr, deps)
if exitCode != exitUsage {
t.Fatalf("exit=%d want %d stderr=%s", exitCode, exitUsage, stderr.String())
}
if !strings.Contains(stderr.String(), "usage: zero plugins "+subcommand) {
t.Fatalf("stderr = %q", stderr.String())
}
})
}
}
func TestRunPluginsDisableUserIgnoresProject(t *testing.T) {
home := t.TempDir()
cwd := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", home)
t.Setenv("HOME", home)
userManifest := filepath.Join(home, "zero", "plugins", "demo", "plugin.json")
projectManifest := filepath.Join(cwd, ".zero", "plugins", "demo", "plugin.json")
for _, path := range []string{userManifest, projectManifest} {
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(`{
"schemaVersion": 1,
"id": "zero.demo",
"name": "Demo",
"version": "1.0.0",
"enabled": true
}
`), 0o600); err != nil {
t.Fatal(err)
}
}
deps := appDeps{getwd: func() (string, error) { return cwd, nil }}
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"plugins", "disable", "zero.demo", "--user"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("disable --user exit=%d stderr=%s", exitCode, stderr.String())
}
if !strings.Contains(stdout.String(), "is now disabled") {
t.Fatalf("disable --user output: %s", stdout.String())
}
userRaw, err := os.ReadFile(userManifest)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(userRaw), `"enabled": false`) {
t.Fatalf("user manifest not disabled: %s", userRaw)
}
projectRaw, err := os.ReadFile(projectManifest)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(projectRaw), `"enabled": false`) {
t.Fatalf("project manifest should stay enabled: %s", projectRaw)
}
}
// The toggle's usage errors echo the caller's arguments, which can be a
// mistyped credential, so they pass through redaction like its other output.
func TestRunPluginsToggleUsageErrorsAreRedacted(t *testing.T) {
home := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", home)
t.Setenv("HOME", home)
deps := appDeps{getwd: func() (string, error) { return t.TempDir(), nil }}
const secret = "sk-proj-abcdefghijklmnopqrstuvwxyz"
cases := []struct {
name string
args []string
want string
}{
{"unknown flag", []string{"zero.demo", "--token=" + secret}, "unknown plugins"},
{"missing plugin", []string{secret}, "plugin is not installed"},
}
for _, subcommand := range []string{"enable", "disable"} {
for _, tc := range cases {
t.Run(subcommand+" "+tc.name, func(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
args := append([]string{"plugins", subcommand}, tc.args...)
exitCode := runWithDeps(args, &stdout, &stderr, deps)
if exitCode != exitUsage {
t.Fatalf("exit=%d want %d stderr=%s", exitCode, exitUsage, stderr.String())
}
if strings.Contains(stderr.String(), secret) {
t.Fatalf("usage error leaked the argument: %q", stderr.String())
}
if !strings.Contains(stderr.String(), tc.want) || !strings.Contains(stderr.String(), "[REDACTED]") {
t.Fatalf("stderr = %q, want a redacted %q error", stderr.String(), tc.want)
}
})
}
}
}
+1 -1
View File
@@ -319,7 +319,7 @@ Flags:
--command <cmd> Command to run (required)
--name <name> Human-readable hook name
--description <text> Hook description
--matcher <pattern> Tool matcher (beforeTool/afterTool only)
--matcher <pattern> Tool/specialist matcher (beforeTool/afterTool/specialistStart/specialistStop)
--arg <value> Command argument (repeatable)
--user Write to user config instead of the project
--json Print command result as JSON
+32 -3
View File
@@ -741,6 +741,13 @@ func (cfg *mcpWritableConfig) upsertServer(name string, server config.MCPServerC
if err != nil {
return false, err
}
if !mergedServer.Disabled && config.IsDefaultMCPServerDisabled(name) {
// Adding a server under the name of a default that ships disabled means
// the user wants it on; the omitted flag would otherwise keep it off.
if data, err = setMCPServerRawDisabledFalse(data); err != nil {
return false, err
}
}
cfg.serverRaw[name] = data
if _, err := cfg.removeLegacyServer(name); err != nil {
return false, err
@@ -800,11 +807,29 @@ func mergeMCPServerRaw(existing json.RawMessage, server config.MCPServerConfig)
if key == "oauth" && preserveRawOAuth {
continue
}
// `mcp add` replaces the entry: it writes only a disable, never a stale
// explicit false carried over from the old entry (upsertServer adds the
// false a default that ships disabled needs).
if key == "disabled" && string(value) == "false" {
continue
}
merged[key] = value
}
return json.Marshal(merged)
}
func setMCPServerRawDisabledFalse(data json.RawMessage) (json.RawMessage, error) {
var server map[string]json.RawMessage
if err := json.Unmarshal(data, &server); err != nil {
return nil, err
}
if server == nil {
server = map[string]json.RawMessage{}
}
server["disabled"] = json.RawMessage("false")
return json.Marshal(server)
}
func (cfg *mcpWritableConfig) removeServer(name string) (bool, error) {
cfg.ensureRaw()
removed := false
@@ -856,15 +881,19 @@ func (cfg *mcpWritableConfig) setServerDisabled(name string, disabled bool) (boo
server = map[string]json.RawMessage{}
}
current := false
// A default that ships disabled (aimlapi) stays off until an entry says
// otherwise, so an absent flag reads as disabled and enabling it must be
// written out explicitly: dropping the flag would leave it off.
shippedDisabled := config.IsDefaultMCPServerDisabled(name)
current := shippedDisabled
if rawDisabled, ok := server["disabled"]; ok && len(rawDisabled) > 0 && string(rawDisabled) != "null" {
if err := json.Unmarshal(rawDisabled, &current); err != nil {
return false, false, err
}
}
changed := current != disabled
if disabled {
data, err := json.Marshal(true)
if disabled || shippedDisabled {
data, err := json.Marshal(disabled)
if err != nil {
return false, false, err
}
+260
View File
@@ -2,10 +2,18 @@ package cli
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"github.com/Twigpine/zero/internal/aimlapi"
"github.com/Twigpine/zero/internal/config"
)
@@ -44,3 +52,255 @@ func TestRunMCPDisableSeededExaDefault(t *testing.T) {
t.Fatal("expected `mcp disable exa` to turn the seeded default off in the resolved config")
}
}
// aimlapiWire stands in for https://mcp.aimlapi.com/mcp at the transport layer:
// requests keep their real URL, nothing is dialed, and each one is recorded.
type aimlapiWire struct {
mu sync.Mutex
requests []*http.Request
}
func (wire *aimlapiWire) RoundTrip(request *http.Request) (*http.Response, error) {
var body []byte
if request.Body != nil {
body, _ = io.ReadAll(request.Body)
_ = request.Body.Close()
}
wire.mu.Lock()
wire.requests = append(wire.requests, request.Clone(context.Background()))
wire.mu.Unlock()
var message struct {
ID json.RawMessage `json:"id"`
Method string `json:"method"`
}
_ = json.Unmarshal(body, &message)
respond := func(status int, payload string) *http.Response {
return &http.Response{StatusCode: status, Header: http.Header{"Content-Type": {"application/json"}}, Body: io.NopCloser(strings.NewReader(payload)), Request: request}
}
if request.URL.String() != aimlapi.MCPServerURL {
return respond(http.StatusBadGateway, `{}`), nil
}
if len(message.ID) == 0 {
return respond(http.StatusAccepted, ""), nil
}
result := `{"protocolVersion":"2024-11-05","capabilities":{},"serverInfo":{"name":"fake","version":"1"}}`
if message.Method == "tools/list" {
result = `{"tools":[{"name":"tools_search","description":"Search the Toolbox","inputSchema":{"type":"object"}}]}`
}
return respond(http.StatusOK, fmt.Sprintf(`{"jsonrpc":"2.0","id":%s,"result":%s}`, message.ID, result)), nil
}
func (wire *aimlapiWire) recorded() []*http.Request {
wire.mu.Lock()
defer wire.mu.Unlock()
return append([]*http.Request(nil), wire.requests...)
}
// isolateAIMLAPIMCPCommand redirects every root the real resolve path reads to
// a temporary home, sets an AI/ML API key in the environment, and routes the
// default HTTP transport to a recorder. It returns the user config path.
func isolateAIMLAPIMCPCommand(t *testing.T, key string) (string, *aimlapiWire, appDeps) {
t.Helper()
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".state"))
t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
t.Setenv("APPDATA", filepath.Join(home, "AppData", "Roaming"))
t.Setenv("LOCALAPPDATA", filepath.Join(home, "AppData", "Local"))
t.Setenv("ZERO_CRED_STORAGE", "file")
t.Setenv("ZERO_PROVIDER", "")
t.Setenv("AIMLAPI_INFERENCE_URL", "")
t.Setenv("AIMLAPI_PARTNER_ID", "")
t.Setenv("AIMLAPI_API_KEY", key)
configPath, err := config.DefaultUserConfigPath()
if err != nil {
t.Fatal(err)
}
writeMCPCommandRawConfig(t, configPath, `{}`)
wire := &aimlapiWire{}
previous := http.DefaultTransport
http.DefaultTransport = wire
t.Cleanup(func() { http.DefaultTransport = previous })
workspace := t.TempDir()
return configPath, wire, appDeps{getwd: func() (string, error) { return workspace, nil }}
}
func TestRunMCPEnableAIMLAPIDefaultWritesOnlyTheToggle(t *testing.T) {
configPath, wire, deps := isolateAIMLAPIMCPCommand(t, "aiml-secret-key")
var out, errBuf bytes.Buffer
if code := runWithDeps([]string{"mcp", "disable", "aimlapi", "--json"}, &out, &errBuf, deps); code != exitSuccess {
t.Fatalf("disable exit=%d stderr=%s", code, errBuf.String())
}
if !strings.Contains(out.String(), `"changed": false`) {
t.Fatalf("disabling the off-by-default server should be a no-op: %s", out.String())
}
out.Reset()
errBuf.Reset()
if code := runWithDeps([]string{"mcp", "enable", "aimlapi", "--json"}, &out, &errBuf, deps); code != exitSuccess {
t.Fatalf("enable exit=%d stderr=%s", code, errBuf.String())
}
if !strings.Contains(out.String(), `"changed": true`) {
t.Fatalf("enable should report a change: %s", out.String())
}
data, err := os.ReadFile(configPath)
if err != nil {
t.Fatal(err)
}
var written struct {
MCP struct {
Servers map[string]map[string]any `json:"servers"`
} `json:"mcp"`
}
if err := json.Unmarshal(data, &written); err != nil {
t.Fatal(err)
}
if entry := written.MCP.Servers["aimlapi"]; len(entry) != 1 || entry["disabled"] != false {
t.Fatalf("enable must write only the toggle, got %#v in %s", entry, data)
}
if strings.Contains(string(data), "aiml-secret-key") || strings.Contains(string(data), "Authorization") {
t.Fatalf("config holds credential material: %s", data)
}
cfg, err := config.ResolveMCP(config.ResolveOptions{UserConfigPath: configPath})
if err != nil {
t.Fatal(err)
}
if server := cfg.Servers["aimlapi"]; server.Disabled || config.IsUnconfiguredDefault("aimlapi", server) {
t.Fatalf("enabled aimlapi = %#v, want enabled and user-configured", server)
}
// An unrelated save rewrites the whole file through FileConfig; the
// explicit enable has to survive it or the shipped disable comes back.
if _, err := config.SetTheme(configPath, "dark"); err != nil {
t.Fatal(err)
}
cfg, err = config.ResolveMCP(config.ResolveOptions{UserConfigPath: configPath})
if err != nil {
t.Fatal(err)
}
if cfg.Servers["aimlapi"].Disabled {
data, _ := os.ReadFile(configPath)
t.Fatalf("an unrelated config save turned aimlapi off again: %s", data)
}
if len(wire.recorded()) != 0 {
t.Fatal("enable must not connect")
}
out.Reset()
if code := runWithDeps([]string{"mcp", "disable", "aimlapi"}, &out, &errBuf, deps); code != exitSuccess {
t.Fatalf("disable exit=%d stderr=%s", code, errBuf.String())
}
cfg, err = config.ResolveMCP(config.ResolveOptions{UserConfigPath: configPath})
if err != nil {
t.Fatal(err)
}
if !cfg.Servers["aimlapi"].Disabled {
t.Fatal("disable after enable must turn aimlapi off again")
}
}
func TestRunMCPAddUnderAIMLAPIDefaultNameEnablesIt(t *testing.T) {
configPath, _, deps := isolateAIMLAPIMCPCommand(t, "")
var out, errBuf bytes.Buffer
if code := runWithDeps([]string{"mcp", "add", "aimlapi", "--type", "http", "--url", aimlapi.MCPServerURL, "--auth", "oauth"}, &out, &errBuf, deps); code != exitSuccess {
t.Fatalf("add exit=%d stderr=%s", code, errBuf.String())
}
cfg, err := config.ResolveMCP(config.ResolveOptions{UserConfigPath: configPath})
if err != nil {
t.Fatal(err)
}
if server := cfg.Servers["aimlapi"]; server.Disabled || server.Auth != "oauth" {
t.Fatalf("added aimlapi = %#v, want enabled with oauth", server)
}
if code := runWithDeps([]string{"mcp", "add", "aimlapi", "--type", "http", "--url", aimlapi.MCPServerURL, "--disabled"}, &out, &errBuf, deps); code != exitSuccess {
t.Fatalf("add --disabled exit=%d stderr=%s", code, errBuf.String())
}
if cfg, err = config.ResolveMCP(config.ResolveOptions{UserConfigPath: configPath}); err != nil || !cfg.Servers["aimlapi"].Disabled {
t.Fatalf("add --disabled left aimlapi enabled: %#v, %v", cfg.Servers["aimlapi"], err)
}
}
// Through the real resolve path (no injected resolveMCPConfig): the default is
// listed as disabled, never connects, and once enabled it reaches session
// startup and `zero mcp check`, which connects with the key on the wire.
func TestAIMLAPIMCPDefaultThroughRealResolvePath(t *testing.T) {
_, wire, deps := isolateAIMLAPIMCPCommand(t, "aiml-secret-key")
var out, errBuf bytes.Buffer
if code := runWithDeps([]string{"mcp", "list"}, &out, &errBuf, deps); code != exitSuccess {
t.Fatalf("list exit=%d stderr=%s", code, errBuf.String())
}
if !strings.Contains(out.String(), "aimlapi [http] disabled https://mcp.aimlapi.com/mcp") || strings.Contains(out.String(), "aiml-secret-key") {
t.Fatalf("list output should show the disabled aimlapi default and no key:\n%s", out.String())
}
out.Reset()
if code := runWithDeps([]string{"mcp", "list", "--json"}, &out, &errBuf, deps); code != exitSuccess {
t.Fatalf("list --json exit=%d stderr=%s", code, errBuf.String())
}
if !strings.Contains(out.String(), `"aimlapi"`) || strings.Contains(out.String(), "aiml-secret-key") {
t.Fatalf("list --json = %s", out.String())
}
out.Reset()
errBuf.Reset()
if code := runWithDeps([]string{"mcp", "check", "aimlapi"}, &out, &errBuf, deps); code == exitSuccess || !strings.Contains(errBuf.String(), "is disabled") {
t.Fatalf("check of the disabled default: exit=%d stderr=%s", code, errBuf.String())
}
resolved, err := fillAppDeps(deps).resolveMCPConfig(t.TempDir(), true)
if err != nil {
t.Fatal(err)
}
critical, optional := splitMCPStartupConfig(resolved)
if _, ok := critical.Servers["aimlapi"]; ok {
t.Fatal("the disabled default must not start with a session")
}
if _, ok := optional.Servers["aimlapi"]; ok {
t.Fatal("the disabled default must not start with a session")
}
if len(wire.recorded()) != 0 {
t.Fatalf("the disabled default connected: %d requests", len(wire.recorded()))
}
if code := runWithDeps([]string{"mcp", "enable", "aimlapi"}, &out, &errBuf, deps); code != exitSuccess {
t.Fatalf("enable exit=%d stderr=%s", code, errBuf.String())
}
resolved, err = fillAppDeps(deps).resolveMCPConfig(t.TempDir(), true)
if err != nil {
t.Fatal(err)
}
critical, _ = splitMCPStartupConfig(resolved)
if _, ok := critical.Servers["aimlapi"]; !ok {
t.Fatal("an enabled aimlapi must start with the session as a user-configured server")
}
out.Reset()
errBuf.Reset()
if code := runWithDeps([]string{"mcp", "check", "aimlapi", "--json"}, &out, &errBuf, deps); code != exitSuccess {
t.Fatalf("check exit=%d stdout=%s stderr=%s", code, out.String(), errBuf.String())
}
if !strings.Contains(out.String(), `"status": "ok"`) || !strings.Contains(out.String(), `"toolCount": 1`) {
t.Fatalf("check output = %s", out.String())
}
if strings.Contains(out.String()+errBuf.String(), "aiml-secret-key") {
t.Fatalf("check output leaks the key: %s %s", out.String(), errBuf.String())
}
requests := wire.recorded()
if len(requests) == 0 {
t.Fatal("check did not connect")
}
for _, request := range requests {
if request.URL.String() != aimlapi.MCPServerURL {
t.Fatalf("request went to %s", request.URL)
}
if got := request.Header.Get("Authorization"); got != "Bearer aiml-secret-key" {
t.Fatalf("Authorization = %q, want the AI/ML API key", got)
}
if request.Header.Get(aimlapi.SourceHeaderName) != aimlapi.SourceValue || request.Header.Get(aimlapi.PartnerHeaderName) == "" {
t.Fatalf("attribution headers missing: %v", request.Header)
}
}
}
@@ -0,0 +1,145 @@
package cli
import (
"bytes"
"context"
"io"
"testing"
"github.com/Twigpine/zero/internal/config"
"github.com/Twigpine/zero/internal/hooks"
"github.com/Twigpine/zero/internal/mcp"
"github.com/Twigpine/zero/internal/specialist"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/tui"
"github.com/Twigpine/zero/internal/zeroruntime"
)
func TestRegisterSpecialistToolsWiresLifecycleHooksIntoTaskOutput(t *testing.T) {
registry := tools.NewRegistry()
runtime, err := registerSpecialistTools(registry, t.TempDir(), 0)
if err != nil {
t.Fatalf("registerSpecialistTools returned error: %v", err)
}
t.Cleanup(func() { closeSpecialistRuntime(io.Discard, runtime) })
if runtime.lifecycleHooks == nil {
t.Fatal("lifecycleHooks is nil after registerSpecialistTools")
}
raw, ok := registry.Get("TaskOutput")
if !ok {
t.Fatal("TaskOutput tool not registered")
}
outputTool, ok := raw.(*specialist.OutputTool)
if !ok {
t.Fatalf("TaskOutput type = %T, want *specialist.OutputTool", raw)
}
if outputTool.LifecycleHooks != runtime.lifecycleHooks {
t.Fatal("TaskOutput LifecycleHooks is not the shared registerSpecialistTools bridge")
}
dispatcher := hooks.NewDispatcher(hooks.DispatcherOptions{})
runtime.attachLifecycleHooks(dispatcher)
if runtime.lifecycleHooks.Dispatch == nil {
t.Fatal("attachLifecycleHooks left Dispatch nil")
}
if outputTool.LifecycleHooks.Dispatch == nil {
t.Fatal("TaskOutput did not observe Dispatch after attachLifecycleHooks")
}
}
// TestRealEntryPointsAttachLifecycleHooks closes the gap the registration-level
// test above cannot see: wiring the bridge when tools register is not enough
// unless the real startup paths actually call attachLifecycleHooks before any
// run. Both surfaces that can launch specialists must do so — the interactive
// TUI and headless exec. attachLifecycleHooks mutates the same
// *specialist.LifecycleHooks the TaskOutput tool holds, so a nil Dispatch on
// that tool after a full startup proves the call never ran and the
// specialistStart/specialistStop hooks would silently never fire.
func TestRealEntryPointsAttachLifecycleHooks(t *testing.T) {
requireTaskOutputDispatchAttached := func(t *testing.T, registry *tools.Registry) {
t.Helper()
if registry == nil {
t.Fatal("startup did not reach the run registry")
}
raw, ok := registry.Get("TaskOutput")
if !ok {
t.Fatal("TaskOutput tool not registered by startup")
}
outputTool, ok := raw.(*specialist.OutputTool)
if !ok {
t.Fatalf("TaskOutput type = %T, want *specialist.OutputTool", raw)
}
if outputTool.LifecycleHooks == nil || outputTool.LifecycleHooks.Dispatch == nil {
t.Fatal("startup left the specialist lifecycle bridge unattached: specialistStart/specialistStop hooks would never fire")
}
}
t.Run("interactive TUI", func(t *testing.T) {
isolateCLIUserState(t)
cwd := t.TempDir()
var registry *tools.Registry
// Empty args route runWithDeps to the interactive TUI path; the injected
// runTUI receives the same registry specialist tools registered into.
exitCode := runWithDeps([]string{}, io.Discard, io.Discard, appDeps{
getwd: func() (string, error) { return cwd, nil },
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
return config.ResolvedConfig{
Provider: config.ProviderProfile{
Name: "p",
ProviderKind: config.ProviderKindOpenAICompatible,
BaseURL: "http://127.0.0.1/v1",
Model: "m",
},
}, nil
},
resolveMCPConfig: func(string, bool) (config.MCPConfig, error) { return config.MCPConfig{}, nil },
runTUI: func(_ context.Context, options tui.Options) int {
registry = options.Registry
return exitSuccess
},
})
if exitCode != exitSuccess {
t.Fatalf("runWithDeps exit = %d, want %d", exitCode, exitSuccess)
}
requireTaskOutputDispatchAttached(t, registry)
})
t.Run("headless exec", func(t *testing.T) {
isolateCLIUserState(t)
cwd := t.TempDir()
var registry *tools.Registry
var stdout, stderr bytes.Buffer
// --auto high is what registers specialist tools on the exec surface.
// MCP registration is the one startup chokepoint that sees the run
// registry before attachLifecycleHooks runs, so capture it there and
// assert after the run completes.
exitCode := runWithDeps([]string{
"exec", "--auto", "high", "--cwd", cwd, "--init-session-id", "hook_wireup", "hello",
}, &stdout, &stderr, appDeps{
getwd: func() (string, error) { return cwd, nil },
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
return execResolvedConfig(), nil
},
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
return echoExecProvider{}, nil
},
resolveMCPConfig: func(string, bool) (config.MCPConfig, error) {
return config.MCPConfig{Servers: map[string]config.MCPServerConfig{
"wireup": {Type: "stdio", Command: "wireup-mcp"},
}}, nil
},
newMCPStore: func() (*mcp.PermissionStore, error) { return nil, nil },
registerMCPTools: func(_ context.Context, captured *tools.Registry, _ config.MCPConfig, _ mcp.RegisterOptions) (mcpToolRuntime, error) {
registry = captured
return closeFunc(func() error { return nil }), nil
},
})
if exitCode != exitSuccess {
t.Fatalf("runWithDeps exit = %d, want %d (stderr=%q)", exitCode, exitSuccess, stderr.String())
}
requireTaskOutputDispatchAttached(t, registry)
})
}
+168
View File
@@ -0,0 +1,168 @@
package config
import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"unicode"
"github.com/Twigpine/zero/internal/aimlapi"
"github.com/Twigpine/zero/internal/providercatalog"
)
const aimlapiKeyEnv = "AIMLAPI_API_KEY"
var (
// ErrAIMLAPIKeyMissing means no AI/ML API key is configured.
ErrAIMLAPIKeyMissing = errors.New("no AI/ML API key is configured; set up aimlapi.com with /provider or set AIMLAPI_API_KEY")
// ErrAIMLAPIProxyProfile means the aimlapi profile points at a proxy, so its
// key belongs to that proxy and is withheld from aimlapi.com services.
ErrAIMLAPIProxyProfile = errors.New("the aimlapi provider points at a proxy base URL; its key is not sent to aimlapi.com services")
// ErrAIMLAPIKeyInvalid means the key cannot be sent as a single Bearer token.
ErrAIMLAPIKeyInvalid = errors.New("the AI/ML API key contains whitespace, a comma or a control character")
)
// AIMLAPIServiceKey returns the AI/ML API key that may be sent to the
// aimlapi.com Toolbox and MCP server. It is resolved again on every call, so a
// provider change is seen by the next request.
//
// The key comes from the aimlapi provider profile (the active one when it is
// aimlapi, otherwise the first), including a key held in the credential store,
// or from AIMLAPI_API_KEY when no aimlapi profile exists. The key is withheld
// when that profile, any aimlapi profile, or any profile reading
// AIMLAPI_API_KEY points at a base URL other than the canonical
// https://api.aimlapi.com/v1, and when AIMLAPI_INFERENCE_URL is overridden:
// in those cases the credential may belong to a proxy.
func AIMLAPIServiceKey(resolved ResolvedConfig, store APIKeyGetter, getenv func(string) string) (string, error) {
if getenv == nil {
getenv = os.Getenv
}
if override := strings.TrimSpace(getenv("AIMLAPI_INFERENCE_URL")); override != "" && !sameBaseURL(override, aimlapi.CanonicalInferenceBaseURL) {
return "", ErrAIMLAPIProxyProfile
}
for _, profile := range resolved.Providers {
if aimlapiKeyedProfile(profile) && !sameBaseURL(profile.BaseURL, aimlapi.CanonicalInferenceBaseURL) {
return "", ErrAIMLAPIProxyProfile
}
}
if profile, ok := aimlapiServiceProfile(resolved); ok {
if !sameBaseURL(profile.BaseURL, aimlapi.CanonicalInferenceBaseURL) {
return "", ErrAIMLAPIProxyProfile
}
if store != nil && profile.APIKeyStored && strings.TrimSpace(profile.APIKey) == "" && strings.TrimSpace(profile.Name) != "" {
key, found, err := store.Get(strings.TrimSpace(profile.Name))
if err != nil {
return "", fmt.Errorf("read AI/ML API credential store: %w", err)
}
if found {
profile.APIKey = key
}
}
return validAIMLAPIServiceKey(profile.APIKey)
}
return validAIMLAPIServiceKey(getenv(aimlapiKeyEnv))
}
// LoadAIMLAPIServiceKey resolves the user configuration, the environment and
// the user credential store, then applies AIMLAPIServiceKey. Project config is
// not read: provider keys are user-scoped. A config with no active provider
// still resolves its profiles (ErrNoActiveProvider), so the key is looked up in
// that partial config; every other resolve error is returned. Resolve drops a
// non-active profile it cannot normalize, so the configured profiles are
// checked as well: such a profile still withholds the key when it carries it.
func LoadAIMLAPIServiceKey() (string, error) {
userConfigPath, err := DefaultUserConfigPath()
if err != nil {
return "", err
}
existing, err := existingConfigFile(userConfigPath)
if err != nil {
return "", err
}
resolved, err := Resolve(ResolveOptions{UserConfigPath: existing, ExcludeProject: true})
if err != nil && !errors.Is(err, ErrNoActiveProvider) {
return "", err
}
if existing != "" {
fileConfig, err := loadConfigFile(existing)
if err != nil {
return "", err
}
var configured FileConfig
mergeConfig(&configured, fileConfig)
if droppedAIMLAPIProxyProfile(configured.Providers, resolved.Providers) {
return "", ErrAIMLAPIProxyProfile
}
}
// Check proxy eligibility and already-resolved keys before opening a store.
key, keyErr := AIMLAPIServiceKey(resolved, nil, os.Getenv)
if !errors.Is(keyErr, ErrAIMLAPIKeyMissing) {
return key, keyErr
}
profile, ok := aimlapiServiceProfile(resolved)
if !ok || !profile.APIKeyStored {
return key, keyErr
}
store, err := ProviderKeyStoreAt(filepath.Dir(userConfigPath))
if err != nil {
return "", fmt.Errorf("open AI/ML API credential store: %w", err)
}
return AIMLAPIServiceKey(resolved, store, os.Getenv)
}
func isAIMLAPIProfile(profile ProviderProfile) bool {
return strings.EqualFold(providercatalog.NormalizeID(profile.CatalogID), providercatalog.AIMLAPIID)
}
// aimlapiKeyedProfile reports whether profile carries an AI/ML API credential:
// an aimlapi catalog profile, or any profile that reads AIMLAPI_API_KEY.
func aimlapiKeyedProfile(profile ProviderProfile) bool {
return isAIMLAPIProfile(profile) || strings.EqualFold(strings.TrimSpace(profile.APIKeyEnv), aimlapiKeyEnv)
}
// droppedAIMLAPIProxyProfile reports whether a configured profile missing from
// the resolved list (Resolve could not normalize it) carries an AI/ML API
// credential and is not explicitly on the canonical base URL. Its endpoint was
// never resolved, so an empty base URL counts as a proxy.
func droppedAIMLAPIProxyProfile(configured, resolved []ProviderProfile) bool {
kept := make(map[string]bool, len(resolved))
for _, profile := range resolved {
kept[profile.Name] = true
}
for _, profile := range configured {
if kept[strings.TrimSpace(profile.Name)] || !aimlapiKeyedProfile(profile) {
continue
}
if !sameBaseURL(profile.BaseURL, aimlapi.CanonicalInferenceBaseURL) {
return true
}
}
return false
}
func aimlapiServiceProfile(resolved ResolvedConfig) (ProviderProfile, bool) {
if isAIMLAPIProfile(resolved.Provider) {
return resolved.Provider, true
}
for _, profile := range resolved.Providers {
if isAIMLAPIProfile(profile) {
return profile, true
}
}
return ProviderProfile{}, false
}
func validAIMLAPIServiceKey(raw string) (string, error) {
key := strings.TrimSpace(raw)
if key == "" {
return "", ErrAIMLAPIKeyMissing
}
for _, r := range key {
if unicode.IsSpace(r) || r <= ' ' || r == 0x7f || r == ',' || (r >= 0x80 && r <= 0x9f) {
return "", ErrAIMLAPIKeyInvalid
}
}
return key, nil
}
+282
View File
@@ -0,0 +1,282 @@
package config
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
)
func aimlapiServiceKeyEnv(values map[string]string) func(string) string {
return func(name string) string { return values[name] }
}
func TestAIMLAPIServiceKeyUsesCanonicalProfileKey(t *testing.T) {
resolved := ResolvedConfig{Providers: []ProviderProfile{
{Name: "openai", BaseURL: "https://api.openai.com/v1", APIKey: "sk-openai"},
{Name: "aimlapi", CatalogID: "aimlapi", BaseURL: "https://api.aimlapi.com/v1/", APIKey: " aiml-key "},
}}
key, err := AIMLAPIServiceKey(resolved, nil, aimlapiServiceKeyEnv(nil))
if err != nil || key != "aiml-key" {
t.Fatalf("AIMLAPIServiceKey = %q, %v; want aiml-key", key, err)
}
}
func TestAIMLAPIServiceKeyReadsCredentialStore(t *testing.T) {
resolved := ResolvedConfig{Provider: ProviderProfile{Name: "aimlapi", CatalogID: "aimlapi", BaseURL: "https://api.aimlapi.com/v1", APIKeyStored: true}}
store := fakeKeyGetter{keys: map[string]string{"aimlapi": "stored-key"}}
key, err := AIMLAPIServiceKey(resolved, store, aimlapiServiceKeyEnv(nil))
if err != nil || key != "stored-key" {
t.Fatalf("AIMLAPIServiceKey = %q, %v; want stored-key", key, err)
}
}
func TestAIMLAPIServiceKeyPrefersResolvedProfileKeyOverCredentialStore(t *testing.T) {
resolved := ResolvedConfig{Provider: ProviderProfile{
Name: "aimlapi", CatalogID: "aimlapi", BaseURL: "https://api.aimlapi.com/v1",
APIKey: "env-key", APIKeyStored: true,
}}
store := fakeKeyGetter{keys: map[string]string{"aimlapi": "stored-key"}}
key, err := AIMLAPIServiceKey(resolved, store, aimlapiServiceKeyEnv(map[string]string{"AIMLAPI_API_KEY": "env-key"}))
if err != nil || key != "env-key" {
t.Fatalf("AIMLAPIServiceKey = %q, %v; want env-key from resolved profile, not stored-key", key, err)
}
}
func TestAIMLAPIServiceKeyFallsBackToEnvironmentWithoutProfile(t *testing.T) {
key, err := AIMLAPIServiceKey(ResolvedConfig{}, nil, aimlapiServiceKeyEnv(map[string]string{"AIMLAPI_API_KEY": "env-key"}))
if err != nil || key != "env-key" {
t.Fatalf("AIMLAPIServiceKey = %q, %v; want env-key", key, err)
}
if _, err := AIMLAPIServiceKey(ResolvedConfig{}, nil, aimlapiServiceKeyEnv(nil)); !errors.Is(err, ErrAIMLAPIKeyMissing) {
t.Fatalf("missing key error = %v, want ErrAIMLAPIKeyMissing", err)
}
}
func TestAIMLAPIServiceKeyWithholdsProxyCredentials(t *testing.T) {
env := aimlapiServiceKeyEnv(map[string]string{"AIMLAPI_API_KEY": "proxy-key"})
cases := map[string]ResolvedConfig{
"active aimlapi profile on a proxy": {Provider: ProviderProfile{Name: "aimlapi", CatalogID: "aimlapi", BaseURL: "https://proxy.example/v1", APIKey: "proxy-key"}},
"a second aimlapi profile on a proxy": {Providers: []ProviderProfile{
{Name: "aimlapi", CatalogID: "aimlapi", BaseURL: "https://api.aimlapi.com/v1", APIKey: "proxy-key"},
{Name: "aimlapi-proxy", CatalogID: "aimlapi", BaseURL: "https://proxy.example/v1", APIKey: "proxy-key"},
}},
"a custom profile reading AIMLAPI_API_KEY": {Providers: []ProviderProfile{
{Name: "gateway", BaseURL: "https://proxy.example/v1", APIKeyEnv: "AIMLAPI_API_KEY", APIKey: "proxy-key"},
}},
}
for name, resolved := range cases {
key, err := AIMLAPIServiceKey(resolved, nil, env)
if !errors.Is(err, ErrAIMLAPIProxyProfile) || key != "" {
t.Errorf("%s: AIMLAPIServiceKey = %q, %v; want ErrAIMLAPIProxyProfile", name, key, err)
}
}
overridden := aimlapiServiceKeyEnv(map[string]string{"AIMLAPI_API_KEY": "proxy-key", "AIMLAPI_INFERENCE_URL": "https://staging.example/v1"})
if key, err := AIMLAPIServiceKey(ResolvedConfig{}, nil, overridden); !errors.Is(err, ErrAIMLAPIProxyProfile) || key != "" {
t.Fatalf("inference URL override: AIMLAPIServiceKey = %q, %v; want ErrAIMLAPIProxyProfile", key, err)
}
}
func TestAIMLAPIServiceKeyRejectsKeysThatAreNotOneToken(t *testing.T) {
for _, raw := range []string{"first\u00a0second", "first\u2003second", "first,second", "first\nsecond", "first\r\nsecond", "a b", "key\x00tail", "key\u0085tail"} {
resolved := ResolvedConfig{Provider: ProviderProfile{Name: "aimlapi", CatalogID: "aimlapi", BaseURL: "https://api.aimlapi.com/v1", APIKey: raw}}
if key, err := AIMLAPIServiceKey(resolved, nil, aimlapiServiceKeyEnv(nil)); !errors.Is(err, ErrAIMLAPIKeyInvalid) || key != "" {
t.Errorf("key %q: AIMLAPIServiceKey = %q, %v; want ErrAIMLAPIKeyInvalid", raw, key, err)
}
}
}
func TestLoadAIMLAPIServiceKeyResolvesUserConfig(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("APPDATA", filepath.Join(home, "AppData"))
t.Setenv("USERPROFILE", home)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
t.Setenv("ZERO_CRED_STORAGE", "file")
t.Setenv("ZERO_PROVIDER", "")
t.Setenv("AIMLAPI_INFERENCE_URL", "")
t.Setenv("AIMLAPI_API_KEY", "env-key")
if key, err := LoadAIMLAPIServiceKey(); err != nil || key != "env-key" {
t.Fatalf("without config: LoadAIMLAPIServiceKey = %q, %v; want env-key", key, err)
}
path, err := DefaultUserConfigPath()
if err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
t.Fatal(err)
}
proxy := `{"activeProvider":"aimlapi","providers":[{"name":"aimlapi","catalogID":"aimlapi","baseURL":"https://proxy.example/v1","model":"m"}]}`
if err := os.WriteFile(path, []byte(proxy), 0o600); err != nil {
t.Fatal(err)
}
if key, err := LoadAIMLAPIServiceKey(); !errors.Is(err, ErrAIMLAPIProxyProfile) || key != "" {
t.Fatalf("proxy profile: LoadAIMLAPIServiceKey = %q, %v; want ErrAIMLAPIProxyProfile", key, err)
}
canonical := `{"activeProvider":"aimlapi","providers":[{"name":"aimlapi","catalogID":"aimlapi"}]}`
if err := os.WriteFile(path, []byte(canonical), 0o600); err != nil {
t.Fatal(err)
}
if key, err := LoadAIMLAPIServiceKey(); err != nil || key != "env-key" {
t.Fatalf("canonical profile: LoadAIMLAPIServiceKey = %q, %v; want env-key", key, err)
}
}
// Several profiles and no activeProvider make Resolve return
// ErrNoActiveProvider with the normalized profiles; the interactive app keeps
// running on that partial config, so the service key must still resolve.
func TestLoadAIMLAPIServiceKeyWithoutActiveProvider(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("APPDATA", filepath.Join(home, "AppData"))
t.Setenv("USERPROFILE", home)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
t.Setenv("ZERO_CRED_STORAGE", "file")
t.Setenv("ZERO_PROVIDER", "")
t.Setenv("AIMLAPI_INFERENCE_URL", "")
t.Setenv("AIMLAPI_API_KEY", "env-key")
path, err := DefaultUserConfigPath()
if err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
t.Fatal(err)
}
other := `{"name":"other","provider":"openai","model":"m","apiKey":"other-key"}`
for _, tc := range []struct {
name string
active string
profile string
want string
wantErr error
}{
{name: "environment key", profile: `{"name":"second","provider":"openai","model":"m","apiKey":"second-key"}`, want: "env-key"},
{name: "stale active provider", active: "gone", profile: `{"name":"second","provider":"openai","model":"m","apiKey":"second-key"}`, want: "env-key"},
{name: "aimlapi profile key", profile: `{"name":"aimlapi","catalogID":"aimlapi","model":"m","apiKey":"profile-key"}`, want: "profile-key"},
{name: "proxy profile", profile: `{"name":"aimlapi","catalogID":"aimlapi","baseURL":"https://proxy.example/v1","model":"m"}`, wantErr: ErrAIMLAPIProxyProfile},
} {
data := `{"activeProvider":"` + tc.active + `","providers":[` + other + `,` + tc.profile + `]}`
if err := os.WriteFile(path, []byte(data), 0o600); err != nil {
t.Fatal(err)
}
key, err := LoadAIMLAPIServiceKey()
if tc.wantErr != nil {
if !errors.Is(err, tc.wantErr) || key != "" {
t.Fatalf("%s: LoadAIMLAPIServiceKey = %q, %v; want %v", tc.name, key, err, tc.wantErr)
}
continue
}
if err != nil || key != tc.want {
t.Fatalf("%s: LoadAIMLAPIServiceKey = %q, %v; want %q", tc.name, key, err, tc.want)
}
}
}
// Resolve drops a non-active profile it cannot normalize (here an unknown
// catalog entry). Such a profile still shows that AIMLAPI_API_KEY belongs to a
// proxy, so the key is withheld whether or not a provider is active.
func TestLoadAIMLAPIServiceKeyWithholdsForDroppedProxyProfile(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("APPDATA", filepath.Join(home, "AppData"))
t.Setenv("USERPROFILE", home)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
t.Setenv("ZERO_CRED_STORAGE", "file")
t.Setenv("ZERO_PROVIDER", "")
t.Setenv("AIMLAPI_INFERENCE_URL", "")
t.Setenv("AIMLAPI_API_KEY", "env-key")
path, err := DefaultUserConfigPath()
if err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
t.Fatal(err)
}
canonical := `{"name":"aimlapi","catalogID":"aimlapi","model":"m","apiKey":"profile-key"}`
other := `{"name":"other","provider":"openai","model":"m","apiKey":"other-key"}`
dropped := `{"name":"future","catalogID":"no-such-catalog","baseURL":"https://proxy.example/v1","apiKeyEnv":"AIMLAPI_API_KEY","model":"m"}`
unrelated := `{"name":"future","catalogID":"no-such-catalog","baseURL":"https://proxy.example/v1","apiKeyEnv":"OTHER_API_KEY","model":"m"}`
for _, tc := range []struct {
name string
data string
want string
wantErr error
}{
{name: "active aimlapi profile", data: `{"activeProvider":"aimlapi","providers":[` + canonical + `,` + dropped + `]}`, wantErr: ErrAIMLAPIProxyProfile},
{name: "no active provider", data: `{"providers":[` + other + `,` + dropped + `]}`, wantErr: ErrAIMLAPIProxyProfile},
{name: "dropped profile without the key", data: `{"activeProvider":"aimlapi","providers":[` + canonical + `,` + unrelated + `]}`, want: "profile-key"},
} {
if err := os.WriteFile(path, []byte(tc.data), 0o600); err != nil {
t.Fatal(err)
}
key, err := LoadAIMLAPIServiceKey()
if tc.wantErr != nil {
if !errors.Is(err, tc.wantErr) || key != "" {
t.Fatalf("%s: LoadAIMLAPIServiceKey = %q, %v; want %v", tc.name, key, err, tc.wantErr)
}
continue
}
if err != nil || key != tc.want {
t.Fatalf("%s: LoadAIMLAPIServiceKey = %q, %v; want %q", tc.name, key, err, tc.want)
}
}
}
func TestLoadAIMLAPIServiceKeyStoreOpenFailure(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("APPDATA", filepath.Join(home, "AppData"))
t.Setenv("USERPROFILE", home)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
t.Setenv("ZERO_PROVIDER", "")
t.Setenv("AIMLAPI_INFERENCE_URL", "")
t.Setenv("AIMLAPI_API_KEY", "env-key")
t.Setenv("ZERO_CRED_STORAGE", "invalid-test-storage")
if key, err := LoadAIMLAPIServiceKey(); err != nil || key != "env-key" {
t.Fatalf("environment key must not require a store: %q, %v", key, err)
}
path, err := DefaultUserConfigPath()
if err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
t.Fatal(err)
}
t.Setenv("AIMLAPI_API_KEY", "")
data := `{"activeProvider":"aimlapi","providers":[{"name":"aimlapi","catalogID":"aimlapi","model":"m","apiKeyStored":true}]}`
if err := os.WriteFile(path, []byte(data), 0o600); err != nil {
t.Fatal(err)
}
if key, err := LoadAIMLAPIServiceKey(); key != "" || err == nil || !strings.Contains(err.Error(), "unknown storage") {
t.Fatalf("stored key must report store failure: %q, %v", key, err)
}
t.Setenv("AIMLAPI_INFERENCE_URL", "https://proxy.example/v1")
if key, err := LoadAIMLAPIServiceKey(); key != "" || !errors.Is(err, ErrAIMLAPIProxyProfile) {
t.Fatalf("proxy rejection must precede store open: %q, %v", key, err)
}
t.Setenv("AIMLAPI_INFERENCE_URL", "")
data = `{"activeProvider":"aimlapi","providers":[{"name":"aimlapi","catalogID":"aimlapi","model":"m","apiKeyStored":true,"apiKey":"inline-key"}]}`
if err := os.WriteFile(path, []byte(data), 0o600); err != nil {
t.Fatal(err)
}
if key, err := LoadAIMLAPIServiceKey(); err != nil || key != "inline-key" {
t.Fatalf("inline key must not require a store: %q, %v", key, err)
}
}
func TestAIMLAPIServiceKeyReportsStoreReadFailure(t *testing.T) {
want := errors.New("test credential read failure")
resolved := ResolvedConfig{Provider: ProviderProfile{Name: "aimlapi", CatalogID: "aimlapi", BaseURL: "https://api.aimlapi.com/v1", APIKeyStored: true}}
if key, err := AIMLAPIServiceKey(resolved, fakeKeyGetter{err: want}, aimlapiServiceKeyEnv(nil)); key != "" || !errors.Is(err, want) {
t.Fatalf("read failure was hidden: %q, %v", key, err)
}
resolved.Provider.BaseURL = "https://proxy.example/v1"
if _, err := AIMLAPIServiceKey(resolved, fakeKeyGetter{err: want}, aimlapiServiceKeyEnv(nil)); !errors.Is(err, ErrAIMLAPIProxyProfile) {
t.Fatalf("proxy check must precede store read: %v", err)
}
}
+51
View File
@@ -0,0 +1,51 @@
package config
import (
"encoding/json"
"os"
"path/filepath"
"testing"
)
func TestEnsureChatGPTDefaultPreservesSavedSelection(t *testing.T) {
for _, saved := range []string{"", "gpt-explicit-saved"} {
name := "new profile"
if saved != "" {
name = "saved selection"
}
t.Run(name, func(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.json")
if saved != "" {
body, err := json.Marshal(FileConfig{ActiveProvider: "codex", Providers: []ProviderProfile{{Name: "codex", CatalogID: "chatgpt", Model: saved}}})
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, body, 0600); err != nil {
t.Fatal(err)
}
}
ensured, err := EnsureCatalogProvider(path, "chatgpt")
if err != nil {
t.Fatal(err)
}
body, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
var cfg FileConfig
if err := json.Unmarshal(body, &cfg); err != nil {
t.Fatal(err)
}
want := "gpt-6.1-sol"
if saved != "" {
want = saved
}
if len(cfg.Providers) != 1 || cfg.Providers[0].Model != want {
t.Fatalf("ChatGPT default/selection = %#v, want model %q", cfg.Providers, want)
}
if saved != "" && (ensured.Name != "codex" || ensured.Created) {
t.Fatalf("saved subscription profile was replaced: %#v", ensured)
}
})
}
}
+27 -5
View File
@@ -3,26 +3,48 @@ package config
import (
"reflect"
"strings"
"github.com/Twigpine/zero/internal/aimlapi"
)
// DefaultMCPServers returns the MCP servers Zero ships ENABLED by default so
// web search and page fetching work out of the box with no setup and no API
// key. They are seeded before user/project config is merged (see ResolveMCP),
// so a user can override any field — for example add an API-key header to lift
// Exa's anonymous rate limit — or disable it entirely with
// DefaultMCPServers returns the MCP servers Zero ships built in. Exa is ENABLED
// by default so web search and page fetching work out of the box with no setup
// and no API key. They are seeded before user/project config is merged (see
// ResolveMCP), so a user can override any field — for example add an API-key
// header to lift Exa's anonymous rate limit — or disable it entirely with
// `zero mcp disable <name>` (which writes `"disabled": true`).
//
// Exa's hosted MCP server works anonymously with rate limits. Users can add an
// Exa API key for higher limits.
//
// The AI/ML API server ships DISABLED: its tool runs and generations are billed
// to the user's AI/ML API balance, so it only connects after
// `zero mcp enable aimlapi` (which writes `"disabled": false`). The disable is
// marked as explicitly set so the lower-trust project layer cannot lift it
// (see mergeMCPServer); only the user and CLI-override scopes can. The key is
// attached per request by the MCP client and is never part of this config.
func DefaultMCPServers() map[string]MCPServerConfig {
return map[string]MCPServerConfig{
"exa": {
Type: "http",
URL: "https://mcp.exa.ai/mcp",
},
"aimlapi": {
Type: "http",
URL: aimlapi.MCPServerURL,
Disabled: true,
disabledSet: true,
},
}
}
// IsDefaultMCPServerDisabled reports whether name is a built-in default that
// ships disabled, so enabling it has to be written out as `"disabled": false`
// rather than by dropping the flag.
func IsDefaultMCPServerDisabled(name string) bool {
return DefaultMCPServers()[strings.TrimSpace(name)].Disabled
}
// IsDefaultMCPServer reports whether name is one of Zero's built-in default MCP
// servers. The config commands use it so a default can be disabled/enabled even
// though it is not written to the user's config file until overridden.
+89
View File
@@ -291,3 +291,92 @@ func TestResolveMCPDisabledRetiredEntryStaysReEnableable(t *testing.T) {
t.Fatal("the disable must still carry to the successor")
}
}
func writeMCPDefaultsTestConfig(t *testing.T, data string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(path, []byte(data), 0o600); err != nil {
t.Fatal(err)
}
return path
}
func TestResolveMCPSeedsDisabledAIMLAPIDefault(t *testing.T) {
if !IsDefaultMCPServer("aimlapi") || !IsDefaultMCPServerDisabled("aimlapi") || IsDefaultMCPServerDisabled("exa") {
t.Fatal("aimlapi must be a built-in default that ships disabled, exa one that ships enabled")
}
// No user config at all, and a user config that never mentions aimlapi.
for name, options := range map[string]ResolveOptions{
"no config": {},
"unrelated user file": {UserConfigPath: writeMCPDefaultsTestConfig(t, `{"mcp":{"servers":{"exa":{"disabled":true}}}}`)},
"user entry with only headers": {UserConfigPath: writeMCPDefaultsTestConfig(t,
`{"mcp":{"servers":{"aimlapi":{"headers":{"X-Trace":"1"}}}}}`)},
} {
cfg, err := ResolveMCP(options)
if err != nil {
t.Fatalf("%s: ResolveMCP: %v", name, err)
}
server, ok := cfg.Servers["aimlapi"]
if !ok {
t.Fatalf("%s: the aimlapi default must be visible in the resolved config", name)
}
if server.Type != "http" || server.URL != "https://mcp.aimlapi.com/mcp" || !server.Disabled {
t.Fatalf("%s: aimlapi default = %#v, want a disabled http server at the MCP URL", name, server)
}
}
cfg, err := ResolveMCP(ResolveOptions{})
if err != nil {
t.Fatal(err)
}
if !IsUnconfiguredDefault("aimlapi", cfg.Servers["aimlapi"]) {
t.Fatal("the untouched aimlapi default should be reported as unconfigured")
}
if exa := cfg.Servers["exa"]; exa.Disabled || !IsUnconfiguredDefault("exa", exa) {
t.Fatalf("exa default changed: %#v", exa)
}
}
func TestResolveMCPUserEnablesAIMLAPIDefault(t *testing.T) {
path := writeMCPDefaultsTestConfig(t, `{"mcp":{"servers":{"aimlapi":{"disabled":false}}}}`)
cfg, err := ResolveMCP(ResolveOptions{UserConfigPath: path})
if err != nil {
t.Fatalf("ResolveMCP: %v", err)
}
server := cfg.Servers["aimlapi"]
if server.Disabled || server.URL != "https://mcp.aimlapi.com/mcp" || server.Type != "http" {
t.Fatalf("user enable did not turn the default on: %#v", server)
}
if IsUnconfiguredDefault("aimlapi", server) {
t.Fatal("an enabled aimlapi is user-configured, so its failures must be reported")
}
enable := MCPServerConfig{Disabled: false, disabledSet: true}
cfg, err = ResolveMCP(ResolveOptions{Overrides: Overrides{MCP: MCPConfig{Servers: map[string]MCPServerConfig{"aimlapi": enable}}}})
if err != nil {
t.Fatal(err)
}
if cfg.Servers["aimlapi"].Disabled {
t.Fatal("the CLI override scope must be able to enable the aimlapi default")
}
}
func TestResolveMCPProjectCannotEnableAIMLAPIDefault(t *testing.T) {
project := writeMCPDefaultsTestConfig(t, `{"mcp":{"servers":{"aimlapi":{"disabled":false}}}}`)
cfg, err := ResolveMCP(ResolveOptions{ProjectConfigPath: project})
if err != nil {
t.Fatalf("ResolveMCP: %v", err)
}
if !cfg.Servers["aimlapi"].Disabled {
t.Fatal("a project config must not turn on the billed aimlapi default")
}
user := writeMCPDefaultsTestConfig(t, `{"mcp":{"servers":{"aimlapi":{"disabled":false}}}}`)
projectDisable := writeMCPDefaultsTestConfig(t, `{"mcp":{"servers":{"aimlapi":{"disabled":true}}}}`)
cfg, err = ResolveMCP(ResolveOptions{UserConfigPath: user, ProjectConfigPath: projectDisable})
if err != nil {
t.Fatalf("ResolveMCP: %v", err)
}
if cfg.Servers["aimlapi"].Disabled {
t.Fatal("the user's explicit enable wins over the project layer, as for any server")
}
}
+7 -4
View File
@@ -455,7 +455,7 @@ func projectAPIKeyEnvAllowed(profile ProviderProfile, envName string) bool {
if baseURL == "" {
baseURL = descriptor.DefaultBaseURL
}
if !sameBaseURL(baseURL, descriptor.DefaultBaseURL) {
if !providercatalog.IsCatalogBaseURL(descriptor, baseURL) {
return false
}
return containsStringFold(descriptor.AuthEnvVars, envName)
@@ -463,7 +463,7 @@ func projectAPIKeyEnvAllowed(profile ProviderProfile, envName string) bool {
func projectBaseURLAllowed(profile ProviderProfile) bool {
descriptor, ok := catalogDescriptorForProfile(profile)
return ok && sameBaseURL(profile.BaseURL, descriptor.DefaultBaseURL)
return ok && providercatalog.IsCatalogBaseURL(descriptor, profile.BaseURL)
}
func hasInheritedProviderCredentialMaterial(project ProviderProfile, candidate ProviderProfile) bool {
@@ -1128,10 +1128,13 @@ func applyCatalogDescriptor(profile *ProviderProfile, descriptor providercatalog
if profile.Model == "" {
profile.Model = descriptor.DefaultModel
}
if profile.APIKeyEnv == "" && len(descriptor.AuthEnvVars) > 0 && (!explicitBaseURL || sameBaseURL(profile.BaseURL, descriptor.DefaultBaseURL)) {
// A legacy catalog base URL (an earlier default that still serves the
// provider, e.g. a profile saved before a host move) counts as the catalog
// endpoint, so it keeps the catalog env var binding.
canonicalCatalogEndpoint := !explicitBaseURL || providercatalog.IsCatalogBaseURL(descriptor, profile.BaseURL)
if profile.APIKeyEnv == "" && len(descriptor.AuthEnvVars) > 0 && canonicalCatalogEndpoint {
profile.APIKeyEnv = descriptor.AuthEnvVars[0]
}
canonicalCatalogEndpoint := !explicitBaseURL || sameBaseURL(profile.BaseURL, descriptor.DefaultBaseURL)
if len(descriptor.CustomHeaders) > 0 && canonicalCatalogEndpoint {
catalogHeaders := descriptor.CustomHeaders
if strings.EqualFold(strings.TrimSpace(descriptor.ID), "aimlapi") {
+68
View File
@@ -1357,6 +1357,74 @@ func TestResolveAppliesProviderCatalogDefaults(t *testing.T) {
}
}
func TestResolveAppliesOpenGatewayCatalogDefaults(t *testing.T) {
path := writeConfig(t, `{
"activeProvider": "gitlawb-opengateway",
"providers": [{
"name": "gitlawb-opengateway",
"catalogID": "gitlawb-opengateway"
}]
}`)
resolved, err := Resolve(ResolveOptions{
UserConfigPath: path,
Env: map[string]string{
"GITLAWB_OPENGATEWAY_API_KEY": "ogw_live_test",
},
})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if resolved.Provider.BaseURL != "https://api.twigpine.com/v1" {
t.Fatalf("BaseURL = %q, want https://api.twigpine.com/v1", resolved.Provider.BaseURL)
}
if resolved.Provider.Model != "xiaomi/mimo-v2.6-pro" {
t.Fatalf("Model = %q, want xiaomi/mimo-v2.6-pro", resolved.Provider.Model)
}
if resolved.Provider.APIKeyEnv != "GITLAWB_OPENGATEWAY_API_KEY" || resolved.Provider.APIKey != "ogw_live_test" {
t.Fatalf("credential = (%q, %q), want GITLAWB_OPENGATEWAY_API_KEY env-resolved key", resolved.Provider.APIKeyEnv, resolved.Provider.APIKey)
}
}
// Profiles saved before the Twigpine rename persist the catalog's old base URL
// (https://opengateway.gitlawb.com/v1) explicitly. That host still serves the
// gateway, so those configs must keep resolving exactly as before: same host,
// same model, catalog env var still bound, credential still picked up.
func TestResolveKeepsLegacyOpenGatewayHostProfiles(t *testing.T) {
path := writeConfig(t, `{
"activeProvider": "gitlawb-opengateway",
"providers": [{
"name": "gitlawb-opengateway",
"catalogID": "gitlawb-opengateway",
"providerKind": "openai-compatible",
"baseURL": "https://opengateway.gitlawb.com/v1",
"model": "mimo-v2.5-pro"
}]
}`)
resolved, err := Resolve(ResolveOptions{
UserConfigPath: path,
Env: map[string]string{
"GITLAWB_OPENGATEWAY_API_KEY": "ogw_live_test",
},
})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if resolved.Provider.BaseURL != "https://opengateway.gitlawb.com/v1" {
t.Fatalf("BaseURL = %q, want the saved legacy host kept", resolved.Provider.BaseURL)
}
if resolved.Provider.Model != "mimo-v2.5-pro" {
t.Fatalf("Model = %q, want the saved model kept", resolved.Provider.Model)
}
if resolved.Provider.APIKeyEnv != "GITLAWB_OPENGATEWAY_API_KEY" {
t.Fatalf("APIKeyEnv = %q, want GITLAWB_OPENGATEWAY_API_KEY", resolved.Provider.APIKeyEnv)
}
if resolved.Provider.APIKey != "ogw_live_test" {
t.Fatalf("APIKey = %q, want env-resolved key", resolved.Provider.APIKey)
}
}
func TestResolveAppliesMiniMaxCNCatalogDefaults(t *testing.T) {
path := writeConfig(t, `{
"activeProvider": "mini-cn",
+33
View File
@@ -724,6 +724,39 @@ func (cfg LocalControlDriverConfig) MarshalJSON() ([]byte, error) {
return json.Marshal(raw)
}
// MarshalJSON writes "disabled" whenever it was explicitly set, not only when
// true. A read-modify-write of config.json (SetTheme, SetActiveProvider, ...)
// would otherwise drop `"disabled": false`, which turns a default that ships
// disabled (aimlapi) back off and lets project config override a user's
// explicit enable (see mergeMCPServer).
func (server MCPServerConfig) MarshalJSON() ([]byte, error) {
type rawServer struct {
Type string `json:"type,omitempty"`
Command string `json:"command,omitempty"`
Args []string `json:"args,omitempty"`
Env map[string]string `json:"env,omitempty"`
URL string `json:"url,omitempty"`
Headers map[string]string `json:"headers,omitempty"`
Auth string `json:"auth,omitempty"`
OAuth *MCPOAuthConfig `json:"oauth,omitempty"`
Disabled *bool `json:"disabled,omitempty"`
}
raw := rawServer{
Type: server.Type,
Command: server.Command,
Args: server.Args,
Env: server.Env,
URL: server.URL,
Headers: server.Headers,
Auth: server.Auth,
OAuth: server.OAuth,
}
if server.disabledSet || server.Disabled {
raw.Disabled = &server.Disabled
}
return json.Marshal(raw)
}
func (server *MCPServerConfig) UnmarshalJSON(data []byte) error {
type rawServer struct {
Type string `json:"type"`
+2 -2
View File
@@ -779,7 +779,7 @@ func normalizeDefinition(raw any, field string) (Definition, error) {
return Definition{}, err
}
if matcher != "" && !eventSupportsMatcher(event) {
return Definition{}, manifestError{fieldPath: field + ".matcher", message: "matcher is only supported for beforeTool and afterTool hooks."}
return Definition{}, manifestError{fieldPath: field + ".matcher", message: "matcher is only supported for beforeTool, afterTool, specialistStart, and specialistStop hooks."}
}
command, err := requiredString(obj, field+".command")
if err != nil {
@@ -843,7 +843,7 @@ func matchesHookMatcher(matcher string, toolName string) bool {
}
func eventSupportsMatcher(event Event) bool {
return event == EventBeforeTool || event == EventAfterTool
return event == EventBeforeTool || event == EventAfterTool || event == EventSpecialistStart || event == EventSpecialistStop
}
func toDiagnostic(err error, source ConfigSource, path string) Diagnostic {
+41 -1
View File
@@ -180,7 +180,7 @@ func TestLoadConfigPreservesUserDisabledStateWhenProjectOmitsEnabled(t *testing.
}
func TestLoadConfigRejectsMatchersOnLifecycleHooks(t *testing.T) {
for _, event := range []string{"sessionStart", "specialistStart"} {
for _, event := range []string{"sessionStart", "sessionEnd"} {
t.Run(event, func(t *testing.T) {
dir := t.TempDir()
projectConfigPath := filepath.Join(dir, "hooks.json")
@@ -210,6 +210,46 @@ func TestLoadConfigRejectsMatchersOnLifecycleHooks(t *testing.T) {
}
}
func TestLoadConfigAcceptsMatchersOnSpecialistHooks(t *testing.T) {
for _, tc := range []struct {
event Event
id string
}{
{event: EventSpecialistStart, id: "zero.explorer-start"},
{event: EventSpecialistStop, id: "zero.explorer-stop"},
} {
t.Run(string(tc.event), func(t *testing.T) {
dir := t.TempDir()
projectConfigPath := filepath.Join(dir, "hooks.json")
writeHookJSON(t, projectConfigPath, map[string]any{
"hooks": []any{map[string]any{
"id": tc.id,
"event": string(tc.event),
"matcher": "explorer",
"command": "node",
}},
})
result, err := LoadConfig(LoadOptions{
UserConfigPath: filepath.Join(dir, "missing-user-hooks.json"),
ProjectConfigPath: projectConfigPath,
})
if err != nil {
t.Fatalf("LoadConfig returned error: %v", err)
}
if len(result.Config.Hooks) != 1 || result.Config.Hooks[0].Matcher != "explorer" {
t.Fatalf("specialist matcher hook = %#v", result.Config.Hooks)
}
if got := hookIDs(Select(result.Config, SelectInput{Event: tc.event, ToolName: "explorer"})); !reflect.DeepEqual(got, []string{tc.id}) {
t.Fatalf("matched selection = %#v", got)
}
if got := Select(result.Config, SelectInput{Event: tc.event, ToolName: "worker"}); len(got) != 0 {
t.Fatalf("unmatched specialist should be empty, got %#v", got)
}
})
}
}
func TestConfigStorePersistsUpdates(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "hooks.json")
store, err := NewConfigStore(StoreOptions{ConfigPath: configPath})
+287
View File
@@ -0,0 +1,287 @@
package mcp
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"sync"
"github.com/Twigpine/zero/internal/aimlapi"
"github.com/Twigpine/zero/internal/config"
"github.com/Twigpine/zero/internal/redaction"
)
// The AI/ML API MCP server authenticates with the user's AI/ML API key.
//
// Invariant: the key and the attribution headers are attached only to an
// outgoing request whose own URL is https://mcp.aimlapi.com on the default port
// with no userinfo (exact hostname, see aimlapi.IsAttributedURL), whatever the
// server is named. The key is resolved again for every request, redirect hops
// included (see reapplyAIMLAPIRedirectHeaders), through
// config.LoadAIMLAPIServiceKey, which in the same call decides whether it may
// be sent (no proxy profile, a single token), so a provider change applies to
// the next request and a withheld or invalid key means no Authorization
// header. It lives only in that request's headers, and for the length of the
// call in the collector its context carries (aimlapiSentKeys), so the reply can
// be scrubbed: never in Server, its Identity (OAuth token binding), config.json,
// list output, errors or tool output. Every key the call sent, redirect hops
// included, and on the SSE transport the keys the stream request sent, is
// scrubbed from the response's error text, method and result before anything
// reads them (scrubAIMLAPIKeys) and from any error the call returns, a
// transport error quoting a URL the server chose among them
// (scrubAIMLAPIKeyError). An error status to such a request is also reported
// without its body. A user-configured Authorization header, OAuth, or a
// project-touched server always goes without it.
var loadAIMLAPIServiceKey = config.LoadAIMLAPIServiceKey
var aimlapiMCPHost = func() string {
parsed, err := url.Parse(aimlapi.MCPServerURL)
if err != nil {
panic(err)
}
return parsed.Hostname()
}()
func isAIMLAPIMCPURL(target *url.URL) bool {
return aimlapi.IsAttributedURL(target) && strings.EqualFold(target.Hostname(), aimlapiMCPHost)
}
// aimlapiAutomaticHeaders are the headers applyAIMLAPIRequestHeaders may add.
var aimlapiAutomaticHeaders = [...]string{"Authorization", aimlapi.SourceHeaderName, aimlapi.PartnerHeaderName}
// applyAIMLAPIRequestHeaders runs after the server's own headers are set.
func applyAIMLAPIRequestHeaders(server Server, request *http.Request) {
if request == nil || !isAIMLAPIMCPURL(request.URL) {
return
}
aimlapi.ApplyAttributionHeaders(request.Header, request.URL)
if request.Header.Get("Authorization") != "" || strings.EqualFold(server.Auth, ServerAuthOAuth) || server.ProjectConfigured {
return
}
key, err := loadAIMLAPIServiceKey()
if err != nil || key == "" {
return
}
request.Header.Set("Authorization", "Bearer "+key)
if sent, ok := request.Context().Value(aimlapiSentKeysContextKey{}).(*aimlapiSentKeys); ok {
sent.add(key)
}
}
// reapplyAIMLAPIRedirectHeaders gives a redirect hop the automatic headers its
// own URL earns. net/http copies the first request's headers onto every hop
// without calling the header hooks, so without this a key or attribution added
// for the first URL would follow a same-origin hop that fails isAIMLAPIMCPURL
// (userinfo, say), and an eligible hop would reuse the first hop's key instead
// of resolving it again. The hop gets back the server's own values for these
// headers, then the same rule as any other request.
func reapplyAIMLAPIRedirectHeaders(server Server, request *http.Request, first *http.Request) {
if request == nil || first == nil || !isAIMLAPIMCPURL(first.URL) {
return
}
for _, name := range aimlapiAutomaticHeaders {
request.Header.Del(name)
}
for name, value := range server.Headers {
for _, automatic := range aimlapiAutomaticHeaders {
if strings.EqualFold(name, automatic) {
request.Header.Set(name, value)
}
}
}
applyAIMLAPIRequestHeaders(server, request)
}
// sentAIMLAPIServiceKey reports whether request went out carrying the key
// applyAIMLAPIRequestHeaders injects, as opposed to the server's own
// Authorization header or OAuth.
func sentAIMLAPIServiceKey(server Server, request *http.Request) bool {
if request == nil || !isAIMLAPIMCPURL(request.URL) || strings.EqualFold(server.Auth, ServerAuthOAuth) || server.ProjectConfigured {
return false
}
return request.Header.Get("Authorization") != "" && !hasUserAuthorizationHeader(server)
}
func hasUserAuthorizationHeader(server Server) bool {
for name, value := range server.Headers {
if strings.EqualFold(name, "Authorization") && value != "" {
return true
}
}
return false
}
// aimlapiSentKeys collects every key applyAIMLAPIRequestHeaders puts on the
// requests of one call. The first request's context carries it, and net/http
// gives each redirect hop that same context, so a key resolved again for a later
// hop is collected too. The final hop's key alone is not enough: a same-origin
// server answering the last hop has seen every earlier one.
type aimlapiSentKeys struct {
mu sync.Mutex
keys []string
}
type aimlapiSentKeysContextKey struct{}
// withAIMLAPISentKeys returns ctx carrying a fresh collector for one call.
func withAIMLAPISentKeys(ctx context.Context) (context.Context, *aimlapiSentKeys) {
sent := &aimlapiSentKeys{}
return context.WithValue(ctx, aimlapiSentKeysContextKey{}, sent), sent
}
func (sent *aimlapiSentKeys) add(key string) {
sent.mu.Lock()
defer sent.mu.Unlock()
for _, existing := range sent.keys {
if existing == key {
return
}
}
sent.keys = append(sent.keys, key)
}
// list returns a copy of the keys collected so far.
func (sent *aimlapiSentKeys) list() []string {
sent.mu.Lock()
defer sent.mu.Unlock()
return append([]string(nil), sent.keys...)
}
// scrubAIMLAPIKeys removes the injected keys from a response before any of it
// is read: the error text, the method name and every string in the result,
// object member names included. A server can echo the credential it was sent,
// and MCP reports a failed tool call as a result rather than an error, so the
// result needs it as much as the error text. Only the known values go, split
// forms included (redaction.RedactSecretValues); a result that holds none of
// them keeps its original bytes.
func scrubAIMLAPIKeys(message rpcMessage, keys ...string) rpcMessage {
var sent []string
for _, key := range keys {
if strings.TrimSpace(key) != "" {
sent = append(sent, key)
}
}
if len(sent) == 0 {
return message
}
if message.Error != nil {
scrubbed := *message.Error
scrubbed.Message = redaction.RedactSecretValues(scrubbed.Message, sent...)
message.Error = &scrubbed
}
message.Method = redaction.RedactSecretValues(message.Method, sent...)
message.Result = scrubAIMLAPIKeysFromJSON(message.Result, sent)
return message
}
func scrubAIMLAPIKeysFromJSON(raw json.RawMessage, keys []string) json.RawMessage {
if len(raw) == 0 {
return raw
}
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.UseNumber()
var value any
if err := decoder.Decode(&value); err != nil {
// Decoding it into the caller's type fails the same way, and that
// error does not quote the text.
return raw
}
scrubbed, changed := scrubAIMLAPIKeysFromValue(value, keys)
if !changed {
return raw
}
encoded, err := json.Marshal(scrubbed)
if err != nil {
// Never hand back the unscrubbed bytes: an empty result reads as
// nothing rather than as the key.
return nil
}
return encoded
}
func scrubAIMLAPIKeysFromValue(value any, keys []string) (any, bool) {
switch typed := value.(type) {
case string:
scrubbed := redaction.RedactSecretValues(typed, keys...)
return scrubbed, scrubbed != typed
case []any:
changed := false
for index, item := range typed {
if scrubbed, itemChanged := scrubAIMLAPIKeysFromValue(item, keys); itemChanged {
typed[index] = scrubbed
changed = true
}
}
return typed, changed
case map[string]any:
changed := false
scrubbed := make(map[string]any, len(typed))
for name, item := range typed {
scrubbedName := redaction.RedactSecretValues(name, keys...)
scrubbedItem, itemChanged := scrubAIMLAPIKeysFromValue(item, keys)
if itemChanged || scrubbedName != name {
changed = true
}
scrubbed[scrubbedName] = scrubbedItem
}
return scrubbed, changed
}
return value, false
}
// scrubAIMLAPIKeyError returns err with the injected keys removed from its text.
// An error the keys do not appear in keeps its chain. One they do appear in is
// replaced by its scrubbed text, because its chain still holds the key (a
// *url.Error keeps the URL the server chose); the replacement unwraps only to
// the context sentinel the original matched, so a cancellation still reads as
// one.
func scrubAIMLAPIKeyError(err error, keys ...string) error {
if err == nil || len(keys) == 0 {
return err
}
text := err.Error()
scrubbed := redaction.RedactSecretValues(text, keys...)
if scrubbed == text {
return err
}
replacement := &aimlapiScrubbedError{text: scrubbed}
for _, sentinel := range []error{context.Canceled, context.DeadlineExceeded} {
if errors.Is(err, sentinel) {
replacement.cause = sentinel
break
}
}
return replacement
}
// aimlapiScrubbedError is an error whose text had an injected key removed.
type aimlapiScrubbedError struct {
text string
cause error
}
func (scrubbed *aimlapiScrubbedError) Error() string { return scrubbed.text }
func (scrubbed *aimlapiScrubbedError) Unwrap() error { return scrubbed.cause }
// aimlapiUnauthorizedHint explains how to authenticate when the AI/ML API MCP
// server rejects a request without OAuth. It names no key material.
func aimlapiUnauthorizedHint(server Server, status int) string {
if status != http.StatusUnauthorized || strings.EqualFold(server.Auth, ServerAuthOAuth) {
return ""
}
target, err := url.Parse(server.URL)
if err != nil || !isAIMLAPIMCPURL(target) {
return ""
}
oauthHint := fmt.Sprintf("To sign in instead, set \"auth\": \"oauth\" on this server and run `zero mcp oauth login %s`", server.Name)
if server.ProjectConfigured || hasUserAuthorizationHeader(server) {
return " (" + oauthHint + ")"
}
return fmt.Sprintf(" (set up the aimlapi.com provider with `zero providers setup aimlapi` or set AIMLAPI_API_KEY; automatic injection is also disabled for proxy profiles, OAuth, project config, and non-empty Authorization headers — see docs/EXTENDING.md#built-in-servers. To sign in instead, set \"auth\": \"oauth\" on this server and run `zero mcp oauth login %s`)", server.Name)
}
+658
View File
@@ -0,0 +1,658 @@
package mcp
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"sync"
"testing"
"time"
"github.com/Twigpine/zero/internal/aimlapi"
)
// scrubTestKey has no credential shape, so only the known-value scrub can find
// it; a shape pattern would leave it alone.
const scrubTestKey = "aiml0123456789abcdef0123456789abcd"
// aimlapiEcho stands in for mcp.aimlapi.com over streamable HTTP. It answers
// each JSON-RPC request with reply(method, id), as a JSON body or as one SSE
// event, and records the Authorization header every request carried.
type aimlapiEcho struct {
sse bool
reply func(method string, id json.RawMessage) string
mu sync.Mutex
auth []string
}
func (echo *aimlapiEcho) RoundTrip(request *http.Request) (*http.Response, error) {
var body []byte
if request.Body != nil {
body, _ = io.ReadAll(request.Body)
_ = request.Body.Close()
}
echo.mu.Lock()
echo.auth = append(echo.auth, request.Header.Get("Authorization"))
echo.mu.Unlock()
var message struct {
ID json.RawMessage `json:"id"`
Method string `json:"method"`
}
_ = json.Unmarshal(body, &message)
if len(message.ID) == 0 {
return fakeHTTPResponse(request, http.StatusAccepted, ""), nil
}
reply := echo.reply(message.Method, message.ID)
response := fakeHTTPResponse(request, http.StatusOK, reply)
if echo.sse {
response.Header.Set("Content-Type", "text/event-stream")
response.Body = io.NopCloser(strings.NewReader("event: message\ndata: " + reply + "\n\n"))
}
return response, nil
}
// assertSent fails unless every request carried want as its Authorization, so a
// test cannot pass by never reaching the injected-key path.
func (echo *aimlapiEcho) assertSent(t *testing.T, want string) {
t.Helper()
echo.mu.Lock()
defer echo.mu.Unlock()
if len(echo.auth) == 0 {
t.Fatal("the fake server was never called")
}
for _, got := range echo.auth {
if got != want {
t.Fatalf("Authorization = %q, want %q", got, want)
}
}
}
func rpcReply(id json.RawMessage, field string, value any) string {
encoded, err := json.Marshal(map[string]any{"jsonrpc": "2.0", "id": id, field: value})
if err != nil {
panic(err)
}
return string(encoded)
}
// echoedKeys are the spellings of the key a server echoes back: whole, and
// split by a NUL and by a zero-width space, which a plain replace would miss.
func echoedKeys() []string {
return []string{
scrubTestKey,
scrubTestKey[:10] + string(rune(0)) + scrubTestKey[10:],
scrubTestKey[:20] + string(rune(0x200b)) + scrubTestKey[20:],
}
}
// assertNoPartOf fails if any eight-byte run of key survives in text.
func assertNoPartOf(t *testing.T, text string, key string) {
t.Helper()
for start := 0; start+8 <= len(key); start++ {
if strings.Contains(text, key[start:start+8]) {
t.Fatalf("text still holds part of the key (%q): %q", key[start:start+8], text)
}
}
}
// assertKeyScrubbed fails if any part of scrubTestKey survives in text, or if
// text does not show the replacement.
func assertKeyScrubbed(t *testing.T, text string) {
t.Helper()
assertNoPartOf(t, text, scrubTestKey)
if !strings.Contains(text, "[REDACTED]") {
t.Fatalf("text = %q, want the key replaced by [REDACTED]", text)
}
}
func TestScrubAIMLAPIKeysKeepsAResultWithoutTheKeyByteForByte(t *testing.T) {
raw := json.RawMessage(`{"tools": [ {"name": "b", "description": "password=hunter2"} ], "a": 1.50}`)
if got := scrubAIMLAPIKeysFromJSON(raw, []string{scrubTestKey}); string(got) != string(raw) {
t.Fatalf("result without the key = %s, want the original bytes %s", got, raw)
}
withKey := json.RawMessage(`{"content": [ {"type": "text", "text": "key ` + scrubTestKey + `"} ], "n": 1.50}`)
got := scrubAIMLAPIKeysFromJSON(withKey, []string{scrubTestKey})
assertKeyScrubbed(t, string(got))
var decoded struct {
Content []Content `json:"content"`
N json.Number `json:"n"`
}
if err := json.Unmarshal(got, &decoded); err != nil || len(decoded.Content) != 1 || decoded.Content[0].Text != "key [REDACTED]" || decoded.N != "1.50" {
t.Fatalf("scrubbed result = %s (%v), want valid JSON with only the key replaced and the number as written", got, err)
}
}
func newEchoNetworkClient(server Server, echo *aimlapiEcho) *networkClient {
return &networkClient{server: server, client: mcpHTTPClient(server, echo), nextID: 1}
}
func TestAIMLAPIMCPScrubsTheInjectedKeyFromResponses(t *testing.T) {
isolateAIMLAPIKeyLookup(t, scrubTestKey)
server := aimlapiTestServer("aimlapi", aimlapi.MCPServerURL)
for _, sse := range []bool{false, true} {
transport := "json"
if sse {
transport = "sse"
}
t.Run(transport+"/error message", func(t *testing.T) {
for _, echoed := range echoedKeys() {
echo := &aimlapiEcho{sse: sse, reply: func(_ string, id json.RawMessage) string {
return rpcReply(id, "error", map[string]any{"code": -32001, "message": "invalid credential " + echoed + " for this account"})
}}
err := newEchoNetworkClient(server, echo).initialize(context.Background())
if err == nil {
t.Fatal("initialize succeeded against a server that answered with an error")
}
echo.assertSent(t, "Bearer "+scrubTestKey)
assertKeyScrubbed(t, err.Error())
if !strings.Contains(err.Error(), "invalid credential [REDACTED] for this account") {
t.Fatalf("error = %q, want the rest of the server text kept", err)
}
}
})
t.Run(transport+"/tool result", func(t *testing.T) {
const image = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="
for _, echoed := range echoedKeys() {
echo := &aimlapiEcho{sse: sse, reply: func(_ string, id json.RawMessage) string {
return rpcReply(id, "result", map[string]any{
"isError": true,
"content": []any{
map[string]any{"type": "text", "text": "upstream rejected key " + echoed},
map[string]any{"type": "image", "mimeType": "image/png", "data": image},
},
})
}}
result, err := newEchoNetworkClient(server, echo).CallTool(context.Background(), "generate", map[string]any{"prompt": "x"})
if err != nil {
t.Fatalf("CallTool: %v", err)
}
echo.assertSent(t, "Bearer "+scrubTestKey)
if len(result.Content) != 2 || !result.IsError {
t.Fatalf("result = %+v, want the error result with both blocks", result)
}
assertKeyScrubbed(t, result.Content[0].Text)
if result.Content[0].Text != "upstream rejected key [REDACTED]" {
t.Fatalf("text block = %q, want only the key replaced", result.Content[0].Text)
}
if result.Content[1].Data != image {
t.Fatalf("image data changed: %q", result.Content[1].Data)
}
}
})
t.Run(transport+"/result without the key", func(t *testing.T) {
// Shapes stay as the server wrote them: only the known value goes.
const description = "Set password=hunter2 or pass Authorization: Bearer other-token"
echo := &aimlapiEcho{sse: sse, reply: func(_ string, id json.RawMessage) string {
return rpcReply(id, "result", map[string]any{"tools": []any{map[string]any{
"name": "generate",
"description": description,
"inputSchema": map[string]any{"type": "object", "properties": map[string]any{"api_key": map[string]any{"type": "string"}}},
}}})
}}
tools, err := newEchoNetworkClient(server, echo).ListTools(context.Background())
if err != nil {
t.Fatalf("ListTools: %v", err)
}
echo.assertSent(t, "Bearer "+scrubTestKey)
if len(tools) != 1 || tools[0].Description != description {
t.Fatalf("tools = %+v, want the description unchanged", tools)
}
properties, _ := tools[0].InputSchema["properties"].(map[string]any)
if _, ok := properties["api_key"]; !ok {
t.Fatalf("inputSchema = %v, want the api_key property kept", tools[0].InputSchema)
}
})
}
t.Run("json/unexpected method", func(t *testing.T) {
echo := &aimlapiEcho{reply: func(string, json.RawMessage) string {
return `{"jsonrpc":"2.0","method":"notifications/` + scrubTestKey + `"}`
}}
err := newEchoNetworkClient(server, echo).initialize(context.Background())
if err == nil || !strings.Contains(err.Error(), "got method") {
t.Fatalf("initialize error = %v, want the unexpected method reported", err)
}
echo.assertSent(t, "Bearer "+scrubTestKey)
assertKeyScrubbed(t, err.Error())
})
}
func TestAIMLAPIMCPLeavesResponsesAloneWithoutTheInjectedKey(t *testing.T) {
isolateAIMLAPIKeyLookup(t, scrubTestKey)
// A user-configured Authorization header means nothing was injected, so the
// server text is reported as written; this change scrubs only what Zero
// itself attached.
server := aimlapiTestServer("aimlapi", aimlapi.MCPServerURL)
server.Headers = map[string]string{"Authorization": "Bearer " + scrubTestKey}
echo := &aimlapiEcho{reply: func(_ string, id json.RawMessage) string {
return rpcReply(id, "error", map[string]any{"code": -32001, "message": "invalid credential " + scrubTestKey})
}}
err := newEchoNetworkClient(server, echo).initialize(context.Background())
echo.assertSent(t, "Bearer "+scrubTestKey)
if err == nil || !strings.Contains(err.Error(), "invalid credential "+scrubTestKey) {
t.Fatalf("error = %v, want the server text unchanged when no key was injected", err)
}
}
// aimlapiSSEEcho stands in for mcp.aimlapi.com over the legacy SSE transport:
// the GET opens a stream that starts with the endpoint event, and each POST is
// answered on that stream with reply(method, id).
type aimlapiSSEEcho struct {
endpoint string
reply func(method string, id json.RawMessage) string
// postErr, when set, fails every POST at the transport.
postErr error
// rawEvent, when set, writes its whole event to the stream instead of a
// message event built from reply.
rawEvent func(method string, id json.RawMessage) string
events chan string
mu sync.Mutex
auth []string
}
func newAIMLAPISSEEcho(endpoint string, reply func(string, json.RawMessage) string) *aimlapiSSEEcho {
return &aimlapiSSEEcho{endpoint: endpoint, reply: reply, events: make(chan string, 8)}
}
func (echo *aimlapiSSEEcho) RoundTrip(request *http.Request) (*http.Response, error) {
echo.mu.Lock()
echo.auth = append(echo.auth, request.Header.Get("Authorization"))
echo.mu.Unlock()
if request.Method == http.MethodGet {
reader, writer := io.Pipe()
go func() {
defer writer.Close()
if _, err := io.WriteString(writer, "event: endpoint\ndata: "+echo.endpoint+"\n\n"); err != nil {
return
}
for {
select {
case event := <-echo.events:
if _, err := io.WriteString(writer, event); err != nil {
return
}
case <-request.Context().Done():
return
}
}
}()
response := fakeHTTPResponse(request, http.StatusOK, "")
response.Header.Set("Content-Type", "text/event-stream")
response.Body = reader
return response, nil
}
if echo.postErr != nil {
return nil, echo.postErr
}
var body []byte
if request.Body != nil {
body, _ = io.ReadAll(request.Body)
_ = request.Body.Close()
}
var message struct {
ID json.RawMessage `json:"id"`
Method string `json:"method"`
}
_ = json.Unmarshal(body, &message)
if len(message.ID) > 0 {
if echo.rawEvent != nil {
echo.events <- echo.rawEvent(message.Method, message.ID)
} else {
echo.events <- "event: message\ndata: " + echo.reply(message.Method, message.ID) + "\n\n"
}
}
return fakeHTTPResponse(request, http.StatusAccepted, ""), nil
}
// rotateAIMLAPIKeys makes the key resolver hand out keys in order, repeating the
// last, the way a provider change between two requests of one call would.
func rotateAIMLAPIKeys(t *testing.T, keys ...string) {
t.Helper()
var mu sync.Mutex
previous := loadAIMLAPIServiceKey
loadAIMLAPIServiceKey = func() (string, error) {
mu.Lock()
defer mu.Unlock()
key := keys[0]
if len(keys) > 1 {
keys = keys[1:]
}
return key, nil
}
t.Cleanup(func() { loadAIMLAPIServiceKey = previous })
}
func (echo *aimlapiSSEEcho) assertSent(t *testing.T, want string, requests int) {
t.Helper()
echo.mu.Lock()
defer echo.mu.Unlock()
if len(echo.auth) < requests {
t.Fatalf("the fake server saw %d requests, want at least %d", len(echo.auth), requests)
}
for _, got := range echo.auth {
if got != want {
t.Fatalf("Authorization = %q, want %q", got, want)
}
}
}
func newEchoSSEClient(echo *aimlapiSSEEcho) *remoteSSEClient {
server := Server{Name: "aimlapi", Type: ServerTypeSSE, URL: aimlapi.MCPServerURL}
return &remoteSSEClient{
server: server,
client: mcpHTTPClient(server, echo),
nextID: 1,
pending: map[string]chan ssePendingResponse{},
}
}
func TestAIMLAPIMCPSSEScrubsTheInjectedKeyFromStreamAnswers(t *testing.T) {
isolateAIMLAPIKeyLookup(t, scrubTestKey)
for _, echoed := range echoedKeys() {
echo := newAIMLAPISSEEcho("/messages", func(method string, id json.RawMessage) string {
if method == "tools/call" {
return rpcReply(id, "result", map[string]any{"isError": true, "content": []any{map[string]any{"type": "text", "text": "rejected " + echoed}}})
}
return rpcReply(id, "error", map[string]any{"code": -32001, "message": "invalid credential " + echoed})
})
client := newEchoSSEClient(echo)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
if err := client.openStream(ctx); err != nil {
cancel()
t.Fatalf("openStream: %v", err)
}
_, listErr := client.ListTools(ctx)
result, callErr := client.CallTool(ctx, "generate", map[string]any{})
_ = client.Close()
cancel()
echo.assertSent(t, "Bearer "+scrubTestKey, 3)
if listErr == nil {
t.Fatal("ListTools succeeded against a server that answered with an error")
}
assertKeyScrubbed(t, listErr.Error())
if callErr != nil || len(result.Content) != 1 {
t.Fatalf("CallTool = %+v, %v, want the error result", result, callErr)
}
assertKeyScrubbed(t, result.Content[0].Text)
}
}
func TestAIMLAPIMCPSSEScrubsTheStreamKeyAndThePostKey(t *testing.T) {
// The key is resolved for every request, so the stream and a later POST can
// carry different keys when the provider changes in between. An answer on
// the stream comes from a server that was sent both.
const streamKey = "strm9f8e7d6c5b4a39281706f5e4d3c2b1"
const postKey = "post1a2b3c4d5e6f708192a3b4c5d6e7f8"
rotateAIMLAPIKeys(t, streamKey, postKey)
echo := newAIMLAPISSEEcho("/messages", func(_ string, id json.RawMessage) string {
return rpcReply(id, "error", map[string]any{"code": -32001, "message": "stream key " + streamKey + ", post key " + postKey})
})
client := newEchoSSEClient(echo)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := client.openStream(ctx); err != nil {
t.Fatalf("openStream: %v", err)
}
_, err := client.ListTools(ctx)
_ = client.Close()
echo.mu.Lock()
auth := append([]string(nil), echo.auth...)
echo.mu.Unlock()
if len(auth) != 2 || auth[0] != "Bearer "+streamKey || auth[1] != "Bearer "+postKey {
t.Fatalf("Authorization headers = %q, want the stream key and then the post key", auth)
}
if err == nil {
t.Fatal("ListTools succeeded against a server that answered with an error")
}
assertNoPartOf(t, err.Error(), streamKey)
assertNoPartOf(t, err.Error(), postKey)
if !strings.Contains(err.Error(), "stream key [REDACTED], post key [REDACTED]") {
t.Fatalf("error = %q, want both keys replaced", err)
}
}
func TestAIMLAPIMCPSSEScrubsTheInjectedKeyFromAnEndpointError(t *testing.T) {
isolateAIMLAPIKeyLookup(t, scrubTestKey)
// An invalid escape makes the endpoint fail to parse, and the parse error
// quotes the endpoint the server sent.
echo := newAIMLAPISSEEcho("/messages/"+scrubTestKey+"%zz", func(string, json.RawMessage) string { return "" })
client := newEchoSSEClient(echo)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
err := client.openStream(ctx)
_ = client.Close()
echo.assertSent(t, "Bearer "+scrubTestKey, 1)
if err == nil || !strings.Contains(err.Error(), "endpoint") {
t.Fatalf("openStream error = %v, want the endpoint parse failure", err)
}
assertKeyScrubbed(t, err.Error())
}
// aimlapiHopEcho answers the first request with a 307 to location and hands the
// second hop to second, recording the Authorization each hop carried.
type aimlapiHopEcho struct {
location string
second func(request *http.Request, id json.RawMessage) (*http.Response, error)
mu sync.Mutex
auth []string
}
func (echo *aimlapiHopEcho) RoundTrip(request *http.Request) (*http.Response, error) {
var body []byte
if request.Body != nil {
body, _ = io.ReadAll(request.Body)
_ = request.Body.Close()
}
echo.mu.Lock()
echo.auth = append(echo.auth, request.Header.Get("Authorization"))
hop := len(echo.auth)
echo.mu.Unlock()
if hop == 1 {
response := fakeHTTPResponse(request, http.StatusTemporaryRedirect, "")
response.Header.Set("Location", echo.location)
return response, nil
}
var message struct {
ID json.RawMessage `json:"id"`
}
_ = json.Unmarshal(body, &message)
return echo.second(request, message.ID)
}
func TestAIMLAPIMCPScrubsEveryKeySentAcrossRedirectHops(t *testing.T) {
// The key is resolved again for a redirect hop, so the hops of one call can
// carry different keys, and the same-origin server answering the last hop
// has seen them all.
const first = "aimlfirst0123456789abcdef01234567"
const second = "aimlsecond0123456789abcdef0123456"
for _, tc := range []struct {
name string
location string
// hopKey is the key the redirect hop must carry, "" for none.
hopKey string
second func(*http.Request, json.RawMessage) (*http.Response, error)
}{
{
name: "error echoes the first hop's key",
location: "https://mcp.aimlapi.com/mcp/",
hopKey: second,
second: func(request *http.Request, id json.RawMessage) (*http.Response, error) {
return fakeHTTPResponse(request, http.StatusOK, rpcReply(id, "error", map[string]any{"code": -32001, "message": "stale " + first})), nil
},
},
{
name: "status body after a hop that dropped the key",
location: "https://[email protected]/mcp",
second: func(request *http.Request, _ json.RawMessage) (*http.Response, error) {
return fakeHTTPResponse(request, http.StatusPaymentRequired, `{"error":"invalid credential `+first+`"}`), nil
},
},
{
name: "transport error quoting the redirect target",
location: "https://mcp.aimlapi.com/mcp?session=" + first,
hopKey: second,
second: func(*http.Request, json.RawMessage) (*http.Response, error) {
return nil, errors.New("connection reset by peer")
},
},
} {
t.Run(tc.name, func(t *testing.T) {
rotateAIMLAPIKeys(t, first, second)
echo := &aimlapiHopEcho{location: tc.location, second: tc.second}
server := aimlapiTestServer("aimlapi", aimlapi.MCPServerURL)
client := &networkClient{server: server, client: mcpHTTPClient(server, echo), nextID: 1}
err := client.initialize(context.Background())
echo.mu.Lock()
auth := append([]string(nil), echo.auth...)
echo.mu.Unlock()
if len(auth) != 2 || auth[0] != "Bearer "+first {
t.Fatalf("Authorization per hop = %q, want the first key on the first request and a redirect hop", auth)
}
if tc.hopKey != "" && auth[1] != "Bearer "+tc.hopKey {
t.Fatalf("redirect hop Authorization = %q, want the key resolved again for it", auth[1])
}
// With no key, net/http fills Basic auth from the hop's own userinfo.
if tc.hopKey == "" && (strings.Contains(auth[1], first) || strings.Contains(auth[1], second)) {
t.Fatalf("redirect hop Authorization = %q, want no automatic key", auth[1])
}
if err == nil {
t.Fatal("initialize succeeded against a failing server")
}
assertNoPartOf(t, err.Error(), first)
assertNoPartOf(t, err.Error(), second)
if !strings.Contains(err.Error(), "[REDACTED]") {
t.Fatalf("error = %q, want the echoed key replaced", err)
}
})
}
}
func TestAIMLAPIMCPSSEScrubsTheStreamKeyFromAFailedPost(t *testing.T) {
// The server chose an endpoint holding the key its stream request carried.
// A POST to it that fails at the transport quotes that URL, and the POST
// itself carried a different key, so only the stream's keys can scrub it.
const streamKey = "strm0a1b2c3d4e5f60718293a4b5c6d7e8"
const postKey = "post9e8d7c6b5a4f30291807f6e5d4c3b2"
rotateAIMLAPIKeys(t, streamKey, postKey)
echo := newAIMLAPISSEEcho("/messages/"+streamKey, func(string, json.RawMessage) string { return "" })
echo.postErr = errors.New("connection reset by peer")
client := newEchoSSEClient(echo)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := client.openStream(ctx); err != nil {
t.Fatalf("openStream: %v", err)
}
_, err := client.ListTools(ctx)
_ = client.Close()
echo.mu.Lock()
auth := append([]string(nil), echo.auth...)
echo.mu.Unlock()
if len(auth) != 2 || auth[0] != "Bearer "+streamKey || auth[1] != "Bearer "+postKey {
t.Fatalf("Authorization headers = %q, want the stream key and then the post key", auth)
}
if err == nil || !strings.Contains(err.Error(), "post MCP SSE message") {
t.Fatalf("ListTools error = %v, want the failed POST", err)
}
assertNoPartOf(t, err.Error(), streamKey)
assertNoPartOf(t, err.Error(), postKey)
if !strings.Contains(err.Error(), "/messages/[REDACTED]") {
t.Fatalf("error = %q, want the endpoint quoted with the key replaced", err)
}
}
func TestAIMLAPIMCPSSEScrubsTheKeyFromALaterEndpointError(t *testing.T) {
// An endpoint event can arrive after the stream is open. Its parse error
// then fails the pending requests directly rather than returning from
// openStream, so it has to be scrubbed where it is made.
isolateAIMLAPIKeyLookup(t, scrubTestKey)
echo := newAIMLAPISSEEcho("/messages", nil)
echo.rawEvent = func(string, json.RawMessage) string {
return "event: endpoint\ndata: /messages/" + scrubTestKey + "%zz\n\n"
}
client := newEchoSSEClient(echo)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := client.openStream(ctx); err != nil {
t.Fatalf("openStream: %v", err)
}
_, err := client.ListTools(ctx)
_ = client.Close()
echo.assertSent(t, "Bearer "+scrubTestKey, 2)
if err == nil || !strings.Contains(err.Error(), "endpoint") {
t.Fatalf("ListTools error = %v, want the later endpoint's parse failure", err)
}
assertKeyScrubbed(t, err.Error())
}
func TestAIMLAPIMCPSSEScrubsKeysFromAFailedStreamRedirect(t *testing.T) {
// The stream request follows redirects like any other, so a hop that fails
// at the transport quotes the target the server chose, which can hold the
// key the first hop carried.
const first = "strmfirst0123456789abcdef01234567"
const second = "strmsecond0123456789abcdef0123456"
rotateAIMLAPIKeys(t, first, second)
echo := &aimlapiHopEcho{
location: "https://mcp.aimlapi.com/mcp?session=" + first,
second: func(*http.Request, json.RawMessage) (*http.Response, error) {
return nil, errors.New("connection reset by peer")
},
}
server := Server{Name: "aimlapi", Type: ServerTypeSSE, URL: aimlapi.MCPServerURL}
client := &remoteSSEClient{server: server, client: mcpHTTPClient(server, echo), nextID: 1, pending: map[string]chan ssePendingResponse{}}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
err := client.openStream(ctx)
_ = client.Close()
echo.mu.Lock()
auth := append([]string(nil), echo.auth...)
echo.mu.Unlock()
if len(auth) != 2 || auth[0] != "Bearer "+first || auth[1] != "Bearer "+second {
t.Fatalf("Authorization per hop = %q, want the first key and then the second", auth)
}
if err == nil || !strings.Contains(err.Error(), "open MCP SSE stream") {
t.Fatalf("openStream error = %v, want the failed stream request", err)
}
assertNoPartOf(t, err.Error(), first)
assertNoPartOf(t, err.Error(), second)
if !strings.Contains(err.Error(), "session=[REDACTED]") {
t.Fatalf("error = %q, want the redirect target quoted with the key replaced", err)
}
}
func TestScrubAIMLAPIKeyErrorKeepsOnlyTheContextSentinel(t *testing.T) {
for _, sentinel := range []error{context.Canceled, context.DeadlineExceeded} {
original := fmt.Errorf("post MCP SSE message to aimlapi: %w", &url.Error{Op: "Post", URL: "https://mcp.aimlapi.com/messages/" + scrubTestKey, Err: sentinel})
scrubbed := scrubAIMLAPIKeyError(original, scrubTestKey)
assertKeyScrubbed(t, scrubbed.Error())
if !errors.Is(scrubbed, sentinel) {
t.Fatalf("scrubbed error %q no longer reads as %v", scrubbed, sentinel)
}
if unwrapped := errors.Unwrap(scrubbed); unwrapped != sentinel {
t.Fatalf("scrubbed error unwraps to %v, want only %v", unwrapped, sentinel)
}
var urlErr *url.Error
if errors.As(scrubbed, &urlErr) {
t.Fatalf("scrubbed error still exposes the *url.Error holding the key: %v", urlErr.URL)
}
}
if scrubbed := scrubAIMLAPIKeyError(fmt.Errorf("decode: %s", scrubTestKey), scrubTestKey); errors.Unwrap(scrubbed) != nil {
t.Fatalf("scrubbed error without a context sentinel unwraps to %v, want nothing", errors.Unwrap(scrubbed))
}
untouched := fmt.Errorf("call MCP: %w", context.Canceled)
if got := scrubAIMLAPIKeyError(untouched, scrubTestKey); got != untouched {
t.Fatalf("an error without the key was replaced: %v", got)
}
}
+537
View File
@@ -0,0 +1,537 @@
package mcp
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"github.com/Twigpine/zero/internal/aimlapi"
"github.com/Twigpine/zero/internal/config"
)
// aimlapiRecorder stands in for mcp.aimlapi.com: it records every request it
// is handed (the URL stays https://mcp.aimlapi.com/mcp, nothing is dialed) and
// answers JSON-RPC, or 401 with the server's OAuth challenge when unauthorized.
type aimlapiRecorder struct {
mu sync.Mutex
headers []http.Header
urls []string
unauthorized bool
// redirectFirstTo, when set, answers the first request with a 307 to it.
redirectFirstTo string
// failStatus, when set, answers every request with it and failBody.
failStatus int
failBody string
}
func (recorder *aimlapiRecorder) RoundTrip(request *http.Request) (*http.Response, error) {
var body []byte
if request.Body != nil {
body, _ = io.ReadAll(request.Body)
_ = request.Body.Close()
}
recorder.mu.Lock()
recorder.headers = append(recorder.headers, request.Header.Clone())
recorder.urls = append(recorder.urls, request.URL.String())
unauthorized := recorder.unauthorized
redirect := recorder.redirectFirstTo != "" && len(recorder.headers) == 1
recorder.mu.Unlock()
if redirect {
response := fakeHTTPResponse(request, http.StatusTemporaryRedirect, "")
response.Header.Set("Location", recorder.redirectFirstTo)
return response, nil
}
if recorder.failStatus != 0 {
return fakeHTTPResponse(request, recorder.failStatus, recorder.failBody), nil
}
if unauthorized {
response := fakeHTTPResponse(request, http.StatusUnauthorized, `{"error":"invalid_token"}`)
response.Header.Set("WWW-Authenticate", `Bearer resource_metadata="https://mcp.aimlapi.com/.well-known/oauth-protected-resource/mcp"`)
return response, nil
}
var message struct {
ID json.RawMessage `json:"id"`
Method string `json:"method"`
}
_ = json.Unmarshal(body, &message)
if len(message.ID) == 0 {
return fakeHTTPResponse(request, http.StatusAccepted, ""), nil
}
result := `{}`
switch message.Method {
case "initialize":
result = `{"protocolVersion":"2024-11-05","capabilities":{},"serverInfo":{"name":"fake","version":"1"}}`
case "tools/list":
result = `{"tools":[{"name":"tools_search","inputSchema":{"type":"object"}}]}`
}
return fakeHTTPResponse(request, http.StatusOK, fmt.Sprintf(`{"jsonrpc":"2.0","id":%s,"result":%s}`, message.ID, result)), nil
}
func (recorder *aimlapiRecorder) recorded() []http.Header {
recorder.mu.Lock()
defer recorder.mu.Unlock()
return append([]http.Header(nil), recorder.headers...)
}
func fakeHTTPResponse(request *http.Request, status int, body string) *http.Response {
header := http.Header{}
if body != "" {
header.Set("Content-Type", "application/json")
}
return &http.Response{
StatusCode: status,
Header: header,
Body: io.NopCloser(bytes.NewBufferString(body)),
Request: request,
}
}
// isolateAIMLAPIKeyLookup points the real key resolver at an empty temporary
// home and returns the user config path it reads.
func isolateAIMLAPIKeyLookup(t *testing.T, envKey string) string {
t.Helper()
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
t.Setenv("APPDATA", filepath.Join(home, "AppData"))
t.Setenv("ZERO_CRED_STORAGE", "file")
t.Setenv("ZERO_PROVIDER", "")
t.Setenv("AIMLAPI_INFERENCE_URL", "")
t.Setenv("AIMLAPI_PARTNER_ID", "")
t.Setenv("AIMLAPI_API_KEY", envKey)
path, err := config.DefaultUserConfigPath()
if err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
t.Fatal(err)
}
return path
}
func writeAIMLAPIProviderConfig(t *testing.T, path string, baseURL string, apiKey string) {
t.Helper()
profile := map[string]any{"name": "aimlapi", "catalogID": "aimlapi", "model": "m", "apiKey": apiKey}
if baseURL != "" {
profile["baseURL"] = baseURL
}
data, err := json.Marshal(map[string]any{"activeProvider": "aimlapi", "providers": []any{profile}})
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, data, 0o600); err != nil {
t.Fatal(err)
}
}
func newRecordedNetworkClient(server Server, recorder *aimlapiRecorder) *networkClient {
return &networkClient{server: server, client: mcpHTTPClient(server, recorder), nextID: 1}
}
func aimlapiTestServer(name string, rawURL string) Server {
return Server{Name: name, Type: ServerTypeHTTP, URL: rawURL}
}
func assertAIMLAPIAttribution(t *testing.T, header http.Header) {
t.Helper()
if got := header.Get(aimlapi.SourceHeaderName); got != aimlapi.SourceValue {
t.Fatalf("%s = %q, want %q", aimlapi.SourceHeaderName, got, aimlapi.SourceValue)
}
if got, want := header.Get(aimlapi.PartnerHeaderName), aimlapi.ResolvePartnerID(""); got == "" || got != want {
t.Fatalf("%s = %q, want %q", aimlapi.PartnerHeaderName, got, want)
}
}
func TestAIMLAPIMCPRequestsCarryServiceKeyAndAttribution(t *testing.T) {
isolateAIMLAPIKeyLookup(t, "aiml-secret")
// A hand-added server on the exact host is treated like the built-in one.
for _, name := range []string{"aimlapi", "media"} {
recorder := &aimlapiRecorder{}
client := newRecordedNetworkClient(aimlapiTestServer(name, aimlapi.MCPServerURL), recorder)
if err := client.initialize(context.Background()); err != nil {
t.Fatalf("%s: initialize: %v", name, err)
}
if _, err := client.ListTools(context.Background()); err != nil {
t.Fatalf("%s: ListTools: %v", name, err)
}
requests := recorder.recorded()
if len(requests) != 3 {
t.Fatalf("%s: recorded %d requests, want initialize, initialized and tools/list", name, len(requests))
}
for _, header := range requests {
if got := header.Get("Authorization"); got != "Bearer aiml-secret" {
t.Fatalf("%s: Authorization = %q, want the AI/ML API key", name, got)
}
assertAIMLAPIAttribution(t, header)
}
}
}
func TestAIMLAPIMCPHeadersOnlyForExactHost(t *testing.T) {
isolateAIMLAPIKeyLookup(t, "aiml-secret")
for _, rawURL := range []string{
"https://mcp.aimlapi.com.example.net/mcp",
"http://mcp.aimlapi.com/mcp",
"https://mcp.aimlapi.com:8443/mcp",
"https://[email protected]/mcp",
"https://api.aimlapi.com/mcp",
"https://evilmcp.aimlapi.com/mcp",
"https://mcp.exa.ai/mcp",
} {
recorder := &aimlapiRecorder{}
server := aimlapiTestServer("other", rawURL)
server.Headers = map[string]string{"X-Custom": "kept"}
client := newRecordedNetworkClient(server, recorder)
if err := client.initialize(context.Background()); err != nil {
t.Fatalf("%s: initialize: %v", rawURL, err)
}
for _, header := range recorder.recorded() {
if strings.Contains(rawURL, "user@") && strings.HasPrefix(header.Get("Authorization"), "Basic ") {
// net/http turns URL userinfo into Basic auth on its own.
header.Del("Authorization")
}
// Byte-for-byte what any other MCP server gets.
want := http.Header{
"X-Custom": {"kept"},
"Content-Type": {"application/json"},
"Accept": {"application/json, text/event-stream"},
}
if fmt.Sprint(header) != fmt.Sprint(want) {
t.Fatalf("%s: headers = %v, want %v", rawURL, header, want)
}
}
}
// Port 443 spelled out and a different host case are still the exact host.
for _, rawURL := range []string{"https://mcp.aimlapi.com:443/mcp", "https://MCP.AIMLAPI.COM/mcp"} {
recorder := &aimlapiRecorder{}
client := newRecordedNetworkClient(aimlapiTestServer("aimlapi", rawURL), recorder)
if err := client.initialize(context.Background()); err != nil {
t.Fatalf("%s: initialize: %v", rawURL, err)
}
header := recorder.recorded()[0]
if header.Get("Authorization") != "Bearer aiml-secret" {
t.Fatalf("%s: Authorization = %q, want the key", rawURL, header.Get("Authorization"))
}
assertAIMLAPIAttribution(t, header)
}
}
// The key and its eligibility are resolved for every request, so switching the
// aimlapi profile to a proxy keeps the proxy's key off mcp.aimlapi.com on the
// very next request of an already-connected client and on new connections.
func TestAIMLAPIMCPKeyIsResolvedPerRequestAcrossProviderSwitch(t *testing.T) {
path := isolateAIMLAPIKeyLookup(t, "")
writeAIMLAPIProviderConfig(t, path, "", "canonical-key")
recorder := &aimlapiRecorder{}
server := aimlapiTestServer("aimlapi", aimlapi.MCPServerURL)
client := newRecordedNetworkClient(server, recorder)
if err := client.initialize(context.Background()); err != nil {
t.Fatalf("initialize: %v", err)
}
if got := recorder.recorded()[0].Get("Authorization"); got != "Bearer canonical-key" {
t.Fatalf("canonical profile: Authorization = %q, want Bearer canonical-key", got)
}
writeAIMLAPIProviderConfig(t, path, "https://proxy.example/v1", "proxy-key")
if _, err := client.ListTools(context.Background()); err != nil {
t.Fatalf("ListTools after switch: %v", err)
}
fresh := newRecordedNetworkClient(server, recorder)
if err := fresh.initialize(context.Background()); err != nil {
t.Fatalf("initialize after switch: %v", err)
}
requests := recorder.recorded()
for _, header := range requests[2:] {
if got := header.Get("Authorization"); got != "" {
t.Fatalf("proxy profile: Authorization = %q, want none", got)
}
assertAIMLAPIAttribution(t, header)
}
for _, header := range requests {
if strings.Contains(fmt.Sprint(header), "proxy-key") {
t.Fatalf("proxy key reached mcp.aimlapi.com: %v", header)
}
}
}
func TestAIMLAPIMCPUnusableKeySendsNoAuthorization(t *testing.T) {
cases := map[string]string{
"multi-line key pool": "first-key\nsecond-key",
"comma key pool": "first-key,second-key",
"no key": "",
}
for name, apiKey := range cases {
t.Run(name, func(t *testing.T) {
path := isolateAIMLAPIKeyLookup(t, "")
writeAIMLAPIProviderConfig(t, path, "", apiKey)
recorder := &aimlapiRecorder{unauthorized: true}
client := newRecordedNetworkClient(aimlapiTestServer("aimlapi", aimlapi.MCPServerURL), recorder)
err := client.initialize(context.Background())
if err == nil {
t.Fatal("initialize succeeded against a 401")
}
requests := recorder.recorded()
if len(requests) != 1 {
t.Fatalf("recorded %d requests, want one intact request", len(requests))
}
header := requests[0]
if got := header.Get("Authorization"); got != "" {
t.Fatalf("Authorization = %q, want none", got)
}
if header.Get("Content-Type") != "application/json" {
t.Fatalf("request headers broken: %v", header)
}
assertAIMLAPIAttribution(t, header)
if !strings.Contains(err.Error(), "returned HTTP 401") || !strings.Contains(err.Error(), "zero mcp oauth login aimlapi") || !strings.Contains(err.Error(), "AIMLAPI_API_KEY") {
t.Fatalf("401 error lacks the authentication hint: %v", err)
}
if strings.Contains(err.Error(), "first-key") {
t.Fatalf("error leaks key material: %v", err)
}
})
}
}
func TestAIMLAPIMCPUnauthorizedHintOnlyForAIMLAPIWithoutOAuth(t *testing.T) {
if hint := aimlapiUnauthorizedHint(aimlapiTestServer("docs", "https://mcp.example.com/mcp"), http.StatusUnauthorized); hint != "" {
t.Fatalf("hint for another server = %q", hint)
}
oauth := aimlapiTestServer("aimlapi", aimlapi.MCPServerURL)
oauth.Auth = ServerAuthOAuth
if hint := aimlapiUnauthorizedHint(oauth, http.StatusUnauthorized); hint != "" {
t.Fatalf("hint for an OAuth server = %q", hint)
}
if hint := aimlapiUnauthorizedHint(aimlapiTestServer("aimlapi", aimlapi.MCPServerURL), http.StatusForbidden); hint != "" {
t.Fatalf("hint for 403 = %q", hint)
}
}
func TestAIMLAPIMCPKeyDefersToUserAuthorizationOAuthAndProjectConfig(t *testing.T) {
isolateAIMLAPIKeyLookup(t, "aiml-secret")
userHeader := aimlapiTestServer("aimlapi", aimlapi.MCPServerURL)
userHeader.Headers = map[string]string{"authorization": "Bearer user-token"}
oauth := aimlapiTestServer("aimlapi", aimlapi.MCPServerURL)
oauth.Auth = ServerAuthOAuth
project := aimlapiTestServer("aimlapi", aimlapi.MCPServerURL)
project.ProjectConfigured = true
cases := []struct {
name string
server Server
want string
}{
{"user Authorization header", userHeader, "Bearer user-token"},
{"OAuth server", oauth, ""},
{"project-configured server", project, ""},
}
for _, tc := range cases {
recorder := &aimlapiRecorder{}
client := newRecordedNetworkClient(tc.server, recorder)
if err := client.initialize(context.Background()); err != nil {
t.Fatalf("%s: initialize: %v", tc.name, err)
}
header := recorder.recorded()[0]
if got := header.Get("Authorization"); got != tc.want {
t.Fatalf("%s: Authorization = %q, want %q", tc.name, got, tc.want)
}
assertAIMLAPIAttribution(t, header)
}
}
func TestAIMLAPIMCPSSERequestsCarryKeyAndAttribution(t *testing.T) {
isolateAIMLAPIKeyLookup(t, "aiml-secret")
server := Server{Name: "aimlapi", Type: ServerTypeSSE, URL: aimlapi.MCPServerURL}
client := &remoteSSEClient{server: server}
apply := map[string]func(*http.Request){
"stream": client.applyStreamHeaders,
"post": client.applyPostHeaders,
}
for name, applyHeaders := range apply {
request, err := http.NewRequest(http.MethodPost, aimlapi.MCPServerURL, nil)
if err != nil {
t.Fatal(err)
}
applyHeaders(request)
if got := request.Header.Get("Authorization"); got != "Bearer aiml-secret" {
t.Fatalf("%s: Authorization = %q, want the key", name, got)
}
assertAIMLAPIAttribution(t, request.Header)
other, err := http.NewRequest(http.MethodPost, "https://mcp.aimlapi.com.example.net/mcp", nil)
if err != nil {
t.Fatal(err)
}
applyHeaders(other)
if other.Header.Get("Authorization") != "" || other.Header.Get(aimlapi.SourceHeaderName) != "" || other.Header.Get(aimlapi.PartnerHeaderName) != "" {
t.Fatalf("%s: lookalike host got AI/ML API headers: %v", name, other.Header)
}
}
}
func TestAIMLAPIMCPKeyStaysOutOfServerConfigAndIdentity(t *testing.T) {
enabled := config.MCPConfig{Servers: map[string]config.MCPServerConfig{
"aimlapi": {Type: "http", URL: aimlapi.MCPServerURL},
}}
isolateAIMLAPIKeyLookup(t, "")
withoutKey, err := NormalizeConfig(enabled)
if err != nil {
t.Fatal(err)
}
isolateAIMLAPIKeyLookup(t, "aiml-secret")
withKey, err := NormalizeConfig(enabled)
if err != nil {
t.Fatal(err)
}
if len(withKey) != 1 || withKey[0].Identity != withoutKey[0].Identity {
t.Fatalf("server identity depends on the key: %#v vs %#v", withKey, withoutKey)
}
if strings.Contains(fmt.Sprintf("%#v", withKey), "aiml-secret") {
t.Fatalf("normalized server carries the key: %#v", withKey)
}
// The shipped default is off, so it is not even normalized for a session.
defaults, err := NormalizeConfig(config.MCPConfig{Servers: config.DefaultMCPServers()})
if err != nil {
t.Fatal(err)
}
for _, server := range defaults {
if server.Name == "aimlapi" {
t.Fatalf("disabled aimlapi default was normalized for connection: %#v", server)
}
}
}
func TestAIMLAPIMCPUnauthorizedHintRespectsCredentialEligibility(t *testing.T) {
for _, tc := range []struct {
name string
headers map[string]string
project bool
wantSetup bool
}{
{"default", nil, false, true},
{"project", nil, true, false},
{"custom bearer", map[string]string{"Authorization": "Bearer manual"}, false, false},
{"mixed case header", map[string]string{"aUtHoRiZaTiOn": "Bearer manual"}, false, false},
{"empty header", map[string]string{"authorization": ""}, false, true},
} {
t.Run(tc.name, func(t *testing.T) {
server := aimlapiTestServer("aimlapi", aimlapi.MCPServerURL)
server.Headers = tc.headers
server.ProjectConfigured = tc.project
hint := aimlapiUnauthorizedHint(server, http.StatusUnauthorized)
if strings.Contains(hint, "zero providers setup aimlapi") != tc.wantSetup || !strings.Contains(hint, "zero mcp oauth login aimlapi") {
t.Fatalf("incorrect hint: %s", hint)
}
})
}
}
// net/http copies the first request's headers onto every redirect hop without
// calling the header hooks, so the redirect guard has to apply the per-request
// rule again: a same-origin hop that fails the exact-URL rule (userinfo) gets
// neither the key nor attribution, an eligible hop gets a freshly resolved key,
// and a user's own Authorization header still follows as before.
func TestAIMLAPIMCPRedirectHopsReapplyAutomaticHeaders(t *testing.T) {
isolateAIMLAPIKeyLookup(t, "")
userHeader := aimlapiTestServer("aimlapi", aimlapi.MCPServerURL)
userHeader.Headers = map[string]string{"authorization": "Bearer user-token"}
for _, tc := range []struct {
name string
server Server
location string
wantFirst string
wantHop string
wantAttribution bool
}{
{"userinfo hop", aimlapiTestServer("aimlapi", aimlapi.MCPServerURL), "https://[email protected]/mcp", "Bearer aiml-first", "", false},
{"eligible hop", aimlapiTestServer("aimlapi", aimlapi.MCPServerURL), "https://mcp.aimlapi.com/mcp/", "Bearer aiml-first", "Bearer aiml-second", true},
{"user header hop", userHeader, "https://[email protected]/mcp", "Bearer user-token", "Bearer user-token", false},
} {
t.Run(tc.name, func(t *testing.T) {
keys := []string{"aiml-first", "aiml-second"}
previous := loadAIMLAPIServiceKey
loadAIMLAPIServiceKey = func() (string, error) {
key := keys[0]
if len(keys) > 1 {
keys = keys[1:]
}
return key, nil
}
t.Cleanup(func() { loadAIMLAPIServiceKey = previous })
recorder := &aimlapiRecorder{redirectFirstTo: tc.location}
client := newRecordedNetworkClient(tc.server, recorder)
if err := client.initialize(context.Background()); err != nil {
t.Fatalf("initialize: %v", err)
}
requests := recorder.recorded()
if len(requests) < 2 || recorder.urls[1] != tc.location {
t.Fatalf("redirect was not followed: %v", recorder.urls)
}
if got := requests[0].Get("Authorization"); got != tc.wantFirst {
t.Fatalf("first hop Authorization = %q, want %q", got, tc.wantFirst)
}
hop := requests[1]
// With no key, net/http fills Basic auth from the hop's own userinfo;
// only the automatic key must be absent.
if got := hop.Get("Authorization"); (tc.wantHop == "" && strings.Contains(got, "aiml-")) || (tc.wantHop != "" && got != tc.wantHop) {
t.Fatalf("redirect hop Authorization = %q, want %q", got, tc.wantHop)
}
if tc.wantAttribution {
assertAIMLAPIAttribution(t, hop)
} else if hop.Get(aimlapi.SourceHeaderName) != "" || hop.Get(aimlapi.PartnerHeaderName) != "" {
t.Fatalf("redirect hop kept attribution headers: %v", hop)
}
})
}
}
// An error body can echo the key Zero injected; the body is left out of the
// error rather than matched, so a key cut by the 1024-byte read limit cannot
// leak its prefix either. Requests without the automatic key keep the detail.
func TestAIMLAPIMCPStatusErrorOmitsBodyWhenKeyWasInjected(t *testing.T) {
const key = "sk-echo-0123456789abcdef"
isolateAIMLAPIKeyLookup(t, key)
for _, tc := range []struct {
name string
body string
}{
{"echoed key", `{"error":"invalid credential ` + key + `"}`},
{"key across the read limit", strings.Repeat("x", 1020) + key},
} {
t.Run(tc.name, func(t *testing.T) {
recorder := &aimlapiRecorder{failStatus: http.StatusPaymentRequired, failBody: tc.body}
err := newRecordedNetworkClient(aimlapiTestServer("aimlapi", aimlapi.MCPServerURL), recorder).initialize(context.Background())
if err == nil {
t.Fatal("initialize succeeded against a failing server")
}
if got := recorder.recorded()[0].Get("Authorization"); got != "Bearer "+key {
t.Fatalf("request did not carry the injected key: %q", got)
}
if strings.Contains(err.Error(), key[:4]) || !strings.Contains(err.Error(), "HTTP 402") {
t.Fatalf("error = %q, want HTTP 402 without the echoed key", err)
}
})
}
userHeader := aimlapiTestServer("aimlapi", aimlapi.MCPServerURL)
userHeader.Headers = map[string]string{"Authorization": "Bearer user-token"}
recorder := &aimlapiRecorder{failStatus: http.StatusPaymentRequired, failBody: `{"error":"insufficient balance"}`}
err := newRecordedNetworkClient(userHeader, recorder).initialize(context.Background())
if err == nil || !strings.Contains(err.Error(), "insufficient balance") {
t.Fatalf("error without the automatic key = %v, want the response detail", err)
}
}
+60 -20
View File
@@ -35,6 +35,11 @@ type remoteSSEClient struct {
pending map[string]chan ssePendingResponse
streamErr error
closed bool
// streamKeys are the injected AI/ML API keys the stream request carried,
// redirect hops included. openStream sets them once, before the reader
// starts; the endpoint and the messages on the stream come from a server
// that was sent them.
streamKeys []string
}
type ssePendingResponse struct {
@@ -259,12 +264,13 @@ func (client *remoteSSEClient) request(ctx context.Context, method string, param
client.pending[key] = responseChannel
client.mu.Unlock()
if err := client.post(ctx, rpcMessage{
sentKeys, err := client.post(ctx, rpcMessage{
JSONRPC: "2.0",
ID: id,
Method: method,
Params: rawParams,
}); err != nil {
})
if err != nil {
client.removePending(key)
return err
}
@@ -274,14 +280,15 @@ func (client *remoteSSEClient) request(ctx context.Context, method string, param
if response.err != nil {
return response.err
}
if !rpcIDMatches(response.message.ID, id) {
message := scrubAIMLAPIKeys(response.message, append(append([]string(nil), client.streamKeys...), sentKeys...)...)
if !rpcIDMatches(message.ID, id) {
return fmt.Errorf("MCP %s response id mismatch for server %s", method, client.server.Name)
}
if response.message.Error != nil {
return fmt.Errorf("MCP %s failed: %s", method, response.message.Error.Message)
if message.Error != nil {
return fmt.Errorf("MCP %s failed: %s", method, message.Error.Message)
}
if target != nil && len(response.message.Result) > 0 {
if err := json.Unmarshal(response.message.Result, target); err != nil {
if target != nil && len(message.Result) > 0 {
if err := json.Unmarshal(message.Result, target); err != nil {
return fmt.Errorf("decode MCP %s result: %w", method, err)
}
}
@@ -320,11 +327,12 @@ func (client *remoteSSEClient) notify(ctx context.Context, method string, params
if err != nil {
return err
}
return client.post(ctx, rpcMessage{
_, err = client.post(ctx, rpcMessage{
JSONRPC: "2.0",
Method: method,
Params: rawParams,
})
return err
}
func (client *networkClient) post(ctx context.Context, message rpcMessage, expectResponse bool) (result rpcMessage, err error) {
@@ -332,6 +340,11 @@ func (client *networkClient) post(ctx context.Context, message rpcMessage, expec
if err != nil {
return rpcMessage{}, err
}
ctx, sent := withAIMLAPISentKeys(ctx)
// From here an error can quote the server or a URL it chose, a redirect
// target among them, so every key the call sent, on any hop, is scrubbed
// from it. This runs last, after the body is closed.
defer func() { err = scrubAIMLAPIKeyError(err, sent.list()...) }()
request, err := http.NewRequestWithContext(ctx, http.MethodPost, client.server.URL, bytes.NewReader(body))
if err != nil {
return rpcMessage{}, fmt.Errorf("create MCP %s request for %s: %w", client.server.Type, client.server.Name, err)
@@ -357,12 +370,18 @@ func (client *networkClient) post(ctx context.Context, message rpcMessage, expec
if response.StatusCode == http.StatusNoContent {
return rpcMessage{}, fmt.Errorf("MCP %s server %s returned no response body", client.server.Type, client.server.Name)
}
return client.decodeResponse(response)
decoded, err := client.decodeResponse(response)
if err != nil {
return rpcMessage{}, err
}
return scrubAIMLAPIKeys(decoded, sent.list()...), nil
}
func (client *remoteSSEClient) openStream(ctx context.Context) error {
func (client *remoteSSEClient) openStream(ctx context.Context) (err error) {
streamCtx, cancel := context.WithCancel(ctx)
client.streamCancel = cancel
streamCtx, sent := withAIMLAPISentKeys(streamCtx)
defer func() { err = scrubAIMLAPIKeyError(err, sent.list()...) }()
request, err := http.NewRequestWithContext(streamCtx, http.MethodGet, client.server.URL, nil)
if err != nil {
@@ -380,6 +399,7 @@ func (client *remoteSSEClient) openStream(ctx context.Context) error {
return err
}
client.streamBody = response.Body
client.streamKeys = sent.list()
ready := make(chan error, 1)
go client.readStream(response.Body, ready)
@@ -391,31 +411,41 @@ func (client *remoteSSEClient) openStream(ctx context.Context) error {
}
}
func (client *remoteSSEClient) post(ctx context.Context, message rpcMessage) (err error) {
// post sends message to the endpoint and returns the injected AI/ML API keys the
// request carried, redirect hops included, since the answer arrives on the
// stream from a server that was sent them. The endpoint is the server's own
// choice and can hold one of the stream's keys, and a transport error quotes it,
// so the stream's keys are scrubbed from any error as well as the POST's.
func (client *remoteSSEClient) post(ctx context.Context, message rpcMessage) (sentKeys []string, err error) {
ctx, sent := withAIMLAPISentKeys(ctx)
defer func() {
sentKeys = sent.list()
err = scrubAIMLAPIKeyError(err, append(append([]string(nil), client.streamKeys...), sentKeys...)...)
}()
endpointURL, err := client.currentEndpoint()
if err != nil {
return err
return nil, err
}
body, err := json.Marshal(message)
if err != nil {
return err
return nil, err
}
request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpointURL, bytes.NewReader(body))
if err != nil {
return fmt.Errorf("create MCP SSE post for %s: %w", client.server.Name, err)
return nil, fmt.Errorf("create MCP SSE post for %s: %w", client.server.Name, err)
}
client.applyPostHeaders(request)
response, err := client.client.Do(request)
if err != nil {
return fmt.Errorf("post MCP SSE message to %s: %w", client.server.Name, err)
return nil, fmt.Errorf("post MCP SSE message to %s: %w", client.server.Name, err)
}
defer closeResponseBody(&err, client.server, response.Body)
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
return httpStatusError(client.server, response)
return nil, httpStatusError(client.server, response)
}
_, _ = io.Copy(io.Discard, response.Body)
return nil
return nil, nil
}
func (client *networkClient) applyHeaders(request *http.Request) {
@@ -427,6 +457,7 @@ func (client *networkClient) applyHeaders(request *http.Request) {
if client.sessionID != "" {
request.Header.Set("Mcp-Session-Id", client.sessionID)
}
applyAIMLAPIRequestHeaders(client.server, request)
}
func (client *remoteSSEClient) applyStreamHeaders(request *http.Request) {
@@ -434,6 +465,7 @@ func (client *remoteSSEClient) applyStreamHeaders(request *http.Request) {
request.Header.Set(key, value)
}
request.Header.Set("Accept", "text/event-stream")
applyAIMLAPIRequestHeaders(client.server, request)
}
func (client *remoteSSEClient) applyPostHeaders(request *http.Request) {
@@ -442,6 +474,7 @@ func (client *remoteSSEClient) applyPostHeaders(request *http.Request) {
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Accept", "application/json")
applyAIMLAPIRequestHeaders(client.server, request)
}
func closeResponseBody(errp *error, server Server, body io.Closer) {
@@ -523,7 +556,8 @@ func (client *remoteSSEClient) readStream(reader io.Reader, ready chan<- error)
func (client *remoteSSEClient) setEndpoint(value string) error {
endpointURL, err := resolveSSEEndpointURL(client.server.URL, strings.TrimSpace(value))
if err != nil {
return err
// A URL parse error quotes the endpoint, which is the server's text.
return scrubAIMLAPIKeyError(err, client.streamKeys...)
}
client.mu.Lock()
client.endpointURL = endpointURL
@@ -607,12 +641,18 @@ func (client *remoteSSEClient) failPending(err error) {
}
func httpStatusError(server Server, response *http.Response) error {
hint := aimlapiUnauthorizedHint(server, response.StatusCode)
if sentAIMLAPIServiceKey(server, response.Request) {
// The body is server text that may echo the key Zero injected, whole or
// cut by the read limit, so it is left out rather than matched.
return fmt.Errorf("MCP %s server %s returned HTTP %d (response body omitted: the request carried your AI/ML API key)%s", server.Type, server.Name, response.StatusCode, hint)
}
body, _ := io.ReadAll(io.LimitReader(response.Body, 1024))
detail := strings.TrimSpace(string(body))
if detail == "" {
return fmt.Errorf("MCP %s server %s returned HTTP %d", server.Type, server.Name, response.StatusCode)
return fmt.Errorf("MCP %s server %s returned HTTP %d%s", server.Type, server.Name, response.StatusCode, hint)
}
return fmt.Errorf("MCP %s server %s returned HTTP %d: %s", server.Type, server.Name, response.StatusCode, detail)
return fmt.Errorf("MCP %s server %s returned HTTP %d: %s%s", server.Type, server.Name, response.StatusCode, detail, hint)
}
func decodeSSERPCMessage(reader io.Reader) (rpcMessage, error) {
+1
View File
@@ -28,6 +28,7 @@ func checkMCPRedirect(server Server) func(*http.Request, []*http.Request) error
if !sameMCPOrigin(via[0].URL, request.URL) {
return fmt.Errorf("MCP %s server %s refused cross-origin redirect to %s", server.Type, server.Name, mcpOrigin(request.URL))
}
reapplyAIMLAPIRedirectHeaders(server, request, via[0])
return nil
}
}
@@ -0,0 +1,14 @@
package observability
import (
"os"
"github.com/Twigpine/zero/internal/pathjail"
)
// renameNoReplace publishes a crash report without replacing an existing one.
// The implementation lives in pathjail because the file tools need the same
// atomic no-replace guarantee.
func renameNoReplace(root *os.Root, oldname, newname string) error {
return pathjail.RenameNoReplace(root, oldname, newname)
}
@@ -1,19 +0,0 @@
//go:build darwin
package observability
import (
"fmt"
"os"
"golang.org/x/sys/unix"
)
func renameNoReplace(root *os.Root, oldname, newname string) error {
directory, err := root.Open(".")
if err != nil {
return fmt.Errorf("open crash directory for publication: %w", err)
}
defer directory.Close()
return unix.RenameatxNp(int(directory.Fd()), oldname, int(directory.Fd()), newname, unix.RENAME_EXCL)
}
@@ -1,19 +0,0 @@
//go:build linux
package observability
import (
"fmt"
"os"
"golang.org/x/sys/unix"
)
func renameNoReplace(root *os.Root, oldname, newname string) error {
directory, err := root.Open(".")
if err != nil {
return fmt.Errorf("open crash directory for publication: %w", err)
}
defer directory.Close()
return unix.Renameat2(int(directory.Fd()), oldname, int(directory.Fd()), newname, unix.RENAME_NOREPLACE)
}
+12 -2
View File
@@ -142,12 +142,22 @@ func trimTrailingSeparators(path string) string {
var randomBytes = rand.Read
// CreateTemp makes an O_EXCL temp file named prefix.<random>suffix inside dir,
// relative to handle, and returns it with its relative path.
// relative to handle, and returns it with its relative path. The file is
// created 0o600 so bytes staged in it are not readable before publication.
//
// O_EXCL rather than a fixed name: a link planted at a predictable temp path
// between the check and the create is the same race this package exists to
// close, and O_EXCL makes the kernel refuse it rather than us.
func CreateTemp(handle *os.Root, dir, prefix, suffix string) (*os.File, string, error) {
return CreateTempMode(handle, dir, prefix, suffix, 0o600)
}
// CreateTempMode is CreateTemp with an explicit create mode. The mode is
// passed to the kernel's exclusive create, so the process umask still applies;
// callers that must publish the permissions a normal create would have been
// granted should read the returned file's mode rather than assigning perm
// later, because Chmod does not consult the umask.
func CreateTempMode(handle *os.Root, dir, prefix, suffix string, perm os.FileMode) (*os.File, string, error) {
// prefix and suffix are NAME fragments, not path fragments. A separator or a
// "."/".." in either would relocate the file out of dir — a prefix of
// "../escaped", or a suffix of ".tmp/../evil" that also erases the random
@@ -164,7 +174,7 @@ func CreateTemp(handle *os.Root, dir, prefix, suffix string) (*os.File, string,
return nil, "", fmt.Errorf("generate a temporary name: %w", err)
}
relative := filepath.Join(dir, prefix+"."+hex.EncodeToString(noise)+suffix)
file, err := handle.OpenFile(relative, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0o600)
file, err := handle.OpenFile(relative, os.O_RDWR|os.O_CREATE|os.O_EXCL, perm)
if err == nil {
return file, relative, nil
}
+28
View File
@@ -0,0 +1,28 @@
package pathjail
import (
"fmt"
"os"
"path/filepath"
)
// RenameNoReplace renames oldname to newname within the directory bound by
// root, and refuses to replace an existing newname with the same atomic
// guarantee the kernel gives a create's exclusive open. A caller that loses
// the race receives fs.ErrExist and nothing at newname changes.
//
// Both names must be single path components: they are resolved relative to
// root's directory handle, so a separator (or an ancestor link) must not be
// able to redirect the syscall outside the checked root. Callers with a path
// in a subdirectory open that directory with Root.OpenRoot first.
//
// Platforms whose kernel cannot publish without replacing fall back to
// ErrUnsupported, which lets the caller use a no-replace hard link instead.
func RenameNoReplace(root *os.Root, oldname, newname string) error {
for _, name := range []string{oldname, newname} {
if name == "" || name == "." || name == ".." || filepath.Base(name) != name {
return fmt.Errorf("%w: rename name %q must be a single path component", ErrEscapes, name)
}
}
return renameNoReplace(root, oldname, newname)
}
@@ -0,0 +1,22 @@
//go:build darwin
package pathjail
import (
"fmt"
"os"
"golang.org/x/sys/unix"
)
func renameNoReplace(root *os.Root, oldname, newname string) error {
directory, err := root.Open(".")
if err != nil {
return fmt.Errorf("open directory for rename: %w", err)
}
defer directory.Close()
if err := unix.RenameatxNp(int(directory.Fd()), oldname, int(directory.Fd()), newname, unix.RENAME_EXCL); err != nil {
return fmt.Errorf("rename %s to %s: %w", oldname, newname, err)
}
return nil
}
@@ -0,0 +1,22 @@
//go:build linux
package pathjail
import (
"fmt"
"os"
"golang.org/x/sys/unix"
)
func renameNoReplace(root *os.Root, oldname, newname string) error {
directory, err := root.Open(".")
if err != nil {
return fmt.Errorf("open directory for rename: %w", err)
}
defer directory.Close()
if err := unix.Renameat2(int(directory.Fd()), oldname, int(directory.Fd()), newname, unix.RENAME_NOREPLACE); err != nil {
return fmt.Errorf("rename %s to %s: %w", oldname, newname, err)
}
return nil
}
@@ -1,6 +1,6 @@
//go:build !darwin && !linux && !windows
package observability
package pathjail
import (
"errors"
@@ -0,0 +1,79 @@
package pathjail
import (
"errors"
"os"
"path/filepath"
"testing"
)
func openRenameRoot(t *testing.T) (*os.Root, string) {
t.Helper()
base := t.TempDir()
root, err := os.OpenRoot(base)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = root.Close() })
return root, base
}
// A CREATE'S NAME MUST NOT BE LOST TO A RACE. The no-replace rename reports the
// collision as fs.ErrExist and leaves both files exactly as they were, so the
// caller can refuse the create instead of replacing the other writer.
func TestRenameNoReplaceRefusesAnExistingDestination(t *testing.T) {
root, base := openRenameRoot(t)
if err := os.WriteFile(filepath.Join(base, "source"), []byte("source\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(base, "target"), []byte("target\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := RenameNoReplace(root, "source", "target"); !errors.Is(err, os.ErrExist) {
t.Fatalf("RenameNoReplace over an existing name = %v, want os.ErrExist", err)
}
for name, want := range map[string]string{"source": "source\n", "target": "target\n"} {
got, err := os.ReadFile(filepath.Join(base, name))
if err != nil || string(got) != want {
t.Fatalf("%s = %q, err=%v; a refused rename must not move either file", name, got, err)
}
}
}
func TestRenameNoReplacePublishesAnAbsentDestination(t *testing.T) {
root, base := openRenameRoot(t)
if err := os.WriteFile(filepath.Join(base, "source"), []byte("whole\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := RenameNoReplace(root, "source", "target"); err != nil {
t.Fatalf("RenameNoReplace = %v", err)
}
if got, err := os.ReadFile(filepath.Join(base, "target")); err != nil || string(got) != "whole\n" {
t.Fatalf("target = %q, err=%v", got, err)
}
if _, err := os.Stat(filepath.Join(base, "source")); !os.IsNotExist(err) {
t.Fatalf("source survived a successful rename: %v", err)
}
}
// The syscall is given a directory handle and bare names. Allowing a
// separator here would let an ancestor component resolve outside the directory
// the caller checked, so it is refused before the syscall.
func TestRenameNoReplaceRejectsPathComponents(t *testing.T) {
root, _ := openRenameRoot(t)
for _, tc := range []struct{ oldname, newname string }{
{"", "target"},
{"source", ""},
{".", "target"},
{"source", ".."},
{"dir/source", "target"},
{"source", "dir/target"},
{filepath.Join("dir", "source"), "target"},
} {
if err := RenameNoReplace(root, tc.oldname, tc.newname); !errors.Is(err, ErrEscapes) {
t.Errorf("RenameNoReplace(%q, %q) = %v, want ErrEscapes", tc.oldname, tc.newname, err)
}
}
}
@@ -1,6 +1,6 @@
//go:build windows
package observability
package pathjail
import (
"errors"
+1 -1
View File
@@ -374,7 +374,7 @@ func (tool skillTool) Run(_ context.Context, args map[string]any) tools.Result {
available := make([]string, 0, len(merged))
for _, skill := range merged {
if skill.Name == name {
return tools.Result{Status: tools.StatusOK, Output: skill.Content}
return tools.Result{Status: tools.StatusOK, Output: skills.ToolOutput(skill)}
}
available = append(available, skill.Name)
}
+25
View File
@@ -673,3 +673,28 @@ func TestExpandPluginRootPathRelative(t *testing.T) {
}
}
}
// The multi-root skill tool is the one the CLI registers, so it has to point at
// a skill's bundled files the same way the core tool does (#584).
func TestNewSkillToolPointsAtTheSkillsBundledFiles(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
dir := t.TempDir()
writeTestSkill(t, dir, "pdf", "Run scripts/fill.py.")
script := filepath.Join(dir, "pdf", "scripts", "fill.py")
if err := os.MkdirAll(filepath.Dir(script), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(script, []byte("print('fill')\n"), 0o755); err != nil {
t.Fatal(err)
}
got := NewSkillTool(dir, nil).Run(context.Background(), map[string]any{"name": "pdf"})
if got.Status != tools.StatusOK {
t.Fatalf("Status = %s (output: %s)", got.Status, got.Output)
}
if !strings.Contains(got.Output, "- scripts/fill.py") || !strings.Contains(got.Output, filepath.Join(dir, "pdf")) {
t.Fatalf("output does not point at the bundled script:\n%s", got.Output)
}
}
+552
View File
@@ -0,0 +1,552 @@
package plugins
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"strings"
"github.com/Twigpine/zero/internal/installtxn"
"github.com/Twigpine/zero/internal/pathjail"
)
// SetEnabledResult describes a successful enable/disable of one plugin manifest.
type SetEnabledResult struct {
ID string `json:"id"`
Enabled bool `json:"enabled"`
Changed bool `json:"changed"`
Source Source `json:"source"`
ManifestPath string `json:"manifestPath"`
}
// maxEnabledRelockAttempts bounds how many times SetEnabledByID re-resolves the
// plugin after it moves to another install root while waiting for the lock.
const maxEnabledRelockAttempts = 4
// FindByID returns the loaded plugin with the given id after normal discovery
// and precedence (project overrides user). A missing id returns ok=false.
func FindByID(options LoadOptions, id string) (LoadedPlugin, bool, error) {
id = strings.TrimSpace(id)
if id == "" {
return LoadedPlugin{}, false, errors.New("plugin id is required")
}
result, err := Load(options)
if err != nil {
return LoadedPlugin{}, false, err
}
for _, plugin := range result.Plugins {
if plugin.ID == id {
return plugin, true, nil
}
}
return LoadedPlugin{}, false, nil
}
// SetEnabledByID finds a plugin by id (respecting LoadOptions) and toggles the
// top-level "enabled" field of its plugin.json.
//
// The read-modify-write is serialized with plugin installs and removals: it
// takes the same cross-process installtxn lock they take on the plugin's
// install root, runs the same interrupted-transaction recovery they run under
// it, re-resolves the plugin under that lock, and publishes the
// manifest with an atomic rename while still holding it, so a concurrent
// install can never be interleaved between the read and the write. Only the
// bytes of the "enabled" value change, so field order, formatting, and numeric
// literals in the rest of the manifest survive the toggle.
func SetEnabledByID(options LoadOptions, id string, enabled bool) (SetEnabledResult, error) {
for attempt := 0; ; attempt++ {
plugin, ok, err := FindByID(options, id)
if err != nil {
return SetEnabledResult{}, err
}
if !ok {
return SetEnabledResult{}, fmt.Errorf("%w: %q", ErrNotInstalled, id)
}
// Take the install root lock before the final lookup and read so an
// install or remove cannot swap the tree between them.
unlock, err := installtxn.Lock(plugin.Root)
if err != nil {
return SetEnabledResult{}, err
}
// Put back anything an earlier install or removal was killed mid-commit,
// and abort if a transaction could not be resolved: editing a tree that is
// still owed a restore loses the edit when the next install or removal
// puts the backup back over it. See installtxn.Recover.
if err := installtxn.Recover(plugin.Root, reconcileInterrupted(plugin.Root)); err != nil {
unlock()
return SetEnabledResult{}, err
}
final, ok, err := FindByID(options, id)
if err != nil {
unlock()
return SetEnabledResult{}, err
}
if !ok {
unlock()
return SetEnabledResult{}, fmt.Errorf("%w: %q", ErrNotInstalled, id)
}
if final.Root != plugin.Root {
// Precedence moved to another root while this lock was pending;
// retry against the root that wins now so the edit still serializes
// with that root's installs.
unlock()
if attempt >= maxEnabledRelockAttempts {
return SetEnabledResult{}, fmt.Errorf("plugin %q kept changing roots while waiting for the install lock", id)
}
continue
}
changed, err := setEnabledLocked(final, enabled)
unlock()
if err != nil {
return SetEnabledResult{}, err
}
return SetEnabledResult{
ID: final.ID,
Enabled: enabled,
Changed: changed,
Source: final.Source,
ManifestPath: final.ManifestPath,
}, nil
}
}
// setEnabledLocked performs the manifest read-modify-write while the caller
// holds the installtxn lock for the plugin's install root.
//
// The lock only serializes cooperating installers; it does not stop the plugin
// directory from being swapped after discovery found it. So the whole update
// works on one directory, pinned by openPluginDir: the manifest is read and
// published, and the tree hashed for the lockfile, through that handle, never
// by re-resolving the path.
func setEnabledLocked(plugin LoadedPlugin, enabled bool) (bool, error) {
pinned, err := openPluginDir(plugin)
if err != nil {
return false, err
}
defer pinned.Close()
return setEnabledPinned(plugin, pinned, enabled)
}
// setEnabledPinned is setEnabledLocked on an already pinned plugin directory.
//
// The edit lands in the pinned directory even if the path now names another
// one, so before refreshing the lockfile or reporting any result it confirms
// that the directory entry still is the pinned directory. Otherwise the
// command would report a state that the installed plugin does not have.
func setEnabledPinned(plugin LoadedPlugin, pinned *pinnedPluginDir, enabled bool) (bool, error) {
manifestName := filepath.Base(plugin.ManifestPath)
raw, perm, err := readManifest(pinned.dir, manifestName)
if err != nil {
return false, err
}
data, changed, err := toggleEnabledManifest(raw, enabled)
if err != nil {
return false, err
}
if !changed {
if err := pinned.verifyLive(); err != nil {
return false, err
}
return false, nil
}
// Read the lockfile before the edit so the refresh below only runs when the
// installed tree still matched its recorded hash; a toggle must never mask
// pre-existing drift.
lock, entry, hashClean := cleanLockEntry(plugin, pinned.dir)
if err := publishManifest(pinned.dir, manifestName, data, perm); err != nil {
return false, err
}
if !hashClean {
if err := pinned.verifyLive(); err != nil {
return false, err
}
return true, nil
}
after, err := hashRootedTree(pinned.dir)
if err != nil {
return true, fmt.Errorf("plugin manifest updated but %s hash refresh failed: %w", LockFileName, err)
}
if err := pinned.verifyLive(); err != nil {
return false, err
}
entry.Hash = after
lock[plugin.ID] = entry
if err := writeLock(plugin.Root, lock); err != nil {
return true, fmt.Errorf("plugin manifest updated but %s hash refresh failed: %w", LockFileName, err)
}
return true, nil
}
// errPluginDirReplaced reports that the plugin's directory entry no longer
// names the directory the toggle pinned and edited.
var errPluginDirReplaced = errors.New("plugin directory was replaced while it was being updated; the plugin installed there now was not changed")
// pinnedPluginDir is the plugin directory discovery found, held open for the
// length of one toggle.
type pinnedPluginDir struct {
root *os.Root // the plugin's install root
name string // the plugin directory's entry name in root
path string // the plugin directory's path, for messages
dir *os.Root // the plugin directory itself
}
// Close releases both handles.
func (p *pinnedPluginDir) Close() {
_ = p.dir.Close()
_ = p.root.Close()
}
// verifyLive confirms that the plugin's entry in its install root is still a
// real directory, not a symlink, junction, or other reparse point, and is the
// very directory p.dir holds open.
func (p *pinnedPluginDir) verifyLive() error {
opened, err := p.dir.Stat(".")
if err != nil {
return fmt.Errorf("inspect plugin directory: %w", err)
}
entry, err := p.root.Lstat(p.name)
if errors.Is(err, fs.ErrNotExist) {
return fmt.Errorf("%w: %s", errPluginDirReplaced, p.path)
}
if err != nil {
return fmt.Errorf("inspect plugin directory: %w", err)
}
if entry.Mode()&(os.ModeSymlink|os.ModeIrregular) != 0 {
return fmt.Errorf("%w: plugin directory %s", pathjail.ErrReparse, p.path)
}
if !os.SameFile(entry, opened) {
return fmt.Errorf("%w: %s", errPluginDirReplaced, p.path)
}
return nil
}
// openPluginDir pins the plugin directory discovery found, confined to the
// plugin's install root.
//
// A link swapped in before the open is refused here. One swapped in after it
// cannot redirect anything, because every later read, write, and hash is
// relative to the returned handle rather than to a path; verifyLive then
// detects that the path no longer names the edited directory.
func openPluginDir(plugin LoadedPlugin) (*pinnedPluginDir, error) {
root, name, err := pathjail.Open(plugin.Root, plugin.PluginDir)
if err != nil {
return nil, fmt.Errorf("open plugin directory: %w", err)
}
if name == "." || filepath.Base(name) != name {
_ = root.Close()
return nil, fmt.Errorf("%w: plugin directory %s is not an entry of %s", pathjail.ErrEscapes, plugin.PluginDir, plugin.Root)
}
if err := pathjail.RefuseReparse(root, name); err != nil {
_ = root.Close()
return nil, fmt.Errorf("open plugin directory: %w", err)
}
dir, err := root.OpenRoot(name)
if err != nil {
_ = root.Close()
return nil, fmt.Errorf("open plugin directory: %w", err)
}
pinned := &pinnedPluginDir{root: root, name: name, path: plugin.PluginDir, dir: dir}
if err := pinned.verifyLive(); err != nil {
pinned.Close()
return nil, err
}
return pinned, nil
}
// readManifest reads name from dir and returns its bytes and permission bits.
// Failing on a stat error keeps the publish from widening the permissions.
func readManifest(dir *os.Root, name string) ([]byte, os.FileMode, error) {
file, err := dir.Open(name)
if err != nil {
return nil, 0, fmt.Errorf("read plugin manifest: %w", err)
}
defer file.Close()
info, err := file.Stat()
if err != nil {
return nil, 0, fmt.Errorf("inspect plugin manifest: %w", err)
}
raw, err := io.ReadAll(file)
if err != nil {
return nil, 0, fmt.Errorf("read plugin manifest: %w", err)
}
return raw, info.Mode().Perm(), nil
}
// publishManifest replaces name in dir with data by renaming a complete
// temporary sibling over it, so a concurrent reader sees either the old
// manifest or the new one, never a partial write.
func publishManifest(dir *os.Root, name string, data []byte, perm os.FileMode) error {
temp, tempName, err := pathjail.CreateTemp(dir, ".", ".zero-manifest", "")
if err != nil {
return fmt.Errorf("write plugin manifest: %w", err)
}
defer func() { _ = dir.Remove(tempName) }()
if err := temp.Chmod(perm); err != nil {
_ = temp.Close()
return fmt.Errorf("write plugin manifest: %w", err)
}
if _, err := temp.Write(data); err != nil {
_ = temp.Close()
return fmt.Errorf("write plugin manifest: %w", err)
}
if err := temp.Sync(); err != nil {
_ = temp.Close()
return fmt.Errorf("write plugin manifest: %w", err)
}
if err := temp.Close(); err != nil {
return fmt.Errorf("write plugin manifest: %w", err)
}
if err := dir.Rename(tempName, name); err != nil {
return fmt.Errorf("write plugin manifest: %w", err)
}
return nil
}
// cleanLockEntry reports whether the pinned plugin directory dir still matches
// the lockfile entry recorded for the plugin and returns both for a refresh.
func cleanLockEntry(plugin LoadedPlugin, dir *os.Root) (map[string]LockEntry, LockEntry, bool) {
lock, err := ReadLock(plugin.Root)
if err != nil {
return nil, LockEntry{}, false
}
entry, ok := lock[plugin.ID]
if !ok {
return nil, LockEntry{}, false
}
current, err := hashRootedTree(dir)
if err != nil || current != entry.Hash {
return nil, LockEntry{}, false
}
return lock, entry, true
}
// toggleEnabledManifest returns raw with the top-level "enabled" member set to
// enabled. A missing or null value counts as enabled, matching the loader's
// optionalBool fallback. changed reports whether the result differs from raw.
func toggleEnabledManifest(raw []byte, enabled bool) (out []byte, changed bool, err error) {
dec := json.NewDecoder(bytes.NewReader(raw))
openTok, err := dec.Token()
if err != nil {
return nil, false, fmt.Errorf("parse plugin manifest: %w", err)
}
if delim, ok := openTok.(json.Delim); !ok || delim != '{' {
return nil, false, errors.New("plugin manifest must be a JSON object")
}
openEnd := dec.InputOffset()
current := true
lastInvalid := false
var valueStart, valueEnd int64 = -1, -1
for dec.More() {
keyTok, err := dec.Token()
if err != nil {
return nil, false, fmt.Errorf("parse plugin manifest: %w", err)
}
key, ok := keyTok.(string)
if !ok {
return nil, false, errors.New("plugin manifest must be a JSON object")
}
keyEnd := dec.InputOffset()
if key != "enabled" {
if err := skipManifestValue(dec); err != nil {
return nil, false, fmt.Errorf("parse plugin manifest: %w", err)
}
continue
}
valueTok, err := dec.Token()
if err != nil {
return nil, false, fmt.Errorf("parse plugin manifest: %w", err)
}
var literal string
switch value := valueTok.(type) {
case bool:
current = value
literal = "false"
if value {
literal = "true"
}
case nil:
current = true
literal = "null"
default:
// Only the last occurrence of a duplicate key counts, as in the
// loader's json.Unmarshal, so an earlier non-boolean value that a
// later one overwrites is not an error. Remember it and decide after
// the last member.
if err := skipManifestComposite(dec, valueTok); err != nil {
return nil, false, fmt.Errorf("parse plugin manifest: %w", err)
}
lastInvalid = true
continue
}
lastInvalid = false
start, err := enabledValueOffset(raw, keyEnd)
if err != nil {
return nil, false, err
}
if start+int64(len(literal)) > int64(len(raw)) ||
!bytes.Equal(raw[start:start+int64(len(literal))], []byte(literal)) {
return nil, false, errors.New("parse plugin manifest: cannot locate the enabled value")
}
// A duplicate key keeps the last occurrence, matching json.Unmarshal.
valueStart, valueEnd = start, start+int64(len(literal))
}
closingTok, err := dec.Token()
if err != nil {
return nil, false, fmt.Errorf("parse plugin manifest: %w", err)
}
if delim, ok := closingTok.(json.Delim); !ok || delim != '}' {
return nil, false, errors.New("plugin manifest must be a JSON object")
}
closingEnd := dec.InputOffset()
if _, err := dec.Token(); err != io.EOF {
return nil, false, errors.New("parse plugin manifest: unexpected data after the top-level object")
}
if lastInvalid {
return nil, false, ManifestError{FieldPath: "enabled", Message: "Expected a boolean."}
}
if current == enabled {
return nil, false, nil
}
newLiteral := "false"
if enabled {
newLiteral = "true"
}
if valueStart >= 0 {
out := make([]byte, 0, len(raw)-int(valueEnd-valueStart)+len(newLiteral))
out = append(out, raw[:valueStart]...)
out = append(out, newLiteral...)
out = append(out, raw[valueEnd:]...)
return out, true, nil
}
return insertEnabledMember(raw, openEnd, closingEnd-1, newLiteral), true, nil
}
// enabledValueOffset returns the byte offset of the value that follows the
// colon after an "enabled" key token that ended at keyEnd.
func enabledValueOffset(raw []byte, keyEnd int64) (int64, error) {
i := keyEnd
for i < int64(len(raw)) && isJSONSpace(raw[i]) {
i++
}
if i >= int64(len(raw)) || raw[i] != ':' {
return 0, errors.New("parse plugin manifest: missing colon after key")
}
i++
for i < int64(len(raw)) && isJSONSpace(raw[i]) {
i++
}
return i, nil
}
// insertEnabledMember adds a top-level "enabled" member to a manifest that has
// none, mirroring the document's own whitespace style where it can. openEnd is
// the byte offset just after the opening brace and bracePos is the byte index
// of the closing brace.
func insertEnabledMember(raw []byte, openEnd, bracePos int64, literal string) []byte {
insertPos := bracePos
for insertPos > openEnd && isJSONSpace(raw[insertPos-1]) {
insertPos--
}
newline := "\n"
if bytes.Contains(raw, []byte("\r\n")) {
newline = "\r\n"
}
first := openEnd
for first < bracePos && isJSONSpace(raw[first]) {
first++
}
empty := first >= bracePos
multiline := bytes.IndexByte(raw[openEnd:bracePos], '\n') >= 0
member := `"enabled": ` + literal
var insert string
switch {
case empty && !multiline:
insert = member
case empty && multiline:
insert = newline + closingLineIndent(raw, openEnd, bracePos) + " " + member
case !empty && !multiline:
insert = "," + member
default:
insert = "," + newline + memberLineIndent(raw, openEnd, first) + member
}
out := make([]byte, 0, len(raw)+len(insert))
out = append(out, raw[:insertPos]...)
out = append(out, insert...)
out = append(out, raw[insertPos:]...)
return out
}
// memberLineIndent returns the indentation of the line the first member of a
// JSON object starts on. first is the byte index of that member's first token.
func memberLineIndent(raw []byte, openEnd, first int64) string {
lineStart := first
for lineStart > openEnd && raw[lineStart-1] != '\n' {
lineStart--
}
end := lineStart
for end < first && (raw[end] == ' ' || raw[end] == '\t') {
end++
}
return string(raw[lineStart:end])
}
// closingLineIndent returns the indentation before an object's closing brace.
func closingLineIndent(raw []byte, openEnd, bracePos int64) string {
start := bracePos
for start > openEnd && (raw[start-1] == ' ' || raw[start-1] == '\t') {
start--
}
return string(raw[start:bracePos])
}
// skipManifestValue consumes the next JSON value from dec.
func skipManifestValue(dec *json.Decoder) error {
tok, err := dec.Token()
if err != nil {
return err
}
return skipManifestComposite(dec, tok)
}
// skipManifestComposite consumes the rest of the value whose first token tok
// was just read from dec. A scalar is already complete; an object or array is
// consumed through its matching closing delimiter.
func skipManifestComposite(dec *json.Decoder, tok json.Token) error {
delim, ok := tok.(json.Delim)
if !ok || (delim != '{' && delim != '[') {
return nil
}
depth := 1
for depth > 0 {
tok, err := dec.Token()
if err != nil {
return err
}
if inner, ok := tok.(json.Delim); ok {
switch inner {
case '{', '[':
depth++
case '}', ']':
depth--
}
}
}
return nil
}
func isJSONSpace(b byte) bool {
return b == ' ' || b == '\t' || b == '\n' || b == '\r'
}
+999
View File
@@ -0,0 +1,999 @@
package plugins
import (
"errors"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"github.com/Twigpine/zero/internal/installtxn"
"github.com/Twigpine/zero/internal/pathjail"
)
func enableTestOptions(root string) LoadOptions {
return LoadOptions{Roots: []Root{{Source: SourceUser, Path: root}}}
}
func writeRawManifest(t *testing.T, root, dir, raw string) string {
t.Helper()
pluginDir := filepath.Join(root, dir)
if err := os.MkdirAll(pluginDir, 0o700); err != nil {
t.Fatal(err)
}
manifestPath := filepath.Join(pluginDir, "plugin.json")
if err := os.WriteFile(manifestPath, []byte(raw), 0o600); err != nil {
t.Fatal(err)
}
return manifestPath
}
func demoManifest(enabled bool) string {
state := "false"
if enabled {
state = "true"
}
return `{
"schemaVersion": 1,
"id": "zero.demo",
"name": "Demo",
"version": "1.0.0",
"enabled": ` + state + `
}
`
}
func TestSetEnabledByIDPreservesManifestBytes(t *testing.T) {
root := t.TempDir()
raw := `{
"schemaVersion": 1,
"id": "zero.demo",
"name": "Demo",
"version": "1.0.0",
"limits": {
"maxBytes": 9007199254740993
},
"description": "keep me",
"enabled": true,
"tools": []
}
`
manifestPath := writeRawManifest(t, root, "demo", raw)
result, err := SetEnabledByID(enableTestOptions(root), "zero.demo", false)
if err != nil {
t.Fatalf("SetEnabledByID(false): %v", err)
}
if !result.Changed || result.Enabled {
t.Fatalf("unexpected result: %#v", result)
}
disabled, err := os.ReadFile(manifestPath)
if err != nil {
t.Fatal(err)
}
want := strings.Replace(raw, `"enabled": true`, `"enabled": false`, 1)
if string(disabled) != want {
t.Fatalf("toggle changed more than the enabled value:\n got %q\nwant %q", disabled, want)
}
if !strings.Contains(string(disabled), "9007199254740993") {
t.Fatal("large integer literal was rounded by the toggle")
}
plugin, ok, err := FindByID(enableTestOptions(root), "zero.demo")
if err != nil || !ok {
t.Fatalf("FindByID after disable: ok=%v err=%v", ok, err)
}
if plugin.Enabled {
t.Fatal("loader still reports the plugin enabled after disable")
}
result, err = SetEnabledByID(enableTestOptions(root), "zero.demo", true)
if err != nil {
t.Fatalf("SetEnabledByID(true): %v", err)
}
if !result.Changed || !result.Enabled {
t.Fatalf("unexpected enable result: %#v", result)
}
enabled, err := os.ReadFile(manifestPath)
if err != nil {
t.Fatal(err)
}
if string(enabled) != raw {
t.Fatalf("round trip did not restore the original bytes:\n got %q\nwant %q", enabled, raw)
}
}
func TestSetEnabledByIDTogglesManifestAndIsIdempotent(t *testing.T) {
root := t.TempDir()
writePluginManifest(t, filepath.Join(root, "demo"), map[string]any{
"schemaVersion": 1,
"id": "zero.demo",
"name": "Demo",
"version": "1.0.0",
"description": "keep me",
"tools": []any{},
})
manifestPath := filepath.Join(root, "demo", "plugin.json")
result, err := SetEnabledByID(enableTestOptions(root), "zero.demo", false)
if err != nil {
t.Fatalf("SetEnabledByID(false): %v", err)
}
if !result.Changed {
t.Fatal("expected first disable to change the manifest")
}
raw, err := os.ReadFile(manifestPath)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(raw), `"enabled": false`) {
t.Fatalf("manifest not disabled: %s", raw)
}
if !strings.Contains(string(raw), `"keep me"`) {
t.Fatalf("description was not preserved: %s", raw)
}
result, err = SetEnabledByID(enableTestOptions(root), "zero.demo", false)
if err != nil {
t.Fatalf("SetEnabledByID(false) again: %v", err)
}
if result.Changed {
t.Fatal("expected second disable to be a no-op")
}
result, err = SetEnabledByID(enableTestOptions(root), "zero.demo", true)
if err != nil {
t.Fatalf("SetEnabledByID(true): %v", err)
}
if !result.Changed {
t.Fatal("expected enable to change the manifest")
}
}
func TestSetEnabledByIDInsertsMissingField(t *testing.T) {
t.Run("pretty manifest", func(t *testing.T) {
root := t.TempDir()
raw := `{
"schemaVersion": 1,
"id": "zero.demo",
"name": "Demo",
"version": "1.0.0"
}
`
manifestPath := writeRawManifest(t, root, "demo", raw)
if _, err := SetEnabledByID(enableTestOptions(root), "zero.demo", false); err != nil {
t.Fatalf("SetEnabledByID(false): %v", err)
}
disabled, err := os.ReadFile(manifestPath)
if err != nil {
t.Fatal(err)
}
wantDisabled := `{
"schemaVersion": 1,
"id": "zero.demo",
"name": "Demo",
"version": "1.0.0",
"enabled": false
}
`
if string(disabled) != wantDisabled {
t.Fatalf("after insert:\n got %q\nwant %q", disabled, wantDisabled)
}
if _, err := SetEnabledByID(enableTestOptions(root), "zero.demo", true); err != nil {
t.Fatalf("SetEnabledByID(true): %v", err)
}
enabled, err := os.ReadFile(manifestPath)
if err != nil {
t.Fatal(err)
}
wantEnabled := strings.Replace(wantDisabled, `"enabled": false`, `"enabled": true`, 1)
if string(enabled) != wantEnabled {
t.Fatalf("after replace:\n got %q\nwant %q", enabled, wantEnabled)
}
})
t.Run("compact manifest", func(t *testing.T) {
root := t.TempDir()
raw := `{"schemaVersion":1,"id":"zero.demo","name":"Demo","version":"1.0.0"}`
manifestPath := writeRawManifest(t, root, "demo", raw)
if _, err := SetEnabledByID(enableTestOptions(root), "zero.demo", false); err != nil {
t.Fatalf("SetEnabledByID(false): %v", err)
}
disabled, err := os.ReadFile(manifestPath)
if err != nil {
t.Fatal(err)
}
want := `{"schemaVersion":1,"id":"zero.demo","name":"Demo","version":"1.0.0","enabled": false}`
if string(disabled) != want {
t.Fatalf("compact insert:\n got %q\nwant %q", disabled, want)
}
})
t.Run("null value", func(t *testing.T) {
root := t.TempDir()
raw := `{
"schemaVersion": 1,
"id": "zero.demo",
"name": "Demo",
"version": "1.0.0",
"enabled": null
}
`
manifestPath := writeRawManifest(t, root, "demo", raw)
result, err := SetEnabledByID(enableTestOptions(root), "zero.demo", true)
if err != nil {
t.Fatalf("SetEnabledByID(true): %v", err)
}
if result.Changed {
t.Fatal("null counts as enabled, so enabling must be a no-op")
}
unchanged, err := os.ReadFile(manifestPath)
if err != nil {
t.Fatal(err)
}
if string(unchanged) != raw {
t.Fatalf("no-op toggle rewrote the manifest: %q", unchanged)
}
if _, err := SetEnabledByID(enableTestOptions(root), "zero.demo", false); err != nil {
t.Fatalf("SetEnabledByID(false): %v", err)
}
disabled, err := os.ReadFile(manifestPath)
if err != nil {
t.Fatal(err)
}
want := strings.Replace(raw, `"enabled": null`, `"enabled": false`, 1)
if string(disabled) != want {
t.Fatalf("null replacement:\n got %q\nwant %q", disabled, want)
}
})
}
func TestToggleEnabledManifestFormattingEdges(t *testing.T) {
cases := []struct {
name string
in string
enabled bool
want string
changed bool
}{
{"empty object", `{}`, false, `{"enabled": false}`, true},
{"empty object with space", `{ }`, false, `{"enabled": false }`, true},
{"empty multiline", "{\n}", false, "{\n \"enabled\": false\n}", true},
{"compact members", `{"a":1}`, false, `{"a":1,"enabled": false}`, true},
{"crlf members", "{\r\n \"a\": 1\r\n}", false, "{\r\n \"a\": 1,\r\n \"enabled\": false\r\n}", true},
{"nested enabled untouched", `{"nested":{"enabled":true},"a":2}`, false, `{"nested":{"enabled":true},"a":2,"enabled": false}`, true},
{"replace with true", `{"enabled": false}`, true, `{"enabled": true}`, true},
{"replace with false", `{"enabled":true}`, false, `{"enabled":false}`, true},
{"null to false", `{"enabled": null}`, false, `{"enabled": false}`, true},
{"null to true is a no-op", `{"enabled": null}`, true, `{"enabled": null}`, false},
{"already enabled is a no-op", `{"enabled": true}`, true, `{"enabled": true}`, false},
// The loader keeps the last duplicate, so an overwritten non-boolean
// value is not an error and only the last value is rewritten.
{"overwritten string", `{"enabled":"obsolete","enabled":true}`, false, `{"enabled":"obsolete","enabled":false}`, true},
{"overwritten composite", `{"enabled":{"a":[1,{"enabled":2}]},"enabled":null}`, false, `{"enabled":{"a":[1,{"enabled":2}]},"enabled":false}`, true},
{"overwritten number is a no-op", `{"enabled":1,"enabled":false}`, false, `{"enabled":1,"enabled":false}`, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
out, changed, err := toggleEnabledManifest([]byte(tc.in), tc.enabled)
if err != nil {
t.Fatalf("toggleEnabledManifest: %v", err)
}
if changed != tc.changed {
t.Fatalf("changed = %v, want %v", changed, tc.changed)
}
if !tc.changed {
if out != nil {
t.Fatalf("no-op returned bytes: %q", out)
}
return
}
if string(out) != tc.want {
t.Fatalf("got %q, want %q", out, tc.want)
}
})
}
}
func TestToggleEnabledManifestRejectsMalformedInput(t *testing.T) {
for _, raw := range []string{`{"enabled": "yes"}`, `{"enabled": 1}`, `{"enabled": []}`, `{"enabled": {}}`, `{"enabled": true, "enabled": "yes"}`} {
t.Run("value "+raw, func(t *testing.T) {
_, _, err := toggleEnabledManifest([]byte(raw), false)
if err == nil {
t.Fatal("expected error")
}
var manifestErr ManifestError
if !errors.As(err, &manifestErr) {
t.Fatalf("error = %v, want ManifestError", err)
}
if !strings.Contains(err.Error(), "enabled") {
t.Fatalf("error = %v, want it to name the enabled field", err)
}
})
}
for _, raw := range []string{``, `{`, `[]`, `null`, `"str"`, `{} {}`, `{}{}`} {
t.Run("document "+raw, func(t *testing.T) {
if _, _, err := toggleEnabledManifest([]byte(raw), false); err == nil {
t.Fatalf("toggleEnabledManifest(%q) succeeded, want error", raw)
}
})
}
}
// TestSetEnabledByIDHonorsTheLastDuplicateEnabledValue keeps the toggle in step
// with the loader on a manifest whose earlier "enabled" value is overwritten:
// the loader discovers the plugin from the last value, so the toggle must be
// able to change it too.
func TestSetEnabledByIDHonorsTheLastDuplicateEnabledValue(t *testing.T) {
root := t.TempDir()
writeRawManifest(t, root, "demo", `{
"schemaVersion": 1,
"id": "zero.demo",
"name": "Demo",
"version": "1.0.0",
"enabled": "obsolete",
"enabled": true
}
`)
loadedEnabled := func() bool {
t.Helper()
plugin, ok, err := FindByID(enableTestOptions(root), "zero.demo")
if err != nil || !ok {
t.Fatalf("FindByID: ok=%v err=%v", ok, err)
}
return plugin.Enabled
}
if !loadedEnabled() {
t.Fatal("the loader should discover the plugin as enabled from the last value")
}
for _, enabled := range []bool{false, true} {
result, err := SetEnabledByID(enableTestOptions(root), "zero.demo", enabled)
if err != nil {
t.Fatalf("SetEnabledByID(%v): %v", enabled, err)
}
if !result.Changed {
t.Fatalf("SetEnabledByID(%v) reported no change", enabled)
}
if got := loadedEnabled(); got != enabled {
t.Fatalf("after SetEnabledByID(%v) the loader sees enabled=%v", enabled, got)
}
}
}
// TestSetEnabledRefusesASwappedPluginDirectory swaps the plugin directory that
// discovery found for a link between the lookup and the edit. The install lock
// does not cover that window, because it only serializes cooperating
// installers. The edit must fail without following the link: neither the
// link's target nor the moved-away original may change.
func TestSetEnabledRefusesASwappedPluginDirectory(t *testing.T) {
cases := []struct {
name string
// link returns the symlink target to plant and the manifest it leads to.
link func(t *testing.T, root string) (target string, manifest string)
}{
{"to a directory outside the plugins root", func(t *testing.T, root string) (string, string) {
manifest := writeRawManifest(t, t.TempDir(), "elsewhere", demoManifest(true))
return filepath.Dir(manifest), manifest
}},
{"to another plugin in the same root", func(t *testing.T, root string) (string, string) {
// A relative link stays inside the root, so confinement to the
// root alone would follow it.
manifest := writeRawManifest(t, root, "other", strings.Replace(demoManifest(true), "zero.demo", "zero.other", 1))
return "other", manifest
}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
root := t.TempDir()
raw := demoManifest(true)
writeRawManifest(t, root, "demo", raw)
plugin, ok, err := FindByID(enableTestOptions(root), "zero.demo")
if err != nil || !ok {
t.Fatalf("FindByID: ok=%v err=%v", ok, err)
}
target, targetManifest := tc.link(t, root)
targetBefore, err := os.ReadFile(targetManifest)
if err != nil {
t.Fatal(err)
}
moved := filepath.Join(t.TempDir(), "moved-away")
if err := os.Rename(plugin.PluginDir, moved); err != nil {
t.Fatal(err)
}
if err := os.Symlink(target, plugin.PluginDir); err != nil {
t.Skipf("cannot create a directory symlink here: %v", err)
}
if _, err := setEnabledLocked(plugin, false); !errors.Is(err, pathjail.ErrReparse) {
t.Fatalf("setEnabledLocked through a swapped directory: err = %v, want %v", err, pathjail.ErrReparse)
}
if after, err := os.ReadFile(targetManifest); err != nil || string(after) != string(targetBefore) {
t.Fatalf("the edit followed the link and changed %s (err %v):\n got %q\nwant %q", targetManifest, err, after, targetBefore)
}
if after, err := os.ReadFile(filepath.Join(moved, "plugin.json")); err != nil || string(after) != raw {
t.Fatalf("the moved-away manifest changed (err %v): %q", err, after)
}
})
}
}
// TestSetEnabledDetectsAPluginDirectoryReplacedAfterOpening replaces the live
// plugin directory after the toggle pinned it. The edit then lands in the
// moved-away directory, so the toggle must not refresh the lockfile from either
// tree and must not report success: the plugin installed at the path now is
// unchanged.
func TestSetEnabledDetectsAPluginDirectoryReplacedAfterOpening(t *testing.T) {
for _, tc := range []struct {
name string
pinned bool // the pinned manifest's enabled value
changes bool // whether disabling it is a real edit
}{
{"edit", true, true},
{"no-op", false, false},
} {
t.Run(tc.name, func(t *testing.T) {
root := t.TempDir()
manifestPath := writeRawManifest(t, root, "demo", demoManifest(tc.pinned))
pluginDir := filepath.Dir(manifestPath)
hash, err := hashTree(pluginDir)
if err != nil {
t.Fatal(err)
}
if err := writeLock(root, map[string]LockEntry{"zero.demo": {Source: "./demo", Hash: hash}}); err != nil {
t.Fatal(err)
}
lockPath := filepath.Join(root, LockFileName)
lockBefore, err := os.ReadFile(lockPath)
if err != nil {
t.Fatal(err)
}
plugin, ok, err := FindByID(enableTestOptions(root), "zero.demo")
if err != nil || !ok {
t.Fatalf("FindByID: ok=%v err=%v", ok, err)
}
pinned, err := openPluginDir(plugin)
if err != nil {
t.Fatalf("openPluginDir: %v", err)
}
defer pinned.Close()
moved := filepath.Join(t.TempDir(), "moved-away")
if err := os.Rename(pluginDir, moved); err != nil {
t.Fatal(err)
}
replacement := strings.Replace(demoManifest(true), `"version": "1.0.0"`, `"version": "2.0.0"`, 1)
writeRawManifest(t, root, "demo", replacement)
// The lockfile check hashes the pinned tree, which still matches the
// recorded hash, not the replacement now at the path.
if _, _, clean := cleanLockEntry(plugin, pinned.dir); !clean {
t.Error("cleanLockEntry hashed the replacement at the path instead of the pinned directory")
}
if _, err := setEnabledPinned(plugin, pinned, false); !errors.Is(err, errPluginDirReplaced) {
t.Fatalf("setEnabledPinned after the directory was replaced: err = %v, want %v", err, errPluginDirReplaced)
}
if after, err := os.ReadFile(manifestPath); err != nil || string(after) != replacement {
t.Fatalf("the replacement manifest changed (err %v): %q", err, after)
}
if after, err := os.ReadFile(lockPath); err != nil || string(after) != string(lockBefore) {
t.Fatalf("the lockfile changed (err %v):\n got %s\nwant %s", err, after, lockBefore)
}
movedRaw, err := os.ReadFile(filepath.Join(moved, "plugin.json"))
if err != nil {
t.Fatal(err)
}
if tc.changes != (string(movedRaw) != demoManifest(tc.pinned)) {
t.Fatalf("pinned manifest edit = %v, want %v: %q", !tc.changes, tc.changes, movedRaw)
}
})
}
}
func TestSetEnabledByIDRespectsUserOnlyFilter(t *testing.T) {
dir := t.TempDir()
userRoot := filepath.Join(dir, "user")
projectRoot := filepath.Join(dir, "project")
writePluginManifest(t, filepath.Join(userRoot, "demo"), map[string]any{
"schemaVersion": 1,
"id": "zero.demo",
"name": "User Demo",
"version": "0.1.0",
"enabled": true,
})
writePluginManifest(t, filepath.Join(projectRoot, "demo"), map[string]any{
"schemaVersion": 1,
"id": "zero.demo",
"name": "Project Demo",
"version": "0.2.0",
"enabled": true,
})
result, err := SetEnabledByID(LoadOptions{
Roots: []Root{
{Source: SourceUser, Path: userRoot},
{Source: SourceProject, Path: projectRoot},
},
}, "zero.demo", false)
if err != nil {
t.Fatalf("SetEnabledByID: %v", err)
}
if result.Source != SourceProject {
t.Fatalf("source = %s, want project (precedence)", result.Source)
}
if !result.Changed || result.Enabled {
t.Fatalf("unexpected result: %#v", result)
}
userOnly, err := SetEnabledByID(LoadOptions{
Roots: []Root{
{Source: SourceUser, Path: userRoot},
{Source: SourceProject, Path: projectRoot},
},
ExcludeProject: true,
}, "zero.demo", false)
if err != nil {
t.Fatalf("SetEnabledByID user-only: %v", err)
}
if userOnly.Source != SourceUser {
t.Fatalf("user-only source = %s, want user", userOnly.Source)
}
}
func TestSetEnabledPreservesManifestMode(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("Windows does not preserve chmod bits in os.Stat mode")
}
root := t.TempDir()
writePluginManifest(t, filepath.Join(root, "demo"), map[string]any{
"schemaVersion": 1,
"id": "zero.demo",
"name": "Demo",
"version": "1.0.0",
})
manifestPath := filepath.Join(root, "demo", "plugin.json")
if err := os.Chmod(manifestPath, 0o644); err != nil {
t.Fatal(err)
}
if _, err := SetEnabledByID(enableTestOptions(root), "zero.demo", false); err != nil {
t.Fatalf("SetEnabledByID: %v", err)
}
info, err := os.Stat(manifestPath)
if err != nil {
t.Fatal(err)
}
if got := info.Mode().Perm(); got != 0o644 {
t.Fatalf("mode = %04o, want 0644", got)
}
}
func TestSetEnabledByIDMissingPlugin(t *testing.T) {
_, err := SetEnabledByID(LoadOptions{Roots: []Root{{Source: SourceUser, Path: t.TempDir()}}}, "missing", false)
if err == nil {
t.Fatal("expected missing plugin error")
}
if !errors.Is(err, ErrNotInstalled) {
t.Fatalf("error = %v, want ErrNotInstalled", err)
}
}
func TestSetEnabledPublishFailureLeavesManifestIntact(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("Windows directory permissions do not block writes the way this test needs")
}
if os.Geteuid() == 0 {
t.Skip("root bypasses directory permission checks")
}
root := t.TempDir()
raw := demoManifest(true)
manifestPath := writeRawManifest(t, root, "demo", raw)
pluginDir := filepath.Dir(manifestPath)
if err := os.Chmod(pluginDir, 0o555); err != nil {
t.Fatal(err)
}
defer func() { _ = os.Chmod(pluginDir, 0o755) }()
if _, err := SetEnabledByID(enableTestOptions(root), "zero.demo", false); err == nil {
t.Fatal("expected the publish to fail in a read-only plugin directory")
}
after, err := os.ReadFile(manifestPath)
if err != nil {
t.Fatal(err)
}
if string(after) != raw {
t.Fatalf("failed publish changed the manifest:\n got %q\nwant %q", after, raw)
}
}
// TestSetEnabledByIDWaitsForInstallRootLock proves the toggle takes the same
// cross-process lock Install and Remove take on the plugin's install root: it
// must not complete while an install holds that lock.
func TestSetEnabledByIDWaitsForInstallRootLock(t *testing.T) {
root := t.TempDir()
manifestPath := writeRawManifest(t, root, "demo", demoManifest(true))
unlock, err := installtxn.Lock(root)
if err != nil {
t.Fatal(err)
}
results := make(chan SetEnabledResult, 1)
failures := make(chan error, 1)
go func() {
result, err := SetEnabledByID(enableTestOptions(root), "zero.demo", false)
if err != nil {
failures <- err
return
}
results <- result
}()
select {
case result := <-results:
unlock()
t.Fatalf("toggle completed while the install lock was held: %#v", result)
case err := <-failures:
unlock()
t.Fatalf("toggle failed while the install lock was held: %v", err)
case <-time.After(200 * time.Millisecond):
}
unlock()
select {
case result := <-results:
if !result.Changed {
t.Fatalf("toggle did not report a change: %#v", result)
}
case err := <-failures:
t.Fatalf("toggle failed after the lock was released: %v", err)
case <-time.After(10 * time.Second):
t.Fatal("toggle did not finish after the install lock was released")
}
raw, err := os.ReadFile(manifestPath)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(raw), `"enabled": false`) {
t.Fatalf("manifest not disabled: %s", raw)
}
}
// TestSetEnabledByIDSynchronizesWithStagedInstallCommit pauses an install
// between staging and commit, lets the toggle try to run, then commits the
// install. The toggle must apply to the newly installed manifest instead of
// overwriting it with bytes read before the install, and the lockfile entry
// must describe the tree actually on disk.
func TestSetEnabledByIDSynchronizesWithStagedInstallCommit(t *testing.T) {
root := t.TempDir()
oldRaw := demoManifest(true)
pluginDir := filepath.Dir(writeRawManifest(t, root, "zero.demo", oldRaw))
// Build the replacement tree with the same staging primitives Install
// uses and leave it staged but uncommitted.
src := t.TempDir()
newRaw := `{
"schemaVersion": 1,
"id": "zero.demo",
"name": "Demo",
"version": "2.0.0",
"description": "reinstalled",
"enabled": true
}
`
srcPlugin := filepath.Join(src, "zero.demo")
if err := os.MkdirAll(srcPlugin, 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(srcPlugin, "plugin.json"), []byte(newRaw), 0o600); err != nil {
t.Fatal(err)
}
staged, cleanup, err := installtxn.StageDir(root)
if err != nil {
t.Fatal(err)
}
defer cleanup()
if err := copyTree(srcPlugin, staged); err != nil {
t.Fatal(err)
}
stagedHash, err := hashTree(staged)
if err != nil {
t.Fatal(err)
}
// An install holds the root lock from before its commit until after it.
unlock, err := installtxn.Lock(root)
if err != nil {
t.Fatal(err)
}
results := make(chan SetEnabledResult, 1)
failures := make(chan error, 1)
go func() {
result, err := SetEnabledByID(enableTestOptions(root), "zero.demo", false)
if err != nil {
failures <- err
return
}
results <- result
}()
select {
case result := <-results:
unlock()
t.Fatalf("toggle completed while the install lock was held: %#v", result)
case err := <-failures:
unlock()
t.Fatalf("toggle failed while the install lock was held: %v", err)
case <-time.After(200 * time.Millisecond):
}
// Commit the staged install exactly like Install does: swap the tree and
// publish the lockfile entry while still holding the lock.
lock, err := ReadLock(root)
if err != nil {
unlock()
t.Fatal(err)
}
lock["zero.demo"] = LockEntry{Source: string(SourceUser), Hash: stagedHash}
if err := installtxn.CommitDir(pluginDir, staged, func() error { return writeLock(root, lock) }); err != nil {
unlock()
t.Fatal(err)
}
unlock()
var result SetEnabledResult
select {
case result = <-results:
case err := <-failures:
t.Fatalf("toggle failed after the install committed: %v", err)
case <-time.After(10 * time.Second):
t.Fatal("toggle did not finish after the install committed")
}
if !result.Changed {
t.Fatalf("toggle result = %#v, want changed", result)
}
// The toggle must have applied to the newly installed manifest, not to the
// bytes it first read.
final, err := os.ReadFile(filepath.Join(pluginDir, "plugin.json"))
if err != nil {
t.Fatal(err)
}
want := strings.Replace(newRaw, `"enabled": true`, `"enabled": false`, 1)
if string(final) != want {
t.Fatalf("manifest after concurrent install + disable:\n got %q\nwant %q", final, want)
}
// The lockfile entry must describe the tree that is actually on disk.
entries, err := ReadLock(root)
if err != nil {
t.Fatal(err)
}
current, err := hashTree(pluginDir)
if err != nil {
t.Fatal(err)
}
if entries["zero.demo"].Hash != current {
t.Fatalf("plugins.lock hash = %q, want current tree hash %q", entries["zero.demo"].Hash, current)
}
}
func TestSetEnabledByIDRefreshesCleanLockEntry(t *testing.T) {
root := t.TempDir()
manifestPath := writeRawManifest(t, root, "zero.demo", demoManifest(true))
pluginDir := filepath.Dir(manifestPath)
current, err := hashTree(pluginDir)
if err != nil {
t.Fatal(err)
}
if err := writeLock(root, map[string]LockEntry{"zero.demo": {Source: string(SourceUser), Hash: current}}); err != nil {
t.Fatal(err)
}
if _, err := SetEnabledByID(enableTestOptions(root), "zero.demo", false); err != nil {
t.Fatalf("SetEnabledByID: %v", err)
}
entries, err := ReadLock(root)
if err != nil {
t.Fatal(err)
}
afterToggle, err := hashTree(pluginDir)
if err != nil {
t.Fatal(err)
}
if entries["zero.demo"].Hash == current {
t.Fatal("lock hash was not refreshed for a clean install")
}
if entries["zero.demo"].Hash != afterToggle {
t.Fatalf("lock hash = %q, want refreshed tree hash %q", entries["zero.demo"].Hash, afterToggle)
}
info, err := Info(InfoOptions{LoadOptions: enableTestOptions(root)}, "zero.demo")
if err != nil {
t.Fatal(err)
}
if info.HashDrift {
t.Fatal("toggle left hash drift behind for a clean install")
}
// Pre-existing drift must survive the next toggle instead of being
// absorbed into the lockfile.
if err := os.WriteFile(filepath.Join(pluginDir, "extra.txt"), []byte("edited\n"), 0o600); err != nil {
t.Fatal(err)
}
if _, err := SetEnabledByID(enableTestOptions(root), "zero.demo", true); err != nil {
t.Fatalf("SetEnabledByID(true): %v", err)
}
entries, err = ReadLock(root)
if err != nil {
t.Fatal(err)
}
if entries["zero.demo"].Hash != afterToggle {
t.Fatalf("toggle absorbed pre-existing drift: hash = %q, want %q", entries["zero.demo"].Hash, afterToggle)
}
}
// TestSetEnabledByIDReportsAPluginRemovedWhileWaiting removes the plugin while
// the toggle is blocked on the install-root lock, exactly as Remove does while
// it holds that lock. The toggle must report the plugin as not installed
// instead of editing, or recreating, a tree that no longer exists.
func TestSetEnabledByIDReportsAPluginRemovedWhileWaiting(t *testing.T) {
root := t.TempDir()
pluginDir := filepath.Dir(writeRawManifest(t, root, "zero.demo", demoManifest(true)))
unlock, err := installtxn.Lock(root)
if err != nil {
t.Fatal(err)
}
failures := make(chan error, 1)
go func() {
_, err := SetEnabledByID(enableTestOptions(root), "zero.demo", false)
failures <- err
}()
select {
case err := <-failures:
unlock()
t.Fatalf("toggle returned while the install lock was held: %v", err)
case <-time.After(200 * time.Millisecond):
}
if err := installtxn.RemoveDir(pluginDir, func() error { return nil }); err != nil {
unlock()
t.Fatal(err)
}
unlock()
select {
case err := <-failures:
if !errors.Is(err, ErrNotInstalled) {
t.Fatalf("toggle after a concurrent removal: error = %v, want ErrNotInstalled", err)
}
case <-time.After(10 * time.Second):
t.Fatal("toggle did not finish after the removal released the lock")
}
if _, err := os.Stat(pluginDir); !os.IsNotExist(err) {
t.Fatalf("toggle recreated the removed plugin: %v", err)
}
}
// TestSetEnabledByIDResolvesInterruptedUpdatesFirst covers every point an
// update can be killed at that leaves the plugin discoverable. The toggle
// holds the install-root lock, so it has to
// run the same recovery pass Install and Remove run before it reads the tree:
// otherwise it edits, or fails to find, a tree that is still owed a restore,
// and the next install or removal puts the backup back over the user's edit.
func TestSetEnabledByIDResolvesInterruptedUpdatesFirst(t *testing.T) {
for _, state := range interruptedStates() {
t.Run(state.name, func(t *testing.T) {
u := seedInterruptedUpdate(t)
workspace := state.plant(t, u)
want := state.want(u)
if _, err := os.Lstat(u.target()); os.IsNotExist(err) {
// No root lists a plugin whose only copy is in a workspace, so the
// toggle reports it not installed without taking any lock, just as
// list and info do; the next install or removal restores it.
t.Skip("nothing is at the target, so the toggle never takes the lock")
}
result, err := SetEnabledByID(enableTestOptions(u.dir), "zero.demo", false)
if err != nil {
t.Fatalf("toggle after the interrupted update: %v", err)
}
if !result.Changed || result.Enabled {
t.Fatalf("toggle result = %#v, want changed and disabled", result)
}
assertDisabledAfterRecovery(t, u, workspace, want)
// The next lock holder runs recovery again. The disable has to survive
// it rather than be replaced by a backup the toggle never saw.
if err := installOther(t, u.dir, "zero.other"); err != nil {
t.Fatalf("install after the toggle: %v", err)
}
assertDisabledAfterRecovery(t, u, workspace, want)
})
}
}
func assertDisabledAfterRecovery(t *testing.T, u interruptedUpdate, workspace string, want recoveryWant) {
t.Helper()
data, err := os.ReadFile(filepath.Join(u.target(), "generation.txt"))
if err != nil {
t.Fatalf("reading the live tree: %v", err)
}
if string(data) != want.generation {
t.Errorf("the live tree is the %q generation, want %q", data, want.generation)
}
plugin, ok, err := FindByID(enableTestOptions(u.dir), "zero.demo")
if err != nil {
t.Fatal(err)
}
if !ok {
t.Fatal("Load does not see the toggled plugin")
}
if plugin.Version != want.version {
t.Errorf("Load sees version %q, want %q", plugin.Version, want.version)
}
if plugin.Enabled {
t.Errorf("the disable did not stick: Load sees the plugin enabled")
}
_, err = os.Stat(workspace)
if want.workspace && err != nil {
t.Errorf("the workspace should be left alone, got %v", err)
}
if !want.workspace && !os.IsNotExist(err) {
t.Errorf("the resolved workspace should be cleared, got %v", err)
}
}
// TestSetEnabledByIDAbortsOnAnUnresolvedInterruptedUpdate leaves a workspace
// whose trees match neither lockfile hash. Recovery cannot pick one, so the
// toggle must fail without editing the live manifest or the lockfile.
func TestSetEnabledByIDAbortsOnAnUnresolvedInterruptedUpdate(t *testing.T) {
u := seedInterruptedUpdate(t)
workspace := u.plantWorkspace(t)
u.mustRename(t, u.target(), filepath.Join(workspace, "previous"))
u.mustRename(t, filepath.Join(workspace, "staged"), u.target())
unmatched := LockEntry{Source: u.oldSource, Hash: "sha256:0000000000000000000000000000000000000000000000000000000000000000"}
lock, err := ReadLock(u.dir)
if err != nil {
t.Fatal(err)
}
lock["zero.demo"] = unmatched
if err := writeLock(u.dir, lock); err != nil {
t.Fatal(err)
}
manifestPath := filepath.Join(u.target(), "plugin.json")
before, err := os.ReadFile(manifestPath)
if err != nil {
t.Fatal(err)
}
if _, err := SetEnabledByID(enableTestOptions(u.dir), "zero.demo", false); err == nil {
t.Fatal("toggle did not report the unresolved transaction")
}
after, err := os.ReadFile(manifestPath)
if err != nil {
t.Fatal(err)
}
if string(after) != string(before) {
t.Errorf("the aborted toggle edited the manifest:\n got %q\nwant %q", after, before)
}
assertUntouched(t, u, "new", unmatched)
if _, err := os.Stat(filepath.Join(workspace, "previous")); err != nil {
t.Errorf("the backup should still be there for a hand rescue, got %v", err)
}
}
+2 -1
View File
@@ -7,7 +7,8 @@ import (
"strings"
)
// ErrNotInstalled is returned when Info cannot find a plugin with the requested id.
// ErrNotInstalled is returned when a plugin id cannot be found under the given
// LoadOptions (for example by Info or SetEnabledByID).
var ErrNotInstalled = errors.New("plugin is not installed")
// PluginInfo combines a loaded plugin with optional lockfile metadata.
+37 -11
View File
@@ -567,15 +567,32 @@ func validInstallID(id string) bool {
// sorted order. Each file contributes its plugin-relative path, executable bit,
// and bytes, so renames, mode flips, and content edits all change the hash.
func hashTree(root string) (string, error) {
handle, err := os.OpenRoot(root)
if err != nil {
return "", err
}
defer handle.Close()
return hashRootedTree(handle)
}
// hashRootedTree is hashTree over a directory that is already open. Walking
// relative to the handle hashes the directory it names even if its path is
// replaced meanwhile, and keeps the walk inside it.
//
// The walk calls the handle's own methods rather than handle.FS(): the fs.FS
// adapter only accepts fs.ValidPath names, which must be UTF-8, so it would
// refuse file names that the filesystem and copyTree accept.
func hashRootedTree(handle *os.Root) (string, error) {
hasher := sha256.New()
if err := hashTreeInto(hasher, root, root); err != nil {
if err := hashTreeInto(hasher, handle, "."); err != nil {
return "", err
}
return "sha256:" + hex.EncodeToString(hasher.Sum(nil)), nil
}
func hashTreeInto(hasher io.Writer, root string, dir string) error {
entries, err := os.ReadDir(dir)
// hashTreeInto hashes dir, a path relative to handle, into hasher.
func hashTreeInto(hasher io.Writer, handle *os.Root, dir string) error {
entries, err := readRootedDir(handle, dir)
if err != nil {
return err
}
@@ -588,8 +605,8 @@ func hashTreeInto(hasher io.Writer, root string, dir string) error {
if name == ".git" {
continue
}
path := filepath.Join(dir, name)
info, err := os.Lstat(path)
rel := filepath.Join(dir, name)
info, err := handle.Lstat(rel)
if err != nil {
return err
}
@@ -598,14 +615,10 @@ func hashTreeInto(hasher io.Writer, root string, dir string) error {
// Skipped by copyTree, so excluded from the hash too.
continue
case info.IsDir():
if err := hashTreeInto(hasher, root, path); err != nil {
if err := hashTreeInto(hasher, handle, rel); err != nil {
return err
}
case info.Mode().IsRegular():
rel, err := filepath.Rel(root, path)
if err != nil {
return err
}
executable := 0
if info.Mode().Perm()&0o111 != 0 {
executable = 1
@@ -616,7 +629,7 @@ func hashTreeInto(hasher io.Writer, root string, dir string) error {
if _, err := io.WriteString(hasher, header); err != nil {
return err
}
file, err := os.Open(path)
file, err := handle.Open(rel)
if err != nil {
return err
}
@@ -634,3 +647,16 @@ func hashTreeInto(hasher io.Writer, root string, dir string) error {
}
return nil
}
// readRootedDir lists dir, a path relative to handle, like os.ReadDir.
func readRootedDir(handle *os.Root, dir string) ([]os.DirEntry, error) {
file, err := handle.Open(dir)
if err != nil {
return nil, err
}
entries, err := file.ReadDir(-1)
if closeErr := file.Close(); err == nil {
err = closeErr
}
return entries, err
}
+59
View File
@@ -1181,3 +1181,62 @@ func TestRecoveryLeavesAPrefixCollidingDirectoryAlone(t *testing.T) {
t.Errorf("recovery published content it does not own: %v", err)
}
}
// TestNonUTF8FileNamesAreInstalledHashedAndToggled covers a plugin whose tree
// holds a file and a directory named with bytes that are not valid UTF-8, which
// Linux allows. copyTree copies them, so hashTree must hash them too: Install
// records the hash, the toggle refreshes it, and Info sees an edit to that file
// as drift. A walk that rejects such names breaks all three.
func TestNonUTF8FileNamesAreInstalledHashedAndToggled(t *testing.T) {
src := writeSourcePlugin(t, filepath.Join(t.TempDir(), "src"), validManifest())
nested := filepath.Join("assets\xff", "data\xfe.bin")
if err := os.Mkdir(filepath.Join(src, "assets\xff"), 0o755); err != nil {
t.Skipf("this filesystem rejects non-UTF-8 names: %v", err)
}
if err := os.WriteFile(filepath.Join(src, nested), []byte("original\n"), 0o644); err != nil {
t.Skipf("this filesystem rejects non-UTF-8 names: %v", err)
}
destDir := t.TempDir()
if _, err := Install(context.Background(), InstallOptions{Source: src, Dir: destDir}); err != nil {
t.Fatalf("Install a plugin with a non-UTF-8 file name: %v", err)
}
pluginDir := filepath.Join(destDir, "zero.demo")
if data, err := os.ReadFile(filepath.Join(pluginDir, nested)); err != nil || string(data) != "original\n" {
t.Fatalf("installed copy of the non-UTF-8 file = %q, %v", data, err)
}
options := LoadOptions{Roots: []Root{{Source: SourceUser, Path: destDir}}}
before, err := ReadLock(destDir)
if err != nil {
t.Fatal(err)
}
if _, err := SetEnabledByID(options, "zero.demo", false); err != nil {
t.Fatalf("SetEnabledByID on a plugin with a non-UTF-8 file name: %v", err)
}
after, err := ReadLock(destDir)
if err != nil {
t.Fatal(err)
}
if after["zero.demo"].Hash == before["zero.demo"].Hash {
t.Fatal("the toggle did not refresh the lock hash")
}
info, err := Info(InfoOptions{LoadOptions: options}, "zero.demo")
if err != nil {
t.Fatal(err)
}
if info.HashDrift {
t.Fatal("the toggle left hash drift behind")
}
if err := os.WriteFile(filepath.Join(pluginDir, nested), []byte("edited\n"), 0o644); err != nil {
t.Fatal(err)
}
info, err = Info(InfoOptions{LoadOptions: options}, "zero.demo")
if err != nil {
t.Fatal(err)
}
if !info.HashDrift {
t.Fatal("an edit to the non-UTF-8 file was not reported as drift")
}
}
+54 -6
View File
@@ -75,6 +75,12 @@ type Descriptor struct {
// catalog-ordered lists and pickers. The recommended descriptors are the first
// entries in the catalog, in order.
Recommended bool
// LegacyBaseURLs lists earlier DefaultBaseURL values that still address
// this provider's own endpoint. Profiles saved against one keep catalog
// endpoint treatment (catalog env var binding, base-URL matching) after the
// default moves; see IsCatalogBaseURL.
LegacyBaseURLs []string
}
func RuntimeSupported(descriptor Descriptor) bool {
@@ -99,11 +105,17 @@ func RuntimeUnsupportedReason(descriptor Descriptor) string {
}
var descriptors = []Descriptor{
// GitLawb OpenGateway — the recommended default. An OpenAI-compatible gateway
// that smart-routes by model id across upstream providers (xiaomi-mimo,
// minimax, qwen, google, nvidia, tencent, z-ai). Flat /v1/chat/completions
// with a Bearer ogw_live_… key; listed first and badged in every picker.
recommended(openAICompat("gitlawb-opengateway", "GitLawb OpenGateway", "https://opengateway.gitlawb.com/v1", "mimo-v2.5-pro", []string{"GITLAWB_OPENGATEWAY_API_KEY"}, "gitlawb opengateway", "opengateway")),
// Twigpine OpenGateway (formerly GitLawb OpenGateway) — the recommended
// default. An OpenAI-compatible gateway at api.twigpine.com that smart-routes
// by model id across upstream providers. Flat /v1/chat/completions with a
// Bearer ogw_live_… key; listed first and badged in every picker. The public
// GET /v1/models lists only active models and is what pickers show when
// reachable. DefaultModel must be an exact id from that list (not an alias)
// so onboarding keeps it selected after live discovery. The id and env var
// keep their gitlawb names so saved configs keep resolving; the old host
// (opengateway.gitlawb.com) still serves clients and stays a legacy catalog
// endpoint so profiles saved against it resolve unchanged.
recommended(withLegacyBaseURLs(openAICompat("gitlawb-opengateway", "Twigpine OpenGateway", "https://api.twigpine.com/v1", "xiaomi/mimo-v2.6-pro", []string{"GITLAWB_OPENGATEWAY_API_KEY"}, "gitlawb opengateway", "opengateway", "twigpine opengateway", "twigpine"), "https://opengateway.gitlawb.com/v1")),
// aimlapi.com — OpenAI-compatible aggregating gateway, also badged as
// recommended (second, right after the OpenGateway default). The built-in TUI
// onboarding can register/top up an account and save the issued key; the partner
@@ -132,7 +144,9 @@ var descriptors = []Descriptor{
// way in; RequiresAuth is forced true so catalog consumers know the provider
// needs an interactive login before a request will succeed.
func() Descriptor {
d := openAICompat("chatgpt", "ChatGPT", "https://chatgpt.com/backend-api/codex", "gpt-5.5", nil)
// New ChatGPT profiles follow the vendor's GPT-5.5 replacement guidance:
// https://learn.chatgpt.com/docs/models#gpt-55-retirement
d := openAICompat("chatgpt", "ChatGPT", "https://chatgpt.com/backend-api/codex", "gpt-6.1-sol", nil)
d.RequiresAuth = true
return oauthProvider(d, false, false)
}(),
@@ -305,6 +319,37 @@ func recommended(descriptor Descriptor) Descriptor {
return descriptor
}
// withLegacyBaseURLs records earlier default base URLs that still serve the
// provider, so profiles saved before a host move keep resolving as the catalog
// endpoint.
func withLegacyBaseURLs(descriptor Descriptor, baseURLs ...string) Descriptor {
descriptor.LegacyBaseURLs = append(descriptor.LegacyBaseURLs, baseURLs...)
return descriptor
}
// IsCatalogBaseURL reports whether baseURL addresses the descriptor's own
// endpoint: its DefaultBaseURL or one of its LegacyBaseURLs, ignoring case,
// surrounding space, and a trailing slash. An empty baseURL never matches.
func IsCatalogBaseURL(descriptor Descriptor, baseURL string) bool {
normalized := normalizeBaseURL(baseURL)
if normalized == "" {
return false
}
if normalized == normalizeBaseURL(descriptor.DefaultBaseURL) {
return true
}
for _, legacy := range descriptor.LegacyBaseURLs {
if normalized == normalizeBaseURL(legacy) {
return true
}
}
return false
}
func normalizeBaseURL(baseURL string) string {
return strings.ToLower(strings.TrimRight(strings.TrimSpace(baseURL), "/"))
}
// oauthProvider marks a descriptor as OAuth-capable. mintsKey => the flow returns
// an API key (OpenRouter); deviceFlow => RFC 8628 device code is also supported.
func oauthProvider(descriptor Descriptor, mintsKey bool, deviceFlow bool) Descriptor {
@@ -380,6 +425,9 @@ func cloneDescriptor(descriptor Descriptor) Descriptor {
descriptor.AuthEnvVars = append([]string{}, descriptor.AuthEnvVars...)
descriptor.SupportedAPIFormats = append([]APIFormat{}, descriptor.SupportedAPIFormats...)
descriptor.Aliases = append([]string{}, descriptor.Aliases...)
if descriptor.LegacyBaseURLs != nil {
descriptor.LegacyBaseURLs = append([]string{}, descriptor.LegacyBaseURLs...)
}
if descriptor.CustomHeaders != nil {
descriptor.CustomHeaders = copyStringMap(descriptor.CustomHeaders)
}
+60 -3
View File
@@ -77,7 +77,8 @@ func TestAllHasStableUniqueIDs(t *testing.T) {
func TestRecommendedProvidersAreTopOfCatalog(t *testing.T) {
descriptors := All()
// The recommended providers are badged and pinned to the top of the catalog,
// in this order (OpenGateway remains the default; aimlapi.com is also badged).
// in this order (Twigpine OpenGateway remains the default; aimlapi.com is also
// badged).
wantTop := []string{"gitlawb-opengateway", "aimlapi"}
if len(descriptors) < len(wantTop) {
t.Fatalf("All() returned %d descriptors, want at least %d", len(descriptors), len(wantTop))
@@ -107,12 +108,66 @@ func TestRecommendedProviderEndpoint(t *testing.T) {
if !ok {
t.Fatal("gitlawb-opengateway not found in catalog")
}
if descriptor.DefaultBaseURL != "https://opengateway.gitlawb.com/v1" {
t.Fatalf("OpenGateway base URL = %q, want %q", descriptor.DefaultBaseURL, "https://opengateway.gitlawb.com/v1")
if descriptor.Name != "Twigpine OpenGateway" {
t.Fatalf("OpenGateway name = %q, want %q", descriptor.Name, "Twigpine OpenGateway")
}
if descriptor.DefaultBaseURL != "https://api.twigpine.com/v1" {
t.Fatalf("OpenGateway base URL = %q, want %q", descriptor.DefaultBaseURL, "https://api.twigpine.com/v1")
}
// The default must be an exact id from the live /v1/models list (not a
// gateway alias such as the bare "mimo-v2.6-pro"): onboarding keeps the
// default selected after live discovery only on an exact id match.
if descriptor.DefaultModel != "xiaomi/mimo-v2.6-pro" {
t.Fatalf("OpenGateway default model = %q, want %q", descriptor.DefaultModel, "xiaomi/mimo-v2.6-pro")
}
if descriptor.Transport != TransportOpenAICompatible {
t.Fatalf("OpenGateway transport = %q, want %q", descriptor.Transport, TransportOpenAICompatible)
}
// Saved configs keep the original credential env var.
if !reflect.DeepEqual(descriptor.AuthEnvVars, []string{"GITLAWB_OPENGATEWAY_API_KEY"}) {
t.Fatalf("OpenGateway auth env vars = %#v, want GITLAWB_OPENGATEWAY_API_KEY", descriptor.AuthEnvVars)
}
// Old and new names both resolve to the same stable catalog id.
for _, name := range []string{"gitlawb-opengateway", "GitLawb OpenGateway", "opengateway", "Twigpine OpenGateway", "twigpine"} {
got, ok := Get(name)
if !ok || got.ID != "gitlawb-opengateway" {
t.Fatalf("Get(%q) = (%q, %v), want gitlawb-opengateway", name, got.ID, ok)
}
}
}
func TestIsCatalogBaseURLAcceptsDefaultAndLegacyHosts(t *testing.T) {
gateway, ok := Get("gitlawb-opengateway")
if !ok {
t.Fatal("gitlawb-opengateway not found in catalog")
}
for _, baseURL := range []string{
"https://api.twigpine.com/v1",
"https://API.twigpine.com/v1/ ",
"https://opengateway.gitlawb.com/v1",
"https://opengateway.gitlawb.com/v1/",
} {
if !IsCatalogBaseURL(gateway, baseURL) {
t.Fatalf("IsCatalogBaseURL(gateway, %q) = false, want true", baseURL)
}
}
for _, baseURL := range []string{"", "https://gateway.example.com/v1", "https://opengateway.gitlawb.com/v2"} {
if IsCatalogBaseURL(gateway, baseURL) {
t.Fatalf("IsCatalogBaseURL(gateway, %q) = true, want false", baseURL)
}
}
openrouter, ok := Get("openrouter")
if !ok {
t.Fatal("openrouter not found in catalog")
}
if !IsCatalogBaseURL(openrouter, "https://openrouter.ai/api/v1") || IsCatalogBaseURL(openrouter, "https://opengateway.gitlawb.com/v1") {
t.Fatal("legacy hosts must only match their own descriptor")
}
// Get returns a copy: mutating it must not leak into the catalog.
gateway.LegacyBaseURLs[0] = "https://mutated.example.com/v1"
if again, _ := Get("gitlawb-opengateway"); again.LegacyBaseURLs[0] != "https://opengateway.gitlawb.com/v1" {
t.Fatalf("LegacyBaseURLs aliased the catalog: %#v", again.LegacyBaseURLs)
}
}
func TestAIMLAPIDescriptor(t *testing.T) {
@@ -385,6 +440,8 @@ func TestLookupNormalizesIDsAndAliases(t *testing.T) {
"custom_openai_compatible": "custom-openai-compatible",
"custom--anthropic compatible": "custom-anthropic-compatible",
"GitLawb OpenGateway": "gitlawb-opengateway",
"Twigpine OpenGateway": "gitlawb-opengateway",
"twigpine": "gitlawb-opengateway",
}
for input, want := range cases {
descriptor, ok := Get(input)
+6 -4
View File
@@ -675,10 +675,12 @@ func blockedAddrReason(addr netip.Addr) string {
}
// overrideHealthEndpoint returns a provider-specific connectivity probe URL when
// the default {baseURL}+/models path does not exist. GitLawb OpenGateway is a
// smart-routing gateway whose flat /v1/models endpoint 404s by design ("Use
// /v1/<provider>/<path>"), so probe its public /health endpoint at the host root
// instead, which reports real reachability without a per-model call.
// the default {baseURL}+/models path is not the right reachability check.
// Twigpine OpenGateway (formerly GitLawb OpenGateway) is a smart-routing gateway
// whose flat /v1/models 404'd on older deployments, so probe its public /health
// endpoint at the host root instead (served by api.twigpine.com and the legacy
// opengateway.gitlawb.com host), which reports real reachability without a
// per-model call.
func overrideHealthEndpoint(profile config.ProviderProfile, baseURL string) (string, bool) {
if profile.CatalogID != "gitlawb-opengateway" {
return "", false
@@ -179,6 +179,10 @@ func TestOverrideHealthEndpointOpenGateway(t *testing.T) {
if got != "https://opengateway.gitlawb.com/health" {
t.Fatalf("OpenGateway health endpoint = %q, want host-root /health", got)
}
got, ok = overrideHealthEndpoint(config.ProviderProfile{CatalogID: "gitlawb-opengateway"}, "https://api.twigpine.com/v1")
if !ok || got != "https://api.twigpine.com/health" {
t.Fatalf("Twigpine OpenGateway health endpoint = (%q, %v), want https://api.twigpine.com/health", got, ok)
}
if _, ok := overrideHealthEndpoint(config.ProviderProfile{CatalogID: "openai"}, "https://api.openai.com/v1"); ok {
t.Fatal("non-OpenGateway providers must not override the health endpoint")
+30 -23
View File
@@ -44,6 +44,20 @@ var minimaxCuratedModels = []Model{
OutputModalities: []string{"text"},
Source: minimaxModelSource,
},
{
ID: "MiniMax-M3.1-Flash",
Description: "fast reasoning model",
ContextWindow: 1_000_000,
ToolCall: true,
Reasoning: true,
ReasoningEfforts: []string{"low", "medium", "high", "xhigh", "max"},
DefaultReasoningEffort: "max",
InputModalities: []string{"text", "image", "video"},
OutputModalities: []string{"text"},
InputCost: 0.15,
OutputCost: 0.60,
Source: minimaxModelSource,
},
{
ID: "MiniMax-M2.7",
Description: "agentic coding model",
@@ -113,10 +127,12 @@ var curatedModels = map[string][]Model{
{ID: "deepseek/deepseek-chat", Description: "coding model"},
},
"chatgpt": {
{ID: "gpt-5.5", Description: "recommended Codex model"},
{ID: "gpt-5.4", Description: "strong Codex model"},
{ID: "gpt-5.4-mini", Description: "fast Codex model"},
{ID: "gpt-5.3-codex-spark", Description: "research preview fast Codex model"},
// Suggestions only: availability comes from the authenticated Codex catalog.
// https://learn.chatgpt.com/docs/models
{ID: "gpt-6.1-sol", Description: "balanced Codex model"},
{ID: "gpt-6.1-terra", Description: "fast model for writing, visual understanding, and computer use"},
{ID: "gpt-6-astra", Description: "advanced reasoning model"},
{ID: "gpt-6-luna", Description: "efficient coding model"},
},
"deepseek": {
{ID: "deepseek-chat", Description: "catalog default"},
@@ -209,26 +225,17 @@ var curatedModels = map[string][]Model{
},
"zai": zaiCuratedModels,
"zai-cn": zaiCuratedModels,
// OpenGateway smart-routes by model id across its upstream providers
// (see /health: xiaomi-mimo, minimax, qwen, google, nvidia, tencent, z-ai).
// These are the curated coding defaults; the gateway accepts any model its
// upstreams expose, so users can also type an id the picker doesn't list.
// Twigpine OpenGateway publishes its live, active-only model list at
// GET /v1/models; pickers and onboarding show that list whenever it is
// reachable (see providermodeldiscovery.DiscoverCatalog). This is only the
// offline fallback, so keep it to ids the gateway is expected to keep
// serving: the smart router, the catalog default, and its fast sibling. Do
// not add promo or time-limited models here. Users can still type any id
// the gateway accepts.
"gitlawb-opengateway": {
{ID: "mimo-v2.5-pro", Description: "catalog default (Xiaomi MiMo)"},
{ID: "xiaomi/mimo-v2.5-pro", Description: "Xiaomi MiMo V2.5 Pro"},
{ID: "mimo-v2.5-pro-ultraspeed", Description: "fast model (Xiaomi MiMo)"},
{ID: "xiaomi/mimo-v2.5", Description: "multimodal model (Xiaomi)"},
{ID: "tencent/hy3", Description: "free Tencent HY3 model"},
{ID: "MiniMax-M3", Description: "MiniMax model"},
{ID: "minimax/minimax-m3", Description: "MiniMax M3 model"},
{ID: "qwen-plus", Description: "Qwen model"},
{ID: "qwen/qwen3.7-max", Description: "Qwen flagship coding model"},
{ID: "gemini-2.5-pro", Description: "long-context model (Google)"},
{ID: "google/gemini-3.1-flash-lite", Description: "Google Gemini 3.1 Flash Lite"},
{ID: "glm-4.6", Description: "Z.ai model"},
{ID: "z-ai/glm-5.2", Description: "GLM coding & reasoning model"},
{ID: "nvidia/llama-3.1-nemotron-70b-instruct", Description: "NVIDIA NIM model"},
{ID: "nvidia/nemotron-3-ultra-550b-a55b:free", Description: "free Nemotron 3 Ultra reasoning MoE"},
{ID: "xiaomi/mimo-v2.6-pro", Description: "catalog default", ContextWindow: 262_144},
{ID: "auto", Description: "smart routing (picks a capable model per request)"},
{ID: "xiaomi/mimo-v2.6-flash", Description: "fast model (Xiaomi MiMo V2.6 Flash)", ContextWindow: 262_144},
},
"atomic-chat": {
{ID: "gpt-4.1", Description: "catalog default"},
+29 -11
View File
@@ -35,8 +35,8 @@ func TestModelsAreProviderScoped(t *testing.T) {
},
{
provider: "chatgpt",
want: []string{"gpt-5.5", "gpt-5.4", "gpt-5.4-mini", "gpt-5.3-codex-spark"},
notWant: []string{"gpt-5", "gpt-4.1", "openai/gpt-4.1"},
want: []string{"gpt-6.1-sol", "gpt-6.1-terra", "gpt-6-astra", "gpt-6-luna"},
notWant: []string{"gpt-5.5", "gpt-5.4", "gpt-5.4-mini", "gpt-5.3-codex-spark", "openai/gpt-4.1"},
},
{
provider: "aimlapi",
@@ -65,7 +65,7 @@ func TestModelsAreProviderScoped(t *testing.T) {
},
{
provider: "minimaxi-cn",
want: []string{"MiniMax-M3", "MiniMax-M2.7", "MiniMax-M2.1"},
want: []string{"MiniMax-M3", "MiniMax-M3.1-Flash", "MiniMax-M2.7", "MiniMax-M2.1"},
notWant: []string{"gpt-4.1", "claude-sonnet-4.5"},
},
{
@@ -74,13 +74,15 @@ func TestModelsAreProviderScoped(t *testing.T) {
notWant: []string{"gpt-4.1", "claude-sonnet-4.5"},
},
{
// Offline fallback only; the live /v1/models list is shown when reachable.
provider: "gitlawb-opengateway",
want: []string{
"mimo-v2.5-pro", "tencent/hy3",
"xiaomi/mimo-v2.5-pro", "xiaomi/mimo-v2.5", "minimax/minimax-m3", "qwen/qwen3.7-max",
"google/gemini-3.1-flash-lite", "z-ai/glm-5.2", "nvidia/nemotron-3-ultra-550b-a55b:free",
want: []string{"xiaomi/mimo-v2.6-pro", "auto", "xiaomi/mimo-v2.6-flash"},
notWant: []string{
"openai/gpt-4.1", "claude-sonnet-4.5",
"mimo-v2.5-pro", "xiaomi/mimo-v2.5-pro", "qwen-plus", "glm-4.6", "gemini-2.5-pro",
"MiniMax-M3", "nvidia/llama-3.1-nemotron-70b-instruct",
"qwen/qwen3.8-max", "qwen/qwen3.8-flash",
},
notWant: []string{"openai/gpt-4.1", "claude-sonnet-4.5"},
},
{
provider: "opencode-go-anthropic-compatible",
@@ -165,8 +167,8 @@ func TestMiniMaxModelsExposeCurrentCapabilities(t *testing.T) {
t.Fatal("provider minimax missing from catalog")
}
models := Models(descriptor)
if len(models) < 2 || models[0].ID != "MiniMax-M3" || models[1].ID != "MiniMax-M2.7" {
t.Fatalf("MiniMax current models = %#v, want MiniMax-M3 followed by MiniMax-M2.7", modelIDs(models))
if len(models) < 3 || models[0].ID != "MiniMax-M3" || models[1].ID != "MiniMax-M3.1-Flash" || models[2].ID != "MiniMax-M2.7" {
t.Fatalf("MiniMax current models = %#v, want MiniMax-M3, MiniMax-M3.1-Flash, then MiniMax-M2.7", modelIDs(models))
}
m3 := models[0]
@@ -178,7 +180,23 @@ func TestMiniMaxModelsExposeCurrentCapabilities(t *testing.T) {
t.Fatalf("MiniMax-M3 modalities = input:%#v output:%#v", m3.InputModalities, m3.OutputModalities)
}
m27 := models[1]
m31Flash := models[1]
if m31Flash.ContextWindow != 1_000_000 || !m31Flash.ToolCall || !m31Flash.Reasoning {
t.Fatalf("MiniMax-M3.1-Flash capabilities = %#v, want 1M context with tools and reasoning", m31Flash)
}
if !reflect.DeepEqual(m31Flash.ReasoningEfforts, []string{"low", "medium", "high", "xhigh", "max"}) ||
m31Flash.DefaultReasoningEffort != "max" {
t.Fatalf("MiniMax-M3.1-Flash reasoning efforts = %#v default %q", m31Flash.ReasoningEfforts, m31Flash.DefaultReasoningEffort)
}
if !reflect.DeepEqual(m31Flash.InputModalities, []string{"text", "image", "video"}) ||
!reflect.DeepEqual(m31Flash.OutputModalities, []string{"text"}) {
t.Fatalf("MiniMax-M3.1-Flash modalities = input:%#v output:%#v", m31Flash.InputModalities, m31Flash.OutputModalities)
}
if m31Flash.InputCost != 0.15 || m31Flash.OutputCost != 0.60 {
t.Fatalf("MiniMax-M3.1-Flash costs = %f/%f, want 0.15/0.60", m31Flash.InputCost, m31Flash.OutputCost)
}
m27 := models[2]
if m27.ContextWindow != 204_800 || !m27.ToolCall || !m27.Reasoning {
t.Fatalf("MiniMax-M2.7 capabilities = %#v, want 204.8K context with tools and reasoning", m27)
}
+14 -1
View File
@@ -54,9 +54,22 @@ func TestDefaultedOpenGatewayURL(t *testing.T) {
if got := defaultedOpenGatewayURL(providercatalog.Descriptor{DefaultBaseURL: "https://gw.example.com/v1"}, ""); got != "https://gw.example.com/v1/models" {
t.Fatalf("derived = %q", got)
}
if got := defaultedOpenGatewayURL(providercatalog.Descriptor{DefaultBaseURL: "::not a url"}, ""); got != "https://opengateway.gitlawb.com/v1/models" {
if got := defaultedOpenGatewayURL(providercatalog.Descriptor{DefaultBaseURL: "::not a url"}, ""); got != "https://api.twigpine.com/v1/models" {
t.Fatalf("fallback = %q", got)
}
// The catalog descriptor points at the Twigpine API host.
descriptor, ok := providercatalog.Get("gitlawb-opengateway")
if !ok {
t.Fatal("gitlawb-opengateway missing from catalog")
}
if got := defaultedOpenGatewayURL(descriptor, ""); got != "https://api.twigpine.com/v1/models" {
t.Fatalf("catalog descriptor = %q, want https://api.twigpine.com/v1/models", got)
}
// A descriptor still carrying the legacy host keeps deriving from it, so
// old-host configs resolve unchanged (the old host still serves clients).
if got := defaultedOpenGatewayURL(providercatalog.Descriptor{DefaultBaseURL: "https://opengateway.gitlawb.com/v1"}, ""); got != "https://opengateway.gitlawb.com/v1/models" {
t.Fatalf("legacy host = %q, want https://opengateway.gitlawb.com/v1/models", got)
}
}
func TestDefaultedOpenRouterURL(t *testing.T) {
+12 -1
View File
@@ -19,7 +19,7 @@ import (
const (
DefaultModelsDevURL = "https://models.dev/api.json"
defaultOpenGatewayURL = "https://opengateway.gitlawb.com/v1/models"
defaultOpenGatewayURL = "https://api.twigpine.com/v1/models"
defaultOpenRouterURL = "https://openrouter.ai/api/v1/models"
modelsDevSource = "models.dev"
openGatewaySource = "opengateway"
@@ -120,6 +120,8 @@ func ParseModelsDevProvider(body []byte, providerID string) ([]Model, error) {
// ParseOpenGatewayCatalog parses OpenGateway's live GET /v1/models payload (or the
// older {models:[...]} shape). The gateway already curates what it exposes, so
// every non-empty id is accepted rather than applying the coding-model heuristic.
// Retired entries (status "retired", listed by ?status=all) are skipped so a
// picker never offers a model the gateway no longer serves.
func ParseOpenGatewayCatalog(body []byte) ([]Model, error) {
items, err := parseOpenAIStyleModelList(body)
if err != nil {
@@ -127,6 +129,9 @@ func ParseOpenGatewayCatalog(body []byte) ([]Model, error) {
}
models := make([]Model, 0, len(items))
for _, item := range items {
if item.retired() {
continue
}
model := item.toModel("", openGatewaySource)
if model.ID == "" {
continue
@@ -224,6 +229,7 @@ func PublicLiveCatalog(providerID string) bool {
type remoteModel struct {
ID string `json:"id"`
Name string `json:"name"`
Status string `json:"status"` // OpenGateway: "active" | "retired"; absent means active
Description string `json:"description"`
ContextWindow int `json:"context_window"`
ContextWindowCamel int `json:"contextWindow"`
@@ -348,6 +354,11 @@ func (model remoteModel) toModel(key string, source string) Model {
}
}
// retired reports whether the catalog marks this entry as retired.
func (model remoteModel) retired() bool {
return strings.EqualFold(strings.TrimSpace(model.Status), "retired")
}
func normalizedServiceTiers(tiers []string, legacy []string) []string {
values := append(append([]string{}, tiers...), legacy...)
seen := map[string]bool{}
@@ -67,6 +67,58 @@ func TestParseModelsDevProviderScopesAndMapsMetadata(t *testing.T) {
}
}
func TestParseOpenGatewayCatalogSkipsRetiredModels(t *testing.T) {
// Mirrors GET https://api.twigpine.com/v1/models?status=all once the gateway
// retires models: an active entry plus a retired one carrying a successor.
body := []byte(`{
"object": "list",
"data": [
{
"id": "xiaomi/mimo-v2.6-pro",
"object": "model",
"name": "MiMo V2.6-Pro",
"description": "flagship omnimodal agent model",
"aliases": ["xiaomi/mimo-v2.5-pro", "mimo-v2.5-pro"],
"context_window": 262144,
"pricing": {"prompt": "0.000001", "completion": "0.000003"},
"supports_tools": true,
"status": "active"
},
{
"id": "qwen/qwen-plus",
"object": "model",
"name": "Qwen Plus",
"description": "retired general model",
"context_window": 131072,
"status": "retired",
"successor": "qwen/qwen3.7-max"
}
]
}`)
models, err := ParseOpenGatewayCatalog(body)
if err != nil {
t.Fatalf("ParseOpenGatewayCatalog returned error: %v", err)
}
if got := strings.Join(modelIDs(models), ","); got != "xiaomi/mimo-v2.6-pro" {
t.Fatalf("models = %q, want only the active entry", got)
}
descriptor, ok := providercatalog.Get("gitlawb-opengateway")
if !ok {
t.Fatal("gitlawb-opengateway missing from catalog")
}
// Onboarding keeps the default selected only on an exact id match against
// the live list, so the default must be a real active id, not an alias.
if !containsModelID(models, descriptor.DefaultModel) {
t.Fatalf("default model %q missing from live list %v", descriptor.DefaultModel, modelIDs(models))
}
// A list made only of retired entries is treated as empty, not offered.
if _, err := ParseOpenGatewayCatalog([]byte(`{"data":[{"id":"old/model","status":"retired"}]}`)); err == nil {
t.Fatal("expected an error when every entry is retired")
}
}
func TestParseOpenGatewayCatalogSupportsRichModelJSON(t *testing.T) {
body := []byte(`{
"models": [
@@ -0,0 +1,31 @@
package providermodeldiscovery
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/Twigpine/zero/internal/config"
)
func TestChatGPTDiscoveryUsesCurrentCompatibilityVersion(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.URL.Query().Get("client_version"); got != "0.159.0" {
t.Errorf("client_version = %q, want documented GPT-6.1 Terra compatibility floor 0.159.0", got)
}
_, _ = w.Write([]byte(`{"models":[{"slug":"gpt-future-coder"}]}`))
}))
defer server.Close()
profile := config.ProviderProfile{
CatalogID: "chatgpt", ProviderKind: config.ProviderKindOpenAICompatible,
BaseURL: server.URL + "/backend-api/codex", APIKey: "test-bearer",
}
models, err := Discover(context.Background(), profile, Options{HTTPClient: server.Client()})
if err != nil {
t.Fatal(err)
}
if len(models) != 1 || models[0].ID != "gpt-future-coder" {
t.Fatalf("models = %#v, want only gpt-future-coder", models)
}
}
+9 -2
View File
@@ -20,8 +20,9 @@ import (
)
const (
anthropicVersion = "2023-06-01"
chatGPTModelsProtocolVersion = "0.146.0"
anthropicVersion = "2023-06-01"
// GPT-6.1 Terra's documented Codex client floor: https://learn.chatgpt.com/docs/models.
chatGPTModelsProtocolVersion = "0.159.0"
)
type Model struct {
@@ -354,6 +355,7 @@ type modelsResponseItem struct {
Name string `json:"name"`
Description string `json:"description"`
Visibility string `json:"visibility"`
Status string `json:"status"`
ContextWindow int `json:"context_window"`
ContextWindowAlt int `json:"contextWindow"`
ContextLength int `json:"context_length"`
@@ -411,6 +413,11 @@ func parseModelsResponse(body []byte) ([]Model, error) {
if visibility := strings.ToLower(strings.TrimSpace(item.Visibility)); visibility != "" && visibility != "list" {
continue
}
// OpenGateway marks lifecycle with status "active" | "retired" (retired
// entries only appear with ?status=all). Never offer a retired model.
if strings.EqualFold(strings.TrimSpace(item.Status), "retired") {
continue
}
seen[id] = true
description := firstNonEmptyDiscovery(
item.DisplayName,
@@ -346,6 +346,70 @@ func TestDiscoverCatalogOpenGatewayUsesLiveListWithoutKey(t *testing.T) {
}
}
func TestDiscoverCatalogOpenGatewayDropsRetiredAndKeepsDefault(t *testing.T) {
// Both the catalog fetch and the live /v1/models probe return one active and
// one retired entry. Users must only ever see the active one, and the
// catalog's default model must be among the ids discovery returns (onboarding
// keeps the default selected only on an exact id match).
const body = `{"object":"list","data":[
{"id":"xiaomi/mimo-v2.6-pro","name":"MiMo V2.6-Pro","context_window":262144,"supports_tools":true,"status":"active"},
{"id":"qwen/qwen-plus","name":"Qwen Plus","context_window":131072,"status":"retired","successor":"qwen/qwen3.7-max"}
]}`
var paths []string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
paths = append(paths, r.URL.Path)
switch r.URL.Path {
case "/catalog/v1/models", "/v1/models":
_, _ = w.Write([]byte(body))
default:
t.Fatalf("unexpected path %q", r.URL.Path)
}
}))
defer server.Close()
descriptor, ok := providercatalog.Get("gitlawb-opengateway")
if !ok {
t.Fatal("gitlawb-opengateway missing from catalog")
}
provider := descriptor
provider.DefaultBaseURL = server.URL + "/v1"
models, err := DiscoverCatalog(context.Background(), provider, config.ProviderProfile{
CatalogID: "gitlawb-opengateway",
ProviderKind: config.ProviderKindOpenAICompatible,
BaseURL: server.URL + "/v1",
Model: descriptor.DefaultModel,
}, Options{
HTTPClient: server.Client(),
OpenGatewayURL: server.URL + "/catalog/v1/models",
})
if err != nil {
t.Fatalf("DiscoverCatalog: %v", err)
}
if got := strings.Join(modelIDs(models), ","); got != "xiaomi/mimo-v2.6-pro" {
t.Fatalf("models = %q, want only the active entry", got)
}
if models[0].ID != descriptor.DefaultModel {
t.Fatalf("default model %q missing from discovered %v", descriptor.DefaultModel, modelIDs(models))
}
if strings.Join(paths, ",") != "/catalog/v1/models,/v1/models" {
t.Fatalf("requests = %v, want catalog fetch then live probe", paths)
}
}
func TestParseModelsResponseSkipsRetiredStatus(t *testing.T) {
models, err := parseModelsResponse([]byte(`{"data":[
{"id":"active-model","status":"active"},
{"id":"retired-model","status":"Retired"},
{"id":"no-status-model"}
]}`))
if err != nil {
t.Fatalf("parseModelsResponse: %v", err)
}
if got := strings.Join(modelIDs(models), ","); got != "active-model,no-status-model" {
t.Fatalf("models = %q, want retired entry dropped", got)
}
}
func TestDiscoverCatalogOpenRouterKeepsLiveOnlyModels(t *testing.T) {
// Catalog omits anthropic/claude-sonnet-4.5 and the generic tools-only id;
// live retains both so preferLive keeps coding-capable live-only entries
+22 -5
View File
@@ -49,6 +49,19 @@ func thinkingBudgetForEffort(effort string) int {
}
}
func miniMaxM31FlashEffort(model, effort string) string {
if !strings.EqualFold(strings.TrimSpace(model), "MiniMax-M3.1-Flash") {
return ""
}
effort = strings.ToLower(strings.TrimSpace(effort))
switch effort {
case "low", "medium", "high", "xhigh", "max":
return effort
default:
return ""
}
}
// resolveThinking returns the thinking budget and the max_tokens to send. When a
// budget is requested, max_tokens is raised if needed so the budget plus a
// minimum response both fit (Anthropic rejects budget >= max_tokens). enabled is
@@ -377,11 +390,15 @@ func (provider *Provider) anthropicRequest(request zeroruntime.CompletionRequest
Messages: messages,
Stream: true,
}
// Extended thinking: enable when a reasoning effort was requested. The budget
// is counted against max_tokens, so raise max_tokens to leave room for the
// response. Temperature is intentionally left unset (Anthropic requires the
// default when thinking is on).
if budget, effectiveMax, enabled := resolveThinking(request.ReasoningEffort, provider.maxTokens); enabled {
// MiniMax-M3.1-Flash uses adaptive thinking with output_config.effort instead
// of Anthropic's token budget. For other models, the budget is counted against
// max_tokens, so raise max_tokens to leave room for the response. Temperature
// is intentionally left unset (Anthropic requires the default when thinking
// is on).
if effort := miniMaxM31FlashEffort(provider.model, request.ReasoningEffort); effort != "" {
mapped.Thinking = &thinkingConfig{Type: "adaptive"}
mapped.OutputConfig = &outputConfig{Effort: effort}
} else if budget, effectiveMax, enabled := resolveThinking(request.ReasoningEffort, provider.maxTokens); enabled {
mapped.MaxTokens = effectiveMax
mapped.Thinking = &thinkingConfig{Type: "enabled", BudgetTokens: budget}
}
@@ -266,6 +266,53 @@ func TestStreamCompletionEnablesThinkingWhenEffortRequested(t *testing.T) {
}
}
func TestStreamCompletionUsesMiniMaxAdaptiveEffort(t *testing.T) {
t.Parallel()
for _, effort := range []string{"low", "xhigh"} {
effort := effort
t.Run(effort, func(t *testing.T) {
t.Parallel()
var gotBody map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil {
t.Fatalf("decode request body: %v", err)
}
writeSSEEvent(w, "message_stop", `{"type":"message_stop"}`)
}))
defer server.Close()
provider, err := New(Options{BaseURL: server.URL + "/", Model: "MiniMax-M3.1-Flash", MaxTokens: 64_000})
if err != nil {
t.Fatalf("New returned error: %v", err)
}
stream, err := provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "hi"}},
ReasoningEffort: effort,
})
if err != nil {
t.Fatalf("StreamCompletion returned error: %v", err)
}
drain(stream)
thinking, ok := gotBody["thinking"].(map[string]any)
if !ok || thinking["type"] != "adaptive" {
t.Fatalf("thinking = %#v, want adaptive", gotBody["thinking"])
}
if _, ok := thinking["budget_tokens"]; ok {
t.Fatalf("MiniMax thinking must not include budget_tokens: %#v", thinking)
}
outputConfig, ok := gotBody["output_config"].(map[string]any)
if !ok || outputConfig["effort"] != effort {
t.Fatalf("output_config = %#v, want effort %q", gotBody["output_config"], effort)
}
if mt, _ := gotBody["max_tokens"].(float64); int(mt) != 64_000 {
t.Fatalf("max_tokens = %#v, want unchanged 64000", gotBody["max_tokens"])
}
})
}
}
func TestStreamCompletionOmitsThinkingWithoutEffort(t *testing.T) {
t.Parallel()
var gotBody map[string]any
+11 -7
View File
@@ -10,17 +10,21 @@ type messagesRequest struct {
System any `json:"system,omitempty"`
// Thinking enables extended thinking when a reasoning effort was requested.
// Omitted (nil) for normal requests so behavior is unchanged.
Thinking *thinkingConfig `json:"thinking,omitempty"`
Tools []anthropicTool `json:"tools,omitempty"`
Stream bool `json:"stream"`
Thinking *thinkingConfig `json:"thinking,omitempty"`
OutputConfig *outputConfig `json:"output_config,omitempty"`
Tools []anthropicTool `json:"tools,omitempty"`
Stream bool `json:"stream"`
}
// thinkingConfig requests extended thinking with a token budget. Type is always
// "enabled"; BudgetTokens is how many tokens the model may spend thinking (it is
// counted against max_tokens).
// thinkingConfig requests extended thinking. Anthropic uses "enabled" with a
// token budget; MiniMax-M3.1-Flash uses "adaptive" with output_config.effort.
type thinkingConfig struct {
Type string `json:"type"`
BudgetTokens int `json:"budget_tokens"`
BudgetTokens int `json:"budget_tokens,omitempty"`
}
type outputConfig struct {
Effort string `json:"effort"`
}
type anthropicMessage struct {
+1 -1
View File
@@ -160,7 +160,7 @@ func TestNewPassesOpenGatewayHY3ModelThrough(t *testing.T) {
for range stream {
}
if transport.request.URL.String() != "https://opengateway.gitlawb.com/v1/chat/completions" {
if transport.request.URL.String() != "https://api.twigpine.com/v1/chat/completions" {
t.Fatalf("request URL = %q, want OpenGateway chat completions", transport.request.URL.String())
}
var body map[string]any
@@ -288,10 +288,11 @@ func TestScanSSEDataWithContextContentResetsOnData(t *testing.T) {
pr, pw := io.Pipe()
go func() {
// One data line every 30ms for ~300ms (well past the 120ms content window,
// with comfortable slack under the 100ms idle timeout), so the content
// watchdog keeps resetting, then close cleanly.
for i := 0; i < 10; i++ {
// One data line every 30ms for ~750ms (well past the 600ms content window,
// with ~470ms of slack under the 500ms idle timeout, so a scheduling stall
// on a busy shared runner does not trip it), so the content watchdog keeps
// resetting, then close cleanly.
for i := 0; i < 25; i++ {
if _, err := io.WriteString(pw, "data: chunk\n\n"); err != nil {
return
}
@@ -303,7 +304,7 @@ func TestScanSSEDataWithContextContentResetsOnData(t *testing.T) {
n := 0
done := make(chan error, 1)
go func() {
done <- ScanSSEDataWithContext(context.Background(), func() {}, pr, 100*time.Millisecond, func(string) bool { n++; return true })
done <- ScanSSEDataWithContext(context.Background(), func() {}, pr, 500*time.Millisecond, func(string) bool { n++; return true })
}()
select {
@@ -314,8 +315,8 @@ func TestScanSSEDataWithContextContentResetsOnData(t *testing.T) {
case <-time.After(3 * time.Second):
t.Fatal("ScanSSEDataWithContext hung on a producing stream")
}
if n != 10 {
t.Fatalf("handled %d data lines, want 10", n)
if n != 25 {
t.Fatalf("handled %d data lines, want 25", n)
}
}
+127
View File
@@ -63,6 +63,14 @@ import (
// rest of a key from prose written after a whole one: that prose would be eaten,
// and a following JWT would lose its header.
//
// CALLER-SUPPLIED SECRETS (Options.ExtraSecretValues) get the same treatment
// from redactExtraSecretValues. Such a value is often opaque, matching no shape
// here, so without its own gap-tolerant match a separator inside it left both
// fragments in the output. Unlike a shape, the value is known exactly and has no
// word boundary to judge, so it is found in a copy with the separators removed
// and mapped back. RedactString runs it first, before any shape matcher, so a
// caller that clips or reformats the result afterwards never sees a fragment.
//
// WHAT THIS DELIBERATELY DOES NOT TREAT AS A SEPARATOR: tab, newline and
// carriage return. Those three are real text structure rather than invisible
// filler, so a credential broken across a line boundary is still two visible
@@ -435,6 +443,125 @@ func openAIShapeIsSecret(match string) bool {
return !strings.Contains(strings.TrimPrefix(match, "sk-"), "-")
}
// redactExtraSecretValues replaces every occurrence of every caller-supplied
// secret, whole or with runs of invisible separators between its characters.
//
// All occurrences, overlapping ones included, are found in the same text and
// overlapping spans are merged before anything is replaced. Replacing one at a
// time let each replacement consume bytes another occurrence needed: a shorter
// value inside a split longer one, a periodic value ("abcabc" in
// "abc<NUL>abcabc"), a value whose first and last characters match, or two
// values that overlap all left a fragment visible. Occurrences that only touch
// stay separate replacements, as they always were.
//
// Separators inside a match are covered by its replacement; a split match never
// starts or ends on one, so those beside it stay, unless the value as written
// starts or ends with them. Tab, newline and carriage return are not separators
// (see above).
func redactExtraSecretValues(value string, secrets []string, replacement string) string {
haystack, offsets := value, []int(nil)
if containsSplitSeparator(value) {
haystack, offsets = compactSplitSeparators(value)
}
var spans [][2]int
for _, secret := range secrets {
if strings.TrimSpace(secret) == "" {
continue
}
needle := secret
if offsets != nil {
// The value as written always matches, as it did before split
// matching existed. The compacted match cannot stand in for it: a
// value made only of separators compacts to nothing, and separators
// at either end of a value fall outside its compacted match.
if strings.Contains(value, secret) {
for _, at := range overlappingIndexes(value, secret) {
spans = append(spans, [2]int{at, at + len(secret)})
}
}
needle = stripSplitSeparators(secret)
}
if needle == "" || !strings.Contains(haystack, needle) {
continue
}
for _, at := range overlappingIndexes(haystack, needle) {
start, end := at, at+len(needle)
if offsets != nil {
start, end = offsets[start], offsets[end-1]+1
}
spans = append(spans, [2]int{start, end})
}
}
if len(spans) == 0 {
return value
}
sort.Slice(spans, func(i, j int) bool { return spans[i][0] < spans[j][0] })
var out strings.Builder
out.Grow(len(value))
written := 0
for i := 0; i < len(spans); {
start, end := spans[i][0], spans[i][1]
for i++; i < len(spans) && spans[i][0] < end; i++ {
end = max(end, spans[i][1])
}
out.WriteString(value[written:start])
out.WriteString(replacement)
written = end
}
out.WriteString(value[written:])
return out.String()
}
// compactSplitSeparators returns value with every splitSecretSeparator removed,
// and for each byte of the result the offset of that byte in value.
func compactSplitSeparators(value string) (string, []int) {
var builder strings.Builder
builder.Grow(len(value))
offsets := make([]int, 0, len(value))
for offset := 0; offset < len(value); {
r, width := utf8.DecodeRuneInString(value[offset:])
if !splitSecretSeparator(r) {
builder.WriteString(value[offset : offset+width])
for i := 0; i < width; i++ {
offsets = append(offsets, offset+i)
}
}
offset += width
}
return builder.String(), offsets
}
// overlappingIndexes returns the start of every occurrence of needle in
// haystack, overlapping ones included, in one linear Knuth-Morris-Pratt pass:
// restarting a search one byte after each match is quadratic for a periodic
// value over text full of it.
func overlappingIndexes(haystack, needle string) []int {
fallback := make([]int, len(needle))
for i, k := 1, 0; i < len(needle); i++ {
for k > 0 && needle[i] != needle[k] {
k = fallback[k-1]
}
if needle[i] == needle[k] {
k++
}
fallback[i] = k
}
var starts []int
for i, k := 0, 0; i < len(haystack); i++ {
for k > 0 && haystack[i] != needle[k] {
k = fallback[k-1]
}
if haystack[i] == needle[k] {
k++
}
if k == len(needle) {
starts = append(starts, i-len(needle)+1)
k = fallback[k-1]
}
}
return starts
}
// redactControlSplitSecrets replaces credentials whose body is interrupted by
// invisible characters. It is a no-op for text containing none of them, which is
// nearly all text, so the contiguous path keeps its behavior exactly.
+108
View File
@@ -105,6 +105,114 @@ func TestRedactStringCoversTheSeparatorsInsideASplitSecret(t *testing.T) {
}
}
// A CALLER-SUPPLIED SECRET IS A CREDENTIAL TOO.
//
// ExtraSecretValues names a value the caller knows is secret, whatever its
// shape: an opaque provider key matches none of the shape matchers, so the
// split pass above never saw it, and a separator inside it left both fragments
// in the output. It has to be matched across the same separators, cover them in
// its replacement, and leave the separators beside it where they were.
func TestRedactStringRedactsExtraSecretValuesSplitByInvisibleCharacters(t *testing.T) {
const secret = "zq7Mv2Lp9Xr4Tn8Wk3"
options := Options{ExtraSecretValues: []string{secret}}
if out := RedactString("key "+secret, Options{}); !strings.Contains(out, secret) {
t.Fatalf("SETUP INVALID: a shape matcher already redacts %q, so this would not reach the explicit-value pass: %q", secret, out)
}
for _, testCase := range []struct {
name string
value string
want string
}{
{"NUL", "key " + secret[:6] + "\x00" + secret[6:], "key [REDACTED]"},
{"zero width space", "key " + secret[:1] + "\u200b" + secret[1:], "key [REDACTED]"},
{"soft hyphen near the end", "key " + secret[:len(secret)-1] + "\u00ad" + secret[len(secret)-1:], "key [REDACTED]"},
{"every gap", "key " + strings.Join(strings.Split(secret, ""), "\x1b"), "key [REDACTED]"},
{"adjacent separators", "key " + secret[:9] + "\x00\x1b\u2060" + secret[9:], "key [REDACTED]"},
{"twice", secret[:3] + "\x00" + secret[3:] + " and " + secret[:12] + "\ufeff" + secret[12:], "[REDACTED] and [REDACTED]"},
{"separators beside it stay", "\x00" + secret[:5] + "\u200b" + secret[5:] + "\x00", "\x00[REDACTED]\x00"},
{"a line break is not a gap", secret[:5] + "\n" + secret[5:], secret[:5] + "\n" + secret[5:]},
} {
t.Run(testCase.name, func(t *testing.T) {
if got := RedactString(testCase.value, options); got != testCase.want {
t.Errorf("RedactString(%q) = %q, want %q", testCase.value, got, testCase.want)
}
})
}
// A SHORTER SECRET MUST NOT CLAIM PART OF A SPLIT LONGER ONE. Longest first
// has to hold across the contiguous and split matches together: replacing
// a contiguous shorter value first left the rest of the split longer one
// ("[REDACTED]\x00Lp9Xr4Tn8Wk3") visible.
for _, testCase := range []struct {
name string
secrets []string
value string
want string
}{
{"prefix", []string{secret[:6], secret}, "key " + secret[:6] + "\x00" + secret[6:], "key [REDACTED]"},
{"suffix", []string{secret[9:], secret}, "key " + secret[:9] + "\u200b" + secret[9:], "key [REDACTED]"},
{"inside", []string{secret[4:10], secret}, "key " + secret[:2] + "\x00" + secret[2:], "key [REDACTED]"},
{"shorter alone too", []string{secret, secret[:6]}, secret[:6] + " and " + secret[:6] + "\x00" + secret[6:], "[REDACTED] and [REDACTED]"},
} {
t.Run("overlap "+testCase.name, func(t *testing.T) {
if got := RedactString(testCase.value, Options{ExtraSecretValues: testCase.secrets}); got != testCase.want {
t.Errorf("RedactString(%q, %q) = %q, want %q", testCase.value, testCase.secrets, got, testCase.want)
}
})
}
// OVERLAPPING OCCURRENCES ARE ONE SPAN. Replacing one occurrence (either
// form) and then looking for the next consumed bytes the other needed:
// "abc\x00abcabc" holds a split "abc\x00abc" overlapping a whole "abcabc",
// and replacing the whole one first left "abc\x00" in front of it. Every
// occurrence of every value, in either form, has to be found in the same
// text and overlapping ones replaced together.
for _, testCase := range []struct {
name string
secrets []string
value string
want string
}{
{"split then whole, periodic secret", []string{"abcabc"}, "abc\x00abcabc", "[REDACTED]"},
{"whole then split, periodic secret", []string{"abcabc"}, "abcabc\x00abc", "[REDACTED]"},
{"shared first and last character", []string{"xY7qx"}, "key xY7q\u200bxY7qx", "key [REDACTED]"},
{"two secrets overlapping across a split", []string{"zq7Mv2Lp", "Lp9Xr4Tn"}, "key zq7Mv2L\x00p9Xr4Tn", "key [REDACTED]"},
{"whole occurrences overlapping", []string{"xY7qx"}, "xY7qxY7qx", "[REDACTED]"},
{"adjacent occurrences stay two", []string{"abc"}, "abc\x00abc", "[REDACTED]\x00[REDACTED]"},
} {
t.Run("overlapping occurrences "+testCase.name, func(t *testing.T) {
if got := RedactString(testCase.value, Options{ExtraSecretValues: testCase.secrets}); got != testCase.want {
t.Errorf("RedactString(%q, %q) = %q, want %q", testCase.value, testCase.secrets, got, testCase.want)
}
})
}
// A SECRET AS WRITTEN IS ALWAYS REDACTED. The split match compares the text
// and the value with separators removed; it adds to the whole match and
// must never replace it. Once the text held any separator, a value made only
// of separators compacted to nothing and was skipped, and separators at
// either end of a value fell outside its compacted match, so both stayed
// visible where a plain replacement had always caught them.
for _, testCase := range []struct {
name string
secrets []string
value string
want string
}{
{"separator-only secret", []string{"\x00"}, "a\x00b", "a[REDACTED]b"},
{"separator-only secret beside another separator", []string{"\u200b"}, "a\u200bb\x00", "a[REDACTED]b\x00"},
{"separators at both ends", []string{"\u200babc\x00"}, "x\u200babc\x00y", "x[REDACTED]y"},
} {
t.Run("as written "+testCase.name, func(t *testing.T) {
if got := RedactString(testCase.value, Options{ExtraSecretValues: testCase.secrets}); got != testCase.want {
t.Errorf("RedactString(%q, %q) = %q, want %q", testCase.value, testCase.secrets, got, testCase.want)
}
})
}
// A value the caller names that is NOT in the text leaves the text alone,
// separators and all.
if value := "unrelated\x00text \u200bhere"; RedactString(value, options) != value {
t.Errorf("RedactString rewrote text without the secret in it: %q", RedactString(value, options))
}
}
// NOTHING ELSE MOVES. The compaction exists to find a match; it must never
// reach text that has no secret in it, and text with no invisible character at
// all must take exactly the path it took before.
@@ -0,0 +1,40 @@
package redaction
import "testing"
func TestRedactSecretValuesRemovesOnlyTheKnownValues(t *testing.T) {
const key = "aiml-0123456789abcdef0123456789abcdef"
nul := string(rune(0))
zeroWidthSpace := string(rune(0x200b))
for _, tc := range []struct {
name string
value string
want string
// shaped marks text RedactString would rewrite by shape alone, so the
// case shows that RedactSecretValues leaves shapes to RedactString.
shaped bool
}{
{name: "whole", value: "invalid key " + key + " for this account", want: "invalid key " + RedactedSecret + " for this account"},
{name: "bearer form", value: "got Bearer " + key, want: "got Bearer " + RedactedSecret},
{name: "split by NUL", value: "invalid key " + key[:9] + nul + key[9:], want: "invalid key " + RedactedSecret},
{name: "split by a zero-width space", value: "value " + key[:20] + zeroWidthSpace + key[20:] + ".", want: "value " + RedactedSecret + "."},
{name: "password assignment", value: "password=hunter2", want: "password=hunter2", shaped: true},
{name: "another bearer token", value: "Authorization: Bearer some-other-token", want: "Authorization: Bearer some-other-token", shaped: true},
{name: "openai shaped key", value: "use sk-proj-abcdefghijklmnopqrstuvwxyz0123456789", want: "use sk-proj-abcdefghijklmnopqrstuvwxyz0123456789", shaped: true},
} {
t.Run(tc.name, func(t *testing.T) {
if got := RedactSecretValues(tc.value, key); got != tc.want {
t.Fatalf("RedactSecretValues(%q) = %q, want %q", tc.value, got, tc.want)
}
if tc.shaped && RedactString(tc.value, Options{}) == tc.value {
t.Fatalf("RedactString(%q) left it unchanged, so this case does not show the shape patterns are off", tc.value)
}
})
}
for _, secrets := range [][]string{nil, {""}, {" "}} {
if got := RedactSecretValues("token "+key, secrets...); got != "token "+key {
t.Fatalf("RedactSecretValues with secrets %q = %q, want the text unchanged", secrets, got)
}
}
}
+14 -9
View File
@@ -463,15 +463,7 @@ func RedactString(value string, options Options) string {
replacement := replacement(options)
redacted := value
if len(options.ExtraSecretValues) > 0 {
secrets := append([]string{}, options.ExtraSecretValues...)
sort.SliceStable(secrets, func(i, j int) bool {
return len(secrets[i]) > len(secrets[j])
})
for _, secret := range secrets {
if strings.TrimSpace(secret) != "" {
redacted = strings.ReplaceAll(redacted, secret, replacement)
}
}
redacted = redactExtraSecretValues(redacted, options.ExtraSecretValues, replacement)
}
if strings.Contains(redacted, "-----BEGIN ") && strings.Contains(redacted, "PRIVATE KEY") {
@@ -636,6 +628,19 @@ func secretMatchHasDigit(s string) bool {
return false
}
// RedactSecretValues replaces every occurrence of each of secrets in value with
// RedactedSecret, whole or split by invisible separators, exactly as
// RedactString treats Options.ExtraSecretValues. Unlike RedactString it runs
// none of the shape patterns, so text that does not contain one of the values
// comes back unchanged. That suits content where only known values must go,
// such as tool output, which a shape pattern could otherwise rewrite.
func RedactSecretValues(value string, secrets ...string) string {
if len(secrets) == 0 {
return value
}
return redactExtraSecretValues(value, secrets, RedactedSecret)
}
func RedactValue(value any, options Options) any {
return redactReflect(reflect.ValueOf(value), redactionContext{
options: options,
+258
View File
@@ -0,0 +1,258 @@
package skills
import (
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
)
// A skill is a directory, not just its SKILL.md: the instructions often point at
// scripts, references or assets that sit beside them (the agentskills.io
// layout). Install copies that directory, and the skill tool says where it is.
// Install refuses a skill tree larger than this. A skill is a handful of
// scripts and documents; a source that is really a whole repository or a pile
// of binaries should fail loudly instead of filling the skills directory.
var (
maxSkillTreeFiles = 1000
maxSkillTreeBytes = int64(32 << 20)
)
// bundledFileListLimit caps how many bundled files the skill tool lists, so a
// large skill does not crowd the instructions out of the tool output.
const bundledFileListLimit = 50
// skipSkillEntry reports whether a skill tree entry is left out of an install,
// a tree hash and a listing alike. Hidden entries cover VCS metadata (.git,
// which a cloned source always has) and local clutter such as .DS_Store or
// .env; they are not part of what a skill ships.
func skipSkillEntry(name string) bool {
return strings.HasPrefix(name, ".")
}
// copySkillTree copies the skill directory src into dst, which must exist.
// Regular files are copied with normalized permissions (0o755 when the source
// is executable, 0o644 otherwise), so a script stays runnable without carrying
// over group- or world-writable bits. Symlinks are not followed or copied:
// their relative paths come back in skipped, so the caller can say so. Other
// non-regular entries (devices, sockets, pipes) are left out. bundled counts
// the files copied besides SKILL.md.
func copySkillTree(src, dst string) (bundled int, skipped []string, err error) {
files := 0
var total int64
err = filepath.WalkDir(src, func(path string, entry fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if path == src {
return nil
}
rel, err := filepath.Rel(src, path)
if err != nil {
return err
}
if skipSkillEntry(entry.Name()) {
if entry.IsDir() {
return filepath.SkipDir
}
return nil
}
if entry.Type()&fs.ModeSymlink != 0 {
skipped = append(skipped, filepath.ToSlash(rel))
return nil
}
target := filepath.Join(dst, rel)
if entry.IsDir() {
return os.MkdirAll(target, 0o755)
}
if !entry.Type().IsRegular() {
return nil
}
info, err := entry.Info()
if err != nil {
return err
}
files++
total += info.Size()
if files > maxSkillTreeFiles {
return fmt.Errorf("skill has more than %d files; a skill directory should hold only what the skill uses", maxSkillTreeFiles)
}
if total > maxSkillTreeBytes {
return fmt.Errorf("skill is larger than %d MiB; a skill directory should hold only what the skill uses", maxSkillTreeBytes>>20)
}
if filepath.ToSlash(rel) != skillFileName {
bundled++
}
mode := os.FileMode(0o644)
if info.Mode().Perm()&0o111 != 0 {
mode = 0o755
}
return copySkillFile(path, target, mode)
})
if err != nil {
return 0, nil, err
}
return bundled, skipped, nil
}
func copySkillFile(src, dst string, mode os.FileMode) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode)
if err != nil {
return err
}
if _, err := io.Copy(out, in); err != nil {
_ = out.Close()
return err
}
if err := out.Close(); err != nil {
return err
}
// The create mode is filtered by the umask; set it outright so an
// executable script is still executable wherever the install ran.
return os.Chmod(dst, mode)
}
// skillTreeHash hashes every file a skill ships, with the same entries
// copySkillTree copies: its relative path, whether it is executable, and its
// content. Sorted by path, so the hash depends only on the tree.
func skillTreeHash(dir string) (string, error) {
type entry struct {
rel string
exec bool
sum string
}
var entries []entry
err := filepath.WalkDir(dir, func(path string, item fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if path == dir {
return nil
}
if skipSkillEntry(item.Name()) {
if item.IsDir() {
return filepath.SkipDir
}
return nil
}
if !item.Type().IsRegular() {
return nil
}
info, err := item.Info()
if err != nil {
return err
}
data, err := os.ReadFile(path)
if err != nil {
return err
}
rel, err := filepath.Rel(dir, path)
if err != nil {
return err
}
sum := sha256.Sum256(data)
entries = append(entries, entry{rel: filepath.ToSlash(rel), exec: info.Mode().Perm()&0o111 != 0, sum: hex.EncodeToString(sum[:])})
return nil
})
if err != nil {
return "", err
}
sort.Slice(entries, func(i, j int) bool { return entries[i].rel < entries[j].rel })
hash := sha256.New()
for _, item := range entries {
flag := "-"
if item.exec {
flag = "x"
}
// Length-prefixed, so no path can be read as the start of another entry.
fmt.Fprintf(hash, "%d:%s|%s|%s;", len(item.rel), item.rel, flag, item.sum)
}
return "sha256:" + hex.EncodeToString(hash.Sum(nil)), nil
}
// BundledFiles lists the files a skill ships besides its SKILL.md, as
// slash-separated paths relative to the skill's directory, sorted. Hidden
// entries and symlinks are left out: the listing tells the model what the
// skill brings, and is not a way to reach anything outside it. total is the
// number of files found; at most limit come back.
func BundledFiles(skill Skill, limit int) (files []string, total int, err error) {
dir := filepath.Dir(skill.Path)
if strings.TrimSpace(skill.Path) == "" {
return nil, 0, errors.New("skill has no path")
}
err = filepath.WalkDir(dir, func(path string, entry fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if path == dir {
return nil
}
if skipSkillEntry(entry.Name()) {
if entry.IsDir() {
return filepath.SkipDir
}
return nil
}
if !entry.Type().IsRegular() {
return nil
}
rel, err := filepath.Rel(dir, path)
if err != nil {
return err
}
rel = filepath.ToSlash(rel)
if rel == skillFileName {
return nil
}
total++
if total > maxSkillTreeFiles {
// A directory this large is not something the listing should walk
// to the end of; the count stops being exact past the cap.
return filepath.SkipAll
}
files = append(files, rel)
return nil
})
if err != nil {
return nil, 0, err
}
sort.Strings(files)
if limit >= 0 && len(files) > limit {
files = files[:limit]
}
return files, total, nil
}
// ToolOutput is what the skill tool returns for a loaded skill: its SKILL.md
// body and, when the skill ships other files, the directory they are in and
// what they are, so instructions that say "run scripts/extract.py" can be
// followed. A skill that is only a SKILL.md gets its body back unchanged.
func ToolOutput(skill Skill) string {
files, total, err := BundledFiles(skill, bundledFileListLimit)
if err != nil || total == 0 {
return skill.Content
}
var builder strings.Builder
builder.WriteString(strings.TrimRight(skill.Content, "\n"))
builder.WriteString("\n\n---\n")
fmt.Fprintf(&builder, "This skill's files are in %s. Paths in the instructions above are relative to that directory.\n", filepath.Dir(skill.Path))
for _, file := range files {
builder.WriteString("- " + file + "\n")
}
if more := total - len(files); more > 0 {
fmt.Fprintf(&builder, "- (and %d more)\n", more)
}
return builder.String()
}
Loaded 100 of 173 files, more files were not shown because too many files have changed in this diff. Show more