Compare commits

..
Author SHA1 Message Date
Vasanthdev2004 58803032cb Merge remote-tracking branch 'origin/main' into split/permission-mode-and-classifier 2026-10-25 19:32:59 +05:30
Vasanthdev2004 65ded9dc05 Merge remote-tracking branch 'origin/main' into split/permission-mode-and-classifier 2026-10-17 17:27:05 +05:30
Vasanthdev2004 786d4fc23e refactor(cli): use PermissionModeFullAuto instead of its deprecated alias
This branch marks PermissionModeUnsafe deprecated, so staticcheck reports
SA1019 at every use outside the agent package, including three in cli and
one in a test that came in from main. The alias is the same value, so
nothing behaves differently.
2026-10-17 16:40:16 +05:30
Vasanthdev2004 ad3c77cc44 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.
2026-10-17 11:30:59 +05:30
Vasanthdev2004 08a8b9f466 Merge remote-tracking branch 'origin/main' into split/permission-mode-and-classifier
# Conflicts:
#	internal/tui/run.go
2026-10-17 11:30:58 +05:30
Vasanthdev2004 6fce00c070 Merge remote-tracking branch 'origin/main' into split/permission-mode-and-classifier
# Conflicts:
#	internal/acp/agent_test.go
2026-10-12 12:38:29 +05:30
Vasanthdev2004 ccc041c855 Merge remote-tracking branch 'origin/main' into split/permission-mode-and-classifier
Conflicts in internal/acp/agent_test.go and
internal/cli/user_state_isolation_test.go were additions on both sides; both
are kept. Both sides also added the charmbracelet/x/ansi import to
internal/tui/view.go, which merged without a conflict marker but did not
compile; main's import, in the third-party group, is the one kept.
2026-10-12 11:33:17 +05:30
Vasanthdev2004 8608699881 fix(tui): end a full-auto offer on cross-session peer traffic
The offer made by shift+tab is valid only until the next user-visible input
transition, whatever delivers it. Paste, blur, mouse, the clipboard and
dictation all honoured that; peer delivery did not. A peer message raises an
approval prompt or lands in the inbox, a held message is released after a
mode sync, a status line or a peer error is appended to the transcript, an
approval expires and its prompt goes away. Each changes what the user is
looking at, so shift+tab, then peer traffic, then an unrelated ctrl+g still
entered full-auto with the confirmation several events behind them.

The offer is cleared for the whole peer message family at the dispatch
boundary in updateModel, not in the three handlers the report named. Those
were the ones that reproduced; the decision, expiry and error deliveries are
the same kind of transition and were armed the same way, and a rule written
per handler is a rule the next handler forgets.

isPeerDelivery is a closed list, and a test reads the package source for
every peer*Msg type and fails by name if one is not classified and not
driven. The regression sends one production message of each of the seven
types through Update, requires the offer gone and ctrl+g to leave the mode
alone, and then requires a fresh shift+tab to offer and confirm again, so it
cannot pass on a mode cycle that has become unreachable. The message that
needs approval also has to have raised its prompt. Reported by jatmn.
2026-09-21 13:37:01 +05:30
Vasanthdev2004 9f427ba172 Merge remote-tracking branch 'origin/main' into split/permission-mode-and-classifier 2026-09-21 13:31:50 +05:30
Vasanthdev2004 7be892028c fix(tui): only offer full-auto where the footer can show the confirmation key
The footer reserves columns for a docked pet before it renders the status line,
so at 24 and 30 columns the offer came out truncated with no ctrl+g in sight
while ctrl+g still confirmed. The armed flag now means what it says only when
the footer, at its effective width after the reservation, shows the key: the
decision is made on the same truncation the footer applies, an offer that
cannot be shown is not raised, and a live offer is withdrawn when the terminal
shrinks under it. Pinned through the complete View at 24 and 30 columns with a
pet, with 30 pet-free and 96 with a pet as controls.
2026-09-15 10:51:52 +05:30
Vasanthdev2004 6f228bfa00 test(cli): isolate user state in the full-auto parity helper and pin it
execListToolsFor supplied a temporary cwd and a mocked provider config, but
runWithDeps filled the rest with production dependencies, so --list-tools
refreshed the developer's models cache, started their MCP servers and could
touch their config before returning. The helper applies isolateCLIUserState
first, and the helper-isolation table now covers it.
2026-09-15 10:51:52 +05:30
Vasanthdev2004 89eab02ee2 fix(tui): deliver peer callbacks to the event loop through an ordered forwarder
Service.UpdateIdentity invokes the held-release handler synchronously for every
message that becomes deliverable, and the shell calls UpdateIdentity from inside
Update on the shift+tab and ctrl+g paths that change the permission class. The
handler called program.Send, which waits on the unbuffered message channel that
only the loop reads, and the loop was inside that Update: confirming full-auto
with a bypass-mode peer's message held froze the shell. Peer callbacks now
append to a FIFO drained by one goroutine, so the caller returns, order among
peer events is kept, and a late callback after exit returns on the cancelled
program context. The wiring is a function with a fake-able service so the
regression drives a real Bubble Tea program through the production shape.
2026-09-15 10:51:52 +05:30
Vasanthdev2004 c9d8ac91a0 Merge origin/main into split/permission-mode-and-classifier
Keeps both sides of the two model.go hunks: a mouse event and a paste still
answer a pending full-auto offer with not now, and then the terminal-attach
overlay from #1055 takes the event as main intends.
2026-09-15 10:26:35 +05:30
Vasanthdev2004 3b52175bd8 fix(tui): keep the full-auto offer visible before ctrl+g can accept it
The offer renders through modeLabel into the left chip, and several statuses
replaced that chip wholesale: an active recording, a transcription, and a model
download. Arming while the mic was live therefore showed REC while ctrl+g still
committed full-auto, so the user was asked to confirm something the footer never
put in front of them, and permission prompts went away.

offerOwnsStatusChip makes a live offer outrank the statuses that merely report
what is happening, in both statusLine branches, since the tiny and non-tiny
paths reach the chip differently. The two confirmations that ask their own
question still win, because those are what the user just pressed a key for.

The confirm key consults the same rule through offerConfirmable, which takes the
armed flag as an argument because the handler captures and clears m.unsafeArmed
before its switch runs. Sharing one expression is the point: an offer that
cannot be displayed is not one that can be accepted, so a status added later
cannot reopen the gap by taking the chip.

The regression asserts the implication through the real renderer and the real
handler, matrixed over the contending states and three widths: if the mode
became full-auto, the frame shown before the key contained the offer. It also
asserts the converse, so a fix that merely made confirmation unreachable would
fail it.

Reported by @jatmn.
2026-09-13 19:57:00 +05:30
Vasanthdev2004 f87a0afa10 Merge remote-tracking branch 'origin/main' into split/permission-mode-and-classifier
# Conflicts:
#	internal/acp/agent_test.go
2026-09-12 20:34:36 +05:30
Vasanthdev2004 32c3527794 fix(sandbox): keep framework builds under the network gate
Splitting LocalServer out of the network classification also dropped localServerPrograms from commandUsesNetwork, so next build, vite build, nuxt generate and astro check came back Network=false. Those CLIs run the repository config, plugins and application code during a build as surely as during a serve, and on the platforms where the approval gate is the network boundary an ordinary shell grant would have run them without the separate network decision main applies. The test that pinned Network=false for builds is replaced.

Network stays conservative for the whole framework inventory; LocalServer alone carries the serve/build distinction. The framework classifier now walks the positions a subcommand can occupy instead of reading the first operand, so a global option before the subcommand (vite --config vite.prod.ts build) no longer makes a build count as a server.

Regressions: build-like and option-bearing forms are Network=true and LocalServer=false at the analyzer, serving forms are both, and Engine.Evaluate still returns the network prompt for a framework build after a bash allow grant. Reported by @gnanam1990.
2026-09-12 11:47:34 +05:30
KRATOS 677b3f0c4f fix(sandbox): a flagged invocation classifies like its unflagged self at any arity
Raised by CodeRabbit. Skipping flags is not enough, because it does not skip the
words they CONSUME: `npm --prefix ./web install` resolved to "./web", the
option's VALUE, and stopped being recognised at all — so a command genuinely
fetching from the network lost the approval its plain spelling gets.

The first fix offered a fixed window of two positions. That covers exactly ONE
value-taking option, and a second walks straight past it. Measured before this
change:

  npm --prefix ./web install                            -> network (correct)
  npm --prefix ./web --loglevel warn install            -> NOT network
  npm --registry https://r.test --prefix ./web install  -> NOT network
  pnpm -C ./web --filter web add left-pad               -> NOT network

All four genuinely fetch.

ADJACENCY DECIDES NOW, because it is the actual grammar: an option can only
consume the word immediately after it. Every leading operand sitting right after
a flag might be that flag's value, so each opens another position; the first
operand NOT preceded by a flag cannot be anyone's value, and the window closes
there. The arity is counted rather than assumed, and no option is ever named —
which was the point of not enumerating them, since that is four tools each free
to add one.

The run has to be CONTIGUOUS, not a count of flags anywhere in the line.
Counting every flag lets TRAILING options widen the window back over words they
have nothing to do with, and three cases flip to false positives when it does:

  npm run --grep foo --tag dev            -> network AND localServer
  npm run test --reporter x --filter dev  -> network
  npm run build --a x --b start           -> network

The false positives the old cap protected against are unaffected, and for a
stronger reason than the cap gave: `npm run build --workspace dev` and `npm run
test -- --grep start` put an unflagged operand FIRST, so the window is one
position wide however many flags follow. The cap held only because two happened
to be short enough.

Tested as a PROPERTY rather than a list: each flagged form is paired against its
unflagged baseline, so a change that shifts both still fails. Eight pairs, the
"="-joined forms and repeated flags included. Two mutations, each caught —
restoring the fixed window mis-classifies five, dropping the contiguity
requirement widens three.

Also from a verification pass:

The doc comment claimed the cap at two was justified purely by argument-position
false positives and never said a second value-taking option defeated it. It now
describes what ships.

`npm --prefix dev install` reads a directory named "dev" as a script name. Pinned
rather than fixed: the ambiguity is the point of the window, Network is already
correct because the install really does fetch, and LocalServer only implies
Network — so the union is conservative in the safe direction.

An earlier note justified leaving internal/tui/session.go unnormalised by saying
the value feeds transcript rendering and the render cache. It feeds only the
cache fingerprint — grepping every reader of PermissionMode in that package
returns render_cache.go and nothing else — so the tradeoff described did not
exist. It is normalised now, and two spellings of one mode no longer evict the
cache for nothing.

The ACP tests asserted on ConfigOptions[1], a magic index that would move onto a
different option if the advertised order changed. They select by ID.

Rebased onto ad34dc8d. Pre-existing here and on main:
TestRunDoctorFormatsRedactedProviderDiagnostics and
TestRunDoctorConnectivityProbesProvider exit 3 in this environment.

Origin-Session: local-c962d7 | Claude Code | 5 prompts
Origin-Snapshot: 3bb420a0a97b
2026-09-12 10:11:23 +05:30
Vasanthdev2004 b75e7cb2b7 fix(tui,sandbox): cancel the offer on a streaming partial, and derive the network fallback from the analyzer
Two findings, both follow-ons to fixes I made last round.

A streaming dictation partial did not cancel the full-auto offer. The final
transcript did, and my note there said starting dictation is a keypress that
already clears the arm, which does not cover this: the offer can be armed AFTER
dictation is running, the next partial rewrites the composer, and active
dictation is not a blocking modal, so an ordinary ctrl+g still confirmed with
the user several actions past the confirmation. Cleared at the dispatch boundary
rather than inside handleDictationPartial, so a future partial-delivery path
inherits the rule. The regression drives a genuinely live session and fails if
the partial never reaches the composer, so it cannot pass on a session-mismatch
early return.

The unparseable network fallback is meant to be a superset of everything the AST
path flags, and it was maintained as a separate inventory, so it drifted. The
analyzer accepts "py" as a Python launcher and the regex listed only python,
python2 and python3:

  py -m http.server 8000                                     parsed=true  network=true
  if "%OS%"=="Windows_NT" (py -m http.server 8000) else (..)  parsed=false network=false
  if "%OS%"=="Windows_NT" (python -m http.server ...) else .. parsed=false network=true

On Windows that approval IS the egress control. Rather than adding the alias and
leaving two lists to drift again, the launchers are one named inventory the
analyzer uses at both call sites, and the fallback test iterates it, so a
launcher added there fails immediately if the regex does not cover it.

Origin-Session: local-c962d7 | Claude Code | 5 prompts
Origin-Snapshot: 3bb420a0a97b
2026-09-12 10:11:23 +05:30
Vasanthdev2004 1cc95f39f3 fix(sandbox,tui,cli): close the unparseable network gap and the deferred-input arm
Five findings from review.

The unparseable fallback lost the serving forms on the reasoning that they bind
rather than fetch. The AST path sets Network for every recognized LocalServer
anyway, deliberately, because nothing consumes LocalServer yet and `npm run dev`
is matched by script name where the repository decides what dev and predev do.
So dropping them did not align the two paths, it split them: the POSIX parser
rejects Windows shell syntax the invoked shell accepts, and

  if "%OS%"=="Windows_NT" (npm run dev) else (npm start)

came back parsed=false network=false while every parseable spelling got network.
That is the approval gate disappearing on the platform where it is the only
egress control. Restored as a superset, with a regression that asserts both
spellings of the same intent classify alike and fails loudly if the parser ever
learns the syntax.

tea.PasteMsg cancels the full-auto offer, but a right-click paste does not
arrive that way: it starts a clipboard read, and shift+tab can reach the offer
while that read is in flight. The delayed delivery inserted text and left the
arm alive, so an ordinary ctrl+g afterwards turned permission prompts off with
the user several actions past the confirmation. Cleared before the branches, on
the image delivery too, and on transcribed dictation for the same reason.

Specialist registration read the raw CLI fields while execution read the
resolved mode, so `--permission-mode full-auto` composed a registry without Task
and swarm tooling while `--auto high` and `--full-auto` composed one with it. The
mode is resolved before the registry now and the predicate consumes it, so a new
alias cannot create another unequal capability set.

The full-auto warning matched the typed --permission-mode value against three
spellings while the resolver accepts four, so `--permission-mode full_auto` was
told --auto high was responsible. It echoes what was typed instead of matching a
list that has already drifted once.

Help and validation text still led with the deprecated spelling: --permission-mode
listed "unsafe" as its value, --auto high was described as enabling "unsafe
tools", and the root-flag errors named --skip-permissions-unsafe. All now present
full-auto as the mode and the primary flag; the deprecated alias stays where its
compatibility is the point.

Origin-Session: local-c962d7 | Claude Code | 5 prompts
Origin-Snapshot: 3bb420a0a97b
2026-09-12 10:11:23 +05:30
Vasanthdev2004 714ce649bc fix(tui): cancel the full-auto offer when the terminal loses focus
Origin-Session: local-c962d7 | Claude Code | 5 prompts
Origin-Snapshot: 3bb420a0a97b
2026-09-12 10:11:23 +05:30
Vasanthdev2004 54613e4ff9 fix(sandbox,cli,tui): keep serving commands gated, and accept the canonical mode
All five of jatmn's findings.

The two P1s were the same mistake in two places: the LocalServer classification
was treated as a REPLACEMENT for Network when nothing consumes it. No policy or
runner code reads LocalServer, so classifying a serving command as local-only
granted it no host listener; it only removed the network approval it used to
get, and the command then ran under the default deny profile. On Linux that is a
network namespace and on macOS (deny network*), so `python -m http.server` and
`vite` started with no prompt and could not serve a preview to the operator's
browser.

The package-manager half is the sharper one. `npm run dev` is matched by SCRIPT
NAME, and the repository decides what `dev` and `predev` actually do; either can
curl before anything binds a port. On Windows the approval gate IS the network
protection, so inferring no-egress from a name lets that egress run unprompted.

LocalServer is now additive: the classification stays, because a scoped
host-listener path will want it, and the approval path stays until that path
exists. One line covers both findings, since both flow through the same
classification.

The PR's own tests asserted the premise being corrected, so they were rewritten
rather than flipped: building still counts as no egress, serving now asserts it
keeps BOTH the LocalServer classification and the network approval, and the
table rows moved with it.

The three P2s:

resolveExecPermissionMode accepted only the deprecated `unsafe`, so
`--permission-mode full-auto` failed usage validation before
NormalizePermissionMode was ever reached, while the warning code already had an
unreachable full-auto branch. It now accepts the canonical spelling and keeps
unsafe as the alias, and the usage text names full-auto.

newModel copied the raw mode through while the TUI tests only for
PermissionModeFullAuto, so an entry point handed the accepted legacy value
rejected ! shell escapes, published a prompting peer identity and chose low
self-correction, even though agent.Run normalized the same run to full-auto. One
value, two behaviours, decided by which layer looked at it. It is normalized at
the TUI boundary now.

Both completion inventories listed only --skip-permissions-unsafe, so no
generated shell script could suggest the documented --full-auto. Both now carry
the canonical flag alongside the deprecated alias.

Origin-Session: local-c962d7 | Claude Code | 5 prompts
Origin-Snapshot: 3bb420a0a97b
2026-09-12 10:11:22 +05:30
Vasanthdev2004 30459e167b test(tui): move the plan-mode guard onto advancePermissionMode
The rebase renamed nextPermissionMode to advancePermissionMode, and main's
plan-mode test still called the old name, so internal/tui did not build.

Also asserts the second return value. Plan must never carry a full-auto OFFER
either, or two presses from Plan would reach the mode that turns permission
prompts off entirely, starting from the one mode that promises no mutation.

Origin-Session: local-c962d7 | Claude Code | 5 prompts
Origin-Snapshot: 3bb420a0a97b
2026-09-12 10:10:57 +05:30
Vasanthdev2004 874db9ff34 fix: make the full-auto rename reach the values that travel as data
The rename kept a Go alias for PermissionModeUnsafe, which covers callers that
name the constant. It does not cover a mode that moves as a string, and several
do, so those paths went on comparing against "unsafe" and stopped matching.

Three places were affected:

- swarm permissionRank did not rank "full-auto", so it scored 0, the strictest
  tier. A full-auto parent clamped its members harder than an ask parent.
- specialist specialistAutonomy did not match it either, so a full-auto parent's
  specialists dropped to read-only "low" autonomy.
- the agent loop compares Options.PermissionMode directly, so a caller passing
  the old spelling got prompts instead of full-auto.

All three fail safe, which is why nothing broke loudly. Added
agent.NormalizePermissionMode and applied it at the single point the loop reads
the mode off Options, and taught the swarm and specialist constants both
spellings. Their existing tables only listed "unsafe", which is exactly how the
rename passed review.

Separately, permission_mode_arm_test.go was never compiled. Go read the trailing
"_arm" as a GOARCH constraint and excluded the file from every amd64 build, so
the five tests covering the shift+tab full-auto offer had never run, locally or
in CI. Two panicked once they did. The file is renamed, the model construction
fixed, and a repository-wide test now fails on any _test.go whose name ends in a
GOOS/GOARCH token without an explicit build tag.

With those tests actually running, the offer gate had a real hole: unsafeArmed
was cleared only in the key handler, so a paste or a mouse click left the offer
live and a later ctrl+g committed full-auto with nobody having accepted it.
Paste and deliberate mouse actions now cancel it. Passive motion does not, since
terminals stream motion while tracking is on and cancelling on a twitch would
make the confirm key unreachable.

Also: commandRunsLocalServer matched on program name alone, so "next build" and
"vite build" claimed to bind a port while compiling. Nothing reads the flag yet,
which is why it could be wrong quietly. Fixed to require a serving subcommand,
falling back to bare-invocation behaviour when firstSubcommand lands on an
option value rather than a subcommand.

Remaining wording fixes name full-auto rather than unsafe in the retry reasons,
the exec warning, and the shell-escape notice.

Origin-Session: local-c962d7 | Claude Code | 5 prompts
Origin-Snapshot: 3bb420a0a97b
2026-09-12 10:10:57 +05:30
Vasanthdev2004 f608752821 fix: keep the old permission-mode constant names as deprecated aliases
The rename broke CI, and it broke it in the merge rather than on the branch:
this branch is ten commits behind main, CI builds the merge, and newer main
code still references PermissionModeUnsafe and PermissionUnsafe. Deleting a
constant that other work is actively using turns an ordinary merge into a
compile failure for whoever merges second.

Both old names are restored as deprecated aliases of the full-auto constants.
Same value, so behaviour is identical, and code on either side of the rename
compiles.

My local check missed this because `go test ./internal/...` returned
"ok (cached)" for internal/agent, and a cached result cannot catch a compile
error. Verified this time with -count=1 and against an actual trial merge with
origin/main rather than the branch alone.

Origin-Session: local-c962d7 | Claude Code | 5 prompts
Origin-Snapshot: 3bb420a0a97b
2026-09-12 10:10:57 +05:30
Vasanthdev2004 dbdd281d59 refactor: rename the unsafe permission mode to full-auto
"unsafe" was a judgement rather than a description, and it sat oddly beside
auto and ask. full-auto says what the mode does and reads as the obvious third
step: ask, auto, full-auto.

Not "unrestricted", which was the other candidate and is factually wrong. This
mode turns off permission PROMPTS. The OS sandbox stays on, the write jail
still holds, and a label implying nothing restrains the agent would be wrong in
the dangerous direction.

The on-disk value changes to "full-auto" and "unsafe" is accepted permanently
as an alias rather than migrated. That value lives in user configs and in
scripts, and a session silently falling back to auto because its saved mode no
longer parsed would be a confusing downgrade rather than a visible error.

--full-auto is the new flag; --skip-permissions-unsafe keeps working and is
documented as the deprecated spelling. The exec warning names "the full-auto
flag" generically, because either spelling reaches it and the bool does not
record which was typed: claiming --full-auto when the caller passed the old one
would be a small lie in a warning, which is the wrong place for one.

The warning also says "prompt-gated tools run without approval" rather than
anything broader, for the same reason the mode is not called unrestricted.

What deliberately does NOT change is the colour. The name can be calm; the
indicator should not be, so full-auto keeps the alarm style in the status bar
and the ctrl+g confirm gate is untouched.

The Go "unsafe" package is imported in fifteen files here, so the rename was
done by identifier token only and the imports were verified intact afterwards
rather than assumed.

Origin-Session: local-c962d7 | Claude Code | 5 prompts
Origin-Snapshot: 3bb420a0a97b
2026-09-12 10:10:57 +05:30
Vasanthdev2004 eda9f3051c fix(sandbox): stop classifying local dev servers as network egress
`python -m http.server` was denied with "network access requires approval".
Serving files out of the workspace on a local port is not reaching out, and
treating it as egress made the single most common thing an agent is asked to
do while building something stop for an approval that protected nobody.

The analyzer already had a localServerPrograms map sitting beside
networkPrograms, but commandUsesNetwork returned true for it anyway, so the
distinction had been drawn and never acted on. It is acted on now:
AnalysisResult carries LocalServer separately, so the information is preserved
rather than dropped, and a caller that does care about inbound can still see
it.

Covers the same set consistently. `next dev` and `npm run dev` are the same
intent, so classifying one as binding and the other as fetching would have left
users hitting exactly the same wall through the other door. Both are
LocalServer now, across npm, pnpm, yarn and bun, as a direct subcommand and
behind `run`.

What still counts as network is unchanged: install, add, ci, create, publish,
login, exec, and every fetching program. `npx http-server` in particular stays
network, because npx downloads the package when it is missing; the test caught
that when a first pass flipped it, which is why the bind-versus-fetch pairs are
now pinned side by side in the table.

The unparseable fallback regex is updated to agree. Left alone it would have
flagged an obfuscated dev server for network while the same command written
plainly did not, which is the kind of inconsistency that is very hard to
explain to whoever hits it.

Being honest about the edge: some of these touch the network incidentally, and
`npm run dev` may install first. The claim is narrow, that BINDING is not
EGRESS, not that dev tooling is inert. Anything that actually fetches still
matches through its own program or subcommand.

Origin-Session: local-c962d7 | Claude Code | 5 prompts
Origin-Snapshot: 3bb420a0a97b
2026-09-12 10:10:22 +05:30
Vasanthdev2004 1bcb2bfadd feat(tui): put unsafe mode in the shift+tab cycle behind a confirm key
Unsafe was reachable only via --skip-permissions-unsafe at launch, which is
bad discoverability: the mode exists, works, and nothing in the TUI tells you
so. It is now the third position in the shift+tab cycle, alongside auto and
ask.

What it is NOT is reachable by repeating a navigation key. Unsafe turns
permission prompts off entirely, so shift+tab only ever OFFERS it; committing
takes ctrl+g while that offer is live. Press shift+tab again instead and the
offer is declined and the cycle continues to auto, so every mode stays
reachable with shift+tab alone.

The offer lives on the model across keypresses, and that is the part worth
being careful about: a stale flag would turn a later innocent ctrl+g into a
silent drop into unsafe. So it is cleared unconditionally at the top of the key
handler and re-armed only by the shift+tab branch. Forgetting a path therefore
cancels the offer rather than leaving it live, which is the harmless direction
to be wrong in. Tests drive the real handler for eight different cancelling
keys and assert the consequence, not just the flag.

ctrl+g is guarded in the case expression rather than the body, so without a
live offer the key is not consumed at all and falls through to whatever would
normally handle it. That is what stops it being a standalone shortcut into
unsafe, and there is a test that presses it repeatedly from every other mode.

Leaving unsafe stays one press and is never gated. Getting stricter should not
need confirming.

Returning to auto from ask now costs two presses, since it passes through the
offer. That is inherent to a third position in a cycle rather than a
regression, and TestShiftTabCyclesPermissionMode was updated to walk the whole
loop rather than assume two positions.

An earlier draft of this had the second shift+tab commit unsafe, which silently
removed ask -> auto from the cycle entirely. TestPermissionModeCycleNeverReaches
UnsafeOnItsOwn walks the full loop and asserts every mode stays reachable, so
that cannot come back unnoticed.

Origin-Session: local-c962d7 | Claude Code | 5 prompts
Origin-Snapshot: 3bb420a0a97b
2026-09-12 10:10:22 +05:30
153 changed files with 3177 additions and 16712 deletions

No files matched your search

