mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-09 05:19:33 -10:00
Compare commits
136
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f2cdb5eeb | ||
|
|
1767f2dacf | ||
|
|
34129dfb5a | ||
|
|
703884b6a1 | ||
|
|
bf096f05a7 | ||
|
|
422e0d3c82 | ||
|
|
bcddd92eae | ||
|
|
16e30a9f18 | ||
|
|
281bc65c71 | ||
|
|
df9989eba1 | ||
|
|
56ce081107 | ||
|
|
378b38c363 | ||
|
|
738881bc11 | ||
|
|
17f55f4e5e | ||
|
|
6e980a8aa6 | ||
|
|
9b72d0da6a | ||
|
|
c753df3020 | ||
|
|
d3aab2c113 | ||
|
|
3ef649094d | ||
|
|
b7e97c7645 | ||
|
|
b0ffba6391 | ||
|
|
b9d86cee59 | ||
|
|
7330a17f99 | ||
|
|
926a1ebc41 | ||
|
|
5de72bfa4a | ||
|
|
c38fcabe67 | ||
|
|
8319484383 | ||
|
|
48f6a88fe4 | ||
|
|
ecbedb9112 | ||
|
|
08005ef5b3 | ||
|
|
4d3f5dc8cd | ||
|
|
76d607ecdc | ||
|
|
a2e9558262 | ||
|
|
8b7ffa4f46 | ||
|
|
a45d4000e1 | ||
|
|
b12b2d71a4 | ||
|
|
9abab7318d | ||
|
|
d376b7f2f0 | ||
|
|
95c8ac6245 | ||
|
|
c887961869 | ||
|
|
a186eb46d7 | ||
|
|
8099409f7c | ||
|
|
4234357774 | ||
|
|
9608817f47 | ||
|
|
673b84d644 | ||
|
|
69c68b18a2 | ||
|
|
daf58c8962 | ||
|
|
a8b295def3 | ||
|
|
30d3c4791e | ||
|
|
26958ac6dd | ||
|
|
7920d3be3d | ||
|
|
4320d08bae | ||
|
|
bd7e98c05c | ||
|
|
5a43074c30 | ||
|
|
0904d88b26 | ||
|
|
2925a6768b | ||
|
|
faa07e3937 | ||
|
|
834c184d4a | ||
|
|
8da16b455f | ||
|
|
c3e5e4bdfc | ||
|
|
89b2e6dfbc | ||
|
|
cd7805062f | ||
|
|
6f0d45323b | ||
|
|
d6b4e73e0e | ||
|
|
3574f62c2f | ||
|
|
fffc780dc3 | ||
|
|
272acb9324 | ||
|
|
d736aae19a | ||
|
|
129800b995 | ||
|
|
474f6e9b08 | ||
|
|
2a765446cb | ||
|
|
81c8326072 | ||
|
|
b9ba54a948 | ||
|
|
b97e744989 | ||
|
|
55023a9805 | ||
|
|
9800065f65 | ||
|
|
18ffd02b93 | ||
|
|
bfa40a79ca | ||
|
|
6e342cee45 | ||
|
|
03c7783905 | ||
|
|
9fa86240be | ||
|
|
282a29f610 | ||
|
|
602b21fab4 | ||
|
|
8c242fc33e | ||
|
|
9cb9139b77 | ||
|
|
8dd74e23d1 | ||
|
|
d0a0506fb4 | ||
|
|
47f26f65a1 | ||
|
|
229a6acdf6 | ||
|
|
05d7c2f62c | ||
|
|
f96b8b2f38 | ||
|
|
d43362aca7 | ||
|
|
2ca6ac69d5 | ||
|
|
a18523ad65 | ||
|
|
b5553577fc | ||
|
|
c3d041b0af | ||
|
|
d2968aef28 | ||
|
|
6a1b4562d0 | ||
|
|
011c4dad15 | ||
|
|
c2cf7b8f3a | ||
|
|
2d5e1f7885 | ||
|
|
bcae716a07 | ||
|
|
7f45a7cd54 | ||
|
|
cae96b2830 | ||
|
|
f18e645c8b | ||
|
|
949ee3d761 | ||
|
|
97c8c98068 | ||
|
|
7070993ef9 | ||
|
|
d685d83df6 | ||
|
|
7e996fa4fd | ||
|
|
6b54ac0814 | ||
|
|
8d42270e30 | ||
|
|
0b5950e882 | ||
|
|
c6921d4145 | ||
|
|
227f4ed6ef | ||
|
|
8a84471dc9 | ||
|
|
6ff26014d5 | ||
|
|
700e749e30 | ||
|
|
73fc1c07ae | ||
|
|
f0bc1a3821 | ||
|
|
2175366d4e | ||
|
|
fd27716dfc | ||
|
|
206046bc04 | ||
|
|
e8a0642c58 | ||
|
|
17d5674d61 | ||
|
|
17879dc480 | ||
|
|
a527ffc040 | ||
|
|
07f6492528 | ||
|
|
949e104844 | ||
|
|
0b8fcf4b1e | ||
|
|
3e238cc5ea | ||
|
|
645976d215 | ||
|
|
273bdede2a | ||
|
|
8dbe818934 | ||
|
|
ea7c04cdbb | ||
|
|
17aa51b530 |
No files matched your search
@@ -1 +1 @@
|
||||
{".":"0.13.0"}
|
||||
{".":"0.11.0"}
|
||||
@@ -6,52 +6,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html) once the first release is
|
||||
tagged. Until then, source builds report the version `dev`.
|
||||
|
||||
## [0.13.0](https://github.com/Twigpine/zero/compare/v0.12.0...v0.13.0) (2026-11-09)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **mcp:** add AI/ML API as an opt-in built-in server ([#1193](https://github.com/Twigpine/zero/issues/1193)) ([16ccd78](https://github.com/Twigpine/zero/commit/16ccd78765b6a2a56b7370e3031710d718aa4757))
|
||||
* **plugins:** add CLI enable/disable for plugin.json ([#1202](https://github.com/Twigpine/zero/issues/1202)) ([05c1b5e](https://github.com/Twigpine/zero/commit/05c1b5e16098e1c60212316d20ae8e055a27b5ae))
|
||||
* **providers:** point OpenGateway at api.twigpine.com and trust its live model list ([#1208](https://github.com/Twigpine/zero/issues/1208)) ([47f78c6](https://github.com/Twigpine/zero/commit/47f78c66247aa96c8753d6bb95e935126ff1761e))
|
||||
* **web-fetch:** add an opt-in AI/ML API Toolbox scraping backend ([#1192](https://github.com/Twigpine/zero/issues/1192)) ([c9f1794](https://github.com/Twigpine/zero/commit/c9f179482a7f6d7111ee64fa9cb8ffa2517c2397))
|
||||
* **web-search:** add AI/ML API Toolbox as an opt-in backend ([#1189](https://github.com/Twigpine/zero/issues/1189)) ([9873645](https://github.com/Twigpine/zero/commit/98736457bdac6b0272e032728a5485cf557780fe))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **hooks:** dispatch specialistStart/Stop lifecycle hooks ([#1204](https://github.com/Twigpine/zero/issues/1204)) ([2ca84cc](https://github.com/Twigpine/zero/commit/2ca84cc127d9369765004b72cf95049fecc6c28b))
|
||||
* **tui:** make reasoning stream rendering linear (coalesced deltas, O(1) append, tail-bounded preview) ([#1198](https://github.com/Twigpine/zero/issues/1198)) ([bbf779e](https://github.com/Twigpine/zero/commit/bbf779e69168d62bb1103aefdd1b10e53433209e))
|
||||
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* **tui:** bounded file read, LRU rendering cache, and size caps for full-file view ([#1203](https://github.com/Twigpine/zero/issues/1203)) ([e034a93](https://github.com/Twigpine/zero/commit/e034a93815629c68976dcdab7dcc08bbe9b03693))
|
||||
|
||||
## [0.12.0](https://github.com/Twigpine/zero/compare/v0.11.0...v0.12.0) (2026-11-02)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **providers:** add MiniMax-M3.1-Flash to catalog ([#1180](https://github.com/Twigpine/zero/issues/1180)) ([d99f92b](https://github.com/Twigpine/zero/commit/d99f92b81a671f186cede81614c89b8e8a7f9451))
|
||||
* **sandbox:** support self-exec pattern to eliminate separate helper binary ([#946](https://github.com/Twigpine/zero/issues/946)) ([8c4e759](https://github.com/Twigpine/zero/commit/8c4e7597989ef9b3ca67b7703dfd15d92372aed2))
|
||||
* **skills:** install a skill's bundled files and point the skill tool at them ([#1167](https://github.com/Twigpine/zero/issues/1167)) ([1641506](https://github.com/Twigpine/zero/commit/164150633a8b2e6ae864ae02fd7105b201eeb68e))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **agent:** mark headless guardrail stops incomplete ([#1182](https://github.com/Twigpine/zero/issues/1182)) ([5aa54fe](https://github.com/Twigpine/zero/commit/5aa54fe1d2b419c9bbef73605a77919c9007cf7b))
|
||||
* bump Go to 1.26.10 for GO-2026-6809 ([#1168](https://github.com/Twigpine/zero/issues/1168)) ([6393a90](https://github.com/Twigpine/zero/commit/6393a90ed618a3536e09c02852f4ec829b97545b))
|
||||
* **chatgpt:** refresh subscription defaults and compatibility version ([#1179](https://github.com/Twigpine/zero/issues/1179)) ([f912f80](https://github.com/Twigpine/zero/commit/f912f80d002602e11ba0ef8548ecee58442874aa))
|
||||
* **deps:** refresh the lockfile to hono 4.13.13 ([#1171](https://github.com/Twigpine/zero/issues/1171)) ([de1443b](https://github.com/Twigpine/zero/commit/de1443b2edb44fd3747cc77200d54dc8cffcd0a7))
|
||||
* **oauth:** reuse the crash-safe secret store and sync encrypted publications ([#1165](https://github.com/Twigpine/zero/issues/1165)) ([84c1b15](https://github.com/Twigpine/zero/commit/84c1b1554076679a342e9cac61f8f8cdbaf3a429))
|
||||
* **redaction:** redact credentials whose trigger a greedy span swallowed ([#1158](https://github.com/Twigpine/zero/issues/1158)) ([a97db88](https://github.com/Twigpine/zero/commit/a97db8832fdba077f2d31bdf03ecef8d7c774fe1))
|
||||
* **sessions:** capture freeform apply_patch checkpoints ([#1183](https://github.com/Twigpine/zero/issues/1183)) ([6396208](https://github.com/Twigpine/zero/commit/63962081cf130c2171407936ce3beb7021d238b8))
|
||||
* **sessions:** preserve image attachment summaries on resume ([#1152](https://github.com/Twigpine/zero/issues/1152)) ([1e84849](https://github.com/Twigpine/zero/commit/1e848492e5c1919d187fb5e1eb5e7ef9bc6140dc)), closes [#1135](https://github.com/Twigpine/zero/issues/1135)
|
||||
* **tools:** grep stops where a file's line breaks stop ([#1160](https://github.com/Twigpine/zero/issues/1160)) ([aa9ba84](https://github.com/Twigpine/zero/commit/aa9ba8412eb8917daa2ca5cb3fdbd0a39276fa88))
|
||||
* **workspaceindex:** import gitsafe under the Twigpine module path ([#1153](https://github.com/Twigpine/zero/issues/1153)) ([ef4da13](https://github.com/Twigpine/zero/commit/ef4da1323f1097863ae278224b5f304c2fff909a))
|
||||
* **workspaceindex:** run Scan's git calls through gitsafe ([#1149](https://github.com/Twigpine/zero/issues/1149)) ([2ffe183](https://github.com/Twigpine/zero/commit/2ffe1831b0950b1463ec46d487d03a5fcb679cc6))
|
||||
|
||||
## [0.11.0](https://github.com/Twigpine/zero/compare/v0.10.0...v0.11.0) (2026-10-15)
|
||||
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
<img alt="Go 1.26.10+" src="https://img.shields.io/badge/Go-1.26.10+-00ADD8?logo=go&logoColor=white">
|
||||
<img alt="25+ providers" src="https://img.shields.io/badge/providers-25+-34E2EA">
|
||||
<a href="https://discord.gg/CaQDS6wdFn"><img alt="Discord" src="https://img.shields.io/badge/Discord-join-5865F2?logo=discord&logoColor=white"></a>
|
||||
<a href="https://vercel.com/open-source-program"><img alt="Vercel OSS Program" src="https://vercel.com/oss/program-badge-2026.svg"></a>
|
||||
<br>
|
||||
<strong>English</strong> | <a href="README_ZH.md">中文</a>
|
||||
</p>
|
||||
@@ -88,18 +87,9 @@ zero exec --output-format stream-json < turns.jsonl
|
||||
</picture>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" width="150" height="80">
|
||||
<a href="https://vercel.com/">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="docs/assets/vercel-logo-dark.svg">
|
||||
<img src="docs/assets/vercel-logo.svg" alt="Vercel logo" width="120">
|
||||
</picture>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><a href="https://www.gmicloud.ai/"><strong>GMI Cloud</strong></a></td>
|
||||
<td align="center"><a href="https://vercel.com/"><strong>Vercel</strong></a></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
@@ -397,76 +387,6 @@ and can rewrite every request through it, so this is the trust you accept by
|
||||
configuring one. Leave the variables unset for the checks to be enforced end to
|
||||
end.
|
||||
|
||||
The built-in `web_search` tool is registered only when a search backend is
|
||||
configured: `ZERO_WEBSEARCH_BASE_URL` (with an optional `ZERO_WEBSEARCH_API_KEY`)
|
||||
for a generic JSON endpoint, `ZERO_WEBSEARCH_PROVIDER=searxng` with a SearXNG
|
||||
base URL, or the AI/ML API Toolbox:
|
||||
|
||||
```bash
|
||||
export ZERO_WEBSEARCH_PROVIDER=aimlapi
|
||||
```
|
||||
|
||||
The AI/ML API backend runs the Toolbox tool `web/search`, and every search is
|
||||
billed to your AI/ML API balance: about $0.0013 per result, with a documented
|
||||
minimum of $0.0091 per search. Each search is sent with a $0.025 spending cap,
|
||||
which the Toolbox enforces. It is used only when
|
||||
`ZERO_WEBSEARCH_PROVIDER` is set to `aimlapi`; an AI/ML API key alone never
|
||||
enables it. The key is read from your user configuration only; provider
|
||||
profiles in a project `.zero` config are not used. It is the aimlapi profile's
|
||||
key (the active profile when it is aimlapi, otherwise the first aimlapi
|
||||
profile), including a key in the credential store, or `AIMLAPI_API_KEY` when no
|
||||
aimlapi profile exists. It is resolved again on every search;
|
||||
`ZERO_WEBSEARCH_API_KEY` is not used. When any aimlapi profile, or any profile
|
||||
that reads `AIMLAPI_API_KEY`, points at a base URL other than
|
||||
`https://api.aimlapi.com/v1`, or `AIMLAPI_INFERENCE_URL` is overridden, the key
|
||||
may belong to a proxy, so it is not sent and the search fails without a request.
|
||||
Requests go only to
|
||||
`https://tools.aimlapi.com` (`ZERO_WEBSEARCH_BASE_URL` is ignored and redirects
|
||||
are refused) and carry `X-AIMLAPI-Source: agent/zero` and an
|
||||
`X-AIMLAPI-Partner-ID` header. The `domains` argument is sent to the Toolbox as
|
||||
`include_domains`, and results are still filtered locally.
|
||||
|
||||
### AI/ML API Toolbox backend for `web_fetch`
|
||||
|
||||
`web_fetch` can fetch pages through the AI/ML API Toolbox tool
|
||||
`search1api/crawl` instead of a direct HTTP GET. It is off unless you choose it:
|
||||
|
||||
```bash
|
||||
export ZERO_WEBFETCH_PROVIDER=aimlapi
|
||||
```
|
||||
|
||||
Having an AI/ML API key never switches it on by itself, and any other value (or
|
||||
none) keeps the direct fetch. The variable is read on every fetch.
|
||||
|
||||
- **Billing:** Toolbox runs are billed to your AI/ML API balance, about
|
||||
$0.0013 per run. Each run is sent with `max_cost_usd` 0.005, so a run
|
||||
priced above that is refused before it starts and not charged. Rate-limited
|
||||
or gateway-failed requests are retried with the same idempotency key, so a
|
||||
retry is never billed twice.
|
||||
- **Key:** the key comes from your `aimlapi` provider profile (including a key
|
||||
in the credential store), or from `AIMLAPI_API_KEY` when there is no such
|
||||
profile. It is withheld, and `web_fetch` returns an error, when any `aimlapi`
|
||||
profile or any profile using `AIMLAPI_API_KEY` points at a base URL other than
|
||||
`https://api.aimlapi.com/v1`, or when `AIMLAPI_INFERENCE_URL` is overridden:
|
||||
the key may then belong to a proxy.
|
||||
- **Destination:** requests go only to the fixed `https://tools.aimlapi.com`
|
||||
endpoint, with `Authorization: Bearer <key>` and the attribution headers
|
||||
`X-AIMLAPI-Source: agent/zero` and `X-AIMLAPI-Partner-ID`. Redirects are
|
||||
refused. `HTTP_PROXY`/`HTTPS_PROXY` apply to these requests as to Zero's other
|
||||
aimlapi.com calls.
|
||||
- **No fallback:** without a usable key, or when the run fails, `web_fetch`
|
||||
returns an error and does not fetch the page directly. Unset the variable to
|
||||
go back to direct fetches.
|
||||
- **Remote fetch:** the page is fetched by a remote service, which may serve a
|
||||
cached copy, and the full URL (query string included) is sent to it. The URL
|
||||
checks above still run first, so loopback, private and metadata URLs are
|
||||
never handed to it. The result is Markdown with a
|
||||
`Backend: aimlapi (search1api/crawl)` line; `max_bytes` cuts the Markdown,
|
||||
and `format: "raw"` is refused because the original response is not
|
||||
available. The complete Toolbox run response is limited to 8 MiB before JSON
|
||||
decoding; larger responses return an error even if `max_bytes` is smaller.
|
||||
The remote run may already have been billed when this limit is detected.
|
||||
|
||||
For local dev servers, use shell commands such as `curl` through `exec_command`
|
||||
so the normal sandbox and permission policy applies. Long-running commands stay
|
||||
attached to a background terminal session and can be listed or stopped from the
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
<img alt="Go 1.26.10+" src="https://img.shields.io/badge/Go-1.26.10+-00ADD8?logo=go&logoColor=white">
|
||||
<img alt="25+ providers" src="https://img.shields.io/badge/providers-25+-34E2EA">
|
||||
<a href="https://discord.gg/CaQDS6wdFn"><img alt="Discord" src="https://img.shields.io/badge/Discord-join-5865F2?logo=discord&logoColor=white"></a>
|
||||
<a href="https://vercel.com/open-source-program"><img alt="Vercel OSS Program" src="https://vercel.com/oss/program-badge-2026.svg"></a>
|
||||
<br>
|
||||
<a href="README.md">English</a> | <strong>中文</strong>
|
||||
</p>
|
||||
@@ -86,18 +85,9 @@ zero exec --output-format stream-json < turns.jsonl
|
||||
</picture>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" width="150" height="80">
|
||||
<a href="https://vercel.com/">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="docs/assets/vercel-logo-dark.svg">
|
||||
<img src="docs/assets/vercel-logo.svg" alt="Vercel logo" width="120">
|
||||
</picture>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><a href="https://www.gmicloud.ai/"><strong>GMI Cloud</strong></a></td>
|
||||
<td align="center"><a href="https://vercel.com/"><strong>Vercel</strong></a></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
|
||||
+1
-13
@@ -33,16 +33,4 @@ Please include, where possible:
|
||||
depend on a user explicitly disabling the sandbox (`--skip-permissions-unsafe`), are generally
|
||||
out of scope — but please report anything you are unsure about.
|
||||
- Zero sends no telemetry. Network calls go to the model/tool providers you configure (and, for
|
||||
keyless web search, to Exa's hosted MCP endpoint at `https://mcp.exa.ai/mcp`). The built-in
|
||||
AI/ML API MCP server (`https://mcp.aimlapi.com/mcp`) is off until you run
|
||||
`zero mcp enable aimlapi`. Once enabled, the Bearer key follows the aimlapi
|
||||
profile resolution order (inline key, then the profile's `apiKeyEnv` — default
|
||||
`AIMLAPI_API_KEY` — then the credential store), or `AIMLAPI_API_KEY` alone when
|
||||
no aimlapi profile exists; a non-empty `AIMLAPI_API_KEY` can override a stored
|
||||
profile key for MCP billing. The automatic key is withheld if an aimlapi profile
|
||||
(or a profile reading `AIMLAPI_API_KEY`) uses a base URL other than
|
||||
`https://api.aimlapi.com/v1`, `AIMLAPI_INFERENCE_URL` points elsewhere, a
|
||||
non-empty user Authorization header is configured, OAuth is selected, project
|
||||
config has touched the server, or the key is missing or not a single token
|
||||
(including Unicode whitespace). See
|
||||
[docs/EXTENDING.md](docs/EXTENDING.md#built-in-servers).
|
||||
keyless web search, to Exa's hosted MCP endpoint at `https://mcp.exa.ai/mcp`).
|
||||
+2
-65
@@ -110,8 +110,6 @@ Discovery roots (earlier wins on name collisions):
|
||||
|
||||
A missing directory is fine — Zero just omits it. Management commands (`zero skill add` / `zero skill remove`) always write to the primary Zero dir only; `list` / `info` search primary + `~/.agents/skills`. `skill info` reports lock source/hash when present and whether the on-disk `SKILL.md` has drifted from the recorded hash.
|
||||
|
||||
For a skill installed since Zero started installing bundled files, `skill info` also reports drift in any file installed with it.
|
||||
|
||||
```text
|
||||
~/.local/share/zero/skills/ # primary (install/remove target)
|
||||
run-benchmarks/
|
||||
@@ -142,15 +140,6 @@ Only `name` and `description` are recognized in the frontmatter today. The `name
|
||||
|
||||
The `skill` tool lets the agent load any discovered skill by name (primary, agents, or plugin).
|
||||
|
||||
A skill can ship files beside its `SKILL.md`, in the [agentskills.io](https://agentskills.io/specification) layout: `scripts/`, `references/`, `assets/` or anything else the instructions refer to. `zero skill add` installs the whole skill directory, leaving out hidden entries such as `.git` and not following or copying symlinks (it lists any it skipped). A skill larger than 1,000 files or 32 MiB is refused. When the `skill` tool loads a skill that has such files, its output ends with the skill's directory and a list of the files, so instructions like "run `scripts/fill.py`" can be followed. Running a bundled script goes through the normal tool permissions; installing or loading a skill never runs anything.
|
||||
|
||||
```text
|
||||
pdf/
|
||||
SKILL.md
|
||||
scripts/fill.py
|
||||
references/form-fields.md
|
||||
```
|
||||
|
||||
## 4. Hooks
|
||||
|
||||
Hooks fire shell commands on lifecycle events. Configure them in JSON:
|
||||
@@ -203,11 +192,7 @@ Events the agent emits (in dispatch order):
|
||||
| `specialistStart` | A sub-agent is spawned | yes (specialist name) |
|
||||
| `specialistStop` | A sub-agent ends | yes (specialist name) |
|
||||
|
||||
For swarm members the matcher subject is the **agent type** (`subagent`, `teammate`, …), not a registered specialist profile name — swarm members run from an inline manifest keyed by agent type.
|
||||
|
||||
`specialistStart` fires once the child process has actually started; a launch that fails before exec (missing binary, spawn error) records no public lifecycle hooks. `specialistStop` fires once per terminal stop: completion, error, explicit stop of a recovered task, and a background task reaped by a later `TaskOutput` all share one durable claim, so a hook that notifies or cleans up does not run twice for the same sub-agent run.
|
||||
|
||||
A hook's exit code decides what happens next: `0` continues, non-zero blocks the tool call (`beforeTool`) or surfaces an error (`afterTool`). `sessionStart` / `sessionEnd` / `specialistStart` / `specialistStop` hooks are advisory (failures are audited but do not interrupt the run). Hook execution is recorded in the audit log; the audit is reachable from the agent's view of past actions, not from a dedicated `zero doctor` check.
|
||||
A hook's exit code decides what happens next: `0` continues, non-zero blocks the tool call (`beforeTool`) or surfaces an error (`afterTool`). Hook execution is recorded in the audit log; the audit is reachable from the agent's view of past actions, not from a dedicated `zero doctor` check.
|
||||
|
||||
> **Roadmap.** An in-UI hooks manager is on the backlog. Today you edit the JSON directly.
|
||||
|
||||
@@ -261,52 +246,6 @@ or use a new server name. OAuth tokens are bound to the resolved server identity
|
||||
so changing a server target can require `zero mcp oauth login <server>` again.
|
||||
OAuth server names ending in `.<32 hex chars>` are reserved for token storage.
|
||||
|
||||
#### Built-in servers
|
||||
|
||||
Zero seeds two MCP servers before your config is merged. Both show up in
|
||||
`zero mcp list`, and any field can be overridden in your user config.
|
||||
|
||||
- `exa` (`https://mcp.exa.ai/mcp`) is on by default for keyless web search.
|
||||
`zero mcp disable exa` turns it off.
|
||||
- `aimlapi` (`https://mcp.aimlapi.com/mcp`, the AI/ML API Toolbox, model
|
||||
discovery, image/video/audio generation, chat, embeddings and balance tools)
|
||||
is **off by default**, because its tool runs and generations are billed to
|
||||
your AI/ML API balance. Turn it on with `zero mcp enable aimlapi`, which
|
||||
writes only `"disabled": false` to your user config, and off again with
|
||||
`zero mcp disable aimlapi`. A project config cannot turn it on.
|
||||
|
||||
When `aimlapi` connects, Zero sends your AI/ML API key as
|
||||
`Authorization: Bearer <key>`. The key is resolved again on every request and
|
||||
is never written to `config.json`. With an aimlapi.com provider profile
|
||||
(`zero providers setup aimlapi`), the key is chosen in this order:
|
||||
|
||||
1. An inline key on the profile (if set in config).
|
||||
2. Otherwise the environment variable named by the profile's `apiKeyEnv`
|
||||
(the catalog default is `AIMLAPI_API_KEY`).
|
||||
3. Otherwise the key held in the credential store when the profile uses
|
||||
`apiKeyStored`.
|
||||
|
||||
When there is no aimlapi profile, Zero uses `AIMLAPI_API_KEY` alone. If both
|
||||
`AIMLAPI_API_KEY` and a stored profile key exist, step 2 wins over the store
|
||||
whenever the inline key is empty — MCP requests may bill the environment key's
|
||||
account, not the stored key.
|
||||
|
||||
No automatic key is sent when an aimlapi profile, or any profile reading
|
||||
`AIMLAPI_API_KEY`, uses a base URL other than `https://api.aimlapi.com/v1`, or
|
||||
when `AIMLAPI_INFERENCE_URL` is overridden, because that credential may belong
|
||||
to a proxy. No key is sent when the resolved value is not a single token (for
|
||||
example a comma- or newline-separated pool), when you set your own
|
||||
non-empty `Authorization` header, when the server uses `"auth": "oauth"`, or
|
||||
when project config touched the server. Without a key the server answers 401.
|
||||
To sign in with your aimlapi.com account instead, set `"auth": "oauth"` on the
|
||||
server and run `zero mcp oauth login aimlapi`.
|
||||
|
||||
Every request to exactly `https://mcp.aimlapi.com` (https, default port,
|
||||
whatever the server is named, including one you add by hand) also carries the
|
||||
attribution headers `X-AIMLAPI-Source: agent/zero` and `X-AIMLAPI-Partner-ID`,
|
||||
and gets the key under the rules above. Other MCP servers never receive these
|
||||
headers or the key.
|
||||
|
||||
### As a server — expose Zero's tools to another agent
|
||||
|
||||
```bash
|
||||
@@ -351,12 +290,10 @@ Install and manage:
|
||||
zero plugins add ./github-pr-review # copy into ~/.config/zero/plugins/ or ./.zero/plugins/
|
||||
zero plugins list
|
||||
zero plugins info github-pr-review # show manifest path, enabled state, lock metadata
|
||||
zero plugins disable github-pr-review # sets "enabled": false in plugin.json
|
||||
zero plugins enable github-pr-review # sets "enabled": true in plugin.json
|
||||
zero plugins remove github-pr-review # alias: rm
|
||||
```
|
||||
|
||||
A plugin is active when it is present under a plugins directory and its `plugin.json` has `"enabled": true` (the default when the field is omitted). Use `zero plugins disable <id>` / `zero plugins enable <id>` to toggle that field without removing the plugin. Pass `--user` to target only user plugins and ignore `./.zero/plugins`; without it, a project plugin with the same id takes precedence. The toggle edits the `enabled` value in place — the rest of the manifest keeps its formatting, field order, and numeric literals — and serializes with `plugins add` / `plugins remove` on the same plugins root. Zero reads plugins when it starts, so the change applies to the next `zero exec` run or interactive session; an interactive session that is already running keeps the plugin tools, hooks, and skills it started with until you restart it.
|
||||
A plugin is enabled by being present in the plugins directory and disabled by removing it (or by the user setting `"enabled": false` in its `plugin.json`). Plugins are not enabled or disabled by a CLI subcommand today.
|
||||
|
||||
Plugin commands run with the plugin directory as their working directory. Use relative paths; the loader resolves them at activation time.
|
||||
|
||||
|
||||
@@ -634,10 +634,6 @@ Core tool groups include:
|
||||
`HTTP_PROXY`/`HTTPS_PROXY` set, the proxy is dialed instead and resolves the
|
||||
target itself, so destination enforcement ends at the proxy; see the note in
|
||||
the README.
|
||||
With `ZERO_WEBFETCH_PROVIDER=aimlapi` (opt-in; a key alone does not select
|
||||
it), the same URL checks run and the page is then fetched as Markdown by the
|
||||
billed AI/ML API Toolbox tool `search1api/crawl`, with no fallback to a
|
||||
direct fetch; see the README.
|
||||
- **Local-control tools**: config-gated wrappers for browser, terminal, desktop,
|
||||
and artifact helpers backed by packages such as `internal/localcontrol` and
|
||||
`internal/browser`.
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="261" height="52" fill="none" viewBox="0 0 261 52"><path fill="white" d="M59.8 52H0L29.9 0zm67.82-38.45q4.9 0 8.81 2.13a15.5 15.5 0 0 1 6.22 6.32q2.3 4.2 2.38 10.26v2.06h-26.35q.27 4.4 2.58 6.92 2.38 2.47 6.36 2.47a8.4 8.4 0 0 0 7.76-4.93l9.16.67q-1.68 4.98-6.29 7.99t-10.63 3q-5.53 0-9.64-2.27a16 16 0 0 1-6.43-6.46 20 20 0 0 1-2.31-9.72q0-5.52 2.3-9.72a16 16 0 0 1 6.44-6.46 20 20 0 0 1 9.64-2.26m62.55 0q4.47 0 8.18 1.66a15.3 15.3 0 0 1 6.15 4.6q2.38 3 2.87 7.05l-9.23.47a8 8 0 0 0-2.8-5 7.7 7.7 0 0 0-5.17-1.86q-4.33 0-6.7 3-2.4 3-2.39 8.52t2.38 8.52q2.37 3 6.71 3 3.15 0 5.39-1.87 2.24-1.92 2.72-5.46l9.3.4a14.7 14.7 0 0 1-2.87 7.33 16 16 0 0 1-6.15 4.86 21 21 0 0 1-8.39 1.66q-5.53 0-9.64-2.26a16 16 0 0 1-6.44-6.46 20 20 0 0 1-2.3-9.72q0-5.52 2.3-9.72a16 16 0 0 1 6.44-6.46 20 20 0 0 1 9.64-2.26m38.66 0q4.9 0 8.8 2.13a15.5 15.5 0 0 1 6.22 6.32q2.31 4.2 2.38 10.26v2.06h-26.35q.28 4.4 2.58 6.92 2.38 2.47 6.36 2.47a8.4 8.4 0 0 0 7.77-4.93l9.15.67q-1.68 5-6.29 7.99t-10.62 3q-5.53 0-9.65-2.27a16 16 0 0 1-6.43-6.46 20 20 0 0 1-2.31-9.72q0-5.52 2.3-9.72a16 16 0 0 1 6.44-6.46 20 20 0 0 1 9.64-2.26M86.9 36.69l17.24-34.33h10.8L89.96 49.63h-6.12L58.85 2.36h10.81zm71.62-15.55a11 11 0 0 1 2.47-4.48q2.28-2.31 6.38-2.31h3.4v7.26h-3.47q-2.91 0-4.79.8a5.8 5.8 0 0 0-2.77 2.5q-.9 1.72-.9 4.37v20.35h-8.89V14.35h8.33zm101.73 28.5h-8.95V2.35h8.95zM127.62 20.26q-3.7 0-6 2.2-2.32 2.2-2.87 6.2h17.05q-.48-4.34-2.72-6.33a7.8 7.8 0 0 0-5.46-2.07m101.2 0q-3.7 0-6 2.2-2.31 2.2-2.87 6.2H237q-.5-4.34-2.73-6.33a7.8 7.8 0 0 0-5.46-2.07" style="fill:white;fill-opacity:1;"/></svg>
|
||||
|
Before Width: | Height: | Size: 1.6 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="261" height="52" fill="none" viewBox="0 0 261 52"><path fill="black" d="M59.8 52H0L29.9 0zm67.82-38.45q4.9 0 8.81 2.13a15.5 15.5 0 0 1 6.22 6.32q2.3 4.2 2.38 10.26v2.06h-26.35q.27 4.4 2.58 6.92 2.38 2.47 6.36 2.47a8.4 8.4 0 0 0 7.76-4.93l9.16.67q-1.68 4.98-6.29 7.99t-10.63 3q-5.53 0-9.64-2.27a16 16 0 0 1-6.43-6.46 20 20 0 0 1-2.31-9.72q0-5.52 2.3-9.72a16 16 0 0 1 6.44-6.46 20 20 0 0 1 9.64-2.26m62.55 0q4.47 0 8.18 1.66a15.3 15.3 0 0 1 6.15 4.6q2.38 3 2.87 7.05l-9.23.47a8 8 0 0 0-2.8-5 7.7 7.7 0 0 0-5.17-1.86q-4.33 0-6.7 3-2.4 3-2.39 8.52t2.38 8.52q2.37 3 6.71 3 3.15 0 5.39-1.87 2.24-1.92 2.72-5.46l9.3.4a14.7 14.7 0 0 1-2.87 7.33 16 16 0 0 1-6.15 4.86 21 21 0 0 1-8.39 1.66q-5.53 0-9.64-2.26a16 16 0 0 1-6.44-6.46 20 20 0 0 1-2.3-9.72q0-5.52 2.3-9.72a16 16 0 0 1 6.44-6.46 20 20 0 0 1 9.64-2.26m38.66 0q4.9 0 8.8 2.13a15.5 15.5 0 0 1 6.22 6.32q2.31 4.2 2.38 10.26v2.06h-26.35q.28 4.4 2.58 6.92 2.38 2.47 6.36 2.47a8.4 8.4 0 0 0 7.77-4.93l9.15.67q-1.68 5-6.29 7.99t-10.62 3q-5.53 0-9.65-2.27a16 16 0 0 1-6.43-6.46 20 20 0 0 1-2.31-9.72q0-5.52 2.3-9.72a16 16 0 0 1 6.44-6.46 20 20 0 0 1 9.64-2.26M86.9 36.69l17.24-34.33h10.8L89.96 49.63h-6.12L58.85 2.36h10.81zm71.62-15.55a11 11 0 0 1 2.47-4.48q2.28-2.31 6.38-2.31h3.4v7.26h-3.47q-2.91 0-4.79.8a5.8 5.8 0 0 0-2.77 2.5q-.9 1.72-.9 4.37v20.35h-8.89V14.35h8.33zm101.73 28.5h-8.95V2.35h8.95zM127.62 20.26q-3.7 0-6 2.2-2.32 2.2-2.87 6.2h17.05q-.48-4.34-2.72-6.33a7.8 7.8 0 0 0-5.46-2.07m101.2 0q-3.7 0-6 2.2-2.31 2.2-2.87 6.2H237q-.5-4.34-2.73-6.33a7.8 7.8 0 0 0-5.46-2.07" style="fill:black;fill-opacity:1;"/></svg>
|
||||
|
Before Width: | Height: | Size: 1.6 KiB |
@@ -1,34 +0,0 @@
|
||||
package agent
|
||||
|
||||
import "github.com/Twigpine/zero/internal/tools"
|
||||
|
||||
// CheckpointTargets returns the workspace-relative paths a tool call will mutate,
|
||||
// resolving its arguments exactly as executeToolCall does. Freeform apply_patch
|
||||
// input is normalized through the registered built-in tool, so absolute headers
|
||||
// are rewritten relative to the selected write root and cwd is set: checkpoint
|
||||
// capture and execution therefore agree on the files at stake.
|
||||
//
|
||||
// Best-effort: nil when the call has no capture targets or its arguments cannot
|
||||
// be resolved. Checkpoint capture never fails a run.
|
||||
func CheckpointTargets(workspaceRoot string, registry *tools.Registry, call ToolCall) []string {
|
||||
if registry == nil {
|
||||
return nil
|
||||
}
|
||||
args := map[string]any{}
|
||||
if call.Freeform {
|
||||
tool, ok := registry.Get(call.Name)
|
||||
if !ok || !tools.IsBuiltInApplyPatch(tool) {
|
||||
return nil
|
||||
}
|
||||
prepared, err := tools.PrepareFreeformApplyPatchArguments(tool, call.Arguments)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
args = prepared
|
||||
} else if call.Arguments != "" {
|
||||
if err := decodeToolArguments(call.Arguments, &args); err != nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return tools.MutationTargets(workspaceRoot, call.Name, args)
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"github.com/Twigpine/zero/internal/tools"
|
||||
)
|
||||
|
||||
func TestCheckpointTargets(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
// Absolute headers must be compared against the physical root: t.TempDir
|
||||
// differs from it on macOS (/var -> /private/var) and via Windows short names.
|
||||
if resolved, err := filepath.EvalSymlinks(root); err == nil {
|
||||
root = resolved
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "main.txt"), []byte("before\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
relativePatch := "*** Begin Patch\n*** Update File: main.txt\n@@\n-before\n+after\n*** End Patch\n"
|
||||
absolutePatch := "*** Begin Patch\n*** Update File: " + filepath.Join(root, "main.txt") + "\n@@\n-before\n+after\n*** End Patch\n"
|
||||
multiFilePatch := "*** Begin Patch\n*** Update File: main.txt\n@@\n-before\n+after\n*** Add File: notes.txt\n+new\n*** End Patch\n"
|
||||
jsonArgs, err := json.Marshal(map[string]any{"patch": relativePatch})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedApplyPatchTool(root, nil))
|
||||
unbuiltinRegistry := tools.NewRegistry()
|
||||
unbuiltinRegistry.Register(&customApplyPatchTool{})
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
registry *tools.Registry
|
||||
call ToolCall
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "freeform relative header",
|
||||
call: ToolCall{Name: "apply_patch", Arguments: relativePatch, Freeform: true},
|
||||
want: []string{"main.txt"},
|
||||
},
|
||||
{
|
||||
name: "freeform absolute header stays workspace-relative",
|
||||
call: ToolCall{Name: "apply_patch", Arguments: absolutePatch, Freeform: true},
|
||||
want: []string{"main.txt"},
|
||||
},
|
||||
{
|
||||
name: "freeform multi-file patch",
|
||||
call: ToolCall{Name: "apply_patch", Arguments: multiFilePatch, Freeform: true},
|
||||
want: []string{"main.txt", "notes.txt"},
|
||||
},
|
||||
{
|
||||
name: "json arguments",
|
||||
call: ToolCall{Name: "apply_patch", Arguments: string(jsonArgs)},
|
||||
want: []string{"main.txt"},
|
||||
},
|
||||
{
|
||||
name: "freeform traversal is rejected",
|
||||
call: ToolCall{Name: "apply_patch", Arguments: "*** Begin Patch\n*** Update File: ../escape.txt\n@@\n-x\n+y\n*** End Patch\n", Freeform: true},
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "freeform custom apply_patch is not prepared",
|
||||
registry: unbuiltinRegistry,
|
||||
call: ToolCall{Name: "apply_patch", Arguments: relativePatch, Freeform: true},
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "unknown freeform tool",
|
||||
call: ToolCall{Name: "read_file", Arguments: "raw", Freeform: true},
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "malformed json arguments",
|
||||
call: ToolCall{Name: "apply_patch", Arguments: "{"},
|
||||
want: nil,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
reg := registry
|
||||
if tc.registry != nil {
|
||||
reg = tc.registry
|
||||
}
|
||||
got := CheckpointTargets(root, reg, tc.call)
|
||||
if !slices.Equal(got, tc.want) {
|
||||
t.Fatalf("CheckpointTargets() = %#v, want %#v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if got := CheckpointTargets(root, nil, ToolCall{Name: "apply_patch", Arguments: relativePatch}); got != nil {
|
||||
t.Fatalf("CheckpointTargets(nil registry) = %#v, want nil", got)
|
||||
}
|
||||
}
|
||||
@@ -80,9 +80,6 @@ func TestRunStopsAfterConsecutiveEmptyTurns(t *testing.T) {
|
||||
if result.FinalAnswer == maxTurnsAnswer {
|
||||
t.Fatalf("no-output guard must stop before reaching maxTurns, got max-turns answer")
|
||||
}
|
||||
if result.Incomplete {
|
||||
t.Fatalf("interactive run must not be marked Incomplete; reason=%q", result.IncompleteReason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunResetsEmptyTurnCounterOnVisibleOutput(t *testing.T) {
|
||||
@@ -516,22 +513,6 @@ func repeatedFlakyTurns(n int) [][]zeroruntime.StreamEvent {
|
||||
return turns
|
||||
}
|
||||
|
||||
// alwaysSucceedingTool is the successful counterpart to alwaysFailingTool, used
|
||||
// to put a productive tool turn before an empty-turn streak.
|
||||
type alwaysSucceedingTool struct{}
|
||||
|
||||
func (alwaysSucceedingTool) Name() string { return "ok" }
|
||||
func (alwaysSucceedingTool) Description() string { return "always succeeds for testing" }
|
||||
func (alwaysSucceedingTool) Parameters() tools.Schema {
|
||||
return tools.Schema{Type: "object", AdditionalProperties: false}
|
||||
}
|
||||
func (alwaysSucceedingTool) Safety() tools.Safety {
|
||||
return tools.Safety{SideEffect: tools.SideEffectRead, Permission: tools.PermissionAllow}
|
||||
}
|
||||
func (alwaysSucceedingTool) Run(context.Context, map[string]any) tools.Result {
|
||||
return tools.Result{Status: tools.StatusOK, Output: "ok"}
|
||||
}
|
||||
|
||||
func TestRunStopsAfterRepeatedToolFailures(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(alwaysFailingTool{})
|
||||
@@ -548,51 +529,6 @@ func TestRunStopsAfterRepeatedToolFailures(t *testing.T) {
|
||||
if len(provider.requests) != toolFailureStopAt {
|
||||
t.Fatalf("expected stop at %d failures, made %d requests", toolFailureStopAt, len(provider.requests))
|
||||
}
|
||||
if result.Incomplete {
|
||||
t.Fatalf("interactive run must not be marked Incomplete; reason=%q", result.IncompleteReason)
|
||||
}
|
||||
}
|
||||
|
||||
// Under the headless completion gate both guardrail stops are unfinished runs,
|
||||
// so they must set Result.Incomplete and a reason; exec maps that to exit 4.
|
||||
// Thresholds and stop answers stay pinned by the two tests above.
|
||||
func TestHeadlessGuardStopsAreIncomplete(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
turns [][]zeroruntime.StreamEvent
|
||||
wantTurns int
|
||||
wantReason string
|
||||
}{
|
||||
{"empty_turns", [][]zeroruntime.StreamEvent{emptyTurn(), emptyTurn(), emptyTurn()}, maxEmptyTurns, "3 consecutive turns with no output"},
|
||||
{"empty_turns_after_progress", [][]zeroruntime.StreamEvent{
|
||||
toolTurn("ok-1", "ok", "{}"),
|
||||
emptyTurn(), emptyTurn(), emptyTurn(),
|
||||
}, maxEmptyTurns + 1, "3 consecutive turns with no output"},
|
||||
{"repeated_tool_failures", repeatedFlakyTurns(10), toolFailureStopAt, "flaky"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(alwaysFailingTool{})
|
||||
registry.Register(alwaysSucceedingTool{})
|
||||
result, err := Run(context.Background(), "go", &mockProvider{turns: tc.turns}, Options{
|
||||
Registry: registry,
|
||||
MaxTurns: 12,
|
||||
RequireCompletionSignal: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Turns != tc.wantTurns {
|
||||
t.Fatalf("guard did not trigger: turns=%d want=%d", result.Turns, tc.wantTurns)
|
||||
}
|
||||
if !result.Incomplete {
|
||||
t.Fatalf("guardrail stop under the headless gate must be Incomplete; final=%q", result.FinalAnswer)
|
||||
}
|
||||
if !strings.Contains(result.IncompleteReason, tc.wantReason) {
|
||||
t.Fatalf("IncompleteReason = %q, want it to mention %q", result.IncompleteReason, tc.wantReason)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunInjectsToolFailureHintWithSchema(t *testing.T) {
|
||||
|
||||
@@ -587,14 +587,6 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
// counted toward the runaway cap so we stop before burning maxTurns.
|
||||
if guards.observeTurn(collected) {
|
||||
result.FinalAnswer = noOutputStopAnswer(result.Turns)
|
||||
// Under the headless completion gate this stop is unfinished work,
|
||||
// not a successful final answer: the guard exists precisely because
|
||||
// the model made no progress. Interactive runs keep the gate off, so
|
||||
// their behavior is unchanged.
|
||||
if options.RequireCompletionSignal {
|
||||
result.Incomplete = true
|
||||
result.IncompleteReason = "stopped after " + strconv.Itoa(guards.emptyTurns) + " consecutive turns with no output"
|
||||
}
|
||||
result.Messages = copyMessages(messages)
|
||||
return result, nil
|
||||
}
|
||||
@@ -816,13 +808,6 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
messages = appendAbortedToolResults(messages, collected.ToolCalls[index+1:])
|
||||
messages = append(messages, buildToolImageMessages()...)
|
||||
result.FinalAnswer = toolFailureStopAnswer(call.Name, outcome.Count)
|
||||
// Same headless contract as the empty-turn guard: halting on a
|
||||
// repeated failure is an unfinished run, not success. Gate-off
|
||||
// (interactive / --no-completion-gate) keeps the old behavior.
|
||||
if options.RequireCompletionSignal {
|
||||
result.Incomplete = true
|
||||
result.IncompleteReason = "the " + call.Name + " tool failed " + strconv.Itoa(outcome.Count) + " times in a row with the same error"
|
||||
}
|
||||
result.Messages = copyMessages(messages)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -1,20 +1,14 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Twigpine/zero/internal/config"
|
||||
"github.com/Twigpine/zero/internal/sandbox"
|
||||
"github.com/Twigpine/zero/internal/testutil"
|
||||
)
|
||||
|
||||
// isolatedUserDirsRoot is where TestMain points every per-user directory.
|
||||
var isolatedUserDirsRoot string
|
||||
|
||||
// TestMain points userConfigDirForPrompt at an empty, package-wide temp
|
||||
// directory for every test in this package. Without this, buildSystemPrompt
|
||||
// falls back to the real config.UserConfigDir, so any developer with a
|
||||
@@ -23,56 +17,17 @@ var isolatedUserDirsRoot string
|
||||
// across contributor machines. Tests that specifically exercise user
|
||||
// guidelines stub userConfigDirForPrompt themselves via
|
||||
// withSystemPromptTestUserConfigDir(Func) and restore this default on cleanup.
|
||||
//
|
||||
// It also points every per-user directory root at the same temp directory:
|
||||
// sandboxed tool runs keep their runtime state and lease files under the user
|
||||
// cache dir, so without this they land in the developer's real cache.
|
||||
func TestMain(m *testing.M) {
|
||||
dir, err := os.MkdirTemp("", "zero-agent-tests-*")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
isolatedUserDirsRoot = dir
|
||||
restore, err := testutil.IsolateUserDirs(dir)
|
||||
if err != nil {
|
||||
os.RemoveAll(dir)
|
||||
panic(err)
|
||||
}
|
||||
userConfigDirForPrompt = func() (string, error) { return dir, nil }
|
||||
code := m.Run()
|
||||
if err := restore(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "agent tests: restore user dirs:", err)
|
||||
code = 1
|
||||
}
|
||||
os.RemoveAll(dir)
|
||||
os.Exit(code)
|
||||
}
|
||||
|
||||
// Guards TestMain: the per-user directories the package can read or write
|
||||
// must resolve inside the isolated root on every OS.
|
||||
func TestUserDirsAreIsolatedFromTheDeveloperHome(t *testing.T) {
|
||||
if isolatedUserDirsRoot == "" {
|
||||
t.Fatal("TestMain did not record an isolated root")
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
userConfig, err := config.UserConfigDir()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cache, err := os.UserCacheDir()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for name, dir := range map[string]string{"home": home, "config": userConfig, "cache": cache} {
|
||||
if !testutil.PathWithin(isolatedUserDirsRoot, dir) {
|
||||
t.Errorf("%s dir %q is outside the isolated root %q", name, dir, isolatedUserDirsRoot)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoreSystemPromptIncludesCodingQualityRules(t *testing.T) {
|
||||
prompt := strings.Join(strings.Fields(strings.ToLower(buildSystemPrompt(Options{}))), " ")
|
||||
if strings.Contains(prompt, "clickable file paths") {
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
package aimlapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
// SourceHeaderName and SourceValue identify Zero as the calling agent on
|
||||
// requests to aimlapi.com services (the Toolbox and the MCP server).
|
||||
SourceHeaderName = "X-AIMLAPI-Source"
|
||||
SourceValue = "agent/zero"
|
||||
|
||||
// CanonicalInferenceBaseURL is the catalog inference endpoint. A provider
|
||||
// profile whose base URL differs is treated as a proxy: its key is never sent
|
||||
// to other aimlapi.com services.
|
||||
CanonicalInferenceBaseURL = "https://api.aimlapi.com/v1"
|
||||
// ToolboxBaseURL is the fixed AI/ML API Toolbox REST base. It is not
|
||||
// configurable, so the key and attribution can only reach tools.aimlapi.com.
|
||||
ToolboxBaseURL = "https://tools.aimlapi.com/v1/tools"
|
||||
// MCPServerURL is the AI/ML API MCP server (streamable HTTP).
|
||||
MCPServerURL = "https://mcp.aimlapi.com/mcp"
|
||||
)
|
||||
|
||||
// attributedHosts are the only hostnames that receive the AI/ML API key or the
|
||||
// attribution headers. Matching is exact; a suffix or substring match would
|
||||
// also accept hosts such as tools.aimlapi.com.example.net.
|
||||
var attributedHosts = [...]string{"api.aimlapi.com", "tools.aimlapi.com", "mcp.aimlapi.com"}
|
||||
|
||||
// IsAttributedURL reports whether target is an https URL on one of the
|
||||
// aimlapi.com service hosts, on the default port and without userinfo.
|
||||
func IsAttributedURL(target *url.URL) bool {
|
||||
if target == nil || !strings.EqualFold(target.Scheme, "https") || target.User != nil {
|
||||
return false
|
||||
}
|
||||
if port := target.Port(); port != "" && port != "443" {
|
||||
return false
|
||||
}
|
||||
host := strings.ToLower(target.Hostname())
|
||||
for _, candidate := range attributedHosts {
|
||||
if host == candidate {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ApplyAttributionHeaders sets the source and partner headers on header only
|
||||
// when target is an aimlapi.com service URL; any other target is left as is.
|
||||
func ApplyAttributionHeaders(header http.Header, target *url.URL) {
|
||||
if header == nil || !IsAttributedURL(target) {
|
||||
return
|
||||
}
|
||||
header.Set(SourceHeaderName, SourceValue)
|
||||
header.Set(PartnerHeaderName, ResolvePartnerID(""))
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
package aimlapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestIsAttributedURLMatchesExactServiceHostsOnly(t *testing.T) {
|
||||
cases := map[string]bool{
|
||||
"https://tools.aimlapi.com/v1/tools/run": true,
|
||||
"https://mcp.aimlapi.com/mcp": true,
|
||||
"https://api.aimlapi.com/v1": true,
|
||||
"https://TOOLS.aimlapi.com:443/v1/tools": true,
|
||||
"http://tools.aimlapi.com/v1/tools/run": false,
|
||||
"https://tools.aimlapi.com:8443/v1/tools": false,
|
||||
"https://[email protected]/v1/tools": false,
|
||||
"https://tools.aimlapi.com.example.net/run": false,
|
||||
"https://eviltools.aimlapi.com/run": false,
|
||||
"https://aimlapi.com/app": false,
|
||||
"https://auth.aimlapi.com/": false,
|
||||
"https://example.com/?h=tools.aimlapi.com": false,
|
||||
}
|
||||
for raw, want := range cases {
|
||||
target, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("parse %q: %v", raw, err)
|
||||
}
|
||||
if got := IsAttributedURL(target); got != want {
|
||||
t.Errorf("IsAttributedURL(%q) = %v, want %v", raw, got, want)
|
||||
}
|
||||
}
|
||||
if IsAttributedURL(nil) {
|
||||
t.Fatal("IsAttributedURL(nil) = true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyAttributionHeadersSetsSourceAndPartnerOnServiceHosts(t *testing.T) {
|
||||
t.Setenv("AIMLAPI_PARTNER_ID", "")
|
||||
header := http.Header{}
|
||||
ApplyAttributionHeaders(header, &url.URL{Scheme: "https", Host: "tools.aimlapi.com", Path: "/v1/tools/run"})
|
||||
if got := header.Get(SourceHeaderName); got != "agent/zero" {
|
||||
t.Fatalf("%s = %q, want agent/zero", SourceHeaderName, got)
|
||||
}
|
||||
if got := header.Get(PartnerHeaderName); got != DefaultPartnerID {
|
||||
t.Fatalf("%s = %q, want %q", PartnerHeaderName, got, DefaultPartnerID)
|
||||
}
|
||||
|
||||
other := http.Header{}
|
||||
ApplyAttributionHeaders(other, &url.URL{Scheme: "https", Host: "tools.aimlapi.com.example.net"})
|
||||
if len(other) != 0 {
|
||||
t.Fatalf("attribution reached a non-aimlapi host: %#v", other)
|
||||
}
|
||||
}
|
||||
+2
-25
@@ -964,7 +964,6 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
// report can be combined with the plugin activation's, and emit at most one
|
||||
// notice when project hooks/plugins were dropped for an untrusted workspace.
|
||||
hookDispatcher, hookSkip := newHookDispatcherWithExtra(workspaceRoot, pluginActivation.hooks, trustRoot, executionRunner)
|
||||
specialistRuntime.attachLifecycleHooks(hookDispatcher)
|
||||
emitTrustNotice(stderr, hookSkip, pluginActivation.trustSkip, mcpSkip)
|
||||
// From the engine the session's commands run through, so the notice
|
||||
// describes the sandbox they actually get.
|
||||
@@ -1222,9 +1221,6 @@ type agentToolRuntime struct {
|
||||
// the orchestrator's system-prompt delegation section. Populated alongside the
|
||||
// tools, so it is present exactly when the Task tool is.
|
||||
specialists []agent.SpecialistInfo
|
||||
// lifecycleHooks is shared with the specialist executor so the hooks
|
||||
// dispatcher (built later in startup) can be attached before any specialist runs.
|
||||
lifecycleHooks *specialist.LifecycleHooks
|
||||
}
|
||||
|
||||
// specialistInfos returns the runtime's specialist summaries, nil-safe so the
|
||||
@@ -1236,26 +1232,12 @@ func (r *agentToolRuntime) specialistInfos() []agent.SpecialistInfo {
|
||||
return r.specialists
|
||||
}
|
||||
|
||||
func (r *agentToolRuntime) attachLifecycleHooks(dispatcher *hooks.Dispatcher) {
|
||||
if r == nil || r.lifecycleHooks == nil || dispatcher == nil {
|
||||
return
|
||||
}
|
||||
r.lifecycleHooks.Dispatch = func(ctx context.Context, event string, specialistName string, payload map[string]any) {
|
||||
dispatcher.Dispatch(ctx, hooks.DispatchInput{
|
||||
Event: hooks.Event(event),
|
||||
ToolName: specialistName,
|
||||
Payload: payload,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func registerSpecialistTools(registry *tools.Registry, workspaceRoot string, maxTeamSize int) (*agentToolRuntime, error) {
|
||||
paths, err := specialist.DefaultPaths(workspaceRoot)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
lifecycleHooks := &specialist.LifecycleHooks{}
|
||||
executor := specialist.Executor{Paths: paths, LifecycleHooks: lifecycleHooks}
|
||||
executor := specialist.Executor{Paths: paths}
|
||||
runtime, err := specialist.RegisterTools(registry, executor)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -1274,12 +1256,7 @@ func registerSpecialistTools(registry *tools.Registry, workspaceRoot string, max
|
||||
return nil, err
|
||||
}
|
||||
swarm.RegisterTools(registry, sw)
|
||||
return &agentToolRuntime{
|
||||
specialist: runtime,
|
||||
swarm: sw,
|
||||
specialists: specialistSummaries(paths),
|
||||
lifecycleHooks: lifecycleHooks,
|
||||
}, nil
|
||||
return &agentToolRuntime{specialist: runtime, swarm: sw, specialists: specialistSummaries(paths)}, nil
|
||||
}
|
||||
|
||||
// specialistSummaries loads the available specialists (built-ins + user/project
|
||||
|
||||
@@ -92,25 +92,13 @@ func runSkillAdd(args []string, dir string, stdout io.Writer, stderr io.Writer)
|
||||
lines := []string{}
|
||||
if result.Updated {
|
||||
lines = append(lines, fmt.Sprintf("Updated skill %q.", result.Name))
|
||||
switch {
|
||||
case result.PreviousHash != result.Hash:
|
||||
lines = append(lines, fmt.Sprintf(" hash: %s -> %s", shortHash(result.PreviousHash), shortHash(result.Hash)))
|
||||
case result.PreviousTreeHash == "":
|
||||
// The install this replaced predates tree hashes and copied SKILL.md
|
||||
// alone, so the bundled files are new rather than changed.
|
||||
lines = append(lines, " SKILL.md is unchanged; the files bundled with it are now installed too")
|
||||
default:
|
||||
lines = append(lines, " SKILL.md is unchanged; the files bundled with it changed")
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf(" hash: %s -> %s", shortHash(result.PreviousHash), shortHash(result.Hash)))
|
||||
} else {
|
||||
lines = append(lines, fmt.Sprintf("Installed skill %q.", result.Name))
|
||||
lines = append(lines, " hash: "+result.Hash)
|
||||
}
|
||||
lines = append(lines, " source: "+result.Source)
|
||||
lines = append(lines, " path: "+result.Path)
|
||||
if len(result.SkippedLinks) > 0 {
|
||||
lines = append(lines, " not copied (symlinks): "+strings.Join(result.SkippedLinks, ", "))
|
||||
}
|
||||
if _, err := fmt.Fprintln(stdout, redaction.RedactString(strings.Join(lines, "\n"), redaction.Options{})); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
|
||||
@@ -7,8 +7,6 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Twigpine/zero/internal/skills"
|
||||
)
|
||||
|
||||
func writeSourceSkillDir(t *testing.T, dir string, content string) string {
|
||||
@@ -393,133 +391,3 @@ func TestRunDistributionCommandsDoNotLaunchTUI(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A reinstall that only changed a bundled script must still read as an update
|
||||
// (#584): the SKILL.md hash is the same, so "hash: X -> X" would say nothing.
|
||||
func TestRunSkillAddReportsAnUpdateToBundledFilesOnly(t *testing.T) {
|
||||
skillsDir := t.TempDir()
|
||||
src := writeSourceSkillDir(t, filepath.Join(t.TempDir(), "src"),
|
||||
"---\nname: pdf\ndescription: Fill forms.\n---\nRun scripts/fill.py.\n")
|
||||
script := filepath.Join(src, "scripts", "fill.py")
|
||||
if err := os.MkdirAll(filepath.Dir(script), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(script, []byte("print('v1')\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
deps := appDeps{skillsDir: func() string { return skillsDir }}
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
if exit := runWithDeps([]string{"skill", "add", src}, &stdout, &stderr, deps); exit != 0 {
|
||||
t.Fatalf("first skill add exit = %d, stderr = %s", exit, stderr.String())
|
||||
}
|
||||
if err := os.WriteFile(script, []byte("print('v2')\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
if exit := runWithDeps([]string{"skill", "add", src}, &stdout, &stderr, deps); exit != 0 {
|
||||
t.Fatalf("second skill add exit = %d, stderr = %s", exit, stderr.String())
|
||||
}
|
||||
for _, want := range []string{`Updated skill "pdf".`, "SKILL.md is unchanged; the files bundled with it changed"} {
|
||||
if !strings.Contains(stdout.String(), want) {
|
||||
t.Fatalf("second add output does not say %q:\n%s", want, stdout.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// An install from before tree hashes copied SKILL.md alone and recorded no tree
|
||||
// hash. Adding the skill again over that entry must not claim bundled files
|
||||
// changed: there are none, or they are installed for the first time.
|
||||
func TestRunSkillAddOverAnEntryWithoutATreeHash(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
script bool
|
||||
want []string
|
||||
notWant []string
|
||||
}{
|
||||
{
|
||||
name: "SKILL.md only",
|
||||
want: []string{`Installed skill "pdf".`},
|
||||
notWant: []string{"Updated skill", "files bundled with it"},
|
||||
},
|
||||
{
|
||||
name: "with a bundled script",
|
||||
script: true,
|
||||
want: []string{`Updated skill "pdf".`, "SKILL.md is unchanged; the files bundled with it are now installed too"},
|
||||
notWant: []string{"files bundled with it changed"},
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
skillsDir := t.TempDir()
|
||||
src := writeSourceSkillDir(t, filepath.Join(t.TempDir(), "src"),
|
||||
"---\nname: pdf\ndescription: Fill forms.\n---\nRun scripts/fill.py.\n")
|
||||
if tc.script {
|
||||
script := filepath.Join(src, "scripts", "fill.py")
|
||||
if err := os.MkdirAll(filepath.Dir(script), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(script, []byte("print('fill')\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
deps := appDeps{skillsDir: func() string { return skillsDir }}
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
if exit := runWithDeps([]string{"skill", "add", src}, &stdout, &stderr, deps); exit != 0 {
|
||||
t.Fatalf("first skill add exit = %d, stderr = %s", exit, stderr.String())
|
||||
}
|
||||
lock, err := skills.ReadLock(skillsDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
entry := lock["pdf"]
|
||||
entry.TreeHash = ""
|
||||
lock["pdf"] = entry
|
||||
data, err := json.Marshal(lock)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(skillsDir, skills.LockFileName), data, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
if exit := runWithDeps([]string{"skill", "add", src}, &stdout, &stderr, deps); exit != 0 {
|
||||
t.Fatalf("second skill add exit = %d, stderr = %s", exit, stderr.String())
|
||||
}
|
||||
for _, want := range tc.want {
|
||||
if !strings.Contains(stdout.String(), want) {
|
||||
t.Fatalf("second add output does not say %q:\n%s", want, stdout.String())
|
||||
}
|
||||
}
|
||||
for _, notWant := range tc.notWant {
|
||||
if strings.Contains(stdout.String(), notWant) {
|
||||
t.Fatalf("second add output says %q:\n%s", notWant, stdout.String())
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSkillAddListsTheSymlinksItDidNotCopy(t *testing.T) {
|
||||
skillsDir := t.TempDir()
|
||||
src := writeSourceSkillDir(t, filepath.Join(t.TempDir(), "src"),
|
||||
"---\nname: pdf\ndescription: Fill forms.\n---\nbody\n")
|
||||
outside := filepath.Join(t.TempDir(), "notes.md")
|
||||
if err := os.WriteFile(outside, []byte("outside\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(outside, filepath.Join(src, "notes.md")); err != nil {
|
||||
t.Skipf("symlinks unavailable: %v", err)
|
||||
}
|
||||
var stdout, stderr bytes.Buffer
|
||||
deps := appDeps{skillsDir: func() string { return skillsDir }}
|
||||
if exit := runWithDeps([]string{"skill", "add", src}, &stdout, &stderr, deps); exit != 0 {
|
||||
t.Fatalf("skill add exit = %d, stderr = %s", exit, stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "not copied (symlinks): notes.md") {
|
||||
t.Fatalf("add output does not list the skipped symlink:\n%s", stdout.String())
|
||||
}
|
||||
}
|
||||
@@ -655,7 +655,6 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
// can be combined with the plugin activation's, and emit at most one notice when
|
||||
// project hooks/plugins were dropped for an untrusted workspace.
|
||||
hookDispatcher, hookSkip := newHookDispatcherWithExtra(workspaceRoot, pluginActivation.hooks, trustRoot, executionRunner)
|
||||
specialistRuntime.attachLifecycleHooks(hookDispatcher)
|
||||
emitTrustNotice(stderr, hookSkip, pluginActivation.trustSkip, mcpSkip)
|
||||
result, err := agent.Run(runCtx, agentPrompt, provider, agent.Options{
|
||||
MaxTurns: resolved.MaxTurns,
|
||||
@@ -713,7 +712,7 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
"arguments": call.Arguments,
|
||||
})
|
||||
// Snapshot before-state of files this call will mutate (safe rewind).
|
||||
if checkpoint, ok := sessionRecorder.captureCheckpoint(workspaceRoot, registry, call); ok {
|
||||
if checkpoint, ok := sessionRecorder.captureCheckpoint(workspaceRoot, call); ok {
|
||||
writer.checkpoint(checkpoint)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,206 +0,0 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/Twigpine/zero/internal/agent"
|
||||
"github.com/Twigpine/zero/internal/config"
|
||||
"github.com/Twigpine/zero/internal/sessions"
|
||||
"github.com/Twigpine/zero/internal/tools"
|
||||
"github.com/Twigpine/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
func freeformApplyPatchArguments(root string, absolute bool) string {
|
||||
target := "main.txt"
|
||||
if absolute {
|
||||
target = filepath.Join(root, "main.txt")
|
||||
}
|
||||
return "*** Begin Patch\n*** Update File: " + target + "\n@@\n-before\n+after\n*** End Patch\n"
|
||||
}
|
||||
|
||||
// captureCheckpoint must resolve a freeform apply_patch call through the same
|
||||
// preparation execution uses, so the before-state is captured and rewind can
|
||||
// restore it. The JSON control pins the unchanged path.
|
||||
func TestCaptureCheckpointFreeformApplyPatch(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
freeform bool
|
||||
absolute bool
|
||||
jsonArgs bool
|
||||
}{
|
||||
{name: "freeform", freeform: true},
|
||||
{name: "freeform absolute header", freeform: true, absolute: true},
|
||||
{name: "json arguments", jsonArgs: true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Setenv("XDG_DATA_HOME", t.TempDir())
|
||||
t.Setenv("ZERO_CHECKPOINTS", "on")
|
||||
root := t.TempDir()
|
||||
// The absolute-header case needs the physical root spelling the tool
|
||||
// resolves (macOS /var vs /private/var, Windows short names).
|
||||
if resolved, err := filepath.EvalSymlinks(root); err == nil {
|
||||
root = resolved
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "main.txt"), []byte("before\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store := sessions.NewStore(sessions.StoreOptions{})
|
||||
session, err := store.Create(sessions.CreateInput{Title: "freeform checkpoint"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := store.AppendEvent(session.SessionID, sessions.AppendEventInput{
|
||||
Type: sessions.EventMessage,
|
||||
Payload: map[string]string{"role": "user", "content": "apply the patch"},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
recorder := &execSessionRecorder{prepared: sessions.PreparedExec{Store: store, Session: session}}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedApplyPatchTool(root, nil))
|
||||
|
||||
call := agent.ToolCall{ID: "call", Name: "apply_patch", Freeform: tc.freeform}
|
||||
if tc.jsonArgs {
|
||||
encoded, err := json.Marshal(map[string]string{"patch": freeformApplyPatchArguments(root, false)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
call.Arguments = string(encoded)
|
||||
} else {
|
||||
call.Arguments = freeformApplyPatchArguments(root, tc.absolute)
|
||||
}
|
||||
|
||||
event, ok := recorder.captureCheckpoint(root, registry, call)
|
||||
if !ok || event.Type != sessions.EventSessionCheckpoint {
|
||||
t.Fatalf("captureCheckpoint() = %#v, %t; want a checkpoint event", event, ok)
|
||||
}
|
||||
var payload sessions.CheckpointPayload
|
||||
if err := json.Unmarshal(event.Payload, &payload); err != nil {
|
||||
t.Fatalf("decode checkpoint payload: %v", err)
|
||||
}
|
||||
if len(payload.Files) != 1 || payload.Files[0].Path != "main.txt" || payload.Files[0].Blob == "" {
|
||||
t.Fatalf("checkpoint files = %#v, want main.txt with a blob", payload.Files)
|
||||
}
|
||||
|
||||
// Simulate the tool's mutation, then rewind past it.
|
||||
if err := os.WriteFile(filepath.Join(root, "main.txt"), []byte("after\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
report, err := store.ApplyRewind(session.SessionID, root, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyRewind: %v", err)
|
||||
}
|
||||
if report.FilesRestored != 1 {
|
||||
t.Fatalf("restore report = %#v, want one restored file", report)
|
||||
}
|
||||
content, err := os.ReadFile(filepath.Join(root, "main.txt"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(content) != "before\n" {
|
||||
t.Fatalf("restored content = %q, want before newline", content)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// freeformPatchExecProvider emits one native freeform apply_patch call, then a
|
||||
// final text turn.
|
||||
type freeformPatchExecProvider struct {
|
||||
patch string
|
||||
calls int
|
||||
}
|
||||
|
||||
func (provider *freeformPatchExecProvider) StreamCompletion(ctx context.Context, request zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
|
||||
ch := make(chan zeroruntime.StreamEvent, 5)
|
||||
calls := provider.calls
|
||||
provider.calls++
|
||||
if calls == 0 {
|
||||
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call_patch", ToolName: "apply_patch", ToolCallFreeform: true}
|
||||
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call_patch", ArgumentsFragment: provider.patch}
|
||||
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call_patch"}
|
||||
} else {
|
||||
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventText, Content: "patched"}
|
||||
}
|
||||
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone}
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
// End-to-end: exec captures the freeform checkpoint and `sessions rewind`
|
||||
// restores the pre-patch content.
|
||||
func TestRunExecFreeformApplyPatchCheckpointsForRewind(t *testing.T) {
|
||||
isolateCLIUserState(t)
|
||||
t.Setenv("ZERO_CHECKPOINTS", "on")
|
||||
root := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(root, "main.txt"), []byte("before\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
provider := &freeformPatchExecProvider{patch: freeformApplyPatchArguments(root, false)}
|
||||
var stdout, stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{
|
||||
"exec", "--init-session-id", "freeform_rewind", "--skip-permissions-unsafe",
|
||||
"--output-format", "stream-json", "apply the patch",
|
||||
}, &stdout, &stderr, appDeps{
|
||||
getwd: func() (string, error) { return root, nil },
|
||||
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
|
||||
return execResolvedConfig(), nil
|
||||
},
|
||||
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
|
||||
return provider, nil
|
||||
},
|
||||
})
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("exit = %d, want %d; stderr=%q stdout=%q", exitCode, exitSuccess, stderr.String(), stdout.String())
|
||||
}
|
||||
content, err := os.ReadFile(filepath.Join(root, "main.txt"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(content) != "after\n" {
|
||||
t.Fatalf("patched content = %q, want after newline", content)
|
||||
}
|
||||
|
||||
store := sessions.NewStore(sessions.StoreOptions{})
|
||||
events, err := store.ReadEvents("freeform_rewind")
|
||||
if err != nil {
|
||||
t.Fatalf("ReadEvents: %v", err)
|
||||
}
|
||||
checkpoints := 0
|
||||
for _, event := range events {
|
||||
if event.Type == sessions.EventSessionCheckpoint {
|
||||
checkpoints++
|
||||
}
|
||||
}
|
||||
if checkpoints != 1 {
|
||||
t.Fatalf("checkpoint events = %d, want 1; events=%v", checkpoints, sessionEventTypes(events))
|
||||
}
|
||||
|
||||
report, err := store.ApplyRewind("freeform_rewind", root, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyRewind: %v", err)
|
||||
}
|
||||
if report.FilesRestored != 1 {
|
||||
t.Fatalf("restore report = %#v, want one restored file", report)
|
||||
}
|
||||
content, err = os.ReadFile(filepath.Join(root, "main.txt"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(content) != "before\n" {
|
||||
t.Fatalf("rewound content = %q, want before newline", content)
|
||||
}
|
||||
}
|
||||
|
||||
func sessionEventTypes(events []sessions.Event) []string {
|
||||
types := make([]string, 0, len(events))
|
||||
for _, event := range events {
|
||||
types = append(types, string(event.Type))
|
||||
}
|
||||
return types
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Twigpine/zero/internal/config"
|
||||
"github.com/Twigpine/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
// guardStopExecProvider emits turns with no visible text and no tool calls, so
|
||||
// the empty-turn guardrail stops the run after its consecutive-empty-turn cap.
|
||||
type guardStopExecProvider struct{}
|
||||
|
||||
func (guardStopExecProvider) StreamCompletion(ctx context.Context, request zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
|
||||
ch := make(chan zeroruntime.StreamEvent, 1)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
close(ch)
|
||||
return ch, ctx.Err()
|
||||
case ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone}:
|
||||
}
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
// A guardrail stop in headless exec must use the existing INCOMPLETE contract
|
||||
// (exit 4, error event, run_end status), and --no-completion-gate must keep
|
||||
// reporting success. isolateCLIUserState also turns off the models.dev refresh
|
||||
// that runExec starts in the background (see the #1152/#1161 flake).
|
||||
func TestRunExecGuardrailStopIncompleteContract(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
args []string
|
||||
wantExit int
|
||||
wantStatus string
|
||||
}{
|
||||
{"gate on", []string{"exec", "--output-format", "stream-json", "complete the task"}, exitIncomplete, "incomplete"},
|
||||
{"gate off", []string{"exec", "--no-completion-gate", "--output-format", "stream-json", "complete the task"}, exitSuccess, "success"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
isolateCLIUserState(t)
|
||||
cwd := t.TempDir()
|
||||
var stdout, stderr bytes.Buffer
|
||||
exitCode := runWithDeps(tc.args, &stdout, &stderr, appDeps{
|
||||
getwd: func() (string, error) { return cwd, nil },
|
||||
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
|
||||
return execResolvedConfig(), nil
|
||||
},
|
||||
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
|
||||
return guardStopExecProvider{}, nil
|
||||
},
|
||||
})
|
||||
if exitCode != tc.wantExit {
|
||||
t.Fatalf("exit code = %d, want %d; stderr=%q", exitCode, tc.wantExit, stderr.String())
|
||||
}
|
||||
|
||||
events := decodeJSONLines(t, stdout.String())
|
||||
if len(events) == 0 || events[len(events)-1]["type"] != "run_end" {
|
||||
t.Fatalf("expected run_end as the last event, got %#v", events)
|
||||
}
|
||||
runEnd := events[len(events)-1]
|
||||
if runEnd["status"] != tc.wantStatus {
|
||||
t.Fatalf("run_end status = %v, want %q", runEnd["status"], tc.wantStatus)
|
||||
}
|
||||
if runEnd["exitCode"] != float64(tc.wantExit) {
|
||||
t.Fatalf("run_end exitCode = %v, want %d", runEnd["exitCode"], tc.wantExit)
|
||||
}
|
||||
finalEvent := findJSONEvent(t, events, "final")
|
||||
if text, _ := finalEvent["text"].(string); !strings.Contains(text, "no output") {
|
||||
t.Fatalf("final text = %q, want the guard stop answer", text)
|
||||
}
|
||||
|
||||
if tc.wantStatus == "incomplete" {
|
||||
errorEvent := findJSONEvent(t, events, "error")
|
||||
if errorEvent["code"] != "incomplete" {
|
||||
t.Fatalf("error code = %v, want incomplete", errorEvent["code"])
|
||||
}
|
||||
if message, _ := errorEvent["message"].(string); !strings.Contains(message, "no output") {
|
||||
t.Fatalf("error message = %q, want the guard reason", message)
|
||||
}
|
||||
} else if slices.Contains(jsonEventTypes(events), "error") {
|
||||
t.Fatalf("gate-off run must not emit an error event: %#v", events)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
@@ -18,11 +19,15 @@ type execSessionRecorder struct {
|
||||
// captureCheckpoint snapshots the before-state of files a tool call will mutate.
|
||||
// Best-effort: failures never affect the run. Returns the checkpoint event and
|
||||
// true when one was recorded.
|
||||
func (recorder *execSessionRecorder) captureCheckpoint(workspaceRoot string, registry *tools.Registry, call agent.ToolCall) (sessions.Event, bool) {
|
||||
func (recorder *execSessionRecorder) captureCheckpoint(workspaceRoot string, call agent.ToolCall) (sessions.Event, bool) {
|
||||
if recorder.prepared.Store == nil || recorder.prepared.Session.SessionID == "" {
|
||||
return sessions.Event{}, false
|
||||
}
|
||||
targets := agent.CheckpointTargets(workspaceRoot, registry, call)
|
||||
var args map[string]any
|
||||
if call.Arguments != "" {
|
||||
_ = json.Unmarshal([]byte(call.Arguments), &args)
|
||||
}
|
||||
targets := tools.MutationTargets(workspaceRoot, call.Name, args)
|
||||
if len(targets) == 0 {
|
||||
return sessions.Event{}, false
|
||||
}
|
||||
|
||||
@@ -148,7 +148,7 @@ func runExecSpecDraft(run execSpecDraftRun) int {
|
||||
"name": call.Name,
|
||||
"arguments": call.Arguments,
|
||||
})
|
||||
if checkpoint, ok := sessionRecorder.captureCheckpoint(run.workspaceRoot, run.registry, call); ok {
|
||||
if checkpoint, ok := sessionRecorder.captureCheckpoint(run.workspaceRoot, call); ok {
|
||||
writer.checkpoint(checkpoint)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -2,7 +2,6 @@ package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
@@ -83,105 +82,11 @@ func runPlugins(args []string, stdout io.Writer, stderr io.Writer, deps appDeps)
|
||||
return runPluginInfo(args[1:], stdout, stderr, deps)
|
||||
case "remove", "rm":
|
||||
return runPluginRemove(args[1:], deps.pluginsDir(), stdout, stderr)
|
||||
case "enable":
|
||||
return runPluginsToggle(args[1:], stdout, stderr, deps, false)
|
||||
case "disable":
|
||||
return runPluginsToggle(args[1:], stdout, stderr, deps, true)
|
||||
default:
|
||||
return writeExecUsageError(stderr, fmt.Sprintf("unknown plugins subcommand %q", args[0]))
|
||||
}
|
||||
}
|
||||
|
||||
type pluginToggleOptions struct {
|
||||
json bool
|
||||
user bool
|
||||
}
|
||||
|
||||
// runPluginsToggle implements `zero plugins enable|disable <id>`.
|
||||
//
|
||||
// Every message it writes, including usage errors that echo the caller's
|
||||
// arguments, passes through the redaction pipeline: an argument is user input
|
||||
// and can be a mistyped credential.
|
||||
func runPluginsToggle(args []string, stdout io.Writer, stderr io.Writer, deps appDeps, disabled bool) int {
|
||||
commandName := "enable"
|
||||
if disabled {
|
||||
commandName = "disable"
|
||||
}
|
||||
usageError := func(message string) int {
|
||||
return writeExecUsageError(stderr, redaction.RedactString(message, redaction.Options{}))
|
||||
}
|
||||
options, positional, help, err := parsePluginToggleArgs(args, commandName)
|
||||
if err != nil {
|
||||
return usageError(err.Error())
|
||||
}
|
||||
if help {
|
||||
if err := writePluginsToggleHelp(stdout, commandName); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
if len(positional) != 1 {
|
||||
return usageError(fmt.Sprintf("usage: zero plugins %s <id> [--user] [--json]", commandName))
|
||||
}
|
||||
pluginID := strings.TrimSpace(positional[0])
|
||||
if pluginID == "" {
|
||||
return usageError(fmt.Sprintf("usage: zero plugins %s <id> [--user] [--json]", commandName))
|
||||
}
|
||||
|
||||
cwd, err := deps.getwd()
|
||||
if err != nil {
|
||||
return writeAppError(stderr, "failed to resolve workspace: "+redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
|
||||
}
|
||||
loadOptions := plugins.LoadOptions{Cwd: cwd, ExcludeProject: options.user}
|
||||
result, err := plugins.SetEnabledByID(loadOptions, pluginID, !disabled)
|
||||
if err != nil {
|
||||
if errors.Is(err, plugins.ErrNotInstalled) {
|
||||
return usageError(err.Error())
|
||||
}
|
||||
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
|
||||
}
|
||||
|
||||
state := "enabled"
|
||||
if disabled {
|
||||
state = "disabled"
|
||||
}
|
||||
if options.json {
|
||||
if err := writePrettyJSON(stdout, redaction.RedactValue(result, redaction.Options{})); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
message := fmt.Sprintf("Plugin %s is now %s in %s.\n", result.ID, state, result.ManifestPath)
|
||||
if !result.Changed {
|
||||
message = fmt.Sprintf("Plugin %s was already %s in %s.\n", result.ID, state, result.ManifestPath)
|
||||
}
|
||||
if _, err := fmt.Fprint(stdout, redaction.RedactString(message, redaction.Options{})); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
func parsePluginToggleArgs(args []string, command string) (pluginToggleOptions, []string, bool, error) {
|
||||
options := pluginToggleOptions{}
|
||||
positional := []string{}
|
||||
for _, arg := range args {
|
||||
switch arg {
|
||||
case "-h", "--help", "help":
|
||||
return options, positional, true, nil
|
||||
case "--json":
|
||||
options.json = true
|
||||
case "--user":
|
||||
options.user = true
|
||||
default:
|
||||
if strings.HasPrefix(arg, "-") {
|
||||
return options, positional, false, execUsageError{fmt.Sprintf("unknown plugins %s flag %q", command, arg)}
|
||||
}
|
||||
positional = append(positional, arg)
|
||||
}
|
||||
}
|
||||
return options, positional, false, nil
|
||||
}
|
||||
|
||||
func runHooks(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
||||
if len(args) == 0 {
|
||||
return writeExecUsageError(stderr, "hooks subcommand required. Use `zero hooks list`.")
|
||||
@@ -661,31 +566,10 @@ Commands:
|
||||
info <id> Show plugin details and lockfile metadata
|
||||
add <git-url|path> Install a plugin (manifest-validated, pinned in plugins.lock)
|
||||
remove <id> Remove an installed plugin and its lockfile entry
|
||||
enable <id> Enable a plugin via its plugin.json
|
||||
disable <id> Disable a plugin via its plugin.json
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
func writePluginsToggleHelp(w io.Writer, command string) error {
|
||||
_, err := fmt.Fprintf(w, `Usage:
|
||||
zero plugins %s <id> [flags]
|
||||
|
||||
Flags:
|
||||
--user Target only user plugins (ignore ./.zero/plugins)
|
||||
--json Print command result as JSON
|
||||
-h, --help Show this help
|
||||
|
||||
Without --user, a project plugin with the same id takes precedence over a user
|
||||
plugin, matching the loader's discovery order.
|
||||
|
||||
Zero reads plugins when it starts, so the change applies to the next zero exec
|
||||
run or interactive session. A running interactive session keeps the plugins it
|
||||
started with until you restart it.
|
||||
`, command)
|
||||
return err
|
||||
}
|
||||
|
||||
func writePluginsListHelp(w io.Writer) error {
|
||||
_, err := fmt.Fprint(w, `Usage:
|
||||
zero plugins list [flags]
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -334,234 +333,3 @@ func TestRunMCPPermissionsHelpDoesNotOpenStore(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunPluginsEnableDisable(t *testing.T) {
|
||||
cwd := t.TempDir()
|
||||
home := t.TempDir()
|
||||
t.Setenv("XDG_CONFIG_HOME", home)
|
||||
t.Setenv("HOME", home)
|
||||
pluginDir := filepath.Join(cwd, ".zero", "plugins", "demo")
|
||||
if err := os.MkdirAll(pluginDir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
manifestPath := filepath.Join(pluginDir, "plugin.json")
|
||||
if err := os.WriteFile(manifestPath, []byte(`{
|
||||
"schemaVersion": 1,
|
||||
"id": "zero.demo",
|
||||
"name": "Demo",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
deps := appDeps{getwd: func() (string, error) { return cwd, nil }}
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{"plugins", "disable", "zero.demo", "--json"}, &stdout, &stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("disable exit=%d stderr=%s", exitCode, stderr.String())
|
||||
}
|
||||
var disablePayload plugins.SetEnabledResult
|
||||
if err := json.Unmarshal(stdout.Bytes(), &disablePayload); err != nil {
|
||||
t.Fatalf("decode disable JSON: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if disablePayload.ID != "zero.demo" || disablePayload.Enabled || !disablePayload.Changed {
|
||||
t.Fatalf("unexpected disable payload: %#v", disablePayload)
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile(manifestPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(raw), `"enabled": false`) {
|
||||
t.Fatalf("manifest not disabled: %s", raw)
|
||||
}
|
||||
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
exitCode = runWithDeps([]string{"plugins", "disable", "zero.demo"}, &stdout, &stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("noop disable exit=%d stderr=%s", exitCode, stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "was already disabled") {
|
||||
t.Fatalf("noop disable output: %s", stdout.String())
|
||||
}
|
||||
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
exitCode = runWithDeps([]string{"plugins", "enable", "zero.demo"}, &stdout, &stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("enable exit=%d stderr=%s", exitCode, stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "is now enabled") {
|
||||
t.Fatalf("enable output: %s", stdout.String())
|
||||
}
|
||||
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
exitCode = runWithDeps([]string{"plugins", "enable", "missing-plugin"}, &stdout, &stderr, deps)
|
||||
if exitCode != exitUsage {
|
||||
t.Fatalf("missing plugin exit=%d want %d stderr=%s", exitCode, exitUsage, stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
// The text-mode toggle output runs through the redaction pipeline, so a
|
||||
// manifest path that contains something secret-shaped is masked.
|
||||
func TestRunPluginsToggleTextOutputIsRedacted(t *testing.T) {
|
||||
cwd := t.TempDir()
|
||||
home := t.TempDir()
|
||||
t.Setenv("XDG_CONFIG_HOME", home)
|
||||
t.Setenv("HOME", home)
|
||||
secretDir := "sk-proj-abcdefghijklmnopqrstuvwxyz"
|
||||
pluginDir := filepath.Join(cwd, ".zero", "plugins", secretDir)
|
||||
if err := os.MkdirAll(pluginDir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
manifestPath := filepath.Join(pluginDir, "plugin.json")
|
||||
if err := os.WriteFile(manifestPath, []byte(`{
|
||||
"schemaVersion": 1,
|
||||
"id": "zero.demo",
|
||||
"name": "Demo",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
deps := appDeps{getwd: func() (string, error) { return cwd, nil }}
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{"plugins", "disable", "zero.demo"}, &stdout, &stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("disable exit=%d stderr=%s", exitCode, stderr.String())
|
||||
}
|
||||
if strings.Contains(stdout.String(), secretDir) {
|
||||
t.Fatalf("toggle text leaked a secret-shaped manifest path: %q", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "[REDACTED]") {
|
||||
t.Fatalf("toggle text was not redacted: %q", stdout.String())
|
||||
}
|
||||
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
exitCode = runWithDeps([]string{"plugins", "disable", "zero.demo"}, &stdout, &stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("noop disable exit=%d stderr=%s", exitCode, stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "was already disabled") {
|
||||
t.Fatalf("noop toggle output: %q", stdout.String())
|
||||
}
|
||||
if strings.Contains(stdout.String(), secretDir) {
|
||||
t.Fatalf("noop toggle text leaked a secret-shaped manifest path: %q", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunPluginsToggleRejectsEmptyID(t *testing.T) {
|
||||
deps := appDeps{getwd: func() (string, error) { return t.TempDir(), nil }}
|
||||
|
||||
for _, subcommand := range []string{"enable", "disable"} {
|
||||
t.Run(subcommand, func(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{"plugins", subcommand, ""}, &stdout, &stderr, deps)
|
||||
if exitCode != exitUsage {
|
||||
t.Fatalf("exit=%d want %d stderr=%s", exitCode, exitUsage, stderr.String())
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "usage: zero plugins "+subcommand) {
|
||||
t.Fatalf("stderr = %q", stderr.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunPluginsDisableUserIgnoresProject(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
cwd := t.TempDir()
|
||||
t.Setenv("XDG_CONFIG_HOME", home)
|
||||
t.Setenv("HOME", home)
|
||||
|
||||
userManifest := filepath.Join(home, "zero", "plugins", "demo", "plugin.json")
|
||||
projectManifest := filepath.Join(cwd, ".zero", "plugins", "demo", "plugin.json")
|
||||
for _, path := range []string{userManifest, projectManifest} {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(`{
|
||||
"schemaVersion": 1,
|
||||
"id": "zero.demo",
|
||||
"name": "Demo",
|
||||
"version": "1.0.0",
|
||||
"enabled": true
|
||||
}
|
||||
`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
deps := appDeps{getwd: func() (string, error) { return cwd, nil }}
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{"plugins", "disable", "zero.demo", "--user"}, &stdout, &stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("disable --user exit=%d stderr=%s", exitCode, stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "is now disabled") {
|
||||
t.Fatalf("disable --user output: %s", stdout.String())
|
||||
}
|
||||
|
||||
userRaw, err := os.ReadFile(userManifest)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(userRaw), `"enabled": false`) {
|
||||
t.Fatalf("user manifest not disabled: %s", userRaw)
|
||||
}
|
||||
projectRaw, err := os.ReadFile(projectManifest)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(projectRaw), `"enabled": false`) {
|
||||
t.Fatalf("project manifest should stay enabled: %s", projectRaw)
|
||||
}
|
||||
}
|
||||
|
||||
// The toggle's usage errors echo the caller's arguments, which can be a
|
||||
// mistyped credential, so they pass through redaction like its other output.
|
||||
func TestRunPluginsToggleUsageErrorsAreRedacted(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("XDG_CONFIG_HOME", home)
|
||||
t.Setenv("HOME", home)
|
||||
deps := appDeps{getwd: func() (string, error) { return t.TempDir(), nil }}
|
||||
const secret = "sk-proj-abcdefghijklmnopqrstuvwxyz"
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{"unknown flag", []string{"zero.demo", "--token=" + secret}, "unknown plugins"},
|
||||
{"missing plugin", []string{secret}, "plugin is not installed"},
|
||||
}
|
||||
for _, subcommand := range []string{"enable", "disable"} {
|
||||
for _, tc := range cases {
|
||||
t.Run(subcommand+" "+tc.name, func(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
args := append([]string{"plugins", subcommand}, tc.args...)
|
||||
exitCode := runWithDeps(args, &stdout, &stderr, deps)
|
||||
if exitCode != exitUsage {
|
||||
t.Fatalf("exit=%d want %d stderr=%s", exitCode, exitUsage, stderr.String())
|
||||
}
|
||||
if strings.Contains(stderr.String(), secret) {
|
||||
t.Fatalf("usage error leaked the argument: %q", stderr.String())
|
||||
}
|
||||
if !strings.Contains(stderr.String(), tc.want) || !strings.Contains(stderr.String(), "[REDACTED]") {
|
||||
t.Fatalf("stderr = %q, want a redacted %q error", stderr.String(), tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -319,7 +319,7 @@ Flags:
|
||||
--command <cmd> Command to run (required)
|
||||
--name <name> Human-readable hook name
|
||||
--description <text> Hook description
|
||||
--matcher <pattern> Tool/specialist matcher (beforeTool/afterTool/specialistStart/specialistStop)
|
||||
--matcher <pattern> Tool matcher (beforeTool/afterTool only)
|
||||
--arg <value> Command argument (repeatable)
|
||||
--user Write to user config instead of the project
|
||||
--json Print command result as JSON
|
||||
|
||||
@@ -741,13 +741,6 @@ func (cfg *mcpWritableConfig) upsertServer(name string, server config.MCPServerC
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !mergedServer.Disabled && config.IsDefaultMCPServerDisabled(name) {
|
||||
// Adding a server under the name of a default that ships disabled means
|
||||
// the user wants it on; the omitted flag would otherwise keep it off.
|
||||
if data, err = setMCPServerRawDisabledFalse(data); err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
cfg.serverRaw[name] = data
|
||||
if _, err := cfg.removeLegacyServer(name); err != nil {
|
||||
return false, err
|
||||
@@ -807,29 +800,11 @@ func mergeMCPServerRaw(existing json.RawMessage, server config.MCPServerConfig)
|
||||
if key == "oauth" && preserveRawOAuth {
|
||||
continue
|
||||
}
|
||||
// `mcp add` replaces the entry: it writes only a disable, never a stale
|
||||
// explicit false carried over from the old entry (upsertServer adds the
|
||||
// false a default that ships disabled needs).
|
||||
if key == "disabled" && string(value) == "false" {
|
||||
continue
|
||||
}
|
||||
merged[key] = value
|
||||
}
|
||||
return json.Marshal(merged)
|
||||
}
|
||||
|
||||
func setMCPServerRawDisabledFalse(data json.RawMessage) (json.RawMessage, error) {
|
||||
var server map[string]json.RawMessage
|
||||
if err := json.Unmarshal(data, &server); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if server == nil {
|
||||
server = map[string]json.RawMessage{}
|
||||
}
|
||||
server["disabled"] = json.RawMessage("false")
|
||||
return json.Marshal(server)
|
||||
}
|
||||
|
||||
func (cfg *mcpWritableConfig) removeServer(name string) (bool, error) {
|
||||
cfg.ensureRaw()
|
||||
removed := false
|
||||
@@ -881,19 +856,15 @@ func (cfg *mcpWritableConfig) setServerDisabled(name string, disabled bool) (boo
|
||||
server = map[string]json.RawMessage{}
|
||||
}
|
||||
|
||||
// A default that ships disabled (aimlapi) stays off until an entry says
|
||||
// otherwise, so an absent flag reads as disabled and enabling it must be
|
||||
// written out explicitly: dropping the flag would leave it off.
|
||||
shippedDisabled := config.IsDefaultMCPServerDisabled(name)
|
||||
current := shippedDisabled
|
||||
current := false
|
||||
if rawDisabled, ok := server["disabled"]; ok && len(rawDisabled) > 0 && string(rawDisabled) != "null" {
|
||||
if err := json.Unmarshal(rawDisabled, ¤t); err != nil {
|
||||
return false, false, err
|
||||
}
|
||||
}
|
||||
changed := current != disabled
|
||||
if disabled || shippedDisabled {
|
||||
data, err := json.Marshal(disabled)
|
||||
if disabled {
|
||||
data, err := json.Marshal(true)
|
||||
if err != nil {
|
||||
return false, false, err
|
||||
}
|
||||
|
||||
@@ -2,18 +2,10 @@ package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/Twigpine/zero/internal/aimlapi"
|
||||
"github.com/Twigpine/zero/internal/config"
|
||||
)
|
||||
|
||||
@@ -52,255 +44,3 @@ func TestRunMCPDisableSeededExaDefault(t *testing.T) {
|
||||
t.Fatal("expected `mcp disable exa` to turn the seeded default off in the resolved config")
|
||||
}
|
||||
}
|
||||
|
||||
// aimlapiWire stands in for https://mcp.aimlapi.com/mcp at the transport layer:
|
||||
// requests keep their real URL, nothing is dialed, and each one is recorded.
|
||||
type aimlapiWire struct {
|
||||
mu sync.Mutex
|
||||
requests []*http.Request
|
||||
}
|
||||
|
||||
func (wire *aimlapiWire) RoundTrip(request *http.Request) (*http.Response, error) {
|
||||
var body []byte
|
||||
if request.Body != nil {
|
||||
body, _ = io.ReadAll(request.Body)
|
||||
_ = request.Body.Close()
|
||||
}
|
||||
wire.mu.Lock()
|
||||
wire.requests = append(wire.requests, request.Clone(context.Background()))
|
||||
wire.mu.Unlock()
|
||||
var message struct {
|
||||
ID json.RawMessage `json:"id"`
|
||||
Method string `json:"method"`
|
||||
}
|
||||
_ = json.Unmarshal(body, &message)
|
||||
respond := func(status int, payload string) *http.Response {
|
||||
return &http.Response{StatusCode: status, Header: http.Header{"Content-Type": {"application/json"}}, Body: io.NopCloser(strings.NewReader(payload)), Request: request}
|
||||
}
|
||||
if request.URL.String() != aimlapi.MCPServerURL {
|
||||
return respond(http.StatusBadGateway, `{}`), nil
|
||||
}
|
||||
if len(message.ID) == 0 {
|
||||
return respond(http.StatusAccepted, ""), nil
|
||||
}
|
||||
result := `{"protocolVersion":"2024-11-05","capabilities":{},"serverInfo":{"name":"fake","version":"1"}}`
|
||||
if message.Method == "tools/list" {
|
||||
result = `{"tools":[{"name":"tools_search","description":"Search the Toolbox","inputSchema":{"type":"object"}}]}`
|
||||
}
|
||||
return respond(http.StatusOK, fmt.Sprintf(`{"jsonrpc":"2.0","id":%s,"result":%s}`, message.ID, result)), nil
|
||||
}
|
||||
|
||||
func (wire *aimlapiWire) recorded() []*http.Request {
|
||||
wire.mu.Lock()
|
||||
defer wire.mu.Unlock()
|
||||
return append([]*http.Request(nil), wire.requests...)
|
||||
}
|
||||
|
||||
// isolateAIMLAPIMCPCommand redirects every root the real resolve path reads to
|
||||
// a temporary home, sets an AI/ML API key in the environment, and routes the
|
||||
// default HTTP transport to a recorder. It returns the user config path.
|
||||
func isolateAIMLAPIMCPCommand(t *testing.T, key string) (string, *aimlapiWire, appDeps) {
|
||||
t.Helper()
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("USERPROFILE", home)
|
||||
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
|
||||
t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".state"))
|
||||
t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
|
||||
t.Setenv("APPDATA", filepath.Join(home, "AppData", "Roaming"))
|
||||
t.Setenv("LOCALAPPDATA", filepath.Join(home, "AppData", "Local"))
|
||||
t.Setenv("ZERO_CRED_STORAGE", "file")
|
||||
t.Setenv("ZERO_PROVIDER", "")
|
||||
t.Setenv("AIMLAPI_INFERENCE_URL", "")
|
||||
t.Setenv("AIMLAPI_PARTNER_ID", "")
|
||||
t.Setenv("AIMLAPI_API_KEY", key)
|
||||
configPath, err := config.DefaultUserConfigPath()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeMCPCommandRawConfig(t, configPath, `{}`)
|
||||
|
||||
wire := &aimlapiWire{}
|
||||
previous := http.DefaultTransport
|
||||
http.DefaultTransport = wire
|
||||
t.Cleanup(func() { http.DefaultTransport = previous })
|
||||
|
||||
workspace := t.TempDir()
|
||||
return configPath, wire, appDeps{getwd: func() (string, error) { return workspace, nil }}
|
||||
}
|
||||
|
||||
func TestRunMCPEnableAIMLAPIDefaultWritesOnlyTheToggle(t *testing.T) {
|
||||
configPath, wire, deps := isolateAIMLAPIMCPCommand(t, "aiml-secret-key")
|
||||
|
||||
var out, errBuf bytes.Buffer
|
||||
if code := runWithDeps([]string{"mcp", "disable", "aimlapi", "--json"}, &out, &errBuf, deps); code != exitSuccess {
|
||||
t.Fatalf("disable exit=%d stderr=%s", code, errBuf.String())
|
||||
}
|
||||
if !strings.Contains(out.String(), `"changed": false`) {
|
||||
t.Fatalf("disabling the off-by-default server should be a no-op: %s", out.String())
|
||||
}
|
||||
|
||||
out.Reset()
|
||||
errBuf.Reset()
|
||||
if code := runWithDeps([]string{"mcp", "enable", "aimlapi", "--json"}, &out, &errBuf, deps); code != exitSuccess {
|
||||
t.Fatalf("enable exit=%d stderr=%s", code, errBuf.String())
|
||||
}
|
||||
if !strings.Contains(out.String(), `"changed": true`) {
|
||||
t.Fatalf("enable should report a change: %s", out.String())
|
||||
}
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var written struct {
|
||||
MCP struct {
|
||||
Servers map[string]map[string]any `json:"servers"`
|
||||
} `json:"mcp"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &written); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if entry := written.MCP.Servers["aimlapi"]; len(entry) != 1 || entry["disabled"] != false {
|
||||
t.Fatalf("enable must write only the toggle, got %#v in %s", entry, data)
|
||||
}
|
||||
if strings.Contains(string(data), "aiml-secret-key") || strings.Contains(string(data), "Authorization") {
|
||||
t.Fatalf("config holds credential material: %s", data)
|
||||
}
|
||||
|
||||
cfg, err := config.ResolveMCP(config.ResolveOptions{UserConfigPath: configPath})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if server := cfg.Servers["aimlapi"]; server.Disabled || config.IsUnconfiguredDefault("aimlapi", server) {
|
||||
t.Fatalf("enabled aimlapi = %#v, want enabled and user-configured", server)
|
||||
}
|
||||
// An unrelated save rewrites the whole file through FileConfig; the
|
||||
// explicit enable has to survive it or the shipped disable comes back.
|
||||
if _, err := config.SetTheme(configPath, "dark"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err = config.ResolveMCP(config.ResolveOptions{UserConfigPath: configPath})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.Servers["aimlapi"].Disabled {
|
||||
data, _ := os.ReadFile(configPath)
|
||||
t.Fatalf("an unrelated config save turned aimlapi off again: %s", data)
|
||||
}
|
||||
if len(wire.recorded()) != 0 {
|
||||
t.Fatal("enable must not connect")
|
||||
}
|
||||
|
||||
out.Reset()
|
||||
if code := runWithDeps([]string{"mcp", "disable", "aimlapi"}, &out, &errBuf, deps); code != exitSuccess {
|
||||
t.Fatalf("disable exit=%d stderr=%s", code, errBuf.String())
|
||||
}
|
||||
cfg, err = config.ResolveMCP(config.ResolveOptions{UserConfigPath: configPath})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !cfg.Servers["aimlapi"].Disabled {
|
||||
t.Fatal("disable after enable must turn aimlapi off again")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunMCPAddUnderAIMLAPIDefaultNameEnablesIt(t *testing.T) {
|
||||
configPath, _, deps := isolateAIMLAPIMCPCommand(t, "")
|
||||
var out, errBuf bytes.Buffer
|
||||
if code := runWithDeps([]string{"mcp", "add", "aimlapi", "--type", "http", "--url", aimlapi.MCPServerURL, "--auth", "oauth"}, &out, &errBuf, deps); code != exitSuccess {
|
||||
t.Fatalf("add exit=%d stderr=%s", code, errBuf.String())
|
||||
}
|
||||
cfg, err := config.ResolveMCP(config.ResolveOptions{UserConfigPath: configPath})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if server := cfg.Servers["aimlapi"]; server.Disabled || server.Auth != "oauth" {
|
||||
t.Fatalf("added aimlapi = %#v, want enabled with oauth", server)
|
||||
}
|
||||
if code := runWithDeps([]string{"mcp", "add", "aimlapi", "--type", "http", "--url", aimlapi.MCPServerURL, "--disabled"}, &out, &errBuf, deps); code != exitSuccess {
|
||||
t.Fatalf("add --disabled exit=%d stderr=%s", code, errBuf.String())
|
||||
}
|
||||
if cfg, err = config.ResolveMCP(config.ResolveOptions{UserConfigPath: configPath}); err != nil || !cfg.Servers["aimlapi"].Disabled {
|
||||
t.Fatalf("add --disabled left aimlapi enabled: %#v, %v", cfg.Servers["aimlapi"], err)
|
||||
}
|
||||
}
|
||||
|
||||
// Through the real resolve path (no injected resolveMCPConfig): the default is
|
||||
// listed as disabled, never connects, and once enabled it reaches session
|
||||
// startup and `zero mcp check`, which connects with the key on the wire.
|
||||
func TestAIMLAPIMCPDefaultThroughRealResolvePath(t *testing.T) {
|
||||
_, wire, deps := isolateAIMLAPIMCPCommand(t, "aiml-secret-key")
|
||||
|
||||
var out, errBuf bytes.Buffer
|
||||
if code := runWithDeps([]string{"mcp", "list"}, &out, &errBuf, deps); code != exitSuccess {
|
||||
t.Fatalf("list exit=%d stderr=%s", code, errBuf.String())
|
||||
}
|
||||
if !strings.Contains(out.String(), "aimlapi [http] disabled https://mcp.aimlapi.com/mcp") || strings.Contains(out.String(), "aiml-secret-key") {
|
||||
t.Fatalf("list output should show the disabled aimlapi default and no key:\n%s", out.String())
|
||||
}
|
||||
out.Reset()
|
||||
if code := runWithDeps([]string{"mcp", "list", "--json"}, &out, &errBuf, deps); code != exitSuccess {
|
||||
t.Fatalf("list --json exit=%d stderr=%s", code, errBuf.String())
|
||||
}
|
||||
if !strings.Contains(out.String(), `"aimlapi"`) || strings.Contains(out.String(), "aiml-secret-key") {
|
||||
t.Fatalf("list --json = %s", out.String())
|
||||
}
|
||||
out.Reset()
|
||||
errBuf.Reset()
|
||||
if code := runWithDeps([]string{"mcp", "check", "aimlapi"}, &out, &errBuf, deps); code == exitSuccess || !strings.Contains(errBuf.String(), "is disabled") {
|
||||
t.Fatalf("check of the disabled default: exit=%d stderr=%s", code, errBuf.String())
|
||||
}
|
||||
resolved, err := fillAppDeps(deps).resolveMCPConfig(t.TempDir(), true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
critical, optional := splitMCPStartupConfig(resolved)
|
||||
if _, ok := critical.Servers["aimlapi"]; ok {
|
||||
t.Fatal("the disabled default must not start with a session")
|
||||
}
|
||||
if _, ok := optional.Servers["aimlapi"]; ok {
|
||||
t.Fatal("the disabled default must not start with a session")
|
||||
}
|
||||
if len(wire.recorded()) != 0 {
|
||||
t.Fatalf("the disabled default connected: %d requests", len(wire.recorded()))
|
||||
}
|
||||
|
||||
if code := runWithDeps([]string{"mcp", "enable", "aimlapi"}, &out, &errBuf, deps); code != exitSuccess {
|
||||
t.Fatalf("enable exit=%d stderr=%s", code, errBuf.String())
|
||||
}
|
||||
resolved, err = fillAppDeps(deps).resolveMCPConfig(t.TempDir(), true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
critical, _ = splitMCPStartupConfig(resolved)
|
||||
if _, ok := critical.Servers["aimlapi"]; !ok {
|
||||
t.Fatal("an enabled aimlapi must start with the session as a user-configured server")
|
||||
}
|
||||
|
||||
out.Reset()
|
||||
errBuf.Reset()
|
||||
if code := runWithDeps([]string{"mcp", "check", "aimlapi", "--json"}, &out, &errBuf, deps); code != exitSuccess {
|
||||
t.Fatalf("check exit=%d stdout=%s stderr=%s", code, out.String(), errBuf.String())
|
||||
}
|
||||
if !strings.Contains(out.String(), `"status": "ok"`) || !strings.Contains(out.String(), `"toolCount": 1`) {
|
||||
t.Fatalf("check output = %s", out.String())
|
||||
}
|
||||
if strings.Contains(out.String()+errBuf.String(), "aiml-secret-key") {
|
||||
t.Fatalf("check output leaks the key: %s %s", out.String(), errBuf.String())
|
||||
}
|
||||
requests := wire.recorded()
|
||||
if len(requests) == 0 {
|
||||
t.Fatal("check did not connect")
|
||||
}
|
||||
for _, request := range requests {
|
||||
if request.URL.String() != aimlapi.MCPServerURL {
|
||||
t.Fatalf("request went to %s", request.URL)
|
||||
}
|
||||
if got := request.Header.Get("Authorization"); got != "Bearer aiml-secret-key" {
|
||||
t.Fatalf("Authorization = %q, want the AI/ML API key", got)
|
||||
}
|
||||
if request.Header.Get(aimlapi.SourceHeaderName) != aimlapi.SourceValue || request.Header.Get(aimlapi.PartnerHeaderName) == "" {
|
||||
t.Fatalf("attribution headers missing: %v", request.Header)
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
-1
@@ -22,7 +22,7 @@ const permissionProfileScopeNote = "permissionProfile is derived from this proce
|
||||
|
||||
func runSandbox(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
||||
if len(args) == 0 {
|
||||
return writeExecUsageError(stderr, "sandbox subcommand required. Use `zero sandbox policy` or `zero sandbox grants list`.")
|
||||
return writeExecUsageError(stderr, "sandbox subcommand required. Use `zero sandbox policy`, `zero sandbox exec`, or `zero sandbox grants list`.")
|
||||
}
|
||||
switch args[0] {
|
||||
case "-h", "--help", "help":
|
||||
@@ -36,6 +36,8 @@ func runSandbox(args []string, stdout io.Writer, stderr io.Writer, deps appDeps)
|
||||
return runSandboxSetup(args[1:], stdout, stderr, deps)
|
||||
case "check":
|
||||
return runSandboxCheck(args[1:], stdout, stderr, deps)
|
||||
case "exec":
|
||||
return runSandboxExec(args[1:], stdout, stderr, deps)
|
||||
case "grants":
|
||||
return runSandboxGrants(args[1:], stdout, stderr, deps)
|
||||
default:
|
||||
@@ -174,10 +176,17 @@ func runSandboxSetup(args []string, stdout io.Writer, stderr io.Writer, deps app
|
||||
if !setupHelper.Available() {
|
||||
return writeAppError(stderr, "Windows sandbox setup helper is not available", exitProvider)
|
||||
}
|
||||
// Resolved here, in the shell the user typed `zero sandbox setup` into, and
|
||||
// carried in the args. The helper may be launched elevated, and an elevated
|
||||
// process does not inherit this shell's environment. Stated explicitly rather
|
||||
// than left nil (which resolves the same way) because this is the call site
|
||||
// the opt-in is about.
|
||||
principalOptIn := zeroSandbox.WindowsSandboxPrincipalOptIn(nil)
|
||||
setupArgs, err := zeroSandbox.BuildWindowsSandboxSetupArgs(zeroSandbox.WindowsSandboxSetupArgsOptions{
|
||||
CommandCWD: workspaceRoot,
|
||||
WorkspaceRoots: []string{workspaceRoot},
|
||||
PermissionProfile: profile,
|
||||
PrincipalOptIn: &principalOptIn,
|
||||
})
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
@@ -646,6 +655,7 @@ Commands:
|
||||
policy Inspect active sandbox policy and platform backend
|
||||
setup Run native platform sandbox setup
|
||||
check Evaluate the sandbox decision for a hypothetical tool action
|
||||
exec Run one command through the real sandbox
|
||||
grants Manage persistent sandbox grants
|
||||
|
||||
`)
|
||||
|
||||
@@ -0,0 +1,358 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Twigpine/zero/internal/config"
|
||||
"github.com/Twigpine/zero/internal/execution"
|
||||
zeroSandbox "github.com/Twigpine/zero/internal/sandbox"
|
||||
)
|
||||
|
||||
// runSandboxExec runs ONE command through the real sandbox and exits with its
|
||||
// status.
|
||||
//
|
||||
// This exists because until now the sandbox could only be exercised through a
|
||||
// full agent turn with a model in the loop. `zero sandbox policy` reports what
|
||||
// the posture would be and `zero sandbox check` evaluates a hypothetical
|
||||
// decision, but nothing actually ran a command and let you look at what
|
||||
// happened on disk afterwards. The practical result is that enforcement is
|
||||
// covered almost entirely by tests asserting the shape of an ACL plan, and
|
||||
// almost not at all by tests asserting a write was refused.
|
||||
//
|
||||
// A plan can be perfectly correct and never reach the filesystem. That is not
|
||||
// hypothetical here: the .git rename guard was emitted correctly by the planner
|
||||
// and silently skipped by the applier, and four tests covering the plan all
|
||||
// passed while the ACE was absent from disk. Something that runs the real
|
||||
// binary and then stats the file is the only thing that catches that class.
|
||||
//
|
||||
// Deliberately NOT a debug curiosity: it takes the same path a shell tool
|
||||
// takes, the engine's decision first and then the engine's command plan, so
|
||||
// what it proves is what users get. It prints the decision, then the resolved
|
||||
// backend and enforcement level, to stderr before running, so a harness can
|
||||
// assert the sandbox was actually engaged rather than quietly downgraded.
|
||||
func runSandboxExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
||||
command, err := parseSandboxExecArgs(args)
|
||||
if err != nil {
|
||||
if errors.Is(err, errSandboxExecHelp) {
|
||||
if writeErr := writeSandboxExecHelp(stdout); writeErr != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
return writeExecUsageError(stderr, err.Error())
|
||||
}
|
||||
|
||||
workspaceRoot, err := resolveWorkspaceRoot("", deps)
|
||||
if err != nil {
|
||||
return writeExecUsageError(stderr, err.Error())
|
||||
}
|
||||
resolved, err := deps.resolveConfig(workspaceRoot, config.Overrides{})
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitProvider)
|
||||
}
|
||||
policy := applyConfiguredSandboxPolicy(zeroSandbox.DefaultPolicy(), resolved.Sandbox)
|
||||
|
||||
scope, err := zeroSandbox.NewScope(workspaceRoot, resolved.Sandbox.AdditionalWriteRoots)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, fmt.Sprintf("resolve sandbox write roots: %v", err), exitCrash)
|
||||
}
|
||||
|
||||
// Through the ENGINE, not a SandboxManager built here.
|
||||
//
|
||||
// The engine is what a real tool call goes through, and it does more than
|
||||
// hand the manager a request: it resolves the permission profile, calls
|
||||
// prepareSandboxRuntime, and folds the runtime state into that profile
|
||||
// before planning. Building a manager directly skipped all of it, so the
|
||||
// command ran without the runtime write root, and cache or temp writes could
|
||||
// pass or fail differently from the sandboxed command this exists to imitate.
|
||||
// A harness that exercises the wrong path is worse than no harness, because
|
||||
// its result still reads as evidence.
|
||||
engine := zeroSandbox.NewEngine(zeroSandbox.EngineOptions{
|
||||
WorkspaceRoot: workspaceRoot,
|
||||
Policy: policy,
|
||||
Scope: scope,
|
||||
Backend: deps.selectSandboxBackend(zeroSandbox.BackendOptions{}),
|
||||
// The same scrub list a real tool call gets. Without it the engine keeps an
|
||||
// empty set, and only the hardcoded names plus the provider catalog's own
|
||||
// AuthEnvVars are removed from the child's environment: a key named by
|
||||
// `apiKeyEnv` in the user's config would be scrubbed for every sandboxed
|
||||
// tool call and handed to this one. Reproducing the production environment
|
||||
// is the entire point of the command, and a credential is the last part of
|
||||
// it that may differ.
|
||||
SensitiveEnvKeys: providerSensitiveEnvKeys(resolved),
|
||||
})
|
||||
|
||||
// The CLI's shared shutdown context, so Ctrl+C and a directed SIGTERM both
|
||||
// arrive here rather than only at whatever the terminal happens to signal.
|
||||
runCtx, stopSignals := signalContext()
|
||||
defer stopSignals()
|
||||
|
||||
// THE DECISION COMES BEFORE THE PLAN, as it does for every real tool call.
|
||||
//
|
||||
// A shell tool is evaluated by the engine and only then planned
|
||||
// (tools.Registry.RunWithOptions). This went straight to BuildCommandPlan, so
|
||||
// it took "the same path a shell tool takes" only from the second step on, and
|
||||
// every refusal that lives in Evaluate did not exist here. The one that
|
||||
// matters most is the nested-repository guard: a workspace governed by an
|
||||
// ancestor repository carries NO git carveouts, on the strength of that guard
|
||||
// refusing to create a repository there, and `zero sandbox exec -- git init`
|
||||
// created one under the plain workspace grant with a writable config and
|
||||
// hooks. A harness that runs what production would have refused does not
|
||||
// prove what users get. Reported by @jatmn.
|
||||
//
|
||||
// PermissionGranted is true because the person at the terminal typed the
|
||||
// command, which is the approval a prompt asks a session user for. It lifts
|
||||
// exactly what an approval lifts in a session and nothing else: a denial is
|
||||
// still a denial, and an approved network command still runs inside a sandbox
|
||||
// that has the network off.
|
||||
decision := sandboxExecDecision(runCtx, engine, workspaceRoot, command)
|
||||
fmt.Fprintf(stderr, "sandbox: decision=%s reason=%s\n", decision.Action, decision.Reason)
|
||||
if decision.Action == zeroSandbox.ActionDeny {
|
||||
return writeAppError(stderr, decision.ErrorString(), exitCrash)
|
||||
}
|
||||
|
||||
plan, err := engine.BuildCommandPlan(zeroSandbox.CommandSpec{
|
||||
Name: command[0],
|
||||
Args: command[1:],
|
||||
Dir: workspaceRoot,
|
||||
Env: os.Environ(),
|
||||
})
|
||||
if err != nil {
|
||||
return writeAppError(stderr, fmt.Sprintf("build sandbox command plan: %v", err), exitCrash)
|
||||
}
|
||||
// The plan owns resources beyond its construction: on Linux it allocates a
|
||||
// policy-report file and registers the removal here, so without this every
|
||||
// invocation leaves a /tmp/zero-sandbox-report-* behind.
|
||||
defer plan.Cleanup()
|
||||
|
||||
// Printed before the command runs and on stderr, so it survives a command
|
||||
// that writes to stdout and stays greppable by a test harness. A downgrade
|
||||
// is reported loudly for the same reason: a smoke test that passes because
|
||||
// the sandbox quietly stood down is worse than no smoke test.
|
||||
fmt.Fprintf(stderr, "sandbox: backend=%s enforcement=%s wrapped=%t workspace=%s\n",
|
||||
plan.Backend.Name, plan.EnforcementLevel, plan.Wrapped, plan.WorkspaceRoot)
|
||||
if strings.TrimSpace(plan.DowngradeReason) != "" {
|
||||
fmt.Fprintf(stderr, "sandbox: DOWNGRADED: %s\n", plan.DowngradeReason)
|
||||
}
|
||||
|
||||
return runSandboxPlannedCommand(runCtx, plan, stdout, stderr)
|
||||
}
|
||||
|
||||
// sandboxExecToolName is the tool a session would have run this command with.
|
||||
// Grants and the engine's shell rules are keyed by it.
|
||||
const sandboxExecToolName = "bash"
|
||||
|
||||
// sandboxExecDecision asks the engine the question a session asks before it
|
||||
// plans a shell command, for the argv this invocation was given.
|
||||
func sandboxExecDecision(ctx context.Context, engine *zeroSandbox.Engine, workspaceRoot string, argv []string) zeroSandbox.Decision {
|
||||
return engine.Evaluate(ctx, zeroSandbox.Request{
|
||||
WorkspaceRoot: workspaceRoot,
|
||||
ToolName: sandboxExecToolName,
|
||||
SideEffect: zeroSandbox.SideEffectShell,
|
||||
PermissionGranted: true,
|
||||
PermissionMode: zeroSandbox.PermissionModeAsk,
|
||||
Args: map[string]any{"command": sandboxExecCommandText(argv)},
|
||||
Reason: "zero sandbox exec",
|
||||
})
|
||||
}
|
||||
|
||||
// sandboxExecCommandText renders argv as the command text the engine analyses.
|
||||
//
|
||||
// The engine classifies a shell tool by parsing its command string, so argv has
|
||||
// to become one without changing what it says. A word made only of characters
|
||||
// the shell does not interpret is left bare, which keeps `git init` reading as a
|
||||
// session would have written it. Anything else is single-quoted, so an argument
|
||||
// such as "git init" stays ONE word and is not mistaken for a command, and a
|
||||
// payload handed to `sh -c` reaches the analyser as the payload it is.
|
||||
func sandboxExecCommandText(argv []string) string {
|
||||
words := make([]string, 0, len(argv))
|
||||
for _, word := range argv {
|
||||
words = append(words, sandboxExecQuoteWord(word))
|
||||
}
|
||||
return strings.Join(words, " ")
|
||||
}
|
||||
|
||||
func sandboxExecQuoteWord(word string) string {
|
||||
if word == "" {
|
||||
return "''"
|
||||
}
|
||||
bare := true
|
||||
for _, r := range word {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
|
||||
case strings.ContainsRune("_@%+=:,./-", r):
|
||||
default:
|
||||
bare = false
|
||||
}
|
||||
}
|
||||
if bare {
|
||||
return word
|
||||
}
|
||||
// A single quote cannot appear inside single quotes, so it closes them, is
|
||||
// written escaped, and opens them again.
|
||||
return "'" + strings.ReplaceAll(word, "'", `'\''`) + "'"
|
||||
}
|
||||
|
||||
const (
|
||||
// sandboxExecShutdownGrace is how long a cancelled child has to exit on its
|
||||
// own after the graceful request, before it is killed outright.
|
||||
sandboxExecShutdownGrace = 5 * time.Second
|
||||
// sandboxExecShutdownPoll is how often that wait re-checks, matching the
|
||||
// background package's termination policy.
|
||||
sandboxExecShutdownPoll = 50 * time.Millisecond
|
||||
)
|
||||
|
||||
func runSandboxPlannedCommand(ctx context.Context, plan zeroSandbox.CommandPlan, stdout io.Writer, stderr io.Writer) int {
|
||||
// CANCELLING THE WRAPPER HAS TO REACH THE COMMAND.
|
||||
//
|
||||
// This started the backend wrapper with a bare exec.Command().Run(): no
|
||||
// context, no forwarding, no shutdown path. A terminal masks it, because
|
||||
// terminals signal the whole foreground process group, but a supervisor or
|
||||
// task runner that sends SIGTERM to the zero sandbox exec PID killed only
|
||||
// Zero. The wrapper and everything under it kept running, doing filesystem
|
||||
// and network work after the caller considered the task cancelled, and the
|
||||
// deferred plan cleanup never ran because the process was gone.
|
||||
//
|
||||
// DELIBERATELY NOT execution.ConfigureProcessGroup. Putting the child in its
|
||||
// own process group closes this hole and opens a worse one: it severs every
|
||||
// kernel-delivered group signal, so a supervisor escalating to a group kill,
|
||||
// and a terminal delivering Ctrl+C to its foreground group, would stop
|
||||
// reaching the child. That trades a path that works today for one that does
|
||||
// not. Keeping the child in Zero's group leaves group delivery unchanged,
|
||||
// while the directed-signal case, which reached nothing at all before, now
|
||||
// reaches the child.
|
||||
process := exec.CommandContext(ctx, plan.Name, plan.Args...)
|
||||
// TWO PHASES, AND THE FIRST ONE HAS TO EXIST.
|
||||
//
|
||||
// The comments here used to describe a graceful signal followed by escalation
|
||||
// while the code did neither: Cancel called KillProcessTree, which is SIGKILL
|
||||
// on Unix and taskkill /T /F on Windows, and WaitDelay only starts counting
|
||||
// AFTER Cancel returns, so it could never postpone a kill that had already
|
||||
// happened. A directed SIGTERM reached the child as 137 rather than 143, with
|
||||
// no chance to flush output, drop a lock file, or run a shutdown handler.
|
||||
//
|
||||
// TerminateProcessTree is the two-phase policy the background package already
|
||||
// uses: SIGTERM, poll for the grace, then SIGKILL what is still alive. On
|
||||
// Windows it is the single-phase kill, which is correct rather than lazy,
|
||||
// because there is no signal to send a child that owns no console.
|
||||
process.Cancel = func() error {
|
||||
return execution.TerminateProcessTree(process.Process.Pid, sandboxExecShutdownGrace, sandboxExecShutdownPoll)
|
||||
}
|
||||
// A backstop only. Cancel above already waits out the grace and escalates, so
|
||||
// this bounds the case where the tree is gone but Wait is still blocked on an
|
||||
// inherited pipe a grandchild holds open.
|
||||
process.WaitDelay = sandboxExecShutdownGrace + sandboxExecShutdownPoll
|
||||
process.Dir = plan.Dir
|
||||
if process.Dir == "" {
|
||||
process.Dir = plan.WorkspaceRoot
|
||||
}
|
||||
// SPECIFIED-EMPTY IS NOT UNSPECIFIED.
|
||||
//
|
||||
// exec.Cmd treats a nil Env as "inherit this process's entire environment",
|
||||
// which is a different statement from "run with no variables". The plan owns
|
||||
// its environment: directCommandEnv and scrubSensitiveEnv return a slice they
|
||||
// built, and that slice is non-nil with length zero when every entry was
|
||||
// sensitive. Testing length collapsed those two states and turned the strictest
|
||||
// possible answer into the loosest one.
|
||||
//
|
||||
// Not reachable through `zero sandbox exec` today, because the child
|
||||
// environment is os.Environ() and an environment holding only sensitive keys
|
||||
// has no %AppData%, so config resolution fails before the planner runs. Fixed
|
||||
// anyway: the guard is one assignment, and the next caller that hands the plan
|
||||
// a deliberately narrow environment would inherit everything instead, silently.
|
||||
if plan.Env != nil {
|
||||
process.Env = plan.Env
|
||||
}
|
||||
process.Stdin = os.Stdin
|
||||
process.Stdout = stdout
|
||||
process.Stderr = stderr
|
||||
|
||||
if err := process.Run(); err != nil {
|
||||
var exitErr *exec.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
// The command's own status, not ours. A harness asserting "the write
|
||||
// was refused" needs the refusal's exit code, not a wrapper's.
|
||||
// A SIGNALED CHILD HAS NO EXIT CODE TO REPORT. ExitCode() answers -1
|
||||
// there, and os.Exit truncates that to 255, so a child that took SIGTERM
|
||||
// became indistinguishable from one that chose to exit 255. Fold the
|
||||
// signal into the conventional 128+n a shell would report, which needs
|
||||
// the ProcessState rather than the integer.
|
||||
if status, signaled := signaledExitStatus(exitErr.ProcessState); signaled {
|
||||
return status
|
||||
}
|
||||
return exitErr.ExitCode()
|
||||
}
|
||||
fmt.Fprintf(stderr, "sandbox exec: %v\n", err)
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
var errSandboxExecHelp = errors.New("help requested")
|
||||
|
||||
// parseSandboxExecArgs takes everything after `--` as the command, so the
|
||||
// command's own flags are never mistaken for ours.
|
||||
func parseSandboxExecArgs(args []string) ([]string, error) {
|
||||
if len(args) == 0 {
|
||||
return nil, errors.New("usage: zero sandbox exec -- <command> [args...]")
|
||||
}
|
||||
// Only the FIRST token is ours to interpret. Everything from the second
|
||||
// onwards belongs to the child, help flags included.
|
||||
//
|
||||
// Written as a straight-line decision on args[0] rather than a loop, because
|
||||
// a loop here is a lie. An earlier version scanned for `--` "up front" and
|
||||
// said so in its comment, but every branch of its body returned, so it only
|
||||
// ever examined index 0 and the promised scan did not exist. The rule below
|
||||
// is what that code actually implemented, and it is the rule we want: it is
|
||||
// the wrapper's own prefix that can ask for the wrapper's help, and the
|
||||
// prefix is at most one token long.
|
||||
switch args[0] {
|
||||
case "--":
|
||||
// Everything after the separator is the command, verbatim, including
|
||||
// `--help`. That is the documented contract: `zero sandbox exec -- cmd
|
||||
// --help` must run cmd's help, not ours.
|
||||
command := args[1:]
|
||||
if len(command) == 0 {
|
||||
return nil, errors.New("usage: zero sandbox exec -- <command> [args...]")
|
||||
}
|
||||
return command, nil
|
||||
case "-h", "--help", "help":
|
||||
return nil, errSandboxExecHelp
|
||||
}
|
||||
// The separator-less form, tolerated for interactive use: the first token is
|
||||
// the command, so nothing after it is ours to read. Without this,
|
||||
// `zero sandbox exec mycmd --help` printed OUR help and never ran mycmd,
|
||||
// which is the same contract break as reading past `--`. The help text still
|
||||
// shows the separator, because anything with a leading dash needs it.
|
||||
return args, nil
|
||||
}
|
||||
|
||||
func writeSandboxExecHelp(w io.Writer) error {
|
||||
_, err := fmt.Fprint(w, `Usage:
|
||||
zero sandbox exec -- <command> [args...]
|
||||
|
||||
Runs one command through the real sandbox and exits with its status.
|
||||
|
||||
Everything after the -- separator is the command, so its own flags are not
|
||||
parsed as Zero's. The command is evaluated the way a session evaluates a shell
|
||||
tool call before anything is planned: the decision is written to stderr, and a
|
||||
command a session refuses is refused here too, with the same reason. Running it
|
||||
from a terminal counts as the approval a session would prompt for, and lifts
|
||||
only what that approval lifts. The resolved backend and enforcement level are
|
||||
then written to stderr before the command runs, and a downgrade is reported
|
||||
there explicitly.
|
||||
|
||||
Examples:
|
||||
zero sandbox exec -- cmd /c echo hello
|
||||
zero sandbox exec -- powershell -Command "Set-Content out.txt x"
|
||||
|
||||
`)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"runtime"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
zeroSandbox "github.com/Twigpine/zero/internal/sandbox"
|
||||
)
|
||||
|
||||
// longLivedPlan returns a plan whose child outlives the test unless something
|
||||
// terminates it.
|
||||
func longLivedPlan(t *testing.T) zeroSandbox.CommandPlan {
|
||||
t.Helper()
|
||||
plan := zeroSandbox.CommandPlan{Dir: t.TempDir()}
|
||||
if runtime.GOOS == "windows" {
|
||||
plan.Name = "cmd.exe"
|
||||
plan.Args = []string{"/c", "ping -n 120 127.0.0.1 >NUL"}
|
||||
return plan
|
||||
}
|
||||
plan.Name = "/bin/sh"
|
||||
plan.Args = []string{"-c", "sleep 120"}
|
||||
return plan
|
||||
}
|
||||
|
||||
// CANCELLING THE WRAPPER HAS TO REACH THE COMMAND.
|
||||
//
|
||||
// The sandboxed command was started with a bare exec.Command().Run(): no
|
||||
// context, no forwarding, no shutdown path. A terminal masks that, because it
|
||||
// signals the whole foreground process group, but a supervisor or task runner
|
||||
// that sends SIGTERM to the wrapper's PID killed only Zero. The command kept
|
||||
// running, doing filesystem and network work after the caller considered the
|
||||
// task cancelled, and the deferred plan cleanup never ran.
|
||||
//
|
||||
// Driven with a real long-lived child and a real cancellation, asserting that
|
||||
// the call actually returns rather than that a field is set.
|
||||
func TestCancellingTheWrapperTerminatesTheSandboxedCommand(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
done := make(chan int, 1)
|
||||
go func() {
|
||||
done <- runSandboxPlannedCommand(ctx, longLivedPlan(t), io.Discard, io.Discard)
|
||||
}()
|
||||
|
||||
// Let the child actually start, or cancelling proves nothing.
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
select {
|
||||
case <-done:
|
||||
t.Fatal("SETUP INVALID: the child exited on its own, so cancellation was not exercised")
|
||||
default:
|
||||
}
|
||||
|
||||
cancel()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(sandboxExecShutdownGrace + 10*time.Second):
|
||||
t.Fatal("cancelling the wrapper did not terminate the sandboxed command; it would keep running after the caller gave up")
|
||||
}
|
||||
}
|
||||
|
||||
// And an uncancelled command still runs to completion and reports its own
|
||||
// status, or the fix above would be "kill everything immediately".
|
||||
func TestAnUncancelledSandboxedCommandStillReportsItsStatus(t *testing.T) {
|
||||
plan := zeroSandbox.CommandPlan{Dir: t.TempDir()}
|
||||
if runtime.GOOS == "windows" {
|
||||
plan.Name = "cmd.exe"
|
||||
plan.Args = []string{"/c", "exit 3"}
|
||||
} else {
|
||||
plan.Name = "/bin/sh"
|
||||
plan.Args = []string{"-c", "exit 3"}
|
||||
}
|
||||
if code := runSandboxPlannedCommand(context.Background(), plan, io.Discard, os.Stderr); code != 3 {
|
||||
t.Fatalf("exit code = %d, want the child's own 3", code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Twigpine/zero/internal/config"
|
||||
"github.com/Twigpine/zero/internal/sandbox"
|
||||
)
|
||||
|
||||
// governedNestedWorkspace is a workspace inside somebody else's repository: an
|
||||
// ancestor carries .git and the workspace itself does not. Setup plans no git
|
||||
// carveouts for it, which is why a repository must not be created there.
|
||||
func governedNestedWorkspace(t *testing.T) (ancestor, workspace string) {
|
||||
t.Helper()
|
||||
ancestor = t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(ancestor, ".git"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
workspace = filepath.Join(ancestor, "packages", "app")
|
||||
if err := os.MkdirAll(filepath.Join(workspace, "sub dir"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return ancestor, workspace
|
||||
}
|
||||
|
||||
func sandboxExecTestDeps(workspace string) appDeps {
|
||||
return appDeps{
|
||||
getwd: func() (string, error) { return workspace, nil },
|
||||
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
|
||||
return config.ResolvedConfig{}, nil
|
||||
},
|
||||
// No native runner. A command that got past the decision would run
|
||||
// unwrapped, which is exactly what makes a missing refusal visible on
|
||||
// disk instead of only in a return value.
|
||||
selectSandboxBackend: func(sandbox.BackendOptions) sandbox.Backend {
|
||||
return sandbox.Backend{Name: sandbox.BackendUnavailable, Fallback: true, Message: "no native sandbox in this test"}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// `zero sandbox exec` REFUSES WHAT A SESSION WOULD REFUSE.
|
||||
//
|
||||
// The command is documented as taking the path a shell tool takes. A shell tool
|
||||
// is evaluated by the engine and then planned; this went straight to the plan,
|
||||
// so nothing that lives in Evaluate applied. In a workspace governed by an
|
||||
// ancestor repository that is the nested-repository guard, and that guard is the
|
||||
// only thing standing in for the git carveouts setup deliberately does not plan
|
||||
// there. `zero sandbox exec -- git init` created the repository, writable config
|
||||
// and hooks included.
|
||||
//
|
||||
// Driven through the real CLI entry point, with a backend that would run the
|
||||
// command unwrapped if it were let through, and checked on disk: the refusal has
|
||||
// to come with the reason an operator can act on, and no repository may exist
|
||||
// afterwards. Reported by @jatmn.
|
||||
func TestSandboxExecRefusesToCreateARepositoryInAGovernedWorkspace(t *testing.T) {
|
||||
for _, testCase := range []struct {
|
||||
name string
|
||||
argv []string
|
||||
made []string
|
||||
}{
|
||||
{name: "git init", argv: []string{"git", "init"}, made: []string{".git"}},
|
||||
{name: "git init behind -C and a quoted directory", argv: []string{"git", "-C", "sub dir", "init"}, made: []string{"sub dir/.git"}},
|
||||
{name: "git init behind a shell launcher", argv: []string{"sh", "-c", "git init"}, made: []string{".git"}},
|
||||
{name: "git init behind env", argv: []string{"env", "GIT_TRACE=0", "git", "init"}, made: []string{".git"}},
|
||||
{name: "git clone", argv: []string{"git", "clone", "https://example.invalid/repo.git", "vendored"}, made: []string{"vendored"}},
|
||||
} {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
_, workspace := governedNestedWorkspace(t)
|
||||
var stdout, stderr bytes.Buffer
|
||||
args := append([]string{"sandbox", "exec", "--"}, testCase.argv...)
|
||||
exitCode := runWithDeps(args, &stdout, &stderr, sandboxExecTestDeps(workspace))
|
||||
|
||||
for _, made := range testCase.made {
|
||||
if _, err := os.Lstat(filepath.Join(workspace, filepath.FromSlash(made))); err == nil {
|
||||
t.Errorf("%s exists after the command: the repository was created under the plain workspace grant", made)
|
||||
}
|
||||
}
|
||||
if exitCode == exitSuccess {
|
||||
t.Errorf("exit = %d for a command a session refuses; stderr:\n%s", exitCode, stderr.String())
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "decision=deny") {
|
||||
t.Errorf("stderr does not report the decision:\n%s", stderr.String())
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "sits inside an existing git repository") {
|
||||
t.Errorf("the refusal does not carry the nested-repository reason and its remedy:\n%s", stderr.String())
|
||||
}
|
||||
if strings.Contains(stderr.String(), "backend=") {
|
||||
t.Errorf("a command plan was built for a refused command:\n%s", stderr.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The decision is the engine's, asked the way a session asks it. These are the
|
||||
// controls: what must NOT be refused, so the gate above is not simply refusing
|
||||
// git, or refusing this workspace.
|
||||
func TestSandboxExecDecisionLeavesOrdinaryCommandsAlone(t *testing.T) {
|
||||
_, governed := governedNestedWorkspace(t)
|
||||
standalone := t.TempDir()
|
||||
|
||||
for _, testCase := range []struct {
|
||||
name string
|
||||
workspace string
|
||||
argv []string
|
||||
}{
|
||||
{name: "an ordinary git command in the governed workspace", workspace: governed, argv: []string{"git", "status"}},
|
||||
{name: "the words git init as one argument", workspace: governed, argv: []string{"echo", "git init"}},
|
||||
{name: "git init in a workspace nobody governs", workspace: standalone, argv: []string{"git", "init"}},
|
||||
} {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
engine := sandbox.NewEngine(sandbox.EngineOptions{
|
||||
WorkspaceRoot: testCase.workspace,
|
||||
Policy: sandbox.DefaultPolicy(),
|
||||
Backend: sandbox.Backend{Name: sandbox.BackendUnavailable, Fallback: true},
|
||||
})
|
||||
decision := sandboxExecDecision(context.Background(), engine, testCase.workspace, testCase.argv)
|
||||
if decision.Action == sandbox.ActionDeny {
|
||||
t.Fatalf("%q was refused: %s", strings.Join(testCase.argv, " "), decision.ErrorString())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ARGV BECOMES COMMAND TEXT WITHOUT CHANGING WHAT IT SAYS. The engine classifies
|
||||
// a shell command by parsing its text, so the rendering decides what the
|
||||
// classifier sees. One argument has to stay one word, and a payload handed to a
|
||||
// shell launcher has to arrive as that payload.
|
||||
func TestSandboxExecCommandTextKeepsArgvIntact(t *testing.T) {
|
||||
for _, testCase := range []struct {
|
||||
name string
|
||||
argv []string
|
||||
want string
|
||||
gitInit bool
|
||||
}{
|
||||
{name: "bare words stay bare", argv: []string{"git", "init"}, want: "git init", gitInit: true},
|
||||
{name: "a spaced argument is one word", argv: []string{"echo", "git init"}, want: "echo 'git init'"},
|
||||
{name: "a launcher payload is analysed as a payload", argv: []string{"sh", "-c", "git init"}, want: "sh -c 'git init'", gitInit: true},
|
||||
{name: "a quote inside an argument", argv: []string{"printf", "it's"}, want: `printf 'it'\''s'`},
|
||||
{name: "substitution syntax is inert", argv: []string{"echo", "$(git init)"}, want: "echo '$(git init)'"},
|
||||
{name: "an empty argument survives", argv: []string{"printf", ""}, want: "printf ''"},
|
||||
{name: "a directory with a space", argv: []string{"git", "-C", "sub dir", "init"}, want: "git -C 'sub dir' init", gitInit: true},
|
||||
} {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
text := sandboxExecCommandText(testCase.argv)
|
||||
if text != testCase.want {
|
||||
t.Fatalf("command text = %q, want %q", text, testCase.want)
|
||||
}
|
||||
analysis := sandbox.AnalyzeCommand(text)
|
||||
if analysis.TooComplex {
|
||||
t.Fatalf("%q does not parse, so the classifier would treat a plain argv as obfuscated", text)
|
||||
}
|
||||
if analysis.GitInit != testCase.gitInit {
|
||||
t.Errorf("%q: GitInit = %v, want %v", text, analysis.GitInit, testCase.gitInit)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
zeroSandbox "github.com/Twigpine/zero/internal/sandbox"
|
||||
)
|
||||
|
||||
// envPrinterPlan returns a plan whose child prints its own environment, so the
|
||||
// assertion is about what the child received rather than about a field value.
|
||||
func envPrinterPlan(t *testing.T, env []string) zeroSandbox.CommandPlan {
|
||||
t.Helper()
|
||||
plan := zeroSandbox.CommandPlan{Dir: t.TempDir(), Env: env}
|
||||
if runtime.GOOS == "windows" {
|
||||
plan.Name = "cmd.exe"
|
||||
plan.Args = []string{"/c", "set"}
|
||||
return plan
|
||||
}
|
||||
plan.Name = "/bin/sh"
|
||||
plan.Args = []string{"-c", "env"}
|
||||
return plan
|
||||
}
|
||||
|
||||
// A PLAN THAT SPECIFIES NO VARIABLES MUST NOT INHERIT EVERY VARIABLE.
|
||||
//
|
||||
// exec.Cmd reads a nil Env as "inherit this process's entire environment", which
|
||||
// is a different statement from "run with no variables". The plan owns its
|
||||
// environment: directCommandEnv and scrubSensitiveEnv return a slice they built,
|
||||
// and that slice is non-nil with length zero when every entry was sensitive.
|
||||
// Testing its length collapsed the two states and turned the strictest possible
|
||||
// answer into the loosest one.
|
||||
//
|
||||
// Not reachable through `zero sandbox exec` today, because the child environment
|
||||
// is os.Environ() and an environment holding only sensitive keys has no
|
||||
// %AppData%, so config resolution fails before the planner runs. Pinned anyway,
|
||||
// because the next caller that hands the plan a deliberately narrow environment
|
||||
// would silently inherit everything instead.
|
||||
func TestAnEmptyPlannedEnvironmentDoesNotInherit(t *testing.T) {
|
||||
const marker = "ZZ_SANDBOX_ENV_MARKER"
|
||||
t.Setenv(marker, "leaked-value")
|
||||
|
||||
var out strings.Builder
|
||||
// Specified, and deliberately empty.
|
||||
code := runSandboxPlannedCommand(context.Background(), envPrinterPlan(t, []string{}), &out, os.Stderr)
|
||||
if code != 0 {
|
||||
t.Fatalf("child exited %d: %s", code, out.String())
|
||||
}
|
||||
if strings.Contains(out.String(), marker) {
|
||||
t.Fatalf("a plan specifying no environment leaked the caller's %s to the child:\n%s", marker, out.String())
|
||||
}
|
||||
}
|
||||
|
||||
// And a nil environment still inherits, which is the meaning the planner relies
|
||||
// on when it did not build one. Without this the fix above could be "never pass
|
||||
// the environment", which would break every ordinary command.
|
||||
func TestANilPlannedEnvironmentStillInherits(t *testing.T) {
|
||||
const marker = "ZZ_SANDBOX_ENV_MARKER"
|
||||
t.Setenv(marker, "inherited-value")
|
||||
|
||||
var out strings.Builder
|
||||
code := runSandboxPlannedCommand(context.Background(), envPrinterPlan(t, nil), &out, os.Stderr)
|
||||
if code != 0 {
|
||||
t.Fatalf("child exited %d: %s", code, out.String())
|
||||
}
|
||||
if !strings.Contains(out.String(), marker) {
|
||||
t.Fatalf("a nil planned environment stopped inheriting, which changes the meaning the planner relies on:\n%s", out.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
//go:build !windows
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
zeroSandbox "github.com/Twigpine/zero/internal/sandbox"
|
||||
)
|
||||
|
||||
// THE GRACEFUL PHASE HAS TO ACTUALLY HAPPEN.
|
||||
//
|
||||
// The shutdown comments described a graceful signal followed by escalation while
|
||||
// the code did neither: Cancel called KillProcessTree, which is SIGKILL here, and
|
||||
// WaitDelay only starts counting after Cancel returns so it could never postpone
|
||||
// a kill that had already happened. A directed SIGTERM therefore reached the
|
||||
// child as 137 rather than 143, with no chance to flush output, drop a lock file,
|
||||
// or run a shutdown handler.
|
||||
//
|
||||
// Driven with a child that traps the signal and records that its handler ran, so
|
||||
// the assertion is about the observed lifecycle rather than about a configured
|
||||
// duration.
|
||||
func TestACancelledCommandGetsAChanceToCleanUp(t *testing.T) {
|
||||
marker := filepath.Join(t.TempDir(), "cleanup-ran")
|
||||
// Exits 42 on the graceful signal: a distinctive code the child could only
|
||||
// have chosen itself, so the status proves the handler ran rather than that
|
||||
// something plausible happened.
|
||||
script := "trap 'echo yes > " + marker + "; exit 42' TERM; while :; do sleep 0.05; done"
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan int, 1)
|
||||
go func() {
|
||||
done <- runSandboxPlannedCommand(ctx, zeroSandbox.CommandPlan{
|
||||
Name: "/bin/sh",
|
||||
Args: []string{"-c", script},
|
||||
Dir: t.TempDir(),
|
||||
}, io.Discard, io.Discard)
|
||||
}()
|
||||
|
||||
// The trap has to be installed before the signal, or this proves nothing.
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
select {
|
||||
case <-done:
|
||||
t.Fatal("SETUP INVALID: the child exited on its own")
|
||||
default:
|
||||
}
|
||||
|
||||
cancel()
|
||||
var status int
|
||||
select {
|
||||
case status = <-done:
|
||||
case <-time.After(sandboxExecShutdownGrace + 10*time.Second):
|
||||
t.Fatal("the cancelled command never terminated")
|
||||
}
|
||||
|
||||
// The status the caller sees is the child's own, so a harness can tell an
|
||||
// orderly shutdown from a kill.
|
||||
if status != 42 {
|
||||
t.Errorf("status = %d, want 42; the child chose that code in its TERM handler, so anything else means it never got to finish", status)
|
||||
}
|
||||
if _, err := os.Stat(marker); err != nil {
|
||||
t.Fatalf("the child was killed outright instead of being asked to stop, so its cleanup handler never ran: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// And a child that ignores the request is still force-killed, after the bound
|
||||
// rather than before it. Without this the fix could be "only ever ask nicely".
|
||||
func TestAStubbornCancelledCommandIsStillKilled(t *testing.T) {
|
||||
// Traps TERM and keeps running: only escalation can end this.
|
||||
script := "trap '' TERM; while :; do sleep 0.05; done"
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan int, 1)
|
||||
go func() {
|
||||
done <- runSandboxPlannedCommand(ctx, zeroSandbox.CommandPlan{
|
||||
Name: "/bin/sh",
|
||||
Args: []string{"-c", script},
|
||||
Dir: t.TempDir(),
|
||||
}, io.Discard, io.Discard)
|
||||
}()
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
started := time.Now()
|
||||
cancel()
|
||||
var status int
|
||||
select {
|
||||
case status = <-done:
|
||||
case <-time.After(sandboxExecShutdownGrace + 15*time.Second):
|
||||
t.Fatal("a child ignoring the graceful request was never force-killed")
|
||||
}
|
||||
// After the bound, not before: a kill that lands immediately is the defect
|
||||
// this replaced.
|
||||
if elapsed := time.Since(started); elapsed < sandboxExecShutdownGrace {
|
||||
t.Fatalf("the child was killed after %s, before the %s grace it was owed", elapsed, sandboxExecShutdownGrace)
|
||||
}
|
||||
// And escalation really was a kill, so "ask nicely and give up" cannot pass.
|
||||
if want := 128 + int(syscall.SIGKILL); status != want {
|
||||
t.Errorf("status = %d, want %d after escalation", status, want)
|
||||
}
|
||||
}
|
||||
|
||||
// AND THE STATUS HAS TO SAY WHICH SIGNAL ARRIVED.
|
||||
//
|
||||
// 137 rather than 143 was the reported symptom, and it is the only part of this
|
||||
// a caller can see. A child that does not trap anything must come back as
|
||||
// 128+SIGTERM: if the first signal were still SIGKILL, this would be 137 and the
|
||||
// two cases above would both still pass, since one traps and the other ignores.
|
||||
func TestACancelledCommandReportsTheGracefulSignal(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan int, 1)
|
||||
go func() {
|
||||
done <- runSandboxPlannedCommand(ctx, zeroSandbox.CommandPlan{
|
||||
Name: "/bin/sh",
|
||||
Args: []string{"-c", "while :; do sleep 0.05; done"},
|
||||
Dir: t.TempDir(),
|
||||
}, io.Discard, io.Discard)
|
||||
}()
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
cancel()
|
||||
|
||||
var status int
|
||||
select {
|
||||
case status = <-done:
|
||||
case <-time.After(sandboxExecShutdownGrace + 10*time.Second):
|
||||
t.Fatal("the cancelled command never terminated")
|
||||
}
|
||||
if want := 128 + int(syscall.SIGTERM); status != want {
|
||||
t.Fatalf("status = %d, want %d; %d means the child was SIGKILLed with no graceful phase", status, want, 128+int(syscall.SIGKILL))
|
||||
}
|
||||
}
|
||||
|
||||
// TERMINATION IS COMPLETE BEFORE THE FUNCTION RETURNS.
|
||||
//
|
||||
// `zero sandbox exec` releases the plan with a defer around this call, so plan
|
||||
// cleanup is ordered after termination only if nothing survives the return. A
|
||||
// future refactor that lets Wait come back while the tree is still up would move
|
||||
// cleanup on top of a live child, deleting the policy-report file it is using.
|
||||
//
|
||||
// The stubborn child is the one that can outlive the call, since it ignores the
|
||||
// graceful request and only escalation ends it.
|
||||
func TestACancelledCommandIsFullyReapedBeforeCleanupCouldRun(t *testing.T) {
|
||||
pidFile := filepath.Join(t.TempDir(), "child.pid")
|
||||
script := "echo $$ > " + pidFile + "; trap '' TERM; while :; do sleep 0.05; done"
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan int, 1)
|
||||
go func() {
|
||||
done <- runSandboxPlannedCommand(ctx, zeroSandbox.CommandPlan{
|
||||
Name: "/bin/sh",
|
||||
Args: []string{"-c", script},
|
||||
Dir: t.TempDir(),
|
||||
}, io.Discard, io.Discard)
|
||||
}()
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
raw, err := os.ReadFile(pidFile)
|
||||
if err != nil {
|
||||
t.Fatalf("SETUP INVALID: the child never recorded its pid, so there is nothing to check for survival: %v", err)
|
||||
}
|
||||
pid, err := strconv.Atoi(strings.TrimSpace(string(raw)))
|
||||
if err != nil || pid <= 0 {
|
||||
t.Fatalf("SETUP INVALID: unusable child pid %q: %v", raw, err)
|
||||
}
|
||||
|
||||
cancel()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(sandboxExecShutdownGrace + 15*time.Second):
|
||||
t.Fatal("a child ignoring the graceful request was never force-killed")
|
||||
}
|
||||
|
||||
// Signal 0 is the existence probe: no signal is sent, only the permission and
|
||||
// liveness checks run, so ESRCH is the answer for a reaped process.
|
||||
if err := syscall.Kill(pid, 0); err == nil {
|
||||
t.Fatalf("the child (pid %d) was still alive when the run returned; plan cleanup is deferred around this call and would delete the policy-report file underneath it", pid)
|
||||
} else if err != syscall.ESRCH {
|
||||
t.Skipf("cannot probe pid %d here: %v", pid, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
//go:build !windows
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"os"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// signaledExitStatus reports the conventional shell status for a child that was
|
||||
// terminated by a signal, which exec.ExitError cannot represent.
|
||||
//
|
||||
// ExitCode() returns -1 for a signaled child, and the top level hands that to
|
||||
// os.Exit, which truncates it to 255. A child that takes SIGTERM is then
|
||||
// indistinguishable from one that exited 255 of its own accord, which breaks the
|
||||
// documented status contract for a command whose whole job is to report the
|
||||
// child's own status faithfully. 128+signal is what a shell reports and what a
|
||||
// harness comparing statuses expects.
|
||||
func signaledExitStatus(state *os.ProcessState) (int, bool) {
|
||||
if state == nil {
|
||||
return 0, false
|
||||
}
|
||||
status, ok := state.Sys().(syscall.WaitStatus)
|
||||
if !ok || !status.Signaled() {
|
||||
return 0, false
|
||||
}
|
||||
return 128 + int(status.Signal()), true
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
//go:build !windows
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os/exec"
|
||||
"syscall"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A SIGNALED CHILD MUST NOT LOOK LIKE ONE THAT CHOSE TO EXIT 255.
|
||||
//
|
||||
// `sandbox exec` exists to report the child's own status faithfully, and
|
||||
// exec.ExitError cannot represent signal termination: ExitCode() answers -1, and
|
||||
// the top level hands that to os.Exit, which truncates it to 255. So a child
|
||||
// killed by SIGTERM was indistinguishable from an ordinary exit of 255, and a
|
||||
// harness comparing statuses could not tell a refusal from a kill.
|
||||
//
|
||||
// Driven with a real subprocess and a real signal rather than a synthetic
|
||||
// WaitStatus, because the mapping is only correct if the OS agrees with it.
|
||||
func TestSignaledChildReportsTheConventionalStatus(t *testing.T) {
|
||||
for _, testCase := range []struct {
|
||||
name string
|
||||
script string
|
||||
want int
|
||||
}{
|
||||
{name: "SIGTERM", script: "kill -TERM $$; sleep 5", want: 128 + int(syscall.SIGTERM)},
|
||||
{name: "SIGINT", script: "kill -INT $$; sleep 5", want: 128 + int(syscall.SIGINT)},
|
||||
} {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
err := exec.Command("/bin/sh", "-c", testCase.script).Run()
|
||||
var exitErr *exec.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("SETUP INVALID: the child did not end with an ExitError: %v", err)
|
||||
}
|
||||
// The behaviour being corrected: the integer alone cannot say this.
|
||||
if code := exitErr.ExitCode(); code != -1 {
|
||||
t.Fatalf("SETUP INVALID: a signaled child reported exit code %d, expected -1 on this platform", code)
|
||||
}
|
||||
status, signaled := signaledExitStatus(exitErr.ProcessState)
|
||||
if !signaled {
|
||||
t.Fatal("a signaled child was not recognised as signaled, so it would be reported as exit 255")
|
||||
}
|
||||
if status != testCase.want {
|
||||
t.Fatalf("status = %d, want %d (128 + signal), which is what a shell reports", status, testCase.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// An ordinary non-zero exit is untouched: it has a real code and must keep it.
|
||||
func TestOrdinaryExitIsNotTreatedAsSignaled(t *testing.T) {
|
||||
err := exec.Command("/bin/sh", "-c", "exit 3").Run()
|
||||
var exitErr *exec.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("SETUP INVALID: the child did not end with an ExitError: %v", err)
|
||||
}
|
||||
if _, signaled := signaledExitStatus(exitErr.ProcessState); signaled {
|
||||
t.Fatal("an ordinary exit was reported as signaled, which would rewrite its status")
|
||||
}
|
||||
if code := exitErr.ExitCode(); code != 3 {
|
||||
t.Fatalf("exit code = %d, want the child's own 3", code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
//go:build windows
|
||||
|
||||
package cli
|
||||
|
||||
import "os"
|
||||
|
||||
// signaledExitStatus has no Windows counterpart: a process there ends with an
|
||||
// exit code, and there is no signal to fold into one. Windows keeps the exit
|
||||
// code exec.ExitError already reports.
|
||||
func signaledExitStatus(*os.ProcessState) (int, bool) {
|
||||
return 0, false
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// parseSandboxExecArgs sits on the boundary between Zero's flags and a child
|
||||
// command's, and it had no tests at all: not for the reported defect, not even
|
||||
// for the happy case. The contract it has to keep is that everything after the
|
||||
// separator belongs to the child, help flags included, so the wrapper can never
|
||||
// answer for a command it was supposed to be running.
|
||||
func TestParseSandboxExecArgs(t *testing.T) {
|
||||
for _, testCase := range []struct {
|
||||
name string
|
||||
args []string
|
||||
command []string
|
||||
help bool
|
||||
usage bool
|
||||
}{
|
||||
{
|
||||
// The reported defect: --help after the separator is the CHILD's.
|
||||
name: "help after the separator belongs to the child",
|
||||
args: []string{"--", "cmd", "--help"},
|
||||
command: []string{"cmd", "--help"},
|
||||
},
|
||||
{
|
||||
name: "a second separator is the child's too",
|
||||
args: []string{"--", "cmd", "--", "inner"},
|
||||
command: []string{"cmd", "--", "inner"},
|
||||
},
|
||||
{
|
||||
name: "a flag-looking command survives the separator",
|
||||
args: []string{"--", "--weird-binary"},
|
||||
command: []string{"--weird-binary"},
|
||||
},
|
||||
{
|
||||
// The separator-less form. The first token is the command, so its own
|
||||
// flags are not ours to read either.
|
||||
name: "help after a separatorless command belongs to the child",
|
||||
args: []string{"cmd", "--help"},
|
||||
command: []string{"cmd", "--help"},
|
||||
},
|
||||
{
|
||||
// A separator arriving AFTER the command has already started is part of
|
||||
// the child's argv, not a delimiter we get a second go at.
|
||||
name: "a separator after the command is the child's argument",
|
||||
args: []string{"ls", "--", "foo"},
|
||||
command: []string{"ls", "--", "foo"},
|
||||
},
|
||||
{name: "bare help", args: []string{"--help"}, help: true},
|
||||
{name: "short help", args: []string{"-h"}, help: true},
|
||||
{name: "help subcommand", args: []string{"help"}, help: true},
|
||||
{name: "help in the prefix wins", args: []string{"--help", "--", "cmd"}, help: true},
|
||||
{name: "no arguments", args: nil, usage: true},
|
||||
{name: "separator with nothing after it", args: []string{"--"}, usage: true},
|
||||
} {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
command, err := parseSandboxExecArgs(testCase.args)
|
||||
switch {
|
||||
case testCase.help:
|
||||
if !errors.Is(err, errSandboxExecHelp) {
|
||||
t.Fatalf("parseSandboxExecArgs(%q) = %q, %v; want the help sentinel", testCase.args, command, err)
|
||||
}
|
||||
case testCase.usage:
|
||||
if err == nil || errors.Is(err, errSandboxExecHelp) {
|
||||
t.Fatalf("parseSandboxExecArgs(%q) = %q, %v; want a usage error", testCase.args, command, err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "usage:") {
|
||||
t.Errorf("a usage error must show the usage, got %q", err.Error())
|
||||
}
|
||||
default:
|
||||
if err != nil {
|
||||
t.Fatalf("parseSandboxExecArgs(%q) returned %v", testCase.args, err)
|
||||
}
|
||||
if strings.Join(command, "\x00") != strings.Join(testCase.command, "\x00") {
|
||||
t.Errorf("parseSandboxExecArgs(%q) = %q, want %q", testCase.args, command, testCase.command)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The wrapper must never consume a token it then fails to hand over. Whatever it
|
||||
// returns as the command has to be a trailing slice of what it was given, with
|
||||
// at most a leading separator removed, or an argument has gone missing on its
|
||||
// way to the child.
|
||||
func TestParseSandboxExecArgsNeverDropsAChildArgument(t *testing.T) {
|
||||
for _, args := range [][]string{
|
||||
{"--", "cmd", "--help", "-x", "--"},
|
||||
{"cmd", "--help"},
|
||||
{"ls", "--", "foo"},
|
||||
{"--", "--weird-binary", "arg"},
|
||||
} {
|
||||
command, err := parseSandboxExecArgs(args)
|
||||
if err != nil {
|
||||
t.Fatalf("parseSandboxExecArgs(%q) returned %v", args, err)
|
||||
}
|
||||
if len(command) == 0 {
|
||||
t.Fatalf("parseSandboxExecArgs(%q) returned an empty command", args)
|
||||
}
|
||||
suffix := strings.Join(args[len(args)-len(command):], "\x00")
|
||||
if strings.Join(command, "\x00") != suffix {
|
||||
t.Errorf("parseSandboxExecArgs(%q) = %q, which is not a trailing slice of the input; an argument was dropped or reordered", args, command)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"github.com/Twigpine/zero/internal/config"
|
||||
"github.com/Twigpine/zero/internal/hooks"
|
||||
"github.com/Twigpine/zero/internal/mcp"
|
||||
"github.com/Twigpine/zero/internal/specialist"
|
||||
"github.com/Twigpine/zero/internal/tools"
|
||||
"github.com/Twigpine/zero/internal/tui"
|
||||
"github.com/Twigpine/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
func TestRegisterSpecialistToolsWiresLifecycleHooksIntoTaskOutput(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
runtime, err := registerSpecialistTools(registry, t.TempDir(), 0)
|
||||
if err != nil {
|
||||
t.Fatalf("registerSpecialistTools returned error: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { closeSpecialistRuntime(io.Discard, runtime) })
|
||||
|
||||
if runtime.lifecycleHooks == nil {
|
||||
t.Fatal("lifecycleHooks is nil after registerSpecialistTools")
|
||||
}
|
||||
raw, ok := registry.Get("TaskOutput")
|
||||
if !ok {
|
||||
t.Fatal("TaskOutput tool not registered")
|
||||
}
|
||||
outputTool, ok := raw.(*specialist.OutputTool)
|
||||
if !ok {
|
||||
t.Fatalf("TaskOutput type = %T, want *specialist.OutputTool", raw)
|
||||
}
|
||||
if outputTool.LifecycleHooks != runtime.lifecycleHooks {
|
||||
t.Fatal("TaskOutput LifecycleHooks is not the shared registerSpecialistTools bridge")
|
||||
}
|
||||
|
||||
dispatcher := hooks.NewDispatcher(hooks.DispatcherOptions{})
|
||||
runtime.attachLifecycleHooks(dispatcher)
|
||||
if runtime.lifecycleHooks.Dispatch == nil {
|
||||
t.Fatal("attachLifecycleHooks left Dispatch nil")
|
||||
}
|
||||
if outputTool.LifecycleHooks.Dispatch == nil {
|
||||
t.Fatal("TaskOutput did not observe Dispatch after attachLifecycleHooks")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRealEntryPointsAttachLifecycleHooks closes the gap the registration-level
|
||||
// test above cannot see: wiring the bridge when tools register is not enough
|
||||
// unless the real startup paths actually call attachLifecycleHooks before any
|
||||
// run. Both surfaces that can launch specialists must do so — the interactive
|
||||
// TUI and headless exec. attachLifecycleHooks mutates the same
|
||||
// *specialist.LifecycleHooks the TaskOutput tool holds, so a nil Dispatch on
|
||||
// that tool after a full startup proves the call never ran and the
|
||||
// specialistStart/specialistStop hooks would silently never fire.
|
||||
func TestRealEntryPointsAttachLifecycleHooks(t *testing.T) {
|
||||
requireTaskOutputDispatchAttached := func(t *testing.T, registry *tools.Registry) {
|
||||
t.Helper()
|
||||
if registry == nil {
|
||||
t.Fatal("startup did not reach the run registry")
|
||||
}
|
||||
raw, ok := registry.Get("TaskOutput")
|
||||
if !ok {
|
||||
t.Fatal("TaskOutput tool not registered by startup")
|
||||
}
|
||||
outputTool, ok := raw.(*specialist.OutputTool)
|
||||
if !ok {
|
||||
t.Fatalf("TaskOutput type = %T, want *specialist.OutputTool", raw)
|
||||
}
|
||||
if outputTool.LifecycleHooks == nil || outputTool.LifecycleHooks.Dispatch == nil {
|
||||
t.Fatal("startup left the specialist lifecycle bridge unattached: specialistStart/specialistStop hooks would never fire")
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("interactive TUI", func(t *testing.T) {
|
||||
isolateCLIUserState(t)
|
||||
cwd := t.TempDir()
|
||||
var registry *tools.Registry
|
||||
|
||||
// Empty args route runWithDeps to the interactive TUI path; the injected
|
||||
// runTUI receives the same registry specialist tools registered into.
|
||||
exitCode := runWithDeps([]string{}, io.Discard, io.Discard, appDeps{
|
||||
getwd: func() (string, error) { return cwd, nil },
|
||||
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
|
||||
return config.ResolvedConfig{
|
||||
Provider: config.ProviderProfile{
|
||||
Name: "p",
|
||||
ProviderKind: config.ProviderKindOpenAICompatible,
|
||||
BaseURL: "http://127.0.0.1/v1",
|
||||
Model: "m",
|
||||
},
|
||||
}, nil
|
||||
},
|
||||
resolveMCPConfig: func(string, bool) (config.MCPConfig, error) { return config.MCPConfig{}, nil },
|
||||
runTUI: func(_ context.Context, options tui.Options) int {
|
||||
registry = options.Registry
|
||||
return exitSuccess
|
||||
},
|
||||
})
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("runWithDeps exit = %d, want %d", exitCode, exitSuccess)
|
||||
}
|
||||
requireTaskOutputDispatchAttached(t, registry)
|
||||
})
|
||||
|
||||
t.Run("headless exec", func(t *testing.T) {
|
||||
isolateCLIUserState(t)
|
||||
cwd := t.TempDir()
|
||||
var registry *tools.Registry
|
||||
var stdout, stderr bytes.Buffer
|
||||
|
||||
// --auto high is what registers specialist tools on the exec surface.
|
||||
// MCP registration is the one startup chokepoint that sees the run
|
||||
// registry before attachLifecycleHooks runs, so capture it there and
|
||||
// assert after the run completes.
|
||||
exitCode := runWithDeps([]string{
|
||||
"exec", "--auto", "high", "--cwd", cwd, "--init-session-id", "hook_wireup", "hello",
|
||||
}, &stdout, &stderr, appDeps{
|
||||
getwd: func() (string, error) { return cwd, nil },
|
||||
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
|
||||
return execResolvedConfig(), nil
|
||||
},
|
||||
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
|
||||
return echoExecProvider{}, nil
|
||||
},
|
||||
resolveMCPConfig: func(string, bool) (config.MCPConfig, error) {
|
||||
return config.MCPConfig{Servers: map[string]config.MCPServerConfig{
|
||||
"wireup": {Type: "stdio", Command: "wireup-mcp"},
|
||||
}}, nil
|
||||
},
|
||||
newMCPStore: func() (*mcp.PermissionStore, error) { return nil, nil },
|
||||
registerMCPTools: func(_ context.Context, captured *tools.Registry, _ config.MCPConfig, _ mcp.RegisterOptions) (mcpToolRuntime, error) {
|
||||
registry = captured
|
||||
return closeFunc(func() error { return nil }), nil
|
||||
},
|
||||
})
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("runWithDeps exit = %d, want %d (stderr=%q)", exitCode, exitSuccess, stderr.String())
|
||||
}
|
||||
requireTaskOutputDispatchAttached(t, registry)
|
||||
})
|
||||
}
|
||||
@@ -1,168 +0,0 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/Twigpine/zero/internal/aimlapi"
|
||||
"github.com/Twigpine/zero/internal/providercatalog"
|
||||
)
|
||||
|
||||
const aimlapiKeyEnv = "AIMLAPI_API_KEY"
|
||||
|
||||
var (
|
||||
// ErrAIMLAPIKeyMissing means no AI/ML API key is configured.
|
||||
ErrAIMLAPIKeyMissing = errors.New("no AI/ML API key is configured; set up aimlapi.com with /provider or set AIMLAPI_API_KEY")
|
||||
// ErrAIMLAPIProxyProfile means the aimlapi profile points at a proxy, so its
|
||||
// key belongs to that proxy and is withheld from aimlapi.com services.
|
||||
ErrAIMLAPIProxyProfile = errors.New("the aimlapi provider points at a proxy base URL; its key is not sent to aimlapi.com services")
|
||||
// ErrAIMLAPIKeyInvalid means the key cannot be sent as a single Bearer token.
|
||||
ErrAIMLAPIKeyInvalid = errors.New("the AI/ML API key contains whitespace, a comma or a control character")
|
||||
)
|
||||
|
||||
// AIMLAPIServiceKey returns the AI/ML API key that may be sent to the
|
||||
// aimlapi.com Toolbox and MCP server. It is resolved again on every call, so a
|
||||
// provider change is seen by the next request.
|
||||
//
|
||||
// The key comes from the aimlapi provider profile (the active one when it is
|
||||
// aimlapi, otherwise the first), including a key held in the credential store,
|
||||
// or from AIMLAPI_API_KEY when no aimlapi profile exists. The key is withheld
|
||||
// when that profile, any aimlapi profile, or any profile reading
|
||||
// AIMLAPI_API_KEY points at a base URL other than the canonical
|
||||
// https://api.aimlapi.com/v1, and when AIMLAPI_INFERENCE_URL is overridden:
|
||||
// in those cases the credential may belong to a proxy.
|
||||
func AIMLAPIServiceKey(resolved ResolvedConfig, store APIKeyGetter, getenv func(string) string) (string, error) {
|
||||
if getenv == nil {
|
||||
getenv = os.Getenv
|
||||
}
|
||||
if override := strings.TrimSpace(getenv("AIMLAPI_INFERENCE_URL")); override != "" && !sameBaseURL(override, aimlapi.CanonicalInferenceBaseURL) {
|
||||
return "", ErrAIMLAPIProxyProfile
|
||||
}
|
||||
for _, profile := range resolved.Providers {
|
||||
if aimlapiKeyedProfile(profile) && !sameBaseURL(profile.BaseURL, aimlapi.CanonicalInferenceBaseURL) {
|
||||
return "", ErrAIMLAPIProxyProfile
|
||||
}
|
||||
}
|
||||
if profile, ok := aimlapiServiceProfile(resolved); ok {
|
||||
if !sameBaseURL(profile.BaseURL, aimlapi.CanonicalInferenceBaseURL) {
|
||||
return "", ErrAIMLAPIProxyProfile
|
||||
}
|
||||
if store != nil && profile.APIKeyStored && strings.TrimSpace(profile.APIKey) == "" && strings.TrimSpace(profile.Name) != "" {
|
||||
key, found, err := store.Get(strings.TrimSpace(profile.Name))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read AI/ML API credential store: %w", err)
|
||||
}
|
||||
if found {
|
||||
profile.APIKey = key
|
||||
}
|
||||
}
|
||||
return validAIMLAPIServiceKey(profile.APIKey)
|
||||
}
|
||||
return validAIMLAPIServiceKey(getenv(aimlapiKeyEnv))
|
||||
}
|
||||
|
||||
// LoadAIMLAPIServiceKey resolves the user configuration, the environment and
|
||||
// the user credential store, then applies AIMLAPIServiceKey. Project config is
|
||||
// not read: provider keys are user-scoped. A config with no active provider
|
||||
// still resolves its profiles (ErrNoActiveProvider), so the key is looked up in
|
||||
// that partial config; every other resolve error is returned. Resolve drops a
|
||||
// non-active profile it cannot normalize, so the configured profiles are
|
||||
// checked as well: such a profile still withholds the key when it carries it.
|
||||
func LoadAIMLAPIServiceKey() (string, error) {
|
||||
userConfigPath, err := DefaultUserConfigPath()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
existing, err := existingConfigFile(userConfigPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
resolved, err := Resolve(ResolveOptions{UserConfigPath: existing, ExcludeProject: true})
|
||||
if err != nil && !errors.Is(err, ErrNoActiveProvider) {
|
||||
return "", err
|
||||
}
|
||||
if existing != "" {
|
||||
fileConfig, err := loadConfigFile(existing)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var configured FileConfig
|
||||
mergeConfig(&configured, fileConfig)
|
||||
if droppedAIMLAPIProxyProfile(configured.Providers, resolved.Providers) {
|
||||
return "", ErrAIMLAPIProxyProfile
|
||||
}
|
||||
}
|
||||
// Check proxy eligibility and already-resolved keys before opening a store.
|
||||
key, keyErr := AIMLAPIServiceKey(resolved, nil, os.Getenv)
|
||||
if !errors.Is(keyErr, ErrAIMLAPIKeyMissing) {
|
||||
return key, keyErr
|
||||
}
|
||||
profile, ok := aimlapiServiceProfile(resolved)
|
||||
if !ok || !profile.APIKeyStored {
|
||||
return key, keyErr
|
||||
}
|
||||
store, err := ProviderKeyStoreAt(filepath.Dir(userConfigPath))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("open AI/ML API credential store: %w", err)
|
||||
}
|
||||
return AIMLAPIServiceKey(resolved, store, os.Getenv)
|
||||
}
|
||||
|
||||
func isAIMLAPIProfile(profile ProviderProfile) bool {
|
||||
return strings.EqualFold(providercatalog.NormalizeID(profile.CatalogID), providercatalog.AIMLAPIID)
|
||||
}
|
||||
|
||||
// aimlapiKeyedProfile reports whether profile carries an AI/ML API credential:
|
||||
// an aimlapi catalog profile, or any profile that reads AIMLAPI_API_KEY.
|
||||
func aimlapiKeyedProfile(profile ProviderProfile) bool {
|
||||
return isAIMLAPIProfile(profile) || strings.EqualFold(strings.TrimSpace(profile.APIKeyEnv), aimlapiKeyEnv)
|
||||
}
|
||||
|
||||
// droppedAIMLAPIProxyProfile reports whether a configured profile missing from
|
||||
// the resolved list (Resolve could not normalize it) carries an AI/ML API
|
||||
// credential and is not explicitly on the canonical base URL. Its endpoint was
|
||||
// never resolved, so an empty base URL counts as a proxy.
|
||||
func droppedAIMLAPIProxyProfile(configured, resolved []ProviderProfile) bool {
|
||||
kept := make(map[string]bool, len(resolved))
|
||||
for _, profile := range resolved {
|
||||
kept[profile.Name] = true
|
||||
}
|
||||
for _, profile := range configured {
|
||||
if kept[strings.TrimSpace(profile.Name)] || !aimlapiKeyedProfile(profile) {
|
||||
continue
|
||||
}
|
||||
if !sameBaseURL(profile.BaseURL, aimlapi.CanonicalInferenceBaseURL) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func aimlapiServiceProfile(resolved ResolvedConfig) (ProviderProfile, bool) {
|
||||
if isAIMLAPIProfile(resolved.Provider) {
|
||||
return resolved.Provider, true
|
||||
}
|
||||
for _, profile := range resolved.Providers {
|
||||
if isAIMLAPIProfile(profile) {
|
||||
return profile, true
|
||||
}
|
||||
}
|
||||
return ProviderProfile{}, false
|
||||
}
|
||||
|
||||
func validAIMLAPIServiceKey(raw string) (string, error) {
|
||||
key := strings.TrimSpace(raw)
|
||||
if key == "" {
|
||||
return "", ErrAIMLAPIKeyMissing
|
||||
}
|
||||
for _, r := range key {
|
||||
if unicode.IsSpace(r) || r <= ' ' || r == 0x7f || r == ',' || (r >= 0x80 && r <= 0x9f) {
|
||||
return "", ErrAIMLAPIKeyInvalid
|
||||
}
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
@@ -1,282 +0,0 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func aimlapiServiceKeyEnv(values map[string]string) func(string) string {
|
||||
return func(name string) string { return values[name] }
|
||||
}
|
||||
|
||||
func TestAIMLAPIServiceKeyUsesCanonicalProfileKey(t *testing.T) {
|
||||
resolved := ResolvedConfig{Providers: []ProviderProfile{
|
||||
{Name: "openai", BaseURL: "https://api.openai.com/v1", APIKey: "sk-openai"},
|
||||
{Name: "aimlapi", CatalogID: "aimlapi", BaseURL: "https://api.aimlapi.com/v1/", APIKey: " aiml-key "},
|
||||
}}
|
||||
key, err := AIMLAPIServiceKey(resolved, nil, aimlapiServiceKeyEnv(nil))
|
||||
if err != nil || key != "aiml-key" {
|
||||
t.Fatalf("AIMLAPIServiceKey = %q, %v; want aiml-key", key, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIMLAPIServiceKeyReadsCredentialStore(t *testing.T) {
|
||||
resolved := ResolvedConfig{Provider: ProviderProfile{Name: "aimlapi", CatalogID: "aimlapi", BaseURL: "https://api.aimlapi.com/v1", APIKeyStored: true}}
|
||||
store := fakeKeyGetter{keys: map[string]string{"aimlapi": "stored-key"}}
|
||||
key, err := AIMLAPIServiceKey(resolved, store, aimlapiServiceKeyEnv(nil))
|
||||
if err != nil || key != "stored-key" {
|
||||
t.Fatalf("AIMLAPIServiceKey = %q, %v; want stored-key", key, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIMLAPIServiceKeyPrefersResolvedProfileKeyOverCredentialStore(t *testing.T) {
|
||||
resolved := ResolvedConfig{Provider: ProviderProfile{
|
||||
Name: "aimlapi", CatalogID: "aimlapi", BaseURL: "https://api.aimlapi.com/v1",
|
||||
APIKey: "env-key", APIKeyStored: true,
|
||||
}}
|
||||
store := fakeKeyGetter{keys: map[string]string{"aimlapi": "stored-key"}}
|
||||
key, err := AIMLAPIServiceKey(resolved, store, aimlapiServiceKeyEnv(map[string]string{"AIMLAPI_API_KEY": "env-key"}))
|
||||
if err != nil || key != "env-key" {
|
||||
t.Fatalf("AIMLAPIServiceKey = %q, %v; want env-key from resolved profile, not stored-key", key, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIMLAPIServiceKeyFallsBackToEnvironmentWithoutProfile(t *testing.T) {
|
||||
key, err := AIMLAPIServiceKey(ResolvedConfig{}, nil, aimlapiServiceKeyEnv(map[string]string{"AIMLAPI_API_KEY": "env-key"}))
|
||||
if err != nil || key != "env-key" {
|
||||
t.Fatalf("AIMLAPIServiceKey = %q, %v; want env-key", key, err)
|
||||
}
|
||||
if _, err := AIMLAPIServiceKey(ResolvedConfig{}, nil, aimlapiServiceKeyEnv(nil)); !errors.Is(err, ErrAIMLAPIKeyMissing) {
|
||||
t.Fatalf("missing key error = %v, want ErrAIMLAPIKeyMissing", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIMLAPIServiceKeyWithholdsProxyCredentials(t *testing.T) {
|
||||
env := aimlapiServiceKeyEnv(map[string]string{"AIMLAPI_API_KEY": "proxy-key"})
|
||||
cases := map[string]ResolvedConfig{
|
||||
"active aimlapi profile on a proxy": {Provider: ProviderProfile{Name: "aimlapi", CatalogID: "aimlapi", BaseURL: "https://proxy.example/v1", APIKey: "proxy-key"}},
|
||||
"a second aimlapi profile on a proxy": {Providers: []ProviderProfile{
|
||||
{Name: "aimlapi", CatalogID: "aimlapi", BaseURL: "https://api.aimlapi.com/v1", APIKey: "proxy-key"},
|
||||
{Name: "aimlapi-proxy", CatalogID: "aimlapi", BaseURL: "https://proxy.example/v1", APIKey: "proxy-key"},
|
||||
}},
|
||||
"a custom profile reading AIMLAPI_API_KEY": {Providers: []ProviderProfile{
|
||||
{Name: "gateway", BaseURL: "https://proxy.example/v1", APIKeyEnv: "AIMLAPI_API_KEY", APIKey: "proxy-key"},
|
||||
}},
|
||||
}
|
||||
for name, resolved := range cases {
|
||||
key, err := AIMLAPIServiceKey(resolved, nil, env)
|
||||
if !errors.Is(err, ErrAIMLAPIProxyProfile) || key != "" {
|
||||
t.Errorf("%s: AIMLAPIServiceKey = %q, %v; want ErrAIMLAPIProxyProfile", name, key, err)
|
||||
}
|
||||
}
|
||||
|
||||
overridden := aimlapiServiceKeyEnv(map[string]string{"AIMLAPI_API_KEY": "proxy-key", "AIMLAPI_INFERENCE_URL": "https://staging.example/v1"})
|
||||
if key, err := AIMLAPIServiceKey(ResolvedConfig{}, nil, overridden); !errors.Is(err, ErrAIMLAPIProxyProfile) || key != "" {
|
||||
t.Fatalf("inference URL override: AIMLAPIServiceKey = %q, %v; want ErrAIMLAPIProxyProfile", key, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIMLAPIServiceKeyRejectsKeysThatAreNotOneToken(t *testing.T) {
|
||||
for _, raw := range []string{"first\u00a0second", "first\u2003second", "first,second", "first\nsecond", "first\r\nsecond", "a b", "key\x00tail", "key\u0085tail"} {
|
||||
resolved := ResolvedConfig{Provider: ProviderProfile{Name: "aimlapi", CatalogID: "aimlapi", BaseURL: "https://api.aimlapi.com/v1", APIKey: raw}}
|
||||
if key, err := AIMLAPIServiceKey(resolved, nil, aimlapiServiceKeyEnv(nil)); !errors.Is(err, ErrAIMLAPIKeyInvalid) || key != "" {
|
||||
t.Errorf("key %q: AIMLAPIServiceKey = %q, %v; want ErrAIMLAPIKeyInvalid", raw, key, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadAIMLAPIServiceKeyResolvesUserConfig(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("APPDATA", filepath.Join(home, "AppData"))
|
||||
t.Setenv("USERPROFILE", home)
|
||||
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
|
||||
t.Setenv("ZERO_CRED_STORAGE", "file")
|
||||
t.Setenv("ZERO_PROVIDER", "")
|
||||
t.Setenv("AIMLAPI_INFERENCE_URL", "")
|
||||
t.Setenv("AIMLAPI_API_KEY", "env-key")
|
||||
|
||||
if key, err := LoadAIMLAPIServiceKey(); err != nil || key != "env-key" {
|
||||
t.Fatalf("without config: LoadAIMLAPIServiceKey = %q, %v; want env-key", key, err)
|
||||
}
|
||||
|
||||
path, err := DefaultUserConfigPath()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
proxy := `{"activeProvider":"aimlapi","providers":[{"name":"aimlapi","catalogID":"aimlapi","baseURL":"https://proxy.example/v1","model":"m"}]}`
|
||||
if err := os.WriteFile(path, []byte(proxy), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if key, err := LoadAIMLAPIServiceKey(); !errors.Is(err, ErrAIMLAPIProxyProfile) || key != "" {
|
||||
t.Fatalf("proxy profile: LoadAIMLAPIServiceKey = %q, %v; want ErrAIMLAPIProxyProfile", key, err)
|
||||
}
|
||||
|
||||
canonical := `{"activeProvider":"aimlapi","providers":[{"name":"aimlapi","catalogID":"aimlapi"}]}`
|
||||
if err := os.WriteFile(path, []byte(canonical), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if key, err := LoadAIMLAPIServiceKey(); err != nil || key != "env-key" {
|
||||
t.Fatalf("canonical profile: LoadAIMLAPIServiceKey = %q, %v; want env-key", key, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Several profiles and no activeProvider make Resolve return
|
||||
// ErrNoActiveProvider with the normalized profiles; the interactive app keeps
|
||||
// running on that partial config, so the service key must still resolve.
|
||||
func TestLoadAIMLAPIServiceKeyWithoutActiveProvider(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("APPDATA", filepath.Join(home, "AppData"))
|
||||
t.Setenv("USERPROFILE", home)
|
||||
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
|
||||
t.Setenv("ZERO_CRED_STORAGE", "file")
|
||||
t.Setenv("ZERO_PROVIDER", "")
|
||||
t.Setenv("AIMLAPI_INFERENCE_URL", "")
|
||||
t.Setenv("AIMLAPI_API_KEY", "env-key")
|
||||
path, err := DefaultUserConfigPath()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
other := `{"name":"other","provider":"openai","model":"m","apiKey":"other-key"}`
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
active string
|
||||
profile string
|
||||
want string
|
||||
wantErr error
|
||||
}{
|
||||
{name: "environment key", profile: `{"name":"second","provider":"openai","model":"m","apiKey":"second-key"}`, want: "env-key"},
|
||||
{name: "stale active provider", active: "gone", profile: `{"name":"second","provider":"openai","model":"m","apiKey":"second-key"}`, want: "env-key"},
|
||||
{name: "aimlapi profile key", profile: `{"name":"aimlapi","catalogID":"aimlapi","model":"m","apiKey":"profile-key"}`, want: "profile-key"},
|
||||
{name: "proxy profile", profile: `{"name":"aimlapi","catalogID":"aimlapi","baseURL":"https://proxy.example/v1","model":"m"}`, wantErr: ErrAIMLAPIProxyProfile},
|
||||
} {
|
||||
data := `{"activeProvider":"` + tc.active + `","providers":[` + other + `,` + tc.profile + `]}`
|
||||
if err := os.WriteFile(path, []byte(data), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
key, err := LoadAIMLAPIServiceKey()
|
||||
if tc.wantErr != nil {
|
||||
if !errors.Is(err, tc.wantErr) || key != "" {
|
||||
t.Fatalf("%s: LoadAIMLAPIServiceKey = %q, %v; want %v", tc.name, key, err, tc.wantErr)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err != nil || key != tc.want {
|
||||
t.Fatalf("%s: LoadAIMLAPIServiceKey = %q, %v; want %q", tc.name, key, err, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve drops a non-active profile it cannot normalize (here an unknown
|
||||
// catalog entry). Such a profile still shows that AIMLAPI_API_KEY belongs to a
|
||||
// proxy, so the key is withheld whether or not a provider is active.
|
||||
func TestLoadAIMLAPIServiceKeyWithholdsForDroppedProxyProfile(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("APPDATA", filepath.Join(home, "AppData"))
|
||||
t.Setenv("USERPROFILE", home)
|
||||
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
|
||||
t.Setenv("ZERO_CRED_STORAGE", "file")
|
||||
t.Setenv("ZERO_PROVIDER", "")
|
||||
t.Setenv("AIMLAPI_INFERENCE_URL", "")
|
||||
t.Setenv("AIMLAPI_API_KEY", "env-key")
|
||||
path, err := DefaultUserConfigPath()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
canonical := `{"name":"aimlapi","catalogID":"aimlapi","model":"m","apiKey":"profile-key"}`
|
||||
other := `{"name":"other","provider":"openai","model":"m","apiKey":"other-key"}`
|
||||
dropped := `{"name":"future","catalogID":"no-such-catalog","baseURL":"https://proxy.example/v1","apiKeyEnv":"AIMLAPI_API_KEY","model":"m"}`
|
||||
unrelated := `{"name":"future","catalogID":"no-such-catalog","baseURL":"https://proxy.example/v1","apiKeyEnv":"OTHER_API_KEY","model":"m"}`
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
data string
|
||||
want string
|
||||
wantErr error
|
||||
}{
|
||||
{name: "active aimlapi profile", data: `{"activeProvider":"aimlapi","providers":[` + canonical + `,` + dropped + `]}`, wantErr: ErrAIMLAPIProxyProfile},
|
||||
{name: "no active provider", data: `{"providers":[` + other + `,` + dropped + `]}`, wantErr: ErrAIMLAPIProxyProfile},
|
||||
{name: "dropped profile without the key", data: `{"activeProvider":"aimlapi","providers":[` + canonical + `,` + unrelated + `]}`, want: "profile-key"},
|
||||
} {
|
||||
if err := os.WriteFile(path, []byte(tc.data), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
key, err := LoadAIMLAPIServiceKey()
|
||||
if tc.wantErr != nil {
|
||||
if !errors.Is(err, tc.wantErr) || key != "" {
|
||||
t.Fatalf("%s: LoadAIMLAPIServiceKey = %q, %v; want %v", tc.name, key, err, tc.wantErr)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err != nil || key != tc.want {
|
||||
t.Fatalf("%s: LoadAIMLAPIServiceKey = %q, %v; want %q", tc.name, key, err, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadAIMLAPIServiceKeyStoreOpenFailure(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("APPDATA", filepath.Join(home, "AppData"))
|
||||
t.Setenv("USERPROFILE", home)
|
||||
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
|
||||
t.Setenv("ZERO_PROVIDER", "")
|
||||
t.Setenv("AIMLAPI_INFERENCE_URL", "")
|
||||
t.Setenv("AIMLAPI_API_KEY", "env-key")
|
||||
t.Setenv("ZERO_CRED_STORAGE", "invalid-test-storage")
|
||||
if key, err := LoadAIMLAPIServiceKey(); err != nil || key != "env-key" {
|
||||
t.Fatalf("environment key must not require a store: %q, %v", key, err)
|
||||
}
|
||||
path, err := DefaultUserConfigPath()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("AIMLAPI_API_KEY", "")
|
||||
data := `{"activeProvider":"aimlapi","providers":[{"name":"aimlapi","catalogID":"aimlapi","model":"m","apiKeyStored":true}]}`
|
||||
if err := os.WriteFile(path, []byte(data), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if key, err := LoadAIMLAPIServiceKey(); key != "" || err == nil || !strings.Contains(err.Error(), "unknown storage") {
|
||||
t.Fatalf("stored key must report store failure: %q, %v", key, err)
|
||||
}
|
||||
t.Setenv("AIMLAPI_INFERENCE_URL", "https://proxy.example/v1")
|
||||
if key, err := LoadAIMLAPIServiceKey(); key != "" || !errors.Is(err, ErrAIMLAPIProxyProfile) {
|
||||
t.Fatalf("proxy rejection must precede store open: %q, %v", key, err)
|
||||
}
|
||||
t.Setenv("AIMLAPI_INFERENCE_URL", "")
|
||||
data = `{"activeProvider":"aimlapi","providers":[{"name":"aimlapi","catalogID":"aimlapi","model":"m","apiKeyStored":true,"apiKey":"inline-key"}]}`
|
||||
if err := os.WriteFile(path, []byte(data), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if key, err := LoadAIMLAPIServiceKey(); err != nil || key != "inline-key" {
|
||||
t.Fatalf("inline key must not require a store: %q, %v", key, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIMLAPIServiceKeyReportsStoreReadFailure(t *testing.T) {
|
||||
want := errors.New("test credential read failure")
|
||||
resolved := ResolvedConfig{Provider: ProviderProfile{Name: "aimlapi", CatalogID: "aimlapi", BaseURL: "https://api.aimlapi.com/v1", APIKeyStored: true}}
|
||||
if key, err := AIMLAPIServiceKey(resolved, fakeKeyGetter{err: want}, aimlapiServiceKeyEnv(nil)); key != "" || !errors.Is(err, want) {
|
||||
t.Fatalf("read failure was hidden: %q, %v", key, err)
|
||||
}
|
||||
resolved.Provider.BaseURL = "https://proxy.example/v1"
|
||||
if _, err := AIMLAPIServiceKey(resolved, fakeKeyGetter{err: want}, aimlapiServiceKeyEnv(nil)); !errors.Is(err, ErrAIMLAPIProxyProfile) {
|
||||
t.Fatalf("proxy check must precede store read: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEnsureChatGPTDefaultPreservesSavedSelection(t *testing.T) {
|
||||
for _, saved := range []string{"", "gpt-explicit-saved"} {
|
||||
name := "new profile"
|
||||
if saved != "" {
|
||||
name = "saved selection"
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "config.json")
|
||||
if saved != "" {
|
||||
body, err := json.Marshal(FileConfig{ActiveProvider: "codex", Providers: []ProviderProfile{{Name: "codex", CatalogID: "chatgpt", Model: saved}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, body, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
ensured, err := EnsureCatalogProvider(path, "chatgpt")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var cfg FileConfig
|
||||
if err := json.Unmarshal(body, &cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := "gpt-6.1-sol"
|
||||
if saved != "" {
|
||||
want = saved
|
||||
}
|
||||
if len(cfg.Providers) != 1 || cfg.Providers[0].Model != want {
|
||||
t.Fatalf("ChatGPT default/selection = %#v, want model %q", cfg.Providers, want)
|
||||
}
|
||||
if saved != "" && (ensured.Name != "codex" || ensured.Created) {
|
||||
t.Fatalf("saved subscription profile was replaced: %#v", ensured)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -3,48 +3,26 @@ package config
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"github.com/Twigpine/zero/internal/aimlapi"
|
||||
)
|
||||
|
||||
// DefaultMCPServers returns the MCP servers Zero ships built in. Exa is ENABLED
|
||||
// by default so web search and page fetching work out of the box with no setup
|
||||
// and no API key. They are seeded before user/project config is merged (see
|
||||
// ResolveMCP), so a user can override any field — for example add an API-key
|
||||
// header to lift Exa's anonymous rate limit — or disable it entirely with
|
||||
// DefaultMCPServers returns the MCP servers Zero ships ENABLED by default so
|
||||
// web search and page fetching work out of the box with no setup and no API
|
||||
// key. They are seeded before user/project config is merged (see ResolveMCP),
|
||||
// so a user can override any field — for example add an API-key header to lift
|
||||
// Exa's anonymous rate limit — or disable it entirely with
|
||||
// `zero mcp disable <name>` (which writes `"disabled": true`).
|
||||
//
|
||||
// Exa's hosted MCP server works anonymously with rate limits. Users can add an
|
||||
// Exa API key for higher limits.
|
||||
//
|
||||
// The AI/ML API server ships DISABLED: its tool runs and generations are billed
|
||||
// to the user's AI/ML API balance, so it only connects after
|
||||
// `zero mcp enable aimlapi` (which writes `"disabled": false`). The disable is
|
||||
// marked as explicitly set so the lower-trust project layer cannot lift it
|
||||
// (see mergeMCPServer); only the user and CLI-override scopes can. The key is
|
||||
// attached per request by the MCP client and is never part of this config.
|
||||
func DefaultMCPServers() map[string]MCPServerConfig {
|
||||
return map[string]MCPServerConfig{
|
||||
"exa": {
|
||||
Type: "http",
|
||||
URL: "https://mcp.exa.ai/mcp",
|
||||
},
|
||||
"aimlapi": {
|
||||
Type: "http",
|
||||
URL: aimlapi.MCPServerURL,
|
||||
Disabled: true,
|
||||
disabledSet: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// IsDefaultMCPServerDisabled reports whether name is a built-in default that
|
||||
// ships disabled, so enabling it has to be written out as `"disabled": false`
|
||||
// rather than by dropping the flag.
|
||||
func IsDefaultMCPServerDisabled(name string) bool {
|
||||
return DefaultMCPServers()[strings.TrimSpace(name)].Disabled
|
||||
}
|
||||
|
||||
// IsDefaultMCPServer reports whether name is one of Zero's built-in default MCP
|
||||
// servers. The config commands use it so a default can be disabled/enabled even
|
||||
// though it is not written to the user's config file until overridden.
|
||||
|
||||
@@ -291,92 +291,3 @@ func TestResolveMCPDisabledRetiredEntryStaysReEnableable(t *testing.T) {
|
||||
t.Fatal("the disable must still carry to the successor")
|
||||
}
|
||||
}
|
||||
|
||||
func writeMCPDefaultsTestConfig(t *testing.T, data string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "config.json")
|
||||
if err := os.WriteFile(path, []byte(data), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func TestResolveMCPSeedsDisabledAIMLAPIDefault(t *testing.T) {
|
||||
if !IsDefaultMCPServer("aimlapi") || !IsDefaultMCPServerDisabled("aimlapi") || IsDefaultMCPServerDisabled("exa") {
|
||||
t.Fatal("aimlapi must be a built-in default that ships disabled, exa one that ships enabled")
|
||||
}
|
||||
// No user config at all, and a user config that never mentions aimlapi.
|
||||
for name, options := range map[string]ResolveOptions{
|
||||
"no config": {},
|
||||
"unrelated user file": {UserConfigPath: writeMCPDefaultsTestConfig(t, `{"mcp":{"servers":{"exa":{"disabled":true}}}}`)},
|
||||
"user entry with only headers": {UserConfigPath: writeMCPDefaultsTestConfig(t,
|
||||
`{"mcp":{"servers":{"aimlapi":{"headers":{"X-Trace":"1"}}}}}`)},
|
||||
} {
|
||||
cfg, err := ResolveMCP(options)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: ResolveMCP: %v", name, err)
|
||||
}
|
||||
server, ok := cfg.Servers["aimlapi"]
|
||||
if !ok {
|
||||
t.Fatalf("%s: the aimlapi default must be visible in the resolved config", name)
|
||||
}
|
||||
if server.Type != "http" || server.URL != "https://mcp.aimlapi.com/mcp" || !server.Disabled {
|
||||
t.Fatalf("%s: aimlapi default = %#v, want a disabled http server at the MCP URL", name, server)
|
||||
}
|
||||
}
|
||||
cfg, err := ResolveMCP(ResolveOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !IsUnconfiguredDefault("aimlapi", cfg.Servers["aimlapi"]) {
|
||||
t.Fatal("the untouched aimlapi default should be reported as unconfigured")
|
||||
}
|
||||
if exa := cfg.Servers["exa"]; exa.Disabled || !IsUnconfiguredDefault("exa", exa) {
|
||||
t.Fatalf("exa default changed: %#v", exa)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveMCPUserEnablesAIMLAPIDefault(t *testing.T) {
|
||||
path := writeMCPDefaultsTestConfig(t, `{"mcp":{"servers":{"aimlapi":{"disabled":false}}}}`)
|
||||
cfg, err := ResolveMCP(ResolveOptions{UserConfigPath: path})
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveMCP: %v", err)
|
||||
}
|
||||
server := cfg.Servers["aimlapi"]
|
||||
if server.Disabled || server.URL != "https://mcp.aimlapi.com/mcp" || server.Type != "http" {
|
||||
t.Fatalf("user enable did not turn the default on: %#v", server)
|
||||
}
|
||||
if IsUnconfiguredDefault("aimlapi", server) {
|
||||
t.Fatal("an enabled aimlapi is user-configured, so its failures must be reported")
|
||||
}
|
||||
|
||||
enable := MCPServerConfig{Disabled: false, disabledSet: true}
|
||||
cfg, err = ResolveMCP(ResolveOptions{Overrides: Overrides{MCP: MCPConfig{Servers: map[string]MCPServerConfig{"aimlapi": enable}}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.Servers["aimlapi"].Disabled {
|
||||
t.Fatal("the CLI override scope must be able to enable the aimlapi default")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveMCPProjectCannotEnableAIMLAPIDefault(t *testing.T) {
|
||||
project := writeMCPDefaultsTestConfig(t, `{"mcp":{"servers":{"aimlapi":{"disabled":false}}}}`)
|
||||
cfg, err := ResolveMCP(ResolveOptions{ProjectConfigPath: project})
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveMCP: %v", err)
|
||||
}
|
||||
if !cfg.Servers["aimlapi"].Disabled {
|
||||
t.Fatal("a project config must not turn on the billed aimlapi default")
|
||||
}
|
||||
|
||||
user := writeMCPDefaultsTestConfig(t, `{"mcp":{"servers":{"aimlapi":{"disabled":false}}}}`)
|
||||
projectDisable := writeMCPDefaultsTestConfig(t, `{"mcp":{"servers":{"aimlapi":{"disabled":true}}}}`)
|
||||
cfg, err = ResolveMCP(ResolveOptions{UserConfigPath: user, ProjectConfigPath: projectDisable})
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveMCP: %v", err)
|
||||
}
|
||||
if cfg.Servers["aimlapi"].Disabled {
|
||||
t.Fatal("the user's explicit enable wins over the project layer, as for any server")
|
||||
}
|
||||
}
|
||||
@@ -455,7 +455,7 @@ func projectAPIKeyEnvAllowed(profile ProviderProfile, envName string) bool {
|
||||
if baseURL == "" {
|
||||
baseURL = descriptor.DefaultBaseURL
|
||||
}
|
||||
if !providercatalog.IsCatalogBaseURL(descriptor, baseURL) {
|
||||
if !sameBaseURL(baseURL, descriptor.DefaultBaseURL) {
|
||||
return false
|
||||
}
|
||||
return containsStringFold(descriptor.AuthEnvVars, envName)
|
||||
@@ -463,7 +463,7 @@ func projectAPIKeyEnvAllowed(profile ProviderProfile, envName string) bool {
|
||||
|
||||
func projectBaseURLAllowed(profile ProviderProfile) bool {
|
||||
descriptor, ok := catalogDescriptorForProfile(profile)
|
||||
return ok && providercatalog.IsCatalogBaseURL(descriptor, profile.BaseURL)
|
||||
return ok && sameBaseURL(profile.BaseURL, descriptor.DefaultBaseURL)
|
||||
}
|
||||
|
||||
func hasInheritedProviderCredentialMaterial(project ProviderProfile, candidate ProviderProfile) bool {
|
||||
@@ -1128,13 +1128,10 @@ func applyCatalogDescriptor(profile *ProviderProfile, descriptor providercatalog
|
||||
if profile.Model == "" {
|
||||
profile.Model = descriptor.DefaultModel
|
||||
}
|
||||
// A legacy catalog base URL (an earlier default that still serves the
|
||||
// provider, e.g. a profile saved before a host move) counts as the catalog
|
||||
// endpoint, so it keeps the catalog env var binding.
|
||||
canonicalCatalogEndpoint := !explicitBaseURL || providercatalog.IsCatalogBaseURL(descriptor, profile.BaseURL)
|
||||
if profile.APIKeyEnv == "" && len(descriptor.AuthEnvVars) > 0 && canonicalCatalogEndpoint {
|
||||
if profile.APIKeyEnv == "" && len(descriptor.AuthEnvVars) > 0 && (!explicitBaseURL || sameBaseURL(profile.BaseURL, descriptor.DefaultBaseURL)) {
|
||||
profile.APIKeyEnv = descriptor.AuthEnvVars[0]
|
||||
}
|
||||
canonicalCatalogEndpoint := !explicitBaseURL || sameBaseURL(profile.BaseURL, descriptor.DefaultBaseURL)
|
||||
if len(descriptor.CustomHeaders) > 0 && canonicalCatalogEndpoint {
|
||||
catalogHeaders := descriptor.CustomHeaders
|
||||
if strings.EqualFold(strings.TrimSpace(descriptor.ID), "aimlapi") {
|
||||
|
||||
@@ -1357,74 +1357,6 @@ func TestResolveAppliesProviderCatalogDefaults(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAppliesOpenGatewayCatalogDefaults(t *testing.T) {
|
||||
path := writeConfig(t, `{
|
||||
"activeProvider": "gitlawb-opengateway",
|
||||
"providers": [{
|
||||
"name": "gitlawb-opengateway",
|
||||
"catalogID": "gitlawb-opengateway"
|
||||
}]
|
||||
}`)
|
||||
|
||||
resolved, err := Resolve(ResolveOptions{
|
||||
UserConfigPath: path,
|
||||
Env: map[string]string{
|
||||
"GITLAWB_OPENGATEWAY_API_KEY": "ogw_live_test",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error = %v", err)
|
||||
}
|
||||
if resolved.Provider.BaseURL != "https://api.twigpine.com/v1" {
|
||||
t.Fatalf("BaseURL = %q, want https://api.twigpine.com/v1", resolved.Provider.BaseURL)
|
||||
}
|
||||
if resolved.Provider.Model != "xiaomi/mimo-v2.6-pro" {
|
||||
t.Fatalf("Model = %q, want xiaomi/mimo-v2.6-pro", resolved.Provider.Model)
|
||||
}
|
||||
if resolved.Provider.APIKeyEnv != "GITLAWB_OPENGATEWAY_API_KEY" || resolved.Provider.APIKey != "ogw_live_test" {
|
||||
t.Fatalf("credential = (%q, %q), want GITLAWB_OPENGATEWAY_API_KEY env-resolved key", resolved.Provider.APIKeyEnv, resolved.Provider.APIKey)
|
||||
}
|
||||
}
|
||||
|
||||
// Profiles saved before the Twigpine rename persist the catalog's old base URL
|
||||
// (https://opengateway.gitlawb.com/v1) explicitly. That host still serves the
|
||||
// gateway, so those configs must keep resolving exactly as before: same host,
|
||||
// same model, catalog env var still bound, credential still picked up.
|
||||
func TestResolveKeepsLegacyOpenGatewayHostProfiles(t *testing.T) {
|
||||
path := writeConfig(t, `{
|
||||
"activeProvider": "gitlawb-opengateway",
|
||||
"providers": [{
|
||||
"name": "gitlawb-opengateway",
|
||||
"catalogID": "gitlawb-opengateway",
|
||||
"providerKind": "openai-compatible",
|
||||
"baseURL": "https://opengateway.gitlawb.com/v1",
|
||||
"model": "mimo-v2.5-pro"
|
||||
}]
|
||||
}`)
|
||||
|
||||
resolved, err := Resolve(ResolveOptions{
|
||||
UserConfigPath: path,
|
||||
Env: map[string]string{
|
||||
"GITLAWB_OPENGATEWAY_API_KEY": "ogw_live_test",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error = %v", err)
|
||||
}
|
||||
if resolved.Provider.BaseURL != "https://opengateway.gitlawb.com/v1" {
|
||||
t.Fatalf("BaseURL = %q, want the saved legacy host kept", resolved.Provider.BaseURL)
|
||||
}
|
||||
if resolved.Provider.Model != "mimo-v2.5-pro" {
|
||||
t.Fatalf("Model = %q, want the saved model kept", resolved.Provider.Model)
|
||||
}
|
||||
if resolved.Provider.APIKeyEnv != "GITLAWB_OPENGATEWAY_API_KEY" {
|
||||
t.Fatalf("APIKeyEnv = %q, want GITLAWB_OPENGATEWAY_API_KEY", resolved.Provider.APIKeyEnv)
|
||||
}
|
||||
if resolved.Provider.APIKey != "ogw_live_test" {
|
||||
t.Fatalf("APIKey = %q, want env-resolved key", resolved.Provider.APIKey)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAppliesMiniMaxCNCatalogDefaults(t *testing.T) {
|
||||
path := writeConfig(t, `{
|
||||
"activeProvider": "mini-cn",
|
||||
|
||||
@@ -724,39 +724,6 @@ func (cfg LocalControlDriverConfig) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(raw)
|
||||
}
|
||||
|
||||
// MarshalJSON writes "disabled" whenever it was explicitly set, not only when
|
||||
// true. A read-modify-write of config.json (SetTheme, SetActiveProvider, ...)
|
||||
// would otherwise drop `"disabled": false`, which turns a default that ships
|
||||
// disabled (aimlapi) back off and lets project config override a user's
|
||||
// explicit enable (see mergeMCPServer).
|
||||
func (server MCPServerConfig) MarshalJSON() ([]byte, error) {
|
||||
type rawServer struct {
|
||||
Type string `json:"type,omitempty"`
|
||||
Command string `json:"command,omitempty"`
|
||||
Args []string `json:"args,omitempty"`
|
||||
Env map[string]string `json:"env,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Headers map[string]string `json:"headers,omitempty"`
|
||||
Auth string `json:"auth,omitempty"`
|
||||
OAuth *MCPOAuthConfig `json:"oauth,omitempty"`
|
||||
Disabled *bool `json:"disabled,omitempty"`
|
||||
}
|
||||
raw := rawServer{
|
||||
Type: server.Type,
|
||||
Command: server.Command,
|
||||
Args: server.Args,
|
||||
Env: server.Env,
|
||||
URL: server.URL,
|
||||
Headers: server.Headers,
|
||||
Auth: server.Auth,
|
||||
OAuth: server.OAuth,
|
||||
}
|
||||
if server.disabledSet || server.Disabled {
|
||||
raw.Disabled = &server.Disabled
|
||||
}
|
||||
return json.Marshal(raw)
|
||||
}
|
||||
|
||||
func (server *MCPServerConfig) UnmarshalJSON(data []byte) error {
|
||||
type rawServer struct {
|
||||
Type string `json:"type"`
|
||||
|
||||
@@ -100,10 +100,17 @@ func windowsSandboxSetupCheck(goos string, backend sandbox.Backend, workspaceRoo
|
||||
}
|
||||
profile := sandbox.PermissionProfileFromPolicy(workspaceRoot, doctorSandboxPolicy(sandboxConfig), scope)
|
||||
setupConfig := sandbox.WindowsSandboxSetupConfig{
|
||||
SandboxHome: sandboxHome,
|
||||
CommandCWD: workspaceRoot,
|
||||
WorkspaceRoots: []string{workspaceRoot},
|
||||
PermissionProfile: profile,
|
||||
SandboxHome: sandboxHome,
|
||||
CommandCWD: workspaceRoot,
|
||||
WorkspaceRoots: []string{workspaceRoot},
|
||||
// Same augmentation the setup args and the command plan apply, so doctor
|
||||
// fingerprints what a real command fingerprints. Safe here: doctor runs in
|
||||
// the operator's shell, not behind the sandbox's TEMP redirection.
|
||||
PermissionProfile: sandbox.WindowsSandboxProfileWithRuntimeRoots(profile, []string{workspaceRoot}),
|
||||
// Same opt-in a command would resolve, so doctor reports the principal
|
||||
// mismatch as out-of-date setup instead of passing a check the next command
|
||||
// will fail.
|
||||
PrincipalOptIn: sandbox.WindowsSandboxPrincipalOptIn(nil),
|
||||
}
|
||||
if err := sandbox.ValidateWindowsSandboxSetupMarker(setupConfig); err != nil {
|
||||
result := check("sandbox.backend", "Sandbox backend", StatusWarn, fmt.Sprintf("Native sandbox backend %s is installed, but Windows sandbox setup is missing or out of date: %v.", backend.Name, err), map[string]any{
|
||||
@@ -115,6 +122,53 @@ func windowsSandboxSetupCheck(goos string, backend sandbox.Backend, workspaceRoo
|
||||
})
|
||||
return &result
|
||||
}
|
||||
// Setup is valid and the opt-in is set, so report which principal a command
|
||||
// will actually run as.
|
||||
//
|
||||
// This used to warn that the principal stood down under the DEFAULT
|
||||
// network-deny policy, which was true while network denial could only be
|
||||
// expressed on the restricted token: a principal token cannot carry the
|
||||
// synthetic offline-marker SID the WFP filters matched. Dual-role provisioning
|
||||
// removed that limit by giving each workspace an offline account whose real
|
||||
// group membership the same filters match, so deny mode now runs on a
|
||||
// principal exactly as allow mode does. Leaving the warning would have doctor
|
||||
// telling operators their reads are unconfined while they are confined.
|
||||
//
|
||||
// Naming the role rather than just saying "active" is the useful part. The two
|
||||
// accounts differ only in that membership, so an operator debugging network
|
||||
// behaviour needs to know which one this workspace picks.
|
||||
if setupConfig.PrincipalOptIn {
|
||||
// Asked of the shared rule rather than re-derived here, so doctor and the
|
||||
// runtime cannot disagree about which account a command runs as. Two
|
||||
// copies drifting is what produced the wrong report this replaces.
|
||||
role := sandbox.WindowsSandboxPrincipalRoleForNetwork(profile.Network.Mode)
|
||||
// SETUP IS CURRENT IS NOT THE SAME AS THE PRINCIPAL IS LIVE, and this used
|
||||
// to claim the second from the first. Marker validation compares serialized
|
||||
// plans and hashes; it names no account. Delete the selected account or its
|
||||
// secret, or drop the offline account out of ZeroSandboxOffline, and the
|
||||
// marker still validates while the runtime either falls back to the weaker
|
||||
// restricted-token path or fails outright. Doctor asserting read
|
||||
// confinement in that state is worse than saying nothing, because it is the
|
||||
// surface an operator checks precisely when they are unsure.
|
||||
//
|
||||
// Verifying liveness needs the account, a usable secret and (for offline)
|
||||
// the group membership, all Windows-only live queries this package cannot
|
||||
// make. So the claim is narrowed to what the marker actually proves, and
|
||||
// the role is still named because that part IS derived rather than assumed
|
||||
// and is what an operator debugging network behaviour needs.
|
||||
result := check("sandbox.principal", "Sandbox principal", StatusPass,
|
||||
fmt.Sprintf("Sandbox principal setup is current; a command in this workspace selects the %s principal. Setup state does not confirm the account is usable.", role), map[string]any{
|
||||
"backend": string(backend.Name),
|
||||
"platform": goos,
|
||||
"optIn": true,
|
||||
// Deliberately not "active": nothing here checked the account, the
|
||||
// secret or the group membership.
|
||||
"setupCurrent": true,
|
||||
"role": role,
|
||||
"networkMode": string(profile.Network.Mode),
|
||||
})
|
||||
return &result
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package doctor
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Twigpine/zero/internal/config"
|
||||
"github.com/Twigpine/zero/internal/sandbox"
|
||||
)
|
||||
|
||||
// A VALID MARKER IS NOT A LIVE PRINCIPAL. Marker validation compares serialized
|
||||
// plans and hashes and names no account, so it stays valid after the selected
|
||||
// account is deleted, its secret is removed, or the offline account is dropped
|
||||
// out of ZeroSandboxOffline. In each of those states the runtime falls back to
|
||||
// the restricted-token path with no read confinement, or fails outright.
|
||||
//
|
||||
// Doctor is the surface an operator checks precisely when they are unsure, so
|
||||
// claiming read confinement it has not verified is worse than reporting less.
|
||||
// This pins the narrower claim: setup is current, the role is named because it
|
||||
// is derived rather than assumed, and nothing asserts the account is usable.
|
||||
func TestPrincipalCheckDoesNotClaimAnUnverifiedPrincipalIsActive(t *testing.T) {
|
||||
sandboxHome := t.TempDir()
|
||||
workspaceRoot := t.TempDir()
|
||||
t.Setenv("ZERO_WINDOWS_SANDBOX_HOME", sandboxHome)
|
||||
t.Setenv("ZERO_WINDOWS_SANDBOX_IDENTITY", "1")
|
||||
|
||||
sandboxConfig := config.SandboxConfig{}
|
||||
scope, err := sandbox.NewScope(workspaceRoot, sandboxConfig.AdditionalWriteRoots)
|
||||
if err != nil {
|
||||
t.Fatalf("NewScope: %v", err)
|
||||
}
|
||||
profile := sandbox.PermissionProfileFromPolicy(workspaceRoot, doctorSandboxPolicy(sandboxConfig), scope)
|
||||
|
||||
// Mirrors what windowsSandboxSetupCheck derives, so the marker it writes is
|
||||
// the one the check will validate against. Nothing here provisions an
|
||||
// account, which is the whole point: the marker is valid and the principal
|
||||
// does not exist.
|
||||
if _, err := sandbox.WriteWindowsSandboxSetupMarker(sandbox.WindowsSandboxSetupConfig{
|
||||
SandboxHome: sandboxHome,
|
||||
CommandCWD: workspaceRoot,
|
||||
WorkspaceRoots: []string{workspaceRoot},
|
||||
PermissionProfile: sandbox.WindowsSandboxProfileWithRuntimeRoots(profile, []string{workspaceRoot}),
|
||||
PrincipalOptIn: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("WriteWindowsSandboxSetupMarker: %v", err)
|
||||
}
|
||||
|
||||
result := windowsSandboxSetupCheck("windows", sandbox.Backend{Name: sandbox.BackendWindowsRestrictedToken}, workspaceRoot, sandboxConfig)
|
||||
if result == nil {
|
||||
t.Fatal("expected a principal check once the marker validates and the opt-in is set")
|
||||
}
|
||||
if result.ID != "sandbox.principal" {
|
||||
t.Fatalf("check = %q (%s): the marker did not validate, so this test is not exercising the principal branch", result.ID, result.Message)
|
||||
}
|
||||
|
||||
lowered := strings.ToLower(result.Message)
|
||||
for _, claim := range []string{"is active", "commands run as"} {
|
||||
if strings.Contains(lowered, claim) {
|
||||
t.Errorf("doctor asserts %q about a principal it never checked: %q", claim, result.Message)
|
||||
}
|
||||
}
|
||||
if active, ok := result.Details["active"]; ok {
|
||||
t.Errorf("details carry active=%v, which nothing here verified", active)
|
||||
}
|
||||
|
||||
// The role is still worth reporting: it is derived from the network mode by
|
||||
// the shared rule rather than assumed, and it is what an operator debugging
|
||||
// network behaviour needs.
|
||||
if role, ok := result.Details["role"]; !ok || strings.TrimSpace(role.(string)) == "" {
|
||||
t.Errorf("the selected role should still be reported: %#v", result.Details)
|
||||
}
|
||||
}
|
||||
@@ -779,7 +779,7 @@ func normalizeDefinition(raw any, field string) (Definition, error) {
|
||||
return Definition{}, err
|
||||
}
|
||||
if matcher != "" && !eventSupportsMatcher(event) {
|
||||
return Definition{}, manifestError{fieldPath: field + ".matcher", message: "matcher is only supported for beforeTool, afterTool, specialistStart, and specialistStop hooks."}
|
||||
return Definition{}, manifestError{fieldPath: field + ".matcher", message: "matcher is only supported for beforeTool and afterTool hooks."}
|
||||
}
|
||||
command, err := requiredString(obj, field+".command")
|
||||
if err != nil {
|
||||
@@ -843,7 +843,7 @@ func matchesHookMatcher(matcher string, toolName string) bool {
|
||||
}
|
||||
|
||||
func eventSupportsMatcher(event Event) bool {
|
||||
return event == EventBeforeTool || event == EventAfterTool || event == EventSpecialistStart || event == EventSpecialistStop
|
||||
return event == EventBeforeTool || event == EventAfterTool
|
||||
}
|
||||
|
||||
func toDiagnostic(err error, source ConfigSource, path string) Diagnostic {
|
||||
|
||||
@@ -180,7 +180,7 @@ func TestLoadConfigPreservesUserDisabledStateWhenProjectOmitsEnabled(t *testing.
|
||||
}
|
||||
|
||||
func TestLoadConfigRejectsMatchersOnLifecycleHooks(t *testing.T) {
|
||||
for _, event := range []string{"sessionStart", "sessionEnd"} {
|
||||
for _, event := range []string{"sessionStart", "specialistStart"} {
|
||||
t.Run(event, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
projectConfigPath := filepath.Join(dir, "hooks.json")
|
||||
@@ -210,46 +210,6 @@ func TestLoadConfigRejectsMatchersOnLifecycleHooks(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigAcceptsMatchersOnSpecialistHooks(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
event Event
|
||||
id string
|
||||
}{
|
||||
{event: EventSpecialistStart, id: "zero.explorer-start"},
|
||||
{event: EventSpecialistStop, id: "zero.explorer-stop"},
|
||||
} {
|
||||
t.Run(string(tc.event), func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
projectConfigPath := filepath.Join(dir, "hooks.json")
|
||||
writeHookJSON(t, projectConfigPath, map[string]any{
|
||||
"hooks": []any{map[string]any{
|
||||
"id": tc.id,
|
||||
"event": string(tc.event),
|
||||
"matcher": "explorer",
|
||||
"command": "node",
|
||||
}},
|
||||
})
|
||||
|
||||
result, err := LoadConfig(LoadOptions{
|
||||
UserConfigPath: filepath.Join(dir, "missing-user-hooks.json"),
|
||||
ProjectConfigPath: projectConfigPath,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig returned error: %v", err)
|
||||
}
|
||||
if len(result.Config.Hooks) != 1 || result.Config.Hooks[0].Matcher != "explorer" {
|
||||
t.Fatalf("specialist matcher hook = %#v", result.Config.Hooks)
|
||||
}
|
||||
if got := hookIDs(Select(result.Config, SelectInput{Event: tc.event, ToolName: "explorer"})); !reflect.DeepEqual(got, []string{tc.id}) {
|
||||
t.Fatalf("matched selection = %#v", got)
|
||||
}
|
||||
if got := Select(result.Config, SelectInput{Event: tc.event, ToolName: "worker"}); len(got) != 0 {
|
||||
t.Fatalf("unmatched specialist should be empty, got %#v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigStorePersistsUpdates(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "hooks.json")
|
||||
store, err := NewConfigStore(StoreOptions{ConfigPath: configPath})
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/Twigpine/zero/internal/aimlapi"
|
||||
"github.com/Twigpine/zero/internal/config"
|
||||
)
|
||||
|
||||
// The AI/ML API MCP server authenticates with the user's AI/ML API key.
|
||||
//
|
||||
// Invariant: the key and the attribution headers are attached only to an
|
||||
// outgoing request whose own URL is https://mcp.aimlapi.com on the default port
|
||||
// with no userinfo (exact hostname, see aimlapi.IsAttributedURL), whatever the
|
||||
// server is named. The key is resolved again for every request, redirect hops
|
||||
// included (see reapplyAIMLAPIRedirectHeaders), through
|
||||
// config.LoadAIMLAPIServiceKey, which in the same call decides whether it may
|
||||
// be sent (no proxy profile, a single token), so a provider change applies to
|
||||
// the next request and a withheld or invalid key means no Authorization
|
||||
// header. It lives only in that request's headers: never in Server, its
|
||||
// Identity (OAuth token binding), config.json, list output or errors (an
|
||||
// error response to such a request is reported without its body). A
|
||||
// user-configured Authorization header, OAuth, or a project-touched server
|
||||
// always goes without it.
|
||||
var loadAIMLAPIServiceKey = config.LoadAIMLAPIServiceKey
|
||||
|
||||
var aimlapiMCPHost = func() string {
|
||||
parsed, err := url.Parse(aimlapi.MCPServerURL)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return parsed.Hostname()
|
||||
}()
|
||||
|
||||
func isAIMLAPIMCPURL(target *url.URL) bool {
|
||||
return aimlapi.IsAttributedURL(target) && strings.EqualFold(target.Hostname(), aimlapiMCPHost)
|
||||
}
|
||||
|
||||
// aimlapiAutomaticHeaders are the headers applyAIMLAPIRequestHeaders may add.
|
||||
var aimlapiAutomaticHeaders = [...]string{"Authorization", aimlapi.SourceHeaderName, aimlapi.PartnerHeaderName}
|
||||
|
||||
// applyAIMLAPIRequestHeaders runs after the server's own headers are set.
|
||||
func applyAIMLAPIRequestHeaders(server Server, request *http.Request) {
|
||||
if request == nil || !isAIMLAPIMCPURL(request.URL) {
|
||||
return
|
||||
}
|
||||
aimlapi.ApplyAttributionHeaders(request.Header, request.URL)
|
||||
if request.Header.Get("Authorization") != "" || strings.EqualFold(server.Auth, ServerAuthOAuth) || server.ProjectConfigured {
|
||||
return
|
||||
}
|
||||
key, err := loadAIMLAPIServiceKey()
|
||||
if err != nil || key == "" {
|
||||
return
|
||||
}
|
||||
request.Header.Set("Authorization", "Bearer "+key)
|
||||
}
|
||||
|
||||
// reapplyAIMLAPIRedirectHeaders gives a redirect hop the automatic headers its
|
||||
// own URL earns. net/http copies the first request's headers onto every hop
|
||||
// without calling the header hooks, so without this a key or attribution added
|
||||
// for the first URL would follow a same-origin hop that fails isAIMLAPIMCPURL
|
||||
// (userinfo, say), and an eligible hop would reuse the first hop's key instead
|
||||
// of resolving it again. The hop gets back the server's own values for these
|
||||
// headers, then the same rule as any other request.
|
||||
func reapplyAIMLAPIRedirectHeaders(server Server, request *http.Request, first *http.Request) {
|
||||
if request == nil || first == nil || !isAIMLAPIMCPURL(first.URL) {
|
||||
return
|
||||
}
|
||||
for _, name := range aimlapiAutomaticHeaders {
|
||||
request.Header.Del(name)
|
||||
}
|
||||
for name, value := range server.Headers {
|
||||
for _, automatic := range aimlapiAutomaticHeaders {
|
||||
if strings.EqualFold(name, automatic) {
|
||||
request.Header.Set(name, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
applyAIMLAPIRequestHeaders(server, request)
|
||||
}
|
||||
|
||||
// sentAIMLAPIServiceKey reports whether request went out carrying the key
|
||||
// applyAIMLAPIRequestHeaders injects, as opposed to the server's own
|
||||
// Authorization header or OAuth.
|
||||
func sentAIMLAPIServiceKey(server Server, request *http.Request) bool {
|
||||
if request == nil || !isAIMLAPIMCPURL(request.URL) || strings.EqualFold(server.Auth, ServerAuthOAuth) || server.ProjectConfigured {
|
||||
return false
|
||||
}
|
||||
return request.Header.Get("Authorization") != "" && !hasUserAuthorizationHeader(server)
|
||||
}
|
||||
|
||||
func hasUserAuthorizationHeader(server Server) bool {
|
||||
for name, value := range server.Headers {
|
||||
if strings.EqualFold(name, "Authorization") && value != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// aimlapiUnauthorizedHint explains how to authenticate when the AI/ML API MCP
|
||||
// server rejects a request without OAuth. It names no key material.
|
||||
func aimlapiUnauthorizedHint(server Server, status int) string {
|
||||
if status != http.StatusUnauthorized || strings.EqualFold(server.Auth, ServerAuthOAuth) {
|
||||
return ""
|
||||
}
|
||||
target, err := url.Parse(server.URL)
|
||||
if err != nil || !isAIMLAPIMCPURL(target) {
|
||||
return ""
|
||||
}
|
||||
oauthHint := fmt.Sprintf("To sign in instead, set \"auth\": \"oauth\" on this server and run `zero mcp oauth login %s`", server.Name)
|
||||
if server.ProjectConfigured || hasUserAuthorizationHeader(server) {
|
||||
return " (" + oauthHint + ")"
|
||||
}
|
||||
return fmt.Sprintf(" (set up the aimlapi.com provider with `zero providers setup aimlapi` or set AIMLAPI_API_KEY; automatic injection is also disabled for proxy profiles, OAuth, project config, and non-empty Authorization headers — see docs/EXTENDING.md#built-in-servers. To sign in instead, set \"auth\": \"oauth\" on this server and run `zero mcp oauth login %s`)", server.Name)
|
||||
}
|
||||
@@ -1,537 +0,0 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/Twigpine/zero/internal/aimlapi"
|
||||
"github.com/Twigpine/zero/internal/config"
|
||||
)
|
||||
|
||||
// aimlapiRecorder stands in for mcp.aimlapi.com: it records every request it
|
||||
// is handed (the URL stays https://mcp.aimlapi.com/mcp, nothing is dialed) and
|
||||
// answers JSON-RPC, or 401 with the server's OAuth challenge when unauthorized.
|
||||
type aimlapiRecorder struct {
|
||||
mu sync.Mutex
|
||||
headers []http.Header
|
||||
urls []string
|
||||
unauthorized bool
|
||||
// redirectFirstTo, when set, answers the first request with a 307 to it.
|
||||
redirectFirstTo string
|
||||
// failStatus, when set, answers every request with it and failBody.
|
||||
failStatus int
|
||||
failBody string
|
||||
}
|
||||
|
||||
func (recorder *aimlapiRecorder) RoundTrip(request *http.Request) (*http.Response, error) {
|
||||
var body []byte
|
||||
if request.Body != nil {
|
||||
body, _ = io.ReadAll(request.Body)
|
||||
_ = request.Body.Close()
|
||||
}
|
||||
recorder.mu.Lock()
|
||||
recorder.headers = append(recorder.headers, request.Header.Clone())
|
||||
recorder.urls = append(recorder.urls, request.URL.String())
|
||||
unauthorized := recorder.unauthorized
|
||||
redirect := recorder.redirectFirstTo != "" && len(recorder.headers) == 1
|
||||
recorder.mu.Unlock()
|
||||
|
||||
if redirect {
|
||||
response := fakeHTTPResponse(request, http.StatusTemporaryRedirect, "")
|
||||
response.Header.Set("Location", recorder.redirectFirstTo)
|
||||
return response, nil
|
||||
}
|
||||
if recorder.failStatus != 0 {
|
||||
return fakeHTTPResponse(request, recorder.failStatus, recorder.failBody), nil
|
||||
}
|
||||
if unauthorized {
|
||||
response := fakeHTTPResponse(request, http.StatusUnauthorized, `{"error":"invalid_token"}`)
|
||||
response.Header.Set("WWW-Authenticate", `Bearer resource_metadata="https://mcp.aimlapi.com/.well-known/oauth-protected-resource/mcp"`)
|
||||
return response, nil
|
||||
}
|
||||
var message struct {
|
||||
ID json.RawMessage `json:"id"`
|
||||
Method string `json:"method"`
|
||||
}
|
||||
_ = json.Unmarshal(body, &message)
|
||||
if len(message.ID) == 0 {
|
||||
return fakeHTTPResponse(request, http.StatusAccepted, ""), nil
|
||||
}
|
||||
result := `{}`
|
||||
switch message.Method {
|
||||
case "initialize":
|
||||
result = `{"protocolVersion":"2024-11-05","capabilities":{},"serverInfo":{"name":"fake","version":"1"}}`
|
||||
case "tools/list":
|
||||
result = `{"tools":[{"name":"tools_search","inputSchema":{"type":"object"}}]}`
|
||||
}
|
||||
return fakeHTTPResponse(request, http.StatusOK, fmt.Sprintf(`{"jsonrpc":"2.0","id":%s,"result":%s}`, message.ID, result)), nil
|
||||
}
|
||||
|
||||
func (recorder *aimlapiRecorder) recorded() []http.Header {
|
||||
recorder.mu.Lock()
|
||||
defer recorder.mu.Unlock()
|
||||
return append([]http.Header(nil), recorder.headers...)
|
||||
}
|
||||
|
||||
func fakeHTTPResponse(request *http.Request, status int, body string) *http.Response {
|
||||
header := http.Header{}
|
||||
if body != "" {
|
||||
header.Set("Content-Type", "application/json")
|
||||
}
|
||||
return &http.Response{
|
||||
StatusCode: status,
|
||||
Header: header,
|
||||
Body: io.NopCloser(bytes.NewBufferString(body)),
|
||||
Request: request,
|
||||
}
|
||||
}
|
||||
|
||||
// isolateAIMLAPIKeyLookup points the real key resolver at an empty temporary
|
||||
// home and returns the user config path it reads.
|
||||
func isolateAIMLAPIKeyLookup(t *testing.T, envKey string) string {
|
||||
t.Helper()
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("USERPROFILE", home)
|
||||
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
|
||||
t.Setenv("APPDATA", filepath.Join(home, "AppData"))
|
||||
t.Setenv("ZERO_CRED_STORAGE", "file")
|
||||
t.Setenv("ZERO_PROVIDER", "")
|
||||
t.Setenv("AIMLAPI_INFERENCE_URL", "")
|
||||
t.Setenv("AIMLAPI_PARTNER_ID", "")
|
||||
t.Setenv("AIMLAPI_API_KEY", envKey)
|
||||
path, err := config.DefaultUserConfigPath()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func writeAIMLAPIProviderConfig(t *testing.T, path string, baseURL string, apiKey string) {
|
||||
t.Helper()
|
||||
profile := map[string]any{"name": "aimlapi", "catalogID": "aimlapi", "model": "m", "apiKey": apiKey}
|
||||
if baseURL != "" {
|
||||
profile["baseURL"] = baseURL
|
||||
}
|
||||
data, err := json.Marshal(map[string]any{"activeProvider": "aimlapi", "providers": []any{profile}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, data, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func newRecordedNetworkClient(server Server, recorder *aimlapiRecorder) *networkClient {
|
||||
return &networkClient{server: server, client: mcpHTTPClient(server, recorder), nextID: 1}
|
||||
}
|
||||
|
||||
func aimlapiTestServer(name string, rawURL string) Server {
|
||||
return Server{Name: name, Type: ServerTypeHTTP, URL: rawURL}
|
||||
}
|
||||
|
||||
func assertAIMLAPIAttribution(t *testing.T, header http.Header) {
|
||||
t.Helper()
|
||||
if got := header.Get(aimlapi.SourceHeaderName); got != aimlapi.SourceValue {
|
||||
t.Fatalf("%s = %q, want %q", aimlapi.SourceHeaderName, got, aimlapi.SourceValue)
|
||||
}
|
||||
if got, want := header.Get(aimlapi.PartnerHeaderName), aimlapi.ResolvePartnerID(""); got == "" || got != want {
|
||||
t.Fatalf("%s = %q, want %q", aimlapi.PartnerHeaderName, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIMLAPIMCPRequestsCarryServiceKeyAndAttribution(t *testing.T) {
|
||||
isolateAIMLAPIKeyLookup(t, "aiml-secret")
|
||||
// A hand-added server on the exact host is treated like the built-in one.
|
||||
for _, name := range []string{"aimlapi", "media"} {
|
||||
recorder := &aimlapiRecorder{}
|
||||
client := newRecordedNetworkClient(aimlapiTestServer(name, aimlapi.MCPServerURL), recorder)
|
||||
if err := client.initialize(context.Background()); err != nil {
|
||||
t.Fatalf("%s: initialize: %v", name, err)
|
||||
}
|
||||
if _, err := client.ListTools(context.Background()); err != nil {
|
||||
t.Fatalf("%s: ListTools: %v", name, err)
|
||||
}
|
||||
requests := recorder.recorded()
|
||||
if len(requests) != 3 {
|
||||
t.Fatalf("%s: recorded %d requests, want initialize, initialized and tools/list", name, len(requests))
|
||||
}
|
||||
for _, header := range requests {
|
||||
if got := header.Get("Authorization"); got != "Bearer aiml-secret" {
|
||||
t.Fatalf("%s: Authorization = %q, want the AI/ML API key", name, got)
|
||||
}
|
||||
assertAIMLAPIAttribution(t, header)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIMLAPIMCPHeadersOnlyForExactHost(t *testing.T) {
|
||||
isolateAIMLAPIKeyLookup(t, "aiml-secret")
|
||||
for _, rawURL := range []string{
|
||||
"https://mcp.aimlapi.com.example.net/mcp",
|
||||
"http://mcp.aimlapi.com/mcp",
|
||||
"https://mcp.aimlapi.com:8443/mcp",
|
||||
"https://[email protected]/mcp",
|
||||
"https://api.aimlapi.com/mcp",
|
||||
"https://evilmcp.aimlapi.com/mcp",
|
||||
"https://mcp.exa.ai/mcp",
|
||||
} {
|
||||
recorder := &aimlapiRecorder{}
|
||||
server := aimlapiTestServer("other", rawURL)
|
||||
server.Headers = map[string]string{"X-Custom": "kept"}
|
||||
client := newRecordedNetworkClient(server, recorder)
|
||||
if err := client.initialize(context.Background()); err != nil {
|
||||
t.Fatalf("%s: initialize: %v", rawURL, err)
|
||||
}
|
||||
for _, header := range recorder.recorded() {
|
||||
if strings.Contains(rawURL, "user@") && strings.HasPrefix(header.Get("Authorization"), "Basic ") {
|
||||
// net/http turns URL userinfo into Basic auth on its own.
|
||||
header.Del("Authorization")
|
||||
}
|
||||
// Byte-for-byte what any other MCP server gets.
|
||||
want := http.Header{
|
||||
"X-Custom": {"kept"},
|
||||
"Content-Type": {"application/json"},
|
||||
"Accept": {"application/json, text/event-stream"},
|
||||
}
|
||||
if fmt.Sprint(header) != fmt.Sprint(want) {
|
||||
t.Fatalf("%s: headers = %v, want %v", rawURL, header, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Port 443 spelled out and a different host case are still the exact host.
|
||||
for _, rawURL := range []string{"https://mcp.aimlapi.com:443/mcp", "https://MCP.AIMLAPI.COM/mcp"} {
|
||||
recorder := &aimlapiRecorder{}
|
||||
client := newRecordedNetworkClient(aimlapiTestServer("aimlapi", rawURL), recorder)
|
||||
if err := client.initialize(context.Background()); err != nil {
|
||||
t.Fatalf("%s: initialize: %v", rawURL, err)
|
||||
}
|
||||
header := recorder.recorded()[0]
|
||||
if header.Get("Authorization") != "Bearer aiml-secret" {
|
||||
t.Fatalf("%s: Authorization = %q, want the key", rawURL, header.Get("Authorization"))
|
||||
}
|
||||
assertAIMLAPIAttribution(t, header)
|
||||
}
|
||||
}
|
||||
|
||||
// The key and its eligibility are resolved for every request, so switching the
|
||||
// aimlapi profile to a proxy keeps the proxy's key off mcp.aimlapi.com on the
|
||||
// very next request of an already-connected client and on new connections.
|
||||
func TestAIMLAPIMCPKeyIsResolvedPerRequestAcrossProviderSwitch(t *testing.T) {
|
||||
path := isolateAIMLAPIKeyLookup(t, "")
|
||||
writeAIMLAPIProviderConfig(t, path, "", "canonical-key")
|
||||
|
||||
recorder := &aimlapiRecorder{}
|
||||
server := aimlapiTestServer("aimlapi", aimlapi.MCPServerURL)
|
||||
client := newRecordedNetworkClient(server, recorder)
|
||||
if err := client.initialize(context.Background()); err != nil {
|
||||
t.Fatalf("initialize: %v", err)
|
||||
}
|
||||
if got := recorder.recorded()[0].Get("Authorization"); got != "Bearer canonical-key" {
|
||||
t.Fatalf("canonical profile: Authorization = %q, want Bearer canonical-key", got)
|
||||
}
|
||||
|
||||
writeAIMLAPIProviderConfig(t, path, "https://proxy.example/v1", "proxy-key")
|
||||
if _, err := client.ListTools(context.Background()); err != nil {
|
||||
t.Fatalf("ListTools after switch: %v", err)
|
||||
}
|
||||
fresh := newRecordedNetworkClient(server, recorder)
|
||||
if err := fresh.initialize(context.Background()); err != nil {
|
||||
t.Fatalf("initialize after switch: %v", err)
|
||||
}
|
||||
requests := recorder.recorded()
|
||||
for _, header := range requests[2:] {
|
||||
if got := header.Get("Authorization"); got != "" {
|
||||
t.Fatalf("proxy profile: Authorization = %q, want none", got)
|
||||
}
|
||||
assertAIMLAPIAttribution(t, header)
|
||||
}
|
||||
for _, header := range requests {
|
||||
if strings.Contains(fmt.Sprint(header), "proxy-key") {
|
||||
t.Fatalf("proxy key reached mcp.aimlapi.com: %v", header)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIMLAPIMCPUnusableKeySendsNoAuthorization(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"multi-line key pool": "first-key\nsecond-key",
|
||||
"comma key pool": "first-key,second-key",
|
||||
"no key": "",
|
||||
}
|
||||
for name, apiKey := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
path := isolateAIMLAPIKeyLookup(t, "")
|
||||
writeAIMLAPIProviderConfig(t, path, "", apiKey)
|
||||
recorder := &aimlapiRecorder{unauthorized: true}
|
||||
client := newRecordedNetworkClient(aimlapiTestServer("aimlapi", aimlapi.MCPServerURL), recorder)
|
||||
err := client.initialize(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("initialize succeeded against a 401")
|
||||
}
|
||||
requests := recorder.recorded()
|
||||
if len(requests) != 1 {
|
||||
t.Fatalf("recorded %d requests, want one intact request", len(requests))
|
||||
}
|
||||
header := requests[0]
|
||||
if got := header.Get("Authorization"); got != "" {
|
||||
t.Fatalf("Authorization = %q, want none", got)
|
||||
}
|
||||
if header.Get("Content-Type") != "application/json" {
|
||||
t.Fatalf("request headers broken: %v", header)
|
||||
}
|
||||
assertAIMLAPIAttribution(t, header)
|
||||
if !strings.Contains(err.Error(), "returned HTTP 401") || !strings.Contains(err.Error(), "zero mcp oauth login aimlapi") || !strings.Contains(err.Error(), "AIMLAPI_API_KEY") {
|
||||
t.Fatalf("401 error lacks the authentication hint: %v", err)
|
||||
}
|
||||
if strings.Contains(err.Error(), "first-key") {
|
||||
t.Fatalf("error leaks key material: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIMLAPIMCPUnauthorizedHintOnlyForAIMLAPIWithoutOAuth(t *testing.T) {
|
||||
if hint := aimlapiUnauthorizedHint(aimlapiTestServer("docs", "https://mcp.example.com/mcp"), http.StatusUnauthorized); hint != "" {
|
||||
t.Fatalf("hint for another server = %q", hint)
|
||||
}
|
||||
oauth := aimlapiTestServer("aimlapi", aimlapi.MCPServerURL)
|
||||
oauth.Auth = ServerAuthOAuth
|
||||
if hint := aimlapiUnauthorizedHint(oauth, http.StatusUnauthorized); hint != "" {
|
||||
t.Fatalf("hint for an OAuth server = %q", hint)
|
||||
}
|
||||
if hint := aimlapiUnauthorizedHint(aimlapiTestServer("aimlapi", aimlapi.MCPServerURL), http.StatusForbidden); hint != "" {
|
||||
t.Fatalf("hint for 403 = %q", hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIMLAPIMCPKeyDefersToUserAuthorizationOAuthAndProjectConfig(t *testing.T) {
|
||||
isolateAIMLAPIKeyLookup(t, "aiml-secret")
|
||||
|
||||
userHeader := aimlapiTestServer("aimlapi", aimlapi.MCPServerURL)
|
||||
userHeader.Headers = map[string]string{"authorization": "Bearer user-token"}
|
||||
oauth := aimlapiTestServer("aimlapi", aimlapi.MCPServerURL)
|
||||
oauth.Auth = ServerAuthOAuth
|
||||
project := aimlapiTestServer("aimlapi", aimlapi.MCPServerURL)
|
||||
project.ProjectConfigured = true
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
server Server
|
||||
want string
|
||||
}{
|
||||
{"user Authorization header", userHeader, "Bearer user-token"},
|
||||
{"OAuth server", oauth, ""},
|
||||
{"project-configured server", project, ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
recorder := &aimlapiRecorder{}
|
||||
client := newRecordedNetworkClient(tc.server, recorder)
|
||||
if err := client.initialize(context.Background()); err != nil {
|
||||
t.Fatalf("%s: initialize: %v", tc.name, err)
|
||||
}
|
||||
header := recorder.recorded()[0]
|
||||
if got := header.Get("Authorization"); got != tc.want {
|
||||
t.Fatalf("%s: Authorization = %q, want %q", tc.name, got, tc.want)
|
||||
}
|
||||
assertAIMLAPIAttribution(t, header)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIMLAPIMCPSSERequestsCarryKeyAndAttribution(t *testing.T) {
|
||||
isolateAIMLAPIKeyLookup(t, "aiml-secret")
|
||||
server := Server{Name: "aimlapi", Type: ServerTypeSSE, URL: aimlapi.MCPServerURL}
|
||||
client := &remoteSSEClient{server: server}
|
||||
apply := map[string]func(*http.Request){
|
||||
"stream": client.applyStreamHeaders,
|
||||
"post": client.applyPostHeaders,
|
||||
}
|
||||
for name, applyHeaders := range apply {
|
||||
request, err := http.NewRequest(http.MethodPost, aimlapi.MCPServerURL, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
applyHeaders(request)
|
||||
if got := request.Header.Get("Authorization"); got != "Bearer aiml-secret" {
|
||||
t.Fatalf("%s: Authorization = %q, want the key", name, got)
|
||||
}
|
||||
assertAIMLAPIAttribution(t, request.Header)
|
||||
|
||||
other, err := http.NewRequest(http.MethodPost, "https://mcp.aimlapi.com.example.net/mcp", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
applyHeaders(other)
|
||||
if other.Header.Get("Authorization") != "" || other.Header.Get(aimlapi.SourceHeaderName) != "" || other.Header.Get(aimlapi.PartnerHeaderName) != "" {
|
||||
t.Fatalf("%s: lookalike host got AI/ML API headers: %v", name, other.Header)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIMLAPIMCPKeyStaysOutOfServerConfigAndIdentity(t *testing.T) {
|
||||
enabled := config.MCPConfig{Servers: map[string]config.MCPServerConfig{
|
||||
"aimlapi": {Type: "http", URL: aimlapi.MCPServerURL},
|
||||
}}
|
||||
isolateAIMLAPIKeyLookup(t, "")
|
||||
withoutKey, err := NormalizeConfig(enabled)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
isolateAIMLAPIKeyLookup(t, "aiml-secret")
|
||||
withKey, err := NormalizeConfig(enabled)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(withKey) != 1 || withKey[0].Identity != withoutKey[0].Identity {
|
||||
t.Fatalf("server identity depends on the key: %#v vs %#v", withKey, withoutKey)
|
||||
}
|
||||
if strings.Contains(fmt.Sprintf("%#v", withKey), "aiml-secret") {
|
||||
t.Fatalf("normalized server carries the key: %#v", withKey)
|
||||
}
|
||||
|
||||
// The shipped default is off, so it is not even normalized for a session.
|
||||
defaults, err := NormalizeConfig(config.MCPConfig{Servers: config.DefaultMCPServers()})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, server := range defaults {
|
||||
if server.Name == "aimlapi" {
|
||||
t.Fatalf("disabled aimlapi default was normalized for connection: %#v", server)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIMLAPIMCPUnauthorizedHintRespectsCredentialEligibility(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
headers map[string]string
|
||||
project bool
|
||||
wantSetup bool
|
||||
}{
|
||||
{"default", nil, false, true},
|
||||
{"project", nil, true, false},
|
||||
{"custom bearer", map[string]string{"Authorization": "Bearer manual"}, false, false},
|
||||
{"mixed case header", map[string]string{"aUtHoRiZaTiOn": "Bearer manual"}, false, false},
|
||||
{"empty header", map[string]string{"authorization": ""}, false, true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
server := aimlapiTestServer("aimlapi", aimlapi.MCPServerURL)
|
||||
server.Headers = tc.headers
|
||||
server.ProjectConfigured = tc.project
|
||||
hint := aimlapiUnauthorizedHint(server, http.StatusUnauthorized)
|
||||
if strings.Contains(hint, "zero providers setup aimlapi") != tc.wantSetup || !strings.Contains(hint, "zero mcp oauth login aimlapi") {
|
||||
t.Fatalf("incorrect hint: %s", hint)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// net/http copies the first request's headers onto every redirect hop without
|
||||
// calling the header hooks, so the redirect guard has to apply the per-request
|
||||
// rule again: a same-origin hop that fails the exact-URL rule (userinfo) gets
|
||||
// neither the key nor attribution, an eligible hop gets a freshly resolved key,
|
||||
// and a user's own Authorization header still follows as before.
|
||||
func TestAIMLAPIMCPRedirectHopsReapplyAutomaticHeaders(t *testing.T) {
|
||||
isolateAIMLAPIKeyLookup(t, "")
|
||||
userHeader := aimlapiTestServer("aimlapi", aimlapi.MCPServerURL)
|
||||
userHeader.Headers = map[string]string{"authorization": "Bearer user-token"}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
server Server
|
||||
location string
|
||||
wantFirst string
|
||||
wantHop string
|
||||
wantAttribution bool
|
||||
}{
|
||||
{"userinfo hop", aimlapiTestServer("aimlapi", aimlapi.MCPServerURL), "https://[email protected]/mcp", "Bearer aiml-first", "", false},
|
||||
{"eligible hop", aimlapiTestServer("aimlapi", aimlapi.MCPServerURL), "https://mcp.aimlapi.com/mcp/", "Bearer aiml-first", "Bearer aiml-second", true},
|
||||
{"user header hop", userHeader, "https://[email protected]/mcp", "Bearer user-token", "Bearer user-token", false},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
keys := []string{"aiml-first", "aiml-second"}
|
||||
previous := loadAIMLAPIServiceKey
|
||||
loadAIMLAPIServiceKey = func() (string, error) {
|
||||
key := keys[0]
|
||||
if len(keys) > 1 {
|
||||
keys = keys[1:]
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
t.Cleanup(func() { loadAIMLAPIServiceKey = previous })
|
||||
|
||||
recorder := &aimlapiRecorder{redirectFirstTo: tc.location}
|
||||
client := newRecordedNetworkClient(tc.server, recorder)
|
||||
if err := client.initialize(context.Background()); err != nil {
|
||||
t.Fatalf("initialize: %v", err)
|
||||
}
|
||||
requests := recorder.recorded()
|
||||
if len(requests) < 2 || recorder.urls[1] != tc.location {
|
||||
t.Fatalf("redirect was not followed: %v", recorder.urls)
|
||||
}
|
||||
if got := requests[0].Get("Authorization"); got != tc.wantFirst {
|
||||
t.Fatalf("first hop Authorization = %q, want %q", got, tc.wantFirst)
|
||||
}
|
||||
hop := requests[1]
|
||||
// With no key, net/http fills Basic auth from the hop's own userinfo;
|
||||
// only the automatic key must be absent.
|
||||
if got := hop.Get("Authorization"); (tc.wantHop == "" && strings.Contains(got, "aiml-")) || (tc.wantHop != "" && got != tc.wantHop) {
|
||||
t.Fatalf("redirect hop Authorization = %q, want %q", got, tc.wantHop)
|
||||
}
|
||||
if tc.wantAttribution {
|
||||
assertAIMLAPIAttribution(t, hop)
|
||||
} else if hop.Get(aimlapi.SourceHeaderName) != "" || hop.Get(aimlapi.PartnerHeaderName) != "" {
|
||||
t.Fatalf("redirect hop kept attribution headers: %v", hop)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// An error body can echo the key Zero injected; the body is left out of the
|
||||
// error rather than matched, so a key cut by the 1024-byte read limit cannot
|
||||
// leak its prefix either. Requests without the automatic key keep the detail.
|
||||
func TestAIMLAPIMCPStatusErrorOmitsBodyWhenKeyWasInjected(t *testing.T) {
|
||||
const key = "sk-echo-0123456789abcdef"
|
||||
isolateAIMLAPIKeyLookup(t, key)
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
body string
|
||||
}{
|
||||
{"echoed key", `{"error":"invalid credential ` + key + `"}`},
|
||||
{"key across the read limit", strings.Repeat("x", 1020) + key},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
recorder := &aimlapiRecorder{failStatus: http.StatusPaymentRequired, failBody: tc.body}
|
||||
err := newRecordedNetworkClient(aimlapiTestServer("aimlapi", aimlapi.MCPServerURL), recorder).initialize(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("initialize succeeded against a failing server")
|
||||
}
|
||||
if got := recorder.recorded()[0].Get("Authorization"); got != "Bearer "+key {
|
||||
t.Fatalf("request did not carry the injected key: %q", got)
|
||||
}
|
||||
if strings.Contains(err.Error(), key[:4]) || !strings.Contains(err.Error(), "HTTP 402") {
|
||||
t.Fatalf("error = %q, want HTTP 402 without the echoed key", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
userHeader := aimlapiTestServer("aimlapi", aimlapi.MCPServerURL)
|
||||
userHeader.Headers = map[string]string{"Authorization": "Bearer user-token"}
|
||||
recorder := &aimlapiRecorder{failStatus: http.StatusPaymentRequired, failBody: `{"error":"insufficient balance"}`}
|
||||
err := newRecordedNetworkClient(userHeader, recorder).initialize(context.Background())
|
||||
if err == nil || !strings.Contains(err.Error(), "insufficient balance") {
|
||||
t.Fatalf("error without the automatic key = %v, want the response detail", err)
|
||||
}
|
||||
}
|
||||
@@ -427,7 +427,6 @@ func (client *networkClient) applyHeaders(request *http.Request) {
|
||||
if client.sessionID != "" {
|
||||
request.Header.Set("Mcp-Session-Id", client.sessionID)
|
||||
}
|
||||
applyAIMLAPIRequestHeaders(client.server, request)
|
||||
}
|
||||
|
||||
func (client *remoteSSEClient) applyStreamHeaders(request *http.Request) {
|
||||
@@ -435,7 +434,6 @@ func (client *remoteSSEClient) applyStreamHeaders(request *http.Request) {
|
||||
request.Header.Set(key, value)
|
||||
}
|
||||
request.Header.Set("Accept", "text/event-stream")
|
||||
applyAIMLAPIRequestHeaders(client.server, request)
|
||||
}
|
||||
|
||||
func (client *remoteSSEClient) applyPostHeaders(request *http.Request) {
|
||||
@@ -444,7 +442,6 @@ func (client *remoteSSEClient) applyPostHeaders(request *http.Request) {
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("Accept", "application/json")
|
||||
applyAIMLAPIRequestHeaders(client.server, request)
|
||||
}
|
||||
|
||||
func closeResponseBody(errp *error, server Server, body io.Closer) {
|
||||
@@ -610,18 +607,12 @@ func (client *remoteSSEClient) failPending(err error) {
|
||||
}
|
||||
|
||||
func httpStatusError(server Server, response *http.Response) error {
|
||||
hint := aimlapiUnauthorizedHint(server, response.StatusCode)
|
||||
if sentAIMLAPIServiceKey(server, response.Request) {
|
||||
// The body is server text that may echo the key Zero injected, whole or
|
||||
// cut by the read limit, so it is left out rather than matched.
|
||||
return fmt.Errorf("MCP %s server %s returned HTTP %d (response body omitted: the request carried your AI/ML API key)%s", server.Type, server.Name, response.StatusCode, hint)
|
||||
}
|
||||
body, _ := io.ReadAll(io.LimitReader(response.Body, 1024))
|
||||
detail := strings.TrimSpace(string(body))
|
||||
if detail == "" {
|
||||
return fmt.Errorf("MCP %s server %s returned HTTP %d%s", server.Type, server.Name, response.StatusCode, hint)
|
||||
return fmt.Errorf("MCP %s server %s returned HTTP %d", server.Type, server.Name, response.StatusCode)
|
||||
}
|
||||
return fmt.Errorf("MCP %s server %s returned HTTP %d: %s%s", server.Type, server.Name, response.StatusCode, detail, hint)
|
||||
return fmt.Errorf("MCP %s server %s returned HTTP %d: %s", server.Type, server.Name, response.StatusCode, detail)
|
||||
}
|
||||
|
||||
func decodeSSERPCMessage(reader io.Reader) (rpcMessage, error) {
|
||||
|
||||
@@ -28,7 +28,6 @@ func checkMCPRedirect(server Server) func(*http.Request, []*http.Request) error
|
||||
if !sameMCPOrigin(via[0].URL, request.URL) {
|
||||
return fmt.Errorf("MCP %s server %s refused cross-origin redirect to %s", server.Type, server.Name, mcpOrigin(request.URL))
|
||||
}
|
||||
reapplyAIMLAPIRedirectHeaders(server, request, via[0])
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
func ensurePrivateDir(path string) error {
|
||||
func EnsurePrivateDir(path string) error {
|
||||
abs, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
func ensurePrivateDir(path string) error {
|
||||
func EnsurePrivateDir(path string) error {
|
||||
abs, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -22,7 +22,13 @@ func ensurePrivateDir(path string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer unix.Close(parentFD)
|
||||
// THE DESCRIPTOR THIS FUNCTION OWNS CHANGES ON EVERY ITERATION. The walk
|
||||
// closes parentFD and replaces it with the child it just opened, so a defer
|
||||
// that captured the first number would close a descriptor this function no
|
||||
// longer owns (or one the kernel has since reused) and leave the last one it
|
||||
// does own open. Close whatever is current when we leave, exactly once, on
|
||||
// every path.
|
||||
defer func() { unix.Close(parentFD) }()
|
||||
for index, component := range components {
|
||||
if component == "" {
|
||||
continue
|
||||
|
||||
@@ -3,9 +3,12 @@
|
||||
package peermsg
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
func TestEnsurePrivateDirRejectsSymlink(t *testing.T) {
|
||||
@@ -18,7 +21,7 @@ func TestEnsurePrivateDirRejectsSymlink(t *testing.T) {
|
||||
if err := os.Symlink(target, link); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ensurePrivateDir(link); err == nil {
|
||||
if err := EnsurePrivateDir(link); err == nil {
|
||||
t.Fatal("expected symlink runtime directory to be rejected")
|
||||
}
|
||||
}
|
||||
@@ -33,7 +36,67 @@ func TestEnsurePrivateDirRejectsSymlinkedParent(t *testing.T) {
|
||||
if err := os.Symlink(target, link); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ensurePrivateDir(filepath.Join(link, "peers")); err == nil {
|
||||
if err := EnsurePrivateDir(filepath.Join(link, "peers")); err == nil {
|
||||
t.Fatal("expected symlinked parent to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
// THE DESCRIPTOR THE WALK ENDS ON HAS TO BE CLOSED TOO.
|
||||
//
|
||||
// EnsurePrivateDir walks the path one component at a time, closing each parent
|
||||
// as it opens the child. A deferred close that captured the first descriptor
|
||||
// closed a number this function no longer owned and left the last one open, so
|
||||
// every fallback-runtime preparation leaked a descriptor. The lowest free
|
||||
// descriptor is the watermark: dup(0) hands it back, and a leak per cycle walks
|
||||
// it upward. Several depths, because the kernel reuses numbers and a single
|
||||
// shallow fixture can hide the leak by accident.
|
||||
func TestEnsurePrivateDirClosesTheDescriptorItOwns(t *testing.T) {
|
||||
lowestFree := func() int {
|
||||
fd, err := unix.Dup(0)
|
||||
if err != nil {
|
||||
t.Fatalf("dup: %v", err)
|
||||
}
|
||||
unix.Close(fd)
|
||||
return fd
|
||||
}
|
||||
// The physical path: on macOS t.TempDir() sits under /var, which is itself
|
||||
// a symlink to /private/var, and this walk refuses a symlink component by
|
||||
// design. Production resolves its temp root the same way before calling.
|
||||
base, err := filepath.EvalSymlinks(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("resolve temp dir: %v", err)
|
||||
}
|
||||
for _, depth := range []int{1, 3, 6} {
|
||||
parts := []string{base}
|
||||
for i := 0; i < depth; i++ {
|
||||
parts = append(parts, fmt.Sprintf("level%d", i))
|
||||
}
|
||||
target := filepath.Join(parts...)
|
||||
|
||||
before := lowestFree()
|
||||
for cycle := 0; cycle < 40; cycle++ {
|
||||
if err := EnsurePrivateDir(target); err != nil {
|
||||
t.Fatalf("depth %d cycle %d: %v", depth, cycle, err)
|
||||
}
|
||||
}
|
||||
if after := lowestFree(); after != before {
|
||||
t.Fatalf("depth %d: lowest free descriptor moved from %d to %d over 40 successful walks, so each one leaks", depth, before, after)
|
||||
}
|
||||
|
||||
// And the failing walk: a symlink component is refused partway down.
|
||||
linked := filepath.Join(base, fmt.Sprintf("linked%d", depth))
|
||||
if err := os.Symlink(target, linked); err != nil {
|
||||
t.Fatalf("symlink: %v", err)
|
||||
}
|
||||
failing := filepath.Join(linked, "below")
|
||||
before = lowestFree()
|
||||
for cycle := 0; cycle < 40; cycle++ {
|
||||
if err := EnsurePrivateDir(failing); err == nil {
|
||||
t.Fatalf("depth %d: a symlinked component was not refused", depth)
|
||||
}
|
||||
}
|
||||
if after := lowestFree(); after != before {
|
||||
t.Fatalf("depth %d: lowest free descriptor moved from %d to %d over 40 refused walks", depth, before, after)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
func ensurePrivateDir(path string) (resultErr error) {
|
||||
func EnsurePrivateDir(path string) (resultErr error) {
|
||||
abs, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -54,7 +54,7 @@ func TestEnsurePrivateDirAppliesOwnerOnlyProtectedDACL(t *testing.T) {
|
||||
if !windowsDirectoryDACLContains(t, path, worldSID) {
|
||||
t.Fatal("test setup did not grant the broad Everyone ACE")
|
||||
}
|
||||
if err := ensurePrivateDir(path); err != nil {
|
||||
if err := EnsurePrivateDir(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
descriptor, err := windows.GetNamedSecurityInfo(
|
||||
@@ -149,7 +149,7 @@ func windowsDirectoryDACLContains(t *testing.T, path string, wanted *windows.SID
|
||||
|
||||
func TestSecurePrivateDirectoryRejectsOwnerMismatch(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "private")
|
||||
if err := ensurePrivateDir(path); err != nil {
|
||||
if err := EnsurePrivateDir(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
handle, err := openWindowsDirectory(path, windows.FILE_LIST_DIRECTORY|windows.FILE_TRAVERSE|windows.SYNCHRONIZE|windows.READ_CONTROL|windows.WRITE_DAC)
|
||||
@@ -172,7 +172,7 @@ func TestSecurePrivateDirectoryRejectsOwnerMismatch(t *testing.T) {
|
||||
|
||||
func TestSecurePrivateDirectoryReportsDACLWriteFailure(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "private")
|
||||
if err := ensurePrivateDir(path); err != nil {
|
||||
if err := EnsurePrivateDir(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
handle, err := openWindowsDirectory(path, windows.FILE_LIST_DIRECTORY|windows.FILE_TRAVERSE|windows.SYNCHRONIZE|windows.READ_CONTROL)
|
||||
@@ -206,7 +206,7 @@ func TestEnsurePrivateDirRejectsWindowsReparseParent(t *testing.T) {
|
||||
if err := os.Symlink(target, link); err != nil {
|
||||
t.Skipf("cannot create Windows directory symlink: %v", err)
|
||||
}
|
||||
if err := ensurePrivateDir(filepath.Join(link, "peers")); err == nil {
|
||||
if err := EnsurePrivateDir(filepath.Join(link, "peers")); err == nil {
|
||||
t.Fatal("expected reparse-point parent to be rejected")
|
||||
}
|
||||
}
|
||||
@@ -217,7 +217,7 @@ func TestEnsurePrivateDirRejectsWindowsFileComponent(t *testing.T) {
|
||||
if err := os.WriteFile(file, []byte("not a directory"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ensurePrivateDir(filepath.Join(file, "peers")); err == nil {
|
||||
if err := EnsurePrivateDir(filepath.Join(file, "peers")); err == nil {
|
||||
t.Fatal("expected file path component to be rejected")
|
||||
}
|
||||
}
|
||||
@@ -158,7 +158,7 @@ func New(options Options) (*Service, error) {
|
||||
}
|
||||
|
||||
// canonicalRuntimePath normalizes aliases in the existing prefix. It does not
|
||||
// establish a security boundary; ensurePrivateDir validates that boundary when
|
||||
// establish a security boundary; EnsurePrivateDir validates that boundary when
|
||||
// the service starts.
|
||||
func canonicalRuntimePath(path string) (string, error) {
|
||||
missing := make([]string, 0, 4)
|
||||
@@ -219,10 +219,10 @@ func (service *Service) Start(handler Handler) error {
|
||||
if service.closed {
|
||||
return errors.New("peer messaging: service is closed")
|
||||
}
|
||||
if err := ensurePrivateDir(service.root); err != nil {
|
||||
if err := EnsurePrivateDir(service.root); err != nil {
|
||||
return fmt.Errorf("peer messaging: create runtime directory: %w", err)
|
||||
}
|
||||
if err := ensurePrivateDir(service.registryDir()); err != nil {
|
||||
if err := EnsurePrivateDir(service.registryDir()); err != nil {
|
||||
return fmt.Errorf("peer messaging: create registry: %w", err)
|
||||
}
|
||||
endpoint, err := service.transport.Endpoint(service.root, service.nonce, service.pid)
|
||||
|
||||
@@ -44,7 +44,7 @@ func canonicalPrivateDir(path string) (string, error) {
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := ensurePrivateDir(canonical); err != nil {
|
||||
if err := EnsurePrivateDir(canonical); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return canonical, nil
|
||||
|
||||
@@ -374,7 +374,7 @@ func (tool skillTool) Run(_ context.Context, args map[string]any) tools.Result {
|
||||
available := make([]string, 0, len(merged))
|
||||
for _, skill := range merged {
|
||||
if skill.Name == name {
|
||||
return tools.Result{Status: tools.StatusOK, Output: skills.ToolOutput(skill)}
|
||||
return tools.Result{Status: tools.StatusOK, Output: skill.Content}
|
||||
}
|
||||
available = append(available, skill.Name)
|
||||
}
|
||||
|
||||
@@ -673,28 +673,3 @@ func TestExpandPluginRootPathRelative(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The multi-root skill tool is the one the CLI registers, so it has to point at
|
||||
// a skill's bundled files the same way the core tool does (#584).
|
||||
func TestNewSkillToolPointsAtTheSkillsBundledFiles(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("USERPROFILE", home)
|
||||
dir := t.TempDir()
|
||||
writeTestSkill(t, dir, "pdf", "Run scripts/fill.py.")
|
||||
script := filepath.Join(dir, "pdf", "scripts", "fill.py")
|
||||
if err := os.MkdirAll(filepath.Dir(script), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(script, []byte("print('fill')\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got := NewSkillTool(dir, nil).Run(context.Background(), map[string]any{"name": "pdf"})
|
||||
if got.Status != tools.StatusOK {
|
||||
t.Fatalf("Status = %s (output: %s)", got.Status, got.Output)
|
||||
}
|
||||
if !strings.Contains(got.Output, "- scripts/fill.py") || !strings.Contains(got.Output, filepath.Join(dir, "pdf")) {
|
||||
t.Fatalf("output does not point at the bundled script:\n%s", got.Output)
|
||||
}
|
||||
}
|
||||
@@ -1,552 +0,0 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/Twigpine/zero/internal/installtxn"
|
||||
"github.com/Twigpine/zero/internal/pathjail"
|
||||
)
|
||||
|
||||
// SetEnabledResult describes a successful enable/disable of one plugin manifest.
|
||||
type SetEnabledResult struct {
|
||||
ID string `json:"id"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Changed bool `json:"changed"`
|
||||
Source Source `json:"source"`
|
||||
ManifestPath string `json:"manifestPath"`
|
||||
}
|
||||
|
||||
// maxEnabledRelockAttempts bounds how many times SetEnabledByID re-resolves the
|
||||
// plugin after it moves to another install root while waiting for the lock.
|
||||
const maxEnabledRelockAttempts = 4
|
||||
|
||||
// FindByID returns the loaded plugin with the given id after normal discovery
|
||||
// and precedence (project overrides user). A missing id returns ok=false.
|
||||
func FindByID(options LoadOptions, id string) (LoadedPlugin, bool, error) {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
return LoadedPlugin{}, false, errors.New("plugin id is required")
|
||||
}
|
||||
result, err := Load(options)
|
||||
if err != nil {
|
||||
return LoadedPlugin{}, false, err
|
||||
}
|
||||
for _, plugin := range result.Plugins {
|
||||
if plugin.ID == id {
|
||||
return plugin, true, nil
|
||||
}
|
||||
}
|
||||
return LoadedPlugin{}, false, nil
|
||||
}
|
||||
|
||||
// SetEnabledByID finds a plugin by id (respecting LoadOptions) and toggles the
|
||||
// top-level "enabled" field of its plugin.json.
|
||||
//
|
||||
// The read-modify-write is serialized with plugin installs and removals: it
|
||||
// takes the same cross-process installtxn lock they take on the plugin's
|
||||
// install root, runs the same interrupted-transaction recovery they run under
|
||||
// it, re-resolves the plugin under that lock, and publishes the
|
||||
// manifest with an atomic rename while still holding it, so a concurrent
|
||||
// install can never be interleaved between the read and the write. Only the
|
||||
// bytes of the "enabled" value change, so field order, formatting, and numeric
|
||||
// literals in the rest of the manifest survive the toggle.
|
||||
func SetEnabledByID(options LoadOptions, id string, enabled bool) (SetEnabledResult, error) {
|
||||
for attempt := 0; ; attempt++ {
|
||||
plugin, ok, err := FindByID(options, id)
|
||||
if err != nil {
|
||||
return SetEnabledResult{}, err
|
||||
}
|
||||
if !ok {
|
||||
return SetEnabledResult{}, fmt.Errorf("%w: %q", ErrNotInstalled, id)
|
||||
}
|
||||
// Take the install root lock before the final lookup and read so an
|
||||
// install or remove cannot swap the tree between them.
|
||||
unlock, err := installtxn.Lock(plugin.Root)
|
||||
if err != nil {
|
||||
return SetEnabledResult{}, err
|
||||
}
|
||||
// Put back anything an earlier install or removal was killed mid-commit,
|
||||
// and abort if a transaction could not be resolved: editing a tree that is
|
||||
// still owed a restore loses the edit when the next install or removal
|
||||
// puts the backup back over it. See installtxn.Recover.
|
||||
if err := installtxn.Recover(plugin.Root, reconcileInterrupted(plugin.Root)); err != nil {
|
||||
unlock()
|
||||
return SetEnabledResult{}, err
|
||||
}
|
||||
final, ok, err := FindByID(options, id)
|
||||
if err != nil {
|
||||
unlock()
|
||||
return SetEnabledResult{}, err
|
||||
}
|
||||
if !ok {
|
||||
unlock()
|
||||
return SetEnabledResult{}, fmt.Errorf("%w: %q", ErrNotInstalled, id)
|
||||
}
|
||||
if final.Root != plugin.Root {
|
||||
// Precedence moved to another root while this lock was pending;
|
||||
// retry against the root that wins now so the edit still serializes
|
||||
// with that root's installs.
|
||||
unlock()
|
||||
if attempt >= maxEnabledRelockAttempts {
|
||||
return SetEnabledResult{}, fmt.Errorf("plugin %q kept changing roots while waiting for the install lock", id)
|
||||
}
|
||||
continue
|
||||
}
|
||||
changed, err := setEnabledLocked(final, enabled)
|
||||
unlock()
|
||||
if err != nil {
|
||||
return SetEnabledResult{}, err
|
||||
}
|
||||
return SetEnabledResult{
|
||||
ID: final.ID,
|
||||
Enabled: enabled,
|
||||
Changed: changed,
|
||||
Source: final.Source,
|
||||
ManifestPath: final.ManifestPath,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// setEnabledLocked performs the manifest read-modify-write while the caller
|
||||
// holds the installtxn lock for the plugin's install root.
|
||||
//
|
||||
// The lock only serializes cooperating installers; it does not stop the plugin
|
||||
// directory from being swapped after discovery found it. So the whole update
|
||||
// works on one directory, pinned by openPluginDir: the manifest is read and
|
||||
// published, and the tree hashed for the lockfile, through that handle, never
|
||||
// by re-resolving the path.
|
||||
func setEnabledLocked(plugin LoadedPlugin, enabled bool) (bool, error) {
|
||||
pinned, err := openPluginDir(plugin)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer pinned.Close()
|
||||
return setEnabledPinned(plugin, pinned, enabled)
|
||||
}
|
||||
|
||||
// setEnabledPinned is setEnabledLocked on an already pinned plugin directory.
|
||||
//
|
||||
// The edit lands in the pinned directory even if the path now names another
|
||||
// one, so before refreshing the lockfile or reporting any result it confirms
|
||||
// that the directory entry still is the pinned directory. Otherwise the
|
||||
// command would report a state that the installed plugin does not have.
|
||||
func setEnabledPinned(plugin LoadedPlugin, pinned *pinnedPluginDir, enabled bool) (bool, error) {
|
||||
manifestName := filepath.Base(plugin.ManifestPath)
|
||||
raw, perm, err := readManifest(pinned.dir, manifestName)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
data, changed, err := toggleEnabledManifest(raw, enabled)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !changed {
|
||||
if err := pinned.verifyLive(); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Read the lockfile before the edit so the refresh below only runs when the
|
||||
// installed tree still matched its recorded hash; a toggle must never mask
|
||||
// pre-existing drift.
|
||||
lock, entry, hashClean := cleanLockEntry(plugin, pinned.dir)
|
||||
|
||||
if err := publishManifest(pinned.dir, manifestName, data, perm); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !hashClean {
|
||||
if err := pinned.verifyLive(); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
after, err := hashRootedTree(pinned.dir)
|
||||
if err != nil {
|
||||
return true, fmt.Errorf("plugin manifest updated but %s hash refresh failed: %w", LockFileName, err)
|
||||
}
|
||||
if err := pinned.verifyLive(); err != nil {
|
||||
return false, err
|
||||
}
|
||||
entry.Hash = after
|
||||
lock[plugin.ID] = entry
|
||||
if err := writeLock(plugin.Root, lock); err != nil {
|
||||
return true, fmt.Errorf("plugin manifest updated but %s hash refresh failed: %w", LockFileName, err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// errPluginDirReplaced reports that the plugin's directory entry no longer
|
||||
// names the directory the toggle pinned and edited.
|
||||
var errPluginDirReplaced = errors.New("plugin directory was replaced while it was being updated; the plugin installed there now was not changed")
|
||||
|
||||
// pinnedPluginDir is the plugin directory discovery found, held open for the
|
||||
// length of one toggle.
|
||||
type pinnedPluginDir struct {
|
||||
root *os.Root // the plugin's install root
|
||||
name string // the plugin directory's entry name in root
|
||||
path string // the plugin directory's path, for messages
|
||||
dir *os.Root // the plugin directory itself
|
||||
}
|
||||
|
||||
// Close releases both handles.
|
||||
func (p *pinnedPluginDir) Close() {
|
||||
_ = p.dir.Close()
|
||||
_ = p.root.Close()
|
||||
}
|
||||
|
||||
// verifyLive confirms that the plugin's entry in its install root is still a
|
||||
// real directory, not a symlink, junction, or other reparse point, and is the
|
||||
// very directory p.dir holds open.
|
||||
func (p *pinnedPluginDir) verifyLive() error {
|
||||
opened, err := p.dir.Stat(".")
|
||||
if err != nil {
|
||||
return fmt.Errorf("inspect plugin directory: %w", err)
|
||||
}
|
||||
entry, err := p.root.Lstat(p.name)
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return fmt.Errorf("%w: %s", errPluginDirReplaced, p.path)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("inspect plugin directory: %w", err)
|
||||
}
|
||||
if entry.Mode()&(os.ModeSymlink|os.ModeIrregular) != 0 {
|
||||
return fmt.Errorf("%w: plugin directory %s", pathjail.ErrReparse, p.path)
|
||||
}
|
||||
if !os.SameFile(entry, opened) {
|
||||
return fmt.Errorf("%w: %s", errPluginDirReplaced, p.path)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// openPluginDir pins the plugin directory discovery found, confined to the
|
||||
// plugin's install root.
|
||||
//
|
||||
// A link swapped in before the open is refused here. One swapped in after it
|
||||
// cannot redirect anything, because every later read, write, and hash is
|
||||
// relative to the returned handle rather than to a path; verifyLive then
|
||||
// detects that the path no longer names the edited directory.
|
||||
func openPluginDir(plugin LoadedPlugin) (*pinnedPluginDir, error) {
|
||||
root, name, err := pathjail.Open(plugin.Root, plugin.PluginDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open plugin directory: %w", err)
|
||||
}
|
||||
if name == "." || filepath.Base(name) != name {
|
||||
_ = root.Close()
|
||||
return nil, fmt.Errorf("%w: plugin directory %s is not an entry of %s", pathjail.ErrEscapes, plugin.PluginDir, plugin.Root)
|
||||
}
|
||||
if err := pathjail.RefuseReparse(root, name); err != nil {
|
||||
_ = root.Close()
|
||||
return nil, fmt.Errorf("open plugin directory: %w", err)
|
||||
}
|
||||
dir, err := root.OpenRoot(name)
|
||||
if err != nil {
|
||||
_ = root.Close()
|
||||
return nil, fmt.Errorf("open plugin directory: %w", err)
|
||||
}
|
||||
pinned := &pinnedPluginDir{root: root, name: name, path: plugin.PluginDir, dir: dir}
|
||||
if err := pinned.verifyLive(); err != nil {
|
||||
pinned.Close()
|
||||
return nil, err
|
||||
}
|
||||
return pinned, nil
|
||||
}
|
||||
|
||||
// readManifest reads name from dir and returns its bytes and permission bits.
|
||||
// Failing on a stat error keeps the publish from widening the permissions.
|
||||
func readManifest(dir *os.Root, name string) ([]byte, os.FileMode, error) {
|
||||
file, err := dir.Open(name)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("read plugin manifest: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
info, err := file.Stat()
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("inspect plugin manifest: %w", err)
|
||||
}
|
||||
raw, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("read plugin manifest: %w", err)
|
||||
}
|
||||
return raw, info.Mode().Perm(), nil
|
||||
}
|
||||
|
||||
// publishManifest replaces name in dir with data by renaming a complete
|
||||
// temporary sibling over it, so a concurrent reader sees either the old
|
||||
// manifest or the new one, never a partial write.
|
||||
func publishManifest(dir *os.Root, name string, data []byte, perm os.FileMode) error {
|
||||
temp, tempName, err := pathjail.CreateTemp(dir, ".", ".zero-manifest", "")
|
||||
if err != nil {
|
||||
return fmt.Errorf("write plugin manifest: %w", err)
|
||||
}
|
||||
defer func() { _ = dir.Remove(tempName) }()
|
||||
if err := temp.Chmod(perm); err != nil {
|
||||
_ = temp.Close()
|
||||
return fmt.Errorf("write plugin manifest: %w", err)
|
||||
}
|
||||
if _, err := temp.Write(data); err != nil {
|
||||
_ = temp.Close()
|
||||
return fmt.Errorf("write plugin manifest: %w", err)
|
||||
}
|
||||
if err := temp.Sync(); err != nil {
|
||||
_ = temp.Close()
|
||||
return fmt.Errorf("write plugin manifest: %w", err)
|
||||
}
|
||||
if err := temp.Close(); err != nil {
|
||||
return fmt.Errorf("write plugin manifest: %w", err)
|
||||
}
|
||||
if err := dir.Rename(tempName, name); err != nil {
|
||||
return fmt.Errorf("write plugin manifest: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// cleanLockEntry reports whether the pinned plugin directory dir still matches
|
||||
// the lockfile entry recorded for the plugin and returns both for a refresh.
|
||||
func cleanLockEntry(plugin LoadedPlugin, dir *os.Root) (map[string]LockEntry, LockEntry, bool) {
|
||||
lock, err := ReadLock(plugin.Root)
|
||||
if err != nil {
|
||||
return nil, LockEntry{}, false
|
||||
}
|
||||
entry, ok := lock[plugin.ID]
|
||||
if !ok {
|
||||
return nil, LockEntry{}, false
|
||||
}
|
||||
current, err := hashRootedTree(dir)
|
||||
if err != nil || current != entry.Hash {
|
||||
return nil, LockEntry{}, false
|
||||
}
|
||||
return lock, entry, true
|
||||
}
|
||||
|
||||
// toggleEnabledManifest returns raw with the top-level "enabled" member set to
|
||||
// enabled. A missing or null value counts as enabled, matching the loader's
|
||||
// optionalBool fallback. changed reports whether the result differs from raw.
|
||||
func toggleEnabledManifest(raw []byte, enabled bool) (out []byte, changed bool, err error) {
|
||||
dec := json.NewDecoder(bytes.NewReader(raw))
|
||||
openTok, err := dec.Token()
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("parse plugin manifest: %w", err)
|
||||
}
|
||||
if delim, ok := openTok.(json.Delim); !ok || delim != '{' {
|
||||
return nil, false, errors.New("plugin manifest must be a JSON object")
|
||||
}
|
||||
openEnd := dec.InputOffset()
|
||||
|
||||
current := true
|
||||
lastInvalid := false
|
||||
var valueStart, valueEnd int64 = -1, -1
|
||||
for dec.More() {
|
||||
keyTok, err := dec.Token()
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("parse plugin manifest: %w", err)
|
||||
}
|
||||
key, ok := keyTok.(string)
|
||||
if !ok {
|
||||
return nil, false, errors.New("plugin manifest must be a JSON object")
|
||||
}
|
||||
keyEnd := dec.InputOffset()
|
||||
if key != "enabled" {
|
||||
if err := skipManifestValue(dec); err != nil {
|
||||
return nil, false, fmt.Errorf("parse plugin manifest: %w", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
valueTok, err := dec.Token()
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("parse plugin manifest: %w", err)
|
||||
}
|
||||
var literal string
|
||||
switch value := valueTok.(type) {
|
||||
case bool:
|
||||
current = value
|
||||
literal = "false"
|
||||
if value {
|
||||
literal = "true"
|
||||
}
|
||||
case nil:
|
||||
current = true
|
||||
literal = "null"
|
||||
default:
|
||||
// Only the last occurrence of a duplicate key counts, as in the
|
||||
// loader's json.Unmarshal, so an earlier non-boolean value that a
|
||||
// later one overwrites is not an error. Remember it and decide after
|
||||
// the last member.
|
||||
if err := skipManifestComposite(dec, valueTok); err != nil {
|
||||
return nil, false, fmt.Errorf("parse plugin manifest: %w", err)
|
||||
}
|
||||
lastInvalid = true
|
||||
continue
|
||||
}
|
||||
lastInvalid = false
|
||||
start, err := enabledValueOffset(raw, keyEnd)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if start+int64(len(literal)) > int64(len(raw)) ||
|
||||
!bytes.Equal(raw[start:start+int64(len(literal))], []byte(literal)) {
|
||||
return nil, false, errors.New("parse plugin manifest: cannot locate the enabled value")
|
||||
}
|
||||
// A duplicate key keeps the last occurrence, matching json.Unmarshal.
|
||||
valueStart, valueEnd = start, start+int64(len(literal))
|
||||
}
|
||||
closingTok, err := dec.Token()
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("parse plugin manifest: %w", err)
|
||||
}
|
||||
if delim, ok := closingTok.(json.Delim); !ok || delim != '}' {
|
||||
return nil, false, errors.New("plugin manifest must be a JSON object")
|
||||
}
|
||||
closingEnd := dec.InputOffset()
|
||||
if _, err := dec.Token(); err != io.EOF {
|
||||
return nil, false, errors.New("parse plugin manifest: unexpected data after the top-level object")
|
||||
}
|
||||
if lastInvalid {
|
||||
return nil, false, ManifestError{FieldPath: "enabled", Message: "Expected a boolean."}
|
||||
}
|
||||
|
||||
if current == enabled {
|
||||
return nil, false, nil
|
||||
}
|
||||
newLiteral := "false"
|
||||
if enabled {
|
||||
newLiteral = "true"
|
||||
}
|
||||
if valueStart >= 0 {
|
||||
out := make([]byte, 0, len(raw)-int(valueEnd-valueStart)+len(newLiteral))
|
||||
out = append(out, raw[:valueStart]...)
|
||||
out = append(out, newLiteral...)
|
||||
out = append(out, raw[valueEnd:]...)
|
||||
return out, true, nil
|
||||
}
|
||||
return insertEnabledMember(raw, openEnd, closingEnd-1, newLiteral), true, nil
|
||||
}
|
||||
|
||||
// enabledValueOffset returns the byte offset of the value that follows the
|
||||
// colon after an "enabled" key token that ended at keyEnd.
|
||||
func enabledValueOffset(raw []byte, keyEnd int64) (int64, error) {
|
||||
i := keyEnd
|
||||
for i < int64(len(raw)) && isJSONSpace(raw[i]) {
|
||||
i++
|
||||
}
|
||||
if i >= int64(len(raw)) || raw[i] != ':' {
|
||||
return 0, errors.New("parse plugin manifest: missing colon after key")
|
||||
}
|
||||
i++
|
||||
for i < int64(len(raw)) && isJSONSpace(raw[i]) {
|
||||
i++
|
||||
}
|
||||
return i, nil
|
||||
}
|
||||
|
||||
// insertEnabledMember adds a top-level "enabled" member to a manifest that has
|
||||
// none, mirroring the document's own whitespace style where it can. openEnd is
|
||||
// the byte offset just after the opening brace and bracePos is the byte index
|
||||
// of the closing brace.
|
||||
func insertEnabledMember(raw []byte, openEnd, bracePos int64, literal string) []byte {
|
||||
insertPos := bracePos
|
||||
for insertPos > openEnd && isJSONSpace(raw[insertPos-1]) {
|
||||
insertPos--
|
||||
}
|
||||
|
||||
newline := "\n"
|
||||
if bytes.Contains(raw, []byte("\r\n")) {
|
||||
newline = "\r\n"
|
||||
}
|
||||
|
||||
first := openEnd
|
||||
for first < bracePos && isJSONSpace(raw[first]) {
|
||||
first++
|
||||
}
|
||||
empty := first >= bracePos
|
||||
multiline := bytes.IndexByte(raw[openEnd:bracePos], '\n') >= 0
|
||||
|
||||
member := `"enabled": ` + literal
|
||||
var insert string
|
||||
switch {
|
||||
case empty && !multiline:
|
||||
insert = member
|
||||
case empty && multiline:
|
||||
insert = newline + closingLineIndent(raw, openEnd, bracePos) + " " + member
|
||||
case !empty && !multiline:
|
||||
insert = "," + member
|
||||
default:
|
||||
insert = "," + newline + memberLineIndent(raw, openEnd, first) + member
|
||||
}
|
||||
|
||||
out := make([]byte, 0, len(raw)+len(insert))
|
||||
out = append(out, raw[:insertPos]...)
|
||||
out = append(out, insert...)
|
||||
out = append(out, raw[insertPos:]...)
|
||||
return out
|
||||
}
|
||||
|
||||
// memberLineIndent returns the indentation of the line the first member of a
|
||||
// JSON object starts on. first is the byte index of that member's first token.
|
||||
func memberLineIndent(raw []byte, openEnd, first int64) string {
|
||||
lineStart := first
|
||||
for lineStart > openEnd && raw[lineStart-1] != '\n' {
|
||||
lineStart--
|
||||
}
|
||||
end := lineStart
|
||||
for end < first && (raw[end] == ' ' || raw[end] == '\t') {
|
||||
end++
|
||||
}
|
||||
return string(raw[lineStart:end])
|
||||
}
|
||||
|
||||
// closingLineIndent returns the indentation before an object's closing brace.
|
||||
func closingLineIndent(raw []byte, openEnd, bracePos int64) string {
|
||||
start := bracePos
|
||||
for start > openEnd && (raw[start-1] == ' ' || raw[start-1] == '\t') {
|
||||
start--
|
||||
}
|
||||
return string(raw[start:bracePos])
|
||||
}
|
||||
|
||||
// skipManifestValue consumes the next JSON value from dec.
|
||||
func skipManifestValue(dec *json.Decoder) error {
|
||||
tok, err := dec.Token()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return skipManifestComposite(dec, tok)
|
||||
}
|
||||
|
||||
// skipManifestComposite consumes the rest of the value whose first token tok
|
||||
// was just read from dec. A scalar is already complete; an object or array is
|
||||
// consumed through its matching closing delimiter.
|
||||
func skipManifestComposite(dec *json.Decoder, tok json.Token) error {
|
||||
delim, ok := tok.(json.Delim)
|
||||
if !ok || (delim != '{' && delim != '[') {
|
||||
return nil
|
||||
}
|
||||
depth := 1
|
||||
for depth > 0 {
|
||||
tok, err := dec.Token()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if inner, ok := tok.(json.Delim); ok {
|
||||
switch inner {
|
||||
case '{', '[':
|
||||
depth++
|
||||
case '}', ']':
|
||||
depth--
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isJSONSpace(b byte) bool {
|
||||
return b == ' ' || b == '\t' || b == '\n' || b == '\r'
|
||||
}
|
||||
@@ -1,999 +0,0 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Twigpine/zero/internal/installtxn"
|
||||
"github.com/Twigpine/zero/internal/pathjail"
|
||||
)
|
||||
|
||||
func enableTestOptions(root string) LoadOptions {
|
||||
return LoadOptions{Roots: []Root{{Source: SourceUser, Path: root}}}
|
||||
}
|
||||
|
||||
func writeRawManifest(t *testing.T, root, dir, raw string) string {
|
||||
t.Helper()
|
||||
pluginDir := filepath.Join(root, dir)
|
||||
if err := os.MkdirAll(pluginDir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
manifestPath := filepath.Join(pluginDir, "plugin.json")
|
||||
if err := os.WriteFile(manifestPath, []byte(raw), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return manifestPath
|
||||
}
|
||||
|
||||
func demoManifest(enabled bool) string {
|
||||
state := "false"
|
||||
if enabled {
|
||||
state = "true"
|
||||
}
|
||||
return `{
|
||||
"schemaVersion": 1,
|
||||
"id": "zero.demo",
|
||||
"name": "Demo",
|
||||
"version": "1.0.0",
|
||||
"enabled": ` + state + `
|
||||
}
|
||||
`
|
||||
}
|
||||
|
||||
func TestSetEnabledByIDPreservesManifestBytes(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
raw := `{
|
||||
"schemaVersion": 1,
|
||||
"id": "zero.demo",
|
||||
"name": "Demo",
|
||||
"version": "1.0.0",
|
||||
"limits": {
|
||||
"maxBytes": 9007199254740993
|
||||
},
|
||||
"description": "keep me",
|
||||
"enabled": true,
|
||||
"tools": []
|
||||
}
|
||||
`
|
||||
manifestPath := writeRawManifest(t, root, "demo", raw)
|
||||
|
||||
result, err := SetEnabledByID(enableTestOptions(root), "zero.demo", false)
|
||||
if err != nil {
|
||||
t.Fatalf("SetEnabledByID(false): %v", err)
|
||||
}
|
||||
if !result.Changed || result.Enabled {
|
||||
t.Fatalf("unexpected result: %#v", result)
|
||||
}
|
||||
|
||||
disabled, err := os.ReadFile(manifestPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := strings.Replace(raw, `"enabled": true`, `"enabled": false`, 1)
|
||||
if string(disabled) != want {
|
||||
t.Fatalf("toggle changed more than the enabled value:\n got %q\nwant %q", disabled, want)
|
||||
}
|
||||
if !strings.Contains(string(disabled), "9007199254740993") {
|
||||
t.Fatal("large integer literal was rounded by the toggle")
|
||||
}
|
||||
|
||||
plugin, ok, err := FindByID(enableTestOptions(root), "zero.demo")
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("FindByID after disable: ok=%v err=%v", ok, err)
|
||||
}
|
||||
if plugin.Enabled {
|
||||
t.Fatal("loader still reports the plugin enabled after disable")
|
||||
}
|
||||
|
||||
result, err = SetEnabledByID(enableTestOptions(root), "zero.demo", true)
|
||||
if err != nil {
|
||||
t.Fatalf("SetEnabledByID(true): %v", err)
|
||||
}
|
||||
if !result.Changed || !result.Enabled {
|
||||
t.Fatalf("unexpected enable result: %#v", result)
|
||||
}
|
||||
enabled, err := os.ReadFile(manifestPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(enabled) != raw {
|
||||
t.Fatalf("round trip did not restore the original bytes:\n got %q\nwant %q", enabled, raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetEnabledByIDTogglesManifestAndIsIdempotent(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writePluginManifest(t, filepath.Join(root, "demo"), map[string]any{
|
||||
"schemaVersion": 1,
|
||||
"id": "zero.demo",
|
||||
"name": "Demo",
|
||||
"version": "1.0.0",
|
||||
"description": "keep me",
|
||||
"tools": []any{},
|
||||
})
|
||||
manifestPath := filepath.Join(root, "demo", "plugin.json")
|
||||
|
||||
result, err := SetEnabledByID(enableTestOptions(root), "zero.demo", false)
|
||||
if err != nil {
|
||||
t.Fatalf("SetEnabledByID(false): %v", err)
|
||||
}
|
||||
if !result.Changed {
|
||||
t.Fatal("expected first disable to change the manifest")
|
||||
}
|
||||
raw, err := os.ReadFile(manifestPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(raw), `"enabled": false`) {
|
||||
t.Fatalf("manifest not disabled: %s", raw)
|
||||
}
|
||||
if !strings.Contains(string(raw), `"keep me"`) {
|
||||
t.Fatalf("description was not preserved: %s", raw)
|
||||
}
|
||||
|
||||
result, err = SetEnabledByID(enableTestOptions(root), "zero.demo", false)
|
||||
if err != nil {
|
||||
t.Fatalf("SetEnabledByID(false) again: %v", err)
|
||||
}
|
||||
if result.Changed {
|
||||
t.Fatal("expected second disable to be a no-op")
|
||||
}
|
||||
|
||||
result, err = SetEnabledByID(enableTestOptions(root), "zero.demo", true)
|
||||
if err != nil {
|
||||
t.Fatalf("SetEnabledByID(true): %v", err)
|
||||
}
|
||||
if !result.Changed {
|
||||
t.Fatal("expected enable to change the manifest")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetEnabledByIDInsertsMissingField(t *testing.T) {
|
||||
t.Run("pretty manifest", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
raw := `{
|
||||
"schemaVersion": 1,
|
||||
"id": "zero.demo",
|
||||
"name": "Demo",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
`
|
||||
manifestPath := writeRawManifest(t, root, "demo", raw)
|
||||
|
||||
if _, err := SetEnabledByID(enableTestOptions(root), "zero.demo", false); err != nil {
|
||||
t.Fatalf("SetEnabledByID(false): %v", err)
|
||||
}
|
||||
disabled, err := os.ReadFile(manifestPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantDisabled := `{
|
||||
"schemaVersion": 1,
|
||||
"id": "zero.demo",
|
||||
"name": "Demo",
|
||||
"version": "1.0.0",
|
||||
"enabled": false
|
||||
}
|
||||
`
|
||||
if string(disabled) != wantDisabled {
|
||||
t.Fatalf("after insert:\n got %q\nwant %q", disabled, wantDisabled)
|
||||
}
|
||||
|
||||
if _, err := SetEnabledByID(enableTestOptions(root), "zero.demo", true); err != nil {
|
||||
t.Fatalf("SetEnabledByID(true): %v", err)
|
||||
}
|
||||
enabled, err := os.ReadFile(manifestPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantEnabled := strings.Replace(wantDisabled, `"enabled": false`, `"enabled": true`, 1)
|
||||
if string(enabled) != wantEnabled {
|
||||
t.Fatalf("after replace:\n got %q\nwant %q", enabled, wantEnabled)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("compact manifest", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
raw := `{"schemaVersion":1,"id":"zero.demo","name":"Demo","version":"1.0.0"}`
|
||||
manifestPath := writeRawManifest(t, root, "demo", raw)
|
||||
|
||||
if _, err := SetEnabledByID(enableTestOptions(root), "zero.demo", false); err != nil {
|
||||
t.Fatalf("SetEnabledByID(false): %v", err)
|
||||
}
|
||||
disabled, err := os.ReadFile(manifestPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := `{"schemaVersion":1,"id":"zero.demo","name":"Demo","version":"1.0.0","enabled": false}`
|
||||
if string(disabled) != want {
|
||||
t.Fatalf("compact insert:\n got %q\nwant %q", disabled, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("null value", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
raw := `{
|
||||
"schemaVersion": 1,
|
||||
"id": "zero.demo",
|
||||
"name": "Demo",
|
||||
"version": "1.0.0",
|
||||
"enabled": null
|
||||
}
|
||||
`
|
||||
manifestPath := writeRawManifest(t, root, "demo", raw)
|
||||
|
||||
result, err := SetEnabledByID(enableTestOptions(root), "zero.demo", true)
|
||||
if err != nil {
|
||||
t.Fatalf("SetEnabledByID(true): %v", err)
|
||||
}
|
||||
if result.Changed {
|
||||
t.Fatal("null counts as enabled, so enabling must be a no-op")
|
||||
}
|
||||
unchanged, err := os.ReadFile(manifestPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(unchanged) != raw {
|
||||
t.Fatalf("no-op toggle rewrote the manifest: %q", unchanged)
|
||||
}
|
||||
|
||||
if _, err := SetEnabledByID(enableTestOptions(root), "zero.demo", false); err != nil {
|
||||
t.Fatalf("SetEnabledByID(false): %v", err)
|
||||
}
|
||||
disabled, err := os.ReadFile(manifestPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := strings.Replace(raw, `"enabled": null`, `"enabled": false`, 1)
|
||||
if string(disabled) != want {
|
||||
t.Fatalf("null replacement:\n got %q\nwant %q", disabled, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestToggleEnabledManifestFormattingEdges(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
enabled bool
|
||||
want string
|
||||
changed bool
|
||||
}{
|
||||
{"empty object", `{}`, false, `{"enabled": false}`, true},
|
||||
{"empty object with space", `{ }`, false, `{"enabled": false }`, true},
|
||||
{"empty multiline", "{\n}", false, "{\n \"enabled\": false\n}", true},
|
||||
{"compact members", `{"a":1}`, false, `{"a":1,"enabled": false}`, true},
|
||||
{"crlf members", "{\r\n \"a\": 1\r\n}", false, "{\r\n \"a\": 1,\r\n \"enabled\": false\r\n}", true},
|
||||
{"nested enabled untouched", `{"nested":{"enabled":true},"a":2}`, false, `{"nested":{"enabled":true},"a":2,"enabled": false}`, true},
|
||||
{"replace with true", `{"enabled": false}`, true, `{"enabled": true}`, true},
|
||||
{"replace with false", `{"enabled":true}`, false, `{"enabled":false}`, true},
|
||||
{"null to false", `{"enabled": null}`, false, `{"enabled": false}`, true},
|
||||
{"null to true is a no-op", `{"enabled": null}`, true, `{"enabled": null}`, false},
|
||||
{"already enabled is a no-op", `{"enabled": true}`, true, `{"enabled": true}`, false},
|
||||
// The loader keeps the last duplicate, so an overwritten non-boolean
|
||||
// value is not an error and only the last value is rewritten.
|
||||
{"overwritten string", `{"enabled":"obsolete","enabled":true}`, false, `{"enabled":"obsolete","enabled":false}`, true},
|
||||
{"overwritten composite", `{"enabled":{"a":[1,{"enabled":2}]},"enabled":null}`, false, `{"enabled":{"a":[1,{"enabled":2}]},"enabled":false}`, true},
|
||||
{"overwritten number is a no-op", `{"enabled":1,"enabled":false}`, false, `{"enabled":1,"enabled":false}`, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
out, changed, err := toggleEnabledManifest([]byte(tc.in), tc.enabled)
|
||||
if err != nil {
|
||||
t.Fatalf("toggleEnabledManifest: %v", err)
|
||||
}
|
||||
if changed != tc.changed {
|
||||
t.Fatalf("changed = %v, want %v", changed, tc.changed)
|
||||
}
|
||||
if !tc.changed {
|
||||
if out != nil {
|
||||
t.Fatalf("no-op returned bytes: %q", out)
|
||||
}
|
||||
return
|
||||
}
|
||||
if string(out) != tc.want {
|
||||
t.Fatalf("got %q, want %q", out, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestToggleEnabledManifestRejectsMalformedInput(t *testing.T) {
|
||||
for _, raw := range []string{`{"enabled": "yes"}`, `{"enabled": 1}`, `{"enabled": []}`, `{"enabled": {}}`, `{"enabled": true, "enabled": "yes"}`} {
|
||||
t.Run("value "+raw, func(t *testing.T) {
|
||||
_, _, err := toggleEnabledManifest([]byte(raw), false)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
var manifestErr ManifestError
|
||||
if !errors.As(err, &manifestErr) {
|
||||
t.Fatalf("error = %v, want ManifestError", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "enabled") {
|
||||
t.Fatalf("error = %v, want it to name the enabled field", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
for _, raw := range []string{``, `{`, `[]`, `null`, `"str"`, `{} {}`, `{}{}`} {
|
||||
t.Run("document "+raw, func(t *testing.T) {
|
||||
if _, _, err := toggleEnabledManifest([]byte(raw), false); err == nil {
|
||||
t.Fatalf("toggleEnabledManifest(%q) succeeded, want error", raw)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetEnabledByIDHonorsTheLastDuplicateEnabledValue keeps the toggle in step
|
||||
// with the loader on a manifest whose earlier "enabled" value is overwritten:
|
||||
// the loader discovers the plugin from the last value, so the toggle must be
|
||||
// able to change it too.
|
||||
func TestSetEnabledByIDHonorsTheLastDuplicateEnabledValue(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeRawManifest(t, root, "demo", `{
|
||||
"schemaVersion": 1,
|
||||
"id": "zero.demo",
|
||||
"name": "Demo",
|
||||
"version": "1.0.0",
|
||||
"enabled": "obsolete",
|
||||
"enabled": true
|
||||
}
|
||||
`)
|
||||
loadedEnabled := func() bool {
|
||||
t.Helper()
|
||||
plugin, ok, err := FindByID(enableTestOptions(root), "zero.demo")
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("FindByID: ok=%v err=%v", ok, err)
|
||||
}
|
||||
return plugin.Enabled
|
||||
}
|
||||
if !loadedEnabled() {
|
||||
t.Fatal("the loader should discover the plugin as enabled from the last value")
|
||||
}
|
||||
for _, enabled := range []bool{false, true} {
|
||||
result, err := SetEnabledByID(enableTestOptions(root), "zero.demo", enabled)
|
||||
if err != nil {
|
||||
t.Fatalf("SetEnabledByID(%v): %v", enabled, err)
|
||||
}
|
||||
if !result.Changed {
|
||||
t.Fatalf("SetEnabledByID(%v) reported no change", enabled)
|
||||
}
|
||||
if got := loadedEnabled(); got != enabled {
|
||||
t.Fatalf("after SetEnabledByID(%v) the loader sees enabled=%v", enabled, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetEnabledRefusesASwappedPluginDirectory swaps the plugin directory that
|
||||
// discovery found for a link between the lookup and the edit. The install lock
|
||||
// does not cover that window, because it only serializes cooperating
|
||||
// installers. The edit must fail without following the link: neither the
|
||||
// link's target nor the moved-away original may change.
|
||||
func TestSetEnabledRefusesASwappedPluginDirectory(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
// link returns the symlink target to plant and the manifest it leads to.
|
||||
link func(t *testing.T, root string) (target string, manifest string)
|
||||
}{
|
||||
{"to a directory outside the plugins root", func(t *testing.T, root string) (string, string) {
|
||||
manifest := writeRawManifest(t, t.TempDir(), "elsewhere", demoManifest(true))
|
||||
return filepath.Dir(manifest), manifest
|
||||
}},
|
||||
{"to another plugin in the same root", func(t *testing.T, root string) (string, string) {
|
||||
// A relative link stays inside the root, so confinement to the
|
||||
// root alone would follow it.
|
||||
manifest := writeRawManifest(t, root, "other", strings.Replace(demoManifest(true), "zero.demo", "zero.other", 1))
|
||||
return "other", manifest
|
||||
}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
raw := demoManifest(true)
|
||||
writeRawManifest(t, root, "demo", raw)
|
||||
plugin, ok, err := FindByID(enableTestOptions(root), "zero.demo")
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("FindByID: ok=%v err=%v", ok, err)
|
||||
}
|
||||
target, targetManifest := tc.link(t, root)
|
||||
targetBefore, err := os.ReadFile(targetManifest)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
moved := filepath.Join(t.TempDir(), "moved-away")
|
||||
if err := os.Rename(plugin.PluginDir, moved); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(target, plugin.PluginDir); err != nil {
|
||||
t.Skipf("cannot create a directory symlink here: %v", err)
|
||||
}
|
||||
|
||||
if _, err := setEnabledLocked(plugin, false); !errors.Is(err, pathjail.ErrReparse) {
|
||||
t.Fatalf("setEnabledLocked through a swapped directory: err = %v, want %v", err, pathjail.ErrReparse)
|
||||
}
|
||||
if after, err := os.ReadFile(targetManifest); err != nil || string(after) != string(targetBefore) {
|
||||
t.Fatalf("the edit followed the link and changed %s (err %v):\n got %q\nwant %q", targetManifest, err, after, targetBefore)
|
||||
}
|
||||
if after, err := os.ReadFile(filepath.Join(moved, "plugin.json")); err != nil || string(after) != raw {
|
||||
t.Fatalf("the moved-away manifest changed (err %v): %q", err, after)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetEnabledDetectsAPluginDirectoryReplacedAfterOpening replaces the live
|
||||
// plugin directory after the toggle pinned it. The edit then lands in the
|
||||
// moved-away directory, so the toggle must not refresh the lockfile from either
|
||||
// tree and must not report success: the plugin installed at the path now is
|
||||
// unchanged.
|
||||
func TestSetEnabledDetectsAPluginDirectoryReplacedAfterOpening(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
pinned bool // the pinned manifest's enabled value
|
||||
changes bool // whether disabling it is a real edit
|
||||
}{
|
||||
{"edit", true, true},
|
||||
{"no-op", false, false},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
manifestPath := writeRawManifest(t, root, "demo", demoManifest(tc.pinned))
|
||||
pluginDir := filepath.Dir(manifestPath)
|
||||
hash, err := hashTree(pluginDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := writeLock(root, map[string]LockEntry{"zero.demo": {Source: "./demo", Hash: hash}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lockPath := filepath.Join(root, LockFileName)
|
||||
lockBefore, err := os.ReadFile(lockPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
plugin, ok, err := FindByID(enableTestOptions(root), "zero.demo")
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("FindByID: ok=%v err=%v", ok, err)
|
||||
}
|
||||
pinned, err := openPluginDir(plugin)
|
||||
if err != nil {
|
||||
t.Fatalf("openPluginDir: %v", err)
|
||||
}
|
||||
defer pinned.Close()
|
||||
|
||||
moved := filepath.Join(t.TempDir(), "moved-away")
|
||||
if err := os.Rename(pluginDir, moved); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
replacement := strings.Replace(demoManifest(true), `"version": "1.0.0"`, `"version": "2.0.0"`, 1)
|
||||
writeRawManifest(t, root, "demo", replacement)
|
||||
|
||||
// The lockfile check hashes the pinned tree, which still matches the
|
||||
// recorded hash, not the replacement now at the path.
|
||||
if _, _, clean := cleanLockEntry(plugin, pinned.dir); !clean {
|
||||
t.Error("cleanLockEntry hashed the replacement at the path instead of the pinned directory")
|
||||
}
|
||||
|
||||
if _, err := setEnabledPinned(plugin, pinned, false); !errors.Is(err, errPluginDirReplaced) {
|
||||
t.Fatalf("setEnabledPinned after the directory was replaced: err = %v, want %v", err, errPluginDirReplaced)
|
||||
}
|
||||
if after, err := os.ReadFile(manifestPath); err != nil || string(after) != replacement {
|
||||
t.Fatalf("the replacement manifest changed (err %v): %q", err, after)
|
||||
}
|
||||
if after, err := os.ReadFile(lockPath); err != nil || string(after) != string(lockBefore) {
|
||||
t.Fatalf("the lockfile changed (err %v):\n got %s\nwant %s", err, after, lockBefore)
|
||||
}
|
||||
movedRaw, err := os.ReadFile(filepath.Join(moved, "plugin.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tc.changes != (string(movedRaw) != demoManifest(tc.pinned)) {
|
||||
t.Fatalf("pinned manifest edit = %v, want %v: %q", !tc.changes, tc.changes, movedRaw)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetEnabledByIDRespectsUserOnlyFilter(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
userRoot := filepath.Join(dir, "user")
|
||||
projectRoot := filepath.Join(dir, "project")
|
||||
writePluginManifest(t, filepath.Join(userRoot, "demo"), map[string]any{
|
||||
"schemaVersion": 1,
|
||||
"id": "zero.demo",
|
||||
"name": "User Demo",
|
||||
"version": "0.1.0",
|
||||
"enabled": true,
|
||||
})
|
||||
writePluginManifest(t, filepath.Join(projectRoot, "demo"), map[string]any{
|
||||
"schemaVersion": 1,
|
||||
"id": "zero.demo",
|
||||
"name": "Project Demo",
|
||||
"version": "0.2.0",
|
||||
"enabled": true,
|
||||
})
|
||||
|
||||
result, err := SetEnabledByID(LoadOptions{
|
||||
Roots: []Root{
|
||||
{Source: SourceUser, Path: userRoot},
|
||||
{Source: SourceProject, Path: projectRoot},
|
||||
},
|
||||
}, "zero.demo", false)
|
||||
if err != nil {
|
||||
t.Fatalf("SetEnabledByID: %v", err)
|
||||
}
|
||||
if result.Source != SourceProject {
|
||||
t.Fatalf("source = %s, want project (precedence)", result.Source)
|
||||
}
|
||||
if !result.Changed || result.Enabled {
|
||||
t.Fatalf("unexpected result: %#v", result)
|
||||
}
|
||||
|
||||
userOnly, err := SetEnabledByID(LoadOptions{
|
||||
Roots: []Root{
|
||||
{Source: SourceUser, Path: userRoot},
|
||||
{Source: SourceProject, Path: projectRoot},
|
||||
},
|
||||
ExcludeProject: true,
|
||||
}, "zero.demo", false)
|
||||
if err != nil {
|
||||
t.Fatalf("SetEnabledByID user-only: %v", err)
|
||||
}
|
||||
if userOnly.Source != SourceUser {
|
||||
t.Fatalf("user-only source = %s, want user", userOnly.Source)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetEnabledPreservesManifestMode(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("Windows does not preserve chmod bits in os.Stat mode")
|
||||
}
|
||||
root := t.TempDir()
|
||||
writePluginManifest(t, filepath.Join(root, "demo"), map[string]any{
|
||||
"schemaVersion": 1,
|
||||
"id": "zero.demo",
|
||||
"name": "Demo",
|
||||
"version": "1.0.0",
|
||||
})
|
||||
manifestPath := filepath.Join(root, "demo", "plugin.json")
|
||||
if err := os.Chmod(manifestPath, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := SetEnabledByID(enableTestOptions(root), "zero.demo", false); err != nil {
|
||||
t.Fatalf("SetEnabledByID: %v", err)
|
||||
}
|
||||
info, err := os.Stat(manifestPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := info.Mode().Perm(); got != 0o644 {
|
||||
t.Fatalf("mode = %04o, want 0644", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetEnabledByIDMissingPlugin(t *testing.T) {
|
||||
_, err := SetEnabledByID(LoadOptions{Roots: []Root{{Source: SourceUser, Path: t.TempDir()}}}, "missing", false)
|
||||
if err == nil {
|
||||
t.Fatal("expected missing plugin error")
|
||||
}
|
||||
if !errors.Is(err, ErrNotInstalled) {
|
||||
t.Fatalf("error = %v, want ErrNotInstalled", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetEnabledPublishFailureLeavesManifestIntact(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("Windows directory permissions do not block writes the way this test needs")
|
||||
}
|
||||
if os.Geteuid() == 0 {
|
||||
t.Skip("root bypasses directory permission checks")
|
||||
}
|
||||
root := t.TempDir()
|
||||
raw := demoManifest(true)
|
||||
manifestPath := writeRawManifest(t, root, "demo", raw)
|
||||
pluginDir := filepath.Dir(manifestPath)
|
||||
if err := os.Chmod(pluginDir, 0o555); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = os.Chmod(pluginDir, 0o755) }()
|
||||
|
||||
if _, err := SetEnabledByID(enableTestOptions(root), "zero.demo", false); err == nil {
|
||||
t.Fatal("expected the publish to fail in a read-only plugin directory")
|
||||
}
|
||||
after, err := os.ReadFile(manifestPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(after) != raw {
|
||||
t.Fatalf("failed publish changed the manifest:\n got %q\nwant %q", after, raw)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetEnabledByIDWaitsForInstallRootLock proves the toggle takes the same
|
||||
// cross-process lock Install and Remove take on the plugin's install root: it
|
||||
// must not complete while an install holds that lock.
|
||||
func TestSetEnabledByIDWaitsForInstallRootLock(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
manifestPath := writeRawManifest(t, root, "demo", demoManifest(true))
|
||||
|
||||
unlock, err := installtxn.Lock(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
results := make(chan SetEnabledResult, 1)
|
||||
failures := make(chan error, 1)
|
||||
go func() {
|
||||
result, err := SetEnabledByID(enableTestOptions(root), "zero.demo", false)
|
||||
if err != nil {
|
||||
failures <- err
|
||||
return
|
||||
}
|
||||
results <- result
|
||||
}()
|
||||
|
||||
select {
|
||||
case result := <-results:
|
||||
unlock()
|
||||
t.Fatalf("toggle completed while the install lock was held: %#v", result)
|
||||
case err := <-failures:
|
||||
unlock()
|
||||
t.Fatalf("toggle failed while the install lock was held: %v", err)
|
||||
case <-time.After(200 * time.Millisecond):
|
||||
}
|
||||
|
||||
unlock()
|
||||
|
||||
select {
|
||||
case result := <-results:
|
||||
if !result.Changed {
|
||||
t.Fatalf("toggle did not report a change: %#v", result)
|
||||
}
|
||||
case err := <-failures:
|
||||
t.Fatalf("toggle failed after the lock was released: %v", err)
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("toggle did not finish after the install lock was released")
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile(manifestPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(raw), `"enabled": false`) {
|
||||
t.Fatalf("manifest not disabled: %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetEnabledByIDSynchronizesWithStagedInstallCommit pauses an install
|
||||
// between staging and commit, lets the toggle try to run, then commits the
|
||||
// install. The toggle must apply to the newly installed manifest instead of
|
||||
// overwriting it with bytes read before the install, and the lockfile entry
|
||||
// must describe the tree actually on disk.
|
||||
func TestSetEnabledByIDSynchronizesWithStagedInstallCommit(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
oldRaw := demoManifest(true)
|
||||
pluginDir := filepath.Dir(writeRawManifest(t, root, "zero.demo", oldRaw))
|
||||
|
||||
// Build the replacement tree with the same staging primitives Install
|
||||
// uses and leave it staged but uncommitted.
|
||||
src := t.TempDir()
|
||||
newRaw := `{
|
||||
"schemaVersion": 1,
|
||||
"id": "zero.demo",
|
||||
"name": "Demo",
|
||||
"version": "2.0.0",
|
||||
"description": "reinstalled",
|
||||
"enabled": true
|
||||
}
|
||||
`
|
||||
srcPlugin := filepath.Join(src, "zero.demo")
|
||||
if err := os.MkdirAll(srcPlugin, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(srcPlugin, "plugin.json"), []byte(newRaw), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
staged, cleanup, err := installtxn.StageDir(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer cleanup()
|
||||
if err := copyTree(srcPlugin, staged); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stagedHash, err := hashTree(staged)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// An install holds the root lock from before its commit until after it.
|
||||
unlock, err := installtxn.Lock(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
results := make(chan SetEnabledResult, 1)
|
||||
failures := make(chan error, 1)
|
||||
go func() {
|
||||
result, err := SetEnabledByID(enableTestOptions(root), "zero.demo", false)
|
||||
if err != nil {
|
||||
failures <- err
|
||||
return
|
||||
}
|
||||
results <- result
|
||||
}()
|
||||
|
||||
select {
|
||||
case result := <-results:
|
||||
unlock()
|
||||
t.Fatalf("toggle completed while the install lock was held: %#v", result)
|
||||
case err := <-failures:
|
||||
unlock()
|
||||
t.Fatalf("toggle failed while the install lock was held: %v", err)
|
||||
case <-time.After(200 * time.Millisecond):
|
||||
}
|
||||
|
||||
// Commit the staged install exactly like Install does: swap the tree and
|
||||
// publish the lockfile entry while still holding the lock.
|
||||
lock, err := ReadLock(root)
|
||||
if err != nil {
|
||||
unlock()
|
||||
t.Fatal(err)
|
||||
}
|
||||
lock["zero.demo"] = LockEntry{Source: string(SourceUser), Hash: stagedHash}
|
||||
if err := installtxn.CommitDir(pluginDir, staged, func() error { return writeLock(root, lock) }); err != nil {
|
||||
unlock()
|
||||
t.Fatal(err)
|
||||
}
|
||||
unlock()
|
||||
|
||||
var result SetEnabledResult
|
||||
select {
|
||||
case result = <-results:
|
||||
case err := <-failures:
|
||||
t.Fatalf("toggle failed after the install committed: %v", err)
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("toggle did not finish after the install committed")
|
||||
}
|
||||
if !result.Changed {
|
||||
t.Fatalf("toggle result = %#v, want changed", result)
|
||||
}
|
||||
|
||||
// The toggle must have applied to the newly installed manifest, not to the
|
||||
// bytes it first read.
|
||||
final, err := os.ReadFile(filepath.Join(pluginDir, "plugin.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := strings.Replace(newRaw, `"enabled": true`, `"enabled": false`, 1)
|
||||
if string(final) != want {
|
||||
t.Fatalf("manifest after concurrent install + disable:\n got %q\nwant %q", final, want)
|
||||
}
|
||||
|
||||
// The lockfile entry must describe the tree that is actually on disk.
|
||||
entries, err := ReadLock(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
current, err := hashTree(pluginDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if entries["zero.demo"].Hash != current {
|
||||
t.Fatalf("plugins.lock hash = %q, want current tree hash %q", entries["zero.demo"].Hash, current)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetEnabledByIDRefreshesCleanLockEntry(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
manifestPath := writeRawManifest(t, root, "zero.demo", demoManifest(true))
|
||||
pluginDir := filepath.Dir(manifestPath)
|
||||
|
||||
current, err := hashTree(pluginDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := writeLock(root, map[string]LockEntry{"zero.demo": {Source: string(SourceUser), Hash: current}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := SetEnabledByID(enableTestOptions(root), "zero.demo", false); err != nil {
|
||||
t.Fatalf("SetEnabledByID: %v", err)
|
||||
}
|
||||
entries, err := ReadLock(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
afterToggle, err := hashTree(pluginDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if entries["zero.demo"].Hash == current {
|
||||
t.Fatal("lock hash was not refreshed for a clean install")
|
||||
}
|
||||
if entries["zero.demo"].Hash != afterToggle {
|
||||
t.Fatalf("lock hash = %q, want refreshed tree hash %q", entries["zero.demo"].Hash, afterToggle)
|
||||
}
|
||||
|
||||
info, err := Info(InfoOptions{LoadOptions: enableTestOptions(root)}, "zero.demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info.HashDrift {
|
||||
t.Fatal("toggle left hash drift behind for a clean install")
|
||||
}
|
||||
|
||||
// Pre-existing drift must survive the next toggle instead of being
|
||||
// absorbed into the lockfile.
|
||||
if err := os.WriteFile(filepath.Join(pluginDir, "extra.txt"), []byte("edited\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := SetEnabledByID(enableTestOptions(root), "zero.demo", true); err != nil {
|
||||
t.Fatalf("SetEnabledByID(true): %v", err)
|
||||
}
|
||||
entries, err = ReadLock(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if entries["zero.demo"].Hash != afterToggle {
|
||||
t.Fatalf("toggle absorbed pre-existing drift: hash = %q, want %q", entries["zero.demo"].Hash, afterToggle)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetEnabledByIDReportsAPluginRemovedWhileWaiting removes the plugin while
|
||||
// the toggle is blocked on the install-root lock, exactly as Remove does while
|
||||
// it holds that lock. The toggle must report the plugin as not installed
|
||||
// instead of editing, or recreating, a tree that no longer exists.
|
||||
func TestSetEnabledByIDReportsAPluginRemovedWhileWaiting(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
pluginDir := filepath.Dir(writeRawManifest(t, root, "zero.demo", demoManifest(true)))
|
||||
|
||||
unlock, err := installtxn.Lock(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
failures := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := SetEnabledByID(enableTestOptions(root), "zero.demo", false)
|
||||
failures <- err
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-failures:
|
||||
unlock()
|
||||
t.Fatalf("toggle returned while the install lock was held: %v", err)
|
||||
case <-time.After(200 * time.Millisecond):
|
||||
}
|
||||
|
||||
if err := installtxn.RemoveDir(pluginDir, func() error { return nil }); err != nil {
|
||||
unlock()
|
||||
t.Fatal(err)
|
||||
}
|
||||
unlock()
|
||||
|
||||
select {
|
||||
case err := <-failures:
|
||||
if !errors.Is(err, ErrNotInstalled) {
|
||||
t.Fatalf("toggle after a concurrent removal: error = %v, want ErrNotInstalled", err)
|
||||
}
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("toggle did not finish after the removal released the lock")
|
||||
}
|
||||
if _, err := os.Stat(pluginDir); !os.IsNotExist(err) {
|
||||
t.Fatalf("toggle recreated the removed plugin: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetEnabledByIDResolvesInterruptedUpdatesFirst covers every point an
|
||||
// update can be killed at that leaves the plugin discoverable. The toggle
|
||||
// holds the install-root lock, so it has to
|
||||
// run the same recovery pass Install and Remove run before it reads the tree:
|
||||
// otherwise it edits, or fails to find, a tree that is still owed a restore,
|
||||
// and the next install or removal puts the backup back over the user's edit.
|
||||
func TestSetEnabledByIDResolvesInterruptedUpdatesFirst(t *testing.T) {
|
||||
for _, state := range interruptedStates() {
|
||||
t.Run(state.name, func(t *testing.T) {
|
||||
u := seedInterruptedUpdate(t)
|
||||
workspace := state.plant(t, u)
|
||||
want := state.want(u)
|
||||
if _, err := os.Lstat(u.target()); os.IsNotExist(err) {
|
||||
// No root lists a plugin whose only copy is in a workspace, so the
|
||||
// toggle reports it not installed without taking any lock, just as
|
||||
// list and info do; the next install or removal restores it.
|
||||
t.Skip("nothing is at the target, so the toggle never takes the lock")
|
||||
}
|
||||
|
||||
result, err := SetEnabledByID(enableTestOptions(u.dir), "zero.demo", false)
|
||||
if err != nil {
|
||||
t.Fatalf("toggle after the interrupted update: %v", err)
|
||||
}
|
||||
if !result.Changed || result.Enabled {
|
||||
t.Fatalf("toggle result = %#v, want changed and disabled", result)
|
||||
}
|
||||
assertDisabledAfterRecovery(t, u, workspace, want)
|
||||
|
||||
// The next lock holder runs recovery again. The disable has to survive
|
||||
// it rather than be replaced by a backup the toggle never saw.
|
||||
if err := installOther(t, u.dir, "zero.other"); err != nil {
|
||||
t.Fatalf("install after the toggle: %v", err)
|
||||
}
|
||||
assertDisabledAfterRecovery(t, u, workspace, want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func assertDisabledAfterRecovery(t *testing.T, u interruptedUpdate, workspace string, want recoveryWant) {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(filepath.Join(u.target(), "generation.txt"))
|
||||
if err != nil {
|
||||
t.Fatalf("reading the live tree: %v", err)
|
||||
}
|
||||
if string(data) != want.generation {
|
||||
t.Errorf("the live tree is the %q generation, want %q", data, want.generation)
|
||||
}
|
||||
plugin, ok, err := FindByID(enableTestOptions(u.dir), "zero.demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("Load does not see the toggled plugin")
|
||||
}
|
||||
if plugin.Version != want.version {
|
||||
t.Errorf("Load sees version %q, want %q", plugin.Version, want.version)
|
||||
}
|
||||
if plugin.Enabled {
|
||||
t.Errorf("the disable did not stick: Load sees the plugin enabled")
|
||||
}
|
||||
_, err = os.Stat(workspace)
|
||||
if want.workspace && err != nil {
|
||||
t.Errorf("the workspace should be left alone, got %v", err)
|
||||
}
|
||||
if !want.workspace && !os.IsNotExist(err) {
|
||||
t.Errorf("the resolved workspace should be cleared, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetEnabledByIDAbortsOnAnUnresolvedInterruptedUpdate leaves a workspace
|
||||
// whose trees match neither lockfile hash. Recovery cannot pick one, so the
|
||||
// toggle must fail without editing the live manifest or the lockfile.
|
||||
func TestSetEnabledByIDAbortsOnAnUnresolvedInterruptedUpdate(t *testing.T) {
|
||||
u := seedInterruptedUpdate(t)
|
||||
workspace := u.plantWorkspace(t)
|
||||
u.mustRename(t, u.target(), filepath.Join(workspace, "previous"))
|
||||
u.mustRename(t, filepath.Join(workspace, "staged"), u.target())
|
||||
unmatched := LockEntry{Source: u.oldSource, Hash: "sha256:0000000000000000000000000000000000000000000000000000000000000000"}
|
||||
lock, err := ReadLock(u.dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lock["zero.demo"] = unmatched
|
||||
if err := writeLock(u.dir, lock); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
manifestPath := filepath.Join(u.target(), "plugin.json")
|
||||
before, err := os.ReadFile(manifestPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := SetEnabledByID(enableTestOptions(u.dir), "zero.demo", false); err == nil {
|
||||
t.Fatal("toggle did not report the unresolved transaction")
|
||||
}
|
||||
after, err := os.ReadFile(manifestPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(after) != string(before) {
|
||||
t.Errorf("the aborted toggle edited the manifest:\n got %q\nwant %q", after, before)
|
||||
}
|
||||
assertUntouched(t, u, "new", unmatched)
|
||||
if _, err := os.Stat(filepath.Join(workspace, "previous")); err != nil {
|
||||
t.Errorf("the backup should still be there for a hand rescue, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -7,8 +7,7 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ErrNotInstalled is returned when a plugin id cannot be found under the given
|
||||
// LoadOptions (for example by Info or SetEnabledByID).
|
||||
// ErrNotInstalled is returned when Info cannot find a plugin with the requested id.
|
||||
var ErrNotInstalled = errors.New("plugin is not installed")
|
||||
|
||||
// PluginInfo combines a loaded plugin with optional lockfile metadata.
|
||||
|
||||
+11
-37
@@ -567,32 +567,15 @@ func validInstallID(id string) bool {
|
||||
// sorted order. Each file contributes its plugin-relative path, executable bit,
|
||||
// and bytes, so renames, mode flips, and content edits all change the hash.
|
||||
func hashTree(root string) (string, error) {
|
||||
handle, err := os.OpenRoot(root)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer handle.Close()
|
||||
return hashRootedTree(handle)
|
||||
}
|
||||
|
||||
// hashRootedTree is hashTree over a directory that is already open. Walking
|
||||
// relative to the handle hashes the directory it names even if its path is
|
||||
// replaced meanwhile, and keeps the walk inside it.
|
||||
//
|
||||
// The walk calls the handle's own methods rather than handle.FS(): the fs.FS
|
||||
// adapter only accepts fs.ValidPath names, which must be UTF-8, so it would
|
||||
// refuse file names that the filesystem and copyTree accept.
|
||||
func hashRootedTree(handle *os.Root) (string, error) {
|
||||
hasher := sha256.New()
|
||||
if err := hashTreeInto(hasher, handle, "."); err != nil {
|
||||
if err := hashTreeInto(hasher, root, root); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "sha256:" + hex.EncodeToString(hasher.Sum(nil)), nil
|
||||
}
|
||||
|
||||
// hashTreeInto hashes dir, a path relative to handle, into hasher.
|
||||
func hashTreeInto(hasher io.Writer, handle *os.Root, dir string) error {
|
||||
entries, err := readRootedDir(handle, dir)
|
||||
func hashTreeInto(hasher io.Writer, root string, dir string) error {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -605,8 +588,8 @@ func hashTreeInto(hasher io.Writer, handle *os.Root, dir string) error {
|
||||
if name == ".git" {
|
||||
continue
|
||||
}
|
||||
rel := filepath.Join(dir, name)
|
||||
info, err := handle.Lstat(rel)
|
||||
path := filepath.Join(dir, name)
|
||||
info, err := os.Lstat(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -615,10 +598,14 @@ func hashTreeInto(hasher io.Writer, handle *os.Root, dir string) error {
|
||||
// Skipped by copyTree, so excluded from the hash too.
|
||||
continue
|
||||
case info.IsDir():
|
||||
if err := hashTreeInto(hasher, handle, rel); err != nil {
|
||||
if err := hashTreeInto(hasher, root, path); err != nil {
|
||||
return err
|
||||
}
|
||||
case info.Mode().IsRegular():
|
||||
rel, err := filepath.Rel(root, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
executable := 0
|
||||
if info.Mode().Perm()&0o111 != 0 {
|
||||
executable = 1
|
||||
@@ -629,7 +616,7 @@ func hashTreeInto(hasher io.Writer, handle *os.Root, dir string) error {
|
||||
if _, err := io.WriteString(hasher, header); err != nil {
|
||||
return err
|
||||
}
|
||||
file, err := handle.Open(rel)
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -647,16 +634,3 @@ func hashTreeInto(hasher io.Writer, handle *os.Root, dir string) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// readRootedDir lists dir, a path relative to handle, like os.ReadDir.
|
||||
func readRootedDir(handle *os.Root, dir string) ([]os.DirEntry, error) {
|
||||
file, err := handle.Open(dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entries, err := file.ReadDir(-1)
|
||||
if closeErr := file.Close(); err == nil {
|
||||
err = closeErr
|
||||
}
|
||||
return entries, err
|
||||
}
|
||||
@@ -1181,62 +1181,3 @@ func TestRecoveryLeavesAPrefixCollidingDirectoryAlone(t *testing.T) {
|
||||
t.Errorf("recovery published content it does not own: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNonUTF8FileNamesAreInstalledHashedAndToggled covers a plugin whose tree
|
||||
// holds a file and a directory named with bytes that are not valid UTF-8, which
|
||||
// Linux allows. copyTree copies them, so hashTree must hash them too: Install
|
||||
// records the hash, the toggle refreshes it, and Info sees an edit to that file
|
||||
// as drift. A walk that rejects such names breaks all three.
|
||||
func TestNonUTF8FileNamesAreInstalledHashedAndToggled(t *testing.T) {
|
||||
src := writeSourcePlugin(t, filepath.Join(t.TempDir(), "src"), validManifest())
|
||||
nested := filepath.Join("assets\xff", "data\xfe.bin")
|
||||
if err := os.Mkdir(filepath.Join(src, "assets\xff"), 0o755); err != nil {
|
||||
t.Skipf("this filesystem rejects non-UTF-8 names: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(src, nested), []byte("original\n"), 0o644); err != nil {
|
||||
t.Skipf("this filesystem rejects non-UTF-8 names: %v", err)
|
||||
}
|
||||
|
||||
destDir := t.TempDir()
|
||||
if _, err := Install(context.Background(), InstallOptions{Source: src, Dir: destDir}); err != nil {
|
||||
t.Fatalf("Install a plugin with a non-UTF-8 file name: %v", err)
|
||||
}
|
||||
pluginDir := filepath.Join(destDir, "zero.demo")
|
||||
if data, err := os.ReadFile(filepath.Join(pluginDir, nested)); err != nil || string(data) != "original\n" {
|
||||
t.Fatalf("installed copy of the non-UTF-8 file = %q, %v", data, err)
|
||||
}
|
||||
|
||||
options := LoadOptions{Roots: []Root{{Source: SourceUser, Path: destDir}}}
|
||||
before, err := ReadLock(destDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := SetEnabledByID(options, "zero.demo", false); err != nil {
|
||||
t.Fatalf("SetEnabledByID on a plugin with a non-UTF-8 file name: %v", err)
|
||||
}
|
||||
after, err := ReadLock(destDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if after["zero.demo"].Hash == before["zero.demo"].Hash {
|
||||
t.Fatal("the toggle did not refresh the lock hash")
|
||||
}
|
||||
info, err := Info(InfoOptions{LoadOptions: options}, "zero.demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info.HashDrift {
|
||||
t.Fatal("the toggle left hash drift behind")
|
||||
}
|
||||
|
||||
if err := os.WriteFile(filepath.Join(pluginDir, nested), []byte("edited\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
info, err = Info(InfoOptions{LoadOptions: options}, "zero.demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !info.HashDrift {
|
||||
t.Fatal("an edit to the non-UTF-8 file was not reported as drift")
|
||||
}
|
||||
}
|
||||
@@ -75,12 +75,6 @@ type Descriptor struct {
|
||||
// catalog-ordered lists and pickers. The recommended descriptors are the first
|
||||
// entries in the catalog, in order.
|
||||
Recommended bool
|
||||
|
||||
// LegacyBaseURLs lists earlier DefaultBaseURL values that still address
|
||||
// this provider's own endpoint. Profiles saved against one keep catalog
|
||||
// endpoint treatment (catalog env var binding, base-URL matching) after the
|
||||
// default moves; see IsCatalogBaseURL.
|
||||
LegacyBaseURLs []string
|
||||
}
|
||||
|
||||
func RuntimeSupported(descriptor Descriptor) bool {
|
||||
@@ -105,17 +99,11 @@ func RuntimeUnsupportedReason(descriptor Descriptor) string {
|
||||
}
|
||||
|
||||
var descriptors = []Descriptor{
|
||||
// Twigpine OpenGateway (formerly GitLawb OpenGateway) — the recommended
|
||||
// default. An OpenAI-compatible gateway at api.twigpine.com that smart-routes
|
||||
// by model id across upstream providers. Flat /v1/chat/completions with a
|
||||
// Bearer ogw_live_… key; listed first and badged in every picker. The public
|
||||
// GET /v1/models lists only active models and is what pickers show when
|
||||
// reachable. DefaultModel must be an exact id from that list (not an alias)
|
||||
// so onboarding keeps it selected after live discovery. The id and env var
|
||||
// keep their gitlawb names so saved configs keep resolving; the old host
|
||||
// (opengateway.gitlawb.com) still serves clients and stays a legacy catalog
|
||||
// endpoint so profiles saved against it resolve unchanged.
|
||||
recommended(withLegacyBaseURLs(openAICompat("gitlawb-opengateway", "Twigpine OpenGateway", "https://api.twigpine.com/v1", "xiaomi/mimo-v2.6-pro", []string{"GITLAWB_OPENGATEWAY_API_KEY"}, "gitlawb opengateway", "opengateway", "twigpine opengateway", "twigpine"), "https://opengateway.gitlawb.com/v1")),
|
||||
// GitLawb OpenGateway — the recommended default. An OpenAI-compatible gateway
|
||||
// that smart-routes by model id across upstream providers (xiaomi-mimo,
|
||||
// minimax, qwen, google, nvidia, tencent, z-ai). Flat /v1/chat/completions
|
||||
// with a Bearer ogw_live_… key; listed first and badged in every picker.
|
||||
recommended(openAICompat("gitlawb-opengateway", "GitLawb OpenGateway", "https://opengateway.gitlawb.com/v1", "mimo-v2.5-pro", []string{"GITLAWB_OPENGATEWAY_API_KEY"}, "gitlawb opengateway", "opengateway")),
|
||||
// aimlapi.com — OpenAI-compatible aggregating gateway, also badged as
|
||||
// recommended (second, right after the OpenGateway default). The built-in TUI
|
||||
// onboarding can register/top up an account and save the issued key; the partner
|
||||
@@ -144,9 +132,7 @@ var descriptors = []Descriptor{
|
||||
// way in; RequiresAuth is forced true so catalog consumers know the provider
|
||||
// needs an interactive login before a request will succeed.
|
||||
func() Descriptor {
|
||||
// New ChatGPT profiles follow the vendor's GPT-5.5 replacement guidance:
|
||||
// https://learn.chatgpt.com/docs/models#gpt-55-retirement
|
||||
d := openAICompat("chatgpt", "ChatGPT", "https://chatgpt.com/backend-api/codex", "gpt-6.1-sol", nil)
|
||||
d := openAICompat("chatgpt", "ChatGPT", "https://chatgpt.com/backend-api/codex", "gpt-5.5", nil)
|
||||
d.RequiresAuth = true
|
||||
return oauthProvider(d, false, false)
|
||||
}(),
|
||||
@@ -319,37 +305,6 @@ func recommended(descriptor Descriptor) Descriptor {
|
||||
return descriptor
|
||||
}
|
||||
|
||||
// withLegacyBaseURLs records earlier default base URLs that still serve the
|
||||
// provider, so profiles saved before a host move keep resolving as the catalog
|
||||
// endpoint.
|
||||
func withLegacyBaseURLs(descriptor Descriptor, baseURLs ...string) Descriptor {
|
||||
descriptor.LegacyBaseURLs = append(descriptor.LegacyBaseURLs, baseURLs...)
|
||||
return descriptor
|
||||
}
|
||||
|
||||
// IsCatalogBaseURL reports whether baseURL addresses the descriptor's own
|
||||
// endpoint: its DefaultBaseURL or one of its LegacyBaseURLs, ignoring case,
|
||||
// surrounding space, and a trailing slash. An empty baseURL never matches.
|
||||
func IsCatalogBaseURL(descriptor Descriptor, baseURL string) bool {
|
||||
normalized := normalizeBaseURL(baseURL)
|
||||
if normalized == "" {
|
||||
return false
|
||||
}
|
||||
if normalized == normalizeBaseURL(descriptor.DefaultBaseURL) {
|
||||
return true
|
||||
}
|
||||
for _, legacy := range descriptor.LegacyBaseURLs {
|
||||
if normalized == normalizeBaseURL(legacy) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func normalizeBaseURL(baseURL string) string {
|
||||
return strings.ToLower(strings.TrimRight(strings.TrimSpace(baseURL), "/"))
|
||||
}
|
||||
|
||||
// oauthProvider marks a descriptor as OAuth-capable. mintsKey => the flow returns
|
||||
// an API key (OpenRouter); deviceFlow => RFC 8628 device code is also supported.
|
||||
func oauthProvider(descriptor Descriptor, mintsKey bool, deviceFlow bool) Descriptor {
|
||||
@@ -425,9 +380,6 @@ func cloneDescriptor(descriptor Descriptor) Descriptor {
|
||||
descriptor.AuthEnvVars = append([]string{}, descriptor.AuthEnvVars...)
|
||||
descriptor.SupportedAPIFormats = append([]APIFormat{}, descriptor.SupportedAPIFormats...)
|
||||
descriptor.Aliases = append([]string{}, descriptor.Aliases...)
|
||||
if descriptor.LegacyBaseURLs != nil {
|
||||
descriptor.LegacyBaseURLs = append([]string{}, descriptor.LegacyBaseURLs...)
|
||||
}
|
||||
if descriptor.CustomHeaders != nil {
|
||||
descriptor.CustomHeaders = copyStringMap(descriptor.CustomHeaders)
|
||||
}
|
||||
|
||||
@@ -77,8 +77,7 @@ func TestAllHasStableUniqueIDs(t *testing.T) {
|
||||
func TestRecommendedProvidersAreTopOfCatalog(t *testing.T) {
|
||||
descriptors := All()
|
||||
// The recommended providers are badged and pinned to the top of the catalog,
|
||||
// in this order (Twigpine OpenGateway remains the default; aimlapi.com is also
|
||||
// badged).
|
||||
// in this order (OpenGateway remains the default; aimlapi.com is also badged).
|
||||
wantTop := []string{"gitlawb-opengateway", "aimlapi"}
|
||||
if len(descriptors) < len(wantTop) {
|
||||
t.Fatalf("All() returned %d descriptors, want at least %d", len(descriptors), len(wantTop))
|
||||
@@ -108,66 +107,12 @@ func TestRecommendedProviderEndpoint(t *testing.T) {
|
||||
if !ok {
|
||||
t.Fatal("gitlawb-opengateway not found in catalog")
|
||||
}
|
||||
if descriptor.Name != "Twigpine OpenGateway" {
|
||||
t.Fatalf("OpenGateway name = %q, want %q", descriptor.Name, "Twigpine OpenGateway")
|
||||
}
|
||||
if descriptor.DefaultBaseURL != "https://api.twigpine.com/v1" {
|
||||
t.Fatalf("OpenGateway base URL = %q, want %q", descriptor.DefaultBaseURL, "https://api.twigpine.com/v1")
|
||||
}
|
||||
// The default must be an exact id from the live /v1/models list (not a
|
||||
// gateway alias such as the bare "mimo-v2.6-pro"): onboarding keeps the
|
||||
// default selected after live discovery only on an exact id match.
|
||||
if descriptor.DefaultModel != "xiaomi/mimo-v2.6-pro" {
|
||||
t.Fatalf("OpenGateway default model = %q, want %q", descriptor.DefaultModel, "xiaomi/mimo-v2.6-pro")
|
||||
if descriptor.DefaultBaseURL != "https://opengateway.gitlawb.com/v1" {
|
||||
t.Fatalf("OpenGateway base URL = %q, want %q", descriptor.DefaultBaseURL, "https://opengateway.gitlawb.com/v1")
|
||||
}
|
||||
if descriptor.Transport != TransportOpenAICompatible {
|
||||
t.Fatalf("OpenGateway transport = %q, want %q", descriptor.Transport, TransportOpenAICompatible)
|
||||
}
|
||||
// Saved configs keep the original credential env var.
|
||||
if !reflect.DeepEqual(descriptor.AuthEnvVars, []string{"GITLAWB_OPENGATEWAY_API_KEY"}) {
|
||||
t.Fatalf("OpenGateway auth env vars = %#v, want GITLAWB_OPENGATEWAY_API_KEY", descriptor.AuthEnvVars)
|
||||
}
|
||||
// Old and new names both resolve to the same stable catalog id.
|
||||
for _, name := range []string{"gitlawb-opengateway", "GitLawb OpenGateway", "opengateway", "Twigpine OpenGateway", "twigpine"} {
|
||||
got, ok := Get(name)
|
||||
if !ok || got.ID != "gitlawb-opengateway" {
|
||||
t.Fatalf("Get(%q) = (%q, %v), want gitlawb-opengateway", name, got.ID, ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsCatalogBaseURLAcceptsDefaultAndLegacyHosts(t *testing.T) {
|
||||
gateway, ok := Get("gitlawb-opengateway")
|
||||
if !ok {
|
||||
t.Fatal("gitlawb-opengateway not found in catalog")
|
||||
}
|
||||
for _, baseURL := range []string{
|
||||
"https://api.twigpine.com/v1",
|
||||
"https://API.twigpine.com/v1/ ",
|
||||
"https://opengateway.gitlawb.com/v1",
|
||||
"https://opengateway.gitlawb.com/v1/",
|
||||
} {
|
||||
if !IsCatalogBaseURL(gateway, baseURL) {
|
||||
t.Fatalf("IsCatalogBaseURL(gateway, %q) = false, want true", baseURL)
|
||||
}
|
||||
}
|
||||
for _, baseURL := range []string{"", "https://gateway.example.com/v1", "https://opengateway.gitlawb.com/v2"} {
|
||||
if IsCatalogBaseURL(gateway, baseURL) {
|
||||
t.Fatalf("IsCatalogBaseURL(gateway, %q) = true, want false", baseURL)
|
||||
}
|
||||
}
|
||||
openrouter, ok := Get("openrouter")
|
||||
if !ok {
|
||||
t.Fatal("openrouter not found in catalog")
|
||||
}
|
||||
if !IsCatalogBaseURL(openrouter, "https://openrouter.ai/api/v1") || IsCatalogBaseURL(openrouter, "https://opengateway.gitlawb.com/v1") {
|
||||
t.Fatal("legacy hosts must only match their own descriptor")
|
||||
}
|
||||
// Get returns a copy: mutating it must not leak into the catalog.
|
||||
gateway.LegacyBaseURLs[0] = "https://mutated.example.com/v1"
|
||||
if again, _ := Get("gitlawb-opengateway"); again.LegacyBaseURLs[0] != "https://opengateway.gitlawb.com/v1" {
|
||||
t.Fatalf("LegacyBaseURLs aliased the catalog: %#v", again.LegacyBaseURLs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIMLAPIDescriptor(t *testing.T) {
|
||||
@@ -440,8 +385,6 @@ func TestLookupNormalizesIDsAndAliases(t *testing.T) {
|
||||
"custom_openai_compatible": "custom-openai-compatible",
|
||||
"custom--anthropic compatible": "custom-anthropic-compatible",
|
||||
"GitLawb OpenGateway": "gitlawb-opengateway",
|
||||
"Twigpine OpenGateway": "gitlawb-opengateway",
|
||||
"twigpine": "gitlawb-opengateway",
|
||||
}
|
||||
for input, want := range cases {
|
||||
descriptor, ok := Get(input)
|
||||
|
||||
@@ -675,12 +675,10 @@ func blockedAddrReason(addr netip.Addr) string {
|
||||
}
|
||||
|
||||
// overrideHealthEndpoint returns a provider-specific connectivity probe URL when
|
||||
// the default {baseURL}+/models path is not the right reachability check.
|
||||
// Twigpine OpenGateway (formerly GitLawb OpenGateway) is a smart-routing gateway
|
||||
// whose flat /v1/models 404'd on older deployments, so probe its public /health
|
||||
// endpoint at the host root instead (served by api.twigpine.com and the legacy
|
||||
// opengateway.gitlawb.com host), which reports real reachability without a
|
||||
// per-model call.
|
||||
// the default {baseURL}+/models path does not exist. GitLawb OpenGateway is a
|
||||
// smart-routing gateway whose flat /v1/models endpoint 404s by design ("Use
|
||||
// /v1/<provider>/<path>"), so probe its public /health endpoint at the host root
|
||||
// instead, which reports real reachability without a per-model call.
|
||||
func overrideHealthEndpoint(profile config.ProviderProfile, baseURL string) (string, bool) {
|
||||
if profile.CatalogID != "gitlawb-opengateway" {
|
||||
return "", false
|
||||
|
||||
@@ -179,10 +179,6 @@ func TestOverrideHealthEndpointOpenGateway(t *testing.T) {
|
||||
if got != "https://opengateway.gitlawb.com/health" {
|
||||
t.Fatalf("OpenGateway health endpoint = %q, want host-root /health", got)
|
||||
}
|
||||
got, ok = overrideHealthEndpoint(config.ProviderProfile{CatalogID: "gitlawb-opengateway"}, "https://api.twigpine.com/v1")
|
||||
if !ok || got != "https://api.twigpine.com/health" {
|
||||
t.Fatalf("Twigpine OpenGateway health endpoint = (%q, %v), want https://api.twigpine.com/health", got, ok)
|
||||
}
|
||||
|
||||
if _, ok := overrideHealthEndpoint(config.ProviderProfile{CatalogID: "openai"}, "https://api.openai.com/v1"); ok {
|
||||
t.Fatal("non-OpenGateway providers must not override the health endpoint")
|
||||
|
||||
@@ -44,20 +44,6 @@ var minimaxCuratedModels = []Model{
|
||||
OutputModalities: []string{"text"},
|
||||
Source: minimaxModelSource,
|
||||
},
|
||||
{
|
||||
ID: "MiniMax-M3.1-Flash",
|
||||
Description: "fast reasoning model",
|
||||
ContextWindow: 1_000_000,
|
||||
ToolCall: true,
|
||||
Reasoning: true,
|
||||
ReasoningEfforts: []string{"low", "medium", "high", "xhigh", "max"},
|
||||
DefaultReasoningEffort: "max",
|
||||
InputModalities: []string{"text", "image", "video"},
|
||||
OutputModalities: []string{"text"},
|
||||
InputCost: 0.15,
|
||||
OutputCost: 0.60,
|
||||
Source: minimaxModelSource,
|
||||
},
|
||||
{
|
||||
ID: "MiniMax-M2.7",
|
||||
Description: "agentic coding model",
|
||||
@@ -127,12 +113,10 @@ var curatedModels = map[string][]Model{
|
||||
{ID: "deepseek/deepseek-chat", Description: "coding model"},
|
||||
},
|
||||
"chatgpt": {
|
||||
// Suggestions only: availability comes from the authenticated Codex catalog.
|
||||
// https://learn.chatgpt.com/docs/models
|
||||
{ID: "gpt-6.1-sol", Description: "balanced Codex model"},
|
||||
{ID: "gpt-6.1-terra", Description: "fast model for writing, visual understanding, and computer use"},
|
||||
{ID: "gpt-6-astra", Description: "advanced reasoning model"},
|
||||
{ID: "gpt-6-luna", Description: "efficient coding model"},
|
||||
{ID: "gpt-5.5", Description: "recommended Codex model"},
|
||||
{ID: "gpt-5.4", Description: "strong Codex model"},
|
||||
{ID: "gpt-5.4-mini", Description: "fast Codex model"},
|
||||
{ID: "gpt-5.3-codex-spark", Description: "research preview fast Codex model"},
|
||||
},
|
||||
"deepseek": {
|
||||
{ID: "deepseek-chat", Description: "catalog default"},
|
||||
@@ -225,17 +209,26 @@ var curatedModels = map[string][]Model{
|
||||
},
|
||||
"zai": zaiCuratedModels,
|
||||
"zai-cn": zaiCuratedModels,
|
||||
// Twigpine OpenGateway publishes its live, active-only model list at
|
||||
// GET /v1/models; pickers and onboarding show that list whenever it is
|
||||
// reachable (see providermodeldiscovery.DiscoverCatalog). This is only the
|
||||
// offline fallback, so keep it to ids the gateway is expected to keep
|
||||
// serving: the smart router, the catalog default, and its fast sibling. Do
|
||||
// not add promo or time-limited models here. Users can still type any id
|
||||
// the gateway accepts.
|
||||
// OpenGateway smart-routes by model id across its upstream providers
|
||||
// (see /health: xiaomi-mimo, minimax, qwen, google, nvidia, tencent, z-ai).
|
||||
// These are the curated coding defaults; the gateway accepts any model its
|
||||
// upstreams expose, so users can also type an id the picker doesn't list.
|
||||
"gitlawb-opengateway": {
|
||||
{ID: "xiaomi/mimo-v2.6-pro", Description: "catalog default", ContextWindow: 262_144},
|
||||
{ID: "auto", Description: "smart routing (picks a capable model per request)"},
|
||||
{ID: "xiaomi/mimo-v2.6-flash", Description: "fast model (Xiaomi MiMo V2.6 Flash)", ContextWindow: 262_144},
|
||||
{ID: "mimo-v2.5-pro", Description: "catalog default (Xiaomi MiMo)"},
|
||||
{ID: "xiaomi/mimo-v2.5-pro", Description: "Xiaomi MiMo V2.5 Pro"},
|
||||
{ID: "mimo-v2.5-pro-ultraspeed", Description: "fast model (Xiaomi MiMo)"},
|
||||
{ID: "xiaomi/mimo-v2.5", Description: "multimodal model (Xiaomi)"},
|
||||
{ID: "tencent/hy3", Description: "free Tencent HY3 model"},
|
||||
{ID: "MiniMax-M3", Description: "MiniMax model"},
|
||||
{ID: "minimax/minimax-m3", Description: "MiniMax M3 model"},
|
||||
{ID: "qwen-plus", Description: "Qwen model"},
|
||||
{ID: "qwen/qwen3.7-max", Description: "Qwen flagship coding model"},
|
||||
{ID: "gemini-2.5-pro", Description: "long-context model (Google)"},
|
||||
{ID: "google/gemini-3.1-flash-lite", Description: "Google Gemini 3.1 Flash Lite"},
|
||||
{ID: "glm-4.6", Description: "Z.ai model"},
|
||||
{ID: "z-ai/glm-5.2", Description: "GLM coding & reasoning model"},
|
||||
{ID: "nvidia/llama-3.1-nemotron-70b-instruct", Description: "NVIDIA NIM model"},
|
||||
{ID: "nvidia/nemotron-3-ultra-550b-a55b:free", Description: "free Nemotron 3 Ultra reasoning MoE"},
|
||||
},
|
||||
"atomic-chat": {
|
||||
{ID: "gpt-4.1", Description: "catalog default"},
|
||||
|
||||
@@ -35,8 +35,8 @@ func TestModelsAreProviderScoped(t *testing.T) {
|
||||
},
|
||||
{
|
||||
provider: "chatgpt",
|
||||
want: []string{"gpt-6.1-sol", "gpt-6.1-terra", "gpt-6-astra", "gpt-6-luna"},
|
||||
notWant: []string{"gpt-5.5", "gpt-5.4", "gpt-5.4-mini", "gpt-5.3-codex-spark", "openai/gpt-4.1"},
|
||||
want: []string{"gpt-5.5", "gpt-5.4", "gpt-5.4-mini", "gpt-5.3-codex-spark"},
|
||||
notWant: []string{"gpt-5", "gpt-4.1", "openai/gpt-4.1"},
|
||||
},
|
||||
{
|
||||
provider: "aimlapi",
|
||||
@@ -65,7 +65,7 @@ func TestModelsAreProviderScoped(t *testing.T) {
|
||||
},
|
||||
{
|
||||
provider: "minimaxi-cn",
|
||||
want: []string{"MiniMax-M3", "MiniMax-M3.1-Flash", "MiniMax-M2.7", "MiniMax-M2.1"},
|
||||
want: []string{"MiniMax-M3", "MiniMax-M2.7", "MiniMax-M2.1"},
|
||||
notWant: []string{"gpt-4.1", "claude-sonnet-4.5"},
|
||||
},
|
||||
{
|
||||
@@ -74,15 +74,13 @@ func TestModelsAreProviderScoped(t *testing.T) {
|
||||
notWant: []string{"gpt-4.1", "claude-sonnet-4.5"},
|
||||
},
|
||||
{
|
||||
// Offline fallback only; the live /v1/models list is shown when reachable.
|
||||
provider: "gitlawb-opengateway",
|
||||
want: []string{"xiaomi/mimo-v2.6-pro", "auto", "xiaomi/mimo-v2.6-flash"},
|
||||
notWant: []string{
|
||||
"openai/gpt-4.1", "claude-sonnet-4.5",
|
||||
"mimo-v2.5-pro", "xiaomi/mimo-v2.5-pro", "qwen-plus", "glm-4.6", "gemini-2.5-pro",
|
||||
"MiniMax-M3", "nvidia/llama-3.1-nemotron-70b-instruct",
|
||||
"qwen/qwen3.8-max", "qwen/qwen3.8-flash",
|
||||
want: []string{
|
||||
"mimo-v2.5-pro", "tencent/hy3",
|
||||
"xiaomi/mimo-v2.5-pro", "xiaomi/mimo-v2.5", "minimax/minimax-m3", "qwen/qwen3.7-max",
|
||||
"google/gemini-3.1-flash-lite", "z-ai/glm-5.2", "nvidia/nemotron-3-ultra-550b-a55b:free",
|
||||
},
|
||||
notWant: []string{"openai/gpt-4.1", "claude-sonnet-4.5"},
|
||||
},
|
||||
{
|
||||
provider: "opencode-go-anthropic-compatible",
|
||||
@@ -167,8 +165,8 @@ func TestMiniMaxModelsExposeCurrentCapabilities(t *testing.T) {
|
||||
t.Fatal("provider minimax missing from catalog")
|
||||
}
|
||||
models := Models(descriptor)
|
||||
if len(models) < 3 || models[0].ID != "MiniMax-M3" || models[1].ID != "MiniMax-M3.1-Flash" || models[2].ID != "MiniMax-M2.7" {
|
||||
t.Fatalf("MiniMax current models = %#v, want MiniMax-M3, MiniMax-M3.1-Flash, then MiniMax-M2.7", modelIDs(models))
|
||||
if len(models) < 2 || models[0].ID != "MiniMax-M3" || models[1].ID != "MiniMax-M2.7" {
|
||||
t.Fatalf("MiniMax current models = %#v, want MiniMax-M3 followed by MiniMax-M2.7", modelIDs(models))
|
||||
}
|
||||
|
||||
m3 := models[0]
|
||||
@@ -180,23 +178,7 @@ func TestMiniMaxModelsExposeCurrentCapabilities(t *testing.T) {
|
||||
t.Fatalf("MiniMax-M3 modalities = input:%#v output:%#v", m3.InputModalities, m3.OutputModalities)
|
||||
}
|
||||
|
||||
m31Flash := models[1]
|
||||
if m31Flash.ContextWindow != 1_000_000 || !m31Flash.ToolCall || !m31Flash.Reasoning {
|
||||
t.Fatalf("MiniMax-M3.1-Flash capabilities = %#v, want 1M context with tools and reasoning", m31Flash)
|
||||
}
|
||||
if !reflect.DeepEqual(m31Flash.ReasoningEfforts, []string{"low", "medium", "high", "xhigh", "max"}) ||
|
||||
m31Flash.DefaultReasoningEffort != "max" {
|
||||
t.Fatalf("MiniMax-M3.1-Flash reasoning efforts = %#v default %q", m31Flash.ReasoningEfforts, m31Flash.DefaultReasoningEffort)
|
||||
}
|
||||
if !reflect.DeepEqual(m31Flash.InputModalities, []string{"text", "image", "video"}) ||
|
||||
!reflect.DeepEqual(m31Flash.OutputModalities, []string{"text"}) {
|
||||
t.Fatalf("MiniMax-M3.1-Flash modalities = input:%#v output:%#v", m31Flash.InputModalities, m31Flash.OutputModalities)
|
||||
}
|
||||
if m31Flash.InputCost != 0.15 || m31Flash.OutputCost != 0.60 {
|
||||
t.Fatalf("MiniMax-M3.1-Flash costs = %f/%f, want 0.15/0.60", m31Flash.InputCost, m31Flash.OutputCost)
|
||||
}
|
||||
|
||||
m27 := models[2]
|
||||
m27 := models[1]
|
||||
if m27.ContextWindow != 204_800 || !m27.ToolCall || !m27.Reasoning {
|
||||
t.Fatalf("MiniMax-M2.7 capabilities = %#v, want 204.8K context with tools and reasoning", m27)
|
||||
}
|
||||
|
||||
@@ -54,22 +54,9 @@ func TestDefaultedOpenGatewayURL(t *testing.T) {
|
||||
if got := defaultedOpenGatewayURL(providercatalog.Descriptor{DefaultBaseURL: "https://gw.example.com/v1"}, ""); got != "https://gw.example.com/v1/models" {
|
||||
t.Fatalf("derived = %q", got)
|
||||
}
|
||||
if got := defaultedOpenGatewayURL(providercatalog.Descriptor{DefaultBaseURL: "::not a url"}, ""); got != "https://api.twigpine.com/v1/models" {
|
||||
if got := defaultedOpenGatewayURL(providercatalog.Descriptor{DefaultBaseURL: "::not a url"}, ""); got != "https://opengateway.gitlawb.com/v1/models" {
|
||||
t.Fatalf("fallback = %q", got)
|
||||
}
|
||||
// The catalog descriptor points at the Twigpine API host.
|
||||
descriptor, ok := providercatalog.Get("gitlawb-opengateway")
|
||||
if !ok {
|
||||
t.Fatal("gitlawb-opengateway missing from catalog")
|
||||
}
|
||||
if got := defaultedOpenGatewayURL(descriptor, ""); got != "https://api.twigpine.com/v1/models" {
|
||||
t.Fatalf("catalog descriptor = %q, want https://api.twigpine.com/v1/models", got)
|
||||
}
|
||||
// A descriptor still carrying the legacy host keeps deriving from it, so
|
||||
// old-host configs resolve unchanged (the old host still serves clients).
|
||||
if got := defaultedOpenGatewayURL(providercatalog.Descriptor{DefaultBaseURL: "https://opengateway.gitlawb.com/v1"}, ""); got != "https://opengateway.gitlawb.com/v1/models" {
|
||||
t.Fatalf("legacy host = %q, want https://opengateway.gitlawb.com/v1/models", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultedOpenRouterURL(t *testing.T) {
|
||||
|
||||
@@ -19,7 +19,7 @@ import (
|
||||
|
||||
const (
|
||||
DefaultModelsDevURL = "https://models.dev/api.json"
|
||||
defaultOpenGatewayURL = "https://api.twigpine.com/v1/models"
|
||||
defaultOpenGatewayURL = "https://opengateway.gitlawb.com/v1/models"
|
||||
defaultOpenRouterURL = "https://openrouter.ai/api/v1/models"
|
||||
modelsDevSource = "models.dev"
|
||||
openGatewaySource = "opengateway"
|
||||
@@ -120,8 +120,6 @@ func ParseModelsDevProvider(body []byte, providerID string) ([]Model, error) {
|
||||
// ParseOpenGatewayCatalog parses OpenGateway's live GET /v1/models payload (or the
|
||||
// older {models:[...]} shape). The gateway already curates what it exposes, so
|
||||
// every non-empty id is accepted rather than applying the coding-model heuristic.
|
||||
// Retired entries (status "retired", listed by ?status=all) are skipped so a
|
||||
// picker never offers a model the gateway no longer serves.
|
||||
func ParseOpenGatewayCatalog(body []byte) ([]Model, error) {
|
||||
items, err := parseOpenAIStyleModelList(body)
|
||||
if err != nil {
|
||||
@@ -129,9 +127,6 @@ func ParseOpenGatewayCatalog(body []byte) ([]Model, error) {
|
||||
}
|
||||
models := make([]Model, 0, len(items))
|
||||
for _, item := range items {
|
||||
if item.retired() {
|
||||
continue
|
||||
}
|
||||
model := item.toModel("", openGatewaySource)
|
||||
if model.ID == "" {
|
||||
continue
|
||||
@@ -229,7 +224,6 @@ func PublicLiveCatalog(providerID string) bool {
|
||||
type remoteModel struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"` // OpenGateway: "active" | "retired"; absent means active
|
||||
Description string `json:"description"`
|
||||
ContextWindow int `json:"context_window"`
|
||||
ContextWindowCamel int `json:"contextWindow"`
|
||||
@@ -354,11 +348,6 @@ func (model remoteModel) toModel(key string, source string) Model {
|
||||
}
|
||||
}
|
||||
|
||||
// retired reports whether the catalog marks this entry as retired.
|
||||
func (model remoteModel) retired() bool {
|
||||
return strings.EqualFold(strings.TrimSpace(model.Status), "retired")
|
||||
}
|
||||
|
||||
func normalizedServiceTiers(tiers []string, legacy []string) []string {
|
||||
values := append(append([]string{}, tiers...), legacy...)
|
||||
seen := map[string]bool{}
|
||||
|
||||
@@ -67,58 +67,6 @@ func TestParseModelsDevProviderScopesAndMapsMetadata(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseOpenGatewayCatalogSkipsRetiredModels(t *testing.T) {
|
||||
// Mirrors GET https://api.twigpine.com/v1/models?status=all once the gateway
|
||||
// retires models: an active entry plus a retired one carrying a successor.
|
||||
body := []byte(`{
|
||||
"object": "list",
|
||||
"data": [
|
||||
{
|
||||
"id": "xiaomi/mimo-v2.6-pro",
|
||||
"object": "model",
|
||||
"name": "MiMo V2.6-Pro",
|
||||
"description": "flagship omnimodal agent model",
|
||||
"aliases": ["xiaomi/mimo-v2.5-pro", "mimo-v2.5-pro"],
|
||||
"context_window": 262144,
|
||||
"pricing": {"prompt": "0.000001", "completion": "0.000003"},
|
||||
"supports_tools": true,
|
||||
"status": "active"
|
||||
},
|
||||
{
|
||||
"id": "qwen/qwen-plus",
|
||||
"object": "model",
|
||||
"name": "Qwen Plus",
|
||||
"description": "retired general model",
|
||||
"context_window": 131072,
|
||||
"status": "retired",
|
||||
"successor": "qwen/qwen3.7-max"
|
||||
}
|
||||
]
|
||||
}`)
|
||||
|
||||
models, err := ParseOpenGatewayCatalog(body)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseOpenGatewayCatalog returned error: %v", err)
|
||||
}
|
||||
if got := strings.Join(modelIDs(models), ","); got != "xiaomi/mimo-v2.6-pro" {
|
||||
t.Fatalf("models = %q, want only the active entry", got)
|
||||
}
|
||||
descriptor, ok := providercatalog.Get("gitlawb-opengateway")
|
||||
if !ok {
|
||||
t.Fatal("gitlawb-opengateway missing from catalog")
|
||||
}
|
||||
// Onboarding keeps the default selected only on an exact id match against
|
||||
// the live list, so the default must be a real active id, not an alias.
|
||||
if !containsModelID(models, descriptor.DefaultModel) {
|
||||
t.Fatalf("default model %q missing from live list %v", descriptor.DefaultModel, modelIDs(models))
|
||||
}
|
||||
|
||||
// A list made only of retired entries is treated as empty, not offered.
|
||||
if _, err := ParseOpenGatewayCatalog([]byte(`{"data":[{"id":"old/model","status":"retired"}]}`)); err == nil {
|
||||
t.Fatal("expected an error when every entry is retired")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseOpenGatewayCatalogSupportsRichModelJSON(t *testing.T) {
|
||||
body := []byte(`{
|
||||
"models": [
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
package providermodeldiscovery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/Twigpine/zero/internal/config"
|
||||
)
|
||||
|
||||
func TestChatGPTDiscoveryUsesCurrentCompatibilityVersion(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if got := r.URL.Query().Get("client_version"); got != "0.159.0" {
|
||||
t.Errorf("client_version = %q, want documented GPT-6.1 Terra compatibility floor 0.159.0", got)
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"models":[{"slug":"gpt-future-coder"}]}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
profile := config.ProviderProfile{
|
||||
CatalogID: "chatgpt", ProviderKind: config.ProviderKindOpenAICompatible,
|
||||
BaseURL: server.URL + "/backend-api/codex", APIKey: "test-bearer",
|
||||
}
|
||||
models, err := Discover(context.Background(), profile, Options{HTTPClient: server.Client()})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(models) != 1 || models[0].ID != "gpt-future-coder" {
|
||||
t.Fatalf("models = %#v, want only gpt-future-coder", models)
|
||||
}
|
||||
}
|
||||
@@ -20,9 +20,8 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
anthropicVersion = "2023-06-01"
|
||||
// GPT-6.1 Terra's documented Codex client floor: https://learn.chatgpt.com/docs/models.
|
||||
chatGPTModelsProtocolVersion = "0.159.0"
|
||||
anthropicVersion = "2023-06-01"
|
||||
chatGPTModelsProtocolVersion = "0.146.0"
|
||||
)
|
||||
|
||||
type Model struct {
|
||||
@@ -355,7 +354,6 @@ type modelsResponseItem struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Visibility string `json:"visibility"`
|
||||
Status string `json:"status"`
|
||||
ContextWindow int `json:"context_window"`
|
||||
ContextWindowAlt int `json:"contextWindow"`
|
||||
ContextLength int `json:"context_length"`
|
||||
@@ -413,11 +411,6 @@ func parseModelsResponse(body []byte) ([]Model, error) {
|
||||
if visibility := strings.ToLower(strings.TrimSpace(item.Visibility)); visibility != "" && visibility != "list" {
|
||||
continue
|
||||
}
|
||||
// OpenGateway marks lifecycle with status "active" | "retired" (retired
|
||||
// entries only appear with ?status=all). Never offer a retired model.
|
||||
if strings.EqualFold(strings.TrimSpace(item.Status), "retired") {
|
||||
continue
|
||||
}
|
||||
seen[id] = true
|
||||
description := firstNonEmptyDiscovery(
|
||||
item.DisplayName,
|
||||
|
||||
@@ -346,70 +346,6 @@ func TestDiscoverCatalogOpenGatewayUsesLiveListWithoutKey(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoverCatalogOpenGatewayDropsRetiredAndKeepsDefault(t *testing.T) {
|
||||
// Both the catalog fetch and the live /v1/models probe return one active and
|
||||
// one retired entry. Users must only ever see the active one, and the
|
||||
// catalog's default model must be among the ids discovery returns (onboarding
|
||||
// keeps the default selected only on an exact id match).
|
||||
const body = `{"object":"list","data":[
|
||||
{"id":"xiaomi/mimo-v2.6-pro","name":"MiMo V2.6-Pro","context_window":262144,"supports_tools":true,"status":"active"},
|
||||
{"id":"qwen/qwen-plus","name":"Qwen Plus","context_window":131072,"status":"retired","successor":"qwen/qwen3.7-max"}
|
||||
]}`
|
||||
var paths []string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
paths = append(paths, r.URL.Path)
|
||||
switch r.URL.Path {
|
||||
case "/catalog/v1/models", "/v1/models":
|
||||
_, _ = w.Write([]byte(body))
|
||||
default:
|
||||
t.Fatalf("unexpected path %q", r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
descriptor, ok := providercatalog.Get("gitlawb-opengateway")
|
||||
if !ok {
|
||||
t.Fatal("gitlawb-opengateway missing from catalog")
|
||||
}
|
||||
provider := descriptor
|
||||
provider.DefaultBaseURL = server.URL + "/v1"
|
||||
models, err := DiscoverCatalog(context.Background(), provider, config.ProviderProfile{
|
||||
CatalogID: "gitlawb-opengateway",
|
||||
ProviderKind: config.ProviderKindOpenAICompatible,
|
||||
BaseURL: server.URL + "/v1",
|
||||
Model: descriptor.DefaultModel,
|
||||
}, Options{
|
||||
HTTPClient: server.Client(),
|
||||
OpenGatewayURL: server.URL + "/catalog/v1/models",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("DiscoverCatalog: %v", err)
|
||||
}
|
||||
if got := strings.Join(modelIDs(models), ","); got != "xiaomi/mimo-v2.6-pro" {
|
||||
t.Fatalf("models = %q, want only the active entry", got)
|
||||
}
|
||||
if models[0].ID != descriptor.DefaultModel {
|
||||
t.Fatalf("default model %q missing from discovered %v", descriptor.DefaultModel, modelIDs(models))
|
||||
}
|
||||
if strings.Join(paths, ",") != "/catalog/v1/models,/v1/models" {
|
||||
t.Fatalf("requests = %v, want catalog fetch then live probe", paths)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseModelsResponseSkipsRetiredStatus(t *testing.T) {
|
||||
models, err := parseModelsResponse([]byte(`{"data":[
|
||||
{"id":"active-model","status":"active"},
|
||||
{"id":"retired-model","status":"Retired"},
|
||||
{"id":"no-status-model"}
|
||||
]}`))
|
||||
if err != nil {
|
||||
t.Fatalf("parseModelsResponse: %v", err)
|
||||
}
|
||||
if got := strings.Join(modelIDs(models), ","); got != "active-model,no-status-model" {
|
||||
t.Fatalf("models = %q, want retired entry dropped", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoverCatalogOpenRouterKeepsLiveOnlyModels(t *testing.T) {
|
||||
// Catalog omits anthropic/claude-sonnet-4.5 and the generic tools-only id;
|
||||
// live retains both so preferLive keeps coding-capable live-only entries
|
||||
|
||||
@@ -49,19 +49,6 @@ func thinkingBudgetForEffort(effort string) int {
|
||||
}
|
||||
}
|
||||
|
||||
func miniMaxM31FlashEffort(model, effort string) string {
|
||||
if !strings.EqualFold(strings.TrimSpace(model), "MiniMax-M3.1-Flash") {
|
||||
return ""
|
||||
}
|
||||
effort = strings.ToLower(strings.TrimSpace(effort))
|
||||
switch effort {
|
||||
case "low", "medium", "high", "xhigh", "max":
|
||||
return effort
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// resolveThinking returns the thinking budget and the max_tokens to send. When a
|
||||
// budget is requested, max_tokens is raised if needed so the budget plus a
|
||||
// minimum response both fit (Anthropic rejects budget >= max_tokens). enabled is
|
||||
@@ -390,15 +377,11 @@ func (provider *Provider) anthropicRequest(request zeroruntime.CompletionRequest
|
||||
Messages: messages,
|
||||
Stream: true,
|
||||
}
|
||||
// MiniMax-M3.1-Flash uses adaptive thinking with output_config.effort instead
|
||||
// of Anthropic's token budget. For other models, the budget is counted against
|
||||
// max_tokens, so raise max_tokens to leave room for the response. Temperature
|
||||
// is intentionally left unset (Anthropic requires the default when thinking
|
||||
// is on).
|
||||
if effort := miniMaxM31FlashEffort(provider.model, request.ReasoningEffort); effort != "" {
|
||||
mapped.Thinking = &thinkingConfig{Type: "adaptive"}
|
||||
mapped.OutputConfig = &outputConfig{Effort: effort}
|
||||
} else if budget, effectiveMax, enabled := resolveThinking(request.ReasoningEffort, provider.maxTokens); enabled {
|
||||
// Extended thinking: enable when a reasoning effort was requested. The budget
|
||||
// is counted against max_tokens, so raise max_tokens to leave room for the
|
||||
// response. Temperature is intentionally left unset (Anthropic requires the
|
||||
// default when thinking is on).
|
||||
if budget, effectiveMax, enabled := resolveThinking(request.ReasoningEffort, provider.maxTokens); enabled {
|
||||
mapped.MaxTokens = effectiveMax
|
||||
mapped.Thinking = &thinkingConfig{Type: "enabled", BudgetTokens: budget}
|
||||
}
|
||||
|
||||
@@ -266,53 +266,6 @@ func TestStreamCompletionEnablesThinkingWhenEffortRequested(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamCompletionUsesMiniMaxAdaptiveEffort(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, effort := range []string{"low", "xhigh"} {
|
||||
effort := effort
|
||||
t.Run(effort, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
var gotBody map[string]any
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil {
|
||||
t.Fatalf("decode request body: %v", err)
|
||||
}
|
||||
writeSSEEvent(w, "message_stop", `{"type":"message_stop"}`)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
provider, err := New(Options{BaseURL: server.URL + "/", Model: "MiniMax-M3.1-Flash", MaxTokens: 64_000})
|
||||
if err != nil {
|
||||
t.Fatalf("New returned error: %v", err)
|
||||
}
|
||||
stream, err := provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
|
||||
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "hi"}},
|
||||
ReasoningEffort: effort,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("StreamCompletion returned error: %v", err)
|
||||
}
|
||||
drain(stream)
|
||||
|
||||
thinking, ok := gotBody["thinking"].(map[string]any)
|
||||
if !ok || thinking["type"] != "adaptive" {
|
||||
t.Fatalf("thinking = %#v, want adaptive", gotBody["thinking"])
|
||||
}
|
||||
if _, ok := thinking["budget_tokens"]; ok {
|
||||
t.Fatalf("MiniMax thinking must not include budget_tokens: %#v", thinking)
|
||||
}
|
||||
outputConfig, ok := gotBody["output_config"].(map[string]any)
|
||||
if !ok || outputConfig["effort"] != effort {
|
||||
t.Fatalf("output_config = %#v, want effort %q", gotBody["output_config"], effort)
|
||||
}
|
||||
if mt, _ := gotBody["max_tokens"].(float64); int(mt) != 64_000 {
|
||||
t.Fatalf("max_tokens = %#v, want unchanged 64000", gotBody["max_tokens"])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamCompletionOmitsThinkingWithoutEffort(t *testing.T) {
|
||||
t.Parallel()
|
||||
var gotBody map[string]any
|
||||
|
||||
@@ -10,21 +10,17 @@ type messagesRequest struct {
|
||||
System any `json:"system,omitempty"`
|
||||
// Thinking enables extended thinking when a reasoning effort was requested.
|
||||
// Omitted (nil) for normal requests so behavior is unchanged.
|
||||
Thinking *thinkingConfig `json:"thinking,omitempty"`
|
||||
OutputConfig *outputConfig `json:"output_config,omitempty"`
|
||||
Tools []anthropicTool `json:"tools,omitempty"`
|
||||
Stream bool `json:"stream"`
|
||||
Thinking *thinkingConfig `json:"thinking,omitempty"`
|
||||
Tools []anthropicTool `json:"tools,omitempty"`
|
||||
Stream bool `json:"stream"`
|
||||
}
|
||||
|
||||
// thinkingConfig requests extended thinking. Anthropic uses "enabled" with a
|
||||
// token budget; MiniMax-M3.1-Flash uses "adaptive" with output_config.effort.
|
||||
// thinkingConfig requests extended thinking with a token budget. Type is always
|
||||
// "enabled"; BudgetTokens is how many tokens the model may spend thinking (it is
|
||||
// counted against max_tokens).
|
||||
type thinkingConfig struct {
|
||||
Type string `json:"type"`
|
||||
BudgetTokens int `json:"budget_tokens,omitempty"`
|
||||
}
|
||||
|
||||
type outputConfig struct {
|
||||
Effort string `json:"effort"`
|
||||
BudgetTokens int `json:"budget_tokens"`
|
||||
}
|
||||
|
||||
type anthropicMessage struct {
|
||||
|
||||
@@ -160,7 +160,7 @@ func TestNewPassesOpenGatewayHY3ModelThrough(t *testing.T) {
|
||||
for range stream {
|
||||
}
|
||||
|
||||
if transport.request.URL.String() != "https://api.twigpine.com/v1/chat/completions" {
|
||||
if transport.request.URL.String() != "https://opengateway.gitlawb.com/v1/chat/completions" {
|
||||
t.Fatalf("request URL = %q, want OpenGateway chat completions", transport.request.URL.String())
|
||||
}
|
||||
var body map[string]any
|
||||
|
||||
@@ -288,11 +288,10 @@ func TestScanSSEDataWithContextContentResetsOnData(t *testing.T) {
|
||||
pr, pw := io.Pipe()
|
||||
|
||||
go func() {
|
||||
// One data line every 30ms for ~750ms (well past the 600ms content window,
|
||||
// with ~470ms of slack under the 500ms idle timeout, so a scheduling stall
|
||||
// on a busy shared runner does not trip it), so the content watchdog keeps
|
||||
// resetting, then close cleanly.
|
||||
for i := 0; i < 25; i++ {
|
||||
// One data line every 30ms for ~300ms (well past the 120ms content window,
|
||||
// with comfortable slack under the 100ms idle timeout), so the content
|
||||
// watchdog keeps resetting, then close cleanly.
|
||||
for i := 0; i < 10; i++ {
|
||||
if _, err := io.WriteString(pw, "data: chunk\n\n"); err != nil {
|
||||
return
|
||||
}
|
||||
@@ -304,7 +303,7 @@ func TestScanSSEDataWithContextContentResetsOnData(t *testing.T) {
|
||||
n := 0
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- ScanSSEDataWithContext(context.Background(), func() {}, pr, 500*time.Millisecond, func(string) bool { n++; return true })
|
||||
done <- ScanSSEDataWithContext(context.Background(), func() {}, pr, 100*time.Millisecond, func(string) bool { n++; return true })
|
||||
}()
|
||||
|
||||
select {
|
||||
@@ -315,8 +314,8 @@ func TestScanSSEDataWithContextContentResetsOnData(t *testing.T) {
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("ScanSSEDataWithContext hung on a producing stream")
|
||||
}
|
||||
if n != 25 {
|
||||
t.Fatalf("handled %d data lines, want 25", n)
|
||||
if n != 10 {
|
||||
t.Fatalf("handled %d data lines, want 10", n)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -63,14 +63,6 @@ import (
|
||||
// rest of a key from prose written after a whole one: that prose would be eaten,
|
||||
// and a following JWT would lose its header.
|
||||
//
|
||||
// CALLER-SUPPLIED SECRETS (Options.ExtraSecretValues) get the same treatment
|
||||
// from redactExtraSecretValues. Such a value is often opaque, matching no shape
|
||||
// here, so without its own gap-tolerant match a separator inside it left both
|
||||
// fragments in the output. Unlike a shape, the value is known exactly and has no
|
||||
// word boundary to judge, so it is found in a copy with the separators removed
|
||||
// and mapped back. RedactString runs it first, before any shape matcher, so a
|
||||
// caller that clips or reformats the result afterwards never sees a fragment.
|
||||
//
|
||||
// WHAT THIS DELIBERATELY DOES NOT TREAT AS A SEPARATOR: tab, newline and
|
||||
// carriage return. Those three are real text structure rather than invisible
|
||||
// filler, so a credential broken across a line boundary is still two visible
|
||||
@@ -443,125 +435,6 @@ func openAIShapeIsSecret(match string) bool {
|
||||
return !strings.Contains(strings.TrimPrefix(match, "sk-"), "-")
|
||||
}
|
||||
|
||||
// redactExtraSecretValues replaces every occurrence of every caller-supplied
|
||||
// secret, whole or with runs of invisible separators between its characters.
|
||||
//
|
||||
// All occurrences, overlapping ones included, are found in the same text and
|
||||
// overlapping spans are merged before anything is replaced. Replacing one at a
|
||||
// time let each replacement consume bytes another occurrence needed: a shorter
|
||||
// value inside a split longer one, a periodic value ("abcabc" in
|
||||
// "abc<NUL>abcabc"), a value whose first and last characters match, or two
|
||||
// values that overlap all left a fragment visible. Occurrences that only touch
|
||||
// stay separate replacements, as they always were.
|
||||
//
|
||||
// Separators inside a match are covered by its replacement; a split match never
|
||||
// starts or ends on one, so those beside it stay, unless the value as written
|
||||
// starts or ends with them. Tab, newline and carriage return are not separators
|
||||
// (see above).
|
||||
func redactExtraSecretValues(value string, secrets []string, replacement string) string {
|
||||
haystack, offsets := value, []int(nil)
|
||||
if containsSplitSeparator(value) {
|
||||
haystack, offsets = compactSplitSeparators(value)
|
||||
}
|
||||
var spans [][2]int
|
||||
for _, secret := range secrets {
|
||||
if strings.TrimSpace(secret) == "" {
|
||||
continue
|
||||
}
|
||||
needle := secret
|
||||
if offsets != nil {
|
||||
// The value as written always matches, as it did before split
|
||||
// matching existed. The compacted match cannot stand in for it: a
|
||||
// value made only of separators compacts to nothing, and separators
|
||||
// at either end of a value fall outside its compacted match.
|
||||
if strings.Contains(value, secret) {
|
||||
for _, at := range overlappingIndexes(value, secret) {
|
||||
spans = append(spans, [2]int{at, at + len(secret)})
|
||||
}
|
||||
}
|
||||
needle = stripSplitSeparators(secret)
|
||||
}
|
||||
if needle == "" || !strings.Contains(haystack, needle) {
|
||||
continue
|
||||
}
|
||||
for _, at := range overlappingIndexes(haystack, needle) {
|
||||
start, end := at, at+len(needle)
|
||||
if offsets != nil {
|
||||
start, end = offsets[start], offsets[end-1]+1
|
||||
}
|
||||
spans = append(spans, [2]int{start, end})
|
||||
}
|
||||
}
|
||||
if len(spans) == 0 {
|
||||
return value
|
||||
}
|
||||
sort.Slice(spans, func(i, j int) bool { return spans[i][0] < spans[j][0] })
|
||||
var out strings.Builder
|
||||
out.Grow(len(value))
|
||||
written := 0
|
||||
for i := 0; i < len(spans); {
|
||||
start, end := spans[i][0], spans[i][1]
|
||||
for i++; i < len(spans) && spans[i][0] < end; i++ {
|
||||
end = max(end, spans[i][1])
|
||||
}
|
||||
out.WriteString(value[written:start])
|
||||
out.WriteString(replacement)
|
||||
written = end
|
||||
}
|
||||
out.WriteString(value[written:])
|
||||
return out.String()
|
||||
}
|
||||
|
||||
// compactSplitSeparators returns value with every splitSecretSeparator removed,
|
||||
// and for each byte of the result the offset of that byte in value.
|
||||
func compactSplitSeparators(value string) (string, []int) {
|
||||
var builder strings.Builder
|
||||
builder.Grow(len(value))
|
||||
offsets := make([]int, 0, len(value))
|
||||
for offset := 0; offset < len(value); {
|
||||
r, width := utf8.DecodeRuneInString(value[offset:])
|
||||
if !splitSecretSeparator(r) {
|
||||
builder.WriteString(value[offset : offset+width])
|
||||
for i := 0; i < width; i++ {
|
||||
offsets = append(offsets, offset+i)
|
||||
}
|
||||
}
|
||||
offset += width
|
||||
}
|
||||
return builder.String(), offsets
|
||||
}
|
||||
|
||||
// overlappingIndexes returns the start of every occurrence of needle in
|
||||
// haystack, overlapping ones included, in one linear Knuth-Morris-Pratt pass:
|
||||
// restarting a search one byte after each match is quadratic for a periodic
|
||||
// value over text full of it.
|
||||
func overlappingIndexes(haystack, needle string) []int {
|
||||
fallback := make([]int, len(needle))
|
||||
for i, k := 1, 0; i < len(needle); i++ {
|
||||
for k > 0 && needle[i] != needle[k] {
|
||||
k = fallback[k-1]
|
||||
}
|
||||
if needle[i] == needle[k] {
|
||||
k++
|
||||
}
|
||||
fallback[i] = k
|
||||
}
|
||||
var starts []int
|
||||
for i, k := 0, 0; i < len(haystack); i++ {
|
||||
for k > 0 && haystack[i] != needle[k] {
|
||||
k = fallback[k-1]
|
||||
}
|
||||
if haystack[i] == needle[k] {
|
||||
k++
|
||||
}
|
||||
if k == len(needle) {
|
||||
starts = append(starts, i-len(needle)+1)
|
||||
k = fallback[k-1]
|
||||
}
|
||||
}
|
||||
return starts
|
||||
}
|
||||
|
||||
// redactControlSplitSecrets replaces credentials whose body is interrupted by
|
||||
// invisible characters. It is a no-op for text containing none of them, which is
|
||||
// nearly all text, so the contiguous path keeps its behavior exactly.
|
||||
|
||||
@@ -105,114 +105,6 @@ func TestRedactStringCoversTheSeparatorsInsideASplitSecret(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A CALLER-SUPPLIED SECRET IS A CREDENTIAL TOO.
|
||||
//
|
||||
// ExtraSecretValues names a value the caller knows is secret, whatever its
|
||||
// shape: an opaque provider key matches none of the shape matchers, so the
|
||||
// split pass above never saw it, and a separator inside it left both fragments
|
||||
// in the output. It has to be matched across the same separators, cover them in
|
||||
// its replacement, and leave the separators beside it where they were.
|
||||
func TestRedactStringRedactsExtraSecretValuesSplitByInvisibleCharacters(t *testing.T) {
|
||||
const secret = "zq7Mv2Lp9Xr4Tn8Wk3"
|
||||
options := Options{ExtraSecretValues: []string{secret}}
|
||||
if out := RedactString("key "+secret, Options{}); !strings.Contains(out, secret) {
|
||||
t.Fatalf("SETUP INVALID: a shape matcher already redacts %q, so this would not reach the explicit-value pass: %q", secret, out)
|
||||
}
|
||||
for _, testCase := range []struct {
|
||||
name string
|
||||
value string
|
||||
want string
|
||||
}{
|
||||
{"NUL", "key " + secret[:6] + "\x00" + secret[6:], "key [REDACTED]"},
|
||||
{"zero width space", "key " + secret[:1] + "\u200b" + secret[1:], "key [REDACTED]"},
|
||||
{"soft hyphen near the end", "key " + secret[:len(secret)-1] + "\u00ad" + secret[len(secret)-1:], "key [REDACTED]"},
|
||||
{"every gap", "key " + strings.Join(strings.Split(secret, ""), "\x1b"), "key [REDACTED]"},
|
||||
{"adjacent separators", "key " + secret[:9] + "\x00\x1b\u2060" + secret[9:], "key [REDACTED]"},
|
||||
{"twice", secret[:3] + "\x00" + secret[3:] + " and " + secret[:12] + "\ufeff" + secret[12:], "[REDACTED] and [REDACTED]"},
|
||||
{"separators beside it stay", "\x00" + secret[:5] + "\u200b" + secret[5:] + "\x00", "\x00[REDACTED]\x00"},
|
||||
{"a line break is not a gap", secret[:5] + "\n" + secret[5:], secret[:5] + "\n" + secret[5:]},
|
||||
} {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
if got := RedactString(testCase.value, options); got != testCase.want {
|
||||
t.Errorf("RedactString(%q) = %q, want %q", testCase.value, got, testCase.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
// A SHORTER SECRET MUST NOT CLAIM PART OF A SPLIT LONGER ONE. Longest first
|
||||
// has to hold across the contiguous and split matches together: replacing
|
||||
// a contiguous shorter value first left the rest of the split longer one
|
||||
// ("[REDACTED]\x00Lp9Xr4Tn8Wk3") visible.
|
||||
for _, testCase := range []struct {
|
||||
name string
|
||||
secrets []string
|
||||
value string
|
||||
want string
|
||||
}{
|
||||
{"prefix", []string{secret[:6], secret}, "key " + secret[:6] + "\x00" + secret[6:], "key [REDACTED]"},
|
||||
{"suffix", []string{secret[9:], secret}, "key " + secret[:9] + "\u200b" + secret[9:], "key [REDACTED]"},
|
||||
{"inside", []string{secret[4:10], secret}, "key " + secret[:2] + "\x00" + secret[2:], "key [REDACTED]"},
|
||||
{"shorter alone too", []string{secret, secret[:6]}, secret[:6] + " and " + secret[:6] + "\x00" + secret[6:], "[REDACTED] and [REDACTED]"},
|
||||
} {
|
||||
t.Run("overlap "+testCase.name, func(t *testing.T) {
|
||||
if got := RedactString(testCase.value, Options{ExtraSecretValues: testCase.secrets}); got != testCase.want {
|
||||
t.Errorf("RedactString(%q, %q) = %q, want %q", testCase.value, testCase.secrets, got, testCase.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
// OVERLAPPING OCCURRENCES ARE ONE SPAN. Replacing one occurrence (either
|
||||
// form) and then looking for the next consumed bytes the other needed:
|
||||
// "abc\x00abcabc" holds a split "abc\x00abc" overlapping a whole "abcabc",
|
||||
// and replacing the whole one first left "abc\x00" in front of it. Every
|
||||
// occurrence of every value, in either form, has to be found in the same
|
||||
// text and overlapping ones replaced together.
|
||||
for _, testCase := range []struct {
|
||||
name string
|
||||
secrets []string
|
||||
value string
|
||||
want string
|
||||
}{
|
||||
{"split then whole, periodic secret", []string{"abcabc"}, "abc\x00abcabc", "[REDACTED]"},
|
||||
{"whole then split, periodic secret", []string{"abcabc"}, "abcabc\x00abc", "[REDACTED]"},
|
||||
{"shared first and last character", []string{"xY7qx"}, "key xY7q\u200bxY7qx", "key [REDACTED]"},
|
||||
{"two secrets overlapping across a split", []string{"zq7Mv2Lp", "Lp9Xr4Tn"}, "key zq7Mv2L\x00p9Xr4Tn", "key [REDACTED]"},
|
||||
{"whole occurrences overlapping", []string{"xY7qx"}, "xY7qxY7qx", "[REDACTED]"},
|
||||
{"adjacent occurrences stay two", []string{"abc"}, "abc\x00abc", "[REDACTED]\x00[REDACTED]"},
|
||||
} {
|
||||
t.Run("overlapping occurrences "+testCase.name, func(t *testing.T) {
|
||||
if got := RedactString(testCase.value, Options{ExtraSecretValues: testCase.secrets}); got != testCase.want {
|
||||
t.Errorf("RedactString(%q, %q) = %q, want %q", testCase.value, testCase.secrets, got, testCase.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
// A SECRET AS WRITTEN IS ALWAYS REDACTED. The split match compares the text
|
||||
// and the value with separators removed; it adds to the whole match and
|
||||
// must never replace it. Once the text held any separator, a value made only
|
||||
// of separators compacted to nothing and was skipped, and separators at
|
||||
// either end of a value fell outside its compacted match, so both stayed
|
||||
// visible where a plain replacement had always caught them.
|
||||
for _, testCase := range []struct {
|
||||
name string
|
||||
secrets []string
|
||||
value string
|
||||
want string
|
||||
}{
|
||||
{"separator-only secret", []string{"\x00"}, "a\x00b", "a[REDACTED]b"},
|
||||
{"separator-only secret beside another separator", []string{"\u200b"}, "a\u200bb\x00", "a[REDACTED]b\x00"},
|
||||
{"separators at both ends", []string{"\u200babc\x00"}, "x\u200babc\x00y", "x[REDACTED]y"},
|
||||
} {
|
||||
t.Run("as written "+testCase.name, func(t *testing.T) {
|
||||
if got := RedactString(testCase.value, Options{ExtraSecretValues: testCase.secrets}); got != testCase.want {
|
||||
t.Errorf("RedactString(%q, %q) = %q, want %q", testCase.value, testCase.secrets, got, testCase.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
// A value the caller names that is NOT in the text leaves the text alone,
|
||||
// separators and all.
|
||||
if value := "unrelated\x00text \u200bhere"; RedactString(value, options) != value {
|
||||
t.Errorf("RedactString rewrote text without the secret in it: %q", RedactString(value, options))
|
||||
}
|
||||
}
|
||||
|
||||
// NOTHING ELSE MOVES. The compaction exists to find a match; it must never
|
||||
// reach text that has no secret in it, and text with no invisible character at
|
||||
// all must take exactly the path it took before.
|
||||
|
||||
@@ -463,7 +463,15 @@ func RedactString(value string, options Options) string {
|
||||
replacement := replacement(options)
|
||||
redacted := value
|
||||
if len(options.ExtraSecretValues) > 0 {
|
||||
redacted = redactExtraSecretValues(redacted, options.ExtraSecretValues, replacement)
|
||||
secrets := append([]string{}, options.ExtraSecretValues...)
|
||||
sort.SliceStable(secrets, func(i, j int) bool {
|
||||
return len(secrets[i]) > len(secrets[j])
|
||||
})
|
||||
for _, secret := range secrets {
|
||||
if strings.TrimSpace(secret) != "" {
|
||||
redacted = strings.ReplaceAll(redacted, secret, replacement)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if strings.Contains(redacted, "-----BEGIN ") && strings.Contains(redacted, "PRIVATE KEY") {
|
||||
|
||||
@@ -20,6 +20,10 @@ type AnalysisResult struct {
|
||||
TooComplex bool
|
||||
// Programs lists the distinct top-level command names found, for diagnostics.
|
||||
Programs []string
|
||||
// GitInit is set when the script would create a git repository. It is a
|
||||
// separate signal from Destructive and Network because the decision it feeds
|
||||
// depends on WHERE the workspace sits, which the analyzer cannot see.
|
||||
GitInit bool
|
||||
}
|
||||
|
||||
// destructivePrograms are commands that can irrecoverably destroy data.
|
||||
@@ -183,6 +187,9 @@ func analyzeInto(script string, result *AnalysisResult, seen map[string]bool, de
|
||||
if commandUsesNetwork(prog, rest) {
|
||||
result.Network = true
|
||||
}
|
||||
if commandCreatesGitRepository(call, prog, rest) {
|
||||
result.GitInit = true
|
||||
}
|
||||
if destructivePrograms[prog] ||
|
||||
(prog == "rm" && hasRecursiveForce(rest)) ||
|
||||
(powerShellRemoveItemPrograms[prog] && hasPowerShellRecursiveForce(rest)) ||
|
||||
@@ -277,8 +284,77 @@ func packageManagerOffline(words []string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// gitGlobalOptionsTakingValue are git's own options that consume the NEXT word.
|
||||
//
|
||||
// firstSubcommand skips words beginning with "-" but not the value that follows
|
||||
// one, so "git -C sub clone <url>" made it answer "sub" and the command was
|
||||
// classified as touching no network at all. The "--opt=value" spelling is one
|
||||
// token and needs no entry here.
|
||||
//
|
||||
// Keys are lowercase because the analyzer lowercases every word before it gets
|
||||
// here, which also means -C and -c arrive identically. That is fine: both
|
||||
// consume a value, and nothing below needs to tell them apart.
|
||||
var gitGlobalOptionsTakingValue = map[string]bool{
|
||||
"-c": true, "-C": true,
|
||||
"--git-dir": true, "--work-tree": true, "--namespace": true,
|
||||
"--exec-path": true, "--config-env": true, "--super-prefix": true,
|
||||
"--attr-source": true,
|
||||
}
|
||||
|
||||
// gitSubcommand returns git's subcommand, skipping global options AND the values
|
||||
// they consume. Returns "" when the words carry no subcommand.
|
||||
func gitSubcommand(words []string) string {
|
||||
for index := 0; index < len(words); index++ {
|
||||
word := words[index]
|
||||
if word == "" {
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(word, "-") {
|
||||
return word
|
||||
}
|
||||
// "--opt=value" is one token and carries its own value.
|
||||
if strings.HasPrefix(word, "--") && strings.Contains(word, "=") {
|
||||
continue
|
||||
}
|
||||
if gitGlobalOptionsTakingValue[word] {
|
||||
index++
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// commandCreatesGitRepository reports whether this call would create a git
|
||||
// repository. It shares gitSubcommand with the network gate deliberately, so the
|
||||
// global options that already bypassed that gate cannot bypass this one either:
|
||||
// "git -C sub init" and "git -c k=v init" both resolve to init.
|
||||
//
|
||||
// init-db is git's original spelling and still works today. clone creates a
|
||||
// repository as surely as init does, with its config and hooks written by the
|
||||
// fetch rather than by hand: "git clone <url> ." into a workspace governed by an
|
||||
// ancestor repository lands a root .git whose carveouts setup never planned, so
|
||||
// it is the same nested-repository decision. The network prompt is a separate
|
||||
// gate and granting it does not answer this one. Reported by @gnanam1990.
|
||||
//
|
||||
// The subcommand is the one git runs after alias resolution, which git_alias.go
|
||||
// models: definitions from the command line and from the environment the call
|
||||
// sets, expanded with git's own quoting and its in-alias option handling. A
|
||||
// definition this analyzer cannot read makes the command unclassifiable, and
|
||||
// under this guard that is a refusal.
|
||||
func commandCreatesGitRepository(call *syntax.CallExpr, prog string, args []*syntax.Word) bool {
|
||||
if prog != "git" {
|
||||
return false
|
||||
}
|
||||
words := literalWordTexts(args)
|
||||
aliases := gitAliasTable{}
|
||||
if !gitEnvironmentAliases(aliases, callAssignments(call, args)) {
|
||||
return true
|
||||
}
|
||||
gitInlineAliases(aliases, words)
|
||||
return gitSubcommandCreatesRepository(gitSubcommand(words), aliases)
|
||||
}
|
||||
|
||||
func gitUsesNetwork(words []string) bool {
|
||||
switch firstSubcommand(words, nil) {
|
||||
switch gitSubcommand(words) {
|
||||
case "clone", "fetch", "pull", "push", "ls-remote", "archive":
|
||||
return true
|
||||
default:
|
||||
|
||||
@@ -394,6 +394,19 @@ func (engine *Engine) Evaluate(ctx context.Context, request Request) Decision {
|
||||
return deny(request, risk, block.Code, block.Path, block.Reason, false)
|
||||
}
|
||||
}
|
||||
// BEFORE THE NETWORK GATE, because this one cannot be approved away.
|
||||
//
|
||||
// `git clone` is both a network command and a repository-creating one. With
|
||||
// the network gate first, a clone in a governed nested workspace under the
|
||||
// default NetworkDeny came back as a network prompt: the operator was asked
|
||||
// to approve network access for a command that approval could never make
|
||||
// runnable, and the reason that actually decides it was never shown. The
|
||||
// refusal below does not depend on policy, permission or mode, so it is the
|
||||
// answer whatever the network gate would have said, and it is the one with a
|
||||
// remedy the operator can act on. Reported by @jatmn.
|
||||
if HasRiskCategory(risk, "nested_git_init") {
|
||||
return deny(request, risk, BlockNestedGitInit, "", ReasonNestedGitInit, false)
|
||||
}
|
||||
netMode := engine.effectiveNetworkMode(policy)
|
||||
if netMode == NetworkDeny && HasRiskCategory(risk, "network") && !engine.toolNetworkExempt(request) {
|
||||
if request.SideEffect == SideEffectShell && request.PermissionMode != PermissionUnsafe {
|
||||
|
||||
@@ -0,0 +1,376 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"mvdan.cc/sh/v3/syntax"
|
||||
)
|
||||
|
||||
// THE NESTED-REPOSITORY GUARD CLASSIFIES THE COMMAND GIT RUNS, NOT THE ONE
|
||||
// TYPED. Git rewrites its argument vector before choosing a builtin: an alias
|
||||
// defined on the command line, in the environment or through --config-env is
|
||||
// split with shell-style quoting, its own leading global options are applied,
|
||||
// and the result is looked up again until a builtin is reached. Reading the
|
||||
// literal token, or the first whitespace-separated field of an alias value,
|
||||
// let `git -c alias.a='-c alias.b=init b' a .` and `git -c 'alias.a="init"' a .`
|
||||
// create a repository the workspace profile carves nothing out for. What
|
||||
// follows models that rewrite, and where the model runs out it refuses rather
|
||||
// than guesses: under this guard the safe answer to "could this make a
|
||||
// repository" is yes. Reported by @gnanam1990.
|
||||
|
||||
// gitAliasTable maps a lowercased alias name to every definition the command
|
||||
// supplies for it. Git keeps one value per key, the last one written, and the
|
||||
// order in which the environment, -c and --config-env feed that list is not
|
||||
// something this guard needs to reproduce: a definition git would discard but
|
||||
// which reaches init here costs one refusal, while the opposite mistake costs
|
||||
// a repository. So every definition is kept, and any of them reaching init
|
||||
// refuses. For the same reason an alias that shadows a builtin, which git
|
||||
// ignores, is followed here rather than skipped: this file carries no list of
|
||||
// git's commands, and refusing `-c alias.status=init status` loses nothing.
|
||||
type gitAliasTable map[string][]gitAliasDefinition
|
||||
|
||||
// gitAliasDefinition is one alias value. unknown marks a definition whose text
|
||||
// cannot be read statically, such as --config-env=alias.x=VAR or a
|
||||
// GIT_CONFIG_VALUE_n written as $var: git will run whatever it holds, and
|
||||
// this guard cannot say what that is.
|
||||
type gitAliasDefinition struct {
|
||||
text string
|
||||
unknown bool
|
||||
}
|
||||
|
||||
func (table gitAliasTable) define(name, text string) {
|
||||
name = strings.ToLower(strings.TrimSpace(name))
|
||||
if name == "" {
|
||||
return
|
||||
}
|
||||
table[name] = append(table[name], gitAliasDefinition{text: strings.ToLower(text)})
|
||||
}
|
||||
|
||||
func (table gitAliasTable) defineUnknown(name string) {
|
||||
name = strings.ToLower(strings.TrimSpace(name))
|
||||
if name == "" {
|
||||
return
|
||||
}
|
||||
table[name] = append(table[name], gitAliasDefinition{unknown: true})
|
||||
}
|
||||
|
||||
// defineSetting records a `key=value` config setting when key names an alias.
|
||||
// readable is false when the value is only known by reference, which makes the
|
||||
// alias unknown rather than absent. A setting without "=" is a boolean in git
|
||||
// (`alias.x` expands to "true") and defines nothing worth following.
|
||||
func (table gitAliasTable) defineSetting(setting string, readable bool) {
|
||||
name, value, found := strings.Cut(setting, "=")
|
||||
if !found {
|
||||
return
|
||||
}
|
||||
alias, ok := strings.CutPrefix(strings.ToLower(strings.TrimSpace(name)), "alias.")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !readable {
|
||||
table.defineUnknown(alias)
|
||||
return
|
||||
}
|
||||
table.define(alias, value)
|
||||
}
|
||||
|
||||
// splitGitAliasExpansion tokenizes an alias value the way git's split_cmdline
|
||||
// does: whitespace separates words, a single or double quote opens a quoted
|
||||
// span anywhere inside a word, and a backslash outside single quotes escapes
|
||||
// the next byte. An unclosed quote or a trailing backslash is a fatal error in
|
||||
// git ("bad alias string"), so ok is false and nothing would have run.
|
||||
func splitGitAliasExpansion(text string) (argv []string, ok bool) {
|
||||
var current strings.Builder
|
||||
var quoted byte
|
||||
inWord := false
|
||||
for index := 0; index < len(text); index++ {
|
||||
c := text[index]
|
||||
switch {
|
||||
case quoted == 0 && isGitSpace(c):
|
||||
if inWord {
|
||||
argv = append(argv, current.String())
|
||||
current.Reset()
|
||||
inWord = false
|
||||
}
|
||||
case quoted == 0 && (c == '\'' || c == '"'):
|
||||
quoted = c
|
||||
inWord = true
|
||||
case c == quoted:
|
||||
quoted = 0
|
||||
default:
|
||||
if c == '\\' && quoted != '\'' {
|
||||
index++
|
||||
if index >= len(text) {
|
||||
return nil, false
|
||||
}
|
||||
c = text[index]
|
||||
}
|
||||
current.WriteByte(c)
|
||||
inWord = true
|
||||
}
|
||||
}
|
||||
if quoted != 0 {
|
||||
return nil, false
|
||||
}
|
||||
if inWord {
|
||||
argv = append(argv, current.String())
|
||||
}
|
||||
return argv, true
|
||||
}
|
||||
|
||||
func isGitSpace(c byte) bool {
|
||||
switch c {
|
||||
case ' ', '\t', '\n', '\r', '\v', '\f':
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// gitAliasExpansionSubcommand runs a split expansion through git's own
|
||||
// handling of global options inside an alias and returns the subcommand git
|
||||
// would look up next. Inside an expansion git accepts -c and --config-env,
|
||||
// which push settings the next lookup sees (that is how `-c alias.b=init b`
|
||||
// reaches init), and the pager switches, which change nothing that runs. An
|
||||
// option that changes the environment (-C, --git-dir, --bare, ...) is fatal
|
||||
// inside an alias, and an expansion made only of options is an empty alias;
|
||||
// both return ok false. So does an option this guard does not model, because
|
||||
// "git would refuse this" and "this guard cannot read this" end the same way.
|
||||
func gitAliasExpansionSubcommand(argv []string, aliases gitAliasTable) (subcommand string, ok bool) {
|
||||
for index := 0; index < len(argv); index++ {
|
||||
word := argv[index]
|
||||
if word == "" {
|
||||
return "", false
|
||||
}
|
||||
if !strings.HasPrefix(word, "-") {
|
||||
return word, true
|
||||
}
|
||||
switch {
|
||||
case word == "-c", word == "--config-env":
|
||||
if index+1 >= len(argv) {
|
||||
return "", false
|
||||
}
|
||||
index++
|
||||
aliases.defineSetting(argv[index], word == "-c")
|
||||
case strings.HasPrefix(word, "--config-env="):
|
||||
aliases.defineSetting(strings.TrimPrefix(word, "--config-env="), false)
|
||||
case word == "-p", word == "--paginate", word == "-P", word == "--no-pager":
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// gitSubcommandCreatesRepository reports whether subcommand, resolved through
|
||||
// aliases the way git resolves it, is init, init-db or clone. The chain is
|
||||
// followed to its end with the names on the current path kept, so a cycle is a
|
||||
// refusal here the way "alias loop detected" is fatal in git. A shell alias
|
||||
// runs a program this analyzer cannot classify; an expansion git cannot split,
|
||||
// cannot run, or that leaves no subcommand behind is the same case, and so is
|
||||
// a definition whose text is not readable. Every one of those refuses.
|
||||
func gitSubcommandCreatesRepository(subcommand string, aliases gitAliasTable) bool {
|
||||
return gitAliasPathCreatesRepository(subcommand, aliases, nil)
|
||||
}
|
||||
|
||||
func gitAliasPathCreatesRepository(subcommand string, aliases gitAliasTable, path []string) bool {
|
||||
switch subcommand {
|
||||
case "init", "init-db", "clone":
|
||||
return true
|
||||
}
|
||||
definitions, ok := aliases[subcommand]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
for _, seen := range path {
|
||||
if seen == subcommand {
|
||||
return true
|
||||
}
|
||||
}
|
||||
path = append(path, subcommand)
|
||||
for _, definition := range definitions {
|
||||
if definition.unknown || strings.HasPrefix(definition.text, "!") {
|
||||
return true
|
||||
}
|
||||
argv, ok := splitGitAliasExpansion(definition.text)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
next, ok := gitAliasExpansionSubcommand(argv, aliases)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
if gitAliasPathCreatesRepository(next, aliases, path) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// gitInlineAliases records the alias definitions supplied ahead of the
|
||||
// subcommand: `-c alias.NAME=EXPANSION`, and `--config-env alias.NAME=VAR` in
|
||||
// both spellings, whose value lives in a variable this analyzer does not read.
|
||||
// Git accepts no attached `-cNAME=VALUE` spelling ("unknown option"), so none
|
||||
// is recognised here. Other global options that take a value are stepped over
|
||||
// the way gitSubcommand steps over them, so a value that happens to look like
|
||||
// a subcommand does not end the scan early.
|
||||
func gitInlineAliases(aliases gitAliasTable, words []string) {
|
||||
for index := 0; index < len(words); index++ {
|
||||
word := words[index]
|
||||
if word == "" {
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(word, "-") {
|
||||
return
|
||||
}
|
||||
switch {
|
||||
case word == "-c", word == "--config-env":
|
||||
if index+1 < len(words) {
|
||||
index++
|
||||
aliases.defineSetting(words[index], word == "-c")
|
||||
}
|
||||
case strings.HasPrefix(word, "--config-env="):
|
||||
aliases.defineSetting(strings.TrimPrefix(word, "--config-env="), false)
|
||||
case strings.HasPrefix(word, "--") && strings.Contains(word, "="):
|
||||
case gitGlobalOptionsTakingValue[word]:
|
||||
index++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// shellAssignment is one NAME=value the command hands to git's environment.
|
||||
// readable is false when the value is an expansion this analyzer cannot see.
|
||||
type shellAssignment struct {
|
||||
value string
|
||||
readable bool
|
||||
}
|
||||
|
||||
// callAssignments collects the assignments in force for the program: the
|
||||
// shell prefix (FOO=bar git ...) and the NAME=value words an env wrapper
|
||||
// forwards (env FOO=bar git ...), which sit ahead of the program word.
|
||||
func callAssignments(call *syntax.CallExpr, rest []*syntax.Word) map[string]shellAssignment {
|
||||
assignments := map[string]shellAssignment{}
|
||||
if call == nil {
|
||||
return assignments
|
||||
}
|
||||
for _, assign := range call.Assigns {
|
||||
if assign == nil || assign.Name == nil {
|
||||
continue
|
||||
}
|
||||
readable := !assign.Append && assign.Array == nil && assign.Index == nil &&
|
||||
(assign.Value == nil || isLiteralWord(assign.Value))
|
||||
assignments[assign.Name.Value] = shellAssignment{value: wordText(assign.Value), readable: readable}
|
||||
}
|
||||
program := len(call.Args) - len(rest) - 1
|
||||
for index := 0; index < program && index < len(call.Args); index++ {
|
||||
name, value, found := strings.Cut(wordText(call.Args[index]), "=")
|
||||
if !found || !isEnvName(name) {
|
||||
continue
|
||||
}
|
||||
assignments[name] = shellAssignment{value: value, readable: isLiteralWord(call.Args[index])}
|
||||
}
|
||||
return assignments
|
||||
}
|
||||
|
||||
func isEnvName(name string) bool {
|
||||
if name == "" {
|
||||
return false
|
||||
}
|
||||
for index, r := range name {
|
||||
switch {
|
||||
case r == '_', r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z':
|
||||
case r >= '0' && r <= '9' && index > 0:
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// gitEnvironmentAliases records the alias definitions the command hands git
|
||||
// through its environment, which git reads exactly as it reads -c:
|
||||
// GIT_CONFIG_COUNT with GIT_CONFIG_KEY_n and GIT_CONFIG_VALUE_n, and
|
||||
// GIT_CONFIG_PARAMETERS. It reports false when a definition this guard needs
|
||||
// cannot be read: a count, a key or the parameter list written as an
|
||||
// expansion, or the value of an alias key written that way. A count git would
|
||||
// reject, or a numbered key with no value, is reported the same way; git dies
|
||||
// on those, so refusing them costs nothing.
|
||||
func gitEnvironmentAliases(aliases gitAliasTable, env map[string]shellAssignment) bool {
|
||||
if parameters, ok := env["GIT_CONFIG_PARAMETERS"]; ok {
|
||||
if !parameters.readable {
|
||||
return false
|
||||
}
|
||||
settings, ok := splitGitConfigParameters(parameters.value)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
for _, setting := range settings {
|
||||
aliases.defineSetting(setting, true)
|
||||
}
|
||||
}
|
||||
count, ok := env["GIT_CONFIG_COUNT"]
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
if !count.readable {
|
||||
return false
|
||||
}
|
||||
n, err := strconv.Atoi(strings.TrimSpace(count.value))
|
||||
if err != nil || n < 0 || n > 1024 {
|
||||
return false
|
||||
}
|
||||
for index := 0; index < n; index++ {
|
||||
key, ok := env["GIT_CONFIG_KEY_"+strconv.Itoa(index)]
|
||||
if !ok || !key.readable {
|
||||
return false
|
||||
}
|
||||
value, ok := env["GIT_CONFIG_VALUE_"+strconv.Itoa(index)]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
aliases.defineSetting(key.value+"="+value.value, value.readable)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// splitGitConfigParameters dequotes GIT_CONFIG_PARAMETERS the way git does:
|
||||
// whitespace-separated entries, each single-quoted, with `'\”` and `'\!'`
|
||||
// standing for a quote or a bang inside one. Anything else is "bogus format"
|
||||
// in git, a fatal error, so ok is false.
|
||||
func splitGitConfigParameters(text string) (settings []string, ok bool) {
|
||||
index := 0
|
||||
for {
|
||||
for index < len(text) && isGitSpace(text[index]) {
|
||||
index++
|
||||
}
|
||||
if index >= len(text) {
|
||||
return settings, true
|
||||
}
|
||||
if text[index] != '\'' {
|
||||
return nil, false
|
||||
}
|
||||
index++
|
||||
var current strings.Builder
|
||||
for {
|
||||
if index >= len(text) {
|
||||
return nil, false
|
||||
}
|
||||
c := text[index]
|
||||
index++
|
||||
if c != '\'' {
|
||||
current.WriteByte(c)
|
||||
continue
|
||||
}
|
||||
if index+2 < len(text) && text[index] == '\\' && (text[index+1] == '\'' || text[index+1] == '!') && text[index+2] == '\'' {
|
||||
current.WriteByte(text[index+1])
|
||||
index += 3
|
||||
continue
|
||||
}
|
||||
if index >= len(text) || isGitSpace(text[index]) {
|
||||
settings = append(settings, current.String())
|
||||
break
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The tokenizer has to agree with git's split_cmdline on the forms that decide
|
||||
// which subcommand runs: quotes anywhere in a word, backslash escapes outside
|
||||
// single quotes, and the two errors git dies on.
|
||||
func TestSplitGitAliasExpansionMatchesGitQuoting(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
expansion string
|
||||
want []string
|
||||
ok bool
|
||||
}{
|
||||
{"init", []string{"init"}, true},
|
||||
{`"init"`, []string{"init"}, true},
|
||||
{`in"it"`, []string{"init"}, true},
|
||||
{`in\it`, []string{"init"}, true},
|
||||
{`'in\it'`, []string{`in\it`}, true},
|
||||
{`"in\"it"`, []string{`in"it`}, true},
|
||||
{`-c alias.b=init b`, []string{"-c", "alias.b=init", "b"}, true},
|
||||
{`-c "alias.b=init x" b`, []string{"-c", "alias.b=init x", "b"}, true},
|
||||
{`'-c' 'alias.b=init' b`, []string{"-c", "alias.b=init", "b"}, true},
|
||||
{" init \t ", []string{"init"}, true},
|
||||
{"", nil, true},
|
||||
{`"init`, nil, false},
|
||||
{`'init`, nil, false},
|
||||
{`init\`, nil, false},
|
||||
} {
|
||||
got, ok := splitGitAliasExpansion(tc.expansion)
|
||||
if ok != tc.ok || !reflect.DeepEqual(got, tc.want) {
|
||||
t.Errorf("splitGitAliasExpansion(%q) = %q, %v; want %q, %v", tc.expansion, got, ok, tc.want, tc.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GIT_CONFIG_PARAMETERS is git's own single-quoted list; an entry git would
|
||||
// call bogus makes the whole variable unreadable rather than partly read.
|
||||
func TestSplitGitConfigParametersDequotesLikeGit(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
text string
|
||||
want []string
|
||||
ok bool
|
||||
}{
|
||||
{`'alias.a=init'`, []string{"alias.a=init"}, true},
|
||||
{`'alias.a=init' 'x=y'`, []string{"alias.a=init", "x=y"}, true},
|
||||
{` 'alias.a=in'\''it' `, []string{"alias.a=in'it"}, true},
|
||||
{`'alias.a=in'\!'it'`, []string{"alias.a=in!it"}, true},
|
||||
{"", nil, true},
|
||||
{`alias.a=init`, nil, false},
|
||||
{`'alias.a=init`, nil, false},
|
||||
{`'a'x`, nil, false},
|
||||
} {
|
||||
got, ok := splitGitConfigParameters(tc.text)
|
||||
if ok != tc.ok || !reflect.DeepEqual(got, tc.want) {
|
||||
t.Errorf("splitGitConfigParameters(%q) = %q, %v; want %q, %v", tc.text, got, ok, tc.want, tc.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Resolution applies git's in-alias option handling: a -c inside an expansion
|
||||
// defines the alias the next lookup uses, pager switches are stepped over, an
|
||||
// environment-changing option or an unreadable definition refuses, and only a
|
||||
// chain that ends in a real repository-creating command is creation.
|
||||
func TestGitAliasResolutionFollowsExpansionOptions(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
subcommand string
|
||||
aliases map[string]string
|
||||
unknown []string
|
||||
creates bool
|
||||
}{
|
||||
{name: "option inside expansion defines the next alias", subcommand: "a", aliases: map[string]string{"a": "-c alias.b=init b"}, creates: true},
|
||||
{name: "quoted expansion", subcommand: "a", aliases: map[string]string{"a": `"init"`}, creates: true},
|
||||
{name: "pager switch does not hide init", subcommand: "a", aliases: map[string]string{"a": "--no-pager init"}, creates: true},
|
||||
{name: "environment-changing option refuses", subcommand: "a", aliases: map[string]string{"a": "-C . init"}, creates: true},
|
||||
{name: "unclosed quote refuses", subcommand: "a", aliases: map[string]string{"a": `"in`}, creates: true},
|
||||
{name: "options only refuses", subcommand: "a", aliases: map[string]string{"a": "-c x=y"}, creates: true},
|
||||
{name: "config-env inside expansion is unreadable", subcommand: "a", aliases: map[string]string{"a": "--config-env=alias.b=var b"}, creates: true},
|
||||
{name: "redefinition loop refuses", subcommand: "a", aliases: map[string]string{"a": "-c alias.a=init a"}, creates: true},
|
||||
{name: "unknown definition refuses", subcommand: "a", unknown: []string{"a"}, creates: true},
|
||||
{name: "harmless option-bearing alias", subcommand: "st", aliases: map[string]string{"st": "-c color.ui=always status"}, creates: false},
|
||||
{name: "harmless quoted alias", subcommand: "st", aliases: map[string]string{"st": `"status"`}, creates: false},
|
||||
{name: "harmless pager alias", subcommand: "l", aliases: map[string]string{"l": "--no-pager log --oneline"}, creates: false},
|
||||
{name: "harmless chain through quotes", subcommand: "s", aliases: map[string]string{"s": "st", "st": `sta"tus"`}, creates: false},
|
||||
{name: "not an alias", subcommand: "status", creates: false},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
table := gitAliasTable{}
|
||||
for name, expansion := range tc.aliases {
|
||||
table.define(name, expansion)
|
||||
}
|
||||
for _, name := range tc.unknown {
|
||||
table.defineUnknown(name)
|
||||
}
|
||||
if got := gitSubcommandCreatesRepository(tc.subcommand, table); got != tc.creates {
|
||||
t.Fatalf("gitSubcommandCreatesRepository(%q) = %v, want %v", tc.subcommand, got, tc.creates)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package sandbox
|
||||
|
||||
import "testing"
|
||||
|
||||
// GIT'S GLOBAL OPTIONS CONSUME THE NEXT WORD, AND THE CLASSIFIER HAS TO KNOW.
|
||||
//
|
||||
// firstSubcommand skips words beginning with "-" but not the value that follows
|
||||
// one. So "git -C sub clone <url>" made it answer "sub", the command classified
|
||||
// as touching no network at all, and the network gate never fired. Two spellings
|
||||
// an ordinary user types by habit walked straight through it.
|
||||
//
|
||||
// Driven through AnalyzeCommand rather than the helper, because the gate reads
|
||||
// the analysis and a helper-level assertion would not have caught this.
|
||||
func TestGitGlobalOptionsDoNotHideTheSubcommand(t *testing.T) {
|
||||
for _, testCase := range []struct {
|
||||
script string
|
||||
want bool
|
||||
}{
|
||||
{script: "git clone https://example.com/x", want: true},
|
||||
{script: "git -C sub clone https://example.com/x", want: true},
|
||||
{script: "git -c user.name=x clone https://example.com/x", want: true},
|
||||
{script: "git -c a=b -C sub clone https://example.com/x", want: true},
|
||||
{script: "git --git-dir=/tmp/g clone https://example.com/x", want: true},
|
||||
{script: "git --namespace ns fetch origin", want: true},
|
||||
{script: "git -C sub push origin main", want: true},
|
||||
// Local-only work stays local, or the fix would be "call everything network".
|
||||
{script: "git status", want: false},
|
||||
{script: "git -C sub status", want: false},
|
||||
{script: "git -c a=b commit -m x", want: false},
|
||||
{script: "git init", want: false},
|
||||
} {
|
||||
if got := AnalyzeCommand(testCase.script).Network; got != testCase.want {
|
||||
t.Errorf("AnalyzeCommand(%q).Network = %v, want %v", testCase.script, got, testCase.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The subcommand parser itself, including the option that consumes a value and
|
||||
// the self-contained "--opt=value" spelling that does not.
|
||||
func TestGitSubcommandSkipsOptionValues(t *testing.T) {
|
||||
for _, testCase := range []struct {
|
||||
words []string
|
||||
want string
|
||||
}{
|
||||
{words: []string{"clone", "url"}, want: "clone"},
|
||||
{words: []string{"-c", "sub", "clone"}, want: "clone"},
|
||||
{words: []string{"--git-dir=/tmp/g", "clone"}, want: "clone"},
|
||||
{words: []string{"--namespace", "ns", "fetch"}, want: "fetch"},
|
||||
{words: []string{"-c", "a=b", "-c", "sub", "push"}, want: "push"},
|
||||
{words: []string{"--no-pager", "status"}, want: "status"},
|
||||
{words: []string{}, want: ""},
|
||||
{words: []string{"-c", "a=b"}, want: ""},
|
||||
} {
|
||||
if got := gitSubcommand(testCase.words); got != testCase.want {
|
||||
t.Errorf("gitSubcommand(%v) = %q, want %q", testCase.words, got, testCase.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,451 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// nestedGitWorkspace returns an ancestor repository and a workspace inside it
|
||||
// that carries no git metadata of its own, which is the shape the whole refusal
|
||||
// is about.
|
||||
func nestedGitWorkspace(t *testing.T) (ancestor string, workspace string) {
|
||||
t.Helper()
|
||||
ancestor = t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(ancestor, ".git"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
workspace = filepath.Join(ancestor, "packages", "app")
|
||||
if err := os.MkdirAll(workspace, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Lstat(filepath.Join(workspace, ".git")); err == nil {
|
||||
t.Fatal("SETUP INVALID: the workspace carries its own .git, which is the protected case, not this one")
|
||||
}
|
||||
return ancestor, workspace
|
||||
}
|
||||
|
||||
func gitCommandRequest(workspace string, command string) Request {
|
||||
return Request{
|
||||
ToolName: "bash",
|
||||
SideEffect: SideEffectShell,
|
||||
Permission: PermissionPrompt,
|
||||
PermissionMode: PermissionModeAsk,
|
||||
WorkspaceRoot: workspace,
|
||||
Args: map[string]any{"command": command},
|
||||
}
|
||||
}
|
||||
|
||||
func gitWorkspaceEngine(t *testing.T, workspace string) *Engine {
|
||||
t.Helper()
|
||||
return NewEngine(EngineOptions{
|
||||
WorkspaceRoot: workspace,
|
||||
Policy: DefaultPolicy(),
|
||||
Backend: nativeWrappingBackend,
|
||||
})
|
||||
}
|
||||
|
||||
// THE LIFECYCLE, END TO END: the workspace is protected at write time AND the
|
||||
// command that would create a repository there is refused with an explanation.
|
||||
//
|
||||
// The setup guard is still load-bearing, but it now asserts the opposite of
|
||||
// what it once did. The carveouts were restored for a nested workspace, because
|
||||
// skipping them left nothing denying writes to <root>/.git and a repository
|
||||
// could be assembled there without running git at all. So the refusal is no
|
||||
// longer the only thing standing between a sandboxed command and a writable
|
||||
// config; it is the layer that tells an operator why, while the deny ACE is
|
||||
// what stops it. If the carveouts disappear again this fails here, because the
|
||||
// refusal on its own does not cover mkdir.
|
||||
func TestNestedWorkspaceRefusesGitInit(t *testing.T) {
|
||||
ancestor, workspace := nestedGitWorkspace(t)
|
||||
|
||||
carved := map[string]bool{}
|
||||
for _, carveout := range gitMetadataWriteCarveouts(workspace) {
|
||||
carved[filepath.Base(carveout)] = true
|
||||
}
|
||||
if !carved["config"] || !carved["hooks"] {
|
||||
t.Fatalf("SETUP INVALID: the nested workspace planned %v, so config and hooks are not denied and the refusal below is the only protection again", gitMetadataWriteCarveouts(workspace))
|
||||
}
|
||||
|
||||
engine := gitWorkspaceEngine(t, workspace)
|
||||
decision := engine.Evaluate(context.Background(), gitCommandRequest(workspace, "git init"))
|
||||
if decision.Action != ActionDeny {
|
||||
t.Fatalf("git init inside the repository at %s was %s, not denied; the repository it creates gets a writable config and hooks under the workspace grant", ancestor, decision.Action)
|
||||
}
|
||||
if decision.Block == nil || decision.Block.Code != BlockNestedGitInit {
|
||||
t.Fatalf("block = %#v, want code %s so the operator gets the nested-repository explanation and not a generic denial", decision.Block, BlockNestedGitInit)
|
||||
}
|
||||
}
|
||||
|
||||
// The refusal sits ahead of every allow path in Evaluate, so a tool already
|
||||
// cleared by permissions does not walk through it.
|
||||
func TestNestedGitInitRefusalOutranksAnAllowPermission(t *testing.T) {
|
||||
_, workspace := nestedGitWorkspace(t)
|
||||
engine := gitWorkspaceEngine(t, workspace)
|
||||
|
||||
request := gitCommandRequest(workspace, "git init")
|
||||
request.Permission = PermissionAllow
|
||||
if decision := engine.Evaluate(context.Background(), request); decision.Action != ActionDeny {
|
||||
t.Fatalf("an allow permission carried git init through: %#v", decision)
|
||||
}
|
||||
|
||||
request = gitCommandRequest(workspace, "git init")
|
||||
request.PermissionMode = PermissionUnsafe
|
||||
request.PermissionGranted = true
|
||||
if decision := engine.Evaluate(context.Background(), request); decision.Action != ActionDeny {
|
||||
t.Fatalf("unsafe mode carried git init through: %#v", decision)
|
||||
}
|
||||
}
|
||||
|
||||
// The global options that bypassed the network gate must not bypass this one.
|
||||
// These are the exact spellings that classified a clone as network=false before
|
||||
// gitSubcommand learned which options take a value.
|
||||
func TestNestedGitInitRefusalSurvivesGitGlobalOptions(t *testing.T) {
|
||||
_, workspace := nestedGitWorkspace(t)
|
||||
engine := gitWorkspaceEngine(t, workspace)
|
||||
|
||||
for _, command := range []string{
|
||||
"git init",
|
||||
"git init-db",
|
||||
"git -C packages/app init",
|
||||
"git -c core.hooksPath=/tmp/h init",
|
||||
"git --git-dir=/tmp/g init",
|
||||
"git --namespace ns init",
|
||||
"sh -c \"git init\"",
|
||||
"sudo git init",
|
||||
"git status && git init",
|
||||
} {
|
||||
t.Run(command, func(t *testing.T) {
|
||||
decision := engine.Evaluate(context.Background(), gitCommandRequest(workspace, command))
|
||||
if decision.Action != ActionDeny {
|
||||
t.Fatalf("%q was %s, not denied", command, decision.Action)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// CONTROL: ordinary git in a nested workspace is untouched.
|
||||
//
|
||||
// Discovery still walks up to the ancestor and every command that uses it keeps
|
||||
// working; only creation is refused. Without this, returning deny for anything
|
||||
// containing the word git would pass the tests above.
|
||||
func TestNestedWorkspaceStillRunsOrdinaryGitCommands(t *testing.T) {
|
||||
_, workspace := nestedGitWorkspace(t)
|
||||
engine := gitWorkspaceEngine(t, workspace)
|
||||
|
||||
for _, command := range []string{
|
||||
"git status",
|
||||
"git add .",
|
||||
"git commit -m init",
|
||||
"git log --oneline",
|
||||
"git worktree list",
|
||||
"git submodule update --init",
|
||||
} {
|
||||
t.Run(command, func(t *testing.T) {
|
||||
decision := engine.Evaluate(context.Background(), gitCommandRequest(workspace, command))
|
||||
if decision.Action == ActionDeny {
|
||||
t.Fatalf("%q was denied in a nested workspace: %s", command, decision.Reason)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// CONTROL: a standalone workspace still creates repositories.
|
||||
//
|
||||
// It keeps its materialized config and hooks carveouts, so the protection the
|
||||
// refusal stands in for is actually there and the command is allowed.
|
||||
func TestStandaloneWorkspaceStillAllowsGitInit(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
if gitMetadataGovernedByAncestor(workspace) {
|
||||
t.Skip("this temp directory sits inside a repository, so it is not the standalone case")
|
||||
}
|
||||
if len(gitMetadataWriteCarveouts(workspace)) == 0 {
|
||||
t.Fatal("SETUP INVALID: a standalone workspace planned no carveouts, so it has no protection to allow git init against")
|
||||
}
|
||||
|
||||
engine := gitWorkspaceEngine(t, workspace)
|
||||
decision := engine.Evaluate(context.Background(), gitCommandRequest(workspace, "git init"))
|
||||
if decision.Action == ActionDeny {
|
||||
t.Fatalf("git init was refused in a standalone workspace: %s", decision.Reason)
|
||||
}
|
||||
}
|
||||
|
||||
// CONTROL: a linked worktree is a workspace whose .git is a FILE.
|
||||
//
|
||||
// It owns git metadata, keeps its pointer-file carveout, and is not somebody
|
||||
// else's repository, so nothing here is refused. Zero's own development
|
||||
// worktrees are this shape, so getting it wrong would refuse git init for the
|
||||
// people writing this code.
|
||||
func TestLinkedWorktreeWorkspaceStillAllowsGitInit(t *testing.T) {
|
||||
ancestor := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(ancestor, ".git", "worktrees", "w"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
workspace := filepath.Join(ancestor, "checkout")
|
||||
if err := os.MkdirAll(workspace, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pointer := filepath.Join(workspace, ".git")
|
||||
if err := os.WriteFile(pointer, []byte("gitdir: ../.git/worktrees/w\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// SETUP: the pointer carveout is intact, or this proves nothing about
|
||||
// worktrees keeping their protection.
|
||||
specs := gitMetadataWriteCarveoutSpecs(workspace)
|
||||
if len(specs) != 1 || specs[0].Path != pointer || !specs[0].IsFile {
|
||||
t.Fatalf("SETUP INVALID: linked worktree carveouts = %+v, want the file-shaped pointer %s", specs, pointer)
|
||||
}
|
||||
|
||||
engine := gitWorkspaceEngine(t, workspace)
|
||||
if decision := engine.Evaluate(context.Background(), gitCommandRequest(workspace, "git init")); decision.Action == ActionDeny {
|
||||
t.Fatalf("git init was refused in a linked worktree, which owns its git metadata: %s", decision.Reason)
|
||||
}
|
||||
}
|
||||
|
||||
// And the workspace-level question is asked of the workspace itself, so a
|
||||
// workspace that already carries .git is never treated as nested no matter what
|
||||
// is above it.
|
||||
func TestWorkspaceOwningGitIsNotTreatedAsNested(t *testing.T) {
|
||||
ancestor := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(ancestor, ".git"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
workspace := filepath.Join(ancestor, "vendored")
|
||||
if err := os.MkdirAll(filepath.Join(workspace, ".git"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if workspaceGovernedByAncestorRepository(workspace) {
|
||||
t.Fatal("a workspace that owns .git was reported as governed by its ancestor, so its own protection would be refused instead of applied")
|
||||
}
|
||||
}
|
||||
|
||||
// CLONE IS CREATION TOO, AND THE NETWORK GRANT DOES NOT ANSWER FOR IT.
|
||||
//
|
||||
// "git clone <url> ." into a workspace governed by an ancestor repository lands
|
||||
// a root .git whose config and hooks setup never planned carveouts for, exactly
|
||||
// the state git init produces, but the guard only knew the init spelling. With
|
||||
// network explicitly allowed and the shell grant already given, the clone came
|
||||
// back as an ordinary allow. The repository-creation refusal is a separate
|
||||
// decision from the network prompt and has to stand on its own. Reported by
|
||||
// @gnanam1990.
|
||||
func TestNestedWorkspaceRefusesGitCloneIntoTheRoot(t *testing.T) {
|
||||
ancestor, workspace := nestedGitWorkspace(t)
|
||||
policy := DefaultPolicy()
|
||||
policy.Network = NetworkAllow
|
||||
engine := NewEngine(EngineOptions{WorkspaceRoot: workspace, Policy: policy, Backend: nativeWrappingBackend})
|
||||
for _, command := range []string{
|
||||
"git clone https://example.invalid/repo.git .",
|
||||
"git -C . clone https://example.invalid/repo.git .",
|
||||
"git clone --depth 1 https://example.invalid/repo.git vendor/dep",
|
||||
} {
|
||||
request := gitCommandRequest(workspace, command)
|
||||
request.Permission = PermissionAllow
|
||||
request.PermissionGranted = true
|
||||
decision := engine.Evaluate(context.Background(), request)
|
||||
if decision.Action != ActionDeny {
|
||||
t.Fatalf("%q inside the repository at %s was %s, not denied; the repository it creates gets a writable config and hooks under the workspace grant", command, ancestor, decision.Action)
|
||||
}
|
||||
if decision.Block == nil || decision.Block.Code != BlockNestedGitInit {
|
||||
t.Fatalf("%q block = %#v, want code %s", command, decision.Block, BlockNestedGitInit)
|
||||
}
|
||||
}
|
||||
// CONTROLS: ordinary git operations and the submodule init form stay clear
|
||||
// of this guard, and a standalone workspace is not governed by anything.
|
||||
for _, command := range []string{"git status", "git submodule update --init", "git fetch origin"} {
|
||||
request := gitCommandRequest(workspace, command)
|
||||
request.Permission = PermissionAllow
|
||||
request.PermissionGranted = true
|
||||
if decision := engine.Evaluate(context.Background(), request); decision.Action == ActionDeny && decision.Block != nil && decision.Block.Code == BlockNestedGitInit {
|
||||
t.Fatalf("%q was refused as nested repository creation; it creates no repository", command)
|
||||
}
|
||||
}
|
||||
standalone := t.TempDir()
|
||||
if workspaceGovernedByAncestorRepository(standalone) {
|
||||
t.Fatalf("SETUP INVALID: %s is governed by an ancestor repository", standalone)
|
||||
}
|
||||
request := gitCommandRequest(standalone, "git clone https://example.invalid/repo.git .")
|
||||
request.Permission = PermissionAllow
|
||||
request.PermissionGranted = true
|
||||
if decision := gitWorkspaceEngine(t, standalone).Evaluate(context.Background(), request); decision.Block != nil && decision.Block.Code == BlockNestedGitInit {
|
||||
t.Fatalf("clone into a standalone workspace was refused as nested: %#v", decision)
|
||||
}
|
||||
}
|
||||
|
||||
// AN INLINE ALIAS IS STILL INIT. The refusal used to read the literal
|
||||
// subcommand token, and `git -c alias.bootstrap=init bootstrap` passed it with
|
||||
// shell and network granted and created a repository the profile carves nothing
|
||||
// out for. The alias is resolved to the subcommand git runs; a chained alias
|
||||
// follows the chain; a shell alias, which this analyzer cannot classify, counts
|
||||
// as creation under this guard and nowhere else.
|
||||
func TestNestedWorkspaceRefusesAnInlineGitAliasForInit(t *testing.T) {
|
||||
ancestor, workspace := nestedGitWorkspace(t)
|
||||
policy := DefaultPolicy()
|
||||
policy.Network = NetworkAllow
|
||||
engine := NewEngine(EngineOptions{WorkspaceRoot: workspace, Policy: policy, Backend: nativeWrappingBackend})
|
||||
for _, command := range []string{
|
||||
"git init",
|
||||
"git -c alias.bootstrap=init bootstrap",
|
||||
"git -c alias.mk=init-db mk",
|
||||
"git -c alias.get=clone get https://example.invalid/repo.git .",
|
||||
"git -c alias.two=bootstrap -c alias.bootstrap=init two",
|
||||
"git -c alias.a=b -c alias.b=c -c alias.c=d -c alias.d=e -c alias.e=init a .",
|
||||
"git -c alias.a=b -c alias.b=a a",
|
||||
"git -c alias.dangling=nowhere -c alias.nowhere= dangling",
|
||||
"git -C . -c alias.bootstrap=init bootstrap",
|
||||
"git -c alias.sh=!sh -c 'git init' sh",
|
||||
// THE EXPANSION IS PARSED THE WAY GIT PARSES IT. A -c inside the
|
||||
// expansion defines the alias the next lookup uses, and quotes are
|
||||
// stripped before the subcommand is read; both of the first two
|
||||
// spellings created a repository while only the first whitespace
|
||||
// field of the expansion was classified.
|
||||
"git -c alias.a='-c alias.b=init b' a .",
|
||||
"git -c 'alias.a=\"init\"' a .",
|
||||
"git -c 'alias.a=in\"it\"' a",
|
||||
"git -c 'alias.a=in\\it' a",
|
||||
"git -c alias.a='--no-pager init' a",
|
||||
"git -c alias.a='-c alias.a=init a' a",
|
||||
// Git dies on these before running anything; refusing them costs
|
||||
// nothing and keeps the rule that an expansion this analyzer cannot
|
||||
// read is not evidence of safety.
|
||||
"git -c 'alias.a=\"in' a",
|
||||
"git -c 'alias.a=-C . init' a",
|
||||
// An alias defined by reference, or through the environment the call
|
||||
// sets, is the same command with its definition moved out of sight.
|
||||
"git --config-env=alias.a=zz a .",
|
||||
"git --config-env alias.a=zz a .",
|
||||
"GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=alias.a GIT_CONFIG_VALUE_0=init git a .",
|
||||
"env GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=alias.a GIT_CONFIG_VALUE_0=init git a .",
|
||||
"GIT_CONFIG_PARAMETERS=\"'alias.a=init'\" git a .",
|
||||
"GIT_CONFIG_PARAMETERS=$params git a .",
|
||||
"GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=alias.a GIT_CONFIG_VALUE_0=$cmd git a .",
|
||||
} {
|
||||
request := gitCommandRequest(workspace, command)
|
||||
request.Permission = PermissionAllow
|
||||
request.PermissionGranted = true
|
||||
decision := engine.Evaluate(context.Background(), request)
|
||||
if decision.Action != ActionDeny || decision.Block == nil || decision.Block.Code != BlockNestedGitInit {
|
||||
t.Fatalf("%q inside the repository at %s was not refused as nested repository creation: action=%s block=%#v", command, ancestor, decision.Action, decision.Block)
|
||||
}
|
||||
}
|
||||
// CONTROLS: a harmless alias, an unrelated -c setting, ordinary use, and
|
||||
// the quoted, option-bearing and environment-defined spellings of an alias
|
||||
// that runs something harmless.
|
||||
for _, command := range []string{
|
||||
"git -c alias.st=status st",
|
||||
"git -c core.autocrlf=false status",
|
||||
"git -c alias.bootstrap=init status",
|
||||
"git -c alias.lg=log lg --oneline",
|
||||
"git -c alias.a=b -c alias.b=c -c alias.c=d -c alias.d=e -c alias.e=status a",
|
||||
"git -c alias.st='-c color.ui=always status' st",
|
||||
"git -c 'alias.st=\"status\"' st",
|
||||
"git -c 'alias.st=sta\"tus\"' st",
|
||||
"git -c alias.l='--no-pager log --oneline' l",
|
||||
"git -c alias.s=st -c 'alias.st=\"status\"' s",
|
||||
"git --config-env=core.pager=p status",
|
||||
"GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=alias.st GIT_CONFIG_VALUE_0=status git st",
|
||||
"GIT_CONFIG_PARAMETERS=\"'alias.st=status'\" git st",
|
||||
"GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=core.pager GIT_CONFIG_VALUE_0=$pager git log",
|
||||
} {
|
||||
request := gitCommandRequest(workspace, command)
|
||||
request.Permission = PermissionAllow
|
||||
request.PermissionGranted = true
|
||||
if decision := engine.Evaluate(context.Background(), request); decision.Action == ActionDeny && decision.Block != nil && decision.Block.Code == BlockNestedGitInit {
|
||||
t.Fatalf("%q was refused as nested repository creation; it creates no repository", command)
|
||||
}
|
||||
}
|
||||
// And a standalone workspace is not governed at all, alias or not.
|
||||
standalone := t.TempDir()
|
||||
for _, command := range []string{
|
||||
"git -c alias.bootstrap=init bootstrap",
|
||||
"git -c alias.a='-c alias.b=init b' a .",
|
||||
"git -c 'alias.a=\"init\"' a .",
|
||||
"GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=alias.a GIT_CONFIG_VALUE_0=init git a .",
|
||||
} {
|
||||
request := gitCommandRequest(standalone, command)
|
||||
request.Permission = PermissionAllow
|
||||
request.PermissionGranted = true
|
||||
if decision := gitWorkspaceEngine(t, standalone).Evaluate(context.Background(), request); decision.Block != nil && decision.Block.Code == BlockNestedGitInit {
|
||||
t.Fatalf("%q in a standalone workspace was refused as nested: %#v", command, decision)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// THE REFUSAL THAT CANNOT BE APPROVED IS THE ONE THE OPERATOR HAS TO SEE.
|
||||
//
|
||||
// `git clone` carries both risks: it uses the network and it creates a
|
||||
// repository. The clone test above sets NetworkAllow, which is the only policy
|
||||
// under which the nested-repository branch was ever reached. Under the DEFAULT
|
||||
// policy the network gate ran first and answered with a prompt, so the operator
|
||||
// was asked to approve network access for a command no approval could make
|
||||
// runnable, and ReasonNestedGitInit, the reason with a remedy in it, was never
|
||||
// shown. Reported by @jatmn.
|
||||
func TestNestedCloneIsRefusedAsNestedUnderTheDefaultNetworkPolicy(t *testing.T) {
|
||||
_, workspace := nestedGitWorkspace(t)
|
||||
policy := DefaultPolicy()
|
||||
if policy.Network != NetworkDeny {
|
||||
t.Fatalf("SETUP INVALID: the default network policy is %q, so this does not exercise the ordering", policy.Network)
|
||||
}
|
||||
engine := NewEngine(EngineOptions{WorkspaceRoot: workspace, Policy: policy, Backend: nativeWrappingBackend})
|
||||
|
||||
for _, granted := range []bool{false, true} {
|
||||
request := gitCommandRequest(workspace, "git clone https://example.invalid/repo.git vendor/dep")
|
||||
request.PermissionGranted = granted
|
||||
decision := engine.Evaluate(context.Background(), request)
|
||||
if decision.Action != ActionDeny || decision.Block == nil || decision.Block.Code != BlockNestedGitInit {
|
||||
t.Fatalf("granted=%v: clone in a governed workspace was %s (block %#v, reason %q), want a %s denial", granted, decision.Action, decision.Block, decision.Reason, BlockNestedGitInit)
|
||||
}
|
||||
if decision.Reason != ReasonNestedGitInit {
|
||||
t.Errorf("granted=%v: reason = %q, want the nested-repository reason and its remedy", granted, decision.Reason)
|
||||
}
|
||||
}
|
||||
|
||||
// CONTROLS. A network command that creates no repository still gets the
|
||||
// network answer, so the reordering did not swallow that gate, and a clone
|
||||
// into a standalone workspace is a network question and nothing else.
|
||||
fetch := engine.Evaluate(context.Background(), gitCommandRequest(workspace, "git fetch origin"))
|
||||
if fetch.Action != ActionPrompt || fetch.Reason != ReasonNetworkBlocked {
|
||||
t.Errorf("git fetch in the governed workspace was %s (%q), want the network prompt", fetch.Action, fetch.Reason)
|
||||
}
|
||||
standalone := t.TempDir()
|
||||
if workspaceGovernedByAncestorRepository(standalone) {
|
||||
t.Fatalf("SETUP INVALID: %s is governed by an ancestor repository", standalone)
|
||||
}
|
||||
standaloneEngine := NewEngine(EngineOptions{WorkspaceRoot: standalone, Policy: policy, Backend: nativeWrappingBackend})
|
||||
clone := standaloneEngine.Evaluate(context.Background(), gitCommandRequest(standalone, "git clone https://example.invalid/repo.git ."))
|
||||
if clone.Action != ActionPrompt || clone.Reason != ReasonNetworkBlocked {
|
||||
t.Errorf("clone into a standalone workspace was %s (%q), want the network prompt", clone.Action, clone.Reason)
|
||||
}
|
||||
}
|
||||
|
||||
// THE LIMIT OF THIS GUARD, WRITTEN DOWN SO IT IS NOT MISTAKEN FOR COVERAGE.
|
||||
//
|
||||
// The nested-repository rule recognises git creating a repository. It does not
|
||||
// recognise a repository assembled by other means, and it cannot: mkdir and a
|
||||
// few writes, an archive, or a script in any language all produce the same
|
||||
// directory, and none of them is a git command. These legs are allowed today.
|
||||
// They are here so that anyone reading the rule as containment of the .git
|
||||
// pathname finds a test saying otherwise, and so that a change which DOES close
|
||||
// it at the decision layer has to come and update this deliberately.
|
||||
//
|
||||
// It is not a statement that the behaviour is acceptable. A repository built
|
||||
// this way carries whatever config its author wrote, and a plain `git status`
|
||||
// outside the sandbox honours it. That has to be closed where the carveouts
|
||||
// live, at write time. Reported by @jatmn.
|
||||
func TestHandAssembledRepositoryIsNotWhatThisGuardCatches(t *testing.T) {
|
||||
_, workspace := nestedGitWorkspace(t)
|
||||
engine := gitWorkspaceEngine(t, workspace)
|
||||
for _, command := range []string{
|
||||
"mkdir .git",
|
||||
"mkdir -p .git/objects .git/refs && printf 'ref: refs/heads/main\n' > .git/HEAD",
|
||||
"cp -r ../template-repo/.git .git",
|
||||
"python3 -c \"import os; os.makedirs('.git/refs')\"",
|
||||
"tar -xf repo.tar",
|
||||
} {
|
||||
decision := engine.Evaluate(context.Background(), gitCommandRequest(workspace, command))
|
||||
if decision.Block != nil && decision.Block.Code == BlockNestedGitInit {
|
||||
t.Errorf("%q is now refused as nested repository creation. If that is deliberate, this test and the WHAT THIS IS NOT note in risk.go both need to say what the rule covers now", command)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A WORKSPACE INSIDE ANOTHER REPOSITORY KEEPS ITS CARVEOUTS.
|
||||
//
|
||||
// This branch skipped them for a while, so that Zero would not create a .git in
|
||||
// a repository it does not own. The cost was worse than the thing avoided:
|
||||
// nothing then denied writes to <root>/.git, so a sandboxed command could build
|
||||
// a repository there by hand and put core.fsmonitor in its config, and Zero runs
|
||||
// git outside the sandbox against that workspace. The nested-repository refusal
|
||||
// recognises git creating a repository; it cannot recognise mkdir.
|
||||
//
|
||||
// TestCarveoutsInsideAnAncestorRepositoryDoNotCaptureDiscovery measures the
|
||||
// reason the skip existed and shows it does not hold.
|
||||
func TestCarveoutsAreSynthesizedInsideAnAncestorRepository(t *testing.T) {
|
||||
parent := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(parent, ".git"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
workspace := filepath.Join(parent, "sub", "project")
|
||||
if err := os.MkdirAll(workspace, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// SETUP: the workspace itself has no .git, or this is a different case.
|
||||
if _, err := os.Lstat(filepath.Join(workspace, ".git")); err == nil {
|
||||
t.Fatal("SETUP INVALID: the workspace already carries .git")
|
||||
}
|
||||
if !gitMetadataGovernedByAncestor(workspace) {
|
||||
t.Fatal("SETUP INVALID: the ancestor is not detected, so this proves nothing")
|
||||
}
|
||||
|
||||
specs := gitMetadataWriteCarveoutSpecs(workspace)
|
||||
want := map[string]bool{
|
||||
filepath.Join(workspace, ".git", "config"): true,
|
||||
filepath.Join(workspace, ".git", "hooks"): true,
|
||||
}
|
||||
for _, spec := range specs {
|
||||
delete(want, spec.Path)
|
||||
}
|
||||
for path := range want {
|
||||
t.Errorf("a workspace inside another repository gets no carveout for %s, so nothing denies writing it", path)
|
||||
}
|
||||
}
|
||||
|
||||
// The reason the skip existed, measured rather than argued: materializing only
|
||||
// config and hooks does not make the directory a repository, so git's discovery
|
||||
// walk still reaches the ancestor and a config planted there is never read.
|
||||
// If a future git changes that, this fails and the trade has to be reopened.
|
||||
func TestCarveoutsInsideAnAncestorRepositoryDoNotCaptureDiscovery(t *testing.T) {
|
||||
git, err := exec.LookPath("git")
|
||||
if err != nil {
|
||||
t.Skip("no git on PATH")
|
||||
}
|
||||
parent := t.TempDir()
|
||||
run := func(dir string, args ...string) (string, error) {
|
||||
command := exec.Command(git, args...)
|
||||
command.Dir = dir
|
||||
command.Env = append(os.Environ(), "GIT_CONFIG_NOSYSTEM=1", "HOME="+parent, "USERPROFILE="+parent)
|
||||
out, err := command.CombinedOutput()
|
||||
return strings.TrimSpace(string(out)), err
|
||||
}
|
||||
if out, err := run(parent, "init", "-q", "."); err != nil {
|
||||
t.Skipf("cannot create the ancestor repository: %v: %s", err, out)
|
||||
}
|
||||
workspace := filepath.Join(parent, "sub", "project")
|
||||
if err := os.MkdirAll(workspace, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ancestor, err := run(workspace, "rev-parse", "--show-toplevel")
|
||||
if err != nil {
|
||||
t.Skipf("cannot resolve the ancestor: %v: %s", err, ancestor)
|
||||
}
|
||||
|
||||
// Materialize exactly what the plan materializes, and make the config as
|
||||
// hostile as a sandboxed command would if it could write one.
|
||||
for _, spec := range gitMetadataWriteCarveoutSpecs(workspace) {
|
||||
if spec.IsFile {
|
||||
if err := os.MkdirAll(filepath.Dir(spec.Path), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(spec.Path, []byte("[core]\n\tfsmonitor = \"exit 9\"\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := os.MkdirAll(spec.Path, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
after, err := run(workspace, "rev-parse", "--show-toplevel")
|
||||
if err != nil {
|
||||
t.Fatalf("git stopped working in the workspace after the carveouts were materialized: %v: %s", err, after)
|
||||
}
|
||||
if after != ancestor {
|
||||
t.Fatalf("discovery moved from %s to %s: the materialized carveouts captured the walk, which is the trade this test exists to watch", ancestor, after)
|
||||
}
|
||||
if out, err := run(workspace, "status", "--porcelain"); err != nil {
|
||||
t.Fatalf("git status failed in the workspace, so the planted config was read: %v: %s", err, out)
|
||||
}
|
||||
}
|
||||
|
||||
// And a standalone directory keeps the materialized carveouts, or the guard
|
||||
// above would be satisfied by returning nothing everywhere and the protection
|
||||
// would be gone for the case it was written for.
|
||||
func TestCarveoutsStillCoverAStandaloneDirectory(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
if gitMetadataGovernedByAncestor(workspace) {
|
||||
t.Skip("this temp directory sits inside a repository, so it is not the standalone case")
|
||||
}
|
||||
|
||||
specs := gitMetadataWriteCarveoutSpecs(workspace)
|
||||
wantConfig := filepath.Join(workspace, ".git", "config")
|
||||
wantHooks := filepath.Join(workspace, ".git", "hooks")
|
||||
var sawConfig, sawHooks bool
|
||||
for _, spec := range specs {
|
||||
switch spec.Path {
|
||||
case wantConfig:
|
||||
sawConfig = true
|
||||
if !spec.IsFile {
|
||||
t.Errorf("%s is planned as a directory; creating .git/config as a directory makes git init fail", spec.Path)
|
||||
}
|
||||
case wantHooks:
|
||||
sawHooks = true
|
||||
}
|
||||
}
|
||||
if !sawConfig || !sawHooks {
|
||||
t.Fatalf("a standalone directory lost its carveouts: config=%t hooks=%t specs=%+v", sawConfig, sawHooks, specs)
|
||||
}
|
||||
}
|
||||
|
||||
// A linked worktree is unchanged: the pointer file is denied and nothing beneath
|
||||
// it is named, which is what keeps elevated setup from descending through a
|
||||
// regular file.
|
||||
func TestCarveoutsStillDenyTheLinkedWorktreePointer(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
pointer := filepath.Join(workspace, ".git")
|
||||
if err := os.WriteFile(pointer, []byte("gitdir: ../real/.git/worktrees/w\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
specs := gitMetadataWriteCarveoutSpecs(workspace)
|
||||
if len(specs) != 1 || specs[0].Path != pointer || !specs[0].IsFile {
|
||||
t.Fatalf("linked worktree carveouts = %+v, want exactly the file-shaped pointer %s", specs, pointer)
|
||||
}
|
||||
}
|
||||
|
||||
// The ancestor test itself has to distinguish shapes, because a linked worktree
|
||||
// or submodule ancestor carries .git as a FILE and still owns this directory.
|
||||
func TestAncestorDetectionAcceptsBothGitShapes(t *testing.T) {
|
||||
for _, shape := range []struct {
|
||||
name string
|
||||
make func(t *testing.T, parent string)
|
||||
}{
|
||||
{name: "directory", make: func(t *testing.T, parent string) {
|
||||
if err := os.MkdirAll(filepath.Join(parent, ".git"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}},
|
||||
{name: "pointer file", make: func(t *testing.T, parent string) {
|
||||
if err := os.WriteFile(filepath.Join(parent, ".git"), []byte("gitdir: elsewhere\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}},
|
||||
} {
|
||||
t.Run(shape.name, func(t *testing.T) {
|
||||
parent := t.TempDir()
|
||||
shape.make(t, parent)
|
||||
workspace := filepath.Join(parent, "nested", "deep")
|
||||
if err := os.MkdirAll(workspace, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !gitMetadataGovernedByAncestor(workspace) {
|
||||
t.Fatalf("an ancestor carrying .git as a %s was not recognised as governing %s", shape.name, workspace)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A NESTED WORKSPACE THAT OWNS ITS OWN REPOSITORY KEEPS ITS OWN CARVEOUTS.
|
||||
//
|
||||
// The ancestor branch above exists so Zero does not synthesize a control
|
||||
// directory inside somebody else's repository. It was asked unconditionally,
|
||||
// so a workspace that already HAS its own .git lost the hooks and config
|
||||
// carveouts too — and those are its own metadata, not the ancestor's. Nothing
|
||||
// compensated: workspaceGovernedByAncestorRepository answers the same question
|
||||
// by testing for a local .git, so it reported this workspace as not governed
|
||||
// and the initialization refusal never fired. Commands then received plain
|
||||
// workspace write access over a live .git/config and .git/hooks, which is
|
||||
// exactly the configuration that decides what git executes. Reported by
|
||||
// @jatmn.
|
||||
//
|
||||
// Asserted through the profile as well as the spec helper, because the spec
|
||||
// list is only load-bearing if it reaches ReadOnlySubpaths.
|
||||
func TestNestedWorkspaceOwningGitKeepsItsOwnCarveouts(t *testing.T) {
|
||||
outer := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(outer, ".git"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
workspace := filepath.Join(outer, "project")
|
||||
if err := os.MkdirAll(filepath.Join(workspace, ".git"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
wantHooks := filepath.Join(workspace, ".git", "hooks")
|
||||
wantConfig := filepath.Join(workspace, ".git", "config")
|
||||
specs := gitMetadataWriteCarveoutSpecs(workspace)
|
||||
foundHooks, foundConfig := false, false
|
||||
for _, spec := range specs {
|
||||
switch {
|
||||
case spec.Path == wantHooks && !spec.IsFile:
|
||||
foundHooks = true
|
||||
case spec.Path == wantConfig && spec.IsFile:
|
||||
foundConfig = true
|
||||
}
|
||||
}
|
||||
if !foundHooks || !foundConfig {
|
||||
t.Fatalf("a workspace owning its own .git lost its carveouts: hooks=%v config=%v specs=%+v",
|
||||
foundHooks, foundConfig, specs)
|
||||
}
|
||||
|
||||
// The specs only matter if they reach the profile the runner enforces.
|
||||
profile := PermissionProfileFromPolicy(workspace, DefaultPolicy(), nil)
|
||||
var root *WritableRoot
|
||||
for index := range profile.FileSystem.WriteRoots {
|
||||
if sameGitTestPath(profile.FileSystem.WriteRoots[index].Root, workspace) {
|
||||
root = &profile.FileSystem.WriteRoots[index]
|
||||
break
|
||||
}
|
||||
}
|
||||
if root == nil {
|
||||
t.Fatalf("no writable root for the workspace: %+v", profile.FileSystem.WriteRoots)
|
||||
}
|
||||
for _, want := range []string{wantHooks, wantConfig} {
|
||||
covered := false
|
||||
for _, sub := range root.ReadOnlySubpaths {
|
||||
if sameGitTestPath(sub, want) {
|
||||
covered = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !covered {
|
||||
t.Errorf("%s is not read-only in the workspace write root: %+v", want, root.ReadOnlySubpaths)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Compared through the profile's own canonicalization. PermissionProfileFromPolicy
|
||||
// stores normalizeProfilePath(root), which resolves symlinks, so a raw t.TempDir()
|
||||
// path does not match it on a runner where the temp directory is reached through
|
||||
// one: macOS /var -> /private/var, and the Windows short-name expansion of the
|
||||
// runner's profile directory. Comparing cleaned strings passed locally and failed
|
||||
// on both CI runners for that reason alone.
|
||||
func sameGitTestPath(a, b string) bool {
|
||||
return normalizeProfilePath(a) == normalizeProfilePath(b)
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func pointerWorkspace(t *testing.T) (workspace string, pointer string) {
|
||||
t.Helper()
|
||||
workspace = t.TempDir()
|
||||
pointer = filepath.Join(workspace, ".git")
|
||||
if err := os.WriteFile(pointer, []byte("gitdir: ../real/.git/worktrees/w\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return workspace, pointer
|
||||
}
|
||||
|
||||
func pointerPlanConfig(t *testing.T, workspace string) WindowsSandboxCommandConfig {
|
||||
t.Helper()
|
||||
return WindowsSandboxCommandConfig{
|
||||
SandboxHome: t.TempDir(),
|
||||
CommandCWD: workspace,
|
||||
WorkspaceRoots: []string{workspace},
|
||||
PermissionProfile: PermissionProfile{
|
||||
FileSystem: FileSystemPolicy{
|
||||
Kind: FileSystemRestricted,
|
||||
WriteRoots: []WritableRoot{{
|
||||
Root: workspace,
|
||||
ReadOnlySubpaths: gitMetadataWriteCarveouts(workspace),
|
||||
ProtectedMetadataNames: sandboxFullyProtectedMetadataNames,
|
||||
}},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// THE SHAPE HAS TO SURVIVE THE STAGE BOUNDARY, NOT JUST BE CORRECT AT STAGE ONE.
|
||||
//
|
||||
// gitMetadataWriteCarveoutSpecs types a linked worktree's .git as a file. That
|
||||
// typed result was flattened to a path before ACL planning, and the planner tried
|
||||
// to recover the shape by deriving suffixes under a sentinel root whose .git never
|
||||
// exists. The derivation therefore always took the directory branch, and a bare
|
||||
// <root>\.git could never match as a file.
|
||||
//
|
||||
// The window that turns a wrong plan into damage is the caller-to-elevated-helper
|
||||
// gap: the profile is built in the user's shell and the plan is applied in a
|
||||
// separately launched helper, across the UAC prompt. If the pointer goes away in
|
||||
// between, the applier materializes a DIRECTORY at the pointer path and the
|
||||
// worktree is broken, persistently, because the runner discards the rollback.
|
||||
//
|
||||
// Driven through BuildWindowsACLPlan and applyWindowsACLPlan with the pointer
|
||||
// removed after planning, which is the only place this is visible. A stage-one
|
||||
// assertion on specs[0].IsFile passed throughout the whole time the bug was live.
|
||||
func TestALinkedWorktreePointerIsNeverMaterializedAsADirectory(t *testing.T) {
|
||||
workspace, pointer := pointerWorkspace(t)
|
||||
plan, err := BuildWindowsACLPlan(pointerPlanConfig(t, workspace))
|
||||
if err != nil {
|
||||
t.Fatalf("BuildWindowsACLPlan: %v", err)
|
||||
}
|
||||
|
||||
// SETUP: the plan really does carry the pointer, typed as a file.
|
||||
var found bool
|
||||
for _, entry := range plan.Entries {
|
||||
if entry.Path == pointer && entry.Action == WindowsACLDenyWrite {
|
||||
found = true
|
||||
if !entry.MaterializeFile {
|
||||
t.Fatalf("the plan types %s as a directory; applying it would replace the worktree pointer with a directory", pointer)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("SETUP INVALID: no deny-write entry for the pointer %s: %+v", pointer, plan.Entries)
|
||||
}
|
||||
|
||||
// The window: the pointer goes away between planning and applying.
|
||||
if err := os.Remove(pointer); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := applyWindowsACLPlan(plan); err != nil {
|
||||
t.Skipf("cannot apply an ACL plan here: %v", err)
|
||||
}
|
||||
|
||||
info, err := os.Lstat(pointer)
|
||||
if err != nil {
|
||||
// Nothing recreated is also acceptable: what must not happen is a directory.
|
||||
return
|
||||
}
|
||||
if info.IsDir() {
|
||||
t.Fatalf("a directory was created at the worktree pointer %s, which breaks the worktree", pointer)
|
||||
}
|
||||
}
|
||||
|
||||
// Control: with the pointer present at apply, it stays a file and is not
|
||||
// replaced. Without this, the assertion above could be satisfied by an apply
|
||||
// that does nothing at all.
|
||||
func TestALinkedWorktreePointerSurvivesAnApply(t *testing.T) {
|
||||
workspace, pointer := pointerWorkspace(t)
|
||||
plan, err := BuildWindowsACLPlan(pointerPlanConfig(t, workspace))
|
||||
if err != nil {
|
||||
t.Fatalf("BuildWindowsACLPlan: %v", err)
|
||||
}
|
||||
if _, err := applyWindowsACLPlan(plan); err != nil {
|
||||
t.Skipf("cannot apply an ACL plan here: %v", err)
|
||||
}
|
||||
info, err := os.Lstat(pointer)
|
||||
if err != nil {
|
||||
t.Fatalf("the pointer vanished across the apply: %v", err)
|
||||
}
|
||||
if info.IsDir() {
|
||||
t.Fatalf("the pointer at %s became a directory", pointer)
|
||||
}
|
||||
body, err := os.ReadFile(pointer)
|
||||
if err != nil || len(body) == 0 {
|
||||
t.Fatalf("the pointer lost its contents: body=%q err=%v", body, err)
|
||||
}
|
||||
}
|
||||
+195
-14
@@ -96,28 +96,209 @@ var protectedMetadataNames = []string{".git", ".zero", ".agents"}
|
||||
// gitMetadataWriteCarveouts below.
|
||||
var sandboxFullyProtectedMetadataNames = []string{".zero", ".agents"}
|
||||
|
||||
// gitMetadataWriteCarveouts returns the .git paths that stay write-denied under
|
||||
// the OS-level sandbox even though the rest of .git is writable to git
|
||||
// subprocesses. Worktrees and submodules store .git as a regular pointer file,
|
||||
// so they protect that file itself: constructing .git/hooks below it makes
|
||||
// bwrap fail before the command starts. All other entry and error states retain
|
||||
// the legacy child paths. The pointer is deliberately not parsed here;
|
||||
// repository-controlled metadata must not redirect sandbox rules elsewhere.
|
||||
// sandboxRenameProtectedMetadataName is the metadata directory that cannot be
|
||||
// fully write-protected but must still not be REPLACEABLE.
|
||||
//
|
||||
// It is deliberately not in the list above. That list denies write, and git has
|
||||
// to write index, objects and refs. But the carveouts guarding it are attached
|
||||
// to .git/config and .git/hooks as objects, so a principal that renames .git and
|
||||
// recreates it gets fresh paths inheriting the workspace allow with no denies,
|
||||
// which restores credential.helper and core.hooksPath. The Windows ACL plan
|
||||
// therefore denies DELETE on this directory alone, uninherited.
|
||||
const sandboxRenameProtectedMetadataName = ".git"
|
||||
|
||||
// gitMetadataWriteCarveouts returns the .git subpaths that stay write-denied
|
||||
// under the OS-level sandbox even though the rest of .git is writable to git
|
||||
// subprocesses. Nonexistent paths are harmless no-ops in every backend's
|
||||
// enforcement (seatbelt regex, bwrap ro-bind, Windows ACL deny entry).
|
||||
func gitMetadataWriteCarveouts(root string) []string {
|
||||
return gitMetadataWriteCarveoutsWithLstat(root, os.Lstat)
|
||||
}
|
||||
|
||||
// gitMetadataWriteCarveoutsWithLstat is the seam the worktree tests drive.
|
||||
// Which carveouts apply is decided by the SHAPE of .git, and a test cannot
|
||||
// create every shape it needs to cover: an lstat that fails is a real state
|
||||
// (a racing checkout, a permission error) with its own required answer, and
|
||||
// there is no filesystem it can be staged on.
|
||||
func gitMetadataWriteCarveoutsWithLstat(root string, lstat func(string) (os.FileInfo, error)) []string {
|
||||
specs := gitMetadataWriteCarveoutSpecsWithLstat(root, lstat)
|
||||
out := make([]string, 0, len(specs))
|
||||
for _, spec := range specs {
|
||||
out = append(out, spec.Path)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// gitMetadataCarveout is a write-denied .git path together with the shape git
|
||||
// expects it to have. The shape matters to exactly one backend: the Windows ACL
|
||||
// plan creates a missing carveout so the deny ACE is in place before git first
|
||||
// runs, and creating .git/config as a directory makes `git init` fail outright.
|
||||
// Every other backend only ever names the path, so it can ignore IsFile.
|
||||
type gitMetadataCarveout struct {
|
||||
Path string
|
||||
IsFile bool
|
||||
}
|
||||
|
||||
// gitMetadataWriteCarveoutSpecs is the single source of truth for the carveout
|
||||
// set. gitMetadataWriteCarveouts derives its list from this so a path can never
|
||||
// be added in one place and have its shape forgotten in the other.
|
||||
func gitMetadataWriteCarveoutSpecs(root string) []gitMetadataCarveout {
|
||||
return gitMetadataWriteCarveoutSpecsWithLstat(root, os.Lstat)
|
||||
}
|
||||
|
||||
func gitMetadataWriteCarveoutSpecsWithLstat(root string, lstat func(string) (os.FileInfo, error)) []gitMetadataCarveout {
|
||||
gitPath := filepath.Join(root, ".git")
|
||||
children := []string{
|
||||
filepath.Join(gitPath, "hooks"),
|
||||
filepath.Join(gitPath, "config"),
|
||||
// A LINKED WORKTREE OR SUBMODULE HAS .git AS A FILE, NOT A DIRECTORY.
|
||||
//
|
||||
// It holds a `gitdir:` pointer to the real control directory, which lives
|
||||
// outside this write root. Naming .git/config and .git/hooks there asks the
|
||||
// Windows plan to materialize them by descending through .git as a directory,
|
||||
// which cannot open a child beneath a regular file, so opted-in elevated setup
|
||||
// aborts and the sandbox is unusable in any worktree. Zero's own development
|
||||
// worktrees are exactly this shape.
|
||||
//
|
||||
// Deny the pointer file itself instead. It is the right protection rather than
|
||||
// a lesser one: a principal that can rewrite `gitdir:` repoints the whole
|
||||
// repository at a control directory it chooses, which subsumes editing config
|
||||
// or dropping a hook. The real control directory is outside the write root, so
|
||||
// the principal has no inherited access to it and needs no carveout there.
|
||||
//
|
||||
// Stat, not a lexical guess. Whether .git is a file is a property of the
|
||||
// checkout, and the two layouts want different ACEs. A missing .git (git has
|
||||
// not run yet) keeps the directory-shaped carveouts, which is what makes them
|
||||
// materialize before git first runs.
|
||||
//
|
||||
// A REGULAR FILE, NOT MERELY A NON-DIRECTORY. The pointer this recognises is
|
||||
// the `gitdir:` file git writes for a linked worktree or submodule. A symlink
|
||||
// named .git, dangling or not, and a special entry such as a fifo are neither
|
||||
// that nor a checkout git will use, so they keep the legacy child carveouts
|
||||
// rather than being handed the stronger single-object protection on the
|
||||
// strength of not being a directory.
|
||||
gitInfo, gitErr := lstat(gitPath)
|
||||
if gitErr == nil && gitInfo.Mode().IsRegular() {
|
||||
return []gitMetadataCarveout{{Path: gitPath, IsFile: true}}
|
||||
}
|
||||
info, err := lstat(gitPath)
|
||||
if err != nil || !info.Mode().IsRegular() {
|
||||
return children
|
||||
// EVERY WRITE ROOT GETS THE CARVEOUTS, INCLUDING ONE INSIDE ANOTHER REPOSITORY.
|
||||
//
|
||||
// The directory-shaped carveouts below are materialized by the Windows plan so
|
||||
// the deny ACE is in place before git first runs. For a while this branch
|
||||
// skipped them when an ancestor owned the git metadata, on the argument that
|
||||
// creating .git/config and .git/hooks there synthesizes a control directory
|
||||
// competing with the ancestor's for git's discovery walk. Measured against the
|
||||
// git this repository builds against, it does not: a .git holding only config
|
||||
// and hooks is not a valid repository, so `rev-parse --show-toplevel` still
|
||||
// answers with the ancestor, `status` still works, and a core.fsmonitor
|
||||
// written into that config is never executed. The test named below drives real
|
||||
// git and fails if that ever stops being true.
|
||||
//
|
||||
// Skipping them cost much more than it saved. With no carveout on this root
|
||||
// nothing denies writes to <root>/.git at all, so a sandboxed command can
|
||||
// assemble a repository there by hand, with mkdir and a few file writes and
|
||||
// not one git command among them, and put core.fsmonitor in its config. Zero
|
||||
// then runs `git -C <workspace> status` OUTSIDE the sandbox (scratch_files.go,
|
||||
// internal/repoinfo, internal/zerogit) and git executes it. The nested-repository
|
||||
// refusal in risk.go does not cover that and cannot: no static reading of a
|
||||
// shell command enumerates the ways to create a directory. It stays as the
|
||||
// layer that explains itself to an operator, not as the boundary.
|
||||
//
|
||||
// So the boundary is here, where it is enforced at write time: config and
|
||||
// hooks are denied on every write root, whatever owns the metadata above it.
|
||||
return []gitMetadataCarveout{
|
||||
{Path: filepath.Join(root, ".git", "hooks")},
|
||||
{Path: filepath.Join(root, ".git", "config"), IsFile: true},
|
||||
}
|
||||
return []string{gitPath}
|
||||
}
|
||||
|
||||
// workspaceGovernedByAncestorRepository is the same question the carveouts ask,
|
||||
// named for the caller that asks it about a whole workspace rather than about
|
||||
// one write root. Sharing the rule is the point: the reason a workspace gets no
|
||||
// carveouts is exactly the reason it may not create a repository.
|
||||
func workspaceGovernedByAncestorRepository(workspaceRoot string) bool {
|
||||
cleaned := strings.TrimSpace(workspaceRoot)
|
||||
if cleaned == "" {
|
||||
return false
|
||||
}
|
||||
if _, err := os.Lstat(filepath.Join(cleaned, ".git")); err == nil {
|
||||
// It already owns git metadata, so setup protected it and nothing here is
|
||||
// being created inside a repository that is not this workspace's.
|
||||
return false
|
||||
}
|
||||
return gitMetadataGovernedByAncestor(cleaned)
|
||||
}
|
||||
|
||||
// gitMetadataGovernedByAncestor reports whether an ancestor of root carries git
|
||||
// metadata, which is git's own discovery rule: the nearest ancestor with a .git
|
||||
// entry owns this directory.
|
||||
//
|
||||
// Lstat rather than a resolved walk, because a .git of either shape answers the
|
||||
// question: a directory is an ordinary repository root and a file is a linked
|
||||
// worktree or submodule pointer. Both mean the metadata is not ours to create.
|
||||
func gitMetadataGovernedByAncestor(root string) bool {
|
||||
current := filepath.Clean(strings.TrimSpace(root))
|
||||
if current == "" || current == "." {
|
||||
return false
|
||||
}
|
||||
for {
|
||||
parent := filepath.Dir(current)
|
||||
if parent == current {
|
||||
return false
|
||||
}
|
||||
if _, err := os.Lstat(filepath.Join(parent, ".git")); err == nil {
|
||||
return true
|
||||
}
|
||||
current = parent
|
||||
}
|
||||
}
|
||||
|
||||
// gitMetadataCarveoutSuffixBase is a sentinel root used only to recover the
|
||||
// trailing segments of the carveout specs. It is never touched on disk.
|
||||
const gitMetadataCarveoutSuffixBase = string(filepath.Separator) + "zero-carveout-base"
|
||||
|
||||
// gitMetadataCarveoutIsFile reports whether path names a carveout git expects
|
||||
// to be a file.
|
||||
//
|
||||
// It matches on the trailing segments rather than on a whole reconstructed
|
||||
// path. The subpaths reaching the ACL plan are already normalized — resolved
|
||||
// through EvalSymlinks where that succeeds — while a rebuilt spec path cannot
|
||||
// be, because .git/config does not exist yet at setup and resolution falls back
|
||||
// to a plain Clean. On a host where two spellings of the same path differ (an
|
||||
// 8.3 short name, different casing) a whole-path equality check silently misses
|
||||
// and the carveout is created as a directory again, which is the original bug
|
||||
// reintroduced quietly. The suffix cannot drift from the spec list because it
|
||||
// is derived from it.
|
||||
func gitMetadataCarveoutIsFile(path string) bool {
|
||||
candidate := strings.ToLower(filepath.Clean(strings.TrimSpace(path)))
|
||||
if candidate == "" {
|
||||
return false
|
||||
}
|
||||
// A BARE .git CARVEOUT IS THE LINKED-WORKTREE POINTER, AND ONLY THAT.
|
||||
//
|
||||
// The suffix derivation below runs specs against a sentinel root whose .git
|
||||
// never exists, so it always takes the directory branch and yields exactly one
|
||||
// file-shaped suffix, .gitconfig. A bare <root>.git could therefore never
|
||||
// match, and both planners emitted MaterializeFile:false for a path the profile
|
||||
// stage had already typed correctly as a file. With the pointer absent at apply
|
||||
// time, the plan then created a DIRECTORY at the pointer path and broke the
|
||||
// worktree.
|
||||
//
|
||||
// The carveout SET already carries the answer. gitMetadataWriteCarveoutSpecs is
|
||||
// the only producer of ReadOnlySubpaths, and it emits a bare .git in exactly one
|
||||
// case: the linked-worktree or submodule pointer. The directory case emits
|
||||
// .githooks and .gitconfig and never the parent. So the shape is not being
|
||||
// guessed from a pathname here, it is being read off which carveout this is.
|
||||
if strings.EqualFold(filepath.Base(candidate), ".git") {
|
||||
return true
|
||||
}
|
||||
for _, spec := range gitMetadataWriteCarveoutSpecs(gitMetadataCarveoutSuffixBase) {
|
||||
if !spec.IsFile {
|
||||
continue
|
||||
}
|
||||
suffix := strings.ToLower(strings.TrimPrefix(spec.Path, gitMetadataCarveoutSuffixBase))
|
||||
if suffix != "" && strings.HasSuffix(candidate, suffix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func PermissionProfileFromPolicy(workspaceRoot string, policy Policy, scope *Scope) PermissionProfile {
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A LINKED WORKTREE HAS .git AS A FILE.
|
||||
//
|
||||
// The Windows principal plan materializes every carveout, and it does so by
|
||||
// descending through .git as a directory. Against a `gitdir:` pointer file that
|
||||
// walk cannot open a child, so opted-in elevated setup aborts and the sandbox
|
||||
// cannot be used in a worktree at all. Zero's own development worktrees have
|
||||
// exactly this layout, so this is the common case for anyone working on the
|
||||
// sandbox itself.
|
||||
func TestGitCarveoutsHandleAWorktreeGitfile(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
gitFile := filepath.Join(root, ".git")
|
||||
// The real shape git writes for a linked worktree.
|
||||
if err := os.WriteFile(gitFile, []byte("gitdir: "+filepath.Join(root, "..", "main", ".git", "worktrees", "wt")+"\n"), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
specs := gitMetadataWriteCarveoutSpecs(root)
|
||||
if len(specs) != 1 {
|
||||
t.Fatalf("carveout specs = %#v, want only the .git pointer file", specs)
|
||||
}
|
||||
if filepath.Clean(specs[0].Path) != filepath.Clean(gitFile) {
|
||||
t.Fatalf("carveout path = %s, want the .git pointer file %s", specs[0].Path, gitFile)
|
||||
}
|
||||
if !specs[0].IsFile {
|
||||
t.Fatal("the .git pointer carveout is not marked as a file, so materialization would create a directory where git needs a file")
|
||||
}
|
||||
// Nothing may name a path BENEATH the pointer file, which is what forces the
|
||||
// directory descent that aborts setup.
|
||||
for _, path := range gitMetadataWriteCarveouts(root) {
|
||||
rest := strings.TrimPrefix(filepath.Clean(path), filepath.Clean(gitFile))
|
||||
if rest != "" {
|
||||
t.Errorf("carveout %s descends through the .git pointer file, which cannot have children", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The ordinary repository layout must keep the config and hooks carveouts. They
|
||||
// are the whole reason the set exists: without them a principal reinstates
|
||||
// credential.helper or core.hooksPath through inherited workspace write access.
|
||||
func TestGitCarveoutsKeepConfigAndHooksForARealGitDir(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(root, ".git"), 0o700); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
paths := gitMetadataWriteCarveouts(root)
|
||||
for _, want := range []string{filepath.Join(root, ".git", "hooks"), filepath.Join(root, ".git", "config")} {
|
||||
found := false
|
||||
for _, got := range paths {
|
||||
if filepath.Clean(got) == filepath.Clean(want) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("carveout %s missing for an ordinary .git directory: %#v", want, paths)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A workspace where git has never run keeps the directory-shaped carveouts. That
|
||||
// is what lets the Windows plan create them before git first runs, so the deny
|
||||
// ACEs are already in place rather than being applied to paths git will create
|
||||
// later with inherited write access.
|
||||
func TestGitCarveoutsKeepConfigAndHooksWhenGitIsAbsent(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
paths := gitMetadataWriteCarveouts(root)
|
||||
if len(paths) != 2 {
|
||||
t.Fatalf("carveouts = %#v, want the config and hooks pair when .git is absent", paths)
|
||||
}
|
||||
}
|
||||
Loaded 100 of 266 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user