mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-08 09:49:34 -10:00
Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e034a93815 | ||
|
|
2ca84cc127 | ||
|
|
05c1b5e160 | ||
|
|
16ccd78765 | ||
|
|
c9f179482a | ||
|
|
98736457bd | ||
|
|
e59632406b | ||
|
|
7c55a09c07 | ||
|
|
d99f92b81a | ||
|
|
5aa54fe1d2 | ||
|
|
f912f80d00 | ||
|
|
9c4e3693e7 | ||
|
|
63962081cf | ||
|
|
2e10a4b60b | ||
|
|
4247a07aba | ||
|
|
b837448dd4 | ||
|
|
a0b036a3c7 | ||
|
|
164150633a |
No files matched your search
@@ -1 +1 @@
|
||||
{".":"0.11.0"}
|
||||
{".":"0.12.0"}
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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 |
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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{}),
|
||||
})
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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(""))
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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, ¤t); 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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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) }
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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(¬icePreparer{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: ¬icePreparer{prepareErr: errors.New("could not build the restricted token")},
|
||||
wantNotice: false,
|
||||
},
|
||||
{
|
||||
name: "the executable was never found",
|
||||
preparer: ¬icePreparer{build: func() *exec.Cmd {
|
||||
return exec.Command("definitely-not-a-real-binary-zzz")
|
||||
}},
|
||||
wantNotice: false,
|
||||
},
|
||||
{
|
||||
name: "the child launched and succeeded",
|
||||
preparer: ¬icePreparer{build: func() *exec.Cmd { return shellCommand("exit 0") }},
|
||||
wantNotice: true,
|
||||
},
|
||||
{
|
||||
name: "the child launched and exited nonzero",
|
||||
preparer: ¬icePreparer{build: func() *exec.Cmd { return shellCommand("exit 3") }},
|
||||
wantNotice: true,
|
||||
},
|
||||
{
|
||||
name: "the child launched and timed out",
|
||||
preparer: ¬icePreparer{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(¬icePreparer{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(¬icePreparer{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)
|
||||
}
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}(),
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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
Reference in new issue
Block a user