+1 -1
View File
@@ -1 +1 @@
{".":"0.12.0"}
{".":"0.11.0"}
-24
View File
@@ -6,30 +6,6 @@ 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.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,7 +15,6 @@
<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>
@@ -88,18 +87,9 @@ 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>
@@ -397,76 +387,6 @@ 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,7 +15,6 @@
<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>
@@ -86,18 +85,9 @@ 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>
+1 -13
View File
@@ -33,16 +33,4 @@ 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`). 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).
keyless web search, to Exa's hosted MCP endpoint at `https://mcp.exa.ai/mcp`).
+2 -65
View File
@@ -110,8 +110,6 @@ 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/
@@ -142,15 +140,6 @@ 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:
@@ -203,11 +192,7 @@ Events the agent emits (in dispatch order):
| `specialistStart` | A sub-agent is spawned | yes (specialist name) |
| `specialistStop` | A sub-agent ends | yes (specialist name) |
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.
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.
> **Roadmap.** An in-UI hooks manager is on the backlog. Today you edit the JSON directly.
@@ -261,52 +246,6 @@ or use a new server name. OAuth tokens are bound to the resolved server identity
so changing a server target can require `zero mcp oauth login <server>` again.
OAuth server names ending in `.<32 hex chars>` are reserved for token storage.
#### Built-in servers
Zero seeds two MCP servers before your config is merged. Both show up in
`zero mcp list`, and any field can be overridden in your user config.
- `exa` (`https://mcp.exa.ai/mcp`) is on by default for keyless web search.
`zero mcp disable exa` turns it off.
- `aimlapi` (`https://mcp.aimlapi.com/mcp`, the AI/ML API Toolbox, model
discovery, image/video/audio generation, chat, embeddings and balance tools)
is **off by default**, because its tool runs and generations are billed to
your AI/ML API balance. Turn it on with `zero mcp enable aimlapi`, which
writes only `"disabled": false` to your user config, and off again with
`zero mcp disable aimlapi`. A project config cannot turn it on.
When `aimlapi` connects, Zero sends your AI/ML API key as
`Authorization: Bearer <key>`. The key is resolved again on every request and
is never written to `config.json`. With an aimlapi.com provider profile
(`zero providers setup aimlapi`), the key is chosen in this order:
1. An inline key on the profile (if set in config).
2. Otherwise the environment variable named by the profile's `apiKeyEnv`
(the catalog default is `AIMLAPI_API_KEY`).
3. Otherwise the key held in the credential store when the profile uses
`apiKeyStored`.
When there is no aimlapi profile, Zero uses `AIMLAPI_API_KEY` alone. If both
`AIMLAPI_API_KEY` and a stored profile key exist, step 2 wins over the store
whenever the inline key is empty — MCP requests may bill the environment key's
account, not the stored key.
No automatic key is sent when an aimlapi profile, or any profile reading
`AIMLAPI_API_KEY`, uses a base URL other than `https://api.aimlapi.com/v1`, or
when `AIMLAPI_INFERENCE_URL` is overridden, because that credential may belong
to a proxy. No key is sent when the resolved value is not a single token (for
example a comma- or newline-separated pool), when you set your own
non-empty `Authorization` header, when the server uses `"auth": "oauth"`, or
when project config touched the server. Without a key the server answers 401.
To sign in with your aimlapi.com account instead, set `"auth": "oauth"` on the
server and run `zero mcp oauth login aimlapi`.
Every request to exactly `https://mcp.aimlapi.com` (https, default port,
whatever the server is named, including one you add by hand) also carries the
attribution headers `X-AIMLAPI-Source: agent/zero` and `X-AIMLAPI-Partner-ID`,
and gets the key under the rules above. Other MCP servers never receive these
headers or the key.
### As a server — expose Zero's tools to another agent
```bash
@@ -351,12 +290,10 @@ 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 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.
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.
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,10 +634,6 @@ 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
@@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 1.6 KiB

-1
View File
@@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 1.6 KiB

+13 -4
View File
@@ -700,7 +700,13 @@ func (a *Agent) handleSetMode(_ context.Context, params json.RawMessage) (any, e
}
sess.turnMu.Lock()
defer sess.turnMu.Unlock()
mode := agent.PermissionMode(p.ModeID)
// Normalized before the switch, for the same reason the TUI normalizes at its
// own boundary: the mode arrives as data, so the accepted legacy "unsafe"
// spelling reaches this switch unrewritten and falls through to default. The
// client that asks for the disallowed mode by its documented old name is then
// told it does not exist, which reads as "try another spelling" rather than
// "this door is closed over ACP".
mode := agent.NormalizePermissionMode(agent.PermissionMode(p.ModeID))
switch mode {
case agent.PermissionModeAuto, agent.PermissionModeAsk, agent.PermissionModePlan:
if err := a.updateMode(sess, mode); err != nil {
@@ -708,7 +714,7 @@ func (a *Agent) handleSetMode(_ context.Context, params json.RawMessage) (any, e
}
(&notifier{conn: a.conn, sessionID: sess.id}).currentMode(string(mode))
return SetSessionModeResult{}, nil
case agent.PermissionModeUnsafe:
case agent.PermissionModeFullAuto:
// Unsafe = run every tool with no prompt. The TUI gates this behind an
// explicit --skip-permissions-unsafe operator flag; an editor client must
// not be able to grant itself unconfined, no-prompt access over the wire.
@@ -738,14 +744,17 @@ func (a *Agent) handleSetConfigOption(_ context.Context, params json.RawMessage)
// set_mode and set_config_option) serialize mode flips consistently.
sess.turnMu.Lock()
defer sess.turnMu.Unlock()
mode := agent.PermissionMode(p.Value)
// Normalized like handleSetMode above: this is the second advertised mode
// door, and an alias that only one of them rewrites is a difference
// between two paths that are meant to be the same contract.
mode := agent.NormalizePermissionMode(agent.PermissionMode(p.Value))
switch mode {
case agent.PermissionModeAuto, agent.PermissionModeAsk, agent.PermissionModePlan:
if err := a.updateMode(sess, mode); err != nil {
return nil, err
}
(&notifier{conn: a.conn, sessionID: sess.id}).currentMode(string(mode))
case agent.PermissionModeUnsafe:
case agent.PermissionModeFullAuto:
return nil, RPCError(codeInvalidParams, "mode not permitted over ACP: "+p.Value)
default:
return nil, RPCError(codeInvalidParams, "unknown mode: "+p.Value)
+84 -4
View File
@@ -205,8 +205,8 @@ func TestACPEndToEndPrompt(t *testing.T) {
if len(newRes.ConfigOptions) != 2 || newRes.ConfigOptions[0].ID != configIDModel || newRes.ConfigOptions[0].CurrentValue != "fake-model" {
t.Fatalf("model config option = %+v, want fake-model fallback", newRes.ConfigOptions)
}
if newRes.ConfigOptions[1].ID != configIDMode || newRes.ConfigOptions[1].CurrentValue != string(agent.PermissionModeAuto) {
t.Fatalf("mode config option = %+v", newRes.ConfigOptions[1])
if mode := configOptionByID(t, newRes.ConfigOptions, configIDMode); mode.CurrentValue != string(agent.PermissionModeAuto) {
t.Fatalf("mode config option = %+v", mode)
}
// session/prompt
@@ -455,7 +455,7 @@ func TestACPModelConfigOptionsCatalogSelectionAndLoad(t *testing.T) {
if len(loaded.ConfigOptions) != 2 || loaded.ConfigOptions[0].CurrentValue != "gpt-5.4-mini" {
t.Fatalf("load model option = %+v", loaded.ConfigOptions)
}
if loaded.ConfigOptions[1].CurrentValue != string(agent.PermissionModeAuto) {
if mode := configOptionByID(t, loaded.ConfigOptions, configIDMode); mode.CurrentValue != string(agent.PermissionModeAuto) {
t.Fatalf("load mode option = %+v", loaded.ConfigOptions[1])
}
}
@@ -654,7 +654,7 @@ func TestACPSetModeUpdatesSession(t *testing.T) {
t.Fatalf("config mode options missing plan: %#v", planConfigured.ConfigOptions[1].Options)
}
// Unsafe must be rejected over ACP — a client can't self-grant no-prompt host access.
if err := h.client.Call(ctx, MethodSessionSetMode, SetSessionModeParams{SessionID: newRes.SessionID, ModeID: string(agent.PermissionModeUnsafe)}, &SetSessionModeResult{}); err == nil {
if err := h.client.Call(ctx, MethodSessionSetMode, SetSessionModeParams{SessionID: newRes.SessionID, ModeID: string(agent.PermissionModeFullAuto)}, &SetSessionModeResult{}); err == nil {
t.Fatal("expected Unsafe mode to be rejected over ACP")
}
// An unknown mode must be rejected.
@@ -663,6 +663,70 @@ func TestACPSetModeUpdatesSession(t *testing.T) {
}
}
// The legacy "unsafe" spelling has to reach the full-auto arm of both mode
// doors, not fall through to "unknown mode".
//
// It is an accepted alias everywhere else in the tree, so a client that sends it
// is naming the mode ACP deliberately refuses. Unnormalized, both handlers
// answered "unknown mode: unsafe" — the mode was still refused, so nothing
// escalated, but the client was told the wrong thing about why: that the mode
// does not exist, rather than that this transport will not grant it. That
// invites retrying under another spelling instead of stopping.
//
// Both doors are checked because they are two entry points onto one contract,
// and the previous round of this bug was exactly one layer normalizing while
// another did not.
func TestACPRejectsLegacyUnsafeAliasAsDisallowedNotUnknown(t *testing.T) {
h := newHarness(t, testDeps(t))
defer h.stop()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var newRes NewSessionResult
if err := h.client.Call(ctx, MethodSessionNew, NewSessionParams{Cwd: t.TempDir(), McpServers: []McpServer{}}, &newRes); err != nil {
t.Fatalf("session/new: %v", err)
}
assertDisallowed := func(door string, err error) {
t.Helper()
if err == nil {
t.Fatalf("%s: legacy unsafe alias was accepted over ACP", door)
}
if !strings.Contains(err.Error(), "mode not permitted over ACP") {
t.Errorf("%s: error = %q, want the disallowed-mode message", door, err)
}
if strings.Contains(err.Error(), "unknown mode") {
t.Errorf("%s: error = %q, want the alias resolved instead of reported as unknown", door, err)
}
}
// Spelled literally on purpose: this is the value that travels over the wire,
// and the Go alias for it is the canonical string, not the legacy one.
const legacyModeID = "unsafe"
assertDisallowed("set_mode", h.client.Call(ctx, MethodSessionSetMode,
SetSessionModeParams{SessionID: newRes.SessionID, ModeID: legacyModeID}, &SetSessionModeResult{}))
assertDisallowed("set_config_option", h.client.Call(ctx, MethodSessionSetConfigOption,
SetSessionConfigOptionParams{SessionID: newRes.SessionID, ConfigID: configIDMode, Value: legacyModeID}, &SetSessionConfigOptionResult{}))
// Refusing must leave the session where it was, not in a half-applied state.
var configured SetSessionConfigOptionResult
if err := h.client.Call(ctx, MethodSessionSetConfigOption, SetSessionConfigOptionParams{
SessionID: newRes.SessionID, ConfigID: configIDMode, Value: string(agent.PermissionModeAsk),
}, &configured); err != nil {
t.Fatalf("set_config_option ask: %v", err)
}
if got := configured.ConfigOptions[1].CurrentValue; got != string(agent.PermissionModeAsk) {
t.Fatalf("mode after the refusals = %q, want ask", got)
}
// A genuinely unknown mode must still say so, or the assertions above would
// hold for a handler that answered "not permitted" to everything.
err := h.client.Call(ctx, MethodSessionSetMode, SetSessionModeParams{SessionID: newRes.SessionID, ModeID: "bogus"}, &SetSessionModeResult{})
if err == nil || !strings.Contains(err.Error(), "unknown mode") {
t.Fatalf("set_mode bogus = %v, want an unknown-mode error", err)
}
}
// TestACPPlanModeWiresPermissionModeIntoAgentOptions confirms selecting "plan"
// over ACP actually reaches agent.Options.PermissionMode for the next turn —
// the same gap this test's TUI counterpart covers for /plan on.
@@ -2546,6 +2610,22 @@ func payloadString(payload any, key string) string {
return value
}
// configOptionByID finds an advertised option by its identity rather than by
// position. Asserting on ConfigOptions[1] made every one of these tests depend
// on the ORDER the options are advertised in: reordering them would move the
// assertion onto a different option, or panic, instead of failing with a
// message about the option it means.
func configOptionByID(t *testing.T, options []SessionConfigOption, id string) SessionConfigOption {
t.Helper()
for _, option := range options {
if option.ID == id {
return option
}
}
t.Fatalf("no config option %q was advertised; got %+v", id, options)
return SessionConfigOption{}
}
// Load is best effort about a history it cannot read, but a session that zero
// sessions prune holds is refused by load and resume alike, and not published:
// this process could not hold it open while prune may be removing it.
-34
View File
@@ -1,34 +0,0 @@
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
@@ -1,99 +0,0 @@
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)
}
}
+4 -4
View File
@@ -365,7 +365,7 @@ func TestRunProactiveCompactionTriggers(t *testing.T) {
result, err := Run(context.Background(), strings.Repeat("y", 8000), provider, Options{
Registry: registry,
PermissionMode: PermissionModeUnsafe,
PermissionMode: PermissionModeFullAuto,
ContextWindow: 1000, // ~250 token 80% threshold; easily exceeded
CompactionPreserveLast: 2,
})
@@ -393,7 +393,7 @@ func TestRunNoCompactionWhenContextWindowZero(t *testing.T) {
_, err := Run(context.Background(), strings.Repeat("y", 8000), provider, Options{
Registry: registry,
PermissionMode: PermissionModeUnsafe,
PermissionMode: PermissionModeFullAuto,
ContextWindow: 0, // disabled
})
if err != nil {
@@ -534,7 +534,7 @@ func TestRunReactiveCompactionRecovers(t *testing.T) {
// only the reactive path can save the run.
result, err := Run(context.Background(), strings.Repeat("z", 6000), provider, Options{
Registry: registry,
PermissionMode: PermissionModeUnsafe,
PermissionMode: PermissionModeFullAuto,
ContextWindow: 10_000_000,
CompactionPreserveLast: 2,
Trace: recorder,
@@ -620,7 +620,7 @@ func TestRunReactiveRetryDoesNotDoubleEmitText(t *testing.T) {
var deltas []string
result, err := Run(context.Background(), strings.Repeat("z", 6000), provider, Options{
Registry: registry,
PermissionMode: PermissionModeUnsafe,
PermissionMode: PermissionModeFullAuto,
ContextWindow: 10_000_000,
CompactionPreserveLast: 2,
OnText: func(delta string) { deltas = append(deltas, delta) },
-64
View File
@@ -80,9 +80,6 @@ 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) {
@@ -516,22 +513,6 @@ 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{})
@@ -548,51 +529,6 @@ 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) {
+10 -21
View File
@@ -188,7 +188,11 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
registry = tools.NewRegistry()
}
permissionMode := options.PermissionMode
// Normalized once, here, because this is the single place the run's mode is
// read off Options; every comparison downstream takes it as a parameter from
// this local. Normalizing at the individual comparison sites instead would
// leave the next one added to be found by whoever it breaks.
permissionMode := NormalizePermissionMode(options.PermissionMode)
if permissionMode == "" {
permissionMode = PermissionModeAuto
}
@@ -587,14 +591,6 @@ 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
}
@@ -816,13 +812,6 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
messages = appendAbortedToolResults(messages, collected.ToolCalls[index+1:])
messages = append(messages, buildToolImageMessages()...)
result.FinalAnswer = toolFailureStopAnswer(call.Name, outcome.Count)
// Same headless contract as the empty-turn guard: halting on a
// repeated failure is an unfinished run, not success. Gate-off
// (interactive / --no-completion-gate) keeps the old behavior.
if options.RequireCompletionSignal {
result.Incomplete = true
result.IncompleteReason = "the " + call.Name + " tool failed " + strconv.Itoa(outcome.Count) + " times in a row with the same error"
}
result.Messages = copyMessages(messages)
return result, nil
}
@@ -1297,7 +1286,7 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
return executeRequestPermissions(ctx, call, args, permissionMode, options)
}
permissionGranted := permissionMode == PermissionModeUnsafe
permissionGranted := permissionMode == PermissionModeFullAuto
if toolFound && effectivePermission(tool, args) == tools.PermissionAllow {
permissionGranted = true
}
@@ -1619,10 +1608,10 @@ func maybeRetryUnsandboxedAfterSandboxRestriction(ctx context.Context, registry
}
requestEvent := sandboxRestrictionRetryEvent(call, tool, args, permissionMode, options, result)
request := permissionRequestFromEvent(requestEvent, args, options)
if permissionMode == PermissionModeUnsafe {
if permissionMode == PermissionModeFullAuto {
retryArgs := unsandboxedRetryArgs(args)
retry := runToolForUnsandboxedRetry(ctx, registry, call.Name, call.ID, retryArgs, permissionMode, options, progressCallback)
return retry, nil, true, PermissionDecisionAllow, "unsafe permission mode permits unsandboxed retry", nil, nil
return retry, nil, true, PermissionDecisionAllow, "full-auto permission mode permits unsandboxed retry", nil, nil
}
decision, err := requestPermission(ctx, request, options)
if err != nil {
@@ -1673,9 +1662,9 @@ func maybeRetryWithNetworkAfterSandboxDenial(ctx context.Context, registry *tool
}
requestEvent := sandboxDeniedNetworkRetryEvent(call, tool, args, permissionMode, options, result)
request := permissionRequestFromEvent(requestEvent, args, options)
if permissionMode == PermissionModeUnsafe {
if permissionMode == PermissionModeFullAuto {
retry := runToolForNetworkRetry(ctx, registry, call.Name, call.ID, args, permissionMode, options, progressCallback)
return retry, nil, true, PermissionDecisionAllow, "unsafe permission mode permits sandbox network retry", nil
return retry, nil, true, PermissionDecisionAllow, "full-auto permission mode permits sandbox network retry", nil
}
decision, err := requestPermission(ctx, request, options)
if err != nil {
+3 -3
View File
@@ -2737,7 +2737,7 @@ func TestRunGrantsPromptToolInUnsafeMode(t *testing.T) {
result, err := Run(context.Background(), "write notes", provider, Options{
Registry: registry,
PermissionMode: PermissionModeUnsafe,
PermissionMode: PermissionModeFullAuto,
OnPermission: func(event PermissionEvent) {
permissionEvents = append(permissionEvents, event)
},
@@ -2763,7 +2763,7 @@ func TestRunGrantsPromptToolInUnsafeMode(t *testing.T) {
if event.Action != PermissionActionAllow || !event.PermissionGranted {
t.Fatalf("expected unsafe approval permission event, got %#v", event)
}
if event.ToolName != "write_file" || event.PermissionMode != PermissionModeUnsafe {
if event.ToolName != "write_file" || event.PermissionMode != PermissionModeFullAuto {
t.Fatalf("unexpected unsafe approval metadata: %#v", event)
}
}
@@ -2968,7 +2968,7 @@ func TestRunAppliesSandboxEvenInUnsafeMode(t *testing.T) {
result, err := Run(context.Background(), "write outside", provider, Options{
Registry: registry,
PermissionMode: PermissionModeUnsafe,
PermissionMode: PermissionModeFullAuto,
Autonomy: "high",
Sandbox: sandbox.NewEngine(sandbox.EngineOptions{
WorkspaceRoot: root,
-45
View File
@@ -1,20 +1,14 @@
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
@@ -23,56 +17,17 @@ var isolatedUserDirsRoot string
// 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") {
+40 -4
View File
@@ -2,6 +2,7 @@ package agent
import (
"context"
"strings"
"github.com/Twigpine/zero/internal/execution"
"github.com/Twigpine/zero/internal/hooks"
@@ -22,10 +23,24 @@ type PermissionAction string
type PermissionDecisionAction string
const (
PermissionModeAuto PermissionMode = "auto"
PermissionModeAsk PermissionMode = "ask"
PermissionModeUnsafe PermissionMode = "unsafe"
PermissionModeSpecDraft PermissionMode = "spec-draft"
PermissionModeAuto PermissionMode = "auto"
PermissionModeAsk PermissionMode = "ask"
PermissionModeFullAuto PermissionMode = "full-auto"
// PermissionModeUnsafe is the former name of PermissionModeFullAuto.
//
// Kept as an alias rather than deleted because this constant is referenced
// across packages and by code that lands independently of this branch, so
// removing it turns an ordinary merge into a compile failure for whoever
// merges second. It is the same value, so behaviour is identical either way.
//
// Deprecated: use PermissionModeFullAuto.
PermissionModeUnsafe = PermissionModeFullAuto
// legacyFullAutoPermissionMode is the raw string full-auto used to be. The
// Go alias above keeps SOURCE compatible, but a value that arrives as data
// rather than as an identifier is unaffected by it, so it still needs
// mapping. See NormalizePermissionMode.
legacyFullAutoPermissionMode PermissionMode = "unsafe"
PermissionModeSpecDraft PermissionMode = "spec-draft"
// PermissionModePlan is an interactive, read-only planning mode. It applies
// to the CURRENT session (unlike spec-draft, which drafts in a separate
// session): the agent may inspect the workspace and shape the plan with
@@ -518,3 +533,24 @@ func (result Result) TruncationNotice() string {
return "Response ended early (" + result.FinishReason + ") and may be incomplete."
}
}
// NormalizePermissionMode maps a permission mode that arrived as data onto its
// canonical value.
//
// full-auto was renamed from "unsafe", and a Go alias only covers callers that
// name the constant. A value that travels as a string does not go through the
// alias: it comes off a command line, out of a swarm member spec, or across a
// protocol, and after the rename "unsafe" stops matching the comparisons the
// loop makes against PermissionModeFullAuto. The mode then reads as unrecognized
// and the run silently behaves as though full-auto was never requested.
//
// Only the legacy spelling is rewritten. Unknown values are returned unchanged
// rather than folded to a default, because this package has modes the sandbox
// layer does not know about (spec-draft, member-auto) and quietly rewriting one
// of those would be a worse bug than the one being fixed.
func NormalizePermissionMode(mode PermissionMode) PermissionMode {
if PermissionMode(strings.ToLower(strings.TrimSpace(string(mode)))) == legacyFullAutoPermissionMode {
return PermissionModeFullAuto
}
return mode
}
-57
View File
@@ -1,57 +0,0 @@
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
@@ -1,54 +0,0 @@
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)
}
}
+28 -39
View File
@@ -332,7 +332,7 @@ func runWithDeps(args []string, stdout io.Writer, stderr io.Writer, deps appDeps
// until it opts in here.
forwardsRootFlags := false
switch args[0] {
case "--skip-permissions-unsafe", "-p", "--prompt", "exec":
case "--skip-permissions-unsafe", "--full-auto", "-p", "--prompt", "exec":
forwardsRootFlags = true
}
if len(addDirs) > 0 && !forwardsRootFlags {
@@ -343,7 +343,7 @@ func runWithDeps(args []string, stdout io.Writer, stderr io.Writer, deps appDeps
}
switch args[0] {
case "--skip-permissions-unsafe":
case "--skip-permissions-unsafe", "--full-auto":
// Launch the interactive TUI directly in unsafe mode. Without this, the
// flag fell through to the unknown-command path, so a user could never
// reach unsafe mode in the shell — and the "!" shell escape (which is
@@ -371,7 +371,7 @@ func runWithDeps(args []string, stdout io.Writer, stderr io.Writer, deps appDeps
// so check for it across all of rest before rejecting stray args.
for _, arg := range rest {
if arg == "--add-dir" || strings.HasPrefix(arg, "--add-dir=") {
return writeAppError(stderr, "--add-dir must come before any other arguments (it may precede or follow --skip-permissions-unsafe)", 1)
return writeAppError(stderr, "--add-dir must come before any other arguments (it may precede or follow --full-auto)", 1)
}
}
// This path launches the interactive TUI, which takes no positional prompt or
@@ -380,10 +380,10 @@ func runWithDeps(args []string, stdout io.Writer, stderr io.Writer, deps appDeps
// hang in the TUI with the prompt discarded. (AUDIT-L3)
for _, arg := range rest {
if strings.TrimSpace(arg) != "" {
return writeAppError(stderr, "--skip-permissions-unsafe launches the interactive TUI and takes no prompt or subcommand; for a one-shot unsafe run use `zero exec --skip-permissions-unsafe -p \"...\"`", 1)
return writeAppError(stderr, "--full-auto launches the interactive TUI and takes no prompt or subcommand; for a one-shot full-auto run use `zero exec --full-auto -p \"...\"`", 1)
}
}
return runInteractiveTUI(stderr, deps, agent.PermissionModeUnsafe, append(append([]string{}, addDirs...), moreDirs...), skipTheme, allowEscalation || skipAllowEscalation)
return runInteractiveTUI(stderr, deps, agent.PermissionModeFullAuto, append(append([]string{}, addDirs...), moreDirs...), skipTheme, allowEscalation || skipAllowEscalation)
case "-h", "--help", "help":
if err := writeHelp(stdout); err != nil {
return 1
@@ -886,9 +886,10 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
// only PermissionAllow tools, so prompt-gated tools (write_file/edit_file/bash/
// apply_patch) would never be offered to the model — the TUI could neither edit
// files nor run shell. Ask advertises them and routes each through the existing
// OnPermissionRequest flow; shift+tab lets the user switch modes live. An
// explicit --skip-permissions-unsafe launch overrides this to unsafe (the only
// way to reach unsafe, since shift+tab deliberately cycles auto↔ask only).
// OnPermissionRequest flow; shift+tab lets the user switch modes live. A
// --full-auto launch overrides this to full-auto. Shift+tab cycles auto↔ask
// and never lands on full-auto by itself: it only OFFERS it, and the offer
// has to be accepted with ctrl+g before the mode changes.
//
// Resolve the effective mode BEFORE the deferral gate below so the registration
// count uses the SAME permission mode the agent loop's partition will use; an
@@ -898,7 +899,7 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
}
sessionStore := deps.newSessionStore()
peerClass := peermsg.PermissionPrompting
if permissionMode == agent.PermissionModeUnsafe {
if permissionMode == agent.PermissionModeFullAuto {
peerClass = peermsg.PermissionBypass
}
peerService, peerErr := peermsg.New(peermsg.Options{
@@ -964,7 +965,6 @@ 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.
@@ -1222,9 +1222,6 @@ 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
@@ -1236,26 +1233,12 @@ 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
}
lifecycleHooks := &specialist.LifecycleHooks{}
executor := specialist.Executor{Paths: paths, LifecycleHooks: lifecycleHooks}
executor := specialist.Executor{Paths: paths}
runtime, err := specialist.RegisterTools(registry, executor)
if err != nil {
return nil, err
@@ -1274,12 +1257,7 @@ func registerSpecialistTools(registry *tools.Registry, workspaceRoot string, max
return nil, err
}
swarm.RegisterTools(registry, sw)
return &agentToolRuntime{
specialist: runtime,
swarm: sw,
specialists: specialistSummaries(paths),
lifecycleHooks: lifecycleHooks,
}, nil
return &agentToolRuntime{specialist: runtime, swarm: sw, specialists: specialistSummaries(paths)}, nil
}
// specialistSummaries loads the available specialists (built-ins + user/project
@@ -1305,7 +1283,7 @@ func specialistSummaries(paths specialist.Paths) []agent.SpecialistInfo {
return summaries
}
func shouldRegisterExecSpecialistTools(options execOptions) bool {
func shouldRegisterExecSpecialistTools(options execOptions, resolved agent.PermissionMode) bool {
if options.useSpec {
return false
}
@@ -1317,6 +1295,14 @@ func shouldRegisterExecSpecialistTools(options execOptions) bool {
// ones, so a non-trivial exec can auto-delegate exploration safely (a headless
// write spawn still gets denied at the prompt). Default "low" stays clean — no
// specialist tooling or swarm runtime for trivial/CI one-shots.
// The RESOLVED mode decides, not the raw flags. Every spelling that reaches
// full-auto (`--full-auto`, `--skip-permissions-unsafe`, `--auto high`,
// `--permission-mode full-auto|full_auto|unsafe|high`) resolves to the same
// value, so a new alias cannot create an unequal capability set the way
// `--permission-mode full-auto` did.
if resolved == agent.PermissionModeFullAuto {
return true
}
autonomy := strings.ToLower(strings.TrimSpace(options.autonomy))
return options.skipPermissionsUnsafe || autonomy == "high" || autonomy == "medium"
}
@@ -1416,7 +1402,8 @@ Flags:
-p, --prompt Run a one-shot prompt
--add-dir <path> Allow writes in an extra directory (repeatable)
--allow-escalation Let the agent escalate to a stronger model mid-run via escalate_model
--skip-permissions-unsafe Launch the interactive shell in unsafe mode (enables the ! shell escape)
--full-auto Launch the interactive shell in full-auto mode (enables the ! shell escape)
--skip-permissions-unsafe Deprecated spelling of --full-auto
`)
return err
}
@@ -1650,13 +1637,14 @@ Flags:
Override draft reasoning effort when --use-spec is set
--plan Read-only planning mode: write and shell tools are hidden
--permission-mode <mode> Set permission mode directly (plan, spec-draft, auto, member,
ask, unsafe). Outranks --auto; prefer --plan / --auto for
ask, full-auto). Outranks --auto; prefer --plan / --auto for
interactive use. Used by specialist/swarm child processes.
--max-turns <number> Override the maximum agent loop turns
--exec-profile <name> Apply an execution profile (balanced, fast, thorough): loop
posture only (turn budget, effort, self-correction, escalation);
composes with --mode (which picks the model) and explicit flags win
--auto <low|medium|high> Set exec autonomy; high enables unsafe tools
--auto <low|medium|high> Set exec autonomy; high is full-auto (prompt-gated tools run
without approval)
--enabled-tools <tools> Only expose these comma or space separated tools
--disabled-tools <tools> Hide these comma or space separated tools
--list-tools List model-visible tools and exit
@@ -1679,7 +1667,8 @@ Flags:
--depth <number> Set specialist nesting depth metadata
--session-title <text> Set the created session title
--init-session-id <id> Create a new exec session with this id
--skip-permissions-unsafe Allow prompt-gated tools without approval
--full-auto Allow prompt-gated tools without approval
--skip-permissions-unsafe Deprecated spelling of --full-auto
--allow-escalation Let the agent escalate to a stronger model mid-run via escalate_model
--self-correct Run the post-edit verify-and-correct loop (auto-fix needs --auto medium or high)
--notify <off|bell|notify|both>
+6 -6
View File
@@ -995,11 +995,11 @@ func TestRunSkipPermissionsUnsafeLaunchesTUIInUnsafeMode(t *testing.T) {
if !launched {
t.Fatal("expected the interactive TUI to launch, but runTUI was never called")
}
if launchedOptions.PermissionMode != agent.PermissionModeUnsafe {
t.Fatalf("PermissionMode = %q, want %q", launchedOptions.PermissionMode, agent.PermissionModeUnsafe)
if launchedOptions.PermissionMode != agent.PermissionModeFullAuto {
t.Fatalf("PermissionMode = %q, want %q", launchedOptions.PermissionMode, agent.PermissionModeFullAuto)
}
if launchedOptions.AgentOptions.PermissionMode != agent.PermissionModeUnsafe {
t.Fatalf("AgentOptions.PermissionMode = %q, want %q", launchedOptions.AgentOptions.PermissionMode, agent.PermissionModeUnsafe)
if launchedOptions.AgentOptions.PermissionMode != agent.PermissionModeFullAuto {
t.Fatalf("AgentOptions.PermissionMode = %q, want %q", launchedOptions.AgentOptions.PermissionMode, agent.PermissionModeFullAuto)
}
}
@@ -1110,8 +1110,8 @@ func TestRunSkipPermissionsUnsafeMergesAddDirGrants(t *testing.T) {
if exitCode != 0 {
t.Fatalf("expected exit code 0, got %d: %s", exitCode, stderr.String())
}
if launchedOptions.PermissionMode != agent.PermissionModeUnsafe {
t.Fatalf("PermissionMode = %q, want %q", launchedOptions.PermissionMode, agent.PermissionModeUnsafe)
if launchedOptions.PermissionMode != agent.PermissionModeFullAuto {
t.Fatalf("PermissionMode = %q, want %q", launchedOptions.PermissionMode, agent.PermissionModeFullAuto)
}
if launchedOptions.AgentOptions.Sandbox == nil {
t.Fatal("AgentOptions.Sandbox = nil, want sandbox engine")
+2 -2
View File
@@ -20,7 +20,7 @@ type completionContext struct {
}
var completionRoot = completionNode{
flags: []string{"-h", "--help", "-v", "--version", "-p", "--prompt", "--add-dir", "--allow-escalation", "--theme", "--skip-permissions-unsafe"},
flags: []string{"-h", "--help", "-v", "--version", "-p", "--prompt", "--add-dir", "--allow-escalation", "--theme", "--full-auto", "--skip-permissions-unsafe"},
children: []completionNode{
{names: []string{"exec"}, flags: []string{
"-h", "--help", "-f", "--file", "--image", "--add-dir", "--mode", "-m", "--model",
@@ -30,7 +30,7 @@ var completionRoot = completionNode{
"--reasoning-effort", "-C", "--cwd", "-w", "--worktree", "--worktree-dir", "-i",
"--input-format", "-o", "--output-format", "--prompt", "--resume", "--fork",
"--calling-session-id", "--calling-tool-use-id", "--tag", "--depth", "--session-title",
"--init-session-id", "--skip-permissions-unsafe", "--allow-escalation", "--self-correct",
"--init-session-id", "--full-auto", "--skip-permissions-unsafe", "--allow-escalation", "--self-correct",
"--notify", "--no-notify", "--no-completion-gate",
}},
{
+5 -2
View File
@@ -153,8 +153,11 @@ func TestCompletionTreeCoversAliasesNestingAndCommonFlags(t *testing.T) {
byPath[context.path] = context.candidates
}
assertCandidates(t, byPath[""], "sessions", "session", "plugins", "plugin", "worktrees", "worktree", "--add-dir", "--theme", "-p", "--prompt")
assertCandidates(t, byPath["exec"], "--model", "--cwd", "--worktree", "--output-format", "--resume", "--skip-permissions-unsafe")
// --full-auto is the canonical spelling and --skip-permissions-unsafe its
// compatibility alias. BOTH are asserted: testing only the surviving alias is
// what lets a later cleanup quietly stop suggesting the documented flag.
assertCandidates(t, byPath[""], "sessions", "session", "plugins", "plugin", "worktrees", "worktree", "--add-dir", "--theme", "-p", "--prompt", "--full-auto", "--skip-permissions-unsafe")
assertCandidates(t, byPath["exec"], "--model", "--cwd", "--worktree", "--output-format", "--resume", "--full-auto", "--skip-permissions-unsafe")
assertCandidates(t, byPath["worktrees"], "prepare", "release")
assertCandidates(t, byPath["worktree"], "prepare", "release")
assertCandidates(t, byPath["daemon"], "start", "stop", "status", "run", "attach")
+1 -13
View File
@@ -92,25 +92,13 @@ 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))
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")
}
lines = append(lines, fmt.Sprintf(" hash: %s -> %s", shortHash(result.PreviousHash), shortHash(result.Hash)))
} 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,8 +7,6 @@ import (
"path/filepath"
"strings"
"testing"
"github.com/Twigpine/zero/internal/skills"
)
func writeSourceSkillDir(t *testing.T, dir string, content string) string {
@@ -393,133 +391,3 @@ 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())
}
}
+63 -27
View File
@@ -254,8 +254,28 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
if options.allowEscalation {
registry.Register(tools.NewEscalateModelTool())
}
// RESOLVED BEFORE THE REGISTRY IS COMPOSED, because registration used to read
// the raw CLI fields while execution read the resolved mode, and the two
// disagreed. `--permission-mode full-auto` resolves to the same effective
// mode as `--auto high` and `--full-auto`, but only the latter two were
// visible to the eligibility check, so that spelling silently shipped without
// Task and swarm tooling: the explicitly selected full-auto agent could not
// delegate, and `--list-tools` showed a different set for the same mode.
//
// Safe to hoist: resolveExecPermissionMode only parses the options it was
// given, so --list-tools stays offline.
permissionMode, err := resolveExecPermissionMode(options)
if err != nil {
return writeExecFormatUsageError(stdout, stderr, options.outputFormat, err.Error())
}
if options.useSpec {
permissionMode = agent.PermissionModeSpecDraft
}
if options.plan {
permissionMode = agent.PermissionModePlan
}
var specialistRuntime *agentToolRuntime
if shouldRegisterExecSpecialistTools(options) {
if shouldRegisterExecSpecialistTools(options, permissionMode) {
// Specialist tools register before the full config resolve below (so
// --list-tools stays offline). swarm.maxTeamSize is not affected by
// overrides, so an empty-overrides resolve yields the same value; a resolve
@@ -271,16 +291,6 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
}
defer closeSpecialistRuntime(stderr, specialistRuntime)
}
permissionMode, err := resolveExecPermissionMode(options)
if err != nil {
return writeExecFormatUsageError(stdout, stderr, options.outputFormat, err.Error())
}
if options.useSpec {
permissionMode = agent.PermissionModeSpecDraft
}
if options.plan {
permissionMode = agent.PermissionModePlan
}
var mcpRuntime mcpToolRuntime
var mcpSkip trustSkip
// Make local plugins live for this run: register their declared tools into the
@@ -594,20 +604,21 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
if writer.err != nil {
return exitCrash
}
// Surface the unsafe-permissions warning whenever the run resolves to unsafe
// mode, covering BOTH --skip-permissions-unsafe and --auto high (which also
// resolves to PermissionModeUnsafe). Previously only the explicit flag path
// warned, so --auto high silently ran without notice.
if permissionMode == agent.PermissionModeUnsafe {
reason := "--auto high"
switch {
case options.skipPermissionsUnsafe:
reason = "--skip-permissions-unsafe"
case strings.EqualFold(strings.TrimSpace(options.permissionMode), "unsafe"),
strings.EqualFold(strings.TrimSpace(options.permissionMode), "high"):
reason = "--permission-mode unsafe"
}
writer.warning(fmt.Sprintf("Unsafe permissions are active for this run because %s was passed.", reason))
// Surface the warning whenever the run resolves to full-auto, covering BOTH
// the explicit flag and --auto high (which also resolves to
// PermissionModeFullAuto). Previously only the explicit flag path warned, so
// --auto high silently ran without notice.
//
// The wording names permission prompts specifically. Full-auto skips
// approvals; it does not remove the OS sandbox, and a warning that implied
// otherwise would misdescribe what is actually happening.
if permissionMode == agent.PermissionModeFullAuto {
// One bool backs both spellings, so which one was typed is not recoverable
// here. Naming both is the only accurate option: reporting "--full-auto"
// to someone who passed --skip-permissions-unsafe sends them looking for a
// flag they did not use, and a warning is the wrong place to be wrong
// about what the user did.
writer.warning(fmt.Sprintf("Full-auto is active for this run because %s was passed: prompt-gated tools run without approval.", fullAutoWarningSource(options)))
if writer.err != nil {
return exitCrash
}
@@ -655,7 +666,6 @@ 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,
@@ -713,7 +723,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, registry, call); ok {
if checkpoint, ok := sessionRecorder.captureCheckpoint(workspaceRoot, call); ok {
writer.checkpoint(checkpoint)
}
},
@@ -1505,3 +1515,29 @@ func writeTraceSnapshot(snapshot *trace.TurnTrace, dest string, stderr io.Writer
defer file.Close()
return trace.WriteNDJSON(file, snapshot)
}
// fullAutoWarningSource names the flag that put this run into full-auto, for the
// warning that explains why prompt-gated tools will run without approval.
//
// ECHOED, NOT MATCHED. This used to test the typed --permission-mode value
// against a list of three spellings while resolveExecPermissionMode accepted
// four, so `--permission-mode full_auto` fell through and the run was told
// full-auto was active because `--auto high` was passed, which it was not.
// Keeping alias acceptance and user-facing attribution in two separate tables
// guarantees they drift again, and a warning explaining why approvals are being
// skipped is the worst place to be wrong about what the user did.
//
// The order matches resolveExecPermissionMode, which consults --permission-mode
// before --auto and the boolean flags.
func fullAutoWarningSource(options execOptions) string {
if mode := strings.TrimSpace(options.permissionMode); mode != "" {
return "--permission-mode " + mode
}
if options.skipPermissionsUnsafe {
// One bool backs both spellings, so naming both is the only accurate
// option: pointing someone at a flag they did not type sends them looking
// for it.
return "--full-auto (or --skip-permissions-unsafe)"
}
return "--auto high"
}
@@ -1,206 +0,0 @@
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,59 @@
package cli
import (
"bytes"
"strings"
"testing"
"github.com/Twigpine/zero/internal/config"
)
func execListToolsFor(t *testing.T, args ...string) string {
t.Helper()
// A temporary cwd isolates the workspace, not the user: runWithDeps fills
// the dependencies left out with production ones, and --list-tools returns
// only after startup has read user config, started configured MCP servers
// and begun the models.dev refresh. Every user root goes to a fixture first.
isolateCLIUserState(t)
cwd := t.TempDir()
var stdout, stderr bytes.Buffer
exitCode := runWithDeps(append([]string{"exec"}, append(args, "--list-tools")...), &stdout, &stderr, appDeps{
getwd: func() (string, error) { return cwd, nil },
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) { return execResolvedConfig(), nil },
})
if exitCode != exitSuccess {
t.Fatalf("exit %d for %v: %s", exitCode, args, stderr.String())
}
return stdout.String()
}
// EVERY SPELLING OF FULL-AUTO GETS THE SAME TOOLS.
//
// Registration read the raw CLI fields while execution read the resolved mode,
// so `--permission-mode full-auto` composed a registry without Task and swarm
// tooling while `--auto high` and `--full-auto`, which resolve to exactly the
// same effective mode, composed one with it. The explicitly selected full-auto
// agent could not delegate, and --list-tools reported a different capability set
// for the same run.
func TestFullAutoEntryPointsListTheSameSpecialistTools(t *testing.T) {
baseline := execListToolsFor(t, "--auto", "high")
if !strings.Contains(baseline, "swarm_spawn") {
t.Fatalf("SETUP INVALID: --auto high did not list swarm_spawn, so this test cannot detect the gap:\n%s", baseline)
}
for _, args := range [][]string{
{"--full-auto"},
{"--permission-mode", "full-auto"},
{"--permission-mode", "full_auto"},
{"--permission-mode", "unsafe"},
{"--permission-mode", "high"},
{"--skip-permissions-unsafe"},
} {
t.Run(strings.Join(args, " "), func(t *testing.T) {
listing := execListToolsFor(t, args...)
if !strings.Contains(listing, "swarm_spawn") {
t.Errorf("%v resolves to full-auto but did not list swarm_spawn, so this entry point cannot delegate:\n%s", args, listing)
}
})
}
}
@@ -0,0 +1,35 @@
package cli
import "testing"
// The warning explains why approvals are being skipped, so misattributing it
// sends someone looking for a flag they never typed. Attribution used to be a
// partial list of --permission-mode spellings, and full_auto was missing from
// it, so that run was told --auto high was responsible.
func TestFullAutoWarningNamesTheFlagThatWasActuallyPassed(t *testing.T) {
for _, testCase := range []struct {
name string
options execOptions
want string
}{
{name: "canonical mode", options: execOptions{permissionMode: "full-auto"}, want: "--permission-mode full-auto"},
{name: "underscore alias", options: execOptions{permissionMode: "full_auto"}, want: "--permission-mode full_auto"},
{name: "legacy unsafe alias", options: execOptions{permissionMode: "unsafe"}, want: "--permission-mode unsafe"},
{name: "legacy high alias", options: execOptions{permissionMode: "high"}, want: "--permission-mode high"},
{name: "boolean flag", options: execOptions{skipPermissionsUnsafe: true}, want: "--full-auto (or --skip-permissions-unsafe)"},
{name: "autonomy", options: execOptions{autonomy: "high"}, want: "--auto high"},
{
// resolveExecPermissionMode reads --permission-mode first, so the
// attribution has to as well.
name: "explicit mode wins over the boolean flag",
options: execOptions{permissionMode: "full-auto", skipPermissionsUnsafe: true},
want: "--permission-mode full-auto",
},
} {
t.Run(testCase.name, func(t *testing.T) {
if got := fullAutoWarningSource(testCase.options); got != testCase.want {
t.Errorf("fullAutoWarningSource = %q, want %q", got, testCase.want)
}
})
}
}
@@ -1,90 +0,0 @@
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)
}
})
}
}
+1 -1
View File
@@ -33,7 +33,7 @@ func parseExecArgs(args []string) (execOptions, bool, error) {
return options, false, err
}
options.permissionMode = value
case arg == "--skip-permissions-unsafe":
case arg == "--skip-permissions-unsafe", arg == "--full-auto":
options.skipPermissionsUnsafe = true
case arg == "--list-tools":
options.listTools = true
+7 -2
View File
@@ -1,6 +1,7 @@
package cli
import (
"encoding/json"
"fmt"
"io"
"strings"
@@ -18,11 +19,15 @@ 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, registry *tools.Registry, call agent.ToolCall) (sessions.Event, bool) {
func (recorder *execSessionRecorder) captureCheckpoint(workspaceRoot string, call agent.ToolCall) (sessions.Event, bool) {
if recorder.prepared.Store == nil || recorder.prepared.Session.SessionID == "" {
return sessions.Event{}, false
}
targets := agent.CheckpointTargets(workspaceRoot, registry, call)
var args map[string]any
if call.Arguments != "" {
_ = json.Unmarshal([]byte(call.Arguments), &args)
}
targets := tools.MutationTargets(workspaceRoot, call.Name, args)
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, run.registry, call); ok {
if checkpoint, ok := sessionRecorder.captureCheckpoint(run.workspaceRoot, call); ok {
writer.checkpoint(checkpoint)
}
},
+8 -8
View File
@@ -514,7 +514,7 @@ func TestResolveExecPermissionModeMember(t *testing.T) {
{"low", agent.PermissionModeAuto},
{"medium", agent.PermissionModeAuto},
{"member", agent.PermissionModeMemberAuto}, // headless members: write + sandboxed shell
{"high", agent.PermissionModeUnsafe},
{"high", agent.PermissionModeFullAuto},
}
for _, c := range cases {
got, err := resolveExecPermissionMode(execOptions{autonomy: c.autonomy})
@@ -785,8 +785,8 @@ func TestRunExecUnsafeTextModeWarns(t *testing.T) {
if !strings.Contains(stdout, "hello") {
t.Fatalf("expected prompt in stdout, got %q", stdout)
}
if got := stderr; !strings.Contains(got, "WARNING") || !strings.Contains(got, "--skip-permissions-unsafe") {
t.Fatalf("expected unsafe warning, got %q", got)
if got := stderr; !strings.Contains(got, "WARNING") || !strings.Contains(got, "full-auto") {
t.Fatalf("expected full-auto warning, got %q", got)
}
}
@@ -935,8 +935,8 @@ func TestRunExecJSONUnsafeOutputsWarningEvent(t *testing.T) {
if !slices.Contains(eventTypes, "warning") {
t.Fatalf("expected JSON warning event in %v; output %q", eventTypes, stdout)
}
if got := events[0]["permission_mode"]; got != "unsafe" {
t.Fatalf("expected run_start permission_mode unsafe, got %v", got)
if got := events[0]["permission_mode"]; got != "full-auto" {
t.Fatalf("expected run_start permission_mode full-auto, got %v", got)
}
}
@@ -1194,7 +1194,7 @@ func TestRunExecReasoningEffortNoticeUsesEffectiveModel(t *testing.T) {
}
// TestRunExecAutoHighEmitsUnsafeWarning asserts that --auto high (which resolves
// to PermissionModeUnsafe) surfaces the same unsafe warning as
// to PermissionModeFullAuto) surfaces the same unsafe warning as
// --skip-permissions-unsafe.
func TestRunExecAutoHighEmitsUnsafeWarning(t *testing.T) {
exitCode, stdout, stderr := runExecWithEcho(t, []string{
@@ -1212,8 +1212,8 @@ func TestRunExecAutoHighEmitsUnsafeWarning(t *testing.T) {
if !slices.Contains(jsonEventTypes(events), "warning") {
t.Fatalf("expected JSON warning event for --auto high, got %v; output %q", jsonEventTypes(events), stdout)
}
if got := events[0]["permission_mode"]; got != "unsafe" {
t.Fatalf("expected run_start permission_mode unsafe, got %v", got)
if got := events[0]["permission_mode"]; got != "full-auto" {
t.Fatalf("expected run_start permission_mode full-auto, got %v", got)
}
}
+9 -5
View File
@@ -85,10 +85,14 @@ func resolveExecPermissionMode(options execOptions) (agent.PermissionMode, error
return agent.PermissionModeMemberAuto, nil
case "ask":
return agent.PermissionModeAsk, nil
case "unsafe", "high":
return agent.PermissionModeUnsafe, nil
// full-auto is the canonical spelling this PR establishes; unsafe stays as
// the deprecated alias so existing invocations keep working. Without the
// canonical value here, `--permission-mode full-auto` failed usage
// validation before NormalizePermissionMode was ever reached.
case "full-auto", "full_auto", "unsafe", "high":
return agent.PermissionModeFullAuto, nil
default:
return "", execUsageError{fmt.Sprintf("Invalid permission mode %q. Expected plan, spec-draft, auto, member, ask, or unsafe.", options.permissionMode)}
return "", execUsageError{fmt.Sprintf("Invalid permission mode %q. Expected plan, spec-draft, auto, member, ask, or full-auto.", options.permissionMode)}
}
}
// Validate --auto first, regardless of --skip-permissions-unsafe, so an
@@ -105,12 +109,12 @@ func resolveExecPermissionMode(options execOptions) (agent.PermissionMode, error
// swarm launcher sets this; it is not part of the public low|medium|high set.
mode = agent.PermissionModeMemberAuto
case "high":
mode = agent.PermissionModeUnsafe
mode = agent.PermissionModeFullAuto
default:
return "", execUsageError{fmt.Sprintf("Invalid autonomy level %q. Expected low, medium, or high.", options.autonomy)}
}
if options.skipPermissionsUnsafe {
return agent.PermissionModeUnsafe, nil
return agent.PermissionModeFullAuto, nil
}
return mode, nil
}
-116
View File
@@ -2,7 +2,6 @@ package cli
import (
"context"
"errors"
"fmt"
"io"
"strings"
@@ -83,105 +82,11 @@ 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`.")
@@ -661,31 +566,10 @@ 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,7 +5,6 @@ import (
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"testing"
@@ -334,234 +333,3 @@ 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/specialist matcher (beforeTool/afterTool/specialistStart/specialistStop)
--matcher <pattern> Tool matcher (beforeTool/afterTool only)
--arg <value> Command argument (repeatable)
--user Write to user config instead of the project
--json Print command result as JSON
+3 -32
View File
@@ -741,13 +741,6 @@ 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
@@ -807,29 +800,11 @@ 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
@@ -881,19 +856,15 @@ func (cfg *mcpWritableConfig) setServerDisabled(name string, disabled bool) (boo
server = map[string]json.RawMessage{}
}
// 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
current := false
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 || shippedDisabled {
data, err := json.Marshal(disabled)
if disabled {
data, err := json.Marshal(true)
if err != nil {
return false, false, err
}
-260
View File
@@ -2,18 +2,10 @@ 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"
)
@@ -52,255 +44,3 @@ 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)
}
}
}
+1 -1
View File
@@ -55,7 +55,7 @@ func TestRootFlagsComposeInAnyOrder(t *testing.T) {
wantMode := agent.PermissionModeAsk
for _, arg := range args {
if arg == "--skip-permissions-unsafe" {
wantMode = agent.PermissionModeUnsafe
wantMode = agent.PermissionModeFullAuto
}
}
if options.PermissionMode != wantMode {
@@ -1,145 +0,0 @@
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)
})
}
+1 -1
View File
@@ -103,7 +103,7 @@ func TestTrustGateBlocksToolHookThroughAgentRun(t *testing.T) {
if _, err := agent.Run(context.Background(), "go", toolThenTextProvider{toolName: "marker_tool"}, agent.Options{
Registry: reg,
Hooks: disp,
PermissionMode: agent.PermissionModeUnsafe,
PermissionMode: agent.PermissionModeFullAuto,
MaxTurns: 3,
}); err != nil {
t.Fatalf("agent.Run: %v", err)
@@ -62,6 +62,7 @@ func TestCLILaunchHelpersLeaveUserStateAlone(t *testing.T) {
{"execAdvertisesEscalateModel", func(t *testing.T) {
_ = execAdvertisesEscalateModel(t, []string{"--allow-escalation", "exec", "say hi"})
}},
{"execListToolsFor", func(t *testing.T) { _ = execListToolsFor(t, "--auto", "high") }},
{"runExecWithSandbox", func(t *testing.T) {
_, _, _ = runExecWithSandbox(t, []string{"exec", "hello"}, unavailableTestSandbox, config.SandboxConfig{})
}},
-168
View File
@@ -1,168 +0,0 @@
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
@@ -1,282 +0,0 @@
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
@@ -1,51 +0,0 @@
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)
}
})
}
}
+5 -27
View File
@@ -3,48 +3,26 @@ package config
import (
"reflect"
"strings"
"github.com/Twigpine/zero/internal/aimlapi"
)
// 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
// 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
// `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,92 +291,3 @@ 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")
}
}
-33
View File
@@ -724,39 +724,6 @@ 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, afterTool, specialistStart, and specialistStop hooks."}
return Definition{}, manifestError{fieldPath: field + ".matcher", message: "matcher is only supported for beforeTool and afterTool 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 || event == EventSpecialistStart || event == EventSpecialistStop
return event == EventBeforeTool || event == EventAfterTool
}
func toDiagnostic(err error, source ConfigSource, path string) Diagnostic {
+1 -41
View File
@@ -180,7 +180,7 @@ func TestLoadConfigPreservesUserDisabledStateWhenProjectOmitsEnabled(t *testing.
}
func TestLoadConfigRejectsMatchersOnLifecycleHooks(t *testing.T) {
for _, event := range []string{"sessionStart", "sessionEnd"} {
for _, event := range []string{"sessionStart", "specialistStart"} {
t.Run(event, func(t *testing.T) {
dir := t.TempDir()
projectConfigPath := filepath.Join(dir, "hooks.json")
@@ -210,46 +210,6 @@ 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})
-119
View File
@@ -1,119 +0,0 @@
package mcp
import (
"fmt"
"net/http"
"net/url"
"strings"
"github.com/Twigpine/zero/internal/aimlapi"
"github.com/Twigpine/zero/internal/config"
)
// 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: never in Server, its
// Identity (OAuth token binding), config.json, list output or errors (an
// error response to such a request is 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)
}
// 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
}
// 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)
}
-537
View File
@@ -1,537 +0,0 @@
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)
}
}
+2 -11
View File
@@ -427,7 +427,6 @@ 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) {
@@ -435,7 +434,6 @@ 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) {
@@ -444,7 +442,6 @@ 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) {
@@ -610,18 +607,12 @@ 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%s", server.Type, server.Name, response.StatusCode, hint)
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%s", server.Type, server.Name, response.StatusCode, detail, hint)
return fmt.Errorf("MCP %s server %s returned HTTP %d: %s", server.Type, server.Name, response.StatusCode, detail)
}
func decodeSSERPCMessage(reader io.Reader) (rpcMessage, error) {
-1
View File
@@ -28,7 +28,6 @@ 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
}
}
+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: skills.ToolOutput(skill)}
return tools.Result{Status: tools.StatusOK, Output: skill.Content}
}
available = append(available, skill.Name)
}
-25
View File
@@ -673,28 +673,3 @@ 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
@@ -1,552 +0,0 @@
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
@@ -1,999 +0,0 @@
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)
}
}
+1 -2
View File
@@ -7,8 +7,7 @@ import (
"strings"
)
// ErrNotInstalled is returned when a plugin id cannot be found under the given
// LoadOptions (for example by Info or SetEnabledByID).
// ErrNotInstalled is returned when Info cannot find a plugin with the requested id.
var ErrNotInstalled = errors.New("plugin is not installed")
// PluginInfo combines a loaded plugin with optional lockfile metadata.
+11 -37
View File
@@ -567,32 +567,15 @@ 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, handle, "."); err != nil {
if err := hashTreeInto(hasher, root, root); err != nil {
return "", err
}
return "sha256:" + hex.EncodeToString(hasher.Sum(nil)), nil
}
// 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)
func hashTreeInto(hasher io.Writer, root string, dir string) error {
entries, err := os.ReadDir(dir)
if err != nil {
return err
}
@@ -605,8 +588,8 @@ func hashTreeInto(hasher io.Writer, handle *os.Root, dir string) error {
if name == ".git" {
continue
}
rel := filepath.Join(dir, name)
info, err := handle.Lstat(rel)
path := filepath.Join(dir, name)
info, err := os.Lstat(path)
if err != nil {
return err
}
@@ -615,10 +598,14 @@ func hashTreeInto(hasher io.Writer, handle *os.Root, dir string) error {
// Skipped by copyTree, so excluded from the hash too.
continue
case info.IsDir():
if err := hashTreeInto(hasher, handle, rel); err != nil {
if err := hashTreeInto(hasher, root, path); 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
@@ -629,7 +616,7 @@ func hashTreeInto(hasher io.Writer, handle *os.Root, dir string) error {
if _, err := io.WriteString(hasher, header); err != nil {
return err
}
file, err := handle.Open(rel)
file, err := os.Open(path)
if err != nil {
return err
}
@@ -647,16 +634,3 @@ func hashTreeInto(hasher io.Writer, handle *os.Root, 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,62 +1181,3 @@ 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")
}
}
+1 -3
View File
@@ -132,9 +132,7 @@ 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 {
// 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 := openAICompat("chatgpt", "ChatGPT", "https://chatgpt.com/backend-api/codex", "gpt-5.5", nil)
d.RequiresAuth = true
return oauthProvider(d, false, false)
}(),
+4 -20
View File
@@ -44,20 +44,6 @@ 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",
@@ -127,12 +113,10 @@ var curatedModels = map[string][]Model{
{ID: "deepseek/deepseek-chat", Description: "coding model"},
},
"chatgpt": {
// 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"},
{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"},
},
"deepseek": {
{ID: "deepseek-chat", Description: "catalog default"},
+6 -22
View File
@@ -35,8 +35,8 @@ func TestModelsAreProviderScoped(t *testing.T) {
},
{
provider: "chatgpt",
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"},
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"},
},
{
provider: "aimlapi",
@@ -65,7 +65,7 @@ func TestModelsAreProviderScoped(t *testing.T) {
},
{
provider: "minimaxi-cn",
want: []string{"MiniMax-M3", "MiniMax-M3.1-Flash", "MiniMax-M2.7", "MiniMax-M2.1"},
want: []string{"MiniMax-M3", "MiniMax-M2.7", "MiniMax-M2.1"},
notWant: []string{"gpt-4.1", "claude-sonnet-4.5"},
},
{
@@ -165,8 +165,8 @@ func TestMiniMaxModelsExposeCurrentCapabilities(t *testing.T) {
t.Fatal("provider minimax missing from catalog")
}
models := Models(descriptor)
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))
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))
}
m3 := models[0]
@@ -178,23 +178,7 @@ func TestMiniMaxModelsExposeCurrentCapabilities(t *testing.T) {
t.Fatalf("MiniMax-M3 modalities = input:%#v output:%#v", m3.InputModalities, m3.OutputModalities)
}
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]
m27 := models[1]
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)
}
@@ -1,31 +0,0 @@
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)
}
}
+2 -3
View File
@@ -20,9 +20,8 @@ import (
)
const (
anthropicVersion = "2023-06-01"
// GPT-6.1 Terra's documented Codex client floor: https://learn.chatgpt.com/docs/models.
chatGPTModelsProtocolVersion = "0.159.0"
anthropicVersion = "2023-06-01"
chatGPTModelsProtocolVersion = "0.146.0"
)
type Model struct {
+5 -22
View File
@@ -49,19 +49,6 @@ 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
@@ -390,15 +377,11 @@ func (provider *Provider) anthropicRequest(request zeroruntime.CompletionRequest
Messages: messages,
Stream: true,
}
// 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 {
// 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 {
mapped.MaxTokens = effectiveMax
mapped.Thinking = &thinkingConfig{Type: "enabled", BudgetTokens: budget}
}
@@ -266,53 +266,6 @@ 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
+7 -11
View File
@@ -10,21 +10,17 @@ 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"`
OutputConfig *outputConfig `json:"output_config,omitempty"`
Tools []anthropicTool `json:"tools,omitempty"`
Stream bool `json:"stream"`
Thinking *thinkingConfig `json:"thinking,omitempty"`
Tools []anthropicTool `json:"tools,omitempty"`
Stream bool `json:"stream"`
}
// thinkingConfig requests extended thinking. Anthropic uses "enabled" with a
// token budget; MiniMax-M3.1-Flash uses "adaptive" with output_config.effort.
// 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).
type thinkingConfig struct {
Type string `json:"type"`
BudgetTokens int `json:"budget_tokens,omitempty"`
}
type outputConfig struct {
Effort string `json:"effort"`
BudgetTokens int `json:"budget_tokens"`
}
type anthropicMessage struct {
@@ -288,11 +288,10 @@ func TestScanSSEDataWithContextContentResetsOnData(t *testing.T) {
pr, pw := io.Pipe()
go func() {
// 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++ {
// 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++ {
if _, err := io.WriteString(pw, "data: chunk\n\n"); err != nil {
return
}
@@ -304,7 +303,7 @@ func TestScanSSEDataWithContextContentResetsOnData(t *testing.T) {
n := 0
done := make(chan error, 1)
go func() {
done <- ScanSSEDataWithContext(context.Background(), func() {}, pr, 500*time.Millisecond, func(string) bool { n++; return true })
done <- ScanSSEDataWithContext(context.Background(), func() {}, pr, 100*time.Millisecond, func(string) bool { n++; return true })
}()
select {
@@ -315,8 +314,8 @@ func TestScanSSEDataWithContextContentResetsOnData(t *testing.T) {
case <-time.After(3 * time.Second):
t.Fatal("ScanSSEDataWithContext hung on a producing stream")
}
if n != 25 {
t.Fatalf("handled %d data lines, want 25", n)
if n != 10 {
t.Fatalf("handled %d data lines, want 10", n)
}
}
-127
View File
@@ -63,14 +63,6 @@ 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
@@ -443,125 +435,6 @@ 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,114 +105,6 @@ 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.
+9 -1
View File
@@ -463,7 +463,15 @@ func RedactString(value string, options Options) string {
replacement := replacement(options)
redacted := value
if len(options.ExtraSecretValues) > 0 {
redacted = redactExtraSecretValues(redacted, options.ExtraSecretValues, replacement)
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)
}
}
}
if strings.Contains(redacted, "-----BEGIN ") && strings.Contains(redacted, "PRIVATE KEY") {
+268 -22
View File
@@ -15,6 +15,15 @@ type AnalysisResult struct {
Interactive bool
Destructive bool
Network bool
// LocalServer is set when a command BINDS a local port rather than reaching
// out: `python -m http.server`, `vite`, `next dev` and friends.
//
// Kept distinct from Network instead of folded into it. Listening and
// fetching are different acts with different consequences, and treating a
// dev server as egress made ordinary local work prompt for network approval
// it never needed. The information is preserved rather than dropped, so a
// caller that does care about inbound can still see it.
LocalServer bool
// TooComplex is set when the script cannot be parsed (obfuscated or invalid),
// so a caller can treat it as higher-risk instead of trusting a clean result.
TooComplex bool
@@ -45,6 +54,20 @@ var networkPrograms = map[string]bool{
"invoke-restmethod": true,
}
// pythonLauncherPrograms is every spelling of the Python launcher the analyzer
// accepts. It is a named list rather than a repeated switch case because the
// unparseable fallback in risk.go has to be a superset of it, and the two were
// maintained separately: "py" was accepted here and missing there, so a Windows
// batch spelling the POSIX parser rejects lost the network gate that its
// parseable equivalent receives. TestUnparseableFallbackCoversEveryPythonLauncher
// now derives from this, so a new alias cannot weaken enforcement silently.
var pythonLauncherPrograms = map[string]bool{
"python": true,
"python2": true,
"python3": true,
"py": true,
}
var localServerPrograms = map[string]bool{
"http-server": true,
"serve": true,
@@ -54,6 +77,17 @@ var localServerPrograms = map[string]bool{
"astro": true,
}
// packageManagerServingScripts are the script names that start a dev server
// across npm, pnpm, yarn and bun. Authoritative: risk.go's unparseable fallback
// has to stay a superset of what this flags, and the superset test drives its
// cases off this map so a name added here cannot silently weaken the fallback.
var packageManagerServingScripts = map[string]bool{
"start": true,
"serve": true,
"dev": true,
"preview": true,
}
// AnalyzeCommand parses script and reports interactive/destructive/network usage
// from the shell AST. A script that cannot be parsed yields TooComplex (with no
// other flags set) so the caller can decide how to treat an unanalyzable command.
@@ -183,6 +217,29 @@ func analyzeInto(script string, result *AnalysisResult, seen map[string]bool, de
if commandUsesNetwork(prog, rest) {
result.Network = true
}
if commandRunsLocalServer(prog, rest) {
result.LocalServer = true
// LocalServer is ADDITIVE, not a replacement for Network, and that is
// the whole of this line.
//
// Nothing consumes LocalServer yet: no policy or runner code reads it,
// so classifying a serving command as local-only did not grant it a
// scoped host listener, it only removed the network approval it used to
// get. The command then ran under the default deny profile, which on
// Linux is a network namespace and on macOS is (deny network*), so
// `python -m http.server` and `vite` started without a prompt and could
// not serve a preview to the operator's browser.
//
// It is also unsound for the package managers. `npm run dev` is matched
// by SCRIPT NAME, and the repository decides what `dev` and `predev`
// actually do; either can curl before anything binds a port. On Windows
// the approval gate IS the network protection, so inferring "no egress"
// from a name there lets that egress run unprompted.
//
// The classification is kept, because a scoped host-listener path will
// want it, and the approval path is kept until that path exists.
result.Network = true
}
if destructivePrograms[prog] ||
(prog == "rm" && hasRecursiveForce(rest)) ||
(powerShellRemoveItemPrograms[prog] && hasPowerShellRecursiveForce(rest)) ||
@@ -198,12 +255,23 @@ func commandUsesNetwork(prog string, args []*syntax.Word) bool {
return true
}
words := literalWordTexts(args)
// A framework CLI stays conservative for Network whatever its subcommand.
// Binding a port is not egress, but next, vite, nuxt and astro execute the
// repository's config, plugins and application code during a build as
// surely as during a serve, and nothing in the subcommand name proves that
// code makes no requests. On Windows the approval gate IS the network
// boundary, so an ordinary shell grant must not run `next build` without
// the separate network decision. LocalServer alone carries the serve/build
// distinction (commandRunsLocalServer); `python -m http.server` is decided
// by pythonModuleUsesNetwork below, not by this inventory. Reported by
// @gnanam1990.
if localServerPrograms[prog] {
return true
}
switch prog {
case "python", "python2", "python3", "py":
if pythonLauncherPrograms[prog] {
return pythonModuleUsesNetwork(words)
}
switch prog {
case "npm":
return packageManagerUsesNetwork(words, map[string]string{
"run": "run",
@@ -246,23 +314,29 @@ func packageManagerUsesNetwork(words []string, aliases map[string]string) bool {
if packageManagerOffline(words) {
return false
}
first := firstSubcommand(words, aliases)
switch first {
case "install", "add", "ci", "create", "dlx", "publish", "unpublish",
"login", "logout", "adduser", "whoami", "ping", "audit", "outdated",
"update", "upgrade", "search", "view", "info", "show", "dist-tag",
"deprecate", "owner", "org", "team", "token", "profile", "access":
return true
case "start", "serve", "dev", "preview":
return true
case "run":
second := secondSubcommand(words)
return second == "start" || second == "serve" || second == "dev" || second == "preview"
case "exec":
// Package-manager exec commands may resolve and download a missing
// package before launching it. An explicit offline flag keeps this path
// inside the isolated namespace; otherwise request egress up front.
return true
operands, eatable := commandOperands(words)
for index := 0; index < subcommandPositions(operands, eatable); index++ {
subcommand := operands[index]
if alias, ok := aliases[subcommand]; ok {
subcommand = alias
}
switch subcommand {
case "install", "add", "ci", "create", "dlx", "publish", "unpublish",
"login", "logout", "adduser", "whoami", "ping", "audit", "outdated",
"update", "upgrade", "search", "view", "info", "show", "dist-tag",
"deprecate", "owner", "org", "team", "token", "profile", "access":
return true
// start / serve / dev / preview are handled by packageManagerRunsLocalServer
// instead. They start a dev server, which binds rather than fetches, and
// `npm run dev` is the single most common command an agent is asked to run
// while building something. Classifying it as egress made every one of them
// stop for a network approval that protected nothing.
case "exec":
// Package-manager exec commands may resolve and download a missing
// package before launching it. An explicit offline flag keeps this path
// inside the isolated namespace; otherwise request egress up front.
return true
}
}
return false
}
@@ -303,11 +377,183 @@ func pythonModuleUsesNetwork(words []string) bool {
if words[index] != "-m" || index+1 >= len(words) {
continue
}
module := words[index+1]
if module == "http.server" {
// http.server is handled by pythonModuleRunsLocalServer instead: it
// listens, it does not fetch. pip install genuinely reaches out.
if words[index+1] == "pip" && firstSubcommand(words[index+2:], nil) == "install" {
return true
}
if module == "pip" && firstSubcommand(words[index+2:], nil) == "install" {
}
return false
}
// commandRunsLocalServer reports a command that binds a local port.
//
// Separate from commandUsesNetwork on purpose. A dev server is the single most
// common thing an agent is asked to start while building something, and making
// it indistinguishable from `curl` meant every one of them stopped for a
// network approval that protected nobody.
//
// Honest about the edges: some of these do touch the network incidentally, and
// `npm run dev` may install first. What is claimed here is narrow, that BINDING
// is not EGRESS, not that dev tooling is inert. Anything that actually fetches
// still matches commandUsesNetwork through its own program or subcommand.
func commandRunsLocalServer(prog string, args []*syntax.Word) bool {
words := literalWordTexts(args)
if localServerPrograms[prog] {
return frameworkSubcommandRunsLocalServer(prog, words)
}
if pythonLauncherPrograms[prog] {
return pythonModuleRunsLocalServer(words)
}
switch prog {
case "npm", "pnpm", "yarn", "bun":
return packageManagerRunsLocalServer(words)
}
return false
}
// frameworkSubcommandRunsLocalServer decides whether a framework CLI is being
// asked to SERVE or merely to compile.
//
// The program name alone is not enough. `next build`, `vite build`, `nuxt
// generate` and `astro check` bind nothing, and classifying them as servers
// makes the flag mean "some dev tool ran", which is not what a reader of it
// would assume.
//
// Dedicated servers stay unconditional: running http-server or serve IS the
// server. vite is also a server when bare, since bare `vite` starts the dev
// server, whereas the multi-command frameworks print help when bare and so need
// an explicit serving subcommand.
func frameworkSubcommandRunsLocalServer(prog string, words []string) bool {
switch prog {
case "http-server", "serve":
return true
}
// The subcommand is not simply the first operand: a global option before it
// consumes the next word (`vite --config vite.prod.ts build`), and reading
// that value as the subcommand made the build fall through to the bare-vite
// rule and count as a server. Walk the positions a subcommand can occupy,
// the same way the package managers do; see subcommandPositions.
operands, eatable := commandOperands(words)
for index := 0; index < subcommandPositions(operands, eatable); index++ {
switch operands[index] {
case "dev", "start", "serve", "preview":
return true
case "build", "optimize", "generate", "check", "lint", "export":
return false
}
}
// No recognized subcommand at any position it could occupy: a bare
// invocation, or only option values. Fall back to what the program does when
// run bare: vite starts its dev server, while the multi-command frameworks
// print help.
return prog == "vite"
}
// packageManagerRunsLocalServer covers `npm run dev` and its siblings across the
// package managers, both as a direct subcommand and behind `run`, and wherever
// global options push the subcommand off its usual position. See
// subcommandPositions for why the subcommand is not simply the first operand.
func packageManagerRunsLocalServer(words []string) bool {
operands, eatable := commandOperands(words)
for index := 0; index < subcommandPositions(operands, eatable); index++ {
if packageManagerServingScripts[operands[index]] {
return true
}
if operands[index] == "run" && index+1 < len(operands) && packageManagerServingScripts[operands[index+1]] {
return true
}
}
return false
}
// commandOperands returns a command's non-flag words in order, and whether a
// flag came before the first of them. The program name is expected to be
// stripped already.
func commandOperands(words []string) (operands []string, eatable int) {
// eatable counts the LEADING RUN of operands that each sit immediately after
// a flag, and so might be that flag's value rather than the subcommand.
// Adjacency is the whole rule: an option can only consume the word next to
// it, and the first operand that is NOT preceded by a flag cannot be anyone's
// value, so the run ends there and the subcommand is at or before it.
previousWasFlag := false
counting := true
for _, word := range words {
if word == "" {
continue
}
if strings.HasPrefix(word, "-") {
previousWasFlag = true
continue
}
if isNumericToken(word) {
previousWasFlag = false
continue
}
if counting {
if previousWasFlag {
eatable++
} else {
counting = false
}
}
operands = append(operands, word)
previousWasFlag = false
}
return operands, eatable
}
// subcommandPositions reports how many leading operands could be the subcommand.
//
// Skipping flags is not enough, because it does not skip the words they CONSUME.
// `npm --prefix ./web run dev` then resolves to "./web" — the option's VALUE —
// and the invocation stops being recognized at all: the dev server loses the
// network approval its unflagged spelling gets, and `npm --prefix ./web install`
// loses it while genuinely fetching. That is not a contrived spelling; a global
// option before the subcommand is documented usage for every one of these tools
// (`npm --prefix`, `pnpm -C`, `yarn --cwd`, `bun --cwd`).
//
// Which options take a value is deliberately not enumerated. That is four tools
// each free to add one, and a list like that leaks silently the moment they do.
// ADJACENCY IS USED INSTEAD, because it is the actual grammar: an option can only
// consume the word immediately after it. Every leading operand that sits right
// after a flag might be that flag's value, so each one opens another position;
// the first operand NOT preceded by a flag cannot be anyone's value, and the
// window closes there.
//
// A FIXED WINDOW OF TWO WAS THE BUG. It covered exactly one value-taking option
// and no more, so a second one walked straight past it — measured before this
// change:
//
// npm --prefix ./web install -> network (correct)
// npm --prefix ./web --loglevel warn install -> NOT network
// npm --registry https://r.test --prefix ./web install -> NOT network
// pnpm -C ./web --filter web add left-pad -> NOT network
//
// all four genuinely fetching. Counting the flags rather than fixing the number
// closes the class at any arity without naming a single option.
//
// The false positives the old cap was protecting against are unaffected, because
// they turn on adjacency too: `npm run build --workspace dev` and `npm run test
// -- --grep start` both put an unflagged operand FIRST, so the window is one
// position and "dev" and "start" are never reached. That is a stronger guarantee
// than the cap gave — it held only because two happened to be short enough.
func subcommandPositions(operands []string, eatable int) int {
if len(operands) == 0 {
return 0
}
if positions := eatable + 1; positions < len(operands) {
return positions
}
return len(operands)
}
func pythonModuleRunsLocalServer(words []string) bool {
for index := 0; index < len(words); index++ {
if words[index] != "-m" || index+1 >= len(words) {
continue
}
if words[index+1] == "http.server" {
return true
}
}
@@ -0,0 +1,151 @@
package sandbox
import "testing"
// LocalServer exists to say one narrow thing: this command BINDS a local port,
// so it is not egress and should not be treated as network access. That claim
// is only worth anything if it tracks what the command actually does.
//
// Matching on the program name alone made every invocation of a framework CLI a
// "server", so `next build` and `vite build` claimed to bind a port while
// compiling to disk. Nothing reads the flag today, which is exactly why it could
// be wrong quietly; the first reader would have inherited the bug.
func TestLocalServerDistinguishesServingFromBuilding(t *testing.T) {
serving := []string{
"next dev", "next start", "nuxt dev", "astro dev", "astro preview",
"vite", "vite serve", "vite preview",
// Options only, no subcommand. firstSubcommand skips the flag but not the
// value it consumes, so this used to resolve to "127.0.0.1" and read as a
// build.
"vite --host 127.0.0.1", "vite --port 5173",
// Dedicated servers, whose entire purpose is to bind.
"http-server", "http-server ./public", "serve", "serve dist",
// Via a package manager, the pre-existing path.
"npm run dev", "pnpm start", "yarn preview",
}
for _, command := range serving {
if !AnalyzeCommand(command).LocalServer {
t.Errorf("%q does bind a local port but was not classified as a local server", command)
}
}
building := []string{
"next build", "next lint", "next",
"vite build", "vite optimize",
"nuxt generate", "nuxt build",
"astro check", "astro build",
"npm run build",
}
for _, command := range building {
if AnalyzeCommand(command).LocalServer {
t.Errorf("%q compiles rather than serving but was classified as a local server", command)
}
}
}
// BUILDING IS NOT SERVING, BUT IT IS STILL NETWORK.
//
// The serve/build distinction lives in LocalServer alone. Network stays
// conservative for the whole framework inventory, because next, vite, nuxt and
// astro run the repository config, plugins and application code during a build
// as surely as during a serve, and the subcommand name cannot prove that code
// makes no requests. An earlier head of this branch let build-like spellings
// drop to Network=false, and on Windows the approval gate IS the network
// boundary, so an ordinary shell grant would have run them without the separate
// network decision main applies. Reported by gnanam1990.
func TestFrameworkBuildsStayNetworkWithoutBeingServers(t *testing.T) {
for _, command := range []string{
"next build", "next lint", "next",
"vite build", "vite optimize",
"nuxt generate", "nuxt build",
"astro check", "astro build",
// Option-bearing forms: a more precise action parse must not remove the gate.
"next build --profile", "vite build --mode production",
"vite --config vite.prod.ts build", "nuxt generate --dotenv .env.prod",
"astro check --watch",
} {
analysis := AnalyzeCommand(command)
if !analysis.Network {
t.Errorf("%q lost its network classification; a framework build runs repository code and cannot be proven inert", command)
}
if analysis.LocalServer {
t.Errorf("%q compiles rather than serving but was classified as a local server", command)
}
}
for _, command := range []string{"next dev", "vite", "nuxt dev", "astro dev", "astro preview"} {
analysis := AnalyzeCommand(command)
if !analysis.Network || !analysis.LocalServer {
t.Errorf("%q = Network %v LocalServer %v, want both: serving binds AND may egress", command, analysis.Network, analysis.LocalServer)
}
}
}
// SERVING KEEPS ITS NETWORK APPROVAL until something consumes LocalServer.
//
// The classification alone grants nothing: no policy or runner code reads
// LocalServer, so treating a serving command as local-only only removed the
// approval it used to get, and the command then ran under the default deny
// profile. On Linux that is a network namespace and on macOS it is
// (deny network*), so the server started unprompted and could not serve a
// preview to the operator's browser.
//
// The package-manager cases are the sharper half. "npm run dev" is matched by
// SCRIPT NAME, and the repository decides what "dev" and "predev" actually do;
// either can curl before anything binds a port. On Windows the approval gate IS
// the network protection, so inferring no-egress from a name lets that through
// unprompted.
func TestServingStillRequiresNetworkApproval(t *testing.T) {
for _, command := range []string{"next dev", "vite", "http-server", "npm run dev", "pnpm dev", "python -m http.server"} {
analysis := AnalyzeCommand(command)
if !analysis.LocalServer {
t.Errorf("%q was not recognised as a local server", command)
}
if !analysis.Network {
t.Errorf("%q lost its network approval, so it runs under the deny profile and cannot serve a preview", command)
}
}
}
// A GLOBAL OPTION BEFORE THE SUBCOMMAND MUST NOT HIDE IT.
//
// The subcommand scan skips flags but not the word they CONSUME, so
// `npm --prefix ./web run dev` resolved to "./web" and the dev server stopped
// being recognised at all — it lost the network approval its unflagged spelling
// gets, and on Windows that approval IS the egress control. A global option
// ahead of the subcommand is documented usage for every one of these tools
// (`npm --prefix`, `pnpm -C`, `yarn --cwd`, `bun --cwd`), not a contrived form.
//
// Driven off packageManagerServingScripts rather than a written-out list, so a
// script name added there is covered in the flagged spelling too instead of only
// in the one someone remembered to type.
func TestPackageManagerOptionValuesDoNotHideTheServingSubcommand(t *testing.T) {
if len(packageManagerServingScripts) == 0 {
t.Fatal("SETUP INVALID: no serving scripts to check")
}
for _, manager := range []struct{ prog, option string }{
{"npm", "--prefix"},
{"pnpm", "-C"},
{"yarn", "--cwd"},
{"bun", "--cwd"},
} {
for script := range packageManagerServingScripts {
for _, command := range []string{
manager.prog + " " + manager.option + " ./web " + script,
manager.prog + " " + manager.option + " ./web run " + script,
} {
t.Run(command, func(t *testing.T) {
analysis := AnalyzeCommand(command)
if analysis.TooComplex {
t.Fatalf("SETUP INVALID: %q was expected to parse", command)
}
if !analysis.LocalServer {
t.Errorf("%q binds a local port, but the option's value was read as its subcommand", command)
}
if !analysis.Network {
t.Errorf("%q lost the network approval its unflagged spelling receives", command)
}
})
}
}
}
}
+143 -9
View File
@@ -9,6 +9,7 @@ func TestAnalyzeCommand(t *testing.T) {
interactive bool
destructive bool
network bool
localServer bool
tooComplex bool
}{
{name: "editor", script: "vim foo.txt", interactive: true},
@@ -80,7 +81,7 @@ func TestAnalyzeCommand(t *testing.T) {
{name: "Windows drive relative path curl exe", script: `'C:curl.exe' https://example.com`, network: true},
{name: "Windows npm cmd", script: "npm.cmd install", network: true},
{name: "wget piped to shell", script: "wget -qO- https://x.test | sh", network: true},
{name: "python http server", script: "python3 -m http.server 8000", network: true},
{name: "python http server", script: "python3 -m http.server 8000", network: true, localServer: true},
{name: "python pip install", script: "python3 -m pip install requests", network: true},
{name: "npm install", script: "npm install", network: true},
{name: "npm ci", script: "npm ci", network: true},
@@ -89,12 +90,12 @@ func TestAnalyzeCommand(t *testing.T) {
{name: "npm metadata search", script: "npm search typescript", network: true},
{name: "npm offline install", script: "npm install --offline", network: false},
{name: "npm version is offline", script: "npm --version", network: false},
{name: "npm start", script: "npm start", network: true},
{name: "npm run dev", script: "npm run dev", network: true},
{name: "npm start", script: "npm start", network: true, localServer: true},
{name: "npm run dev", script: "npm run dev", network: true, localServer: true},
{name: "npx http server", script: "npx http-server public -p 8080 -a 127.0.0.1", network: true},
{name: "direct http server", script: "http-server public -p 8080 -a 127.0.0.1", network: true},
{name: "direct vite", script: "vite --host 127.0.0.1", network: true},
{name: "next dev", script: "next dev", network: true},
{name: "direct http server", script: "http-server public -p 8080 -a 127.0.0.1", network: true, localServer: true},
{name: "direct vite", script: "vite --host 127.0.0.1", network: true, localServer: true},
{name: "next dev", script: "next dev", network: true, localServer: true},
{name: "git clone", script: "git clone https://example.com/repo.git", network: true},
{name: "git fetch", script: "git fetch origin", network: true},
{name: "git status is offline", script: "git status", network: false},
@@ -103,15 +104,42 @@ func TestAnalyzeCommand(t *testing.T) {
{name: "process pattern is not network", script: `pkill -f "python3 -m http.server 8000"`, network: false},
{name: "process listing is not special-cased", script: "ps aux", network: false},
// Binding a port is not reaching out. These four are the shape that made
// ordinary local work stop for a network approval it never needed, and the
// fetching siblings beside them are what must keep asking.
{name: "pnpm dev binds", script: "pnpm dev", network: true, localServer: true},
{name: "yarn serve binds", script: "yarn serve", network: true, localServer: true},
{name: "bun run preview binds", script: "bun run preview", network: true, localServer: true},
{name: "pnpm install still fetches", script: "pnpm install", network: true},
{name: "yarn add still fetches", script: "yarn add left-pad", network: true},
{name: "npm publish still fetches", script: "npm publish", network: true},
{name: "python pip install still fetches", script: "python3 -m pip install requests", network: true},
// A global option BEFORE the subcommand is documented usage for all four
// package managers, and the subcommand scan skipped the flag but not the
// word it consumed — so the option's VALUE was read as the subcommand and
// both the dev server and the genuine fetch fell out of their categories.
{name: "npm prefix then run dev", script: "npm --prefix ./web run dev", network: true, localServer: true},
{name: "pnpm dir then run dev", script: "pnpm -C ./web run dev", network: true, localServer: true},
{name: "yarn cwd then dev", script: "yarn --cwd ./web dev", network: true, localServer: true},
{name: "npm prefix then install still fetches", script: "npm --prefix ./web install", network: true},
{name: "npm registry then install still fetches", script: "npm --registry https://registry.test install", network: true},
{name: "pnpm filter then add still fetches", script: "pnpm --filter web add left-pad", network: true},
// The candidate window stops at two operands. Past that, "dev" and "start"
// are arguments to something else and reading them as the subcommand would
// stop an ordinary build for a network approval.
{name: "npm run build with a workspace named dev", script: "npm run build --workspace dev", network: false},
{name: "npm run test forwarding a start flag", script: "npm run test -- --grep start", network: false},
{name: "unparseable", script: `'unterminated quote`, tooComplex: true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := AnalyzeCommand(tc.script)
if got.Interactive != tc.interactive || got.Destructive != tc.destructive ||
got.Network != tc.network || got.TooComplex != tc.tooComplex {
t.Fatalf("AnalyzeCommand(%q) = %#v, want interactive=%v destructive=%v network=%v tooComplex=%v",
tc.script, got, tc.interactive, tc.destructive, tc.network, tc.tooComplex)
got.Network != tc.network || got.LocalServer != tc.localServer || got.TooComplex != tc.tooComplex {
t.Fatalf("AnalyzeCommand(%q) = %#v, want interactive=%v destructive=%v network=%v localServer=%v tooComplex=%v",
tc.script, got, tc.interactive, tc.destructive, tc.network, tc.localServer, tc.tooComplex)
}
})
}
@@ -122,3 +150,109 @@ func TestAnalyzeCommandEmptyIsClean(t *testing.T) {
t.Fatalf("empty script should be clean, got %#v", got)
}
}
// A FLAGGED INVOCATION CLASSIFIES LIKE ITS UNFLAGGED SELF, AT ANY ARITY.
//
// Skipping flags is not enough, because it does not skip the words they CONSUME:
// `npm --prefix ./web install` resolved to "./web", the option's VALUE, and the
// invocation stopped being recognised at all — so a command genuinely fetching
// from the network lost the approval its plain spelling gets.
//
// The first fix offered a fixed window of two positions, which covered exactly
// ONE value-taking option. A second one walked straight past it. All four of
// these were measured wrong before the window was replaced:
//
// npm --prefix ./web --loglevel warn install
// npm --registry https://r.test --prefix ./web install
// pnpm -C ./web --filter web add left-pad
// yarn --cwd ./web --verbose install
//
// Adjacency decides now — an option can only eat the word beside it — so the
// arity is counted rather than assumed, and no option is ever named. Pairing
// each flagged form against its unflagged baseline is what makes that a property
// rather than a list of examples: a change that shifts BOTH still fails.
func TestAFlaggedInvocationClassifiesLikeItsUnflaggedSelf(t *testing.T) {
for _, pair := range []struct{ bare, flagged string }{
{"npm install", "npm --prefix ./web --loglevel warn install"},
{"npm install", "npm --registry https://r.test --prefix ./web install"},
{"npm install", "npm --prefix=./web --loglevel=warn install"},
{"npm install", "npm -w a -w b --loglevel warn install"},
{"npm run dev", "npm --prefix ./web --loglevel warn run dev"},
{"npm run deploy", "npm --prefix ./web --loglevel warn run deploy"},
{"pnpm add left-pad", "pnpm -C ./web --filter web add left-pad"},
{"yarn install", "yarn --cwd ./web --verbose install"},
} {
bare, flagged := AnalyzeCommand(pair.bare), AnalyzeCommand(pair.flagged)
if bare.Network != flagged.Network {
t.Errorf("%q needs network=%v but %q reports %v: an option value was read as the subcommand",
pair.bare, bare.Network, pair.flagged, flagged.Network)
}
if bare.LocalServer != flagged.LocalServer {
t.Errorf("%q localServer=%v but %q reports %v", pair.bare, bare.LocalServer, pair.flagged, flagged.LocalServer)
}
}
}
// AND THE WINDOW STILL CLOSES ON AN UNFLAGGED OPERAND. Widening it by arity must
// not reach words that belong to something else: "dev" and "start" here are
// arguments to a script, not subcommands. They are safe for a structural reason
// rather than a lucky one — the FIRST operand is unflagged, so it cannot be any
// option's value and the window is one position wide however many flags follow.
func TestAnUnflaggedFirstOperandClosesTheSubcommandWindow(t *testing.T) {
for _, command := range []string{
"npm run build --workspace dev",
"npm run test -- --grep start",
"npm run build --filter start",
} {
if got := AnalyzeCommand(command); got.LocalServer {
t.Errorf("%q read an argument as a dev server", command)
}
}
// THE RUN HAS TO BE CONTIGUOUS, not merely a count of flags anywhere in the
// line. Counting every flag would let TRAILING options widen the window back
// over words they have nothing to do with, and these three flip to false
// positives the moment it does — measured:
//
// npm run --grep foo --tag dev -> network AND localServer
// npm run test --reporter x --filter dev -> network
// npm run build --a x --b start -> network
//
// In each, the first operand is unflagged, so nothing after it can be an
// option's value however many options follow.
for _, command := range []string{
"npm run --grep foo --tag dev",
"npm run test --reporter x --filter dev",
"npm run build --a x --b start",
} {
if got := AnalyzeCommand(command); got.Network || got.LocalServer {
t.Errorf("%q was widened past its unflagged first operand: network=%v localServer=%v",
command, got.Network, got.LocalServer)
}
}
}
// A DIRECTORY NAMED dev OR start IS READ AS A SCRIPT NAME, and that is pinned
// rather than fixed. The ambiguity is the point of the window: with `npm --prefix
// dev install` the analyzer genuinely cannot tell whether "dev" is the option's
// value or the subcommand, so it offers both readings and takes the union.
//
// It costs nothing here. Network is already true and correct — the install
// really does fetch — and LocalServer only implies Network, so the union is
// conservative in the safe direction. Removing the ambiguity would mean naming
// which options take values, which is the enumeration this design exists to
// avoid.
func TestADirectoryNamedLikeAScriptIsReadConservatively(t *testing.T) {
for _, command := range []string{
"npm --prefix dev install",
"npm --prefix start install",
} {
got := AnalyzeCommand(command)
if !got.Network {
t.Errorf("%q is an install and must still need network", command)
}
if !got.LocalServer {
t.Errorf("%q no longer takes the conservative reading; if that is deliberate, update this test", command)
}
}
}
+5 -5
View File
@@ -396,13 +396,13 @@ func (engine *Engine) Evaluate(ctx context.Context, request Request) Decision {
}
netMode := engine.effectiveNetworkMode(policy)
if netMode == NetworkDeny && HasRiskCategory(risk, "network") && !engine.toolNetworkExempt(request) {
if request.SideEffect == SideEffectShell && request.PermissionMode != PermissionUnsafe {
if request.SideEffect == SideEffectShell && request.PermissionMode != PermissionFullAuto {
return Decision{Action: ActionPrompt, Risk: risk, Reason: ReasonNetworkBlocked}
}
return deny(request, risk, BlockNetwork, "", ReasonNetworkBlocked, false)
}
if HasRiskCategory(risk, "destructive") {
if request.SideEffect == SideEffectShell && !request.PermissionGranted && request.PermissionMode != PermissionUnsafe {
if request.SideEffect == SideEffectShell && !request.PermissionGranted && request.PermissionMode != PermissionFullAuto {
return Decision{Action: ActionPrompt, Risk: risk, Reason: "destructive shell command requires approval"}
}
if request.SideEffect == SideEffectShell && !request.PermissionGranted {
@@ -410,7 +410,7 @@ func (engine *Engine) Evaluate(ctx context.Context, request Request) Decision {
}
}
if request.SideEffect == SideEffectShell && requestRequiresEscalatedSandbox(request) {
if !request.PermissionGranted && request.PermissionMode != PermissionUnsafe {
if !request.PermissionGranted && request.PermissionMode != PermissionFullAuto {
return Decision{Action: ActionPrompt, Risk: risk, Reason: ReasonEscalatedSandboxRequired}
}
// Unsafe mode may auto-allow ordinary shell prompts, but it must not
@@ -453,7 +453,7 @@ func (engine *Engine) Evaluate(ctx context.Context, request Request) Decision {
if request.SideEffect == SideEffectShell && engine.shellSandboxActive(policy) {
return Decision{Action: ActionAllow, Risk: risk, Reason: "auto-allowed: sandbox is active for this shell command", AutoAllowed: true}
}
if request.PermissionGranted || request.PermissionMode == PermissionUnsafe {
if request.PermissionGranted || request.PermissionMode == PermissionFullAuto {
return Decision{Action: ActionAllow, Risk: risk, Reason: permissionReason(request)}
}
return Decision{Action: ActionPrompt, Risk: risk, Reason: permissionReason(request)}
@@ -534,7 +534,7 @@ func promptablePathBlock(request Request, block *pathBlock) bool {
if block == nil || block.Code != BlockOutsideWorkspace {
return false
}
if request.PermissionMode == PermissionUnsafe {
if request.PermissionMode == PermissionFullAuto {
return false
}
if request.ToolName == "apply_patch" {
+46 -9
View File
@@ -176,7 +176,7 @@ func TestEngineEvaluatesReadPromptAndPersistentDecisions(t *testing.T) {
ToolName: "write_file",
SideEffect: SideEffectWrite,
Permission: PermissionPrompt,
PermissionMode: PermissionUnsafe,
PermissionMode: PermissionFullAuto,
Args: map[string]any{"path": "notes.txt"},
})
if write.Action != ActionDeny || !write.GrantMatched || write.Block == nil || write.Block.Code != BlockPersistentDeny {
@@ -225,7 +225,7 @@ func TestEngineGrantScopesToFileAndDirectory(t *testing.T) {
t.Fatalf("engine.Grant dir deny: %v", err)
}
denied := writeReq(filepath.Join("secrets", "creds.txt"))
denied.PermissionMode = PermissionUnsafe
denied.PermissionMode = PermissionFullAuto
if d := engine.Evaluate(context.Background(), denied); d.Action != ActionDeny || !d.GrantMatched || d.Block == nil || d.Block.Code != BlockPersistentDeny {
t.Fatalf("path under deny subtree should be denied, got %#v", d)
}
@@ -512,7 +512,7 @@ func TestEngineDeniesOutOfWorkspacePaths(t *testing.T) {
ToolName: "write_file",
SideEffect: SideEffectWrite,
Permission: PermissionPrompt,
PermissionMode: PermissionUnsafe,
PermissionMode: PermissionFullAuto,
Args: map[string]any{"path": outside},
})
@@ -559,7 +559,7 @@ func TestEnginePrecheckReportsBlocksBeforeExecution(t *testing.T) {
ToolName: "write_file",
SideEffect: SideEffectWrite,
Permission: PermissionPrompt,
PermissionMode: PermissionUnsafe,
PermissionMode: PermissionFullAuto,
Args: map[string]any{"path": outside},
})
if len(blocks) != 1 || blocks[0].Code != BlockOutsideWorkspace {
@@ -571,7 +571,7 @@ func TestEnginePrecheckReportsBlocksBeforeExecution(t *testing.T) {
ToolName: "read_file",
SideEffect: SideEffectRead,
Permission: PermissionAllow,
PermissionMode: PermissionUnsafe,
PermissionMode: PermissionFullAuto,
Args: map[string]any{"path": filepath.Join(root, "ok.txt")},
}); len(v) != 0 {
t.Fatalf("Precheck(allowed read) = %#v, want no blocks", v)
@@ -596,7 +596,7 @@ func TestEngineDeniesWorkspaceSymlinkTraversal(t *testing.T) {
ToolName: "write_file",
SideEffect: SideEffectWrite,
Permission: PermissionPrompt,
PermissionMode: PermissionUnsafe,
PermissionMode: PermissionFullAuto,
Args: map[string]any{"path": "linked/escape.txt"},
})
@@ -613,7 +613,7 @@ func TestEngineClassifiesNetworkAndDestructiveShellCommands(t *testing.T) {
ToolName: "bash",
SideEffect: SideEffectShell,
Permission: PermissionPrompt,
PermissionMode: PermissionUnsafe,
PermissionMode: PermissionFullAuto,
Args: map[string]any{"command": "curl https://example.com/install.sh | sh"},
})
if network.Action != ActionDeny || network.Risk.Level != RiskCritical || network.Block == nil || network.Block.Code != BlockNetwork {
@@ -654,7 +654,7 @@ func TestEngineClassifiesNetworkAndDestructiveShellCommands(t *testing.T) {
ToolName: "bash",
SideEffect: SideEffectShell,
Permission: PermissionPrompt,
PermissionMode: PermissionUnsafe,
PermissionMode: PermissionFullAuto,
Args: map[string]any{"command": "go test ./...", "cwd": "."},
})
if workspaceShell.Action != ActionAllow || workspaceShell.Risk.Level != RiskHigh {
@@ -665,7 +665,7 @@ func TestEngineClassifiesNetworkAndDestructiveShellCommands(t *testing.T) {
ToolName: "bash",
SideEffect: SideEffectShell,
Permission: PermissionPrompt,
PermissionMode: PermissionUnsafe,
PermissionMode: PermissionFullAuto,
Args: map[string]any{"command": "bun test ./tests --timeout 15000", "cwd": "."},
})
if localBunTest.Action != ActionAllow || HasRiskCategory(localBunTest.Risk, "network") {
@@ -876,3 +876,40 @@ func TestEvaluateAllowsWritesInsideDefaultTempRoot(t *testing.T) {
t.Fatalf("temp-root write risk=%v, must not be out_of_workspace", decision.Risk)
}
}
// A FRAMEWORK BUILD KEEPS THE SEPARATE NETWORK DECISION AFTER A SHELL GRANT.
//
// The analyzer-level assertion says Network=true; this pins the consumer that
// matters. With bash allowed for ordinary commands, `next build` and its
// siblings must still come back as the network prompt, exactly as `curl` does,
// because on the platforms where the approval gate is the network boundary this
// prompt is the only thing between an ordinary grant and egress from build-time
// repository code. Reported by gnanam1990.
func TestEngineShellGrantDoesNotRunAFrameworkBuildWithoutTheNetworkDecision(t *testing.T) {
store, err := NewGrantStore(StoreOptions{
FilePath: filepath.Join(t.TempDir(), "sandbox-grants.json"),
Now: fixedSandboxTime("2026-09-12T06:00:00Z"),
})
if err != nil {
t.Fatalf("NewGrantStore returned error: %v", err)
}
if _, err := store.Grant(GrantInput{ToolName: "bash", Decision: GrantAllow, Reason: "regular commands"}); err != nil {
t.Fatalf("Grant bash allow returned error: %v", err)
}
engine := NewEngine(EngineOptions{WorkspaceRoot: t.TempDir(), Policy: DefaultPolicy(), Store: store})
for _, command := range []string{"next build", "vite build --mode production", "nuxt generate", "astro check"} {
decision := engine.Evaluate(context.Background(), Request{
ToolName: "bash",
SideEffect: SideEffectShell,
Permission: PermissionPrompt,
PermissionMode: PermissionModeAsk,
Args: map[string]any{"command": command},
})
if decision.Action != ActionPrompt || decision.Reason != ReasonNetworkBlocked {
t.Errorf("%q with a bash allow grant = %#v, want the network prompt", command, decision)
}
if decision.GrantMatched {
t.Errorf("%q: the network prompt must not report the bash allow grant as matched: %#v", command, decision)
}
}
}
+10 -2
View File
@@ -5,13 +5,21 @@ import (
"strings"
)
// legacyFullAutoPermissionMode is what full-auto used to be called on disk.
//
// Accepted permanently rather than migrated: this value lives in user configs
// and in scripts, and a session that silently fell back to auto because its
// saved mode no longer parsed would be a confusing downgrade rather than a
// visible error.
const legacyFullAutoPermissionMode PermissionMode = "unsafe"
func NormalizePermissionMode(value PermissionMode) PermissionMode {
normalized := PermissionMode(strings.ToLower(strings.TrimSpace(string(value))))
switch normalized {
case PermissionModeAsk:
return PermissionModeAsk
case PermissionUnsafe:
return PermissionUnsafe
case PermissionFullAuto, legacyFullAutoPermissionMode:
return PermissionFullAuto
default:
return PermissionModeAuto
}
+34 -1
View File
@@ -9,6 +9,18 @@ import (
"strings"
)
// leadingOptionsPattern spans the global options a package manager accepts
// before its subcommand, including the word an option consumes as its value:
// `npm --prefix ./web run dev` and `pnpm -C ./web run dev` are documented usage,
// and without this the fallback only ever saw the unflagged spelling.
//
// The value is optional on purpose, so both readings of `--flag word` are
// covered. That over-matches — `npm --prefix run` matches too — which is the
// direction this fallback is already documented to favour: it runs only after
// the command was declared too complex to parse, where catching an obvious
// network program matters more than proving exact shell syntax.
const leadingOptionsPattern = `(\s+-{1,2}\S+(\s+\S+)?)*`
var (
// destructiveCommandPattern matches the highest-risk shell forms:
// - rm -rf (with combined/reordered r/f flags) targeting /, $HOME (bare,
@@ -33,7 +45,28 @@ var (
// unparseableNetworkPattern is used only after the shell parser fails. At
// that point the command is already marked too complex, so this intentionally
// favors catching obvious network programs over proving exact shell syntax.
unparseableNetworkPattern = regexp.MustCompile(`(?i)\b(curl|wget|fetch|aria2c|ssh|scp|sftp|rsync|nc|ncat|netcat|telnet|ftp|npx|http-server|vite|next|nuxt|astro)\b|\b(npm|pnpm|yarn|bun|pip|pip2|pip3)\s+(install|add|publish|login|start|serve|dev|preview|run\s+(start|serve|dev|preview)|exec|x|dlx)\b|\bgo\s+get\b|\bgit\s+clone\b|\bpython(2|3)?\s+-m\s+(http\.server|pip\s+install)\b|\bgh\s+(api|repo\s+clone|release\s+download)\b`)
//
// IT MUST BE A SUPERSET OF WHAT THE AST PATH FLAGS FOR NETWORK, not a mirror
// of the analyzer's category names. The serving forms were dropped from here
// on the reasoning that they bind rather than fetch, but the analyzer sets
// Network for every recognized LocalServer anyway, deliberately and for two
// reasons it documents: nothing consumes LocalServer yet, so the approval IS
// the protection, and `npm run dev` is matched by SCRIPT NAME, where the
// repository decides what `dev` and `predev` actually do and either can reach
// out before a port is bound.
//
// So dropping them here did not align the two paths, it split them. The POSIX
// parser rejects Windows shell syntax the invoked shell accepts, and the
// analyzer names that as a fallback case, so:
//
// if "%OS%"=="Windows_NT" (npm run dev) else (npm start)
// -> parsed=false network=false categories=[shell unparseable_command]
//
// while every parseable spelling of the same command gets network. That is
// the approval gate disappearing on exactly the platform where it is the only
// egress control. Restored until a runner actually provides a scoped listener
// capability and the AST path stops setting Network for these itself.
unparseableNetworkPattern = regexp.MustCompile(`(?i)\b(curl|wget|fetch|aria2c|ssh|scp|sftp|rsync|nc|ncat|netcat|telnet|ftp|npx)\b|\b(npm|pnpm|yarn|bun|pip|pip2|pip3)` + leadingOptionsPattern + `\s+(install|add|publish|login|exec|x|dlx)\b|\b(npm|pnpm|yarn|bun)` + leadingOptionsPattern + `\s+(run|dev|serve|start|preview)\b|\b(http-server|serve|vite|next|nuxt|astro)\b|\b(python(2|3)?|py)\s+-m\s+http\.server\b|\bgo\s+get\b|\bgit\s+clone\b|\b(python(2|3)?|py)\s+-m\s+pip\s+install\b|\bgh\s+(api|repo\s+clone|release\s+download)\b`)
// destructiveExtraPatterns hold high-severity patterns that the legacy
// destructiveCommandPattern does not already cover. Folded in from the
// blueprint safe_bash.go without duplicating existing matches.
+4 -1
View File
@@ -70,7 +70,10 @@ const (
const (
PermissionModeAuto PermissionMode = "auto"
PermissionModeAsk PermissionMode = "ask"
PermissionUnsafe PermissionMode = "unsafe"
PermissionFullAuto PermissionMode = "full-auto"
// Deprecated: use PermissionFullAuto. Kept as an alias so code landing
// independently of this branch still compiles after a merge.
PermissionUnsafe = PermissionFullAuto
)
const (
@@ -0,0 +1,120 @@
package sandbox
import "testing"
// THE UNPARSEABLE FALLBACK MUST BE A SUPERSET OF WHAT THE AST PATH FLAGS.
//
// The POSIX parser rejects shell syntax the invoked shell accepts, and the
// analyzer names Windows command strings as exactly that fallback case. When a
// command form loses its network category by being written in a spelling the
// parser cannot read, the approval gate disappears for it — and on Windows that
// approval IS the egress control, because there is no namespace or seatbelt
// underneath it.
//
// The serving forms were briefly dropped from the fallback on the reasoning that
// they bind rather than fetch. The AST path sets Network for them anyway, and
// says why: nothing consumes LocalServer yet, and `npm run dev` is matched by
// script name, so the repository decides what `dev` and `predev` do and either
// can reach out before a port is bound.
func TestUnparseableFallbackKeepsNetworkForServingCommands(t *testing.T) {
// Each pair is the same intent written two ways: one the POSIX parser reads,
// one it does not. Both must be classified the same.
for _, testCase := range []struct{ name, parseable, unparseable string }{
{
name: "package manager dev script",
parseable: `npm run dev`,
unparseable: `if "%OS%"=="Windows_NT" (npm run dev) else (npm start)`,
},
{
// The flagged spelling is the one that regressed: the AST path lost it
// to option-value confusion and the fallback only ever listed the
// serving word directly after the program name, so BOTH paths dropped
// the gate for the same command at the same time.
name: "package manager dev script behind a global option",
parseable: `npm --prefix ./web run dev`,
unparseable: `if "%OS%"=="Windows_NT" (npm --prefix ./web run dev) else (npm --prefix ./web start)`,
},
{
// The fetching half of the same confusion: `npm --prefix ./web install`
// really does reach the registry, and it lost the gate on both paths for
// the same reason the serving form did.
name: "package manager install behind a global option",
parseable: `npm --prefix ./web install`,
unparseable: `if "%OS%"=="Windows_NT" (npm --prefix ./web install) else (true)`,
},
{
name: "python http server",
parseable: `python -m http.server 8000`,
unparseable: `if "%OS%"=="Windows_NT" (python -m http.server 8000) else (true)`,
},
{
name: "framework dev server",
parseable: `vite --host`,
unparseable: `if "%OS%"=="Windows_NT" (vite --host) else (true)`,
},
} {
t.Run(testCase.name, func(t *testing.T) {
// The premise: the first parses and the second does not. If the parser
// ever learns this syntax, this test is measuring nothing and should
// fail loudly rather than pass vacuously.
if AnalyzeCommand(testCase.parseable).TooComplex {
t.Fatalf("SETUP INVALID: %q was expected to parse", testCase.parseable)
}
if !AnalyzeCommand(testCase.unparseable).TooComplex {
t.Fatalf("SETUP INVALID: %q was expected to defeat the parser", testCase.unparseable)
}
for _, script := range []string{testCase.parseable, testCase.unparseable} {
risk := Classify(Request{
ToolName: "bash",
SideEffect: SideEffectShell,
Args: map[string]any{"command": script},
})
if !HasRiskCategory(risk, "network") {
t.Errorf("%q was not classified as network, so it skips the NetworkDeny prompt that its parseable form receives: %v", script, risk.Categories)
}
}
})
}
}
// THE FALLBACK IS DERIVED FROM THE ANALYZER, NOT MAINTAINED BESIDE IT.
//
// The unparseable fallback is meant to be a conservative SUPERSET of everything
// the AST path flags for network. It was maintained as a separate inventory and
// drifted: the analyzer accepted "py" as a Python launcher, the regex listed
// only python, python2 and python3, and a Windows batch spelling the POSIX
// parser rejects therefore lost the approval gate that its parseable equivalent
// receives. On Windows that gate IS the egress control.
//
// Driving the loop off pythonLauncherPrograms means a launcher added there
// cannot silently weaken the fallback: this fails the moment the two disagree.
func TestUnparseableFallbackCoversEveryPythonLauncher(t *testing.T) {
if len(pythonLauncherPrograms) == 0 {
t.Fatal("SETUP INVALID: no python launchers to check")
}
for launcher := range pythonLauncherPrograms {
t.Run(launcher, func(t *testing.T) {
parseable := launcher + " -m http.server 8000"
unparseable := `if "%OS%"=="Windows_NT" (` + parseable + ") else (true)"
if AnalyzeCommand(parseable).TooComplex {
t.Fatalf("SETUP INVALID: %q was expected to parse", parseable)
}
if !AnalyzeCommand(unparseable).TooComplex {
t.Fatalf("SETUP INVALID: %q was expected to defeat the parser", unparseable)
}
for _, script := range []string{parseable, unparseable} {
risk := Classify(Request{
ToolName: "bash",
SideEffect: SideEffectShell,
Args: map[string]any{"command": script},
})
if !HasRiskCategory(risk, "network") {
t.Errorf("%q was not classified as network, so it skips the approval its parseable form receives: %v", script, risk.Categories)
}
}
})
}
}
-258
View File
@@ -1,258 +0,0 @@
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()
}
-351
View File
@@ -1,351 +0,0 @@
package skills
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
const bundledSkillManifest = "---\nname: pdf\ndescription: fill PDF forms\n---\nRun scripts/fill.py with the form path.\n"
// writeBundledSourceSkill lays down a skill in the agentskills.io shape: a
// SKILL.md plus the scripts, references and assets it points at, and the hidden
// clutter a cloned source carries.
func writeBundledSourceSkill(t *testing.T, dir string) string {
t.Helper()
writeSourceSkill(t, dir, bundledSkillManifest)
files := map[string]string{
"scripts/fill.py": "print('fill')\n",
"references/form.md": "# Form fields\n",
"assets/logo.svg": "<svg/>\n",
"LICENSE.txt": "MIT\n",
".git/config": "[core]\n",
".env": "TOKEN=local-only\n",
"scripts/.cache/tmp": "scratch\n",
}
for rel, content := range files {
path := filepath.Join(dir, filepath.FromSlash(rel))
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
mode := os.FileMode(0o644)
if strings.HasPrefix(rel, "scripts/") {
mode = 0o755
}
if err := os.WriteFile(path, []byte(content), mode); err != nil {
t.Fatal(err)
}
}
return dir
}
func TestInstallCopiesTheSkillDirectory(t *testing.T) {
destDir := t.TempDir()
src := writeBundledSourceSkill(t, filepath.Join(t.TempDir(), "pdf"))
result, err := Install(context.Background(), InstallOptions{Source: src, Dir: destDir})
if err != nil {
t.Fatalf("Install: %v", err)
}
installed := filepath.Join(destDir, "pdf")
for _, rel := range []string{"SKILL.md", "scripts/fill.py", "references/form.md", "assets/logo.svg", "LICENSE.txt"} {
if _, err := os.Stat(filepath.Join(installed, filepath.FromSlash(rel))); err != nil {
t.Errorf("%s was not installed: %v", rel, err)
}
}
for _, rel := range []string{".git", ".env", "scripts/.cache"} {
if _, err := os.Stat(filepath.Join(installed, filepath.FromSlash(rel))); err == nil {
t.Errorf("hidden %s was installed; it is not part of the skill", rel)
}
}
if !strings.HasPrefix(result.TreeHash, "sha256:") {
t.Fatalf("TreeHash = %q, want a sha256 tree hash", result.TreeHash)
}
lock, err := ReadLock(destDir)
if err != nil {
t.Fatal(err)
}
if lock["pdf"].TreeHash != result.TreeHash || lock["pdf"].Hash != result.Hash {
t.Fatalf("lock entry = %+v, want the install's hashes %q and %q", lock["pdf"], result.Hash, result.TreeHash)
}
if runtime.GOOS == "windows" {
return
}
script, err := os.Stat(filepath.Join(installed, "scripts", "fill.py"))
if err != nil {
t.Fatal(err)
}
if script.Mode().Perm() != 0o755 {
t.Errorf("installed script mode = %v, want 0755 so the skill can still run it", script.Mode().Perm())
}
reference, err := os.Stat(filepath.Join(installed, "references", "form.md"))
if err != nil {
t.Fatal(err)
}
if reference.Mode().Perm() != 0o644 {
t.Errorf("installed reference mode = %v, want 0644", reference.Mode().Perm())
}
}
func TestInstallSkipsSymlinksAndSaysSo(t *testing.T) {
destDir := t.TempDir()
src := writeBundledSourceSkill(t, filepath.Join(t.TempDir(), "pdf"))
outside := filepath.Join(t.TempDir(), "secret.txt")
if err := os.WriteFile(outside, []byte("not part of the skill\n"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.Symlink(outside, filepath.Join(src, "references", "linked.md")); err != nil {
t.Skipf("symlinks unavailable: %v", err)
}
result, err := Install(context.Background(), InstallOptions{Source: src, Dir: destDir})
if err != nil {
t.Fatalf("Install: %v", err)
}
if len(result.SkippedLinks) != 1 || result.SkippedLinks[0] != "references/linked.md" {
t.Fatalf("SkippedLinks = %v, want [references/linked.md]", result.SkippedLinks)
}
if _, err := os.Lstat(filepath.Join(destDir, "pdf", "references", "linked.md")); err == nil {
t.Fatal("a symlink from the source was installed")
}
}
func TestInstallRefusesAnOversizedSkill(t *testing.T) {
for _, tc := range []struct {
name string
files int
bytes int64
want string
}{
{name: "too many files", files: 3, bytes: maxSkillTreeBytes, want: "more than 3 files"},
{name: "too many bytes", files: maxSkillTreeFiles, bytes: 1 << 20, want: "larger than 1 MiB"},
} {
t.Run(tc.name, func(t *testing.T) {
files, bytes := maxSkillTreeFiles, maxSkillTreeBytes
t.Cleanup(func() { maxSkillTreeFiles, maxSkillTreeBytes = files, bytes })
maxSkillTreeFiles, maxSkillTreeBytes = tc.files, tc.bytes
destDir := t.TempDir()
src := writeBundledSourceSkill(t, filepath.Join(t.TempDir(), "pdf"))
big := make([]byte, 2<<20)
if err := os.WriteFile(filepath.Join(src, "assets", "big.bin"), big, 0o644); err != nil {
t.Fatal(err)
}
_, err := Install(context.Background(), InstallOptions{Source: src, Dir: destDir})
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("Install error = %v, want %q", err, tc.want)
}
if _, statErr := os.Stat(filepath.Join(destDir, "pdf")); statErr == nil {
t.Fatal("a refused install left a skill directory behind")
}
})
}
}
func TestReinstallWithOnlyABundledFileChangedIsAnUpdate(t *testing.T) {
destDir := t.TempDir()
src := writeBundledSourceSkill(t, filepath.Join(t.TempDir(), "pdf"))
first, err := Install(context.Background(), InstallOptions{Source: src, Dir: destDir})
if err != nil {
t.Fatalf("first Install: %v", err)
}
if err := os.WriteFile(filepath.Join(src, "scripts", "fill.py"), []byte("print('fill v2')\n"), 0o755); err != nil {
t.Fatal(err)
}
second, err := Install(context.Background(), InstallOptions{Source: src, Dir: destDir})
if err != nil {
t.Fatalf("second Install: %v", err)
}
if !second.Updated {
t.Fatal("a reinstall that changed a bundled script was not reported as an update")
}
if second.Hash != first.Hash || second.TreeHash == first.TreeHash {
t.Fatalf("hashes = %q/%q after %q/%q, want the same SKILL.md hash and a new tree hash", second.Hash, second.TreeHash, first.Hash, first.TreeHash)
}
if second.PreviousHash != first.Hash || second.PreviousTreeHash != first.TreeHash {
t.Fatalf("previous hashes = %q/%q, want the first install's %q/%q", second.PreviousHash, second.PreviousTreeHash, first.Hash, first.TreeHash)
}
got, err := os.ReadFile(filepath.Join(destDir, "pdf", "scripts", "fill.py"))
if err != nil || string(got) != "print('fill v2')\n" {
t.Fatalf("installed script = %q, %v; want the new version", got, err)
}
}
func TestInfoReportsDriftInABundledFile(t *testing.T) {
destDir := t.TempDir()
src := writeBundledSourceSkill(t, filepath.Join(t.TempDir(), "pdf"))
if _, err := Install(context.Background(), InstallOptions{Source: src, Dir: destDir}); err != nil {
t.Fatalf("Install: %v", err)
}
if info, ok := Info(destDir, "pdf"); !ok || info.HashDrift {
t.Fatalf("a fresh install reports drift: %+v, %v", info, ok)
}
script := filepath.Join(destDir, "pdf", "scripts", "fill.py")
if err := os.WriteFile(script, []byte("print('edited by hand')\n"), 0o755); err != nil {
t.Fatal(err)
}
if info, _ := Info(destDir, "pdf"); !info.HashDrift {
t.Fatal("an edited bundled script is not reported as drift")
}
}
// A lock entry written before installs carried their bundled files has no tree
// hash. Checking its tree would report every such skill as drifted, so only
// its SKILL.md is checked, as before.
func TestLockEntryWithoutATreeHashChecksOnlySKILLmd(t *testing.T) {
destDir := t.TempDir()
src := writeBundledSourceSkill(t, filepath.Join(t.TempDir(), "pdf"))
if _, err := Install(context.Background(), InstallOptions{Source: src, Dir: destDir}); err != nil {
t.Fatalf("Install: %v", err)
}
dropTreeHash(t, destDir, "pdf")
if err := os.WriteFile(filepath.Join(destDir, "pdf", "scripts", "fill.py"), []byte("print('edited')\n"), 0o755); err != nil {
t.Fatal(err)
}
if info, _ := Info(destDir, "pdf"); info.HashDrift {
t.Fatal("an entry without a tree hash reported drift for a bundled file")
}
if err := os.WriteFile(filepath.Join(destDir, "pdf", skillFileName), []byte(bundledSkillManifest+"edited\n"), 0o644); err != nil {
t.Fatal(err)
}
if info, _ := Info(destDir, "pdf"); !info.HashDrift {
t.Fatal("an edited SKILL.md under an old lock entry is not reported as drift")
}
}
// Reinstalling over an entry from before tree hashes compares with what that
// install left, SKILL.md alone: an unchanged skill that is only a SKILL.md is
// not an update, and one whose bundled files this install adds is.
func TestReinstallOverAnEntryWithoutATreeHash(t *testing.T) {
t.Run("SKILL.md only", func(t *testing.T) {
destDir := t.TempDir()
src := writeSourceSkill(t, filepath.Join(t.TempDir(), "pdf"), bundledSkillManifest)
if _, err := Install(context.Background(), InstallOptions{Source: src, Dir: destDir}); err != nil {
t.Fatalf("first Install: %v", err)
}
dropTreeHash(t, destDir, "pdf")
result, err := Install(context.Background(), InstallOptions{Source: src, Dir: destDir})
if err != nil {
t.Fatalf("second Install: %v", err)
}
if result.Updated {
t.Fatalf("reinstalling an unchanged SKILL.md-only skill over an old entry was reported as an update: %+v", result)
}
lock, err := ReadLock(destDir)
if err != nil {
t.Fatal(err)
}
if lock["pdf"].TreeHash != result.TreeHash {
t.Fatalf("lock tree hash = %q after the reinstall, want %q", lock["pdf"].TreeHash, result.TreeHash)
}
})
t.Run("with bundled files", func(t *testing.T) {
destDir := t.TempDir()
src := writeBundledSourceSkill(t, filepath.Join(t.TempDir(), "pdf"))
first, err := Install(context.Background(), InstallOptions{Source: src, Dir: destDir})
if err != nil {
t.Fatalf("first Install: %v", err)
}
dropTreeHash(t, destDir, "pdf")
result, err := Install(context.Background(), InstallOptions{Source: src, Dir: destDir})
if err != nil {
t.Fatalf("second Install: %v", err)
}
if !result.Updated || result.Hash != first.Hash || result.PreviousHash != first.Hash || result.PreviousTreeHash != "" {
t.Fatalf("result = %+v, want an update with SKILL.md hash %q unchanged and no previous tree hash", result, first.Hash)
}
})
}
// dropTreeHash rewrites the lock entry for name the way an install from before
// tree hashes left it: a source and a SKILL.md hash, nothing for the tree.
func dropTreeHash(t *testing.T, destDir, name string) {
t.Helper()
lock, err := ReadLock(destDir)
if err != nil {
t.Fatal(err)
}
entry := lock[name]
entry.TreeHash = ""
lock[name] = entry
data, err := json.Marshal(lock)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(destDir, LockFileName), data, 0o644); err != nil {
t.Fatal(err)
}
}
func TestToolOutputSaysWhereTheBundledFilesAre(t *testing.T) {
root := t.TempDir()
writeBundledSourceSkill(t, filepath.Join(root, "pdf"))
skill, ok := Get(root, "pdf")
if !ok {
t.Fatal("skill not loaded")
}
output := ToolOutput(skill)
if !strings.HasPrefix(output, strings.TrimRight(skill.Content, "\n")) {
t.Fatalf("the instructions do not come first:\n%s", output)
}
if !strings.Contains(output, filepath.Dir(skill.Path)) {
t.Fatalf("the output does not say where the skill's files are:\n%s", output)
}
for _, want := range []string{"- LICENSE.txt", "- assets/logo.svg", "- references/form.md", "- scripts/fill.py"} {
if !strings.Contains(output, want+"\n") {
t.Errorf("output does not list %q:\n%s", want, output)
}
}
for _, hidden := range []string{".env", ".git", ".cache", "SKILL.md\n"} {
if strings.Contains(output, "- "+hidden) {
t.Errorf("output lists %q:\n%s", hidden, output)
}
}
}
func TestToolOutputCapsALongListing(t *testing.T) {
root := t.TempDir()
dir := writeSourceSkill(t, filepath.Join(root, "many"), "---\nname: many\n---\nbody\n")
for i := 0; i < bundledFileListLimit+7; i++ {
path := filepath.Join(dir, "references", fmt.Sprintf("part-%03d.md", i))
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte("x\n"), 0o644); err != nil {
t.Fatal(err)
}
}
skill, ok := Get(root, "many")
if !ok {
t.Fatal("skill not loaded")
}
output := ToolOutput(skill)
if got := strings.Count(output, "\n- references/"); got != bundledFileListLimit {
t.Fatalf("listed %d files, want the first %d:\n%s", got, bundledFileListLimit, output)
}
if !strings.Contains(output, "- (and 7 more)\n") {
t.Fatalf("output does not say how many more there are:\n%s", output)
}
}
func TestToolOutputOfAManifestOnlySkillIsItsContent(t *testing.T) {
root := t.TempDir()
writeSourceSkill(t, filepath.Join(root, "plain"), "---\nname: plain\n---\nJust instructions.\n")
skill, ok := Get(root, "plain")
if !ok {
t.Fatal("skill not loaded")
}
if got := ToolOutput(skill); got != skill.Content {
t.Fatalf("ToolOutput = %q, want the unchanged content %q", got, skill.Content)
}
}
+1 -2
View File
@@ -15,10 +15,9 @@ func Info(dir string, name string) (SkillInfo, bool) {
if entry, found := lock[skill.Name]; found {
info.Source = entry.Source
info.Hash = entry.Hash
info.TreeHash = entry.TreeHash
}
}
info.HashDrift = skillHashDrift(skill, info.Hash, info.TreeHash)
info.HashDrift = skillHashDrift(skill, info.Hash)
return info, true
}
+28 -93
View File
@@ -1,9 +1,9 @@
package skills
// Distribution: install a skill from a git URL or a local path into the skills
// directory, with content hashes recorded in a lockfile (skills.lock) so every
// install/update is verifiable. Install copies the skill's directory, its
// SKILL.md and the files beside it, and NEVER executes fetched content.
// directory, with a content hash recorded in a lockfile (skills.lock) so every
// install/update is verifiable. Skills are markdown, so install NEVER executes
// fetched content — it copies and validates the SKILL.md and nothing else.
import (
"context"
@@ -61,30 +61,16 @@ type InstallResult struct {
Hash string `json:"hash"`
// Source echoes the source the skill was installed from.
Source string `json:"source"`
// TreeHash covers every file the skill ships, SKILL.md included; Hash is
// still the SKILL.md hash alone.
TreeHash string `json:"treeHash"`
// Updated is true when an existing install was replaced; PreviousHash then
// carries the prior recorded hash so a caller can show the change.
Updated bool `json:"updated"`
PreviousHash string `json:"previousHash,omitempty"`
// PreviousTreeHash is the tree hash the replaced install recorded. It is
// empty when that install predates tree hashes, and so copied SKILL.md
// alone.
PreviousTreeHash string `json:"previousTreeHash,omitempty"`
// SkippedLinks lists symlinks in the source, relative to the skill
// directory. Install neither follows nor copies them.
SkippedLinks []string `json:"skippedLinks,omitempty"`
}
// LockEntry records the source and content hash for one installed skill.
type LockEntry struct {
Source string `json:"source"`
Hash string `json:"hash"`
// TreeHash covers the whole installed skill directory. Entries written
// before skills installed their bundled files have none, and are checked on
// their SKILL.md alone.
TreeHash string `json:"treeHash,omitempty"`
}
// SkillInfo bundles a discovered skill with its recorded source and hash, for
@@ -93,14 +79,12 @@ type SkillInfo struct {
Skill Skill `json:"skill"`
Source string `json:"source,omitempty"`
Hash string `json:"hash,omitempty"`
TreeHash string `json:"treeHash,omitempty"`
HashDrift bool `json:"hashDrift"`
}
// Install fetches the skill at options.Source and copies its directory into
// options.Dir/<name>/: SKILL.md and the scripts, references and assets beside
// it. It validates the frontmatter and records the SKILL.md hash and a hash of
// the whole tree in the lockfile. A git URL is fetched via the (injectable) GitRunner into a
// Install fetches the skill at options.Source and copies its SKILL.md into
// options.Dir/<name>/, validating the frontmatter and recording a content hash
// in the lockfile. A git URL is fetched via the (injectable) GitRunner into a
// temp dir; a local path is read in place. Fetched content is never executed.
func Install(ctx context.Context, options InstallOptions) (InstallResult, error) {
source := strings.TrimSpace(options.Source)
@@ -149,11 +133,10 @@ func Install(ctx context.Context, options InstallOptions) (InstallResult, error)
if err := os.MkdirAll(staged, 0o755); err != nil {
return InstallResult{}, fmt.Errorf("create staged skill dir: %w", err)
}
bundled, skippedLinks, err := copySkillTree(skillDir, staged)
if err != nil {
return InstallResult{}, fmt.Errorf("stage skill directory: %w", err)
}
stagedManifest := filepath.Join(staged, skillFileName)
if err := os.WriteFile(stagedManifest, data, 0o644); err != nil {
return InstallResult{}, fmt.Errorf("stage SKILL.md: %w", err)
}
stagedData, err := os.ReadFile(stagedManifest)
if err != nil || hashContent(stagedData) != hash {
if err != nil {
@@ -186,13 +169,8 @@ func Install(ctx context.Context, options InstallOptions) (InstallResult, error)
return InstallResult{}, fmt.Errorf("%w: %q is installed from %s (use --force to overwrite)", ErrNameClash, name, previous.Source)
}
treeHash, err := skillTreeHash(staged)
if err != nil {
return InstallResult{}, fmt.Errorf("hash staged skill directory: %w", err)
}
target := filepath.Join(dir, name)
lock[name] = LockEntry{Source: source, Hash: hash, TreeHash: treeHash}
lock[name] = LockEntry{Source: source, Hash: hash}
if err := installtxn.CommitDir(target, staged, func() error {
return writeLock(dir, lock)
}); err != nil {
@@ -200,26 +178,14 @@ func Install(ctx context.Context, options InstallOptions) (InstallResult, error)
}
result := InstallResult{
Name: name,
Path: filepath.Join(target, skillFileName),
Hash: hash,
TreeHash: treeHash,
Source: source,
SkippedLinks: skippedLinks,
Name: name,
Path: filepath.Join(target, skillFileName),
Hash: hash,
Source: source,
}
if existed {
result.Updated = previous.Hash != hash
result.PreviousHash = previous.Hash
result.PreviousTreeHash = previous.TreeHash
if previous.TreeHash == "" {
// An entry from before tree hashes is from an install that copied
// SKILL.md alone. Against that, this install is an update when
// SKILL.md changed or it now brings bundled files; a skill that is
// still only its unchanged SKILL.md is not.
result.Updated = previous.Hash != hash || bundled > 0
} else {
// A changed script with the same SKILL.md is still an update.
result.Updated = previous.Hash != hash || previous.TreeHash != treeHash
}
}
return result, nil
}
@@ -227,8 +193,8 @@ func Install(ctx context.Context, options InstallOptions) (InstallResult, error)
// lockReconciler answers, for one interrupted workspace, which of the two trees
// beside each other the lockfile records. A kill between the tree swap and the
// lockfile publish leaves exactly what a kill after both leaves, so filesystem
// shape cannot tell them apart and only the recorded hashes can: the tree they
// describe is the one the user was told they had.
// shape cannot tell them apart and only the recorded hash can: the tree it
// describes is the one the user was told they had.
func lockReconciler(dir string) installtxn.Reconciler {
return func(name string, target string, backup string) (installtxn.Phase, error) {
lock, err := ReadLock(dir)
@@ -249,45 +215,24 @@ func lockReconciler(dir string) installtxn.Reconciler {
// recorded one and recovery must not pick between them.
return installtxn.PhaseUnknown, nil
}
committed, err := treeMatchesEntry(target, entry)
targetHash, err := manifestHash(target)
if err != nil {
return installtxn.PhaseUnknown, err
}
if committed {
if targetHash == entry.Hash {
return installtxn.PhaseCommitted, nil
}
prePublish, err := treeMatchesEntry(backup, entry)
backupHash, err := manifestHash(backup)
if err != nil {
return installtxn.PhaseUnknown, err
}
if prePublish {
if backupHash == entry.Hash {
return installtxn.PhasePrePublish, nil
}
return installtxn.PhaseUnknown, nil
}
}
// treeMatchesEntry reports whether the skill tree at path is the one entry
// records. A reinstall that changes only a bundled file leaves SKILL.md the same
// in both trees of an interrupted workspace, so an entry with a tree hash has to
// match on that as well. An entry from before tree hashes records SKILL.md
// alone, and that is all it can be matched on. As in manifestHash, a tree with
// no SKILL.md is a non-match and any other read error is returned.
func treeMatchesEntry(path string, entry LockEntry) (bool, error) {
sum, err := manifestHash(path)
if err != nil || sum != entry.Hash {
return false, err
}
if entry.TreeHash == "" {
return true, nil
}
tree, err := skillTreeHash(path)
if err != nil {
return false, err
}
return tree == entry.TreeHash, nil
}
// manifestHash hashes the SKILL.md under path the same way an install records
// it. A tree with no SKILL.md is a non-match rather than a failure: an install
// killed mid-copy can leave one, and it simply is not the tree the lockfile
@@ -401,10 +346,9 @@ func InfoFromRoots(primaryDir string, roots []string, name string) (SkillInfo, b
if entry, found := lock[skill.Name]; found {
info.Source = entry.Source
info.Hash = entry.Hash
info.TreeHash = entry.TreeHash
}
}
info.HashDrift = skillHashDrift(skill, info.Hash, info.TreeHash)
info.HashDrift = skillHashDrift(skill, info.Hash)
return info, true
}
@@ -612,12 +556,11 @@ func hashContent(data []byte) string {
return "sha256:" + hex.EncodeToString(sum[:])
}
// skillHashDrift reports whether the installed skill no longer matches the
// lockfile hashes recorded at install time: the SKILL.md hash, and the tree
// hash when the entry has one, so an edited script counts as drift too. Missing
// lock hashes never count as drift (skills Zero did not install). A locked
// skill that cannot be read is treated as drifted, not as a clean match.
func skillHashDrift(skill Skill, lockHash string, lockTreeHash string) bool {
// skillHashDrift reports whether the on-disk SKILL.md no longer matches the
// lockfile hash recorded at install time. Missing lock hashes never count as
// drift (agents-only / unlocked skills). A locked skill whose SKILL.md cannot
// be read is treated as drifted — not as a clean match.
func skillHashDrift(skill Skill, lockHash string) bool {
lockHash = strings.TrimSpace(lockHash)
if lockHash == "" {
return false
@@ -626,13 +569,5 @@ func skillHashDrift(skill Skill, lockHash string, lockTreeHash string) bool {
if err != nil {
return true
}
if hashContent(data) != lockHash {
return true
}
lockTreeHash = strings.TrimSpace(lockTreeHash)
if lockTreeHash == "" {
return false
}
treeHash, err := skillTreeHash(filepath.Dir(skill.Path))
return err != nil || treeHash != lockTreeHash
return hashContent(data) != lockHash
}
+6 -147
View File
@@ -364,11 +364,10 @@ func TestInstallGitSourceUsesRunnerAndDoesNotExecuteContent(t *testing.T) {
if _, err := os.Stat(filepath.Join(cloneRoot, "PWNED")); err == nil {
t.Fatalf("install must never execute fetched content")
}
// A script beside SKILL.md is part of the skill (#584), so it is installed,
// and still only copied: running it is up to whoever uses the skill later,
// through the normal tool permissions.
if _, err := os.Stat(filepath.Join(destDir, "remote", "install.sh")); err != nil {
t.Fatalf("the skill's bundled script was not installed: %v", err)
// The fetched executable must not be copied into the skills dir either (skills
// are markdown — only SKILL.md is installed, not arbitrary fetched files).
if _, err := os.Stat(filepath.Join(destDir, "remote", "install.sh")); err == nil {
t.Fatalf("install must not copy fetched executable into the skills dir")
}
}
@@ -441,10 +440,10 @@ func TestInfoReturnsFrontmatterSourceAndHash(t *testing.T) {
func TestSkillHashDriftUnreadableLockedPath(t *testing.T) {
missing := filepath.Join(t.TempDir(), "missing", "SKILL.md")
if !skillHashDrift(Skill{Path: missing}, "sha256:deadbeef", "") {
if !skillHashDrift(Skill{Path: missing}, "sha256:deadbeef") {
t.Fatal("expected drift when locked SKILL.md cannot be read")
}
if skillHashDrift(Skill{Path: missing}, "", "") {
if skillHashDrift(Skill{Path: missing}, "") {
t.Fatal("missing lock hash must not count as drift")
}
}
@@ -862,146 +861,6 @@ func TestRecoveryResolvesEveryInterruptedUpdateStep(t *testing.T) {
}
}
const (
bundledScriptV1 = "print('v1')\n"
bundledScriptV2 = "print('v2')\n"
)
func writeBundledScript(t *testing.T, skillDir string, content string) string {
t.Helper()
path := filepath.Join(skillDir, "scripts", "run.py")
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(content), 0o755); err != nil {
t.Fatal(err)
}
return path
}
// plantInterruptedBundledUpdate is plantInterruptedAlphaUpdate for a reinstall
// that changes only a bundled script, killed after the tree swap: alpha's
// SKILL.md is the same in the live tree and the backup, and only the tree hash
// in the lockfile tells them apart. published says whether the lockfile was
// rewritten for the new tree before the kill (S4) or not (S3).
func plantInterruptedBundledUpdate(t *testing.T, published bool) interruptedUpdate {
t.Helper()
dir := t.TempDir()
source := writeSourceSkill(t, filepath.Join(t.TempDir(), "alpha"), oldAlphaSkill)
writeBundledScript(t, source, bundledScriptV1)
if _, err := Install(context.Background(), InstallOptions{Source: source, Dir: dir}); err != nil {
t.Fatalf("seed alpha: %v", err)
}
gammaSource := writeSourceSkill(t, filepath.Join(t.TempDir(), "gamma"), gammaSkill)
if _, err := Install(context.Background(), InstallOptions{Source: gammaSource, Dir: dir}); err != nil {
t.Fatalf("seed gamma: %v", err)
}
staged, _, err := installtxn.StageDir(dir)
if err != nil {
t.Fatal(err)
}
workspace := filepath.Dir(staged)
writeWorkspaceMarker(t, workspace, "alpha")
writeSourceSkill(t, staged, oldAlphaSkill)
writeBundledScript(t, staged, bundledScriptV2)
target := filepath.Join(dir, "alpha")
if err := os.Rename(target, filepath.Join(workspace, "previous")); err != nil {
t.Fatal(err)
}
if err := os.Rename(staged, target); err != nil {
t.Fatal(err)
}
if published {
treeHash, err := skillTreeHash(target)
if err != nil {
t.Fatal(err)
}
publishLockEntry(t, dir, "alpha", LockEntry{Source: canonicalSource(source), Hash: hashContent([]byte(oldAlphaSkill)), TreeHash: treeHash})
}
return interruptedUpdate{dir: dir, workspace: workspace, oldSource: canonicalSource(source), newSource: canonicalSource(source)}
}
// SKILL.md cannot say which tree of an interrupted bundled-only update the
// lockfile records, since both carry the same one. The tree hash can: before the
// publish it still describes the backup, after it the live tree.
func TestRecoveryTellsABundledOnlyUpdateApartByTreeHash(t *testing.T) {
steps := []struct {
step string
published bool
want string
}{
{step: "S3", published: false, want: bundledScriptV1},
{step: "S4", published: true, want: bundledScriptV2},
}
for _, step := range steps {
for _, driver := range recoveryDrivers {
t.Run(step.step+"/"+driver.name, func(t *testing.T) {
update := plantInterruptedBundledUpdate(t, step.published)
if err := driver.run(t, update.dir); err != nil {
t.Fatalf("%s over a recoverable state: %v", driver.name, err)
}
target := filepath.Join(update.dir, "alpha")
got, err := os.ReadFile(filepath.Join(target, "scripts", "run.py"))
if err != nil || string(got) != step.want {
t.Fatalf("recovered script = %q, %v; want %q", got, err, step.want)
}
lock, err := ReadLock(update.dir)
if err != nil {
t.Fatal(err)
}
tree, err := skillTreeHash(target)
if err != nil {
t.Fatal(err)
}
if lock["alpha"].TreeHash != tree {
t.Errorf("lock tree hash %q does not describe the recovered tree %q", lock["alpha"].TreeHash, tree)
}
if _, err := os.Stat(update.workspace); !os.IsNotExist(err) {
t.Errorf("a resolved workspace must be cleared, got %v", err)
}
})
}
}
}
// A live tree whose files cannot be read cannot be shown to be the one the
// lockfile records, or not to be. Recovery reports it, as it does for a SKILL.md
// it cannot read, and decides nothing until a later pass can read it.
func TestRecoveryReportsALiveTreeItCannotHash(t *testing.T) {
for _, driver := range recoveryDrivers {
t.Run(driver.name, func(t *testing.T) {
update := plantInterruptedBundledUpdate(t, false)
lockBefore := readLockBytes(t, update.dir)
dirBefore := treeSnapshot(t, update.dir)
release := holdUnreadable(t, filepath.Join(update.dir, "alpha", "scripts", "run.py"))
err := driver.run(t, update.dir)
release()
if err == nil || !strings.Contains(err.Error(), "classify interrupted install alpha") {
t.Fatalf("%s over a live tree it cannot hash: %v, want the classification error", driver.name, err)
}
assertSnapshotUnchanged(t, dirBefore, treeSnapshot(t, update.dir))
if got := readLockBytes(t, update.dir); got != lockBefore {
t.Errorf("the lockfile was rewritten by a caller that must have aborted:\n%s", got)
}
// Once the tree can be read, the next pass resolves it: the lockfile
// still describes the backup, so the backup comes back.
if err := driver.run(t, update.dir); err != nil {
t.Fatalf("%s once the tree is readable: %v", driver.name, err)
}
got, err := os.ReadFile(filepath.Join(update.dir, "alpha", "scripts", "run.py"))
if err != nil || string(got) != bundledScriptV1 {
t.Fatalf("recovered script = %q, %v; want %q", got, err, bundledScriptV1)
}
})
}
}
// A recovery that could not be made is not the same as nothing to recover.
// Installing over or reporting the removal of a tree that is still owed a
// restore destroys the only copy of it, so the caller has to stop before it
-30
View File
@@ -1,30 +0,0 @@
//go:build !windows
package skills
import (
"os"
"sync"
"testing"
)
// holdUnreadable takes every permission off path, so reading it fails until
// release puts them back. Cleanup releases it too. Root reads a file whatever
// its mode, so a test that needs the read to fail is skipped there.
func holdUnreadable(t *testing.T, path string) (release func()) {
t.Helper()
if os.Geteuid() == 0 {
t.Skip("root reads a file whatever its mode")
}
info, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
if err := os.Chmod(path, 0); err != nil {
t.Fatal(err)
}
var once sync.Once
release = func() { once.Do(func() { _ = os.Chmod(path, info.Mode().Perm()) }) }
t.Cleanup(release)
return release
}
@@ -1,28 +0,0 @@
//go:build windows
package skills
import (
"sync"
"testing"
"golang.org/x/sys/windows"
)
// holdUnreadable keeps path open with no sharing, so every other open of it
// fails until release runs. Cleanup releases it too.
func holdUnreadable(t *testing.T, path string) (release func()) {
t.Helper()
name, err := windows.UTF16PtrFromString(path)
if err != nil {
t.Fatal(err)
}
handle, err := windows.CreateFile(name, windows.GENERIC_READ, 0, nil, windows.OPEN_EXISTING, windows.FILE_ATTRIBUTE_NORMAL, 0)
if err != nil {
t.Fatalf("hold %s open: %v", path, err)
}
var once sync.Once
release = func() { once.Do(func() { _ = windows.CloseHandle(handle) }) }
t.Cleanup(release)
return release
}
+17 -179
View File
@@ -1,9 +1,7 @@
package specialist
import (
"context"
"encoding/json"
"os"
"strings"
"sync"
@@ -33,41 +31,18 @@ type specialistAccountingInput struct {
Mode string
Background bool
PID int
// LaunchFailed marks a child that never started (e.g. the command failed to
// exec). The internal start/stop accounting still records the attempt, but no
// public specialistStart/specialistStop hook fires for it: there was no
// sub-agent lifecycle to observe. The zero value is "started", so a caller
// that forgets to set it never silently suppresses hooks.
LaunchFailed bool
}
// recordSpecialistStart records the internal attempt accounting. It deliberately
// does NOT dispatch the public specialistStart hook: the hook fires only once
// the child has actually started (dispatchSpecialistStartHook), so a failed
// launch cannot announce a sub-agent that never existed.
func (executor Executor) recordSpecialistStart(input specialistAccountingInput) {
payload := baseSpecialistPayload(input)
_, _ = appendSpecialistSessionEvent(executor.SessionStore, input.ParentSessionID, sessions.EventSpecialistStart, payload)
}
// dispatchSpecialistStartHook publishes the specialistStart hook for a child
// whose startup was confirmed. Callers pass the same accounting input used for
// the attempt record so payload and matcher subject stay identical.
func (executor Executor) dispatchSpecialistStartHook(input specialistAccountingInput) {
if input.LaunchFailed || executor.LifecycleHooks == nil || executor.LifecycleHooks.Dispatch == nil {
return
}
executor.dispatchLifecycleHook("specialistStart", input, baseSpecialistPayload(input))
}
func (executor Executor) recordSpecialistStop(input specialistAccountingInput, summary StreamResult, status string, exitCode int, runErr error, usageRolledUp bool) {
store := accountingStore(executor.SessionStore)
accountingMu.Lock()
defer accountingMu.Unlock()
payload := baseSpecialistPayload(input)
// childStarted is the durable eligibility flag for the public stop hook: a
// later reaper (TaskOutput poll, recovered TaskStop, onExit in another
// process) derives whether the terminal transition deserves a hook from the
// recorded stop event, since it cannot know whether the child ever started.
payload["childStarted"] = !input.LaunchFailed
if summary.RunID != "" {
payload["runId"] = summary.RunID
}
@@ -80,127 +55,9 @@ func (executor Executor) recordSpecialistStop(input specialistAccountingInput, s
if len(summary.Errors) > 0 {
payload["errors"] = append([]string(nil), summary.Errors...)
}
// Hold accountingMu only for the deduped append so a slow lifecycle hook cannot
// stall concurrent specialist stop/usage accounting.
accountingMu.Lock()
// Atomic check+append under the session lock so a concurrent stop path cannot
// also pass the existence check and write a duplicate stop event.
_, _ = appendSpecialistEventOnce(store, input.ParentSessionID, sessions.EventSpecialistStop, payload, input.ChildSessionID, summary.RunID)
accountingMu.Unlock()
executor.dispatchSpecialistStopHook(store, input, payload, summary.RunID)
}
// specialistStopHookEvent durably records that the specialistStop hook dispatch
// decision for one child/run was settled. It lives in the parent session's event
// log - the same cross-process, lock-guarded ledger the accounting dedup uses -
// so a later Zero process reaping the same task (fresh LifecycleHooks bridge)
// does not re-run the hook. This is internal bookkeeping, not a lifecycle event.
const specialistStopHookEvent sessions.EventType = "specialist_stop_hook"
// dispatchSpecialistStopHook runs the specialistStop hook at most once per
// child/run. The claim follows the accounting equivalence rules (an empty runId
// is a catch-all for the same child), so concurrent or sequential finishers -
// Task.onExit, a TaskOutput poll in another process, a recovered TaskStop - share
// one durable claim. Swarm members have no parent session (nothing durable to
// key on), so they fall back to this bridge's in-process claim, which mirrors
// the same run-ID equivalence.
//
// Hooks are external side effects, so on a store error the durable claim cannot
// be established; the hook then falls back to the in-process claim rather than
// running unchecked, keeping at-most-once within this process. A won durable
// claim also takes the in-process claim, so a transient store error before or
// after it on the same bridge cannot dispatch the same stop a second time.
func (executor Executor) dispatchSpecialistStopHook(store *sessions.Store, input specialistAccountingInput, payload map[string]any, runID string) {
hooks := executor.LifecycleHooks
if hooks == nil || hooks.Dispatch == nil {
return
}
started := !input.LaunchFailed
parentSessionID := strings.TrimSpace(input.ParentSessionID)
if parentSessionID == "" || !sessions.ValidSessionID(parentSessionID) {
if started && hooks.claimInProcessStopDispatch(input.ChildSessionID, runID) {
executor.dispatchLifecycleHook("specialistStop", input, payload)
}
return
}
if recorded, ok := recordedStopChildStarted(store, parentSessionID, input.ChildSessionID, runID); ok {
started = recorded
}
claimed, err := appendSpecialistEventOnce(store, parentSessionID, specialistStopHookEvent, stopHookClaimPayload(input, runID, started), input.ChildSessionID, runID)
if err != nil {
// The durable ledger is unavailable. Keep the hook advisory but bounded to
// a single dispatch in this process rather than dropping it or risking an
// unbounded duplicate.
if started && hooks.claimInProcessStopDispatch(input.ChildSessionID, runID) {
executor.dispatchLifecycleHook("specialistStop", input, payload)
}
return
}
if !claimed {
return
}
// Take the in-process claim even for an ineligible stop: this bridge has
// settled the decision, and a later fallback must not re-decide it.
if hooks.claimInProcessStopDispatch(input.ChildSessionID, runID) && started {
executor.dispatchLifecycleHook("specialistStop", input, payload)
}
}
// recordedStopChildStarted reads the first recorded stop event matching the
// child/run equivalence rules and reports its childStarted eligibility flag.
// ok is false when no stop event exists yet or it predates the flag, in which
// case the caller keeps its locally known eligibility.
func recordedStopChildStarted(store *sessions.Store, parentSessionID, childSessionID, runID string) (started bool, ok bool) {
store = accountingStore(store)
childSessionID = strings.TrimSpace(childSessionID)
if childSessionID == "" {
return false, false
}
runID = strings.TrimSpace(runID)
events, err := store.ReadEvents(parentSessionID)
if err != nil {
return false, false
}
for _, event := range events {
if event.Type != sessions.EventSpecialistStop {
continue
}
var payload map[string]any
if err := json.Unmarshal(event.Payload, &payload); err != nil {
continue
}
if !specialistPayloadMatches(payload, childSessionID, runID) {
continue
}
if started, ok := payload["childStarted"].(bool); ok {
return started, true
}
return false, false
}
return false, false
}
func stopHookClaimPayload(input specialistAccountingInput, runID string, started bool) map[string]any {
payload := baseSpecialistPayload(input)
payload["hook"] = "specialistStop"
// childStarted is the resolved eligibility (locally known or read back from
// the durable stop event); dispatched records that a hook was run for it.
payload["childStarted"] = started
payload["dispatched"] = started
if runID = strings.TrimSpace(runID); runID != "" {
payload["runId"] = runID
}
return payload
}
func (executor Executor) dispatchLifecycleHook(event string, input specialistAccountingInput, payload map[string]any) {
if executor.LifecycleHooks == nil || executor.LifecycleHooks.Dispatch == nil {
return
}
hookPayload := make(map[string]any, len(payload)+1)
for key, value := range payload {
hookPayload[key] = value
}
hookPayload["event"] = event
executor.LifecycleHooks.Dispatch(context.Background(), event, strings.TrimSpace(input.SpecialistName), hookPayload)
}
func (executor Executor) rollUpSpecialistUsage(input specialistAccountingInput, summary StreamResult) bool {
@@ -225,18 +82,6 @@ func (executor Executor) recordBackgroundTaskAccounting(task background.Task, su
executor.recordSpecialistStop(input, summary, string(task.Status), task.ExitCode, nil, rolledUp)
}
// taskStreamSummary builds the stream summary for a finished background task
// from its output file. Every reaper (Task.onExit, TaskOutput, TaskStop) derives
// its runId this way, so the durable stop event and stop-hook claim agree on the
// run identity regardless of which path observed the task first.
func taskStreamSummary(task background.Task) StreamResult {
summary := StreamResult{ExitCode: task.ExitCode}
if data, err := os.ReadFile(task.OutputFile); err == nil {
summary, _ = summarizeTaskData(string(data), task.ExitCode)
}
return summary
}
func appendSpecialistUsageRollup(store *sessions.Store, input specialistAccountingInput, summary StreamResult) (bool, error) {
if !summary.Usage.HasUsage() {
return false, nil
@@ -287,7 +132,19 @@ func specialistEventMatcher(eventType sessions.EventType, childSessionID, runID
if err := json.Unmarshal(event.Payload, &payload); err != nil {
continue
}
if specialistPayloadMatches(payload, childSessionID, runID) {
if specialistPayloadString(payload, "source") != specialistAccountingSource {
continue
}
if specialistPayloadString(payload, "childSessionId") != childSessionID && specialistPayloadString(payload, "taskId") != childSessionID {
continue
}
// An already-recorded event with NO runId is a catch-all for this child
// (e.g. the immediate stop written when a PID couldn't be registered) and
// must match a later event that does carry a runId — otherwise the same
// child gets two stop/usage events. We match when: querying without a runId,
// the existing event has none, or the runIds are equal.
existingRunID := specialistPayloadString(payload, "runId")
if runID == "" || existingRunID == "" || existingRunID == runID {
return true
}
}
@@ -295,25 +152,6 @@ func specialistEventMatcher(eventType sessions.EventType, childSessionID, runID
}
}
// specialistPayloadMatches applies the shared "same specialist stop" identity to
// one event payload. The accounting dedup (specialistEventMatcher) and the
// durable stop-hook claim both use it, so the audit event and the hook side
// effect can never disagree about what counts as a repeat. An already-recorded
// event with NO runId is a catch-all for this child (e.g. the immediate stop
// written when a PID couldn't be registered) and matches a later event that
// carries a runId - otherwise the same child gets two stop/usage events. A query
// without a runId likewise matches any recorded run.
func specialistPayloadMatches(payload map[string]any, childSessionID, runID string) bool {
if specialistPayloadString(payload, "source") != specialistAccountingSource {
return false
}
if specialistPayloadString(payload, "childSessionId") != childSessionID && specialistPayloadString(payload, "taskId") != childSessionID {
return false
}
existingRunID := specialistPayloadString(payload, "runId")
return runID == "" || existingRunID == "" || existingRunID == runID
}
// appendSpecialistEventOnce atomically records a "record once" specialist event:
// it appends payload only if no matching event already exists, under the session
// store's lock so concurrent stop/usage callers (even cross-process) cannot both
+2 -836
View File
@@ -3,15 +3,10 @@ package specialist
import (
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"reflect"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/Twigpine/zero/internal/background"
"github.com/Twigpine/zero/internal/sessions"
@@ -26,21 +21,10 @@ func TestExecutorRecordsForegroundLifecycleAndUsageRollup(t *testing.T) {
t.Fatalf("Create parent returned error: %v", err)
}
zero := 0
var hookEvents []string
var hookNames []string
executor := Executor{
BinaryPath: "/usr/local/bin/zero",
SessionStore: store,
NewSessionID: func() (string, error) { return "child_task", nil },
LifecycleHooks: &LifecycleHooks{
Dispatch: func(_ context.Context, event string, specialistName string, payload map[string]any) {
hookEvents = append(hookEvents, event)
hookNames = append(hookNames, specialistName)
if payload["event"] != event {
t.Errorf("payload event = %#v, want %q", payload["event"], event)
}
},
},
Load: func(LoadOptions) (LoadResult, error) {
return LoadResult{Specialists: []Manifest{{
Metadata: Metadata{Name: "worker", Description: "Does focused work"},
@@ -75,18 +59,12 @@ func TestExecutorRecordsForegroundLifecycleAndUsageRollup(t *testing.T) {
if result.Result.Status != tools.StatusOK {
t.Fatalf("Run status = %s, output=%s", result.Result.Status, result.Result.Output)
}
if got, want := strings.Join(hookEvents, ","), "specialistStart,specialistStop"; got != want {
t.Fatalf("lifecycle hooks = %q, want %q", got, want)
}
if got, want := strings.Join(hookNames, ","), "worker,worker"; got != want {
t.Fatalf("lifecycle specialist names = %q, want %q", got, want)
}
events, err := store.ReadEvents(parent.SessionID)
if err != nil {
t.Fatalf("ReadEvents returned error: %v", err)
}
if got, want := eventTypes(events), []sessions.EventType{sessions.EventSpecialistStart, sessions.EventUsage, sessions.EventSpecialistStop, specialistStopHookEvent}; strings.Join(eventTypesString(got), ",") != strings.Join(eventTypesString(want), ",") {
if got, want := eventTypes(events), []sessions.EventType{sessions.EventSpecialistStart, sessions.EventUsage, sessions.EventSpecialistStop}; strings.Join(eventTypesString(got), ",") != strings.Join(eventTypesString(want), ",") {
t.Fatalf("event types = %#v, want %#v", got, want)
}
startPayload := eventPayload(t, events[0])
@@ -110,13 +88,6 @@ func TestExecutorRecordsForegroundLifecycleAndUsageRollup(t *testing.T) {
requirePayloadString(t, stopPayload, "status", "success")
requirePayloadInt(t, stopPayload, "exitCode", 0)
requirePayloadBool(t, stopPayload, "usageRolledUp", true)
requirePayloadBool(t, stopPayload, "childStarted", true)
claimPayload := eventPayload(t, events[3])
requirePayloadString(t, claimPayload, "hook", "specialistStop")
requirePayloadString(t, claimPayload, "runId", "run_1")
requirePayloadBool(t, claimPayload, "dispatched", true)
requirePayloadBool(t, claimPayload, "childStarted", true)
}
func TestExecutorRecordsStartedChildErrorExitCode(t *testing.T) {
@@ -315,17 +286,7 @@ func TestRecordSpecialistStopDedupesUnderConcurrency(t *testing.T) {
if err != nil {
t.Fatalf("Create parent returned error: %v", err)
}
var hookStops atomic.Int32
executor := Executor{
SessionStore: store,
LifecycleHooks: &LifecycleHooks{
Dispatch: func(_ context.Context, event string, _ string, _ map[string]any) {
if event == "specialistStop" {
hookStops.Add(1)
}
},
},
}
executor := Executor{SessionStore: store}
input := specialistAccountingInput{
ParentSessionID: parent.SessionID,
ChildSessionID: "child_task",
@@ -360,799 +321,4 @@ func TestRecordSpecialistStopDedupesUnderConcurrency(t *testing.T) {
if stops != 1 {
t.Fatalf("expected exactly 1 stop event under concurrency, got %d", stops)
}
if got := hookStops.Load(); got != 1 {
t.Fatalf("specialistStop hooks = %d, want 1 under concurrency", got)
}
}
func TestRecordSpecialistStopFiresWithoutParentSession(t *testing.T) {
var hookStops atomic.Int32
executor := Executor{
LifecycleHooks: &LifecycleHooks{
Dispatch: func(_ context.Context, event string, name string, _ map[string]any) {
if event == "specialistStop" {
hookStops.Add(1)
if name != "subagent" {
t.Errorf("specialist name = %q, want subagent", name)
}
}
},
},
}
executor.recordSpecialistStop(specialistAccountingInput{
// Swarm members omit ParentSessionID; append never succeeds.
ChildSessionID: "member_child",
SpecialistName: "subagent",
Mode: "foreground",
}, StreamResult{RunID: "run_swarm"}, "success", 0, nil, false)
if got := hookStops.Load(); got != 1 {
t.Fatalf("specialistStop hooks = %d, want 1 with empty parent", got)
}
// Second observation of the same child/run must not re-fire.
executor.recordSpecialistStop(specialistAccountingInput{
ChildSessionID: "member_child",
SpecialistName: "subagent",
Mode: "foreground",
}, StreamResult{RunID: "run_swarm"}, "success", 0, nil, false)
if got := hookStops.Load(); got != 1 {
t.Fatalf("specialistStop hooks = %d after duplicate, want 1", got)
}
}
func TestRecordSpecialistStopHookLessWinnerDoesNotSuppressLaterHooks(t *testing.T) {
store := sessions.NewStore(sessions.StoreOptions{RootDir: t.TempDir()})
parent, err := store.Create(sessions.CreateInput{SessionID: "parent_session"})
if err != nil {
t.Fatalf("Create parent returned error: %v", err)
}
input := specialistAccountingInput{
ParentSessionID: parent.SessionID,
ChildSessionID: "child_task",
SpecialistName: "worker",
Mode: "background",
Background: true,
}
summary := StreamResult{RunID: "run_1"}
// Hook-less TaskOutput path wins the session append race.
Executor{SessionStore: store}.recordSpecialistStop(input, summary, "success", 0, nil, true)
var hookStops atomic.Int32
hooks := &LifecycleHooks{
Dispatch: func(_ context.Context, event string, _ string, _ map[string]any) {
if event == "specialistStop" {
hookStops.Add(1)
}
},
}
Executor{SessionStore: store, LifecycleHooks: hooks}.recordSpecialistStop(input, summary, "success", 0, nil, true)
if got := hookStops.Load(); got != 1 {
t.Fatalf("specialistStop hooks = %d, want 1 after hook-less winner", got)
}
}
// A durable stop-hook claim must survive a process boundary: a later Zero
// process (fresh LifecycleHooks bridge over the same session store) reaping the
// same task must not re-run the hook even though its session-event append is
// rejected as a duplicate.
func TestRecordSpecialistStopHookClaimDurableAcrossBridges(t *testing.T) {
store := sessions.NewStore(sessions.StoreOptions{RootDir: t.TempDir()})
parent, err := store.Create(sessions.CreateInput{SessionID: "parent_session"})
if err != nil {
t.Fatalf("Create parent returned error: %v", err)
}
input := specialistAccountingInput{
ParentSessionID: parent.SessionID,
ChildSessionID: "child_task",
SpecialistName: "worker",
Mode: "background",
Background: true,
}
summary := StreamResult{RunID: "run_1"}
var firstBridge atomic.Int32
Executor{SessionStore: store, LifecycleHooks: stopCounterHooks(&firstBridge)}.recordSpecialistStop(input, summary, "success", 0, nil, true)
if got := firstBridge.Load(); got != 1 {
t.Fatalf("first bridge stop hooks = %d, want 1", got)
}
var secondBridge atomic.Int32
Executor{SessionStore: store, LifecycleHooks: stopCounterHooks(&secondBridge)}.recordSpecialistStop(input, summary, "success", 0, nil, true)
if got := secondBridge.Load(); got != 0 {
t.Fatalf("second bridge stop hooks = %d, want 0: the durable claim must suppress a re-dispatch", got)
}
events, err := store.ReadEvents(parent.SessionID)
if err != nil {
t.Fatalf("ReadEvents returned error: %v", err)
}
if claims := eventsOfType(events, specialistStopHookEvent); len(claims) != 1 {
t.Fatalf("stop-hook claim events = %d, want 1", len(claims))
}
if stops := eventsOfType(events, sessions.EventSpecialistStop); len(stops) != 1 {
t.Fatalf("stop events = %d, want 1", len(stops))
}
}
// The durable claim must follow the accounting run-ID equivalence: an empty
// runId is a catch-all in both directions, while genuinely distinct runs stay
// distinct stop transitions.
func TestRecordSpecialistStopHookClaimRunIDEquivalence(t *testing.T) {
for _, tc := range []struct {
name string
first string
second string
want int32
}{
{name: "empty run id is a catch-all for a later run", first: "", second: "run_1", want: 1},
{name: "a recorded run covers a later empty query", first: "run_1", second: "", want: 1},
{name: "distinct runs are distinct stops", first: "run_1", second: "run_2", want: 2},
} {
t.Run(tc.name, func(t *testing.T) {
store := sessions.NewStore(sessions.StoreOptions{RootDir: t.TempDir()})
parent, err := store.Create(sessions.CreateInput{SessionID: "parent_session"})
if err != nil {
t.Fatalf("Create parent returned error: %v", err)
}
var stops atomic.Int32
executor := Executor{SessionStore: store, LifecycleHooks: stopCounterHooks(&stops)}
input := specialistAccountingInput{
ParentSessionID: parent.SessionID,
ChildSessionID: "child_task",
SpecialistName: "worker",
Mode: "background",
Background: true,
}
executor.recordSpecialistStop(input, StreamResult{RunID: tc.first}, "success", 0, nil, false)
executor.recordSpecialistStop(input, StreamResult{RunID: tc.second}, "success", 0, nil, false)
if got := stops.Load(); got != tc.want {
t.Fatalf("stop hooks = %d, want %d", got, tc.want)
}
})
}
}
// The in-process fallback (swarm members have no parent session) applies the
// same run-ID equivalence, so a later observation carrying a runId cannot
// re-fire a stop that was already claimed without one.
func TestRecordSpecialistStopInProcessClaimRunIDEquivalence(t *testing.T) {
var stops atomic.Int32
executor := Executor{LifecycleHooks: stopCounterHooks(&stops)}
input := specialistAccountingInput{ChildSessionID: "member_child", SpecialistName: "subagent", Mode: "foreground"}
executor.recordSpecialistStop(input, StreamResult{}, "success", 0, nil, false)
executor.recordSpecialistStop(input, StreamResult{RunID: "run_later"}, "success", 0, nil, false)
if got := stops.Load(); got != 1 {
t.Fatalf("stop hooks = %d, want 1", got)
}
}
// When the durable ledger is briefly unavailable, the in-process fallback and a
// durable claim on the same bridge must make one dispatch decision between
// them, whichever order they happen in.
func TestRecordSpecialistStopDurableAndFallbackClaimsShareOneDispatch(t *testing.T) {
for _, tc := range []struct {
name string
fallbackFirst bool
}{
{name: "fallback then durable", fallbackFirst: true},
{name: "durable then fallback", fallbackFirst: false},
} {
t.Run(tc.name, func(t *testing.T) {
store := sessions.NewStore(sessions.StoreOptions{RootDir: t.TempDir()})
parent, err := store.Create(sessions.CreateInput{SessionID: "parent_session"})
if err != nil {
t.Fatalf("Create parent returned error: %v", err)
}
eventsFile := filepath.Join(store.RootDir, parent.SessionID, sessions.EventsFile)
asideFile := eventsFile + ".aside"
// A directory where the event log lives makes every read and append
// fail on all platforms, the way a transient store error would.
breakLedger := func() {
t.Helper()
if err := os.Rename(eventsFile, asideFile); err != nil && !errors.Is(err, os.ErrNotExist) {
t.Fatalf("move event log aside: %v", err)
}
if err := os.Mkdir(eventsFile, 0o700); err != nil {
t.Fatalf("block event log: %v", err)
}
}
restoreLedger := func() {
t.Helper()
if err := os.Remove(eventsFile); err != nil {
t.Fatalf("unblock event log: %v", err)
}
if err := os.Rename(asideFile, eventsFile); err != nil && !errors.Is(err, os.ErrNotExist) {
t.Fatalf("restore event log: %v", err)
}
}
var stops atomic.Int32
executor := Executor{SessionStore: store, LifecycleHooks: stopCounterHooks(&stops)}
input := specialistAccountingInput{
ParentSessionID: parent.SessionID,
ChildSessionID: "child_task",
SpecialistName: "worker",
Mode: "background",
Background: true,
}
summary := StreamResult{RunID: "run_1"}
if tc.fallbackFirst {
breakLedger()
executor.recordSpecialistStop(input, summary, "success", 0, nil, false)
restoreLedger()
executor.recordSpecialistStop(input, summary, "success", 0, nil, false)
} else {
executor.recordSpecialistStop(input, summary, "success", 0, nil, false)
breakLedger()
executor.recordSpecialistStop(input, summary, "success", 0, nil, false)
restoreLedger()
}
if got := stops.Load(); got != 1 {
t.Fatalf("stop hooks = %d, want 1 across durable and fallback claims", got)
}
})
}
}
// A child that never started must not publish public lifecycle hooks, while the
// internal attempt/stop accounting is retained and durably marks the stop as
// ineligible so a later reaper cannot fire the hook either.
func TestExecutorSkipsHooksWhenChildNeverStarts(t *testing.T) {
store := sessions.NewStore(sessions.StoreOptions{RootDir: t.TempDir()})
parent, err := store.Create(sessions.CreateInput{SessionID: "parent_session"})
if err != nil {
t.Fatalf("Create parent returned error: %v", err)
}
var hooks []string
executor := Executor{
BinaryPath: "/usr/local/bin/zero",
SessionStore: store,
NewSessionID: func() (string, error) { return "child_task", nil },
LifecycleHooks: &LifecycleHooks{
Dispatch: func(_ context.Context, event string, _ string, _ map[string]any) {
hooks = append(hooks, event)
},
},
Load: func(LoadOptions) (LoadResult, error) {
return LoadResult{Specialists: []Manifest{{
Metadata: Metadata{Name: "worker", Description: "Does focused work"},
SystemPrompt: "Work carefully.",
ResolvedTools: []string{"read_file"},
}}}, nil
},
RunChild: func(context.Context, string, []string, func(streamjson.Event)) (ChildRunResult, error) {
return ChildRunResult{}, errors.New("exec: no such file or directory")
},
}
if _, err := executor.Run(context.Background(), TaskParameters{Name: "worker", Prompt: "inspect auth"}, TaskRunOptions{ParentSessionID: parent.SessionID}); err == nil {
t.Fatal("Run returned nil error for a failed child start")
}
if len(hooks) != 0 {
t.Fatalf("public lifecycle hooks fired for a child that never started: %#v", hooks)
}
events, err := store.ReadEvents(parent.SessionID)
if err != nil {
t.Fatalf("ReadEvents returned error: %v", err)
}
if starts := eventsOfType(events, sessions.EventSpecialistStart); len(starts) != 1 {
t.Fatalf("attempt start events = %d, want 1", len(starts))
}
stopEvents := eventsOfType(events, sessions.EventSpecialistStop)
if len(stopEvents) != 1 {
t.Fatalf("stop events = %#v", events)
}
requirePayloadBool(t, eventPayload(t, stopEvents[0]), "childStarted", false)
claims := eventsOfType(events, specialistStopHookEvent)
if len(claims) != 1 {
t.Fatalf("stop-hook claim events = %d, want 1", len(claims))
}
requirePayloadBool(t, eventPayload(t, claims[0]), "dispatched", false)
}
// A never-started background child leaves an errored task behind; a later
// TaskOutput reaper must not fire the stop hook from the recorded eligibility.
func TestBackgroundLaunchFailureDoesNotFireStopHookOnReap(t *testing.T) {
store := sessions.NewStore(sessions.StoreOptions{RootDir: t.TempDir()})
parent, err := store.Create(sessions.CreateInput{SessionID: "parent_session"})
if err != nil {
t.Fatalf("Create parent returned error: %v", err)
}
manager, err := background.NewManager(t.TempDir())
if err != nil {
t.Fatalf("NewManager returned error: %v", err)
}
var hooks []string
executor := Executor{
BinaryPath: "/usr/local/bin/zero",
SessionStore: store,
BackgroundManager: manager,
NewSessionID: func() (string, error) { return "child_task", nil },
LifecycleHooks: &LifecycleHooks{
Dispatch: func(_ context.Context, event string, _ string, _ map[string]any) {
hooks = append(hooks, event)
},
},
Load: func(LoadOptions) (LoadResult, error) {
return LoadResult{Specialists: []Manifest{{
Metadata: Metadata{Name: "worker", Description: "Does focused work"},
SystemPrompt: "Work carefully.",
ResolvedTools: []string{"read_file"},
}}}, nil
},
LaunchBackground: func(string, []string, string, func(int)) (int, error) {
return 0, errors.New("launch failed")
},
}
_, err = executor.Run(context.Background(), TaskParameters{Name: "worker", Prompt: "inspect auth", RunInBackground: true}, TaskRunOptions{ParentSessionID: parent.SessionID})
if err == nil {
t.Fatal("Run returned nil error for a failed background launch")
}
if len(hooks) != 0 {
t.Fatalf("public lifecycle hooks fired for a child that never started: %#v", hooks)
}
// A separate process reaping the errored task must stay silent too.
var reapHooks atomic.Int32
tool := NewOutputTool(manager)
tool.SessionStore = store
tool.LifecycleHooks = stopCounterHooks(&reapHooks)
if result := tool.Run(context.Background(), map[string]any{"task_id": "child_task"}); result.Status != tools.StatusOK {
t.Fatalf("TaskOutput status = %s output=%s", result.Status, result.Output)
}
if got := reapHooks.Load(); got != 0 {
t.Fatalf("stop hooks from reaping a never-started task = %d, want 0", got)
}
events, err := store.ReadEvents(parent.SessionID)
if err != nil {
t.Fatalf("ReadEvents returned error: %v", err)
}
if stopEvents := eventsOfType(events, sessions.EventSpecialistStop); len(stopEvents) != 1 {
t.Fatalf("stop events = %d, want 1", len(stopEvents))
}
if claims := eventsOfType(events, specialistStopHookEvent); len(claims) != 1 {
t.Fatalf("stop-hook claim events = %d, want 1", len(claims))
}
}
// A background child that exits before the launcher returns must not run its
// terminal accounting - and therefore must not fire the specialistStop hook -
// until the start decision has completed. Otherwise a fast-exiting child
// publishes specialistStop before specialistStart, and the recorded stop can
// miss the pid that SetPID has not stored yet. The synchronous launcher below
// also pins the deadlock requirement: the exit callback may not wait inline,
// because onExit runs before the launcher (and the start decision) can return.
func TestRunBackgroundDefersExitUntilStartDecisionCompletes(t *testing.T) {
store := sessions.NewStore(sessions.StoreOptions{RootDir: t.TempDir()})
parent, err := store.Create(sessions.CreateInput{SessionID: "parent_session"})
if err != nil {
t.Fatalf("Create parent returned error: %v", err)
}
manager, err := background.NewManager(t.TempDir())
if err != nil {
t.Fatalf("NewManager returned error: %v", err)
}
var hookMu sync.Mutex
var hookEvents []string
stopDispatched := make(chan struct{}, 1)
executor := Executor{
BinaryPath: "/usr/local/bin/zero",
SessionStore: store,
BackgroundManager: manager,
NewSessionID: func() (string, error) { return "child_task", nil },
LifecycleHooks: &LifecycleHooks{
Dispatch: func(_ context.Context, event string, _ string, _ map[string]any) {
hookMu.Lock()
hookEvents = append(hookEvents, event)
hookMu.Unlock()
if event == "specialistStop" {
select {
case stopDispatched <- struct{}{}:
default:
}
}
},
},
Load: func(LoadOptions) (LoadResult, error) {
return LoadResult{Specialists: []Manifest{{
Metadata: Metadata{Name: "worker", Description: "Does focused work"},
SystemPrompt: "Work carefully.",
ResolvedTools: []string{"read_file"},
}}}, nil
},
// The child exits before the launcher returns, so the exit callback runs
// synchronously - exactly the ordering hazard this test guards.
LaunchBackground: func(_ string, _ []string, _ string, onExit func(exitCode int)) (int, error) {
onExit(0)
return 1234, nil
},
}
runErr := make(chan error, 1)
go func() {
_, err := executor.Run(context.Background(), TaskParameters{
Name: "worker",
Prompt: "inspect auth",
RunInBackground: true,
}, TaskRunOptions{ParentSessionID: parent.SessionID})
runErr <- err
}()
select {
case err := <-runErr:
if err != nil {
t.Fatalf("Run returned error: %v", err)
}
case <-time.After(10 * time.Second):
t.Fatal("synchronous launcher deadlocked the background start decision")
}
// Run publishes specialistStart before it returns; the deferred exit handling
// publishes specialistStop afterwards.
select {
case <-stopDispatched:
case <-time.After(10 * time.Second):
t.Fatal("deferred exit accounting never dispatched specialistStop")
}
hookMu.Lock()
gotHooks := strings.Join(hookEvents, ",")
hookMu.Unlock()
if gotHooks != "specialistStart,specialistStop" {
t.Fatalf("lifecycle hooks = %q, want start before stop", gotHooks)
}
events, err := store.ReadEvents(parent.SessionID)
if err != nil {
t.Fatalf("ReadEvents returned error: %v", err)
}
stopEvents := eventsOfType(events, sessions.EventSpecialistStop)
if len(stopEvents) != 1 {
t.Fatalf("stop events = %#v", events)
}
// The deferred stop must observe the task after SetPID; a stop recorded
// before the start decision completes would omit the pid.
requirePayloadInt(t, eventPayload(t, stopEvents[0]), "pid", 1234)
}
// When SetPID fails after the child started, Run kills the unrecorded child. Only
// a successful kill is a terminal stop. A failed kill may leave the child
// running, so Run must keep the task running (no reaper may claim it) and must
// not record the stop or take the one-shot specialistStop claim; the launcher's
// exit callback does both when the child really ends.
func TestRunBackgroundSetPIDFailureRecordsStopOnlyAfterTermination(t *testing.T) {
for _, tc := range []struct {
name string
killErr error
}{
{name: "kill succeeds"},
{name: "kill fails", killErr: errors.New("operation not permitted")},
} {
t.Run(tc.name, func(t *testing.T) {
store := sessions.NewStore(sessions.StoreOptions{RootDir: t.TempDir()})
parent, err := store.Create(sessions.CreateInput{SessionID: "parent_session"})
if err != nil {
t.Fatalf("Create parent returned error: %v", err)
}
manager, err := background.NewManager(t.TempDir())
if err != nil {
t.Fatalf("NewManager returned error: %v", err)
}
var killed []int
originalTerminate := terminateOrphanedProcess
terminateOrphanedProcess = func(pid int) error {
killed = append(killed, pid)
return tc.killErr
}
t.Cleanup(func() { terminateOrphanedProcess = originalTerminate })
var hookMu sync.Mutex
var hookEvents []string
hooksSoFar := func() string {
hookMu.Lock()
defer hookMu.Unlock()
return strings.Join(hookEvents, ",")
}
stopDispatched := make(chan struct{}, 1)
metadataFile := filepath.Join(manager.RootDir(), "child_task.json")
// The child reads its prompt from this file, so it must outlive any
// child that may still be running.
promptFile := filepath.Join(t.TempDir(), "prompt.md")
promptExists := func() bool {
_, err := os.Stat(promptFile)
return err == nil
}
var onExitChild func(exitCode int)
executor := Executor{
BinaryPath: "/usr/local/bin/zero",
SessionStore: store,
BackgroundManager: manager,
NewSessionID: func() (string, error) { return "child_task", nil },
PromptFileMaxSize: 1,
WritePromptFile: func(prompt string) (string, error) {
return promptFile, os.WriteFile(promptFile, []byte(prompt), 0o600)
},
LifecycleHooks: &LifecycleHooks{
Dispatch: func(_ context.Context, event string, _ string, _ map[string]any) {
hookMu.Lock()
hookEvents = append(hookEvents, event)
hookMu.Unlock()
if event == "specialistStop" {
select {
case stopDispatched <- struct{}{}:
default:
}
}
},
},
Load: func(LoadOptions) (LoadResult, error) {
return LoadResult{Specialists: []Manifest{{
Metadata: Metadata{Name: "worker", Description: "Does focused work"},
SystemPrompt: "Work carefully.",
ResolvedTools: []string{"read_file"},
}}}, nil
},
LaunchBackground: func(_ string, _ []string, _ string, onExit func(exitCode int)) (int, error) {
onExitChild = onExit
// The child has started. Put a non-empty directory where the
// task metadata lives so SetPID's atomic replace fails.
if err := os.Remove(metadataFile); err != nil {
return 0, err
}
if err := os.MkdirAll(filepath.Join(metadataFile, "blocker"), 0o700); err != nil {
return 0, err
}
return 1234, nil
},
}
_, runErr := executor.Run(context.Background(), TaskParameters{
Name: "worker",
Prompt: "inspect auth",
RunInBackground: true,
}, TaskRunOptions{ParentSessionID: parent.SessionID})
if runErr == nil {
t.Fatal("Run returned nil error after SetPID failed")
}
if !reflect.DeepEqual(killed, []int{1234}) {
t.Fatalf("terminated pids = %v, want [1234]", killed)
}
readStops := func() ([]sessions.Event, []sessions.Event) {
t.Helper()
events, err := store.ReadEvents(parent.SessionID)
if err != nil {
t.Fatalf("ReadEvents returned error: %v", err)
}
return eventsOfType(events, sessions.EventSpecialistStop), eventsOfType(events, specialistStopHookEvent)
}
if tc.killErr == nil {
// The child is gone, so the stop is terminal and recorded now.
stops, claims := readStops()
if len(stops) != 1 || len(claims) != 1 {
t.Fatalf("after successful kill: stop events = %d, claims = %d, want 1 and 1", len(stops), len(claims))
}
if got := hooksSoFar(); got != "specialistStart,specialistStop" {
t.Fatalf("lifecycle hooks = %q, want specialistStart,specialistStop", got)
}
if promptExists() {
t.Fatal("prompt file survived after the orphan was killed")
}
return
}
if !strings.Contains(runErr.Error(), "terminate orphaned pid 1234") {
t.Fatalf("Run error = %v, want the failed kill surfaced", runErr)
}
if !promptExists() {
t.Fatal("prompt file removed while the child may still be running")
}
if got := hooksSoFar(); got != "specialistStart" {
t.Fatalf("lifecycle hooks after failed kill = %q, want only specialistStart", got)
}
if stops, claims := readStops(); len(stops) != 0 || len(claims) != 0 {
t.Fatalf("after failed kill: stop events = %d, claims = %d, want none while the child may run", len(stops), len(claims))
}
task, ok := manager.Get("child_task")
if !ok || task.Status != background.StatusRunning {
t.Fatalf("task after failed kill = %#v (found %v), want still running", task, ok)
}
// A TaskOutput or TaskStop reap in the meantime must not claim it.
executor.recordBackgroundTaskAccounting(task, taskStreamSummary(task))
if stops, claims := readStops(); len(stops) != 0 || len(claims) != 0 {
t.Fatalf("reap of running task recorded stop events = %d, claims = %d", len(stops), len(claims))
}
// The surviving child finally exits: its exit callback records the stop
// and fires the hook exactly once.
if err := os.RemoveAll(metadataFile); err != nil {
t.Fatalf("RemoveAll metadata blocker returned error: %v", err)
}
onExitChild(1)
select {
case <-stopDispatched:
case <-time.After(10 * time.Second):
t.Fatal("exit callback never dispatched specialistStop")
}
if got := hooksSoFar(); got != "specialistStart,specialistStop" {
t.Fatalf("lifecycle hooks = %q, want specialistStart,specialistStop", got)
}
stops, claims := readStops()
if len(stops) != 1 || len(claims) != 1 {
t.Fatalf("after child exit: stop events = %d, claims = %d, want 1 and 1", len(stops), len(claims))
}
requirePayloadInt(t, eventPayload(t, stops[0]), "exitCode", 1)
// The exit callback owns the prompt cleanup once the child is gone.
deadline := time.Now().Add(10 * time.Second)
for promptExists() {
if time.Now().After(deadline) {
t.Fatal("exit callback never removed the prompt file")
}
time.Sleep(10 * time.Millisecond)
}
})
}
}
// A recovered task stopped explicitly (launcher process gone) must record the
// terminal transition and fire the stop hook exactly once through the shared
// durable claim, even when a later TaskOutput poll reaps it again.
func TestStopToolRecordsRecoveredTaskStopOnce(t *testing.T) {
killed := []int{}
manager, err := background.NewManagerWithOptions(background.ManagerOptions{
RootDir: t.TempDir(),
KillProcess: func(pid int) error {
killed = append(killed, pid)
return nil
},
})
if err != nil {
t.Fatalf("NewManagerWithOptions returned error: %v", err)
}
store := sessions.NewStore(sessions.StoreOptions{RootDir: t.TempDir()})
parent, err := store.Create(sessions.CreateInput{SessionID: "parent_session"})
if err != nil {
t.Fatalf("Create parent returned error: %v", err)
}
outputFile, err := manager.Register(background.RegisterInput{
TaskID: "child_task",
Type: specialistTaskType,
SpecialistName: "worker",
Description: "Auth check",
ParentID: parent.SessionID,
PID: 4321,
})
if err != nil {
t.Fatalf("Register returned error: %v", err)
}
if err := os.WriteFile(outputFile, []byte(strings.Join([]string{
`{"schemaVersion":2,"type":"run_start","runId":"run_1","sessionId":"child_task"}`,
`{"schemaVersion":2,"type":"final","runId":"run_1","text":"done"}`,
`{"schemaVersion":2,"type":"run_end","runId":"run_1","status":"success","exitCode":0}`,
"",
}, "\n")), 0o600); err != nil {
t.Fatalf("WriteFile returned error: %v", err)
}
var counter atomic.Int32
tool := &StopTool{manager: manager, SessionStore: store, LifecycleHooks: stopCounterHooks(&counter)}
result := tool.Run(context.Background(), map[string]any{"task_id": "child_task"})
if result.Status != tools.StatusOK || result.Meta["status"] != string(background.StatusKilled) {
t.Fatalf("TaskStop result = %#v", result)
}
if !reflect.DeepEqual(killed, []int{4321}) {
t.Fatalf("killed pids = %#v", killed)
}
if got := counter.Load(); got != 1 {
t.Fatalf("stop hooks = %d, want 1 from TaskStop", got)
}
// A later TaskOutput reaper in another process (fresh bridge) must not
// re-dispatch; the durable claim keys on the runId read from the output.
var reapCounter atomic.Int32
outputTool := NewOutputTool(manager)
outputTool.SessionStore = store
outputTool.LifecycleHooks = stopCounterHooks(&reapCounter)
if result := outputTool.Run(context.Background(), map[string]any{"task_id": "child_task"}); result.Status != tools.StatusOK {
t.Fatalf("TaskOutput status = %s output=%s", result.Status, result.Output)
}
if got := reapCounter.Load(); got != 0 {
t.Fatalf("stop hooks after later reap = %d, want 0", got)
}
events, err := store.ReadEvents(parent.SessionID)
if err != nil {
t.Fatalf("ReadEvents returned error: %v", err)
}
if stopEvents := eventsOfType(events, sessions.EventSpecialistStop); len(stopEvents) != 1 {
t.Fatalf("stop events = %d, want 1", len(stopEvents))
}
claims := eventsOfType(events, specialistStopHookEvent)
if len(claims) != 1 {
t.Fatalf("stop-hook claim events = %d, want 1", len(claims))
}
requirePayloadString(t, eventPayload(t, claims[0]), "runId", "run_1")
}
func stopCounterHooks(counter *atomic.Int32) *LifecycleHooks {
return &LifecycleHooks{
Dispatch: func(_ context.Context, event string, _ string, _ map[string]any) {
if event == "specialistStop" {
counter.Add(1)
}
},
}
}
func TestOutputToolSharesLifecycleHooksWithStopAccounting(t *testing.T) {
store := sessions.NewStore(sessions.StoreOptions{RootDir: t.TempDir()})
parent, err := store.Create(sessions.CreateInput{SessionID: "parent_session"})
if err != nil {
t.Fatalf("Create parent returned error: %v", err)
}
manager, err := background.NewManager(t.TempDir())
if err != nil {
t.Fatalf("NewManager returned error: %v", err)
}
outputFile, err := manager.Register(background.RegisterInput{
TaskID: "child_task",
Type: "specialist",
SpecialistName: "worker",
Description: "Auth check",
ParentID: parent.SessionID,
})
if err != nil {
t.Fatalf("Register returned error: %v", err)
}
if err := os.WriteFile(outputFile, []byte(strings.Join([]string{
`{"schemaVersion":2,"type":"run_start","runId":"run_1","sessionId":"child_task"}`,
`{"schemaVersion":2,"type":"final","runId":"run_1","text":"done"}`,
`{"schemaVersion":2,"type":"run_end","runId":"run_1","status":"success","exitCode":0}`,
"",
}, "\n")), 0o600); err != nil {
t.Fatalf("WriteFile returned error: %v", err)
}
if err := manager.UpdateStatus("child_task", background.StatusCompleted, 0); err != nil {
t.Fatalf("UpdateStatus returned error: %v", err)
}
var hookStops atomic.Int32
hooks := &LifecycleHooks{
Dispatch: func(_ context.Context, event string, _ string, _ map[string]any) {
if event == "specialistStop" {
hookStops.Add(1)
}
},
}
tool := NewOutputTool(manager)
tool.SessionStore = store
tool.LifecycleHooks = hooks
if result := tool.Run(context.Background(), map[string]any{"task_id": "child_task"}); result.Status != tools.StatusOK {
t.Fatalf("TaskOutput status = %s output=%s", result.Status, result.Output)
}
if got := hookStops.Load(); got != 1 {
t.Fatalf("specialistStop hooks = %d, want 1 from TaskOutput", got)
}
// Shared bridge: a later Task.onExit-style caller must not re-fire.
Executor{SessionStore: store, LifecycleHooks: hooks}.recordBackgroundTaskAccounting(
mustGetBackgroundTask(t, manager, "child_task"),
StreamResult{RunID: "run_1"},
)
if got := hookStops.Load(); got != 1 {
t.Fatalf("specialistStop hooks = %d after second path, want 1", got)
}
}
func mustGetBackgroundTask(t *testing.T, manager *background.Manager, taskID string) background.Task {
t.Helper()
task, ok := manager.Get(taskID)
if !ok {
t.Fatalf("background task %q not found", taskID)
}
return task
}
+36 -139
View File
@@ -15,7 +15,6 @@ import (
"path/filepath"
"strconv"
"strings"
"sync"
"github.com/Twigpine/zero/internal/background"
"github.com/Twigpine/zero/internal/sessions"
@@ -25,17 +24,12 @@ import (
const (
sessionTagSpecialist = "specialist"
specialistTaskType = "specialist"
promptFileThresholdBytes = 4 * 1024
maxSpecialistDepth = 8 // hard cap to prevent infinite recursion/resource exhaustion via Task
)
const SessionTagSpecialist = sessionTagSpecialist
// terminateOrphanedProcess stops a launched background child whose PID could not
// be recorded. Tests replace it to drive the failed-kill path.
var terminateOrphanedProcess = background.TerminateProcess
type NewSessionIDFunc func() (string, error)
type WritePromptFileFunc func(prompt string) (string, error)
type LoadFunc func(LoadOptions) (LoadResult, error)
@@ -56,64 +50,6 @@ type Executor struct {
BackgroundManager *background.Manager
BackgroundManagerFunc BackgroundManagerFunc
BackgroundRuntime *Runtime
// LifecycleHooks optionally dispatches specialistStart/specialistStop hooks.
// The shared pointer lets the CLI attach the hooks dispatcher after specialist
// tools are registered (hooks are constructed later in startup).
LifecycleHooks *LifecycleHooks
}
// LifecycleHooks bridges specialist accounting into the hooks dispatcher without
// importing the hooks package into every specialist call site.
type LifecycleHooks struct {
// Dispatch runs configured hooks for event ("specialistStart"/"specialistStop").
// specialistName is used as the matcher subject. nil Dispatch is a no-op.
Dispatch func(ctx context.Context, event string, specialistName string, payload map[string]any)
// stopMu guards stopClaims, the in-process fallback used when no durable
// claim can be written (swarm members have no parent session) or the session
// store is unavailable.
stopMu sync.Mutex
stopClaims map[string]map[string]struct{}
}
// claimInProcessStopDispatch reports whether this bridge should run
// specialistStop for the given child/run. It is the process-local fallback for
// callers without a durable ledger; the run-ID equivalence mirrors
// specialistPayloadMatches: an empty runId is a catch-all for both the query and
// any recorded claim, so a later observation with a runId cannot re-fire a stop
// that was already claimed without one. An empty child session never dedups
// (there is nothing to key on), matching the accounting matcher.
func (h *LifecycleHooks) claimInProcessStopDispatch(childSessionID, runID string) bool {
if h == nil || h.Dispatch == nil {
return false
}
childSessionID = strings.TrimSpace(childSessionID)
if childSessionID == "" {
return true
}
runID = strings.TrimSpace(runID)
h.stopMu.Lock()
defer h.stopMu.Unlock()
if h.stopClaims == nil {
h.stopClaims = map[string]map[string]struct{}{}
}
claims := h.stopClaims[childSessionID]
if claims == nil {
claims = map[string]struct{}{}
h.stopClaims[childSessionID] = claims
}
claimed := false
if runID == "" {
claimed = len(claims) > 0
} else {
_, emptyClaim := claims[""]
_, exactClaim := claims[runID]
claimed = emptyClaim || exactClaim
}
if !claimed {
claims[runID] = struct{}{}
}
return !claimed
}
type BuildArgsInput struct {
@@ -204,7 +140,7 @@ type TaskRunOptions struct {
// for a caller that omitted it.) The child's authority never exceeds the parent's.
func specialistAutonomy(permissionMode string) string {
switch strings.TrimSpace(permissionMode) {
case string(permissionModeUnsafe):
case string(permissionModeFullAuto), string(permissionModeUnsafe):
return "high"
default:
return "low" // unset/unknown modes do NOT inherit unsafe autonomy
@@ -231,7 +167,7 @@ func memberAwareAutonomy(permissionMode string, member bool) string {
// childPermissionModeFlag returns the --permission-mode value to pass a child
// process, or "" when the child's tool set must be driven by --auto alone.
// Only plan and spec-draft require the flag: resolveExecPermissionMode prefers
// --permission-mode over --auto, so forwarding auto/ask/member/unsafe would
// --permission-mode over --auto, so forwarding auto/ask/member/full-auto would
// discard the member rung and widen headless ask children.
func childPermissionModeFlag(permissionMode string) string {
switch strings.TrimSpace(permissionMode) {
@@ -242,10 +178,18 @@ func childPermissionModeFlag(permissionMode string) string {
}
}
// permissionModeUnsafe mirrors agent.PermissionModeUnsafe without importing the
// agent package (which would create an import cycle): exec resolves "--auto high"
// to this mode.
const permissionModeUnsafe = "unsafe"
// permissionModeFullAuto mirrors agent.PermissionModeFullAuto without importing
// the agent package (which would create an import cycle): exec resolves
// "--auto high" to this mode.
//
// permissionModeUnsafe is what that mode used to be called. Both are matched
// because the value crosses this boundary as a raw string, so the deprecated Go
// alias in the agent package does not apply to it. Recognizing only one spelling
// silently drops a full-auto parent's specialists to read-only "low" autonomy.
const (
permissionModeFullAuto = "full-auto"
permissionModeUnsafe = "unsafe"
)
// readOnlySpecialistTools are the tools a "safe" specialist may hold — pure reads
// plus planning. A specialist whose resolved tools are ALL in this set cannot
@@ -542,7 +486,7 @@ func (executor Executor) runBackground(ctx context.Context, built BuildArgsResul
}
outputFile, err := manager.Register(background.RegisterInput{
TaskID: built.SessionID,
Type: specialistTaskType,
Type: "specialist",
SpecialistName: manifest.Metadata.Name,
Description: params.Description,
ParentID: options.ParentSessionID,
@@ -580,57 +524,38 @@ func (executor Executor) runBackground(ctx context.Context, built BuildArgsResul
Background: true,
}
executor.recordSpecialistStart(accounting)
// The launcher's exit callback runs the terminal accounting, which can claim
// and dispatch the specialistStop hook. A child that exits immediately would
// otherwise publish specialistStop before the start decision below completes
// (and before SetPID records the pid). The callback waits for startSettled
// from its own goroutine: waiting inline would deadlock a synchronous
// injected launcher whose onExit runs before it returns.
startSettled := make(chan struct{})
defer close(startSettled)
pid, err := executor.launchBackground(binaryPath, built.Args, outputFile, func(exitCode int) {
go func() {
<-startSettled
status := background.StatusCompleted
if exitCode != 0 {
status = background.StatusError
status := background.StatusCompleted
if exitCode != 0 {
status = background.StatusError
}
_ = manager.MarkExited(built.SessionID, status, exitCode)
if task, ok := manager.Get(built.SessionID); ok {
summary := StreamResult{ExitCode: task.ExitCode}
if data, err := os.ReadFile(task.OutputFile); err == nil {
summary, _ = summarizeTaskData(string(data), task.ExitCode)
}
_ = manager.MarkExited(built.SessionID, status, exitCode)
if task, ok := manager.Get(built.SessionID); ok {
executor.recordBackgroundTaskAccounting(task, taskStreamSummary(task))
}
executor.cleanupBackgroundPromptFile(built.SessionID, built.PromptFile)
}()
executor.recordBackgroundTaskAccounting(task, summary)
}
executor.cleanupBackgroundPromptFile(built.SessionID, built.PromptFile)
})
if err != nil {
// The child never started: keep the attempt accounting, but publish no
// public lifecycle hooks for a sub-agent that did not exist.
accounting.LaunchFailed = true
_ = manager.UpdateStatus(built.SessionID, background.StatusError, -1)
executor.cleanupBackgroundPromptFile(built.SessionID, built.PromptFile)
executor.recordSpecialistStop(accounting, StreamResult{ExitCode: -1}, "error", -1, err, false)
return ExecResult{}, err
}
// Startup is confirmed (launchBackground only returns without error after the
// child process started), so the specialistStart hook can fire now.
executor.dispatchSpecialistStartHook(accounting)
if pid > 0 {
if err := manager.SetPID(built.SessionID, pid); err != nil {
// The child is running but its PID was never recorded, so nothing can
// track or stop it later. Kill it (and its process group) now, mark the
// task errored, and record the stop so it cannot become an untracked
// orphan. Once the kill succeeds the stop is a real terminal transition:
// both the accounting dedup and the shared stop-hook claim make the
// later onExit accounting a no-op.
if killErr := terminateOrphanedProcess(pid); killErr != nil {
// orphan. The dedup in recordSpecialistStop makes the later onExit
// accounting a no-op.
if killErr := background.TerminateProcess(pid); killErr != nil {
// Don't mask a failed kill: a surviving orphan is worse than the
// SetPID error alone, so surface both to the caller. The child may
// still be running, so this is not a terminal stop: leave the task
// running and let the launcher's exit callback mark it exited,
// record the stop (and claim specialistStop), and remove the prompt
// file it may still need to read when it really ends.
// SetPID error alone, so surface both to the caller.
err = errors.Join(err, fmt.Errorf("terminate orphaned pid %d: %w", pid, killErr))
return ExecResult{}, err
}
_ = manager.UpdateStatus(built.SessionID, background.StatusError, -1)
executor.cleanupBackgroundPromptFile(built.SessionID, built.PromptFile)
@@ -710,23 +635,7 @@ func (executor Executor) runBuiltArgs(ctx context.Context, built BuildArgsResult
Background: false,
}
executor.recordSpecialistStart(accounting)
startDispatched := false
onStarted := func() {
if startDispatched {
return
}
startDispatched = true
executor.dispatchSpecialistStartHook(accounting)
}
run, err := executor.runChild(ctx, binaryPath, built.Args, progress, onStarted)
started := run.Started || err == nil
if started {
// A seam that returns a completed run without the explicit Started flag
// still proves the child existed; make sure the hook fired exactly once.
onStarted()
} else {
accounting.LaunchFailed = true
}
run, err := executor.runChild(ctx, binaryPath, built.Args, progress)
if err != nil {
exitCode := run.exitCodeOr(-1)
summary := SummarizeStream(run.Events, exitCode)
@@ -796,20 +705,11 @@ func (executor Executor) binaryPath() (string, error) {
return path, nil
}
// runChild runs one child process. onStarted, when non-nil, is invoked exactly
// once as soon as the child process has actually started (after os/exec Start
// succeeds), so public lifecycle hooks cannot fire for a launch that failed.
// The injected RunChild seam cannot stream that signal, so a seam result marked
// Started reports it when the seam returns.
func (executor Executor) runChild(ctx context.Context, binaryPath string, args []string, progress func(streamjson.Event), onStarted func()) (ChildRunResult, error) {
func (executor Executor) runChild(ctx context.Context, binaryPath string, args []string, progress func(streamjson.Event)) (ChildRunResult, error) {
if executor.RunChild != nil {
result, err := executor.RunChild(ctx, binaryPath, append([]string(nil), args...), progress)
if result.Started && onStarted != nil {
onStarted()
}
return result, err
return executor.RunChild(ctx, binaryPath, append([]string(nil), args...), progress)
}
return runChildProcess(ctx, binaryPath, args, progress, onStarted)
return runChildProcess(ctx, binaryPath, args, progress)
}
func (executor Executor) launchBackground(binaryPath string, args []string, outputFile string, onExit func(exitCode int)) (int, error) {
@@ -938,7 +838,7 @@ func cleanupPromptFile(promptFile string) {
_ = os.Remove(promptFile)
}
func runChildProcess(ctx context.Context, binaryPath string, args []string, progress func(streamjson.Event), onStarted func()) (ChildRunResult, error) {
func runChildProcess(ctx context.Context, binaryPath string, args []string, progress func(streamjson.Event)) (ChildRunResult, error) {
var stderr bytes.Buffer
command := osexec.CommandContext(ctx, binaryPath, args...)
// Put the child in its own process group and group-kill on cancel/timeout, so a
@@ -952,9 +852,6 @@ func runChildProcess(ctx context.Context, binaryPath string, args []string, prog
if err := command.Start(); err != nil {
return ChildRunResult{Stderr: stderr.String()}, fmt.Errorf("start specialist child: %w", err)
}
if onStarted != nil {
onStarted()
}
events := []streamjson.Event{}
reader := bufio.NewReader(stdout)
for {
+9 -1
View File
@@ -67,7 +67,13 @@ func TestBuildArgsCreatesFreshSpecialistExecInvocation(t *testing.T) {
func TestSpecialistAutonomyByPermissionMode(t *testing.T) {
cases := map[string]string{
"": "low", // fail-safe: an unset mode does NOT inherit unsafe autonomy
"": "low", // fail-safe: an unset mode does NOT inherit unsafe autonomy
// Both spellings of the most permissive mode. The value arrives here as a
// raw string, so the agent package's deprecated Go alias does not reach
// it: when this table listed only "unsafe", renaming the mode to
// "full-auto" silently dropped every full-auto parent's specialists to
// read-only "low" and nothing failed.
"full-auto": "high",
"unsafe": "high",
"auto": "low",
"ask": "low",
@@ -96,6 +102,8 @@ func TestMemberAwareAutonomy(t *testing.T) {
{"", true, "member"}, // member, fail-safe non-unsafe, still write-capable
{"unsafe", true, "high"}, // unsafe parent keeps full autonomy
{"unsafe", false, "high"},
{"full-auto", true, "high"}, // the current spelling must behave identically
{"full-auto", false, "high"},
}
for _, c := range cases {
if got := memberAwareAutonomy(c.mode, c.member); got != c.want {
+6 -12
View File
@@ -21,12 +21,9 @@ const (
)
type OutputTool struct {
manager *background.Manager
managerFunc BackgroundManagerFunc
SessionStore *sessions.Store
// LifecycleHooks must be the same bridge as Task so onExit and TaskOutput
// poll share stop-hook dedup. nil is safe (accounting still runs).
LifecycleHooks *LifecycleHooks
manager *background.Manager
managerFunc BackgroundManagerFunc
SessionStore *sessions.Store
PollInterval time.Duration
DefaultTimeout time.Duration
MaxTimeout time.Duration
@@ -38,8 +35,8 @@ type outputParameters struct {
TimeoutMS int
}
func newOutputToolWithManagerFunc(managerFunc BackgroundManagerFunc, sessionStore *sessions.Store, lifecycleHooks *LifecycleHooks) *OutputTool {
return &OutputTool{managerFunc: managerFunc, SessionStore: sessionStore, LifecycleHooks: lifecycleHooks}
func newOutputToolWithManagerFunc(managerFunc BackgroundManagerFunc, sessionStore *sessions.Store) *OutputTool {
return &OutputTool{managerFunc: managerFunc, SessionStore: sessionStore}
}
func (tool *OutputTool) Name() string {
@@ -146,10 +143,7 @@ func (tool *OutputTool) readOutput(task background.Task) tools.Result {
dataString := string(data)
summary, rawLines := summarizeTaskData(dataString, task.ExitCode)
if task.Status != background.StatusRunning {
Executor{
SessionStore: tool.SessionStore,
LifecycleHooks: tool.LifecycleHooks,
}.recordBackgroundTaskAccounting(task, summary)
Executor{SessionStore: tool.SessionStore}.recordBackgroundTaskAccounting(task, summary)
}
return tools.Result{
Status: tools.StatusOK,
+2 -2
View File
@@ -14,8 +14,8 @@ func RegisterTools(registry *tools.Registry, executor Executor) (*Runtime, error
toolExecutor.BackgroundRuntime = runtime
toolExecutor.BackgroundManagerFunc = runtime.Manager
registry.Register(NewTaskTool(toolExecutor))
registry.Register(newOutputToolWithManagerFunc(runtime.Manager, executor.SessionStore, executor.LifecycleHooks))
registry.Register(newStopToolWithManagerFunc(runtime.Manager, executor.SessionStore, executor.LifecycleHooks))
registry.Register(newOutputToolWithManagerFunc(runtime.Manager, executor.SessionStore))
registry.Register(newStopToolWithManagerFunc(runtime.Manager))
registry.Register(NewGenerateTool(NewStorage(executor.Paths)))
return runtime, nil
}
+2 -20
View File
@@ -6,26 +6,20 @@ import (
"strings"
"github.com/Twigpine/zero/internal/background"
"github.com/Twigpine/zero/internal/sessions"
"github.com/Twigpine/zero/internal/tools"
)
type StopTool struct {
manager *background.Manager
managerFunc BackgroundManagerFunc
// SessionStore and LifecycleHooks participate in the shared, deduped stop
// transition so an explicitly stopped recovered task produces exactly one
// specialistStop hook, the same as a launcher-owned exit.
SessionStore *sessions.Store
LifecycleHooks *LifecycleHooks
}
type stopParameters struct {
TaskID string
}
func newStopToolWithManagerFunc(managerFunc BackgroundManagerFunc, sessionStore *sessions.Store, lifecycleHooks *LifecycleHooks) *StopTool {
return &StopTool{managerFunc: managerFunc, SessionStore: sessionStore, LifecycleHooks: lifecycleHooks}
func newStopToolWithManagerFunc(managerFunc BackgroundManagerFunc) *StopTool {
return &StopTool{managerFunc: managerFunc}
}
func (tool *StopTool) Name() string {
@@ -71,18 +65,6 @@ func (tool *StopTool) Run(ctx context.Context, args map[string]any) tools.Result
if err := manager.Kill(params.TaskID); err != nil {
return taskError(err)
}
// A recovered task (launcher process gone) may only ever be reaped here: the
// in-memory onExit callback does not exist in this process, and a later
// TaskOutput poll is optional. Record the terminal transition now so the
// specialistStop hook fires exactly once through the shared durable claim.
// recordBackgroundTaskAccounting is a no-op for non-specialist tasks and for
// tasks that are still running (Kill raced a status change).
if task, ok := manager.Get(params.TaskID); ok && task.Type == specialistTaskType {
Executor{
SessionStore: tool.SessionStore,
LifecycleHooks: tool.LifecycleHooks,
}.recordBackgroundTaskAccounting(task, taskStreamSummary(task))
}
return tools.Result{
Status: tools.StatusOK,
Output: fmt.Sprintf("task_id: %s\nstatus: killed", params.TaskID),
Loaded 100 of 153 files, more files were not shown because too many files have changed in this diff. Show more