Compare commits

..
Author SHA1 Message Date
e034a93815 perf(tui): bounded file read, LRU rendering cache, and size caps for full-file view (#1203)
* perf(tui): bounded file read, LRU rendering cache, and size caps for full-file view

- Bound full-file view reads (4000 lines, 1 MiB total, 4 KiB per line) with async loading and an LRU render cache capped at 64 entries and 8 MiB.
- Refresh both BTW surfaces when a tool result (such as write_stdin) carries both exact changed paths and an unknown-scope or aggregated directory summary outside Git.
- Gate Run details FILES mouse hit-testing on Run details being the visible foreground overlay so higher-priority modals (speech API-key prompt, leader help) block clicks behind them.

(cherry picked from commit 100d847aaf)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: hazyhaar <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-07 10:28:29 -08:00
2ca84cc127 fix(hooks): dispatch specialistStart/Stop lifecycle hooks (#1204)
* fix(hooks): dispatch specialistStart/Stop and allow matchers

These events were documented and accepted by hooks add, but never fired.
Wire advisory dispatch from specialist accounting and allow specialist
name matchers so EXTENDING.md matches runtime behavior.

* fix(hooks): unlock before specialistStop hooks and fire once

Release accountingMu before running lifecycle hooks, and dispatch
specialistStop only when the stop event is newly recorded so concurrent
finishers cannot re-run arbitrary hook commands.

* fix(hooks): fire specialistStop without relying on session append

Decouple stop-hook dispatch from parent-session event append so swarm
members (empty parent) and TaskOutput still get exactly one hook via a
shared LifecycleHooks claim, and wire that bridge into TaskOutput.

* fix(hooks): claim specialistStop durably and gate hooks on real launches

Address the review findings on specialist lifecycle dispatch:

- specialistStop is now claimed in the parent session's event log via a
  record-once specialist_stop_hook event, sharing the accounting run-ID
  equivalence (empty runId is a catch-all). A later reaper in another
  process (TaskOutput poll, recovered TaskStop, restart) no longer re-runs
  the hook. The in-process claim remains only as fallback for swarm
  members with no parent session and for session-store errors.
- Recovered TaskStop records the terminal transition after Kill, so an
  explicitly stopped recovered task emits specialistStop exactly once.
- specialistStart fires only after the child process actually started
  (onStarted after Start()); a launch that fails before exec records
  attempt accounting but no public start/stop hooks. Reapers derive the
  same eligibility from the durable stop event's childStarted flag.

Adds a CLI startup wire-up test asserting both real entry points
(interactive TUI, headless exec) attach the lifecycle bridge to the same
LifecycleHooks the TaskOutput tool holds, fixes the stale import path on
the rebased branch, and documents the once-per-run semantics.

* fix(hooks): gate background exit accounting behind the start decision

A background child that exits immediately can run its launcher exit
callback before Executor.LaunchBackground returns, so the terminal
accounting could claim and dispatch specialistStop before runBackground
dispatched specialistStart, and the recorded stop missed the pid that
SetPID had not stored yet.

The exit callback now waits on a settled channel closed when runBackground
returns (after the start hook and SetPID), from its own goroutine so a
synchronous injected launcher cannot deadlock waiting for a signal only
the launch return can close.

Regression test: TestRunBackgroundDefersExitUntilStartDecisionCompletes
drives a synchronous fast-exit launcher and asserts start-before-stop hook
order, the recorded stop pid, and no deadlock. It fails on the previous
code with hooks "specialistStop,specialistStart".

* fix(hooks): record specialistStop only after the orphan kill succeeds

When SetPID fails after a background child has started, runBackground kills
the unrecorded child and records the stop. If TerminateProcess fails, the
child may still be running, but the branch still marked the task errored,
recorded the stop, took the durable specialist_stop_hook claim and fired
specialistStop. The hook could then run while the sub-agent was alive, and
the one-shot claim suppressed the hook on the child's real exit. Marking the
task non-running also let a TaskOutput or TaskStop reap claim the stop
before the launcher's Wait observed the exit.

On a failed kill, surface the joined error and return without the status
update or the stop. The task stays running, so reapers remain no-ops, and the
launcher's exit callback marks it exited and records the single stop when the
child ends. A successful kill keeps the immediate stop as before.

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

* fix(hooks): keep the prompt on a failed orphan kill and share one stop claim

When SetPID fails and terminating the unrecorded child also fails, the child
may still be running and the task stays running. That branch still deleted
the --file prompt, so a child that had not yet read it failed at startup.
The launcher's exit callback already removes the prompt once the child
really exits, and Runtime.Close removes tracked prompts at shutdown, so the
failed-kill branch no longer removes it.

dispatchSpecialistStopHook made two independent dispatch decisions: the
durable specialist_stop_hook claim, and the in-process claim used when the
session store errors. A won durable claim never recorded the in-process
claim, so on one bridge a transient store error before or after a durable
claim dispatched the same stop twice, breaking the documented at-most-once
per process. A won durable claim now also takes the in-process claim
(including for an ineligible stop), and the hook runs only if both are free.

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

---------

Co-authored-by: Ayush7614 <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-07 10:19:56 -08:00
05c1b5e160 feat(plugins): add CLI enable/disable for plugin.json (#1202)
* feat(plugins): add CLI enable/disable for plugin.json

Add zero plugins enable|disable so installed plugins can be toggled
without removing them, matching hooks/MCP parity and EXTENDING docs.

* fix(plugins): serialize toggles with installs and preserve manifest bytes

Address review findings on the enable/disable CLI:

- Take the same cross-process installtxn lock plugins add/remove take on
  the plugin's install root before the final lookup and read, and publish
  plugin.json with installtxn.WriteFileAtomically while holding it.
- Re-resolve the plugin under the lock so a concurrent install can never
  be overwritten with manifest bytes read before it committed, retrying
  when precedence moves to the other root; a plugin removed under the
  lock reports ErrNotInstalled instead of being recreated.
- Edit the enabled value in place (a targeted token walk) so field
  order, formatting, and numeric literals survive; the old
  decode/re-encode dropped precision (9007199254740993 became
  9007199254740992) and sorted the keys.
- Fail closed on manifest stat errors, refresh a clean plugins.lock hash
  after a toggle, and leave pre-existing drift in place instead of
  absorbing it into the lockfile.
- Redact the text-mode output, reject an empty plugin id as a usage
  error, and document project-over-user precedence in the help text.

* fix(plugins): recover interrupted install transactions before toggling

SetEnabledByID takes the plugin root's installtxn lock but went straight
to reading and rewriting the tree. installtxn.Recover requires every
holder of that lock to resolve interrupted transactions first and to
abort on its error, as Install and Remove already do.

Without it, a toggle after an install killed between its tree swap and
its lockfile publish edited the unpublished tree, and the next install
or removal restored the retained backup over it, silently undoing the
enable/disable. A transaction recovery could not resolve also failed to
stop the toggle.

Call installtxn.Recover with the plugins reconciler right after taking
the lock, and add regression tests for every interrupted state that
leaves the plugin discoverable, the unresolved-transaction abort, and a
plugin removed while the toggle waits on the lock.

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

* docs(plugins): say when a plugin toggle takes effect

Zero reads plugin.json "enabled" only when it activates plugins at
startup (runExec and the interactive TUI bootstrap), and nothing reloads
them afterwards. A running interactive session therefore keeps the
tools, hooks, and skills of a plugin disabled from another terminal.

State that in the plugins docs and in the enable/disable help so the
documented activation rule matches what the command does.

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

* fix(plugins): pin toggled manifests, redact toggle errors, honor last enabled key

Address the review findings on the plugin enable/disable toggle.

Pin the plugin directory before editing its manifest. The install lock
only serializes cooperating installers, so swapping the plugin directory
for a link after discovery redirected the read and the atomic publish
through it, overwriting another manifest. The toggle now opens the
directory under its install root with pathjail, refuses a link or
reparse point, checks that the handle is the directory entry itself,
and reads, stats, creates the temporary file, and renames through that
handle.

Redact every stderr message the toggle writes. Parser and not-installed
usage errors echoed the raw argument, so a mistyped credential reached
stderr unredacted while the toggle's other output was redacted.

Validate only the last "enabled" value, as the loader does. The loader
keeps the last duplicate key, but the toggle rejected an earlier,
overwritten non-boolean value, so a plugin discovered as enabled could
not be toggled.

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

* fix(plugins): keep the toggle on the pinned plugin directory throughout

The previous fix pinned the manifest read and publish to a handle on the
plugin directory but still hashed the tree for plugins.lock by path, and
reported success without checking that the path still named the pinned
directory. If the directory was replaced after it was opened, the edit
landed in the moved-away original while the lockfile check and refresh
read the replacement, and the command reported a state the installed
plugin did not have.

Hash the tree before and after the edit through the pinned handle, and
confirm that the plugin's directory entry is still the pinned directory
before refreshing the lockfile or reporting any result, including a
no-op. hashTree now delegates to the same rooted walker, so path and
handle callers hash identical bytes.

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

* fix(plugins): hash plugin trees through native os.Root calls

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

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

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

---------

Co-authored-by: Ayush7614 <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-07 09:56:16 -08:00
16ccd78765 feat(mcp): add AI/ML API as an opt-in built-in server (#1193)
* feat(aimlapi): shared service key and attribution for aimlapi.com services

Adds the pieces the Toolbox and MCP integrations share:

- internal/aimlapi: X-AIMLAPI-Source (agent/zero), the fixed Toolbox and
  MCP URLs, and an exact-hostname check (https, default port, no
  userinfo) for api/tools/mcp.aimlapi.com before the key or attribution
  headers are attached. The partner id still comes from ResolvePartnerID.
- internal/config: AIMLAPIServiceKey picks the key from the aimlapi
  profile (or AIMLAPI_API_KEY without one), reading the credential
  store, and withholds it when any aimlapi-keyed profile points at a
  proxy base URL or AIMLAPI_INFERENCE_URL is overridden. Keys that are
  not a single token are refused.

* feat(mcp): AI/ML API MCP server as an opt-in built-in default

Seeds `aimlapi` (https://mcp.aimlapi.com/mcp, streamable HTTP) next to
Exa as a built-in MCP server that ships disabled: its tool runs and
generations are billed to the user's AI/ML API balance. The disable is
marked as explicitly set, so a project config cannot lift it; the user
turns it on with `zero mcp enable aimlapi`, which now writes
`"disabled": false` for a default that ships off (and `zero mcp add
aimlapi` does the same). `zero doctor` reports the untouched default as
a pass rather than a warning.

The MCP client attaches the AI/ML API key and the X-AIMLAPI-Source /
X-AIMLAPI-Partner-ID headers in one place (internal/mcp/aimlapi.go),
called from the three header-application points. Both apply only to a
request whose own URL is exactly https://mcp.aimlapi.com on the default
port, whatever the server is named. The key is resolved for every
request through config.LoadAIMLAPIServiceKey, so switching the aimlapi
provider to a proxy, or a key that is not a single token, means no
Authorization header on the next request. It is never put in config,
the server identity used for OAuth token binding, list output or errors.
A user Authorization header, `"auth": "oauth"`, or a project-touched
server goes without it. A 401 from that host explains how to add a key
or sign in with `zero mcp oauth login`.

* fix(aimlapi): validate whitespace and preserve store errors

* fix(mcp): tailor unauthorized hints to credential policy

* docs(mcp): clarify automatic credential eligibility

* test(mcp): cover edited disabled built-in defaults

* fix(aimlapi): report store reads after proxy eligibility checks

* docs(aimlapi): document MCP key precedence and clarify 401 hints

Operator copy now matches normalizeProvider resolution (inline key,
apiKeyEnv, then credential store) and warns that AIMLAPI_API_KEY can
bill MCP before a stored profile key. SECURITY and service-key godoc
stay aligned. 401 hints no longer imply a proxy-only failure mode.

Adds a regression test that resolved profile keys win over the store.

Co-authored-by: Cursor <[email protected]>

* fix(mcp): keep the aimlapi key off redirects and error text, persist enable

Redirect hops: net/http copies the first request's headers onto every
redirect hop without calling the header hooks, so the automatic AI/ML API
key and attribution followed a same-origin hop that fails the exact-URL
rule (userinfo) and an eligible hop reused the first hop's key. The
redirect guard now restores the server's own values for those headers and
applies the per-request rule again.

Error text: httpStatusError copied up to 1 KiB of the response body into
the error, so a body echoing the injected key (or its prefix, when cut by
the read limit) reached CLI output. When the request carried the automatic
key, the body is now omitted instead of matched.

Enable persistence: MCPServerConfig had no serializer and Disabled is
omitempty, so any read-modify-write of config.json (SetTheme,
SetActiveProvider, ...) dropped `"disabled": false` and the shipped
disable turned aimlapi off again. MarshalJSON now writes the flag whenever
it was explicitly set; `zero mcp add` keeps writing only a disable.

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

---------

Co-authored-by: Stan <[email protected]>
Co-authored-by: Hugo <[email protected]>
Co-authored-by: Cursor <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-04 20:13:53 -08:00
c9f179482a feat(web-fetch): add an opt-in AI/ML API Toolbox scraping backend (#1192)
* feat(web-fetch): AI/ML API Toolbox as an opt-in web_fetch backend

With ZERO_WEBFETCH_PROVIDER=aimlapi, web_fetch fetches the page as Markdown
through the Toolbox tool search1api/crawl instead of a direct GET. A key
alone never selects it; unset or unknown values keep the direct fetch.

- The URL checks (pre-permission and resolved host) run before the URL is
  sent, so loopback, private and metadata URLs never reach the crawler.
- The key is resolved on every call through LoadAIMLAPIServiceKey; a
  missing, proxied or invalid key is an error with no request and no
  fallback. A failed run (is_error, failed status, no content) is also an
  error.
- Requests go only to aimlapi.ToolboxBaseURL, checked with
  IsAttributedURL before the key and attribution headers are attached;
  redirects are refused. The client uses the default transport, so the
  proxy environment applies and the target-site SSRF pin does not.
- Every run carries max_cost_usd 0.005, wait_ms 30000 and a fresh
  idempotency key. 202 runs are polled (hint clamped to 250 ms..5 s,
  120 s overall). 429/502/503/504 are retried with the identical body,
  honouring Retry-After (capped at 10 s); max_cost_exceeded is not.
- Output keeps the result shape with a Backend line, respects max_bytes,
  refuses format raw, and scrubs the key and redacts all Toolbox text.

* fix(web-fetch): honor poll retries and reject raw before permission

* docs(web-fetch): explain the bounded run response

* test(web-fetch): rename the Toolbox fake that collides with web_search tests

#1189 added a fakeToolbox type with a recorded method to the tools test
package. The web_fetch Toolbox tests declared their own fakeToolbox with a
different shape, so the package's tests no longer compiled once both were on
main. Rename the web_fetch one to webFetchFakeToolbox, matching the file's
webFetch prefix. Test-only; no behavior change.

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

* fix(web-fetch): describe the aimlapi backend in the tool schema

With ZERO_WEBFETCH_PROVIDER=aimlapi the max_bytes and format descriptions
still promised direct-fetch behavior: raw body bytes before conversion and
HTML conversion with a raw option. The Toolbox backend returns crawled
Markdown, applies max_bytes to that Markdown and refuses format "raw", as
the README says. Say so in both descriptions so the model sees the same
contract.

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

* fix(web-fetch): keep the AI/ML API key out of Toolbox errors and output

The Toolbox backend scrubbed the key with a literal string replace. A key
echoed back split by an invisible character (NUL, ZWSP) passed it on every
path the model sees: error bodies, run errors, page content and the run_id
meta field. scrubWebFetchKey now uses scrubAIMLAPIResult, the same
split-aware redaction the web_search backend uses.

A failed Toolbox request also returned the *url.Error from client.Do. That
error quotes the request URL, and a polling URL carries the run id
percent-escaped, a form no key scrubbing can match. send now reports the
cause without the URL, as web_search does.

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

* docs(web-fetch): state only established billing for Toolbox runs

The README said every fetch is a billed Toolbox run, while the next
sentence says a run over max_cost_usd is refused and not charged, and a
code comment said a 404 comes back as an unbilled is_error run. The
AI/ML API Toolbox documentation says you pay for runs but not whether a
failed run is billed. Both now say only that Toolbox runs are billed.

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

---------

Co-authored-by: Stan <[email protected]>
Co-authored-by: Hugo <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-04 19:20:17 -08:00
98736457bd feat(web-search): add AI/ML API Toolbox as an opt-in backend (#1189)
* feat(aimlapi): shared service key and attribution for aimlapi.com services

Adds the pieces the Toolbox and MCP integrations share:

- internal/aimlapi: X-AIMLAPI-Source (agent/zero), the fixed Toolbox and
  MCP URLs, and an exact-hostname check (https, default port, no
  userinfo) for api/tools/mcp.aimlapi.com before the key or attribution
  headers are attached. The partner id still comes from ResolvePartnerID.
- internal/config: AIMLAPIServiceKey picks the key from the aimlapi
  profile (or AIMLAPI_API_KEY without one), reading the credential
  store, and withholds it when any aimlapi-keyed profile points at a
  proxy base URL or AIMLAPI_INFERENCE_URL is overridden. Keys that are
  not a single token are refused.

* feat(web-search): AI/ML API Toolbox as an opt-in web_search backend

ZERO_WEBSEARCH_PROVIDER=aimlapi runs the Toolbox tool web/search at the
fixed https://tools.aimlapi.com/v1/tools URL. It is selected only by that
setting; an AI/ML API key alone never enables it or registers web_search.

- The key comes from config.LoadAIMLAPIServiceKey on every search; a
  missing, proxy-profile or invalid key fails before any request.
  ZERO_WEBSEARCH_BASE_URL and ZERO_WEBSEARCH_API_KEY are ignored.
- The key and attribution headers are attached only after
  aimlapi.IsAttributedURL accepts the URL, and redirects are refused.
- Every run carries max_cost_usd 0.025 and a fresh idempotency key;
  limit maps to max_results and domains to include_domains, with the
  local domain filter kept.
- A queued run (202) is polled until it ends, honouring
  next_poll_hint_ms clamped to 250 ms..5 s. This backend gets a 30 s
  budget with wait_ms 20 s; the generic backends keep 10 s.
- Non-2xx responses, is_error runs, failed/stopped/timed_out runs and
  malformed bodies are errors carrying the error code and request id,
  with the key scrubbed.

* fix(aimlapi): preserve store errors and isolate Windows config tests

* fix(web-search): redact results and preserve domain errors

* docs(web-search): clarify minimum search charge

* fix(aimlapi): reject Unicode whitespace in service keys

* fix(aimlapi): report store reads after proxy eligibility checks

* fix(web-search): keep plain no-results for unscoped backends

Only a backend that applied the domain allowlist itself (the AI/ML API
Toolbox) should turn an empty result into the "no results matched
domains" error. A generic backend never saw the allowlist, so an empty
result from it is an ordinary "No results for query", as before this
branch.

Also clear ZERO_WEBSEARCH_PROVIDER in the unconfigured registry test:
ZERO_WEBSEARCH_PROVIDER=aimlapi alone now registers web_search, so an
exported value flipped the test's oracle.

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

* fix(aimlapi): resolve the Toolbox key without an active provider

A user config that lists several providers but marks none active (or
names a removed one) makes Resolve return ErrNoActiveProvider together
with the normalized profiles. The app keeps running on that partial
config, but LoadAIMLAPIServiceKey stopped on the error, so web_search
failed even with an aimlapi profile or AIMLAPI_API_KEY set. Look the
key up in the partial config instead; the proxy check still scans every
resolved profile, and all other resolve errors are still returned.

Also state the key rules in the README as implemented: user config
only (project .zero providers are not used), which aimlapi profile is
used, and that any aimlapi profile or any profile reading
AIMLAPI_API_KEY on a non-canonical base URL withholds the key.

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

* fix(aimlapi): withhold the Toolbox key for dropped proxy profiles

Resolve drops a non-active profile it cannot normalize (an unknown or
unsupported catalog entry, a missing base URL, ...), so the proxy check
never saw it. A dropped profile that reads AIMLAPI_API_KEY from a proxy
still shows the key belongs to that proxy, yet the key was sent to
tools.aimlapi.com. LoadAIMLAPIServiceKey now also checks the configured
user profiles, merged the same way Resolve merges them, and withholds
the key when a dropped profile carries it and is not explicitly on the
canonical base URL.

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

* test(web-search): pin the generic backend route in BASE_URL registration tests

defaultSearchBackend now checks ZERO_WEBSEARCH_PROVIDER=aimlapi before
ZERO_WEBSEARCH_BASE_URL. TestWebSearchRegisteredInCoreNetworkTools and
TestCoreNetworkToolsExposeSafetyMetadata set only the base URL and inherit
the provider from the process environment, so with an exported
ZERO_WEBSEARCH_PROVIDER=aimlapi they took the Toolbox route and still passed
even if generic base-URL registration broke. Clear the provider so they
exercise the generic route they describe, matching
TestCoreNetworkToolsOmitWebSearchWhenUnconfigured.

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

* fix(redaction): redact caller-supplied secrets split by invisible characters

RedactString replaced Options.ExtraSecretValues only as contiguous text,
and the gap-tolerant pass from #1067 covers only the built-in credential
shapes. An opaque key such as the AI/ML API service key, echoed by a
service with a NUL or zero-width space inside it, matched neither pass.
Both fragments then reached Toolbox web_search errors (before clipping)
and successful results sent to the model and transcript.

Match each caller-supplied secret with runs of the same invisible
separators allowed between its characters, right after the contiguous
replacement. Separators inside the match are covered; those beside it
stay. Text without a separator takes the old path unchanged.

Regressions: split explicit values in the redaction package, and split
keys in the Toolbox HTTP error, run error and successful-result paths.
Raised by Copilot on #1189.

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

* fix(redaction): claim each caller secret whole or split before shorter ones

RedactString replaced every ExtraSecretValues entry contiguously, longest
first, and only then ran the split match. A shorter secret inside a
longer one that was split by an invisible character was replaced first,
so "abc\x00def" with secrets abcdef and abc became "[REDACTED]\x00def"
and the rest of the longer secret stayed visible.

Run the contiguous and split match together per value, longest first,
so a longer value is claimed in either form before any shorter one.
Regressions cover a shorter secret at the start, end and middle of a
split longer one, and alongside it. Raised by CodeRabbit on #1189.

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

* fix(redaction): merge overlapping caller-secret occurrences before replacing

ExtraSecretValues were replaced one occurrence at a time, so each
replacement could consume bytes another occurrence needed. With secret
abcabc, "abc\x00abcabc" became "abc\x00[REDACTED]". A key whose first and
last characters match lost all but one character the same way
("xY7q​[REDACTED]"), and two partly overlapping values left the
rest of one visible. Reordering the passes cannot fix this: the
contiguous and regexp replacements are both non-overlapping.

Find every occurrence of every value, whole or split by invisible
separators, in one separator-free copy of the text (linear KMP, all
overlapping starts), map the spans back, merge overlapping ones, and
replace each merged span once. Occurrences that only touch stay
separate replacements; separators beside a match stay; tab, newline
and carriage return are still not gaps. This honours the existing
TestRedactString_OverlappingExtraSecrets contract for both forms.
Raised by CodeRabbit on #1189.

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

* fix(web-search): keep the request URL out of Toolbox transport errors

When http.Client.Do failed, the backend scrubbed err.Error() from the
*url.Error, which quotes the request URL. A polling URL carries the run
id the Toolbox returned, percent-escaped, so a key echoed in that id
appeared as "Lp9%00Xr4" or "Lp9%E2%80%8BXr4". The key scrubber matches
the key whole or split by raw invisible separators, not percent-escapes,
so the key stayed recoverable in the web_search error.

Report only the cause inside the *url.Error. The message prefix, the
redirect refusal text and key scrubbing are unchanged. The regression
fails a polling GET after a queued run whose id carries a NUL- or
ZWSP-split key and checks the output has neither the polling URL nor
the key. Raised by CodeRabbit on #1189.

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

* fix(redaction): always redact a caller secret as written

Once the text held any invisible separator, redactExtraSecretValues
matched each caller secret only with separators removed from both. That
replaced the contiguous match instead of adding to it: a secret made
only of separators compacted to nothing and was skipped, and separators
at either end of a secret fell outside its match. RedactString("a\x00b")
with secret "\x00" returned the text unchanged, where main replaced it.

In the separator path, also record every occurrence of the secret as
written, then merge spans as before. Text without a separator takes the
unchanged path. Raised by CodeRabbit on #1189.

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

---------

Co-authored-by: Stan <[email protected]>
Co-authored-by: Hugo <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-04 18:24:09 -08:00
ShryukGrandhi e59632406b test(agent): isolate user home, config and cache dirs for the agent tests (#1184)
Sandboxed tool runs in the agent tests keep runtime state and lease files
under os.UserCacheDir, so go test ./internal/agent/ left
zero/runtime/v1/*.lease files in the developer's real cache. TestMain
now points every per-user root at its temp dir via
testutil.IsolateUserDirs, as internal/tui does, and a guard test pins it.

Refs #1103

Signed-off-by: ShryukGrandhi <[email protected]>
2026-11-04 12:27:48 +08:00
github-actions[bot] 7c55a09c07 chore(main): release 0.12.0 (#1150)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-11-03 08:10:03 +08:00
Octopusandocto-patch d99f92b81a feat(providers): add MiniMax-M3.1-Flash to catalog (#1180)
* feat(providers): add MiniMax-M3.1-Flash to catalog

* fix(providers): forward MiniMax adaptive effort

---------

Co-authored-by: octo-patch <[email protected]>
2026-11-02 18:28:17 +08:00
zzucs 5aa54fe1d2 fix(agent): mark headless guardrail stops incomplete (#1182)
* fix(agent): mark headless guardrail stops incomplete

The empty-turn and repeated-tool-failure guards returned the run as a
successful final answer even with RequireCompletionSignal enabled, so
zero exec reported exit 0 and a success run_end for work it had just
abandoned.

Set Result.Incomplete and a reason at both stops when the headless gate
is on. Thresholds, stop answers, retry behavior, interactive runs, and
--no-completion-gate are unchanged.

Fixes #1154

* fix(agent): count consecutive empty turns in stop reason

The headless no-output stop reason used result.Turns, which counts every
turn in the run, so a run with an earlier tool-call turn claimed "stopped
after 4 turns with no output" for a 3-turn empty streak. Use the guard's
emptyTurns counter and say "consecutive".

Pinned by a case with a successful tool turn before the empty streak.
2026-11-02 14:25:40 +08:00
oniisan f912f80d00 fix(chatgpt): refresh subscription defaults and compatibility version (#1179)
* fix(chatgpt): refresh subscription defaults and compatibility version

Replace retired preset suggestions with vendor-documented Sol, Terra, Astra, and Luna IDs. Update the Codex discovery compatibility floor for Terra and verify that saved model selections are retained.

Refs Twigpine/zero#1177 (defaults portion)

* test(chatgpt): assert discovered model in compatibility probe

Verify the live response contains exactly gpt-future-coder in addition to checking the Codex compatibility query value.

Refs Twigpine/zero#1177

* docs(chatgpt): align Terra description with vendor guidance
2026-11-02 14:25:00 +08:00
ShryukGrandhi 9c4e3693e7 test(providerio): widen the idle window in the content-reset test (#1172)
TestScanSSEDataWithContextContentResetsOnData wrote a data line every 30ms against a 100ms idle timeout, so a ~70ms scheduling stall on a shared runner tripped the idle watchdog. Use a 500ms idle timeout (600ms content window) and 25 writes (~750ms), which keeps every gap far under the idle timeout while the run still outlasts the content window.

Signed-off-by: ShryukGrandhi <[email protected]>
2026-11-02 14:24:24 +08:00
zzucs 63962081cf fix(sessions): capture freeform apply_patch checkpoints (#1183)
* fix(sessions): capture freeform apply_patch checkpoints

Freeform apply_patch arguments are raw patch text, but both checkpoint
paths passed them to json.Unmarshal and dropped the error, so they found
no mutation targets and recorded no checkpoint. The tool still executed,
leaving rewind unable to restore the edit.

Resolve targets through a shared agent.CheckpointTargets helper that
prepares freeform input on the registered built-in apply_patch, exactly
as executeToolCall does. This keeps absolute patch headers
workspace-relative (rewriting them and setting cwd) instead of handing
absolute paths to the checkpoint layer. Non-freeform calls now use the
same tolerant decoder as execution, so the two cannot disagree.

Covers the CLI, spec-draft, and TUI capture paths.

Fixes #1162

* test(sessions): canonicalize freeform absolute-header fixtures

t.TempDir returns the symlinked /var spelling on macOS and short names on
Windows, while the apply_patch tool resolves the physical root. The
absolute-header cases must build the header from the resolved root, as the
repo's cross-platform test guidance requires.
2026-11-02 14:23:58 +08:00
Vasanth T 2e10a4b60b test(swarm): wait for every slot to be released before checking the drain (#1181)
A finishing member's task reads Done before its slot is freed: the
supervisor completes the task, removes the member and only then frees
the slot in afterExit. The test waited for Done == 5 and checked the
running count straight after, so a busy runner could see one slot still
held. It now waits for the drain itself.
2026-10-29 06:30:41 -07:00
Vasanth T 4247a07aba test(tools): stop the tty session before removing its root (#1175)
TestExecCommandTTYSkipsInteractiveBlock runs ssh to a bogus host under
tty with a 250ms yield. When ssh is still resolving the host after the
yield, the session keeps running in the test's directory and nothing
stops it, so on Windows t.TempDir's cleanup fails with a sharing
violation. It failed Smoke (windows-latest) on a README-only PR.

Stop the session in a cleanup, and build the tool on execTestRoot,
which retries the removal while Windows still holds the directory right
after the stop, as the other exec tests that leave a session do.
2026-10-27 06:25:41 -07:00
Vasanth T b837448dd4 docs: list Vercel under Partners (#1176)
Adds Vercel beside GMI Cloud in the Partners table of both READMEs,
with Vercel's official logotype in light and dark variants, taken
unchanged from its brand page apart from a trailing newline.
2026-10-27 15:29:59 +08:00
Vasanth T a0b036a3c7 docs: add Vercel OSS Program badge (#1174)
* docs: add Vercel OSS Program badge

* docs: add the Vercel OSS Program badge to README_ZH too

The two READMEs carry the same badge row, so the Chinese one gets the
badge as well. Both now list alt before src, like the other badges in
the row.
2026-10-27 12:30:50 +05:30
Vasanth T 164150633a feat(skills): install a skill's bundled files and point the skill tool at them (#1167)
* feat(skills): install a skill's bundled files and point the skill tool at them

A skill was only its SKILL.md: install copied that one file, and the skill
tool returned only the markdown body, so a skill whose instructions run its
own scripts was broken by installing it, and a hand-placed skill directory
gave the model no way to find the files beside it.

Install now copies the whole skill directory, leaving out hidden entries
(.git, .env) and symlinks, which it neither follows nor copies but reports.
A script stays executable; everything else is installed 0644. A tree over
1,000 files or 32 MiB is refused. skills.lock records a treeHash beside the
SKILL.md hash; skill info reports drift in bundled files, and an entry from
before this is still checked on SKILL.md alone. A reinstall that changed
only a bundled file is reported as an update.

Both skill tools end their output with the skill's directory and a list of
up to 50 bundled files when there are any; a SKILL.md-only skill is
returned unchanged.

Closes #584

* fix(skills): compare a reinstall over an old lock entry with SKILL.md alone

A lock entry written before tree hashes has no treeHash, so the reinstall
compared the new tree hash with an empty string and always counted as an
update. Re-adding an unchanged skill that is only a SKILL.md printed
"Updated" and "the files bundled with it changed" when there were none.

That entry comes from an install that copied SKILL.md alone, so compare
with that: it is an update when SKILL.md changed or the reinstall now
brings bundled files. InstallResult gains previousTreeHash, and the CLI
uses it to say the bundled files are now installed rather than changed
on the first reinstall over such an entry.

* fix(skills): match the tree hash when recovering an interrupted install

lockReconciler matched both trees of an interrupted workspace on the
SKILL.md hash alone. A reinstall that changes only a bundled file keeps
SKILL.md, so a kill between the tree swap and the lockfile publish left
two trees carrying the recorded hash, and recovery kept the new one and
retired the backup the lockfile still described.

A tree now matches an entry with a tree hash only when both hashes
match. An entry from before tree hashes is still matched on SKILL.md
alone. A tree hash that cannot be computed is returned as an error, the
way an unreadable SKILL.md already is, so recovery decides nothing about
a tree it could not read.

Also bring the package comment up to date: install copies the skill
directory, not SKILL.md alone.
2026-10-25 07:24:59 -07:00
184 changed files with 16781 additions and 8547 deletions

No files matched your search

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

After

Width:  |  Height:  |  Size: 1.6 KiB

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

After

Width:  |  Height:  |  Size: 1.6 KiB

+20 -45
View File
@@ -962,25 +962,16 @@ func toolCallEvent(call agent.ToolCall) sessions.AppendEventInput {
}
}
// toolResultEvent persists a tool result through the ONE session contract every
// writer shares, agent.ToolResultSessionPayload.
//
// This used to spell its own payload and wrote result.Output. After the
// output/notice split that field is the UNDECORATED text, and nothing else in
// the payload carried the enforcement notices, so the disclosure was dropped at
// the moment of persisting. The live tool_call_update for the same result had
// shown it (toolResultContent reads ModelOutput), and then session/load
// replayed the call with the sandbox's narrowing gone. Fixing only the live
// translation could not reach this: the text was already lost on disk.
//
// The session store is shared with the TUI and headless exec, so the same
// payload is also what lets a TUI resume of an ACP-written session draw the
// notice as card furniture, from the typed field and the undecorated body.
// Reported by @jatmn.
func toolResultEvent(result agent.ToolResult) sessions.AppendEventInput {
return sessions.AppendEventInput{
Type: sessions.EventToolResult,
Payload: agent.ToolResultSessionPayload(result),
Type: sessions.EventToolResult,
Payload: map[string]any{
"toolCallId": result.ToolCallID,
"name": result.Name,
"status": result.Status,
"output": result.Output,
"changedFiles": append([]string(nil), result.ChangedFiles...),
},
}
}
@@ -1153,14 +1144,13 @@ func replayToolUpdate(event sessions.Event) *ToolCallUpdate {
return nil
}
var payload struct {
Name string `json:"name"`
ToolCallID string `json:"toolCallId"`
ID string `json:"id"`
Arguments string `json:"arguments"`
Status string `json:"status"`
Output string `json:"output"`
ChangedFiles []string `json:"changedFiles"`
EnforcementNotices []string `json:"enforcementNotices"`
Name string `json:"name"`
ToolCallID string `json:"toolCallId"`
ID string `json:"id"`
Arguments string `json:"arguments"`
Status string `json:"status"`
Output string `json:"output"`
ChangedFiles []string `json:"changedFiles"`
}
if json.Unmarshal(raw, &payload) != nil {
return nil
@@ -1180,27 +1170,12 @@ func replayToolUpdate(event sessions.Event) *ToolCallUpdate {
if status == "" {
status = tools.StatusOK
}
// THE DISCLOSURE IS RESTORED AS WHAT IT WAS: typed notices beside undecorated
// text, so toolCallResult composes it the way it did live and the client
// sees it exactly once. The stored output already has the notices composed
// in (that is the shared contract, because output is the provider-facing
// text), so handing it to ModelOutput together with the typed field would
// draw the disclosure twice, and dropping the typed field would make this
// reader depend on every writer having decorated the text. Taking the
// notices back off the front covers both: a record whose output was never
// decorated comes through unchanged and still gets its notice.
//
// displayPreview is deliberately not read. It is the card body for a
// terminal, and the ACP wire has no card: the live update sends the model
// text, so a replay that sent the preview instead would not match it.
notices := append([]string(nil), payload.EnforcementNotices...)
upd := toolCallResult(agent.ToolResult{
ToolCallID: id,
Name: payload.Name,
Status: status,
Output: tools.WithoutEnforcementNotices(payload.Output, notices),
EnforcementNotices: notices,
ChangedFiles: append([]string(nil), payload.ChangedFiles...),
ToolCallID: id,
Name: payload.Name,
Status: status,
Output: payload.Output,
ChangedFiles: append([]string(nil), payload.ChangedFiles...),
})
return &upd
}
@@ -1,195 +0,0 @@
package acp
import (
"context"
"encoding/json"
"reflect"
"strings"
"testing"
"time"
"github.com/Twigpine/zero/internal/agent"
"github.com/Twigpine/zero/internal/sessions"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/zeroruntime"
)
const persistedNotice = "least-privilege notice: read access was narrowed"
func mustRawPayload(t *testing.T, payload map[string]any) json.RawMessage {
t.Helper()
encoded, err := json.Marshal(payload)
if err != nil {
t.Fatal(err)
}
return encoded
}
func toolUpdateText(update ToolCallUpdate) string {
var text strings.Builder
for _, part := range update.Content {
if part.Content != nil {
text.WriteString(part.Content.Text)
}
}
return text.String()
}
// nextToolResultUpdate returns the next tool_call_update, skipping the
// tool_call that announces it.
func nextToolResultUpdate(t *testing.T, ctx context.Context, updates <-chan ToolCallUpdate, stage string) ToolCallUpdate {
t.Helper()
for {
select {
case update := <-updates:
if update.SessionUpdate == UpdateToolCallUpdate {
return update
}
case <-ctx.Done():
t.Fatalf("%s: the tool result update never arrived", stage)
}
}
}
// A DISCLOSURE SHOWN LIVE HAS TO SURVIVE BEING WRITTEN DOWN.
//
// enforcement_notice_test.go pins the live translation: toolResultContent reads
// ModelOutput, so a connected ACP client sees the sandbox's narrowing. That was
// the only half fixed. The turn also persists each result, and the persisted
// payload was spelled by hand in this package with result.Output, which after
// the output/notice split is the undecorated text, and with no notices field at
// all. The disclosure was gone from disk, so session/load replayed a sandboxed
// command as if nothing had constrained it.
//
// This runs a real turn, so the result reaches the store through the same
// OnToolResult callback production uses, then loads the session in a fresh
// agent and reads the replayed update. It fails if only translate.go is fixed,
// which is the state it was written against. Reported by @jatmn.
func TestACPReloadedToolResultCarriesTheEnforcementNoticeOnce(t *testing.T) {
for _, tc := range []struct {
name string
output string
}{
{name: "a command with output", output: "the command output"},
// An enforced command that printed nothing still has a disclosure, and
// an empty body is where a reader that falls back to the decorated text
// draws it twice.
{name: "a command that printed nothing", output: ""},
} {
t.Run(tc.name, func(t *testing.T) {
deps := testDeps(t)
deps.RunAgent = func(_ context.Context, _ string, _ zeroruntime.Provider, opts agent.Options) (agent.Result, error) {
call := agent.ToolCall{ID: "call-sandboxed", Name: "bash", Arguments: `{"command":"ls"}`}
opts.OnToolCall(call)
opts.OnToolResult(agent.ToolResult{
ToolCallID: call.ID,
Name: call.Name,
Status: tools.StatusOK,
Output: tc.output,
EnforcementNotices: []string{persistedNotice},
})
return agent.Result{FinalAnswer: "done"}, nil
}
workspace := t.TempDir()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
live := newHarness(t, deps)
var created NewSessionResult
if err := live.client.Call(ctx, MethodSessionNew, NewSessionParams{Cwd: workspace, McpServers: []McpServer{}}, &created); err != nil {
t.Fatalf("session/new: %v", err)
}
if err := live.client.Call(ctx, MethodSessionPrompt, PromptParams{SessionID: created.SessionID, Prompt: []ContentBlock{TextBlock("list the files")}}, &PromptResult{}); err != nil {
t.Fatalf("session/prompt: %v", err)
}
liveText := toolUpdateText(nextToolResultUpdate(t, ctx, live.tools, "live"))
if count := strings.Count(liveText, persistedNotice); count != 1 {
t.Fatalf("premise: the live update carries the notice %d times, want 1:\n%s", count, liveText)
}
live.stop()
loader := newHarness(t, deps)
defer loader.stop()
if err := loader.client.Call(ctx, MethodSessionLoad, LoadSessionParams{SessionID: created.SessionID, Cwd: workspace, McpServers: []McpServer{}}, &LoadSessionResult{}); err != nil {
t.Fatalf("session/load: %v", err)
}
replayed := toolUpdateText(nextToolResultUpdate(t, ctx, loader.tools, "replay"))
if count := strings.Count(replayed, persistedNotice); count != 1 {
t.Errorf("the replayed result carries the notice %d times, want exactly 1:\n%s", count, replayed)
}
if replayed != liveText {
t.Errorf("the replayed result does not match what the client saw live\nlive: %q\nreplayed: %q", liveText, replayed)
}
})
}
}
// THE ACP WRITER IS THE SHARED CONTRACT, NOT A COPY OF IT. The session store is
// the one the TUI and headless exec also write to and resume from, so a payload
// spelled here by hand is a third representation waiting to drift. It already
// had: it was the only writer with no notices and no undecorated body.
func TestToolResultEventIsTheSharedSessionPayload(t *testing.T) {
result := agent.ToolResult{
ToolCallID: "call-1",
Name: "bash",
Status: tools.StatusOK,
Output: "the command output",
EnforcementNotices: []string{persistedNotice},
ChangedFiles: []string{"a.go"},
}
event := toolResultEvent(result)
if event.Type != sessions.EventToolResult {
t.Fatalf("event type = %q", event.Type)
}
if want := agent.ToolResultSessionPayload(result); !reflect.DeepEqual(event.Payload, want) {
t.Fatalf("the ACP payload is not the shared one\n got: %#v\nwant: %#v", event.Payload, want)
}
payload, ok := event.Payload.(map[string]any)
if !ok {
t.Fatalf("payload is %T", event.Payload)
}
if notices, _ := payload["enforcementNotices"].([]string); len(notices) != 1 || notices[0] != persistedNotice {
t.Errorf("typed notices were not persisted: %#v", payload["enforcementNotices"])
}
if body, present := payload["displayPreview"]; !present || body != "the command output" {
t.Errorf("the undecorated body was not persisted: %#v (present=%v)", body, present)
}
}
// A record can reach the reader in either shape, and both have to come out as
// one disclosure: output with the notices already composed in, which is what
// every current writer stores, and output left undecorated beside the typed
// field, which a reader must not answer by dropping the notice.
func TestReplayedToolResultComposesTheNoticeOnceForEitherStoredShape(t *testing.T) {
for _, tc := range []struct {
name string
output string
want string
}{
{name: "output stored decorated", output: persistedNotice + "\n\nthe command output", want: persistedNotice + "\n\nthe command output"},
{name: "output stored undecorated", output: "the command output", want: persistedNotice + "\n\nthe command output"},
{name: "decorated and otherwise empty", output: persistedNotice, want: persistedNotice},
{name: "undecorated and empty", output: "", want: persistedNotice},
} {
t.Run(tc.name, func(t *testing.T) {
update := replayToolUpdate(sessions.Event{Type: sessions.EventToolResult, Payload: mustRawPayload(t, map[string]any{
"toolCallId": "call-1", "name": "bash", "status": "ok",
"output": tc.output, "enforcementNotices": []string{persistedNotice},
})})
if update == nil {
t.Fatal("the stored result was not replayed")
}
if got := toolUpdateText(*update); got != tc.want {
t.Errorf("replayed text = %q, want %q", got, tc.want)
}
})
}
// And a stored result with no notices is replayed as it was written.
plain := replayToolUpdate(sessions.Event{Type: sessions.EventToolResult, Payload: mustRawPayload(t, map[string]any{
"toolCallId": "call-2", "name": "bash", "status": "ok", "output": "plain output",
})})
if plain == nil || toolUpdateText(*plain) != "plain output" {
t.Errorf("an ordinary stored result changed on replay: %+v", plain)
}
}
-65
View File
@@ -1,65 +0,0 @@
package acp
import (
"strings"
"testing"
"github.com/Twigpine/zero/internal/agent"
"github.com/Twigpine/zero/internal/tools"
)
// AN ACP CLIENT MUST SEE THE DISCLOSURE THE TUI SEES.
//
// agent.ToolResult stores the UNDECORATED model text alongside the typed
// enforcement notices; ModelOutput is what composes them. Reading .Output
// directly compiles and looks right, and silently drops the notice for every
// ACP client, which is the one surface with no other way to learn the sandbox
// narrowed what the command could do.
func TestToolResultContentCarriesTheEnforcementNotice(t *testing.T) {
const notice = "least-privilege notice: read access was narrowed"
result := agent.ToolResult{
Name: "bash",
Status: tools.StatusOK,
Output: "the command output",
EnforcementNotices: []string{notice},
}
content := toolResultContent(result)
if len(content) == 0 {
t.Fatal("no content produced for a successful tool result")
}
var text strings.Builder
for _, part := range content {
if part.Content != nil {
text.WriteString(part.Content.Text)
}
}
got := text.String()
if count := strings.Count(got, notice); count != 1 {
t.Errorf("the notice appears %d times, want exactly 1:\n%s", count, got)
}
if !strings.Contains(got, "the command output") {
t.Errorf("the underlying output was lost:\n%s", got)
}
}
// And a result with no notice is unchanged, so the accessor is not adding
// anything to ordinary output.
func TestToolResultContentLeavesAnOrdinaryResultAlone(t *testing.T) {
result := agent.ToolResult{
Name: "bash",
Status: tools.StatusOK,
Output: "plain output",
}
content := toolResultContent(result)
if len(content) == 0 {
t.Fatal("no content produced")
}
if content[0].Content == nil {
t.Fatal("content block missing")
}
if got := content[0].Content.Text; got != "plain output" {
t.Errorf("ordinary output = %q, want it untouched", got)
}
}
+1 -5
View File
@@ -248,11 +248,7 @@ func toolCallResult(result agent.ToolResult) ToolCallUpdate {
func toolResultContent(result agent.ToolResult) []ToolCallContent {
content := make([]ToolCallContent, 0, 1+len(result.FileDiffs))
// ModelOutput, not the raw field. agent.ToolResult stores the undecorated
// model text alongside the typed enforcement notices, and the accessor is
// what composes the two; reading Output directly sends an ACP client the
// output with the disclosure missing.
text := strings.TrimRight(result.ModelOutput(), "\n")
text := strings.TrimRight(result.Output, "\n")
if text == "" {
text = result.Display.Summary
}
-160
View File
@@ -1,160 +0,0 @@
package agent
import (
"context"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
"github.com/Twigpine/zero/internal/execution"
"github.com/Twigpine/zero/internal/hooks"
"github.com/Twigpine/zero/internal/tools"
)
// The tool's own disclosure and the afterTool hook's are the SAME STRING, which
// is the real case: both come from the fixed Windows deny_read warning, so a hook
// running under the same token shape as the tool it follows reports exactly what
// the tool reported.
const sharedEnforcementNotice = "least-privilege notice"
const afterToolChatter = "vet-found-nothing"
// sharedNoticeHookPreparer runs a hook that prints ordinary output and carries
// the same enforcement notice the tool carries.
type sharedNoticeHookPreparer struct{}
func (sharedNoticeHookPreparer) PrepareExecution(_ context.Context, _ execution.Request) (execution.PreparedCommand, error) {
var command *exec.Cmd
if runtime.GOOS == "windows" {
command = exec.Command("cmd.exe", "/c", "echo "+afterToolChatter)
} else {
command = exec.Command("/bin/sh", "-c", "echo "+afterToolChatter)
}
return execution.PreparedCommand{
Command: command,
Enforcement: execution.Enforcement{Notices: []string{sharedEnforcementNotice}},
}, nil
}
func afterToolNoticeDispatcher(t *testing.T) *hooks.Dispatcher {
t.Helper()
audit, err := hooks.NewAuditStore(hooks.AuditStoreOptions{AuditPath: filepath.Join(t.TempDir(), "audit.jsonl")})
if err != nil {
t.Fatalf("NewAuditStore: %v", err)
}
return hooks.NewDispatcher(hooks.DispatcherOptions{
Config: hooks.Config{
Enabled: true,
Hooks: []hooks.Definition{
{ID: "zero.after-tool", Event: hooks.EventAfterTool, Matcher: "notice_projection", Command: "vet", Enabled: true},
},
},
Audit: audit,
Cwd: t.TempDir(),
Execution: execution.NewRunner(sharedNoticeHookPreparer{}),
})
}
// ONE DISCLOSURE, ONE DELIVERY, FROM EITHER HOOK PHASE.
//
// beforeTool was moved onto the typed EnforcementNotices slice and afterTool was
// left folding its notices into the prose feedback. Both halves then wrote the
// same fact: the typed slice, which every surface composes through ModelOutput
// and HumanDisplay, and the "Hook output:" block appended to the body. Since the
// two carry the identical fixed string, the model saw the disclosure twice and a
// bash or exec card showed it in the amber furniture and again in the body.
//
// Half a symmetry is its own defect, and this is the composition that catches it:
// a tool that reports a notice, followed by an afterTool hook that reports the
// same one.
func TestAnAfterToolNoticeIsDeliveredOnce(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(noticeProjectionTool{})
result, err := executeToolCall(context.Background(), registry, ToolCall{
ID: "call-1", Name: "notice_projection", Arguments: `{}`,
}, PermissionModeAuto, Options{Cwd: t.TempDir(), Hooks: afterToolNoticeDispatcher(t)})
if err != nil {
t.Fatalf("executeToolCall: %v", err)
}
// SETUP: the hook really ran, or the silence below is the silence of a hook
// that never executed.
if !strings.Contains(result.ModelOutput(), afterToolChatter) {
t.Fatalf("SETUP INVALID: the afterTool hook's own output never arrived, so nothing here is under test:\n%s", result.ModelOutput())
}
if got := strings.Count(result.ModelOutput(), sharedEnforcementNotice); got != 1 {
t.Errorf("the disclosure reached the model %d times, want once:\n%s", got, result.ModelOutput())
}
if got := strings.Count(strings.Join(result.EnforcementNotices, "\n"), sharedEnforcementNotice); got != 1 {
t.Errorf("the typed slice carries the disclosure %d times, want once: %v", got, result.EnforcementNotices)
}
// The body must not carry it at all: the surfaces draw it from the slice, so a
// copy in the body is what renders it twice.
if strings.Contains(result.BaseModelOutput(), sharedEnforcementNotice) {
t.Errorf("the disclosure is in the result body as well as the typed slice:\n%s", result.BaseModelOutput())
}
if strings.Count(result.HumanDisplay().Summary, sharedEnforcementNotice) != 1 {
t.Errorf("the card summary shows the disclosure %d times, want once:\n%s",
strings.Count(result.HumanDisplay().Summary, sharedEnforcementNotice), result.HumanDisplay().Summary)
}
}
// And an afterTool hook that discloses something the tool did not still gets
// through, on the typed channel rather than as prose.
func TestAnAfterToolNoticeReachesTheTypedSlice(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(quietTool{})
result, err := executeToolCall(context.Background(), registry, ToolCall{
ID: "call-1", Name: "quiet_tool", Arguments: `{}`,
}, PermissionModeAuto, Options{Cwd: t.TempDir(), Hooks: quietToolAfterHookDispatcher(t)})
if err != nil {
t.Fatalf("executeToolCall: %v", err)
}
if !strings.Contains(result.ModelOutput(), afterToolChatter) {
t.Fatalf("SETUP INVALID: the afterTool hook never ran:\n%s", result.ModelOutput())
}
if len(result.EnforcementNotices) != 1 || result.EnforcementNotices[0] != sharedEnforcementNotice {
t.Errorf("an afterTool hook's disclosure did not reach the typed slice, so no card renders it: %v", result.EnforcementNotices)
}
if strings.Contains(result.BaseModelOutput(), sharedEnforcementNotice) {
t.Errorf("the disclosure travelled as prose in the body instead:\n%s", result.BaseModelOutput())
}
}
// quietTool carries no enforcement notice of its own.
type quietTool struct{}
func (quietTool) Name() string { return "quiet_tool" }
func (quietTool) Description() string { return "test tool with no enforcement notice" }
func (quietTool) Parameters() tools.Schema { return tools.Schema{Type: "object"} }
func (quietTool) Safety() tools.Safety {
return tools.Safety{SideEffect: tools.SideEffectRead, Permission: tools.PermissionAllow}
}
func (quietTool) Run(ctx context.Context, args map[string]any) tools.Result {
return tools.Result{Status: tools.StatusOK, Output: "the command output"}
}
func quietToolAfterHookDispatcher(t *testing.T) *hooks.Dispatcher {
t.Helper()
audit, err := hooks.NewAuditStore(hooks.AuditStoreOptions{AuditPath: filepath.Join(t.TempDir(), "audit.jsonl")})
if err != nil {
t.Fatalf("NewAuditStore: %v", err)
}
return hooks.NewDispatcher(hooks.DispatcherOptions{
Config: hooks.Config{
Enabled: true,
Hooks: []hooks.Definition{
{ID: "zero.after-tool", Event: hooks.EventAfterTool, Matcher: "quiet_tool", Command: "vet", Enabled: true},
},
},
Audit: audit,
Cwd: t.TempDir(),
Execution: execution.NewRunner(sharedNoticeHookPreparer{}),
})
}
-210
View File
@@ -1,210 +0,0 @@
package agent
import (
"context"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
"github.com/Twigpine/zero/internal/execution"
"github.com/Twigpine/zero/internal/hooks"
"github.com/Twigpine/zero/internal/tools"
zeroruntime "github.com/Twigpine/zero/internal/zeroruntime"
)
const beforeToolNotice = "denyRead is configured, so the write jail is not confining writes"
// beforeToolChatter is what a hook prints for its own reasons. It must never
// reach the model: main is silent for a successful hook, and a hook that logs is
// not asking to be heard by anything but the operator's terminal.
const beforeToolChatter = "hook-ran-and-logged-this"
// noticeHookPreparer plans the hook command with an enforcement notice attached,
// the way the sandbox does for a command it weakened. The prepared child prints
// ordinary output as well, so one run carries both kinds of text and the
// delivery decision has to tell them apart.
type noticeHookPreparer struct{}
func (noticeHookPreparer) PrepareExecution(_ context.Context, _ execution.Request) (execution.PreparedCommand, error) {
var command *exec.Cmd
if runtime.GOOS == "windows" {
command = exec.Command("cmd.exe", "/c", "echo "+beforeToolChatter)
} else {
command = exec.Command("/bin/sh", "-c", "echo "+beforeToolChatter)
}
return execution.PreparedCommand{
Command: command,
Enforcement: execution.Enforcement{Notices: []string{beforeToolNotice}},
}, nil
}
func beforeToolDispatcher(t *testing.T, event hooks.Event, exitCode int) *hooks.Dispatcher {
t.Helper()
audit, err := hooks.NewAuditStore(hooks.AuditStoreOptions{AuditPath: filepath.Join(t.TempDir(), "audit.jsonl")})
if err != nil {
t.Fatalf("NewAuditStore: %v", err)
}
return hooks.NewDispatcher(hooks.DispatcherOptions{
Config: hooks.Config{
Enabled: true,
Hooks: []hooks.Definition{
{ID: "zero.before-tool", Event: event, Matcher: "read_file", Command: "hook", Enabled: true},
},
},
Audit: audit,
Cwd: t.TempDir(),
Execution: execution.NewRunner(noticeHookPreparer{}),
})
}
func readFileRunOptions(t *testing.T, dispatcher *hooks.Dispatcher) (Options, *mockProvider, string) {
t.Helper()
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "notes.txt"), []byte("hello"), 0o644); err != nil {
t.Fatalf("write notes.txt: %v", err)
}
registry := tools.NewRegistry()
registry.Register(tools.NewReadFileTool(root))
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "read_file"},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"path":"notes.txt"}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
{Type: zeroruntime.StreamEventDone},
},
{
{Type: zeroruntime.StreamEventText, Content: "read it"},
{Type: zeroruntime.StreamEventDone},
},
}}
return Options{
SessionID: "session-hook",
Cwd: root,
Registry: registry,
ProviderName: "test-provider",
Model: "test-model",
Hooks: dispatcher,
MaxTurns: 2,
}, provider, root
}
// countRequestsContaining reports how many provider requests carry needle, so a
// notice delivered twice is distinguishable from one delivered once.
func countRequestsContaining(requests []zeroruntime.CompletionRequest, needle string) int {
total := 0
for _, request := range requests {
for _, message := range request.Messages {
if strings.Contains(message.Content, needle) {
total++
}
}
}
return total
}
// THE NOTICE CROSSES TO THE MODEL. THE HOOK'S OWN OUTPUT DOES NOT.
//
// executeToolCall used to read the beforeTool outcome only when Blocked was
// true, so a hook that ran under the weakened DenyRead token said so to nobody.
// Delivering DispatchOutcome.Messages fixed that and overshot: hookMessage folds
// the notice together with the hook's ordinary stdout, so every successful
// hook's routine logging became a standing input channel into the next model
// request, which is not what main does.
//
// One hook run produces both kinds of text here, because the bug is exactly a
// failure to tell them apart. Asserted on what the PROVIDER received, since that
// is the boundary that matters; a unit test on the joining helper cannot see
// which slice the loop passes it.
func TestSuccessfulBeforeToolHookDeliversItsNoticeAndNotItsOutput(t *testing.T) {
options, provider, _ := readFileRunOptions(t, beforeToolDispatcher(t, hooks.EventBeforeTool, 0))
if _, err := Run(context.Background(), "read the notes", provider, options); err != nil {
t.Fatalf("Run: %v", err)
}
// The tool ran, so this is the successful-hook path rather than a blocked
// call that never reached the tool.
if !someRequestContains(provider.requests, "hello") {
t.Fatal("SETUP INVALID: the tool result never reached the model, so nothing was delivered to check")
}
// And the hook really did run and really did print, or the silence asserted
// below would be the silence of a hook that never executed.
if !someRequestContains(provider.requests, beforeToolNotice) {
t.Fatal("the enforcement notice never reached the model, so a hook could run under the weakened token and say so to nobody")
}
if got := countRequestsContaining(provider.requests, beforeToolNotice); got != 1 {
t.Errorf("the notice reached the model %d times, want exactly once", got)
}
if someRequestContains(provider.requests, beforeToolChatter) {
t.Error("the hook's ordinary output reached the model; main is silent for a successful hook and routine logging must not become model input")
}
}
// A VETO MUST NOT SWALLOW A NOTICE FROM A HOOK THAT ALREADY RAN.
//
// Dispatch runs hooks in order and returns at the first veto. The successful
// hook ahead of it may already have run under the weakened token, and that is a
// fact about something that happened. The veto result used to be built from the
// blocking hook's Reason alone, so the earlier disclosure existed only in the
// audit record.
func TestABlockedCallStillCarriesTheEarlierHooksNotice(t *testing.T) {
audit, err := hooks.NewAuditStore(hooks.AuditStoreOptions{AuditPath: filepath.Join(t.TempDir(), "audit.jsonl")})
if err != nil {
t.Fatalf("NewAuditStore: %v", err)
}
dispatcher := hooks.NewDispatcher(hooks.DispatcherOptions{
Config: hooks.Config{
Enabled: true,
Hooks: []hooks.Definition{
{ID: "zero.first", Event: hooks.EventBeforeTool, Matcher: "read_file", Command: "hook", Enabled: true},
{ID: "zero.veto", Event: hooks.EventBeforeTool, Matcher: "read_file", Command: "veto", Enabled: true},
},
},
Audit: audit,
Cwd: t.TempDir(),
Execution: execution.NewRunner(vetoSecondPreparer{}),
})
options, provider, _ := readFileRunOptions(t, dispatcher)
if _, err := Run(context.Background(), "read the notes", provider, options); err != nil {
t.Fatalf("Run: %v", err)
}
// SETUP: the second hook really did veto, or this is the ordinary path.
if !someRequestContains(provider.requests, "was blocked by hook") {
t.Fatal("SETUP INVALID: the call was not blocked, so the veto path is not under test")
}
if !someRequestContains(provider.requests, beforeToolNotice) {
t.Error("the veto result dropped the notice from the hook that had already run under the weakened token")
}
if got := countRequestsContaining(provider.requests, beforeToolNotice); got != 1 {
t.Errorf("the notice reached the model %d times, want exactly once", got)
}
if someRequestContains(provider.requests, beforeToolChatter) {
t.Error("the vetoed result carried the earlier hook's ordinary output")
}
}
// vetoSecondPreparer runs the first hook successfully with a notice and makes
// the second one exit non-zero, which is a veto for a blocking event.
type vetoSecondPreparer struct{}
func (vetoSecondPreparer) PrepareExecution(_ context.Context, request execution.Request) (execution.PreparedCommand, error) {
script := "echo " + beforeToolChatter
notices := []string{beforeToolNotice}
if request.Command.Name == "veto" {
script = "exit 2"
notices = nil
}
var command *exec.Cmd
if runtime.GOOS == "windows" {
command = exec.Command("cmd.exe", "/c", script)
} else {
command = exec.Command("/bin/sh", "-c", script)
}
return execution.PreparedCommand{
Command: command,
Enforcement: execution.Enforcement{Notices: notices},
}, nil
}
@@ -1,129 +0,0 @@
package agent
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"github.com/Twigpine/zero/internal/execution"
"github.com/Twigpine/zero/internal/hooks"
"github.com/Twigpine/zero/internal/tools"
zeroruntime "github.com/Twigpine/zero/internal/zeroruntime"
)
// editMatchingDispatcher is beforeToolDispatcher for the edit tool, so the same
// notice-and-chatter hook runs ahead of a call whose result carries a diff.
func editMatchingDispatcher(t *testing.T) *hooks.Dispatcher {
t.Helper()
audit, err := hooks.NewAuditStore(hooks.AuditStoreOptions{AuditPath: filepath.Join(t.TempDir(), "audit.jsonl")})
if err != nil {
t.Fatalf("NewAuditStore: %v", err)
}
return hooks.NewDispatcher(hooks.DispatcherOptions{
Config: hooks.Config{
Enabled: true,
Hooks: []hooks.Definition{
{ID: "zero.before-tool", Event: hooks.EventBeforeTool, Matcher: "edit_file", Command: "hook", Enabled: true},
},
},
Audit: audit,
Cwd: t.TempDir(),
Execution: execution.NewRunner(noticeHookPreparer{}),
})
}
// editFileRunOptions is readFileRunOptions for a tool whose result carries a
// rich preview, which is the case the disclosure went missing on.
func editFileRunOptions(t *testing.T, dispatcher *hooks.Dispatcher, onResult func(ToolResult)) (Options, *mockProvider) {
t.Helper()
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "x.go"), []byte("package main\n\nconst answer = 41\n"), 0o644); err != nil {
t.Fatalf("write x.go: %v", err)
}
registry := tools.NewRegistry()
registry.Register(tools.NewScopedEditFileTool(root, nil))
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "edit_file"},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"path":"x.go","old_string":"41","new_string":"42"}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
{Type: zeroruntime.StreamEventDone},
},
{
{Type: zeroruntime.StreamEventText, Content: "edited it"},
{Type: zeroruntime.StreamEventDone},
},
}}
return Options{
SessionID: "session-hook-edit",
Cwd: root,
Registry: registry,
ProviderName: "test-provider",
Model: "test-model",
Hooks: dispatcher,
OnToolResult: onResult,
PermissionMode: PermissionModeUnsafe,
MaxTurns: 2,
}, provider
}
// THE NOTICE HAS TO SURVIVE A RESULT WHOSE BODY IS A DIFF.
//
// This is the boundary the component suites left between them. The delivery test
// above proves a beforeTool notice reaches the provider with read_file, whose
// body is its output. The card tests prove a card renders a notice when the
// result already carries one typed. Neither composed the two, and in between them
// the notice was being folded into result.Output as hook prose.
//
// For an edit or a write that is exactly where it disappears: the card builds its
// body from Display.Preview rather than Output, and draws enforcement furniture
// only from the typed slice, so the operator saw the diff and no disclosure at
// all, live and restored, while the model saw the disclosure. A notice about a
// weakened token was hidden on precisely the results where something was written.
func TestABeforeToolNoticeSurvivesARichPreviewResult(t *testing.T) {
dispatcher := editMatchingDispatcher(t)
var results []ToolResult
options, provider := editFileRunOptions(t, dispatcher, func(result ToolResult) {
results = append(results, result)
})
if _, err := Run(context.Background(), "bump the answer", provider, options); err != nil {
t.Fatalf("Run: %v", err)
}
if len(results) != 1 {
t.Fatalf("SETUP INVALID: %d tool results, want the one edit", len(results))
}
result := results[0]
if result.Status != tools.StatusOK {
t.Fatalf("SETUP INVALID: the edit failed, so this is not the successful rich-preview path: %s", result.Output)
}
preview := result.BaseDisplay().Preview
if !strings.Contains(preview, "42") {
t.Fatalf("SETUP INVALID: the result carries no diff preview, which is the whole case under test: %q", preview)
}
// TYPED, which is what every interactive surface reads.
if len(result.EnforcementNotices) != 1 || result.EnforcementNotices[0] != beforeToolNotice {
t.Errorf("the hook's disclosure did not reach the typed field, so the card and the restored card show the diff and no disclosure: %v", result.EnforcementNotices)
}
// AND NOT IN THE BODY AS WELL. Decoration has one owner per surface; carrying
// it in both places renders it twice wherever the surface draws the slice.
if strings.Contains(result.BaseModelOutput(), beforeToolNotice) {
t.Errorf("the disclosure is in the result body as well as the typed field:\n%s", result.BaseModelOutput())
}
// The diff itself stays a parseable diff.
if strings.Contains(preview, beforeToolNotice) {
t.Errorf("the disclosure was glued onto the diff:\n%s", preview)
}
// And the model still sees it, exactly once, with none of the hook's chatter.
if got := countRequestsContaining(provider.requests, beforeToolNotice); got != 1 {
t.Errorf("the notice reached the model %d times, want exactly once", got)
}
if someRequestContains(provider.requests, beforeToolChatter) {
t.Error("the hook's ordinary output reached the model")
}
}
+34
View File
@@ -0,0 +1,34 @@
package agent
import "github.com/Twigpine/zero/internal/tools"
// CheckpointTargets returns the workspace-relative paths a tool call will mutate,
// resolving its arguments exactly as executeToolCall does. Freeform apply_patch
// input is normalized through the registered built-in tool, so absolute headers
// are rewritten relative to the selected write root and cwd is set: checkpoint
// capture and execution therefore agree on the files at stake.
//
// Best-effort: nil when the call has no capture targets or its arguments cannot
// be resolved. Checkpoint capture never fails a run.
func CheckpointTargets(workspaceRoot string, registry *tools.Registry, call ToolCall) []string {
if registry == nil {
return nil
}
args := map[string]any{}
if call.Freeform {
tool, ok := registry.Get(call.Name)
if !ok || !tools.IsBuiltInApplyPatch(tool) {
return nil
}
prepared, err := tools.PrepareFreeformApplyPatchArguments(tool, call.Arguments)
if err != nil {
return nil
}
args = prepared
} else if call.Arguments != "" {
if err := decodeToolArguments(call.Arguments, &args); err != nil {
return nil
}
}
return tools.MutationTargets(workspaceRoot, call.Name, args)
}
+99
View File
@@ -0,0 +1,99 @@
package agent
import (
"encoding/json"
"os"
"path/filepath"
"slices"
"testing"
"github.com/Twigpine/zero/internal/tools"
)
func TestCheckpointTargets(t *testing.T) {
root := t.TempDir()
// Absolute headers must be compared against the physical root: t.TempDir
// differs from it on macOS (/var -> /private/var) and via Windows short names.
if resolved, err := filepath.EvalSymlinks(root); err == nil {
root = resolved
}
if err := os.WriteFile(filepath.Join(root, "main.txt"), []byte("before\n"), 0o644); err != nil {
t.Fatal(err)
}
relativePatch := "*** Begin Patch\n*** Update File: main.txt\n@@\n-before\n+after\n*** End Patch\n"
absolutePatch := "*** Begin Patch\n*** Update File: " + filepath.Join(root, "main.txt") + "\n@@\n-before\n+after\n*** End Patch\n"
multiFilePatch := "*** Begin Patch\n*** Update File: main.txt\n@@\n-before\n+after\n*** Add File: notes.txt\n+new\n*** End Patch\n"
jsonArgs, err := json.Marshal(map[string]any{"patch": relativePatch})
if err != nil {
t.Fatal(err)
}
registry := tools.NewRegistry()
registry.Register(tools.NewScopedApplyPatchTool(root, nil))
unbuiltinRegistry := tools.NewRegistry()
unbuiltinRegistry.Register(&customApplyPatchTool{})
for _, tc := range []struct {
name string
registry *tools.Registry
call ToolCall
want []string
}{
{
name: "freeform relative header",
call: ToolCall{Name: "apply_patch", Arguments: relativePatch, Freeform: true},
want: []string{"main.txt"},
},
{
name: "freeform absolute header stays workspace-relative",
call: ToolCall{Name: "apply_patch", Arguments: absolutePatch, Freeform: true},
want: []string{"main.txt"},
},
{
name: "freeform multi-file patch",
call: ToolCall{Name: "apply_patch", Arguments: multiFilePatch, Freeform: true},
want: []string{"main.txt", "notes.txt"},
},
{
name: "json arguments",
call: ToolCall{Name: "apply_patch", Arguments: string(jsonArgs)},
want: []string{"main.txt"},
},
{
name: "freeform traversal is rejected",
call: ToolCall{Name: "apply_patch", Arguments: "*** Begin Patch\n*** Update File: ../escape.txt\n@@\n-x\n+y\n*** End Patch\n", Freeform: true},
want: nil,
},
{
name: "freeform custom apply_patch is not prepared",
registry: unbuiltinRegistry,
call: ToolCall{Name: "apply_patch", Arguments: relativePatch, Freeform: true},
want: nil,
},
{
name: "unknown freeform tool",
call: ToolCall{Name: "read_file", Arguments: "raw", Freeform: true},
want: nil,
},
{
name: "malformed json arguments",
call: ToolCall{Name: "apply_patch", Arguments: "{"},
want: nil,
},
} {
t.Run(tc.name, func(t *testing.T) {
reg := registry
if tc.registry != nil {
reg = tc.registry
}
got := CheckpointTargets(root, reg, tc.call)
if !slices.Equal(got, tc.want) {
t.Fatalf("CheckpointTargets() = %#v, want %#v", got, tc.want)
}
})
}
if got := CheckpointTargets(root, nil, ToolCall{Name: "apply_patch", Arguments: relativePatch}); got != nil {
t.Fatalf("CheckpointTargets(nil registry) = %#v, want nil", got)
}
}
@@ -1,114 +0,0 @@
package agent
import (
"context"
"strings"
"testing"
"github.com/Twigpine/zero/internal/tools"
)
// noticeProjectionTool stands in for any sandboxed command tool: it reports the
// enforcement disclosure the way the real ones do, through the sandbox metadata
// key that finalizeToolOutcome promotes into the typed notice slice.
type noticeProjectionTool struct{}
func (noticeProjectionTool) Name() string { return "notice_projection" }
func (noticeProjectionTool) Description() string { return "test tool carrying an enforcement notice" }
func (noticeProjectionTool) Parameters() tools.Schema { return tools.Schema{Type: "object"} }
func (noticeProjectionTool) Safety() tools.Safety {
return tools.Safety{SideEffect: tools.SideEffectRead, Permission: tools.PermissionAllow}
}
func (noticeProjectionTool) Run(ctx context.Context, args map[string]any) tools.Result {
return tools.Result{
Status: tools.StatusOK,
Output: "the command output",
Display: tools.Display{Summary: "the human summary"},
Meta: map[string]string{"sandbox_notices": "least-privilege notice"},
}
}
// THE PROJECTION MUST CARRY ONE REPRESENTATION, NOT TWO.
//
// executeToolCall copies the typed notice slice into agent.ToolResult, so the
// text fields it copies alongside must be the UNDECORATED base. Storing the
// already-rendered text there instead leaves the same fact in two places with no
// contract between them: it happens to render once today only because the
// outcome arrives finalized and the agent accessor then reads Outcome.ModelView
// rather than the stored field. Any result that reaches the accessor without a
// finalized outcome renders the disclosure twice, and every raw reader of
// .Output sees text that disagrees with Outcome.ModelView.
func TestEnforcementNoticeIsStoredOnceAndRenderedOnce(t *testing.T) {
const notice = "least-privilege notice"
registry := tools.NewRegistry()
registry.Register(noticeProjectionTool{})
result, err := executeToolCall(context.Background(), registry, ToolCall{
ID: "call-1", Name: "notice_projection", Arguments: `{}`,
}, PermissionModeAuto, Options{Cwd: t.TempDir()})
if err != nil {
t.Fatalf("executeToolCall: %v", err)
}
if len(result.EnforcementNotices) == 0 {
t.Fatalf("the notice never reached the agent result: %#v", result)
}
// The stored fields are the canonical undecorated base, and they agree with
// the finalized outcome they were projected from.
if strings.Contains(result.Output, notice) {
t.Errorf("ToolResult.Output stores the rendered notice as well as the slice: %q", result.Output)
}
if strings.Contains(result.Display.Summary, notice) {
t.Errorf("ToolResult.Display.Summary stores the rendered notice as well as the slice: %q", result.Display.Summary)
}
if result.Output != result.Outcome.ModelView {
t.Errorf("stored output %q disagrees with the finalized model view %q", result.Output, result.Outcome.ModelView)
}
if result.Display.Summary != result.Outcome.HumanView.Summary {
t.Errorf("stored summary %q disagrees with the finalized human view %q", result.Display.Summary, result.Outcome.HumanView.Summary)
}
// And every consumer that renders goes through the accessors, which show the
// disclosure exactly once without hiding the output it is attached to.
// loop.go builds the provider transcript from ModelOutput; the CLI writer and
// the TUI cards use both accessors.
transcript := result.ModelOutput()
if got := strings.Count(transcript, notice); got != 1 {
t.Errorf("the transcript shows the notice %d times, want 1: %q", got, transcript)
}
if !strings.Contains(transcript, "the command output") {
t.Errorf("the transcript lost the command output: %q", transcript)
}
summary := result.HumanDisplay().Summary
if got := strings.Count(summary, notice); got != 1 {
t.Errorf("the human summary shows the notice %d times, want 1: %q", got, summary)
}
if !strings.Contains(summary, "the human summary") {
t.Errorf("the human summary lost the tool summary: %q", summary)
}
}
// A result that never crossed the registry has no finalized outcome, so the
// accessor falls back to the stored field. That is the path on which a stored
// rendering would double, and it is the reason the contract above is stated on
// the stored fields rather than only on the accessors.
func TestUnfinalizedResultStillRendersTheNoticeOnce(t *testing.T) {
const notice = "least-privilege notice"
result := ToolResult{
Status: tools.StatusOK,
Output: "the command output",
Display: tools.Display{Summary: "the human summary"},
EnforcementNotices: []string{notice},
}
if result.Outcome.Finalized() {
t.Fatal("fixture is finalized; it no longer covers the fallback path")
}
if got := strings.Count(result.ModelOutput(), notice); got != 1 {
t.Errorf("ModelOutput shows the notice %d times, want 1: %q", got, result.ModelOutput())
}
if got := strings.Count(result.HumanDisplay().Summary, notice); got != 1 {
t.Errorf("HumanDisplay shows the notice %d times, want 1: %q", got, result.HumanDisplay().Summary)
}
}
+64
View File
@@ -80,6 +80,9 @@ func TestRunStopsAfterConsecutiveEmptyTurns(t *testing.T) {
if result.FinalAnswer == maxTurnsAnswer {
t.Fatalf("no-output guard must stop before reaching maxTurns, got max-turns answer")
}
if result.Incomplete {
t.Fatalf("interactive run must not be marked Incomplete; reason=%q", result.IncompleteReason)
}
}
func TestRunResetsEmptyTurnCounterOnVisibleOutput(t *testing.T) {
@@ -513,6 +516,22 @@ func repeatedFlakyTurns(n int) [][]zeroruntime.StreamEvent {
return turns
}
// alwaysSucceedingTool is the successful counterpart to alwaysFailingTool, used
// to put a productive tool turn before an empty-turn streak.
type alwaysSucceedingTool struct{}
func (alwaysSucceedingTool) Name() string { return "ok" }
func (alwaysSucceedingTool) Description() string { return "always succeeds for testing" }
func (alwaysSucceedingTool) Parameters() tools.Schema {
return tools.Schema{Type: "object", AdditionalProperties: false}
}
func (alwaysSucceedingTool) Safety() tools.Safety {
return tools.Safety{SideEffect: tools.SideEffectRead, Permission: tools.PermissionAllow}
}
func (alwaysSucceedingTool) Run(context.Context, map[string]any) tools.Result {
return tools.Result{Status: tools.StatusOK, Output: "ok"}
}
func TestRunStopsAfterRepeatedToolFailures(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(alwaysFailingTool{})
@@ -529,6 +548,51 @@ func TestRunStopsAfterRepeatedToolFailures(t *testing.T) {
if len(provider.requests) != toolFailureStopAt {
t.Fatalf("expected stop at %d failures, made %d requests", toolFailureStopAt, len(provider.requests))
}
if result.Incomplete {
t.Fatalf("interactive run must not be marked Incomplete; reason=%q", result.IncompleteReason)
}
}
// Under the headless completion gate both guardrail stops are unfinished runs,
// so they must set Result.Incomplete and a reason; exec maps that to exit 4.
// Thresholds and stop answers stay pinned by the two tests above.
func TestHeadlessGuardStopsAreIncomplete(t *testing.T) {
for _, tc := range []struct {
name string
turns [][]zeroruntime.StreamEvent
wantTurns int
wantReason string
}{
{"empty_turns", [][]zeroruntime.StreamEvent{emptyTurn(), emptyTurn(), emptyTurn()}, maxEmptyTurns, "3 consecutive turns with no output"},
{"empty_turns_after_progress", [][]zeroruntime.StreamEvent{
toolTurn("ok-1", "ok", "{}"),
emptyTurn(), emptyTurn(), emptyTurn(),
}, maxEmptyTurns + 1, "3 consecutive turns with no output"},
{"repeated_tool_failures", repeatedFlakyTurns(10), toolFailureStopAt, "flaky"},
} {
t.Run(tc.name, func(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(alwaysFailingTool{})
registry.Register(alwaysSucceedingTool{})
result, err := Run(context.Background(), "go", &mockProvider{turns: tc.turns}, Options{
Registry: registry,
MaxTurns: 12,
RequireCompletionSignal: true,
})
if err != nil {
t.Fatal(err)
}
if result.Turns != tc.wantTurns {
t.Fatalf("guard did not trigger: turns=%d want=%d", result.Turns, tc.wantTurns)
}
if !result.Incomplete {
t.Fatalf("guardrail stop under the headless gate must be Incomplete; final=%q", result.FinalAnswer)
}
if !strings.Contains(result.IncompleteReason, tc.wantReason) {
t.Fatalf("IncompleteReason = %q, want it to mention %q", result.IncompleteReason, tc.wantReason)
}
})
}
}
func TestRunInjectsToolFailureHintWithSchema(t *testing.T) {
+2 -92
View File
@@ -84,102 +84,12 @@ func TestBlockedByHookResultFallsBackWhenReasonEmpty(t *testing.T) {
}
}
// THE BLOCKING HOOK'S OWN NOTICE IS A NOTICE, NOT PART OF THE VETO TEXT.
//
// It used to arrive folded into Reason, so it reached the model inside the error
// message and never reached EnforcementNotices, which is what every surface
// renders as the disclosure and what the session record keeps apart from the
// body.
func TestBlockedByHookResultCarriesTheBlockersNoticeOnTheTypedSlice(t *testing.T) {
const notice = "denyRead is configured, so the write jail is not confining writes"
out := blockedByHookResult(
ToolCall{ID: "c4", Name: "bash"},
hooks.DispatchOutcome{Blocked: true, BlockedBy: "policy", Reason: "policy violation", Notices: []string{notice}},
)
if len(out.EnforcementNotices) != 1 || out.EnforcementNotices[0] != notice {
t.Fatalf("EnforcementNotices = %#v, want the blocking hook's notice", out.EnforcementNotices)
}
if strings.Contains(out.Output, notice) {
t.Errorf("the notice is also inside the veto message, so it is said twice:\n%s", out.Output)
}
if got := strings.Count(out.ModelOutput(), notice); got != 1 {
t.Errorf("the model sees the notice %d times, want once:\n%s", got, out.ModelOutput())
}
}
// AN EARLIER HOOK'S NOTICE SURVIVES A REASON THAT QUOTES IT.
//
// The veto path used to drop every notice whose text appeared anywhere in the
// blocking hook's reason, on the theory that the reason already carried it. A
// blocking hook that printed the same sentence, for example by relaying another
// tool's stderr, therefore removed an earlier hook's disclosure from the typed
// slice.
func TestBlockedByHookResultKeepsAnEarlierNoticeTheReasonQuotes(t *testing.T) {
const notice = "denyRead is configured, so the write jail is not confining writes"
out := blockedByHookResult(
ToolCall{ID: "c5", Name: "bash"},
hooks.DispatchOutcome{
Blocked: true,
BlockedBy: "veto",
Reason: "refusing: the last run reported \"" + notice + "\"",
Notices: []string{notice},
},
)
if len(out.EnforcementNotices) != 1 || out.EnforcementNotices[0] != notice {
t.Fatalf("the earlier hook's notice was dropped because the veto quoted it: %#v", out.EnforcementNotices)
}
}
func TestDispatchHelpersAreNoopWithoutDispatcher(t *testing.T) {
options := Options{} // Hooks is nil
if _, blocked := dispatchBeforeTool(context.Background(), options, ToolCall{Name: "bash"}, nil); blocked {
t.Fatal("a nil dispatcher must never block a tool")
}
if feedback, notices := dispatchAfterTool(context.Background(), options, ToolCall{Name: "bash"}, nil, tools.Result{}); feedback != "" || notices != nil {
t.Fatalf("a nil dispatcher must yield no feedback and no notices, got %q and %v", feedback, notices)
}
}
// A SUCCESSFUL beforeTool HOOK'S NOTICE MUST STAY TYPED.
//
// executeToolCall used to read the beforeTool outcome only when Blocked was true,
// so a hook that ran fine and produced an enforcement notice — for instance that
// it ran under the weakened DenyRead token — put that notice in the audit record
// and nowhere anybody could see it. The first fix delivered it as prose appended
// to the result output, which reached the model and no interactive surface,
// because those build their enforcement furniture from the typed slice.
//
// So the delivery is the typed field, and this pins the merge: the hook's notice
// first, the tool's own after it, each exactly once, blanks contributing nothing.
func TestBeforeToolNoticesMergeIntoTheTypedField(t *testing.T) {
const notice = "hook ran without WRITE_RESTRICTED because denyRead is configured"
const toolOwned = "the sandbox dropped the network capability for this call"
result := withAppliedHookNotices(ToolResult{Output: "ok"}, []string{notice}, nil)
if len(result.EnforcementNotices) != 1 || result.EnforcementNotices[0] != notice {
t.Fatalf("a successful beforeTool notice did not reach the typed field: %v", result.EnforcementNotices)
}
// AND NOT THE OUTPUT AS WELL, or every surface that renders the slice shows
// the disclosure twice.
if strings.Contains(result.Output, notice) {
t.Errorf("the notice was written into the output as well as the typed field: %q", result.Output)
}
// Both arrive, hook first, when the tool carries its own.
result = withAppliedHookNotices(ToolResult{EnforcementNotices: []string{toolOwned}}, []string{notice}, nil)
if len(result.EnforcementNotices) != 2 || result.EnforcementNotices[0] != notice || result.EnforcementNotices[1] != toolOwned {
t.Fatalf("the hook and tool notices did not merge in order: %v", result.EnforcementNotices)
}
// The same disclosure from both sides is carried once.
result = withAppliedHookNotices(ToolResult{EnforcementNotices: []string{notice}}, []string{notice}, nil)
if len(result.EnforcementNotices) != 1 {
t.Errorf("one disclosure reported by both the hook and the tool was carried %d times: %v", len(result.EnforcementNotices), result.EnforcementNotices)
}
// Blank notices contribute nothing, so a run with no hook output stays silent.
result = withAppliedHookNotices(ToolResult{}, []string{"", " "}, nil)
if len(result.EnforcementNotices) != 0 {
t.Errorf("blank hook notices produced %v, want nothing", result.EnforcementNotices)
if feedback := dispatchAfterTool(context.Background(), options, ToolCall{Name: "bash"}, nil, tools.Result{}); feedback != "" {
t.Fatalf("a nil dispatcher must yield no feedback, got %q", feedback)
}
}
+51 -191
View File
@@ -587,6 +587,14 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
// counted toward the runaway cap so we stop before burning maxTurns.
if guards.observeTurn(collected) {
result.FinalAnswer = noOutputStopAnswer(result.Turns)
// Under the headless completion gate this stop is unfinished work,
// not a successful final answer: the guard exists precisely because
// the model made no progress. Interactive runs keep the gate off, so
// their behavior is unchanged.
if options.RequireCompletionSignal {
result.Incomplete = true
result.IncompleteReason = "stopped after " + strconv.Itoa(guards.emptyTurns) + " consecutive turns with no output"
}
result.Messages = copyMessages(messages)
return result, nil
}
@@ -808,6 +816,13 @@ 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
}
@@ -1467,31 +1482,10 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
}
// beforeTool hooks may veto the call before it runs (a non-zero exit blocks).
//
// A SUCCESSFUL beforeTool HOOK STILL HAS SOMETHING TO SAY, BUT ONLY ITS NOTICE.
//
// Reading the outcome only when Blocked left the enforcement disclosure in the
// audit record and nowhere the model or the operator could see it: a hook could
// run under the weakened DenyRead token and say so to nobody.
//
// Notices, NOT Messages. Messages is presentation text that hookMessage builds
// by folding the notice together with the hook's ordinary stdout, so delivering
// it would put every successful hook's routine logging, large diagnostics, and
// whatever text a hook happened to process into the next model request. That is
// a behaviour change nobody asked for and a standing input channel. main is
// silent for successful hooks and stays silent here for everything except the
// disclosure. Carried to the tool result below, the same surface afterTool
// feedback already uses.
var beforeToolNotices []string
// Filled after the tool runs, and merged with beforeTool's at the one
// finalization below so both phases land on the same contract.
var afterToolNotices []string
if toolFound {
outcome, blocked := dispatchBeforeTool(ctx, options, call, args)
if blocked {
if outcome, blocked := dispatchBeforeTool(ctx, options, call, args); blocked {
return blockedByHookResult(call, outcome), nil
}
beforeToolNotices = outcome.Notices
}
args = shellExecutionArgsForApproval(call.Name, args, decisionAction, options)
@@ -1537,10 +1531,7 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
})
if retryResult, directResult, retried, action, reason, prefix, abortErr := maybeRetryUnsandboxedAfterSandboxRestriction(ctx, registry, call, tool, args, result, permissionMode, options, progressCallback); retried || directResult != nil || abortErr != nil {
if directResult != nil {
// A denied, cancelled, or ungrantable retry still returns a result for a
// call whose beforeTool hook already ran. Without this the disclosure is
// produced and then dropped on the floor.
return withAppliedHookNotices(*directResult, beforeToolNotices, nil), abortErr
return *directResult, abortErr
}
result = retryResult
permissionGranted = true
@@ -1566,15 +1557,7 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
// afterTool hooks run once the tool has executed; their output (e.g. a
// formatter or vet result) is surfaced back to the model on the result.
if toolFound {
// NEITHER PHASE'S NOTICES ARE FOLDED IN HERE. They are typed enforcement
// data on both sides now, and appending them as hook prose was what kept
// beforeTool's out of every surface that renders the typed slice, and what
// made afterTool's arrive twice once that slice was composed. Only the
// hook's own output, which is what an afterTool validator asked to say,
// still travels this way.
feedback, notices := dispatchAfterTool(ctx, options, call, args, result)
afterToolNotices = notices
if strings.TrimSpace(feedback) != "" {
if feedback := dispatchAfterTool(ctx, options, call, args, result); feedback != "" {
var didRedact bool
result.Output, didRedact = appendHookFeedback(result.Output, feedback)
if didRedact {
@@ -1601,32 +1584,28 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
// Secret scrubbing happens at the registry boundary (the single point both
// the agent loop and the MCP server pass through), so result.Output is
// already redacted here and result.Redacted reflects whether it changed.
//
// Wrapped in the same finalization every other return from a hooked call
// uses, so the normal path cannot drift away from the veto and retry paths.
return withAppliedHookNotices(ToolResult{
Risk: executedRisk,
ToolCallID: call.ID,
Name: call.Name,
Status: result.Status,
Output: result.BaseModelOutput(),
Truncated: result.Truncated,
Meta: result.Meta,
EnforcementNotices: append([]string(nil), result.EnforcementNotices...),
Images: result.Images,
Redacted: result.Redacted,
ChangedFiles: result.ChangedFiles,
FileDiffs: result.FileDiffs,
ChangeSummaries: result.ChangeSummaries,
Display: result.BaseDisplay(),
Outcome: result.Outcome,
LoadedTools: loadedToolsFromResult(result.Meta),
return ToolResult{
Risk: executedRisk,
ToolCallID: call.ID,
Name: call.Name,
Status: result.Status,
Output: result.ModelOutput(),
Truncated: result.Truncated,
Meta: result.Meta,
Images: result.Images,
Redacted: result.Redacted,
ChangedFiles: result.ChangedFiles,
FileDiffs: result.FileDiffs,
ChangeSummaries: result.ChangeSummaries,
Display: result.HumanDisplay(),
Outcome: result.Outcome,
LoadedTools: loadedToolsFromResult(result.Meta),
// A tool may signal a mid-run model escalation by carrying the target id
// in Meta["escalate_to_model"]. Lift it into the typed loop-level field;
// the Run turn loop performs the actual provider switch. Empty for every
// ordinary tool result.
RequestedModel: result.Meta["escalate_to_model"],
}, beforeToolNotices, afterToolNotices), nil
}, nil
}
const sandboxNamespaceLimitedReason = "sandbox output is limited to the sandbox PID namespace; host/global state requires approval"
@@ -2010,9 +1989,9 @@ func dispatchBeforeTool(ctx context.Context, options Options, call ToolCall, arg
// dispatchAfterTool runs configured afterTool hooks once a tool has executed and
// returns any advisory output (e.g. a formatter or vet result) to surface back
// to the model. afterTool hooks never block. A nil dispatcher is a no-op.
func dispatchAfterTool(ctx context.Context, options Options, call ToolCall, args map[string]any, result tools.Result) (string, []string) {
func dispatchAfterTool(ctx context.Context, options Options, call ToolCall, args map[string]any, result tools.Result) string {
if options.Hooks == nil || hooksSuppressed(options) {
return "", nil
return ""
}
outcome := options.Hooks.Dispatch(ctx, hooks.DispatchInput{
Event: hooks.EventAfterTool,
@@ -2028,13 +2007,7 @@ func dispatchAfterTool(ctx context.Context, options Options, call ToolCall, args
"changedFiles": result.ChangedFiles,
},
})
// Both halves, because they have different destinations. The hook's own output
// is prose that belongs in the result body where an afterTool validator's
// findings have always gone; its enforcement notices are typed data that the
// caller merges into the result the same way beforeTool's are. Returning only
// the joined messages was what left afterTool on the old channel while
// beforeTool moved, and a notice reported by both then appeared twice.
return strings.TrimSpace(strings.Join(outcome.Messages, "\n")), outcome.Notices
return strings.TrimSpace(strings.Join(outcome.Messages, "\n"))
}
// dispatchSessionStart runs configured sessionStart hooks once before the first
@@ -2098,7 +2071,7 @@ func blockedByHookResult(call ToolCall, outcome hooks.DispatchOutcome) ToolResul
reason = "blocked by a beforeTool hook"
}
message := fmt.Sprintf("Error: %q was blocked by hook %q: %s", call.Name, outcome.BlockedBy, reason)
result := ToolResult{
return ToolResult{
ToolCallID: call.ID,
Name: call.Name,
Status: tools.StatusError,
@@ -2106,124 +2079,12 @@ func blockedByHookResult(call ToolCall, outcome hooks.DispatchOutcome) ToolResul
Redacted: redacted,
DenialReason: DenialHookBlocked,
}
// Dispatch runs hooks in order and stops at the first veto, so an earlier hook
// may already have run under a weakened token before this one said no. Its
// notice describes something that happened and has to survive the veto, and so
// does the blocking hook's own.
//
// ALL OF THEM, FROM THE TYPED SLICE. Reason carries the hook's words only, so
// nothing here is said twice and nothing has to be filtered out. The filter
// this replaced matched notices against the reason text, and dropped an
// earlier hook's disclosure whenever the blocking hook's output quoted it.
return withAppliedHookNotices(result, outcome.Notices, nil)
}
// withAppliedHookNotices is the single place a hook enforcement notice
// reaches a tool result on a path that does NOT run afterTool.
//
// The normal tail joins the notices with the afterTool feedback and delivers
// both at once. Two other exits return a result for a call whose hook already
// ran: a later hook's veto, and a denied, cancelled, or ungrantable unsandboxed
// retry. Routing all three through one function is what keeps "the hook ran
// under this token" from depending on which exit the call happened to take.
//
// NO REBUDGET HERE, AND THAT IS LOAD-BEARING ON WHAT MAY PASS THROUGH. The
// normal tail appends and then calls Registry.RebudgetAfterHook, because what it
// appends is afterTool feedback: hook stdout, which a hook can make arbitrarily
// large. These notices cannot be: they are CommandPlan.Notes, which only this
// package's own fixed sentences may fill (none does today; #1006 refuses the
// denyRead trade the last one described), and nothing hook-authored reaches
// this slice: DispatchOutcome.Messages is where
// hook output lives, and the capture site deliberately does not read it.
//
// So if anything ever widens what is delivered here to include text a hook or a
// tool can size, this needs the rebudget step as well, which means converting
// through tools.Result the way the tail does rather than editing Output in
// place. Do not widen it without that.
// withAppliedHookNotices carries a hook's disclosures onto the result as TYPED
// enforcement notices, and is the single finalization point every return path
// from a hooked call goes through.
//
// BOTH PHASES, because half a symmetry is its own defect. beforeTool was moved
// onto the typed slice while afterTool was left folding its notices into the
// prose feedback, and since the two carry the identical fixed string, a
// disclosure reported by both then reached the model twice: once from the typed
// prepend every surface composes, once inside the hook feedback in the body. A
// bash or exec card showed it in the furniture and again in the body.
//
// It used to fold them into result.Output as prose. That reached the provider,
// which reads the output, and reached nothing else. Every interactive surface
// builds its enforcement furniture from the typed slice, and for an edit or a
// write the card shows Display.Preview instead of Output, so the disclosure was
// absent from the live card in both its collapsed and expanded states and from
// the session payload that restores them. A notice about a weakened token was
// therefore shown to the model and hidden from the operator, on exactly the
// results where something was written.
//
// Typed here, the canonical accessors compose it per surface, the same way a
// tool's own notices already work. Nothing is written into Output as well:
// decoration has one owner per surface or the disclosure appears twice.
// Hook notices lead, in run order, with the tool's own after them: dedupe makes
// the ordering moot for the duplicate case that motivated this, and for distinct
// disclosures it keeps the shape the tool-owned notices already had.
func withAppliedHookNotices(result ToolResult, before []string, after []string) ToolResult {
hookNotices := before
if len(after) > 0 {
hookNotices = append(append([]string(nil), before...), after...)
}
merged, didRedact := mergeEnforcementNotices(hookNotices, result.EnforcementNotices)
result.EnforcementNotices = merged
if didRedact {
result.Redacted = true
}
return result
}
// mergeEnforcementNotices puts the hook disclosures ahead of the
// tool's own and drops exact repeats, so a surface rendering the slice shows
// each disclosure exactly once.
//
// Hook notices are third-party text arriving on an intercepted path that bypasses
// the registry's redaction boundary, so they are scrubbed here the way
// appendHookFeedback scrubbed them while they travelled as prose. The bool
// reports whether scrubbing changed anything, so Redacted keeps matching the
// registry's contract.
func mergeEnforcementNotices(before []string, own []string) ([]string, bool) {
if len(before) == 0 && len(own) == 0 {
return nil, false
}
merged := make([]string, 0, len(before)+len(own))
seen := make(map[string]struct{}, len(before)+len(own))
redacted := false
for index, notice := range append(append([]string(nil), before...), own...) {
if index < len(before) {
scrubbed := redaction.RedactString(notice, redaction.Options{})
redacted = redacted || scrubbed != notice
notice = scrubbed
}
if strings.TrimSpace(notice) == "" {
continue
}
if _, already := seen[notice]; already {
continue
}
seen[notice] = struct{}{}
merged = append(merged, notice)
}
if len(merged) == 0 {
return nil, redacted
}
return merged, redacted
}
// appendHookFeedback appends afterTool hook output to a tool result's output,
// scrubbed for secrets like every other string crossing the tool boundary. The
// returned bool reports whether scrubbing changed the feedback, so the caller can
// set ToolResult.Redacted to match the registry's redaction contract.
// The joiner that folded beforeTool notices in with the afterTool feedback is
// gone. Sending a typed enforcement notice out as hook prose was the defect, not
// the delivery: see withAppliedHookNotices. afterTool feedback still arrives here
// on its own, which is all this path was ever meant to carry.
func appendHookFeedback(output string, feedback string) (string, bool) {
scrubbed := redaction.RedactString(feedback, redaction.Options{})
redacted := scrubbed != feedback
@@ -2358,19 +2219,18 @@ func askUserFallbackResult(ctx context.Context, registry *tools.Registry, call T
Cwd: options.Cwd,
})
return ToolResult{
ToolCallID: call.ID,
Name: call.Name,
Status: result.Status,
Output: result.BaseModelOutput(),
Truncated: result.Truncated,
Meta: result.Meta,
EnforcementNotices: append([]string(nil), result.EnforcementNotices...),
Redacted: result.Redacted,
ChangedFiles: result.ChangedFiles,
FileDiffs: result.FileDiffs,
ChangeSummaries: result.ChangeSummaries,
Display: result.BaseDisplay(),
Outcome: result.Outcome,
ToolCallID: call.ID,
Name: call.Name,
Status: result.Status,
Output: result.ModelOutput(),
Truncated: result.Truncated,
Meta: result.Meta,
Redacted: result.Redacted,
ChangedFiles: result.ChangedFiles,
FileDiffs: result.FileDiffs,
ChangeSummaries: result.ChangeSummaries,
Display: result.HumanDisplay(),
Outcome: result.Outcome,
}
}
return ToolResult{
-84
View File
@@ -1,84 +0,0 @@
package agent
import (
"strings"
"github.com/Twigpine/zero/internal/tools"
)
// CardBody is the card body source: the rich card-only Display.Preview (a
// code/diff preview) when present on a successful result, else the text the
// model also saw. Error results keep their output so the failure shows.
//
// UNDECORATED, ALWAYS. The enforcement disclosure is carried separately as typed
// notices and rendered by the card as its own furniture, so a body that already
// had the notice composed into it drew the warning twice: once in the notice
// lines and once at the top of the output. A rich preview never carried it, so
// only the no-preview results (every bash and exec card, and every error) were
// wrong, which is exactly the shape a preview-only test cannot see.
//
// One owner for composition: this returns the base text, and whoever presents it
// decorates once. Provider-facing text still goes through ModelOutput.
func (result ToolResult) CardBody() string {
display := result.BaseDisplay()
if strings.TrimSpace(display.Preview) != "" && (result.Status != tools.StatusError || result.Outcome.Finalized()) {
return display.Preview
}
return result.BaseModelOutput()
}
// ToolResultSessionPayload is THE serialization of a tool result into a session
// event, shared by every writer: the TUI, headless exec, and the ACP agent.
//
// It lives here, beside ToolResult, because it has three callers and only one of
// them is a terminal. The writers used to spell the payload separately, and each
// time one of them was left behind the same thing went missing on reload. The
// headless writer persisted only the decorated ModelOutput, so a CLI-written
// result restored into the TUI arrived with no typed notices and no undecorated
// body. The ACP writer persisted the raw Output field, which after the
// output/notice split is the UNDECORATED text, so an ACP client that loaded a
// session saw every sandboxed result with its disclosure gone, although the
// live update for the same call had shown it. All of them write to the same
// session store, so a session written by one surface is restored by another.
// One owner for the contract means one place where a field can go missing, and a
// test against this function covers every writer.
//
// output is the provider-facing text with the disclosure composed in.
// displayPreview is the undecorated card body, written whenever it differs from
// output. A result carrying enforcement notices ALWAYS differs, because output
// is decorated and the card body is not, so the undecorated body is written
// even when it is empty. Readers key on the field being PRESENT rather than
// non-empty for exactly that case: a command that printed nothing under an
// enforced profile has an empty body and a real notice, and falling back to
// output there would restore the decorated text and draw the notice twice.
func ToolResultSessionPayload(result ToolResult) map[string]any {
output := result.ModelOutput()
payload := map[string]any{
"toolCallId": result.ToolCallID,
"name": result.Name,
"status": string(result.Status),
"output": output,
}
if preview := result.CardBody(); preview != output {
payload["displayPreview"] = preview
}
if result.Truncated {
payload["truncated"] = true
}
if result.Redacted {
payload["redacted"] = true
}
if len(result.Meta) > 0 {
payload["meta"] = result.Meta
}
if len(result.EnforcementNotices) > 0 {
payload["enforcementNotices"] = result.EnforcementNotices
}
if len(result.ChangedFiles) > 0 {
payload["changedFiles"] = result.ChangedFiles
}
if len(result.ChangeSummaries) > 0 {
payload["changeSummaries"] = result.ChangeSummaries
}
return payload
}
+45
View File
@@ -1,14 +1,20 @@
package agent
import (
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"github.com/Twigpine/zero/internal/config"
"github.com/Twigpine/zero/internal/sandbox"
"github.com/Twigpine/zero/internal/testutil"
)
// isolatedUserDirsRoot is where TestMain points every per-user directory.
var isolatedUserDirsRoot string
// TestMain points userConfigDirForPrompt at an empty, package-wide temp
// directory for every test in this package. Without this, buildSystemPrompt
// falls back to the real config.UserConfigDir, so any developer with a
@@ -17,17 +23,56 @@ import (
// across contributor machines. Tests that specifically exercise user
// guidelines stub userConfigDirForPrompt themselves via
// withSystemPromptTestUserConfigDir(Func) and restore this default on cleanup.
//
// It also points every per-user directory root at the same temp directory:
// sandboxed tool runs keep their runtime state and lease files under the user
// cache dir, so without this they land in the developer's real cache.
func TestMain(m *testing.M) {
dir, err := os.MkdirTemp("", "zero-agent-tests-*")
if err != nil {
panic(err)
}
isolatedUserDirsRoot = dir
restore, err := testutil.IsolateUserDirs(dir)
if err != nil {
os.RemoveAll(dir)
panic(err)
}
userConfigDirForPrompt = func() (string, error) { return dir, nil }
code := m.Run()
if err := restore(); err != nil {
fmt.Fprintln(os.Stderr, "agent tests: restore user dirs:", err)
code = 1
}
os.RemoveAll(dir)
os.Exit(code)
}
// Guards TestMain: the per-user directories the package can read or write
// must resolve inside the isolated root on every OS.
func TestUserDirsAreIsolatedFromTheDeveloperHome(t *testing.T) {
if isolatedUserDirsRoot == "" {
t.Fatal("TestMain did not record an isolated root")
}
home, err := os.UserHomeDir()
if err != nil {
t.Fatal(err)
}
userConfig, err := config.UserConfigDir()
if err != nil {
t.Fatal(err)
}
cache, err := os.UserCacheDir()
if err != nil {
t.Fatal(err)
}
for name, dir := range map[string]string{"home": home, "config": userConfig, "cache": cache} {
if !testutil.PathWithin(isolatedUserDirsRoot, dir) {
t.Errorf("%s dir %q is outside the isolated root %q", name, dir, isolatedUserDirsRoot)
}
}
}
func TestCoreSystemPromptIncludesCodingQualityRules(t *testing.T) {
prompt := strings.Join(strings.Fields(strings.ToLower(buildSystemPrompt(Options{}))), " ")
if strings.Contains(prompt, "clickable file paths") {
+6 -28
View File
@@ -79,10 +79,6 @@ type ToolResult struct {
// The full result may be recoverable through Meta["spill_path"].
Truncated bool
Meta map[string]string
// EnforcementNotices mirrors tools.Result.EnforcementNotices so the
// disclosure survives the conversion into the agent-facing result and
// reaches the model, the transcript and the interactive display.
EnforcementNotices []string
// Images the tool produced, delivered to the model as a following user
// message rather than on this result. See tools.Result.Images.
Images []zeroruntime.ImageBlock
@@ -114,42 +110,24 @@ type ToolResult struct {
RequestedModel string
}
// BaseModelOutput is the bounded provider-facing result WITHOUT the enforcement
// disclosure composed into it, mirroring tools.Result.BaseModelOutput.
//
// A surface that renders the typed EnforcementNotices itself must build its body
// from here, or the disclosure appears twice. Decoration has exactly one owner
// per surface: either the text carries it or the surface draws it, never both.
func (result ToolResult) BaseModelOutput() string {
// ModelOutput returns the bounded provider-facing result while preserving
// compatibility with synthetic and restored results created before outcomes
// were finalized.
func (result ToolResult) ModelOutput() string {
if result.Outcome.Finalized() {
return result.Outcome.ModelView
}
return result.Output
}
// BaseDisplay is BaseModelOutput's presentation half, and carries no enforcement
// notices for the same reason.
func (result ToolResult) BaseDisplay() tools.Display {
// HumanDisplay returns the presentation intended for interactive surfaces.
func (result ToolResult) HumanDisplay() tools.Display {
if result.Outcome.Finalized() {
return result.Outcome.HumanView
}
return result.Display
}
// ModelOutput returns the bounded provider-facing result while preserving
// compatibility with synthetic and restored results created before outcomes
// were finalized.
func (result ToolResult) ModelOutput() string {
return tools.WithEnforcementNotices(result.BaseModelOutput(), result.EnforcementNotices)
}
// HumanDisplay returns the presentation intended for interactive surfaces.
func (result ToolResult) HumanDisplay() tools.Display {
display := result.BaseDisplay()
display.Summary = tools.WithEnforcementNotices(display.Summary, result.EnforcementNotices)
return display
}
// DenialCategory classifies why a tool call was blocked before it executed.
type DenialCategory string
+57
View File
@@ -0,0 +1,57 @@
package aimlapi
import (
"net/http"
"net/url"
"strings"
)
const (
// SourceHeaderName and SourceValue identify Zero as the calling agent on
// requests to aimlapi.com services (the Toolbox and the MCP server).
SourceHeaderName = "X-AIMLAPI-Source"
SourceValue = "agent/zero"
// CanonicalInferenceBaseURL is the catalog inference endpoint. A provider
// profile whose base URL differs is treated as a proxy: its key is never sent
// to other aimlapi.com services.
CanonicalInferenceBaseURL = "https://api.aimlapi.com/v1"
// ToolboxBaseURL is the fixed AI/ML API Toolbox REST base. It is not
// configurable, so the key and attribution can only reach tools.aimlapi.com.
ToolboxBaseURL = "https://tools.aimlapi.com/v1/tools"
// MCPServerURL is the AI/ML API MCP server (streamable HTTP).
MCPServerURL = "https://mcp.aimlapi.com/mcp"
)
// attributedHosts are the only hostnames that receive the AI/ML API key or the
// attribution headers. Matching is exact; a suffix or substring match would
// also accept hosts such as tools.aimlapi.com.example.net.
var attributedHosts = [...]string{"api.aimlapi.com", "tools.aimlapi.com", "mcp.aimlapi.com"}
// IsAttributedURL reports whether target is an https URL on one of the
// aimlapi.com service hosts, on the default port and without userinfo.
func IsAttributedURL(target *url.URL) bool {
if target == nil || !strings.EqualFold(target.Scheme, "https") || target.User != nil {
return false
}
if port := target.Port(); port != "" && port != "443" {
return false
}
host := strings.ToLower(target.Hostname())
for _, candidate := range attributedHosts {
if host == candidate {
return true
}
}
return false
}
// ApplyAttributionHeaders sets the source and partner headers on header only
// when target is an aimlapi.com service URL; any other target is left as is.
func ApplyAttributionHeaders(header http.Header, target *url.URL) {
if header == nil || !IsAttributedURL(target) {
return
}
header.Set(SourceHeaderName, SourceValue)
header.Set(PartnerHeaderName, ResolvePartnerID(""))
}
+54
View File
@@ -0,0 +1,54 @@
package aimlapi
import (
"net/http"
"net/url"
"testing"
)
func TestIsAttributedURLMatchesExactServiceHostsOnly(t *testing.T) {
cases := map[string]bool{
"https://tools.aimlapi.com/v1/tools/run": true,
"https://mcp.aimlapi.com/mcp": true,
"https://api.aimlapi.com/v1": true,
"https://TOOLS.aimlapi.com:443/v1/tools": true,
"http://tools.aimlapi.com/v1/tools/run": false,
"https://tools.aimlapi.com:8443/v1/tools": false,
"https://[email protected]/v1/tools": false,
"https://tools.aimlapi.com.example.net/run": false,
"https://eviltools.aimlapi.com/run": false,
"https://aimlapi.com/app": false,
"https://auth.aimlapi.com/": false,
"https://example.com/?h=tools.aimlapi.com": false,
}
for raw, want := range cases {
target, err := url.Parse(raw)
if err != nil {
t.Fatalf("parse %q: %v", raw, err)
}
if got := IsAttributedURL(target); got != want {
t.Errorf("IsAttributedURL(%q) = %v, want %v", raw, got, want)
}
}
if IsAttributedURL(nil) {
t.Fatal("IsAttributedURL(nil) = true")
}
}
func TestApplyAttributionHeadersSetsSourceAndPartnerOnServiceHosts(t *testing.T) {
t.Setenv("AIMLAPI_PARTNER_ID", "")
header := http.Header{}
ApplyAttributionHeaders(header, &url.URL{Scheme: "https", Host: "tools.aimlapi.com", Path: "/v1/tools/run"})
if got := header.Get(SourceHeaderName); got != "agent/zero" {
t.Fatalf("%s = %q, want agent/zero", SourceHeaderName, got)
}
if got := header.Get(PartnerHeaderName); got != DefaultPartnerID {
t.Fatalf("%s = %q, want %q", PartnerHeaderName, got, DefaultPartnerID)
}
other := http.Header{}
ApplyAttributionHeaders(other, &url.URL{Scheme: "https", Host: "tools.aimlapi.com.example.net"})
if len(other) != 0 {
t.Fatalf("attribution reached a non-aimlapi host: %#v", other)
}
}
+25 -19
View File
@@ -874,19 +874,6 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
}
fmt.Fprintf(stderr, "warning: MCP server %s unavailable, skipped: %s\n", skipped.Name, redaction.ErrorMessage(skipped.Err, redaction.Options{}))
}
// AND WHAT THE SERVERS THAT DID START RAN UNDER. A stdio MCP server prepared
// with a weakened write jail serves the whole session from that process, so
// the disclosure is about startup and no later tool result can carry it. Said
// once, here, next to the skip warnings, rather than pasted onto every
// response the server produces. Network servers launch no local process and
// report nothing, which is why the optional background registration is not
// asked: its only member is the built-in HTTP default, which starts no local
// process. A stdio default would need this statement from that path too.
// NOT deferred: stderr here is the bare terminal, and the TUI takes it over at
// deps.runTUI below. Delivery stops before that hand-off, so a late launch can
// never write raw text into the alt screen; see stopMCPDisclosures's call site.
guardedStderr, stopMCPDisclosures := reportMCPStartupDisclosures(stderr, mcpRuntime)
stderr = guardedStderr
// Make local plugins live: register their declared tools into the registry and
// collect their hooks + skill roots for the dispatcher and skill tool below.
// Done after specialist + MCP registration so plugin tools are part of the
@@ -977,15 +964,12 @@ 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.
startupNotices := []string{sandboxEngine.DegradedNotice()}
modelRPM := zeroruntime.NewModelRPMLimiter(resolved.ModelRPM)
// The terminal stops being ours on the next line. Stop and join the disclosure
// pump first: anything already queued is printed here, on this goroutine, and a
// launch that resolves later is dropped rather than written raw over the TUI.
stopMCPDisclosures()
return deps.runTUI(context.Background(), tui.Options{
ModelRPM: modelRPM,
Cwd: workspaceRoot,
@@ -1238,6 +1222,9 @@ type agentToolRuntime struct {
// the orchestrator's system-prompt delegation section. Populated alongside the
// tools, so it is present exactly when the Task tool is.
specialists []agent.SpecialistInfo
// lifecycleHooks is shared with the specialist executor so the hooks
// dispatcher (built later in startup) can be attached before any specialist runs.
lifecycleHooks *specialist.LifecycleHooks
}
// specialistInfos returns the runtime's specialist summaries, nil-safe so the
@@ -1249,12 +1236,26 @@ func (r *agentToolRuntime) specialistInfos() []agent.SpecialistInfo {
return r.specialists
}
func (r *agentToolRuntime) attachLifecycleHooks(dispatcher *hooks.Dispatcher) {
if r == nil || r.lifecycleHooks == nil || dispatcher == nil {
return
}
r.lifecycleHooks.Dispatch = func(ctx context.Context, event string, specialistName string, payload map[string]any) {
dispatcher.Dispatch(ctx, hooks.DispatchInput{
Event: hooks.Event(event),
ToolName: specialistName,
Payload: payload,
})
}
}
func registerSpecialistTools(registry *tools.Registry, workspaceRoot string, maxTeamSize int) (*agentToolRuntime, error) {
paths, err := specialist.DefaultPaths(workspaceRoot)
if err != nil {
return nil, err
}
executor := specialist.Executor{Paths: paths}
lifecycleHooks := &specialist.LifecycleHooks{}
executor := specialist.Executor{Paths: paths, LifecycleHooks: lifecycleHooks}
runtime, err := specialist.RegisterTools(registry, executor)
if err != nil {
return nil, err
@@ -1273,7 +1274,12 @@ func registerSpecialistTools(registry *tools.Registry, workspaceRoot string, max
return nil, err
}
swarm.RegisterTools(registry, sw)
return &agentToolRuntime{specialist: runtime, swarm: sw, specialists: specialistSummaries(paths)}, nil
return &agentToolRuntime{
specialist: runtime,
swarm: sw,
specialists: specialistSummaries(paths),
lifecycleHooks: lifecycleHooks,
}, nil
}
// specialistSummaries loads the available specialists (built-ins + user/project
+13 -1
View File
@@ -92,13 +92,25 @@ func runSkillAdd(args []string, dir string, stdout io.Writer, stderr io.Writer)
lines := []string{}
if result.Updated {
lines = append(lines, fmt.Sprintf("Updated skill %q.", result.Name))
lines = append(lines, fmt.Sprintf(" hash: %s -> %s", shortHash(result.PreviousHash), shortHash(result.Hash)))
switch {
case result.PreviousHash != result.Hash:
lines = append(lines, fmt.Sprintf(" hash: %s -> %s", shortHash(result.PreviousHash), shortHash(result.Hash)))
case result.PreviousTreeHash == "":
// The install this replaced predates tree hashes and copied SKILL.md
// alone, so the bundled files are new rather than changed.
lines = append(lines, " SKILL.md is unchanged; the files bundled with it are now installed too")
default:
lines = append(lines, " SKILL.md is unchanged; the files bundled with it changed")
}
} else {
lines = append(lines, fmt.Sprintf("Installed skill %q.", result.Name))
lines = append(lines, " hash: "+result.Hash)
}
lines = append(lines, " source: "+result.Source)
lines = append(lines, " path: "+result.Path)
if len(result.SkippedLinks) > 0 {
lines = append(lines, " not copied (symlinks): "+strings.Join(result.SkippedLinks, ", "))
}
if _, err := fmt.Fprintln(stdout, redaction.RedactString(strings.Join(lines, "\n"), redaction.Options{})); err != nil {
return exitCrash
}
+132
View File
@@ -7,6 +7,8 @@ import (
"path/filepath"
"strings"
"testing"
"github.com/Twigpine/zero/internal/skills"
)
func writeSourceSkillDir(t *testing.T, dir string, content string) string {
@@ -391,3 +393,133 @@ func TestRunDistributionCommandsDoNotLaunchTUI(t *testing.T) {
}
}
}
// A reinstall that only changed a bundled script must still read as an update
// (#584): the SKILL.md hash is the same, so "hash: X -> X" would say nothing.
func TestRunSkillAddReportsAnUpdateToBundledFilesOnly(t *testing.T) {
skillsDir := t.TempDir()
src := writeSourceSkillDir(t, filepath.Join(t.TempDir(), "src"),
"---\nname: pdf\ndescription: Fill forms.\n---\nRun scripts/fill.py.\n")
script := filepath.Join(src, "scripts", "fill.py")
if err := os.MkdirAll(filepath.Dir(script), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(script, []byte("print('v1')\n"), 0o755); err != nil {
t.Fatal(err)
}
deps := appDeps{skillsDir: func() string { return skillsDir }}
var stdout, stderr bytes.Buffer
if exit := runWithDeps([]string{"skill", "add", src}, &stdout, &stderr, deps); exit != 0 {
t.Fatalf("first skill add exit = %d, stderr = %s", exit, stderr.String())
}
if err := os.WriteFile(script, []byte("print('v2')\n"), 0o755); err != nil {
t.Fatal(err)
}
stdout.Reset()
stderr.Reset()
if exit := runWithDeps([]string{"skill", "add", src}, &stdout, &stderr, deps); exit != 0 {
t.Fatalf("second skill add exit = %d, stderr = %s", exit, stderr.String())
}
for _, want := range []string{`Updated skill "pdf".`, "SKILL.md is unchanged; the files bundled with it changed"} {
if !strings.Contains(stdout.String(), want) {
t.Fatalf("second add output does not say %q:\n%s", want, stdout.String())
}
}
}
// An install from before tree hashes copied SKILL.md alone and recorded no tree
// hash. Adding the skill again over that entry must not claim bundled files
// changed: there are none, or they are installed for the first time.
func TestRunSkillAddOverAnEntryWithoutATreeHash(t *testing.T) {
for _, tc := range []struct {
name string
script bool
want []string
notWant []string
}{
{
name: "SKILL.md only",
want: []string{`Installed skill "pdf".`},
notWant: []string{"Updated skill", "files bundled with it"},
},
{
name: "with a bundled script",
script: true,
want: []string{`Updated skill "pdf".`, "SKILL.md is unchanged; the files bundled with it are now installed too"},
notWant: []string{"files bundled with it changed"},
},
} {
t.Run(tc.name, func(t *testing.T) {
skillsDir := t.TempDir()
src := writeSourceSkillDir(t, filepath.Join(t.TempDir(), "src"),
"---\nname: pdf\ndescription: Fill forms.\n---\nRun scripts/fill.py.\n")
if tc.script {
script := filepath.Join(src, "scripts", "fill.py")
if err := os.MkdirAll(filepath.Dir(script), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(script, []byte("print('fill')\n"), 0o755); err != nil {
t.Fatal(err)
}
}
deps := appDeps{skillsDir: func() string { return skillsDir }}
var stdout, stderr bytes.Buffer
if exit := runWithDeps([]string{"skill", "add", src}, &stdout, &stderr, deps); exit != 0 {
t.Fatalf("first skill add exit = %d, stderr = %s", exit, stderr.String())
}
lock, err := skills.ReadLock(skillsDir)
if err != nil {
t.Fatal(err)
}
entry := lock["pdf"]
entry.TreeHash = ""
lock["pdf"] = entry
data, err := json.Marshal(lock)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(skillsDir, skills.LockFileName), data, 0o644); err != nil {
t.Fatal(err)
}
stdout.Reset()
stderr.Reset()
if exit := runWithDeps([]string{"skill", "add", src}, &stdout, &stderr, deps); exit != 0 {
t.Fatalf("second skill add exit = %d, stderr = %s", exit, stderr.String())
}
for _, want := range tc.want {
if !strings.Contains(stdout.String(), want) {
t.Fatalf("second add output does not say %q:\n%s", want, stdout.String())
}
}
for _, notWant := range tc.notWant {
if strings.Contains(stdout.String(), notWant) {
t.Fatalf("second add output says %q:\n%s", notWant, stdout.String())
}
}
})
}
}
func TestRunSkillAddListsTheSymlinksItDidNotCopy(t *testing.T) {
skillsDir := t.TempDir()
src := writeSourceSkillDir(t, filepath.Join(t.TempDir(), "src"),
"---\nname: pdf\ndescription: Fill forms.\n---\nbody\n")
outside := filepath.Join(t.TempDir(), "notes.md")
if err := os.WriteFile(outside, []byte("outside\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Symlink(outside, filepath.Join(src, "notes.md")); err != nil {
t.Skipf("symlinks unavailable: %v", err)
}
var stdout, stderr bytes.Buffer
deps := appDeps{skillsDir: func() string { return skillsDir }}
if exit := runWithDeps([]string{"skill", "add", src}, &stdout, &stderr, deps); exit != 0 {
t.Fatalf("skill add exit = %d, stderr = %s", exit, stderr.String())
}
if !strings.Contains(stdout.String(), "not copied (symlinks): notes.md") {
t.Fatalf("add output does not list the skipped symlink:\n%s", stdout.String())
}
}
+21 -39
View File
@@ -365,18 +365,6 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
return writeExecProviderError(stdout, stderr, options.outputFormat, "mcp_error", err.Error())
}
defer closeMCPRuntime(stderr, mcpRuntime)
// Said HERE, before --list-tools and before the first result, because both
// return early and the process this describes is already running by now. On
// stderr, so text, JSON and stream-JSON framing on stdout are untouched:
// this is the same channel the skipped-server and trust notices use.
// Deferred AFTER closeMCPRuntime was deferred, so it runs BEFORE it: the
// pump stops and joins while stderr is still ours, and only then are the
// clients closed.
// Adopt the guarded writer for the rest of startup: the pump is live from
// here until stop, and everything below writes to this same stderr.
guardedStderr, stopDisclosures := reportMCPStartupDisclosures(stderr, mcpRuntime)
stderr = guardedStderr
defer stopDisclosures()
}
pluginActivation = activatePlugins(workspaceRoot, registry, deps, stderr, trustRoot, executionRunner)
registerLocalControlTools(registry, workspaceRoot, resolved.LocalControl)
@@ -667,6 +655,7 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
// can be combined with the plugin activation's, and emit at most one notice when
// project hooks/plugins were dropped for an untrusted workspace.
hookDispatcher, hookSkip := newHookDispatcherWithExtra(workspaceRoot, pluginActivation.hooks, trustRoot, executionRunner)
specialistRuntime.attachLifecycleHooks(hookDispatcher)
emitTrustNotice(stderr, hookSkip, pluginActivation.trustSkip, mcpSkip)
result, err := agent.Run(runCtx, agentPrompt, provider, agent.Options{
MaxTurns: resolved.MaxTurns,
@@ -724,7 +713,7 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
"arguments": call.Arguments,
})
// Snapshot before-state of files this call will mutate (safe rewind).
if checkpoint, ok := sessionRecorder.captureCheckpoint(workspaceRoot, call); ok {
if checkpoint, ok := sessionRecorder.captureCheckpoint(workspaceRoot, registry, call); ok {
writer.checkpoint(checkpoint)
}
},
@@ -734,7 +723,25 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
},
OnToolResult: func(result agent.ToolResult) {
writer.toolResult(result)
sessionRecorder.append(sessions.EventToolResult, persistedToolResultPayload(result))
payload := map[string]any{
"toolCallId": result.ToolCallID,
"name": result.Name,
"status": string(result.Status),
"output": result.Output,
}
if len(result.Meta) > 0 {
payload["meta"] = result.Meta
}
if result.Truncated {
payload["truncated"] = true
}
if result.Redacted {
payload["redacted"] = true
}
if len(result.ChangedFiles) > 0 {
payload["changedFiles"] = result.ChangedFiles
}
sessionRecorder.append(sessions.EventToolResult, payload)
},
OnUsage: func(u agent.Usage) {
writer.usage(u)
@@ -1498,28 +1505,3 @@ func writeTraceSnapshot(snapshot *trace.TurnTrace, dest string, stderr io.Writer
defer file.Close()
return trace.WriteNDJSON(file, snapshot)
}
// persistedToolResultPayload renders one tool result for the durable session
// log.
//
// IT USES THE ACCESSOR, NOT THE RAW FIELD. agent.ToolResult stores the
// undecorated model text alongside the typed enforcement notices, and
// ModelOutput is what composes the two. Replay reads this payload straight back
// into the transcript without reconstructing a ToolResult, so a disclosure that
// is not rendered here is simply absent from resumed and compacted context even
// though it was visible during the original run.
//
// Both headless writers go through this, because they previously spelled the
// same payload separately and had already drifted: one persisted the raw field
// while the stream writer used the accessor.
func persistedToolResultPayload(result agent.ToolResult) map[string]any {
// ONE CONTRACT WITH THE TUI. This used to build its own payload with the
// decorated output only, and both writers append to the same default
// session store the TUI resumes from. A CLI-written result restored into
// the TUI therefore arrived without typed enforcement notices and without
// the undecorated card body, so a long collapsed result rendered no body
// and, with it, no disclosure. internal/agent owns the shape now, beside
// ToolResult, and every writer calls the same function rather than keeping
// a matching copy of it.
return agent.ToolResultSessionPayload(result)
}
@@ -0,0 +1,206 @@
package cli
import (
"bytes"
"context"
"encoding/json"
"os"
"path/filepath"
"testing"
"github.com/Twigpine/zero/internal/agent"
"github.com/Twigpine/zero/internal/config"
"github.com/Twigpine/zero/internal/sessions"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/zeroruntime"
)
func freeformApplyPatchArguments(root string, absolute bool) string {
target := "main.txt"
if absolute {
target = filepath.Join(root, "main.txt")
}
return "*** Begin Patch\n*** Update File: " + target + "\n@@\n-before\n+after\n*** End Patch\n"
}
// captureCheckpoint must resolve a freeform apply_patch call through the same
// preparation execution uses, so the before-state is captured and rewind can
// restore it. The JSON control pins the unchanged path.
func TestCaptureCheckpointFreeformApplyPatch(t *testing.T) {
for _, tc := range []struct {
name string
freeform bool
absolute bool
jsonArgs bool
}{
{name: "freeform", freeform: true},
{name: "freeform absolute header", freeform: true, absolute: true},
{name: "json arguments", jsonArgs: true},
} {
t.Run(tc.name, func(t *testing.T) {
t.Setenv("XDG_DATA_HOME", t.TempDir())
t.Setenv("ZERO_CHECKPOINTS", "on")
root := t.TempDir()
// The absolute-header case needs the physical root spelling the tool
// resolves (macOS /var vs /private/var, Windows short names).
if resolved, err := filepath.EvalSymlinks(root); err == nil {
root = resolved
}
if err := os.WriteFile(filepath.Join(root, "main.txt"), []byte("before\n"), 0o644); err != nil {
t.Fatal(err)
}
store := sessions.NewStore(sessions.StoreOptions{})
session, err := store.Create(sessions.CreateInput{Title: "freeform checkpoint"})
if err != nil {
t.Fatal(err)
}
if _, err := store.AppendEvent(session.SessionID, sessions.AppendEventInput{
Type: sessions.EventMessage,
Payload: map[string]string{"role": "user", "content": "apply the patch"},
}); err != nil {
t.Fatal(err)
}
recorder := &execSessionRecorder{prepared: sessions.PreparedExec{Store: store, Session: session}}
registry := tools.NewRegistry()
registry.Register(tools.NewScopedApplyPatchTool(root, nil))
call := agent.ToolCall{ID: "call", Name: "apply_patch", Freeform: tc.freeform}
if tc.jsonArgs {
encoded, err := json.Marshal(map[string]string{"patch": freeformApplyPatchArguments(root, false)})
if err != nil {
t.Fatal(err)
}
call.Arguments = string(encoded)
} else {
call.Arguments = freeformApplyPatchArguments(root, tc.absolute)
}
event, ok := recorder.captureCheckpoint(root, registry, call)
if !ok || event.Type != sessions.EventSessionCheckpoint {
t.Fatalf("captureCheckpoint() = %#v, %t; want a checkpoint event", event, ok)
}
var payload sessions.CheckpointPayload
if err := json.Unmarshal(event.Payload, &payload); err != nil {
t.Fatalf("decode checkpoint payload: %v", err)
}
if len(payload.Files) != 1 || payload.Files[0].Path != "main.txt" || payload.Files[0].Blob == "" {
t.Fatalf("checkpoint files = %#v, want main.txt with a blob", payload.Files)
}
// Simulate the tool's mutation, then rewind past it.
if err := os.WriteFile(filepath.Join(root, "main.txt"), []byte("after\n"), 0o644); err != nil {
t.Fatal(err)
}
report, err := store.ApplyRewind(session.SessionID, root, 1)
if err != nil {
t.Fatalf("ApplyRewind: %v", err)
}
if report.FilesRestored != 1 {
t.Fatalf("restore report = %#v, want one restored file", report)
}
content, err := os.ReadFile(filepath.Join(root, "main.txt"))
if err != nil {
t.Fatal(err)
}
if string(content) != "before\n" {
t.Fatalf("restored content = %q, want before newline", content)
}
})
}
}
// freeformPatchExecProvider emits one native freeform apply_patch call, then a
// final text turn.
type freeformPatchExecProvider struct {
patch string
calls int
}
func (provider *freeformPatchExecProvider) StreamCompletion(ctx context.Context, request zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
ch := make(chan zeroruntime.StreamEvent, 5)
calls := provider.calls
provider.calls++
if calls == 0 {
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call_patch", ToolName: "apply_patch", ToolCallFreeform: true}
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call_patch", ArgumentsFragment: provider.patch}
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call_patch"}
} else {
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventText, Content: "patched"}
}
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone}
close(ch)
return ch, nil
}
// End-to-end: exec captures the freeform checkpoint and `sessions rewind`
// restores the pre-patch content.
func TestRunExecFreeformApplyPatchCheckpointsForRewind(t *testing.T) {
isolateCLIUserState(t)
t.Setenv("ZERO_CHECKPOINTS", "on")
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "main.txt"), []byte("before\n"), 0o644); err != nil {
t.Fatal(err)
}
provider := &freeformPatchExecProvider{patch: freeformApplyPatchArguments(root, false)}
var stdout, stderr bytes.Buffer
exitCode := runWithDeps([]string{
"exec", "--init-session-id", "freeform_rewind", "--skip-permissions-unsafe",
"--output-format", "stream-json", "apply the patch",
}, &stdout, &stderr, appDeps{
getwd: func() (string, error) { return root, nil },
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
return execResolvedConfig(), nil
},
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
return provider, nil
},
})
if exitCode != exitSuccess {
t.Fatalf("exit = %d, want %d; stderr=%q stdout=%q", exitCode, exitSuccess, stderr.String(), stdout.String())
}
content, err := os.ReadFile(filepath.Join(root, "main.txt"))
if err != nil {
t.Fatal(err)
}
if string(content) != "after\n" {
t.Fatalf("patched content = %q, want after newline", content)
}
store := sessions.NewStore(sessions.StoreOptions{})
events, err := store.ReadEvents("freeform_rewind")
if err != nil {
t.Fatalf("ReadEvents: %v", err)
}
checkpoints := 0
for _, event := range events {
if event.Type == sessions.EventSessionCheckpoint {
checkpoints++
}
}
if checkpoints != 1 {
t.Fatalf("checkpoint events = %d, want 1; events=%v", checkpoints, sessionEventTypes(events))
}
report, err := store.ApplyRewind("freeform_rewind", root, 1)
if err != nil {
t.Fatalf("ApplyRewind: %v", err)
}
if report.FilesRestored != 1 {
t.Fatalf("restore report = %#v, want one restored file", report)
}
content, err = os.ReadFile(filepath.Join(root, "main.txt"))
if err != nil {
t.Fatal(err)
}
if string(content) != "before\n" {
t.Fatalf("rewound content = %q, want before newline", content)
}
}
func sessionEventTypes(events []sessions.Event) []string {
types := make([]string, 0, len(events))
for _, event := range events {
types = append(types, string(event.Type))
}
return types
}
@@ -0,0 +1,90 @@
package cli
import (
"bytes"
"context"
"slices"
"strings"
"testing"
"github.com/Twigpine/zero/internal/config"
"github.com/Twigpine/zero/internal/zeroruntime"
)
// guardStopExecProvider emits turns with no visible text and no tool calls, so
// the empty-turn guardrail stops the run after its consecutive-empty-turn cap.
type guardStopExecProvider struct{}
func (guardStopExecProvider) StreamCompletion(ctx context.Context, request zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
ch := make(chan zeroruntime.StreamEvent, 1)
select {
case <-ctx.Done():
close(ch)
return ch, ctx.Err()
case ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone}:
}
close(ch)
return ch, nil
}
// A guardrail stop in headless exec must use the existing INCOMPLETE contract
// (exit 4, error event, run_end status), and --no-completion-gate must keep
// reporting success. isolateCLIUserState also turns off the models.dev refresh
// that runExec starts in the background (see the #1152/#1161 flake).
func TestRunExecGuardrailStopIncompleteContract(t *testing.T) {
for _, tc := range []struct {
name string
args []string
wantExit int
wantStatus string
}{
{"gate on", []string{"exec", "--output-format", "stream-json", "complete the task"}, exitIncomplete, "incomplete"},
{"gate off", []string{"exec", "--no-completion-gate", "--output-format", "stream-json", "complete the task"}, exitSuccess, "success"},
} {
t.Run(tc.name, func(t *testing.T) {
isolateCLIUserState(t)
cwd := t.TempDir()
var stdout, stderr bytes.Buffer
exitCode := runWithDeps(tc.args, &stdout, &stderr, appDeps{
getwd: func() (string, error) { return cwd, nil },
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
return execResolvedConfig(), nil
},
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
return guardStopExecProvider{}, nil
},
})
if exitCode != tc.wantExit {
t.Fatalf("exit code = %d, want %d; stderr=%q", exitCode, tc.wantExit, stderr.String())
}
events := decodeJSONLines(t, stdout.String())
if len(events) == 0 || events[len(events)-1]["type"] != "run_end" {
t.Fatalf("expected run_end as the last event, got %#v", events)
}
runEnd := events[len(events)-1]
if runEnd["status"] != tc.wantStatus {
t.Fatalf("run_end status = %v, want %q", runEnd["status"], tc.wantStatus)
}
if runEnd["exitCode"] != float64(tc.wantExit) {
t.Fatalf("run_end exitCode = %v, want %d", runEnd["exitCode"], tc.wantExit)
}
finalEvent := findJSONEvent(t, events, "final")
if text, _ := finalEvent["text"].(string); !strings.Contains(text, "no output") {
t.Fatalf("final text = %q, want the guard stop answer", text)
}
if tc.wantStatus == "incomplete" {
errorEvent := findJSONEvent(t, events, "error")
if errorEvent["code"] != "incomplete" {
t.Fatalf("error code = %v, want incomplete", errorEvent["code"])
}
if message, _ := errorEvent["message"].(string); !strings.Contains(message, "no output") {
t.Fatalf("error message = %q, want the guard reason", message)
}
} else if slices.Contains(jsonEventTypes(events), "error") {
t.Fatalf("gate-off run must not emit an error event: %#v", events)
}
})
}
}
-47
View File
@@ -1,47 +0,0 @@
package cli
import (
"strings"
"testing"
"github.com/Twigpine/zero/internal/agent"
"github.com/Twigpine/zero/internal/tools"
)
// THE HEADLESS WRITER PERSISTS THE SAME SHAPE THE TUI DOES.
//
// The tui-side restore test proves the shared payload restores a disclosure
// exactly once; this proves the CLI actually WRITES that shared payload rather
// than its own. The two used to be spelled separately and the headless one had
// already drifted to decorated output only. Reverting the delegation leaves
// the tui test green and fails this one, which is the point of having both.
func TestHeadlessPayloadCarriesTypedNoticesAndUndecoratedBody(t *testing.T) {
const notice = "denyRead is configured, so the Windows sandbox uses the token shape without WRITE_RESTRICTED (#869)"
result := agent.ToolResult{
ToolCallID: "call-cli",
Name: "bash",
Status: tools.StatusOK,
Output: "PROBE-BODY",
Truncated: true,
EnforcementNotices: []string{notice},
}
payload := persistedToolResultPayload(result)
notices, _ := payload["enforcementNotices"].([]string)
if len(notices) != 1 || notices[0] != notice {
t.Errorf("headless payload does not carry the typed notice: %#v", payload["enforcementNotices"])
}
preview, _ := payload["displayPreview"].(string)
if preview != "PROBE-BODY" {
t.Errorf("headless payload does not carry the undecorated body as displayPreview: %q", preview)
}
output, _ := payload["output"].(string)
if !strings.Contains(output, notice) || !strings.Contains(output, "PROBE-BODY") {
t.Errorf("provider-facing output is no longer the decorated text: %q", output)
}
// The one field the headless writer added on its own must survive the
// delegation, or a truncation marker silently stops being persisted.
if truncated, _ := payload["truncated"].(bool); !truncated {
t.Errorf("truncated flag was lost in the shared payload: %#v", payload["truncated"])
}
}
+2 -7
View File
@@ -1,7 +1,6 @@
package cli
import (
"encoding/json"
"fmt"
"io"
"strings"
@@ -19,15 +18,11 @@ type execSessionRecorder struct {
// captureCheckpoint snapshots the before-state of files a tool call will mutate.
// Best-effort: failures never affect the run. Returns the checkpoint event and
// true when one was recorded.
func (recorder *execSessionRecorder) captureCheckpoint(workspaceRoot string, call agent.ToolCall) (sessions.Event, bool) {
func (recorder *execSessionRecorder) captureCheckpoint(workspaceRoot string, registry *tools.Registry, call agent.ToolCall) (sessions.Event, bool) {
if recorder.prepared.Store == nil || recorder.prepared.Session.SessionID == "" {
return sessions.Event{}, false
}
var args map[string]any
if call.Arguments != "" {
_ = json.Unmarshal([]byte(call.Arguments), &args)
}
targets := tools.MutationTargets(workspaceRoot, call.Name, args)
targets := agent.CheckpointTargets(workspaceRoot, registry, call)
if len(targets) == 0 {
return sessions.Event{}, false
}
+17 -2
View File
@@ -148,7 +148,7 @@ func runExecSpecDraft(run execSpecDraftRun) int {
"name": call.Name,
"arguments": call.Arguments,
})
if checkpoint, ok := sessionRecorder.captureCheckpoint(run.workspaceRoot, call); ok {
if checkpoint, ok := sessionRecorder.captureCheckpoint(run.workspaceRoot, run.registry, call); ok {
writer.checkpoint(checkpoint)
}
},
@@ -161,7 +161,22 @@ func runExecSpecDraft(run execSpecDraftRun) int {
if info, ok := execSpecDraftInfoFromToolResult(result); ok {
draftInfo = info
}
sessionRecorder.append(sessions.EventToolResult, persistedToolResultPayload(result))
payload := map[string]any{
"toolCallId": result.ToolCallID,
"name": result.Name,
"status": string(result.Status),
"output": result.Output,
}
if len(result.Meta) > 0 {
payload["meta"] = result.Meta
}
if result.Redacted {
payload["redacted"] = true
}
if len(result.ChangedFiles) > 0 {
payload["changedFiles"] = result.ChangedFiles
}
sessionRecorder.append(sessions.EventToolResult, payload)
},
OnUsage: func(u agent.Usage) {
writer.usage(u)
@@ -1,127 +0,0 @@
package cli
import (
"bytes"
"context"
"encoding/json"
"strings"
"testing"
"github.com/Twigpine/zero/internal/config"
"github.com/Twigpine/zero/internal/mcp"
"github.com/Twigpine/zero/internal/tools"
)
// disclosingExecRuntime is an MCP runtime that launched a process under reduced
// enforcement.
type disclosingExecRuntime struct {
noopMCPRuntime
disclosures []mcp.StartupDisclosure
}
func (r disclosingExecRuntime) StartupDisclosures() []mcp.StartupDisclosure { return r.disclosures }
const execDisclosureNotice = "denyRead is configured, so the write jail is not confining writes"
// isolateConfigDirs points every config/cache root at test-owned storage.
//
// Without it these tests build a sandbox engine against the developer's REAL
// config dir, trigger the one-time grant migration there, and the migration
// notice then turns up on a LATER test's stderr, failing whichever test happens
// to assert an empty one. The failure moves between runs, which is what makes it
// look like flakiness rather than contamination.
func isolateConfigDirs(t *testing.T) {
t.Helper()
dir := t.TempDir()
t.Setenv("HOME", dir)
t.Setenv("USERPROFILE", dir)
t.Setenv("APPDATA", dir)
t.Setenv("LOCALAPPDATA", dir)
t.Setenv("XDG_CONFIG_HOME", dir)
t.Setenv("XDG_CACHE_HOME", dir)
}
func execDisclosureDeps(cwd string) appDeps {
return appDeps{
getwd: func() (string, error) { return cwd, nil },
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) { return execResolvedConfig(), nil },
resolveMCPConfig: func(string, bool) (config.MCPConfig, error) {
return config.MCPConfig{Servers: map[string]config.MCPServerConfig{
"docs": {Type: "stdio", Command: "docs-mcp"},
}}, nil
},
registerMCPTools: func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) {
return disclosingExecRuntime{disclosures: []mcp.StartupDisclosure{
{Name: "docs", Notices: []string{execDisclosureNotice}},
}}, nil
},
}
}
// A HEADLESS RUN IS A DISCLOSURE SURFACE TOO.
//
// `zero exec` registers workspace MCP servers through the same sandbox-backed
// runner interactive startup uses, so a stdio server here can launch under the
// weakened token and serve the whole run. Reporting the disclosure only from the
// TUI meant every text, JSON, stream-JSON and --list-tools caller was told
// nothing about the enforcement trade, for a process that was already running.
func TestExecReportsMCPStartupDisclosures(t *testing.T) {
isolateConfigDirs(t)
for _, format := range []string{"", "--output-format=json", "--output-format=stream-json"} {
name := format
if name == "" {
name = "text"
}
t.Run(name, func(t *testing.T) {
args := []string{"exec", "--list-tools"}
if format != "" {
args = append(args, format)
}
var stdout, stderr bytes.Buffer
if code := runWithDeps(args, &stdout, &stderr, execDisclosureDeps(t.TempDir())); code != exitSuccess {
t.Fatalf("exit = %d, stderr = %s", code, stderr.String())
}
if !strings.Contains(stderr.String(), execDisclosureNotice) {
t.Errorf("the headless run said nothing about the enforcement trade: %q", stderr.String())
}
if !strings.Contains(stderr.String(), "docs") {
t.Errorf("the report does not name the server: %q", stderr.String())
}
// The machine-readable surfaces must stay parseable: the disclosure
// belongs on stderr precisely so stdout framing is untouched.
if format == "--output-format=json" {
var any map[string]any
if err := json.Unmarshal(bytes.TrimSpace(stdout.Bytes()), &any); err != nil {
t.Errorf("stdout is no longer valid JSON: %v (%q)", err, stdout.String())
}
}
if format == "--output-format=stream-json" {
for _, line := range strings.Split(strings.TrimSpace(stdout.String()), "\n") {
if strings.TrimSpace(line) == "" {
continue
}
var any map[string]any
if err := json.Unmarshal([]byte(line), &any); err != nil {
t.Errorf("a stream-json line is not valid JSON: %v (%q)", err, line)
}
}
}
})
}
}
// A run whose servers launched nothing says nothing.
func TestExecWithoutDisclosuresStaysQuiet(t *testing.T) {
isolateConfigDirs(t)
deps := execDisclosureDeps(t.TempDir())
deps.registerMCPTools = func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) {
return noopMCPRuntime{}, nil
}
var stdout, stderr bytes.Buffer
if code := runWithDeps([]string{"exec", "--list-tools"}, &stdout, &stderr, deps); code != exitSuccess {
t.Fatalf("exit = %d, stderr = %s", code, stderr.String())
}
if strings.Contains(stderr.String(), "reduced enforcement") {
t.Errorf("a run with nothing to disclose printed one: %q", stderr.String())
}
}
+116
View File
@@ -2,6 +2,7 @@ package cli
import (
"context"
"errors"
"fmt"
"io"
"strings"
@@ -82,11 +83,105 @@ func runPlugins(args []string, stdout io.Writer, stderr io.Writer, deps appDeps)
return runPluginInfo(args[1:], stdout, stderr, deps)
case "remove", "rm":
return runPluginRemove(args[1:], deps.pluginsDir(), stdout, stderr)
case "enable":
return runPluginsToggle(args[1:], stdout, stderr, deps, false)
case "disable":
return runPluginsToggle(args[1:], stdout, stderr, deps, true)
default:
return writeExecUsageError(stderr, fmt.Sprintf("unknown plugins subcommand %q", args[0]))
}
}
type pluginToggleOptions struct {
json bool
user bool
}
// runPluginsToggle implements `zero plugins enable|disable <id>`.
//
// Every message it writes, including usage errors that echo the caller's
// arguments, passes through the redaction pipeline: an argument is user input
// and can be a mistyped credential.
func runPluginsToggle(args []string, stdout io.Writer, stderr io.Writer, deps appDeps, disabled bool) int {
commandName := "enable"
if disabled {
commandName = "disable"
}
usageError := func(message string) int {
return writeExecUsageError(stderr, redaction.RedactString(message, redaction.Options{}))
}
options, positional, help, err := parsePluginToggleArgs(args, commandName)
if err != nil {
return usageError(err.Error())
}
if help {
if err := writePluginsToggleHelp(stdout, commandName); err != nil {
return exitCrash
}
return exitSuccess
}
if len(positional) != 1 {
return usageError(fmt.Sprintf("usage: zero plugins %s <id> [--user] [--json]", commandName))
}
pluginID := strings.TrimSpace(positional[0])
if pluginID == "" {
return usageError(fmt.Sprintf("usage: zero plugins %s <id> [--user] [--json]", commandName))
}
cwd, err := deps.getwd()
if err != nil {
return writeAppError(stderr, "failed to resolve workspace: "+redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
}
loadOptions := plugins.LoadOptions{Cwd: cwd, ExcludeProject: options.user}
result, err := plugins.SetEnabledByID(loadOptions, pluginID, !disabled)
if err != nil {
if errors.Is(err, plugins.ErrNotInstalled) {
return usageError(err.Error())
}
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
}
state := "enabled"
if disabled {
state = "disabled"
}
if options.json {
if err := writePrettyJSON(stdout, redaction.RedactValue(result, redaction.Options{})); err != nil {
return exitCrash
}
return exitSuccess
}
message := fmt.Sprintf("Plugin %s is now %s in %s.\n", result.ID, state, result.ManifestPath)
if !result.Changed {
message = fmt.Sprintf("Plugin %s was already %s in %s.\n", result.ID, state, result.ManifestPath)
}
if _, err := fmt.Fprint(stdout, redaction.RedactString(message, redaction.Options{})); err != nil {
return exitCrash
}
return exitSuccess
}
func parsePluginToggleArgs(args []string, command string) (pluginToggleOptions, []string, bool, error) {
options := pluginToggleOptions{}
positional := []string{}
for _, arg := range args {
switch arg {
case "-h", "--help", "help":
return options, positional, true, nil
case "--json":
options.json = true
case "--user":
options.user = true
default:
if strings.HasPrefix(arg, "-") {
return options, positional, false, execUsageError{fmt.Sprintf("unknown plugins %s flag %q", command, arg)}
}
positional = append(positional, arg)
}
}
return options, positional, false, nil
}
func runHooks(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
if len(args) == 0 {
return writeExecUsageError(stderr, "hooks subcommand required. Use `zero hooks list`.")
@@ -566,10 +661,31 @@ Commands:
info <id> Show plugin details and lockfile metadata
add <git-url|path> Install a plugin (manifest-validated, pinned in plugins.lock)
remove <id> Remove an installed plugin and its lockfile entry
enable <id> Enable a plugin via its plugin.json
disable <id> Disable a plugin via its plugin.json
`)
return err
}
func writePluginsToggleHelp(w io.Writer, command string) error {
_, err := fmt.Fprintf(w, `Usage:
zero plugins %s <id> [flags]
Flags:
--user Target only user plugins (ignore ./.zero/plugins)
--json Print command result as JSON
-h, --help Show this help
Without --user, a project plugin with the same id takes precedence over a user
plugin, matching the loader's discovery order.
Zero reads plugins when it starts, so the change applies to the next zero exec
run or interactive session. A running interactive session keeps the plugins it
started with until you restart it.
`, command)
return err
}
func writePluginsListHelp(w io.Writer) error {
_, err := fmt.Fprint(w, `Usage:
zero plugins list [flags]
+232
View File
@@ -5,6 +5,7 @@ import (
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"testing"
@@ -333,3 +334,234 @@ func TestRunMCPPermissionsHelpDoesNotOpenStore(t *testing.T) {
})
}
}
func TestRunPluginsEnableDisable(t *testing.T) {
cwd := t.TempDir()
home := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", home)
t.Setenv("HOME", home)
pluginDir := filepath.Join(cwd, ".zero", "plugins", "demo")
if err := os.MkdirAll(pluginDir, 0o700); err != nil {
t.Fatal(err)
}
manifestPath := filepath.Join(pluginDir, "plugin.json")
if err := os.WriteFile(manifestPath, []byte(`{
"schemaVersion": 1,
"id": "zero.demo",
"name": "Demo",
"version": "1.0.0"
}
`), 0o600); err != nil {
t.Fatal(err)
}
deps := appDeps{getwd: func() (string, error) { return cwd, nil }}
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"plugins", "disable", "zero.demo", "--json"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("disable exit=%d stderr=%s", exitCode, stderr.String())
}
var disablePayload plugins.SetEnabledResult
if err := json.Unmarshal(stdout.Bytes(), &disablePayload); err != nil {
t.Fatalf("decode disable JSON: %v\n%s", err, stdout.String())
}
if disablePayload.ID != "zero.demo" || disablePayload.Enabled || !disablePayload.Changed {
t.Fatalf("unexpected disable payload: %#v", disablePayload)
}
raw, err := os.ReadFile(manifestPath)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(raw), `"enabled": false`) {
t.Fatalf("manifest not disabled: %s", raw)
}
stdout.Reset()
stderr.Reset()
exitCode = runWithDeps([]string{"plugins", "disable", "zero.demo"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("noop disable exit=%d stderr=%s", exitCode, stderr.String())
}
if !strings.Contains(stdout.String(), "was already disabled") {
t.Fatalf("noop disable output: %s", stdout.String())
}
stdout.Reset()
stderr.Reset()
exitCode = runWithDeps([]string{"plugins", "enable", "zero.demo"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("enable exit=%d stderr=%s", exitCode, stderr.String())
}
if !strings.Contains(stdout.String(), "is now enabled") {
t.Fatalf("enable output: %s", stdout.String())
}
stdout.Reset()
stderr.Reset()
exitCode = runWithDeps([]string{"plugins", "enable", "missing-plugin"}, &stdout, &stderr, deps)
if exitCode != exitUsage {
t.Fatalf("missing plugin exit=%d want %d stderr=%s", exitCode, exitUsage, stderr.String())
}
}
// The text-mode toggle output runs through the redaction pipeline, so a
// manifest path that contains something secret-shaped is masked.
func TestRunPluginsToggleTextOutputIsRedacted(t *testing.T) {
cwd := t.TempDir()
home := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", home)
t.Setenv("HOME", home)
secretDir := "sk-proj-abcdefghijklmnopqrstuvwxyz"
pluginDir := filepath.Join(cwd, ".zero", "plugins", secretDir)
if err := os.MkdirAll(pluginDir, 0o700); err != nil {
t.Fatal(err)
}
manifestPath := filepath.Join(pluginDir, "plugin.json")
if err := os.WriteFile(manifestPath, []byte(`{
"schemaVersion": 1,
"id": "zero.demo",
"name": "Demo",
"version": "1.0.0"
}
`), 0o600); err != nil {
t.Fatal(err)
}
deps := appDeps{getwd: func() (string, error) { return cwd, nil }}
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"plugins", "disable", "zero.demo"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("disable exit=%d stderr=%s", exitCode, stderr.String())
}
if strings.Contains(stdout.String(), secretDir) {
t.Fatalf("toggle text leaked a secret-shaped manifest path: %q", stdout.String())
}
if !strings.Contains(stdout.String(), "[REDACTED]") {
t.Fatalf("toggle text was not redacted: %q", stdout.String())
}
stdout.Reset()
stderr.Reset()
exitCode = runWithDeps([]string{"plugins", "disable", "zero.demo"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("noop disable exit=%d stderr=%s", exitCode, stderr.String())
}
if !strings.Contains(stdout.String(), "was already disabled") {
t.Fatalf("noop toggle output: %q", stdout.String())
}
if strings.Contains(stdout.String(), secretDir) {
t.Fatalf("noop toggle text leaked a secret-shaped manifest path: %q", stdout.String())
}
}
func TestRunPluginsToggleRejectsEmptyID(t *testing.T) {
deps := appDeps{getwd: func() (string, error) { return t.TempDir(), nil }}
for _, subcommand := range []string{"enable", "disable"} {
t.Run(subcommand, func(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"plugins", subcommand, ""}, &stdout, &stderr, deps)
if exitCode != exitUsage {
t.Fatalf("exit=%d want %d stderr=%s", exitCode, exitUsage, stderr.String())
}
if !strings.Contains(stderr.String(), "usage: zero plugins "+subcommand) {
t.Fatalf("stderr = %q", stderr.String())
}
})
}
}
func TestRunPluginsDisableUserIgnoresProject(t *testing.T) {
home := t.TempDir()
cwd := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", home)
t.Setenv("HOME", home)
userManifest := filepath.Join(home, "zero", "plugins", "demo", "plugin.json")
projectManifest := filepath.Join(cwd, ".zero", "plugins", "demo", "plugin.json")
for _, path := range []string{userManifest, projectManifest} {
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(`{
"schemaVersion": 1,
"id": "zero.demo",
"name": "Demo",
"version": "1.0.0",
"enabled": true
}
`), 0o600); err != nil {
t.Fatal(err)
}
}
deps := appDeps{getwd: func() (string, error) { return cwd, nil }}
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"plugins", "disable", "zero.demo", "--user"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("disable --user exit=%d stderr=%s", exitCode, stderr.String())
}
if !strings.Contains(stdout.String(), "is now disabled") {
t.Fatalf("disable --user output: %s", stdout.String())
}
userRaw, err := os.ReadFile(userManifest)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(userRaw), `"enabled": false`) {
t.Fatalf("user manifest not disabled: %s", userRaw)
}
projectRaw, err := os.ReadFile(projectManifest)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(projectRaw), `"enabled": false`) {
t.Fatalf("project manifest should stay enabled: %s", projectRaw)
}
}
// The toggle's usage errors echo the caller's arguments, which can be a
// mistyped credential, so they pass through redaction like its other output.
func TestRunPluginsToggleUsageErrorsAreRedacted(t *testing.T) {
home := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", home)
t.Setenv("HOME", home)
deps := appDeps{getwd: func() (string, error) { return t.TempDir(), nil }}
const secret = "sk-proj-abcdefghijklmnopqrstuvwxyz"
cases := []struct {
name string
args []string
want string
}{
{"unknown flag", []string{"zero.demo", "--token=" + secret}, "unknown plugins"},
{"missing plugin", []string{secret}, "plugin is not installed"},
}
for _, subcommand := range []string{"enable", "disable"} {
for _, tc := range cases {
t.Run(subcommand+" "+tc.name, func(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
args := append([]string{"plugins", subcommand}, tc.args...)
exitCode := runWithDeps(args, &stdout, &stderr, deps)
if exitCode != exitUsage {
t.Fatalf("exit=%d want %d stderr=%s", exitCode, exitUsage, stderr.String())
}
if strings.Contains(stderr.String(), secret) {
t.Fatalf("usage error leaked the argument: %q", stderr.String())
}
if !strings.Contains(stderr.String(), tc.want) || !strings.Contains(stderr.String(), "[REDACTED]") {
t.Fatalf("stderr = %q, want a redacted %q error", stderr.String(), tc.want)
}
})
}
}
}
+1 -1
View File
@@ -319,7 +319,7 @@ Flags:
--command <cmd> Command to run (required)
--name <name> Human-readable hook name
--description <text> Hook description
--matcher <pattern> Tool matcher (beforeTool/afterTool only)
--matcher <pattern> Tool/specialist matcher (beforeTool/afterTool/specialistStart/specialistStop)
--arg <value> Command argument (repeatable)
--user Write to user config instead of the project
--json Print command result as JSON
+32 -3
View File
@@ -741,6 +741,13 @@ func (cfg *mcpWritableConfig) upsertServer(name string, server config.MCPServerC
if err != nil {
return false, err
}
if !mergedServer.Disabled && config.IsDefaultMCPServerDisabled(name) {
// Adding a server under the name of a default that ships disabled means
// the user wants it on; the omitted flag would otherwise keep it off.
if data, err = setMCPServerRawDisabledFalse(data); err != nil {
return false, err
}
}
cfg.serverRaw[name] = data
if _, err := cfg.removeLegacyServer(name); err != nil {
return false, err
@@ -800,11 +807,29 @@ func mergeMCPServerRaw(existing json.RawMessage, server config.MCPServerConfig)
if key == "oauth" && preserveRawOAuth {
continue
}
// `mcp add` replaces the entry: it writes only a disable, never a stale
// explicit false carried over from the old entry (upsertServer adds the
// false a default that ships disabled needs).
if key == "disabled" && string(value) == "false" {
continue
}
merged[key] = value
}
return json.Marshal(merged)
}
func setMCPServerRawDisabledFalse(data json.RawMessage) (json.RawMessage, error) {
var server map[string]json.RawMessage
if err := json.Unmarshal(data, &server); err != nil {
return nil, err
}
if server == nil {
server = map[string]json.RawMessage{}
}
server["disabled"] = json.RawMessage("false")
return json.Marshal(server)
}
func (cfg *mcpWritableConfig) removeServer(name string) (bool, error) {
cfg.ensureRaw()
removed := false
@@ -856,15 +881,19 @@ func (cfg *mcpWritableConfig) setServerDisabled(name string, disabled bool) (boo
server = map[string]json.RawMessage{}
}
current := false
// A default that ships disabled (aimlapi) stays off until an entry says
// otherwise, so an absent flag reads as disabled and enabling it must be
// written out explicitly: dropping the flag would leave it off.
shippedDisabled := config.IsDefaultMCPServerDisabled(name)
current := shippedDisabled
if rawDisabled, ok := server["disabled"]; ok && len(rawDisabled) > 0 && string(rawDisabled) != "null" {
if err := json.Unmarshal(rawDisabled, &current); err != nil {
return false, false, err
}
}
changed := current != disabled
if disabled {
data, err := json.Marshal(true)
if disabled || shippedDisabled {
data, err := json.Marshal(disabled)
if err != nil {
return false, false, err
}
+260
View File
@@ -2,10 +2,18 @@ package cli
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"github.com/Twigpine/zero/internal/aimlapi"
"github.com/Twigpine/zero/internal/config"
)
@@ -44,3 +52,255 @@ func TestRunMCPDisableSeededExaDefault(t *testing.T) {
t.Fatal("expected `mcp disable exa` to turn the seeded default off in the resolved config")
}
}
// aimlapiWire stands in for https://mcp.aimlapi.com/mcp at the transport layer:
// requests keep their real URL, nothing is dialed, and each one is recorded.
type aimlapiWire struct {
mu sync.Mutex
requests []*http.Request
}
func (wire *aimlapiWire) RoundTrip(request *http.Request) (*http.Response, error) {
var body []byte
if request.Body != nil {
body, _ = io.ReadAll(request.Body)
_ = request.Body.Close()
}
wire.mu.Lock()
wire.requests = append(wire.requests, request.Clone(context.Background()))
wire.mu.Unlock()
var message struct {
ID json.RawMessage `json:"id"`
Method string `json:"method"`
}
_ = json.Unmarshal(body, &message)
respond := func(status int, payload string) *http.Response {
return &http.Response{StatusCode: status, Header: http.Header{"Content-Type": {"application/json"}}, Body: io.NopCloser(strings.NewReader(payload)), Request: request}
}
if request.URL.String() != aimlapi.MCPServerURL {
return respond(http.StatusBadGateway, `{}`), nil
}
if len(message.ID) == 0 {
return respond(http.StatusAccepted, ""), nil
}
result := `{"protocolVersion":"2024-11-05","capabilities":{},"serverInfo":{"name":"fake","version":"1"}}`
if message.Method == "tools/list" {
result = `{"tools":[{"name":"tools_search","description":"Search the Toolbox","inputSchema":{"type":"object"}}]}`
}
return respond(http.StatusOK, fmt.Sprintf(`{"jsonrpc":"2.0","id":%s,"result":%s}`, message.ID, result)), nil
}
func (wire *aimlapiWire) recorded() []*http.Request {
wire.mu.Lock()
defer wire.mu.Unlock()
return append([]*http.Request(nil), wire.requests...)
}
// isolateAIMLAPIMCPCommand redirects every root the real resolve path reads to
// a temporary home, sets an AI/ML API key in the environment, and routes the
// default HTTP transport to a recorder. It returns the user config path.
func isolateAIMLAPIMCPCommand(t *testing.T, key string) (string, *aimlapiWire, appDeps) {
t.Helper()
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".state"))
t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
t.Setenv("APPDATA", filepath.Join(home, "AppData", "Roaming"))
t.Setenv("LOCALAPPDATA", filepath.Join(home, "AppData", "Local"))
t.Setenv("ZERO_CRED_STORAGE", "file")
t.Setenv("ZERO_PROVIDER", "")
t.Setenv("AIMLAPI_INFERENCE_URL", "")
t.Setenv("AIMLAPI_PARTNER_ID", "")
t.Setenv("AIMLAPI_API_KEY", key)
configPath, err := config.DefaultUserConfigPath()
if err != nil {
t.Fatal(err)
}
writeMCPCommandRawConfig(t, configPath, `{}`)
wire := &aimlapiWire{}
previous := http.DefaultTransport
http.DefaultTransport = wire
t.Cleanup(func() { http.DefaultTransport = previous })
workspace := t.TempDir()
return configPath, wire, appDeps{getwd: func() (string, error) { return workspace, nil }}
}
func TestRunMCPEnableAIMLAPIDefaultWritesOnlyTheToggle(t *testing.T) {
configPath, wire, deps := isolateAIMLAPIMCPCommand(t, "aiml-secret-key")
var out, errBuf bytes.Buffer
if code := runWithDeps([]string{"mcp", "disable", "aimlapi", "--json"}, &out, &errBuf, deps); code != exitSuccess {
t.Fatalf("disable exit=%d stderr=%s", code, errBuf.String())
}
if !strings.Contains(out.String(), `"changed": false`) {
t.Fatalf("disabling the off-by-default server should be a no-op: %s", out.String())
}
out.Reset()
errBuf.Reset()
if code := runWithDeps([]string{"mcp", "enable", "aimlapi", "--json"}, &out, &errBuf, deps); code != exitSuccess {
t.Fatalf("enable exit=%d stderr=%s", code, errBuf.String())
}
if !strings.Contains(out.String(), `"changed": true`) {
t.Fatalf("enable should report a change: %s", out.String())
}
data, err := os.ReadFile(configPath)
if err != nil {
t.Fatal(err)
}
var written struct {
MCP struct {
Servers map[string]map[string]any `json:"servers"`
} `json:"mcp"`
}
if err := json.Unmarshal(data, &written); err != nil {
t.Fatal(err)
}
if entry := written.MCP.Servers["aimlapi"]; len(entry) != 1 || entry["disabled"] != false {
t.Fatalf("enable must write only the toggle, got %#v in %s", entry, data)
}
if strings.Contains(string(data), "aiml-secret-key") || strings.Contains(string(data), "Authorization") {
t.Fatalf("config holds credential material: %s", data)
}
cfg, err := config.ResolveMCP(config.ResolveOptions{UserConfigPath: configPath})
if err != nil {
t.Fatal(err)
}
if server := cfg.Servers["aimlapi"]; server.Disabled || config.IsUnconfiguredDefault("aimlapi", server) {
t.Fatalf("enabled aimlapi = %#v, want enabled and user-configured", server)
}
// An unrelated save rewrites the whole file through FileConfig; the
// explicit enable has to survive it or the shipped disable comes back.
if _, err := config.SetTheme(configPath, "dark"); err != nil {
t.Fatal(err)
}
cfg, err = config.ResolveMCP(config.ResolveOptions{UserConfigPath: configPath})
if err != nil {
t.Fatal(err)
}
if cfg.Servers["aimlapi"].Disabled {
data, _ := os.ReadFile(configPath)
t.Fatalf("an unrelated config save turned aimlapi off again: %s", data)
}
if len(wire.recorded()) != 0 {
t.Fatal("enable must not connect")
}
out.Reset()
if code := runWithDeps([]string{"mcp", "disable", "aimlapi"}, &out, &errBuf, deps); code != exitSuccess {
t.Fatalf("disable exit=%d stderr=%s", code, errBuf.String())
}
cfg, err = config.ResolveMCP(config.ResolveOptions{UserConfigPath: configPath})
if err != nil {
t.Fatal(err)
}
if !cfg.Servers["aimlapi"].Disabled {
t.Fatal("disable after enable must turn aimlapi off again")
}
}
func TestRunMCPAddUnderAIMLAPIDefaultNameEnablesIt(t *testing.T) {
configPath, _, deps := isolateAIMLAPIMCPCommand(t, "")
var out, errBuf bytes.Buffer
if code := runWithDeps([]string{"mcp", "add", "aimlapi", "--type", "http", "--url", aimlapi.MCPServerURL, "--auth", "oauth"}, &out, &errBuf, deps); code != exitSuccess {
t.Fatalf("add exit=%d stderr=%s", code, errBuf.String())
}
cfg, err := config.ResolveMCP(config.ResolveOptions{UserConfigPath: configPath})
if err != nil {
t.Fatal(err)
}
if server := cfg.Servers["aimlapi"]; server.Disabled || server.Auth != "oauth" {
t.Fatalf("added aimlapi = %#v, want enabled with oauth", server)
}
if code := runWithDeps([]string{"mcp", "add", "aimlapi", "--type", "http", "--url", aimlapi.MCPServerURL, "--disabled"}, &out, &errBuf, deps); code != exitSuccess {
t.Fatalf("add --disabled exit=%d stderr=%s", code, errBuf.String())
}
if cfg, err = config.ResolveMCP(config.ResolveOptions{UserConfigPath: configPath}); err != nil || !cfg.Servers["aimlapi"].Disabled {
t.Fatalf("add --disabled left aimlapi enabled: %#v, %v", cfg.Servers["aimlapi"], err)
}
}
// Through the real resolve path (no injected resolveMCPConfig): the default is
// listed as disabled, never connects, and once enabled it reaches session
// startup and `zero mcp check`, which connects with the key on the wire.
func TestAIMLAPIMCPDefaultThroughRealResolvePath(t *testing.T) {
_, wire, deps := isolateAIMLAPIMCPCommand(t, "aiml-secret-key")
var out, errBuf bytes.Buffer
if code := runWithDeps([]string{"mcp", "list"}, &out, &errBuf, deps); code != exitSuccess {
t.Fatalf("list exit=%d stderr=%s", code, errBuf.String())
}
if !strings.Contains(out.String(), "aimlapi [http] disabled https://mcp.aimlapi.com/mcp") || strings.Contains(out.String(), "aiml-secret-key") {
t.Fatalf("list output should show the disabled aimlapi default and no key:\n%s", out.String())
}
out.Reset()
if code := runWithDeps([]string{"mcp", "list", "--json"}, &out, &errBuf, deps); code != exitSuccess {
t.Fatalf("list --json exit=%d stderr=%s", code, errBuf.String())
}
if !strings.Contains(out.String(), `"aimlapi"`) || strings.Contains(out.String(), "aiml-secret-key") {
t.Fatalf("list --json = %s", out.String())
}
out.Reset()
errBuf.Reset()
if code := runWithDeps([]string{"mcp", "check", "aimlapi"}, &out, &errBuf, deps); code == exitSuccess || !strings.Contains(errBuf.String(), "is disabled") {
t.Fatalf("check of the disabled default: exit=%d stderr=%s", code, errBuf.String())
}
resolved, err := fillAppDeps(deps).resolveMCPConfig(t.TempDir(), true)
if err != nil {
t.Fatal(err)
}
critical, optional := splitMCPStartupConfig(resolved)
if _, ok := critical.Servers["aimlapi"]; ok {
t.Fatal("the disabled default must not start with a session")
}
if _, ok := optional.Servers["aimlapi"]; ok {
t.Fatal("the disabled default must not start with a session")
}
if len(wire.recorded()) != 0 {
t.Fatalf("the disabled default connected: %d requests", len(wire.recorded()))
}
if code := runWithDeps([]string{"mcp", "enable", "aimlapi"}, &out, &errBuf, deps); code != exitSuccess {
t.Fatalf("enable exit=%d stderr=%s", code, errBuf.String())
}
resolved, err = fillAppDeps(deps).resolveMCPConfig(t.TempDir(), true)
if err != nil {
t.Fatal(err)
}
critical, _ = splitMCPStartupConfig(resolved)
if _, ok := critical.Servers["aimlapi"]; !ok {
t.Fatal("an enabled aimlapi must start with the session as a user-configured server")
}
out.Reset()
errBuf.Reset()
if code := runWithDeps([]string{"mcp", "check", "aimlapi", "--json"}, &out, &errBuf, deps); code != exitSuccess {
t.Fatalf("check exit=%d stdout=%s stderr=%s", code, out.String(), errBuf.String())
}
if !strings.Contains(out.String(), `"status": "ok"`) || !strings.Contains(out.String(), `"toolCount": 1`) {
t.Fatalf("check output = %s", out.String())
}
if strings.Contains(out.String()+errBuf.String(), "aiml-secret-key") {
t.Fatalf("check output leaks the key: %s %s", out.String(), errBuf.String())
}
requests := wire.recorded()
if len(requests) == 0 {
t.Fatal("check did not connect")
}
for _, request := range requests {
if request.URL.String() != aimlapi.MCPServerURL {
t.Fatalf("request went to %s", request.URL)
}
if got := request.Header.Get("Authorization"); got != "Bearer aiml-secret-key" {
t.Fatalf("Authorization = %q, want the AI/ML API key", got)
}
if request.Header.Get(aimlapi.SourceHeaderName) != aimlapi.SourceValue || request.Header.Get(aimlapi.PartnerHeaderName) == "" {
t.Fatalf("attribution headers missing: %v", request.Header)
}
}
}
-163
View File
@@ -1,163 +0,0 @@
package cli
import (
"bytes"
"context"
"errors"
"strings"
"testing"
"time"
"github.com/Twigpine/zero/internal/config"
"github.com/Twigpine/zero/internal/mcp"
"github.com/Twigpine/zero/internal/tools"
)
// A LAUNCH THAT COMPLETES AFTER THE REPORTER HAS RUN MUST STILL BE SAID, ONCE,
// AND ONLY WHILE SOMEONE OWNS THE WRITER.
//
// Both production paths call reportMCPStartupDisclosures exactly once, right
// after RegisterTools returns. A stdio attempt abandoned at the connect timeout
// can still be inside cmd.Start at that moment; the process then starts under
// the reduced write confinement, the reaper closes its client, and a reporter
// that merely SAMPLED the runtime has already come and gone. The retained sink
// held the fact and nobody read it again, so the operator saw the skipped
// server and never the disclosure.
//
// This drives the REAL reporter against a REAL runtime, rather than polling
// StartupDisclosures by hand, which is what an earlier regression did and which
// is precisely how it masked the original bug: a test that re-reads on the
// tester's behalf proves nothing about a production path that does not.
//
// It also never reads stderr while the pump could write. The buffer is examined
// only after stop has joined the pump, which is the same discipline both
// production callers follow, and is why this passes under -race.
func TestLateMCPLaunchReachesTheStartupReporterExactlyOnce(t *testing.T) {
const notice = "MCP server started without WRITE_RESTRICTED because denyRead is configured (#869)"
released := make(chan struct{})
published := make(chan struct{})
runtime, err := mcp.RegisterTools(context.Background(), tools.NewRegistry(),
config.MCPConfig{Servers: map[string]config.MCPServerConfig{
"slow": {Type: "stdio", Command: "slow-mcp"},
}},
mcp.RegisterOptions{
ConnectTimeout: 50 * time.Millisecond,
ClientFactory: func(ctx context.Context, server mcp.Server) (mcp.ToolClient, error) {
// Held past the registration timeout AND past the settle grace, so
// registration has already reaped this attempt and returned.
<-released
mcp.PublishLaunchForTest(ctx, []string{notice})
// Publishing is synchronous into the stream, so by the time this
// closes the disclosure is queued and stop cannot race past it.
close(published)
return nil, errors.New("initialize failed long after start")
},
})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = runtime.Close() })
// The reporter runs ONCE, here, exactly as runExec and the interactive
// startup path run it: before the launch has resolved.
var stderr bytes.Buffer
_, stop := reportMCPStartupDisclosures(&stderr, runtime)
close(released)
<-published
// The owner ends delivery and joins the pump. Every write to the buffer has
// happened by the time this returns, so the reads below are unsynchronised
// only because there is no longer anything to synchronise with.
stop()
got := stderr.String()
if n := strings.Count(got, notice); n != 1 {
t.Fatalf("a launch that completed after the reporter ran was disclosed %d time(s), want exactly 1:\n%s", n, got)
}
if !strings.Contains(got, "MCP server slow started with reduced enforcement") {
t.Errorf("the late disclosure does not name the server:\n%s", got)
}
if skipped := runtime.Skipped(); len(skipped) != 1 {
t.Errorf("the server should still be recorded as skipped: %#v", skipped)
}
}
// And a server whose launch was already known when the reporter ran is said
// once by it, and not again by the late path.
func TestKnownMCPLaunchIsNotReportedTwice(t *testing.T) {
const notice = "MCP server started under reduced enforcement"
runtime, err := mcp.RegisterTools(context.Background(), tools.NewRegistry(),
config.MCPConfig{Servers: map[string]config.MCPServerConfig{
"fast": {Type: "stdio", Command: "fast-mcp"},
}},
mcp.RegisterOptions{
ConnectTimeout: time.Second,
ClientFactory: func(ctx context.Context, server mcp.Server) (mcp.ToolClient, error) {
mcp.PublishLaunchForTest(ctx, []string{notice})
return nil, errors.New("initialize failed after start")
},
})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = runtime.Close() })
var stderr bytes.Buffer
_, stop := reportMCPStartupDisclosures(&stderr, runtime)
stop()
if n := strings.Count(stderr.String(), notice); n != 1 {
t.Fatalf("a launch known at registration was disclosed %d time(s), want exactly 1:\n%s", n, stderr.String())
}
}
// THE OWNERSHIP BOUNDARY ITSELF: once the caller has stopped delivery, nothing
// may write to its writer again.
//
// This is the property that the retained presentation callback could not hold.
// It invoked the CLI's print function from the abandoned connect goroutine
// whenever the launch happened to resolve, so a write could land after runExec
// had returned or after Bubble Tea had taken the alt screen. The interactive
// path stops delivery on the line before it hands over the terminal, and this
// pins what that buys: a launch resolving afterwards is dropped, not printed.
func TestMCPDisclosureAfterStopIsDroppedNotWritten(t *testing.T) {
const notice = "MCP server started under reduced enforcement"
released := make(chan struct{})
published := make(chan struct{})
runtime, err := mcp.RegisterTools(context.Background(), tools.NewRegistry(),
config.MCPConfig{Servers: map[string]config.MCPServerConfig{
"slow": {Type: "stdio", Command: "slow-mcp"},
}},
mcp.RegisterOptions{
ConnectTimeout: 50 * time.Millisecond,
ClientFactory: func(ctx context.Context, server mcp.Server) (mcp.ToolClient, error) {
<-released
mcp.PublishLaunchForTest(ctx, []string{notice})
close(published)
return nil, errors.New("initialize failed long after start")
},
})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = runtime.Close() })
var stderr bytes.Buffer
_, stop := reportMCPStartupDisclosures(&stderr, runtime)
// The owner gives up the writer BEFORE the launch resolves, which is the
// interactive hand-off to the TUI.
stop()
close(released)
<-published
// Asserting an absence, so the wrong behaviour is given time to appear: once
// publishing has returned the disclosure is queued, and a delivery path that
// outlived stop would have this long to print it. With delivery ended and the
// pump joined there is no writer left, so this window changes nothing.
time.Sleep(50 * time.Millisecond)
if got := stderr.String(); got != "" {
t.Fatalf("a launch that resolved after the owner stopped still wrote to its writer: %q", got)
}
}
@@ -1,52 +0,0 @@
package cli
import (
"bytes"
"strings"
"testing"
"github.com/Twigpine/zero/internal/mcp"
)
type disclosingRuntime struct {
noopMCPRuntime
disclosures []mcp.StartupDisclosure
}
func (runtime disclosingRuntime) StartupDisclosures() []mcp.StartupDisclosure {
return runtime.disclosures
}
// SAID ONCE, WHERE THE USER IS ALREADY BEING TOLD WHAT STARTED.
//
// The disclosure describes a server PROCESS, which serves the whole session, so
// it cannot ride on a tool result and must not be repeated on every one.
func TestStartupDisclosuresAreReportedOnce(t *testing.T) {
const notice = "denyRead is configured, so the write jail is not confining writes"
var stderr bytes.Buffer
reportMCPStartupDisclosures(&stderr, disclosingRuntime{
disclosures: []mcp.StartupDisclosure{{Name: "docs", Notices: []string{notice}}},
})
output := stderr.String()
if count := strings.Count(output, notice); count != 1 {
t.Errorf("the disclosure appears %d times, want exactly 1: %q", count, output)
}
if !strings.Contains(output, "docs") {
t.Errorf("the report does not name the server it is about: %q", output)
}
}
// A run with nothing to disclose prints nothing at all.
func TestNoStartupDisclosuresPrintNothing(t *testing.T) {
var stderr bytes.Buffer
reportMCPStartupDisclosures(&stderr, disclosingRuntime{})
if stderr.Len() != 0 {
t.Errorf("a run with no disclosure wrote %q", stderr.String())
}
stderr.Reset()
// And a runtime that launches nothing is not required to answer.
reportMCPStartupDisclosures(&stderr, noopMCPRuntime{})
if stderr.Len() != 0 {
t.Errorf("a runtime that launches nothing wrote %q", stderr.String())
}
}
-106
View File
@@ -3,11 +3,9 @@ package cli
import (
"context"
"fmt"
"io"
"net/url"
"sort"
"strings"
"sync"
"github.com/Twigpine/zero/internal/config"
"github.com/Twigpine/zero/internal/execution"
@@ -192,107 +190,3 @@ func isSensitiveMCPDisplayKey(key string) bool {
}
return false
}
// mcpStartupDisclosing is the optional interface a runtime implements when it
// can report what its launched server processes ran under. Optional rather than
// part of mcpToolRuntime so a runtime that launches nothing, and every test
// double, stays unchanged.
type mcpStartupDisclosing interface {
StartupDisclosures() []mcp.StartupDisclosure
}
// mcpStartupStreaming is the push form: the runtime queues each disclosure as a
// typed event, including a launch that completes after registration returned,
// and this package drains it on the goroutine that owns stderr.
type mcpStartupStreaming interface {
StartupDisclosureStream() *mcp.StartupDisclosureStream
}
// reportMCPStartupDisclosures states once what enforcement applied to the MCP
// server processes this run launched.
//
// A PUSH, NOT A SAMPLE. This used to read StartupDisclosures once, here, and a
// stdio attempt abandoned at the connect timeout could still be inside cmd.Start
// at that moment. The process then started under the reduced write confinement,
// the reaper closed its client, and nothing read the runtime again: the operator
// saw the skipped-server warning and never the disclosure.
//
// THIS GOROUTINE OWNS THE WRITER. The runtime queues typed disclosures; every
// write to stderr happens either on the caller's goroutine (the set already known
// when this returns, in server order, so startup output keeps its order) or on
// the single pump started here, never both at once and never after stop.
//
// The returned stop ends delivery and joins the pump, so no write to stderr can
// outlive the caller's ownership of it. The caller must run it before handing the
// terminal to anything else. A disclosure that arrives after stop is dropped: it
// is worth printing while someone owns the writer, and worth losing rather than
// writing into a screen that now belongs to Bubble Tea. Anything already queued
// when stop runs is still printed, on the caller's goroutine, with the pump
// already finished.
//
// ONE WRITER, ONE CALLER AT A TIME. Joining the pump stops writes after its
// lifetime but does nothing about the overlap: startup keeps emitting plugin,
// trust, peer, provider and validation output to the same writer while the pump
// is live. That is unsafe for an ordinary bytes.Buffer and interleaves lines even
// on a writer that tolerates concurrent calls. A mutex private to the pump would
// not help, because the other writes do not go through it. So the returned writer
// is a guarded view of the caller's, and the caller adopts it for the rest of
// startup; both sides then take the same lock.
//
// The pull form is kept for a runtime that implements no stream, which today is
// only test doubles; it has no late launches to deliver, so its stop is a no-op
// and its writer is handed back unchanged.
func reportMCPStartupDisclosures(stderr io.Writer, runtime mcpToolRuntime) (guarded io.Writer, stop func()) {
serialized := &serializedWriter{writer: stderr}
print := func(disclosure mcp.StartupDisclosure) {
for _, notice := range disclosure.Notices {
fmt.Fprintf(serialized, "notice: MCP server %s started with reduced enforcement: %s\n", disclosure.Name, notice)
}
}
printAll := func(disclosures []mcp.StartupDisclosure) {
for _, disclosure := range disclosures {
print(disclosure)
}
}
streaming, ok := runtime.(mcpStartupStreaming)
if !ok {
if disclosing, ok := runtime.(mcpStartupDisclosing); ok {
printAll(disclosing.StartupDisclosures())
}
return stderr, func() {}
}
stream := streaming.StartupDisclosureStream()
if stream == nil {
return stderr, func() {}
}
printAll(stream.Drain())
pumped := make(chan struct{})
go func() {
defer close(pumped)
for stream.Wait() {
printAll(stream.Drain())
}
}()
var once sync.Once
return serialized, func() {
once.Do(func() {
stream.Close()
<-pumped
printAll(stream.Drain())
})
}
}
// serializedWriter gives one underlying writer a single owner at a time, so the
// late-disclosure pump and the foreground startup path cannot be inside it
// together.
type serializedWriter struct {
mu sync.Mutex
writer io.Writer
}
func (w *serializedWriter) Write(data []byte) (int, error) {
w.mu.Lock()
defer w.mu.Unlock()
return w.writer.Write(data)
}
-146
View File
@@ -1,146 +0,0 @@
package cli
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"strings"
"sync"
"testing"
"time"
"github.com/Twigpine/zero/internal/config"
"github.com/Twigpine/zero/internal/mcp"
"github.com/Twigpine/zero/internal/tools"
)
// blockingWriter makes the overlap deterministic instead of hoping to hit it.
//
// The first write parks inside Write until the test releases it, which is the
// window the pump and the foreground startup path really share: startup keeps
// emitting plugin, trust, peer and provider output to the same stderr while a
// late MCP disclosure can arrive at any moment. It also records whether it was
// ever entered twice at once, which is the property under test.
type blockingWriter struct {
mu sync.Mutex
inside int
overlaps int
buf bytes.Buffer
block chan struct{}
blockOne sync.Once
entered chan struct{}
}
func newBlockingWriter() *blockingWriter {
return &blockingWriter{block: make(chan struct{}), entered: make(chan struct{})}
}
func (w *blockingWriter) Write(data []byte) (int, error) {
w.mu.Lock()
w.inside++
if w.inside > 1 {
w.overlaps++
}
w.mu.Unlock()
// Only the first writer parks, and it announces that it is inside.
first := false
w.blockOne.Do(func() {
first = true
close(w.entered)
})
if first {
<-w.block
}
w.mu.Lock()
n, err := w.buf.Write(data)
w.inside--
w.mu.Unlock()
return n, err
}
func (w *blockingWriter) overlapCount() int {
w.mu.Lock()
defer w.mu.Unlock()
return w.overlaps
}
func (w *blockingWriter) String() string {
w.mu.Lock()
defer w.mu.Unlock()
return w.buf.String()
}
// ONE CALLER AT A TIME, FOR THE WHOLE OVERLAP.
//
// Joining the pump at stop bounds writes to the pump's lifetime but says nothing
// about what happens DURING it. The startup paths keep writing to the same
// io.Writer the whole time, and the caller may legitimately hand in a plain
// bytes.Buffer, which corrupts under concurrent use. A mutex private to the pump
// would not have helped, because the foreground writes do not go through it; the
// reporter therefore hands back a guarded view of the caller's writer and the
// caller adopts it, so both sides take the same lock.
func TestLateDisclosureAndForegroundStartupNeverShareTheWriter(t *testing.T) {
const notice = "MCP server started under reduced enforcement"
released := make(chan struct{})
published := make(chan struct{})
runtime, err := mcp.RegisterTools(context.Background(), tools.NewRegistry(),
config.MCPConfig{Servers: map[string]config.MCPServerConfig{
"slow": {Type: "stdio", Command: "slow-mcp"},
}},
mcp.RegisterOptions{
ConnectTimeout: 50 * time.Millisecond,
ClientFactory: func(ctx context.Context, server mcp.Server) (mcp.ToolClient, error) {
<-released
mcp.PublishLaunchForTest(ctx, []string{notice})
close(published)
return nil, errors.New("initialize failed long after start")
},
})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = runtime.Close() })
writer := newBlockingWriter()
guarded, stop := reportMCPStartupDisclosures(writer, runtime)
if guarded == io.Writer(writer) {
t.Fatal("SETUP INVALID: the reporter handed back the raw writer, so the caller cannot share its lock")
}
// Foreground startup writes through the guarded writer and parks inside it,
// exactly as a slow terminal would.
foregroundDone := make(chan struct{})
go func() {
defer close(foregroundDone)
fmt.Fprintln(guarded, "warning: MCP server other unavailable, skipped: dial tcp: refused")
}()
<-writer.entered
// While the foreground write is parked, the late launch resolves and the pump
// tries to print. If the two did not share a lock, this would enter Write
// concurrently.
close(released)
<-published
time.Sleep(50 * time.Millisecond)
close(writer.block)
<-foregroundDone
stop()
if n := writer.overlapCount(); n != 0 {
t.Fatalf("the pump and foreground startup were inside the writer together %d time(s)", n)
}
got := writer.String()
if count := strings.Count(got, notice); count != 1 {
t.Fatalf("the late disclosure was written %d time(s), want exactly 1 after stop drained it:\n%s", count, got)
}
if !strings.Contains(got, "unavailable, skipped") {
t.Errorf("the foreground startup message was lost:\n%s", got)
}
}
@@ -1,72 +0,0 @@
package cli
import (
"strings"
"testing"
"github.com/Twigpine/zero/internal/agent"
"github.com/Twigpine/zero/internal/tools"
)
// A DISCLOSURE THAT IS NOT PERSISTED DID NOT SURVIVE THE RUN.
//
// The session log is what a resumed or compacted conversation is rebuilt from,
// and replay reads this payload's "output" straight into the transcript without
// reconstructing an agent.ToolResult. So persisting the raw undecorated field
// makes a warning that was visible during the original run disappear the moment
// the session is resumed, with nothing failing anywhere to say so.
func TestPersistedToolResultKeepsTheEnforcementNotice(t *testing.T) {
const notice = "least-privilege notice: read access was narrowed"
payload := persistedToolResultPayload(agent.ToolResult{
ToolCallID: "call-1",
Name: "bash",
Status: tools.StatusOK,
Output: "the command output",
EnforcementNotices: []string{notice},
})
output, _ := payload["output"].(string)
if count := strings.Count(output, notice); count != 1 {
t.Errorf("persisted output carries the notice %d times, want exactly 1: %q", count, output)
}
if !strings.Contains(output, "the command output") {
t.Errorf("persisted output lost the command output: %q", output)
}
}
// The other fields still round-trip, so the shared helper did not quietly drop
// what the two writers used to record separately.
func TestPersistedToolResultKeepsItsOtherFields(t *testing.T) {
payload := persistedToolResultPayload(agent.ToolResult{
ToolCallID: "call-2",
Name: "write_file",
Status: tools.StatusError,
Output: "boom",
Meta: map[string]string{"k": "v"},
Truncated: true,
Redacted: true,
ChangedFiles: []string{"a.go"},
})
for _, field := range []string{"toolCallId", "name", "status", "output", "meta", "truncated", "redacted", "changedFiles"} {
if _, ok := payload[field]; !ok {
t.Errorf("payload is missing %q: %#v", field, payload)
}
}
if payload["status"] != string(tools.StatusError) {
t.Errorf("status = %v, want %q", payload["status"], tools.StatusError)
}
}
// An ordinary result records exactly what it did before, so the accessor is not
// adding anything where there is nothing to add.
func TestPersistedToolResultLeavesAnOrdinaryResultAlone(t *testing.T) {
payload := persistedToolResultPayload(agent.ToolResult{
ToolCallID: "call-3",
Name: "bash",
Status: tools.StatusOK,
Output: "plain output",
})
if got := payload["output"]; got != "plain output" {
t.Errorf("persisted output = %v, want it untouched", got)
}
}
@@ -0,0 +1,145 @@
package cli
import (
"bytes"
"context"
"io"
"testing"
"github.com/Twigpine/zero/internal/config"
"github.com/Twigpine/zero/internal/hooks"
"github.com/Twigpine/zero/internal/mcp"
"github.com/Twigpine/zero/internal/specialist"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/tui"
"github.com/Twigpine/zero/internal/zeroruntime"
)
func TestRegisterSpecialistToolsWiresLifecycleHooksIntoTaskOutput(t *testing.T) {
registry := tools.NewRegistry()
runtime, err := registerSpecialistTools(registry, t.TempDir(), 0)
if err != nil {
t.Fatalf("registerSpecialistTools returned error: %v", err)
}
t.Cleanup(func() { closeSpecialistRuntime(io.Discard, runtime) })
if runtime.lifecycleHooks == nil {
t.Fatal("lifecycleHooks is nil after registerSpecialistTools")
}
raw, ok := registry.Get("TaskOutput")
if !ok {
t.Fatal("TaskOutput tool not registered")
}
outputTool, ok := raw.(*specialist.OutputTool)
if !ok {
t.Fatalf("TaskOutput type = %T, want *specialist.OutputTool", raw)
}
if outputTool.LifecycleHooks != runtime.lifecycleHooks {
t.Fatal("TaskOutput LifecycleHooks is not the shared registerSpecialistTools bridge")
}
dispatcher := hooks.NewDispatcher(hooks.DispatcherOptions{})
runtime.attachLifecycleHooks(dispatcher)
if runtime.lifecycleHooks.Dispatch == nil {
t.Fatal("attachLifecycleHooks left Dispatch nil")
}
if outputTool.LifecycleHooks.Dispatch == nil {
t.Fatal("TaskOutput did not observe Dispatch after attachLifecycleHooks")
}
}
// TestRealEntryPointsAttachLifecycleHooks closes the gap the registration-level
// test above cannot see: wiring the bridge when tools register is not enough
// unless the real startup paths actually call attachLifecycleHooks before any
// run. Both surfaces that can launch specialists must do so — the interactive
// TUI and headless exec. attachLifecycleHooks mutates the same
// *specialist.LifecycleHooks the TaskOutput tool holds, so a nil Dispatch on
// that tool after a full startup proves the call never ran and the
// specialistStart/specialistStop hooks would silently never fire.
func TestRealEntryPointsAttachLifecycleHooks(t *testing.T) {
requireTaskOutputDispatchAttached := func(t *testing.T, registry *tools.Registry) {
t.Helper()
if registry == nil {
t.Fatal("startup did not reach the run registry")
}
raw, ok := registry.Get("TaskOutput")
if !ok {
t.Fatal("TaskOutput tool not registered by startup")
}
outputTool, ok := raw.(*specialist.OutputTool)
if !ok {
t.Fatalf("TaskOutput type = %T, want *specialist.OutputTool", raw)
}
if outputTool.LifecycleHooks == nil || outputTool.LifecycleHooks.Dispatch == nil {
t.Fatal("startup left the specialist lifecycle bridge unattached: specialistStart/specialistStop hooks would never fire")
}
}
t.Run("interactive TUI", func(t *testing.T) {
isolateCLIUserState(t)
cwd := t.TempDir()
var registry *tools.Registry
// Empty args route runWithDeps to the interactive TUI path; the injected
// runTUI receives the same registry specialist tools registered into.
exitCode := runWithDeps([]string{}, io.Discard, io.Discard, appDeps{
getwd: func() (string, error) { return cwd, nil },
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
return config.ResolvedConfig{
Provider: config.ProviderProfile{
Name: "p",
ProviderKind: config.ProviderKindOpenAICompatible,
BaseURL: "http://127.0.0.1/v1",
Model: "m",
},
}, nil
},
resolveMCPConfig: func(string, bool) (config.MCPConfig, error) { return config.MCPConfig{}, nil },
runTUI: func(_ context.Context, options tui.Options) int {
registry = options.Registry
return exitSuccess
},
})
if exitCode != exitSuccess {
t.Fatalf("runWithDeps exit = %d, want %d", exitCode, exitSuccess)
}
requireTaskOutputDispatchAttached(t, registry)
})
t.Run("headless exec", func(t *testing.T) {
isolateCLIUserState(t)
cwd := t.TempDir()
var registry *tools.Registry
var stdout, stderr bytes.Buffer
// --auto high is what registers specialist tools on the exec surface.
// MCP registration is the one startup chokepoint that sees the run
// registry before attachLifecycleHooks runs, so capture it there and
// assert after the run completes.
exitCode := runWithDeps([]string{
"exec", "--auto", "high", "--cwd", cwd, "--init-session-id", "hook_wireup", "hello",
}, &stdout, &stderr, appDeps{
getwd: func() (string, error) { return cwd, nil },
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
return execResolvedConfig(), nil
},
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
return echoExecProvider{}, nil
},
resolveMCPConfig: func(string, bool) (config.MCPConfig, error) {
return config.MCPConfig{Servers: map[string]config.MCPServerConfig{
"wireup": {Type: "stdio", Command: "wireup-mcp"},
}}, nil
},
newMCPStore: func() (*mcp.PermissionStore, error) { return nil, nil },
registerMCPTools: func(_ context.Context, captured *tools.Registry, _ config.MCPConfig, _ mcp.RegisterOptions) (mcpToolRuntime, error) {
registry = captured
return closeFunc(func() error { return nil }), nil
},
})
if exitCode != exitSuccess {
t.Fatalf("runWithDeps exit = %d, want %d (stderr=%q)", exitCode, exitSuccess, stderr.String())
}
requireTaskOutputDispatchAttached(t, registry)
})
}
+168
View File
@@ -0,0 +1,168 @@
package config
import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"unicode"
"github.com/Twigpine/zero/internal/aimlapi"
"github.com/Twigpine/zero/internal/providercatalog"
)
const aimlapiKeyEnv = "AIMLAPI_API_KEY"
var (
// ErrAIMLAPIKeyMissing means no AI/ML API key is configured.
ErrAIMLAPIKeyMissing = errors.New("no AI/ML API key is configured; set up aimlapi.com with /provider or set AIMLAPI_API_KEY")
// ErrAIMLAPIProxyProfile means the aimlapi profile points at a proxy, so its
// key belongs to that proxy and is withheld from aimlapi.com services.
ErrAIMLAPIProxyProfile = errors.New("the aimlapi provider points at a proxy base URL; its key is not sent to aimlapi.com services")
// ErrAIMLAPIKeyInvalid means the key cannot be sent as a single Bearer token.
ErrAIMLAPIKeyInvalid = errors.New("the AI/ML API key contains whitespace, a comma or a control character")
)
// AIMLAPIServiceKey returns the AI/ML API key that may be sent to the
// aimlapi.com Toolbox and MCP server. It is resolved again on every call, so a
// provider change is seen by the next request.
//
// The key comes from the aimlapi provider profile (the active one when it is
// aimlapi, otherwise the first), including a key held in the credential store,
// or from AIMLAPI_API_KEY when no aimlapi profile exists. The key is withheld
// when that profile, any aimlapi profile, or any profile reading
// AIMLAPI_API_KEY points at a base URL other than the canonical
// https://api.aimlapi.com/v1, and when AIMLAPI_INFERENCE_URL is overridden:
// in those cases the credential may belong to a proxy.
func AIMLAPIServiceKey(resolved ResolvedConfig, store APIKeyGetter, getenv func(string) string) (string, error) {
if getenv == nil {
getenv = os.Getenv
}
if override := strings.TrimSpace(getenv("AIMLAPI_INFERENCE_URL")); override != "" && !sameBaseURL(override, aimlapi.CanonicalInferenceBaseURL) {
return "", ErrAIMLAPIProxyProfile
}
for _, profile := range resolved.Providers {
if aimlapiKeyedProfile(profile) && !sameBaseURL(profile.BaseURL, aimlapi.CanonicalInferenceBaseURL) {
return "", ErrAIMLAPIProxyProfile
}
}
if profile, ok := aimlapiServiceProfile(resolved); ok {
if !sameBaseURL(profile.BaseURL, aimlapi.CanonicalInferenceBaseURL) {
return "", ErrAIMLAPIProxyProfile
}
if store != nil && profile.APIKeyStored && strings.TrimSpace(profile.APIKey) == "" && strings.TrimSpace(profile.Name) != "" {
key, found, err := store.Get(strings.TrimSpace(profile.Name))
if err != nil {
return "", fmt.Errorf("read AI/ML API credential store: %w", err)
}
if found {
profile.APIKey = key
}
}
return validAIMLAPIServiceKey(profile.APIKey)
}
return validAIMLAPIServiceKey(getenv(aimlapiKeyEnv))
}
// LoadAIMLAPIServiceKey resolves the user configuration, the environment and
// the user credential store, then applies AIMLAPIServiceKey. Project config is
// not read: provider keys are user-scoped. A config with no active provider
// still resolves its profiles (ErrNoActiveProvider), so the key is looked up in
// that partial config; every other resolve error is returned. Resolve drops a
// non-active profile it cannot normalize, so the configured profiles are
// checked as well: such a profile still withholds the key when it carries it.
func LoadAIMLAPIServiceKey() (string, error) {
userConfigPath, err := DefaultUserConfigPath()
if err != nil {
return "", err
}
existing, err := existingConfigFile(userConfigPath)
if err != nil {
return "", err
}
resolved, err := Resolve(ResolveOptions{UserConfigPath: existing, ExcludeProject: true})
if err != nil && !errors.Is(err, ErrNoActiveProvider) {
return "", err
}
if existing != "" {
fileConfig, err := loadConfigFile(existing)
if err != nil {
return "", err
}
var configured FileConfig
mergeConfig(&configured, fileConfig)
if droppedAIMLAPIProxyProfile(configured.Providers, resolved.Providers) {
return "", ErrAIMLAPIProxyProfile
}
}
// Check proxy eligibility and already-resolved keys before opening a store.
key, keyErr := AIMLAPIServiceKey(resolved, nil, os.Getenv)
if !errors.Is(keyErr, ErrAIMLAPIKeyMissing) {
return key, keyErr
}
profile, ok := aimlapiServiceProfile(resolved)
if !ok || !profile.APIKeyStored {
return key, keyErr
}
store, err := ProviderKeyStoreAt(filepath.Dir(userConfigPath))
if err != nil {
return "", fmt.Errorf("open AI/ML API credential store: %w", err)
}
return AIMLAPIServiceKey(resolved, store, os.Getenv)
}
func isAIMLAPIProfile(profile ProviderProfile) bool {
return strings.EqualFold(providercatalog.NormalizeID(profile.CatalogID), providercatalog.AIMLAPIID)
}
// aimlapiKeyedProfile reports whether profile carries an AI/ML API credential:
// an aimlapi catalog profile, or any profile that reads AIMLAPI_API_KEY.
func aimlapiKeyedProfile(profile ProviderProfile) bool {
return isAIMLAPIProfile(profile) || strings.EqualFold(strings.TrimSpace(profile.APIKeyEnv), aimlapiKeyEnv)
}
// droppedAIMLAPIProxyProfile reports whether a configured profile missing from
// the resolved list (Resolve could not normalize it) carries an AI/ML API
// credential and is not explicitly on the canonical base URL. Its endpoint was
// never resolved, so an empty base URL counts as a proxy.
func droppedAIMLAPIProxyProfile(configured, resolved []ProviderProfile) bool {
kept := make(map[string]bool, len(resolved))
for _, profile := range resolved {
kept[profile.Name] = true
}
for _, profile := range configured {
if kept[strings.TrimSpace(profile.Name)] || !aimlapiKeyedProfile(profile) {
continue
}
if !sameBaseURL(profile.BaseURL, aimlapi.CanonicalInferenceBaseURL) {
return true
}
}
return false
}
func aimlapiServiceProfile(resolved ResolvedConfig) (ProviderProfile, bool) {
if isAIMLAPIProfile(resolved.Provider) {
return resolved.Provider, true
}
for _, profile := range resolved.Providers {
if isAIMLAPIProfile(profile) {
return profile, true
}
}
return ProviderProfile{}, false
}
func validAIMLAPIServiceKey(raw string) (string, error) {
key := strings.TrimSpace(raw)
if key == "" {
return "", ErrAIMLAPIKeyMissing
}
for _, r := range key {
if unicode.IsSpace(r) || r <= ' ' || r == 0x7f || r == ',' || (r >= 0x80 && r <= 0x9f) {
return "", ErrAIMLAPIKeyInvalid
}
}
return key, nil
}
+282
View File
@@ -0,0 +1,282 @@
package config
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
)
func aimlapiServiceKeyEnv(values map[string]string) func(string) string {
return func(name string) string { return values[name] }
}
func TestAIMLAPIServiceKeyUsesCanonicalProfileKey(t *testing.T) {
resolved := ResolvedConfig{Providers: []ProviderProfile{
{Name: "openai", BaseURL: "https://api.openai.com/v1", APIKey: "sk-openai"},
{Name: "aimlapi", CatalogID: "aimlapi", BaseURL: "https://api.aimlapi.com/v1/", APIKey: " aiml-key "},
}}
key, err := AIMLAPIServiceKey(resolved, nil, aimlapiServiceKeyEnv(nil))
if err != nil || key != "aiml-key" {
t.Fatalf("AIMLAPIServiceKey = %q, %v; want aiml-key", key, err)
}
}
func TestAIMLAPIServiceKeyReadsCredentialStore(t *testing.T) {
resolved := ResolvedConfig{Provider: ProviderProfile{Name: "aimlapi", CatalogID: "aimlapi", BaseURL: "https://api.aimlapi.com/v1", APIKeyStored: true}}
store := fakeKeyGetter{keys: map[string]string{"aimlapi": "stored-key"}}
key, err := AIMLAPIServiceKey(resolved, store, aimlapiServiceKeyEnv(nil))
if err != nil || key != "stored-key" {
t.Fatalf("AIMLAPIServiceKey = %q, %v; want stored-key", key, err)
}
}
func TestAIMLAPIServiceKeyPrefersResolvedProfileKeyOverCredentialStore(t *testing.T) {
resolved := ResolvedConfig{Provider: ProviderProfile{
Name: "aimlapi", CatalogID: "aimlapi", BaseURL: "https://api.aimlapi.com/v1",
APIKey: "env-key", APIKeyStored: true,
}}
store := fakeKeyGetter{keys: map[string]string{"aimlapi": "stored-key"}}
key, err := AIMLAPIServiceKey(resolved, store, aimlapiServiceKeyEnv(map[string]string{"AIMLAPI_API_KEY": "env-key"}))
if err != nil || key != "env-key" {
t.Fatalf("AIMLAPIServiceKey = %q, %v; want env-key from resolved profile, not stored-key", key, err)
}
}
func TestAIMLAPIServiceKeyFallsBackToEnvironmentWithoutProfile(t *testing.T) {
key, err := AIMLAPIServiceKey(ResolvedConfig{}, nil, aimlapiServiceKeyEnv(map[string]string{"AIMLAPI_API_KEY": "env-key"}))
if err != nil || key != "env-key" {
t.Fatalf("AIMLAPIServiceKey = %q, %v; want env-key", key, err)
}
if _, err := AIMLAPIServiceKey(ResolvedConfig{}, nil, aimlapiServiceKeyEnv(nil)); !errors.Is(err, ErrAIMLAPIKeyMissing) {
t.Fatalf("missing key error = %v, want ErrAIMLAPIKeyMissing", err)
}
}
func TestAIMLAPIServiceKeyWithholdsProxyCredentials(t *testing.T) {
env := aimlapiServiceKeyEnv(map[string]string{"AIMLAPI_API_KEY": "proxy-key"})
cases := map[string]ResolvedConfig{
"active aimlapi profile on a proxy": {Provider: ProviderProfile{Name: "aimlapi", CatalogID: "aimlapi", BaseURL: "https://proxy.example/v1", APIKey: "proxy-key"}},
"a second aimlapi profile on a proxy": {Providers: []ProviderProfile{
{Name: "aimlapi", CatalogID: "aimlapi", BaseURL: "https://api.aimlapi.com/v1", APIKey: "proxy-key"},
{Name: "aimlapi-proxy", CatalogID: "aimlapi", BaseURL: "https://proxy.example/v1", APIKey: "proxy-key"},
}},
"a custom profile reading AIMLAPI_API_KEY": {Providers: []ProviderProfile{
{Name: "gateway", BaseURL: "https://proxy.example/v1", APIKeyEnv: "AIMLAPI_API_KEY", APIKey: "proxy-key"},
}},
}
for name, resolved := range cases {
key, err := AIMLAPIServiceKey(resolved, nil, env)
if !errors.Is(err, ErrAIMLAPIProxyProfile) || key != "" {
t.Errorf("%s: AIMLAPIServiceKey = %q, %v; want ErrAIMLAPIProxyProfile", name, key, err)
}
}
overridden := aimlapiServiceKeyEnv(map[string]string{"AIMLAPI_API_KEY": "proxy-key", "AIMLAPI_INFERENCE_URL": "https://staging.example/v1"})
if key, err := AIMLAPIServiceKey(ResolvedConfig{}, nil, overridden); !errors.Is(err, ErrAIMLAPIProxyProfile) || key != "" {
t.Fatalf("inference URL override: AIMLAPIServiceKey = %q, %v; want ErrAIMLAPIProxyProfile", key, err)
}
}
func TestAIMLAPIServiceKeyRejectsKeysThatAreNotOneToken(t *testing.T) {
for _, raw := range []string{"first\u00a0second", "first\u2003second", "first,second", "first\nsecond", "first\r\nsecond", "a b", "key\x00tail", "key\u0085tail"} {
resolved := ResolvedConfig{Provider: ProviderProfile{Name: "aimlapi", CatalogID: "aimlapi", BaseURL: "https://api.aimlapi.com/v1", APIKey: raw}}
if key, err := AIMLAPIServiceKey(resolved, nil, aimlapiServiceKeyEnv(nil)); !errors.Is(err, ErrAIMLAPIKeyInvalid) || key != "" {
t.Errorf("key %q: AIMLAPIServiceKey = %q, %v; want ErrAIMLAPIKeyInvalid", raw, key, err)
}
}
}
func TestLoadAIMLAPIServiceKeyResolvesUserConfig(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("APPDATA", filepath.Join(home, "AppData"))
t.Setenv("USERPROFILE", home)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
t.Setenv("ZERO_CRED_STORAGE", "file")
t.Setenv("ZERO_PROVIDER", "")
t.Setenv("AIMLAPI_INFERENCE_URL", "")
t.Setenv("AIMLAPI_API_KEY", "env-key")
if key, err := LoadAIMLAPIServiceKey(); err != nil || key != "env-key" {
t.Fatalf("without config: LoadAIMLAPIServiceKey = %q, %v; want env-key", key, err)
}
path, err := DefaultUserConfigPath()
if err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
t.Fatal(err)
}
proxy := `{"activeProvider":"aimlapi","providers":[{"name":"aimlapi","catalogID":"aimlapi","baseURL":"https://proxy.example/v1","model":"m"}]}`
if err := os.WriteFile(path, []byte(proxy), 0o600); err != nil {
t.Fatal(err)
}
if key, err := LoadAIMLAPIServiceKey(); !errors.Is(err, ErrAIMLAPIProxyProfile) || key != "" {
t.Fatalf("proxy profile: LoadAIMLAPIServiceKey = %q, %v; want ErrAIMLAPIProxyProfile", key, err)
}
canonical := `{"activeProvider":"aimlapi","providers":[{"name":"aimlapi","catalogID":"aimlapi"}]}`
if err := os.WriteFile(path, []byte(canonical), 0o600); err != nil {
t.Fatal(err)
}
if key, err := LoadAIMLAPIServiceKey(); err != nil || key != "env-key" {
t.Fatalf("canonical profile: LoadAIMLAPIServiceKey = %q, %v; want env-key", key, err)
}
}
// Several profiles and no activeProvider make Resolve return
// ErrNoActiveProvider with the normalized profiles; the interactive app keeps
// running on that partial config, so the service key must still resolve.
func TestLoadAIMLAPIServiceKeyWithoutActiveProvider(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("APPDATA", filepath.Join(home, "AppData"))
t.Setenv("USERPROFILE", home)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
t.Setenv("ZERO_CRED_STORAGE", "file")
t.Setenv("ZERO_PROVIDER", "")
t.Setenv("AIMLAPI_INFERENCE_URL", "")
t.Setenv("AIMLAPI_API_KEY", "env-key")
path, err := DefaultUserConfigPath()
if err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
t.Fatal(err)
}
other := `{"name":"other","provider":"openai","model":"m","apiKey":"other-key"}`
for _, tc := range []struct {
name string
active string
profile string
want string
wantErr error
}{
{name: "environment key", profile: `{"name":"second","provider":"openai","model":"m","apiKey":"second-key"}`, want: "env-key"},
{name: "stale active provider", active: "gone", profile: `{"name":"second","provider":"openai","model":"m","apiKey":"second-key"}`, want: "env-key"},
{name: "aimlapi profile key", profile: `{"name":"aimlapi","catalogID":"aimlapi","model":"m","apiKey":"profile-key"}`, want: "profile-key"},
{name: "proxy profile", profile: `{"name":"aimlapi","catalogID":"aimlapi","baseURL":"https://proxy.example/v1","model":"m"}`, wantErr: ErrAIMLAPIProxyProfile},
} {
data := `{"activeProvider":"` + tc.active + `","providers":[` + other + `,` + tc.profile + `]}`
if err := os.WriteFile(path, []byte(data), 0o600); err != nil {
t.Fatal(err)
}
key, err := LoadAIMLAPIServiceKey()
if tc.wantErr != nil {
if !errors.Is(err, tc.wantErr) || key != "" {
t.Fatalf("%s: LoadAIMLAPIServiceKey = %q, %v; want %v", tc.name, key, err, tc.wantErr)
}
continue
}
if err != nil || key != tc.want {
t.Fatalf("%s: LoadAIMLAPIServiceKey = %q, %v; want %q", tc.name, key, err, tc.want)
}
}
}
// Resolve drops a non-active profile it cannot normalize (here an unknown
// catalog entry). Such a profile still shows that AIMLAPI_API_KEY belongs to a
// proxy, so the key is withheld whether or not a provider is active.
func TestLoadAIMLAPIServiceKeyWithholdsForDroppedProxyProfile(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("APPDATA", filepath.Join(home, "AppData"))
t.Setenv("USERPROFILE", home)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
t.Setenv("ZERO_CRED_STORAGE", "file")
t.Setenv("ZERO_PROVIDER", "")
t.Setenv("AIMLAPI_INFERENCE_URL", "")
t.Setenv("AIMLAPI_API_KEY", "env-key")
path, err := DefaultUserConfigPath()
if err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
t.Fatal(err)
}
canonical := `{"name":"aimlapi","catalogID":"aimlapi","model":"m","apiKey":"profile-key"}`
other := `{"name":"other","provider":"openai","model":"m","apiKey":"other-key"}`
dropped := `{"name":"future","catalogID":"no-such-catalog","baseURL":"https://proxy.example/v1","apiKeyEnv":"AIMLAPI_API_KEY","model":"m"}`
unrelated := `{"name":"future","catalogID":"no-such-catalog","baseURL":"https://proxy.example/v1","apiKeyEnv":"OTHER_API_KEY","model":"m"}`
for _, tc := range []struct {
name string
data string
want string
wantErr error
}{
{name: "active aimlapi profile", data: `{"activeProvider":"aimlapi","providers":[` + canonical + `,` + dropped + `]}`, wantErr: ErrAIMLAPIProxyProfile},
{name: "no active provider", data: `{"providers":[` + other + `,` + dropped + `]}`, wantErr: ErrAIMLAPIProxyProfile},
{name: "dropped profile without the key", data: `{"activeProvider":"aimlapi","providers":[` + canonical + `,` + unrelated + `]}`, want: "profile-key"},
} {
if err := os.WriteFile(path, []byte(tc.data), 0o600); err != nil {
t.Fatal(err)
}
key, err := LoadAIMLAPIServiceKey()
if tc.wantErr != nil {
if !errors.Is(err, tc.wantErr) || key != "" {
t.Fatalf("%s: LoadAIMLAPIServiceKey = %q, %v; want %v", tc.name, key, err, tc.wantErr)
}
continue
}
if err != nil || key != tc.want {
t.Fatalf("%s: LoadAIMLAPIServiceKey = %q, %v; want %q", tc.name, key, err, tc.want)
}
}
}
func TestLoadAIMLAPIServiceKeyStoreOpenFailure(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("APPDATA", filepath.Join(home, "AppData"))
t.Setenv("USERPROFILE", home)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
t.Setenv("ZERO_PROVIDER", "")
t.Setenv("AIMLAPI_INFERENCE_URL", "")
t.Setenv("AIMLAPI_API_KEY", "env-key")
t.Setenv("ZERO_CRED_STORAGE", "invalid-test-storage")
if key, err := LoadAIMLAPIServiceKey(); err != nil || key != "env-key" {
t.Fatalf("environment key must not require a store: %q, %v", key, err)
}
path, err := DefaultUserConfigPath()
if err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
t.Fatal(err)
}
t.Setenv("AIMLAPI_API_KEY", "")
data := `{"activeProvider":"aimlapi","providers":[{"name":"aimlapi","catalogID":"aimlapi","model":"m","apiKeyStored":true}]}`
if err := os.WriteFile(path, []byte(data), 0o600); err != nil {
t.Fatal(err)
}
if key, err := LoadAIMLAPIServiceKey(); key != "" || err == nil || !strings.Contains(err.Error(), "unknown storage") {
t.Fatalf("stored key must report store failure: %q, %v", key, err)
}
t.Setenv("AIMLAPI_INFERENCE_URL", "https://proxy.example/v1")
if key, err := LoadAIMLAPIServiceKey(); key != "" || !errors.Is(err, ErrAIMLAPIProxyProfile) {
t.Fatalf("proxy rejection must precede store open: %q, %v", key, err)
}
t.Setenv("AIMLAPI_INFERENCE_URL", "")
data = `{"activeProvider":"aimlapi","providers":[{"name":"aimlapi","catalogID":"aimlapi","model":"m","apiKeyStored":true,"apiKey":"inline-key"}]}`
if err := os.WriteFile(path, []byte(data), 0o600); err != nil {
t.Fatal(err)
}
if key, err := LoadAIMLAPIServiceKey(); err != nil || key != "inline-key" {
t.Fatalf("inline key must not require a store: %q, %v", key, err)
}
}
func TestAIMLAPIServiceKeyReportsStoreReadFailure(t *testing.T) {
want := errors.New("test credential read failure")
resolved := ResolvedConfig{Provider: ProviderProfile{Name: "aimlapi", CatalogID: "aimlapi", BaseURL: "https://api.aimlapi.com/v1", APIKeyStored: true}}
if key, err := AIMLAPIServiceKey(resolved, fakeKeyGetter{err: want}, aimlapiServiceKeyEnv(nil)); key != "" || !errors.Is(err, want) {
t.Fatalf("read failure was hidden: %q, %v", key, err)
}
resolved.Provider.BaseURL = "https://proxy.example/v1"
if _, err := AIMLAPIServiceKey(resolved, fakeKeyGetter{err: want}, aimlapiServiceKeyEnv(nil)); !errors.Is(err, ErrAIMLAPIProxyProfile) {
t.Fatalf("proxy check must precede store read: %v", err)
}
}
+51
View File
@@ -0,0 +1,51 @@
package config
import (
"encoding/json"
"os"
"path/filepath"
"testing"
)
func TestEnsureChatGPTDefaultPreservesSavedSelection(t *testing.T) {
for _, saved := range []string{"", "gpt-explicit-saved"} {
name := "new profile"
if saved != "" {
name = "saved selection"
}
t.Run(name, func(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.json")
if saved != "" {
body, err := json.Marshal(FileConfig{ActiveProvider: "codex", Providers: []ProviderProfile{{Name: "codex", CatalogID: "chatgpt", Model: saved}}})
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, body, 0600); err != nil {
t.Fatal(err)
}
}
ensured, err := EnsureCatalogProvider(path, "chatgpt")
if err != nil {
t.Fatal(err)
}
body, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
var cfg FileConfig
if err := json.Unmarshal(body, &cfg); err != nil {
t.Fatal(err)
}
want := "gpt-6.1-sol"
if saved != "" {
want = saved
}
if len(cfg.Providers) != 1 || cfg.Providers[0].Model != want {
t.Fatalf("ChatGPT default/selection = %#v, want model %q", cfg.Providers, want)
}
if saved != "" && (ensured.Name != "codex" || ensured.Created) {
t.Fatalf("saved subscription profile was replaced: %#v", ensured)
}
})
}
}
+27 -5
View File
@@ -3,26 +3,48 @@ package config
import (
"reflect"
"strings"
"github.com/Twigpine/zero/internal/aimlapi"
)
// DefaultMCPServers returns the MCP servers Zero ships ENABLED by default so
// web search and page fetching work out of the box with no setup and no API
// key. They are seeded before user/project config is merged (see ResolveMCP),
// so a user can override any field — for example add an API-key header to lift
// Exa's anonymous rate limit — or disable it entirely with
// DefaultMCPServers returns the MCP servers Zero ships built in. Exa is ENABLED
// by default so web search and page fetching work out of the box with no setup
// and no API key. They are seeded before user/project config is merged (see
// ResolveMCP), so a user can override any field — for example add an API-key
// header to lift Exa's anonymous rate limit — or disable it entirely with
// `zero mcp disable <name>` (which writes `"disabled": true`).
//
// Exa's hosted MCP server works anonymously with rate limits. Users can add an
// Exa API key for higher limits.
//
// The AI/ML API server ships DISABLED: its tool runs and generations are billed
// to the user's AI/ML API balance, so it only connects after
// `zero mcp enable aimlapi` (which writes `"disabled": false`). The disable is
// marked as explicitly set so the lower-trust project layer cannot lift it
// (see mergeMCPServer); only the user and CLI-override scopes can. The key is
// attached per request by the MCP client and is never part of this config.
func DefaultMCPServers() map[string]MCPServerConfig {
return map[string]MCPServerConfig{
"exa": {
Type: "http",
URL: "https://mcp.exa.ai/mcp",
},
"aimlapi": {
Type: "http",
URL: aimlapi.MCPServerURL,
Disabled: true,
disabledSet: true,
},
}
}
// IsDefaultMCPServerDisabled reports whether name is a built-in default that
// ships disabled, so enabling it has to be written out as `"disabled": false`
// rather than by dropping the flag.
func IsDefaultMCPServerDisabled(name string) bool {
return DefaultMCPServers()[strings.TrimSpace(name)].Disabled
}
// IsDefaultMCPServer reports whether name is one of Zero's built-in default MCP
// servers. The config commands use it so a default can be disabled/enabled even
// though it is not written to the user's config file until overridden.
+89
View File
@@ -291,3 +291,92 @@ func TestResolveMCPDisabledRetiredEntryStaysReEnableable(t *testing.T) {
t.Fatal("the disable must still carry to the successor")
}
}
func writeMCPDefaultsTestConfig(t *testing.T, data string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(path, []byte(data), 0o600); err != nil {
t.Fatal(err)
}
return path
}
func TestResolveMCPSeedsDisabledAIMLAPIDefault(t *testing.T) {
if !IsDefaultMCPServer("aimlapi") || !IsDefaultMCPServerDisabled("aimlapi") || IsDefaultMCPServerDisabled("exa") {
t.Fatal("aimlapi must be a built-in default that ships disabled, exa one that ships enabled")
}
// No user config at all, and a user config that never mentions aimlapi.
for name, options := range map[string]ResolveOptions{
"no config": {},
"unrelated user file": {UserConfigPath: writeMCPDefaultsTestConfig(t, `{"mcp":{"servers":{"exa":{"disabled":true}}}}`)},
"user entry with only headers": {UserConfigPath: writeMCPDefaultsTestConfig(t,
`{"mcp":{"servers":{"aimlapi":{"headers":{"X-Trace":"1"}}}}}`)},
} {
cfg, err := ResolveMCP(options)
if err != nil {
t.Fatalf("%s: ResolveMCP: %v", name, err)
}
server, ok := cfg.Servers["aimlapi"]
if !ok {
t.Fatalf("%s: the aimlapi default must be visible in the resolved config", name)
}
if server.Type != "http" || server.URL != "https://mcp.aimlapi.com/mcp" || !server.Disabled {
t.Fatalf("%s: aimlapi default = %#v, want a disabled http server at the MCP URL", name, server)
}
}
cfg, err := ResolveMCP(ResolveOptions{})
if err != nil {
t.Fatal(err)
}
if !IsUnconfiguredDefault("aimlapi", cfg.Servers["aimlapi"]) {
t.Fatal("the untouched aimlapi default should be reported as unconfigured")
}
if exa := cfg.Servers["exa"]; exa.Disabled || !IsUnconfiguredDefault("exa", exa) {
t.Fatalf("exa default changed: %#v", exa)
}
}
func TestResolveMCPUserEnablesAIMLAPIDefault(t *testing.T) {
path := writeMCPDefaultsTestConfig(t, `{"mcp":{"servers":{"aimlapi":{"disabled":false}}}}`)
cfg, err := ResolveMCP(ResolveOptions{UserConfigPath: path})
if err != nil {
t.Fatalf("ResolveMCP: %v", err)
}
server := cfg.Servers["aimlapi"]
if server.Disabled || server.URL != "https://mcp.aimlapi.com/mcp" || server.Type != "http" {
t.Fatalf("user enable did not turn the default on: %#v", server)
}
if IsUnconfiguredDefault("aimlapi", server) {
t.Fatal("an enabled aimlapi is user-configured, so its failures must be reported")
}
enable := MCPServerConfig{Disabled: false, disabledSet: true}
cfg, err = ResolveMCP(ResolveOptions{Overrides: Overrides{MCP: MCPConfig{Servers: map[string]MCPServerConfig{"aimlapi": enable}}}})
if err != nil {
t.Fatal(err)
}
if cfg.Servers["aimlapi"].Disabled {
t.Fatal("the CLI override scope must be able to enable the aimlapi default")
}
}
func TestResolveMCPProjectCannotEnableAIMLAPIDefault(t *testing.T) {
project := writeMCPDefaultsTestConfig(t, `{"mcp":{"servers":{"aimlapi":{"disabled":false}}}}`)
cfg, err := ResolveMCP(ResolveOptions{ProjectConfigPath: project})
if err != nil {
t.Fatalf("ResolveMCP: %v", err)
}
if !cfg.Servers["aimlapi"].Disabled {
t.Fatal("a project config must not turn on the billed aimlapi default")
}
user := writeMCPDefaultsTestConfig(t, `{"mcp":{"servers":{"aimlapi":{"disabled":false}}}}`)
projectDisable := writeMCPDefaultsTestConfig(t, `{"mcp":{"servers":{"aimlapi":{"disabled":true}}}}`)
cfg, err = ResolveMCP(ResolveOptions{UserConfigPath: user, ProjectConfigPath: projectDisable})
if err != nil {
t.Fatalf("ResolveMCP: %v", err)
}
if cfg.Servers["aimlapi"].Disabled {
t.Fatal("the user's explicit enable wins over the project layer, as for any server")
}
}
+33
View File
@@ -724,6 +724,39 @@ func (cfg LocalControlDriverConfig) MarshalJSON() ([]byte, error) {
return json.Marshal(raw)
}
// MarshalJSON writes "disabled" whenever it was explicitly set, not only when
// true. A read-modify-write of config.json (SetTheme, SetActiveProvider, ...)
// would otherwise drop `"disabled": false`, which turns a default that ships
// disabled (aimlapi) back off and lets project config override a user's
// explicit enable (see mergeMCPServer).
func (server MCPServerConfig) MarshalJSON() ([]byte, error) {
type rawServer struct {
Type string `json:"type,omitempty"`
Command string `json:"command,omitempty"`
Args []string `json:"args,omitempty"`
Env map[string]string `json:"env,omitempty"`
URL string `json:"url,omitempty"`
Headers map[string]string `json:"headers,omitempty"`
Auth string `json:"auth,omitempty"`
OAuth *MCPOAuthConfig `json:"oauth,omitempty"`
Disabled *bool `json:"disabled,omitempty"`
}
raw := rawServer{
Type: server.Type,
Command: server.Command,
Args: server.Args,
Env: server.Env,
URL: server.URL,
Headers: server.Headers,
Auth: server.Auth,
OAuth: server.OAuth,
}
if server.disabledSet || server.Disabled {
raw.Disabled = &server.Disabled
}
return json.Marshal(raw)
}
func (server *MCPServerConfig) UnmarshalJSON(data []byte) error {
type rawServer struct {
Type string `json:"type"`
-146
View File
@@ -1,146 +0,0 @@
package execution
import "sync"
// ChildLaunchTracker is the one monotonic answer to "did the requested child
// run", shared by every consumer of a prepared command.
//
// THE REPORT HAS THREE STATES, NOT TWO. For a plan whose child is created inside
// an adapter, the report file passes through: not settled yet, settled with no
// child, and child created. An absent file, an empty file the adapter opened but
// has not written, a half-written one, and a decode error are all the FIRST
// state, and collapsing them to "no child" is a claim about a process that may be
// running at that instant. Consumers used to make that collapse independently and
// then compensate for the timing on their own, which is why fixing one
// presentation site kept exposing the next.
//
// Two things make an answer terminal here:
//
// - Confirm, when the consumer has observed the child directly. A stdio MCP
// server answering initialize is that observation: the adapter speaks no MCP,
// so a well-formed response can only have come from the requested child.
// - Settle, when the adapter process itself has exited. After that the report
// will never change, so whatever it says, including nothing, is the truth.
//
// Settle runs from Cleanup, BEFORE the report file is deleted. Reading the
// evidence and then destroying it in one step is what lets a consumer ask after
// close and still get an answer, rather than racing a deletion it does not
// control.
//
// The decision only ever moves from unknown to known and never back, so two
// consumers of the same prepared command cannot disagree, and a retry cannot turn
// a launch that happened into one that did not.
type ChildLaunchTracker struct {
mu sync.Mutex
settled bool
launched bool
ownedByAdapter bool
report func() (AdapterReport, error)
}
// NewChildLaunchTracker builds the tracker for a prepared command and returns it
// with a Cleanup that settles before releasing the plan's resources.
//
// The returned cleanup is the one the caller must use. Calling the prepared
// command's own Cleanup instead deletes the report while the decision is still
// unknown, which is the ordering hazard this type exists to remove.
func NewChildLaunchTracker(prepared PreparedCommand) (*ChildLaunchTracker, func()) {
tracker := &ChildLaunchTracker{
ownedByAdapter: prepared.ChildLaunchOwnedByAdapter,
report: prepared.Report,
}
// A plan whose child is the started process needs no evidence: Start
// succeeding IS the launch, so the answer is terminal from the beginning.
if !tracker.ownedByAdapter {
tracker.settled = true
tracker.launched = true
}
inner := prepared.Cleanup
return tracker, func() {
tracker.Settle()
if inner != nil {
inner()
}
}
}
// Confirm latches a launch the consumer observed for itself.
//
// Positive evidence is always terminal. Nothing that happens later can make a
// child that answered not have run, so this needs no settlement and cannot be
// undone by a subsequent Settle finding no report.
func (tracker *ChildLaunchTracker) Confirm() {
if tracker == nil {
return
}
tracker.mu.Lock()
defer tracker.mu.Unlock()
tracker.settled = true
tracker.launched = true
}
// Settle freezes the answer because the adapter has finished.
//
// One last read first: the adapter may have published between the consumer's
// last look and its exit, and this is the only remaining chance to see it.
func (tracker *ChildLaunchTracker) Settle() {
if tracker == nil {
return
}
tracker.mu.Lock()
defer tracker.mu.Unlock()
if tracker.settled {
return
}
tracker.launched = tracker.readReportLocked()
tracker.settled = true
}
// Launched reports the decision so far.
//
// Before settlement this reads the report and answers true only for a published
// launch. A negative answer here is NOT remembered, because the adapter may still
// be between creating the child and recording it; the next caller asks again.
// Consumers that need a final answer settle first, which Cleanup does for them.
func (tracker *ChildLaunchTracker) Launched() bool {
if tracker == nil {
return false
}
tracker.mu.Lock()
defer tracker.mu.Unlock()
if tracker.settled {
return tracker.launched
}
if tracker.readReportLocked() {
tracker.launched = true
tracker.settled = true
return true
}
return false
}
// Settled reports whether the answer is final, for callers that would otherwise
// present an unknown as a fact.
func (tracker *ChildLaunchTracker) Settled() bool {
if tracker == nil {
return false
}
tracker.mu.Lock()
defer tracker.mu.Unlock()
return tracker.settled
}
// readReportLocked answers only the positive case. A missing file, an empty one,
// a partial write and a decode error are all "nothing published yet", which is
// the state this type refuses to record as an outcome.
func (tracker *ChildLaunchTracker) readReportLocked() bool {
if tracker.report == nil {
return false
}
report, err := tracker.report()
if err != nil {
return false
}
return ResolveChildLaunched(true, tracker.ownedByAdapter, report)
}
-168
View File
@@ -1,168 +0,0 @@
package execution
import "testing"
// scriptedReport is an adapter report whose answer changes over time, the way a
// real one does: the file is opened empty, decodes to an error while nothing has
// been written, and only later carries the fact.
type scriptedReport struct {
launched bool
reads int
err error
}
func (script *scriptedReport) read() (AdapterReport, error) {
script.reads++
if script.err != nil {
return AdapterReport{}, script.err
}
if !script.launched {
// What an empty or half-written file decodes to. NOT childLaunched=false,
// which would be the adapter stating an outcome.
return AdapterReport{}, nil
}
launched := true
return AdapterReport{ChildLaunched: &launched}, nil
}
func trackerOver(script *scriptedReport) (*ChildLaunchTracker, func()) {
return NewChildLaunchTracker(PreparedCommand{
ChildLaunchOwnedByAdapter: true,
Report: script.read,
})
}
// A NEGATIVE READ IS NOT AN OUTCOME UNTIL THE ADAPTER IS DONE.
//
// This is the whole point of the type. Reading the report between the adapter
// creating the child and recording it answers "nothing published", and caching
// that answer freezes "not yet" into "never" for the rest of the session.
func TestAnUnsettledNegativeIsNotRemembered(t *testing.T) {
script := &scriptedReport{}
tracker, _ := trackerOver(script)
if tracker.Launched() {
t.Fatal("SETUP INVALID: an unpublished report answered launched, so there is no negative here to cache")
}
if tracker.Settled() {
t.Fatal("a read that found nothing settled the decision; the adapter may still be about to publish")
}
// The adapter publishes, as it does a moment after CreateProcessAsUser.
script.launched = true
if !tracker.Launched() {
t.Fatal("the launch published after the first read was never seen; the earlier negative was cached")
}
if !tracker.Settled() {
t.Fatal("a confirmed launch left the decision open")
}
}
// And once it IS an outcome it stays one, so the answer is monotonic and two
// consumers of the same prepared command cannot disagree.
func TestASettledLaunchIsNeverWithdrawn(t *testing.T) {
script := &scriptedReport{launched: true}
tracker, cleanup := trackerOver(script)
if !tracker.Launched() {
t.Fatal("SETUP INVALID: a published report did not answer launched")
}
// Cleanup deletes the report in production, which is what a later read would
// find. The decision must not follow it.
script.launched = false
cleanup()
if !tracker.Launched() {
t.Fatal("the decision followed the report file into deletion; a server that ran became one that did not")
}
}
// CLEANUP SETTLES BEFORE IT DESTROYS THE EVIDENCE.
//
// The report is a file the plan's cleanup removes. A consumer that asks after
// cleanup used to read an absent report and answer "no child" about a server that
// really did run, which is why the decision was being taken early and hitting the
// unsettled window instead.
func TestCleanupSettlesBeforeReleasingThePlan(t *testing.T) {
script := &scriptedReport{launched: true}
var order []string
tracker, cleanup := NewChildLaunchTracker(PreparedCommand{
ChildLaunchOwnedByAdapter: true,
Report: func() (AdapterReport, error) {
order = append(order, "read")
return script.read()
},
Cleanup: func() { order = append(order, "release") },
})
cleanup()
if len(order) != 2 || order[0] != "read" || order[1] != "release" {
t.Fatalf("cleanup order = %v, want the report read before the plan is released", order)
}
if !tracker.Launched() {
t.Fatal("the launch published just before cleanup was lost; nothing read the report on the way out")
}
}
// An adapter that exits having published nothing settles negative, and that
// answer is final: this is the case the unsettled rule must not swallow.
func TestAnAdapterThatPublishedNothingSettlesNegative(t *testing.T) {
script := &scriptedReport{}
tracker, cleanup := trackerOver(script)
cleanup()
if !tracker.Settled() {
t.Fatal("the adapter finished without the decision becoming final")
}
if tracker.Launched() {
t.Fatal("an adapter that published nothing was credited with a launch")
}
// And a report that changes after the adapter is gone changes nothing.
script.launched = true
if tracker.Launched() {
t.Fatal("a settled decision was reopened by a later read")
}
}
// Direct evidence outranks the report and needs no settlement, because nothing
// that happens later can make a child that answered not have run.
func TestConfirmIsTerminalWithoutAReport(t *testing.T) {
script := &scriptedReport{}
tracker, cleanup := trackerOver(script)
tracker.Confirm()
if !tracker.Launched() || !tracker.Settled() {
t.Fatal("an observed child did not settle the decision")
}
cleanup()
if !tracker.Launched() {
t.Fatal("settling from cleanup overwrote an observed launch with an absent report")
}
}
// A plan whose started process IS the requested command has nothing to decide,
// and must not be made to wait on a report it will never have.
func TestAPlanTheAdapterDoesNotOwnIsLaunchedFromTheStart(t *testing.T) {
tracker, _ := NewChildLaunchTracker(PreparedCommand{ChildLaunchOwnedByAdapter: false})
if !tracker.Launched() || !tracker.Settled() {
t.Fatal("a directly started command was not treated as launched")
}
}
// A report that fails to read is the unsettled state, not a negative outcome.
func TestADecodeErrorIsNotAnAnswer(t *testing.T) {
script := &scriptedReport{err: errScriptedReportBroken}
tracker, _ := trackerOver(script)
if tracker.Launched() {
t.Fatal("a broken report was read as a launch")
}
if tracker.Settled() {
t.Fatal("a broken report settled the decision; a half-written file is a moment, not an outcome")
}
}
var errScriptedReportBroken = errScriptedReport("unexpected end of JSON input")
type errScriptedReport string
func (e errScriptedReport) Error() string { return string(e) }
+1 -62
View File
@@ -173,11 +173,6 @@ type Enforcement struct {
Level string `json:"level,omitempty"`
Degraded bool `json:"degraded,omitempty"`
DowngradeReason string `json:"downgradeReason,omitempty"`
// Notices are least-privilege disclosures about the enforcement actually
// applied to THIS command, as opposed to the diagnostic views produced by
// `zero sandbox policy` and `zero sandbox check`. A trade an operator only
// discovers by running a separate diagnostic command is not disclosed.
Notices []string `json:"notices,omitempty"`
}
type Outcome struct {
@@ -187,18 +182,7 @@ type Outcome struct {
Exit *Exit `json:"exit,omitempty"`
Denial *Denial `json:"denial,omitempty"`
Enforcement Enforcement `json:"enforcement"`
// Launched records whether an OS process was actually created, observed at
// the boundary that calls Run rather than inferred afterwards.
//
// OutcomeKind is not a launch-state field, and reading it as one is wrong in
// both directions. A child that ran and then produced an unreadable adapter
// report is rewritten to a setup failure, so inference drops a disclosure that
// did apply; a context already cancelled before os.StartProcess yields a
// cancellation, so inference claims reduced enforcement for a child that never
// existed. Report decoding can fail after launch without rewriting history,
// and cancellation happens on either side of Start.
Launched bool `json:"launched,omitempty"`
Changes []Change `json:"changes,omitempty"`
Changes []Change `json:"changes,omitempty"`
}
// AdapterReport is the structured, machine-readable result emitted by a
@@ -206,51 +190,6 @@ type Outcome struct {
// command text cannot impersonate a policy decision.
type AdapterReport struct {
Denial *Denial `json:"denial,omitempty"`
// ChildLaunched is the adapter's authoritative statement that the REQUESTED
// process started, for a plan where the command the runner starts is not that
// process.
//
// A wrapped plan starts a helper, and the helper creates the sandboxed child
// only after validating the setup marker, applying ACLs, checking the network
// policy, building capability SIDs and minting the restricted token. Any of
// those can fail with the helper already running, so the runner's own
// exec.Cmd.Process tells it the WRAPPER started and nothing about the child.
// Only the adapter sees that transition, so only the adapter may report it.
//
// nil means the adapter does not speak to this, and the runner keeps its own
// observation. That is correct for every direct, unwrapped command, where the
// process the runner starts IS the requested one.
ChildLaunched *bool `json:"childLaunched,omitempty"`
}
// ChildLaunched reports whether this outcome describes a process that actually
// started, from the recorded fact rather than from the terminal outcome kind.
func (outcome Outcome) ChildLaunched() bool {
return outcome.Launched
}
// AppliedEnforcementNotices returns the least-privilege disclosures that are
// true of what actually happened.
//
// ONE DECISION, AT THE BOUNDARY WHERE THE OUTCOME IS KNOWN. Enforcement.Notices
// is planned: it describes the shape the command was PREPARED to run under, and
// planning is not proof that anything ran. Every consumer that copied the field
// straight out therefore made the completed-enforcement claim for commands that
// never launched, telling an operator the write jail had been traded away for a
// child that failed before it existed.
//
// Keeping this on Outcome rather than repeating an outcome-kind switch in hooks,
// plugins and tools is the point: a new pre-launch outcome kind has to be
// classified once, here, instead of being silently disclosed by whichever
// consumer was not updated.
func (outcome Outcome) AppliedEnforcementNotices() []string {
if !outcome.ChildLaunched() {
return nil
}
if len(outcome.Enforcement.Notices) == 0 {
return nil
}
return append([]string(nil), outcome.Enforcement.Notices...)
}
func (outcome Outcome) Validate() error {
-111
View File
@@ -1,111 +0,0 @@
package execution
import (
"context"
"errors"
"os/exec"
"runtime"
"testing"
)
const launchStateNotice = "denyRead is configured, so the write jail is not confining writes"
func launchStateShell(ctx context.Context, script string) *exec.Cmd {
if runtime.GOOS == "windows" {
return exec.CommandContext(ctx, "cmd.exe", "/c", script)
}
return exec.CommandContext(ctx, "/bin/sh", "-c", script)
}
// launchStatePreparer plans a command carrying an enforcement notice, and can
// make the adapter report fail after the child has already run.
type launchStatePreparer struct {
script string
reportErr error
missing bool
}
func (p *launchStatePreparer) PrepareExecution(ctx context.Context, _ Request) (PreparedCommand, error) {
command := launchStateShell(ctx, p.script)
if p.missing {
command = exec.CommandContext(ctx, "definitely-not-a-real-binary-zzz")
}
prepared := PreparedCommand{
Command: command,
Enforcement: Enforcement{Notices: []string{launchStateNotice}},
}
if p.reportErr != nil {
prepared.Report = func() (AdapterReport, error) { return AdapterReport{}, p.reportErr }
}
return prepared, nil
}
func captured(t *testing.T, ctx context.Context, p *launchStatePreparer) CapturedResult {
t.Helper()
return NewRunner(p).ExecuteCaptured(ctx, CapturedRequest{Request: Request{
Origin: OriginHook,
Mode: ModeCaptured,
Command: Command{Name: "irrelevant"},
WorkingDirectory: t.TempDir(),
WorkspaceRoots: []string{t.TempDir()},
Approval: ApprovalContext{PolicyVersion: PolicyVersion},
}})
}
// THE OUTCOME KIND IS NOT A LAUNCH-STATE FIELD, IN EITHER DIRECTION.
//
// Deriving launch from the terminal kind is wrong twice over. The adapter report
// is read AFTER Run, so a child that really ran and then produced an unreadable
// report is rewritten to a setup failure: inference drops a disclosure that did
// apply. And a context already cancelled before os.StartProcess still selects a
// cancellation, so inference claims reduced enforcement for a process that never
// existed.
func TestLaunchStateIsRecordedNotInferred(t *testing.T) {
t.Run("ran, then the adapter report failed", func(t *testing.T) {
result := captured(t, context.Background(), &launchStatePreparer{
script: "exit 0",
reportErr: errors.New("adapter report is unreadable"),
})
if result.Outcome.Kind != OutcomeSandboxSetupFailure {
t.Fatalf("SETUP INVALID: kind = %q, want the report failure to rewrite it", result.Outcome.Kind)
}
if !result.Outcome.Launched {
t.Error("a child that ran was recorded as never launched")
}
if got := result.Outcome.AppliedEnforcementNotices(); len(got) != 1 {
t.Errorf("the disclosure was dropped for a child that did run: %#v", got)
}
})
t.Run("cancelled before the process started", func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
result := captured(t, ctx, &launchStatePreparer{script: "exit 0"})
if result.Outcome.Launched {
t.Error("a process that never started was recorded as launched")
}
if got := result.Outcome.AppliedEnforcementNotices(); len(got) != 0 {
t.Errorf("an enforcement trade was claimed for a process that never existed: %#v", got)
}
})
t.Run("never found", func(t *testing.T) {
result := captured(t, context.Background(), &launchStatePreparer{missing: true})
if result.Outcome.Launched {
t.Error("a missing executable was recorded as launched")
}
if got := result.Outcome.AppliedEnforcementNotices(); len(got) != 0 {
t.Errorf("a missing executable claimed an enforcement trade: %#v", got)
}
})
t.Run("ordinary success still discloses", func(t *testing.T) {
result := captured(t, context.Background(), &launchStatePreparer{script: "exit 0"})
if !result.Outcome.Launched {
t.Fatal("an ordinary run was recorded as never launched")
}
if got := result.Outcome.AppliedEnforcementNotices(); len(got) != 1 {
t.Errorf("an ordinary run lost its disclosure: %#v", got)
}
})
}
@@ -1,199 +0,0 @@
package execution
import (
"context"
"encoding/json"
"errors"
"os"
"os/exec"
"path/filepath"
"runtime"
"testing"
"time"
)
const liveLaunchNotice = "denyRead is configured, so the write jail is not confining writes"
// liveReportReader mirrors sandbox.CommandPlan.ExecutionReport: read the file the
// helper publishes, treat "not there yet" as nothing recorded.
func liveReportReader(path string) func() (AdapterReport, error) {
return func() (AdapterReport, error) {
raw, err := os.ReadFile(path)
if errors.Is(err, os.ErrNotExist) {
return AdapterReport{}, nil
}
if err != nil {
return AdapterReport{}, err
}
var report AdapterReport
if err := json.Unmarshal(raw, &report); err != nil {
return AdapterReport{}, err
}
return report, nil
}
}
// liveHelperCommand stands in for the Windows helper: publish the launch fact the
// way it does right after CreateProcessAsUser, then stay alive the way it does
// while waiting on the child.
func liveHelperCommand(t *testing.T, reportPath string, publish bool) *exec.Cmd {
t.Helper()
if publish {
if err := os.WriteFile(reportPath, []byte(`{"childLaunched":true}`), 0o600); err != nil {
t.Fatalf("publish the launch report: %v", err)
}
}
if runtime.GOOS == "windows" {
// A child that holds itself open without exiting.
return exec.Command("cmd.exe", "/c", "pause")
}
return exec.Command("/bin/sh", "-c", "sleep 30")
}
// liveRequest is a valid interactive request; ProcessManager.Start validates it
// before anything under test runs.
func liveRequest(t *testing.T) Request {
t.Helper()
return Request{
Origin: OriginInteractiveCommand,
Mode: ModeInteractive,
Command: Command{Name: "helper"},
WorkingDirectory: t.TempDir(),
WorkspaceRoots: []string{t.TempDir()},
Approval: ApprovalContext{PolicyVersion: PolicyVersion},
}
}
// A RETAINED SESSION HAS TO DISCLOSE WHILE IT IS STILL RUNNING.
//
// The helper publishes childLaunched immediately after it creates the restricted
// child, and only then waits for it. The manager read that report exclusively in
// the post-Wait goroutine, so for the whole live lifetime of a wrapped session the
// report was the zero value: the first exec_command reply and every write_stdin
// poll resolved Launched=false and disclosed nothing, while the fact sat readable
// on disk. A watcher, or a retained session nobody polls to completion, would
// never be told the write jail had been traded away.
//
// Driven through the real ProcessManager with a real child process, and asserted
// on the ProcessResult the tool layer consumes.
func TestALiveWrappedSessionCarriesTheLaunchFact(t *testing.T) {
directory := t.TempDir()
reportPath := filepath.Join(directory, "report.json")
command := liveHelperCommand(t, reportPath, true)
manager := NewProcessManager(ProcessManagerOptions{})
result, err := manager.Start(context.Background(), ProcessStart{
Prepared: PreparedCommand{
Command: command,
ChildLaunchOwnedByAdapter: true,
Enforcement: Enforcement{Notices: []string{liveLaunchNotice}},
Report: liveReportReader(reportPath),
},
Request: liveRequest(t),
}, 300*time.Millisecond)
if err != nil {
t.Fatalf("Start: %v", err)
}
t.Cleanup(func() { manager.StopAll() })
// SETUP: this has to be the LIVE state, and the fact has to be on disk, or the
// assertion below would be about a terminal read.
if result.Exited {
t.Fatal("SETUP INVALID: the stand-in helper exited, so the live lifecycle is not under test")
}
if _, statErr := os.Stat(reportPath); statErr != nil {
t.Fatalf("SETUP INVALID: the launch report is not on disk, so there is nothing to observe: %v", statErr)
}
if !ResolveChildLaunched(true, result.ChildLaunchOwnedByAdapter, result.Report) {
t.Fatal("a live wrapped session resolved as not launched, so its enforcement disclosure is withheld while the command runs")
}
// And again on a poll, which is the write_stdin leg.
polled, err := manager.Continue(context.Background(), ProcessContinue{ProcessID: result.ProcessID, Wait: 200 * time.Millisecond})
if err != nil {
t.Fatalf("Continue: %v", err)
}
if polled.Exited {
t.Fatal("SETUP INVALID: the helper exited before the poll, so the live poll is not under test")
}
if !ResolveChildLaunched(true, polled.ChildLaunchOwnedByAdapter, polled.Report) {
t.Fatal("a live poll of a wrapped session resolved as not launched")
}
if got := polled.Enforcement.Notices; len(got) != 1 || got[0] != liveLaunchNotice {
t.Fatalf("the live poll carries notices %v, want exactly the one planned notice", got)
}
}
// AND A HELPER THAT NEVER CREATED THE CHILD STAYS SILENT.
//
// This is the negative the live read must not destroy. A helper that starts and
// then fails setup, ACL application, or CreateProcessAsUser has an outer process
// running and no child, so nothing may be promoted from the fact that the
// wrapper itself is alive.
func TestALiveWrappedSessionWithNoReportedChildStaysSilent(t *testing.T) {
directory := t.TempDir()
reportPath := filepath.Join(directory, "report.json")
command := liveHelperCommand(t, reportPath, false)
manager := NewProcessManager(ProcessManagerOptions{})
result, err := manager.Start(context.Background(), ProcessStart{
Prepared: PreparedCommand{
Command: command,
ChildLaunchOwnedByAdapter: true,
Enforcement: Enforcement{Notices: []string{liveLaunchNotice}},
Report: liveReportReader(reportPath),
},
Request: liveRequest(t),
}, 300*time.Millisecond)
if err != nil {
t.Fatalf("Start: %v", err)
}
t.Cleanup(func() { manager.StopAll() })
if result.Exited {
t.Fatal("SETUP INVALID: the stand-in helper exited, so the live lifecycle is not under test")
}
if _, statErr := os.Stat(reportPath); statErr == nil {
t.Fatal("SETUP INVALID: a report exists, so this is not the no-child case")
}
if ResolveChildLaunched(true, result.ChildLaunchOwnedByAdapter, result.Report) {
t.Fatal("a helper that reported no child was promoted to a launch, so the operator is told a write jail was traded away for a child that never existed")
}
}
// A report that appears mid-flight is observed on the next poll, which is what
// makes this a lifecycle transition rather than a start-time snapshot.
func TestTheLaunchFactIsObservedWhenItAppearsMidFlight(t *testing.T) {
directory := t.TempDir()
reportPath := filepath.Join(directory, "report.json")
command := liveHelperCommand(t, reportPath, false)
manager := NewProcessManager(ProcessManagerOptions{})
result, err := manager.Start(context.Background(), ProcessStart{
Prepared: PreparedCommand{
Command: command,
ChildLaunchOwnedByAdapter: true,
Report: liveReportReader(reportPath),
},
Request: liveRequest(t),
}, 200*time.Millisecond)
if err != nil {
t.Fatalf("Start: %v", err)
}
t.Cleanup(func() { manager.StopAll() })
if ResolveChildLaunched(true, result.ChildLaunchOwnedByAdapter, result.Report) {
t.Fatal("SETUP INVALID: nothing was published yet, so the first result must not be a launch")
}
if err := os.WriteFile(reportPath, []byte(`{"childLaunched":true}`), 0o600); err != nil {
t.Fatalf("publish the launch report: %v", err)
}
polled, err := manager.Continue(context.Background(), ProcessContinue{ProcessID: result.ProcessID, Wait: 200 * time.Millisecond})
if err != nil {
t.Fatalf("Continue: %v", err)
}
if !ResolveChildLaunched(true, polled.ChildLaunchOwnedByAdapter, polled.Report) {
t.Fatal("the launch published while the session was live was never observed")
}
}
+29 -112
View File
@@ -79,12 +79,8 @@ type ProcessResult struct {
Enforcement Enforcement
Report AdapterReport
ReportErr error
// ChildLaunchOwnedByAdapter carries the prepared plan's ownership of the
// requested-child launch fact through to the caller, which for a retained
// session no longer has the plan.
ChildLaunchOwnedByAdapter bool
Changes []Change
Metadata map[string]string
Changes []Change
Metadata map[string]string
}
type ProcessSnapshot struct {
@@ -166,7 +162,6 @@ func (manager *ProcessManager) Start(ctx context.Context, input ProcessStart, wa
command: command,
request: request,
enforcement: input.Prepared.Enforcement,
ownedLaunch: input.Prepared.ChildLaunchOwnedByAdapter,
report: input.Prepared.Report,
cleanup: input.Prepared.Cleanup,
stdin: stdin,
@@ -428,46 +423,31 @@ func (manager *ProcessManager) removeCompletedLater(process *managedProcess) {
}
type managedProcess struct {
id int
commandText string
cwd string
relativeCwd string
startedAt time.Time
lastUsedAt time.Time
tty bool
command *exec.Cmd
request Request
enforcement Enforcement
ownedLaunch bool
// launchObserved latches the adapter's launch transition the first time it is
// seen, so a live result can report it. Guarded by mu.
launchObserved bool
report func() (AdapterReport, error)
cleanup func()
stdin io.WriteCloser
output *processOutputBuffer
reaped chan struct{}
doneOnce sync.Once
done chan struct{}
kill func(int) error
mu sync.Mutex
exitCode *int
waitErr error
resultReport AdapterReport
reportErr error
changes []Change
metadata map[string]string
}
// launchedReportLocked returns the report to hand out, with a latched live
// launch folded in. Caller holds mu.
func (process *managedProcess) launchedReportLocked() AdapterReport {
report := process.resultReport
if report.ChildLaunched == nil && process.launchObserved {
launched := true
report.ChildLaunched = &launched
}
return report
id int
commandText string
cwd string
relativeCwd string
startedAt time.Time
lastUsedAt time.Time
tty bool
command *exec.Cmd
request Request
enforcement Enforcement
report func() (AdapterReport, error)
cleanup func()
stdin io.WriteCloser
output *processOutputBuffer
reaped chan struct{}
doneOnce sync.Once
done chan struct{}
kill func(int) error
mu sync.Mutex
exitCode *int
waitErr error
resultReport AdapterReport
reportErr error
changes []Change
metadata map[string]string
}
func (process *managedProcess) markDone(err error, exitCode int, report AdapterReport, reportErr error, changes []Change) {
@@ -475,76 +455,14 @@ func (process *managedProcess) markDone(err error, exitCode int, report AdapterR
process.waitErr = err
process.exitCode = &exitCode
process.resultReport = report
// The plan's cleanup has already removed the report file by the time this
// runs on some orderings, so a terminal read can answer "nothing recorded"
// about a child that demonstrably started. A launch we already saw is not
// un-seen by that.
if report.ChildLaunched == nil && process.launchObserved {
launched := true
process.resultReport.ChildLaunched = &launched
}
process.reportErr = reportErr
process.changes = append([]Change(nil), changes...)
process.mu.Unlock()
process.doneOnce.Do(func() { close(process.done) })
}
// observeLaunch reads the adapter's launch report while the process is still
// running, and latches a confirmed launch.
//
// THE LAUNCH FACT IS A LIFECYCLE TRANSITION, NOT TERMINAL DATA. The Windows
// helper publishes childLaunched immediately after CreateProcessAsUser creates
// the restricted child, and then waits for it. The manager used to read the
// report only in the post-Wait goroutine, so for the entire live lifetime of a
// retained session the report was the zero value: the first exec_command reply
// and every write_stdin poll resolved Launched=false and disclosed nothing,
// even though the fact was sitting readable on disk. A watcher or an abandoned
// retained session could therefore never be told the write jail had been traded
// away. The MCP launcher already reads the report while its server is live;
// this is the same read, in the launcher that was left behind.
//
// SILENCE IS NOT A NEGATIVE, BUT AN EXPLICIT NEGATIVE IS. An absent, partial or
// undecodable report, and a helper that failed before it ever created the child,
// must all leave the live result exactly as it was: not confirmed, nothing
// disclosed. Reading absence as false, or surfacing a read error or a denial
// from here, would let a mid-flight poll rewrite a running command into a setup
// failure, and absence is the normal state once the plan's cleanup has removed
// the file.
//
// A report that SAYS false is different, and it revokes. The Windows helper
// publishes the launch before it resumes the suspended child, so there is a
// window where the fact is readable and the child has still executed nothing; if
// the resume then fails, the helper retracts the record with an explicit false.
// Without this, a poll that landed inside that window would hold a launch that
// never happened, and hold it through completion, since the final read finds the
// file cleaned away and restores what was latched.
//
// Which is why the observation is repeated while the command runs rather than
// latched once. It costs one small read per poll on a wrapped plan, and every
// unwrapped plan still does no extra work at all; a fact that can be withdrawn
// is not one to cache.
func (process *managedProcess) observeLaunch() {
if process.report == nil {
return
}
process.mu.Lock()
skip := !process.ownedLaunch
process.mu.Unlock()
if skip || process.doneClosed() {
return
}
report, err := process.report()
if err != nil || report.ChildLaunched == nil {
return
}
process.mu.Lock()
process.launchObserved = *report.ChildLaunched
process.mu.Unlock()
}
func (process *managedProcess) collectResult(ctx context.Context, wait time.Duration, interrupted bool) ProcessResult {
output, truncated := process.collect(ctx, wait)
process.observeLaunch()
process.mu.Lock()
exitCode := 0
exited := process.exitCode != nil
@@ -555,9 +473,8 @@ func (process *managedProcess) collectResult(ctx context.Context, wait time.Dura
ProcessID: process.id, CommandText: process.commandText, RelativeCwd: process.relativeCwd,
TTY: process.tty, Output: output, OutputTruncated: truncated, Exited: exited,
ExitCode: exitCode, Interrupted: interrupted, Request: process.request,
Enforcement: process.enforcement, Report: process.launchedReportLocked(), ReportErr: process.reportErr,
ChildLaunchOwnedByAdapter: process.ownedLaunch,
Changes: append([]Change(nil), process.changes...), Metadata: cloneStringMap(process.metadata),
Enforcement: process.enforcement, Report: process.resultReport, ReportErr: process.reportErr,
Changes: append([]Change(nil), process.changes...), Metadata: cloneStringMap(process.metadata),
}
process.mu.Unlock()
return result
-128
View File
@@ -1,128 +0,0 @@
package execution
import (
"context"
"os"
"path/filepath"
"testing"
"time"
)
// A LAUNCH THE HELPER TAKES BACK MUST NOT SURVIVE IN EITHER RESULT.
//
// The Windows helper publishes childLaunched before it resumes the suspended
// child, so the fact is readable while the child has still executed nothing. A
// poll landing in that window latched it, and the latch outlived the file: the
// terminal read finds the report cleaned away and restores what was observed, so
// a child that never became runnable was reported as launched, and the write-jail
// trade it never made was disclosed as if it had.
//
// Deleting the report on that path cannot fix it, because deletion is also what a
// normal cleanup does. The helper retracts with an explicit false instead, and
// this is the interleaving that pins it: publish, live read, retract, poll, then
// finish and read terminally.
func TestARetractedLaunchIsNotCommittedLiveOrTerminally(t *testing.T) {
directory := t.TempDir()
reportPath := filepath.Join(directory, "report.json")
command := liveHelperCommand(t, reportPath, true)
manager := NewProcessManager(ProcessManagerOptions{})
result, err := manager.Start(context.Background(), ProcessStart{
Prepared: PreparedCommand{
Command: command,
ChildLaunchOwnedByAdapter: true,
Enforcement: Enforcement{Notices: []string{liveLaunchNotice}},
Report: liveReportReader(reportPath),
},
Request: liveRequest(t),
}, 300*time.Millisecond)
if err != nil {
t.Fatalf("Start: %v", err)
}
t.Cleanup(func() { manager.StopAll() })
// SETUP: the publication window. The live read has to have seen the positive,
// or the revocation below would have nothing to revoke.
if result.Exited {
t.Fatal("SETUP INVALID: the stand-in helper exited, so the live lifecycle is not under test")
}
if !ResolveChildLaunched(true, result.ChildLaunchOwnedByAdapter, result.Report) {
t.Fatal("SETUP INVALID: the published launch was not observed live, so this test cannot show it being taken back")
}
// The resume fails: the helper retracts the record it published.
if err := os.WriteFile(reportPath, []byte(`{"childLaunched":false}`), 0o600); err != nil {
t.Fatal(err)
}
polled, err := manager.Continue(context.Background(), ProcessContinue{ProcessID: result.ProcessID, Wait: 200 * time.Millisecond})
if err != nil {
t.Fatalf("Continue: %v", err)
}
if polled.Exited {
t.Fatal("SETUP INVALID: the helper exited before the poll, so the live poll is not under test")
}
if ResolveChildLaunched(true, polled.ChildLaunchOwnedByAdapter, polled.Report) {
t.Error("a live poll still reports a launch the helper retracted, so the disclosure stands for a child that never ran")
}
// Finish, with the plan's cleanup removing the report the way it always does.
// The terminal read then finds absence, which is exactly the case the restore
// exists for, and it must not resurrect the retracted launch.
if !manager.Stop(result.ProcessID) {
t.Fatal("Stop: process not found")
}
_ = os.Remove(reportPath)
final, err := manager.Continue(context.Background(), ProcessContinue{ProcessID: result.ProcessID, Wait: time.Second})
if err != nil {
t.Fatalf("Continue after stop: %v", err)
}
if !final.Exited {
t.Fatal("SETUP INVALID: the helper never exited, so there is no terminal result under test")
}
if ResolveChildLaunched(true, final.ChildLaunchOwnedByAdapter, final.Report) {
t.Error("the final result reports a launch the helper retracted")
}
}
// AND A GENUINE LAUNCH STILL SURVIVES ITS OWN CLEANUP. The revocation must key
// on an explicit denial and nothing else: a report removed by the plan's normal
// cleanup is silence, and silence does not take back what was seen.
func TestAConfirmedLaunchSurvivesTheReportCleanup(t *testing.T) {
directory := t.TempDir()
reportPath := filepath.Join(directory, "report.json")
command := liveHelperCommand(t, reportPath, true)
manager := NewProcessManager(ProcessManagerOptions{})
result, err := manager.Start(context.Background(), ProcessStart{
Prepared: PreparedCommand{
Command: command,
ChildLaunchOwnedByAdapter: true,
Enforcement: Enforcement{Notices: []string{liveLaunchNotice}},
Report: liveReportReader(reportPath),
},
Request: liveRequest(t),
}, 300*time.Millisecond)
if err != nil {
t.Fatalf("Start: %v", err)
}
t.Cleanup(func() { manager.StopAll() })
if !ResolveChildLaunched(true, result.ChildLaunchOwnedByAdapter, result.Report) {
t.Fatal("SETUP INVALID: the published launch was not observed live")
}
if !manager.Stop(result.ProcessID) {
t.Fatal("Stop: process not found")
}
_ = os.Remove(reportPath)
final, err := manager.Continue(context.Background(), ProcessContinue{ProcessID: result.ProcessID, Wait: time.Second})
if err != nil {
t.Fatalf("Continue after stop: %v", err)
}
if !final.Exited {
t.Fatal("SETUP INVALID: the helper never exited")
}
if !ResolveChildLaunched(true, final.ChildLaunchOwnedByAdapter, final.Report) {
t.Error("a confirmed launch was lost when the plan's cleanup removed its report")
}
}
-44
View File
@@ -24,12 +24,6 @@ type PreparedCommand struct {
Enforcement Enforcement
Report func() (AdapterReport, error)
Cleanup func()
// ChildLaunchOwnedByAdapter marks a plan where Command is a WRAPPER and the
// requested process is created inside it, so exec.Cmd.Process says nothing
// about whether the sandboxed child ever existed. The adapter must state the
// fact in its report; if it does not, the runner treats the child as not
// launched rather than crediting the wrapper's start.
ChildLaunchOwnedByAdapter bool
}
type CapturedRequest struct {
@@ -96,25 +90,10 @@ func (runner *Runner) ExecuteCaptured(ctx context.Context, input CapturedRequest
prepared.Command.Stdout = stdout
prepared.Command.Stderr = stderr
runErr := prepared.Command.Run()
// Observed HERE, from the only thing that knows: exec.Cmd sets Process only
// once os.StartProcess has succeeded, so this is false for a missing
// executable and for a context cancelled before Start, and true for anything
// that ran, including a later timeout or cancellation.
launched := prepared.Command.Process != nil
report, reportErr := AdapterReport{}, error(nil)
if prepared.Report != nil {
report, reportErr = prepared.Report()
}
// A WRAPPED PLAN'S LAUNCH BIT BELONGS TO THE ADAPTER. The line above observes
// the process THIS command started, which for a Windows restricted-token plan
// is the helper, not the requested executable: the helper validates the setup
// marker, applies ACLs, checks the network policy, builds capability SIDs and
// mints the restricted token after it is already running, and any of those can
// fail with no sandboxed child ever created. Believing the outer bit there
// reports that reads were denied as requested when only the unsandboxed
// adapter ran. An adapter that owns the inner transition overrides it; one
// that stays silent leaves the direct-command observation alone.
launched = ResolveChildLaunched(launched, prepared.ChildLaunchOwnedByAdapter, report)
result := CapturedResult{
Stdout: stdout.String(),
Stderr: stderr.String(),
@@ -122,7 +101,6 @@ func (runner *Runner) ExecuteCaptured(ctx context.Context, input CapturedRequest
Err: runErr,
Outcome: Outcome{
Enforcement: prepared.Enforcement,
Launched: launched,
},
}
exitCode := commandExitCode(runErr)
@@ -179,28 +157,6 @@ func (runner *Runner) Prepare(ctx context.Context, request Request) (PreparedCom
return preparer.PrepareExecution(ctx, request)
}
// ResolveChildLaunched decides whether the REQUESTED process launched.
//
// ONE IMPLEMENTATION, because every launcher needs the same answer and each one
// that re-derived it got a different one. observed is what the caller saw of the
// process IT started, which for a wrapped plan is the helper and not the
// requested child.
//
// - the adapter stated the fact: believe the adapter, in both directions.
// - the adapter owns the fact and stayed silent: not launched. An absent report
// must not be read as proof that enforcement applied.
// - nobody owns it but the caller: keep the direct observation, which is
// correct for a direct command and for bwrap.
func ResolveChildLaunched(observed bool, ownedByAdapter bool, report AdapterReport) bool {
if report.ChildLaunched != nil {
return *report.ChildLaunched
}
if ownedByAdapter {
return false
}
return observed
}
func capturedSetupFailure(message string, err error, enforcement Enforcement) CapturedResult {
return CapturedResult{
Stderr: message,
@@ -1,103 +0,0 @@
package execution
import (
"context"
"testing"
)
// A WRAPPER'S START IS NOT THE REQUESTED CHILD'S START.
//
// For a Windows restricted-token plan the command the runner starts is the
// sandbox helper, not the executable the caller asked for. Inside that helper,
// setup-marker validation, unelevated ACL application, network-policy
// validation, capability and offline SID construction, restricted-token creation
// and the CreateProcessAsUser call all happen afterwards, and each of them can
// return with no sandboxed child ever created. exec.Cmd.Process is already
// non-nil by then, so reading the launch state off it reports that reads were
// denied as requested when the only thing that ran was the unsandboxed adapter.
//
// The fact belongs to whoever sees the transition. These pin both directions of
// that boundary.
type wrappedPreparer struct {
script string
owned bool
childLaunched *bool
reportNothing bool
}
func (p *wrappedPreparer) PrepareExecution(ctx context.Context, _ Request) (PreparedCommand, error) {
prepared := PreparedCommand{
Command: launchStateShell(ctx, p.script),
Enforcement: Enforcement{Notices: []string{launchStateNotice}},
ChildLaunchOwnedByAdapter: p.owned,
}
if !p.reportNothing {
launched := p.childLaunched
prepared.Report = func() (AdapterReport, error) {
return AdapterReport{ChildLaunched: launched}, nil
}
}
return prepared, nil
}
func capturedWrapped(t *testing.T, p *wrappedPreparer) CapturedResult {
t.Helper()
return NewRunner(p).ExecuteCaptured(context.Background(), CapturedRequest{Request: Request{
Origin: OriginHook,
Mode: ModeCaptured,
Command: Command{Name: "irrelevant"},
WorkingDirectory: t.TempDir(),
WorkspaceRoots: []string{t.TempDir()},
Approval: ApprovalContext{PolicyVersion: PolicyVersion},
}})
}
func TestWrappedPlanDisclosesOnlyWhatTheAdapterConfirms(t *testing.T) {
// The helper starts and then fails before it can create the restricted child:
// a bad setup marker, an ACL it could not apply, a network policy it rejected,
// a token it could not mint. The wrapper process exists; the sandboxed one
// never did, so nothing may be claimed about enforcement.
t.Run("helper ran but never created the child", func(t *testing.T) {
no := false
result := capturedWrapped(t, &wrappedPreparer{script: "exit 1", owned: true, childLaunched: &no})
// The wrapper really did run, which is the whole point: its exit code is
// the script's. Without this the test could pass because nothing executed.
if result.Outcome.Exit == nil || result.Outcome.Exit.Code != 1 {
t.Fatalf("SETUP INVALID: the wrapper itself must have run; outcome = %+v", result.Outcome)
}
if notices := result.Outcome.AppliedEnforcementNotices(); len(notices) != 0 {
t.Fatalf("a helper that never created the restricted child disclosed %q", notices)
}
})
// Same shape, but the adapter says nothing at all. Silence from the owner of
// the fact is not permission to fall back to the wrapper's own start.
t.Run("adapter that owns the fact stayed silent", func(t *testing.T) {
result := capturedWrapped(t, &wrappedPreparer{script: "exit 1", owned: true, reportNothing: true})
if notices := result.Outcome.AppliedEnforcementNotices(); len(notices) != 0 {
t.Fatalf("an unreported child launch was disclosed as applied enforcement: %q", notices)
}
})
// And the other side of the boundary: a restricted child that really started
// and then exited non-zero DID run under the disclosed enforcement, so the
// notice must still be made, exactly once.
t.Run("restricted child started, then failed", func(t *testing.T) {
yes := true
result := capturedWrapped(t, &wrappedPreparer{script: "exit 3", owned: true, childLaunched: &yes})
notices := result.Outcome.AppliedEnforcementNotices()
if len(notices) != 1 || notices[0] != launchStateNotice {
t.Fatalf("a child that ran and then failed disclosed %q, want exactly one %q", notices, launchStateNotice)
}
})
// A direct, unwrapped command is unchanged: the process the runner starts IS
// the requested one, so its own observation still decides.
t.Run("direct command keeps its own observation", func(t *testing.T) {
result := capturedWrapped(t, &wrappedPreparer{script: "exit 0", owned: false, reportNothing: true})
notices := result.Outcome.AppliedEnforcementNotices()
if len(notices) != 1 || notices[0] != launchStateNotice {
t.Fatalf("a direct command that ran disclosed %q, want exactly one %q", notices, launchStateNotice)
}
})
}
+6 -74
View File
@@ -36,25 +36,7 @@ type DispatchOutcome struct {
// Messages collects the output (stdout, else stderr) of each hook that
// produced any, in run order. afterTool validators use this to feed results
// (e.g. a formatter diff or vet warning) back to the model on the tool result.
//
// PRESENTATION TEXT, NOT A NOTICE CHANNEL, and it no longer carries notices at
// all. hookMessage used to fold them in so afterTool got both on one line;
// that made the disclosure arrive twice once the typed slice was composed by
// every surface. Enforcement disclosures are on Notices, for both hook phases.
// A caller that only wants to know what the sandbox did must read that:
// delivering this slice would put every successful hook's routine logging into
// the model's context.
Messages []string
// Notices carries only the enforcement disclosures, one entry per notice, in
// run order across every hook that ran.
//
// Separate from Messages because they answer different questions and have
// different audiences. A notice says the hook ran under a weakened token,
// which the model and the operator both need; the hook's own output is for
// afterTool validators that asked to be heard. Appended as each hook runs,
// rather than read off the final result, so a disclosure from a hook that
// already ran survives a later hook's veto ending the chain.
Notices []string
}
type commandResult struct {
@@ -63,13 +45,6 @@ type commandResult struct {
Stderr string
Err error // set when the command could not be executed (not a non-zero exit)
TimedOut bool // the hook started but its deadline/cancellation fired before it returned
// Notices carries the enforcement disclosures the execution runner attached.
//
// Same reason as the plugin path: the generic execution contract is not
// transport-only. Enforcement.Notices says what the sandbox actually did, and
// a projection that keeps only stdout, stderr and an exit code drops it, so a
// hook ran under a weakened token with nothing said about it.
Notices []string
}
// commandRunner executes one hook command. It is injectable so the dispatch
@@ -164,10 +139,6 @@ func executionCommandRunner(runner *execution.Runner) commandRunner {
Stderr: stderr,
Err: commandErr,
TimedOut: result.Outcome.Kind == execution.OutcomeTimedOut,
// One shared decision: see Outcome.AppliedEnforcementNotices. A setup
// failure or a missing executable launched no hook child, so the notice
// would describe a token trade nobody made.
Notices: result.Outcome.AppliedEnforcementNotices(),
}
}
}
@@ -211,19 +182,11 @@ func (dispatcher *Dispatcher) Dispatch(ctx context.Context, input DispatchInput)
if message := hookMessage(result); message != "" {
outcome.Messages = append(outcome.Messages, message)
}
// Appended per hook rather than read off the final result, so hook A's
// disclosure is not lost when hook B stops the chain. A notice describes
// something that ALREADY happened.
for _, notice := range result.Notices {
if strings.TrimSpace(notice) != "" {
outcome.Notices = append(outcome.Notices, notice)
}
}
if blocked {
outcome.Blocked = true
outcome.BlockedBy = hook.ID
outcome.Reason = blockCause(result)
outcome.Reason = blockReason(result)
// Stop on the first veto: the action is already denied.
return outcome
}
@@ -280,38 +243,14 @@ func classifyResult(event Event, result commandResult) (AuditStatus, bool) {
// hookMessage returns the output worth surfacing from a hook run: stdout when
// present, else stderr. Empty when the hook produced no output.
// STDOUT OR STDERR, AND NOTHING ELSE. The enforcement notices used to be
// prepended here as well, so that afterTool got the disclosure and its own output
// on one line. That was the only delivery available when this was written, and it
// is not any more: the notices travel typed on DispatchOutcome.Notices, and the
// agent loop merges them into ToolResult.EnforcementNotices for beforeTool and
// afterTool alike.
//
// Keeping the fold as well made the same disclosure arrive twice, once from the
// typed slice that every surface composes and once inside the hook feedback block
// in the body. The two writers carry the identical fixed string, so the model saw
// it doubled and a bash or exec card showed it in both the furniture and the body.
// One owner per fact, and for a notice that owner is the typed slice.
func hookMessage(result commandResult) string {
message := strings.TrimSpace(result.Stdout)
if message == "" {
message = strings.TrimSpace(result.Stderr)
if trimmed := strings.TrimSpace(result.Stdout); trimmed != "" {
return trimmed
}
return message
return strings.TrimSpace(result.Stderr)
}
// blockCause explains a veto in the hook's own words, and says nothing about
// enforcement.
//
// THE SAME RULE AS hookMessage. The blocking hook's notices used to be folded in
// here as well, from before the typed slice existed, and Dispatch had already
// appended them to DispatchOutcome.Notices, so a hook that vetoed while running
// without write confinement put one disclosure on two channels. The one consumer
// made up for it by dropping every notice whose text appeared anywhere in the
// reason, which also dropped an EARLIER hook's disclosure whenever the blocking
// hook's output happened to quote it. The notice now travels on the typed slice
// only, whichever hook in the chain produced it. Reported by CodeRabbit.
func blockCause(result commandResult) string {
func blockReason(result commandResult) string {
if result.TimedOut {
if trimmed := strings.TrimSpace(result.Stderr); trimmed != "" {
return "hook timed out: " + trimmed
@@ -349,14 +288,7 @@ func (dispatcher *Dispatcher) recordCompleted(hook Definition, input DispatchInp
Matcher: hook.Matcher,
ToolCallID: input.ToolCallID,
Status: status,
Results: []AuditResult{{
ExitCode: result.ExitCode,
Stdout: result.Stdout,
Stderr: result.Stderr,
// The notice is not in stdout or stderr by design, so the durable record
// has to carry it or the fact ends with the dispatch result.
EnforcementNotices: append([]string(nil), result.Notices...),
}},
Results: []AuditResult{{ExitCode: result.ExitCode, Stdout: result.Stdout, Stderr: result.Stderr}},
DurationMs: durationMs,
})
}
+2 -2
View File
@@ -244,8 +244,8 @@ func TestClassifyResultTimedOutFailsClosedForBeforeTool(t *testing.T) {
if status, blocked := classifyResult(EventAfterTool, timedOut); blocked || status != AuditError {
t.Fatalf("afterTool timeout classify = (%q, %v), want (error, false)", status, blocked)
}
if reason := blockCause(timedOut); !strings.Contains(reason, "timed out") {
t.Fatalf("blockCause = %q, want a timeout message", reason)
if reason := blockReason(timedOut); !strings.Contains(reason, "timed out") {
t.Fatalf("blockReason = %q, want a timeout message", reason)
}
}
@@ -1,119 +0,0 @@
package hooks
import (
"context"
"os/exec"
"path/filepath"
"testing"
)
// auditedDispatcher wires a real audit store to a dispatcher whose hook result
// is whatever the caller wants, and returns the events that survived the write.
func auditedDispatcher(t *testing.T, hook Definition, result commandResult) []AuditEvent {
t.Helper()
store, err := NewAuditStore(AuditStoreOptions{AuditPath: filepath.Join(t.TempDir(), "audit.jsonl")})
if err != nil {
t.Fatalf("NewAuditStore: %v", err)
}
dispatcher := NewDispatcher(DispatcherOptions{
Config: beforeToolConfig(hook),
Audit: store,
run: func(context.Context, string, []string, []byte, string, []string) commandResult {
return result
},
})
dispatcher.Dispatch(context.Background(), DispatchInput{Event: EventBeforeTool, ToolName: "bash", ToolCallID: "call_1"})
// READ BACK FROM DISK, not from the in-memory event the append returned. The
// durable reader is the consumer this field exists for.
events, err := store.ReadEvents()
if err != nil {
t.Fatalf("ReadEvents: %v", err)
}
return events
}
func completedResults(t *testing.T, events []AuditEvent) []AuditResult {
t.Helper()
for _, event := range events {
if len(event.Results) > 0 {
return event.Results
}
}
t.Fatalf("no completed record was written: %#v", events)
return nil
}
// THE TRANSIENT DISPATCH RESULT IS NOT WHERE THIS FACT CAN LIVE.
//
// recordCompleted kept an exit code, stdout and stderr, and the notice is
// deliberately in none of those. So once the dispatch result was gone, an audit
// or recovery reader could not tell that a hook had run under the weakened
// DenyRead token, whatever the hook did afterwards.
func TestTheAuditRecordKeepsTheEnforcementNotice(t *testing.T) {
const notice = "denyRead is configured, so the write jail is not confining writes"
for _, testCase := range []struct {
name string
result commandResult
}{
{"launched and succeeded", commandResult{ExitCode: 0, Stdout: "looks fine", Notices: []string{notice}}},
{"vetoed the tool", commandResult{ExitCode: 2, Stderr: "policy violation", Notices: []string{notice}}},
{"silent hook", commandResult{ExitCode: 0, Notices: []string{notice}}},
} {
t.Run(testCase.name, func(t *testing.T) {
results := completedResults(t, auditedDispatcher(t,
Definition{ID: "policy", Event: EventBeforeTool, Command: "policy-check", Enabled: true},
testCase.result))
if len(results) != 1 {
t.Fatalf("results = %#v, want one", results)
}
if len(results[0].EnforcementNotices) != 1 || results[0].EnforcementNotices[0] != notice {
t.Errorf("the durable record lost the disclosure: %#v", results[0])
}
// The existing semantics are untouched.
if results[0].ExitCode != testCase.result.ExitCode {
t.Errorf("ExitCode = %d, want %d", results[0].ExitCode, testCase.result.ExitCode)
}
if results[0].Stdout != testCase.result.Stdout || results[0].Stderr != testCase.result.Stderr {
t.Errorf("stdout/stderr changed: %#v", results[0])
}
})
}
}
// A hook with nothing to disclose writes exactly what it wrote before, so a
// reader of historical records sees no difference.
func TestAnOrdinaryHookWritesNoEnforcementField(t *testing.T) {
results := completedResults(t, auditedDispatcher(t,
Definition{ID: "policy", Event: EventBeforeTool, Command: "policy-check", Enabled: true},
commandResult{ExitCode: 0, Stdout: "looks fine"}))
if len(results[0].EnforcementNotices) != 0 {
t.Errorf("a hook with no disclosure recorded one: %#v", results[0])
}
}
// And the durable record inherits the launch-state rule rather than restating
// it: a hook that never started records no enforcement claim.
func TestTheAuditRecordMakesNoClaimForAHookThatNeverLaunched(t *testing.T) {
store, err := NewAuditStore(AuditStoreOptions{AuditPath: filepath.Join(t.TempDir(), "audit.jsonl")})
if err != nil {
t.Fatalf("NewAuditStore: %v", err)
}
dispatcher := NewDispatcher(DispatcherOptions{
Config: beforeToolConfig(Definition{ID: "policy", Event: EventBeforeTool, Command: "policy-check", Enabled: true}),
Audit: store,
Cwd: t.TempDir(),
Execution: newRunnerFor(&noticePreparer{build: func() *exec.Cmd { return exec.Command("definitely-not-a-real-binary-zzz") }}),
})
dispatcher.Dispatch(context.Background(), DispatchInput{Event: EventBeforeTool, ToolName: "bash", ToolCallID: "call_1"})
events, err := store.ReadEvents()
if err != nil {
t.Fatalf("ReadEvents: %v", err)
}
for _, result := range completedResults(t, events) {
if len(result.EnforcementNotices) != 0 {
t.Errorf("the durable record claims an enforcement trade for a hook that never started: %#v", result)
}
}
}
@@ -1,6 +0,0 @@
//go:build !windows
package hooks
// sleepScript keeps a launched child alive long enough for a timeout to fire.
const sleepScript = "sleep 2"
@@ -1,6 +0,0 @@
package hooks
// sleepScript keeps a launched child alive long enough for a timeout to fire.
// Paired with the !windows file of the same name; both must exist or the
// platform without one silently loses the timeout case.
const sleepScript = "ping -n 3 127.0.0.1 > NUL"
@@ -1,184 +0,0 @@
package hooks
import (
"context"
"errors"
"os/exec"
"runtime"
"strings"
"testing"
"time"
"github.com/Twigpine/zero/internal/execution"
)
const launchStateNotice = "denyRead is configured, so the write jail is not confining writes"
// shellCommand builds a portable child that the platform can actually launch.
func shellCommand(script string) *exec.Cmd {
if runtime.GOOS == "windows" {
return exec.Command("cmd.exe", "/c", script)
}
return exec.Command("/bin/sh", "-c", script)
}
// noticePreparer plans a command carrying an enforcement notice, and can fail
// the way the sandbox does before the child exists.
type noticePreparer struct {
prepareErr error
build func() *exec.Cmd
}
func (preparer *noticePreparer) PrepareExecution(_ context.Context, request execution.Request) (execution.PreparedCommand, error) {
if preparer.prepareErr != nil {
return execution.PreparedCommand{}, preparer.prepareErr
}
command := preparer.build
if command == nil {
command = func() *exec.Cmd { return exec.Command(request.Command.Name, request.Command.Args...) }
}
return execution.PreparedCommand{
Command: command(),
Enforcement: execution.Enforcement{Notices: []string{launchStateNotice}},
}, nil
}
// PLANNING A WRAPPED COMMAND IS NOT PROOF THAT ANYTHING RAN.
//
// Enforcement.Notices describes the shape the command was PREPARED to run
// under. Copying it straight out made the completed-enforcement claim for
// commands that never existed: a sandbox setup failure and a missing executable
// are both decided before the child launches, so the hook message told the
// operator the write jail had been traded away for a process that never
// started.
//
// Everything after launch keeps the disclosure, including a nonzero exit, a
// timeout and a cancellation: those happened to a child that really did run
// under that token.
//
// Driven through the execution runner rather than a hand-built commandResult,
// because the projection is the thing under test.
func TestTheHookRunnerOnlyDisclosesEnforcementForAChildThatLaunched(t *testing.T) {
for _, testCase := range []struct {
name string
preparer *noticePreparer
timeout time.Duration
wantNotice bool
wantTimedOut bool
}{
{
name: "sandbox setup failed before the child existed",
preparer: &noticePreparer{prepareErr: errors.New("could not build the restricted token")},
wantNotice: false,
},
{
name: "the executable was never found",
preparer: &noticePreparer{build: func() *exec.Cmd {
return exec.Command("definitely-not-a-real-binary-zzz")
}},
wantNotice: false,
},
{
name: "the child launched and succeeded",
preparer: &noticePreparer{build: func() *exec.Cmd { return shellCommand("exit 0") }},
wantNotice: true,
},
{
name: "the child launched and exited nonzero",
preparer: &noticePreparer{build: func() *exec.Cmd { return shellCommand("exit 3") }},
wantNotice: true,
},
{
name: "the child launched and timed out",
preparer: &noticePreparer{build: func() *exec.Cmd { return shellCommand(sleepScript) }},
timeout: 150 * time.Millisecond,
wantNotice: true,
wantTimedOut: true,
},
} {
t.Run(testCase.name, func(t *testing.T) {
ctx := context.Background()
if testCase.timeout > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, testCase.timeout)
defer cancel()
}
run := executionCommandRunner(execution.NewRunner(testCase.preparer))
result := run(ctx, "hook-command", nil, nil, t.TempDir(), nil)
if result.TimedOut != testCase.wantTimedOut {
t.Fatalf("TimedOut = %v, want %v: the case did not reach the outcome kind it is named for", result.TimedOut, testCase.wantTimedOut)
}
if got := len(result.Notices) > 0; got != testCase.wantNotice {
t.Fatalf("notices present = %v, want %v: %#v", got, testCase.wantNotice, result.Notices)
}
// Asserted on Notices, which is where the disclosure travels. It used
// to be folded into the hook message as well, and the agent loop now
// merges Notices into the typed enforcement slice for both hook
// phases, so carrying it in both places delivered it twice.
notices := strings.Join(result.Notices, "\n")
if testCase.wantNotice && !strings.Contains(notices, launchStateNotice) {
t.Errorf("a launched child lost its disclosure:\n%s", notices)
}
if !testCase.wantNotice && strings.Contains(notices, launchStateNotice) {
t.Errorf("a child that never launched claimed the token was traded away:\n%s", notices)
}
if message := hookMessage(result); strings.Contains(message, launchStateNotice) {
t.Errorf("the disclosure also rode along in the hook message, so it reaches the model twice:\n%s", message)
}
})
}
}
// And the same rule has to hold on the veto path, which returns early and is what
// the model actually sees. Both channels are checked: the notice travels on
// Notices, and Reason must not grow a copy of it either.
func TestAVetoingHookThatNeverLaunchedClaimsNoEnforcement(t *testing.T) {
dispatcher := NewDispatcher(DispatcherOptions{
Config: beforeToolConfig(Definition{ID: "policy", Event: EventBeforeTool, Command: "policy-check", Enabled: true}),
Cwd: t.TempDir(),
// A missing executable rather than a prepare error: a prepare error never
// builds the PreparedCommand, so its outcome carries no planned notice and
// the assertion below would hold with the launch gate deleted. This shape
// plans the notice and then fails to launch.
Execution: execution.NewRunner(&noticePreparer{build: func() *exec.Cmd {
return exec.Command("definitely-not-a-real-binary-zzz")
}}),
})
outcome := dispatcher.Dispatch(context.Background(), DispatchInput{Event: EventBeforeTool, ToolName: "bash"})
if !outcome.Blocked {
t.Fatal("SETUP INVALID: a beforeTool hook that could not run must fail closed, or the veto path is not exercised")
}
if len(outcome.Notices) != 0 {
t.Errorf("the dispatch outcome claims an enforcement trade for a hook that never started: %#v", outcome.Notices)
}
if strings.Contains(outcome.Reason, launchStateNotice) {
t.Errorf("the veto reason claims an enforcement trade for a hook that never started:\n%s", outcome.Reason)
}
}
// A launched hook still carries it all the way into the dispatch outcome, on the
// typed slice and only there.
func TestALaunchedHookCarriesTheNoticeIntoTheDispatchOutcome(t *testing.T) {
dispatcher := NewDispatcher(DispatcherOptions{
Config: beforeToolConfig(Definition{ID: "policy", Event: EventBeforeTool, Command: "policy-check", Enabled: true}),
Cwd: t.TempDir(),
Execution: execution.NewRunner(&noticePreparer{build: func() *exec.Cmd { return shellCommand("exit 2") }}),
})
outcome := dispatcher.Dispatch(context.Background(), DispatchInput{Event: EventBeforeTool, ToolName: "bash"})
if !outcome.Blocked {
t.Fatal("SETUP INVALID: the hook did not veto, so the reason path is not exercised")
}
if got := strings.Count(strings.Join(outcome.Notices, "\n"), launchStateNotice); got != 1 {
t.Errorf("a hook that really ran under the weakened token disclosed it %d times on Notices, want once: %#v", got, outcome.Notices)
}
if strings.Contains(outcome.Reason, launchStateNotice) {
t.Errorf("the veto reason carries a second copy of the notice:\n%s", outcome.Reason)
}
}
// newRunnerFor keeps the audit tests readable without importing the execution
// package into every file that needs one.
func newRunnerFor(preparer *noticePreparer) *execution.Runner {
return execution.NewRunner(preparer)
}
-116
View File
@@ -1,116 +0,0 @@
package hooks
import (
"context"
"strings"
"testing"
)
// A HOOK THAT RAN UNDER THE WEAKENED TOKEN STILL SAYS SO, ON THE TYPED CHANNEL.
//
// The projection once kept stdout, stderr and an exit code and dropped the
// notices, so such a hook ran silently. The first fix put them into the message
// alongside the hook's own output, which delivered them but made Messages two
// things at once. Now that the agent loop merges Notices into the typed
// EnforcementNotices for beforeTool and afterTool alike, folding them into the
// message as well delivered the same disclosure twice.
//
// So the property is unchanged and its carrier moved: whatever the hook printed,
// the notice reaches Notices exactly once and never rides along in Messages.
func TestAHookSurfacesTheEnforcementNoticeOnTheTypedChannel(t *testing.T) {
const notice = "denyRead is configured, so the write jail is not confining writes"
for _, testCase := range []struct {
name string
result commandResult
}{
{"hook printed nothing", commandResult{ExitCode: 0, Notices: []string{notice}}},
{"hook printed to stdout", commandResult{ExitCode: 0, Stdout: "looks fine", Notices: []string{notice}}},
{"hook printed to stderr only", commandResult{ExitCode: 0, Stderr: "a warning", Notices: []string{notice}}},
} {
t.Run(testCase.name, func(t *testing.T) {
result := testCase.result
dispatcher := NewDispatcher(DispatcherOptions{
Config: beforeToolConfig(Definition{ID: "vet", Event: EventAfterTool, Command: "vet", Enabled: true}),
run: func(context.Context, string, []string, []byte, string, []string) commandResult {
return result
},
})
outcome := dispatcher.Dispatch(context.Background(), DispatchInput{Event: EventAfterTool, ToolName: "bash"})
if got := strings.Count(strings.Join(outcome.Notices, "\n"), notice); got != 1 {
t.Fatalf("the notice reached Notices %d times, want once: %v", got, outcome.Notices)
}
// AND NOT IN THE PROSE AS WELL, which is what made it arrive twice.
if joined := strings.Join(outcome.Messages, "\n"); strings.Contains(joined, notice) {
t.Errorf("the notice also rode along in Messages, so every surface that composes the typed slice shows it twice:\n%s", joined)
}
})
}
}
// A hook's own output is untouched, with a notice or without one.
func TestAHookMessageCarriesOnlyTheHooksOwnOutput(t *testing.T) {
const notice = "denyRead is configured, so the write jail is not confining writes"
if message := hookMessage(commandResult{ExitCode: 0, Stdout: "looks fine"}); message != "looks fine" {
t.Errorf("hookMessage = %q, want the hook's own output untouched", message)
}
if message := hookMessage(commandResult{ExitCode: 0, Stdout: "looks fine", Notices: []string{notice}}); message != "looks fine" {
t.Errorf("hookMessage = %q, want the notice left to the typed channel", message)
}
if message := hookMessage(commandResult{ExitCode: 0}); message != "" {
t.Errorf("a silent hook with no notice produced %q", message)
}
if message := hookMessage(commandResult{ExitCode: 0, Notices: []string{notice}}); message != "" {
t.Errorf("a silent hook produced %q, want nothing: its disclosure travels typed", message)
}
}
// THROUGH Dispatch, NOT A HAND-BUILT commandResult.
//
// The blocking branch returns immediately, so it never touches hookMessage, and a
// vetoing beforeTool hook that ran without write confinement once reported only
// the veto. The disclosure still has to come out of that branch, and it has to
// come out ONCE: the blocking hook's notices used to be folded into Reason as well
// as appended to Notices, so the same fact travelled on two channels and the
// consumer had to guess which copy to drop. Reason is the hook's words, and the
// notice travels on the typed slice like every other hook's.
func TestABlockedBeforeToolHookCarriesItsNoticeOnTheTypedSliceOnly(t *testing.T) {
const notice = "denyRead is configured, so the write jail is not confining writes"
dispatcher := NewDispatcher(DispatcherOptions{
Config: beforeToolConfig(Definition{ID: "policy", Event: EventBeforeTool, Command: "policy-check", Enabled: true}),
run: func(context.Context, string, []string, []byte, string, []string) commandResult {
return commandResult{ExitCode: 2, Stderr: "policy violation", Notices: []string{notice}}
},
})
outcome := dispatcher.Dispatch(context.Background(), DispatchInput{Event: EventBeforeTool, ToolName: "bash"})
if !outcome.Blocked {
t.Fatal("SETUP INVALID: the hook did not block, so the blocking branch was never taken")
}
if len(outcome.Notices) != 1 || outcome.Notices[0] != notice {
t.Errorf("the veto lost the enforcement notice from the typed slice: %#v", outcome.Notices)
}
if outcome.Reason != "policy violation" {
t.Errorf("Reason = %q, want the hook's own explanation and nothing else", outcome.Reason)
}
// Across BOTH channels, because a copy in each is the defect this pins.
if got := strings.Count(outcome.Reason, notice) + strings.Count(strings.Join(outcome.Notices, "\n"), notice); got != 1 {
t.Errorf("the notice appears %d times across Reason and Notices, want once", got)
}
}
// And a veto with no notice reads exactly as it did before.
func TestABlockedHookWithoutANoticeIsUnchanged(t *testing.T) {
dispatcher := NewDispatcher(DispatcherOptions{
Config: beforeToolConfig(Definition{ID: "policy", Event: EventBeforeTool, Command: "policy-check", Enabled: true}),
run: func(context.Context, string, []string, []byte, string, []string) commandResult {
return commandResult{ExitCode: 2, Stderr: "policy violation"}
},
})
outcome := dispatcher.Dispatch(context.Background(), DispatchInput{Event: EventBeforeTool, ToolName: "bash"})
if outcome.Reason != "policy violation" {
t.Errorf("Reason = %q, want the hook's own explanation untouched", outcome.Reason)
}
}
+2 -15
View File
@@ -118,19 +118,6 @@ type AuditResult struct {
ExitCode int `json:"exitCode"`
Stdout string `json:"stdout,omitempty"`
Stderr string `json:"stderr,omitempty"`
// EnforcementNotices are the least-privilege disclosures that were true of
// this hook's execution.
//
// A DURABLE READER CANNOT RECOVER A FACT THAT WAS DROPPED IN CONVERSION. The
// notice is deliberately not written into stdout or stderr, so recording only
// those three fields meant that once the transient dispatch result was gone,
// nothing could tell an audit or recovery reader that a successful, failing or
// vetoing hook had run under the weakened DenyRead token.
//
// Typed rather than a rendered line, and omitempty, so historical records that
// predate the field read back unchanged and an ordinary hook writes exactly
// what it wrote before.
EnforcementNotices []string `json:"enforcementNotices,omitempty"`
}
type AuditEvent struct {
@@ -792,7 +779,7 @@ func normalizeDefinition(raw any, field string) (Definition, error) {
return Definition{}, err
}
if matcher != "" && !eventSupportsMatcher(event) {
return Definition{}, manifestError{fieldPath: field + ".matcher", message: "matcher is only supported for beforeTool and afterTool hooks."}
return Definition{}, manifestError{fieldPath: field + ".matcher", message: "matcher is only supported for beforeTool, afterTool, specialistStart, and specialistStop hooks."}
}
command, err := requiredString(obj, field+".command")
if err != nil {
@@ -856,7 +843,7 @@ func matchesHookMatcher(matcher string, toolName string) bool {
}
func eventSupportsMatcher(event Event) bool {
return event == EventBeforeTool || event == EventAfterTool
return event == EventBeforeTool || event == EventAfterTool || event == EventSpecialistStart || event == EventSpecialistStop
}
func toDiagnostic(err error, source ConfigSource, path string) Diagnostic {
+41 -1
View File
@@ -180,7 +180,7 @@ func TestLoadConfigPreservesUserDisabledStateWhenProjectOmitsEnabled(t *testing.
}
func TestLoadConfigRejectsMatchersOnLifecycleHooks(t *testing.T) {
for _, event := range []string{"sessionStart", "specialistStart"} {
for _, event := range []string{"sessionStart", "sessionEnd"} {
t.Run(event, func(t *testing.T) {
dir := t.TempDir()
projectConfigPath := filepath.Join(dir, "hooks.json")
@@ -210,6 +210,46 @@ func TestLoadConfigRejectsMatchersOnLifecycleHooks(t *testing.T) {
}
}
func TestLoadConfigAcceptsMatchersOnSpecialistHooks(t *testing.T) {
for _, tc := range []struct {
event Event
id string
}{
{event: EventSpecialistStart, id: "zero.explorer-start"},
{event: EventSpecialistStop, id: "zero.explorer-stop"},
} {
t.Run(string(tc.event), func(t *testing.T) {
dir := t.TempDir()
projectConfigPath := filepath.Join(dir, "hooks.json")
writeHookJSON(t, projectConfigPath, map[string]any{
"hooks": []any{map[string]any{
"id": tc.id,
"event": string(tc.event),
"matcher": "explorer",
"command": "node",
}},
})
result, err := LoadConfig(LoadOptions{
UserConfigPath: filepath.Join(dir, "missing-user-hooks.json"),
ProjectConfigPath: projectConfigPath,
})
if err != nil {
t.Fatalf("LoadConfig returned error: %v", err)
}
if len(result.Config.Hooks) != 1 || result.Config.Hooks[0].Matcher != "explorer" {
t.Fatalf("specialist matcher hook = %#v", result.Config.Hooks)
}
if got := hookIDs(Select(result.Config, SelectInput{Event: tc.event, ToolName: "explorer"})); !reflect.DeepEqual(got, []string{tc.id}) {
t.Fatalf("matched selection = %#v", got)
}
if got := Select(result.Config, SelectInput{Event: tc.event, ToolName: "worker"}); len(got) != 0 {
t.Fatalf("unmatched specialist should be empty, got %#v", got)
}
})
}
}
func TestConfigStorePersistsUpdates(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "hooks.json")
store, err := NewConfigStore(StoreOptions{ConfigPath: configPath})
@@ -1,163 +0,0 @@
package mcp
import (
"context"
"encoding/json"
"errors"
"os"
"os/exec"
"path/filepath"
"runtime"
"testing"
"github.com/Twigpine/zero/internal/config"
"github.com/Twigpine/zero/internal/execution"
"github.com/Twigpine/zero/internal/tools"
)
// helperCommandName is a command that resolves on this platform, so registration
// reaches connectStdio. What actually runs is whatever the preparer returns.
func helperCommandName() string {
if runtime.GOOS == "windows" {
return "cmd.exe"
}
return "sh"
}
const adapterLaunchNotice = "denyRead is configured, so the write jail is not confining writes"
// adapterHelperPreparer models the Windows wrapped plan: the command is the
// HELPER, the launch fact is a report file, and the plan's cleanup removes that
// file. The cleanup is the part that matters, because it is what runs inside
// client.Close and destroys the evidence a later decision needs.
type adapterHelperPreparer struct {
reportPath string
reportBody string
writeReport bool
called bool
}
func (preparer *adapterHelperPreparer) PrepareExecution(_ context.Context, _ execution.Request) (execution.PreparedCommand, error) {
preparer.called = true
if preparer.writeReport {
if err := os.WriteFile(preparer.reportPath, []byte(preparer.reportBody), 0o600); err != nil {
return execution.PreparedCommand{}, err
}
}
// A helper that starts, says nothing an MCP client understands, and exits, so
// the handshake fails the way it does when the requested server never existed.
var command *exec.Cmd
if runtime.GOOS == "windows" {
command = exec.Command("cmd.exe", "/c", "exit 0")
} else {
command = exec.Command("/bin/sh", "-c", "exit 0")
}
path := preparer.reportPath
return execution.PreparedCommand{
Command: command,
ChildLaunchOwnedByAdapter: true,
Enforcement: execution.Enforcement{Notices: []string{adapterLaunchNotice}},
Report: func() (execution.AdapterReport, error) {
raw, err := os.ReadFile(path)
if errors.Is(err, os.ErrNotExist) {
return execution.AdapterReport{}, nil
}
if err != nil {
return execution.AdapterReport{}, err
}
var report execution.AdapterReport
if err := json.Unmarshal(raw, &report); err != nil {
return execution.AdapterReport{}, err
}
return report, nil
},
Cleanup: func() { _ = os.Remove(path) },
}, nil
}
func registerWithAdapterHelper(t *testing.T, preparer *adapterHelperPreparer) *Runtime {
t.Helper()
runtime, err := RegisterTools(context.Background(), tools.NewRegistry(), config.MCPConfig{Servers: map[string]config.MCPServerConfig{
"docs": {Type: "stdio", Command: helperCommandName()},
}}, RegisterOptions{
// No ClientFactory on purpose: an injected factory skips connectStdio
// entirely, which is where the decision under test is made, and the test
// would pass with the fix removed.
Execution: execution.NewRunner(preparer),
WorkspaceRoot: t.TempDir(),
})
if err != nil {
t.Fatalf("RegisterTools: %v", err)
}
t.Cleanup(func() { _ = runtime.Close() })
return runtime
}
// A HELPER THAT NEVER CREATED THE SERVER HAS NOTHING TO DISCLOSE.
//
// For an adapter-owned launch, cmd.Start proves only that the sandbox helper
// started. It can then fail setup-marker validation, ACL application, network
// validation, token construction, or CreateProcessAsUser without ever creating
// the requested MCP server. The launch sink already made that distinction; the
// initialize-error path did not, and carried the planned notices out
// unconditionally. The operator was told the server had run without write
// confinement when no server had run at all.
func TestAnMCPHelperThatReportedNoChildDisclosesNothing(t *testing.T) {
directory := t.TempDir()
preparer := &adapterHelperPreparer{
reportPath: filepath.Join(directory, "report.json"),
reportBody: `{"childLaunched":false}`,
writeReport: true,
}
runtime := registerWithAdapterHelper(t, preparer)
// SETUP: the attempt really did fail, or there is no disclosure decision here.
if len(runtime.Skipped()) == 0 {
t.Fatal("SETUP INVALID: the server connected, so the initialize-failure path is not under test")
}
if got := runtime.StartupDisclosures(); len(got) != 0 {
t.Fatalf("a helper that reported no child announced %v; no server ran, confined or otherwise", got)
}
}
// AND ONE THAT DID CREATE IT DISCLOSES ONCE.
//
// The companion case, and the one that keeps the assertion above from being
// satisfied by a path that discloses nothing ever. The child ran under the
// planned token and may have done filesystem work before the handshake failed,
// so the disclosure has to survive the failure.
func TestAnMCPHelperThatLaunchedTheChildDisclosesOnce(t *testing.T) {
directory := t.TempDir()
preparer := &adapterHelperPreparer{
reportPath: filepath.Join(directory, "report.json"),
reportBody: `{"childLaunched":true}`,
writeReport: true,
}
runtime := registerWithAdapterHelper(t, preparer)
if !preparer.called {
t.Fatal("SETUP INVALID: the preparer never ran, so connectStdio was never reached")
}
disclosures := runtime.StartupDisclosures()
if len(disclosures) != 1 {
t.Fatalf("a server that really ran under the weakened token produced %d disclosures, want exactly one: %v", len(disclosures), disclosures)
}
if len(disclosures[0].Notices) != 1 || disclosures[0].Notices[0] != adapterLaunchNotice {
t.Fatalf("the disclosure carries %v, want the one planned notice", disclosures[0].Notices)
}
}
// A helper that wrote no report at all is the same answer as one that reported
// no child: absence is not confirmation.
func TestAnMCPHelperThatWroteNoReportDisclosesNothing(t *testing.T) {
directory := t.TempDir()
preparer := &adapterHelperPreparer{reportPath: filepath.Join(directory, "report.json")}
runtime := registerWithAdapterHelper(t, preparer)
if len(runtime.Skipped()) == 0 {
t.Fatal("SETUP INVALID: the server connected, so the initialize-failure path is not under test")
}
if got := runtime.StartupDisclosures(); len(got) != 0 {
t.Fatalf("a helper that published nothing announced %v", got)
}
}
@@ -1,281 +0,0 @@
package mcp
import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"github.com/Twigpine/zero/internal/config"
"github.com/Twigpine/zero/internal/execution"
"github.com/Twigpine/zero/internal/tools"
)
const (
adapterHelperModeEnv = "ZERO_TEST_MCP_ADAPTER_MODE"
adapterHelperReportEnv = "ZERO_TEST_MCP_ADAPTER_REPORT"
// answerThenPublish models the ordering the fix is about: the child is
// serving before the adapter has recorded that it exists.
answerThenPublish = "answer-then-publish"
// failThenPublish is the same ordering on the other exit: the handshake dies
// first and the adapter publishes on its way out.
failThenPublish = "fail-then-publish"
// failAndNeverPublish is the companion that keeps the two above from being
// satisfied by disclosing unconditionally.
failAndNeverPublish = "fail-and-never-publish"
)
// TestAdapterHelperProcess is the helper process body, not a test.
//
// It exists so the report can be published at a controlled point RELATIVE TO THE
// HANDSHAKE. A fixture that writes the finished report during PrepareExecution,
// as the older test does, publishes before the helper command has even started,
// so every ordering this file is about is already over before the parent looks.
func TestAdapterHelperProcess(t *testing.T) {
mode := os.Getenv(adapterHelperModeEnv)
if mode == "" {
t.Skip("not the helper process")
}
reportPath := os.Getenv(adapterHelperReportEnv)
publish := func() {
// Same shape the Windows helper writes, into the file the preparer already
// created empty.
_ = os.WriteFile(reportPath, []byte(`{"childLaunched":true}`), 0o600)
}
switch mode {
case answerThenPublish:
serveOneMCPSessionThenPublish(publish)
case failThenPublish:
// The handshake dies here, before anything is recorded. Closing stdout is
// the child going away; the adapter is still running.
_ = os.Stdout.Close()
time.Sleep(300 * time.Millisecond)
publish()
case failAndNeverPublish:
_ = os.Stdout.Close()
time.Sleep(300 * time.Millisecond)
}
// Before the framework can write anything to the pipe the parent is reading.
os.Exit(0)
}
// serveOneMCPSessionThenPublish answers the handshake and only then records that
// the child exists, which is the interleaving that used to lose the disclosure.
func serveOneMCPSessionThenPublish(publish func()) {
reader := bufio.NewReader(os.Stdin)
out := bufio.NewWriter(os.Stdout)
published := false
for {
line, err := reader.ReadString('\n')
if strings.TrimSpace(line) != "" {
var message struct {
ID *int `json:"id"`
Method string `json:"method"`
}
if json.Unmarshal([]byte(strings.TrimSpace(line)), &message) == nil && message.ID != nil {
var result string
switch message.Method {
case "initialize":
result = `{"protocolVersion":"2024-11-05"}`
case "tools/list":
result = `{"tools":[]}`
default:
result = `{}`
}
fmt.Fprintf(out, `{"jsonrpc":"2.0","id":%d,"result":%s}`+"\n", *message.ID, result)
_ = out.Flush()
if message.Method == "initialize" && !published {
// AFTER the response is on the wire. The parent can act on a
// handshake that succeeded before this line runs.
time.Sleep(300 * time.Millisecond)
publish()
published = true
}
}
}
if err != nil {
return
}
}
}
// livePublishPreparer models the Windows wrapped plan with the real publication
// ordering: the report file is created EMPTY before the launch, exactly as
// openWindowsExecutionReport does, and the helper fills it in later.
type livePublishPreparer struct {
mode string
reportPath string
called bool
}
func (preparer *livePublishPreparer) PrepareExecution(_ context.Context, _ execution.Request) (execution.PreparedCommand, error) {
preparer.called = true
// The empty file the parent can see before anything is published. This is the
// state that is neither "no child" nor "child created".
if err := os.WriteFile(preparer.reportPath, nil, 0o600); err != nil {
return execution.PreparedCommand{}, err
}
command := exec.Command(os.Args[0], "-test.run=^TestAdapterHelperProcess$")
command.Env = append(os.Environ(),
adapterHelperModeEnv+"="+preparer.mode,
adapterHelperReportEnv+"="+preparer.reportPath,
)
path := preparer.reportPath
return execution.PreparedCommand{
Command: command,
ChildLaunchOwnedByAdapter: true,
Enforcement: execution.Enforcement{Notices: []string{adapterLaunchNotice}},
Report: func() (execution.AdapterReport, error) {
raw, err := os.ReadFile(path)
if errors.Is(err, os.ErrNotExist) {
return execution.AdapterReport{}, nil
}
if err != nil {
return execution.AdapterReport{}, err
}
var report execution.AdapterReport
// An empty or half-written file decodes to an error, which is the
// unsettled state and not an answer.
if err := json.Unmarshal(raw, &report); err != nil {
return execution.AdapterReport{}, err
}
return report, nil
},
Cleanup: func() { _ = os.Remove(path) },
}, nil
}
func registerWithLivePublisher(t *testing.T, mode string) (*Runtime, *livePublishPreparer) {
t.Helper()
preparer := &livePublishPreparer{mode: mode, reportPath: filepath.Join(t.TempDir(), "report.json")}
runtime, err := RegisterTools(context.Background(), tools.NewRegistry(), config.MCPConfig{Servers: map[string]config.MCPServerConfig{
"docs": {Type: "stdio", Command: helperCommandName()},
}}, RegisterOptions{
Execution: execution.NewRunner(preparer),
WorkspaceRoot: t.TempDir(),
})
if err != nil {
t.Fatalf("RegisterTools: %v", err)
}
t.Cleanup(func() { _ = runtime.Close() })
if !preparer.called {
t.Fatal("SETUP INVALID: the preparer never ran, so connectStdio was never reached")
}
return runtime, preparer
}
// A HANDSHAKE THAT SUCCEEDED IS PROOF THE CHILD RAN, WHATEVER THE REPORT SAYS YET.
//
// The child is created before the adapter records it, and it inherits the MCP
// pipes, so it can answer initialize while the report file is still the empty one
// the adapter opened. Reading it at that instant and remembering the answer
// turned "not yet" into "never" for the rest of the session, and the operator was
// never told the server serving these tools ran without the write jail.
//
// The adapter speaks no MCP, so a well-formed response can only have come from
// the requested child. That is terminal evidence and needs no report.
func TestADisclosureSurvivesAReportPublishedAfterTheHandshake(t *testing.T) {
runtime, _ := registerWithLivePublisher(t, answerThenPublish)
// SETUP: the server really connected, or this is the failure path instead.
if skipped := runtime.Skipped(); len(skipped) != 0 {
t.Fatalf("SETUP INVALID: the server did not connect (%v), so the successful-handshake path is not under test", skipped)
}
disclosures := runtime.StartupDisclosures()
if len(disclosures) != 1 {
t.Fatalf("a server that answered the handshake produced %d disclosures, want exactly one: %v", len(disclosures), disclosures)
}
if len(disclosures[0].Notices) != 1 || disclosures[0].Notices[0] != adapterLaunchNotice {
t.Fatalf("the disclosure carries %v, want the one planned notice", disclosures[0].Notices)
}
}
// AND ON THE OTHER EXIT, THE DECISION WAITS FOR THE ADAPTER.
//
// Here the handshake dies first and the adapter publishes on its way out. The
// decision used to be taken before Close, precisely because Close deletes the
// report, so it read the empty file and answered "no child" about a server that
// really had run. Settling from inside cleanup, with the file still there, is what
// lets the answer be taken after the adapter is terminal instead of before it.
func TestADisclosureSurvivesAReportPublishedAfterAFailedHandshake(t *testing.T) {
runtime, _ := registerWithLivePublisher(t, failThenPublish)
// SETUP: the attempt really failed, or the successful path is being measured.
if len(runtime.Skipped()) == 0 {
t.Fatal("SETUP INVALID: the server connected, so the initialize-failure path is not under test")
}
disclosures := runtime.StartupDisclosures()
if len(disclosures) != 1 {
t.Fatalf("a helper that published its launch on the way out produced %d disclosures, want exactly one: %v", len(disclosures), disclosures)
}
// THROUGH THE ERROR, not only through the sink. Registration merges the two,
// so asserting the sink alone passes while the failure the operator actually
// reads carries nothing. This is the carrier that exists because the client
// holding the notices has already been closed and discarded by then.
skipped := runtime.Skipped()
if len(skipped) != 1 {
t.Fatalf("skipped = %v, want exactly the one server", skipped)
}
carried := startupNoticesFromError(skipped[0].Err)
if len(carried) != 1 || carried[0] != adapterLaunchNotice {
t.Fatalf("the initialize failure carries %v, want the planned notice; the decision was taken before the adapter settled", carried)
}
}
// AND AN ADAPTER THAT NEVER PUBLISHED STILL DISCLOSES NOTHING.
//
// The companion that keeps both cases above from being satisfied by announcing
// unconditionally. Settling reads the report one last time and finds nothing,
// which after the adapter has exited is the answer rather than a race.
func TestNoDisclosureWhenTheAdapterExitsWithoutPublishing(t *testing.T) {
runtime, _ := registerWithLivePublisher(t, failAndNeverPublish)
if len(runtime.Skipped()) == 0 {
t.Fatal("SETUP INVALID: the server connected, so the initialize-failure path is not under test")
}
if got := runtime.StartupDisclosures(); len(got) != 0 {
t.Fatalf("a helper that published nothing announced %v; no server is known to have run", got)
}
}
// THE CARRIER ENFORCES THE RULE ITSELF, NOT ONLY ITS CALLERS.
//
// connectAndList reads StartupNotices on the success path and passes the result
// straight through to the disclosure sources, so this method is a carrier of the
// launch fact in its own right. Today the handshake confirmation makes the gate
// redundant: every route that reaches here has already settled the decision
// positive. It is kept because the redundancy is on the safe side. An edit that
// moves or loses the confirmation makes this return nothing rather than announce
// a confinement on the strength of the helper having started, which is the claim
// this whole mechanism exists to stop making.
//
// Driven directly, because no path through connectStdio can reach it with a
// negative decision, and a test that cannot construct the state it is about would
// be asserting nothing.
func TestStartupNoticesAreEmptyWhileTheLaunchIsUnknown(t *testing.T) {
client := &Client{startupNotices: []string{adapterLaunchNotice}}
// SETUP: ungated, this client discloses, or the assertion below is vacuous.
if len(client.StartupNotices()) != 1 {
t.Fatal("SETUP INVALID: the client discloses nothing even before the gate, so the gate cannot be what is under test")
}
client.launched = func() bool { return false }
if got := client.StartupNotices(); len(got) != 0 {
t.Fatalf("a client whose launch is not established carries %v", got)
}
client.launched = func() bool { return true }
if got := client.StartupNotices(); len(got) != 1 || got[0] != adapterLaunchNotice {
t.Fatalf("an established launch carries %v, want the planned notice", got)
}
}
+119
View File
@@ -0,0 +1,119 @@
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
@@ -0,0 +1,537 @@
package mcp
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"github.com/Twigpine/zero/internal/aimlapi"
"github.com/Twigpine/zero/internal/config"
)
// aimlapiRecorder stands in for mcp.aimlapi.com: it records every request it
// is handed (the URL stays https://mcp.aimlapi.com/mcp, nothing is dialed) and
// answers JSON-RPC, or 401 with the server's OAuth challenge when unauthorized.
type aimlapiRecorder struct {
mu sync.Mutex
headers []http.Header
urls []string
unauthorized bool
// redirectFirstTo, when set, answers the first request with a 307 to it.
redirectFirstTo string
// failStatus, when set, answers every request with it and failBody.
failStatus int
failBody string
}
func (recorder *aimlapiRecorder) RoundTrip(request *http.Request) (*http.Response, error) {
var body []byte
if request.Body != nil {
body, _ = io.ReadAll(request.Body)
_ = request.Body.Close()
}
recorder.mu.Lock()
recorder.headers = append(recorder.headers, request.Header.Clone())
recorder.urls = append(recorder.urls, request.URL.String())
unauthorized := recorder.unauthorized
redirect := recorder.redirectFirstTo != "" && len(recorder.headers) == 1
recorder.mu.Unlock()
if redirect {
response := fakeHTTPResponse(request, http.StatusTemporaryRedirect, "")
response.Header.Set("Location", recorder.redirectFirstTo)
return response, nil
}
if recorder.failStatus != 0 {
return fakeHTTPResponse(request, recorder.failStatus, recorder.failBody), nil
}
if unauthorized {
response := fakeHTTPResponse(request, http.StatusUnauthorized, `{"error":"invalid_token"}`)
response.Header.Set("WWW-Authenticate", `Bearer resource_metadata="https://mcp.aimlapi.com/.well-known/oauth-protected-resource/mcp"`)
return response, nil
}
var message struct {
ID json.RawMessage `json:"id"`
Method string `json:"method"`
}
_ = json.Unmarshal(body, &message)
if len(message.ID) == 0 {
return fakeHTTPResponse(request, http.StatusAccepted, ""), nil
}
result := `{}`
switch message.Method {
case "initialize":
result = `{"protocolVersion":"2024-11-05","capabilities":{},"serverInfo":{"name":"fake","version":"1"}}`
case "tools/list":
result = `{"tools":[{"name":"tools_search","inputSchema":{"type":"object"}}]}`
}
return fakeHTTPResponse(request, http.StatusOK, fmt.Sprintf(`{"jsonrpc":"2.0","id":%s,"result":%s}`, message.ID, result)), nil
}
func (recorder *aimlapiRecorder) recorded() []http.Header {
recorder.mu.Lock()
defer recorder.mu.Unlock()
return append([]http.Header(nil), recorder.headers...)
}
func fakeHTTPResponse(request *http.Request, status int, body string) *http.Response {
header := http.Header{}
if body != "" {
header.Set("Content-Type", "application/json")
}
return &http.Response{
StatusCode: status,
Header: header,
Body: io.NopCloser(bytes.NewBufferString(body)),
Request: request,
}
}
// isolateAIMLAPIKeyLookup points the real key resolver at an empty temporary
// home and returns the user config path it reads.
func isolateAIMLAPIKeyLookup(t *testing.T, envKey string) string {
t.Helper()
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
t.Setenv("APPDATA", filepath.Join(home, "AppData"))
t.Setenv("ZERO_CRED_STORAGE", "file")
t.Setenv("ZERO_PROVIDER", "")
t.Setenv("AIMLAPI_INFERENCE_URL", "")
t.Setenv("AIMLAPI_PARTNER_ID", "")
t.Setenv("AIMLAPI_API_KEY", envKey)
path, err := config.DefaultUserConfigPath()
if err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
t.Fatal(err)
}
return path
}
func writeAIMLAPIProviderConfig(t *testing.T, path string, baseURL string, apiKey string) {
t.Helper()
profile := map[string]any{"name": "aimlapi", "catalogID": "aimlapi", "model": "m", "apiKey": apiKey}
if baseURL != "" {
profile["baseURL"] = baseURL
}
data, err := json.Marshal(map[string]any{"activeProvider": "aimlapi", "providers": []any{profile}})
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, data, 0o600); err != nil {
t.Fatal(err)
}
}
func newRecordedNetworkClient(server Server, recorder *aimlapiRecorder) *networkClient {
return &networkClient{server: server, client: mcpHTTPClient(server, recorder), nextID: 1}
}
func aimlapiTestServer(name string, rawURL string) Server {
return Server{Name: name, Type: ServerTypeHTTP, URL: rawURL}
}
func assertAIMLAPIAttribution(t *testing.T, header http.Header) {
t.Helper()
if got := header.Get(aimlapi.SourceHeaderName); got != aimlapi.SourceValue {
t.Fatalf("%s = %q, want %q", aimlapi.SourceHeaderName, got, aimlapi.SourceValue)
}
if got, want := header.Get(aimlapi.PartnerHeaderName), aimlapi.ResolvePartnerID(""); got == "" || got != want {
t.Fatalf("%s = %q, want %q", aimlapi.PartnerHeaderName, got, want)
}
}
func TestAIMLAPIMCPRequestsCarryServiceKeyAndAttribution(t *testing.T) {
isolateAIMLAPIKeyLookup(t, "aiml-secret")
// A hand-added server on the exact host is treated like the built-in one.
for _, name := range []string{"aimlapi", "media"} {
recorder := &aimlapiRecorder{}
client := newRecordedNetworkClient(aimlapiTestServer(name, aimlapi.MCPServerURL), recorder)
if err := client.initialize(context.Background()); err != nil {
t.Fatalf("%s: initialize: %v", name, err)
}
if _, err := client.ListTools(context.Background()); err != nil {
t.Fatalf("%s: ListTools: %v", name, err)
}
requests := recorder.recorded()
if len(requests) != 3 {
t.Fatalf("%s: recorded %d requests, want initialize, initialized and tools/list", name, len(requests))
}
for _, header := range requests {
if got := header.Get("Authorization"); got != "Bearer aiml-secret" {
t.Fatalf("%s: Authorization = %q, want the AI/ML API key", name, got)
}
assertAIMLAPIAttribution(t, header)
}
}
}
func TestAIMLAPIMCPHeadersOnlyForExactHost(t *testing.T) {
isolateAIMLAPIKeyLookup(t, "aiml-secret")
for _, rawURL := range []string{
"https://mcp.aimlapi.com.example.net/mcp",
"http://mcp.aimlapi.com/mcp",
"https://mcp.aimlapi.com:8443/mcp",
"https://[email protected]/mcp",
"https://api.aimlapi.com/mcp",
"https://evilmcp.aimlapi.com/mcp",
"https://mcp.exa.ai/mcp",
} {
recorder := &aimlapiRecorder{}
server := aimlapiTestServer("other", rawURL)
server.Headers = map[string]string{"X-Custom": "kept"}
client := newRecordedNetworkClient(server, recorder)
if err := client.initialize(context.Background()); err != nil {
t.Fatalf("%s: initialize: %v", rawURL, err)
}
for _, header := range recorder.recorded() {
if strings.Contains(rawURL, "user@") && strings.HasPrefix(header.Get("Authorization"), "Basic ") {
// net/http turns URL userinfo into Basic auth on its own.
header.Del("Authorization")
}
// Byte-for-byte what any other MCP server gets.
want := http.Header{
"X-Custom": {"kept"},
"Content-Type": {"application/json"},
"Accept": {"application/json, text/event-stream"},
}
if fmt.Sprint(header) != fmt.Sprint(want) {
t.Fatalf("%s: headers = %v, want %v", rawURL, header, want)
}
}
}
// Port 443 spelled out and a different host case are still the exact host.
for _, rawURL := range []string{"https://mcp.aimlapi.com:443/mcp", "https://MCP.AIMLAPI.COM/mcp"} {
recorder := &aimlapiRecorder{}
client := newRecordedNetworkClient(aimlapiTestServer("aimlapi", rawURL), recorder)
if err := client.initialize(context.Background()); err != nil {
t.Fatalf("%s: initialize: %v", rawURL, err)
}
header := recorder.recorded()[0]
if header.Get("Authorization") != "Bearer aiml-secret" {
t.Fatalf("%s: Authorization = %q, want the key", rawURL, header.Get("Authorization"))
}
assertAIMLAPIAttribution(t, header)
}
}
// The key and its eligibility are resolved for every request, so switching the
// aimlapi profile to a proxy keeps the proxy's key off mcp.aimlapi.com on the
// very next request of an already-connected client and on new connections.
func TestAIMLAPIMCPKeyIsResolvedPerRequestAcrossProviderSwitch(t *testing.T) {
path := isolateAIMLAPIKeyLookup(t, "")
writeAIMLAPIProviderConfig(t, path, "", "canonical-key")
recorder := &aimlapiRecorder{}
server := aimlapiTestServer("aimlapi", aimlapi.MCPServerURL)
client := newRecordedNetworkClient(server, recorder)
if err := client.initialize(context.Background()); err != nil {
t.Fatalf("initialize: %v", err)
}
if got := recorder.recorded()[0].Get("Authorization"); got != "Bearer canonical-key" {
t.Fatalf("canonical profile: Authorization = %q, want Bearer canonical-key", got)
}
writeAIMLAPIProviderConfig(t, path, "https://proxy.example/v1", "proxy-key")
if _, err := client.ListTools(context.Background()); err != nil {
t.Fatalf("ListTools after switch: %v", err)
}
fresh := newRecordedNetworkClient(server, recorder)
if err := fresh.initialize(context.Background()); err != nil {
t.Fatalf("initialize after switch: %v", err)
}
requests := recorder.recorded()
for _, header := range requests[2:] {
if got := header.Get("Authorization"); got != "" {
t.Fatalf("proxy profile: Authorization = %q, want none", got)
}
assertAIMLAPIAttribution(t, header)
}
for _, header := range requests {
if strings.Contains(fmt.Sprint(header), "proxy-key") {
t.Fatalf("proxy key reached mcp.aimlapi.com: %v", header)
}
}
}
func TestAIMLAPIMCPUnusableKeySendsNoAuthorization(t *testing.T) {
cases := map[string]string{
"multi-line key pool": "first-key\nsecond-key",
"comma key pool": "first-key,second-key",
"no key": "",
}
for name, apiKey := range cases {
t.Run(name, func(t *testing.T) {
path := isolateAIMLAPIKeyLookup(t, "")
writeAIMLAPIProviderConfig(t, path, "", apiKey)
recorder := &aimlapiRecorder{unauthorized: true}
client := newRecordedNetworkClient(aimlapiTestServer("aimlapi", aimlapi.MCPServerURL), recorder)
err := client.initialize(context.Background())
if err == nil {
t.Fatal("initialize succeeded against a 401")
}
requests := recorder.recorded()
if len(requests) != 1 {
t.Fatalf("recorded %d requests, want one intact request", len(requests))
}
header := requests[0]
if got := header.Get("Authorization"); got != "" {
t.Fatalf("Authorization = %q, want none", got)
}
if header.Get("Content-Type") != "application/json" {
t.Fatalf("request headers broken: %v", header)
}
assertAIMLAPIAttribution(t, header)
if !strings.Contains(err.Error(), "returned HTTP 401") || !strings.Contains(err.Error(), "zero mcp oauth login aimlapi") || !strings.Contains(err.Error(), "AIMLAPI_API_KEY") {
t.Fatalf("401 error lacks the authentication hint: %v", err)
}
if strings.Contains(err.Error(), "first-key") {
t.Fatalf("error leaks key material: %v", err)
}
})
}
}
func TestAIMLAPIMCPUnauthorizedHintOnlyForAIMLAPIWithoutOAuth(t *testing.T) {
if hint := aimlapiUnauthorizedHint(aimlapiTestServer("docs", "https://mcp.example.com/mcp"), http.StatusUnauthorized); hint != "" {
t.Fatalf("hint for another server = %q", hint)
}
oauth := aimlapiTestServer("aimlapi", aimlapi.MCPServerURL)
oauth.Auth = ServerAuthOAuth
if hint := aimlapiUnauthorizedHint(oauth, http.StatusUnauthorized); hint != "" {
t.Fatalf("hint for an OAuth server = %q", hint)
}
if hint := aimlapiUnauthorizedHint(aimlapiTestServer("aimlapi", aimlapi.MCPServerURL), http.StatusForbidden); hint != "" {
t.Fatalf("hint for 403 = %q", hint)
}
}
func TestAIMLAPIMCPKeyDefersToUserAuthorizationOAuthAndProjectConfig(t *testing.T) {
isolateAIMLAPIKeyLookup(t, "aiml-secret")
userHeader := aimlapiTestServer("aimlapi", aimlapi.MCPServerURL)
userHeader.Headers = map[string]string{"authorization": "Bearer user-token"}
oauth := aimlapiTestServer("aimlapi", aimlapi.MCPServerURL)
oauth.Auth = ServerAuthOAuth
project := aimlapiTestServer("aimlapi", aimlapi.MCPServerURL)
project.ProjectConfigured = true
cases := []struct {
name string
server Server
want string
}{
{"user Authorization header", userHeader, "Bearer user-token"},
{"OAuth server", oauth, ""},
{"project-configured server", project, ""},
}
for _, tc := range cases {
recorder := &aimlapiRecorder{}
client := newRecordedNetworkClient(tc.server, recorder)
if err := client.initialize(context.Background()); err != nil {
t.Fatalf("%s: initialize: %v", tc.name, err)
}
header := recorder.recorded()[0]
if got := header.Get("Authorization"); got != tc.want {
t.Fatalf("%s: Authorization = %q, want %q", tc.name, got, tc.want)
}
assertAIMLAPIAttribution(t, header)
}
}
func TestAIMLAPIMCPSSERequestsCarryKeyAndAttribution(t *testing.T) {
isolateAIMLAPIKeyLookup(t, "aiml-secret")
server := Server{Name: "aimlapi", Type: ServerTypeSSE, URL: aimlapi.MCPServerURL}
client := &remoteSSEClient{server: server}
apply := map[string]func(*http.Request){
"stream": client.applyStreamHeaders,
"post": client.applyPostHeaders,
}
for name, applyHeaders := range apply {
request, err := http.NewRequest(http.MethodPost, aimlapi.MCPServerURL, nil)
if err != nil {
t.Fatal(err)
}
applyHeaders(request)
if got := request.Header.Get("Authorization"); got != "Bearer aiml-secret" {
t.Fatalf("%s: Authorization = %q, want the key", name, got)
}
assertAIMLAPIAttribution(t, request.Header)
other, err := http.NewRequest(http.MethodPost, "https://mcp.aimlapi.com.example.net/mcp", nil)
if err != nil {
t.Fatal(err)
}
applyHeaders(other)
if other.Header.Get("Authorization") != "" || other.Header.Get(aimlapi.SourceHeaderName) != "" || other.Header.Get(aimlapi.PartnerHeaderName) != "" {
t.Fatalf("%s: lookalike host got AI/ML API headers: %v", name, other.Header)
}
}
}
func TestAIMLAPIMCPKeyStaysOutOfServerConfigAndIdentity(t *testing.T) {
enabled := config.MCPConfig{Servers: map[string]config.MCPServerConfig{
"aimlapi": {Type: "http", URL: aimlapi.MCPServerURL},
}}
isolateAIMLAPIKeyLookup(t, "")
withoutKey, err := NormalizeConfig(enabled)
if err != nil {
t.Fatal(err)
}
isolateAIMLAPIKeyLookup(t, "aiml-secret")
withKey, err := NormalizeConfig(enabled)
if err != nil {
t.Fatal(err)
}
if len(withKey) != 1 || withKey[0].Identity != withoutKey[0].Identity {
t.Fatalf("server identity depends on the key: %#v vs %#v", withKey, withoutKey)
}
if strings.Contains(fmt.Sprintf("%#v", withKey), "aiml-secret") {
t.Fatalf("normalized server carries the key: %#v", withKey)
}
// The shipped default is off, so it is not even normalized for a session.
defaults, err := NormalizeConfig(config.MCPConfig{Servers: config.DefaultMCPServers()})
if err != nil {
t.Fatal(err)
}
for _, server := range defaults {
if server.Name == "aimlapi" {
t.Fatalf("disabled aimlapi default was normalized for connection: %#v", server)
}
}
}
func TestAIMLAPIMCPUnauthorizedHintRespectsCredentialEligibility(t *testing.T) {
for _, tc := range []struct {
name string
headers map[string]string
project bool
wantSetup bool
}{
{"default", nil, false, true},
{"project", nil, true, false},
{"custom bearer", map[string]string{"Authorization": "Bearer manual"}, false, false},
{"mixed case header", map[string]string{"aUtHoRiZaTiOn": "Bearer manual"}, false, false},
{"empty header", map[string]string{"authorization": ""}, false, true},
} {
t.Run(tc.name, func(t *testing.T) {
server := aimlapiTestServer("aimlapi", aimlapi.MCPServerURL)
server.Headers = tc.headers
server.ProjectConfigured = tc.project
hint := aimlapiUnauthorizedHint(server, http.StatusUnauthorized)
if strings.Contains(hint, "zero providers setup aimlapi") != tc.wantSetup || !strings.Contains(hint, "zero mcp oauth login aimlapi") {
t.Fatalf("incorrect hint: %s", hint)
}
})
}
}
// net/http copies the first request's headers onto every redirect hop without
// calling the header hooks, so the redirect guard has to apply the per-request
// rule again: a same-origin hop that fails the exact-URL rule (userinfo) gets
// neither the key nor attribution, an eligible hop gets a freshly resolved key,
// and a user's own Authorization header still follows as before.
func TestAIMLAPIMCPRedirectHopsReapplyAutomaticHeaders(t *testing.T) {
isolateAIMLAPIKeyLookup(t, "")
userHeader := aimlapiTestServer("aimlapi", aimlapi.MCPServerURL)
userHeader.Headers = map[string]string{"authorization": "Bearer user-token"}
for _, tc := range []struct {
name string
server Server
location string
wantFirst string
wantHop string
wantAttribution bool
}{
{"userinfo hop", aimlapiTestServer("aimlapi", aimlapi.MCPServerURL), "https://[email protected]/mcp", "Bearer aiml-first", "", false},
{"eligible hop", aimlapiTestServer("aimlapi", aimlapi.MCPServerURL), "https://mcp.aimlapi.com/mcp/", "Bearer aiml-first", "Bearer aiml-second", true},
{"user header hop", userHeader, "https://[email protected]/mcp", "Bearer user-token", "Bearer user-token", false},
} {
t.Run(tc.name, func(t *testing.T) {
keys := []string{"aiml-first", "aiml-second"}
previous := loadAIMLAPIServiceKey
loadAIMLAPIServiceKey = func() (string, error) {
key := keys[0]
if len(keys) > 1 {
keys = keys[1:]
}
return key, nil
}
t.Cleanup(func() { loadAIMLAPIServiceKey = previous })
recorder := &aimlapiRecorder{redirectFirstTo: tc.location}
client := newRecordedNetworkClient(tc.server, recorder)
if err := client.initialize(context.Background()); err != nil {
t.Fatalf("initialize: %v", err)
}
requests := recorder.recorded()
if len(requests) < 2 || recorder.urls[1] != tc.location {
t.Fatalf("redirect was not followed: %v", recorder.urls)
}
if got := requests[0].Get("Authorization"); got != tc.wantFirst {
t.Fatalf("first hop Authorization = %q, want %q", got, tc.wantFirst)
}
hop := requests[1]
// With no key, net/http fills Basic auth from the hop's own userinfo;
// only the automatic key must be absent.
if got := hop.Get("Authorization"); (tc.wantHop == "" && strings.Contains(got, "aiml-")) || (tc.wantHop != "" && got != tc.wantHop) {
t.Fatalf("redirect hop Authorization = %q, want %q", got, tc.wantHop)
}
if tc.wantAttribution {
assertAIMLAPIAttribution(t, hop)
} else if hop.Get(aimlapi.SourceHeaderName) != "" || hop.Get(aimlapi.PartnerHeaderName) != "" {
t.Fatalf("redirect hop kept attribution headers: %v", hop)
}
})
}
}
// An error body can echo the key Zero injected; the body is left out of the
// error rather than matched, so a key cut by the 1024-byte read limit cannot
// leak its prefix either. Requests without the automatic key keep the detail.
func TestAIMLAPIMCPStatusErrorOmitsBodyWhenKeyWasInjected(t *testing.T) {
const key = "sk-echo-0123456789abcdef"
isolateAIMLAPIKeyLookup(t, key)
for _, tc := range []struct {
name string
body string
}{
{"echoed key", `{"error":"invalid credential ` + key + `"}`},
{"key across the read limit", strings.Repeat("x", 1020) + key},
} {
t.Run(tc.name, func(t *testing.T) {
recorder := &aimlapiRecorder{failStatus: http.StatusPaymentRequired, failBody: tc.body}
err := newRecordedNetworkClient(aimlapiTestServer("aimlapi", aimlapi.MCPServerURL), recorder).initialize(context.Background())
if err == nil {
t.Fatal("initialize succeeded against a failing server")
}
if got := recorder.recorded()[0].Get("Authorization"); got != "Bearer "+key {
t.Fatalf("request did not carry the injected key: %q", got)
}
if strings.Contains(err.Error(), key[:4]) || !strings.Contains(err.Error(), "HTTP 402") {
t.Fatalf("error = %q, want HTTP 402 without the echoed key", err)
}
})
}
userHeader := aimlapiTestServer("aimlapi", aimlapi.MCPServerURL)
userHeader.Headers = map[string]string{"Authorization": "Bearer user-token"}
recorder := &aimlapiRecorder{failStatus: http.StatusPaymentRequired, failBody: `{"error":"insufficient balance"}`}
err := newRecordedNetworkClient(userHeader, recorder).initialize(context.Background())
if err == nil || !strings.Contains(err.Error(), "insufficient balance") {
t.Fatalf("error without the automatic key = %v, want the response detail", err)
}
}
+3 -160
View File
@@ -54,56 +54,6 @@ type ToolClient interface {
Close() error
}
// startupDisclosureError carries a launch disclosure out through a failure.
//
// A launched process is a fact about the past: once Start has succeeded the
// disclosure is true whatever the handshake does next. The client is the only
// thing that holds it, and the failure paths close and discard the client, so
// without this the fact dies with the connection it was attached to.
type startupDisclosureError struct {
err error
notices []string
}
func (e *startupDisclosureError) Error() string { return e.err.Error() }
func (e *startupDisclosureError) Unwrap() error { return e.err }
// startupNoticesFromError recovers a launch disclosure from a failed connect.
func startupNoticesFromError(err error) []string {
var disclosure *startupDisclosureError
if errors.As(err, &disclosure) {
return disclosure.notices
}
return nil
}
// startupDisclosing is the optional interface a client implements when its
// LAUNCH carried a least-privilege disclosure. A network server launches no
// local process, so it does not implement this and reports nothing, which is the
// correct answer rather than an empty one.
type startupDisclosing interface {
StartupNotices() []string
}
// StartupNotices reports the disclosures that applied to this server's launch.
//
// GATED ON THE SAME DECISION AS EVERY OTHER CARRIER. connectAndList reads this on
// the success path and hands the result straight to the disclosure sources, so
// for a while a successfully connected wrapped server disclosed on the strength
// of cmd.Start returning: the HELPER starting, which is the one thing the report
// exists because it does not prove. It happened to be right, since a completed
// handshake does imply the child ran, but by coincidence rather than by rule, and
// the failure path next to it was already asking the adapter.
func (client *Client) StartupNotices() []string {
if client == nil || len(client.startupNotices) == 0 {
return nil
}
if client.launched != nil && !client.launched() {
return nil
}
return append([]string(nil), client.startupNotices...)
}
type Client struct {
server Server
cmd *exec.Cmd
@@ -114,20 +64,6 @@ type Client struct {
idMu sync.Mutex
nextID int
cleanup func()
// startupNotices are the least-privilege disclosures that applied to THIS
// server's launch.
//
// THE FACT DESCRIBES STARTUP, SO IT CANNOT BE RECOVERED FROM A TOOL RESULT.
// A stdio server prepared under the weakened token runs for the whole session,
// and connectStdio used to keep only the command and its cleanup, so nothing
// downstream could tell the operator that the process serving these tools had
// reduced write confinement. Kept typed here and rendered exactly once at
// registration rather than pasted onto every later tool result.
startupNotices []string
// launched is the launch decision this server's disclosures are gated on, nil
// for a plan whose started process IS the server and where there is nothing to
// decide. See internal/execution/child_launch.go.
launched func() bool
writeMu sync.Mutex
writeQueue chan writeOp
@@ -233,11 +169,6 @@ func (b *boundedBuffer) String() string {
func connectStdio(ctx context.Context, server Server, options ConnectOptions) (*Client, error) {
var cmd *exec.Cmd
var cleanup func()
var plannedEnforcement execution.Enforcement
// Retained from the prepared plan rather than dropped: for a wrapped plan the
// adapter, not cmd.Start, owns whether the requested server process exists.
var launchTracker *execution.ChildLaunchTracker
var ownedLaunch bool
cleanupTransferred := false
defer func() {
if cleanup != nil && !cleanupTransferred {
@@ -261,11 +192,7 @@ func connectStdio(ctx context.Context, server Server, options ConnectOptions) (*
return nil, fmt.Errorf("start MCP server %s: %w", server.Name, err)
}
cmd = prepared.Command
plannedEnforcement = prepared.Enforcement
ownedLaunch = prepared.ChildLaunchOwnedByAdapter
// The tracker's cleanup, not the plan's: it settles the launch decision
// before the report file it was read from is deleted.
launchTracker, cleanup = execution.NewChildLaunchTracker(prepared)
cleanup = prepared.Cleanup
} else {
cmd = exec.CommandContext(ctx, server.Command, server.Args...)
cmd.Env = mergeProcessEnv(server.Env)
@@ -284,55 +211,6 @@ func connectStdio(ctx context.Context, server Server, options ConnectOptions) (*
return nil, fmt.Errorf("start MCP server %s: %w", server.Name, err)
}
// PUBLISHED AT START, not at return. Registration abandons a server that
// exceeds the connect timeout, and everything below this line (initialize, and
// tools/list above it in the caller) can hang past that. Announcing the launch
// here is what lets an abandoned attempt still disclose the confinement its
// process ran under.
//
// EXCEPT WHEN START IS NOT THE LAUNCH. For a wrapped plan the process started
// above is the sandbox helper, which creates the MCP server only after marker,
// ACL, network, SID and token setup, any of which can fail leaving no server at
// all. Publishing here would make the durable delivery machinery reliably
// announce a confinement nothing ever ran under. Those plans publish from
// publishAdapterLaunch below, once the adapter has stated the fact.
if !ownedLaunch {
publishLaunch(ctx, plannedEnforcement.Notices)
}
// ONE ANSWER, AND IT IS ONLY CACHED ONCE IT CANNOT CHANGE.
//
// The adapter's report is a file the plan's cleanup deletes, so a decision made
// after cleanup used to read an absent report and answer "no child" about a
// server that really did run. Memoizing the first read fixed that ordering and
// introduced another: the read can land while the adapter has created the child
// and not yet recorded it, and "not yet" got frozen as "never".
//
// ChildLaunchTracker holds the three states apart. It caches a launch, never an
// absence, until the adapter is terminal, and it settles from cleanup with the
// report still on disk. See internal/execution/child_launch.go.
//
// It also gives the success, late and failed paths the same input. The failure
// path used to carry client.StartupNotices() unconditionally while the sink was
// gated on the adapter, which is two competing definitions of applied
// enforcement: an operator was told a server ran without write confinement when
// only the sandbox helper ran and the requested server never existed.
launchedOnce := func() bool {
if !ownedLaunch {
return true
}
return launchTracker.Launched()
}
// publishAdapterLaunch announces a wrapped plan's launch, but only if the
// adapter confirms the requested child was created. Called on both ways this
// attempt can end, which is also where an attempt abandoned at the connect
// timeout eventually arrives, so a late disclosure is still delivered once.
publishAdapterLaunch := func() {
if !ownedLaunch || !launchedOnce() {
return
}
publishLaunch(ctx, plannedEnforcement.Notices)
}
client := &Client{
server: server,
cmd: cmd,
@@ -341,51 +219,16 @@ func connectStdio(ctx context.Context, server Server, options ConnectOptions) (*
writer: newMessageWriter(stdin),
nextID: 1,
cleanup: cleanup,
// Recorded only now, AFTER Start returned. Everything above returns early,
// so a prepare failure or an executable that could not be launched records
// nothing: same launch-state rule hooks and plugins use, expressed by where
// this assignment sits rather than by another outcome-kind switch.
startupNotices: append([]string(nil), plannedEnforcement.Notices...),
launched: launchedOnce,
}
cleanupTransferred = true
if err := client.initialize(ctx); err != nil {
// THE LAUNCH ALREADY HAPPENED, so the fact has to leave through the error.
// Start succeeded above, which means the process ran under the planned token
// and may have done filesystem work before the handshake failed. Returning a
// bare error discards the client, and with it the only carrier the notices
// had, so the operator was told the server was unavailable and not that it
// had already run without the write jail.
// AFTER Close, which waits out the adapter and then settles the decision
// with the report still on disk. Asking first would ask an adapter that may
// be between creating the child and recording it, and read "not yet" as
// "never": the connect timeout ends the attempt here while the helper is
// still working.
_ = client.Close()
launched := launchedOnce()
publishAdapterLaunch()
message := strings.TrimSpace(stderr.String())
failure := fmt.Errorf("initialize MCP server %s: %w", server.Name, err)
if message != "" {
failure = fmt.Errorf("initialize MCP server %s: %w: %s", server.Name, err, message)
return nil, fmt.Errorf("initialize MCP server %s: %w: %s", server.Name, err, message)
}
// Same decision as the sink above. For a wrapped plan whose helper started
// and then failed before creating the requested server, there is nothing to
// disclose: no server ran, confined or otherwise.
var carried []string
if launched {
carried = client.StartupNotices()
}
return nil, &startupDisclosureError{err: failure, notices: carried}
return nil, fmt.Errorf("initialize MCP server %s: %w", server.Name, err)
}
// THE HANDSHAKE IS THE OBSERVATION. A wrapped plan's adapter speaks no MCP and
// creates the child suspended, so a well-formed initialize response can only
// have come from the requested server, already running. That is terminal
// evidence and does not depend on the report having been read yet, which is
// what keeps a long-lived session from having to wait for an adapter that will
// not exit until the session ends.
launchTracker.Confirm()
publishAdapterLaunch()
return client, nil
}
@@ -1,105 +0,0 @@
package mcp
import (
"bytes"
"context"
"strings"
"testing"
"github.com/Twigpine/zero/internal/tools"
)
// A MODEL-FACING PROTOCOL BOUNDARY IS A PRESENTATION CONSUMER.
//
// This branch changed the result contract: Result.Output holds the UNDECORATED
// base text and ModelOutput is the sole model-facing projection that composes it
// with the typed enforcement notices. tools/call serialized Output directly,
// which was a complete value before and is not one now, so an affected Windows
// command reached an MCP client with its ordinary output and no statement that
// its DenyRead token shape left writes unconfined.
//
// Driven through Serve rather than the accessor, because the question is what
// goes on the wire.
func TestMCPToolsCallCarriesTheEnforcementNotice(t *testing.T) {
const notice = "denyRead is configured, so the write jail is not confining writes"
const output = "ran the command"
for _, testCase := range []struct {
name string
result tools.Result
wantText string
wantIsErr bool
wantNotice bool
}{
{
name: "successful command with a notice",
result: tools.Result{Status: tools.StatusOK, Output: output, EnforcementNotices: []string{notice}},
wantIsErr: false,
wantNotice: true,
},
{
name: "failed command with a notice",
result: tools.Result{Status: tools.StatusError, Output: output, EnforcementNotices: []string{notice}},
wantIsErr: true,
wantNotice: true,
},
{
name: "ordinary command with no notice",
result: tools.Result{Status: tools.StatusOK, Output: output},
wantIsErr: false,
wantNotice: false,
},
} {
t.Run(testCase.name, func(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(serverFakeTool{
name: "run_thing",
description: "runs a thing",
parameters: tools.Schema{Type: "object", AdditionalProperties: false},
safety: tools.Safety{SideEffect: tools.SideEffectRead, Permission: tools.PermissionAllow, Reason: "test"},
run: func(map[string]any) tools.Result { return testCase.result },
})
var input bytes.Buffer
writeServerTestMessage(t, &input, rpcMessage{ID: 1, Method: "initialize"})
writeServerTestMessage(t, &input, rpcMessage{Method: "notifications/initialized"})
writeServerTestMessage(t, &input, rpcMessage{
ID: 2,
Method: "tools/call",
Params: mustRaw(map[string]any{"name": "run_thing", "arguments": map[string]any{}}),
})
var out bytes.Buffer
if err := Serve(context.Background(), &input, &out, registry, ServeOptions{Name: "zero-test", Version: "1.2.3"}); err != nil {
t.Fatalf("Serve() error = %v", err)
}
reader := newMessageReader(&out)
readServerTestMessage(t, reader) // initialize
var call CallToolResult
decodeServerTestResult(t, readServerTestMessage(t, reader), &call)
if len(call.Content) != 1 || call.Content[0].Type != "text" {
t.Fatalf("content shape changed: %#v", call.Content)
}
text := call.Content[0].Text
if call.IsError != testCase.wantIsErr {
t.Errorf("IsError = %v, want %v", call.IsError, testCase.wantIsErr)
}
if count := strings.Count(text, output); count != 1 {
t.Errorf("the command's own output appears %d times, want exactly 1: %q", count, text)
}
gotNotice := strings.Count(text, notice)
if testCase.wantNotice && gotNotice != 1 {
t.Errorf("the disclosure appears %d times, want exactly 1: %q", gotNotice, text)
}
if !testCase.wantNotice {
if gotNotice != 0 {
t.Errorf("a disclosure appeared for a command that had none: %q", text)
}
if text != output {
t.Errorf("ordinary output was altered: got %q, want %q", text, output)
}
}
})
}
}
-118
View File
@@ -1,118 +0,0 @@
package mcp
import (
"context"
"sync"
)
// launchSink carries the fact that a server's PROCESS STARTED out of the connect
// attempt, without waiting for that attempt to finish.
//
// The startup notices used to leave connectStdio only on the returned client, or
// on the returned error. Both require the attempt to return. Registration
// abandons a server that exceeds the connect timeout, so a server that started
// under the reduced write confinement and then hung in initialize or tools/list
// was recorded as skipped with nothing said about the confinement it ran under.
// The reaper that later collects the abandoned attempt runs after the serial
// commit phase has finished, so it cannot contribute without breaking the
// deterministic ordering that phase exists to provide.
//
// Publishing at Start splits the two facts apart, which is the point: launch and
// connection usability have different lifetimes. A sink that was never published
// to means Start never happened, so prepare, pipe, and Start failures stay silent
// exactly as before.
//
// IT IS ALSO AN EVENT, NOT ONLY A VALUE. A retained sink that nobody re-reads is
// still a lost disclosure: both production reporters sample once, immediately
// after registration returns, and a Start that completes after that sample had
// no way to reach them. onPublish lets a reporter subscribe; if the launch has
// already happened by the time it subscribes, it is told at once, so the fact
// reaches exactly one presentation regardless of which side won the race.
type launchSink struct {
mu sync.Mutex
launched bool
notices []string
onPublish func(notices []string)
delivered bool
}
type launchSinkKey struct{}
// withLaunchSink attaches a sink to the context handed to the client factory.
// Carried on the context rather than added to the factory signature so an
// injected or third-party factory that knows nothing about it still works, and
// simply discloses nothing.
func withLaunchSink(ctx context.Context, sink *launchSink) context.Context {
return context.WithValue(ctx, launchSinkKey{}, sink)
}
// publishLaunch records that the process for this connect attempt has started,
// along with the enforcement notices that applied to it. Safe on a context with
// no sink, which is every caller outside registration.
func publishLaunch(ctx context.Context, notices []string) {
sink, _ := ctx.Value(launchSinkKey{}).(*launchSink)
if sink == nil {
return
}
sink.mu.Lock()
sink.launched = true
sink.notices = append([]string(nil), notices...)
deliver := sink.pendingDeliveryLocked()
sink.mu.Unlock()
if deliver != nil {
deliver()
}
}
// PublishLaunchForTest is publishLaunch for a test in another package that
// injects a client factory and needs to mark its fake process as started. It
// is the same function with the same context lookup, so a test exercises the
// real sink rather than a stand-in, and it is inert on any context that did
// not come through registration.
func PublishLaunchForTest(ctx context.Context, notices []string) {
publishLaunch(ctx, notices)
}
// observe reports whether Start was reached and what applied to it. Read from
// the registration goroutine while the connect goroutine may still be running,
// hence the lock.
func (sink *launchSink) observe() (bool, []string) {
if sink == nil {
return false, nil
}
sink.mu.Lock()
defer sink.mu.Unlock()
return sink.launched, append([]string(nil), sink.notices...)
}
// subscribe registers the one presentation this launch should reach. If the
// launch already happened, fn runs before subscribe returns; otherwise it runs
// from publishLaunch. Either way it runs at most once, and a second subscriber
// replaces nothing: the first delivery is the only delivery.
func (sink *launchSink) subscribe(fn func(notices []string)) {
if sink == nil || fn == nil {
return
}
sink.mu.Lock()
if sink.onPublish == nil {
sink.onPublish = fn
}
deliver := sink.pendingDeliveryLocked()
sink.mu.Unlock()
if deliver != nil {
deliver()
}
}
// pendingDeliveryLocked returns the delivery to perform, or nil, and marks it
// done. Called with mu held; the returned closure must be invoked with mu
// released, since a subscriber may itself take other locks.
func (sink *launchSink) pendingDeliveryLocked() func() {
if !sink.launched || sink.onPublish == nil || sink.delivered {
return nil
}
sink.delivered = true
fn := sink.onPublish
notices := append([]string(nil), sink.notices...)
return func() { fn(notices) }
}
@@ -1,199 +0,0 @@
package mcp
import (
"context"
"errors"
"testing"
"time"
"github.com/Twigpine/zero/internal/config"
"github.com/Twigpine/zero/internal/tools"
)
const launchNotice = "denyRead is configured, so the Windows sandbox uses the token shape without WRITE_RESTRICTED (#869)"
func registerWithFactory(t *testing.T, factory func(context.Context, Server) (ToolClient, error)) *Runtime {
t.Helper()
runtime, err := RegisterTools(context.Background(), tools.NewRegistry(), config.MCPConfig{Servers: map[string]config.MCPServerConfig{
"slow": {Type: "stdio", Command: "slow-mcp"},
}}, RegisterOptions{
ConnectTimeout: 50 * time.Millisecond,
ClientFactory: factory,
})
if err != nil {
t.Fatalf("RegisterTools error: %v", err)
}
t.Cleanup(func() { _ = runtime.Close() })
return runtime
}
// A SERVER THAT STARTED AND THEN HUNG STILL RAN UNDER THE REDUCED TOKEN.
//
// Registration abandons a server that exceeds the connect timeout and records it
// as skipped. The startup notices used to leave connectStdio only on the returned
// client or the returned error, and the abandoned attempt returns neither before
// the serial commit phase is over, so the process ran with reduced write
// confinement and startup said only that the server was skipped.
func TestTimeoutAfterLaunchKeepsTheStartupDisclosure(t *testing.T) {
runtime := registerWithFactory(t, func(ctx context.Context, server Server) (ToolClient, error) {
// The process started under the reduced token, then initialize hangs.
publishLaunch(ctx, []string{launchNotice})
<-ctx.Done()
return nil, ctx.Err()
})
disclosures := runtime.StartupDisclosures()
if len(disclosures) == 0 {
t.Fatal("a server that started and then timed out disclosed nothing, so it ran under reduced write confinement unannounced")
}
if disclosures[0].Name != "slow" || len(disclosures[0].Notices) != 1 || disclosures[0].Notices[0] != launchNotice {
t.Errorf("StartupDisclosures() = %#v, want one entry for slow carrying the launch notice", disclosures)
}
if skipped := runtime.Skipped(); len(skipped) != 1 || skipped[0].Name != "slow" {
t.Errorf("the server should still be recorded as skipped: %#v", skipped)
}
}
// AND A TIMEOUT BEFORE LAUNCH STAYS SILENT.
//
// Without this, retaining the disclosure on timeout could be satisfied by
// disclosing on every timeout, which would claim a token trade for a process
// that was never created.
func TestTimeoutBeforeLaunchDisclosesNothing(t *testing.T) {
runtime := registerWithFactory(t, func(ctx context.Context, server Server) (ToolClient, error) {
// Never reached Start: no publish.
<-ctx.Done()
return nil, ctx.Err()
})
if disclosures := runtime.StartupDisclosures(); len(disclosures) != 0 {
t.Errorf("a server that never started claimed a token trade: %#v", disclosures)
}
if skipped := runtime.Skipped(); len(skipped) != 1 {
t.Errorf("the server should still be recorded as skipped: %#v", skipped)
}
}
// THE PUBLISH HAS TO SIT AFTER Start, AND ONLY A REAL LAUNCH PROVES IT.
//
// The two tests above inject a factory, so they exercise the registry's handling
// of the sink and say nothing about where connectStdio publishes to it. Moving
// the call one line up, above cmd.Start, leaves both of them green while every
// failed launch starts claiming the token trade. This one drives the real
// connectStdio with a command that cannot start.
func TestAFailedStartPublishesNoLaunch(t *testing.T) {
sink := &launchSink{}
ctx := withLaunchSink(context.Background(), sink)
client, err := connectStdio(ctx, Server{
Name: "missing",
Type: "stdio",
Command: "zero-nonexistent-mcp-binary-for-test",
}, ConnectOptions{})
if err == nil {
if client != nil {
_ = client.Close()
}
t.Fatal("expected a nonexistent executable to fail to start")
}
if launched, notices := sink.observe(); launched {
t.Errorf("a server whose process never started was published as launched (notices %#v)", notices)
}
}
// A START THAT COMPLETES JUST AFTER THE TIMEOUT MUST STILL BE DISCLOSED.
//
// connectStdio publishes only once cmd.Start has returned, and the timeout
// branch used to sample the sink the instant it fired. Those interleave: the
// sample reads empty, the result commits with no notice, and the reaper closes
// the late client without being able to amend a commit that already happened.
//
// The real window is microseconds wide, so this drives the CONTRACT instead:
// the attempt starts after the registration timeout has elapsed but inside the
// settle grace, which is the case the synchronization exists to catch.
func TestStartJustAfterTheTimeoutIsStillDisclosed(t *testing.T) {
runtime := registerWithFactory(t, func(ctx context.Context, server Server) (ToolClient, error) {
// 50ms registration timeout has fired; this lands inside launchSettleGrace.
time.Sleep(120 * time.Millisecond)
publishLaunch(ctx, []string{launchNotice})
return nil, errors.New("initialize failed after start")
})
disclosures := runtime.StartupDisclosures()
if len(disclosures) != 1 || len(disclosures[0].Notices) != 1 || disclosures[0].Notices[0] != launchNotice {
t.Fatalf("a process that started just after the timeout was not disclosed: %#v", disclosures)
}
if skipped := runtime.Skipped(); len(skipped) != 1 {
t.Errorf("the server should still be recorded as skipped: %#v", skipped)
}
}
// And an attempt that never starts is not held for the grace, nor disclosed.
func TestTimeoutBeforeStartIsNotDelayedOrDisclosed(t *testing.T) {
start := time.Now()
runtime := registerWithFactory(t, func(ctx context.Context, server Server) (ToolClient, error) {
<-ctx.Done() // cancel arrives with the timeout; returns immediately
return nil, ctx.Err()
})
elapsed := time.Since(start)
if disclosures := runtime.StartupDisclosures(); len(disclosures) != 0 {
t.Errorf("a server that never started claimed a token trade: %#v", disclosures)
}
// The grace is 250ms; an attempt that returns on cancel must not pay it.
if elapsed > 200*time.Millisecond {
t.Errorf("registration waited %v for an attempt that never started", elapsed)
}
}
// AND A START THAT COMPLETES AFTER THE SETTLE GRACE MUST STILL BE DISCLOSED.
//
// The grace is only another timeout. Once it expires, registration reaps the
// attempt in the background and returns; the process can still be inside
// cmd.Start at that moment and start successfully afterwards. If Runtime were a
// snapshot taken at commit time, that launch would have no owner: the reaper can
// close the late client but cannot amend a value already returned, so the server
// would have run under the reduced write confinement with startup reporting it
// only as skipped.
//
// A larger grace changes the probability, not the contract, which is why this
// releases the launch strictly AFTER the bound rather than inside it. The sink
// outlives registration and StartupDisclosures reads through it.
func TestStartAfterTheSettleGraceIsStillDisclosed(t *testing.T) {
released := make(chan struct{})
runtime := registerWithFactory(t, func(ctx context.Context, server Server) (ToolClient, error) {
// Held past the 50ms registration timeout AND past launchSettleGrace, so
// registration has already reaped this attempt and returned.
<-released
publishLaunch(ctx, []string{launchNotice})
return nil, errors.New("initialize failed long after start")
})
// Registration is done and the disclosure legitimately is not known yet.
if disclosures := runtime.StartupDisclosures(); len(disclosures) != 0 {
t.Fatalf("nothing had started yet, so nothing should be disclosed: %#v", disclosures)
}
close(released)
deadline := time.Now().Add(2 * time.Second)
var disclosures []StartupDisclosure
for time.Now().Before(deadline) {
if disclosures = runtime.StartupDisclosures(); len(disclosures) > 0 {
break
}
time.Sleep(5 * time.Millisecond)
}
if len(disclosures) != 1 || len(disclosures[0].Notices) != 1 || disclosures[0].Notices[0] != launchNotice {
t.Fatalf("a process that started after the settle grace was never disclosed: %#v", disclosures)
}
// Reading again must not duplicate it.
if again := runtime.StartupDisclosures(); len(again) != 1 {
t.Errorf("a second read changed the disclosures: %#v", again)
}
if skipped := runtime.Skipped(); len(skipped) != 1 {
t.Errorf("the server should still be recorded as skipped: %#v", skipped)
}
}
+11 -2
View File
@@ -427,6 +427,7 @@ func (client *networkClient) applyHeaders(request *http.Request) {
if client.sessionID != "" {
request.Header.Set("Mcp-Session-Id", client.sessionID)
}
applyAIMLAPIRequestHeaders(client.server, request)
}
func (client *remoteSSEClient) applyStreamHeaders(request *http.Request) {
@@ -434,6 +435,7 @@ func (client *remoteSSEClient) applyStreamHeaders(request *http.Request) {
request.Header.Set(key, value)
}
request.Header.Set("Accept", "text/event-stream")
applyAIMLAPIRequestHeaders(client.server, request)
}
func (client *remoteSSEClient) applyPostHeaders(request *http.Request) {
@@ -442,6 +444,7 @@ func (client *remoteSSEClient) applyPostHeaders(request *http.Request) {
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Accept", "application/json")
applyAIMLAPIRequestHeaders(client.server, request)
}
func closeResponseBody(errp *error, server Server, body io.Closer) {
@@ -607,12 +610,18 @@ func (client *remoteSSEClient) failPending(err error) {
}
func httpStatusError(server Server, response *http.Response) error {
hint := aimlapiUnauthorizedHint(server, response.StatusCode)
if sentAIMLAPIServiceKey(server, response.Request) {
// The body is server text that may echo the key Zero injected, whole or
// cut by the read limit, so it is left out rather than matched.
return fmt.Errorf("MCP %s server %s returned HTTP %d (response body omitted: the request carried your AI/ML API key)%s", server.Type, server.Name, response.StatusCode, hint)
}
body, _ := io.ReadAll(io.LimitReader(response.Body, 1024))
detail := strings.TrimSpace(string(body))
if detail == "" {
return fmt.Errorf("MCP %s server %s returned HTTP %d", server.Type, server.Name, response.StatusCode)
return fmt.Errorf("MCP %s server %s returned HTTP %d%s", server.Type, server.Name, response.StatusCode, hint)
}
return fmt.Errorf("MCP %s server %s returned HTTP %d: %s", server.Type, server.Name, response.StatusCode, detail)
return fmt.Errorf("MCP %s server %s returned HTTP %d: %s%s", server.Type, server.Name, response.StatusCode, detail, hint)
}
func decodeSSERPCMessage(reader io.Reader) (rpcMessage, error) {
+1
View File
@@ -28,6 +28,7 @@ func checkMCPRedirect(server Server) func(*http.Request, []*http.Request) error
if !sameMCPOrigin(via[0].URL, request.URL) {
return fmt.Errorf("MCP %s server %s refused cross-origin redirect to %s", server.Type, server.Name, mcpOrigin(request.URL))
}
reapplyAIMLAPIRedirectHeaders(server, request, via[0])
return nil
}
}
+15 -260
View File
@@ -18,11 +18,6 @@ import (
// so a slow or unreachable server (e.g. a hosted endpoint blocked by the local
// network) cannot delay the first model response. Servers connect concurrently,
// so total startup cost is the slowest reachable server, not the sum.
// launchSettleGrace bounds how long an abandoned connect attempt is given to
// say whether it had already started. It is paid only after cancel, so an
// attempt that never reached Start returns well inside it.
const launchSettleGrace = 250 * time.Millisecond
const defaultConnectTimeout = 8 * time.Second
type RegisterOptions struct {
@@ -52,18 +47,6 @@ type SkippedServer struct {
UnconfiguredDefault bool
}
// StartupDisclosure is a least-privilege statement about one MCP server's
// LAUNCH, as opposed to anything a later tool call does.
//
// A stdio server prepared under a weakened token serves the whole session from
// that process, so the fact describes startup and cannot be recovered from an
// individual tool result afterwards. It is reported once, here, rather than
// appended to every response the server produces.
type StartupDisclosure struct {
Name string
Notices []string
}
type Runtime struct {
clients []ToolClient
// cancels releases the per-server connect contexts of the clients we KEPT.
@@ -72,83 +55,8 @@ type Runtime struct {
// is closed). Same length/order as clients is not required.
cancels []context.CancelFunc
skipped []SkippedServer
// disclosureSources are the least-privilege statements that applied to each
// server process this registration LAUNCHED, in server order, each still
// holding the sink that carries the authoritative launch fact.
//
// NOT a frozen snapshot. Registration is bounded and a launch is not: an
// attempt abandoned at the connect timeout can still be inside cmd.Start when
// wg.Wait returns, so the serial commit samples an empty sink and the process
// then starts under the reduced confinement with nobody left to say so. The
// sink outlives registration and StartupDisclosures reads through it, so a
// late Start is reported instead of lost. See StartupDisclosures.
disclosureSources []disclosureSource
// disclosureStream is the typed hand-off to whoever owns the output. Created
// on the first StartupDisclosureStream call and closed by Close, so a launch
// that resolves after the runtime is gone has somewhere defined to land:
// nowhere.
disclosureStreamOnce sync.Once
disclosureStream *StartupDisclosureStream
once sync.Once
err error
}
// disclosureSource pairs a server with both the notices known at commit time and
// the sink that may still learn them. notices wins when it is already populated,
// so a settled server never re-reads the sink.
type disclosureSource struct {
name string
notices []string
sink *launchSink
}
// ReportStartupDisclosures delivers each server's launch disclosure to report
// EXACTLY ONCE, whether the launch had already completed when this was called
// or completes later.
//
// StartupDisclosures reads through the sink, which stopped a late Start from
// being lost, but a value nobody re-reads is still a lost disclosure: both
// production reporters sample once, right after RegisterTools returns, and an
// attempt abandoned at the connect timeout can finish Start after that sample.
// The reaper only closes the late client. So the operator saw the skipped-server
// warning and never learned that a local process had run under the reduced
// enforcement.
//
// Servers whose notices were known at commit are reported now, in server order.
// Every other server subscribes its sink: if the launch already happened the
// subscriber runs before this returns, otherwise it runs from publishLaunch on
// the connect goroutine. Either way each server reaches report once. A server
// that never starts never publishes, so prepare, pipe and Start failures stay
// silent, and network servers, which launch no process, contribute nothing.
//
// Late deliveries arrive in completion order, which is the only order they
// have; the immediate set keeps server order.
//
// A STREAM, NOT A CALLBACK. An earlier version took the presentation function
// and invoked it from whichever goroutine resolved the launch, which for an
// abandoned attempt is the connect goroutine. That put a write to the caller's
// writer on a goroutine and at a time the caller did not control. The runtime
// owns the fact; it appends the fact here and the owner drains it. See
// StartupDisclosureStream.
func (runtime *Runtime) StartupDisclosureStream() *StartupDisclosureStream {
if runtime == nil {
return nil
}
runtime.disclosureStreamOnce.Do(func() {
stream := newStartupDisclosureStream()
runtime.disclosureStream = stream
for _, source := range runtime.disclosureSources {
if len(source.notices) > 0 {
stream.offer(StartupDisclosure{Name: source.name, Notices: append([]string(nil), source.notices...)})
continue
}
name := source.name
source.sink.subscribe(func(notices []string) {
stream.offer(StartupDisclosure{Name: name, Notices: notices})
})
}
})
return runtime.disclosureStream
once sync.Once
err error
}
// Skipped returns the servers that were skipped during registration (unreachable
@@ -202,38 +110,19 @@ func RegisterTools(ctx context.Context, registry *tools.Registry, cfg config.MCP
remote []RemoteTool
cancel context.CancelFunc
err error
// notices travels with the indexed result rather than being appended to
// shared state from inside the goroutine. The concurrent phase touches no
// shared state, which is the property the comment above promises and the
// reason the serial phase can be deterministic; appending here broke both,
// racing the slice header and ordering disclosures by completion time.
notices []string
}
results := make([]connectResult, len(servers))
// RETAINED PAST THE CONCURRENT PHASE. The timeout branch samples the sink the
// moment it fires, but connectStdio does not publish until cmd.Start has
// returned, so a Start that succeeds just after the timeout selected was
// sampled as "never launched" and its disclosure was lost: the reaper closes
// the late client and cannot amend a commit that has already happened.
// Reading the sink again in the serial phase is strictly later than the
// timeout branch and still deterministic, because it runs after wg.Wait.
sinks := make([]*launchSink, len(servers))
var wg sync.WaitGroup
for index := range servers {
wg.Add(1)
go func(index int) {
defer wg.Done()
server := servers[index]
// The sink hears about Start as it happens, so an attempt abandoned below
// can still report the confinement its process ran under. The connect
// result cannot supply that: it does not arrive until after this phase.
sink := &launchSink{}
sinks[index] = sink
serverCtx, cancel := context.WithCancel(withLaunchSink(ctx, sink))
serverCtx, cancel := context.WithCancel(ctx)
done := make(chan connectResult, 1)
go func() {
client, remote, notices, err := connectAndList(serverCtx, factory, server)
done <- connectResult{client: client, remote: remote, notices: notices, err: err}
client, remote, err := connectAndList(serverCtx, factory, server)
done <- connectResult{client: client, remote: remote, err: err}
}()
select {
case res := <-done:
@@ -245,47 +134,14 @@ func RegisterTools(ctx context.Context, registry *tools.Registry, cfg config.MCP
results[index] = res
case <-time.After(timeout):
cancel() // abandon the slow connect: tears down the conn/subprocess
timedOut := connectResult{err: fmt.Errorf("connect timed out after %s", timeout)}
// SYNCHRONIZE WITH THE START, briefly, before deciding there was none.
//
// connectStdio publishes only after cmd.Start returns, so sampling the
// sink the instant the timeout fires races a Start that is about to
// succeed: the sample reads empty, the result commits with no notice,
// and the reaper cannot amend a commit that has already happened. The
// window is microseconds and unreachable from a test seam, which is
// exactly why it must be closed by construction rather than measured.
//
// cancel() has already fired, so an attempt that has NOT started fails
// fast and this returns immediately; only one that did start can still
// be in Start, and it publishes on the way out. The grace is therefore
// paid only when there is something to learn.
select {
case res := <-done:
if res.client != nil {
// Reap the goroutine + any partial client in the background so a
// slow server never blocks startup.
go func() {
if res := <-done; res.client != nil {
_ = res.client.Close()
}
if len(res.notices) > 0 {
timedOut.notices = res.notices
}
case <-time.After(launchSettleGrace):
// Still stuck past the grace. Reap in the background so a slow
// server never blocks startup.
go func() {
if res := <-done; res.client != nil {
_ = res.client.Close()
}
}()
}
// A server that reached Start ran under the planned enforcement even
// though its connection never became usable. One that timed out
// before Start discloses nothing, so the sink stays empty and this
// adds nothing.
if len(timedOut.notices) == 0 {
if launched, notices := sink.observe(); launched {
timedOut.notices = notices
}
}
results[index] = timedOut
}()
results[index] = connectResult{err: fmt.Errorf("connect timed out after %s", timeout)}
}
}(index)
}
@@ -301,28 +157,6 @@ func RegisterTools(ctx context.Context, registry *tools.Registry, cfg config.MCP
stagedNames := make(map[string]struct{})
for index, server := range servers {
res := results[index]
// Recorded here, in server order, for any server whose PROCESS STARTED,
// including one whose tools are rejected below: the launch happened under
// that token either way, and a skip warning does not say what confinement
// the process ran with while it was alive.
notices := res.notices
if len(notices) == 0 {
// A launch that published after the timeout branch sampled. Checked here
// rather than only there so the window between Start succeeding and the
// timeout committing cannot swallow the disclosure.
if launched, late := sinks[index].observe(); launched {
notices = late
}
}
// Recorded whether or not notices are known YET. An abandoned attempt can
// still be inside cmd.Start, and keeping its sink here is what lets
// StartupDisclosures report that launch after this phase has finished.
// Order is server order, so a late arrival does not reorder the rest.
runtime.disclosureSources = append(runtime.disclosureSources, disclosureSource{
name: server.Name,
notices: notices,
sink: sinks[index],
})
if res.err != nil {
runtime.skipped = append(runtime.skipped, SkippedServer{Name: server.Name, Err: res.err, UnconfiguredDefault: server.UnconfiguredDefault})
continue
@@ -356,42 +190,17 @@ func RegisterTools(ctx context.Context, registry *tools.Registry, cfg config.MCP
// connectAndList connects to one server and lists its tools. It does ONLY I/O
// (no registry, permission-store, or other shared state), so it is safe to run
// concurrently for every server. On a list error it closes the client.
// connectAndList returns the client, its tools, and the least-privilege
// disclosures that applied to its LAUNCH.
//
// THE LAUNCH FACT OUTLIVES THE CONNECTION. A stdio server can start, and do
// filesystem work, and then fail initialize or tools/list. Returning the notices
// separately rather than leaving them on the client means the fact survives that
// failure: the client is closed and discarded here, so anything reachable only
// through it is gone by the time the caller sees the error, and the skip warning
// on its own does not say the process already ran with reduced write
// confinement.
func connectAndList(ctx context.Context, factory func(context.Context, Server) (ToolClient, error), server Server) (ToolClient, []RemoteTool, []string, error) {
func connectAndList(ctx context.Context, factory func(context.Context, Server) (ToolClient, error), server Server) (ToolClient, []RemoteTool, error) {
client, err := factory(ctx, server)
if err != nil {
// A failure BEFORE the process started discloses nothing. A failure after it
// started carries the fact out through the error, because the client that
// held it has already been closed and discarded by then.
return nil, nil, startupNoticesFromError(err), err
return nil, nil, err
}
notices := startupNoticesOf(client)
remoteTools, err := client.ListTools(ctx)
if err != nil {
_ = client.Close()
return nil, nil, notices, fmt.Errorf("list MCP tools for %s: %w", server.Name, err)
return nil, nil, fmt.Errorf("list MCP tools for %s: %w", server.Name, err)
}
return client, remoteTools, notices, nil
}
// startupNoticesOf reads a client's launch disclosures, if it reports any.
func startupNoticesOf(client ToolClient) []string {
if client == nil {
return nil
}
if disclosing, ok := client.(startupDisclosing); ok {
return disclosing.StartupNotices()
}
return nil
return client, remoteTools, nil
}
// buildServerTools validates a server's remote tools against the registry and the
@@ -427,25 +236,6 @@ func (runtime *Runtime) Close() error {
return nil
}
runtime.once.Do(func() {
// End disclosure delivery FIRST. A launch that resolves while the clients
// are being closed has no owner left to print it, and the runtime must not
// leave a subscriber holding a writer whose lifetime it does not know.
//
// THROUGH THE ONCE, NOT THE FIELD. Reading disclosureStream directly raced
// a first StartupDisclosureStream call on another goroutine, and a Close
// that came before any call found nil and closed nothing, so a later call
// built a fresh, open stream and subscribed it to launches from a runtime
// that was already gone. Reported by CodeRabbit.
//
// If nobody asked for the stream before this, nobody is draining it, so it
// is published EMPTY rather than built the usual way: no sink is
// subscribed and nothing is queued for an owner that does not exist. Every
// later caller then gets this stream, already closed. The read after Do is
// ordered by the Once whichever call got there first.
runtime.disclosureStreamOnce.Do(func() {
runtime.disclosureStream = newStartupDisclosureStream()
})
runtime.disclosureStream.Close()
for _, client := range runtime.clients {
if err := client.Close(); err != nil && runtime.err == nil {
runtime.err = err
@@ -644,41 +434,6 @@ func isPersistentlyApproved(store *PermissionStore, server Server, toolName stri
return err == nil && approved
}
// StartupDisclosures returns the least-privilege statements that applied to the
// MCP server processes this registration launched, so a caller can report them
// once. Empty when no server was launched under reduced enforcement, and always
// empty for network servers, which launch no local process.
//
// READ THROUGH THE SINK, so this is not fixed at the moment registration
// returned. A server abandoned at the connect timeout may still have been inside
// cmd.Start then, and its process starts under the reduced write confinement
// regardless of whether the connection ever became usable. Registration stays
// bounded; the disclosure does not expire with it.
//
// Server order, and a settled entry never re-reads its sink, so calling this
// twice cannot reorder or duplicate anything.
func (runtime *Runtime) StartupDisclosures() []StartupDisclosure {
if runtime == nil {
return nil
}
disclosures := make([]StartupDisclosure, 0, len(runtime.disclosureSources))
for _, source := range runtime.disclosureSources {
notices := source.notices
if len(notices) == 0 {
if launched, late := source.sink.observe(); launched {
notices = late
}
}
if len(notices) > 0 {
disclosures = append(disclosures, StartupDisclosure{Name: source.name, Notices: notices})
}
}
if len(disclosures) == 0 {
return nil
}
return disclosures
}
// appendServerNote joins a [zero] note to output, choosing "returned" or
// "also returned" based on whether anything precedes it.
func appendServerNote(output, subject, tail string) string {
@@ -1,127 +0,0 @@
package mcp
import (
"context"
"sync"
"testing"
"time"
"github.com/Twigpine/zero/internal/config"
"github.com/Twigpine/zero/internal/tools"
)
// streamStillOpen asks Wait whether the stream is worth draining, without
// letting it block the suite. An open, empty stream blocks Wait forever, which
// is the very defect these tests pin, so an unbounded call hangs until the test
// binary times out instead of failing. Past the bound the stream is reported
// open and closed here, so the waiting goroutine returns.
func streamStillOpen(t *testing.T, stream *StartupDisclosureStream) bool {
t.Helper()
result := make(chan bool, 1)
go func() { result <- stream.Wait() }()
select {
case open := <-result:
return open
case <-time.After(5 * time.Second):
stream.Close()
<-result
return true
}
}
// disclosingRuntime registers one server whose launch carried a startup notice,
// so its runtime has something the stream could deliver.
func disclosingRuntime(t *testing.T) *Runtime {
t.Helper()
client := &disclosingFakeClient{
fakeToolClient: fakeToolClient{listed: []RemoteTool{{Name: "lookup", Description: "Lookup"}}},
notices: []string{startupNotice},
}
runtime, err := RegisterTools(context.Background(), tools.NewRegistry(), config.MCPConfig{Servers: map[string]config.MCPServerConfig{
"docs": {Type: "stdio", Command: "docs-mcp"},
}}, RegisterOptions{ClientFactory: func(context.Context, Server) (ToolClient, error) { return client, nil }})
if err != nil {
t.Fatalf("RegisterTools() error = %v", err)
}
if disclosures := runtime.StartupDisclosures(); len(disclosures) != 1 {
t.Fatalf("SETUP INVALID: the runtime has %d disclosures, so an empty stream would prove nothing", len(disclosures))
}
return runtime
}
// A CLOSED RUNTIME HANDS OUT A CLOSED STREAM.
//
// Close read the stream field directly. Before anyone had asked for the stream
// that field was nil, so Close closed nothing, and the next caller built a fresh,
// open stream, queued this runtime's disclosures into it and subscribed it to
// launches from a runtime that was already gone. A pump started on it then
// waited forever.
func TestAStreamRequestedAfterCloseIsAlreadyClosed(t *testing.T) {
runtime := disclosingRuntime(t)
if err := runtime.Close(); err != nil {
t.Fatalf("Close() error = %v", err)
}
stream := runtime.StartupDisclosureStream()
if stream == nil {
t.Fatal("StartupDisclosureStream() returned nil for a runtime")
}
if queued := stream.Drain(); len(queued) != 0 {
t.Errorf("a stream requested after Close delivered %#v from a runtime that is gone", queued)
}
if streamStillOpen(t, stream) {
t.Error("the stream is still open after Close, so a pump started on it never returns")
}
}
// And the stream an owner already holds is the one Close ends, with what was
// queued before Close still drainable.
func TestCloseEndsTheStreamAnOwnerAlreadyHolds(t *testing.T) {
runtime := disclosingRuntime(t)
stream := runtime.StartupDisclosureStream()
if err := runtime.Close(); err != nil {
t.Fatalf("Close() error = %v", err)
}
if again := runtime.StartupDisclosureStream(); again != stream {
t.Error("Close replaced the stream the owner holds, so the owner's pump is never told to stop")
}
if queued := stream.Drain(); len(queued) != 1 {
t.Errorf("the disclosure queued before Close was lost: %#v", queued)
}
if streamStillOpen(t, stream) {
t.Error("Close did not end the stream the owner holds")
}
}
// NO RACE BETWEEN CLOSE AND THE FIRST REQUEST FOR THE STREAM.
//
// Close read the field while a first StartupDisclosureStream call could be
// writing it inside the Once on another goroutine. Only meaningful under -race,
// which the race job runs.
func TestCloseAndTheFirstStreamRequestDoNotRace(t *testing.T) {
for attempt := 0; attempt < 20; attempt++ {
runtime := disclosingRuntime(t)
start := make(chan struct{})
var group sync.WaitGroup
group.Add(2)
go func() {
defer group.Done()
<-start
_ = runtime.StartupDisclosureStream()
}()
go func() {
defer group.Done()
<-start
_ = runtime.Close()
}()
close(start)
group.Wait()
// Whichever call won the Once, the stream is closed now. A disclosure
// queued before Close stays drainable, so drain before asking.
stream := runtime.StartupDisclosureStream()
stream.Drain()
if streamStillOpen(t, stream) {
t.Fatal("the stream survived Close")
}
}
}
+1 -7
View File
@@ -218,14 +218,8 @@ func (server toolServer) callTool(ctx context.Context, rawParams json.RawMessage
result := server.registry.RunWithOptions(ctx, params.Name, params.Arguments, tools.RunOptions{
PermissionGranted: server.options.PermissionGranted,
})
// ModelOutput, not the raw field. This is a model-facing protocol boundary,
// and Result.Output now holds the UNDECORATED base text: the enforcement
// disclosure lives in typed state and the accessor is what composes the two.
// Serializing Output directly hands an MCP client a Windows command's ordinary
// output with no statement that its DenyRead token shape left writes
// unconfined, which is the one thing the disclosure exists to say.
return CallToolResult{
Content: []Content{{Type: "text", Text: result.ModelOutput()}},
Content: []Content{{Type: "text", Text: result.Output}},
IsError: result.Status != tools.StatusOK,
}, nil
}
@@ -1,193 +0,0 @@
package mcp
import (
"context"
"fmt"
"sort"
"testing"
"github.com/Twigpine/zero/internal/config"
"github.com/Twigpine/zero/internal/tools"
)
// disclosingRaceClient is a launched server that reports a disclosure.
type disclosingRaceClient struct {
fakeToolClient
notices []string
}
func (c *disclosingRaceClient) StartupNotices() []string { return c.notices }
// THE CONCURRENT PHASE TOUCHES NO SHARED STATE, AND THAT IS LOAD-BEARING.
//
// RegisterTools runs one goroutine per server and commits everything in a
// deterministic serial phase afterwards, which is what lets the result be
// identical regardless of completion order. Collecting the startup disclosures
// inside the goroutine broke both halves of that: the append raced the slice
// header, so entries could be lost or overwritten, and whichever survived were
// ordered by completion time rather than by server.
//
// Many servers rather than one, because a single disclosing server cannot
// exercise a shared write at all. Run this package with -race.
func TestStartupDisclosuresAreCollectedWithoutRacing(t *testing.T) {
const servers = 32
configured := map[string]config.MCPServerConfig{}
for index := range servers {
configured[fmt.Sprintf("srv%02d", index)] = config.MCPServerConfig{Type: "stdio", Command: "server"}
}
registry := tools.NewRegistry()
runtime, err := RegisterTools(context.Background(), registry, config.MCPConfig{Servers: configured}, RegisterOptions{
ClientFactory: func(_ context.Context, server Server) (ToolClient, error) {
return &disclosingRaceClient{
fakeToolClient: fakeToolClient{listed: []RemoteTool{{Name: "tool_" + server.Name, Description: "d"}}},
notices: []string{"denyRead is configured for " + server.Name},
}, nil
},
})
if err != nil {
t.Fatalf("RegisterTools() error = %v", err)
}
defer runtime.Close()
got := runtime.StartupDisclosures()
if len(got) != servers {
t.Fatalf("collected %d disclosures, want %d: a shared append loses entries", len(got), servers)
}
// Deterministic server order, not completion order. Sorting the result and
// then comparing would hide exactly the defect this pins.
names := make([]string, 0, len(got))
for _, disclosure := range got {
names = append(names, disclosure.Name)
}
sorted := append([]string(nil), names...)
sort.Strings(sorted)
for index := range names {
if names[index] != sorted[index] {
t.Fatalf("disclosure %d is %q, want %q: order follows completion rather than server order", index, names[index], sorted[index])
}
}
}
// failingDisclosingClient launches (so it has a disclosure) and then fails
// tools/list, which is the shape that used to drop the fact.
type failingDisclosingClient struct {
fakeToolClient
notices []string
}
func (c *failingDisclosingClient) StartupNotices() []string { return c.notices }
func (c *failingDisclosingClient) ListTools(context.Context) ([]RemoteTool, error) {
return nil, fmt.Errorf("initialize failed after the process started")
}
// THE LAUNCH FACT OUTLIVES THE CONNECTION.
//
// connectStdio records the disclosure once cmd.Start returns, which is the right
// moment. But a stdio server can start, do filesystem work, and then fail
// initialize or tools/list, and that path closes the client and returns nil. The
// disclosure was reachable only through that client, so it died with it, and the
// operator was told the server was unavailable without being told the process
// had already run without the write jail.
func TestADisclosureSurvivesAFailureAfterTheProcessLaunched(t *testing.T) {
const notice = "denyRead is configured, so the write jail is not confining writes"
registry := tools.NewRegistry()
runtime, err := RegisterTools(context.Background(), registry, config.MCPConfig{Servers: map[string]config.MCPServerConfig{
"docs": {Type: "stdio", Command: "docs-mcp"},
}}, RegisterOptions{
ClientFactory: func(context.Context, Server) (ToolClient, error) {
return &failingDisclosingClient{notices: []string{notice}}, nil
},
})
if err != nil {
t.Fatalf("RegisterTools() error = %v", err)
}
defer runtime.Close()
skipped := runtime.Skipped()
if len(skipped) != 1 {
t.Fatalf("Skipped() = %#v, want the failure recorded", skipped)
}
disclosures := runtime.StartupDisclosures()
if len(disclosures) != 1 || len(disclosures[0].Notices) != 1 || disclosures[0].Notices[0] != notice {
t.Fatalf("StartupDisclosures() = %#v, want the launch disclosure kept despite the failure", disclosures)
}
if disclosures[0].Name != "docs" {
t.Errorf("Name = %q, want the server it describes", disclosures[0].Name)
}
}
// And a server that never launched still discloses nothing.
func TestAFactoryFailureDisclosesNothing(t *testing.T) {
registry := tools.NewRegistry()
runtime, err := RegisterTools(context.Background(), registry, config.MCPConfig{Servers: map[string]config.MCPServerConfig{
"docs": {Type: "stdio", Command: "docs-mcp"},
}}, RegisterOptions{
ClientFactory: func(context.Context, Server) (ToolClient, error) {
return nil, fmt.Errorf("could not start the process")
},
})
if err != nil {
t.Fatalf("RegisterTools() error = %v", err)
}
defer runtime.Close()
if disclosures := runtime.StartupDisclosures(); len(disclosures) != 0 {
t.Errorf("StartupDisclosures() = %#v, want none for a process that never started", disclosures)
}
}
// A launched process that fails its HANDSHAKE keeps its disclosure too.
//
// connectStdio records the notices once cmd.Start returns, which is the right
// moment, but the initialize failure path closes and discards the client. The
// client was the only carrier, so the fact died with the connection unless the
// failure carries it out itself.
func TestADisclosureSurvivesAnInitializeFailure(t *testing.T) {
const notice = "denyRead is configured, so the write jail is not confining writes"
registry := tools.NewRegistry()
runtime, err := RegisterTools(context.Background(), registry, config.MCPConfig{Servers: map[string]config.MCPServerConfig{
"docs": {Type: "stdio", Command: "docs-mcp"},
}}, RegisterOptions{
ClientFactory: func(context.Context, Server) (ToolClient, error) {
// What connectStdio does once Start has succeeded and the handshake
// then fails: the client is gone, the fact rides the error.
return nil, &startupDisclosureError{
err: fmt.Errorf("initialize MCP server docs: handshake timed out"),
notices: []string{notice},
}
},
})
if err != nil {
t.Fatalf("RegisterTools() error = %v", err)
}
defer runtime.Close()
if skipped := runtime.Skipped(); len(skipped) != 1 {
t.Fatalf("Skipped() = %#v, want the failure recorded", skipped)
}
disclosures := runtime.StartupDisclosures()
if len(disclosures) != 1 || len(disclosures[0].Notices) != 1 || disclosures[0].Notices[0] != notice {
t.Fatalf("StartupDisclosures() = %#v, want the launch disclosure kept through the handshake failure", disclosures)
}
}
// And a plain failure with no launch behind it still discloses nothing, so the
// error path is not just attaching notices to everything.
func TestAPlainConnectFailureDisclosesNothing(t *testing.T) {
registry := tools.NewRegistry()
runtime, err := RegisterTools(context.Background(), registry, config.MCPConfig{Servers: map[string]config.MCPServerConfig{
"docs": {Type: "stdio", Command: "docs-mcp"},
}}, RegisterOptions{
ClientFactory: func(context.Context, Server) (ToolClient, error) {
return nil, fmt.Errorf("could not start the process")
},
})
if err != nil {
t.Fatalf("RegisterTools() error = %v", err)
}
defer runtime.Close()
if disclosures := runtime.StartupDisclosures(); len(disclosures) != 0 {
t.Errorf("StartupDisclosures() = %#v, want none", disclosures)
}
}
-116
View File
@@ -1,116 +0,0 @@
package mcp
import "sync"
// StartupDisclosureStream carries launch disclosures out of the runtime as
// TYPED EVENTS, for whichever component owns the output to drain on its own
// goroutine.
//
// It replaces handing the runtime a presentation callback. That callback closed
// over the CLI's writer, and the runtime invoked it synchronously from whichever
// goroutine happened to resolve the launch. For a server still inside cmd.Start
// when registration gave up, that goroutine is the abandoned connect attempt,
// which runs on no schedule the caller controls: the write landed off the output
// owner's goroutine, raced any other writer, and could arrive after the owner had
// returned or after Bubble Tea had taken the alt screen. The runtime owns the
// FACT that a process started; it does not own anyone's writer.
//
// So the runtime only ever appends a value here. Delivery, ordering against other
// startup output, and the decision to stop listening all belong to the owner.
//
// LIFETIME IS EXPLICIT. Close is the owner saying "I will not write again".
// Offers after Close are dropped rather than queued for a consumer that no longer
// exists, and Wait returns false so a pump exits. Both are deliberate: a
// disclosure is worth printing while someone can print it, and worth dropping
// rather than corrupting a screen that now belongs to something else. Close is
// idempotent and safe from any goroutine, so the runtime and the owner may both
// call it.
type StartupDisclosureStream struct {
mu sync.Mutex
queue []StartupDisclosure
closed bool
wake chan struct{}
}
func newStartupDisclosureStream() *StartupDisclosureStream {
return &StartupDisclosureStream{wake: make(chan struct{}, 1)}
}
// offer queues one disclosure. Called from the registration goroutine for a
// launch already known at commit, and from an abandoned connect goroutine for one
// that resolves later. It takes a lock and appends; it never touches a writer,
// which is the whole point of the type.
func (stream *StartupDisclosureStream) offer(disclosure StartupDisclosure) {
if stream == nil || len(disclosure.Notices) == 0 {
return
}
stream.mu.Lock()
if stream.closed {
stream.mu.Unlock()
return
}
stream.queue = append(stream.queue, disclosure)
stream.mu.Unlock()
select {
case stream.wake <- struct{}{}:
default:
}
}
// Drain removes and returns everything queued right now, without blocking. The
// owner calls this on the goroutine that owns the writer, so every disclosure is
// printed by exactly one goroutine at a time.
func (stream *StartupDisclosureStream) Drain() []StartupDisclosure {
if stream == nil {
return nil
}
stream.mu.Lock()
defer stream.mu.Unlock()
if len(stream.queue) == 0 {
return nil
}
queued := stream.queue
stream.queue = nil
return queued
}
// Wait blocks until at least one disclosure is queued or the stream is closed. It
// reports whether draining is still worthwhile: false means closed and empty, so
// a pump loop should return.
func (stream *StartupDisclosureStream) Wait() bool {
if stream == nil {
return false
}
for {
stream.mu.Lock()
queued := len(stream.queue) > 0
closed := stream.closed
stream.mu.Unlock()
if queued {
return true
}
if closed {
return false
}
<-stream.wake
}
}
// Close ends delivery. Idempotent, safe from any goroutine, and safe to call
// from both the runtime and the output owner.
func (stream *StartupDisclosureStream) Close() {
if stream == nil {
return
}
stream.mu.Lock()
if stream.closed {
stream.mu.Unlock()
return
}
stream.closed = true
stream.mu.Unlock()
select {
case stream.wake <- struct{}{}:
default:
}
}
-200
View File
@@ -1,200 +0,0 @@
package mcp
import (
"context"
"errors"
"os"
"os/exec"
"strings"
"testing"
"time"
"github.com/Twigpine/zero/internal/config"
"github.com/Twigpine/zero/internal/execution"
"github.com/Twigpine/zero/internal/tools"
)
const startupNotice = "denyRead is configured, so the write jail is not confining writes"
// disclosingPreparer plans an MCP server launch that carries an enforcement
// notice, and can fail the way the sandbox does before the child exists.
type disclosingPreparer struct {
prepareErr error
missing bool
}
func (preparer *disclosingPreparer) PrepareExecution(ctx context.Context, request execution.Request) (execution.PreparedCommand, error) {
if preparer.prepareErr != nil {
return execution.PreparedCommand{}, preparer.prepareErr
}
name, args := request.Command.Name, request.Command.Args
if preparer.missing {
name, args = "definitely-not-a-real-binary-zzz", nil
}
command := exec.CommandContext(ctx, name, args...)
command.Dir = request.WorkingDirectory
command.Env = request.Command.Env
return execution.PreparedCommand{
Command: command,
Enforcement: execution.Enforcement{Notices: []string{startupNotice}},
}, nil
}
func helperServer(t *testing.T) Server {
t.Helper()
executable, err := os.Executable()
if err != nil {
t.Fatal(err)
}
return Server{
Name: "docs",
Type: ServerTypeStdio,
Command: executable,
Args: []string{"-test.run=TestMCPStdioHelperProcess", "--"},
Env: map[string]string{"ZERO_MCP_STDIO_HELPER": "1"},
}
}
// THE FACT DESCRIBES STARTUP, SO NOTHING LATER CAN CARRY IT.
//
// The generic adapter puts plan notes on PreparedCommand.Enforcement for
// OriginMCPServer, and connectStdio kept only the command and its cleanup. A
// stdio server launched under the weakened token then served the whole session
// with no path able to tell the operator that its write confinement was
// reduced, and no individual tool result could recover it, because the fact is
// about the process rather than about any response.
func TestAnMCPServerLaunchKeepsItsEnforcementDisclosure(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
client, err := ConnectWithOptions(ctx, helperServer(t), ConnectOptions{
Execution: execution.NewRunner(&disclosingPreparer{}),
WorkspaceRoot: t.TempDir(),
})
if err != nil {
t.Fatalf("ConnectWithOptions() error = %v", err)
}
defer client.Close()
disclosing, ok := client.(startupDisclosing)
if !ok {
t.Fatal("a launched stdio client does not report its startup enforcement at all")
}
notices := disclosing.StartupNotices()
if len(notices) != 1 || notices[0] != startupNotice {
t.Fatalf("StartupNotices() = %#v, want the launch disclosure", notices)
}
}
// A launch with nothing to disclose reports nothing, or every server would carry
// a notice and the statement would mean nothing.
func TestAnUnrestrictedMCPServerLaunchDisclosesNothing(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
client, err := ConnectWithOptions(ctx, helperServer(t), ConnectOptions{
Execution: execution.NewRunner(&mcpExecutionPreparer{}),
WorkspaceRoot: t.TempDir(),
})
if err != nil {
t.Fatalf("ConnectWithOptions() error = %v", err)
}
defer client.Close()
disclosing, ok := client.(startupDisclosing)
if !ok {
t.Fatal("a launched stdio client does not report its startup enforcement at all")
}
if notices := disclosing.StartupNotices(); len(notices) != 0 {
t.Errorf("StartupNotices() = %#v, want none", notices)
}
}
// And a launch that never happened claims nothing, which is the same launch-state
// rule hooks and plugins apply. Here it is expressed by WHERE the notice is
// recorded: every failure above returns before the client exists.
func TestAnMCPServerThatNeverLaunchedClaimsNoEnforcement(t *testing.T) {
for _, testCase := range []struct {
name string
preparer *disclosingPreparer
}{
{"sandbox setup failed", &disclosingPreparer{prepareErr: errors.New("could not build the restricted token")}},
{"executable not found", &disclosingPreparer{missing: true}},
} {
t.Run(testCase.name, func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
client, err := ConnectWithOptions(ctx, helperServer(t), ConnectOptions{
Execution: execution.NewRunner(testCase.preparer),
WorkspaceRoot: t.TempDir(),
})
if err == nil {
client.Close()
t.Fatal("the server started even though its launch was supposed to fail")
}
// THE CARRIER, NOT THE TEXT. A connect failure hands its notices on
// startupDisclosureError, whose Error() prints only the cause, so a
// check of the message could never see a notice that was carried.
if carried := startupNoticesFromError(err); len(carried) != 0 {
t.Errorf("a launch that never happened claimed an enforcement trade: %#v", carried)
}
if strings.Contains(err.Error(), startupNotice) {
t.Errorf("a launch that never happened claimed an enforcement trade in its error: %v", err)
}
})
}
}
// disclosingFakeClient is a launched server that carries a disclosure.
type disclosingFakeClient struct {
fakeToolClient
notices []string
}
func (client *disclosingFakeClient) StartupNotices() []string { return client.notices }
// Registration is the boundary that reports it, once, for the process it
// launched.
func TestRegistrationCollectsStartupDisclosures(t *testing.T) {
registry := tools.NewRegistry()
client := &disclosingFakeClient{
fakeToolClient: fakeToolClient{listed: []RemoteTool{{Name: "lookup", Description: "Lookup"}}},
notices: []string{startupNotice},
}
runtime, err := RegisterTools(context.Background(), registry, config.MCPConfig{Servers: map[string]config.MCPServerConfig{
"docs": {Type: "stdio", Command: "docs-mcp"},
}}, RegisterOptions{ClientFactory: func(context.Context, Server) (ToolClient, error) { return client, nil }})
if err != nil {
t.Fatalf("RegisterTools() error = %v", err)
}
defer runtime.Close()
disclosures := runtime.StartupDisclosures()
if len(disclosures) != 1 {
t.Fatalf("StartupDisclosures() = %#v, want one", disclosures)
}
if disclosures[0].Name != "docs" {
t.Errorf("Name = %q, want the server it describes", disclosures[0].Name)
}
if len(disclosures[0].Notices) != 1 || disclosures[0].Notices[0] != startupNotice {
t.Errorf("Notices = %#v, want the launch disclosure", disclosures[0].Notices)
}
}
// A network server launches no local process, so it implements nothing and
// reports nothing. That is a different answer from an empty one and the negative
// case that keeps the statement meaningful.
func TestANetworkServerReportsNoStartupDisclosure(t *testing.T) {
registry := tools.NewRegistry()
runtime, err := RegisterTools(context.Background(), registry, config.MCPConfig{Servers: map[string]config.MCPServerConfig{
"docs": {Type: "http", URL: "https://host.invalid/mcp"},
}}, RegisterOptions{ClientFactory: func(context.Context, Server) (ToolClient, error) {
return &fakeToolClient{listed: []RemoteTool{{Name: "lookup", Description: "Lookup"}}}, nil
}})
if err != nil {
t.Fatalf("RegisterTools() error = %v", err)
}
defer runtime.Close()
if disclosures := runtime.StartupDisclosures(); len(disclosures) != 0 {
t.Errorf("StartupDisclosures() = %#v, want none for a server that launches no process", disclosures)
}
}
+13 -44
View File
@@ -56,15 +56,6 @@ type commandOutput struct {
Stderr string
ExitCode int
Err error
// Notices carries the enforcement disclosures the execution runner attached
// to this command.
//
// The generic contract is not transport-only. Enforcement.Notices says what
// the sandbox actually did, and on Windows that includes trading the write
// jail away for a deny-read profile. A projection that copies stdout, stderr
// and an exit code drops it, so a plugin tool ran under the weakened token
// and said nothing about it.
Notices []string
}
// toolRunner executes a resolved plugin tool command. It is injectable so
@@ -383,7 +374,7 @@ func (tool skillTool) Run(_ context.Context, args map[string]any) tools.Result {
available := make([]string, 0, len(merged))
for _, skill := range merged {
if skill.Name == name {
return tools.Result{Status: tools.StatusOK, Output: skill.Content}
return tools.Result{Status: tools.StatusOK, Output: skills.ToolOutput(skill)}
}
available = append(available, skill.Name)
}
@@ -558,37 +549,26 @@ func (tool pluginTool) invoke(ctx context.Context, args map[string]any, cwd stri
meta["exit_code"] = strconv.Itoa(output.ExitCode)
if output.Err != nil {
// EVERY POST-LAUNCH TERMINAL OUTCOME CARRIES THE DISCLOSURE. This branch
// used to rebuild a result from status, output and metadata alone, so a
// plugin that timed out or was cancelled reported only that and said
// nothing about having run without write confinement. Whether the notice
// survives must not depend on how the process ended. The launched-or-not
// question is answered in execPluginCommandWithExecution; by here
// output.Notices is empty for anything that never started.
return tools.Result{
Status: tools.StatusError,
Output: "Error executing plugin tool " + tool.name + ": " + output.Err.Error(),
Meta: meta,
EnforcementNotices: output.Notices,
Display: tools.Display{Summary: tool.name + " failed", Kind: "plugin"},
Status: tools.StatusError,
Output: "Error executing plugin tool " + tool.name + ": " + output.Err.Error(),
Meta: meta,
}
}
formatted := formatPluginToolOutput(output)
if output.ExitCode != 0 {
return tools.Result{
Status: tools.StatusError,
Output: formatted,
Meta: meta,
EnforcementNotices: output.Notices,
Display: tools.Display{Summary: tool.name + " failed", Kind: "plugin"},
Status: tools.StatusError,
Output: formatted,
Meta: meta,
Display: tools.Display{Summary: tool.name + " failed", Kind: "plugin"},
}
}
return tools.Result{
Status: tools.StatusOK,
Output: formatted,
Meta: meta,
EnforcementNotices: output.Notices,
Display: tools.Display{Summary: tool.name, Kind: "plugin"},
Status: tools.StatusOK,
Output: formatted,
Meta: meta,
Display: tools.Display{Summary: tool.name, Kind: "plugin"},
}
}
@@ -739,18 +719,7 @@ func execPluginCommandWithExecution(ctx context.Context, runner *execution.Runne
if result.Outcome.Exit != nil {
exitCode = result.Outcome.Exit.Code
}
output := commandOutput{
Stdout: result.Stdout,
Stderr: result.Stderr,
ExitCode: exitCode,
}
// THE NOTICE DESCRIBES A CHILD THAT RAN. Deciding that here, where the outcome
// kind is known, rather than at each result constructor: a timeout or a
// cancellation happened to a process that had already launched under the
// weakened token, so the disclosure is still true of it. A setup failure or a
// missing executable launched nothing, and claiming the write jail was traded
// away there would describe a trade nobody made.
output.Notices = result.Outcome.AppliedEnforcementNotices()
output := commandOutput{Stdout: result.Stdout, Stderr: result.Stderr, ExitCode: exitCode}
switch result.Outcome.Kind {
case execution.OutcomeSandboxSetupFailure, execution.OutcomeExecutableNotFound, execution.OutcomeTimedOut, execution.OutcomeCancelled:
output.Err = result.Err
+25
View File
@@ -673,3 +673,28 @@ func TestExpandPluginRootPathRelative(t *testing.T) {
}
}
}
// The multi-root skill tool is the one the CLI registers, so it has to point at
// a skill's bundled files the same way the core tool does (#584).
func TestNewSkillToolPointsAtTheSkillsBundledFiles(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
dir := t.TempDir()
writeTestSkill(t, dir, "pdf", "Run scripts/fill.py.")
script := filepath.Join(dir, "pdf", "scripts", "fill.py")
if err := os.MkdirAll(filepath.Dir(script), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(script, []byte("print('fill')\n"), 0o755); err != nil {
t.Fatal(err)
}
got := NewSkillTool(dir, nil).Run(context.Background(), map[string]any{"name": "pdf"})
if got.Status != tools.StatusOK {
t.Fatalf("Status = %s (output: %s)", got.Status, got.Output)
}
if !strings.Contains(got.Output, "- scripts/fill.py") || !strings.Contains(got.Output, filepath.Join(dir, "pdf")) {
t.Fatalf("output does not point at the bundled script:\n%s", got.Output)
}
}
+552
View File
@@ -0,0 +1,552 @@
package plugins
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"strings"
"github.com/Twigpine/zero/internal/installtxn"
"github.com/Twigpine/zero/internal/pathjail"
)
// SetEnabledResult describes a successful enable/disable of one plugin manifest.
type SetEnabledResult struct {
ID string `json:"id"`
Enabled bool `json:"enabled"`
Changed bool `json:"changed"`
Source Source `json:"source"`
ManifestPath string `json:"manifestPath"`
}
// maxEnabledRelockAttempts bounds how many times SetEnabledByID re-resolves the
// plugin after it moves to another install root while waiting for the lock.
const maxEnabledRelockAttempts = 4
// FindByID returns the loaded plugin with the given id after normal discovery
// and precedence (project overrides user). A missing id returns ok=false.
func FindByID(options LoadOptions, id string) (LoadedPlugin, bool, error) {
id = strings.TrimSpace(id)
if id == "" {
return LoadedPlugin{}, false, errors.New("plugin id is required")
}
result, err := Load(options)
if err != nil {
return LoadedPlugin{}, false, err
}
for _, plugin := range result.Plugins {
if plugin.ID == id {
return plugin, true, nil
}
}
return LoadedPlugin{}, false, nil
}
// SetEnabledByID finds a plugin by id (respecting LoadOptions) and toggles the
// top-level "enabled" field of its plugin.json.
//
// The read-modify-write is serialized with plugin installs and removals: it
// takes the same cross-process installtxn lock they take on the plugin's
// install root, runs the same interrupted-transaction recovery they run under
// it, re-resolves the plugin under that lock, and publishes the
// manifest with an atomic rename while still holding it, so a concurrent
// install can never be interleaved between the read and the write. Only the
// bytes of the "enabled" value change, so field order, formatting, and numeric
// literals in the rest of the manifest survive the toggle.
func SetEnabledByID(options LoadOptions, id string, enabled bool) (SetEnabledResult, error) {
for attempt := 0; ; attempt++ {
plugin, ok, err := FindByID(options, id)
if err != nil {
return SetEnabledResult{}, err
}
if !ok {
return SetEnabledResult{}, fmt.Errorf("%w: %q", ErrNotInstalled, id)
}
// Take the install root lock before the final lookup and read so an
// install or remove cannot swap the tree between them.
unlock, err := installtxn.Lock(plugin.Root)
if err != nil {
return SetEnabledResult{}, err
}
// Put back anything an earlier install or removal was killed mid-commit,
// and abort if a transaction could not be resolved: editing a tree that is
// still owed a restore loses the edit when the next install or removal
// puts the backup back over it. See installtxn.Recover.
if err := installtxn.Recover(plugin.Root, reconcileInterrupted(plugin.Root)); err != nil {
unlock()
return SetEnabledResult{}, err
}
final, ok, err := FindByID(options, id)
if err != nil {
unlock()
return SetEnabledResult{}, err
}
if !ok {
unlock()
return SetEnabledResult{}, fmt.Errorf("%w: %q", ErrNotInstalled, id)
}
if final.Root != plugin.Root {
// Precedence moved to another root while this lock was pending;
// retry against the root that wins now so the edit still serializes
// with that root's installs.
unlock()
if attempt >= maxEnabledRelockAttempts {
return SetEnabledResult{}, fmt.Errorf("plugin %q kept changing roots while waiting for the install lock", id)
}
continue
}
changed, err := setEnabledLocked(final, enabled)
unlock()
if err != nil {
return SetEnabledResult{}, err
}
return SetEnabledResult{
ID: final.ID,
Enabled: enabled,
Changed: changed,
Source: final.Source,
ManifestPath: final.ManifestPath,
}, nil
}
}
// setEnabledLocked performs the manifest read-modify-write while the caller
// holds the installtxn lock for the plugin's install root.
//
// The lock only serializes cooperating installers; it does not stop the plugin
// directory from being swapped after discovery found it. So the whole update
// works on one directory, pinned by openPluginDir: the manifest is read and
// published, and the tree hashed for the lockfile, through that handle, never
// by re-resolving the path.
func setEnabledLocked(plugin LoadedPlugin, enabled bool) (bool, error) {
pinned, err := openPluginDir(plugin)
if err != nil {
return false, err
}
defer pinned.Close()
return setEnabledPinned(plugin, pinned, enabled)
}
// setEnabledPinned is setEnabledLocked on an already pinned plugin directory.
//
// The edit lands in the pinned directory even if the path now names another
// one, so before refreshing the lockfile or reporting any result it confirms
// that the directory entry still is the pinned directory. Otherwise the
// command would report a state that the installed plugin does not have.
func setEnabledPinned(plugin LoadedPlugin, pinned *pinnedPluginDir, enabled bool) (bool, error) {
manifestName := filepath.Base(plugin.ManifestPath)
raw, perm, err := readManifest(pinned.dir, manifestName)
if err != nil {
return false, err
}
data, changed, err := toggleEnabledManifest(raw, enabled)
if err != nil {
return false, err
}
if !changed {
if err := pinned.verifyLive(); err != nil {
return false, err
}
return false, nil
}
// Read the lockfile before the edit so the refresh below only runs when the
// installed tree still matched its recorded hash; a toggle must never mask
// pre-existing drift.
lock, entry, hashClean := cleanLockEntry(plugin, pinned.dir)
if err := publishManifest(pinned.dir, manifestName, data, perm); err != nil {
return false, err
}
if !hashClean {
if err := pinned.verifyLive(); err != nil {
return false, err
}
return true, nil
}
after, err := hashRootedTree(pinned.dir)
if err != nil {
return true, fmt.Errorf("plugin manifest updated but %s hash refresh failed: %w", LockFileName, err)
}
if err := pinned.verifyLive(); err != nil {
return false, err
}
entry.Hash = after
lock[plugin.ID] = entry
if err := writeLock(plugin.Root, lock); err != nil {
return true, fmt.Errorf("plugin manifest updated but %s hash refresh failed: %w", LockFileName, err)
}
return true, nil
}
// errPluginDirReplaced reports that the plugin's directory entry no longer
// names the directory the toggle pinned and edited.
var errPluginDirReplaced = errors.New("plugin directory was replaced while it was being updated; the plugin installed there now was not changed")
// pinnedPluginDir is the plugin directory discovery found, held open for the
// length of one toggle.
type pinnedPluginDir struct {
root *os.Root // the plugin's install root
name string // the plugin directory's entry name in root
path string // the plugin directory's path, for messages
dir *os.Root // the plugin directory itself
}
// Close releases both handles.
func (p *pinnedPluginDir) Close() {
_ = p.dir.Close()
_ = p.root.Close()
}
// verifyLive confirms that the plugin's entry in its install root is still a
// real directory, not a symlink, junction, or other reparse point, and is the
// very directory p.dir holds open.
func (p *pinnedPluginDir) verifyLive() error {
opened, err := p.dir.Stat(".")
if err != nil {
return fmt.Errorf("inspect plugin directory: %w", err)
}
entry, err := p.root.Lstat(p.name)
if errors.Is(err, fs.ErrNotExist) {
return fmt.Errorf("%w: %s", errPluginDirReplaced, p.path)
}
if err != nil {
return fmt.Errorf("inspect plugin directory: %w", err)
}
if entry.Mode()&(os.ModeSymlink|os.ModeIrregular) != 0 {
return fmt.Errorf("%w: plugin directory %s", pathjail.ErrReparse, p.path)
}
if !os.SameFile(entry, opened) {
return fmt.Errorf("%w: %s", errPluginDirReplaced, p.path)
}
return nil
}
// openPluginDir pins the plugin directory discovery found, confined to the
// plugin's install root.
//
// A link swapped in before the open is refused here. One swapped in after it
// cannot redirect anything, because every later read, write, and hash is
// relative to the returned handle rather than to a path; verifyLive then
// detects that the path no longer names the edited directory.
func openPluginDir(plugin LoadedPlugin) (*pinnedPluginDir, error) {
root, name, err := pathjail.Open(plugin.Root, plugin.PluginDir)
if err != nil {
return nil, fmt.Errorf("open plugin directory: %w", err)
}
if name == "." || filepath.Base(name) != name {
_ = root.Close()
return nil, fmt.Errorf("%w: plugin directory %s is not an entry of %s", pathjail.ErrEscapes, plugin.PluginDir, plugin.Root)
}
if err := pathjail.RefuseReparse(root, name); err != nil {
_ = root.Close()
return nil, fmt.Errorf("open plugin directory: %w", err)
}
dir, err := root.OpenRoot(name)
if err != nil {
_ = root.Close()
return nil, fmt.Errorf("open plugin directory: %w", err)
}
pinned := &pinnedPluginDir{root: root, name: name, path: plugin.PluginDir, dir: dir}
if err := pinned.verifyLive(); err != nil {
pinned.Close()
return nil, err
}
return pinned, nil
}
// readManifest reads name from dir and returns its bytes and permission bits.
// Failing on a stat error keeps the publish from widening the permissions.
func readManifest(dir *os.Root, name string) ([]byte, os.FileMode, error) {
file, err := dir.Open(name)
if err != nil {
return nil, 0, fmt.Errorf("read plugin manifest: %w", err)
}
defer file.Close()
info, err := file.Stat()
if err != nil {
return nil, 0, fmt.Errorf("inspect plugin manifest: %w", err)
}
raw, err := io.ReadAll(file)
if err != nil {
return nil, 0, fmt.Errorf("read plugin manifest: %w", err)
}
return raw, info.Mode().Perm(), nil
}
// publishManifest replaces name in dir with data by renaming a complete
// temporary sibling over it, so a concurrent reader sees either the old
// manifest or the new one, never a partial write.
func publishManifest(dir *os.Root, name string, data []byte, perm os.FileMode) error {
temp, tempName, err := pathjail.CreateTemp(dir, ".", ".zero-manifest", "")
if err != nil {
return fmt.Errorf("write plugin manifest: %w", err)
}
defer func() { _ = dir.Remove(tempName) }()
if err := temp.Chmod(perm); err != nil {
_ = temp.Close()
return fmt.Errorf("write plugin manifest: %w", err)
}
if _, err := temp.Write(data); err != nil {
_ = temp.Close()
return fmt.Errorf("write plugin manifest: %w", err)
}
if err := temp.Sync(); err != nil {
_ = temp.Close()
return fmt.Errorf("write plugin manifest: %w", err)
}
if err := temp.Close(); err != nil {
return fmt.Errorf("write plugin manifest: %w", err)
}
if err := dir.Rename(tempName, name); err != nil {
return fmt.Errorf("write plugin manifest: %w", err)
}
return nil
}
// cleanLockEntry reports whether the pinned plugin directory dir still matches
// the lockfile entry recorded for the plugin and returns both for a refresh.
func cleanLockEntry(plugin LoadedPlugin, dir *os.Root) (map[string]LockEntry, LockEntry, bool) {
lock, err := ReadLock(plugin.Root)
if err != nil {
return nil, LockEntry{}, false
}
entry, ok := lock[plugin.ID]
if !ok {
return nil, LockEntry{}, false
}
current, err := hashRootedTree(dir)
if err != nil || current != entry.Hash {
return nil, LockEntry{}, false
}
return lock, entry, true
}
// toggleEnabledManifest returns raw with the top-level "enabled" member set to
// enabled. A missing or null value counts as enabled, matching the loader's
// optionalBool fallback. changed reports whether the result differs from raw.
func toggleEnabledManifest(raw []byte, enabled bool) (out []byte, changed bool, err error) {
dec := json.NewDecoder(bytes.NewReader(raw))
openTok, err := dec.Token()
if err != nil {
return nil, false, fmt.Errorf("parse plugin manifest: %w", err)
}
if delim, ok := openTok.(json.Delim); !ok || delim != '{' {
return nil, false, errors.New("plugin manifest must be a JSON object")
}
openEnd := dec.InputOffset()
current := true
lastInvalid := false
var valueStart, valueEnd int64 = -1, -1
for dec.More() {
keyTok, err := dec.Token()
if err != nil {
return nil, false, fmt.Errorf("parse plugin manifest: %w", err)
}
key, ok := keyTok.(string)
if !ok {
return nil, false, errors.New("plugin manifest must be a JSON object")
}
keyEnd := dec.InputOffset()
if key != "enabled" {
if err := skipManifestValue(dec); err != nil {
return nil, false, fmt.Errorf("parse plugin manifest: %w", err)
}
continue
}
valueTok, err := dec.Token()
if err != nil {
return nil, false, fmt.Errorf("parse plugin manifest: %w", err)
}
var literal string
switch value := valueTok.(type) {
case bool:
current = value
literal = "false"
if value {
literal = "true"
}
case nil:
current = true
literal = "null"
default:
// Only the last occurrence of a duplicate key counts, as in the
// loader's json.Unmarshal, so an earlier non-boolean value that a
// later one overwrites is not an error. Remember it and decide after
// the last member.
if err := skipManifestComposite(dec, valueTok); err != nil {
return nil, false, fmt.Errorf("parse plugin manifest: %w", err)
}
lastInvalid = true
continue
}
lastInvalid = false
start, err := enabledValueOffset(raw, keyEnd)
if err != nil {
return nil, false, err
}
if start+int64(len(literal)) > int64(len(raw)) ||
!bytes.Equal(raw[start:start+int64(len(literal))], []byte(literal)) {
return nil, false, errors.New("parse plugin manifest: cannot locate the enabled value")
}
// A duplicate key keeps the last occurrence, matching json.Unmarshal.
valueStart, valueEnd = start, start+int64(len(literal))
}
closingTok, err := dec.Token()
if err != nil {
return nil, false, fmt.Errorf("parse plugin manifest: %w", err)
}
if delim, ok := closingTok.(json.Delim); !ok || delim != '}' {
return nil, false, errors.New("plugin manifest must be a JSON object")
}
closingEnd := dec.InputOffset()
if _, err := dec.Token(); err != io.EOF {
return nil, false, errors.New("parse plugin manifest: unexpected data after the top-level object")
}
if lastInvalid {
return nil, false, ManifestError{FieldPath: "enabled", Message: "Expected a boolean."}
}
if current == enabled {
return nil, false, nil
}
newLiteral := "false"
if enabled {
newLiteral = "true"
}
if valueStart >= 0 {
out := make([]byte, 0, len(raw)-int(valueEnd-valueStart)+len(newLiteral))
out = append(out, raw[:valueStart]...)
out = append(out, newLiteral...)
out = append(out, raw[valueEnd:]...)
return out, true, nil
}
return insertEnabledMember(raw, openEnd, closingEnd-1, newLiteral), true, nil
}
// enabledValueOffset returns the byte offset of the value that follows the
// colon after an "enabled" key token that ended at keyEnd.
func enabledValueOffset(raw []byte, keyEnd int64) (int64, error) {
i := keyEnd
for i < int64(len(raw)) && isJSONSpace(raw[i]) {
i++
}
if i >= int64(len(raw)) || raw[i] != ':' {
return 0, errors.New("parse plugin manifest: missing colon after key")
}
i++
for i < int64(len(raw)) && isJSONSpace(raw[i]) {
i++
}
return i, nil
}
// insertEnabledMember adds a top-level "enabled" member to a manifest that has
// none, mirroring the document's own whitespace style where it can. openEnd is
// the byte offset just after the opening brace and bracePos is the byte index
// of the closing brace.
func insertEnabledMember(raw []byte, openEnd, bracePos int64, literal string) []byte {
insertPos := bracePos
for insertPos > openEnd && isJSONSpace(raw[insertPos-1]) {
insertPos--
}
newline := "\n"
if bytes.Contains(raw, []byte("\r\n")) {
newline = "\r\n"
}
first := openEnd
for first < bracePos && isJSONSpace(raw[first]) {
first++
}
empty := first >= bracePos
multiline := bytes.IndexByte(raw[openEnd:bracePos], '\n') >= 0
member := `"enabled": ` + literal
var insert string
switch {
case empty && !multiline:
insert = member
case empty && multiline:
insert = newline + closingLineIndent(raw, openEnd, bracePos) + " " + member
case !empty && !multiline:
insert = "," + member
default:
insert = "," + newline + memberLineIndent(raw, openEnd, first) + member
}
out := make([]byte, 0, len(raw)+len(insert))
out = append(out, raw[:insertPos]...)
out = append(out, insert...)
out = append(out, raw[insertPos:]...)
return out
}
// memberLineIndent returns the indentation of the line the first member of a
// JSON object starts on. first is the byte index of that member's first token.
func memberLineIndent(raw []byte, openEnd, first int64) string {
lineStart := first
for lineStart > openEnd && raw[lineStart-1] != '\n' {
lineStart--
}
end := lineStart
for end < first && (raw[end] == ' ' || raw[end] == '\t') {
end++
}
return string(raw[lineStart:end])
}
// closingLineIndent returns the indentation before an object's closing brace.
func closingLineIndent(raw []byte, openEnd, bracePos int64) string {
start := bracePos
for start > openEnd && (raw[start-1] == ' ' || raw[start-1] == '\t') {
start--
}
return string(raw[start:bracePos])
}
// skipManifestValue consumes the next JSON value from dec.
func skipManifestValue(dec *json.Decoder) error {
tok, err := dec.Token()
if err != nil {
return err
}
return skipManifestComposite(dec, tok)
}
// skipManifestComposite consumes the rest of the value whose first token tok
// was just read from dec. A scalar is already complete; an object or array is
// consumed through its matching closing delimiter.
func skipManifestComposite(dec *json.Decoder, tok json.Token) error {
delim, ok := tok.(json.Delim)
if !ok || (delim != '{' && delim != '[') {
return nil
}
depth := 1
for depth > 0 {
tok, err := dec.Token()
if err != nil {
return err
}
if inner, ok := tok.(json.Delim); ok {
switch inner {
case '{', '[':
depth++
case '}', ']':
depth--
}
}
}
return nil
}
func isJSONSpace(b byte) bool {
return b == ' ' || b == '\t' || b == '\n' || b == '\r'
}
+999
View File
@@ -0,0 +1,999 @@
package plugins
import (
"errors"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"github.com/Twigpine/zero/internal/installtxn"
"github.com/Twigpine/zero/internal/pathjail"
)
func enableTestOptions(root string) LoadOptions {
return LoadOptions{Roots: []Root{{Source: SourceUser, Path: root}}}
}
func writeRawManifest(t *testing.T, root, dir, raw string) string {
t.Helper()
pluginDir := filepath.Join(root, dir)
if err := os.MkdirAll(pluginDir, 0o700); err != nil {
t.Fatal(err)
}
manifestPath := filepath.Join(pluginDir, "plugin.json")
if err := os.WriteFile(manifestPath, []byte(raw), 0o600); err != nil {
t.Fatal(err)
}
return manifestPath
}
func demoManifest(enabled bool) string {
state := "false"
if enabled {
state = "true"
}
return `{
"schemaVersion": 1,
"id": "zero.demo",
"name": "Demo",
"version": "1.0.0",
"enabled": ` + state + `
}
`
}
func TestSetEnabledByIDPreservesManifestBytes(t *testing.T) {
root := t.TempDir()
raw := `{
"schemaVersion": 1,
"id": "zero.demo",
"name": "Demo",
"version": "1.0.0",
"limits": {
"maxBytes": 9007199254740993
},
"description": "keep me",
"enabled": true,
"tools": []
}
`
manifestPath := writeRawManifest(t, root, "demo", raw)
result, err := SetEnabledByID(enableTestOptions(root), "zero.demo", false)
if err != nil {
t.Fatalf("SetEnabledByID(false): %v", err)
}
if !result.Changed || result.Enabled {
t.Fatalf("unexpected result: %#v", result)
}
disabled, err := os.ReadFile(manifestPath)
if err != nil {
t.Fatal(err)
}
want := strings.Replace(raw, `"enabled": true`, `"enabled": false`, 1)
if string(disabled) != want {
t.Fatalf("toggle changed more than the enabled value:\n got %q\nwant %q", disabled, want)
}
if !strings.Contains(string(disabled), "9007199254740993") {
t.Fatal("large integer literal was rounded by the toggle")
}
plugin, ok, err := FindByID(enableTestOptions(root), "zero.demo")
if err != nil || !ok {
t.Fatalf("FindByID after disable: ok=%v err=%v", ok, err)
}
if plugin.Enabled {
t.Fatal("loader still reports the plugin enabled after disable")
}
result, err = SetEnabledByID(enableTestOptions(root), "zero.demo", true)
if err != nil {
t.Fatalf("SetEnabledByID(true): %v", err)
}
if !result.Changed || !result.Enabled {
t.Fatalf("unexpected enable result: %#v", result)
}
enabled, err := os.ReadFile(manifestPath)
if err != nil {
t.Fatal(err)
}
if string(enabled) != raw {
t.Fatalf("round trip did not restore the original bytes:\n got %q\nwant %q", enabled, raw)
}
}
func TestSetEnabledByIDTogglesManifestAndIsIdempotent(t *testing.T) {
root := t.TempDir()
writePluginManifest(t, filepath.Join(root, "demo"), map[string]any{
"schemaVersion": 1,
"id": "zero.demo",
"name": "Demo",
"version": "1.0.0",
"description": "keep me",
"tools": []any{},
})
manifestPath := filepath.Join(root, "demo", "plugin.json")
result, err := SetEnabledByID(enableTestOptions(root), "zero.demo", false)
if err != nil {
t.Fatalf("SetEnabledByID(false): %v", err)
}
if !result.Changed {
t.Fatal("expected first disable to change the manifest")
}
raw, err := os.ReadFile(manifestPath)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(raw), `"enabled": false`) {
t.Fatalf("manifest not disabled: %s", raw)
}
if !strings.Contains(string(raw), `"keep me"`) {
t.Fatalf("description was not preserved: %s", raw)
}
result, err = SetEnabledByID(enableTestOptions(root), "zero.demo", false)
if err != nil {
t.Fatalf("SetEnabledByID(false) again: %v", err)
}
if result.Changed {
t.Fatal("expected second disable to be a no-op")
}
result, err = SetEnabledByID(enableTestOptions(root), "zero.demo", true)
if err != nil {
t.Fatalf("SetEnabledByID(true): %v", err)
}
if !result.Changed {
t.Fatal("expected enable to change the manifest")
}
}
func TestSetEnabledByIDInsertsMissingField(t *testing.T) {
t.Run("pretty manifest", func(t *testing.T) {
root := t.TempDir()
raw := `{
"schemaVersion": 1,
"id": "zero.demo",
"name": "Demo",
"version": "1.0.0"
}
`
manifestPath := writeRawManifest(t, root, "demo", raw)
if _, err := SetEnabledByID(enableTestOptions(root), "zero.demo", false); err != nil {
t.Fatalf("SetEnabledByID(false): %v", err)
}
disabled, err := os.ReadFile(manifestPath)
if err != nil {
t.Fatal(err)
}
wantDisabled := `{
"schemaVersion": 1,
"id": "zero.demo",
"name": "Demo",
"version": "1.0.0",
"enabled": false
}
`
if string(disabled) != wantDisabled {
t.Fatalf("after insert:\n got %q\nwant %q", disabled, wantDisabled)
}
if _, err := SetEnabledByID(enableTestOptions(root), "zero.demo", true); err != nil {
t.Fatalf("SetEnabledByID(true): %v", err)
}
enabled, err := os.ReadFile(manifestPath)
if err != nil {
t.Fatal(err)
}
wantEnabled := strings.Replace(wantDisabled, `"enabled": false`, `"enabled": true`, 1)
if string(enabled) != wantEnabled {
t.Fatalf("after replace:\n got %q\nwant %q", enabled, wantEnabled)
}
})
t.Run("compact manifest", func(t *testing.T) {
root := t.TempDir()
raw := `{"schemaVersion":1,"id":"zero.demo","name":"Demo","version":"1.0.0"}`
manifestPath := writeRawManifest(t, root, "demo", raw)
if _, err := SetEnabledByID(enableTestOptions(root), "zero.demo", false); err != nil {
t.Fatalf("SetEnabledByID(false): %v", err)
}
disabled, err := os.ReadFile(manifestPath)
if err != nil {
t.Fatal(err)
}
want := `{"schemaVersion":1,"id":"zero.demo","name":"Demo","version":"1.0.0","enabled": false}`
if string(disabled) != want {
t.Fatalf("compact insert:\n got %q\nwant %q", disabled, want)
}
})
t.Run("null value", func(t *testing.T) {
root := t.TempDir()
raw := `{
"schemaVersion": 1,
"id": "zero.demo",
"name": "Demo",
"version": "1.0.0",
"enabled": null
}
`
manifestPath := writeRawManifest(t, root, "demo", raw)
result, err := SetEnabledByID(enableTestOptions(root), "zero.demo", true)
if err != nil {
t.Fatalf("SetEnabledByID(true): %v", err)
}
if result.Changed {
t.Fatal("null counts as enabled, so enabling must be a no-op")
}
unchanged, err := os.ReadFile(manifestPath)
if err != nil {
t.Fatal(err)
}
if string(unchanged) != raw {
t.Fatalf("no-op toggle rewrote the manifest: %q", unchanged)
}
if _, err := SetEnabledByID(enableTestOptions(root), "zero.demo", false); err != nil {
t.Fatalf("SetEnabledByID(false): %v", err)
}
disabled, err := os.ReadFile(manifestPath)
if err != nil {
t.Fatal(err)
}
want := strings.Replace(raw, `"enabled": null`, `"enabled": false`, 1)
if string(disabled) != want {
t.Fatalf("null replacement:\n got %q\nwant %q", disabled, want)
}
})
}
func TestToggleEnabledManifestFormattingEdges(t *testing.T) {
cases := []struct {
name string
in string
enabled bool
want string
changed bool
}{
{"empty object", `{}`, false, `{"enabled": false}`, true},
{"empty object with space", `{ }`, false, `{"enabled": false }`, true},
{"empty multiline", "{\n}", false, "{\n \"enabled\": false\n}", true},
{"compact members", `{"a":1}`, false, `{"a":1,"enabled": false}`, true},
{"crlf members", "{\r\n \"a\": 1\r\n}", false, "{\r\n \"a\": 1,\r\n \"enabled\": false\r\n}", true},
{"nested enabled untouched", `{"nested":{"enabled":true},"a":2}`, false, `{"nested":{"enabled":true},"a":2,"enabled": false}`, true},
{"replace with true", `{"enabled": false}`, true, `{"enabled": true}`, true},
{"replace with false", `{"enabled":true}`, false, `{"enabled":false}`, true},
{"null to false", `{"enabled": null}`, false, `{"enabled": false}`, true},
{"null to true is a no-op", `{"enabled": null}`, true, `{"enabled": null}`, false},
{"already enabled is a no-op", `{"enabled": true}`, true, `{"enabled": true}`, false},
// The loader keeps the last duplicate, so an overwritten non-boolean
// value is not an error and only the last value is rewritten.
{"overwritten string", `{"enabled":"obsolete","enabled":true}`, false, `{"enabled":"obsolete","enabled":false}`, true},
{"overwritten composite", `{"enabled":{"a":[1,{"enabled":2}]},"enabled":null}`, false, `{"enabled":{"a":[1,{"enabled":2}]},"enabled":false}`, true},
{"overwritten number is a no-op", `{"enabled":1,"enabled":false}`, false, `{"enabled":1,"enabled":false}`, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
out, changed, err := toggleEnabledManifest([]byte(tc.in), tc.enabled)
if err != nil {
t.Fatalf("toggleEnabledManifest: %v", err)
}
if changed != tc.changed {
t.Fatalf("changed = %v, want %v", changed, tc.changed)
}
if !tc.changed {
if out != nil {
t.Fatalf("no-op returned bytes: %q", out)
}
return
}
if string(out) != tc.want {
t.Fatalf("got %q, want %q", out, tc.want)
}
})
}
}
func TestToggleEnabledManifestRejectsMalformedInput(t *testing.T) {
for _, raw := range []string{`{"enabled": "yes"}`, `{"enabled": 1}`, `{"enabled": []}`, `{"enabled": {}}`, `{"enabled": true, "enabled": "yes"}`} {
t.Run("value "+raw, func(t *testing.T) {
_, _, err := toggleEnabledManifest([]byte(raw), false)
if err == nil {
t.Fatal("expected error")
}
var manifestErr ManifestError
if !errors.As(err, &manifestErr) {
t.Fatalf("error = %v, want ManifestError", err)
}
if !strings.Contains(err.Error(), "enabled") {
t.Fatalf("error = %v, want it to name the enabled field", err)
}
})
}
for _, raw := range []string{``, `{`, `[]`, `null`, `"str"`, `{} {}`, `{}{}`} {
t.Run("document "+raw, func(t *testing.T) {
if _, _, err := toggleEnabledManifest([]byte(raw), false); err == nil {
t.Fatalf("toggleEnabledManifest(%q) succeeded, want error", raw)
}
})
}
}
// TestSetEnabledByIDHonorsTheLastDuplicateEnabledValue keeps the toggle in step
// with the loader on a manifest whose earlier "enabled" value is overwritten:
// the loader discovers the plugin from the last value, so the toggle must be
// able to change it too.
func TestSetEnabledByIDHonorsTheLastDuplicateEnabledValue(t *testing.T) {
root := t.TempDir()
writeRawManifest(t, root, "demo", `{
"schemaVersion": 1,
"id": "zero.demo",
"name": "Demo",
"version": "1.0.0",
"enabled": "obsolete",
"enabled": true
}
`)
loadedEnabled := func() bool {
t.Helper()
plugin, ok, err := FindByID(enableTestOptions(root), "zero.demo")
if err != nil || !ok {
t.Fatalf("FindByID: ok=%v err=%v", ok, err)
}
return plugin.Enabled
}
if !loadedEnabled() {
t.Fatal("the loader should discover the plugin as enabled from the last value")
}
for _, enabled := range []bool{false, true} {
result, err := SetEnabledByID(enableTestOptions(root), "zero.demo", enabled)
if err != nil {
t.Fatalf("SetEnabledByID(%v): %v", enabled, err)
}
if !result.Changed {
t.Fatalf("SetEnabledByID(%v) reported no change", enabled)
}
if got := loadedEnabled(); got != enabled {
t.Fatalf("after SetEnabledByID(%v) the loader sees enabled=%v", enabled, got)
}
}
}
// TestSetEnabledRefusesASwappedPluginDirectory swaps the plugin directory that
// discovery found for a link between the lookup and the edit. The install lock
// does not cover that window, because it only serializes cooperating
// installers. The edit must fail without following the link: neither the
// link's target nor the moved-away original may change.
func TestSetEnabledRefusesASwappedPluginDirectory(t *testing.T) {
cases := []struct {
name string
// link returns the symlink target to plant and the manifest it leads to.
link func(t *testing.T, root string) (target string, manifest string)
}{
{"to a directory outside the plugins root", func(t *testing.T, root string) (string, string) {
manifest := writeRawManifest(t, t.TempDir(), "elsewhere", demoManifest(true))
return filepath.Dir(manifest), manifest
}},
{"to another plugin in the same root", func(t *testing.T, root string) (string, string) {
// A relative link stays inside the root, so confinement to the
// root alone would follow it.
manifest := writeRawManifest(t, root, "other", strings.Replace(demoManifest(true), "zero.demo", "zero.other", 1))
return "other", manifest
}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
root := t.TempDir()
raw := demoManifest(true)
writeRawManifest(t, root, "demo", raw)
plugin, ok, err := FindByID(enableTestOptions(root), "zero.demo")
if err != nil || !ok {
t.Fatalf("FindByID: ok=%v err=%v", ok, err)
}
target, targetManifest := tc.link(t, root)
targetBefore, err := os.ReadFile(targetManifest)
if err != nil {
t.Fatal(err)
}
moved := filepath.Join(t.TempDir(), "moved-away")
if err := os.Rename(plugin.PluginDir, moved); err != nil {
t.Fatal(err)
}
if err := os.Symlink(target, plugin.PluginDir); err != nil {
t.Skipf("cannot create a directory symlink here: %v", err)
}
if _, err := setEnabledLocked(plugin, false); !errors.Is(err, pathjail.ErrReparse) {
t.Fatalf("setEnabledLocked through a swapped directory: err = %v, want %v", err, pathjail.ErrReparse)
}
if after, err := os.ReadFile(targetManifest); err != nil || string(after) != string(targetBefore) {
t.Fatalf("the edit followed the link and changed %s (err %v):\n got %q\nwant %q", targetManifest, err, after, targetBefore)
}
if after, err := os.ReadFile(filepath.Join(moved, "plugin.json")); err != nil || string(after) != raw {
t.Fatalf("the moved-away manifest changed (err %v): %q", err, after)
}
})
}
}
// TestSetEnabledDetectsAPluginDirectoryReplacedAfterOpening replaces the live
// plugin directory after the toggle pinned it. The edit then lands in the
// moved-away directory, so the toggle must not refresh the lockfile from either
// tree and must not report success: the plugin installed at the path now is
// unchanged.
func TestSetEnabledDetectsAPluginDirectoryReplacedAfterOpening(t *testing.T) {
for _, tc := range []struct {
name string
pinned bool // the pinned manifest's enabled value
changes bool // whether disabling it is a real edit
}{
{"edit", true, true},
{"no-op", false, false},
} {
t.Run(tc.name, func(t *testing.T) {
root := t.TempDir()
manifestPath := writeRawManifest(t, root, "demo", demoManifest(tc.pinned))
pluginDir := filepath.Dir(manifestPath)
hash, err := hashTree(pluginDir)
if err != nil {
t.Fatal(err)
}
if err := writeLock(root, map[string]LockEntry{"zero.demo": {Source: "./demo", Hash: hash}}); err != nil {
t.Fatal(err)
}
lockPath := filepath.Join(root, LockFileName)
lockBefore, err := os.ReadFile(lockPath)
if err != nil {
t.Fatal(err)
}
plugin, ok, err := FindByID(enableTestOptions(root), "zero.demo")
if err != nil || !ok {
t.Fatalf("FindByID: ok=%v err=%v", ok, err)
}
pinned, err := openPluginDir(plugin)
if err != nil {
t.Fatalf("openPluginDir: %v", err)
}
defer pinned.Close()
moved := filepath.Join(t.TempDir(), "moved-away")
if err := os.Rename(pluginDir, moved); err != nil {
t.Fatal(err)
}
replacement := strings.Replace(demoManifest(true), `"version": "1.0.0"`, `"version": "2.0.0"`, 1)
writeRawManifest(t, root, "demo", replacement)
// The lockfile check hashes the pinned tree, which still matches the
// recorded hash, not the replacement now at the path.
if _, _, clean := cleanLockEntry(plugin, pinned.dir); !clean {
t.Error("cleanLockEntry hashed the replacement at the path instead of the pinned directory")
}
if _, err := setEnabledPinned(plugin, pinned, false); !errors.Is(err, errPluginDirReplaced) {
t.Fatalf("setEnabledPinned after the directory was replaced: err = %v, want %v", err, errPluginDirReplaced)
}
if after, err := os.ReadFile(manifestPath); err != nil || string(after) != replacement {
t.Fatalf("the replacement manifest changed (err %v): %q", err, after)
}
if after, err := os.ReadFile(lockPath); err != nil || string(after) != string(lockBefore) {
t.Fatalf("the lockfile changed (err %v):\n got %s\nwant %s", err, after, lockBefore)
}
movedRaw, err := os.ReadFile(filepath.Join(moved, "plugin.json"))
if err != nil {
t.Fatal(err)
}
if tc.changes != (string(movedRaw) != demoManifest(tc.pinned)) {
t.Fatalf("pinned manifest edit = %v, want %v: %q", !tc.changes, tc.changes, movedRaw)
}
})
}
}
func TestSetEnabledByIDRespectsUserOnlyFilter(t *testing.T) {
dir := t.TempDir()
userRoot := filepath.Join(dir, "user")
projectRoot := filepath.Join(dir, "project")
writePluginManifest(t, filepath.Join(userRoot, "demo"), map[string]any{
"schemaVersion": 1,
"id": "zero.demo",
"name": "User Demo",
"version": "0.1.0",
"enabled": true,
})
writePluginManifest(t, filepath.Join(projectRoot, "demo"), map[string]any{
"schemaVersion": 1,
"id": "zero.demo",
"name": "Project Demo",
"version": "0.2.0",
"enabled": true,
})
result, err := SetEnabledByID(LoadOptions{
Roots: []Root{
{Source: SourceUser, Path: userRoot},
{Source: SourceProject, Path: projectRoot},
},
}, "zero.demo", false)
if err != nil {
t.Fatalf("SetEnabledByID: %v", err)
}
if result.Source != SourceProject {
t.Fatalf("source = %s, want project (precedence)", result.Source)
}
if !result.Changed || result.Enabled {
t.Fatalf("unexpected result: %#v", result)
}
userOnly, err := SetEnabledByID(LoadOptions{
Roots: []Root{
{Source: SourceUser, Path: userRoot},
{Source: SourceProject, Path: projectRoot},
},
ExcludeProject: true,
}, "zero.demo", false)
if err != nil {
t.Fatalf("SetEnabledByID user-only: %v", err)
}
if userOnly.Source != SourceUser {
t.Fatalf("user-only source = %s, want user", userOnly.Source)
}
}
func TestSetEnabledPreservesManifestMode(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("Windows does not preserve chmod bits in os.Stat mode")
}
root := t.TempDir()
writePluginManifest(t, filepath.Join(root, "demo"), map[string]any{
"schemaVersion": 1,
"id": "zero.demo",
"name": "Demo",
"version": "1.0.0",
})
manifestPath := filepath.Join(root, "demo", "plugin.json")
if err := os.Chmod(manifestPath, 0o644); err != nil {
t.Fatal(err)
}
if _, err := SetEnabledByID(enableTestOptions(root), "zero.demo", false); err != nil {
t.Fatalf("SetEnabledByID: %v", err)
}
info, err := os.Stat(manifestPath)
if err != nil {
t.Fatal(err)
}
if got := info.Mode().Perm(); got != 0o644 {
t.Fatalf("mode = %04o, want 0644", got)
}
}
func TestSetEnabledByIDMissingPlugin(t *testing.T) {
_, err := SetEnabledByID(LoadOptions{Roots: []Root{{Source: SourceUser, Path: t.TempDir()}}}, "missing", false)
if err == nil {
t.Fatal("expected missing plugin error")
}
if !errors.Is(err, ErrNotInstalled) {
t.Fatalf("error = %v, want ErrNotInstalled", err)
}
}
func TestSetEnabledPublishFailureLeavesManifestIntact(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("Windows directory permissions do not block writes the way this test needs")
}
if os.Geteuid() == 0 {
t.Skip("root bypasses directory permission checks")
}
root := t.TempDir()
raw := demoManifest(true)
manifestPath := writeRawManifest(t, root, "demo", raw)
pluginDir := filepath.Dir(manifestPath)
if err := os.Chmod(pluginDir, 0o555); err != nil {
t.Fatal(err)
}
defer func() { _ = os.Chmod(pluginDir, 0o755) }()
if _, err := SetEnabledByID(enableTestOptions(root), "zero.demo", false); err == nil {
t.Fatal("expected the publish to fail in a read-only plugin directory")
}
after, err := os.ReadFile(manifestPath)
if err != nil {
t.Fatal(err)
}
if string(after) != raw {
t.Fatalf("failed publish changed the manifest:\n got %q\nwant %q", after, raw)
}
}
// TestSetEnabledByIDWaitsForInstallRootLock proves the toggle takes the same
// cross-process lock Install and Remove take on the plugin's install root: it
// must not complete while an install holds that lock.
func TestSetEnabledByIDWaitsForInstallRootLock(t *testing.T) {
root := t.TempDir()
manifestPath := writeRawManifest(t, root, "demo", demoManifest(true))
unlock, err := installtxn.Lock(root)
if err != nil {
t.Fatal(err)
}
results := make(chan SetEnabledResult, 1)
failures := make(chan error, 1)
go func() {
result, err := SetEnabledByID(enableTestOptions(root), "zero.demo", false)
if err != nil {
failures <- err
return
}
results <- result
}()
select {
case result := <-results:
unlock()
t.Fatalf("toggle completed while the install lock was held: %#v", result)
case err := <-failures:
unlock()
t.Fatalf("toggle failed while the install lock was held: %v", err)
case <-time.After(200 * time.Millisecond):
}
unlock()
select {
case result := <-results:
if !result.Changed {
t.Fatalf("toggle did not report a change: %#v", result)
}
case err := <-failures:
t.Fatalf("toggle failed after the lock was released: %v", err)
case <-time.After(10 * time.Second):
t.Fatal("toggle did not finish after the install lock was released")
}
raw, err := os.ReadFile(manifestPath)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(raw), `"enabled": false`) {
t.Fatalf("manifest not disabled: %s", raw)
}
}
// TestSetEnabledByIDSynchronizesWithStagedInstallCommit pauses an install
// between staging and commit, lets the toggle try to run, then commits the
// install. The toggle must apply to the newly installed manifest instead of
// overwriting it with bytes read before the install, and the lockfile entry
// must describe the tree actually on disk.
func TestSetEnabledByIDSynchronizesWithStagedInstallCommit(t *testing.T) {
root := t.TempDir()
oldRaw := demoManifest(true)
pluginDir := filepath.Dir(writeRawManifest(t, root, "zero.demo", oldRaw))
// Build the replacement tree with the same staging primitives Install
// uses and leave it staged but uncommitted.
src := t.TempDir()
newRaw := `{
"schemaVersion": 1,
"id": "zero.demo",
"name": "Demo",
"version": "2.0.0",
"description": "reinstalled",
"enabled": true
}
`
srcPlugin := filepath.Join(src, "zero.demo")
if err := os.MkdirAll(srcPlugin, 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(srcPlugin, "plugin.json"), []byte(newRaw), 0o600); err != nil {
t.Fatal(err)
}
staged, cleanup, err := installtxn.StageDir(root)
if err != nil {
t.Fatal(err)
}
defer cleanup()
if err := copyTree(srcPlugin, staged); err != nil {
t.Fatal(err)
}
stagedHash, err := hashTree(staged)
if err != nil {
t.Fatal(err)
}
// An install holds the root lock from before its commit until after it.
unlock, err := installtxn.Lock(root)
if err != nil {
t.Fatal(err)
}
results := make(chan SetEnabledResult, 1)
failures := make(chan error, 1)
go func() {
result, err := SetEnabledByID(enableTestOptions(root), "zero.demo", false)
if err != nil {
failures <- err
return
}
results <- result
}()
select {
case result := <-results:
unlock()
t.Fatalf("toggle completed while the install lock was held: %#v", result)
case err := <-failures:
unlock()
t.Fatalf("toggle failed while the install lock was held: %v", err)
case <-time.After(200 * time.Millisecond):
}
// Commit the staged install exactly like Install does: swap the tree and
// publish the lockfile entry while still holding the lock.
lock, err := ReadLock(root)
if err != nil {
unlock()
t.Fatal(err)
}
lock["zero.demo"] = LockEntry{Source: string(SourceUser), Hash: stagedHash}
if err := installtxn.CommitDir(pluginDir, staged, func() error { return writeLock(root, lock) }); err != nil {
unlock()
t.Fatal(err)
}
unlock()
var result SetEnabledResult
select {
case result = <-results:
case err := <-failures:
t.Fatalf("toggle failed after the install committed: %v", err)
case <-time.After(10 * time.Second):
t.Fatal("toggle did not finish after the install committed")
}
if !result.Changed {
t.Fatalf("toggle result = %#v, want changed", result)
}
// The toggle must have applied to the newly installed manifest, not to the
// bytes it first read.
final, err := os.ReadFile(filepath.Join(pluginDir, "plugin.json"))
if err != nil {
t.Fatal(err)
}
want := strings.Replace(newRaw, `"enabled": true`, `"enabled": false`, 1)
if string(final) != want {
t.Fatalf("manifest after concurrent install + disable:\n got %q\nwant %q", final, want)
}
// The lockfile entry must describe the tree that is actually on disk.
entries, err := ReadLock(root)
if err != nil {
t.Fatal(err)
}
current, err := hashTree(pluginDir)
if err != nil {
t.Fatal(err)
}
if entries["zero.demo"].Hash != current {
t.Fatalf("plugins.lock hash = %q, want current tree hash %q", entries["zero.demo"].Hash, current)
}
}
func TestSetEnabledByIDRefreshesCleanLockEntry(t *testing.T) {
root := t.TempDir()
manifestPath := writeRawManifest(t, root, "zero.demo", demoManifest(true))
pluginDir := filepath.Dir(manifestPath)
current, err := hashTree(pluginDir)
if err != nil {
t.Fatal(err)
}
if err := writeLock(root, map[string]LockEntry{"zero.demo": {Source: string(SourceUser), Hash: current}}); err != nil {
t.Fatal(err)
}
if _, err := SetEnabledByID(enableTestOptions(root), "zero.demo", false); err != nil {
t.Fatalf("SetEnabledByID: %v", err)
}
entries, err := ReadLock(root)
if err != nil {
t.Fatal(err)
}
afterToggle, err := hashTree(pluginDir)
if err != nil {
t.Fatal(err)
}
if entries["zero.demo"].Hash == current {
t.Fatal("lock hash was not refreshed for a clean install")
}
if entries["zero.demo"].Hash != afterToggle {
t.Fatalf("lock hash = %q, want refreshed tree hash %q", entries["zero.demo"].Hash, afterToggle)
}
info, err := Info(InfoOptions{LoadOptions: enableTestOptions(root)}, "zero.demo")
if err != nil {
t.Fatal(err)
}
if info.HashDrift {
t.Fatal("toggle left hash drift behind for a clean install")
}
// Pre-existing drift must survive the next toggle instead of being
// absorbed into the lockfile.
if err := os.WriteFile(filepath.Join(pluginDir, "extra.txt"), []byte("edited\n"), 0o600); err != nil {
t.Fatal(err)
}
if _, err := SetEnabledByID(enableTestOptions(root), "zero.demo", true); err != nil {
t.Fatalf("SetEnabledByID(true): %v", err)
}
entries, err = ReadLock(root)
if err != nil {
t.Fatal(err)
}
if entries["zero.demo"].Hash != afterToggle {
t.Fatalf("toggle absorbed pre-existing drift: hash = %q, want %q", entries["zero.demo"].Hash, afterToggle)
}
}
// TestSetEnabledByIDReportsAPluginRemovedWhileWaiting removes the plugin while
// the toggle is blocked on the install-root lock, exactly as Remove does while
// it holds that lock. The toggle must report the plugin as not installed
// instead of editing, or recreating, a tree that no longer exists.
func TestSetEnabledByIDReportsAPluginRemovedWhileWaiting(t *testing.T) {
root := t.TempDir()
pluginDir := filepath.Dir(writeRawManifest(t, root, "zero.demo", demoManifest(true)))
unlock, err := installtxn.Lock(root)
if err != nil {
t.Fatal(err)
}
failures := make(chan error, 1)
go func() {
_, err := SetEnabledByID(enableTestOptions(root), "zero.demo", false)
failures <- err
}()
select {
case err := <-failures:
unlock()
t.Fatalf("toggle returned while the install lock was held: %v", err)
case <-time.After(200 * time.Millisecond):
}
if err := installtxn.RemoveDir(pluginDir, func() error { return nil }); err != nil {
unlock()
t.Fatal(err)
}
unlock()
select {
case err := <-failures:
if !errors.Is(err, ErrNotInstalled) {
t.Fatalf("toggle after a concurrent removal: error = %v, want ErrNotInstalled", err)
}
case <-time.After(10 * time.Second):
t.Fatal("toggle did not finish after the removal released the lock")
}
if _, err := os.Stat(pluginDir); !os.IsNotExist(err) {
t.Fatalf("toggle recreated the removed plugin: %v", err)
}
}
// TestSetEnabledByIDResolvesInterruptedUpdatesFirst covers every point an
// update can be killed at that leaves the plugin discoverable. The toggle
// holds the install-root lock, so it has to
// run the same recovery pass Install and Remove run before it reads the tree:
// otherwise it edits, or fails to find, a tree that is still owed a restore,
// and the next install or removal puts the backup back over the user's edit.
func TestSetEnabledByIDResolvesInterruptedUpdatesFirst(t *testing.T) {
for _, state := range interruptedStates() {
t.Run(state.name, func(t *testing.T) {
u := seedInterruptedUpdate(t)
workspace := state.plant(t, u)
want := state.want(u)
if _, err := os.Lstat(u.target()); os.IsNotExist(err) {
// No root lists a plugin whose only copy is in a workspace, so the
// toggle reports it not installed without taking any lock, just as
// list and info do; the next install or removal restores it.
t.Skip("nothing is at the target, so the toggle never takes the lock")
}
result, err := SetEnabledByID(enableTestOptions(u.dir), "zero.demo", false)
if err != nil {
t.Fatalf("toggle after the interrupted update: %v", err)
}
if !result.Changed || result.Enabled {
t.Fatalf("toggle result = %#v, want changed and disabled", result)
}
assertDisabledAfterRecovery(t, u, workspace, want)
// The next lock holder runs recovery again. The disable has to survive
// it rather than be replaced by a backup the toggle never saw.
if err := installOther(t, u.dir, "zero.other"); err != nil {
t.Fatalf("install after the toggle: %v", err)
}
assertDisabledAfterRecovery(t, u, workspace, want)
})
}
}
func assertDisabledAfterRecovery(t *testing.T, u interruptedUpdate, workspace string, want recoveryWant) {
t.Helper()
data, err := os.ReadFile(filepath.Join(u.target(), "generation.txt"))
if err != nil {
t.Fatalf("reading the live tree: %v", err)
}
if string(data) != want.generation {
t.Errorf("the live tree is the %q generation, want %q", data, want.generation)
}
plugin, ok, err := FindByID(enableTestOptions(u.dir), "zero.demo")
if err != nil {
t.Fatal(err)
}
if !ok {
t.Fatal("Load does not see the toggled plugin")
}
if plugin.Version != want.version {
t.Errorf("Load sees version %q, want %q", plugin.Version, want.version)
}
if plugin.Enabled {
t.Errorf("the disable did not stick: Load sees the plugin enabled")
}
_, err = os.Stat(workspace)
if want.workspace && err != nil {
t.Errorf("the workspace should be left alone, got %v", err)
}
if !want.workspace && !os.IsNotExist(err) {
t.Errorf("the resolved workspace should be cleared, got %v", err)
}
}
// TestSetEnabledByIDAbortsOnAnUnresolvedInterruptedUpdate leaves a workspace
// whose trees match neither lockfile hash. Recovery cannot pick one, so the
// toggle must fail without editing the live manifest or the lockfile.
func TestSetEnabledByIDAbortsOnAnUnresolvedInterruptedUpdate(t *testing.T) {
u := seedInterruptedUpdate(t)
workspace := u.plantWorkspace(t)
u.mustRename(t, u.target(), filepath.Join(workspace, "previous"))
u.mustRename(t, filepath.Join(workspace, "staged"), u.target())
unmatched := LockEntry{Source: u.oldSource, Hash: "sha256:0000000000000000000000000000000000000000000000000000000000000000"}
lock, err := ReadLock(u.dir)
if err != nil {
t.Fatal(err)
}
lock["zero.demo"] = unmatched
if err := writeLock(u.dir, lock); err != nil {
t.Fatal(err)
}
manifestPath := filepath.Join(u.target(), "plugin.json")
before, err := os.ReadFile(manifestPath)
if err != nil {
t.Fatal(err)
}
if _, err := SetEnabledByID(enableTestOptions(u.dir), "zero.demo", false); err == nil {
t.Fatal("toggle did not report the unresolved transaction")
}
after, err := os.ReadFile(manifestPath)
if err != nil {
t.Fatal(err)
}
if string(after) != string(before) {
t.Errorf("the aborted toggle edited the manifest:\n got %q\nwant %q", after, before)
}
assertUntouched(t, u, "new", unmatched)
if _, err := os.Stat(filepath.Join(workspace, "previous")); err != nil {
t.Errorf("the backup should still be there for a hand rescue, got %v", err)
}
}
-164
View File
@@ -1,164 +0,0 @@
package plugins
import (
"context"
"strings"
"testing"
"github.com/Twigpine/zero/internal/execution"
"github.com/Twigpine/zero/internal/tools"
)
// THE DISCLOSURE HAS TO SURVIVE THE PROJECTION.
//
// The execution runner puts enforcement notices on the structured outcome, and
// this path used to copy only stdout, stderr and an exit code out of it. A
// plugin tool therefore ran under the non-WRITE_RESTRICTED token and returned a
// result that said nothing about the write jail it had just traded away.
//
// Asserted through pluginTool.invoke, which is what the registry calls, and
// through Result.ModelOutput, which is what the model actually reads.
func TestAPluginToolCarriesTheEnforcementNotice(t *testing.T) {
const notice = "denyRead is configured, so the write jail is not confining writes"
for _, testCase := range []struct {
name string
exitCode int
}{
{"successful command", 0},
{"failed command", 3},
} {
t.Run(testCase.name, func(t *testing.T) {
tool := pluginTool{
name: "demo",
run: func(context.Context, pluginCommand) commandOutput {
return commandOutput{Stdout: "hello", ExitCode: testCase.exitCode, Notices: []string{notice}}
},
}
result := tool.invoke(context.Background(), map[string]any{}, t.TempDir())
if len(result.EnforcementNotices) == 0 {
t.Fatal("the plugin result carried no enforcement notice; the command ran under the weakened token and said nothing")
}
model := result.ModelOutput()
if !strings.Contains(model, notice) {
t.Errorf("the model-facing output does not contain the notice:\n%s", model)
}
if strings.Count(model, notice) != 1 {
t.Errorf("the notice appears %d times, want exactly once:\n%s", strings.Count(model, notice), model)
}
if summary := result.HumanDisplay().Summary; !strings.Contains(summary, notice) {
t.Errorf("the human summary does not contain the notice: %q", summary)
}
})
}
}
// And a command with no notice is unchanged, or the assertion above would be
// satisfied by text pasted onto everything.
func TestAPluginToolWithoutANoticeIsUnchanged(t *testing.T) {
tool := pluginTool{
name: "demo",
run: func(context.Context, pluginCommand) commandOutput {
return commandOutput{Stdout: "hello", ExitCode: 0}
},
}
result := tool.invoke(context.Background(), map[string]any{}, t.TempDir())
if len(result.EnforcementNotices) != 0 {
t.Errorf("a command with no enforcement notice grew one: %v", result.EnforcementNotices)
}
if result.Status != tools.StatusOK {
t.Errorf("status = %v, want ok", result.Status)
}
}
// A TIMEOUT OR CANCELLATION STILL RAN THE CHILD.
//
// invoke's error branch rebuilt a result from status, output and metadata alone,
// so a plugin that timed out under the non-WRITE_RESTRICTED token reported only
// the timeout. The process had already launched without write confinement;
// whether the disclosure survives must not depend on how it ended.
func TestAPluginToolCarriesTheNoticeWhenItTimesOutOrIsCancelled(t *testing.T) {
const notice = "denyRead is configured, so the write jail is not confining writes"
for _, testCase := range []struct {
name string
err error
}{
{"timed out", context.DeadlineExceeded},
{"cancelled", context.Canceled},
} {
t.Run(testCase.name, func(t *testing.T) {
tool := pluginTool{
name: "demo",
run: func(context.Context, pluginCommand) commandOutput {
return commandOutput{ExitCode: -1, Err: testCase.err, Notices: []string{notice}}
},
}
result := tool.invoke(context.Background(), map[string]any{}, t.TempDir())
model := result.ModelOutput()
if !strings.Contains(model, notice) {
t.Errorf("the model-facing output lost the notice:\n%s", model)
}
if strings.Count(model, notice) != 1 {
t.Errorf("the notice appears %d times, want once:\n%s", strings.Count(model, notice), model)
}
if summary := result.HumanDisplay().Summary; !strings.Contains(summary, notice) {
t.Errorf("the human summary lost the notice: %q", summary)
}
})
}
}
// But a child that never launched must stay silent, or the notice describes a
// trade nobody made.
//
// KEYED ON THE RECORDED FACT, NOT ON THE OUTCOME KIND. Reading the kind as a
// launch-state field is wrong in both directions: a child that ran and then
// produced an unreadable adapter report is rewritten to a setup failure, so the
// disclosure is dropped although it applied, and a context cancelled before
// os.StartProcess yields a cancellation, so the disclosure is claimed for a
// process that never existed. This is why the earlier version of this test,
// which asserted that the kind decides, was encoding the defect.
func TestAPluginNoticeFollowsRecordedLaunchState(t *testing.T) {
const notice = "denyRead is configured, so the write jail is not confining writes"
enforcement := execution.Enforcement{Notices: []string{notice}}
// The two directions the kind gets wrong, spelled out.
ranThenReportFailed := execution.Outcome{
Kind: execution.OutcomeSandboxSetupFailure,
Launched: true,
Enforcement: enforcement,
}
if got := ranThenReportFailed.AppliedEnforcementNotices(); len(got) != 1 {
t.Errorf("a child that ran lost its disclosure because the report failed afterwards: %#v", got)
}
cancelledBeforeStart := execution.Outcome{
Kind: execution.OutcomeCancelled,
Launched: false,
Enforcement: enforcement,
}
if got := cancelledBeforeStart.AppliedEnforcementNotices(); len(got) != 0 {
t.Errorf("a process that never started claimed an enforcement trade: %#v", got)
}
// And the ordinary pairs still behave.
for _, testCase := range []struct {
name string
outcome execution.Outcome
discloses bool
}{
{"launched and succeeded", execution.Outcome{Kind: execution.OutcomeSuccess, Launched: true, Enforcement: enforcement}, true},
{"launched then timed out", execution.Outcome{Kind: execution.OutcomeTimedOut, Launched: true, Enforcement: enforcement}, true},
{"never launched, missing executable", execution.Outcome{Kind: execution.OutcomeExecutableNotFound, Launched: false, Enforcement: enforcement}, false},
{"never launched, setup failed", execution.Outcome{Kind: execution.OutcomeSandboxSetupFailure, Launched: false, Enforcement: enforcement}, false},
} {
t.Run(testCase.name, func(t *testing.T) {
if got := len(testCase.outcome.AppliedEnforcementNotices()) > 0; got != testCase.discloses {
t.Errorf("discloses = %v, want %v", got, testCase.discloses)
}
})
}
}
+2 -1
View File
@@ -7,7 +7,8 @@ import (
"strings"
)
// ErrNotInstalled is returned when Info cannot find a plugin with the requested id.
// ErrNotInstalled is returned when a plugin id cannot be found under the given
// LoadOptions (for example by Info or SetEnabledByID).
var ErrNotInstalled = errors.New("plugin is not installed")
// PluginInfo combines a loaded plugin with optional lockfile metadata.
+37 -11
View File
@@ -567,15 +567,32 @@ func validInstallID(id string) bool {
// sorted order. Each file contributes its plugin-relative path, executable bit,
// and bytes, so renames, mode flips, and content edits all change the hash.
func hashTree(root string) (string, error) {
handle, err := os.OpenRoot(root)
if err != nil {
return "", err
}
defer handle.Close()
return hashRootedTree(handle)
}
// hashRootedTree is hashTree over a directory that is already open. Walking
// relative to the handle hashes the directory it names even if its path is
// replaced meanwhile, and keeps the walk inside it.
//
// The walk calls the handle's own methods rather than handle.FS(): the fs.FS
// adapter only accepts fs.ValidPath names, which must be UTF-8, so it would
// refuse file names that the filesystem and copyTree accept.
func hashRootedTree(handle *os.Root) (string, error) {
hasher := sha256.New()
if err := hashTreeInto(hasher, root, root); err != nil {
if err := hashTreeInto(hasher, handle, "."); err != nil {
return "", err
}
return "sha256:" + hex.EncodeToString(hasher.Sum(nil)), nil
}
func hashTreeInto(hasher io.Writer, root string, dir string) error {
entries, err := os.ReadDir(dir)
// hashTreeInto hashes dir, a path relative to handle, into hasher.
func hashTreeInto(hasher io.Writer, handle *os.Root, dir string) error {
entries, err := readRootedDir(handle, dir)
if err != nil {
return err
}
@@ -588,8 +605,8 @@ func hashTreeInto(hasher io.Writer, root string, dir string) error {
if name == ".git" {
continue
}
path := filepath.Join(dir, name)
info, err := os.Lstat(path)
rel := filepath.Join(dir, name)
info, err := handle.Lstat(rel)
if err != nil {
return err
}
@@ -598,14 +615,10 @@ func hashTreeInto(hasher io.Writer, root string, dir string) error {
// Skipped by copyTree, so excluded from the hash too.
continue
case info.IsDir():
if err := hashTreeInto(hasher, root, path); err != nil {
if err := hashTreeInto(hasher, handle, rel); err != nil {
return err
}
case info.Mode().IsRegular():
rel, err := filepath.Rel(root, path)
if err != nil {
return err
}
executable := 0
if info.Mode().Perm()&0o111 != 0 {
executable = 1
@@ -616,7 +629,7 @@ func hashTreeInto(hasher io.Writer, root string, dir string) error {
if _, err := io.WriteString(hasher, header); err != nil {
return err
}
file, err := os.Open(path)
file, err := handle.Open(rel)
if err != nil {
return err
}
@@ -634,3 +647,16 @@ func hashTreeInto(hasher io.Writer, root string, dir string) error {
}
return nil
}
// readRootedDir lists dir, a path relative to handle, like os.ReadDir.
func readRootedDir(handle *os.Root, dir string) ([]os.DirEntry, error) {
file, err := handle.Open(dir)
if err != nil {
return nil, err
}
entries, err := file.ReadDir(-1)
if closeErr := file.Close(); err == nil {
err = closeErr
}
return entries, err
}
+59
View File
@@ -1181,3 +1181,62 @@ func TestRecoveryLeavesAPrefixCollidingDirectoryAlone(t *testing.T) {
t.Errorf("recovery published content it does not own: %v", err)
}
}
// TestNonUTF8FileNamesAreInstalledHashedAndToggled covers a plugin whose tree
// holds a file and a directory named with bytes that are not valid UTF-8, which
// Linux allows. copyTree copies them, so hashTree must hash them too: Install
// records the hash, the toggle refreshes it, and Info sees an edit to that file
// as drift. A walk that rejects such names breaks all three.
func TestNonUTF8FileNamesAreInstalledHashedAndToggled(t *testing.T) {
src := writeSourcePlugin(t, filepath.Join(t.TempDir(), "src"), validManifest())
nested := filepath.Join("assets\xff", "data\xfe.bin")
if err := os.Mkdir(filepath.Join(src, "assets\xff"), 0o755); err != nil {
t.Skipf("this filesystem rejects non-UTF-8 names: %v", err)
}
if err := os.WriteFile(filepath.Join(src, nested), []byte("original\n"), 0o644); err != nil {
t.Skipf("this filesystem rejects non-UTF-8 names: %v", err)
}
destDir := t.TempDir()
if _, err := Install(context.Background(), InstallOptions{Source: src, Dir: destDir}); err != nil {
t.Fatalf("Install a plugin with a non-UTF-8 file name: %v", err)
}
pluginDir := filepath.Join(destDir, "zero.demo")
if data, err := os.ReadFile(filepath.Join(pluginDir, nested)); err != nil || string(data) != "original\n" {
t.Fatalf("installed copy of the non-UTF-8 file = %q, %v", data, err)
}
options := LoadOptions{Roots: []Root{{Source: SourceUser, Path: destDir}}}
before, err := ReadLock(destDir)
if err != nil {
t.Fatal(err)
}
if _, err := SetEnabledByID(options, "zero.demo", false); err != nil {
t.Fatalf("SetEnabledByID on a plugin with a non-UTF-8 file name: %v", err)
}
after, err := ReadLock(destDir)
if err != nil {
t.Fatal(err)
}
if after["zero.demo"].Hash == before["zero.demo"].Hash {
t.Fatal("the toggle did not refresh the lock hash")
}
info, err := Info(InfoOptions{LoadOptions: options}, "zero.demo")
if err != nil {
t.Fatal(err)
}
if info.HashDrift {
t.Fatal("the toggle left hash drift behind")
}
if err := os.WriteFile(filepath.Join(pluginDir, nested), []byte("edited\n"), 0o644); err != nil {
t.Fatal(err)
}
info, err = Info(InfoOptions{LoadOptions: options}, "zero.demo")
if err != nil {
t.Fatal(err)
}
if !info.HashDrift {
t.Fatal("an edit to the non-UTF-8 file was not reported as drift")
}
}
+3 -1
View File
@@ -132,7 +132,9 @@ var descriptors = []Descriptor{
// way in; RequiresAuth is forced true so catalog consumers know the provider
// needs an interactive login before a request will succeed.
func() Descriptor {
d := openAICompat("chatgpt", "ChatGPT", "https://chatgpt.com/backend-api/codex", "gpt-5.5", nil)
// New ChatGPT profiles follow the vendor's GPT-5.5 replacement guidance:
// https://learn.chatgpt.com/docs/models#gpt-55-retirement
d := openAICompat("chatgpt", "ChatGPT", "https://chatgpt.com/backend-api/codex", "gpt-6.1-sol", nil)
d.RequiresAuth = true
return oauthProvider(d, false, false)
}(),
+20 -4
View File
@@ -44,6 +44,20 @@ var minimaxCuratedModels = []Model{
OutputModalities: []string{"text"},
Source: minimaxModelSource,
},
{
ID: "MiniMax-M3.1-Flash",
Description: "fast reasoning model",
ContextWindow: 1_000_000,
ToolCall: true,
Reasoning: true,
ReasoningEfforts: []string{"low", "medium", "high", "xhigh", "max"},
DefaultReasoningEffort: "max",
InputModalities: []string{"text", "image", "video"},
OutputModalities: []string{"text"},
InputCost: 0.15,
OutputCost: 0.60,
Source: minimaxModelSource,
},
{
ID: "MiniMax-M2.7",
Description: "agentic coding model",
@@ -113,10 +127,12 @@ var curatedModels = map[string][]Model{
{ID: "deepseek/deepseek-chat", Description: "coding model"},
},
"chatgpt": {
{ID: "gpt-5.5", Description: "recommended Codex model"},
{ID: "gpt-5.4", Description: "strong Codex model"},
{ID: "gpt-5.4-mini", Description: "fast Codex model"},
{ID: "gpt-5.3-codex-spark", Description: "research preview fast Codex model"},
// Suggestions only: availability comes from the authenticated Codex catalog.
// https://learn.chatgpt.com/docs/models
{ID: "gpt-6.1-sol", Description: "balanced Codex model"},
{ID: "gpt-6.1-terra", Description: "fast model for writing, visual understanding, and computer use"},
{ID: "gpt-6-astra", Description: "advanced reasoning model"},
{ID: "gpt-6-luna", Description: "efficient coding model"},
},
"deepseek": {
{ID: "deepseek-chat", Description: "catalog default"},
+22 -6
View File
@@ -35,8 +35,8 @@ func TestModelsAreProviderScoped(t *testing.T) {
},
{
provider: "chatgpt",
want: []string{"gpt-5.5", "gpt-5.4", "gpt-5.4-mini", "gpt-5.3-codex-spark"},
notWant: []string{"gpt-5", "gpt-4.1", "openai/gpt-4.1"},
want: []string{"gpt-6.1-sol", "gpt-6.1-terra", "gpt-6-astra", "gpt-6-luna"},
notWant: []string{"gpt-5.5", "gpt-5.4", "gpt-5.4-mini", "gpt-5.3-codex-spark", "openai/gpt-4.1"},
},
{
provider: "aimlapi",
@@ -65,7 +65,7 @@ func TestModelsAreProviderScoped(t *testing.T) {
},
{
provider: "minimaxi-cn",
want: []string{"MiniMax-M3", "MiniMax-M2.7", "MiniMax-M2.1"},
want: []string{"MiniMax-M3", "MiniMax-M3.1-Flash", "MiniMax-M2.7", "MiniMax-M2.1"},
notWant: []string{"gpt-4.1", "claude-sonnet-4.5"},
},
{
@@ -165,8 +165,8 @@ func TestMiniMaxModelsExposeCurrentCapabilities(t *testing.T) {
t.Fatal("provider minimax missing from catalog")
}
models := Models(descriptor)
if len(models) < 2 || models[0].ID != "MiniMax-M3" || models[1].ID != "MiniMax-M2.7" {
t.Fatalf("MiniMax current models = %#v, want MiniMax-M3 followed by MiniMax-M2.7", modelIDs(models))
if len(models) < 3 || models[0].ID != "MiniMax-M3" || models[1].ID != "MiniMax-M3.1-Flash" || models[2].ID != "MiniMax-M2.7" {
t.Fatalf("MiniMax current models = %#v, want MiniMax-M3, MiniMax-M3.1-Flash, then MiniMax-M2.7", modelIDs(models))
}
m3 := models[0]
@@ -178,7 +178,23 @@ func TestMiniMaxModelsExposeCurrentCapabilities(t *testing.T) {
t.Fatalf("MiniMax-M3 modalities = input:%#v output:%#v", m3.InputModalities, m3.OutputModalities)
}
m27 := models[1]
m31Flash := models[1]
if m31Flash.ContextWindow != 1_000_000 || !m31Flash.ToolCall || !m31Flash.Reasoning {
t.Fatalf("MiniMax-M3.1-Flash capabilities = %#v, want 1M context with tools and reasoning", m31Flash)
}
if !reflect.DeepEqual(m31Flash.ReasoningEfforts, []string{"low", "medium", "high", "xhigh", "max"}) ||
m31Flash.DefaultReasoningEffort != "max" {
t.Fatalf("MiniMax-M3.1-Flash reasoning efforts = %#v default %q", m31Flash.ReasoningEfforts, m31Flash.DefaultReasoningEffort)
}
if !reflect.DeepEqual(m31Flash.InputModalities, []string{"text", "image", "video"}) ||
!reflect.DeepEqual(m31Flash.OutputModalities, []string{"text"}) {
t.Fatalf("MiniMax-M3.1-Flash modalities = input:%#v output:%#v", m31Flash.InputModalities, m31Flash.OutputModalities)
}
if m31Flash.InputCost != 0.15 || m31Flash.OutputCost != 0.60 {
t.Fatalf("MiniMax-M3.1-Flash costs = %f/%f, want 0.15/0.60", m31Flash.InputCost, m31Flash.OutputCost)
}
m27 := models[2]
if m27.ContextWindow != 204_800 || !m27.ToolCall || !m27.Reasoning {
t.Fatalf("MiniMax-M2.7 capabilities = %#v, want 204.8K context with tools and reasoning", m27)
}
@@ -0,0 +1,31 @@
package providermodeldiscovery
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/Twigpine/zero/internal/config"
)
func TestChatGPTDiscoveryUsesCurrentCompatibilityVersion(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.URL.Query().Get("client_version"); got != "0.159.0" {
t.Errorf("client_version = %q, want documented GPT-6.1 Terra compatibility floor 0.159.0", got)
}
_, _ = w.Write([]byte(`{"models":[{"slug":"gpt-future-coder"}]}`))
}))
defer server.Close()
profile := config.ProviderProfile{
CatalogID: "chatgpt", ProviderKind: config.ProviderKindOpenAICompatible,
BaseURL: server.URL + "/backend-api/codex", APIKey: "test-bearer",
}
models, err := Discover(context.Background(), profile, Options{HTTPClient: server.Client()})
if err != nil {
t.Fatal(err)
}
if len(models) != 1 || models[0].ID != "gpt-future-coder" {
t.Fatalf("models = %#v, want only gpt-future-coder", models)
}
}
Loaded 100 of 184 files, more files were not shown because too many files have changed in this diff. Show more