mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-08 09:49:34 -10:00
Compare commits
49
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e6d43c62d9 | ||
|
|
673fce11bd | ||
|
|
f00e83a5ed | ||
|
|
1f92b25e8c | ||
|
|
a4279662b9 | ||
|
|
2cf6fd2334 | ||
|
|
995b9086c7 | ||
|
|
4582727f95 | ||
|
|
b903825139 | ||
|
|
eb608741b0 | ||
|
|
2d62387e08 | ||
|
|
a1b9bbb07c | ||
|
|
4cb1f10952 | ||
|
|
c0fedade8d | ||
|
|
7d66942564 | ||
|
|
29fc29a41d | ||
|
|
7c36805505 | ||
|
|
922a5bab5f | ||
|
|
6df5c2b4cb | ||
|
|
b7f90a787f | ||
|
|
b1cf24831d | ||
|
|
0b496ce87d | ||
|
|
724167e90e | ||
|
|
6092303e73 | ||
|
|
1e25fde3b1 | ||
|
|
7fae5c530f | ||
|
|
7af2a1b3b6 | ||
|
|
9c1cc69b59 | ||
|
|
a0b9876b69 | ||
|
|
c38444157c | ||
|
|
75ecf8e385 | ||
|
|
efb6b79164 | ||
|
|
2bcb85bfb3 | ||
|
|
e1253c826c | ||
|
|
f857c4afb4 | ||
|
|
bb7de29c80 | ||
|
|
de5d402e1f | ||
|
|
9ddef0e957 | ||
|
|
ba8fbb8b71 | ||
|
|
265aa52ba7 | ||
|
|
135e3c5657 | ||
|
|
355b76cc78 | ||
|
|
6a9f44a716 | ||
|
|
e1f1b111a1 | ||
|
|
284a948f00 | ||
|
|
671af594fb | ||
|
|
7153ec7a7d | ||
|
|
73eb8d3376 | ||
|
|
0444eb0e6f |
No files matched your search
@@ -1,16 +1,320 @@
|
||||
---
|
||||
description: Use Go-native project commands; Node is only for the npm wrapper.
|
||||
globs: "*.go, *.js, package.json, .github/workflows/*.yml, docs/*.md"
|
||||
description: Extending Zero — how to write AGENTS.md files, custom specialists, skills, hooks, MCP servers, and plugins for the open-source CLI coding agent.
|
||||
globs: "*.go, *.js, *.md, *.json, *.toml, *.yaml, *.yml"
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
Default to Go commands in this repository.
|
||||
# Extending Zero
|
||||
|
||||
- Use `go run ./cmd/zero` to launch the local CLI.
|
||||
- Use `go test ./...` for the repository test suite.
|
||||
- Use `go run ./cmd/zero-release build` to build the release-facing binary.
|
||||
- Use `go run ./cmd/zero-release smoke` to smoke-test the built binary.
|
||||
- Use `go run ./cmd/zero-release package` and `go run ./cmd/zero-release verify` for release artifact checks.
|
||||
- Use `go run ./cmd/zero-perf-bench` for performance smoke work.
|
||||
- Do not add Bun or TypeScript build/test dependencies for repository validation.
|
||||
- Use Node only when directly exercising the npm wrapper at `bin/zero.js`.
|
||||
Zero is an open-source terminal coding agent in the same family as Claude Code, Codex, and Droid. Out of the box it does the obvious things — read, edit, run, search — but the design point of the project is that **every surface is configurable**. This document is the user-facing guide for that configuration.
|
||||
|
||||
If you only want to *use* Zero, the [README](README.md) is enough. This page is for the other three jobs:
|
||||
|
||||
1. Tell the agent about *your* project (drop an `AGENTS.md` in your repo).
|
||||
2. Add new specialist sub-agents.
|
||||
3. Wire Zero into the rest of your toolchain (MCP, skills, hooks, plugins).
|
||||
|
||||
## 1. Drop a project `AGENTS.md`
|
||||
|
||||
When Zero starts in a directory, it looks for project-level instructions and injects them into the system prompt. The lookup walks from your current working directory **up to the nearest git root** and reads the first matching file at each level — general rules at the repo root, more specific rules in sub-trees. Files are labeled with their directory in the prompt (e.g. `## Project guidelines (services/api/AGENTS.md)`).
|
||||
|
||||
Accepted file names, in priority order at each level:
|
||||
|
||||
| Path | Notes |
|
||||
| --- | --- |
|
||||
| `./AGENTS.md` | The classic spot — committed to your repo, shared with the team. |
|
||||
| `./ZERO.md` | Brand-specific alias. Same format, lower priority. |
|
||||
| `./.zero/AGENTS.md` | Project-local, hidden, gitignored. Personal notes that stay out of git. |
|
||||
|
||||
Matching is **case-insensitive** on the basename, so `AGENTS.md`, `Agents.md`, and `agents.md` resolve to the same file on Windows and macOS. The git-tracked filename in this repo is `AGENTS.MD` (uppercase `MD`) — keep that on case-sensitive filesystems (Linux, the WSL filesystem, or a CI runner) to match what the loader looks for.
|
||||
|
||||
Both files use the same format. YAML frontmatter is optional; the markdown body is loaded as instructions for the agent. Zero reads the file once at session start, so changes take effect on the next `zero` launch — not mid-session.
|
||||
|
||||
```markdown
|
||||
# Project conventions for <your project>
|
||||
|
||||
- Build with `make`, not `go build` directly.
|
||||
- Tests live next to the source file (`foo_test.go` next to `foo.go`).
|
||||
- Run `make lint` before opening a PR.
|
||||
- Never edit files under `third_party/` — those are vendored.
|
||||
```
|
||||
|
||||
Tips:
|
||||
|
||||
- Keep each file under ~8 KiB. Zero caps the **total** across all matched files at 32 KiB; everything past the cap is dropped.
|
||||
- Re-state rules in the imperative voice: "Run `make lint`", not "you should consider running the linter".
|
||||
- Don't put secrets, model IDs, or environment-specific paths in `AGENTS.md`. Use `config.json` for those.
|
||||
- In a monorepo, drop a narrower `AGENTS.md` in each sub-tree (e.g. `services/api/AGENTS.md`). Zero picks those up automatically when you launch from inside the sub-tree.
|
||||
- A YAML frontmatter block (`---\n...\n---`) at the top is preserved verbatim in the injected prompt but is not parsed for `globs:` or `alwaysApply:` scoping today — keep the body self-contained.
|
||||
|
||||
## 2. Custom specialists
|
||||
|
||||
Specialists are Zero's sub-agents. Three scopes, in priority order:
|
||||
|
||||
| Scope | Path | Shared? |
|
||||
| --- | --- | --- |
|
||||
| Built-in | compiled into Zero | yes — `worker`, `explorer`, `code-review` |
|
||||
| User | `~/.config/zero/specialists/*.md` | no — your machine only |
|
||||
| Project | `./.zero/specialists/*.md` | yes — the repo team |
|
||||
|
||||
Project overrides user overrides built-in when names collide.
|
||||
|
||||
A specialist is a markdown manifest with frontmatter and a system prompt:
|
||||
|
||||
```markdown
|
||||
---
|
||||
description: Reviews API changes for breaking-change risk and missing tests.
|
||||
tools: read-only,plan
|
||||
---
|
||||
|
||||
You review API changes. For every changed hunk in `internal/api/` or any file
|
||||
that ends in `_api.go`:
|
||||
|
||||
1. Confirm the public signature is backward-compatible, or note the breaking
|
||||
change explicitly with the migration path.
|
||||
2. Confirm a corresponding test exists in `internal/api/*_test.go` and that
|
||||
the new behaviour is exercised.
|
||||
3. Flag any new exported symbol without a doc comment.
|
||||
|
||||
Reply with one JSON object per finding: `{"file", "line", "severity", "message", "fix"}`.
|
||||
```
|
||||
|
||||
CLI management (the prompt is passed inline via `--prompt`):
|
||||
|
||||
```bash
|
||||
zero specialist list
|
||||
zero specialist show api-reviewer
|
||||
zero specialist create api-reviewer \
|
||||
--project \
|
||||
--description "Reviews API changes" \
|
||||
--tools read-only,plan \
|
||||
--prompt "$(cat api-reviewer.md)"
|
||||
zero specialist edit api-reviewer --project
|
||||
zero specialist delete api-reviewer --project
|
||||
zero specialist path # prints the resolved specialists directory
|
||||
```
|
||||
|
||||
The full format spec (frontmatter fields, tool scopes, prompt conventions) is in [`docs/SPECIALISTS.md`](docs/SPECIALISTS.md).
|
||||
|
||||
> **Roadmap.** An in-UI specialist manager (create / edit / delete / preview) is on the backlog. Today you use the `zero specialist` CLI subcommands above.
|
||||
|
||||
## 3. Skills
|
||||
|
||||
Skills are markdown instruction packs the agent can pull in on demand. Each skill is a directory containing a `SKILL.md`. Skills are **user-level only** in this version — there's no project-scoped skill directory yet, so anything you want shared with the team goes in `AGENTS.md` (section 1) or as a hook (section 4).
|
||||
|
||||
Discovery root: `$ZERO_SKILLS_DIR` → `$XDG_DATA_HOME/zero/skills` → `~/.local/share/zero/skills/`. A missing directory is fine — Zero just reports "no skills".
|
||||
|
||||
```
|
||||
~/.local/share/zero/skills/
|
||||
run-benchmarks/
|
||||
SKILL.md
|
||||
write-changelog/
|
||||
SKILL.md
|
||||
```
|
||||
|
||||
`SKILL.md` format:
|
||||
|
||||
```markdown
|
||||
---
|
||||
description: Run the project's benchmark suite and summarize the deltas.
|
||||
---
|
||||
|
||||
# Run benchmarks
|
||||
|
||||
1. `make bench` — captures the wall-clock and RSS before and after.
|
||||
2. `benchstat before.txt after.txt` — diffs the two.
|
||||
3. Report any regression > 5% with the function name and the previous value.
|
||||
```
|
||||
|
||||
Only `name` and `description` are recognized in the frontmatter today. The `name` defaults to the directory name. If two skills declare the same name, the one in the lexicographically-first directory wins — duplicates are dropped silently. Plugin-declared skills (section 6) are not yet merged into the loader, so a `skills:` entry inside a plugin's `plugin.json` is not visible to the `skill` tool today.
|
||||
|
||||
The `skill` core tool lets the agent load any discovered skill by name.
|
||||
|
||||
## 4. Hooks
|
||||
|
||||
Hooks fire shell commands on lifecycle events. Configure them in JSON:
|
||||
|
||||
- User: `~/.config/zero/hooks.json`
|
||||
- Project: `./.zero/hooks.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"enabled": true,
|
||||
"hooks": [
|
||||
{
|
||||
"id": "block-rm-rf",
|
||||
"event": "beforeTool",
|
||||
"matcher": "bash",
|
||||
"command": "/usr/local/bin/zero-hook-block-rmrf.sh",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"id": "log-session",
|
||||
"event": "sessionStart",
|
||||
"command": "/usr/local/bin/zero-hook-log.sh",
|
||||
"enabled": true
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
The `args` array (when present) is passed verbatim to `exec.CommandContext`. The actual hook payload — event name, matcher, tool call id, tool name, tool input, tool output, status — is delivered to the command as **JSON on stdin**, not via `${...}` substitution. A typical handler reads stdin and decides what to do:
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
# /usr/local/bin/zero-hook-block-rmrf.sh
|
||||
set -euo pipefail
|
||||
payload="$(cat)"
|
||||
if printf '%s' "$payload" | grep -q '"input":"[^"]*rm[[:space:]]+-rf'; then
|
||||
echo "refusing rm -rf" >&2
|
||||
exit 1
|
||||
fi
|
||||
```
|
||||
|
||||
Events the agent emits (in dispatch order):
|
||||
|
||||
| Event | Fires when | Matcher allowed? |
|
||||
| --- | --- | --- |
|
||||
| `beforeTool` | A tool is about to run | yes (tool name) |
|
||||
| `afterTool` | A tool just returned | yes (tool name) |
|
||||
| `sessionStart` | A session begins | no |
|
||||
| `sessionEnd` | A session ends | no |
|
||||
| `specialistStart` | A sub-agent is spawned | yes (specialist name) |
|
||||
| `specialistStop` | A sub-agent ends | yes (specialist name) |
|
||||
|
||||
A hook's exit code decides what happens next: `0` continues, non-zero blocks the tool call (`beforeTool`) or surfaces an error (`afterTool`). Hook execution is recorded in the audit log; the audit is reachable from the agent's view of past actions, not from a dedicated `zero doctor` check.
|
||||
|
||||
> **Roadmap.** An in-UI hooks manager is on the backlog. Today you edit the JSON directly.
|
||||
|
||||
## 5. MCP — Model Context Protocol
|
||||
|
||||
Zero is both an **MCP client** (it can call external MCP servers) and an **MCP server** (other agents can call its tools).
|
||||
|
||||
### As a client — configure MCP servers in `config.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"mcp": {
|
||||
"servers": {
|
||||
"docs": {
|
||||
"type": "stdio",
|
||||
"command": "docs-mcp",
|
||||
"args": ["--port", "7777"]
|
||||
},
|
||||
"github": {
|
||||
"type": "http",
|
||||
"url": "https://api.example.com/mcp",
|
||||
"headers": { "Authorization": "Bearer YOUR_TOKEN_HERE" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Manage via CLI:
|
||||
|
||||
```bash
|
||||
zero mcp add docs --type stdio -- docs-mcp --port 7777
|
||||
zero mcp add github --type http --url https://api.example.com/mcp \
|
||||
--header "Authorization=Bearer YOUR_TOKEN_HERE"
|
||||
zero mcp list
|
||||
zero mcp check docs
|
||||
zero mcp remove github
|
||||
zero mcp oauth login github
|
||||
```
|
||||
|
||||
Servers are merged from user and project configs (project wins on conflicts). Token-bearing values in `config.json` are sent verbatim — there is no `${env:...}` expansion — so prefer one of:
|
||||
|
||||
- A wrapper script that sources the secret and execs the real command.
|
||||
- A `--header` value produced by command substitution (`"Authorization=Bearer $(print-token)"`) in a private shell config that you keep out of git.
|
||||
- A secret manager that injects the env var your MCP server reads on its own (the `command` and `args` then run inside that environment).
|
||||
|
||||
### As a server — expose Zero's tools to another agent
|
||||
|
||||
```bash
|
||||
zero serve --mcp
|
||||
```
|
||||
|
||||
The server speaks MCP over stdio. Configure it from the receiving side as a `stdio` server whose command is `zero serve --mcp`.
|
||||
|
||||
## 6. Plugins
|
||||
|
||||
A plugin is a self-contained directory that bundles tools, hooks, and skills for one capability. Plugins live at:
|
||||
|
||||
- User: `~/.config/zero/plugins/<id>/`
|
||||
- Project: `./.zero/plugins/<id>/`
|
||||
|
||||
Each plugin has a `plugin.json` manifest:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "github-pr-review",
|
||||
"name": "GitHub PR Review",
|
||||
"description": "Adds review tools for GitHub PRs.",
|
||||
"version": "1.0.0",
|
||||
"tools": [
|
||||
{ "name": "list_prs", "command": "./tools/list_prs.sh" }
|
||||
],
|
||||
"hooks": [
|
||||
{ "name": "pre-merge-check", "event": "beforeTool", "command": "./hooks/pre-merge.sh" }
|
||||
],
|
||||
"skills": [
|
||||
{ "path": "./skills/review-checklist/SKILL.md" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Install and manage:
|
||||
|
||||
```bash
|
||||
zero plugins add ./github-pr-review # copy into ~/.config/zero/plugins/ or ./.zero/plugins/
|
||||
zero plugins list
|
||||
zero plugins remove github-pr-review # alias: rm
|
||||
```
|
||||
|
||||
A plugin is enabled by being present in the plugins directory and disabled by removing it (or by the user setting `"enabled": false` in its `plugin.json`). Plugins are not enabled or disabled by a CLI subcommand today.
|
||||
|
||||
Plugin commands run with the plugin directory as their working directory. Use relative paths; the loader resolves them at activation time.
|
||||
|
||||
> **Roadmap.** An in-UI plugins manager (browse, install, enable / disable) is on the backlog. Today you use the `zero plugins` CLI subcommands above. Skills declared inside a plugin's `plugin.json` are not yet merged into the `skill` tool's discovery (see section 3).
|
||||
|
||||
## 7. Configuration locations
|
||||
|
||||
Three layers, applied in order (later layers override earlier ones):
|
||||
|
||||
| Layer | Path | Notes |
|
||||
| --- | --- | --- |
|
||||
| Built-in defaults | compiled in | Lowest priority. |
|
||||
| User config | `~/.config/zero/config.json` | Your machine. Never committed. |
|
||||
| Project config | `./.zero/config.json` | The repo. Committed (or not, your call). |
|
||||
| CLI flags | `--model`, `--mode`, ... | Highest priority, per-invocation. |
|
||||
| Environment | `ZERO_*` | Provider commands, secrets, skills dir override. |
|
||||
|
||||
The user config holds things that should follow the user across projects (default provider, default model, theme). The project config holds things the team agreed on (provider catalog, sandbox policies, model restrictions).
|
||||
|
||||
The sandbox `additionalWriteRoots` key is **ignored in project config** by design — a checked-out repo cannot widen its own sandbox. Set it in the user config or pass `--add-dir` per-invocation.
|
||||
|
||||
## 8. End-to-end example
|
||||
|
||||
A team that wants every contributor's Zero to behave the same way commits:
|
||||
|
||||
- `AGENTS.md` — project conventions, build commands, do-not-edit lists.
|
||||
- `.zero/config.json` — provider catalog, default model, allowed tools.
|
||||
- `.zero/specialists/api-reviewer.md` — the team's PR-review specialist.
|
||||
- `.zero/hooks.json` — block `rm -rf` and `git push --force` on `beforeTool`.
|
||||
- `.zero/plugins/internal-tooling/` — a plugin that adds the team's internal CLI tools to the agent's toolset.
|
||||
|
||||
Each contributor adds only:
|
||||
|
||||
- `~/.config/zero/config.json` — their personal API keys, theme, default mode.
|
||||
- `~/.local/share/zero/skills/` — personal skills they keep across projects.
|
||||
|
||||
That's it. Run `zero` from the repo root and the agent has the team's full instruction set, every contributor's personal setup, and nothing else.
|
||||
|
||||
## 9. Reference
|
||||
|
||||
- [README](README.md) — install, quickstart, command reference.
|
||||
- [docs/SPECIALISTS.md](docs/SPECIALISTS.md) — full specialist manifest spec.
|
||||
- [docs/PRD.md](docs/PRD.md) — product requirements, full feature surface.
|
||||
- [docs/STREAM_JSON_PROTOCOL.md](docs/STREAM_JSON_PROTOCOL.md) — `zero exec` I/O contract.
|
||||
- [docs/INSTALL.md](docs/INSTALL.md) — install from source or release.
|
||||
@@ -179,6 +179,40 @@ The model registry tracks each model's capabilities, context window, and cost
|
||||
|
||||
Every mutating tool routes through the permission policy **before** any side effect.
|
||||
|
||||
### Web search & scraping (free, no API key)
|
||||
|
||||
`web_fetch` is built in and always available: it pulls a single URL into clean
|
||||
markdown locally, with no third party. For **search**, JS-rendered scraping,
|
||||
whole-site crawls, PDF→markdown, and structured extraction, Zero ships the
|
||||
[Firecrawl](https://firecrawl.dev) **keyless** MCP server **enabled by default**, so
|
||||
web search and scraping work out of the box with **no setup and no API key** (1,000
|
||||
free credits/month). It is a normal MCP server, so you stay in control:
|
||||
|
||||
```bash
|
||||
zero mcp tools list # see the firecrawl_* tools it exposes
|
||||
zero mcp disable firecrawl # turn it off — web_fetch stays as the local floor
|
||||
```
|
||||
|
||||
- **Privacy / disclosure:** keyless requests route through `firecrawl.dev`. If you
|
||||
prefer nothing leaves your machine, `zero mcp disable firecrawl` and rely on
|
||||
`web_fetch`, or self-host below.
|
||||
- **Self-host (unlimited, private):** Firecrawl is open source (AGPL-3.0). Run your
|
||||
own instance and override the default URL — Zero only *calls* it over the network,
|
||||
so the AGPL never reaches into Zero's own code:
|
||||
```jsonc
|
||||
// config.json
|
||||
{ "mcp": { "servers": { "firecrawl": { "type": "http", "url": "http://localhost:3002/v2/mcp" } } } }
|
||||
```
|
||||
- **Bring your own key (higher limits):** add an auth header over the default:
|
||||
```bash
|
||||
zero mcp add firecrawl --type http --url https://mcp.firecrawl.dev/v2/mcp \
|
||||
--header "Authorization: Bearer fc-your-key"
|
||||
```
|
||||
|
||||
An MCP server that can't be reached at startup (e.g. Firecrawl on an offline
|
||||
machine) is **skipped with a warning, not fatal** — Zero still launches and the
|
||||
rest of its tools, including `web_fetch`, keep working.
|
||||
|
||||
### Extra write directories (`--add-dir`)
|
||||
|
||||
Zero confines writes to the workspace by default. To let the agent write somewhere
|
||||
@@ -204,14 +238,14 @@ still resolve against the workspace only, and network and destructive-shell poli
|
||||
are unchanged. A write denied outside all roots returns an error that suggests
|
||||
`/add-dir`.
|
||||
|
||||
Two extra hardening flags are available in the `sandbox` config block, both **off
|
||||
by default** and safe to leave unset:
|
||||
Two extra hardening/diagnostic flags are available in the `sandbox` config block,
|
||||
both **off by default** and safe to leave unset:
|
||||
|
||||
- `sandbox.blockUnixSockets` (Linux) — on the bubblewrap backend, installs a
|
||||
seccomp filter that denies `AF_UNIX` socket creation in the sandboxed command,
|
||||
closing the Unix-socket channel that filesystem/network isolation leaves open.
|
||||
Degrades gracefully (runs without the filter) when the `zero-seccomp` helper is
|
||||
not installed alongside the binary.
|
||||
- `sandbox.blockUnixSockets` (Linux) — asks the Linux sandbox helper to install a
|
||||
best-effort seccomp filter that denies `AF_UNIX` socket creation in the
|
||||
sandboxed command. Linux packages also continue to ship `zero-seccomp` as a
|
||||
compatibility wrapper for existing direct uses; new sandbox plans do not depend
|
||||
on that external wrapper.
|
||||
- `sandbox.monitorDenials` (macOS) — tails the unified log for this run's seatbelt
|
||||
denials and appends them to a command's stderr as a `<sandbox_violations>` block
|
||||
so blocked operations are visible. A no-op on OS versions that do not deliver
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
//go:build linux
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
)
|
||||
|
||||
func main() {
|
||||
os.Exit(sandbox.RunLinuxSandboxHelper(os.Args[1:], os.Stderr))
|
||||
}
|
||||
@@ -1,14 +1,9 @@
|
||||
//go:build linux
|
||||
|
||||
// Command zero-seccomp is a tiny exec wrapper for the Linux sandbox: it installs
|
||||
// the Unix-socket-blocking seccomp filter on itself and then execs the real
|
||||
// command, so a bubblewrap-sandboxed process cannot create AF_UNIX sockets (a gap
|
||||
// bubblewrap's filesystem/network isolation leaves open). Wire it by prefixing the
|
||||
// sandboxed command with this binary, e.g. `bwrap ... -- zero-seccomp <command>`.
|
||||
//
|
||||
// It degrades gracefully: if the filter cannot be installed (e.g. the kernel lacks
|
||||
// seccomp), it warns and runs the command anyway, since bubblewrap still provides
|
||||
// the primary isolation.
|
||||
// Command zero-seccomp is retained as a compatibility wrapper for existing Linux
|
||||
// installs and scripts. The main sandbox path now applies the same optional
|
||||
// Unix-socket filter inside zero-linux-sandbox when sandbox.blockUnixSockets is
|
||||
// enabled.
|
||||
package main
|
||||
|
||||
import (
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
//go:build !linux
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
func main() {
|
||||
fmt.Fprintln(os.Stderr, "zero-seccomp is only supported on Linux")
|
||||
os.Exit(2)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
)
|
||||
|
||||
func main() {
|
||||
os.Exit(sandbox.RunWindowsSandboxCommandRunner(os.Args[1:], os.Stderr))
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
)
|
||||
|
||||
func main() {
|
||||
os.Exit(sandbox.RunWindowsSandboxSetup(os.Args[1:], os.Stderr))
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
# OAuth logins & using ChatGPT / Claude subscriptions
|
||||
|
||||
Zero supports two distinct things people mean by "log in with OAuth":
|
||||
|
||||
1. **OAuth login for a provider/gateway** that issues a *standard* bearer token —
|
||||
fully built in (`zero auth login …`). Zero attaches the token to model calls
|
||||
automatically.
|
||||
2. **Using a ChatGPT or Claude *subscription*** (Plus / Pro / Max) instead of a
|
||||
pay-per-token API key — only possible through a **local proxy**, for the
|
||||
reasons documented below. Zero ships a convenience preset and this recipe.
|
||||
|
||||
---
|
||||
|
||||
## 1. OAuth login for a provider or gateway (built in)
|
||||
|
||||
For any OAuth 2.0 / OIDC provider that returns a normal access token usable as
|
||||
`Authorization: Bearer …` on its API, configure it with `ZERO_OAUTH_<NAME>_*`
|
||||
env vars and log in:
|
||||
|
||||
```sh
|
||||
export ZERO_OAUTH_ACME_CLIENT_ID=…
|
||||
export ZERO_OAUTH_ACME_AUTHORIZE_URL=https://acme.example/oauth/authorize
|
||||
export ZERO_OAUTH_ACME_TOKEN_URL=https://acme.example/oauth/token
|
||||
export ZERO_OAUTH_ACME_SCOPES="openid profile"
|
||||
zero auth login acme # browser (loopback); --device for headless
|
||||
zero auth status
|
||||
```
|
||||
|
||||
When a login exists for a provider, the **OpenAI and Anthropic** providers send
|
||||
`Authorization: Bearer <fresh-token>` (auto-refreshed; one refresh-and-retry on a
|
||||
`401`) instead of the API key. With no login they use the API key exactly as
|
||||
before. Tokens are stored 0600 (or the OS keyring with
|
||||
`ZERO_OAUTH_STORAGE=keyring`) and never logged. See `zero auth --help`.
|
||||
|
||||
### In the setup wizard (`/provider`)
|
||||
|
||||
Running `/provider` opens a **"How do you want to connect?"** chooser:
|
||||
|
||||
```text
|
||||
❯ Sign in with OAuth One-click browser login (OpenRouter, xAI)
|
||||
Paste an API key / browse providers Any of 20+ providers, local, or a proxy
|
||||
```
|
||||
|
||||
Pick **Sign in with OAuth** → the list of providers that do real OAuth → choose one:
|
||||
|
||||
```text
|
||||
❯ OpenRouter browser sign-in · creates a key
|
||||
xAI (Grok) browser or device code
|
||||
```
|
||||
|
||||
- **OpenRouter / xAI** are real OAuth: your browser opens to approve → done (no
|
||||
key to paste). OpenRouter mints a key; xAI stores a refreshable token. The same
|
||||
chooser appears in first-run onboarding. (xAI uses an opt-in preset — set
|
||||
`ZERO_OAUTH_ALLOW_PRESETS=1` or your own `ZERO_OAUTH_XAI_*`; see below.)
|
||||
- **Device code (headless / SSH):** for a provider that supports it (xAI), press
|
||||
**d** on the list to get a code to enter on another device instead of opening a
|
||||
browser. On an SSH session or headless Linux box (no `DISPLAY`) device code is
|
||||
used automatically; set `ZERO_OAUTH_DEVICE=1` to force it anywhere. The CLI
|
||||
equivalent is `zero auth login xai --device`.
|
||||
- **ChatGPT / Claude are intentionally not in this list** — they can't do in-app
|
||||
OAuth (see §2). Use *Paste an API key / browse providers* + a local proxy
|
||||
(`chatgpt-proxy` / `custom-anthropic-compatible`), as described below.
|
||||
|
||||
### Built-in OAuth providers
|
||||
|
||||
- **OpenRouter (no env needed)** — `zero auth openrouter` opens a browser, you
|
||||
approve, and it **mints an OpenRouter API key** (public PKCE flow, no client_id).
|
||||
In the interactive setup wizard, pick **OpenRouter** and press **ctrl+o** at the
|
||||
key step to do the same inline ("Log in with OAuth"). The minted key is saved to
|
||||
the provider profile and used normally.
|
||||
- **xAI (Grok) — opt-in preset** — xAI's flow needs an OAuth `client_id`. Zero
|
||||
ships a built-in preset for the public Grok-CLI client, but to keep third-party
|
||||
client identities out of the default credential path it is **off by default**.
|
||||
Enable it with `export ZERO_OAUTH_ALLOW_PRESETS=1`, then `zero auth login xai`
|
||||
(browser, or `--device` for headless) works one-click; the token is used directly
|
||||
on `api.x.ai/v1`. Without the opt-in, set `ZERO_OAUTH_XAI_CLIENT_ID` (and
|
||||
endpoints, or an issuer) yourself via `ZERO_OAUTH_XAI_*`. Either way the preset is
|
||||
fully overridable by `ZERO_OAUTH_XAI_*` (env wins), and it requires a
|
||||
SuperGrok / X Premium+ subscription; the client_id is an undocumented public
|
||||
Grok-CLI client that may change without notice.
|
||||
|
||||
Any field of a preset is overridable via `ZERO_OAUTH_<NAME>_*`. For a fully custom
|
||||
OAuth/OIDC provider, set those env vars (see `zero auth --help`) and
|
||||
`zero auth login <name>`.
|
||||
|
||||
---
|
||||
|
||||
## 2. ChatGPT / Claude subscriptions — why a proxy is required
|
||||
|
||||
We researched this carefully. As of mid-2026, a **subscription** OAuth token does
|
||||
**not** work as a drop-in bearer against the standard APIs:
|
||||
|
||||
- **OpenAI (ChatGPT):** a "Sign in with ChatGPT" token only works against
|
||||
ChatGPT's own backend (`chatgpt.com/backend-api/codex/responses`, the Responses
|
||||
API), **not** `api.openai.com`. That backend is **Cloudflare bot-protected** —
|
||||
non-browser / headless clients get `cf-mitigated: challenge` → `403`. It also
|
||||
requires mimicking the official Codex client (originator + account-id header).
|
||||
- **Anthropic (Claude):** the Messages API **rejects** subscription OAuth tokens
|
||||
for third-party use unless the request spoofs the Claude Code identity
|
||||
(`anthropic-beta: oauth-2025-04-20`, `claude-cli` UA, and a verbatim
|
||||
*"You are Claude Code…"* system prompt) — and **even then** tool-using requests
|
||||
on Max plans are routed to a disabled billing lane and `400`. Anthropic's policy
|
||||
**prohibits** subscription-token use outside Claude Code / claude.ai, and the
|
||||
timeline hardened through 2026: a **Feb 19 2026** docs update spelled out that
|
||||
Free/Pro/Max OAuth tokens may not be used in third-party tools or the Agent SDK,
|
||||
then on **April 4 2026** enforcement landed and subscription OAuth tokens
|
||||
**stopped working in third-party harnesses** (starting with OpenClaw, then the
|
||||
rest). As of mid-2026 the only supported ways to drive Claude from a third-party
|
||||
tool are a standard **API key** or pay-as-you-go **"Extra Usage"** billing —
|
||||
both per-token, not the flat subscription. The request to allow subscription use
|
||||
(claude-code #37205) was closed *"not planned."*
|
||||
|
||||
So Zero does **not** call those backends directly or spoof those clients — that
|
||||
would be fragile, account-risky, and (for Anthropic) against the vendor's terms.
|
||||
The robust, supported pattern is a **local proxy** that holds your subscription
|
||||
session and exposes a clean OpenAI- or Anthropic-compatible endpoint on
|
||||
`127.0.0.1`. The proxy absorbs the Cloudflare / client-spoofing surface; Zero
|
||||
just points at it.
|
||||
|
||||
### ChatGPT via a local proxy
|
||||
|
||||
Run a local ChatGPT OAuth proxy that exposes an OpenAI-compatible endpoint (these
|
||||
typically listen on `127.0.0.1:10531/v1`). Then use the built-in **`chatgpt-proxy`**
|
||||
preset (no API key — the proxy authenticates):
|
||||
|
||||
```jsonc
|
||||
// ~/.config/zero/config.json (or ./.zero/config.json)
|
||||
{
|
||||
"activeProvider": "chatgpt",
|
||||
"providers": [
|
||||
{
|
||||
"name": "chatgpt",
|
||||
"catalogID": "chatgpt-proxy", // OpenAI-compatible, local, no key
|
||||
"baseURL": "http://localhost:10531/v1", // override for your proxy's port
|
||||
"model": "gpt-5" // whatever model your proxy serves
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
```sh
|
||||
zero exec --prompt "say hi" # routes through the proxy → your ChatGPT plan
|
||||
```
|
||||
|
||||
### Claude via a local proxy
|
||||
|
||||
There is no single canonical Claude OAuth-proxy port, so use the generic
|
||||
**`custom-anthropic-compatible`** entry pointed at your proxy's Anthropic-compatible
|
||||
endpoint:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"activeProvider": "claude",
|
||||
"providers": [
|
||||
{
|
||||
"name": "claude",
|
||||
"catalogID": "custom-anthropic-compatible",
|
||||
"baseURL": "http://localhost:<port>", // your Claude proxy
|
||||
"apiKey": "unused-by-proxy",
|
||||
"model": "claude-sonnet-4.5"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Supported alternatives (no proxy)
|
||||
|
||||
- **API key (recommended, simplest):** set `OPENAI_API_KEY` / `ANTHROPIC_API_KEY`
|
||||
(or per-profile `apiKey`) and use the `openai` / `anthropic` catalog providers.
|
||||
Bills as API usage.
|
||||
- **Anthropic subscription automation, sanctioned:** spawn the real `claude` CLI
|
||||
(e.g. `claude -p …`) as a subprocess — the only path Anthropic recognizes as a
|
||||
first-class subscription session.
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- The `chatgpt-proxy` base URL / port and model are defaults you override for your
|
||||
setup; they are not an endorsement of any specific proxy implementation.
|
||||
- Subscription-via-proxy depends on third-party tools and undocumented vendor
|
||||
backends; it can break without notice. The API-key path is the stable one.
|
||||
@@ -1,37 +1,36 @@
|
||||
module github.com/Gitlawb/zero
|
||||
|
||||
go 1.24.2
|
||||
go 1.25.0
|
||||
|
||||
toolchain go1.26.4
|
||||
|
||||
require (
|
||||
github.com/charmbracelet/bubbles v1.0.0
|
||||
github.com/charmbracelet/bubbletea v1.3.10
|
||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834
|
||||
github.com/charmbracelet/x/ansi v0.11.6
|
||||
charm.land/bubbles/v2 v2.1.0
|
||||
charm.land/bubbletea/v2 v2.0.7
|
||||
charm.land/lipgloss/v2 v2.0.4
|
||||
github.com/atotto/clipboard v0.1.4
|
||||
github.com/charmbracelet/colorprofile v0.4.3
|
||||
github.com/charmbracelet/x/ansi v0.11.7
|
||||
github.com/ledongthuc/pdf v0.0.0-20250511090121-5959a4027728
|
||||
golang.org/x/sys v0.38.0
|
||||
mvdan.cc/sh/v3 v3.11.0
|
||||
golang.org/x/sys v0.46.0
|
||||
mvdan.cc/sh/v3 v3.13.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/atotto/clipboard v0.1.4 // indirect
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.4.1 // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260615092913-2399af76d5b1 // indirect
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20260615092313-b57e5e6d29bb // indirect
|
||||
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||
github.com/clipperhouse/displaywidth v0.9.0 // indirect
|
||||
github.com/clipperhouse/stringish v0.1.1 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.19 // indirect
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||
github.com/charmbracelet/x/termios v0.1.1 // indirect
|
||||
github.com/charmbracelet/x/windows v0.2.2 // indirect
|
||||
github.com/clipperhouse/displaywidth v0.11.0 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
||||
github.com/go-quicktest/qt v1.102.0 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.24 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/muesli/termenv v0.16.0 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/rogpeppe/go-internal v1.15.0 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
golang.org/x/text v0.30.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20260611194520-c48552f49976 // indirect
|
||||
golang.org/x/sync v0.21.0 // indirect
|
||||
)
|
||||
@@ -1,31 +1,33 @@
|
||||
charm.land/bubbles/v2 v2.1.0 h1:YSnNh5cPYlYjPxRrzs5VEn3vwhtEn3jVGRBT3M7/I0g=
|
||||
charm.land/bubbles/v2 v2.1.0/go.mod h1:l97h4hym2hvWBVfmJDtrEHHCtkIKeTEb3TTJ4ZOB3wY=
|
||||
charm.land/bubbletea/v2 v2.0.7 h1:7qw2tTAVar7m7klOPBYfTB0mniv/RuexsYwMRNxSeL0=
|
||||
charm.land/bubbletea/v2 v2.0.7/go.mod h1:DGW2q8gvzHnOpMpZTORs0aySVHCox5C+2Svk0fci1qs=
|
||||
charm.land/lipgloss/v2 v2.0.4 h1:lcPeVtcp23SNra7lHy8iYE4UC2aIipVQ47sbGyyxR5Q=
|
||||
charm.land/lipgloss/v2 v2.0.4/go.mod h1:0653x8epbZSzdDfO/XPS1a/uYPOBeSsCssOpJOqDzik=
|
||||
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
|
||||
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
|
||||
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
||||
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
||||
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
|
||||
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
|
||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=
|
||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=
|
||||
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
|
||||
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
|
||||
github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
|
||||
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
|
||||
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
|
||||
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260615092913-2399af76d5b1 h1:4+r3uOJ69ueRBt4okgEfWZeXs3BD36HcDBmOIAUlETk=
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260615092913-2399af76d5b1/go.mod h1:f/jRa757WUmaOZrbPspXymbg/GnbF+rwe4OLsG7aXYo=
|
||||
github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI=
|
||||
github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20260615092313-b57e5e6d29bb h1:m1Uub9uM7meZ8ofVyeRfK7M/knCi3sF2gKn9kldVW0I=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20260615092313-b57e5e6d29bb/go.mod h1:6fMpcW6iwN/kX+xJ52eqVWsDiBTe0UJD24JLoHFe+P0=
|
||||
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
||||
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
||||
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
|
||||
github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
|
||||
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
|
||||
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
|
||||
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
|
||||
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||
github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI=
|
||||
github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow=
|
||||
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
|
||||
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
|
||||
github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
|
||||
github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
|
||||
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
|
||||
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
||||
github.com/go-quicktest/qt v1.102.0 h1:HSQxCeh5YZH3EL3W39ixjtyaEhcWSXQHtHnMBzSs474=
|
||||
github.com/go-quicktest/qt v1.102.0/go.mod h1:p4lGIVX+8Wa6ZPNDvqcxq36XpUDLh42FLetFU7odllI=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
@@ -34,33 +36,23 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/ledongthuc/pdf v0.0.0-20250511090121-5959a4027728 h1:QwWKgMY28TAXaDl+ExRDqGQltzXqN/xypdKP86niVn8=
|
||||
github.com/ledongthuc/pdf v0.0.0-20250511090121-5959a4027728/go.mod h1:1fEHWurg7pvf5SG6XNE5Q8UZmOwex51Mkx3SLhrW5B4=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
|
||||
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
||||
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
|
||||
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU=
|
||||
github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc=
|
||||
github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
|
||||
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
|
||||
mvdan.cc/sh/v3 v3.11.0 h1:q5h+XMDRfUGUedCqFFsjoFjrhwf2Mvtt1rkMvVz0blw=
|
||||
mvdan.cc/sh/v3 v3.11.0/go.mod h1:LRM+1NjoYCzuq/WZ6y44x14YNAI0NK7FLPeQSaFagGg=
|
||||
golang.org/x/exp v0.0.0-20260611194520-c48552f49976 h1:X8Hz2ImujgbmetVuW+w2YkyZChE3cBpZi2P158rTG9M=
|
||||
golang.org/x/exp v0.0.0-20260611194520-c48552f49976/go.mod h1:vnf4pv9iKZXY58sQE1L86zmNWJ4159e1RkcWiLCkeEY=
|
||||
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
mvdan.cc/sh/v3 v3.13.1 h1:DP3TfgZhDkT7lerUdnp6PTGKyxxzz6T+cOlY/xEvfWk=
|
||||
mvdan.cc/sh/v3 v3.13.1/go.mod h1:lXJ8SexMvEVcHCoDvAGLZgFJ9Wsm2sulmoNEXGhYZD0=
|
||||
@@ -0,0 +1,158 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
"mvdan.cc/sh/v3/syntax"
|
||||
)
|
||||
|
||||
func proposedCommandPrefix(toolName string, args map[string]any) []string {
|
||||
if toolName != "bash" {
|
||||
return nil
|
||||
}
|
||||
command, ok := firstStringArg(args, "command", "cmd", "script", "shell")
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
tokens, ok := safeShellCommandTokens(command)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if requested, ok := requestedPrefixRule(args); ok {
|
||||
if safeRequestedPrefix(requested, tokens) {
|
||||
return requested
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !sandbox.ValidCommandPrefix(tokens) {
|
||||
return nil
|
||||
}
|
||||
return append([]string(nil), tokens...)
|
||||
}
|
||||
|
||||
func matchCommandPrefix(toolName string, args map[string]any, options Options) (sandbox.CommandPrefixGrant, bool, bool) {
|
||||
if toolName != "bash" || options.Sandbox == nil {
|
||||
return sandbox.CommandPrefixGrant{}, false, false
|
||||
}
|
||||
command, ok := firstStringArg(args, "command", "cmd", "script", "shell")
|
||||
if !ok {
|
||||
return sandbox.CommandPrefixGrant{}, false, false
|
||||
}
|
||||
tokens, ok := safeShellCommandTokens(command)
|
||||
if !ok {
|
||||
return sandbox.CommandPrefixGrant{}, false, false
|
||||
}
|
||||
if grant, ok := options.Sandbox.LookupCommandPrefix(toolName, tokens); ok {
|
||||
return grant, true, false
|
||||
}
|
||||
if grant, ok := options.Sandbox.LookupCommandPrefixForSession(toolName, tokens); ok {
|
||||
return grant, true, true
|
||||
}
|
||||
return sandbox.CommandPrefixGrant{}, false, false
|
||||
}
|
||||
|
||||
func firstStringArg(args map[string]any, names ...string) (string, bool) {
|
||||
for _, name := range names {
|
||||
if raw, ok := args[name].(string); ok {
|
||||
value := strings.TrimSpace(raw)
|
||||
if value != "" {
|
||||
return value, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func requestedPrefixRule(args map[string]any) ([]string, bool) {
|
||||
raw, ok := args["prefix_rule"]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
switch value := raw.(type) {
|
||||
case []string:
|
||||
return cleanPrefixRule(value), true
|
||||
case []any:
|
||||
out := make([]string, 0, len(value))
|
||||
for _, item := range value {
|
||||
part, ok := item.(string)
|
||||
if !ok {
|
||||
return nil, true
|
||||
}
|
||||
out = append(out, part)
|
||||
}
|
||||
return cleanPrefixRule(out), true
|
||||
default:
|
||||
return nil, true
|
||||
}
|
||||
}
|
||||
|
||||
func cleanPrefixRule(prefix []string) []string {
|
||||
cleaned := make([]string, 0, len(prefix))
|
||||
for _, part := range prefix {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
return nil
|
||||
}
|
||||
cleaned = append(cleaned, part)
|
||||
}
|
||||
return cleaned
|
||||
}
|
||||
|
||||
func safeRequestedPrefix(prefix []string, command []string) bool {
|
||||
if len(prefix) == 0 || !sandbox.ValidCommandPrefix(prefix) || len(prefix) > len(command) {
|
||||
return false
|
||||
}
|
||||
for index := range prefix {
|
||||
if prefix[index] != command[index] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func safeShellCommandTokens(command string) ([]string, bool) {
|
||||
command = strings.TrimSpace(command)
|
||||
if command == "" {
|
||||
return nil, false
|
||||
}
|
||||
file, err := syntax.NewParser().Parse(strings.NewReader(command), "")
|
||||
if err != nil || len(file.Stmts) != 1 {
|
||||
return nil, false
|
||||
}
|
||||
stmt := file.Stmts[0]
|
||||
if stmt.Negated || stmt.Background || stmt.Coprocess || stmt.Disown || stmt.Semicolon.IsValid() || len(stmt.Redirs) > 0 {
|
||||
return nil, false
|
||||
}
|
||||
call, ok := stmt.Cmd.(*syntax.CallExpr)
|
||||
if !ok || len(call.Assigns) > 0 || len(call.Args) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
tokens := make([]string, 0, len(call.Args))
|
||||
for _, word := range call.Args {
|
||||
if len(word.Parts) != 1 {
|
||||
return nil, false
|
||||
}
|
||||
lit, ok := word.Parts[0].(*syntax.Lit)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
tokens = append(tokens, lit.Value)
|
||||
}
|
||||
if !sandbox.ValidCommandPrefix(tokens) {
|
||||
return nil, false
|
||||
}
|
||||
return tokens, true
|
||||
}
|
||||
|
||||
func equalStringSlices(left []string, right []string) bool {
|
||||
if len(left) != len(right) {
|
||||
return false
|
||||
}
|
||||
for index := range left {
|
||||
if left[index] != right[index] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package agent
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestProposedCommandPrefixUsesSafeSimpleCommands(t *testing.T) {
|
||||
got := proposedCommandPrefix("bash", map[string]any{"command": "go test ./..."})
|
||||
want := []string{"go", "test", "./..."}
|
||||
if !equalStringSlices(got, want) {
|
||||
t.Fatalf("prefix = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProposedCommandPrefixHonorsValidatedRequestedPrefix(t *testing.T) {
|
||||
got := proposedCommandPrefix("bash", map[string]any{
|
||||
"command": "git status --short",
|
||||
"prefix_rule": []any{"git", "status"},
|
||||
})
|
||||
want := []string{"git", "status"}
|
||||
if !equalStringSlices(got, want) {
|
||||
t.Fatalf("prefix = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProposedCommandPrefixRejectsUnsafeRequestedPrefix(t *testing.T) {
|
||||
got := proposedCommandPrefix("bash", map[string]any{
|
||||
"command": "git status --short",
|
||||
"prefix_rule": []any{"git"},
|
||||
})
|
||||
if got != nil {
|
||||
t.Fatalf("broad requested prefix should be rejected, got %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProposedCommandPrefixRejectsUnsafeShellForms(t *testing.T) {
|
||||
cases := []string{
|
||||
"echo hi && echo bye",
|
||||
"cat < in > out",
|
||||
"FOO=bar go test",
|
||||
"echo $(whoami)",
|
||||
"cat *.go",
|
||||
"bash -lc go test",
|
||||
}
|
||||
for _, command := range cases {
|
||||
t.Run(command, func(t *testing.T) {
|
||||
if got := proposedCommandPrefix("bash", map[string]any{"command": command}); got != nil {
|
||||
t.Fatalf("unsafe command got prefix %#v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProposedCommandPrefixRejectsUnsafeLaunchers(t *testing.T) {
|
||||
cases := []string{
|
||||
"find . -type f",
|
||||
"xargs rm -rf /tmp/x",
|
||||
"timeout 5 go test ./...",
|
||||
"nice go test ./...",
|
||||
"nohup go test ./...",
|
||||
"watch ls",
|
||||
"ssh host ls",
|
||||
"make test",
|
||||
"npm run dev",
|
||||
"command git status",
|
||||
"eval echo hi",
|
||||
"exec echo hi",
|
||||
"python script.py",
|
||||
"node script.js",
|
||||
"./script.sh --flag",
|
||||
"/tmp/script.sh --flag",
|
||||
}
|
||||
for _, command := range cases {
|
||||
t.Run(command, func(t *testing.T) {
|
||||
if got := proposedCommandPrefix("bash", map[string]any{"command": command}); got != nil {
|
||||
t.Fatalf("unsafe launcher got prefix %#v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProposedCommandPrefixRejectsRequestedUnsafeLauncherPrefix(t *testing.T) {
|
||||
got := proposedCommandPrefix("bash", map[string]any{
|
||||
"command": "find . -type f",
|
||||
"prefix_rule": []any{"find", "."},
|
||||
})
|
||||
if got != nil {
|
||||
t.Fatalf("unsafe requested launcher prefix should be rejected, got %#v", got)
|
||||
}
|
||||
}
|
||||
@@ -77,17 +77,38 @@ type CompactionResult struct {
|
||||
// ~1k tokens is a representative mid-range cost across providers.
|
||||
const imageTokenEstimate = 1000
|
||||
|
||||
// estimateTokens is a cheap, dependency-free token estimate (~4 chars/token)
|
||||
// ApproxTextTokens estimates the token count of text WITHOUT a real tokenizer.
|
||||
// The BPE tokenizers zero targets fold a run's leading space into the following
|
||||
// token rather than emitting whitespace as its own token, so naive len/4
|
||||
// overcounts real text by ~15-20% (measured: a 24.8k-char prompt counted 5.2k
|
||||
// real tokens where len/4 said 6.2k). Counting NON-whitespace bytes / 4 tracks
|
||||
// the provider's actual count closely (validated against live usage) while
|
||||
// staying allocation- and dependency-free. zero still receives the exact count
|
||||
// back as usage on every request; this estimate is only for the pre-request
|
||||
// context budget preview and the compaction threshold.
|
||||
func ApproxTextTokens(value string) int {
|
||||
nonSpace := 0
|
||||
for i := 0; i < len(value); i++ {
|
||||
switch value[i] {
|
||||
case ' ', '\t', '\n', '\r', '\f', '\v':
|
||||
default:
|
||||
nonSpace++
|
||||
}
|
||||
}
|
||||
return nonSpace / 4
|
||||
}
|
||||
|
||||
// estimateTokens is a cheap, dependency-free token estimate (see ApproxTextTokens)
|
||||
// across message content plus tool call names/arguments and a flat per-image
|
||||
// cost. It deliberately uses no real tokenizer; it only needs to be monotonic
|
||||
// and roughly proportional so the loop can decide when to compact.
|
||||
func estimateTokens(messages []zeroruntime.Message) int {
|
||||
total := 0
|
||||
for _, message := range messages {
|
||||
total += len(message.Content) / 4
|
||||
total += ApproxTextTokens(message.Content)
|
||||
for _, call := range message.ToolCalls {
|
||||
total += len(call.Name) / 4
|
||||
total += len(call.Arguments) / 4
|
||||
total += ApproxTextTokens(call.Name)
|
||||
total += ApproxTextTokens(call.Arguments)
|
||||
total += 4 // small per-call overhead
|
||||
}
|
||||
total += len(message.Images) * imageTokenEstimate
|
||||
@@ -104,11 +125,11 @@ func estimateTokens(messages []zeroruntime.Message) int {
|
||||
func estimateToolDefTokens(tools []zeroruntime.ToolDefinition) int {
|
||||
total := 0
|
||||
for _, tool := range tools {
|
||||
total += len(tool.Name) / 4
|
||||
total += len(tool.Description) / 4
|
||||
total += ApproxTextTokens(tool.Name)
|
||||
total += ApproxTextTokens(tool.Description)
|
||||
if len(tool.Parameters) > 0 {
|
||||
if encoded, err := json.Marshal(tool.Parameters); err == nil {
|
||||
total += len(encoded) / 4
|
||||
total += ApproxTextTokens(string(encoded))
|
||||
}
|
||||
}
|
||||
total += 4 // per-tool overhead
|
||||
|
||||
@@ -48,16 +48,16 @@ func MeasureContext(messages []zeroruntime.Message, tools []zeroruntime.ToolDefi
|
||||
}
|
||||
|
||||
// estimateToolTokens approximates the token footprint of advertised tool
|
||||
// definitions (name + description + JSON schema), matching estimateTokens'
|
||||
// ~4-chars/token heuristic so all categories use the same scale.
|
||||
// definitions (name + description + JSON schema), using the same ApproxTextTokens
|
||||
// heuristic as estimateTokens so all categories share one scale.
|
||||
func estimateToolTokens(tools []zeroruntime.ToolDefinition) int {
|
||||
total := 0
|
||||
for _, tool := range tools {
|
||||
total += len(tool.Name) / 4
|
||||
total += len(tool.Description) / 4
|
||||
total += ApproxTextTokens(tool.Name)
|
||||
total += ApproxTextTokens(tool.Description)
|
||||
if len(tool.Parameters) > 0 {
|
||||
if encoded, err := json.Marshal(tool.Parameters); err == nil {
|
||||
total += len(encoded) / 4
|
||||
total += ApproxTextTokens(string(encoded))
|
||||
}
|
||||
}
|
||||
total += 4 // per-tool overhead
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
)
|
||||
|
||||
// richMCPTool mimics a real MCP tool's schema (Exa/Firecrawl-style search): an
|
||||
// object with several typed, described properties. Real MCP tools are this size
|
||||
// or larger, which is why shipping their full schemas every turn is expensive.
|
||||
type richMCPTool struct{ name string }
|
||||
|
||||
func (t richMCPTool) Name() string { return t.name }
|
||||
func (t richMCPTool) Description() string {
|
||||
return "Search the web and return ranked, structured results with titles, URLs, snippets, and optional cleaned full-page content. Supports natural-language and keyword queries with domain and date filtering."
|
||||
}
|
||||
func (t richMCPTool) Parameters() tools.Schema {
|
||||
return tools.Schema{
|
||||
Type: "object",
|
||||
AdditionalProperties: false,
|
||||
Required: []string{"query"},
|
||||
Properties: map[string]tools.PropertySchema{
|
||||
"query": {Type: "string", Description: "The search query to run against the web index. Supports natural-language and keyword queries."},
|
||||
"num_results": {Type: "integer", Description: "Maximum number of results to return (1-100). Defaults to 10."},
|
||||
"include_domains": {Type: "array", Description: "Only return results from these domains.", Items: &tools.PropertySchema{Type: "string"}},
|
||||
"exclude_domains": {Type: "array", Description: "Never return results from these domains.", Items: &tools.PropertySchema{Type: "string"}},
|
||||
"start_published_date": {Type: "string", Description: "ISO-8601 date; only results published on or after this date are returned."},
|
||||
"end_published_date": {Type: "string", Description: "ISO-8601 date; only results published on or before this date are returned."},
|
||||
"category": {Type: "string", Description: "Restrict results to a content category.", Enum: []string{"news", "research paper", "company", "tweet", "pdf"}},
|
||||
"include_text": {Type: "boolean", Description: "Include cleaned full-page text for each result (slower, larger payload)."},
|
||||
},
|
||||
}
|
||||
}
|
||||
func (t richMCPTool) Safety() tools.Safety {
|
||||
return tools.Safety{SideEffect: tools.SideEffectNetwork, Permission: tools.PermissionAllow}
|
||||
}
|
||||
func (t richMCPTool) Run(_ context.Context, _ map[string]any) tools.Result {
|
||||
return tools.Result{Status: tools.StatusOK}
|
||||
}
|
||||
func (t richMCPTool) Deferred() bool { return true }
|
||||
|
||||
// TestDeferralTokenSavingsMeasurement quantifies the win from the lowered
|
||||
// deferThreshold default: with a typical small MCP server (6 tools), threshold 10
|
||||
// (the OLD default) kept deferral OFF so all six full schemas shipped every turn,
|
||||
// while threshold 3 (the NEW default) collapses them to compact reminder lines.
|
||||
func TestDeferralTokenSavingsMeasurement(t *testing.T) {
|
||||
const mcpToolCount = 6
|
||||
registry := tools.NewRegistry()
|
||||
for i := 0; i < mcpToolCount; i++ {
|
||||
registry.Register(richMCPTool{name: fmt.Sprintf("mcp__websearch__search_v%d", i)})
|
||||
}
|
||||
registry.Register(fakeToolSearchTool{})
|
||||
|
||||
measure := func(threshold int) (int, int) {
|
||||
defs, reminder := partitionTools(registry, PermissionModeAuto, Options{DeferThreshold: threshold}, map[string]bool{})
|
||||
encoded, err := json.Marshal(defs)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal tool defs: %v", err)
|
||||
}
|
||||
bytes := len(encoded) + len(reminder)
|
||||
return bytes, bytes / 4 // ~tokens (approx 4 chars/token)
|
||||
}
|
||||
|
||||
eagerBytes, eagerTok := measure(10) // 6 < 10 -> inactive -> all schemas eager
|
||||
deferBytes, deferTok := measure(3) // 6 >= 3 -> active -> compact reminder
|
||||
savedPct := 100 * float64(eagerBytes-deferBytes) / float64(eagerBytes)
|
||||
|
||||
t.Logf("MCP tools in set: %d", mcpToolCount)
|
||||
t.Logf("threshold 10 (OLD, eager schemas): %d bytes (~%d tokens) per turn", eagerBytes, eagerTok)
|
||||
t.Logf("threshold 3 (NEW, deferred lines): %d bytes (~%d tokens) per turn", deferBytes, deferTok)
|
||||
t.Logf("SAVED: ~%d tokens per turn (%.0f%% smaller tool payload)", eagerTok-deferTok, savedPct)
|
||||
|
||||
if deferBytes >= eagerBytes {
|
||||
t.Fatalf("deferral should shrink the tool payload: eager=%d defer=%d", eagerBytes, deferBytes)
|
||||
}
|
||||
if savedPct < 50 {
|
||||
t.Fatalf("expected a large reduction, got only %.0f%%", savedPct)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
)
|
||||
|
||||
// argsPermTool implements tools.ArgsPermissioner; plainTool does not.
|
||||
type argsPermTool struct{ perm tools.Permission }
|
||||
|
||||
func (argsPermTool) Name() string { return "args" }
|
||||
func (argsPermTool) Description() string { return "" }
|
||||
func (argsPermTool) Parameters() tools.Schema { return tools.Schema{} }
|
||||
func (argsPermTool) Safety() tools.Safety { return tools.Safety{Permission: tools.PermissionPrompt} }
|
||||
func (argsPermTool) Run(context.Context, map[string]any) tools.Result { return tools.Result{} }
|
||||
func (t argsPermTool) PermissionForArgs(map[string]any) tools.Permission { return t.perm }
|
||||
|
||||
type plainTool struct{ perm tools.Permission }
|
||||
|
||||
func (plainTool) Name() string { return "plain" }
|
||||
func (plainTool) Description() string { return "" }
|
||||
func (plainTool) Parameters() tools.Schema { return tools.Schema{} }
|
||||
func (t plainTool) Safety() tools.Safety { return tools.Safety{Permission: t.perm} }
|
||||
func (plainTool) Run(context.Context, map[string]any) tools.Result { return tools.Result{} }
|
||||
|
||||
func TestEffectivePermission(t *testing.T) {
|
||||
// A tool implementing ArgsPermissioner refines its permission per call.
|
||||
if got := effectivePermission(argsPermTool{perm: tools.PermissionAllow}, nil); got != tools.PermissionAllow {
|
||||
t.Fatalf("args-aware tool: got %q, want allow", got)
|
||||
}
|
||||
if got := effectivePermission(argsPermTool{perm: tools.PermissionPrompt}, nil); got != tools.PermissionPrompt {
|
||||
t.Fatalf("args-aware tool: got %q, want prompt", got)
|
||||
}
|
||||
// A tool WITHOUT the interface falls back to its static Safety().Permission —
|
||||
// the args-aware path must never change a normal tool's behavior.
|
||||
if got := effectivePermission(plainTool{perm: tools.PermissionAllow}, nil); got != tools.PermissionAllow {
|
||||
t.Fatalf("plain tool: got %q, want its static allow", got)
|
||||
}
|
||||
if got := effectivePermission(plainTool{perm: tools.PermissionPrompt}, nil); got != tools.PermissionPrompt {
|
||||
t.Fatalf("plain tool: got %q, want its static prompt", got)
|
||||
}
|
||||
}
|
||||
@@ -90,11 +90,48 @@ func toolFailureStopAnswer(toolName string, count int) string {
|
||||
"Please check the request or adjust the tool arguments."
|
||||
}
|
||||
|
||||
// The no-output stop answer is assembled from these fixed parts (only the turn
|
||||
// count varies). IsNoProgressStop matches all three so a legitimate message that
|
||||
// merely quotes the marker substring is not misclassified as a failed empty run.
|
||||
const (
|
||||
noOutputStopPrefix = "Agent stopped after "
|
||||
noOutputStopMarker = "with no output (no visible text and no tool calls)"
|
||||
noOutputStopSuffix = "to avoid consuming tokens without making progress."
|
||||
)
|
||||
|
||||
// noOutputStopAnswer is the final answer returned when the no-output guard
|
||||
// stops the run. The turn count is interpolated at the call site.
|
||||
func noOutputStopAnswer(turns int) string {
|
||||
return "Agent stopped after " + strconv.Itoa(turns) +
|
||||
" turns with no output (no visible text and no tool calls) to avoid consuming tokens without making progress."
|
||||
return noOutputStopPrefix + strconv.Itoa(turns) + " turns " + noOutputStopMarker + " " + noOutputStopSuffix
|
||||
}
|
||||
|
||||
// IsNoProgressStop reports whether content IS the no-output guardrail stop answer
|
||||
// (a run that produced no visible text and no tool calls). It matches the EXACT
|
||||
// structure noOutputStopAnswer emits — prefix + "<int> turns " + marker + " " +
|
||||
// suffix, where only the integer turn count varies — rather than just looking for
|
||||
// the three parts in order. A loose check (prefix && contains-marker && suffix)
|
||||
// would misclassify a genuine assistant/tool message that merely quotes the
|
||||
// marker amid other prose, which would wrongly hide a real session from /resume
|
||||
// and skip its title generation.
|
||||
func IsNoProgressStop(content string) bool {
|
||||
trimmed := strings.TrimSpace(content)
|
||||
if !strings.HasPrefix(trimmed, noOutputStopPrefix) {
|
||||
return false
|
||||
}
|
||||
rest := trimmed[len(noOutputStopPrefix):]
|
||||
const turnsSep = " turns "
|
||||
sep := strings.Index(rest, turnsSep)
|
||||
if sep < 0 {
|
||||
return false
|
||||
}
|
||||
// The text between the prefix and " turns " must be exactly the bare integer
|
||||
// count; anything else means this isn't the guard's own answer.
|
||||
if _, err := strconv.Atoi(rest[:sep]); err != nil {
|
||||
return false
|
||||
}
|
||||
// The marker must be immediately followed (one space) by the suffix and then
|
||||
// end — no arbitrary text wedged in between.
|
||||
return rest[sep+len(turnsSep):] == noOutputStopMarker+" "+noOutputStopSuffix
|
||||
}
|
||||
|
||||
// Reminder markers are stable substrings used both to build the reminder text
|
||||
|
||||
+510
-25
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/hooks"
|
||||
"github.com/Gitlawb/zero/internal/redaction"
|
||||
@@ -23,6 +24,35 @@ const (
|
||||
toolResultControlSpecReview = "spec_review_required"
|
||||
)
|
||||
|
||||
var errPermissionApprovalCanceled = errors.New("permission approval cancelled")
|
||||
|
||||
type permissionRunState struct {
|
||||
mu sync.Mutex
|
||||
cleanups []func()
|
||||
}
|
||||
|
||||
func (state *permissionRunState) add(cleanup func()) {
|
||||
if state == nil || cleanup == nil {
|
||||
return
|
||||
}
|
||||
state.mu.Lock()
|
||||
state.cleanups = append(state.cleanups, cleanup)
|
||||
state.mu.Unlock()
|
||||
}
|
||||
|
||||
func (state *permissionRunState) cleanup() {
|
||||
if state == nil {
|
||||
return
|
||||
}
|
||||
state.mu.Lock()
|
||||
cleanups := state.cleanups
|
||||
state.cleanups = nil
|
||||
state.mu.Unlock()
|
||||
for i := len(cleanups) - 1; i >= 0; i-- {
|
||||
cleanups[i]()
|
||||
}
|
||||
}
|
||||
|
||||
// abortedToolResultNotice is the placeholder tool result recorded for a tool
|
||||
// call that was advertised by the assistant turn but never executed because the
|
||||
// repeated-failure guard halted the run first. It keeps every tool_use paired
|
||||
@@ -64,6 +94,9 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
if permissionMode == "" {
|
||||
permissionMode = PermissionModeAuto
|
||||
}
|
||||
runPermissions := &permissionRunState{}
|
||||
options.runPermissions = runPermissions
|
||||
defer runPermissions.cleanup()
|
||||
|
||||
messages := zeroruntime.SeedMessagesWithImages(buildSystemPrompt(options), prompt, options.Images)
|
||||
|
||||
@@ -545,36 +578,93 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
|
||||
if call.Name == "ask_user" {
|
||||
return executeAskUser(ctx, registry, call, args, permissionMode, options)
|
||||
}
|
||||
if call.Name == tools.RequestPermissionsToolName {
|
||||
return executeRequestPermissions(ctx, call, args, permissionMode, options)
|
||||
}
|
||||
|
||||
permissionGranted := permissionMode == PermissionModeUnsafe
|
||||
if toolFound && tool.Safety().Permission == tools.PermissionAllow {
|
||||
if toolFound && effectivePermission(tool, args) == tools.PermissionAllow {
|
||||
permissionGranted = true
|
||||
}
|
||||
|
||||
decisionReason := ""
|
||||
var decisionAction PermissionDecisionAction
|
||||
var decisionCommandPrefix []string
|
||||
if toolFound && !permissionGranted {
|
||||
if grant, ok, session := matchCommandPrefix(call.Name, args, options); ok {
|
||||
permissionGranted = true
|
||||
if session {
|
||||
decisionAction = PermissionDecisionAllowPrefix
|
||||
decisionReason = "session command prefix approval matched"
|
||||
} else {
|
||||
decisionAction = PermissionDecisionAlwaysAllowPrefix
|
||||
decisionReason = "persistent command prefix approval matched"
|
||||
}
|
||||
decisionCommandPrefix = grant.Prefix
|
||||
}
|
||||
}
|
||||
|
||||
var preflightDecision *sandbox.Decision
|
||||
if toolFound && options.Sandbox != nil {
|
||||
decision := options.Sandbox.Evaluate(ctx, sandboxRequest(call.Name, tool, args, permissionGranted, permissionMode, options))
|
||||
preflightDecision = &decision
|
||||
}
|
||||
|
||||
decisionReason := ""
|
||||
if toolFound && options.OnPermissionRequest != nil && shouldRequestPermission(tool, permissionGranted, preflightDecision) {
|
||||
requestEvent, ok := buildPermissionEvent(call, tool, args, permissionGranted, permissionMode, options, preflightDecision)
|
||||
if !ok {
|
||||
requestEvent = fallbackPermissionEvent(call, tool, args, permissionMode, options)
|
||||
}
|
||||
request := permissionRequestFromEvent(requestEvent, args)
|
||||
request := permissionRequestFromEvent(requestEvent, args, options)
|
||||
decision, err := requestPermission(ctx, request, options)
|
||||
if err != nil {
|
||||
decision = PermissionDecision{Action: PermissionDecisionDeny, Reason: err.Error()}
|
||||
}
|
||||
decision.Action = normalizePermissionDecisionAction(decision.Action)
|
||||
decisionReason = strings.TrimSpace(decision.Reason)
|
||||
decisionAction = decision.Action
|
||||
switch decision.Action {
|
||||
case PermissionDecisionAllow:
|
||||
permissionGranted = true
|
||||
requestEvent.DecisionAction = decision.Action
|
||||
case PermissionDecisionAllowForSession:
|
||||
permissionGranted = true
|
||||
requestEvent.DecisionAction = decision.Action
|
||||
if options.Sandbox != nil {
|
||||
// The current call stays allowed if recording the session grant
|
||||
// fails; the user is simply prompted again for a later match.
|
||||
if grant, err := persistSessionPermissionGrant(call.Name, args, decisionReason, options); err == nil {
|
||||
requestEvent.GrantMatched = true
|
||||
requestEvent.Grant = &grant
|
||||
}
|
||||
}
|
||||
case PermissionDecisionAllowPrefix:
|
||||
if len(request.CommandPrefix) == 0 {
|
||||
emitDeniedPermission(options, call, requestEvent, decisionReason)
|
||||
return deniedPermissionResult(call, decisionReason, requestEvent), nil
|
||||
}
|
||||
permissionGranted = true
|
||||
requestEvent.DecisionAction = decision.Action
|
||||
decisionCommandPrefix = append([]string(nil), request.CommandPrefix...)
|
||||
if options.Sandbox != nil && len(decisionCommandPrefix) > 0 {
|
||||
options.Sandbox.GrantCommandPrefixForSession(call.Name, decisionCommandPrefix)
|
||||
}
|
||||
case PermissionDecisionAlwaysAllowPrefix:
|
||||
if len(request.CommandPrefix) == 0 {
|
||||
emitDeniedPermission(options, call, requestEvent, decisionReason)
|
||||
return deniedPermissionResult(call, decisionReason, requestEvent), nil
|
||||
}
|
||||
permissionGranted = true
|
||||
requestEvent.DecisionAction = decision.Action
|
||||
decisionCommandPrefix = append([]string(nil), request.CommandPrefix...)
|
||||
if options.Sandbox != nil && len(decisionCommandPrefix) > 0 {
|
||||
if grant, err := persistCommandPrefixGrant(call.Name, decisionCommandPrefix, decisionReason, options); err == nil {
|
||||
decisionCommandPrefix = append([]string(nil), grant.Prefix...)
|
||||
}
|
||||
}
|
||||
case PermissionDecisionAlwaysAllow:
|
||||
permissionGranted = true
|
||||
requestEvent.DecisionAction = decision.Action
|
||||
// "Always allow" also persists a grant so FUTURE calls skip the prompt.
|
||||
// With no sandbox engine there is nowhere to persist it, and persistence
|
||||
// can also fail — neither must deny what the user explicitly allowed, so
|
||||
@@ -584,8 +674,14 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
|
||||
// (buildPermissionEvent reads decision.GrantMatched/Grant), so the
|
||||
// persisted grant does not need to be recorded on requestEvent here.
|
||||
if options.Sandbox != nil {
|
||||
_, _ = persistPermissionGrant(call.Name, args, decisionReason, options)
|
||||
if grant, err := persistPermissionGrant(call.Name, args, decisionReason, options); err == nil {
|
||||
requestEvent.GrantMatched = true
|
||||
requestEvent.Grant = &grant
|
||||
}
|
||||
}
|
||||
case PermissionDecisionCancel:
|
||||
emitCanceledPermission(options, call, requestEvent, decisionReason)
|
||||
return canceledPermissionResult(call, decisionReason, requestEvent), fmt.Errorf("%w for %s", errPermissionApprovalCanceled, call.Name)
|
||||
default:
|
||||
emitDeniedPermission(options, call, requestEvent, decisionReason)
|
||||
return deniedPermissionResult(call, decisionReason, requestEvent), nil
|
||||
@@ -624,6 +720,12 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
|
||||
if toolFound && options.OnPermission != nil {
|
||||
if event, ok := buildPermissionEvent(call, tool, args, permissionGranted, permissionMode, options, sandboxDecision); ok {
|
||||
event.DecisionReason = decisionReason
|
||||
if decisionAction != "" {
|
||||
event.DecisionAction = decisionAction
|
||||
}
|
||||
if len(decisionCommandPrefix) > 0 {
|
||||
event.CommandPrefix = append([]string(nil), decisionCommandPrefix...)
|
||||
}
|
||||
options.OnPermission(event)
|
||||
}
|
||||
}
|
||||
@@ -912,6 +1014,17 @@ func sandboxRequest(toolName string, tool tools.Tool, args map[string]any, permi
|
||||
}
|
||||
}
|
||||
|
||||
// effectivePermission returns the permission for THIS specific call. A tool that
|
||||
// implements tools.ArgsPermissioner may relax its static permission for arguments
|
||||
// it can prove are safe (the Task tool auto-allows a read-only sub-agent); every
|
||||
// other tool falls back to its static Safety().Permission unchanged.
|
||||
func effectivePermission(tool tools.Tool, args map[string]any) tools.Permission {
|
||||
if p, ok := tool.(tools.ArgsPermissioner); ok {
|
||||
return p.PermissionForArgs(args)
|
||||
}
|
||||
return tool.Safety().Permission
|
||||
}
|
||||
|
||||
func shouldRequestPermission(tool tools.Tool, permissionGranted bool, decision *sandbox.Decision) bool {
|
||||
if permissionGranted || tool.Safety().Permission != tools.PermissionPrompt {
|
||||
return false
|
||||
@@ -931,13 +1044,251 @@ func requestPermission(ctx context.Context, request PermissionRequest, options O
|
||||
|
||||
func normalizePermissionDecisionAction(action PermissionDecisionAction) PermissionDecisionAction {
|
||||
switch action {
|
||||
case PermissionDecisionAllow, PermissionDecisionAlwaysAllow:
|
||||
case PermissionDecisionAllow, PermissionDecisionAllowStrict, PermissionDecisionAllowForSession, PermissionDecisionAllowPrefix, PermissionDecisionAlwaysAllowPrefix, PermissionDecisionAlwaysAllow, PermissionDecisionCancel:
|
||||
return action
|
||||
default:
|
||||
return PermissionDecisionDeny
|
||||
}
|
||||
}
|
||||
|
||||
type requestPermissionsArgs struct {
|
||||
EnvironmentID string `json:"environment_id"`
|
||||
Reason string `json:"reason"`
|
||||
Permissions sandbox.RequestPermissionProfile `json:"permissions"`
|
||||
}
|
||||
|
||||
func executeRequestPermissions(ctx context.Context, call ToolCall, args map[string]any, permissionMode PermissionMode, options Options) (ToolResult, error) {
|
||||
parsed, err := parseRequestPermissionsArgs(args)
|
||||
if err != nil {
|
||||
return ToolResult{
|
||||
ToolCallID: call.ID,
|
||||
Name: call.Name,
|
||||
Status: tools.StatusError,
|
||||
Output: "Error: Invalid arguments for request_permissions: " + err.Error(),
|
||||
}, nil
|
||||
}
|
||||
normalized, err := sandbox.NormalizeRequestPermissionProfile(parsed.Permissions, options.Cwd)
|
||||
if err != nil {
|
||||
return ToolResult{
|
||||
ToolCallID: call.ID,
|
||||
Name: call.Name,
|
||||
Status: tools.StatusError,
|
||||
Output: "Error: Invalid permissions for request_permissions: " + err.Error(),
|
||||
}, nil
|
||||
}
|
||||
if normalized.Empty() {
|
||||
return ToolResult{
|
||||
ToolCallID: call.ID,
|
||||
Name: call.Name,
|
||||
Status: tools.StatusError,
|
||||
Output: "Error: request_permissions requires at least one permission.",
|
||||
}, nil
|
||||
}
|
||||
grantProfile, err := sandbox.RequestPermissionGrantProfile(normalized)
|
||||
if err != nil {
|
||||
return ToolResult{
|
||||
ToolCallID: call.ID,
|
||||
Name: call.Name,
|
||||
Status: tools.StatusError,
|
||||
Output: "Error: Invalid permissions for request_permissions: " + err.Error(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
request := requestPermissionsPrompt(call, parsed, grantProfile, permissionMode, options)
|
||||
if options.OnPermissionRequest == nil {
|
||||
response := sandbox.RequestPermissionsResponse{
|
||||
Permissions: sandbox.RequestPermissionProfile{},
|
||||
Scope: sandbox.PermissionGrantScopeTurn,
|
||||
}
|
||||
return requestPermissionsResult(call, response, false), nil
|
||||
}
|
||||
|
||||
decision, err := requestPermission(ctx, request, options)
|
||||
if err != nil {
|
||||
decision = PermissionDecision{Action: PermissionDecisionDeny, Reason: err.Error()}
|
||||
}
|
||||
decision.Action = normalizePermissionDecisionAction(decision.Action)
|
||||
decisionReason := strings.TrimSpace(decision.Reason)
|
||||
if decisionReason == "" {
|
||||
decisionReason = request.Reason
|
||||
}
|
||||
|
||||
response := sandbox.RequestPermissionsResponse{
|
||||
Permissions: sandbox.RequestPermissionProfile{},
|
||||
Scope: sandbox.PermissionGrantScopeTurn,
|
||||
}
|
||||
permissionGranted := false
|
||||
switch decision.Action {
|
||||
case PermissionDecisionAllow:
|
||||
response.Permissions = grantProfile
|
||||
response.Scope = sandbox.PermissionGrantScopeTurn
|
||||
permissionGranted = true
|
||||
case PermissionDecisionAllowStrict:
|
||||
response.Permissions = grantProfile
|
||||
response.Scope = sandbox.PermissionGrantScopeTurn
|
||||
response.StrictAutoReview = true
|
||||
permissionGranted = true
|
||||
case PermissionDecisionAllowForSession:
|
||||
response.Permissions = grantProfile
|
||||
response.Scope = sandbox.PermissionGrantScopeSession
|
||||
permissionGranted = true
|
||||
case PermissionDecisionCancel:
|
||||
decision.Action = PermissionDecisionDeny
|
||||
decisionReason = firstNonEmptyString(decisionReason, "continued without granting permissions")
|
||||
default:
|
||||
decision.Action = PermissionDecisionDeny
|
||||
decisionReason = firstNonEmptyString(decisionReason, "continued without granting permissions")
|
||||
}
|
||||
|
||||
if permissionGranted {
|
||||
if options.Sandbox == nil {
|
||||
return ToolResult{
|
||||
ToolCallID: call.ID,
|
||||
Name: call.Name,
|
||||
Status: tools.StatusError,
|
||||
Output: "Error: request_permissions cannot grant permissions because the sandbox engine is not configured.",
|
||||
}, nil
|
||||
}
|
||||
cleanup, err := options.Sandbox.GrantRequestPermissions(response.Permissions, response.Scope)
|
||||
if err != nil {
|
||||
return ToolResult{
|
||||
ToolCallID: call.ID,
|
||||
Name: call.Name,
|
||||
Status: tools.StatusError,
|
||||
Output: "Error: Failed to grant requested permissions: " + err.Error(),
|
||||
}, nil
|
||||
}
|
||||
if response.Scope == sandbox.PermissionGrantScopeTurn && options.runPermissions != nil {
|
||||
options.runPermissions.add(cleanup)
|
||||
}
|
||||
}
|
||||
|
||||
emitRequestPermissionsDecision(options, call, request, decision.Action, decisionReason, permissionGranted)
|
||||
return requestPermissionsResult(call, response, false), nil
|
||||
}
|
||||
|
||||
func parseRequestPermissionsArgs(args map[string]any) (requestPermissionsArgs, error) {
|
||||
data, err := json.Marshal(args)
|
||||
if err != nil {
|
||||
return requestPermissionsArgs{}, err
|
||||
}
|
||||
var parsed requestPermissionsArgs
|
||||
if err := json.Unmarshal(data, &parsed); err != nil {
|
||||
return requestPermissionsArgs{}, err
|
||||
}
|
||||
if parsed.EnvironmentID == "" {
|
||||
if raw, ok := args["environmentId"].(string); ok {
|
||||
parsed.EnvironmentID = raw
|
||||
}
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func requestPermissionsPrompt(call ToolCall, parsed requestPermissionsArgs, profile sandbox.RequestPermissionProfile, permissionMode PermissionMode, options Options) PermissionRequest {
|
||||
autonomy := options.Autonomy
|
||||
if normalized, err := sandbox.NormalizeAutonomy(sandbox.Autonomy(autonomy)); err == nil {
|
||||
autonomy = string(normalized)
|
||||
}
|
||||
reason := strings.TrimSpace(parsed.Reason)
|
||||
if reason == "" {
|
||||
reason = "The agent is requesting additional permissions."
|
||||
}
|
||||
return PermissionRequest{
|
||||
ToolCallID: call.ID,
|
||||
ToolName: tools.RequestPermissionsToolName,
|
||||
Action: PermissionActionPrompt,
|
||||
Permission: string(tools.PermissionPrompt),
|
||||
PermissionMode: permissionMode,
|
||||
Autonomy: autonomy,
|
||||
SideEffect: "permissions",
|
||||
Reason: reason,
|
||||
Scope: requestPermissionsScope(profile),
|
||||
Args: map[string]any{
|
||||
"environment_id": parsed.EnvironmentID,
|
||||
"reason": parsed.Reason,
|
||||
"permissions": profile,
|
||||
},
|
||||
AvailableDecisions: []PermissionDecisionAction{
|
||||
PermissionDecisionAllow,
|
||||
PermissionDecisionAllowStrict,
|
||||
PermissionDecisionAllowForSession,
|
||||
PermissionDecisionDeny,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func requestPermissionsScope(profile sandbox.RequestPermissionProfile) string {
|
||||
parts := []string{}
|
||||
if profile.Network != nil && profile.Network.Enabled != nil && *profile.Network.Enabled {
|
||||
parts = append(parts, "network all outbound access")
|
||||
}
|
||||
if profile.FileSystem != nil {
|
||||
for _, path := range profile.FileSystem.Read {
|
||||
parts = append(parts, "read "+path)
|
||||
}
|
||||
for _, path := range profile.FileSystem.Write {
|
||||
parts = append(parts, "write "+path)
|
||||
}
|
||||
for _, path := range profile.FileSystem.DenyRead {
|
||||
parts = append(parts, "deny read "+path)
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
func emitRequestPermissionsDecision(options Options, call ToolCall, request PermissionRequest, action PermissionDecisionAction, reason string, granted bool) {
|
||||
if options.OnPermission == nil {
|
||||
return
|
||||
}
|
||||
eventAction := PermissionActionDeny
|
||||
if granted {
|
||||
eventAction = PermissionActionAllow
|
||||
}
|
||||
options.OnPermission(PermissionEvent{
|
||||
ToolCallID: call.ID,
|
||||
ToolName: call.Name,
|
||||
Action: eventAction,
|
||||
DecisionAction: action,
|
||||
Permission: request.Permission,
|
||||
PermissionGranted: granted,
|
||||
PermissionMode: request.PermissionMode,
|
||||
Autonomy: request.Autonomy,
|
||||
SideEffect: request.SideEffect,
|
||||
Reason: reason,
|
||||
Scope: request.Scope,
|
||||
DecisionReason: reason,
|
||||
})
|
||||
}
|
||||
|
||||
func requestPermissionsResult(call ToolCall, response sandbox.RequestPermissionsResponse, redacted bool) ToolResult {
|
||||
data, err := json.Marshal(response)
|
||||
if err != nil {
|
||||
return ToolResult{
|
||||
ToolCallID: call.ID,
|
||||
Name: call.Name,
|
||||
Status: tools.StatusError,
|
||||
Output: "Error: Failed to serialize request_permissions response: " + err.Error(),
|
||||
}
|
||||
}
|
||||
output, didRedact := scrubInterceptedOutput(string(data))
|
||||
return ToolResult{
|
||||
ToolCallID: call.ID,
|
||||
Name: call.Name,
|
||||
Status: tools.StatusOK,
|
||||
Output: output,
|
||||
Redacted: redacted || didRedact,
|
||||
}
|
||||
}
|
||||
|
||||
func firstNonEmptyString(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func persistPermissionGrant(toolName string, args map[string]any, reason string, options Options) (sandbox.Grant, error) {
|
||||
if options.Sandbox == nil {
|
||||
return sandbox.Grant{}, errors.New("sandbox engine is not configured")
|
||||
@@ -963,6 +1314,39 @@ func persistPermissionGrant(toolName string, args map[string]any, reason string,
|
||||
})
|
||||
}
|
||||
|
||||
func persistSessionPermissionGrant(toolName string, args map[string]any, reason string, options Options) (sandbox.Grant, error) {
|
||||
if options.Sandbox == nil {
|
||||
return sandbox.Grant{}, errors.New("sandbox engine is not configured")
|
||||
}
|
||||
maxAutonomy := sandbox.Autonomy(options.Autonomy)
|
||||
if maxAutonomy == "" {
|
||||
maxAutonomy = sandbox.AutonomyMedium
|
||||
}
|
||||
if normalized, err := sandbox.NormalizeAutonomy(maxAutonomy); err == nil {
|
||||
maxAutonomy = normalized
|
||||
}
|
||||
scope, kind := sandbox.DeriveScope(toolName, args)
|
||||
return options.Sandbox.GrantForSession(sandbox.GrantInput{
|
||||
ToolName: toolName,
|
||||
Decision: sandbox.GrantAllow,
|
||||
MaxAutonomy: maxAutonomy,
|
||||
Reason: reason,
|
||||
Scope: scope,
|
||||
ScopeKind: kind,
|
||||
})
|
||||
}
|
||||
|
||||
func persistCommandPrefixGrant(toolName string, prefix []string, reason string, options Options) (sandbox.CommandPrefixGrant, error) {
|
||||
if options.Sandbox == nil {
|
||||
return sandbox.CommandPrefixGrant{}, errors.New("sandbox engine is not configured")
|
||||
}
|
||||
return options.Sandbox.GrantCommandPrefix(sandbox.CommandPrefixInput{
|
||||
ToolName: toolName,
|
||||
Prefix: prefix,
|
||||
Reason: reason,
|
||||
})
|
||||
}
|
||||
|
||||
func emitDeniedPermission(options Options, call ToolCall, requestEvent PermissionEvent, reason string) {
|
||||
if options.OnPermission == nil {
|
||||
return
|
||||
@@ -975,11 +1359,31 @@ func emitDeniedPermission(options Options, call ToolCall, requestEvent Permissio
|
||||
event.ToolCallID = call.ID
|
||||
event.ToolName = call.Name
|
||||
event.Action = PermissionActionDeny
|
||||
event.DecisionAction = PermissionDecisionDeny
|
||||
event.PermissionGranted = false
|
||||
event.DecisionReason = reason
|
||||
options.OnPermission(event)
|
||||
}
|
||||
|
||||
func emitCanceledPermission(options Options, call ToolCall, requestEvent PermissionEvent, reason string) {
|
||||
if options.OnPermission == nil {
|
||||
return
|
||||
}
|
||||
reason = strings.TrimSpace(reason)
|
||||
if reason == "" {
|
||||
reason = "cancelled in TUI"
|
||||
}
|
||||
event := requestEvent
|
||||
event.ToolCallID = call.ID
|
||||
event.ToolName = call.Name
|
||||
event.Action = PermissionActionCancel
|
||||
event.DecisionAction = PermissionDecisionCancel
|
||||
event.PermissionGranted = false
|
||||
event.DecisionReason = reason
|
||||
event.Reason = reason
|
||||
options.OnPermission(event)
|
||||
}
|
||||
|
||||
func deniedPermissionResult(call ToolCall, reason string, requestEvent PermissionEvent) ToolResult {
|
||||
reason = strings.TrimSpace(reason)
|
||||
if reason == "" {
|
||||
@@ -990,6 +1394,7 @@ func deniedPermissionResult(call ToolCall, reason string, requestEvent Permissio
|
||||
}
|
||||
event := requestEvent
|
||||
event.Action = PermissionActionDeny
|
||||
event.DecisionAction = PermissionDecisionDeny
|
||||
event.PermissionGranted = false
|
||||
event.DecisionReason = reason
|
||||
if event.Risk.Level == "" {
|
||||
@@ -1016,12 +1421,38 @@ func deniedPermissionResult(call ToolCall, reason string, requestEvent Permissio
|
||||
}
|
||||
}
|
||||
|
||||
func canceledPermissionResult(call ToolCall, reason string, requestEvent PermissionEvent) ToolResult {
|
||||
reason = strings.TrimSpace(reason)
|
||||
if reason == "" {
|
||||
reason = "cancelled in TUI"
|
||||
}
|
||||
event := requestEvent
|
||||
event.Action = PermissionActionCancel
|
||||
event.DecisionAction = PermissionDecisionCancel
|
||||
event.PermissionGranted = false
|
||||
event.DecisionReason = reason
|
||||
event.Reason = reason
|
||||
if requestEvent.ToolName == "" {
|
||||
event.ToolName = call.Name
|
||||
}
|
||||
return ToolResult{
|
||||
ToolCallID: call.ID,
|
||||
Name: call.Name,
|
||||
Status: tools.StatusError,
|
||||
Output: "Error: Permission approval cancelled for " + call.Name + ": " + reason,
|
||||
DenialReason: DenialApprovalCanceled,
|
||||
Meta: map[string]string{
|
||||
"permission_action": string(event.Action),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// permissionScope returns a concise, human-readable description of what a tool
|
||||
// call will actually touch — the file path, directory, or working dir lifted
|
||||
// from its arguments — so the permission card and the persisted decision can
|
||||
// show the user exactly what "allow" covers. It is empty when the tool exposes
|
||||
// no path-like argument (the grant is then plainly tool-wide). The first
|
||||
// matching key wins, ordered most-specific (a concrete file) first.
|
||||
// call will actually touch -- a file path, directory, working dir, or network
|
||||
// host lifted from its arguments -- so the permission card and persisted
|
||||
// decision can show the user exactly what "allow" covers. It is empty when the
|
||||
// tool exposes no scoped argument (the grant is then plainly tool-wide). The
|
||||
// first matching key wins, ordered most-specific (a concrete file) first.
|
||||
func permissionScope(toolName string, args map[string]any) string {
|
||||
// sandbox.DeriveScope is the single source of truth for which arguments carry
|
||||
// a scope, shared with grant persistence and matching so the card display can
|
||||
@@ -1098,6 +1529,7 @@ func buildPermissionEvent(call ToolCall, tool tools.Tool, args map[string]any, p
|
||||
ToolCallID: call.ID,
|
||||
ToolName: call.Name,
|
||||
Action: action,
|
||||
DecisionAction: grantDecisionAction(grant),
|
||||
Permission: string(safety.Permission),
|
||||
PermissionGranted: permissionGranted,
|
||||
PermissionMode: permissionMode,
|
||||
@@ -1109,30 +1541,83 @@ func buildPermissionEvent(call ToolCall, tool tools.Tool, args map[string]any, p
|
||||
Violation: violation,
|
||||
GrantMatched: grantMatched,
|
||||
Grant: grant,
|
||||
CommandPrefix: proposedCommandPrefix(call.Name, args),
|
||||
}, true
|
||||
}
|
||||
|
||||
func grantDecisionAction(grant *sandbox.Grant) PermissionDecisionAction {
|
||||
if grant == nil {
|
||||
return ""
|
||||
}
|
||||
if grant.Session {
|
||||
return PermissionDecisionAllowForSession
|
||||
}
|
||||
if grant.Decision == sandbox.GrantAllow {
|
||||
return PermissionDecisionAlwaysAllow
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func fallbackPermissionEvent(call ToolCall, tool tools.Tool, args map[string]any, permissionMode PermissionMode, options Options) PermissionEvent {
|
||||
event, _ := buildPermissionEvent(call, tool, args, false, permissionMode, options, nil)
|
||||
return event
|
||||
}
|
||||
|
||||
func permissionRequestFromEvent(event PermissionEvent, args map[string]any) PermissionRequest {
|
||||
func permissionRequestFromEvent(event PermissionEvent, args map[string]any, options Options) PermissionRequest {
|
||||
return PermissionRequest{
|
||||
ToolCallID: event.ToolCallID,
|
||||
ToolName: event.ToolName,
|
||||
Action: event.Action,
|
||||
Permission: event.Permission,
|
||||
PermissionMode: event.PermissionMode,
|
||||
Autonomy: event.Autonomy,
|
||||
SideEffect: event.SideEffect,
|
||||
Reason: event.Reason,
|
||||
Scope: event.Scope,
|
||||
Risk: event.Risk,
|
||||
Args: cloneArgs(args),
|
||||
Violation: event.Violation,
|
||||
GrantMatched: event.GrantMatched,
|
||||
Grant: event.Grant,
|
||||
ToolCallID: event.ToolCallID,
|
||||
ToolName: event.ToolName,
|
||||
Action: event.Action,
|
||||
Permission: event.Permission,
|
||||
PermissionMode: event.PermissionMode,
|
||||
Autonomy: event.Autonomy,
|
||||
SideEffect: event.SideEffect,
|
||||
Reason: event.Reason,
|
||||
Scope: event.Scope,
|
||||
Risk: event.Risk,
|
||||
Args: cloneArgs(args),
|
||||
Violation: event.Violation,
|
||||
GrantMatched: event.GrantMatched,
|
||||
Grant: event.Grant,
|
||||
CommandPrefix: append([]string(nil), event.CommandPrefix...),
|
||||
AvailableDecisions: availablePermissionDecisions(event, options),
|
||||
}
|
||||
}
|
||||
|
||||
func availablePermissionDecisions(event PermissionEvent, options Options) []PermissionDecisionAction {
|
||||
if event.Action != PermissionActionPrompt {
|
||||
return nil
|
||||
}
|
||||
decisions := []PermissionDecisionAction{PermissionDecisionAllow}
|
||||
if options.Sandbox != nil {
|
||||
decisions = append(decisions, PermissionDecisionAllowForSession)
|
||||
if event.ToolName == "bash" && len(event.CommandPrefix) > 0 {
|
||||
decisions = append(decisions, PermissionDecisionAllowPrefix)
|
||||
if options.Sandbox.CanPersistGrants() {
|
||||
decisions = append(decisions, PermissionDecisionAlwaysAllowPrefix)
|
||||
}
|
||||
}
|
||||
if options.Sandbox.CanPersistGrants() && permissionSupportsPersistentDecision(event.ToolName) {
|
||||
decisions = append(decisions, PermissionDecisionAlwaysAllow)
|
||||
}
|
||||
}
|
||||
switch event.ToolName {
|
||||
case "bash":
|
||||
decisions = append(decisions, PermissionDecisionDeny, PermissionDecisionCancel)
|
||||
case "apply_patch":
|
||||
decisions = append(decisions, PermissionDecisionCancel)
|
||||
default:
|
||||
decisions = append(decisions, PermissionDecisionDeny)
|
||||
}
|
||||
return decisions
|
||||
}
|
||||
|
||||
func permissionSupportsPersistentDecision(toolName string) bool {
|
||||
switch toolName {
|
||||
case "bash", "apply_patch":
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+550
-1
@@ -54,6 +54,7 @@ func TestIsRetriableToolError(t *testing.T) {
|
||||
// Structured denial categories are authoritative regardless of message text.
|
||||
{"denial: filtered", ToolResult{Status: tools.StatusError, Output: "anything", DenialReason: DenialFiltered}, false},
|
||||
{"denial: permission", ToolResult{Status: tools.StatusError, Output: "anything", DenialReason: DenialPermissionDenied}, false},
|
||||
{"denial: approval canceled", ToolResult{Status: tools.StatusError, Output: "anything", DenialReason: DenialApprovalCanceled}, false},
|
||||
{"denial: sandbox", ToolResult{Status: tools.StatusError, Output: "anything", DenialReason: DenialSandboxViolation}, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
@@ -315,6 +316,37 @@ func TestRunAdvertisesWebFetchInAutoMode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunAdvertisesAllowedWebSearchInAutoMode(t *testing.T) {
|
||||
t.Setenv("ZERO_WEBSEARCH_BASE_URL", "https://search.example/api")
|
||||
registry := tools.NewRegistry()
|
||||
for _, tool := range tools.CoreNetworkTools() {
|
||||
if tool.Name() == "web_search" {
|
||||
registry.Register(tool)
|
||||
}
|
||||
}
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{{
|
||||
{Type: zeroruntime.StreamEventText, Content: "done"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
}},
|
||||
}
|
||||
|
||||
_, err := Run(context.Background(), "what tools exist?", provider, Options{
|
||||
Registry: registry,
|
||||
PermissionMode: PermissionModeAuto,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(provider.requests) != 1 {
|
||||
t.Fatalf("expected one request, got %d", len(provider.requests))
|
||||
}
|
||||
if len(provider.requests[0].Tools) != 1 || provider.requests[0].Tools[0].Name != "web_search" {
|
||||
t.Fatalf("expected web_search to be advertised in auto mode, got %#v", provider.requests[0].Tools)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunFiltersAdvertisedTools(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
@@ -761,6 +793,55 @@ func TestRunRequestsPromptToolPermissionBeforeExecution(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunAllowsWorkspaceWriteWithoutPromptWhenSandboxPolicyPermits(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewWriteFileTool(root))
|
||||
provider := providerCallingWriteFileThenAnswer("write done")
|
||||
var permissionEvents []PermissionEvent
|
||||
|
||||
result, err := Run(context.Background(), "write notes", provider, Options{
|
||||
Registry: registry,
|
||||
PermissionMode: PermissionModeAsk,
|
||||
Autonomy: string(sandbox.AutonomyMedium),
|
||||
Sandbox: sandbox.NewEngine(sandbox.EngineOptions{
|
||||
WorkspaceRoot: root,
|
||||
Policy: sandbox.DefaultPolicy(),
|
||||
}),
|
||||
OnPermissionRequest: func(context.Context, PermissionRequest) (PermissionDecision, error) {
|
||||
t.Fatal("workspace write should not request permission")
|
||||
return PermissionDecision{}, nil
|
||||
},
|
||||
OnPermission: func(event PermissionEvent) {
|
||||
permissionEvents = append(permissionEvents, event)
|
||||
},
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.FinalAnswer != "write done" {
|
||||
t.Fatalf("expected final answer, got %q", result.FinalAnswer)
|
||||
}
|
||||
content, err := os.ReadFile(filepath.Join(root, "notes.txt"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(content) != "hello" {
|
||||
t.Fatalf("expected workspace write content, got %q", content)
|
||||
}
|
||||
if len(permissionEvents) != 1 {
|
||||
t.Fatalf("expected one auto permission event, got %#v", permissionEvents)
|
||||
}
|
||||
event := permissionEvents[0]
|
||||
if event.Action != PermissionActionAllow || event.PermissionGranted || event.GrantMatched {
|
||||
t.Fatalf("expected sandbox policy allow without user grant, got %#v", event)
|
||||
}
|
||||
if event.Reason != "workspace write permitted by sandbox policy" {
|
||||
t.Fatalf("expected workspace-write reason, got %#v", event)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDeniesPromptToolWhenPermissionRequestDenied(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
@@ -809,6 +890,98 @@ func TestRunDeniesPromptToolWhenPermissionRequestDenied(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunAbortsWhenPermissionRequestCanceled(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewWriteFileTool(root))
|
||||
provider := providerCallingWriteFileThenAnswer("write should not continue")
|
||||
var permissionEvents []PermissionEvent
|
||||
|
||||
result, err := Run(context.Background(), "write notes", provider, Options{
|
||||
Registry: registry,
|
||||
PermissionMode: PermissionModeAsk,
|
||||
OnPermissionRequest: func(context.Context, PermissionRequest) (PermissionDecision, error) {
|
||||
return PermissionDecision{Action: PermissionDecisionCancel, Reason: "redirect needed"}, nil
|
||||
},
|
||||
OnPermission: func(event PermissionEvent) {
|
||||
permissionEvents = append(permissionEvents, event)
|
||||
},
|
||||
})
|
||||
|
||||
if !errors.Is(err, errPermissionApprovalCanceled) {
|
||||
t.Fatalf("expected permission approval cancel error, got %v", err)
|
||||
}
|
||||
if result.FinalAnswer != "" {
|
||||
t.Fatalf("canceled run must not continue to final answer, got %q", result.FinalAnswer)
|
||||
}
|
||||
if len(provider.requests) != 1 {
|
||||
t.Fatalf("canceled permission prompt should stop after first turn, got %d provider requests", len(provider.requests))
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "notes.txt")); !os.IsNotExist(err) {
|
||||
t.Fatalf("expected canceled write to leave file missing, stat error: %v", err)
|
||||
}
|
||||
if len(permissionEvents) != 1 {
|
||||
t.Fatalf("expected one cancel permission event, got %#v", permissionEvents)
|
||||
}
|
||||
event := permissionEvents[0]
|
||||
if event.Action != PermissionActionCancel || event.DecisionAction != PermissionDecisionCancel || event.PermissionGranted {
|
||||
t.Fatalf("expected final cancel event, got %#v", event)
|
||||
}
|
||||
if event.DecisionReason != "redirect needed" {
|
||||
t.Fatalf("expected cancel reason in final event, got %#v", event)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAvailablePermissionDecisionsSplitDenyAndCancel(t *testing.T) {
|
||||
options := Options{Sandbox: sandbox.NewEngine(sandbox.EngineOptions{WorkspaceRoot: t.TempDir(), Policy: sandbox.DefaultPolicy()})}
|
||||
cases := []struct {
|
||||
name string
|
||||
tool string
|
||||
want []PermissionDecisionAction
|
||||
}{
|
||||
{
|
||||
name: "bash keeps recoverable deny and explicit cancel",
|
||||
tool: "bash",
|
||||
want: []PermissionDecisionAction{
|
||||
PermissionDecisionAllow,
|
||||
PermissionDecisionAllowForSession,
|
||||
PermissionDecisionDeny,
|
||||
PermissionDecisionCancel,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "apply patch uses cancel without recoverable deny",
|
||||
tool: "apply_patch",
|
||||
want: []PermissionDecisionAction{
|
||||
PermissionDecisionAllow,
|
||||
PermissionDecisionAllowForSession,
|
||||
PermissionDecisionCancel,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "file write keeps recoverable deny",
|
||||
tool: "write_file",
|
||||
want: []PermissionDecisionAction{
|
||||
PermissionDecisionAllow,
|
||||
PermissionDecisionAllowForSession,
|
||||
PermissionDecisionDeny,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := availablePermissionDecisions(PermissionEvent{
|
||||
ToolName: tc.tool,
|
||||
Action: PermissionActionPrompt,
|
||||
}, options)
|
||||
if !equalPermissionDecisions(got, tc.want) {
|
||||
t.Fatalf("decisions = %#v, want %#v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunPersistsAlwaysAllowPermissionDecision(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
store, err := sandbox.NewGrantStore(sandbox.StoreOptions{FilePath: filepath.Join(t.TempDir(), "sandbox-grants.json")})
|
||||
@@ -819,6 +992,8 @@ func TestRunPersistsAlwaysAllowPermissionDecision(t *testing.T) {
|
||||
registry.Register(tools.NewWriteFileTool(root))
|
||||
provider := providerCallingWriteFileThenAnswer("write approved")
|
||||
var permissionEvents []PermissionEvent
|
||||
policy := sandbox.DefaultPolicy()
|
||||
policy.EnforceWorkspace = false
|
||||
|
||||
result, err := Run(context.Background(), "write notes", provider, Options{
|
||||
Registry: registry,
|
||||
@@ -826,7 +1001,7 @@ func TestRunPersistsAlwaysAllowPermissionDecision(t *testing.T) {
|
||||
Autonomy: string(sandbox.AutonomyMedium),
|
||||
Sandbox: sandbox.NewEngine(sandbox.EngineOptions{
|
||||
WorkspaceRoot: root,
|
||||
Policy: sandbox.DefaultPolicy(),
|
||||
Policy: policy,
|
||||
Store: store,
|
||||
}),
|
||||
OnPermissionRequest: func(_ context.Context, request PermissionRequest) (PermissionDecision, error) {
|
||||
@@ -879,6 +1054,359 @@ func TestRunPersistsAlwaysAllowPermissionDecision(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSessionAllowSkipsMatchingPromptWithoutPersistentGrant(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
store, err := sandbox.NewGrantStore(sandbox.StoreOptions{FilePath: filepath.Join(t.TempDir(), "sandbox-grants.json")})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewWriteFileTool(root))
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "write_file"},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"path":"notes.txt","content":"first","overwrite":true}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-2", ToolName: "write_file"},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-2", ArgumentsFragment: `{"path":"notes.txt","content":"second","overwrite":true}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-2"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
{
|
||||
{Type: zeroruntime.StreamEventText, Content: "done"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
},
|
||||
}
|
||||
var requests []PermissionRequest
|
||||
var permissionEvents []PermissionEvent
|
||||
policy := sandbox.DefaultPolicy()
|
||||
policy.EnforceWorkspace = false
|
||||
|
||||
result, err := Run(context.Background(), "write notes twice", provider, Options{
|
||||
Registry: registry,
|
||||
PermissionMode: PermissionModeAsk,
|
||||
Autonomy: string(sandbox.AutonomyMedium),
|
||||
Sandbox: sandbox.NewEngine(sandbox.EngineOptions{
|
||||
WorkspaceRoot: root,
|
||||
Policy: policy,
|
||||
Store: store,
|
||||
}),
|
||||
OnPermissionRequest: func(_ context.Context, request PermissionRequest) (PermissionDecision, error) {
|
||||
requests = append(requests, request)
|
||||
return PermissionDecision{Action: PermissionDecisionAllowForSession, Reason: "trust this file for the session"}, nil
|
||||
},
|
||||
OnPermission: func(event PermissionEvent) {
|
||||
permissionEvents = append(permissionEvents, event)
|
||||
},
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.FinalAnswer != "done" {
|
||||
t.Fatalf("expected final answer, got %q", result.FinalAnswer)
|
||||
}
|
||||
content, err := os.ReadFile(filepath.Join(root, "notes.txt"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(content) != "second" {
|
||||
t.Fatalf("expected second write content, got %q", content)
|
||||
}
|
||||
if len(requests) != 1 {
|
||||
t.Fatalf("expected one permission request, got %#v", requests)
|
||||
}
|
||||
if len(permissionEvents) != 2 {
|
||||
t.Fatalf("expected two permission events, got %#v", permissionEvents)
|
||||
}
|
||||
if permissionEvents[0].DecisionAction != PermissionDecisionAllowForSession || permissionEvents[0].Grant == nil || !permissionEvents[0].Grant.Session {
|
||||
t.Fatalf("expected first event to carry session grant, got %#v", permissionEvents[0])
|
||||
}
|
||||
if !permissionEvents[1].GrantMatched || permissionEvents[1].Grant == nil || !permissionEvents[1].Grant.Session {
|
||||
t.Fatalf("expected second event to be session-grant matched, got %#v", permissionEvents[1])
|
||||
}
|
||||
lookup, err := store.Lookup("write_file", filepath.Join(root, "notes.txt"), sandbox.AutonomyMedium)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if lookup.Matched {
|
||||
t.Fatalf("session approval must not persist a grant, got %#v", lookup)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunCommandPrefixApprovalSkipsLaterMatchingBashPrompt(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewBashTool(root))
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "bash"},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"command":"echo prefix-ok"}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-2", ToolName: "bash"},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-2", ArgumentsFragment: `{"command":"echo prefix-ok again"}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-2"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
{
|
||||
{Type: zeroruntime.StreamEventText, Content: "done"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
},
|
||||
}
|
||||
var requests []PermissionRequest
|
||||
var permissionEvents []PermissionEvent
|
||||
|
||||
result, err := Run(context.Background(), "run twice", provider, Options{
|
||||
Registry: registry,
|
||||
PermissionMode: PermissionModeAsk,
|
||||
Autonomy: string(sandbox.AutonomyMedium),
|
||||
Sandbox: sandbox.NewEngine(sandbox.EngineOptions{
|
||||
WorkspaceRoot: root,
|
||||
Policy: sandbox.DefaultPolicy(),
|
||||
Backend: sandbox.Backend{Name: sandbox.BackendPolicyOnly, Message: "policy-only fallback"},
|
||||
}),
|
||||
OnPermissionRequest: func(_ context.Context, request PermissionRequest) (PermissionDecision, error) {
|
||||
requests = append(requests, request)
|
||||
if !containsPermissionDecision(request.AvailableDecisions, PermissionDecisionAllowPrefix) {
|
||||
t.Fatalf("bash prompt missing prefix approval decision: %#v", request.AvailableDecisions)
|
||||
}
|
||||
if !equalStringSlices(request.CommandPrefix, []string{"echo", "prefix-ok"}) {
|
||||
t.Fatalf("request command prefix = %#v", request.CommandPrefix)
|
||||
}
|
||||
return PermissionDecision{Action: PermissionDecisionAllowPrefix, Reason: "trust this command prefix"}, nil
|
||||
},
|
||||
OnPermission: func(event PermissionEvent) {
|
||||
permissionEvents = append(permissionEvents, event)
|
||||
},
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.FinalAnswer != "done" {
|
||||
t.Fatalf("expected final answer, got %q", result.FinalAnswer)
|
||||
}
|
||||
if len(requests) != 1 {
|
||||
t.Fatalf("expected only the first bash call to prompt, got %#v", requests)
|
||||
}
|
||||
if len(permissionEvents) != 2 {
|
||||
t.Fatalf("expected two bash permission events, got %#v", permissionEvents)
|
||||
}
|
||||
for index, event := range permissionEvents {
|
||||
if event.DecisionAction != PermissionDecisionAllowPrefix || !event.PermissionGranted {
|
||||
t.Fatalf("event %d = %#v, want prefix allow", index, event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunPersistentCommandPrefixApprovalSkipsFutureSessionPrompt(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
store, err := sandbox.NewGrantStore(sandbox.StoreOptions{FilePath: filepath.Join(t.TempDir(), "sandbox-grants.json")})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewBashTool(root))
|
||||
policy := sandbox.DefaultPolicy()
|
||||
policy.Network = sandbox.NetworkAllow
|
||||
|
||||
firstProvider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "bash"},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"command":"echo persist-ok"}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
{
|
||||
{Type: zeroruntime.StreamEventText, Content: "first done"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
},
|
||||
}
|
||||
var firstRequests []PermissionRequest
|
||||
var firstEvents []PermissionEvent
|
||||
if _, err := Run(context.Background(), "run once", firstProvider, Options{
|
||||
Registry: registry,
|
||||
PermissionMode: PermissionModeAsk,
|
||||
Autonomy: string(sandbox.AutonomyMedium),
|
||||
Sandbox: sandbox.NewEngine(sandbox.EngineOptions{
|
||||
WorkspaceRoot: root,
|
||||
Policy: policy,
|
||||
Store: store,
|
||||
Backend: sandbox.Backend{Name: sandbox.BackendPolicyOnly, Message: "policy-only fallback"},
|
||||
}),
|
||||
OnPermissionRequest: func(_ context.Context, request PermissionRequest) (PermissionDecision, error) {
|
||||
firstRequests = append(firstRequests, request)
|
||||
if !containsPermissionDecision(request.AvailableDecisions, PermissionDecisionAlwaysAllowPrefix) {
|
||||
t.Fatalf("bash prompt missing persistent prefix approval decision: %#v", request.AvailableDecisions)
|
||||
}
|
||||
return PermissionDecision{Action: PermissionDecisionAlwaysAllowPrefix, Reason: "trust this command prefix"}, nil
|
||||
},
|
||||
OnPermission: func(event PermissionEvent) {
|
||||
firstEvents = append(firstEvents, event)
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(firstRequests) != 1 {
|
||||
t.Fatalf("expected first run to prompt once, got %#v", firstRequests)
|
||||
}
|
||||
if len(firstEvents) != 1 || firstEvents[0].DecisionAction != PermissionDecisionAlwaysAllowPrefix {
|
||||
t.Fatalf("expected persistent prefix event, got %#v", firstEvents)
|
||||
}
|
||||
prefixes, err := store.ListCommandPrefixes()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(prefixes) != 1 || !equalStringSlices(prefixes[0].Prefix, []string{"echo", "persist-ok"}) {
|
||||
t.Fatalf("persisted prefixes = %#v, want echo persist-ok", prefixes)
|
||||
}
|
||||
|
||||
secondProvider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-2", ToolName: "bash"},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-2", ArgumentsFragment: `{"command":"echo persist-ok again"}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-2"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
{
|
||||
{Type: zeroruntime.StreamEventText, Content: "second done"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
},
|
||||
}
|
||||
var secondEvents []PermissionEvent
|
||||
if _, err := Run(context.Background(), "run again", secondProvider, Options{
|
||||
Registry: registry,
|
||||
PermissionMode: PermissionModeAsk,
|
||||
Autonomy: string(sandbox.AutonomyMedium),
|
||||
Sandbox: sandbox.NewEngine(sandbox.EngineOptions{
|
||||
WorkspaceRoot: root,
|
||||
Policy: policy,
|
||||
Store: store,
|
||||
Backend: sandbox.Backend{Name: sandbox.BackendPolicyOnly, Message: "policy-only fallback"},
|
||||
}),
|
||||
OnPermissionRequest: func(_ context.Context, request PermissionRequest) (PermissionDecision, error) {
|
||||
t.Fatalf("persistent command prefix should skip prompt, got %#v", request)
|
||||
return PermissionDecision{}, nil
|
||||
},
|
||||
OnPermission: func(event PermissionEvent) {
|
||||
secondEvents = append(secondEvents, event)
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(secondEvents) != 1 || secondEvents[0].DecisionAction != PermissionDecisionAlwaysAllowPrefix || !secondEvents[0].PermissionGranted {
|
||||
t.Fatalf("expected persistent prefix match event, got %#v", secondEvents)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunPersistentCommandPrefixDoesNotBypassNetworkDeny(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
store, err := sandbox.NewGrantStore(sandbox.StoreOptions{FilePath: filepath.Join(t.TempDir(), "sandbox-grants.json")})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := store.GrantCommandPrefix(sandbox.CommandPrefixInput{ToolName: "bash", Prefix: []string{"curl"}}); err != nil {
|
||||
t.Fatalf("seed command prefix: %v", err)
|
||||
}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewBashTool(root))
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "bash"},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"command":"curl https://example.com"}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
{
|
||||
{Type: zeroruntime.StreamEventText, Content: "done"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
},
|
||||
}
|
||||
var events []PermissionEvent
|
||||
if _, err := Run(context.Background(), "curl", provider, Options{
|
||||
Registry: registry,
|
||||
PermissionMode: PermissionModeAsk,
|
||||
Autonomy: string(sandbox.AutonomyMedium),
|
||||
Sandbox: sandbox.NewEngine(sandbox.EngineOptions{
|
||||
WorkspaceRoot: root,
|
||||
Policy: sandbox.DefaultPolicy(),
|
||||
Store: store,
|
||||
Backend: sandbox.Backend{Name: sandbox.BackendPolicyOnly, Message: "policy-only fallback"},
|
||||
}),
|
||||
OnPermissionRequest: func(_ context.Context, request PermissionRequest) (PermissionDecision, error) {
|
||||
t.Fatalf("matched persistent command prefix should not prompt before sandbox denial, got %#v", request)
|
||||
return PermissionDecision{}, nil
|
||||
},
|
||||
OnPermission: func(event PermissionEvent) {
|
||||
events = append(events, event)
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(events) != 1 || events[0].Action != PermissionActionDeny || events[0].Violation == nil || events[0].Violation.Code != sandbox.ViolationNetwork {
|
||||
t.Fatalf("expected network sandbox denial despite prefix match, got %#v", events)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDoesNotOfferPrefixApprovalForUnsafeBashCommand(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewBashTool(root))
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "bash"},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"command":"echo hi && echo bye","prefix_rule":["echo"]}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
{
|
||||
{Type: zeroruntime.StreamEventText, Content: "done"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
_, err := Run(context.Background(), "run unsafe", provider, Options{
|
||||
Registry: registry,
|
||||
PermissionMode: PermissionModeAsk,
|
||||
Autonomy: string(sandbox.AutonomyMedium),
|
||||
Sandbox: sandbox.NewEngine(sandbox.EngineOptions{
|
||||
WorkspaceRoot: root,
|
||||
Policy: sandbox.DefaultPolicy(),
|
||||
Backend: sandbox.Backend{Name: sandbox.BackendPolicyOnly, Message: "policy-only fallback"},
|
||||
}),
|
||||
OnPermissionRequest: func(_ context.Context, request PermissionRequest) (PermissionDecision, error) {
|
||||
if len(request.CommandPrefix) != 0 {
|
||||
t.Fatalf("unsafe command should not have a command prefix: %#v", request.CommandPrefix)
|
||||
}
|
||||
if containsPermissionDecision(request.AvailableDecisions, PermissionDecisionAllowPrefix) {
|
||||
t.Fatalf("unsafe command should not offer prefix approval: %#v", request.AvailableDecisions)
|
||||
}
|
||||
return PermissionDecision{Action: PermissionDecisionDeny, Reason: "deny unsafe"}, nil
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunAlwaysAllowWithoutSandboxStillAllowsCall(t *testing.T) {
|
||||
// Choosing "always allow" with NO sandbox engine configured must still allow
|
||||
// THIS call (there is just nowhere to persist a grant for future calls). The
|
||||
@@ -917,6 +1445,15 @@ func TestRunAlwaysAllowWithoutSandboxStillAllowsCall(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func containsPermissionDecision(decisions []PermissionDecisionAction, want PermissionDecisionAction) bool {
|
||||
for _, decision := range decisions {
|
||||
if decision == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// cancelMidStreamProvider cancels the run while the provider stream is open and
|
||||
// never sends a terminal event, so CollectStream returns via ctx.Done().
|
||||
type cancelMidStreamProvider struct{ cancel context.CancelFunc }
|
||||
@@ -1188,6 +1725,18 @@ func quoteJSONString(value string) string {
|
||||
return string(encoded)
|
||||
}
|
||||
|
||||
func equalPermissionDecisions(a, b []PermissionDecisionAction) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func TestRunAppendsConfirmationPolicyToSystemPrompt(t *testing.T) {
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{{
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package agent
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestIsNoProgressStopRequiresFullStructure(t *testing.T) {
|
||||
// The actual guardrail answer (any turn count) is recognized.
|
||||
for _, turns := range []int{1, 3, 12} {
|
||||
if !IsNoProgressStop(noOutputStopAnswer(turns)) {
|
||||
t.Fatalf("the real no-output stop answer (turns=%d) should be recognized", turns)
|
||||
}
|
||||
}
|
||||
|
||||
// A legitimate message that merely QUOTES the marker must NOT be classified as
|
||||
// a no-progress stop — that would wrongly hide a real session from /resume.
|
||||
rejected := []string{
|
||||
`The previous run ended "with no output (no visible text and no tool calls)" so here is what I did instead.`,
|
||||
"with no output (no visible text and no tool calls)", // bare marker, no prefix/suffix
|
||||
"Agent stopped after 3 turns.", // prefix only
|
||||
"to avoid consuming tokens without making progress.", // suffix only
|
||||
"Here is your e-commerce site.", // unrelated
|
||||
"",
|
||||
// Hostile input: prefix + marker + suffix ALL present, but the marker is
|
||||
// quoted mid-sentence rather than being the guard's own structured answer.
|
||||
// A loose prefix/contains/suffix check misclassifies this.
|
||||
`Agent stopped after 3 turns. The marker is "with no output (no visible text and no tool calls)" so here it is: to avoid consuming tokens without making progress.`,
|
||||
// Arbitrary text wedged between the marker and the suffix.
|
||||
"Agent stopped after 3 turns with no output (no visible text and no tool calls) and then some more text to avoid consuming tokens without making progress.",
|
||||
// Non-integer "turn count" between the prefix and the marker.
|
||||
"Agent stopped after several turns with no output (no visible text and no tool calls) to avoid consuming tokens without making progress.",
|
||||
}
|
||||
for _, content := range rejected {
|
||||
if IsNoProgressStop(content) {
|
||||
t.Fatalf("content must not be classified as a no-progress stop: %q", content)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
)
|
||||
|
||||
func TestPermissionScope(t *testing.T) {
|
||||
@@ -21,6 +24,7 @@ func TestPermissionScope(t *testing.T) {
|
||||
{name: "non-string path ignored", tool: "write_file", args: map[string]any{"path": 42}, want: ""},
|
||||
{name: "path wins over cwd", tool: "x", args: map[string]any{"cwd": "a", "path": "b"}, want: "b"},
|
||||
{name: "whitespace path is no scope", tool: "write_file", args: map[string]any{"path": " "}, want: ""},
|
||||
{name: "web fetch host", tool: "web_fetch", args: map[string]any{"url": "https://Example.COM:443/a"}, want: "example.com"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
@@ -41,3 +45,32 @@ func TestPermissionScopeTruncatesLongPaths(t *testing.T) {
|
||||
t.Fatalf("truncated scope should end with an ellipsis: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersistPermissionGrantScopesWebFetchToHost(t *testing.T) {
|
||||
store, err := sandbox.NewGrantStore(sandbox.StoreOptions{FilePath: filepath.Join(t.TempDir(), "sandbox-grants.json")})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
engine := sandbox.NewEngine(sandbox.EngineOptions{
|
||||
WorkspaceRoot: t.TempDir(),
|
||||
Policy: sandbox.DefaultPolicy(),
|
||||
Store: store,
|
||||
})
|
||||
|
||||
grant, err := persistPermissionGrant("web_fetch", map[string]any{"url": "https://Example.COM:443/docs"}, "trust this host", Options{
|
||||
Sandbox: engine,
|
||||
Autonomy: string(sandbox.AutonomyMedium),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if grant.ScopeKind != sandbox.ScopeHost || grant.Scope != "example.com" {
|
||||
t.Fatalf("grant = %#v, want host-scoped example.com", grant)
|
||||
}
|
||||
if lookup, err := store.Lookup("web_fetch", "example.com", sandbox.AutonomyMedium); err != nil || !lookup.Matched {
|
||||
t.Fatalf("expected exact host lookup to match: lookup=%#v err=%v", lookup, err)
|
||||
}
|
||||
if lookup, err := store.Lookup("web_fetch", "api.example.com", sandbox.AutonomyMedium); err != nil || lookup.Matched {
|
||||
t.Fatalf("expected subdomain lookup to re-prompt: lookup=%#v err=%v", lookup, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
func TestRequestPermissionsTurnGrantAllowsLaterToolAndCleansUp(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
outside := t.TempDir()
|
||||
target := filepath.Join(outside, "vegetables.txt")
|
||||
scope, err := sandbox.NewScope(workspace, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewScope: %v", err)
|
||||
}
|
||||
engine := sandbox.NewEngine(sandbox.EngineOptions{
|
||||
WorkspaceRoot: workspace,
|
||||
Policy: sandbox.DefaultPolicy(),
|
||||
Scope: scope,
|
||||
})
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewRequestPermissionsTool())
|
||||
registry.Register(tools.NewScopedWriteFileTool(workspace, scope))
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "grant-1", ToolName: tools.RequestPermissionsToolName},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "grant-1", ArgumentsFragment: `{"reason":"Need to write outside the workspace.","permissions":{"file_system":{"write":[` + quoteJSONString(target) + `]}}}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "grant-1"},
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "write-1", ToolName: "write_file"},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "write-1", ArgumentsFragment: `{"path":` + quoteJSONString(target) + `,"content":"carrot\n","overwrite":true}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "write-1"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
{
|
||||
{Type: zeroruntime.StreamEventText, Content: "done"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
},
|
||||
}
|
||||
var requests []PermissionRequest
|
||||
var results []ToolResult
|
||||
normalizedProfile, err := sandbox.NormalizeRequestPermissionProfile(sandbox.RequestPermissionProfile{
|
||||
FileSystem: &sandbox.FileSystemPermissions{Write: []string{target}},
|
||||
}, workspace)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
expectedGrantProfile, err := sandbox.RequestPermissionGrantProfile(normalizedProfile)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
expectedWriteRoot := expectedGrantProfile.FileSystem.Write[0]
|
||||
|
||||
result, err := Run(context.Background(), "write the file", provider, Options{
|
||||
Registry: registry,
|
||||
PermissionMode: PermissionModeAuto,
|
||||
Autonomy: string(sandbox.AutonomyMedium),
|
||||
Cwd: workspace,
|
||||
Sandbox: engine,
|
||||
OnPermissionRequest: func(_ context.Context, request PermissionRequest) (PermissionDecision, error) {
|
||||
requests = append(requests, request)
|
||||
return PermissionDecision{Action: PermissionDecisionAllow, Reason: "ok"}, nil
|
||||
},
|
||||
OnToolResult: func(result ToolResult) {
|
||||
results = append(results, result)
|
||||
},
|
||||
MaxTurns: 2,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.FinalAnswer != "done" {
|
||||
t.Fatalf("final answer = %q, want done", result.FinalAnswer)
|
||||
}
|
||||
if len(requests) != 1 || requests[0].ToolName != tools.RequestPermissionsToolName {
|
||||
t.Fatalf("permission requests = %#v, want one request_permissions prompt", requests)
|
||||
}
|
||||
if !strings.Contains(requests[0].Scope, "write "+expectedWriteRoot) || strings.Contains(requests[0].Scope, target) {
|
||||
t.Fatalf("permission request scope = %q, want widened directory root %q and not file %q", requests[0].Scope, expectedWriteRoot, target)
|
||||
}
|
||||
if len(results) != 2 || results[0].Status != tools.StatusOK || results[1].Status != tools.StatusOK {
|
||||
t.Fatalf("tool results = %#v, want request_permissions and write_file ok", results)
|
||||
}
|
||||
var grantResponse sandbox.RequestPermissionsResponse
|
||||
if err := json.Unmarshal([]byte(results[0].Output), &grantResponse); err != nil {
|
||||
t.Fatalf("unmarshal grant response %q: %v", results[0].Output, err)
|
||||
}
|
||||
if grantResponse.Permissions.FileSystem == nil ||
|
||||
len(grantResponse.Permissions.FileSystem.Write) != 1 ||
|
||||
grantResponse.Permissions.FileSystem.Write[0] != expectedWriteRoot {
|
||||
t.Fatalf("grant response permissions = %#v, want write root %q", grantResponse.Permissions, expectedWriteRoot)
|
||||
}
|
||||
content, err := os.ReadFile(target)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(content) != "carrot\n" {
|
||||
t.Fatalf("target content = %q", content)
|
||||
}
|
||||
|
||||
decision := engine.Evaluate(context.Background(), sandbox.Request{
|
||||
WorkspaceRoot: workspace,
|
||||
ToolName: "write_file",
|
||||
SideEffect: sandbox.SideEffectWrite,
|
||||
Permission: sandbox.PermissionPrompt,
|
||||
PermissionMode: sandbox.PermissionModeAuto,
|
||||
Autonomy: sandbox.AutonomyMedium,
|
||||
Args: map[string]any{"path": target},
|
||||
})
|
||||
if decision.Action != sandbox.ActionDeny {
|
||||
t.Fatalf("turn grant should be cleaned up after Run, got decision %#v", decision)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestPermissionsDenyReturnsEmptyGrantAndContinues(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewRequestPermissionsTool())
|
||||
provider := requestPermissionsOnlyProvider(`{"reason":"Need network.","permissions":{"network":{"enabled":true}}}`, "done")
|
||||
var results []ToolResult
|
||||
|
||||
result, err := Run(context.Background(), "request network", provider, Options{
|
||||
Registry: registry,
|
||||
PermissionMode: PermissionModeAuto,
|
||||
Cwd: workspace,
|
||||
Sandbox: sandbox.NewEngine(sandbox.EngineOptions{WorkspaceRoot: workspace, Policy: sandbox.DefaultPolicy()}),
|
||||
OnPermissionRequest: func(context.Context, PermissionRequest) (PermissionDecision, error) {
|
||||
return PermissionDecision{Action: PermissionDecisionDeny, Reason: "no"}, nil
|
||||
},
|
||||
OnToolResult: func(result ToolResult) { results = append(results, result) },
|
||||
MaxTurns: 2,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.FinalAnswer != "done" {
|
||||
t.Fatalf("final answer = %q, want done", result.FinalAnswer)
|
||||
}
|
||||
if len(results) != 1 || results[0].Status != tools.StatusOK {
|
||||
t.Fatalf("request_permissions denial should be an ok tool result, got %#v", results)
|
||||
}
|
||||
var response sandbox.RequestPermissionsResponse
|
||||
if err := json.Unmarshal([]byte(results[0].Output), &response); err != nil {
|
||||
t.Fatalf("unmarshal response %q: %v", results[0].Output, err)
|
||||
}
|
||||
if !response.Permissions.Empty() || response.Scope != sandbox.PermissionGrantScopeTurn {
|
||||
t.Fatalf("deny response = %#v, want empty turn grant", response)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestPermissionsStrictReviewResponse(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewRequestPermissionsTool())
|
||||
provider := requestPermissionsOnlyProvider(`{"reason":"Need read access.","permissions":{"file_system":{"read":[`+quoteJSONString(workspace)+`]}}}`, "done")
|
||||
var output string
|
||||
|
||||
_, err := Run(context.Background(), "request read", provider, Options{
|
||||
Registry: registry,
|
||||
PermissionMode: PermissionModeAuto,
|
||||
Cwd: workspace,
|
||||
Sandbox: sandbox.NewEngine(sandbox.EngineOptions{WorkspaceRoot: workspace, Policy: sandbox.DefaultPolicy()}),
|
||||
OnPermissionRequest: func(context.Context, PermissionRequest) (PermissionDecision, error) {
|
||||
return PermissionDecision{Action: PermissionDecisionAllowStrict, Reason: "review commands"}, nil
|
||||
},
|
||||
OnToolResult: func(result ToolResult) { output = result.Output },
|
||||
MaxTurns: 2,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var response sandbox.RequestPermissionsResponse
|
||||
if err := json.Unmarshal([]byte(output), &response); err != nil {
|
||||
t.Fatalf("unmarshal response %q: %v", output, err)
|
||||
}
|
||||
if !response.StrictAutoReview || response.Scope != sandbox.PermissionGrantScopeTurn {
|
||||
t.Fatalf("strict response = %#v, want strict turn grant", response)
|
||||
}
|
||||
if response.Permissions.FileSystem == nil || len(response.Permissions.FileSystem.Read) != 1 {
|
||||
t.Fatalf("strict response permissions = %#v, want read permission", response.Permissions)
|
||||
}
|
||||
}
|
||||
|
||||
func requestPermissionsOnlyProvider(arguments string, finalAnswer string) *mockProvider {
|
||||
if !strings.HasPrefix(arguments, "{") {
|
||||
arguments = "{}"
|
||||
}
|
||||
return &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "grant-1", ToolName: tools.RequestPermissionsToolName},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "grant-1", ArgumentsFragment: arguments},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "grant-1"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
{
|
||||
{Type: zeroruntime.StreamEventText, Content: finalAnswer},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSpecialistDelegationContext(t *testing.T) {
|
||||
if got := specialistDelegationContext(Options{}); got != "" {
|
||||
t.Fatalf("no specialists should yield an empty section, got %q", got)
|
||||
}
|
||||
got := specialistDelegationContext(Options{Specialists: []SpecialistInfo{
|
||||
{Name: "explorer", WhenToUse: "Read-only codebase exploration."},
|
||||
{Name: " ", WhenToUse: "nameless, should be skipped"},
|
||||
{Name: "worker"},
|
||||
}})
|
||||
if !strings.Contains(got, "<specialists>") || !strings.Contains(got, "</specialists>") {
|
||||
t.Fatalf("missing specialists block: %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "- explorer: Read-only codebase exploration.") {
|
||||
t.Fatalf("missing explorer line: %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "- worker\n") {
|
||||
t.Fatalf("worker (no description) line missing: %q", got)
|
||||
}
|
||||
if strings.Contains(got, "nameless") {
|
||||
t.Fatalf("nameless entry should be skipped: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSystemPromptIncludesDelegationOnlyWithSpecialists(t *testing.T) {
|
||||
with := buildSystemPrompt(Options{Specialists: []SpecialistInfo{
|
||||
{Name: "explorer", WhenToUse: "Explore."},
|
||||
}})
|
||||
if !strings.Contains(with, "<specialists>") || !strings.Contains(with, "Task tool") {
|
||||
t.Fatalf("expected delegation guidance in system prompt: %q", with)
|
||||
}
|
||||
// Default (no specialists) must reproduce the prior prompt: no delegation block.
|
||||
without := buildSystemPrompt(Options{})
|
||||
if strings.Contains(without, "<specialists>") {
|
||||
t.Fatalf("delegation block must not appear without specialists")
|
||||
}
|
||||
}
|
||||
+266
-14
@@ -31,13 +31,20 @@ var confirmationPolicy string
|
||||
const fallbackSystemPrompt = "You are Zero, a terminal coding agent. Help with the current workspace and use tools when needed."
|
||||
|
||||
// projectContextFiles are workspace docs injected into the system prompt so the
|
||||
// agent honors project-specific conventions (mirrors AGENTS.md / CLAUDE.md). The
|
||||
// first one found wins.
|
||||
// agent honors project-specific conventions (mirrors AGENTS.md / CLAUDE.md).
|
||||
// The first match at each directory level wins; the loader walks the chain
|
||||
// from the git root down to the cwd and injects the matches in
|
||||
// general-to-specific order.
|
||||
var projectContextFiles = []string{"AGENTS.md", "ZERO.md", ".zero/AGENTS.md"}
|
||||
|
||||
// maxProjectContextBytes caps how much of a project doc is injected so a large
|
||||
// guidelines file can't blow the context budget.
|
||||
const maxProjectContextBytes = 8 << 10 // 8 KiB
|
||||
// maxProjectContextBytes caps how much of a single project doc is injected so
|
||||
// a large guidelines file can't blow the context budget.
|
||||
const maxProjectContextBytes = 8 << 10 // 8 KiB per file
|
||||
|
||||
// maxProjectContextTotalBytes caps the total bytes across all matched
|
||||
// project guideline files. With path-walking, multiple files can match (root
|
||||
// + sub-tree), so a per-file cap alone is not enough.
|
||||
const maxProjectContextTotalBytes = 32 << 10 // 32 KiB total
|
||||
|
||||
// maxRepoMapContextBytes keeps the repository map useful but compact enough to
|
||||
// remain stable across normal agent turns.
|
||||
@@ -67,18 +74,76 @@ func buildSystemPrompt(options Options) string {
|
||||
if session := sessionRuntimeContext(options); session != "" {
|
||||
sections = append(sections, session)
|
||||
}
|
||||
if prefixes := approvedCommandPrefixContext(options); prefixes != "" {
|
||||
sections = append(sections, prefixes)
|
||||
}
|
||||
if seed := workspaceSeedContext(options.Cwd); seed != "" {
|
||||
sections = append(sections, seed)
|
||||
}
|
||||
if ws := workspaceContext(options.Cwd); ws != "" {
|
||||
sections = append(sections, ws)
|
||||
}
|
||||
if delegation := specialistDelegationContext(options); delegation != "" {
|
||||
sections = append(sections, delegation)
|
||||
}
|
||||
if policy := strings.TrimSpace(confirmationPolicy); policy != "" {
|
||||
sections = append(sections, policy)
|
||||
}
|
||||
return strings.Join(sections, "\n\n")
|
||||
}
|
||||
|
||||
func approvedCommandPrefixContext(options Options) string {
|
||||
if options.Sandbox == nil {
|
||||
return ""
|
||||
}
|
||||
prefixes := options.Sandbox.ApprovedCommandPrefixes()
|
||||
if len(prefixes) == 0 {
|
||||
return ""
|
||||
}
|
||||
lines := make([]string, 0, len(prefixes))
|
||||
for _, grant := range prefixes {
|
||||
if len(grant.Prefix) == 0 {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, "- "+grant.ToolName+": "+strings.Join(grant.Prefix, " "))
|
||||
}
|
||||
if len(lines) == 0 {
|
||||
return ""
|
||||
}
|
||||
return "## Approved Command Prefixes\n\nThe following command prefixes have already been approved and do not need another permission prompt:\n" + strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// specialistDelegationContext nudges the orchestrator to offload read-heavy or
|
||||
// parallelizable work to a specialist sub-agent via the Task tool, keeping large
|
||||
// tool outputs out of the main context. It renders only when specialists are
|
||||
// known (which is only where the Task tool is actually registered), so a run with
|
||||
// no delegatable specialists produces the previous prompt unchanged.
|
||||
func specialistDelegationContext(options Options) string {
|
||||
if len(options.Specialists) == 0 {
|
||||
return ""
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString("<specialists>\n")
|
||||
b.WriteString("Delegate focused or read-heavy work to a specialist sub-agent with the Task tool instead of doing it inline. ")
|
||||
b.WriteString("When a request matches a specialist's purpose, delegate to it proactively — you do not need the user to ask first. ")
|
||||
b.WriteString("This keeps large tool outputs — searches, file dumps, multi-step exploration — out of your own context, so you stay fast and token-efficient. ")
|
||||
b.WriteString("Prefer delegating codebase search and exploration; for independent subtasks, launch several specialists in parallel. Handle small, direct edits yourself.\n")
|
||||
b.WriteString("Available specialists (call Task with the matching name when the task fits its purpose):\n")
|
||||
for _, info := range options.Specialists {
|
||||
name := strings.TrimSpace(info.Name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
if desc := strings.TrimSpace(info.WhenToUse); desc != "" {
|
||||
b.WriteString("- " + name + ": " + desc + "\n")
|
||||
} else {
|
||||
b.WriteString("- " + name + "\n")
|
||||
}
|
||||
}
|
||||
b.WriteString("</specialists>")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func sessionRuntimeContext(options Options) string {
|
||||
provider := strings.TrimSpace(options.ProviderName)
|
||||
model := strings.TrimSpace(options.Model)
|
||||
@@ -121,8 +186,36 @@ func workspaceContext(cwd string) string {
|
||||
}
|
||||
b.WriteString("</environment>")
|
||||
|
||||
for _, name := range projectContextFiles {
|
||||
data, err := os.ReadFile(filepath.Join(cwd, name))
|
||||
b.WriteString(projectGuidelines(cwd, findProjectGitRoot(cwd)))
|
||||
if repoMap := repoMapContext(cwd); repoMap != "" {
|
||||
b.WriteString("\n\n## Repo map\n\n" + repoMap)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// projectGuidelines walks the directory chain from the git root to cwd
|
||||
// (inclusive), finds the first matching project context file at each level
|
||||
// (case-insensitive basename match), reads it, and returns the joined
|
||||
// content in general-to-specific order — the most general file (at the git
|
||||
// root) appears first, the most specific (at cwd) last. Each file is capped
|
||||
// at maxProjectContextBytes; the total across all files is capped at
|
||||
// maxProjectContextTotalBytes. Returns "" when no file matches.
|
||||
func projectGuidelines(cwd, gitRoot string) string {
|
||||
dirs := projectGuidelineDirs(cwd, gitRoot)
|
||||
if len(dirs) == 0 {
|
||||
return ""
|
||||
}
|
||||
var b strings.Builder
|
||||
totalUsed := 0
|
||||
for _, dir := range dirs {
|
||||
if totalUsed >= maxProjectContextTotalBytes {
|
||||
break
|
||||
}
|
||||
match := findProjectContextFile(dir)
|
||||
if match == "" {
|
||||
continue
|
||||
}
|
||||
data, err := os.ReadFile(match)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
@@ -130,22 +223,181 @@ func workspaceContext(cwd string) string {
|
||||
if content == "" {
|
||||
continue
|
||||
}
|
||||
if len(content) > maxProjectContextBytes {
|
||||
cut := maxProjectContextBytes
|
||||
// Per-file cap is the lesser of the configured per-file limit and
|
||||
// whatever remains of the total budget, so a single file can never
|
||||
// blow past either bound.
|
||||
limit := maxProjectContextBytes
|
||||
if remaining := maxProjectContextTotalBytes - totalUsed; remaining < limit {
|
||||
limit = remaining
|
||||
}
|
||||
if len(content) > limit {
|
||||
cut := limit
|
||||
for cut > 0 && !utf8.RuneStart(content[cut]) {
|
||||
cut--
|
||||
}
|
||||
content = content[:cut] + "\n… (truncated)"
|
||||
}
|
||||
b.WriteString("\n\n## Project guidelines (" + name + ")\n\n" + content)
|
||||
break // first match wins
|
||||
}
|
||||
if repoMap := repoMapContext(cwd); repoMap != "" {
|
||||
b.WriteString("\n\n## Repo map\n\n" + repoMap)
|
||||
label := projectGuidelineLabel(match, gitRoot)
|
||||
b.WriteString("\n\n## Project guidelines (" + label + ")\n\n" + content)
|
||||
totalUsed += len(content)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// projectGuidelineDirs returns the directory chain from gitRoot to cwd
|
||||
// (inclusive), in root-to-leaf order. If gitRoot is empty or unreachable
|
||||
// from cwd, the chain collapses to [cwd].
|
||||
func projectGuidelineDirs(cwd, gitRoot string) []string {
|
||||
cwd = strings.TrimSpace(cwd)
|
||||
if cwd == "" {
|
||||
return nil
|
||||
}
|
||||
gitRoot = strings.TrimSpace(gitRoot)
|
||||
if gitRoot == "" {
|
||||
return []string{cwd}
|
||||
}
|
||||
rel, err := filepath.Rel(gitRoot, cwd)
|
||||
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || filepath.IsAbs(rel) {
|
||||
return []string{cwd}
|
||||
}
|
||||
if rel == "." {
|
||||
return []string{gitRoot}
|
||||
}
|
||||
dirs := []string{gitRoot}
|
||||
cur := gitRoot
|
||||
for _, seg := range strings.Split(rel, string(filepath.Separator)) {
|
||||
if seg == "" || seg == "." {
|
||||
continue
|
||||
}
|
||||
cur = filepath.Join(cur, seg)
|
||||
dirs = append(dirs, cur)
|
||||
}
|
||||
return dirs
|
||||
}
|
||||
|
||||
// projectGuidelineLabel renders the path of match as a short label relative
|
||||
// to gitRoot. When gitRoot is empty, the basename is returned.
|
||||
func projectGuidelineLabel(match, gitRoot string) string {
|
||||
if gitRoot == "" {
|
||||
return filepath.Base(match)
|
||||
}
|
||||
rel, err := filepath.Rel(gitRoot, match)
|
||||
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
||||
return filepath.Base(match)
|
||||
}
|
||||
return rel
|
||||
}
|
||||
|
||||
// findProjectContextFile returns the first matching project guideline file
|
||||
// in dir. Lookup is case-insensitive on the basename and on the actual
|
||||
// filename returned, so a git-tracked file like AGENTS.MD (uppercase MD)
|
||||
// still resolves to its true filename on case-sensitive filesystems — the
|
||||
// returned path is what should appear in the project guidelines label.
|
||||
// Returns "" when nothing matches.
|
||||
func findProjectContextFile(dir string) string {
|
||||
for _, name := range projectContextFiles {
|
||||
baseLower := strings.ToLower(filepath.Base(name))
|
||||
// Walk to the file's parent through dir with case-insensitive segment
|
||||
// matching, then find a regular file with the same lowercased
|
||||
// basename. This works on both case-sensitive and case-insensitive
|
||||
// filesystems and always returns the on-disk filename.
|
||||
parent, ok := resolveDirCaseInsensitive(filepath.Dir(filepath.Join(dir, name)), dir)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
entries, err := os.ReadDir(parent)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() && strings.ToLower(e.Name()) == baseLower {
|
||||
return filepath.Join(parent, e.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// resolveDirCaseInsensitive walks from anchor to target, matching each
|
||||
// segment case-insensitively. Returns the resolved absolute path and true on
|
||||
// success, or "" and false if any segment cannot be found.
|
||||
func resolveDirCaseInsensitive(target, anchor string) (string, bool) {
|
||||
if target == "" || target == "." {
|
||||
return anchor, true
|
||||
}
|
||||
rel, err := filepath.Rel(anchor, target)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
if rel == "." {
|
||||
return anchor, true
|
||||
}
|
||||
if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || filepath.IsAbs(rel) {
|
||||
return "", false
|
||||
}
|
||||
parts := strings.Split(rel, string(filepath.Separator))
|
||||
cur := anchor
|
||||
for _, p := range parts {
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
entries, err := os.ReadDir(cur)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
found := false
|
||||
for _, e := range entries {
|
||||
if e.IsDir() && strings.EqualFold(e.Name(), p) {
|
||||
cur = filepath.Join(cur, e.Name())
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
return cur, true
|
||||
}
|
||||
|
||||
// findProjectGitRoot returns the nearest ancestor of cwd that contains a
|
||||
// .git entry (file or directory). Returns "" when no git root is found, so
|
||||
// the caller can fall back to cwd-only lookup.
|
||||
func findProjectGitRoot(cwd string) string {
|
||||
cwd = strings.TrimSpace(cwd)
|
||||
if cwd == "" {
|
||||
return ""
|
||||
}
|
||||
cur := cwd
|
||||
for {
|
||||
if hasGitMetadata(cur) {
|
||||
return cur
|
||||
}
|
||||
parent := filepath.Dir(cur)
|
||||
if parent == cur {
|
||||
return ""
|
||||
}
|
||||
cur = parent
|
||||
}
|
||||
}
|
||||
|
||||
func hasGitMetadata(dir string) bool {
|
||||
gitPath := filepath.Join(dir, ".git")
|
||||
info, err := os.Stat(gitPath)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if info.IsDir() {
|
||||
_, err := os.Stat(filepath.Join(gitPath, "HEAD"))
|
||||
return err == nil
|
||||
}
|
||||
data, err := os.ReadFile(gitPath)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return strings.HasPrefix(strings.TrimSpace(string(data)), "gitdir: ")
|
||||
}
|
||||
|
||||
func workspaceSeedContext(cwd string) string {
|
||||
cwd = strings.TrimSpace(cwd)
|
||||
if cwd == "" {
|
||||
|
||||
@@ -71,6 +71,17 @@ work.
|
||||
managers). It is not a substitute for the file tools.
|
||||
- Treat tool output as ground truth. If a command fails, read the error, form a
|
||||
hypothesis, and address the root cause; do not retry the same call blindly.
|
||||
- When a web search or fetch tool is available, search the web before answering
|
||||
about an external entity, product, library, model, company, version, or recent
|
||||
release you do not recognize. Do not guess, and do not assume the most common
|
||||
meaning of an ambiguous name — use the conversation's domain (this is an AI /
|
||||
software tool) to disambiguate. If the first results look off-topic, refine the
|
||||
query and search again before answering; never reply that you don't know without
|
||||
searching first.
|
||||
- Do not web-search timeless facts, fundamentals, or anything answerable from the
|
||||
workspace — read the code with the file tools instead. Keep queries short (a few
|
||||
words), start broad then narrow, and scale the number of searches to the
|
||||
question: one for a single fact, a few for a deeper answer.
|
||||
|
||||
## Permission and safety
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
)
|
||||
|
||||
func TestCoreSystemPromptIncludesCodingQualityRules(t *testing.T) {
|
||||
@@ -19,6 +21,8 @@ func TestCoreSystemPromptIncludesCodingQualityRules(t *testing.T) {
|
||||
"verify after edits",
|
||||
"honor the active permission mode",
|
||||
"avoid broad refactors",
|
||||
"search the web before answering",
|
||||
"do not recognize",
|
||||
"final response",
|
||||
} {
|
||||
if !strings.Contains(prompt, want) {
|
||||
@@ -69,6 +73,22 @@ func TestBuildSystemPromptOmitsWorkspaceSeedWithoutCwd(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSystemPromptIncludesApprovedCommandPrefixes(t *testing.T) {
|
||||
store, err := sandbox.NewGrantStore(sandbox.StoreOptions{FilePath: filepath.Join(t.TempDir(), "sandbox-grants.json")})
|
||||
if err != nil {
|
||||
t.Fatalf("NewGrantStore returned error: %v", err)
|
||||
}
|
||||
if _, err := store.GrantCommandPrefix(sandbox.CommandPrefixInput{ToolName: "bash", Prefix: []string{"git", "status"}}); err != nil {
|
||||
t.Fatalf("GrantCommandPrefix returned error: %v", err)
|
||||
}
|
||||
prompt := buildSystemPrompt(Options{Sandbox: sandbox.NewEngine(sandbox.EngineOptions{Store: store})})
|
||||
for _, want := range []string{"Approved Command Prefixes", "bash: git status"} {
|
||||
if !strings.Contains(prompt, want) {
|
||||
t.Fatalf("system prompt missing %q:\n%s", want, prompt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func writeSystemPromptTestFile(t *testing.T, root, rel, contents string) {
|
||||
t.Helper()
|
||||
path := filepath.Join(root, filepath.FromSlash(rel))
|
||||
@@ -93,3 +113,179 @@ func systemPromptTestBlock(t *testing.T, prompt, start, end string) string {
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func TestBuildSystemPromptInjectsProjectGuidelinesCaseInsensitive(t *testing.T) {
|
||||
// Git tracks AGENTS.MD (uppercase MD) on a case-sensitive filesystem; the
|
||||
// loader must still resolve it when the cwd lookup uses lowercase.
|
||||
cwd := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(cwd, "AGENTS.MD"), []byte("Always run `make lint`."), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
prompt := buildSystemPrompt(Options{Cwd: cwd})
|
||||
if !strings.Contains(prompt, "## Project guidelines (AGENTS.MD)") {
|
||||
t.Fatalf("expected case-insensitive AGENTS.MD resolution, got:\n%s", prompt)
|
||||
}
|
||||
if !strings.Contains(prompt, "make lint") {
|
||||
t.Fatalf("expected AGENTS.MD content injected, got:\n%s", prompt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSystemPromptProjectGuidelinesPathWalkingMonorepo(t *testing.T) {
|
||||
// Simulate a monorepo: root + sub-tree each have their own AGENTS.md.
|
||||
// The user launches Zero from the sub-tree, so both files should be
|
||||
// injected in general-to-specific order (root first, cwd last).
|
||||
root := t.TempDir()
|
||||
leaf := filepath.Join(root, "services", "api")
|
||||
if err := os.MkdirAll(filepath.Join(root, ".git"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, ".git", "HEAD"), []byte("ref: refs/heads/main\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(leaf, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "AGENTS.md"), []byte("Repo-wide: prefer Go."), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(leaf, "AGENTS.md"), []byte("API: follow REST conventions."), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
prompt := buildSystemPrompt(Options{Cwd: leaf})
|
||||
normalized := filepath.ToSlash(prompt)
|
||||
rootLabel := "Project guidelines (AGENTS.md)"
|
||||
leafLabel := "Project guidelines (services/api/AGENTS.md)"
|
||||
rootBlock := systemPromptTestBlock(t, normalized, rootLabel, leafLabel)
|
||||
leafBlock := systemPromptTestBlock(t, normalized, leafLabel, "## Repo map")
|
||||
if !strings.Contains(rootBlock, "Repo-wide: prefer Go.") {
|
||||
t.Fatalf("expected root AGENTS.md in general-to-specific slot, got:\n%s", rootBlock)
|
||||
}
|
||||
if !strings.Contains(leafBlock, "API: follow REST conventions.") {
|
||||
t.Fatalf("expected leaf AGENTS.md in specific slot, got:\n%s", leafBlock)
|
||||
}
|
||||
// Root must appear before leaf in the prompt.
|
||||
rootIdx := strings.Index(normalized, "## "+rootLabel)
|
||||
leafIdx := strings.Index(normalized, "## "+leafLabel)
|
||||
if rootIdx < 0 || leafIdx < 0 || rootIdx > leafIdx {
|
||||
t.Fatalf("expected root (general) before leaf (specific) in prompt, got root=%d leaf=%d", rootIdx, leafIdx)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSystemPromptProjectGuidelinesZeroFallback(t *testing.T) {
|
||||
// ZERO.md is the second-priority name at each level; the loader picks it
|
||||
// when no AGENTS.md is present.
|
||||
cwd := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(cwd, "ZERO.md"), []byte("Brand-specific rule."), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
prompt := buildSystemPrompt(Options{Cwd: cwd})
|
||||
if !strings.Contains(prompt, "## Project guidelines (ZERO.md)") {
|
||||
t.Fatalf("expected ZERO.md fallback to be injected, got:\n%s", prompt)
|
||||
}
|
||||
if !strings.Contains(prompt, "Brand-specific rule.") {
|
||||
t.Fatalf("expected ZERO.md content, got:\n%s", prompt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSystemPromptProjectGuidelinesProjectLocalFallback(t *testing.T) {
|
||||
cwd := t.TempDir()
|
||||
dot := filepath.Join(cwd, ".zero")
|
||||
if err := os.MkdirAll(dot, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dot, "AGENTS.md"), []byte("Personal: use dark theme."), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
prompt := buildSystemPrompt(Options{Cwd: cwd})
|
||||
// Without a git root, the label collapses to the basename; the test
|
||||
// confirms the .zero/AGENTS.md file's content is the one injected, not
|
||||
// any other file.
|
||||
if !strings.Contains(prompt, "Personal: use dark theme.") {
|
||||
t.Fatalf("expected .zero/AGENTS.md content, got:\n%s", prompt)
|
||||
}
|
||||
// The project guidelines block must be present (regardless of label).
|
||||
if !strings.Contains(prompt, "## Project guidelines (") {
|
||||
t.Fatalf("expected a project guidelines block, got:\n%s", prompt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSystemPromptProjectGuidelinesTruncatesAtTotalCap(t *testing.T) {
|
||||
// Root file fits in the per-file cap; leaf file is bigger than what's
|
||||
// left of the total budget, so it must be truncated. The truncation
|
||||
// marker must appear in the prompt and the full untruncated payload
|
||||
// must not.
|
||||
root := t.TempDir()
|
||||
leaf := filepath.Join(root, "sub")
|
||||
if err := os.MkdirAll(filepath.Join(root, ".git"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, ".git", "HEAD"), []byte("ref: refs/heads/main\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(leaf, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rootContent := strings.Repeat("r", maxProjectContextBytes) // exactly per-file cap
|
||||
leafContent := strings.Repeat("L", maxProjectContextTotalBytes+1) // over the total cap
|
||||
if err := os.WriteFile(filepath.Join(root, "AGENTS.md"), []byte(rootContent), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(leaf, "AGENTS.md"), []byte(leafContent), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
prompt := buildSystemPrompt(Options{Cwd: leaf})
|
||||
if !strings.Contains(prompt, "truncated") {
|
||||
t.Fatalf("expected truncation marker on second file, prompt length=%d", len(prompt))
|
||||
}
|
||||
// Sanity: the leaf file's full payload must NOT be present untruncated.
|
||||
if strings.Contains(prompt, strings.Repeat("L", maxProjectContextTotalBytes-1)) {
|
||||
t.Fatalf("leaf file appears untruncated; total cap is not enforced")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectGuidelineDirsOrdersRootToLeaf(t *testing.T) {
|
||||
root := filepath.Join("r")
|
||||
leaf := filepath.Join(root, "a", "b")
|
||||
got := projectGuidelineDirs(leaf, root)
|
||||
want := []string{root, filepath.Join(root, "a"), leaf}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("projectGuidelineDirs = %v, want %v", got, want)
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("projectGuidelineDirs[%d] = %q, want %q (full %v)", i, got[i], want[i], got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectGuidelineDirsCollapsesToCwdWithoutGitRoot(t *testing.T) {
|
||||
got := projectGuidelineDirs(filepath.Join("some", "path"), "")
|
||||
if len(got) != 1 || got[0] != filepath.Join("some", "path") {
|
||||
t.Fatalf("projectGuidelineDirs = %v, want [some/path]", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindProjectContextFileCaseInsensitiveBasename(t *testing.T) {
|
||||
cwd := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(cwd, "AGENTS.MD"), []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := findProjectContextFile(cwd)
|
||||
if filepath.Base(got) != "AGENTS.MD" {
|
||||
t.Fatalf("findProjectContextFile = %q, want basename AGENTS.MD", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindProjectGitRootIgnoresEmptyGitDirectory(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(root, ".git"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
leaf := filepath.Join(root, "child")
|
||||
if err := os.MkdirAll(leaf, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := findProjectGitRoot(leaf); got != "" {
|
||||
t.Fatalf("findProjectGitRoot = %q, want empty for invalid .git directory", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package agent
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestApproxTextTokens(t *testing.T) {
|
||||
if got := ApproxTextTokens(""); got != 0 {
|
||||
t.Fatalf("empty = %d, want 0", got)
|
||||
}
|
||||
// Whitespace is folded out (BPE merges it into the adjacent token):
|
||||
// "aaaa bbbb" has 8 non-space bytes -> 2 tokens, where naive len/4 says 3.
|
||||
if got := ApproxTextTokens("aaaa bbbb"); got != 2 {
|
||||
t.Fatalf("ApproxTextTokens = %d, want 2 (8 non-space / 4)", got)
|
||||
}
|
||||
// It must never exceed naive len/4 — it only removes whitespace — and on
|
||||
// whitespace-bearing text it should be strictly smaller.
|
||||
cases := []string{"hello world", "func main() {\n\treturn\n}", "a\tb\nc d e f"}
|
||||
for _, s := range cases {
|
||||
approx, naive := ApproxTextTokens(s), len(s)/4
|
||||
if approx > naive {
|
||||
t.Fatalf("ApproxTextTokens(%q)=%d exceeded len/4=%d", s, approx, naive)
|
||||
}
|
||||
}
|
||||
// Pure non-whitespace matches len/4 exactly (nothing to fold).
|
||||
if got := ApproxTextTokens("abcdefgh"); got != 2 {
|
||||
t.Fatalf("ApproxTextTokens(no spaces) = %d, want 2", got)
|
||||
}
|
||||
}
|
||||
+61
-32
@@ -35,12 +35,18 @@ const (
|
||||
PermissionActionAllow PermissionAction = "allow"
|
||||
PermissionActionPrompt PermissionAction = "prompt"
|
||||
PermissionActionDeny PermissionAction = "deny"
|
||||
PermissionActionCancel PermissionAction = "cancel"
|
||||
)
|
||||
|
||||
const (
|
||||
PermissionDecisionAllow PermissionDecisionAction = "allow"
|
||||
PermissionDecisionDeny PermissionDecisionAction = "deny"
|
||||
PermissionDecisionAlwaysAllow PermissionDecisionAction = "always_allow"
|
||||
PermissionDecisionAllow PermissionDecisionAction = "allow"
|
||||
PermissionDecisionAllowStrict PermissionDecisionAction = "allow_with_strict_auto_review"
|
||||
PermissionDecisionAllowForSession PermissionDecisionAction = "allow_for_session"
|
||||
PermissionDecisionAllowPrefix PermissionDecisionAction = "allow_prefix_for_session"
|
||||
PermissionDecisionAlwaysAllowPrefix PermissionDecisionAction = "always_allow_prefix"
|
||||
PermissionDecisionDeny PermissionDecisionAction = "deny"
|
||||
PermissionDecisionAlwaysAllow PermissionDecisionAction = "always_allow"
|
||||
PermissionDecisionCancel PermissionDecisionAction = "cancel"
|
||||
)
|
||||
|
||||
type ToolResult struct {
|
||||
@@ -73,25 +79,28 @@ const (
|
||||
DenialNone DenialCategory = ""
|
||||
DenialFiltered DenialCategory = "filtered" // tool not enabled for this run
|
||||
DenialPermissionDenied DenialCategory = "permission_denied" // approval declined
|
||||
DenialApprovalCanceled DenialCategory = "approval_canceled" // approval canceled and run aborted
|
||||
DenialSandboxViolation DenialCategory = "sandbox_violation" // blocked by the sandbox
|
||||
DenialHookBlocked DenialCategory = "hook_blocked" // vetoed by a beforeTool hook
|
||||
)
|
||||
|
||||
type PermissionRequest struct {
|
||||
ToolCallID string `json:"toolCallId"`
|
||||
ToolName string `json:"name"`
|
||||
Action PermissionAction `json:"action"`
|
||||
Permission string `json:"permission"`
|
||||
PermissionMode PermissionMode `json:"permissionMode"`
|
||||
Autonomy string `json:"autonomy,omitempty"`
|
||||
SideEffect string `json:"sideEffect"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
Risk sandbox.Risk `json:"risk"`
|
||||
Args map[string]any `json:"args,omitempty"`
|
||||
Violation *sandbox.Violation `json:"violation,omitempty"`
|
||||
GrantMatched bool `json:"grantMatched,omitempty"`
|
||||
Grant *sandbox.Grant `json:"grant,omitempty"`
|
||||
ToolCallID string `json:"toolCallId"`
|
||||
ToolName string `json:"name"`
|
||||
Action PermissionAction `json:"action"`
|
||||
Permission string `json:"permission"`
|
||||
PermissionMode PermissionMode `json:"permissionMode"`
|
||||
Autonomy string `json:"autonomy,omitempty"`
|
||||
SideEffect string `json:"sideEffect"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
Risk sandbox.Risk `json:"risk"`
|
||||
Args map[string]any `json:"args,omitempty"`
|
||||
Violation *sandbox.Violation `json:"violation,omitempty"`
|
||||
GrantMatched bool `json:"grantMatched,omitempty"`
|
||||
Grant *sandbox.Grant `json:"grant,omitempty"`
|
||||
CommandPrefix []string `json:"commandPrefix,omitempty"`
|
||||
AvailableDecisions []PermissionDecisionAction `json:"availableDecisions,omitempty"`
|
||||
}
|
||||
|
||||
type PermissionDecision struct {
|
||||
@@ -100,21 +109,23 @@ type PermissionDecision struct {
|
||||
}
|
||||
|
||||
type PermissionEvent struct {
|
||||
ToolCallID string `json:"toolCallId"`
|
||||
ToolName string `json:"name"`
|
||||
Action PermissionAction `json:"action"`
|
||||
Permission string `json:"permission"`
|
||||
PermissionGranted bool `json:"permissionGranted,omitempty"`
|
||||
PermissionMode PermissionMode `json:"permissionMode"`
|
||||
Autonomy string `json:"autonomy,omitempty"`
|
||||
SideEffect string `json:"sideEffect"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
DecisionReason string `json:"decisionReason,omitempty"`
|
||||
Risk sandbox.Risk `json:"risk"`
|
||||
Violation *sandbox.Violation `json:"violation,omitempty"`
|
||||
GrantMatched bool `json:"grantMatched,omitempty"`
|
||||
Grant *sandbox.Grant `json:"grant,omitempty"`
|
||||
ToolCallID string `json:"toolCallId"`
|
||||
ToolName string `json:"name"`
|
||||
Action PermissionAction `json:"action"`
|
||||
DecisionAction PermissionDecisionAction `json:"decisionAction,omitempty"`
|
||||
Permission string `json:"permission"`
|
||||
PermissionGranted bool `json:"permissionGranted,omitempty"`
|
||||
PermissionMode PermissionMode `json:"permissionMode"`
|
||||
Autonomy string `json:"autonomy,omitempty"`
|
||||
SideEffect string `json:"sideEffect"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
DecisionReason string `json:"decisionReason,omitempty"`
|
||||
Risk sandbox.Risk `json:"risk"`
|
||||
Violation *sandbox.Violation `json:"violation,omitempty"`
|
||||
GrantMatched bool `json:"grantMatched,omitempty"`
|
||||
Grant *sandbox.Grant `json:"grant,omitempty"`
|
||||
CommandPrefix []string `json:"commandPrefix,omitempty"`
|
||||
}
|
||||
|
||||
// AskUserQuestion is one clarifying question the agent wants answered.
|
||||
@@ -136,6 +147,15 @@ type AskUserResponse struct {
|
||||
Answers []string `json:"answers"`
|
||||
}
|
||||
|
||||
// SpecialistInfo is a one-line summary of a delegatable sub-agent (its name and
|
||||
// when-to-use description) surfaced to the orchestrator's system prompt so it can
|
||||
// route work to the right specialist. It is plain data so the agent package needs
|
||||
// no dependency on internal/specialist.
|
||||
type SpecialistInfo struct {
|
||||
Name string
|
||||
WhenToUse string
|
||||
}
|
||||
|
||||
type Options struct {
|
||||
MaxTurns int
|
||||
// DeferThreshold activates deferred MCP-tool loading: when the number of
|
||||
@@ -144,6 +164,13 @@ type Options struct {
|
||||
// 0 (or below the eligible count) keeps every tool eager — byte-identical to
|
||||
// the pre-deferral behavior.
|
||||
DeferThreshold int
|
||||
// Specialists lists the sub-agents the orchestrator may delegate to via the
|
||||
// Task tool; when non-empty the system prompt gains a delegation section that
|
||||
// names them and nudges the model to offload read-heavy work (search,
|
||||
// exploration) so verbose tool output stays out of the main context. It is
|
||||
// populated only where the Task tool is actually registered, so an empty slice
|
||||
// (the default) reproduces the previous prompt byte-for-byte.
|
||||
Specialists []SpecialistInfo
|
||||
// Specialist/sub-agent metadata is carried through exec now and consumed by
|
||||
// the specialist runtime in later slices.
|
||||
SessionID string
|
||||
@@ -208,6 +235,8 @@ type Options struct {
|
||||
// ceiling and the autonomy gate. nil disables it entirely (the loop is
|
||||
// byte-identical to before). One instance per run — it holds attempt state.
|
||||
SelfCorrect *SelfCorrector
|
||||
|
||||
runPermissions *permissionRunState
|
||||
}
|
||||
|
||||
type Result struct {
|
||||
|
||||
@@ -186,10 +186,6 @@ func validateFileList(taskPath string, field string, files []string, required bo
|
||||
return problems
|
||||
}
|
||||
|
||||
func validateExpectedChangedFiles(taskPath string, files []string) []string {
|
||||
return validateFileList(taskPath, "expectedChangedFiles", files, true)
|
||||
}
|
||||
|
||||
func validateStringList(taskPath string, field string, values []string) []string {
|
||||
problems := []string{}
|
||||
seen := map[string]int{}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
// Package browser opens a URL in the user's default web browser. It is used by
|
||||
// interactive flows (e.g. the provider setup wizard's OAuth login) that need to
|
||||
// hand the user off to a browser for authorization.
|
||||
package browser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
// OpenURL launches url in the default browser. It returns once the launcher
|
||||
// process has started (it does not wait for the browser to close). A failure to
|
||||
// start the launcher is returned so callers can fall back to printing the URL.
|
||||
func OpenURL(url string) error {
|
||||
name, args := openCommand(runtime.GOOS, url)
|
||||
cmd := exec.Command(name, args...)
|
||||
if err := cmd.Start(); err != nil {
|
||||
return fmt.Errorf("browser: open url: %w", err)
|
||||
}
|
||||
// Reap the short-lived launcher (open/xdg-open/rundll32 exit promptly) so it
|
||||
// does not linger as a zombie; the browser window itself is independent.
|
||||
go func() { _ = cmd.Wait() }()
|
||||
return nil
|
||||
}
|
||||
|
||||
// openCommand returns the launcher command + args for a platform. Split out as a
|
||||
// pure function so the per-OS choice is unit-testable without spawning anything.
|
||||
func openCommand(goos, url string) (string, []string) {
|
||||
switch goos {
|
||||
case "darwin":
|
||||
return "open", []string{url}
|
||||
case "windows":
|
||||
return "rundll32", []string{"url.dll,FileProtocolHandler", url}
|
||||
default: // linux and other unixes
|
||||
return "xdg-open", []string{url}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package browser
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestOpenCommand(t *testing.T) {
|
||||
cases := []struct {
|
||||
goos string
|
||||
name string
|
||||
args []string
|
||||
}{
|
||||
{"darwin", "open", []string{"https://x.test"}},
|
||||
{"linux", "xdg-open", []string{"https://x.test"}},
|
||||
{"freebsd", "xdg-open", []string{"https://x.test"}},
|
||||
{"windows", "rundll32", []string{"url.dll,FileProtocolHandler", "https://x.test"}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.goos, func(t *testing.T) {
|
||||
name, args := openCommand(tc.goos, "https://x.test")
|
||||
if name != tc.name || !reflect.DeepEqual(args, tc.args) {
|
||||
t.Fatalf("openCommand(%s) = %q %v, want %q %v", tc.goos, name, args, tc.name, tc.args)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+206
-52
@@ -7,6 +7,8 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -17,12 +19,15 @@ import (
|
||||
"github.com/Gitlawb/zero/internal/observability"
|
||||
"github.com/Gitlawb/zero/internal/plugins"
|
||||
"github.com/Gitlawb/zero/internal/providerhealth"
|
||||
"github.com/Gitlawb/zero/internal/provideronboarding"
|
||||
"github.com/Gitlawb/zero/internal/providers"
|
||||
"github.com/Gitlawb/zero/internal/redaction"
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
"github.com/Gitlawb/zero/internal/selfverify"
|
||||
"github.com/Gitlawb/zero/internal/sessions"
|
||||
"github.com/Gitlawb/zero/internal/skills"
|
||||
"github.com/Gitlawb/zero/internal/specialist"
|
||||
"github.com/Gitlawb/zero/internal/swarm"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/tui"
|
||||
"github.com/Gitlawb/zero/internal/update"
|
||||
@@ -35,39 +40,42 @@ import (
|
||||
var version = "dev"
|
||||
|
||||
type appDeps struct {
|
||||
getwd func() (string, error)
|
||||
stdin io.Reader
|
||||
userConfigPath func() (string, error)
|
||||
resolveConfig func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error)
|
||||
resolveMCPConfig func(workspaceRoot string) (config.MCPConfig, error)
|
||||
newProvider func(config.ProviderProfile) (zeroruntime.Provider, error)
|
||||
probeProviderHealth func(context.Context, providerhealth.Options) providerhealth.Result
|
||||
newSessionStore func() *sessions.Store
|
||||
loadPlugins func(plugins.LoadOptions) (plugins.LoadResult, error)
|
||||
loadHooks func(hooks.LoadOptions) (hooks.LoadResult, error)
|
||||
skillsDir func() string
|
||||
pluginsDir func() string
|
||||
toolsDir func() string
|
||||
newMCPStore func() (*mcp.PermissionStore, error)
|
||||
newMCPTokenStore func() (*mcp.TokenStore, error)
|
||||
newSandboxStore func() (*sandbox.GrantStore, error)
|
||||
selectSandboxBackend func(sandbox.BackendOptions) sandbox.Backend
|
||||
registerMCPTools func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error)
|
||||
prepareWorktree func(context.Context, worktrees.Options) (worktrees.Result, error)
|
||||
detectVerifyPlan func(string) (verify.Plan, error)
|
||||
runVerify func(context.Context, verify.Plan, verify.RunOptions) verify.Report
|
||||
runSelfVerify func(context.Context, verify.Plan, selfverify.Options) selfverify.Report
|
||||
runAgentEval func(context.Context, agentEvalOptions) (agentEvalReport, error)
|
||||
inspectChanges func(context.Context, zerogit.InspectOptions) (zerogit.ChangeSummary, error)
|
||||
commitChanges func(context.Context, zerogit.CommitOptions) (zerogit.CommitResult, error)
|
||||
runTUI func(context.Context, tui.Options) int
|
||||
runEditor func(string) error
|
||||
checkUpdate func(context.Context, update.Options) (update.Result, error)
|
||||
now func() time.Time
|
||||
getwd func() (string, error)
|
||||
stdin io.Reader
|
||||
userConfigPath func() (string, error)
|
||||
resolveConfig func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error)
|
||||
resolveMCPConfig func(workspaceRoot string) (config.MCPConfig, error)
|
||||
newProvider func(config.ProviderProfile) (zeroruntime.Provider, error)
|
||||
probeProviderHealth func(context.Context, providerhealth.Options) providerhealth.Result
|
||||
detectLocalRuntimes func(context.Context, provideronboarding.LocalDetectOptions) []provideronboarding.DetectedLocalRuntime
|
||||
newSessionStore func() *sessions.Store
|
||||
loadPlugins func(plugins.LoadOptions) (plugins.LoadResult, error)
|
||||
loadHooks func(hooks.LoadOptions) (hooks.LoadResult, error)
|
||||
skillsDir func() string
|
||||
pluginsDir func() string
|
||||
toolsDir func() string
|
||||
newMCPStore func() (*mcp.PermissionStore, error)
|
||||
newMCPTokenStore func() (*mcp.TokenStore, error)
|
||||
newSandboxStore func() (*sandbox.GrantStore, error)
|
||||
selectSandboxBackend func(sandbox.BackendOptions) sandbox.Backend
|
||||
runSandboxSetupHelper func(path string, args []string, stdout io.Writer, stderr io.Writer) error
|
||||
registerMCPTools func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error)
|
||||
prepareWorktree func(context.Context, worktrees.Options) (worktrees.Result, error)
|
||||
detectVerifyPlan func(string) (verify.Plan, error)
|
||||
runVerify func(context.Context, verify.Plan, verify.RunOptions) verify.Report
|
||||
runSelfVerify func(context.Context, verify.Plan, selfverify.Options) selfverify.Report
|
||||
runAgentEval func(context.Context, agentEvalOptions) (agentEvalReport, error)
|
||||
inspectChanges func(context.Context, zerogit.InspectOptions) (zerogit.ChangeSummary, error)
|
||||
commitChanges func(context.Context, zerogit.CommitOptions) (zerogit.CommitResult, error)
|
||||
runTUI func(context.Context, tui.Options) int
|
||||
runEditor func(string) error
|
||||
checkUpdate func(context.Context, update.Options) (update.Result, error)
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
type mcpToolRuntime interface {
|
||||
Close() error
|
||||
Skipped() []mcp.SkippedServer
|
||||
}
|
||||
|
||||
type noopMCPRuntime struct{}
|
||||
@@ -76,6 +84,10 @@ func (noopMCPRuntime) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (noopMCPRuntime) Skipped() []mcp.SkippedServer {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Run executes the minimal Go CLI surface. It returns an exit code so tests can
|
||||
// exercise command behavior without terminating the test process.
|
||||
func Run(args []string, stdout io.Writer, stderr io.Writer) int {
|
||||
@@ -103,9 +115,13 @@ func defaultAppDeps() appDeps {
|
||||
return config.ResolveMCP(options)
|
||||
},
|
||||
newProvider: func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
|
||||
return providers.New(profile, providers.Options{UserAgent: userAgent()})
|
||||
return providers.New(profile, providers.Options{
|
||||
UserAgent: userAgent(),
|
||||
OAuthResolver: oauthResolverForProfile(profile),
|
||||
})
|
||||
},
|
||||
probeProviderHealth: providerhealth.Probe,
|
||||
detectLocalRuntimes: provideronboarding.DetectLocalRuntimes,
|
||||
newSessionStore: func() *sessions.Store {
|
||||
return sessions.NewStore(sessions.StoreOptions{})
|
||||
},
|
||||
@@ -128,6 +144,12 @@ func defaultAppDeps() appDeps {
|
||||
return sandbox.NewGrantStore(sandbox.StoreOptions{})
|
||||
},
|
||||
selectSandboxBackend: sandbox.SelectBackend,
|
||||
runSandboxSetupHelper: func(path string, args []string, stdout io.Writer, stderr io.Writer) error {
|
||||
cmd := exec.Command(path, args...)
|
||||
cmd.Stdout = stdout
|
||||
cmd.Stderr = stderr
|
||||
return cmd.Run()
|
||||
},
|
||||
registerMCPTools: func(ctx context.Context, registry *tools.Registry, cfg config.MCPConfig, options mcp.RegisterOptions) (mcpToolRuntime, error) {
|
||||
return mcp.RegisterTools(ctx, registry, cfg, options)
|
||||
},
|
||||
@@ -240,6 +262,8 @@ func runWithDeps(args []string, stdout io.Writer, stderr io.Writer, deps appDeps
|
||||
case "exec":
|
||||
// Forward leading --add-dir occurrences so exec's own parser collects them.
|
||||
return runExec(append(addDirFlagArgs(addDirs), args[1:]...), stdout, stderr, deps)
|
||||
case "daemon":
|
||||
return runDaemon(args[1:], stdout, stderr, deps)
|
||||
case "config":
|
||||
return runConfig(args[1:], stdout, stderr, deps)
|
||||
case "models":
|
||||
@@ -274,6 +298,8 @@ func runWithDeps(args []string, stdout io.Writer, stderr io.Writer, deps appDeps
|
||||
return runHooks(args[1:], stdout, stderr, deps)
|
||||
case "mcp":
|
||||
return runMCP(args[1:], stdout, stderr, deps)
|
||||
case "auth":
|
||||
return runAuth(args[1:], stdout, stderr, deps)
|
||||
case "sandbox":
|
||||
return runSandbox(args[1:], stdout, stderr, deps)
|
||||
case "update":
|
||||
@@ -328,6 +354,9 @@ func fillAppDeps(deps appDeps) appDeps {
|
||||
if deps.probeProviderHealth == nil {
|
||||
deps.probeProviderHealth = defaults.probeProviderHealth
|
||||
}
|
||||
if deps.detectLocalRuntimes == nil {
|
||||
deps.detectLocalRuntimes = defaults.detectLocalRuntimes
|
||||
}
|
||||
if deps.newSessionStore == nil {
|
||||
deps.newSessionStore = defaults.newSessionStore
|
||||
}
|
||||
@@ -358,6 +387,9 @@ func fillAppDeps(deps appDeps) appDeps {
|
||||
if deps.selectSandboxBackend == nil {
|
||||
deps.selectSandboxBackend = defaults.selectSandboxBackend
|
||||
}
|
||||
if deps.runSandboxSetupHelper == nil {
|
||||
deps.runSandboxSetupHelper = defaults.runSandboxSetupHelper
|
||||
}
|
||||
if deps.registerMCPTools == nil {
|
||||
deps.registerMCPTools = defaults.registerMCPTools
|
||||
}
|
||||
@@ -415,8 +447,26 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
if err != nil {
|
||||
return writeAppError(stderr, "failed to resolve user config path: "+err.Error(), 1)
|
||||
}
|
||||
doctorUserConfigPath := ""
|
||||
projectConfigPath := ""
|
||||
if resolveOptions, optErr := config.DefaultResolveOptions(workspaceRoot); optErr == nil {
|
||||
doctorUserConfigPath = resolveOptions.UserConfigPath
|
||||
projectConfigPath = resolveOptions.ProjectConfigPath
|
||||
}
|
||||
|
||||
needsSetup := setupRequired(resolved)
|
||||
if needsSetup && !forceSetup {
|
||||
// The active provider lacks a usable credential, but if another saved
|
||||
// provider already has one, fall back to it instead of forcing onboarding
|
||||
// again. Saved logins persist across launches; switch the active provider
|
||||
// any time with `zero provider use <name>`. Onboarding only runs when no
|
||||
// configured provider is usable (a genuinely fresh setup).
|
||||
if usable, ok := firstUsableProvider(resolved.Providers); ok {
|
||||
resolved.Provider = usable
|
||||
resolved.ActiveProvider = usable.Name
|
||||
needsSetup = false
|
||||
}
|
||||
}
|
||||
setupVisible := forceSetup || needsSetup
|
||||
configPath := ""
|
||||
if setupVisible {
|
||||
@@ -437,7 +487,7 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
}
|
||||
|
||||
registry := newCoreRegistryScoped(workspaceRoot, scope)
|
||||
specialistRuntime, err := registerSpecialistTools(registry, workspaceRoot)
|
||||
specialistRuntime, err := registerSpecialistTools(registry, workspaceRoot, resolved.Swarm.MaxTeamSize)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, "failed to initialize specialist tools: "+err.Error(), 1)
|
||||
}
|
||||
@@ -468,6 +518,12 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
return writeAppError(stderr, err.Error(), 1)
|
||||
}
|
||||
defer closeMCPRuntime(stderr, mcpRuntime)
|
||||
// A server that could not be reached or validated is skipped, not fatal (one
|
||||
// bad MCP server must not abort startup) — surface each so a missing tool set is
|
||||
// explained rather than silently absent.
|
||||
for _, skipped := range mcpRuntime.Skipped() {
|
||||
fmt.Fprintf(stderr, "warning: MCP server %s unavailable, skipped: %s\n", skipped.Name, redaction.ErrorMessage(skipped.Err, redaction.Options{}))
|
||||
}
|
||||
// Make local plugins live: register their declared tools into the registry and
|
||||
// collect their hooks + skill roots for the dispatcher and skill tool below.
|
||||
// Done after specialist + MCP registration so plugin tools are part of the
|
||||
@@ -498,29 +554,34 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
if err != nil {
|
||||
return writeAppError(stderr, "failed to initialize sandbox grants: "+err.Error(), 1)
|
||||
}
|
||||
sandboxBackend := deps.selectSandboxBackend(sandbox.BackendOptions{})
|
||||
sandboxEngine := sandbox.NewEngine(sandbox.EngineOptions{
|
||||
WorkspaceRoot: workspaceRoot,
|
||||
Policy: applyConfiguredSandboxPolicy(sandbox.DefaultPolicy(), resolved.Sandbox),
|
||||
Store: sandboxStore,
|
||||
Backend: deps.selectSandboxBackend(sandbox.BackendOptions{}),
|
||||
Backend: sandboxBackend,
|
||||
Scope: scope,
|
||||
})
|
||||
lastKnownMCPConfig := mcpConfig
|
||||
return deps.runTUI(context.Background(), tui.Options{
|
||||
Cwd: workspaceRoot,
|
||||
UserConfigPath: userConfigPath,
|
||||
ProviderName: resolved.Provider.Name,
|
||||
ModelName: resolved.Provider.Model,
|
||||
ProviderProfile: resolved.Provider,
|
||||
FavoriteModels: resolved.Preferences.FavoriteModels,
|
||||
Provider: provider,
|
||||
NewProvider: deps.newProvider,
|
||||
Registry: registry,
|
||||
SessionStore: deps.newSessionStore(),
|
||||
SandboxStore: sandboxStore,
|
||||
MCPConfig: mcpConfig,
|
||||
MCPPermissionStore: mcpPermissionStore,
|
||||
MCPTokenStore: mcpTokenStore,
|
||||
Cwd: workspaceRoot,
|
||||
UserConfigPath: userConfigPath,
|
||||
DoctorUserConfigPath: doctorUserConfigPath,
|
||||
ProjectConfigPath: projectConfigPath,
|
||||
ProviderName: resolved.Provider.Name,
|
||||
ModelName: resolved.Provider.Model,
|
||||
ProviderProfile: resolved.Provider,
|
||||
FavoriteModels: resolved.Preferences.FavoriteModels,
|
||||
Provider: provider,
|
||||
NewProvider: deps.newProvider,
|
||||
ProbeProviderHealth: deps.probeProviderHealth,
|
||||
UserAgent: userAgent(),
|
||||
Registry: registry,
|
||||
SessionStore: deps.newSessionStore(),
|
||||
SandboxStore: sandboxStore,
|
||||
MCPConfig: mcpConfig,
|
||||
MCPPermissionStore: mcpPermissionStore,
|
||||
MCPTokenStore: mcpTokenStore,
|
||||
MCPCommand: func(ctx context.Context, args []string) tui.MCPCommandResult {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
@@ -539,6 +600,7 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
ExitCode: exitCode,
|
||||
}
|
||||
},
|
||||
SandboxSetupCommand: tuiSandboxSetupCommand(sandboxBackend, deps),
|
||||
AgentOptions: agent.Options{
|
||||
MaxTurns: resolved.MaxTurns,
|
||||
Registry: registry,
|
||||
@@ -548,6 +610,7 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
FileTracker: tools.NewFileTracker(),
|
||||
Hooks: newHookDispatcherWithExtra(workspaceRoot, pluginActivation.hooks),
|
||||
DeferThreshold: resolved.Tools.DeferThreshold,
|
||||
Specialists: specialistRuntime.specialists,
|
||||
},
|
||||
PermissionMode: permissionMode,
|
||||
Notify: resolved.Notify,
|
||||
@@ -563,6 +626,21 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
})
|
||||
}
|
||||
|
||||
func tuiSandboxSetupCommand(backend sandbox.Backend, deps appDeps) func(context.Context) tui.SandboxSetupCommandResult {
|
||||
if backend.Platform != "windows" || backend.Name != sandbox.BackendWindowsRestrictedToken || !backend.Available || backend.Executable == "" {
|
||||
return nil
|
||||
}
|
||||
return func(context.Context) tui.SandboxSetupCommandResult {
|
||||
var stdout, stderr bytes.Buffer
|
||||
exitCode := runSandboxSetup(nil, &stdout, &stderr, deps)
|
||||
return tui.SandboxSetupCommandResult{
|
||||
Output: strings.TrimSpace(stdout.String()),
|
||||
Error: strings.TrimSpace(stderr.String()),
|
||||
ExitCode: exitCode,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func buildProvider(resolved config.ResolvedConfig, deps appDeps) (zeroruntime.Provider, error) {
|
||||
if !config.HasProviderProfile(resolved.Provider) {
|
||||
return nil, nil
|
||||
@@ -582,12 +660,75 @@ func newCoreRegistryScoped(workspaceRoot string, scope tools.PathScope) *tools.R
|
||||
return registry
|
||||
}
|
||||
|
||||
func registerSpecialistTools(registry *tools.Registry, workspaceRoot string) (*specialist.Runtime, error) {
|
||||
// agentToolRuntime bundles the specialist runtime with the swarm it backs so
|
||||
// their lifetimes (and shutdown) stay paired: registerSpecialistTools brings up
|
||||
// both, closeSpecialistRuntime tears down both.
|
||||
type agentToolRuntime struct {
|
||||
specialist *specialist.Runtime
|
||||
swarm *swarm.Swarm
|
||||
// specialists summarizes the registered specialists (name + description) for
|
||||
// the orchestrator's system-prompt delegation section. Populated alongside the
|
||||
// tools, so it is present exactly when the Task tool is.
|
||||
specialists []agent.SpecialistInfo
|
||||
}
|
||||
|
||||
// specialistInfos returns the runtime's specialist summaries, nil-safe so the
|
||||
// exec path can call it whether or not specialist tools were registered.
|
||||
func (r *agentToolRuntime) specialistInfos() []agent.SpecialistInfo {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
return r.specialists
|
||||
}
|
||||
|
||||
func registerSpecialistTools(registry *tools.Registry, workspaceRoot string, maxTeamSize int) (*agentToolRuntime, error) {
|
||||
paths, err := specialist.DefaultPaths(workspaceRoot)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return specialist.RegisterTools(registry, specialist.Executor{Paths: paths})
|
||||
executor := specialist.Executor{Paths: paths}
|
||||
runtime, err := specialist.RegisterTools(registry, executor)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// The swarm reuses the same specialist executor to launch each member, so
|
||||
// every member runs under the orchestrator's sandbox + policy. Mailbox state
|
||||
// lives under the workspace so its files fall within the sandbox write rules.
|
||||
// MaxTeamSize (0 => the swarm's default of 8) caps concurrent members per team.
|
||||
sw, err := swarm.New(swarm.Options{
|
||||
BaseDir: filepath.Join(workspaceRoot, ".zero", "swarm"),
|
||||
Launcher: swarm.NewSpecialistLauncher(executor),
|
||||
MaxTeamSize: maxTeamSize,
|
||||
})
|
||||
if err != nil {
|
||||
runtime.Close()
|
||||
return nil, err
|
||||
}
|
||||
swarm.RegisterTools(registry, sw)
|
||||
return &agentToolRuntime{specialist: runtime, swarm: sw, specialists: specialistSummaries(paths)}, nil
|
||||
}
|
||||
|
||||
// specialistSummaries loads the available specialists (built-ins + user/project
|
||||
// profiles) and returns their name + description for the orchestrator's
|
||||
// delegation prompt. A load error yields no summaries (the prompt simply omits
|
||||
// the delegation section) rather than failing the run.
|
||||
func specialistSummaries(paths specialist.Paths) []agent.SpecialistInfo {
|
||||
result, err := specialist.Load(specialist.LoadOptions{Paths: paths})
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
summaries := make([]agent.SpecialistInfo, 0, len(result.Specialists))
|
||||
for _, manifest := range result.Specialists {
|
||||
name := strings.TrimSpace(manifest.Metadata.Name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
summaries = append(summaries, agent.SpecialistInfo{
|
||||
Name: name,
|
||||
WhenToUse: strings.TrimSpace(manifest.Metadata.Description),
|
||||
})
|
||||
}
|
||||
return summaries
|
||||
}
|
||||
|
||||
func shouldRegisterExecSpecialistTools(options execOptions) bool {
|
||||
@@ -597,7 +738,13 @@ func shouldRegisterExecSpecialistTools(options execOptions) bool {
|
||||
if strings.EqualFold(strings.TrimSpace(options.tag), specialist.SessionTagSpecialist) {
|
||||
return false
|
||||
}
|
||||
return options.skipPermissionsUnsafe || strings.EqualFold(strings.TrimSpace(options.autonomy), "high")
|
||||
// Register at medium/high (and unsafe). The Task tool's permission gate
|
||||
// auto-approves only read-only specialist spawns and prompts for write-capable
|
||||
// ones, so a non-trivial exec can auto-delegate exploration safely (a headless
|
||||
// write spawn still gets denied at the prompt). Default "low" stays clean — no
|
||||
// specialist tooling or swarm runtime for trivial/CI one-shots.
|
||||
autonomy := strings.ToLower(strings.TrimSpace(options.autonomy))
|
||||
return options.skipPermissionsUnsafe || autonomy == "high" || autonomy == "medium"
|
||||
}
|
||||
|
||||
func closeMCPRuntime(stderr io.Writer, runtime mcpToolRuntime) {
|
||||
@@ -609,12 +756,17 @@ func closeMCPRuntime(stderr io.Writer, runtime mcpToolRuntime) {
|
||||
}
|
||||
}
|
||||
|
||||
func closeSpecialistRuntime(stderr io.Writer, runtime *specialist.Runtime) {
|
||||
func closeSpecialistRuntime(stderr io.Writer, runtime *agentToolRuntime) {
|
||||
if runtime == nil {
|
||||
return
|
||||
}
|
||||
if err := runtime.Close(); err != nil {
|
||||
_, _ = fmt.Fprintf(stderr, "[zero] specialist_cleanup_error: %s\n", err)
|
||||
if runtime.swarm != nil {
|
||||
runtime.swarm.Close()
|
||||
}
|
||||
if runtime.specialist != nil {
|
||||
if err := runtime.specialist.Close(); err != nil {
|
||||
_, _ = fmt.Fprintf(stderr, "[zero] specialist_cleanup_error: %s\n", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -637,6 +789,7 @@ Usage:
|
||||
|
||||
Commands:
|
||||
exec Run a one-shot prompt through the Go agent runtime
|
||||
daemon Manage the local background worker daemon (start/stop/status/run/attach)
|
||||
setup Guide first-run provider setup
|
||||
config Inspect resolved Go configuration without leaking secrets
|
||||
models List Zero model registry entries
|
||||
@@ -655,6 +808,7 @@ Commands:
|
||||
tools Scaffold and list local Zero plugin-tools
|
||||
hooks Inspect Zero hook configuration
|
||||
mcp Manage MCP backend settings
|
||||
auth Log in to model providers via OAuth
|
||||
sandbox Inspect sandbox policy and persistent grants
|
||||
update Check for Zero CLI updates
|
||||
worktrees Prepare isolated git worktrees
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -59,10 +60,38 @@ func TestRunPrintsHelp(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func setCLIUserConfigRoot(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
root := t.TempDir()
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
t.Setenv("APPDATA", root)
|
||||
case "darwin":
|
||||
t.Setenv("HOME", root)
|
||||
default:
|
||||
t.Setenv("XDG_CONFIG_HOME", root)
|
||||
}
|
||||
|
||||
configRoot, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
t.Fatalf("UserConfigDir() error = %v", err)
|
||||
}
|
||||
return configRoot
|
||||
}
|
||||
|
||||
func TestRunNoArgsLaunchesSetupTUIWithNilProviderWhenNoProviderConfigured(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
cwd := t.TempDir()
|
||||
setCLIUserConfigRoot(t)
|
||||
projectConfigPath := filepath.Join(cwd, ".zero", "config.json")
|
||||
if err := os.MkdirAll(filepath.Dir(projectConfigPath), 0o700); err != nil {
|
||||
t.Fatalf("create project config parent: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(projectConfigPath, []byte("{}"), 0o600); err != nil {
|
||||
t.Fatalf("write project config: %v", err)
|
||||
}
|
||||
userConfigPath := filepath.Join(t.TempDir(), "zero", "config.json")
|
||||
var launchedOptions tui.Options
|
||||
|
||||
@@ -83,6 +112,9 @@ func TestRunNoArgsLaunchesSetupTUIWithNilProviderWhenNoProviderConfigured(t *tes
|
||||
userConfigPath: func() (string, error) {
|
||||
return userConfigPath, nil
|
||||
},
|
||||
registerMCPTools: func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) {
|
||||
return noopMCPRuntime{}, nil
|
||||
},
|
||||
runTUI: func(ctx context.Context, options tui.Options) int {
|
||||
launchedOptions = options
|
||||
return 7
|
||||
@@ -104,6 +136,12 @@ func TestRunNoArgsLaunchesSetupTUIWithNilProviderWhenNoProviderConfigured(t *tes
|
||||
if launchedOptions.UserConfigPath != userConfigPath {
|
||||
t.Fatalf("UserConfigPath = %q, want %q", launchedOptions.UserConfigPath, userConfigPath)
|
||||
}
|
||||
if launchedOptions.DoctorUserConfigPath != "" {
|
||||
t.Fatalf("DoctorUserConfigPath = %q, want empty for missing user config", launchedOptions.DoctorUserConfigPath)
|
||||
}
|
||||
if launchedOptions.ProjectConfigPath != projectConfigPath {
|
||||
t.Fatalf("ProjectConfigPath = %q, want %q", launchedOptions.ProjectConfigPath, projectConfigPath)
|
||||
}
|
||||
if launchedOptions.Provider != nil {
|
||||
t.Fatalf("Provider = %#v, want nil", launchedOptions.Provider)
|
||||
}
|
||||
@@ -411,6 +449,9 @@ func TestRunNoArgsLaunchesTUIWithResolvedProviderMetadata(t *testing.T) {
|
||||
userConfigPath: func() (string, error) {
|
||||
return userConfigPath, nil
|
||||
},
|
||||
registerMCPTools: func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) {
|
||||
return noopMCPRuntime{}, nil
|
||||
},
|
||||
runTUI: func(ctx context.Context, options tui.Options) int {
|
||||
launchedOptions = options
|
||||
return 0
|
||||
@@ -810,6 +851,9 @@ func TestRunSetupNoArgsForcesSetupTUI(t *testing.T) {
|
||||
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
|
||||
return &cliFakeProvider{}, nil
|
||||
},
|
||||
registerMCPTools: func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) {
|
||||
return noopMCPRuntime{}, nil
|
||||
},
|
||||
runTUI: func(ctx context.Context, options tui.Options) int {
|
||||
launchedOptions = options
|
||||
return 0
|
||||
|
||||
@@ -0,0 +1,403 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/oauth"
|
||||
"github.com/Gitlawb/zero/internal/provideroauth"
|
||||
"github.com/Gitlawb/zero/internal/redaction"
|
||||
)
|
||||
|
||||
// runAuth dispatches `zero auth <command>` for provider OAuth login. It is
|
||||
// additive and independent of `zero mcp oauth` (MCP server auth), which is
|
||||
// unchanged.
|
||||
func runAuth(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
||||
if len(args) == 0 {
|
||||
return writeExecUsageError(stderr, "auth subcommand required. Use `zero auth status`.")
|
||||
}
|
||||
if args[0] == "-h" || args[0] == "--help" || args[0] == "help" {
|
||||
if err := writeAuthHelp(stdout); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
switch args[0] {
|
||||
case "login":
|
||||
return runAuthLogin(args[1:], stdout, stderr, deps)
|
||||
case "logout":
|
||||
return runAuthLogout(args[1:], stdout, stderr, deps)
|
||||
case "status":
|
||||
return runAuthStatus(args[1:], stdout, stderr, deps)
|
||||
case "refresh":
|
||||
return runAuthRefresh(args[1:], stdout, stderr, deps)
|
||||
case "openrouter":
|
||||
return runAuthOpenRouter(args[1:], stdout, stderr, deps)
|
||||
default:
|
||||
return writeExecUsageError(stderr, fmt.Sprintf("unknown auth subcommand %q", args[0]))
|
||||
}
|
||||
}
|
||||
|
||||
// runAuthOpenRouter runs OpenRouter's browser PKCE login and prints the freshly
|
||||
// minted API key. Unlike `auth login` (which stores an OAuth bearer token),
|
||||
// OpenRouter's flow mints a normal API key; the setup wizard saves it to a
|
||||
// provider profile, while this command prints it for manual configuration.
|
||||
func runAuthOpenRouter(args []string, stdout io.Writer, stderr io.Writer, _ appDeps) int {
|
||||
for _, a := range args {
|
||||
if a == "-h" || a == "--help" || a == "help" {
|
||||
_ = writeAuthHelp(stdout)
|
||||
return exitSuccess
|
||||
}
|
||||
}
|
||||
// openrouter takes no positional args or flags; reject the unexpected so a
|
||||
// typo/unsupported flag fails fast instead of silently running the login.
|
||||
if len(args) > 0 {
|
||||
return writeExecUsageError(stderr, fmt.Sprintf("zero auth openrouter takes no arguments (got %q)", args[0]))
|
||||
}
|
||||
key, err := provideroauth.OpenRouterLogin(context.Background(), provideroauth.OpenRouterOptions{
|
||||
Out: stdout,
|
||||
HTTPClient: &http.Client{Timeout: 30 * time.Second},
|
||||
// ZERO_OPENROUTER_BASE_URL overrides the endpoint (self-hosted gateway or tests).
|
||||
BaseURL: strings.TrimSpace(os.Getenv("ZERO_OPENROUTER_BASE_URL")),
|
||||
})
|
||||
if err != nil {
|
||||
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
|
||||
}
|
||||
if _, err := fmt.Fprintf(stdout, "\nOpenRouter login complete — new API key minted.\nUse it with zero, e.g.:\n export OPENROUTER_API_KEY=%s\n(or add it to a provider profile with catalogID \"openrouter\").\n", key); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
// authArgs is the parsed form of an auth subcommand's arguments.
|
||||
type authArgs struct {
|
||||
positional []string
|
||||
json bool
|
||||
device bool
|
||||
watch bool
|
||||
scopes []string
|
||||
help bool
|
||||
}
|
||||
|
||||
func parseAuthArgs(sub string, args []string) (authArgs, error) {
|
||||
var parsed authArgs
|
||||
addScope := func(scope string) error {
|
||||
scope = strings.TrimSpace(scope)
|
||||
if scope == "" {
|
||||
return fmt.Errorf("--scope requires a non-empty value")
|
||||
}
|
||||
parsed.scopes = append(parsed.scopes, scope)
|
||||
return nil
|
||||
}
|
||||
for i := 0; i < len(args); i++ {
|
||||
arg := args[i]
|
||||
switch {
|
||||
case arg == "-h" || arg == "--help" || arg == "help":
|
||||
parsed.help = true
|
||||
case arg == "--json":
|
||||
parsed.json = true
|
||||
case arg == "--device":
|
||||
parsed.device = true
|
||||
case arg == "--watch":
|
||||
parsed.watch = true
|
||||
case arg == "--scope":
|
||||
if i+1 >= len(args) {
|
||||
return authArgs{}, fmt.Errorf("--scope requires a value")
|
||||
}
|
||||
i++
|
||||
if err := addScope(args[i]); err != nil {
|
||||
return authArgs{}, err
|
||||
}
|
||||
case strings.HasPrefix(arg, "--scope="):
|
||||
if err := addScope(strings.TrimPrefix(arg, "--scope=")); err != nil {
|
||||
return authArgs{}, err
|
||||
}
|
||||
case strings.HasPrefix(arg, "-"):
|
||||
return authArgs{}, fmt.Errorf("unknown flag %q", arg)
|
||||
default:
|
||||
parsed.positional = append(parsed.positional, arg)
|
||||
}
|
||||
}
|
||||
if parsed.help {
|
||||
return parsed, nil // help short-circuits flag validation
|
||||
}
|
||||
if err := validateAuthFlags(sub, parsed); err != nil {
|
||||
return authArgs{}, err
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
// validateAuthFlags rejects flags a subcommand does not accept, so an ambiguous
|
||||
// invocation fails fast instead of silently ignoring a flag.
|
||||
func validateAuthFlags(sub string, a authArgs) error {
|
||||
allowed := map[string]map[string]bool{
|
||||
"login": {"device": true, "scope": true},
|
||||
"logout": {"json": true},
|
||||
"status": {"json": true},
|
||||
"refresh": {"watch": true},
|
||||
}[sub]
|
||||
bad := func(name string) error { return fmt.Errorf("zero auth %s does not accept %s", sub, name) }
|
||||
if a.json && !allowed["json"] {
|
||||
return bad("--json")
|
||||
}
|
||||
if a.device && !allowed["device"] {
|
||||
return bad("--device")
|
||||
}
|
||||
if a.watch && !allowed["watch"] {
|
||||
return bad("--watch")
|
||||
}
|
||||
if len(a.scopes) > 0 && !allowed["scope"] {
|
||||
return bad("--scope")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// newAuthManager builds an oauth.Manager backed by the file store, printing the
|
||||
// authorization URL / device code to stdout. The store path honors
|
||||
// ZERO_OAUTH_TOKENS_PATH (env), so callers/tests can redirect it. Setting
|
||||
// ZERO_OAUTH_STORAGE=encrypted-file selects the AES-256-GCM encrypted-at-rest
|
||||
// backend (a per-user secret is created beside the token file).
|
||||
func newAuthManager(deps appDeps, out io.Writer) (*oauth.Manager, error) {
|
||||
// Validate ZERO_OAUTH_STORAGE up front: a mistyped value must fail fast rather
|
||||
// than silently change the backend. Empty = default (plaintext 0600 file);
|
||||
// "encrypted-file" = AES-256-GCM; "keyring" = the OS keyring.
|
||||
storage := strings.ToLower(strings.TrimSpace(os.Getenv("ZERO_OAUTH_STORAGE")))
|
||||
switch storage {
|
||||
case "", "file", "encrypted-file", "keyring":
|
||||
default:
|
||||
return nil, fmt.Errorf("invalid ZERO_OAUTH_STORAGE %q (supported: file, encrypted-file, keyring)", storage)
|
||||
}
|
||||
store, err := oauth.NewStore(oauth.StoreOptions{
|
||||
Now: deps.now,
|
||||
Storage: storage,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return oauth.NewManager(oauth.ManagerOptions{
|
||||
Store: store,
|
||||
HTTPClient: &http.Client{Timeout: 30 * time.Second},
|
||||
Now: deps.now,
|
||||
Out: out,
|
||||
// The opener prints the URL so headless shells can copy it; the URL
|
||||
// carries no token material. A real browser launch is intentionally not
|
||||
// performed (the sandbox/headless contexts make printing the safer default).
|
||||
OpenBrowser: func(string) error { return nil },
|
||||
})
|
||||
}
|
||||
|
||||
func runAuthLogin(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
||||
parsed, err := parseAuthArgs("login", args)
|
||||
if err != nil {
|
||||
return writeExecUsageError(stderr, err.Error())
|
||||
}
|
||||
if parsed.help {
|
||||
_ = writeAuthHelp(stdout)
|
||||
return exitSuccess
|
||||
}
|
||||
if len(parsed.positional) != 1 {
|
||||
return writeExecUsageError(stderr, "usage: zero auth login <provider> [--device] [--scope <scope>]")
|
||||
}
|
||||
manager, err := newAuthManager(deps, stdout)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
|
||||
}
|
||||
status, err := manager.Login(context.Background(), oauth.LoginOptions{
|
||||
Provider: parsed.positional[0],
|
||||
Device: parsed.device,
|
||||
ExtraScopes: parsed.scopes,
|
||||
})
|
||||
if err != nil {
|
||||
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
|
||||
}
|
||||
if _, err := fmt.Fprintf(stdout, "Logged in to %s.\n%s\n", parsed.positional[0], oauth.FormatStatuses([]oauth.Status{status})); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
func runAuthLogout(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
||||
parsed, err := parseAuthArgs("logout", args)
|
||||
if err != nil {
|
||||
return writeExecUsageError(stderr, err.Error())
|
||||
}
|
||||
if parsed.help {
|
||||
_ = writeAuthHelp(stdout)
|
||||
return exitSuccess
|
||||
}
|
||||
if len(parsed.positional) != 1 {
|
||||
return writeExecUsageError(stderr, "usage: zero auth logout <provider>")
|
||||
}
|
||||
provider := parsed.positional[0]
|
||||
manager, err := newAuthManager(deps, stdout)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
|
||||
}
|
||||
removed, err := manager.Logout(provider)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
|
||||
}
|
||||
if parsed.json {
|
||||
payload := struct {
|
||||
Provider string `json:"provider"`
|
||||
Removed bool `json:"removed"`
|
||||
}{Provider: provider, Removed: removed}
|
||||
if err := writePrettyJSON(stdout, payload); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
msg := fmt.Sprintf("No OAuth login stored for %s.\n", provider)
|
||||
if removed {
|
||||
msg = fmt.Sprintf("Logged out of %s.\n", provider)
|
||||
}
|
||||
if _, err := fmt.Fprint(stdout, msg); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
func runAuthStatus(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
||||
parsed, err := parseAuthArgs("status", args)
|
||||
if err != nil {
|
||||
return writeExecUsageError(stderr, err.Error())
|
||||
}
|
||||
if parsed.help {
|
||||
_ = writeAuthHelp(stdout)
|
||||
return exitSuccess
|
||||
}
|
||||
if len(parsed.positional) > 1 {
|
||||
return writeExecUsageError(stderr, "usage: zero auth status [provider]")
|
||||
}
|
||||
manager, err := newAuthManager(deps, stdout)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
|
||||
}
|
||||
statuses, err := manager.StatusAll()
|
||||
if err != nil {
|
||||
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
|
||||
}
|
||||
if len(parsed.positional) == 1 {
|
||||
statuses = filterAuthStatuses(statuses, parsed.positional[0])
|
||||
}
|
||||
if parsed.json {
|
||||
payload := struct {
|
||||
Logins []oauth.Status `json:"logins"`
|
||||
}{Logins: statuses}
|
||||
if err := writePrettyJSON(stdout, payload); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
if _, err := fmt.Fprintln(stdout, oauth.FormatStatuses(statuses)); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
func runAuthRefresh(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
||||
parsed, err := parseAuthArgs("refresh", args)
|
||||
if err != nil {
|
||||
return writeExecUsageError(stderr, err.Error())
|
||||
}
|
||||
if parsed.help {
|
||||
_ = writeAuthHelp(stdout)
|
||||
return exitSuccess
|
||||
}
|
||||
if len(parsed.positional) != 1 {
|
||||
return writeExecUsageError(stderr, "usage: zero auth refresh <provider>")
|
||||
}
|
||||
provider := parsed.positional[0]
|
||||
manager, err := newAuthManager(deps, stdout)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
|
||||
}
|
||||
key := oauth.ProviderKey(provider)
|
||||
if parsed.watch {
|
||||
return runAuthRefreshWatch(manager, key, provider, stdout, stderr)
|
||||
}
|
||||
if _, err := manager.Handle401(context.Background(), key); err != nil {
|
||||
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
|
||||
}
|
||||
if _, err := fmt.Fprintf(stdout, "Refreshed OAuth token for %s.\n", provider); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
// runAuthRefreshWatch keeps a provider's token fresh in the foreground until
|
||||
// interrupted. This is the opt-in proactive-refresh scheduler surface (for a
|
||||
// long-running external process that reads the token file). It validates a
|
||||
// refreshable token exists first, then schedules refreshes before each expiry.
|
||||
func runAuthRefreshWatch(manager *oauth.Manager, key, provider string, stdout io.Writer, stderr io.Writer) int {
|
||||
ctx, stop := signalContext()
|
||||
defer stop()
|
||||
// Validate (and refresh-if-needed) once up front so a missing/non-refreshable
|
||||
// token fails fast instead of silently watching nothing.
|
||||
if _, err := manager.GetFresh(ctx, key); err != nil {
|
||||
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
|
||||
}
|
||||
scheduler := oauth.NewRefreshScheduler()
|
||||
scheduler.Start(ctx, manager, key)
|
||||
defer scheduler.Stop()
|
||||
if _, err := fmt.Fprintf(stdout, "Watching %s — refreshing before expiry. Press Ctrl+C to stop.\n", provider); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
<-ctx.Done()
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
func filterAuthStatuses(statuses []oauth.Status, provider string) []oauth.Status {
|
||||
want := oauth.ProviderKey(provider)
|
||||
filtered := make([]oauth.Status, 0, 1)
|
||||
for _, st := range statuses {
|
||||
if st.Key == want {
|
||||
filtered = append(filtered, st)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func writeAuthHelp(w io.Writer) error {
|
||||
_, err := fmt.Fprint(w, `Usage:
|
||||
zero auth <command>
|
||||
|
||||
Commands:
|
||||
login <provider> [--device] [--scope <scope>] Log in to a provider via OAuth
|
||||
logout <provider> Delete a provider's stored login
|
||||
status [provider] Show login presence/expiry (never the token)
|
||||
refresh <provider> [--watch] Force a token refresh (--watch keeps it fresh)
|
||||
openrouter Log in to OpenRouter in the browser; mints an API key
|
||||
|
||||
A provider is any OAuth 2.0 / OIDC server. "openrouter" ('zero auth openrouter')
|
||||
works out of the box. "xai" ('zero auth login xai') uses a built-in preset that is
|
||||
off by default — enable it with ZERO_OAUTH_ALLOW_PRESETS=1, or set the
|
||||
ZERO_OAUTH_XAI_* vars yourself. Any preset field is overridable via the env vars
|
||||
below. For a custom provider named <name>, set:
|
||||
ZERO_OAUTH_<NAME>_CLIENT_ID (required)
|
||||
ZERO_OAUTH_<NAME>_CLIENT_SECRET (optional)
|
||||
ZERO_OAUTH_<NAME>_AUTHORIZE_URL ZERO_OAUTH_<NAME>_TOKEN_URL
|
||||
ZERO_OAUTH_<NAME>_DEVICE_URL ZERO_OAUTH_<NAME>_ISSUER_URL (for discovery)
|
||||
ZERO_OAUTH_<NAME>_SCOPES ZERO_OAUTH_<NAME>_FLOW (loopback|device)
|
||||
Endpoint URLs must be https (loopback exempt).
|
||||
|
||||
Storage: tokens are written 0600 under $XDG_CONFIG_HOME/zero (override with
|
||||
ZERO_OAUTH_TOKENS_PATH). Set ZERO_OAUTH_STORAGE=encrypted-file to encrypt them at
|
||||
rest with AES-256-GCM (a per-user secret beside the file), or
|
||||
ZERO_OAUTH_STORAGE=keyring to use the OS keyring (macOS Keychain / Linux
|
||||
secret-tool). MCP server tokens share the same store.
|
||||
|
||||
Flags:
|
||||
--device Use the device-code flow (headless/SSH; no browser)
|
||||
--scope Add an OAuth scope (repeatable)
|
||||
--watch Keep the token fresh in the foreground (refresh only)
|
||||
--json Print result as JSON (status/logout)
|
||||
-h, --help Show this help
|
||||
`)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/oauth"
|
||||
)
|
||||
|
||||
// withAuthStore points the provider OAuth store at a temp file for the test.
|
||||
func withAuthStore(t *testing.T) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "oauth-tokens.json")
|
||||
t.Setenv("ZERO_OAUTH_TOKENS_PATH", path)
|
||||
return path
|
||||
}
|
||||
|
||||
func TestRunAuthRejectsInvalidStorageMode(t *testing.T) {
|
||||
withAuthStore(t)
|
||||
// A mistyped value must fail fast, not silently fall back to plaintext while
|
||||
// the user believes encryption is active.
|
||||
t.Setenv("ZERO_OAUTH_STORAGE", "encryptd")
|
||||
var stdout, stderr bytes.Buffer
|
||||
if code := runWithDeps([]string{"auth", "status"}, &stdout, &stderr, appDeps{}); code == exitSuccess {
|
||||
t.Fatalf("invalid ZERO_OAUTH_STORAGE should fail, got success; stdout=%q", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "ZERO_OAUTH_STORAGE") {
|
||||
t.Fatalf("error should name the offending env var, stderr=%q", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunAuthStatusEmpty(t *testing.T) {
|
||||
withAuthStore(t)
|
||||
var stdout, stderr bytes.Buffer
|
||||
if code := runWithDeps([]string{"auth", "status"}, &stdout, &stderr, appDeps{}); code != exitSuccess {
|
||||
t.Fatalf("exit = %d stderr=%s", code, stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "No OAuth provider logins are stored.") {
|
||||
t.Fatalf("status output = %q", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunAuthStatusReportsLoginWithoutSecret(t *testing.T) {
|
||||
path := withAuthStore(t)
|
||||
store, err := oauth.NewStore(oauth.StoreOptions{FilePath: path})
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore: %v", err)
|
||||
}
|
||||
if err := store.Save(oauth.ProviderKey("demo"), oauth.Token{
|
||||
AccessToken: "super-secret", RefreshToken: "super-secret-rt", Account: "[email protected]",
|
||||
}); err != nil {
|
||||
t.Fatalf("Save: %v", err)
|
||||
}
|
||||
var stdout, stderr bytes.Buffer
|
||||
if code := runWithDeps([]string{"auth", "status"}, &stdout, &stderr, appDeps{}); code != exitSuccess {
|
||||
t.Fatalf("exit = %d stderr=%s", code, stderr.String())
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "demo") || !strings.Contains(out, "[email protected]") {
|
||||
t.Fatalf("status should show provider + account: %q", out)
|
||||
}
|
||||
if strings.Contains(out, "super-secret") {
|
||||
t.Fatalf("status leaked token material: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunAuthLogoutNothing(t *testing.T) {
|
||||
withAuthStore(t)
|
||||
var stdout, stderr bytes.Buffer
|
||||
if code := runWithDeps([]string{"auth", "logout", "demo"}, &stdout, &stderr, appDeps{}); code != exitSuccess {
|
||||
t.Fatalf("exit = %d stderr=%s", code, stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "No OAuth login stored for demo") {
|
||||
t.Fatalf("logout output = %q", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunAuthLoginValidation(t *testing.T) {
|
||||
withAuthStore(t)
|
||||
var stdout, stderr bytes.Buffer
|
||||
// Missing provider.
|
||||
if code := runWithDeps([]string{"auth", "login"}, &stdout, &stderr, appDeps{}); code == exitSuccess {
|
||||
t.Fatal("login with no provider should fail")
|
||||
}
|
||||
// --json is rejected for the interactive login.
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
if code := runWithDeps([]string{"auth", "login", "demo", "--json"}, &stdout, &stderr, appDeps{}); code == exitSuccess {
|
||||
t.Fatal("login --json should be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunAuthLoginUnknownProvider(t *testing.T) {
|
||||
withAuthStore(t)
|
||||
var stdout, stderr bytes.Buffer
|
||||
if code := runWithDeps([]string{"auth", "login", "does-not-exist"}, &stdout, &stderr, appDeps{}); code == exitSuccess {
|
||||
t.Fatal("unknown provider login should fail")
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "not configured") {
|
||||
t.Fatalf("stderr = %q, want not-configured error", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunAuthRefreshNoToken(t *testing.T) {
|
||||
withAuthStore(t)
|
||||
t.Setenv("ZERO_OAUTH_DEMO_CLIENT_ID", "client") // so config resolves; refresh still fails (no token)
|
||||
var stdout, stderr bytes.Buffer
|
||||
if code := runWithDeps([]string{"auth", "refresh", "demo"}, &stdout, &stderr, appDeps{}); code == exitSuccess {
|
||||
t.Fatal("refresh with no stored token should fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunAuthRejectsWrongFlags(t *testing.T) {
|
||||
withAuthStore(t)
|
||||
cases := [][]string{
|
||||
{"auth", "login", "demo", "--watch"}, // watch is refresh-only
|
||||
{"auth", "login", "demo", "--json"}, // json not for interactive login
|
||||
{"auth", "status", "demo", "--device"}, // device is login-only
|
||||
{"auth", "logout", "demo", "--scope", "x"}, // scope is login-only
|
||||
{"auth", "refresh", "demo", "--json"}, // json not for refresh
|
||||
{"auth", "login", "demo", "--scope", ""}, // empty scope rejected
|
||||
}
|
||||
for _, args := range cases {
|
||||
var stdout, stderr bytes.Buffer
|
||||
if code := runWithDeps(args, &stdout, &stderr, appDeps{}); code == exitSuccess {
|
||||
t.Errorf("args %v should be rejected, got success", args)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunAuthOpenRouterRejectsArgs(t *testing.T) {
|
||||
withAuthStore(t)
|
||||
var stdout, stderr bytes.Buffer
|
||||
// An unexpected arg/flag must fail fast, not silently run the login.
|
||||
if code := runWithDeps([]string{"auth", "openrouter", "--json"}, &stdout, &stderr, appDeps{}); code == exitSuccess {
|
||||
t.Fatalf("openrouter with an unexpected flag should fail; stdout=%q", stdout.String())
|
||||
}
|
||||
// --help still works.
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
if code := runWithDeps([]string{"auth", "openrouter", "--help"}, &stdout, &stderr, appDeps{}); code != exitSuccess {
|
||||
t.Fatalf("openrouter --help should succeed, stderr=%q", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunAuthHelp(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
if code := runWithDeps([]string{"auth", "--help"}, &stdout, &stderr, appDeps{}); code != exitSuccess {
|
||||
t.Fatalf("exit = %d", code)
|
||||
}
|
||||
for _, want := range []string{"zero auth", "login", "logout", "status", "refresh", "--device"} {
|
||||
if !strings.Contains(stdout.String(), want) {
|
||||
t.Fatalf("help missing %q:\n%s", want, stdout.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,13 @@ type backendStatusOptions struct {
|
||||
}
|
||||
|
||||
func runBackends(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
||||
if len(args) > 0 {
|
||||
switch args[0] {
|
||||
case "doctor":
|
||||
return runBackendsDoctor(args[1:], stdout, stderr, deps)
|
||||
}
|
||||
}
|
||||
|
||||
options, help, err := parseBackendsArgs(args)
|
||||
if err != nil {
|
||||
return writeExecUsageError(stderr, err.Error())
|
||||
@@ -44,6 +51,44 @@ func runBackends(args []string, stdout io.Writer, stderr io.Writer, deps appDeps
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
type backendDoctorOptions struct {
|
||||
json bool
|
||||
}
|
||||
|
||||
func runBackendsDoctor(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
||||
options, help, err := parseBackendsDoctorArgs(args)
|
||||
if err != nil {
|
||||
return writeExecUsageError(stderr, err.Error())
|
||||
}
|
||||
if help {
|
||||
if err := writeBackendsDoctorHelp(stdout); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
report, err := backendDoctorReport(deps)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
|
||||
}
|
||||
if options.json {
|
||||
if err := writePrettyJSON(stdout, report); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
if !report.OK {
|
||||
return exitProvider
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
if _, err := fmt.Fprintln(stdout, redaction.RedactString(formatBackendDoctorReport(report), redaction.Options{})); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
if !report.OK {
|
||||
return exitProvider
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
func parseBackendsArgs(args []string) (backendStatusOptions, bool, error) {
|
||||
options := backendStatusOptions{}
|
||||
for _, arg := range args {
|
||||
@@ -59,6 +104,21 @@ func parseBackendsArgs(args []string) (backendStatusOptions, bool, error) {
|
||||
return options, false, nil
|
||||
}
|
||||
|
||||
func parseBackendsDoctorArgs(args []string) (backendDoctorOptions, bool, error) {
|
||||
options := backendDoctorOptions{}
|
||||
for _, arg := range args {
|
||||
switch arg {
|
||||
case "-h", "--help", "help":
|
||||
return options, true, nil
|
||||
case "--json":
|
||||
options.json = true
|
||||
default:
|
||||
return options, false, execUsageError{fmt.Sprintf("unknown backends doctor flag %q", arg)}
|
||||
}
|
||||
}
|
||||
return options, false, nil
|
||||
}
|
||||
|
||||
func backendLifecycleSnapshot(deps appDeps) (zerocommands.BackendLifecycleSnapshot, error) {
|
||||
cwd, err := deps.getwd()
|
||||
if err != nil {
|
||||
@@ -86,6 +146,32 @@ func backendLifecycleSnapshot(deps appDeps) (zerocommands.BackendLifecycleSnapsh
|
||||
return zerocommands.NewBackendLifecycleSnapshot(servers, hookResult.Config.Hooks, pluginResult.Plugins), nil
|
||||
}
|
||||
|
||||
func backendDoctorReport(deps appDeps) (zerocommands.BackendDoctorReport, error) {
|
||||
cwd, err := deps.getwd()
|
||||
if err != nil {
|
||||
return zerocommands.BackendDoctorReport{}, fmt.Errorf("failed to resolve workspace: %w", err)
|
||||
}
|
||||
|
||||
cfg, err := deps.resolveMCPConfig(cwd)
|
||||
if err != nil {
|
||||
return zerocommands.BackendDoctorReport{}, err
|
||||
}
|
||||
hookResult, err := deps.loadHooks(hooks.LoadOptions{Cwd: cwd})
|
||||
if err != nil {
|
||||
return zerocommands.BackendDoctorReport{}, err
|
||||
}
|
||||
pluginResult, err := deps.loadPlugins(plugins.LoadOptions{Cwd: cwd})
|
||||
if err != nil {
|
||||
return zerocommands.BackendDoctorReport{}, err
|
||||
}
|
||||
|
||||
return zerocommands.NewBackendDoctorReport(zerocommands.BackendDoctorInput{
|
||||
MCP: cfg,
|
||||
Hooks: hookResult,
|
||||
Plugins: pluginResult,
|
||||
}), nil
|
||||
}
|
||||
|
||||
func formatBackendLifecycleSnapshot(snapshot zerocommands.BackendLifecycleSnapshot) string {
|
||||
lines := []string{"Zero Backends:"}
|
||||
lines = append(lines, fmt.Sprintf(" MCP servers: %d", len(snapshot.MCPServers)))
|
||||
@@ -135,16 +221,52 @@ func formatBackendLifecycleSnapshot(snapshot zerocommands.BackendLifecycleSnapsh
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func formatBackendDoctorReport(report zerocommands.BackendDoctorReport) string {
|
||||
lines := []string{
|
||||
"Zero backend doctor",
|
||||
"Overall: " + string(report.Status),
|
||||
}
|
||||
for _, check := range report.Checks {
|
||||
target := ""
|
||||
if check.Target != "" {
|
||||
target = " " + check.Target
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf("[%s] %s%s - %s", check.Status, check.ID, target, check.Message))
|
||||
if check.Action != "" {
|
||||
lines = append(lines, " action: "+check.Action)
|
||||
}
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func writeBackendsHelp(w io.Writer) error {
|
||||
_, err := fmt.Fprint(w, `Usage:
|
||||
zero backends [flags]
|
||||
zero backends doctor [flags]
|
||||
|
||||
Inspect MCP, hook, and plugin backend lifecycle state without connecting to
|
||||
external MCP servers.
|
||||
|
||||
Commands:
|
||||
doctor Diagnose MCP, hook, and plugin backend setup
|
||||
|
||||
Flags:
|
||||
--json Print backend lifecycle data as JSON
|
||||
-h, --help Show this help
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
func writeBackendsDoctorHelp(w io.Writer) error {
|
||||
_, err := fmt.Fprint(w, `Usage:
|
||||
zero backends doctor [flags]
|
||||
|
||||
Diagnose MCP, hook, and plugin backend setup without connecting to external MCP
|
||||
servers or executing hooks/plugins.
|
||||
|
||||
Flags:
|
||||
--json Print backend diagnostics as JSON
|
||||
-h, --help Show this help
|
||||
`)
|
||||
return err
|
||||
}
|
||||
@@ -5,7 +5,12 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
@@ -153,3 +158,220 @@ func TestRunBackendsTextAndHelp(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunBackendsDoctorJSONAndTextWithoutConnectingMCP(t *testing.T) {
|
||||
cwd := t.TempDir()
|
||||
secret := "sk-proj-" + strings.Repeat("b", 24)
|
||||
deps := appDeps{
|
||||
getwd: func() (string, error) { return cwd, nil },
|
||||
resolveMCPConfig: func(workspaceRoot string) (config.MCPConfig, error) {
|
||||
if workspaceRoot != cwd {
|
||||
t.Fatalf("workspaceRoot = %q, want %q", workspaceRoot, cwd)
|
||||
}
|
||||
return config.MCPConfig{Servers: map[string]config.MCPServerConfig{
|
||||
"remote": {
|
||||
Type: "http",
|
||||
URL: "https://api.example.com/mcp?token=" + secret,
|
||||
},
|
||||
"broken": {
|
||||
Type: "http",
|
||||
},
|
||||
}}, nil
|
||||
},
|
||||
loadHooks: func(options hooks.LoadOptions) (hooks.LoadResult, error) {
|
||||
if options.Cwd != cwd {
|
||||
t.Fatalf("hook Cwd = %q, want %q", options.Cwd, cwd)
|
||||
}
|
||||
return hooks.LoadResult{Diagnostics: []hooks.Diagnostic{{
|
||||
Kind: hooks.DiagnosticSchema,
|
||||
Message: "schema failed " + secret,
|
||||
HookID: "zero.bad",
|
||||
}}}, nil
|
||||
},
|
||||
loadPlugins: func(options plugins.LoadOptions) (plugins.LoadResult, error) {
|
||||
if options.Cwd != cwd {
|
||||
t.Fatalf("plugin Cwd = %q, want %q", options.Cwd, cwd)
|
||||
}
|
||||
return plugins.LoadResult{Plugins: []plugins.LoadedPlugin{{
|
||||
ID: "zero.docs",
|
||||
Name: "Docs",
|
||||
Enabled: true,
|
||||
Source: plugins.SourceProject,
|
||||
}}}, nil
|
||||
},
|
||||
registerMCPTools: func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) {
|
||||
return nil, errors.New("zero backends doctor must not connect to MCP servers")
|
||||
},
|
||||
}
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{"backends", "doctor", "--json"}, &stdout, &stderr, deps)
|
||||
if exitCode != exitProvider {
|
||||
t.Fatalf("exitCode = %d, want %d stderr=%s stdout=%s", exitCode, exitProvider, stderr.String(), stdout.String())
|
||||
}
|
||||
if strings.Contains(stdout.String(), secret) || strings.Contains(stdout.String(), "sk-proj-") {
|
||||
t.Fatalf("backend doctor JSON leaked secret material:\n%s", stdout.String())
|
||||
}
|
||||
var payload zerocommands.BackendDoctorReport
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("backend doctor JSON failed to decode: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if payload.OK {
|
||||
t.Fatalf("payload.OK = true, want false for invalid MCP/hook diagnostic: %#v", payload.Checks)
|
||||
}
|
||||
if payload.Status != zerocommands.BackendDoctorStatusFail {
|
||||
t.Fatalf("payload.Status = %q, want %q", payload.Status, zerocommands.BackendDoctorStatusFail)
|
||||
}
|
||||
assertBackendDoctorPayloadCheck(t, payload, "backend.mcp.invalid", "broken")
|
||||
assertBackendDoctorPayloadCheck(t, payload, "backend.hooks.diagnostic", "zero.bad")
|
||||
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
exitCode = runWithDeps([]string{"backends", "doctor"}, &stdout, &stderr, deps)
|
||||
if exitCode != exitProvider {
|
||||
t.Fatalf("text exitCode = %d, want %d stderr=%s", exitCode, exitProvider, stderr.String())
|
||||
}
|
||||
for _, want := range []string{"Zero backend doctor", "[fail] backend.mcp.invalid", "zero mcp add broken", "[fail] backend.hooks.diagnostic"} {
|
||||
if !strings.Contains(stdout.String(), want) {
|
||||
t.Fatalf("backend doctor text missing %q:\n%s", want, stdout.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunBackendsDoctorHelp(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{"backends", "doctor", "--help"}, &stdout, &stderr, appDeps{})
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("exitCode = %d stderr=%s", exitCode, stderr.String())
|
||||
}
|
||||
for _, want := range []string{"Usage:", "zero backends doctor", "--json"} {
|
||||
if !strings.Contains(stdout.String(), want) {
|
||||
t.Fatalf("backend doctor help missing %q:\n%s", want, stdout.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunBackendsDoctorDoesNotConnectOrExecuteConfiguredBackends(t *testing.T) {
|
||||
cwd := t.TempDir()
|
||||
hookSentinel := filepath.Join(cwd, "hook-ran")
|
||||
pluginToolSentinel := filepath.Join(cwd, "plugin-tool-ran")
|
||||
pluginHookSentinel := filepath.Join(cwd, "plugin-hook-ran")
|
||||
helperCommand := os.Args[0]
|
||||
helperArgs := func(path string) []string {
|
||||
return []string{"-test.run=TestBackendDoctorHelperProcess", "--", "--zero-backend-doctor-sentinel", path}
|
||||
}
|
||||
|
||||
var hits int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt32(&hits, 1)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
hookConfigPath := filepath.Join(cwd, ".zero", "hooks.json")
|
||||
writeBackendDoctorJSON(t, hookConfigPath, map[string]any{
|
||||
"enabled": true,
|
||||
"hooks": []any{map[string]any{
|
||||
"id": "zero.sentinel",
|
||||
"event": "sessionStart",
|
||||
"command": helperCommand,
|
||||
"args": helperArgs(hookSentinel),
|
||||
}},
|
||||
})
|
||||
pluginDir := filepath.Join(cwd, ".zero", "plugins", "sentinel")
|
||||
writeBackendDoctorJSON(t, filepath.Join(pluginDir, "plugin.json"), map[string]any{
|
||||
"schemaVersion": 1,
|
||||
"id": "zero.sentinel",
|
||||
"name": "Sentinel",
|
||||
"version": "1.0.0",
|
||||
"tools": []any{map[string]any{
|
||||
"name": "sentinel_tool",
|
||||
"command": helperCommand,
|
||||
"args": helperArgs(pluginToolSentinel),
|
||||
}},
|
||||
"hooks": []any{map[string]any{
|
||||
"name": "sentinel_hook",
|
||||
"event": "sessionStart",
|
||||
"command": helperCommand,
|
||||
"args": helperArgs(pluginHookSentinel),
|
||||
}},
|
||||
})
|
||||
|
||||
deps := appDeps{
|
||||
getwd: func() (string, error) { return cwd, nil },
|
||||
resolveMCPConfig: func(string) (config.MCPConfig, error) {
|
||||
return config.MCPConfig{Servers: map[string]config.MCPServerConfig{
|
||||
"remote": {Type: "http", URL: server.URL + "/mcp"},
|
||||
}}, nil
|
||||
},
|
||||
loadHooks: func(options hooks.LoadOptions) (hooks.LoadResult, error) {
|
||||
return hooks.LoadConfig(hooks.LoadOptions{
|
||||
Cwd: options.Cwd,
|
||||
UserConfigPath: filepath.Join(cwd, "missing-user-hooks.json"),
|
||||
ProjectConfigPath: hookConfigPath,
|
||||
})
|
||||
},
|
||||
loadPlugins: func(options plugins.LoadOptions) (plugins.LoadResult, error) {
|
||||
return plugins.Load(plugins.LoadOptions{
|
||||
Roots: []plugins.Root{{Source: plugins.SourceProject, Path: filepath.Join(cwd, ".zero", "plugins")}},
|
||||
Cwd: options.Cwd,
|
||||
})
|
||||
},
|
||||
registerMCPTools: func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) {
|
||||
return nil, errors.New("zero backends doctor must not register MCP tools")
|
||||
},
|
||||
}
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{"backends", "doctor", "--json"}, &stdout, &stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("exitCode = %d stderr=%s stdout=%s", exitCode, stderr.String(), stdout.String())
|
||||
}
|
||||
if got := atomic.LoadInt32(&hits); got != 0 {
|
||||
t.Fatalf("backend doctor connected to MCP HTTP server %d time(s)", got)
|
||||
}
|
||||
for _, sentinel := range []string{hookSentinel, pluginToolSentinel, pluginHookSentinel} {
|
||||
if _, err := os.Stat(sentinel); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("backend doctor executed configured backend command, sentinel=%s err=%v", sentinel, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertBackendDoctorPayloadCheck(t *testing.T, report zerocommands.BackendDoctorReport, id string, target string) {
|
||||
t.Helper()
|
||||
for _, check := range report.Checks {
|
||||
if check.ID == id && check.Target == target {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("check %s/%s not found in %#v", id, target, report.Checks)
|
||||
}
|
||||
|
||||
func writeBackendDoctorJSON(t *testing.T, path string, value any) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
t.Fatalf("create dir for %s: %v", path, err)
|
||||
}
|
||||
data, err := json.MarshalIndent(value, "", " ")
|
||||
if err != nil {
|
||||
t.Fatalf("marshal %s: %v", path, err)
|
||||
}
|
||||
if err := os.WriteFile(path, data, 0o600); err != nil {
|
||||
t.Fatalf("write %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackendDoctorHelperProcess(t *testing.T) {
|
||||
for index, arg := range os.Args {
|
||||
if arg != "--zero-backend-doctor-sentinel" || index+1 >= len(os.Args) {
|
||||
continue
|
||||
}
|
||||
if err := os.WriteFile(os.Args[index+1], []byte("executed"), 0o600); err != nil {
|
||||
os.Exit(2)
|
||||
}
|
||||
os.Exit(0)
|
||||
}
|
||||
}
|
||||
@@ -78,6 +78,9 @@ func runProviders(args []string, stdout io.Writer, stderr io.Writer, deps appDep
|
||||
if command == "setup" {
|
||||
return runProvidersSetup(args, stdout, stderr, deps)
|
||||
}
|
||||
if command == "detect" {
|
||||
return runProvidersDetect(args, stdout, stderr, deps)
|
||||
}
|
||||
if command != "list" && command != "current" && command != "catalog" {
|
||||
return writeExecUsageError(stderr, fmt.Sprintf("unknown providers command %q", command))
|
||||
}
|
||||
@@ -451,8 +454,10 @@ func writeProvidersHelp(w io.Writer) error {
|
||||
zero providers check [name] [flags]
|
||||
zero providers use <name> [flags]
|
||||
zero providers setup <catalog-id> [flags]
|
||||
zero providers detect [flags]
|
||||
|
||||
Inspects resolved provider profiles and provider catalog descriptors without printing secrets.
|
||||
Detect probes for running local runtimes (Ollama, LM Studio) and prints adopt commands plus per-provider next steps.
|
||||
|
||||
Flags:
|
||||
--json Print JSON summary
|
||||
|
||||
@@ -0,0 +1,672 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/background"
|
||||
"github.com/Gitlawb/zero/internal/daemon"
|
||||
"github.com/Gitlawb/zero/internal/daemon/remote"
|
||||
)
|
||||
|
||||
// runDaemon dispatches the `zero daemon ...` subcommands. The daemon supervises a
|
||||
// pool of headless `zero exec` workers and routes sessions to them over an
|
||||
// owner-only local control socket. It is an ADDITIVE surface — interactive and
|
||||
// one-shot exec are unchanged.
|
||||
func runDaemon(args []string, stdout io.Writer, stderr io.Writer, _ appDeps) int {
|
||||
if len(args) == 0 {
|
||||
return writeDaemonUsage(stderr, exitUsage)
|
||||
}
|
||||
sub, rest := args[0], args[1:]
|
||||
switch sub {
|
||||
case "start":
|
||||
return runDaemonStart(rest, stdout, stderr)
|
||||
case "stop":
|
||||
return runDaemonStop(rest, stdout, stderr)
|
||||
case "status":
|
||||
return runDaemonStatus(rest, stdout, stderr)
|
||||
case "run":
|
||||
return runDaemonRun(rest, stdout, stderr)
|
||||
case "attach":
|
||||
return runDaemonAttach(rest, stdout, stderr)
|
||||
case "serve-remote":
|
||||
return runDaemonServeRemote(rest, stdout, stderr)
|
||||
case "link":
|
||||
return runDaemonLink(rest, stdout, stderr)
|
||||
case "-h", "--help", "help":
|
||||
return writeDaemonUsage(stdout, exitSuccess)
|
||||
default:
|
||||
fmt.Fprintf(stderr, "unknown daemon subcommand %q\n", sub)
|
||||
return writeDaemonUsage(stderr, exitUsage)
|
||||
}
|
||||
}
|
||||
|
||||
func writeDaemonUsage(w io.Writer, code int) int {
|
||||
fmt.Fprint(w, `Usage: zero daemon <command>
|
||||
|
||||
Commands:
|
||||
start [--foreground] Start the daemon (background by default).
|
||||
stop Gracefully stop the running daemon.
|
||||
status Show daemon / pool / session status.
|
||||
run --session <id> [--cwd <dir>] [--prompt <text>] [exec flags...]
|
||||
Create/route a session and stream its output.
|
||||
attach <session> Attach to a running session's stream.
|
||||
serve-remote --addr <host:port> --tls-cert <f> --tls-key <f> [--bundle-dir <d>]
|
||||
Serve an opt-in, TLS-only network bridge to this
|
||||
daemon. Requires a bearer token in $ZERO_DAEMON_REMOTE_TOKEN
|
||||
(or $ZERO_DAEMON_REMOTE_TOKEN_FILE). --bundle-dir enables
|
||||
git-bundle uploads, extracted into per-link work trees.
|
||||
link --remote <host:port> --repo <dir> --id <name> [--out <file>]
|
||||
Upload repo's git history to the remote as a bundle and
|
||||
print the extracted remote path. --out saves a session
|
||||
link file (0600). Accepts --token/--ca-cert/--server-name.
|
||||
link --show <file> Print a saved session link.
|
||||
|
||||
run and attach accept --remote <host:port> [--token <t>] [--ca-cert <f>]
|
||||
[--server-name <name>] to drive a remote daemon over the bridge instead of the
|
||||
local socket. Use the link's remote path as --cwd to run against a linked repo.
|
||||
`)
|
||||
return code
|
||||
}
|
||||
|
||||
// runDaemonStart starts the daemon. Without --foreground it spawns a detached
|
||||
// background process running the foreground daemon and returns once it is up.
|
||||
func runDaemonStart(args []string, stdout io.Writer, stderr io.Writer) int {
|
||||
foreground := false
|
||||
for _, a := range args {
|
||||
switch a {
|
||||
case "--foreground", "-f":
|
||||
foreground = true
|
||||
case "-h", "--help":
|
||||
return writeDaemonUsage(stdout, exitSuccess)
|
||||
default:
|
||||
return writeExecUsageError(stderr, fmt.Sprintf("unknown flag %q for daemon start", a))
|
||||
}
|
||||
}
|
||||
paths, err := daemon.DefaultPaths()
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
if foreground {
|
||||
return runDaemonForeground(paths, stdout, stderr)
|
||||
}
|
||||
return runDaemonStartDetached(paths, stdout, stderr)
|
||||
}
|
||||
|
||||
// runDaemonForeground runs the daemon in this process until SIGINT/SIGTERM. Each
|
||||
// worker is a headless `zero exec` child with the sandbox re-entrancy markers
|
||||
// scrubbed (NewExecLauncher) so it establishes its own sandbox.
|
||||
func runDaemonForeground(paths daemon.Paths, stdout io.Writer, stderr io.Writer) int {
|
||||
launcher, err := daemon.NewExecLauncher(daemon.ExecLauncherConfig{})
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
logf := func(line string) { fmt.Fprintln(stderr, "[daemon] "+line) }
|
||||
pool, err := daemon.NewPool(daemon.PoolOptions{Launcher: launcher, Log: logf})
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
mgr, err := daemon.NewSessionManager(daemon.SessionManagerOptions{Pool: pool})
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
srv, err := daemon.NewServer(daemon.ServerOptions{Paths: paths, Manager: mgr, Pool: pool, Log: logf})
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
|
||||
go func() {
|
||||
<-sigCh
|
||||
srv.Shutdown()
|
||||
}()
|
||||
|
||||
if err := srv.Serve(); err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
// runDaemonStartDetached spawns the foreground daemon as a detached background
|
||||
// process (its own process group, output to a log file) and waits for it to bind.
|
||||
func runDaemonStartDetached(paths daemon.Paths, stdout io.Writer, stderr io.Writer) int {
|
||||
if daemonReachable(paths) {
|
||||
fmt.Fprintln(stdout, "zero daemon is already running")
|
||||
return exitSuccess
|
||||
}
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(paths.Socket), 0o700); err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
logPath := filepath.Join(filepath.Dir(paths.Socket), "daemon.log")
|
||||
logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
defer logFile.Close()
|
||||
|
||||
cmd := exec.Command(exe, "daemon", "start", "--foreground")
|
||||
cmd.Stdout = logFile
|
||||
cmd.Stderr = logFile
|
||||
background.ConfigureChildProcessGroup(cmd) // own process group: outlives this shell
|
||||
if err := cmd.Start(); err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
_ = cmd.Process.Release()
|
||||
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if daemonReachable(paths) {
|
||||
fmt.Fprintf(stdout, "zero daemon started (socket %s)\n", paths.Socket)
|
||||
return exitSuccess
|
||||
}
|
||||
time.Sleep(25 * time.Millisecond)
|
||||
}
|
||||
return writeAppError(stderr, "daemon did not come up within timeout; see "+logPath, exitCrash)
|
||||
}
|
||||
|
||||
func runDaemonStop(args []string, stdout io.Writer, stderr io.Writer) int {
|
||||
if helpRequested(args) {
|
||||
return writeDaemonUsage(stdout, exitSuccess)
|
||||
}
|
||||
if len(args) > 0 {
|
||||
return writeExecUsageError(stderr, "daemon stop takes no arguments")
|
||||
}
|
||||
paths, err := daemon.DefaultPaths()
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
client, err := daemon.Dial(paths.Socket)
|
||||
if err != nil {
|
||||
fmt.Fprintln(stdout, "zero daemon is not running")
|
||||
return exitSuccess
|
||||
}
|
||||
defer client.Close()
|
||||
if err := client.Shutdown(); err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
fmt.Fprintln(stdout, "zero daemon stopped")
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
func runDaemonStatus(args []string, stdout io.Writer, stderr io.Writer) int {
|
||||
if helpRequested(args) {
|
||||
return writeDaemonUsage(stdout, exitSuccess)
|
||||
}
|
||||
if len(args) > 0 {
|
||||
return writeExecUsageError(stderr, "daemon status takes no arguments")
|
||||
}
|
||||
paths, err := daemon.DefaultPaths()
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
client, err := daemon.Dial(paths.Socket)
|
||||
if err != nil {
|
||||
fmt.Fprintln(stdout, "zero daemon is not running")
|
||||
return exitSuccess
|
||||
}
|
||||
defer client.Close()
|
||||
report, err := client.Status()
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
fmt.Fprintf(stdout, "daemon pid=%d version=%d socket=%s\n", report.PID, report.Version, report.Socket)
|
||||
fmt.Fprintf(stdout, "pool size=%d busy=%d queue=%d\n", report.PoolSize, len(report.Workers), report.QueueDepth)
|
||||
for _, s := range report.Sessions {
|
||||
fmt.Fprintf(stdout, " session %s state=%s lines=%d\n", s.ID, s.State, s.Lines)
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
func runDaemonRun(args []string, stdout io.Writer, stderr io.Writer) int {
|
||||
session, cwd, prompt := "", "", ""
|
||||
var remoteFlags remoteDialFlags
|
||||
var forward []string
|
||||
for i := 0; i < len(args); i++ {
|
||||
a := args[i]
|
||||
value := func() (string, bool) {
|
||||
if i+1 >= len(args) {
|
||||
return "", false
|
||||
}
|
||||
i++
|
||||
return args[i], true
|
||||
}
|
||||
switch {
|
||||
case a == "-h" || a == "--help":
|
||||
return writeDaemonUsage(stdout, exitSuccess)
|
||||
case a == "--remote":
|
||||
v, ok := value()
|
||||
if !ok {
|
||||
return writeExecUsageError(stderr, "--remote requires a value")
|
||||
}
|
||||
remoteFlags.Addr = v
|
||||
case strings.HasPrefix(a, "--remote="):
|
||||
remoteFlags.Addr = strings.TrimPrefix(a, "--remote=")
|
||||
case a == "--token":
|
||||
v, ok := value()
|
||||
if !ok {
|
||||
return writeExecUsageError(stderr, "--token requires a value")
|
||||
}
|
||||
remoteFlags.Token = v
|
||||
case strings.HasPrefix(a, "--token="):
|
||||
remoteFlags.Token = strings.TrimPrefix(a, "--token=")
|
||||
case a == "--ca-cert":
|
||||
v, ok := value()
|
||||
if !ok {
|
||||
return writeExecUsageError(stderr, "--ca-cert requires a value")
|
||||
}
|
||||
remoteFlags.CACert = v
|
||||
case strings.HasPrefix(a, "--ca-cert="):
|
||||
remoteFlags.CACert = strings.TrimPrefix(a, "--ca-cert=")
|
||||
case a == "--server-name":
|
||||
v, ok := value()
|
||||
if !ok {
|
||||
return writeExecUsageError(stderr, "--server-name requires a value")
|
||||
}
|
||||
remoteFlags.ServerName = v
|
||||
case strings.HasPrefix(a, "--server-name="):
|
||||
remoteFlags.ServerName = strings.TrimPrefix(a, "--server-name=")
|
||||
case a == "--session":
|
||||
v, ok := value()
|
||||
if !ok {
|
||||
return writeExecUsageError(stderr, "--session requires a value")
|
||||
}
|
||||
session = v
|
||||
case strings.HasPrefix(a, "--session="):
|
||||
session = strings.TrimPrefix(a, "--session=")
|
||||
case a == "--cwd":
|
||||
v, ok := value()
|
||||
if !ok {
|
||||
return writeExecUsageError(stderr, "--cwd requires a value")
|
||||
}
|
||||
cwd = v
|
||||
case strings.HasPrefix(a, "--cwd="):
|
||||
cwd = strings.TrimPrefix(a, "--cwd=")
|
||||
case a == "--prompt" || a == "-p":
|
||||
v, ok := value()
|
||||
if !ok {
|
||||
return writeExecUsageError(stderr, "--prompt requires a value")
|
||||
}
|
||||
prompt = v
|
||||
case strings.HasPrefix(a, "--prompt="):
|
||||
prompt = strings.TrimPrefix(a, "--prompt=")
|
||||
default:
|
||||
// Forwarded verbatim to the worker `zero exec` (reuses exec's own flag
|
||||
// parsing for per-session run options).
|
||||
forward = append(forward, a)
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(session) == "" {
|
||||
return writeExecUsageError(stderr, "daemon run requires --session <id>")
|
||||
}
|
||||
if prompt == "" && len(forward) == 0 {
|
||||
return writeExecUsageError(stderr, "daemon run requires --prompt <text> or exec args")
|
||||
}
|
||||
client, err := dialForCLI(remoteFlags)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, daemonDialError(remoteFlags, err), exitCrash)
|
||||
}
|
||||
defer client.Close()
|
||||
if err := client.Run(session, cwd, prompt, forward, func(line string) { fmt.Fprintln(stdout, line) }); err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
func runDaemonAttach(args []string, stdout io.Writer, stderr io.Writer) int {
|
||||
session := ""
|
||||
var remoteFlags remoteDialFlags
|
||||
extra := 0
|
||||
for i := 0; i < len(args); i++ {
|
||||
a := args[i]
|
||||
value := func() (string, bool) {
|
||||
if i+1 >= len(args) {
|
||||
return "", false
|
||||
}
|
||||
i++
|
||||
return args[i], true
|
||||
}
|
||||
switch {
|
||||
case a == "-h" || a == "--help":
|
||||
return writeDaemonUsage(stdout, exitSuccess)
|
||||
case a == "--remote":
|
||||
v, ok := value()
|
||||
if !ok {
|
||||
return writeExecUsageError(stderr, "--remote requires a value")
|
||||
}
|
||||
remoteFlags.Addr = v
|
||||
case strings.HasPrefix(a, "--remote="):
|
||||
remoteFlags.Addr = strings.TrimPrefix(a, "--remote=")
|
||||
case a == "--token":
|
||||
v, ok := value()
|
||||
if !ok {
|
||||
return writeExecUsageError(stderr, "--token requires a value")
|
||||
}
|
||||
remoteFlags.Token = v
|
||||
case strings.HasPrefix(a, "--token="):
|
||||
remoteFlags.Token = strings.TrimPrefix(a, "--token=")
|
||||
case a == "--ca-cert":
|
||||
v, ok := value()
|
||||
if !ok {
|
||||
return writeExecUsageError(stderr, "--ca-cert requires a value")
|
||||
}
|
||||
remoteFlags.CACert = v
|
||||
case strings.HasPrefix(a, "--ca-cert="):
|
||||
remoteFlags.CACert = strings.TrimPrefix(a, "--ca-cert=")
|
||||
case a == "--server-name":
|
||||
v, ok := value()
|
||||
if !ok {
|
||||
return writeExecUsageError(stderr, "--server-name requires a value")
|
||||
}
|
||||
remoteFlags.ServerName = v
|
||||
case strings.HasPrefix(a, "--server-name="):
|
||||
remoteFlags.ServerName = strings.TrimPrefix(a, "--server-name=")
|
||||
case session == "":
|
||||
session = a
|
||||
default:
|
||||
extra++
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(session) == "" {
|
||||
return writeExecUsageError(stderr, "daemon attach requires a <session> id")
|
||||
}
|
||||
if extra > 0 {
|
||||
return writeExecUsageError(stderr, "daemon attach accepts exactly one <session> id")
|
||||
}
|
||||
client, err := dialForCLI(remoteFlags)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, daemonDialError(remoteFlags, err), exitCrash)
|
||||
}
|
||||
defer client.Close()
|
||||
if err := client.Attach(session, func(line string) { fmt.Fprintln(stdout, line) }); err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
// daemonDialError tailors the connect-failure message for local vs remote.
|
||||
func daemonDialError(flags remoteDialFlags, err error) string {
|
||||
if strings.TrimSpace(flags.Addr) != "" {
|
||||
return "failed to reach remote daemon at " + flags.Addr + ": " + err.Error()
|
||||
}
|
||||
return "zero daemon is not running (start it with `zero daemon start`)"
|
||||
}
|
||||
|
||||
// runDaemonServeRemote starts the local daemon plus an opt-in, TLS-only network
|
||||
// bridge. TLS and a bearer token are mandatory (fail closed): it refuses to
|
||||
// start without a cert/key pair and a token from the environment.
|
||||
func runDaemonServeRemote(args []string, stdout io.Writer, stderr io.Writer) int {
|
||||
addr, certFile, keyFile, bundleDir := "", "", "", ""
|
||||
minVersion, maxConns := 0, 0
|
||||
for i := 0; i < len(args); i++ {
|
||||
a := args[i]
|
||||
value := func() (string, bool) {
|
||||
if i+1 >= len(args) {
|
||||
return "", false
|
||||
}
|
||||
i++
|
||||
return args[i], true
|
||||
}
|
||||
switch {
|
||||
case a == "-h" || a == "--help":
|
||||
return writeDaemonUsage(stdout, exitSuccess)
|
||||
case a == "--addr":
|
||||
v, ok := value()
|
||||
if !ok {
|
||||
return writeExecUsageError(stderr, "--addr requires a value")
|
||||
}
|
||||
addr = v
|
||||
case strings.HasPrefix(a, "--addr="):
|
||||
addr = strings.TrimPrefix(a, "--addr=")
|
||||
case a == "--tls-cert":
|
||||
v, ok := value()
|
||||
if !ok {
|
||||
return writeExecUsageError(stderr, "--tls-cert requires a value")
|
||||
}
|
||||
certFile = v
|
||||
case strings.HasPrefix(a, "--tls-cert="):
|
||||
certFile = strings.TrimPrefix(a, "--tls-cert=")
|
||||
case a == "--tls-key":
|
||||
v, ok := value()
|
||||
if !ok {
|
||||
return writeExecUsageError(stderr, "--tls-key requires a value")
|
||||
}
|
||||
keyFile = v
|
||||
case strings.HasPrefix(a, "--tls-key="):
|
||||
keyFile = strings.TrimPrefix(a, "--tls-key=")
|
||||
case a == "--min-version":
|
||||
v, ok := value()
|
||||
if !ok {
|
||||
return writeExecUsageError(stderr, "--min-version requires a value")
|
||||
}
|
||||
minVersion = atoiOrZero(v)
|
||||
case a == "--max-conns":
|
||||
v, ok := value()
|
||||
if !ok {
|
||||
return writeExecUsageError(stderr, "--max-conns requires a value")
|
||||
}
|
||||
maxConns = atoiOrZero(v)
|
||||
case a == "--bundle-dir":
|
||||
v, ok := value()
|
||||
if !ok {
|
||||
return writeExecUsageError(stderr, "--bundle-dir requires a value")
|
||||
}
|
||||
bundleDir = v
|
||||
case strings.HasPrefix(a, "--bundle-dir="):
|
||||
bundleDir = strings.TrimPrefix(a, "--bundle-dir=")
|
||||
default:
|
||||
return writeExecUsageError(stderr, fmt.Sprintf("unknown flag %q for daemon serve-remote", a))
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(addr) == "" {
|
||||
return writeExecUsageError(stderr, "daemon serve-remote requires --addr <host:port>")
|
||||
}
|
||||
// Fail closed: TLS cert/key + a bearer token are mandatory.
|
||||
tlsConfig, err := remote.ServerTLSConfig(certFile, keyFile)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
token, err := remote.TokenFromEnv()
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
auth, err := remote.NewTokenAuthenticator(token)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
paths, err := daemon.DefaultPaths()
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
launcher, err := daemon.NewExecLauncher(daemon.ExecLauncherConfig{})
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
logf := func(line string) { fmt.Fprintln(stderr, "[daemon] "+line) }
|
||||
pool, err := daemon.NewPool(daemon.PoolOptions{Launcher: launcher, Log: logf})
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
mgr, err := daemon.NewSessionManager(daemon.SessionManagerOptions{Pool: pool})
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
srv, err := daemon.NewServer(daemon.ServerOptions{Paths: paths, Manager: mgr, Pool: pool, Log: logf})
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
bridge, err := remote.NewBridge(remote.BridgeOptions{
|
||||
Server: srv, Authenticator: auth, MinVersion: minVersion, MaxConnections: maxConns, BundleDir: bundleDir, Log: logf,
|
||||
})
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
|
||||
// Serve the local control socket too, so local clients keep working.
|
||||
go func() {
|
||||
if serveErr := srv.Serve(); serveErr != nil {
|
||||
logf("local serve error: " + serveErr.Error())
|
||||
}
|
||||
}()
|
||||
|
||||
serveErr := make(chan error, 1)
|
||||
go func() { serveErr <- bridge.ListenAndServeTLS(addr, tlsConfig) }()
|
||||
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
|
||||
|
||||
fmt.Fprintf(stdout, "zero daemon remote bridge listening on %s (TLS)\n", addr)
|
||||
select {
|
||||
case <-sigCh:
|
||||
srv.Shutdown()
|
||||
_ = bridge.Close()
|
||||
<-serveErr // wait for the accept loop to unwind
|
||||
return exitSuccess
|
||||
case err := <-serveErr:
|
||||
// Bind/serve failed before any signal (e.g. address in use).
|
||||
srv.Shutdown()
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
}
|
||||
|
||||
// runDaemonLink uploads a repo's git history to a remote bridge as a bundle
|
||||
// (link without --show), or prints a saved session link (--show <file>).
|
||||
func runDaemonLink(args []string, stdout io.Writer, stderr io.Writer) int {
|
||||
var addr, repo, id, token, caCert, serverName, out, show string
|
||||
// flags maps each "--name" to the string it sets; both "--name v" and
|
||||
// "--name=v" forms are accepted.
|
||||
flags := map[string]*string{
|
||||
"--remote": &addr, "--repo": &repo, "--id": &id, "--token": &token,
|
||||
"--ca-cert": &caCert, "--server-name": &serverName, "--out": &out, "--show": &show,
|
||||
}
|
||||
for i := 0; i < len(args); i++ {
|
||||
a := args[i]
|
||||
if a == "-h" || a == "--help" {
|
||||
return writeDaemonUsage(stdout, exitSuccess)
|
||||
}
|
||||
name, inlineVal, hasInline := a, "", false
|
||||
if eq := strings.IndexByte(a, '='); eq >= 0 {
|
||||
name, inlineVal, hasInline = a[:eq], a[eq+1:], true
|
||||
}
|
||||
dst, ok := flags[name]
|
||||
if !ok {
|
||||
return writeExecUsageError(stderr, fmt.Sprintf("unknown flag %q for daemon link", a))
|
||||
}
|
||||
if hasInline {
|
||||
*dst = inlineVal
|
||||
continue
|
||||
}
|
||||
if i+1 >= len(args) {
|
||||
return writeExecUsageError(stderr, fmt.Sprintf("%s requires a value", name))
|
||||
}
|
||||
i++
|
||||
*dst = args[i]
|
||||
}
|
||||
|
||||
if strings.TrimSpace(show) != "" {
|
||||
link, err := remote.LoadSessionLink(show)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
fmt.Fprintf(stdout, "link %s -> %s on %s\n", link.LinkID, link.RemotePath, link.Address)
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
if strings.TrimSpace(addr) == "" || strings.TrimSpace(repo) == "" || strings.TrimSpace(id) == "" {
|
||||
return writeExecUsageError(stderr, "daemon link requires --remote, --repo, and --id (or --show <file>)")
|
||||
}
|
||||
if strings.TrimSpace(token) == "" {
|
||||
token, _ = remote.TokenFromEnv() // best effort; UploadRepoBundle rejects an empty token
|
||||
}
|
||||
link, err := remote.UploadRepoBundle(remote.RemoteConfig{
|
||||
Address: addr,
|
||||
Token: token,
|
||||
CACertFile: caCert,
|
||||
ServerName: serverName,
|
||||
}, repo, id)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
fmt.Fprintf(stdout, "uploaded %s; remote repo at %s\n", link.LinkID, link.RemotePath)
|
||||
fmt.Fprintf(stdout, "run it with: zero daemon run --remote %s --cwd %s ...\n", link.Address, link.RemotePath)
|
||||
if strings.TrimSpace(out) != "" {
|
||||
if err := link.Save(out); err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
fmt.Fprintf(stdout, "saved session link to %s\n", out)
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
// remoteDialFlags holds the optional flags that redirect run/attach to a remote
|
||||
// daemon. When Addr is empty the local control socket is used.
|
||||
type remoteDialFlags struct {
|
||||
Addr string
|
||||
Token string
|
||||
CACert string
|
||||
ServerName string
|
||||
}
|
||||
|
||||
// dialForCLI returns a daemon.Client for either the local socket (Addr empty) or
|
||||
// a remote bridge (Addr set). For remote, the token falls back to the env.
|
||||
func dialForCLI(flags remoteDialFlags) (*daemon.Client, error) {
|
||||
if strings.TrimSpace(flags.Addr) == "" {
|
||||
paths, err := daemon.DefaultPaths()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return daemon.Dial(paths.Socket)
|
||||
}
|
||||
token := strings.TrimSpace(flags.Token)
|
||||
if token == "" {
|
||||
token, _ = remote.TokenFromEnv() // best effort; DialRemote rejects an empty token
|
||||
}
|
||||
return remote.DialRemote(remote.RemoteConfig{
|
||||
Address: flags.Addr,
|
||||
Token: token,
|
||||
CACertFile: flags.CACert,
|
||||
ServerName: flags.ServerName,
|
||||
})
|
||||
}
|
||||
|
||||
func atoiOrZero(s string) int {
|
||||
n := 0
|
||||
for _, r := range strings.TrimSpace(s) {
|
||||
if r < '0' || r > '9' {
|
||||
return 0
|
||||
}
|
||||
n = n*10 + int(r-'0')
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// daemonReachable reports whether a daemon is accepting connections (a successful
|
||||
// handshake). Used for the single-instance check and start-up wait.
|
||||
func daemonReachable(paths daemon.Paths) bool {
|
||||
client, err := daemon.Dial(paths.Socket)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
_ = client.Close()
|
||||
return true
|
||||
}
|
||||
|
||||
func helpRequested(args []string) bool {
|
||||
for _, a := range args {
|
||||
if a == "-h" || a == "--help" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// isolateDaemonPaths points DefaultPaths at a temp dir so the test never touches
|
||||
// a real daemon on the dev machine.
|
||||
func isolateDaemonPaths(t *testing.T) {
|
||||
t.Helper()
|
||||
t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
|
||||
}
|
||||
|
||||
func runDaemonCLI(t *testing.T, args ...string) (int, string, string) {
|
||||
t.Helper()
|
||||
var out, errb bytes.Buffer
|
||||
code := runDaemon(args, &out, &errb, appDeps{})
|
||||
return code, out.String(), errb.String()
|
||||
}
|
||||
|
||||
func TestDaemonUsage(t *testing.T) {
|
||||
code, _, _ := runDaemonCLI(t)
|
||||
if code != exitUsage {
|
||||
t.Fatalf("no-args exit = %d, want exitUsage", code)
|
||||
}
|
||||
code, out, _ := runDaemonCLI(t, "--help")
|
||||
if code != exitSuccess || !strings.Contains(out, "Usage: zero daemon") {
|
||||
t.Fatalf("--help exit=%d out=%q", code, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDaemonUnknownSubcommand(t *testing.T) {
|
||||
code, _, errb := runDaemonCLI(t, "frobnicate")
|
||||
if code != exitUsage {
|
||||
t.Fatalf("unknown subcommand exit = %d, want exitUsage", code)
|
||||
}
|
||||
if !strings.Contains(errb, "unknown daemon subcommand") {
|
||||
t.Fatalf("stderr = %q, want unknown-subcommand message", errb)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDaemonRunRequiresSession(t *testing.T) {
|
||||
isolateDaemonPaths(t)
|
||||
code, _, errb := runDaemonCLI(t, "run", "--prompt", "hi")
|
||||
if code != exitUsage {
|
||||
t.Fatalf("run without --session exit = %d, want exitUsage", code)
|
||||
}
|
||||
if !strings.Contains(errb, "--session") {
|
||||
t.Fatalf("stderr = %q, want a --session hint", errb)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDaemonRunRequiresPromptOrArgs(t *testing.T) {
|
||||
isolateDaemonPaths(t)
|
||||
code, _, errb := runDaemonCLI(t, "run", "--session", "s1")
|
||||
if code != exitUsage {
|
||||
t.Fatalf("run without prompt/args exit = %d, want exitUsage", code)
|
||||
}
|
||||
if !strings.Contains(errb, "--prompt") {
|
||||
t.Fatalf("stderr = %q, want a --prompt hint", errb)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDaemonStopWhenNotRunning(t *testing.T) {
|
||||
isolateDaemonPaths(t)
|
||||
code, out, _ := runDaemonCLI(t, "stop")
|
||||
if code != exitSuccess {
|
||||
t.Fatalf("stop (not running) exit = %d, want exitSuccess", code)
|
||||
}
|
||||
if !strings.Contains(out, "not running") {
|
||||
t.Fatalf("stop output = %q, want 'not running'", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDaemonStatusWhenNotRunning(t *testing.T) {
|
||||
isolateDaemonPaths(t)
|
||||
code, out, _ := runDaemonCLI(t, "status")
|
||||
if code != exitSuccess {
|
||||
t.Fatalf("status (not running) exit = %d, want exitSuccess", code)
|
||||
}
|
||||
if !strings.Contains(out, "not running") {
|
||||
t.Fatalf("status output = %q, want 'not running'", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDaemonAttachRequiresSession(t *testing.T) {
|
||||
isolateDaemonPaths(t)
|
||||
code, _, errb := runDaemonCLI(t, "attach")
|
||||
if code != exitUsage {
|
||||
t.Fatalf("attach without session exit = %d, want exitUsage", code)
|
||||
}
|
||||
if !strings.Contains(errb, "session") {
|
||||
t.Fatalf("stderr = %q, want a session hint", errb)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDaemonRunWhenNotRunning(t *testing.T) {
|
||||
isolateDaemonPaths(t)
|
||||
code, _, errb := runDaemonCLI(t, "run", "--session", "s1", "--prompt", "hello")
|
||||
if code != exitCrash {
|
||||
t.Fatalf("run (no daemon) exit = %d, want exitCrash", code)
|
||||
}
|
||||
if !strings.Contains(errb, "not running") {
|
||||
t.Fatalf("stderr = %q, want 'not running'", errb)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDaemonSubcommandsRejectExtraArgs(t *testing.T) {
|
||||
isolateDaemonPaths(t)
|
||||
cases := [][]string{
|
||||
{"stop", "oops"},
|
||||
{"status", "oops"},
|
||||
{"attach", "s1", "extra"},
|
||||
}
|
||||
for _, args := range cases {
|
||||
code, _, errb := runDaemonCLI(t, args...)
|
||||
if code != exitUsage {
|
||||
t.Fatalf("%v exit = %d, want exitUsage (reject extra args); stderr=%q", args, code, errb)
|
||||
}
|
||||
}
|
||||
}
|
||||
+39
-8
@@ -19,7 +19,6 @@ import (
|
||||
"github.com/Gitlawb/zero/internal/providers"
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
"github.com/Gitlawb/zero/internal/sessions"
|
||||
"github.com/Gitlawb/zero/internal/specialist"
|
||||
"github.com/Gitlawb/zero/internal/specmode"
|
||||
"github.com/Gitlawb/zero/internal/streamjson"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
@@ -163,10 +162,18 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
if options.allowEscalation {
|
||||
registry.Register(tools.NewEscalateModelTool())
|
||||
}
|
||||
var specialistRuntime *specialist.Runtime
|
||||
var specialistRuntime *agentToolRuntime
|
||||
if shouldRegisterExecSpecialistTools(options) {
|
||||
// Specialist tools register before the full config resolve below (so
|
||||
// --list-tools stays offline). swarm.maxTeamSize is not affected by
|
||||
// overrides, so an empty-overrides resolve yields the same value; a resolve
|
||||
// error falls back to the swarm's built-in default (0 => 8).
|
||||
maxTeamSize := 0
|
||||
if swarmCfg, cfgErr := deps.resolveConfig(workspaceRoot, config.Overrides{}); cfgErr == nil {
|
||||
maxTeamSize = swarmCfg.Swarm.MaxTeamSize
|
||||
}
|
||||
var err error
|
||||
specialistRuntime, err = registerSpecialistTools(registry, workspaceRoot)
|
||||
specialistRuntime, err = registerSpecialistTools(registry, workspaceRoot, maxTeamSize)
|
||||
if err != nil {
|
||||
return writeExecProviderError(stdout, stderr, options.outputFormat, "specialist_error", err.Error())
|
||||
}
|
||||
@@ -368,6 +375,12 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
Mode: notify.Mode(strings.TrimSpace(execNotifyMode(options, resolved))),
|
||||
FocusMode: notify.FocusAlways,
|
||||
})
|
||||
// Opt-in webhook fan-out (ZERO_NOTIFY_WEBHOOK_URL). Headless runs can safely
|
||||
// log a failed delivery to stderr (never stdout). The sink redacts before
|
||||
// logging, so a token in the URL or message is masked.
|
||||
notify.MaybeAddWebhookSink(notifier, os.Getenv, func(format string, args ...any) {
|
||||
fmt.Fprintf(stderr, "[notify] "+format+"\n", args...)
|
||||
})
|
||||
if options.useSpec {
|
||||
return runExecSpecDraft(execSpecDraftRun{
|
||||
options: options,
|
||||
@@ -473,6 +486,7 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
MaxTurns: resolved.MaxTurns,
|
||||
ContextWindow: modelContextWindow(modelRegistry, resolved.Provider.Model),
|
||||
DeferThreshold: effectiveDeferThreshold,
|
||||
Specialists: specialistRuntime.specialistInfos(),
|
||||
SessionID: preparedSession.Session.SessionID,
|
||||
CallingSessionID: options.callingSessionID,
|
||||
CallingToolUseID: options.callingToolUseID,
|
||||
@@ -704,23 +718,40 @@ func buildExecSandboxEngine(workspaceRoot string, resolved config.ResolvedConfig
|
||||
// depth — it guarantees that even a direct/programmatic caller passing a typo
|
||||
// can never WIDEN the ceiling back to the High default.
|
||||
// applyConfiguredSandboxPolicy overlays every config-sourced sandbox knob onto
|
||||
// the default policy: the autonomy ceiling and the network mode.
|
||||
// the default policy (the autonomy ceiling and the network mode), plus the
|
||||
// ZERO_SANDBOX_AUTO_ALLOW_BASH environment opt-in.
|
||||
func applyConfiguredSandboxPolicy(policy sandbox.Policy, cfg config.SandboxConfig) sandbox.Policy {
|
||||
policy = applyConfiguredAutonomyCeiling(policy, cfg.MaxAutonomy)
|
||||
if network := strings.TrimSpace(cfg.Network); network != "" {
|
||||
switch sandbox.NetworkMode(network) {
|
||||
case sandbox.NetworkAllow, sandbox.NetworkDeny:
|
||||
case sandbox.NetworkAllow, sandbox.NetworkDeny, sandbox.NetworkScoped:
|
||||
policy.Network = sandbox.NetworkMode(network)
|
||||
}
|
||||
}
|
||||
// Opt-in hardening flags: only ever turn a feature ON from config, never off,
|
||||
// so a programmatic default can't be silently disabled by an omitted key.
|
||||
// Scoped egress allow/deny lists from (global) config; the engine consults
|
||||
// these only under NetworkScoped and falls closed to deny on an empty
|
||||
// allowlist or a backend that can't enforce scoping.
|
||||
if len(cfg.NetworkAllowedDomains) > 0 {
|
||||
policy.AllowedDomains = append([]string(nil), cfg.NetworkAllowedDomains...)
|
||||
}
|
||||
if len(cfg.NetworkDeniedDomains) > 0 {
|
||||
policy.DeniedDomains = append([]string(nil), cfg.NetworkDeniedDomains...)
|
||||
}
|
||||
// Opt-in hardening/diagnostic flags: only ever turn a feature ON from
|
||||
// config, never off, so a programmatic default can't be silently disabled by
|
||||
// an omitted key.
|
||||
if cfg.BlockUnixSockets {
|
||||
policy.BlockUnixSockets = true
|
||||
}
|
||||
if cfg.MonitorDenials {
|
||||
policy.MonitorDenials = true
|
||||
}
|
||||
// ZERO_SANDBOX_AUTO_ALLOW_BASH opts into auto-allowing a *sandboxed* shell
|
||||
// command without a prompt — the active sandbox is the safety boundary, and
|
||||
// the engine only honors this when the shell sandbox is actually active. Like
|
||||
// the flags above it only ever turns the opt-in ON; unset/blank stays off
|
||||
// (prompt), and an explicit config opt-in is preserved.
|
||||
policy = sandbox.ApplyAutoAllowBashEnv(policy)
|
||||
return policy
|
||||
}
|
||||
|
||||
@@ -1060,7 +1091,7 @@ func sessionPermissionEventType(event agent.PermissionEvent) sessions.EventType
|
||||
if event.Action == agent.PermissionActionPrompt {
|
||||
return sessions.EventPermissionRequest
|
||||
}
|
||||
if event.Action == agent.PermissionActionAllow || event.Action == agent.PermissionActionDeny {
|
||||
if event.Action == agent.PermissionActionAllow || event.Action == agent.PermissionActionDeny || event.Action == agent.PermissionActionCancel {
|
||||
return sessions.EventPermissionDecision
|
||||
}
|
||||
return sessions.EventPermission
|
||||
|
||||
@@ -357,7 +357,7 @@ func TestRunExecStreamJSONEmitsAndRecordsPermissionEvents(t *testing.T) {
|
||||
toolCallID: "call_write",
|
||||
toolName: "write_file",
|
||||
arguments: `{"path":"notes.txt","content":"hello"}`,
|
||||
answer: "write denied",
|
||||
answer: "write allowed",
|
||||
}, nil
|
||||
},
|
||||
newSandboxStore: func() (*sandbox.GrantStore, error) {
|
||||
@@ -374,16 +374,22 @@ func TestRunExecStreamJSONEmitsAndRecordsPermissionEvents(t *testing.T) {
|
||||
|
||||
events := decodeJSONLines(t, stdout.String())
|
||||
eventTypes := jsonEventTypes(events)
|
||||
if !slices.Contains(eventTypes, "permission_request") {
|
||||
t.Fatalf("expected permission_request event in %v; output %q", eventTypes, stdout.String())
|
||||
if slices.Contains(eventTypes, "permission_request") {
|
||||
t.Fatalf("workspace write should not request permission in %v; output %q", eventTypes, stdout.String())
|
||||
}
|
||||
permissionEvent := findJSONEvent(t, events, "permission_request")
|
||||
if permissionEvent["id"] != "call_write" || permissionEvent["name"] != "write_file" || permissionEvent["action"] != "prompt" {
|
||||
permissionEvent := findJSONEvent(t, events, "permission_decision")
|
||||
if permissionEvent["id"] != "call_write" || permissionEvent["name"] != "write_file" || permissionEvent["action"] != "allow" {
|
||||
t.Fatalf("unexpected permission event: %#v", permissionEvent)
|
||||
}
|
||||
if permissionEvent["permission"] != "prompt" || permissionEvent["permissionMode"] != "auto" || permissionEvent["sideEffect"] != "write" {
|
||||
t.Fatalf("unexpected permission metadata: %#v", permissionEvent)
|
||||
}
|
||||
if permissionEvent["permissionGranted"] == true {
|
||||
t.Fatalf("workspace policy allow should not be recorded as user-granted permission: %#v", permissionEvent)
|
||||
}
|
||||
if permissionEvent["reason"] != "workspace write permitted by sandbox policy" {
|
||||
t.Fatalf("unexpected workspace permission reason: %#v", permissionEvent)
|
||||
}
|
||||
risk, ok := permissionEvent["risk"].(map[string]any)
|
||||
if !ok || risk["level"] == "" {
|
||||
t.Fatalf("expected permission risk payload, got %#v", permissionEvent)
|
||||
@@ -400,12 +406,12 @@ func TestRunExecStreamJSONEmitsAndRecordsPermissionEvents(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("ReadEvents returned error: %v", err)
|
||||
}
|
||||
permissionRecord := findSessionEvent(t, recorded, sessions.EventPermissionRequest)
|
||||
permissionRecord := findSessionEvent(t, recorded, sessions.EventPermissionDecision)
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(permissionRecord.Payload, &payload); err != nil {
|
||||
t.Fatalf("decode permission payload: %v", err)
|
||||
}
|
||||
if payload["toolCallId"] != "call_write" || payload["name"] != "write_file" || payload["action"] != "prompt" {
|
||||
if payload["toolCallId"] != "call_write" || payload["name"] != "write_file" || payload["action"] != "allow" {
|
||||
t.Fatalf("unexpected recorded permission payload: %#v", payload)
|
||||
}
|
||||
|
||||
@@ -715,6 +721,10 @@ func (fn closeFunc) Close() error {
|
||||
return fn()
|
||||
}
|
||||
|
||||
func (fn closeFunc) Skipped() []mcp.SkippedServer {
|
||||
return nil
|
||||
}
|
||||
|
||||
type toolCallingExecProvider struct {
|
||||
toolCallID string
|
||||
toolName string
|
||||
|
||||
@@ -242,7 +242,7 @@ func streamJSONPermissionEventType(event agent.PermissionEvent) streamjson.Event
|
||||
if event.Action == agent.PermissionActionPrompt {
|
||||
return streamjson.EventPermissionRequest
|
||||
}
|
||||
if event.Action == agent.PermissionActionAllow || event.Action == agent.PermissionActionDeny {
|
||||
if event.Action == agent.PermissionActionAllow || event.Action == agent.PermissionActionDeny || event.Action == agent.PermissionActionCancel {
|
||||
return streamjson.EventPermissionDecision
|
||||
}
|
||||
return streamjson.EventPermission
|
||||
|
||||
@@ -697,11 +697,20 @@ func (cfg *mcpWritableConfig) setServerDisabled(name string, disabled bool) (boo
|
||||
if err != nil {
|
||||
return false, false, err
|
||||
}
|
||||
if !legacyFound {
|
||||
switch {
|
||||
case legacyFound:
|
||||
raw = legacyRaw
|
||||
found = true
|
||||
case config.IsDefaultMCPServer(name):
|
||||
// A built-in default server isn't written to the file until the user
|
||||
// overrides it. Treat it as present with an empty base so disabling it
|
||||
// writes a minimal {"disabled":true} entry that merges over the default —
|
||||
// letting `zero mcp disable <default>` work even though it lives in code.
|
||||
raw = nil
|
||||
found = true
|
||||
default:
|
||||
return false, false, nil
|
||||
}
|
||||
raw = legacyRaw
|
||||
found = true
|
||||
}
|
||||
var server map[string]json.RawMessage
|
||||
if len(raw) > 0 && string(raw) != "null" {
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
)
|
||||
|
||||
// `zero mcp disable firecrawl` must work even though firecrawl is a built-in
|
||||
// default that is not written to the user's config file until overridden.
|
||||
func TestRunMCPDisableSeededFirecrawlDefault(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "zero", "config.json")
|
||||
// A config with no firecrawl entry — the default lives in code, not the file.
|
||||
writeMCPCommandRawConfig(t, configPath, `{"activeProvider":"fast"}`)
|
||||
|
||||
var out, errBuf bytes.Buffer
|
||||
code := runWithDeps([]string{"mcp", "disable", "firecrawl", "--json"}, &out, &errBuf, appDeps{
|
||||
userConfigPath: func() (string, error) { return configPath, nil },
|
||||
})
|
||||
if code != exitSuccess {
|
||||
t.Fatalf("disable exit=%d stderr=%s", code, errBuf.String())
|
||||
}
|
||||
var payload struct {
|
||||
ServerName string `json:"serverName"`
|
||||
Disabled bool `json:"disabled"`
|
||||
Changed bool `json:"changed"`
|
||||
}
|
||||
if err := json.Unmarshal(out.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode disable JSON: %v\n%s", err, out.String())
|
||||
}
|
||||
if payload.ServerName != "firecrawl" || !payload.Disabled || !payload.Changed {
|
||||
t.Fatalf("disable payload = %#v, want firecrawl disabled+changed", payload)
|
||||
}
|
||||
|
||||
// End-to-end: resolving that config now turns the default off.
|
||||
cfg, err := config.ResolveMCP(config.ResolveOptions{UserConfigPath: configPath})
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveMCP: %v", err)
|
||||
}
|
||||
if !cfg.Servers["firecrawl"].Disabled {
|
||||
t.Fatal("expected `mcp disable firecrawl` to turn the seeded default off in the resolved config")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/oauth"
|
||||
)
|
||||
|
||||
func TestProviderHasOAuthLogin(t *testing.T) {
|
||||
xai := config.ProviderProfile{Name: "xai", CatalogID: "xai"}
|
||||
if providerHasOAuthLogin(xai, nil) {
|
||||
t.Fatal("no stored login must be false")
|
||||
}
|
||||
if !providerHasOAuthLogin(xai, map[string]bool{"xai": true}) {
|
||||
t.Fatal("a stored login keyed by name must be true")
|
||||
}
|
||||
if !providerHasOAuthLogin(config.ProviderProfile{Name: "grok", CatalogID: "xai"}, map[string]bool{"xai": true}) {
|
||||
t.Fatal("a stored login keyed by catalog id must be true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetupRequiredRecognizesOAuthLogin(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "tok.json")
|
||||
t.Setenv("ZERO_OAUTH_TOKENS_PATH", path)
|
||||
store, err := oauth.NewStore(oauth.StoreOptions{FilePath: path})
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore: %v", err)
|
||||
}
|
||||
if err := store.Save(oauth.ProviderKey("xai"), oauth.Token{AccessToken: "tok", ExpiresAt: time.Now().Add(time.Hour)}); err != nil {
|
||||
t.Fatalf("seed token: %v", err)
|
||||
}
|
||||
|
||||
// xai has no inline key (env-only) but IS logged in via OAuth → no setup.
|
||||
loggedIn := config.ResolvedConfig{Provider: config.ProviderProfile{Name: "xai", CatalogID: "xai", APIKeyEnv: "XAI_API_KEY"}}
|
||||
if setupRequired(loggedIn) {
|
||||
t.Fatal("a provider with a stored OAuth login must not require onboarding")
|
||||
}
|
||||
|
||||
// A keyless provider with no OAuth login still requires setup.
|
||||
noLogin := config.ResolvedConfig{Provider: config.ProviderProfile{Name: "openai", CatalogID: "openai", APIKeyEnv: "OPENAI_API_KEY"}}
|
||||
if !setupRequired(noLogin) {
|
||||
t.Fatal("a keyless provider with no OAuth login must require onboarding")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/oauth"
|
||||
"github.com/Gitlawb/zero/internal/providers/providerio"
|
||||
)
|
||||
|
||||
// providerOAuthName resolves the OAuth login name for a provider profile. It is
|
||||
// the name a user logs in under (`zero auth login <name>`) whose token then
|
||||
// authenticates this provider's model calls. For now it is the profile name;
|
||||
// the provider presets map well-known kinds (openai/anthropic) to canonical
|
||||
// names on top of this.
|
||||
func providerOAuthName(profile config.ProviderProfile) string {
|
||||
return strings.TrimSpace(profile.Name)
|
||||
}
|
||||
|
||||
// oauthResolverForProfile returns a TokenResolver that authenticates a provider's
|
||||
// model calls with the user's OAuth login, or nil when no login exists for this
|
||||
// provider. Returning nil for the no-login case keeps API-key users free of any
|
||||
// per-request store lookups: the resolver is only attached once a login is
|
||||
// present at construction time.
|
||||
func oauthResolverForProfile(profile config.ProviderProfile) providerio.TokenResolver {
|
||||
name := providerOAuthName(profile)
|
||||
if name == "" {
|
||||
return nil
|
||||
}
|
||||
store, err := oauth.NewStore(oauth.StoreOptions{})
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
key := oauth.ProviderKey(name)
|
||||
if _, ok, loadErr := store.Load(key); loadErr != nil || !ok {
|
||||
// No login (or an unreadable/invalid key) → API-key auth, no resolver.
|
||||
return nil
|
||||
}
|
||||
manager, err := oauth.NewManager(oauth.ManagerOptions{
|
||||
Store: store,
|
||||
HTTPClient: &http.Client{Timeout: 30 * time.Second},
|
||||
})
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return func(ctx context.Context, forceRefresh bool) (string, string, bool, error) {
|
||||
var token string
|
||||
var rerr error
|
||||
if forceRefresh {
|
||||
token, rerr = manager.Handle401(ctx, key)
|
||||
} else {
|
||||
token, rerr = manager.GetFresh(ctx, key)
|
||||
}
|
||||
if errors.Is(rerr, oauth.ErrNoToken) {
|
||||
// The login was removed since construction → fall back to the API key.
|
||||
return "", "", false, nil
|
||||
}
|
||||
if rerr != nil {
|
||||
return "", "", false, rerr
|
||||
}
|
||||
return "Authorization", "Bearer " + token, true, nil
|
||||
}
|
||||
}
|
||||
@@ -46,8 +46,10 @@ func runDoctor(args []string, stdout io.Writer, stderr io.Writer, deps appDeps)
|
||||
}
|
||||
|
||||
var provider config.ProviderProfile
|
||||
var sandboxConfig config.SandboxConfig
|
||||
if resolved, resolveErr := deps.resolveConfig(workspaceRoot, config.Overrides{}); resolveErr == nil {
|
||||
provider = resolved.Provider
|
||||
sandboxConfig = resolved.Sandbox
|
||||
}
|
||||
var health *providerhealth.Result
|
||||
if options.connectivity && config.HasProviderProfile(provider) {
|
||||
@@ -67,6 +69,8 @@ func runDoctor(args []string, stdout io.Writer, stderr io.Writer, deps appDeps)
|
||||
UserConfig: userConfig,
|
||||
ProjectConfig: projectConfig,
|
||||
Provider: provider,
|
||||
WorkspaceRoot: workspaceRoot,
|
||||
Sandbox: sandboxConfig,
|
||||
Connectivity: options.connectivity,
|
||||
ProviderHealth: health,
|
||||
})
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/provideronboarding"
|
||||
)
|
||||
|
||||
type providerDetectOptions struct {
|
||||
json bool
|
||||
}
|
||||
|
||||
// providerDetectAction is the JSON/text shape of a single suggested next step.
|
||||
type providerDetectAction struct {
|
||||
Label string `json:"label"`
|
||||
Command string `json:"command"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
}
|
||||
|
||||
type providerDetectRuntime struct {
|
||||
CatalogID string `json:"catalogID"`
|
||||
Name string `json:"name"`
|
||||
BaseURL string `json:"baseURL"`
|
||||
Models []string `json:"models,omitempty"`
|
||||
Action providerDetectAction `json:"action"`
|
||||
}
|
||||
|
||||
type providerDetectProvider struct {
|
||||
Name string `json:"name"`
|
||||
Active bool `json:"active"`
|
||||
Actions []providerDetectAction `json:"actions,omitempty"`
|
||||
}
|
||||
|
||||
type providerDetectReport struct {
|
||||
DetectedRuntimes []providerDetectRuntime `json:"detectedRuntimes"`
|
||||
Providers []providerDetectProvider `json:"providers"`
|
||||
}
|
||||
|
||||
// runProvidersDetect probes the machine for running local, OpenAI-compatible
|
||||
// model runtimes (Ollama, LM Studio) and prints a no-key adopt command for each
|
||||
// one it finds, followed by the next-step actions for every already-configured
|
||||
// provider. It is the onboarding-advice surface — "what can I do right now?" —
|
||||
// and never errors on a machine with nothing running locally (it just reports an
|
||||
// empty detected list).
|
||||
func runProvidersDetect(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
||||
options, help, err := parseProviderDetectArgs(args)
|
||||
if err != nil {
|
||||
return writeExecUsageError(stderr, err.Error())
|
||||
}
|
||||
if help {
|
||||
if err := writeProvidersHelp(stdout); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
resolved, exitCode := resolveCommandCenterConfig(stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
return exitCode
|
||||
}
|
||||
|
||||
ctx, stop := signalContext()
|
||||
defer stop()
|
||||
detected := deps.detectLocalRuntimes(ctx, provideronboarding.LocalDetectOptions{})
|
||||
|
||||
report := buildProviderDetectReport(resolved, detected)
|
||||
|
||||
if options.json {
|
||||
if err := writePrettyJSON(stdout, report); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
if _, err := fmt.Fprintln(stdout, formatProviderDetectReport(report)); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
func parseProviderDetectArgs(args []string) (providerDetectOptions, bool, error) {
|
||||
options := providerDetectOptions{}
|
||||
for _, arg := range args {
|
||||
switch {
|
||||
case arg == "-h" || arg == "--help" || arg == "help":
|
||||
return options, true, nil
|
||||
case arg == "--json":
|
||||
options.json = true
|
||||
default:
|
||||
return options, false, execUsageError{fmt.Sprintf("unexpected argument %q", arg)}
|
||||
}
|
||||
}
|
||||
return options, false, nil
|
||||
}
|
||||
|
||||
func buildProviderDetectReport(resolved config.ResolvedConfig, detected []provideronboarding.DetectedLocalRuntime) providerDetectReport {
|
||||
report := providerDetectReport{
|
||||
DetectedRuntimes: make([]providerDetectRuntime, 0, len(detected)),
|
||||
Providers: make([]providerDetectProvider, 0, len(resolved.Providers)),
|
||||
}
|
||||
for _, runtime := range detected {
|
||||
report.DetectedRuntimes = append(report.DetectedRuntimes, providerDetectRuntime{
|
||||
CatalogID: runtime.CatalogID,
|
||||
Name: runtime.Name,
|
||||
BaseURL: runtime.BaseURL,
|
||||
Models: runtime.Models,
|
||||
Action: providerDetectActionFrom(runtime.SetupAction()),
|
||||
})
|
||||
}
|
||||
for _, profile := range resolved.Providers {
|
||||
active := strings.TrimSpace(profile.Name) != "" && profile.Name == resolved.ActiveProvider
|
||||
state := provideronboarding.ProviderState{Profile: profile, Active: active}
|
||||
actions := state.Actions()
|
||||
entry := providerDetectProvider{
|
||||
Name: profile.Name,
|
||||
Active: active,
|
||||
Actions: make([]providerDetectAction, 0, len(actions)),
|
||||
}
|
||||
for _, action := range actions {
|
||||
entry.Actions = append(entry.Actions, providerDetectActionFrom(action))
|
||||
}
|
||||
report.Providers = append(report.Providers, entry)
|
||||
}
|
||||
return report
|
||||
}
|
||||
|
||||
func providerDetectActionFrom(action provideronboarding.Action) providerDetectAction {
|
||||
return providerDetectAction{Label: action.Label, Command: action.Command, Detail: action.Detail}
|
||||
}
|
||||
|
||||
func formatProviderDetectReport(report providerDetectReport) string {
|
||||
lines := []string{"Detected local runtimes:"}
|
||||
if len(report.DetectedRuntimes) == 0 {
|
||||
lines = append(lines, " (none running on default ports)")
|
||||
}
|
||||
for _, runtime := range report.DetectedRuntimes {
|
||||
name := strings.TrimSpace(runtime.Name)
|
||||
if name == "" {
|
||||
name = runtime.CatalogID
|
||||
}
|
||||
lines = append(lines, " "+name+" — "+runtime.BaseURL)
|
||||
if len(runtime.Models) > 0 {
|
||||
lines = append(lines, " models: "+strings.Join(runtime.Models, ", "))
|
||||
}
|
||||
if command := strings.TrimSpace(runtime.Action.Command); command != "" {
|
||||
lines = append(lines, " "+runtime.Action.Label+": "+command)
|
||||
}
|
||||
}
|
||||
|
||||
lines = append(lines, "", "Configured providers:")
|
||||
if len(report.Providers) == 0 {
|
||||
lines = append(lines, " (none configured — run `zero setup`)")
|
||||
}
|
||||
for _, provider := range report.Providers {
|
||||
name := strings.TrimSpace(provider.Name)
|
||||
if name == "" {
|
||||
name = "(unnamed)"
|
||||
}
|
||||
marker := ""
|
||||
if provider.Active {
|
||||
marker = " (active)"
|
||||
}
|
||||
lines = append(lines, " "+name+marker)
|
||||
if len(provider.Actions) == 0 {
|
||||
lines = append(lines, " (ready)")
|
||||
}
|
||||
for _, action := range provider.Actions {
|
||||
line := " " + action.Label
|
||||
if command := strings.TrimSpace(action.Command); command != "" {
|
||||
line += ": " + command
|
||||
}
|
||||
lines = append(lines, line)
|
||||
}
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/provideronboarding"
|
||||
)
|
||||
|
||||
func TestRunProvidersDetectSurfacesLocalRuntimeAndProviderActions(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
deps := commandCenterDeps(t)
|
||||
deps.detectLocalRuntimes = func(context.Context, provideronboarding.LocalDetectOptions) []provideronboarding.DetectedLocalRuntime {
|
||||
return []provideronboarding.DetectedLocalRuntime{{
|
||||
LocalRuntime: provideronboarding.LocalRuntime{CatalogID: "ollama", Name: "Ollama Local", BaseURL: "http://localhost:11434/v1"},
|
||||
Reachable: true,
|
||||
Models: []string{"llama3.1"},
|
||||
}}
|
||||
}
|
||||
deps.resolveConfig = func(string, config.Overrides) (config.ResolvedConfig, error) {
|
||||
// Configured-but-inactive provider with no credential: should yield the full
|
||||
// "Use provider" / "Check provider" / "Set API key" action set.
|
||||
profile := config.ProviderProfile{
|
||||
Name: "openai",
|
||||
ProviderKind: config.ProviderKindOpenAI,
|
||||
Model: "gpt-4o",
|
||||
APIKeyEnv: "OPENAI_API_KEY",
|
||||
}
|
||||
return config.ResolvedConfig{ActiveProvider: "other", Providers: []config.ProviderProfile{profile}}, nil
|
||||
}
|
||||
|
||||
exitCode := runWithDeps([]string{"providers", "detect"}, &stdout, &stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("exit=%d stderr=%s", exitCode, stderr.String())
|
||||
}
|
||||
out := stdout.String()
|
||||
for _, want := range []string{
|
||||
"Detected local runtimes:",
|
||||
"Ollama Local — http://localhost:11434/v1",
|
||||
"models: llama3.1",
|
||||
"zero providers add ollama", // SetupAction's no-key adopt command
|
||||
"Configured providers:",
|
||||
"openai",
|
||||
"Use provider",
|
||||
"Check provider",
|
||||
"Set API key",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("detect output missing %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunProvidersDetectJSONNoRuntimesActiveProvider(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
deps := commandCenterDeps(t)
|
||||
deps.detectLocalRuntimes = func(context.Context, provideronboarding.LocalDetectOptions) []provideronboarding.DetectedLocalRuntime {
|
||||
return nil // nothing running locally
|
||||
}
|
||||
deps.resolveConfig = func(string, config.Overrides) (config.ResolvedConfig, error) {
|
||||
// Active provider with an inline key: only "Check provider" remains.
|
||||
profile := config.ProviderProfile{
|
||||
Name: "local",
|
||||
ProviderKind: config.ProviderKindOpenAICompatible,
|
||||
BaseURL: "https://api.example.com/v1",
|
||||
APIKey: "sk-secret",
|
||||
Model: "custom",
|
||||
}
|
||||
return config.ResolvedConfig{ActiveProvider: "local", Provider: profile, Providers: []config.ProviderProfile{profile}}, nil
|
||||
}
|
||||
|
||||
exitCode := runWithDeps([]string{"providers", "detect", "--json"}, &stdout, &stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("exit=%d stderr=%s", exitCode, stderr.String())
|
||||
}
|
||||
if strings.Contains(stdout.String(), "sk-secret") {
|
||||
t.Fatalf("detect JSON leaked the API key: %s", stdout.String())
|
||||
}
|
||||
var payload struct {
|
||||
DetectedRuntimes []struct {
|
||||
CatalogID string `json:"catalogID"`
|
||||
} `json:"detectedRuntimes"`
|
||||
Providers []struct {
|
||||
Name string `json:"name"`
|
||||
Active bool `json:"active"`
|
||||
Actions []struct {
|
||||
Label string `json:"label"`
|
||||
} `json:"actions"`
|
||||
} `json:"providers"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("detect JSON did not decode: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if len(payload.DetectedRuntimes) != 0 {
|
||||
t.Fatalf("expected no detected runtimes, got %#v", payload.DetectedRuntimes)
|
||||
}
|
||||
if len(payload.Providers) != 1 || payload.Providers[0].Name != "local" || !payload.Providers[0].Active {
|
||||
t.Fatalf("expected one active provider 'local', got %#v", payload.Providers)
|
||||
}
|
||||
if len(payload.Providers[0].Actions) != 1 || payload.Providers[0].Actions[0].Label != "Check provider" {
|
||||
t.Fatalf("expected only a Check action for an active keyed provider, got %#v", payload.Providers[0].Actions)
|
||||
}
|
||||
}
|
||||
+137
-3
@@ -31,6 +31,10 @@ func runSandbox(args []string, stdout io.Writer, stderr io.Writer, deps appDeps)
|
||||
return exitSuccess
|
||||
case "policy":
|
||||
return runSandboxPolicy(args[1:], stdout, stderr, deps)
|
||||
case "setup":
|
||||
return runSandboxSetup(args[1:], stdout, stderr, deps)
|
||||
case "check":
|
||||
return runSandboxCheck(args[1:], stdout, stderr, deps)
|
||||
case "grants":
|
||||
return runSandboxGrants(args[1:], stdout, stderr, deps)
|
||||
default:
|
||||
@@ -112,6 +116,102 @@ func runSandboxPolicy(args []string, stdout io.Writer, stderr io.Writer, deps ap
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
func runSandboxSetup(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
||||
options, help, err := parseSandboxCommandOptions(args, sandboxCommandFlags{})
|
||||
if err != nil {
|
||||
return writeExecUsageError(stderr, err.Error())
|
||||
}
|
||||
if help {
|
||||
if err := writeSandboxSetupHelp(stdout); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
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)
|
||||
backend := deps.selectSandboxBackend(zeroSandbox.BackendOptions{})
|
||||
if backend.Platform != "windows" {
|
||||
message := "No native sandbox setup is required for " + displayPlatform(backend.Platform)
|
||||
return writeSandboxSetupResult(stdout, options.json, sandboxSetupResult{
|
||||
Platform: displayPlatform(backend.Platform),
|
||||
Backend: backend.Name,
|
||||
Ran: false,
|
||||
Message: message,
|
||||
})
|
||||
}
|
||||
if backend.Name != zeroSandbox.BackendWindowsRestrictedToken || !backend.Available || backend.Executable == "" {
|
||||
message := "Windows sandbox setup helper is not available"
|
||||
if strings.TrimSpace(backend.Message) != "" {
|
||||
message += ": " + backend.Message
|
||||
}
|
||||
return writeAppError(stderr, message, exitProvider)
|
||||
}
|
||||
scope, err := zeroSandbox.NewScope(workspaceRoot, resolved.Sandbox.AdditionalWriteRoots)
|
||||
if err != nil {
|
||||
return writeExecUsageError(stderr, err.Error())
|
||||
}
|
||||
profile := zeroSandbox.PermissionProfileFromPolicy(workspaceRoot, policy, scope)
|
||||
setupPath := zeroSandbox.WindowsSandboxSetupPathForRunner(backend.Executable)
|
||||
setupArgs, err := zeroSandbox.BuildWindowsSandboxSetupArgs(zeroSandbox.WindowsSandboxSetupArgsOptions{
|
||||
CommandCWD: workspaceRoot,
|
||||
WorkspaceRoots: []string{workspaceRoot},
|
||||
PermissionProfile: profile,
|
||||
})
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
if err := deps.runSandboxSetupHelper(setupPath, setupArgs, stdout, stderr); err != nil {
|
||||
return writeAppError(stderr, "Windows sandbox setup failed: "+err.Error(), exitProvider)
|
||||
}
|
||||
return writeSandboxSetupResult(stdout, options.json, sandboxSetupResult{
|
||||
Platform: "windows",
|
||||
Backend: backend.Name,
|
||||
Helper: setupPath,
|
||||
Ran: true,
|
||||
Message: "Windows sandbox setup complete",
|
||||
})
|
||||
}
|
||||
|
||||
type sandboxSetupResult struct {
|
||||
Platform string `json:"platform"`
|
||||
Backend zeroSandbox.BackendName `json:"backend"`
|
||||
Helper string `json:"helper,omitempty"`
|
||||
Ran bool `json:"ran"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func writeSandboxSetupResult(stdout io.Writer, asJSON bool, result sandboxSetupResult) int {
|
||||
if asJSON {
|
||||
if err := writePrettyJSON(stdout, result); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
if _, err := fmt.Fprintln(stdout, result.Message); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
if result.Helper != "" {
|
||||
if _, err := fmt.Fprintln(stdout, "helper: "+result.Helper); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
func displayPlatform(platform string) string {
|
||||
if strings.TrimSpace(platform) == "" {
|
||||
return "this platform"
|
||||
}
|
||||
return platform
|
||||
}
|
||||
|
||||
// sandboxGuards is the resolved set of pre-execution safety guards the engine
|
||||
// applies for the effective policy. There is no layered/merged config today, so
|
||||
// the "effective" view is the fully-resolved DefaultPolicy plus the platform
|
||||
@@ -192,13 +292,20 @@ func formatEffectiveSandboxPolicy(workspaceRoot string, policy zeroSandbox.Polic
|
||||
"allow_policy_only_runner: "+fmt.Sprintf("%t", policy.AllowPolicyOnlyRunner),
|
||||
"max_autonomy: "+string(policy.MaxAutonomy),
|
||||
"backend: "+string(backend.Name),
|
||||
"target_backend: "+string(plan.TargetBackend),
|
||||
"support_level: "+string(plan.SupportLevel),
|
||||
"command_wrapped: "+fmt.Sprintf("%t", plan.CommandWrapped),
|
||||
"enforcement_level: "+string(plan.EnforcementLevel),
|
||||
"requires_platform_sandbox: "+fmt.Sprintf("%t", plan.RequiresPlatformSandbox),
|
||||
"interactive_command_guard: "+enabledLabel(guards.InteractiveCommand),
|
||||
"destructive_shell_guard: "+enabledLabel(guards.DestructiveShell),
|
||||
"network_guard: "+enabledLabel(guards.Network),
|
||||
"workspace_guard: "+enabledLabel(guards.Workspace),
|
||||
"grants: "+grantsPath,
|
||||
)
|
||||
if plan.DowngradeReason != "" {
|
||||
lines = append(lines, "downgrade_reason: "+plan.DowngradeReason)
|
||||
}
|
||||
if backend.Platform != "" {
|
||||
lines = append(lines, "backend_platform: "+backend.Platform)
|
||||
}
|
||||
@@ -247,15 +354,20 @@ func runSandboxGrants(args []string, stdout io.Writer, stderr io.Writer, deps ap
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
prefixes, err := store.ListCommandPrefixes()
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
if options.json {
|
||||
if err := writePrettyJSON(stdout, struct {
|
||||
Grants []zeroSandbox.Grant `json:"grants"`
|
||||
}{Grants: grants}); err != nil {
|
||||
Grants []zeroSandbox.Grant `json:"grants"`
|
||||
CommandPrefixes []zeroSandbox.CommandPrefixGrant `json:"commandPrefixes,omitempty"`
|
||||
}{Grants: grants, CommandPrefixes: prefixes}); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
if _, err := fmt.Fprintln(stdout, zeroSandbox.FormatGrantList(grants)); err != nil {
|
||||
if _, err := fmt.Fprintln(stdout, zeroSandbox.FormatGrantListWithCommandPrefixes(grants, prefixes)); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
@@ -499,7 +611,14 @@ func formatSandboxPolicy(workspaceRoot string, policy zeroSandbox.Policy, backen
|
||||
"network: " + string(policy.Network),
|
||||
"max_autonomy: " + string(policy.MaxAutonomy),
|
||||
"backend: " + string(backend.Name),
|
||||
"target_backend: " + string(plan.TargetBackend),
|
||||
"support_level: " + string(plan.SupportLevel),
|
||||
"command_wrapped: " + fmt.Sprintf("%t", plan.CommandWrapped),
|
||||
"enforcement_level: " + string(plan.EnforcementLevel),
|
||||
"requires_platform_sandbox: " + fmt.Sprintf("%t", plan.RequiresPlatformSandbox),
|
||||
}
|
||||
if plan.DowngradeReason != "" {
|
||||
lines = append(lines, "downgrade_reason: "+plan.DowngradeReason)
|
||||
}
|
||||
if backend.Platform != "" {
|
||||
lines = append(lines, "backend_platform: "+backend.Platform)
|
||||
@@ -526,6 +645,8 @@ func writeSandboxHelp(w io.Writer) error {
|
||||
|
||||
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
|
||||
grants Manage persistent sandbox grants
|
||||
|
||||
`)
|
||||
@@ -544,6 +665,19 @@ Flags:
|
||||
return err
|
||||
}
|
||||
|
||||
func writeSandboxSetupHelp(w io.Writer) error {
|
||||
_, err := fmt.Fprint(w, `Usage:
|
||||
zero sandbox setup [flags]
|
||||
|
||||
Runs native platform sandbox setup when the selected backend requires it.
|
||||
|
||||
Flags:
|
||||
--json Print JSON output
|
||||
-h, --help Show this help
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
func writeSandboxGrantsHelp(w io.Writer) error {
|
||||
_, err := fmt.Fprint(w, `Usage:
|
||||
zero sandbox grants <command>
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
zeroSandbox "github.com/Gitlawb/zero/internal/sandbox"
|
||||
"github.com/Gitlawb/zero/internal/zerocommands"
|
||||
)
|
||||
|
||||
type sandboxCheckOptions struct {
|
||||
tool string
|
||||
sideEffect string
|
||||
path string
|
||||
autonomy string
|
||||
reason string
|
||||
json bool
|
||||
}
|
||||
|
||||
// sandboxCheckReport is the combined snapshot `zero sandbox check` emits: the
|
||||
// active plan (policy + backend + restrictions), the decision the engine would
|
||||
// make for the described tool action, and any persistent grant that matches the
|
||||
// tool. It is the production consumer of the zerocommands sandbox-snapshot
|
||||
// contract, giving operators and CI a stable, redacted JSON view of "what would
|
||||
// the sandbox do for this action?".
|
||||
type sandboxCheckReport struct {
|
||||
Tool string `json:"tool"`
|
||||
Plan zerocommands.SandboxPlanSnapshot `json:"plan"`
|
||||
Decision zerocommands.SandboxDecisionSnapshot `json:"decision"`
|
||||
Grant zerocommands.SandboxGrantMatchSnapshot `json:"grant"`
|
||||
}
|
||||
|
||||
func runSandboxCheck(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
||||
options, help, err := parseSandboxCheckArgs(args)
|
||||
if err != nil {
|
||||
return writeExecUsageError(stderr, err.Error())
|
||||
}
|
||||
if help {
|
||||
if err := writeSandboxCheckHelp(stdout); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
if strings.TrimSpace(options.tool) == "" {
|
||||
return writeExecUsageError(stderr, "tool name is required: zero sandbox check <tool> [flags]")
|
||||
}
|
||||
|
||||
workspaceRoot, err := resolveWorkspaceRoot("", deps)
|
||||
if err != nil {
|
||||
return writeExecUsageError(stderr, err.Error())
|
||||
}
|
||||
store, err := deps.newSandboxStore()
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
resolved, err := deps.resolveConfig(workspaceRoot, config.Overrides{})
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitProvider)
|
||||
}
|
||||
policy := applyConfiguredSandboxPolicy(zeroSandbox.DefaultPolicy(), resolved.Sandbox)
|
||||
backend := deps.selectSandboxBackend(zeroSandbox.BackendOptions{})
|
||||
plan := backend.BuildPlan(workspaceRoot, policy)
|
||||
|
||||
sideEffect, err := parseSandboxCheckSideEffect(options.sideEffect)
|
||||
if err != nil {
|
||||
return writeExecUsageError(stderr, err.Error())
|
||||
}
|
||||
autonomy, err := parseSandboxCheckAutonomy(options.autonomy)
|
||||
if err != nil {
|
||||
return writeExecUsageError(stderr, err.Error())
|
||||
}
|
||||
|
||||
requestArgs := map[string]any{}
|
||||
scopeAbs := workspaceRoot
|
||||
if path := strings.TrimSpace(options.path); path != "" {
|
||||
requestArgs["path"] = path
|
||||
if filepath.IsAbs(path) {
|
||||
scopeAbs = filepath.Clean(path)
|
||||
} else {
|
||||
scopeAbs = filepath.Join(workspaceRoot, path)
|
||||
}
|
||||
}
|
||||
|
||||
// Build the engine with the SAME write scope the real entrypoints use
|
||||
// (workspace root + configured additional write roots) so the check's
|
||||
// decision matches actual enforcement and a stale extra-root config surfaces
|
||||
// here too, instead of silently falling back to a workspace-only scope.
|
||||
scope, err := zeroSandbox.NewScope(workspaceRoot, resolved.Sandbox.AdditionalWriteRoots)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitProvider)
|
||||
}
|
||||
engine := zeroSandbox.NewEngine(zeroSandbox.EngineOptions{
|
||||
WorkspaceRoot: workspaceRoot,
|
||||
Policy: policy,
|
||||
Store: store,
|
||||
Backend: backend,
|
||||
Scope: scope,
|
||||
})
|
||||
ctx, stop := signalContext()
|
||||
defer stop()
|
||||
decision := engine.Evaluate(ctx, zeroSandbox.Request{
|
||||
WorkspaceRoot: workspaceRoot,
|
||||
ToolName: options.tool,
|
||||
SideEffect: sideEffect,
|
||||
PermissionMode: zeroSandbox.PermissionModeAsk,
|
||||
Autonomy: autonomy,
|
||||
Args: requestArgs,
|
||||
Reason: strings.TrimSpace(options.reason),
|
||||
})
|
||||
|
||||
lookup, err := store.Lookup(options.tool, scopeAbs, autonomy)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
|
||||
report := sandboxCheckReport{
|
||||
Tool: strings.TrimSpace(options.tool),
|
||||
Plan: zerocommands.SandboxPlanSnapshotFromPlan(plan),
|
||||
Decision: zerocommands.SandboxDecisionSnapshotFromDecision(decision),
|
||||
Grant: zerocommands.SandboxGrantMatchSnapshotFromLookup(options.tool, lookup),
|
||||
}
|
||||
|
||||
if options.json {
|
||||
if err := writePrettyJSON(stdout, report); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
if _, err := fmt.Fprintln(stdout, formatSandboxCheckReport(report)); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
func parseSandboxCheckArgs(args []string) (sandboxCheckOptions, bool, error) {
|
||||
options := sandboxCheckOptions{}
|
||||
for index := 0; index < len(args); index++ {
|
||||
arg := args[index]
|
||||
switch {
|
||||
case arg == "-h" || arg == "--help" || arg == "help":
|
||||
return options, true, nil
|
||||
case arg == "--json":
|
||||
options.json = true
|
||||
case arg == "--side-effect" || arg == "--path" || arg == "--autonomy" || arg == "--reason":
|
||||
value, next, err := nextFlagValue(args, index, arg)
|
||||
if err != nil {
|
||||
return options, false, err
|
||||
}
|
||||
assignSandboxCheckFlag(&options, arg, value)
|
||||
index = next
|
||||
case strings.HasPrefix(arg, "--side-effect="):
|
||||
value, err := requiredInlineFlagValue(arg, "--side-effect")
|
||||
if err != nil {
|
||||
return options, false, err
|
||||
}
|
||||
options.sideEffect = value
|
||||
case strings.HasPrefix(arg, "--path="):
|
||||
value, err := requiredInlineFlagValue(arg, "--path")
|
||||
if err != nil {
|
||||
return options, false, err
|
||||
}
|
||||
options.path = value
|
||||
case strings.HasPrefix(arg, "--autonomy="):
|
||||
value, err := requiredInlineFlagValue(arg, "--autonomy")
|
||||
if err != nil {
|
||||
return options, false, err
|
||||
}
|
||||
options.autonomy = value
|
||||
case strings.HasPrefix(arg, "--reason="):
|
||||
value, err := requiredInlineFlagValue(arg, "--reason")
|
||||
if err != nil {
|
||||
return options, false, err
|
||||
}
|
||||
options.reason = value
|
||||
case strings.HasPrefix(arg, "-"):
|
||||
return options, false, execUsageError{fmt.Sprintf("unknown flag %q", arg)}
|
||||
default:
|
||||
if options.tool != "" {
|
||||
return options, false, execUsageError{fmt.Sprintf("unexpected argument %q", arg)}
|
||||
}
|
||||
options.tool = arg
|
||||
}
|
||||
}
|
||||
return options, false, nil
|
||||
}
|
||||
|
||||
func assignSandboxCheckFlag(options *sandboxCheckOptions, flag string, value string) {
|
||||
switch flag {
|
||||
case "--side-effect":
|
||||
options.sideEffect = value
|
||||
case "--path":
|
||||
options.path = value
|
||||
case "--autonomy":
|
||||
options.autonomy = value
|
||||
case "--reason":
|
||||
options.reason = value
|
||||
}
|
||||
}
|
||||
|
||||
// parseSandboxCheckSideEffect validates --side-effect against the closed set the
|
||||
// help advertises (empty defaults to read), so a typo returns a usage error
|
||||
// instead of being passed to the engine as an unknown side effect.
|
||||
func parseSandboxCheckSideEffect(value string) (zeroSandbox.SideEffect, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "", "read":
|
||||
return zeroSandbox.SideEffectRead, nil
|
||||
case "write":
|
||||
return zeroSandbox.SideEffectWrite, nil
|
||||
case "shell":
|
||||
return zeroSandbox.SideEffectShell, nil
|
||||
case "network":
|
||||
return zeroSandbox.SideEffectNetwork, nil
|
||||
case "none":
|
||||
return zeroSandbox.SideEffectNone, nil
|
||||
default:
|
||||
return "", execUsageError{fmt.Sprintf("invalid --side-effect %q: expected read, write, shell, network, or none", value)}
|
||||
}
|
||||
}
|
||||
|
||||
// parseSandboxCheckAutonomy validates --autonomy against the closed set the help
|
||||
// advertises (empty defaults to high), so a typo returns a usage error instead
|
||||
// of the engine silently clamping an unknown tier.
|
||||
func parseSandboxCheckAutonomy(value string) (zeroSandbox.Autonomy, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "":
|
||||
return zeroSandbox.AutonomyHigh, nil
|
||||
case string(zeroSandbox.AutonomyLow):
|
||||
return zeroSandbox.AutonomyLow, nil
|
||||
case string(zeroSandbox.AutonomyMedium):
|
||||
return zeroSandbox.AutonomyMedium, nil
|
||||
case string(zeroSandbox.AutonomyHigh):
|
||||
return zeroSandbox.AutonomyHigh, nil
|
||||
default:
|
||||
return "", execUsageError{fmt.Sprintf("invalid --autonomy %q: expected low, medium, or high", value)}
|
||||
}
|
||||
}
|
||||
|
||||
func formatSandboxCheckReport(report sandboxCheckReport) string {
|
||||
lines := []string{
|
||||
"Sandbox check: " + report.Tool,
|
||||
"decision: " + report.Decision.Action,
|
||||
}
|
||||
if reason := strings.TrimSpace(report.Decision.Reason); reason != "" {
|
||||
lines = append(lines, "reason: "+reason)
|
||||
}
|
||||
risk := report.Decision.Risk
|
||||
riskLine := "risk: " + risk.Level
|
||||
if len(risk.Categories) > 0 {
|
||||
riskLine += " [" + strings.Join(risk.Categories, ", ") + "]"
|
||||
}
|
||||
lines = append(lines, riskLine)
|
||||
if violation := report.Decision.Violation; violation != nil {
|
||||
line := "violation: [" + violation.Code + "] " + violation.Reason
|
||||
if violation.Path != "" {
|
||||
line += " (path: " + violation.Path + ")"
|
||||
}
|
||||
lines = append(lines, line)
|
||||
}
|
||||
if report.Grant.Matched && report.Grant.Grant != nil {
|
||||
lines = append(lines, "grant: matched "+report.Grant.Grant.Decision+" (maxAutonomy "+report.Grant.Grant.MaxAutonomy+")")
|
||||
} else {
|
||||
lines = append(lines, "grant: no grant matched this action")
|
||||
}
|
||||
lines = append(lines,
|
||||
"policy: mode="+report.Plan.Policy.EffectiveMode+" network="+report.Plan.Policy.Network+fmt.Sprintf(" enforceWorkspace=%t", report.Plan.Policy.EnforceWorkspace),
|
||||
"backend: "+report.Plan.Backend.Name+fmt.Sprintf(" (available=%t)", report.Plan.Backend.Available),
|
||||
)
|
||||
if len(report.Plan.Restrictions) > 0 {
|
||||
lines = append(lines, "restrictions:")
|
||||
for _, restriction := range report.Plan.Restrictions {
|
||||
lines = append(lines, " - "+restriction)
|
||||
}
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func writeSandboxCheckHelp(w io.Writer) error {
|
||||
_, err := fmt.Fprint(w, `Usage:
|
||||
zero sandbox check <tool> [flags]
|
||||
|
||||
Evaluates the sandbox decision for a hypothetical tool action against the
|
||||
resolved policy, and prints the plan, the decision (allow/prompt/deny with risk
|
||||
and any violation), and any persistent grant that matches the tool. Secrets in
|
||||
grant reasons are redacted.
|
||||
|
||||
Flags:
|
||||
--side-effect <kind> read | write | shell | network | none (default read)
|
||||
--path <path> Target path for the action (read/write checks)
|
||||
--autonomy <level> low | medium | high (default high)
|
||||
--reason <text> Reason recorded with the request
|
||||
--json Print the machine-readable snapshot
|
||||
-h, --help Show this help
|
||||
`)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
)
|
||||
|
||||
func sandboxCheckDeps(t *testing.T) (appDeps, string) {
|
||||
t.Helper()
|
||||
store := newSandboxTestStore(t)
|
||||
root := t.TempDir()
|
||||
return appDeps{
|
||||
getwd: func() (string, error) { return root, nil },
|
||||
newSandboxStore: func() (*sandbox.GrantStore, error) { return store, nil },
|
||||
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
|
||||
return config.ResolvedConfig{}, nil
|
||||
},
|
||||
selectSandboxBackend: func(sandbox.BackendOptions) sandbox.Backend {
|
||||
return sandbox.Backend{Name: sandbox.BackendPolicyOnly, Platform: "windows", Fallback: true}
|
||||
},
|
||||
}, root
|
||||
}
|
||||
|
||||
func TestRunSandboxCheckJSONDeniesOutOfWorkspaceWrite(t *testing.T) {
|
||||
deps, _ := sandboxCheckDeps(t)
|
||||
// An absolute path outside the workspace, portable across OSes: a Unix literal
|
||||
// like /etc/passwd is not absolute on Windows (it would be joined into the
|
||||
// workspace and allowed), so build one under a separate temp dir instead.
|
||||
outside := filepath.Join(t.TempDir(), "outside.txt")
|
||||
var stdout, stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{"sandbox", "check", "write_file", "--side-effect", "write", "--path", outside, "--json"}, &stdout, &stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("check exit=%d stderr=%s", exitCode, stderr.String())
|
||||
}
|
||||
var payload struct {
|
||||
Tool string `json:"tool"`
|
||||
Plan struct {
|
||||
Policy struct {
|
||||
EffectiveMode string `json:"effectiveMode"`
|
||||
} `json:"policy"`
|
||||
Backend struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"backend"`
|
||||
} `json:"plan"`
|
||||
Decision struct {
|
||||
Action string `json:"action"`
|
||||
Risk struct {
|
||||
Level string `json:"level"`
|
||||
} `json:"risk"`
|
||||
Violation *struct {
|
||||
Code string `json:"code"`
|
||||
} `json:"violation"`
|
||||
} `json:"decision"`
|
||||
Grant struct {
|
||||
ToolName string `json:"toolName"`
|
||||
Matched bool `json:"matched"`
|
||||
} `json:"grant"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode check JSON: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if payload.Tool != "write_file" {
|
||||
t.Fatalf("tool = %q", payload.Tool)
|
||||
}
|
||||
if payload.Plan.Policy.EffectiveMode != string(sandbox.ModeEnforce) {
|
||||
t.Fatalf("effectiveMode = %q, want enforce", payload.Plan.Policy.EffectiveMode)
|
||||
}
|
||||
if payload.Plan.Backend.Name != string(sandbox.BackendPolicyOnly) {
|
||||
t.Fatalf("backend = %q", payload.Plan.Backend.Name)
|
||||
}
|
||||
if payload.Decision.Action != string(sandbox.ActionDeny) {
|
||||
t.Fatalf("expected deny for out-of-workspace write, got %q\n%s", payload.Decision.Action, stdout.String())
|
||||
}
|
||||
if payload.Decision.Violation == nil {
|
||||
t.Fatalf("expected a violation for the out-of-workspace write")
|
||||
}
|
||||
if payload.Grant.ToolName != "write_file" || payload.Grant.Matched {
|
||||
t.Fatalf("expected an unmatched grant for write_file, got %#v", payload.Grant)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSandboxCheckTextRendersDecisionAndPlan(t *testing.T) {
|
||||
deps, _ := sandboxCheckDeps(t)
|
||||
var stdout, stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{"sandbox", "check", "read_file", "--side-effect", "read", "--path", "notes.txt"}, &stdout, &stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("check exit=%d stderr=%s", exitCode, stderr.String())
|
||||
}
|
||||
out := stdout.String()
|
||||
for _, want := range []string{
|
||||
"Sandbox check: read_file",
|
||||
"decision: ",
|
||||
"risk: ",
|
||||
"policy: mode=enforce",
|
||||
"backend: policy-only",
|
||||
"grant: no grant matched this action",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("check text missing %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSandboxCheckRequiresTool(t *testing.T) {
|
||||
deps, _ := sandboxCheckDeps(t)
|
||||
var stdout, stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{"sandbox", "check"}, &stdout, &stderr, deps)
|
||||
if exitCode == exitSuccess {
|
||||
t.Fatalf("expected a usage error when no tool is given, got success: %s", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "tool name is required") {
|
||||
t.Fatalf("expected 'tool name is required', got %q", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSandboxCheckRejectsInvalidFlags(t *testing.T) {
|
||||
deps, _ := sandboxCheckDeps(t)
|
||||
for _, args := range [][]string{
|
||||
{"sandbox", "check", "read_file", "--side-effect", "wrtie"},
|
||||
{"sandbox", "check", "read_file", "--autonomy", "supreme"},
|
||||
} {
|
||||
var stdout, stderr bytes.Buffer
|
||||
if code := runWithDeps(args, &stdout, &stderr, deps); code == exitSuccess {
|
||||
t.Fatalf("args %v: expected a usage error, got success: %s", args, stdout.String())
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "invalid --") {
|
||||
t.Fatalf("args %v: expected an 'invalid --…' usage error, got %q", args, stderr.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSandboxCheckMatchedGrantRedactsReason(t *testing.T) {
|
||||
store := newSandboxTestStore(t)
|
||||
root := t.TempDir()
|
||||
deps := appDeps{
|
||||
getwd: func() (string, error) { return root, nil },
|
||||
newSandboxStore: func() (*sandbox.GrantStore, error) { return store, nil },
|
||||
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
|
||||
return config.ResolvedConfig{}, nil
|
||||
},
|
||||
selectSandboxBackend: func(sandbox.BackendOptions) sandbox.Backend {
|
||||
return sandbox.Backend{Name: sandbox.BackendPolicyOnly}
|
||||
},
|
||||
}
|
||||
// Seed a tool-wide allow grant whose reason embeds a secret; the matched-grant
|
||||
// snapshot must redact it.
|
||||
if _, err := store.Grant(sandbox.GrantInput{
|
||||
ToolName: "read_file",
|
||||
Decision: sandbox.GrantAllow,
|
||||
MaxAutonomy: sandbox.AutonomyHigh,
|
||||
Reason: "approved with sk-test-secret1234567890",
|
||||
}); err != nil {
|
||||
t.Fatalf("seed grant: %v", err)
|
||||
}
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{"sandbox", "check", "read_file", "--autonomy", "high", "--json"}, &stdout, &stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("check exit=%d stderr=%s", exitCode, stderr.String())
|
||||
}
|
||||
if strings.Contains(stdout.String(), "sk-test-secret1234567890") {
|
||||
t.Fatalf("grant reason leaked a secret into the snapshot:\n%s", stdout.String())
|
||||
}
|
||||
var payload struct {
|
||||
Grant struct {
|
||||
Matched bool `json:"matched"`
|
||||
Grant *struct {
|
||||
Reason string `json:"reason"`
|
||||
} `json:"grant"`
|
||||
} `json:"grant"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode check JSON: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if !payload.Grant.Matched || payload.Grant.Grant == nil {
|
||||
t.Fatalf("expected a matched grant, got %#v", payload.Grant)
|
||||
}
|
||||
if !strings.Contains(payload.Grant.Grant.Reason, "REDACTED") {
|
||||
t.Fatalf("expected the reason to be redacted, got %q", payload.Grant.Grant.Reason)
|
||||
}
|
||||
}
|
||||
+244
-16
@@ -5,6 +5,8 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
@@ -103,6 +105,41 @@ func TestRunSandboxGrantsAllowListDenyRevokeAndClear(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSandboxGrantsListIncludesCommandPrefixes(t *testing.T) {
|
||||
store := newSandboxTestStore(t)
|
||||
if _, err := store.GrantCommandPrefix(sandbox.CommandPrefixInput{ToolName: "bash", Prefix: []string{"git", "status"}, Reason: "status checks"}); err != nil {
|
||||
t.Fatalf("GrantCommandPrefix: %v", err)
|
||||
}
|
||||
deps := appDeps{newSandboxStore: func() (*sandbox.GrantStore, error) { return store, nil }}
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{"sandbox", "grants", "list", "--json"}, &stdout, &stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("list json exit = %d, stderr %q", exitCode, stderr.String())
|
||||
}
|
||||
var payload struct {
|
||||
CommandPrefixes []sandbox.CommandPrefixGrant `json:"commandPrefixes"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode list JSON: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if len(payload.CommandPrefixes) != 1 || !reflect.DeepEqual(payload.CommandPrefixes[0].Prefix, []string{"git", "status"}) {
|
||||
t.Fatalf("unexpected commandPrefixes payload: %#v", payload.CommandPrefixes)
|
||||
}
|
||||
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
exitCode = runWithDeps([]string{"sandbox", "grants", "list"}, &stdout, &stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("list text exit = %d, stderr %q", exitCode, stderr.String())
|
||||
}
|
||||
for _, want := range []string{"bash", "`git status`", "command-prefix", "status checks"} {
|
||||
if !strings.Contains(stdout.String(), want) {
|
||||
t.Fatalf("list text = %q, missing %q", stdout.String(), want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSandboxGrantsCreateAndRevokeByPath(t *testing.T) {
|
||||
store := newSandboxTestStore(t)
|
||||
deps := appDeps{newSandboxStore: func() (*sandbox.GrantStore, error) { return store, nil }}
|
||||
@@ -218,7 +255,7 @@ func TestRunSandboxPolicyInspectTextAndJSON(t *testing.T) {
|
||||
Name: sandbox.BackendPolicyOnly,
|
||||
Platform: "windows",
|
||||
Fallback: true,
|
||||
Message: "policy-only fallback: Windows native sandbox adapter is not implemented",
|
||||
Message: "policy-only fallback: Windows sandbox command runner is not available",
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -242,10 +279,14 @@ func TestRunSandboxPolicyInspectTextAndJSON(t *testing.T) {
|
||||
Policy sandbox.Policy `json:"policy"`
|
||||
Backend sandbox.Backend `json:"backend"`
|
||||
Plan struct {
|
||||
SupportLevel string `json:"supportLevel"`
|
||||
Capabilities []sandbox.BackendCapability `json:"capabilities"`
|
||||
Restrictions []string `json:"restrictions"`
|
||||
Warnings []string `json:"warnings"`
|
||||
TargetBackend sandbox.BackendName `json:"targetBackend"`
|
||||
CommandWrapped bool `json:"commandWrapped"`
|
||||
EnforcementLevel sandbox.EnforcementLevel `json:"enforcementLevel"`
|
||||
DowngradeReason string `json:"downgradeReason"`
|
||||
SupportLevel string `json:"supportLevel"`
|
||||
Capabilities []sandbox.BackendCapability `json:"capabilities"`
|
||||
Restrictions []string `json:"restrictions"`
|
||||
Warnings []string `json:"warnings"`
|
||||
} `json:"plan"`
|
||||
Grants string `json:"grantsPath"`
|
||||
}
|
||||
@@ -261,13 +302,16 @@ func TestRunSandboxPolicyInspectTextAndJSON(t *testing.T) {
|
||||
if payload.Plan.SupportLevel != string(sandbox.BackendSupportPolicyOnly) {
|
||||
t.Fatalf("support level = %q, want policy-only", payload.Plan.SupportLevel)
|
||||
}
|
||||
if payload.Plan.TargetBackend != sandbox.BackendWindowsRestrictedToken || payload.Plan.CommandWrapped || payload.Plan.EnforcementLevel != sandbox.EnforcementDegraded || payload.Plan.DowngradeReason == "" {
|
||||
t.Fatalf("unexpected manager baseline fields: %#v", payload.Plan)
|
||||
}
|
||||
if sandboxPolicyCapabilityStatus(payload.Plan.Capabilities, "native_process_isolation") != sandbox.CapabilityUnavailable {
|
||||
t.Fatalf("expected native isolation unavailable, got %#v", payload.Plan.Capabilities)
|
||||
}
|
||||
if !sandboxPolicyRestrictionContains(payload.Plan.Restrictions, "native process isolation unavailable on windows") {
|
||||
t.Fatalf("expected JSON plan to document Windows fallback, got %#v", payload.Plan.Restrictions)
|
||||
}
|
||||
if !sandboxPolicyRestrictionContains(payload.Plan.Warnings, "Windows native sandbox adapter is not implemented") {
|
||||
if !sandboxPolicyRestrictionContains(payload.Plan.Warnings, "Windows sandbox command runner is not available") {
|
||||
t.Fatalf("expected JSON warnings to document Windows fallback, got %#v", payload.Plan.Warnings)
|
||||
}
|
||||
} else {
|
||||
@@ -275,12 +319,16 @@ func TestRunSandboxPolicyInspectTextAndJSON(t *testing.T) {
|
||||
for _, want := range []string{
|
||||
"Zero sandbox policy",
|
||||
"backend: policy-only",
|
||||
"target_backend: windows-restricted-token",
|
||||
"support_level: policy-only",
|
||||
"command_wrapped: false",
|
||||
"enforcement_level: degraded",
|
||||
"downgrade_reason: policy-only fallback: Windows sandbox command runner is not available",
|
||||
"backend_fallback: true",
|
||||
"backend_command_wrapping: false",
|
||||
"backend_native_isolation: false",
|
||||
"backend_platform: windows",
|
||||
"Windows native sandbox adapter is not implemented",
|
||||
"Windows sandbox command runner is not available",
|
||||
} {
|
||||
if !strings.Contains(output, want) {
|
||||
t.Fatalf("expected policy text to contain %q, got %q", want, output)
|
||||
@@ -291,6 +339,173 @@ func TestRunSandboxPolicyInspectTextAndJSON(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSandboxSetupRunsWindowsHelper(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
runnerDir := t.TempDir()
|
||||
runnerPath := filepath.Join(runnerDir, sandbox.WindowsSandboxCommandRunnerName)
|
||||
var gotPath string
|
||||
var gotArgs []string
|
||||
deps := appDeps{
|
||||
getwd: func() (string, error) { return workspace, nil },
|
||||
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
|
||||
return config.ResolvedConfig{
|
||||
Sandbox: config.SandboxConfig{Network: "allow"},
|
||||
}, nil
|
||||
},
|
||||
selectSandboxBackend: func(options sandbox.BackendOptions) sandbox.Backend {
|
||||
return sandbox.Backend{
|
||||
Name: sandbox.BackendWindowsRestrictedToken,
|
||||
Available: true,
|
||||
Platform: "windows",
|
||||
Executable: runnerPath,
|
||||
CommandWrapping: true,
|
||||
NativeIsolation: true,
|
||||
}
|
||||
},
|
||||
runSandboxSetupHelper: func(path string, args []string, stdout io.Writer, stderr io.Writer) error {
|
||||
gotPath = path
|
||||
gotArgs = append([]string{}, args...)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{"sandbox", "setup", "--json"}, &stdout, &stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("setup exit = %d, stderr %q", exitCode, stderr.String())
|
||||
}
|
||||
wantPath := filepath.Join(runnerDir, sandbox.WindowsSandboxSetupName)
|
||||
if gotPath != wantPath {
|
||||
t.Fatalf("setup helper path = %q, want %q", gotPath, wantPath)
|
||||
}
|
||||
config, err := sandbox.ParseWindowsSandboxSetupArgs(gotArgs)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseWindowsSandboxSetupArgs: %v", err)
|
||||
}
|
||||
if config.CommandCWD != workspace || len(config.WorkspaceRoots) != 1 || config.WorkspaceRoots[0] != workspace {
|
||||
t.Fatalf("setup args config = %#v, want workspace cwd/root", config)
|
||||
}
|
||||
if config.PermissionProfile.Network.Mode != sandbox.NetworkAllow {
|
||||
t.Fatalf("setup network profile = %#v, want allow", config.PermissionProfile.Network)
|
||||
}
|
||||
var payload sandboxSetupResult
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode setup JSON: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if !payload.Ran || payload.Helper != wantPath || payload.Backend != sandbox.BackendWindowsRestrictedToken {
|
||||
t.Fatalf("setup payload = %#v, want ran Windows helper", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSandboxSetupNoopsForNonWindowsBackend(t *testing.T) {
|
||||
deps := appDeps{
|
||||
getwd: func() (string, error) { return t.TempDir(), nil },
|
||||
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
|
||||
return config.ResolvedConfig{}, nil
|
||||
},
|
||||
selectSandboxBackend: func(options sandbox.BackendOptions) sandbox.Backend {
|
||||
return sandbox.Backend{Name: sandbox.BackendLinuxBwrap, Platform: "linux", Available: true}
|
||||
},
|
||||
runSandboxSetupHelper: func(string, []string, io.Writer, io.Writer) error {
|
||||
t.Fatal("setup helper should not run for non-Windows backend")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{"sandbox", "setup"}, &stdout, &stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("setup exit = %d, stderr %q", exitCode, stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "No native sandbox setup is required for linux") {
|
||||
t.Fatalf("setup output = %q, want non-Windows no-op message", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestTUISandboxSetupCommandGatedToWindowsNativeBackend(t *testing.T) {
|
||||
if got := tuiSandboxSetupCommand(sandbox.Backend{Name: sandbox.BackendLinuxBwrap, Platform: "linux", Available: true}, appDeps{}); got != nil {
|
||||
t.Fatal("linux backend should not enable the TUI sandbox setup command")
|
||||
}
|
||||
if got := tuiSandboxSetupCommand(sandbox.Backend{Name: sandbox.BackendPolicyOnly, Platform: "windows", Available: false}, appDeps{}); got != nil {
|
||||
t.Fatal("policy-only Windows backend should not enable the TUI sandbox setup command")
|
||||
}
|
||||
got := tuiSandboxSetupCommand(sandbox.Backend{
|
||||
Name: sandbox.BackendWindowsRestrictedToken,
|
||||
Platform: "windows",
|
||||
Available: true,
|
||||
Executable: filepath.Join(t.TempDir(), sandbox.WindowsSandboxCommandRunnerName),
|
||||
CommandWrapping: true,
|
||||
NativeIsolation: true,
|
||||
}, appDeps{})
|
||||
if got == nil {
|
||||
t.Fatal("native Windows backend should enable the TUI sandbox setup command")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSandboxPolicyJSONGoldenIncludesManagerBaselineFields(t *testing.T) {
|
||||
store := newSandboxTestStore(t)
|
||||
workspace := t.TempDir()
|
||||
deps := appDeps{
|
||||
getwd: func() (string, error) { return workspace, nil },
|
||||
newSandboxStore: func() (*sandbox.GrantStore, error) { return store, nil },
|
||||
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
|
||||
return config.ResolvedConfig{}, nil
|
||||
},
|
||||
selectSandboxBackend: func(options sandbox.BackendOptions) sandbox.Backend {
|
||||
return sandbox.Backend{
|
||||
Name: sandbox.BackendPolicyOnly,
|
||||
Platform: "windows",
|
||||
Fallback: true,
|
||||
Message: "policy-only fallback: Windows sandbox command runner is not available",
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{"sandbox", "policy", "--json"}, &stdout, &stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("policy exit = %d, stderr %q", exitCode, stderr.String())
|
||||
}
|
||||
|
||||
got := stdout.String()
|
||||
got = replacePathToken(got, workspace, "$WORKSPACE")
|
||||
got = replacePathToken(got, store.FilePath(), "$GRANTS")
|
||||
wantBytes, err := os.ReadFile(filepath.Join("testdata", "sandbox_policy_windows_policy_only.golden.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("read golden: %v", err)
|
||||
}
|
||||
if !jsonValuesEqual(t, wantBytes, []byte(got)) {
|
||||
t.Fatalf("policy JSON golden mismatch\nwant:\n%s\ngot:\n%s", string(wantBytes), got)
|
||||
}
|
||||
}
|
||||
|
||||
func jsonValuesEqual(t *testing.T, wantBytes []byte, gotBytes []byte) bool {
|
||||
t.Helper()
|
||||
var wantValue any
|
||||
if err := json.Unmarshal(wantBytes, &wantValue); err != nil {
|
||||
t.Fatalf("decode wanted JSON: %v", err)
|
||||
}
|
||||
var gotValue any
|
||||
if err := json.Unmarshal(gotBytes, &gotValue); err != nil {
|
||||
t.Fatalf("decode got JSON: %v\n%s", err, string(gotBytes))
|
||||
}
|
||||
return reflect.DeepEqual(wantValue, gotValue)
|
||||
}
|
||||
|
||||
func replacePathToken(value string, path string, token string) string {
|
||||
replace := func(value string, path string) string {
|
||||
value = strings.ReplaceAll(value, path, token)
|
||||
if encoded, err := json.Marshal(path); err == nil && len(encoded) >= 2 {
|
||||
value = strings.ReplaceAll(value, string(encoded[1:len(encoded)-1]), token)
|
||||
}
|
||||
return value
|
||||
}
|
||||
if resolved, err := filepath.EvalSymlinks(path); err == nil && resolved != path {
|
||||
value = replace(value, resolved)
|
||||
}
|
||||
return replace(value, path)
|
||||
}
|
||||
|
||||
func TestRunSandboxPolicyEffectiveTextAndJSON(t *testing.T) {
|
||||
store := newSandboxTestStore(t)
|
||||
deps := appDeps{
|
||||
@@ -641,24 +856,37 @@ func TestApplyConfiguredAutonomyCeiling(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyConfiguredSandboxPolicyHardeningFlags(t *testing.T) {
|
||||
func TestApplyConfiguredSandboxPolicyDiagnosticsFlags(t *testing.T) {
|
||||
base := sandbox.DefaultPolicy()
|
||||
if base.BlockUnixSockets || base.MonitorDenials {
|
||||
t.Fatalf("precondition: hardening flags must default off, got block=%v monitor=%v", base.BlockUnixSockets, base.MonitorDenials)
|
||||
t.Fatalf("precondition: diagnostic flags must default off")
|
||||
}
|
||||
|
||||
// Omitted keys leave the (off) defaults untouched.
|
||||
if got := applyConfiguredSandboxPolicy(sandbox.DefaultPolicy(), config.SandboxConfig{}); got.BlockUnixSockets || got.MonitorDenials {
|
||||
t.Fatalf("empty config must not enable hardening flags, got block=%v monitor=%v", got.BlockUnixSockets, got.MonitorDenials)
|
||||
t.Fatalf("empty config must not enable diagnostic flags: %#v", got)
|
||||
}
|
||||
|
||||
// Each flag opts in independently.
|
||||
got := applyConfiguredSandboxPolicy(sandbox.DefaultPolicy(), config.SandboxConfig{BlockUnixSockets: true, MonitorDenials: true})
|
||||
if !got.BlockUnixSockets {
|
||||
t.Fatal("BlockUnixSockets config not applied to policy")
|
||||
// The flags opt in.
|
||||
got := applyConfiguredSandboxPolicy(sandbox.DefaultPolicy(), config.SandboxConfig{
|
||||
BlockUnixSockets: true,
|
||||
MonitorDenials: true,
|
||||
})
|
||||
if !got.BlockUnixSockets || !got.MonitorDenials {
|
||||
t.Fatalf("diagnostic config not applied to policy: %#v", got)
|
||||
}
|
||||
if !got.MonitorDenials {
|
||||
t.Fatal("MonitorDenials config not applied to policy")
|
||||
}
|
||||
|
||||
func TestApplyConfiguredSandboxPolicyHonorsAutoAllowBashEnv(t *testing.T) {
|
||||
// Unset/blank: the opt-in stays off so a sandboxed bash command still prompts.
|
||||
t.Setenv(sandbox.EnvAutoAllowBash, "")
|
||||
if got := applyConfiguredSandboxPolicy(sandbox.DefaultPolicy(), config.SandboxConfig{}); got.AutoAllowBashWhenSandboxed {
|
||||
t.Fatal("auto-allow-bash must be off when ZERO_SANDBOX_AUTO_ALLOW_BASH is unset")
|
||||
}
|
||||
// Truthy: the env opt-in is wired through the central policy builder.
|
||||
t.Setenv(sandbox.EnvAutoAllowBash, "true")
|
||||
if got := applyConfiguredSandboxPolicy(sandbox.DefaultPolicy(), config.SandboxConfig{}); !got.AutoAllowBashWhenSandboxed {
|
||||
t.Fatal("ZERO_SANDBOX_AUTO_ALLOW_BASH=true must enable AutoAllowBashWhenSandboxed")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
)
|
||||
|
||||
// The central config->policy translation must carry scoped mode + the egress
|
||||
// allow/deny lists into the engine policy (this path feeds exec, the TUI app,
|
||||
// and the sandbox CLI commands).
|
||||
func TestApplyConfiguredSandboxPolicyWiresScopedNetwork(t *testing.T) {
|
||||
cfg := config.SandboxConfig{
|
||||
Network: "scoped",
|
||||
NetworkAllowedDomains: []string{"github.com", "pypi.org"},
|
||||
NetworkDeniedDomains: []string{"evil.example"},
|
||||
}
|
||||
policy := applyConfiguredSandboxPolicy(sandbox.DefaultPolicy(), cfg)
|
||||
if policy.Network != sandbox.NetworkScoped {
|
||||
t.Fatalf("Network = %q, want scoped", policy.Network)
|
||||
}
|
||||
if len(policy.AllowedDomains) != 2 || policy.AllowedDomains[0] != "github.com" {
|
||||
t.Fatalf("AllowedDomains = %#v", policy.AllowedDomains)
|
||||
}
|
||||
if len(policy.DeniedDomains) != 1 || policy.DeniedDomains[0] != "evil.example" {
|
||||
t.Fatalf("DeniedDomains = %#v", policy.DeniedDomains)
|
||||
}
|
||||
}
|
||||
+104
-2
@@ -3,11 +3,13 @@ package cli
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/oauth"
|
||||
"github.com/Gitlawb/zero/internal/providercatalog"
|
||||
"github.com/Gitlawb/zero/internal/providerhealth"
|
||||
"github.com/Gitlawb/zero/internal/provideronboarding"
|
||||
@@ -285,8 +287,108 @@ func setupRequired(resolved config.ResolvedConfig) bool {
|
||||
if !config.HasProviderProfile(resolved.Provider) {
|
||||
return true
|
||||
}
|
||||
_, missing := setupMissingCredentialEnv(resolved.Provider)
|
||||
return missing
|
||||
if _, missing := setupMissingCredentialEnv(resolved.Provider); !missing {
|
||||
return false
|
||||
}
|
||||
// A stored OAuth login (e.g. `zero auth login xai`) is a credential too, even
|
||||
// though the profile has no inline key / env var — so a logged-in provider
|
||||
// must not trigger onboarding.
|
||||
return !providerHasOAuthLogin(resolved.Provider, oauthLoggedInProviders())
|
||||
}
|
||||
|
||||
// providerHasOAuthLogin reports whether a stored OAuth login exists for the
|
||||
// provider, keyed by its profile name or catalog id.
|
||||
func providerHasOAuthLogin(profile config.ProviderProfile, oauthLogins map[string]bool) bool {
|
||||
for _, name := range []string{strings.TrimSpace(profile.Name), strings.TrimSpace(profile.CatalogID)} {
|
||||
if name != "" && oauthLogins[name] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// oauthLoggedInProviders returns the set of provider names that have a stored
|
||||
// OAuth token, so credential checks recognize an OAuth login (not just inline
|
||||
// keys / env vars). Errors degrade to an empty set (no logins).
|
||||
func oauthLoggedInProviders() map[string]bool {
|
||||
out := map[string]bool{}
|
||||
store, err := oauth.NewStore(oauth.StoreOptions{})
|
||||
if err != nil {
|
||||
return out
|
||||
}
|
||||
statuses, err := store.Status(oauth.KeyPrefixProvider)
|
||||
if err != nil {
|
||||
return out
|
||||
}
|
||||
for _, status := range statuses {
|
||||
if status.HasToken {
|
||||
out[strings.TrimPrefix(status.Key, oauth.KeyPrefixProvider)] = true
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// firstUsableProvider returns the saved provider best suited to run without
|
||||
// onboarding: the first usable (inline credential present, or no-auth/local)
|
||||
// non-local provider, else the first usable local one. It lets the CLI fall back
|
||||
// to an already-configured login when the active provider happens to lack a
|
||||
// credential, instead of re-running onboarding every launch.
|
||||
func firstUsableProvider(providers []config.ProviderProfile) (config.ProviderProfile, bool) {
|
||||
var localFallback config.ProviderProfile
|
||||
haveLocal := false
|
||||
for _, profile := range providers {
|
||||
if !config.HasProviderProfile(profile) {
|
||||
continue
|
||||
}
|
||||
// A profile whose catalog entry no longer resolves AND that has no explicit
|
||||
// BaseURL has no endpoint to talk to, so it cannot become a working
|
||||
// provider — skip it rather than picking a fallback that fails at first use.
|
||||
// (A stale CatalogID with a BaseURL still works as a custom endpoint.)
|
||||
if catalogID := strings.TrimSpace(profile.CatalogID); catalogID != "" && strings.TrimSpace(profile.BaseURL) == "" {
|
||||
if _, err := providercatalog.Require(catalogID); err != nil {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if _, missing := setupMissingCredentialEnv(profile); missing {
|
||||
continue
|
||||
}
|
||||
if providerProfileIsLocal(profile) {
|
||||
if !haveLocal {
|
||||
localFallback = profile
|
||||
haveLocal = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
return profile, true
|
||||
}
|
||||
if haveLocal {
|
||||
return localFallback, true
|
||||
}
|
||||
return config.ProviderProfile{}, false
|
||||
}
|
||||
|
||||
// providerProfileIsLocal reports whether a provider points at a local endpoint
|
||||
// (a loopback URL or a catalog entry flagged Local), so the fallback prefers a
|
||||
// remote keyed provider that is more likely reachable.
|
||||
func providerProfileIsLocal(profile config.ProviderProfile) bool {
|
||||
if catalogID := strings.TrimSpace(profile.CatalogID); catalogID != "" {
|
||||
if descriptor, err := providercatalog.Require(catalogID); err == nil && descriptor.Local {
|
||||
return true
|
||||
}
|
||||
}
|
||||
base := strings.TrimSpace(profile.BaseURL)
|
||||
if base == "" {
|
||||
return false
|
||||
}
|
||||
parsed, err := url.Parse(base)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
switch strings.ToLower(parsed.Hostname()) {
|
||||
case "localhost", "127.0.0.1", "::1":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func formatSetupComplete(result tui.SetupResult) string {
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
)
|
||||
|
||||
func TestFirstUsableProviderPrefersRemoteKeyed(t *testing.T) {
|
||||
providers := []config.ProviderProfile{
|
||||
{Name: "ollama", CatalogID: "ollama", BaseURL: "http://localhost:11434/v1", APIKey: "k"}, // usable but local
|
||||
{Name: "moonshot", CatalogID: "moonshot", BaseURL: "https://api.moonshot.ai/v1", APIKey: "k"}, // usable, remote
|
||||
{Name: "xai", CatalogID: "xai", APIKeyEnv: "XAI_API_KEY"}, // not usable (env only, no inline key)
|
||||
}
|
||||
got, ok := firstUsableProvider(providers)
|
||||
if !ok || got.Name != "moonshot" {
|
||||
t.Fatalf("want remote keyed provider (moonshot), got %q ok=%v", got.Name, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFirstUsableProviderFallsBackToLocal(t *testing.T) {
|
||||
providers := []config.ProviderProfile{
|
||||
{Name: "xai", CatalogID: "xai", APIKeyEnv: "XAI_API_KEY"}, // not usable
|
||||
{Name: "ollama", CatalogID: "ollama", BaseURL: "http://localhost:11434/v1", APIKey: "k"}, // local, usable
|
||||
}
|
||||
got, ok := firstUsableProvider(providers)
|
||||
if !ok || got.Name != "ollama" {
|
||||
t.Fatalf("want local usable fallback (ollama), got %q ok=%v", got.Name, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFirstUsableProviderNoneUsable(t *testing.T) {
|
||||
providers := []config.ProviderProfile{
|
||||
{Name: "xai", CatalogID: "xai", APIKeyEnv: "XAI_API_KEY"},
|
||||
{Name: "openai", CatalogID: "openai", APIKeyEnv: "OPENAI_API_KEY"},
|
||||
}
|
||||
if got, ok := firstUsableProvider(providers); ok {
|
||||
t.Fatalf("no provider has a credential, want ok=false, got %q", got.Name)
|
||||
}
|
||||
}
|
||||
|
||||
// A keyless local proxy (chatgpt-proxy, RequiresAuth=false) is usable without a
|
||||
// credential, so it can serve as a fallback rather than forcing onboarding.
|
||||
func TestFirstUsableProviderAcceptsKeylessLocalProxy(t *testing.T) {
|
||||
providers := []config.ProviderProfile{
|
||||
{Name: "xai", CatalogID: "xai", APIKeyEnv: "XAI_API_KEY"},
|
||||
{Name: "chatgpt", CatalogID: "chatgpt-proxy", BaseURL: "http://localhost:10531/v1"},
|
||||
}
|
||||
got, ok := firstUsableProvider(providers)
|
||||
if !ok || got.Name != "chatgpt" {
|
||||
t.Fatalf("want keyless local proxy fallback, got %q ok=%v", got.Name, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// A profile whose catalog entry no longer resolves and that carries no explicit
|
||||
// BaseURL has no endpoint, so it must be skipped rather than selected as a
|
||||
// fallback that fails at first use. A stale CatalogID with a BaseURL still works.
|
||||
func TestFirstUsableProviderSkipsUnresolvableCatalogWithoutBaseURL(t *testing.T) {
|
||||
providers := []config.ProviderProfile{
|
||||
{Name: "ghost", CatalogID: "no-such-catalog-entry", APIKey: "k"}, // unusable: no endpoint
|
||||
{Name: "custom", CatalogID: "no-such-catalog-entry", BaseURL: "https://api.custom.test/v1", APIKey: "k"},
|
||||
}
|
||||
got, ok := firstUsableProvider(providers)
|
||||
if !ok || got.Name != "custom" {
|
||||
t.Fatalf("want custom-endpoint fallback, got %q ok=%v", got.Name, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderProfileIsLocal(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
baseURL string
|
||||
want bool
|
||||
}{
|
||||
{"loopback name", "http://localhost:11434/v1", true},
|
||||
{"loopback v4", "http://127.0.0.1:8080", true},
|
||||
{"loopback v6", "http://[::1]:10531/v1", true},
|
||||
{"remote", "https://api.moonshot.ai/v1", false},
|
||||
{"contains-localhost-substring", "https://notlocalhost.com/v1", false},
|
||||
{"host-with-127-substring", "https://api127.0.0.1.example.com", false},
|
||||
{"empty", "", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := providerProfileIsLocal(config.ProviderProfile{BaseURL: tc.baseURL})
|
||||
if got != tc.want {
|
||||
t.Fatalf("providerProfileIsLocal(%q) = %v, want %v", tc.baseURL, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/specialist"
|
||||
)
|
||||
|
||||
// TestSpecialistSummariesIncludesBuiltins confirms the orchestrator delegation
|
||||
// prompt is fed the built-in specialists (the wiring behind auto-delegation).
|
||||
func TestSpecialistSummariesIncludesBuiltins(t *testing.T) {
|
||||
paths, err := specialist.DefaultPaths(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("DefaultPaths: %v", err)
|
||||
}
|
||||
descByName := map[string]string{}
|
||||
for _, info := range specialistSummaries(paths) {
|
||||
descByName[info.Name] = info.WhenToUse
|
||||
}
|
||||
for _, name := range []string{"worker", "explorer", "code-review"} {
|
||||
if descByName[name] == "" {
|
||||
t.Fatalf("specialistSummaries missing built-in %q (or empty description); got %v", name, descByName)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
{
|
||||
"policy": {
|
||||
"mode": "enforce",
|
||||
"network": "deny",
|
||||
"enforceWorkspace": true,
|
||||
"denyDestructiveShell": true,
|
||||
"allowPolicyOnlyRunner": true,
|
||||
"maxAutonomy": "high"
|
||||
},
|
||||
"backend": {
|
||||
"name": "policy-only",
|
||||
"available": false,
|
||||
"platform": "windows",
|
||||
"fallback": true,
|
||||
"commandWrapping": false,
|
||||
"nativeIsolation": false,
|
||||
"message": "policy-only fallback: Windows sandbox command runner is not available"
|
||||
},
|
||||
"plan": {
|
||||
"backend": {
|
||||
"name": "policy-only",
|
||||
"available": false,
|
||||
"platform": "windows",
|
||||
"fallback": true,
|
||||
"commandWrapping": false,
|
||||
"nativeIsolation": false,
|
||||
"message": "policy-only fallback: Windows sandbox command runner is not available"
|
||||
},
|
||||
"targetBackend": "windows-restricted-token",
|
||||
"workspaceRoot": "$WORKSPACE",
|
||||
"policy": {
|
||||
"mode": "enforce",
|
||||
"network": "deny",
|
||||
"enforceWorkspace": true,
|
||||
"denyDestructiveShell": true,
|
||||
"allowPolicyOnlyRunner": true,
|
||||
"maxAutonomy": "high"
|
||||
},
|
||||
"permissionProfile": {
|
||||
"fileSystem": {
|
||||
"kind": "restricted",
|
||||
"readRoots": [
|
||||
"$WORKSPACE"
|
||||
],
|
||||
"writeRoots": [
|
||||
{
|
||||
"root": "$WORKSPACE",
|
||||
"protectedMetadataNames": [
|
||||
".git",
|
||||
".zero",
|
||||
".agents"
|
||||
]
|
||||
}
|
||||
],
|
||||
"includePlatformRoots": true,
|
||||
"allowTemp": true
|
||||
},
|
||||
"network": {
|
||||
"mode": "deny"
|
||||
}
|
||||
},
|
||||
"commandWrapped": false,
|
||||
"enforcementLevel": "degraded",
|
||||
"downgradeReason": "policy-only fallback: Windows sandbox command runner is not available",
|
||||
"supportLevel": "policy-only",
|
||||
"requiresPlatformSandbox": true,
|
||||
"capabilities": [
|
||||
{
|
||||
"key": "policy_evaluation",
|
||||
"status": "preflight",
|
||||
"detail": "tool requests are evaluated against sandbox policy before execution"
|
||||
},
|
||||
{
|
||||
"key": "workspace_write_guard",
|
||||
"status": "preflight",
|
||||
"detail": "filesystem writes are checked against the workspace root before execution"
|
||||
},
|
||||
{
|
||||
"key": "network_guard",
|
||||
"status": "preflight",
|
||||
"detail": "network-capable tool requests are denied before execution"
|
||||
},
|
||||
{
|
||||
"key": "destructive_shell_guard",
|
||||
"status": "preflight",
|
||||
"detail": "destructive shell patterns are denied before execution"
|
||||
},
|
||||
{
|
||||
"key": "native_process_isolation",
|
||||
"status": "unavailable",
|
||||
"detail": "Windows sandbox command runner is not available"
|
||||
},
|
||||
{
|
||||
"key": "command_wrapping",
|
||||
"status": "unavailable",
|
||||
"detail": "shell commands are not wrapped by a native platform sandbox"
|
||||
}
|
||||
],
|
||||
"restrictions": [
|
||||
"filesystem writes must stay inside workspace",
|
||||
"network access denied unless a future adapter grants it explicitly",
|
||||
"destructive shell patterns denied before execution",
|
||||
"native process isolation unavailable on windows; policy engine still evaluates tool requests before execution",
|
||||
"shell commands are not wrapped by a native platform sandbox"
|
||||
],
|
||||
"warnings": [
|
||||
"Windows sandbox command runner is not available; using policy-only preflight checks",
|
||||
"shell commands are not wrapped by a native platform sandbox"
|
||||
]
|
||||
},
|
||||
"grantsPath": "$GRANTS"
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package config
|
||||
|
||||
import "strings"
|
||||
|
||||
// DefaultMCPServers returns the MCP servers Zero ships ENABLED by default so web
|
||||
// search and scraping 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 point firecrawl at a self-hosted instance, or
|
||||
// add an API-key header to lift the free-tier limit — or disable it entirely with
|
||||
// `zero mcp disable <name>` (which writes `"disabled": true`).
|
||||
//
|
||||
// Keyless Firecrawl routes requests through firecrawl.dev (1,000 free credits per
|
||||
// month, no account). Self-host Firecrawl (AGPL-3.0) for unlimited and private
|
||||
// use. Zero only calls it over the network, so Firecrawl's license never reaches
|
||||
// into Zero's own code.
|
||||
func DefaultMCPServers() map[string]MCPServerConfig {
|
||||
return map[string]MCPServerConfig{
|
||||
"firecrawl": {
|
||||
Type: "http",
|
||||
URL: "https://mcp.firecrawl.dev/v2/mcp",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
func IsDefaultMCPServer(name string) bool {
|
||||
_, ok := DefaultMCPServers()[strings.TrimSpace(name)]
|
||||
return ok
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestIsDefaultMCPServer(t *testing.T) {
|
||||
if !IsDefaultMCPServer("firecrawl") {
|
||||
t.Fatal("firecrawl should be a built-in default")
|
||||
}
|
||||
if IsDefaultMCPServer(" firecrawl ") == false {
|
||||
t.Fatal("IsDefaultMCPServer should trim whitespace")
|
||||
}
|
||||
if IsDefaultMCPServer("not-a-default") {
|
||||
t.Fatal("unknown server should not be a default")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveMCPSeedsEnabledFirecrawlDefault(t *testing.T) {
|
||||
cfg, err := ResolveMCP(ResolveOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveMCP: %v", err)
|
||||
}
|
||||
firecrawl, ok := cfg.Servers["firecrawl"]
|
||||
if !ok {
|
||||
t.Fatal("expected the firecrawl default to be seeded with no user config")
|
||||
}
|
||||
if firecrawl.Type != "http" || firecrawl.URL != "https://mcp.firecrawl.dev/v2/mcp" {
|
||||
t.Fatalf("unexpected firecrawl default: %#v", firecrawl)
|
||||
}
|
||||
if firecrawl.Disabled {
|
||||
t.Fatal("the firecrawl default must be enabled out of the box")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveMCPUserCanDisableDefault(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "config.json")
|
||||
if err := os.WriteFile(path, []byte(`{"mcp":{"servers":{"firecrawl":{"disabled":true}}}}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := ResolveMCP(ResolveOptions{UserConfigPath: path})
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveMCP: %v", err)
|
||||
}
|
||||
if !cfg.Servers["firecrawl"].Disabled {
|
||||
t.Fatal("a user must be able to disable the default by writing over it")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveMCPUserCanOverrideDefaultURLKeepingOtherFields(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "config.json")
|
||||
// Point firecrawl at a self-hosted instance; the default's Type must survive.
|
||||
if err := os.WriteFile(path, []byte(`{"mcp":{"servers":{"firecrawl":{"url":"http://localhost:3002/mcp"}}}}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := ResolveMCP(ResolveOptions{UserConfigPath: path})
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveMCP: %v", err)
|
||||
}
|
||||
firecrawl := cfg.Servers["firecrawl"]
|
||||
if firecrawl.URL != "http://localhost:3002/mcp" {
|
||||
t.Fatalf("user override of the default URL did not apply: %#v", firecrawl)
|
||||
}
|
||||
if firecrawl.Type != "http" {
|
||||
t.Fatalf("override should keep the default's other fields (type), got %#v", firecrawl)
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,16 @@ import (
|
||||
|
||||
const defaultMaxTurns = 30
|
||||
|
||||
const defaultDeferThreshold = 10
|
||||
// defaultDeferThreshold is the number of deferred-eligible (MCP) tools at which
|
||||
// Zero collapses their full JSON schemas into compact `tool_search` reminder
|
||||
// lines instead of advertising every schema on every turn. MCP tool schemas run
|
||||
// 300-600 tokens each, so eagerly shipping even a small server's toolset wastes
|
||||
// thousands of input tokens per message. Kept low so a typical single-server set
|
||||
// (Exa-style, a handful of tools) defers and stays cheap; the only cost is one
|
||||
// `tool_search` round-trip before a deferred tool's first use, after which it is
|
||||
// loaded for the rest of the run. Override per-config with tools.deferThreshold
|
||||
// (set 0 to always advertise every schema, e.g. for a model without tool_search).
|
||||
const defaultDeferThreshold = 3
|
||||
|
||||
func Resolve(options ResolveOptions) (ResolvedConfig, error) {
|
||||
cfg := FileConfig{
|
||||
@@ -58,6 +67,10 @@ func Resolve(options ResolveOptions) (ResolvedConfig, error) {
|
||||
return ResolvedConfig{}, fmt.Errorf("invalid tools.deferThreshold %d: must be >= 0", cfg.Tools.DeferThreshold)
|
||||
}
|
||||
|
||||
if cfg.Swarm.MaxTeamSize < 0 {
|
||||
return ResolvedConfig{}, fmt.Errorf("invalid swarm.maxTeamSize %d: must be >= 0 (0 uses the default)", cfg.Swarm.MaxTeamSize)
|
||||
}
|
||||
|
||||
if maxAutonomy := strings.TrimSpace(cfg.Sandbox.MaxAutonomy); maxAutonomy != "" {
|
||||
// Fail loud on an invalid ceiling. An unvalidated typo (e.g. "moderate")
|
||||
// would otherwise survive Resolve untouched, reach the sandbox bridge,
|
||||
@@ -70,8 +83,14 @@ func Resolve(options ResolveOptions) (ResolvedConfig, error) {
|
||||
if network := strings.TrimSpace(cfg.Sandbox.Network); network != "" {
|
||||
switch sandbox.NetworkMode(network) {
|
||||
case sandbox.NetworkAllow, sandbox.NetworkDeny:
|
||||
case sandbox.NetworkScoped:
|
||||
// Scoped with no allowlist would collapse to deny in the engine; reject
|
||||
// it loudly so the operator isn't surprised by a silent downgrade.
|
||||
if !hasNonEmptyDomain(cfg.Sandbox.NetworkAllowedDomains) {
|
||||
return ResolvedConfig{}, fmt.Errorf("invalid sandbox.network %q: scoped requires a non-empty sandbox.networkAllowedDomains", network)
|
||||
}
|
||||
default:
|
||||
return ResolvedConfig{}, fmt.Errorf("invalid sandbox.network %q: expected allow or deny", network)
|
||||
return ResolvedConfig{}, fmt.Errorf("invalid sandbox.network %q: expected allow, deny, or scoped", network)
|
||||
}
|
||||
}
|
||||
if mode := strings.TrimSpace(cfg.Notify.Mode); mode != "" {
|
||||
@@ -103,12 +122,16 @@ func Resolve(options ResolveOptions) (ResolvedConfig, error) {
|
||||
Sandbox: cfg.Sandbox,
|
||||
Notify: cfg.Notify,
|
||||
Tools: cfg.Tools,
|
||||
Swarm: cfg.Swarm,
|
||||
Preferences: cfg.Preferences,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func ResolveMCP(options ResolveOptions) (MCPConfig, error) {
|
||||
cfg := FileConfig{}
|
||||
// Seed Zero's built-in default MCP servers (e.g. keyless Firecrawl for free,
|
||||
// no-setup web search/scrape) BEFORE merging user/project config, so the user
|
||||
// can override any field or disable a default by writing over it.
|
||||
cfg := FileConfig{MCP: MCPConfig{Servers: DefaultMCPServers()}}
|
||||
|
||||
for _, path := range []string{options.UserConfigPath, options.ProjectConfigPath} {
|
||||
if path == "" {
|
||||
@@ -154,7 +177,18 @@ func mergeConfig(dst *FileConfig, src FileConfig) {
|
||||
if network := strings.TrimSpace(src.Sandbox.Network); network != "" {
|
||||
dst.Sandbox.Network = network
|
||||
}
|
||||
// Scoped egress allow/deny lists are a GRANT, layered here (global user
|
||||
// config) like AdditionalWriteRoots and likewise NOT taken from project
|
||||
// config (see mergeProjectConfig).
|
||||
dst.Sandbox.NetworkAllowedDomains = unionStrings(dst.Sandbox.NetworkAllowedDomains, src.Sandbox.NetworkAllowedDomains)
|
||||
dst.Sandbox.NetworkDeniedDomains = unionStrings(dst.Sandbox.NetworkDeniedDomains, src.Sandbox.NetworkDeniedDomains)
|
||||
dst.Sandbox.AdditionalWriteRoots = unionStrings(dst.Sandbox.AdditionalWriteRoots, src.Sandbox.AdditionalWriteRoots)
|
||||
if src.Sandbox.BlockUnixSockets {
|
||||
dst.Sandbox.BlockUnixSockets = true
|
||||
}
|
||||
if src.Sandbox.MonitorDenials {
|
||||
dst.Sandbox.MonitorDenials = true
|
||||
}
|
||||
if mode := strings.TrimSpace(src.Notify.Mode); mode != "" {
|
||||
dst.Notify.Mode = mode
|
||||
}
|
||||
@@ -165,6 +199,9 @@ func mergeConfig(dst *FileConfig, src FileConfig) {
|
||||
dst.Tools.DeferThreshold = src.Tools.DeferThreshold
|
||||
dst.Tools.deferThresholdSet = true
|
||||
}
|
||||
if src.Swarm.MaxTeamSize != 0 {
|
||||
dst.Swarm.MaxTeamSize = src.Swarm.MaxTeamSize
|
||||
}
|
||||
if src.Preferences.FavoriteModels != nil {
|
||||
dst.Preferences.FavoriteModels = normalizeFavoriteModels(src.Preferences.FavoriteModels)
|
||||
}
|
||||
@@ -185,16 +222,23 @@ func mergeProjectConfig(dst *FileConfig, src FileConfig) error {
|
||||
mergeProvider(dst, provider)
|
||||
}
|
||||
mergeMCPConfig(&dst.MCP, src.MCP)
|
||||
// Sandbox.AdditionalWriteRoots is intentionally NOT merged from project
|
||||
// config: a cloned repo's .zero/config.json must not be able to grant
|
||||
// itself write access outside the workspace. Global config and CLI flags
|
||||
// are the only config sources for write roots.
|
||||
// Sandbox.AdditionalWriteRoots and Sandbox.NetworkAllowedDomains/
|
||||
// NetworkDeniedDomains are intentionally NOT merged from project config: a
|
||||
// cloned repo's .zero/config.json must not be able to grant itself write
|
||||
// access outside the workspace, nor widen its own network egress allowlist.
|
||||
// Global config (and CLI flags) are the only sources for those grants.
|
||||
if maxAutonomy := strings.TrimSpace(src.Sandbox.MaxAutonomy); maxAutonomy != "" {
|
||||
dst.Sandbox.MaxAutonomy = maxAutonomy
|
||||
}
|
||||
if network := strings.TrimSpace(src.Sandbox.Network); network != "" {
|
||||
dst.Sandbox.Network = network
|
||||
}
|
||||
if src.Sandbox.BlockUnixSockets {
|
||||
dst.Sandbox.BlockUnixSockets = true
|
||||
}
|
||||
if src.Sandbox.MonitorDenials {
|
||||
dst.Sandbox.MonitorDenials = true
|
||||
}
|
||||
if mode := strings.TrimSpace(src.Notify.Mode); mode != "" {
|
||||
dst.Notify.Mode = mode
|
||||
}
|
||||
@@ -205,6 +249,9 @@ func mergeProjectConfig(dst *FileConfig, src FileConfig) error {
|
||||
dst.Tools.DeferThreshold = src.Tools.DeferThreshold
|
||||
dst.Tools.deferThresholdSet = true
|
||||
}
|
||||
if src.Swarm.MaxTeamSize != 0 {
|
||||
dst.Swarm.MaxTeamSize = src.Swarm.MaxTeamSize
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -509,6 +556,12 @@ func applyOverrides(cfg *FileConfig, overrides Overrides) {
|
||||
if maxAutonomy := strings.TrimSpace(overrides.Sandbox.MaxAutonomy); maxAutonomy != "" {
|
||||
cfg.Sandbox.MaxAutonomy = maxAutonomy
|
||||
}
|
||||
if overrides.Sandbox.BlockUnixSockets {
|
||||
cfg.Sandbox.BlockUnixSockets = true
|
||||
}
|
||||
if overrides.Sandbox.MonitorDenials {
|
||||
cfg.Sandbox.MonitorDenials = true
|
||||
}
|
||||
if mode := strings.TrimSpace(overrides.Notify.Mode); mode != "" {
|
||||
cfg.Notify.Mode = mode
|
||||
}
|
||||
@@ -590,6 +643,18 @@ func hasProviderFields(profile ProviderProfile) bool {
|
||||
// base, preserving order. Used for additive config keys like
|
||||
// sandbox.additionalWriteRoots where a later layer must not erase earlier
|
||||
// grants.
|
||||
// hasNonEmptyDomain reports whether domains contains at least one entry that is
|
||||
// non-empty after trimming — used to reject a scoped network policy that has no
|
||||
// usable allowlist.
|
||||
func hasNonEmptyDomain(domains []string) bool {
|
||||
for _, domain := range domains {
|
||||
if strings.TrimSpace(domain) != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func unionStrings(base []string, extra []string) []string {
|
||||
seen := make(map[string]struct{}, len(base))
|
||||
for _, value := range base {
|
||||
|
||||
@@ -1094,7 +1094,8 @@ func TestResolveProviderProfileExtendedJSONAliases(t *testing.T) {
|
||||
"auth_scheme": "raw",
|
||||
"auth_header_value": "header-secret",
|
||||
"custom_headers": {"X-Zero": "1"},
|
||||
"model_id": "custom-model"
|
||||
"model_id": "custom-model",
|
||||
"parse_think_tags": true
|
||||
}]
|
||||
}`)
|
||||
|
||||
@@ -1119,6 +1120,30 @@ func TestResolveProviderProfileExtendedJSONAliases(t *testing.T) {
|
||||
if profile.CustomHeaders["X-Zero"] != "1" {
|
||||
t.Fatalf("CustomHeaders = %#v, want X-Zero header", profile.CustomHeaders)
|
||||
}
|
||||
if profile.ParseThinkTags == nil || !*profile.ParseThinkTags {
|
||||
t.Fatalf("ParseThinkTags = %#v, want true", profile.ParseThinkTags)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveProviderProfileParseThinkTagsFalseAlias(t *testing.T) {
|
||||
path := writeConfig(t, `{
|
||||
"activeProvider": "custom",
|
||||
"providers": [{
|
||||
"name": "custom",
|
||||
"provider_kind": "openai-compatible",
|
||||
"base_url": "https://custom.example/v1",
|
||||
"model_id": "custom-model",
|
||||
"parse_think_tags": false
|
||||
}]
|
||||
}`)
|
||||
|
||||
resolved, err := Resolve(ResolveOptions{UserConfigPath: path, Env: map[string]string{}})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error = %v", err)
|
||||
}
|
||||
if resolved.Provider.ParseThinkTags == nil || *resolved.Provider.ParseThinkTags {
|
||||
t.Fatalf("ParseThinkTags = %#v, want explicit false", resolved.Provider.ParseThinkTags)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveRejectsUnknownProviderCatalogID(t *testing.T) {
|
||||
@@ -1244,6 +1269,26 @@ func TestResolveSandboxMaxAutonomyFromFile(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSandboxBlockUnixSocketsFromFile(t *testing.T) {
|
||||
path := writeConfig(t, `{
|
||||
"sandbox": {"blockUnixSockets": true},
|
||||
"providers": [{
|
||||
"name": "openai",
|
||||
"provider": "openai",
|
||||
"api_key": "sk-test",
|
||||
"model": "gpt-test"
|
||||
}]
|
||||
}`)
|
||||
|
||||
resolved, err := Resolve(ResolveOptions{ProjectConfigPath: path, Env: map[string]string{}})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error = %v", err)
|
||||
}
|
||||
if !resolved.Sandbox.BlockUnixSockets {
|
||||
t.Fatal("resolved.Sandbox.BlockUnixSockets = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSandboxMaxAutonomyPrecedence(t *testing.T) {
|
||||
userPath := writeConfig(t, `{
|
||||
"sandbox": {"maxAutonomy": "low"},
|
||||
@@ -1392,6 +1437,15 @@ func TestResolveDefaultsDeferThresholdWhenUnset(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The default must stay low so a typical single MCP server's toolset defers
|
||||
// instead of shipping every full schema each turn (the token-economy fix). This
|
||||
// guards against a regression silently bumping it back up.
|
||||
func TestDefaultDeferThresholdStaysLowForTokenEconomy(t *testing.T) {
|
||||
if defaultDeferThreshold <= 0 || defaultDeferThreshold > 4 {
|
||||
t.Fatalf("defaultDeferThreshold = %d, want a small positive value (<=4) so small MCP sets defer", defaultDeferThreshold)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDeferThresholdFromFile(t *testing.T) {
|
||||
path := writeConfig(t, `{
|
||||
"activeProvider": "p",
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestResolveAcceptsScopedNetworkWithAllowlist(t *testing.T) {
|
||||
userPath := writeConfig(t, `{
|
||||
"sandbox": {"network": "scoped", "networkAllowedDomains": ["github.com", "pypi.org"], "networkDeniedDomains": ["evil.example"]}
|
||||
}`)
|
||||
resolved, err := Resolve(ResolveOptions{UserConfigPath: userPath, Env: map[string]string{}})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error = %v", err)
|
||||
}
|
||||
if resolved.Sandbox.Network != "scoped" {
|
||||
t.Fatalf("Network = %q, want scoped", resolved.Sandbox.Network)
|
||||
}
|
||||
if len(resolved.Sandbox.NetworkAllowedDomains) != 2 || resolved.Sandbox.NetworkAllowedDomains[0] != "github.com" {
|
||||
t.Fatalf("NetworkAllowedDomains = %#v", resolved.Sandbox.NetworkAllowedDomains)
|
||||
}
|
||||
if len(resolved.Sandbox.NetworkDeniedDomains) != 1 {
|
||||
t.Fatalf("NetworkDeniedDomains = %#v", resolved.Sandbox.NetworkDeniedDomains)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveRejectsScopedNetworkWithoutAllowlist(t *testing.T) {
|
||||
userPath := writeConfig(t, `{"sandbox": {"network": "scoped"}}`)
|
||||
_, err := Resolve(ResolveOptions{UserConfigPath: userPath, Env: map[string]string{}})
|
||||
if err == nil || !strings.Contains(err.Error(), "scoped requires a non-empty sandbox.networkAllowedDomains") {
|
||||
t.Fatalf("expected scoped-without-allowlist error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveRejectsUnknownNetworkMode(t *testing.T) {
|
||||
userPath := writeConfig(t, `{"sandbox": {"network": "bogus"}}`)
|
||||
_, err := Resolve(ResolveOptions{UserConfigPath: userPath, Env: map[string]string{}})
|
||||
if err == nil || !strings.Contains(err.Error(), "expected allow, deny, or scoped") {
|
||||
t.Fatalf("expected unknown-mode error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Security: a project config (a cloned repo) must NOT be able to add to the
|
||||
// scoped egress allowlist — only the global user config can.
|
||||
func TestResolveProjectCannotWidenNetworkAllowlist(t *testing.T) {
|
||||
userPath := writeConfig(t, `{"sandbox": {"network": "scoped", "networkAllowedDomains": ["github.com"]}}`)
|
||||
projectPath := writeConfig(t, `{"sandbox": {"networkAllowedDomains": ["exfil.example"]}}`)
|
||||
resolved, err := Resolve(ResolveOptions{UserConfigPath: userPath, ProjectConfigPath: projectPath, Env: map[string]string{}})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error = %v", err)
|
||||
}
|
||||
for _, d := range resolved.Sandbox.NetworkAllowedDomains {
|
||||
if d == "exfil.example" {
|
||||
t.Fatalf("project config widened the egress allowlist: %#v", resolved.Sandbox.NetworkAllowedDomains)
|
||||
}
|
||||
}
|
||||
if len(resolved.Sandbox.NetworkAllowedDomains) != 1 || resolved.Sandbox.NetworkAllowedDomains[0] != "github.com" {
|
||||
t.Fatalf("allowlist = %#v, want [github.com] only", resolved.Sandbox.NetworkAllowedDomains)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package config
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestResolveSwarmMaxTeamSize(t *testing.T) {
|
||||
provider := `"activeProvider":"p","providers":[{"name":"p","provider":"openai","api_key":"sk","model_id":"m"}]`
|
||||
cases := []struct {
|
||||
name string
|
||||
json string
|
||||
wantSize int
|
||||
wantError bool
|
||||
}{
|
||||
{"parsed from config", `{` + provider + `,"swarm":{"maxTeamSize":3}}`, 3, false},
|
||||
{"unset stays 0 so the swarm applies its own default", `{` + provider + `}`, 0, false},
|
||||
{"negative is rejected", `{` + provider + `,"swarm":{"maxTeamSize":-1}}`, 0, true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
path := writeConfig(t, tc.json)
|
||||
resolved, err := Resolve(ResolveOptions{UserConfigPath: path, Env: map[string]string{}})
|
||||
if tc.wantError {
|
||||
if err == nil {
|
||||
t.Fatal("expected an error for an invalid maxTeamSize")
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve: %v", err)
|
||||
}
|
||||
if resolved.Swarm.MaxTeamSize != tc.wantSize {
|
||||
t.Fatalf("MaxTeamSize = %d, want %d", resolved.Swarm.MaxTeamSize, tc.wantSize)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,7 @@ type ProviderProfile struct {
|
||||
AuthHeaderValue string `json:"authHeaderValue,omitempty"`
|
||||
CustomHeaders map[string]string `json:"customHeaders,omitempty"`
|
||||
Model string `json:"model,omitempty"`
|
||||
ParseThinkTags *bool `json:"parseThinkTags,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
@@ -51,16 +52,29 @@ func HasProviderProfile(profile ProviderProfile) bool {
|
||||
strings.TrimSpace(profile.AuthHeaderValue) != "" ||
|
||||
profile.CustomHeaders != nil ||
|
||||
strings.TrimSpace(profile.Model) != "" ||
|
||||
profile.ParseThinkTags != nil ||
|
||||
strings.TrimSpace(profile.Description) != ""
|
||||
}
|
||||
|
||||
type SandboxConfig struct {
|
||||
MaxAutonomy string `json:"maxAutonomy,omitempty"`
|
||||
// Network controls whether shell commands classified as network-touching
|
||||
// (curl, git push, package installs, …) are allowed: "allow" or "deny".
|
||||
// Empty keeps the built-in default (deny). Without this knob the engine's
|
||||
// hard-coded NetworkDeny was unreachable from any config surface.
|
||||
// (curl, git push, package installs, …) are allowed: "allow", "deny", or
|
||||
// "scoped". Empty keeps the built-in default (deny). "scoped" permits egress
|
||||
// only to NetworkAllowedDomains (minus NetworkDeniedDomains) and REQUIRES a
|
||||
// non-empty NetworkAllowedDomains; it falls closed to "deny" on a backend that
|
||||
// cannot enforce scoped egress.
|
||||
Network string `json:"network,omitempty"`
|
||||
// NetworkAllowedDomains is the egress allowlist consulted only when Network is
|
||||
// "scoped": the sandboxed process may reach only these domains (minus
|
||||
// NetworkDeniedDomains) and nothing else. Ignored for "allow"/"deny". Like
|
||||
// AdditionalWriteRoots this is a GRANT, so it is honored from the GLOBAL user
|
||||
// config only — never project config — so a cloned repo cannot widen its own
|
||||
// network egress.
|
||||
NetworkAllowedDomains []string `json:"networkAllowedDomains,omitempty"`
|
||||
// NetworkDeniedDomains subtracts from NetworkAllowedDomains under "scoped".
|
||||
// Global-user-config only, for the same reason as NetworkAllowedDomains.
|
||||
NetworkDeniedDomains []string `json:"networkDeniedDomains,omitempty"`
|
||||
// AdditionalWriteRoots lists directories outside the workspace the sandbox
|
||||
// allows writes in. Each entry must be an existing directory; entries are
|
||||
// normalized (~-expanded, absolutized, symlink-resolved) at startup and an
|
||||
@@ -68,12 +82,9 @@ type SandboxConfig struct {
|
||||
// flags only — deliberately not project config, so a cloned repo cannot
|
||||
// grant itself write access. Session-only grants use /add-dir instead.
|
||||
AdditionalWriteRoots []string `json:"additionalWriteRoots,omitempty"`
|
||||
// BlockUnixSockets, when true, installs a seccomp filter on the Linux
|
||||
// (bubblewrap) backend that denies AF_UNIX socket creation in the sandboxed
|
||||
// command, closing the Unix-socket channel bubblewrap's filesystem/network
|
||||
// isolation leaves open. Off by default; degrades gracefully (runs without the
|
||||
// filter) when the zero-seccomp helper is not installed. Ignored on other
|
||||
// backends.
|
||||
// BlockUnixSockets, when true, asks the Linux sandbox helper to install a
|
||||
// best-effort seccomp filter that denies AF_UNIX socket creation inside the
|
||||
// sandboxed command. Off by default; ignored on non-Linux backends.
|
||||
BlockUnixSockets bool `json:"blockUnixSockets,omitempty"`
|
||||
// MonitorDenials, when true on macOS, tails the unified log for this run's
|
||||
// sandbox denials and appends them to a command's stderr so blocked operations
|
||||
@@ -96,6 +107,14 @@ type PreferencesConfig struct {
|
||||
FavoriteModels []string `json:"favoriteModels,omitempty"`
|
||||
}
|
||||
|
||||
// SwarmConfig tunes the multi-agent swarm. MaxTeamSize caps how many members run
|
||||
// concurrently per team; 0 uses the built-in default (8). Spawns past the cap
|
||||
// queue and launch as slots free, so lowering it bounds parallelism (and provider
|
||||
// load / rate-limit pressure) without dropping work.
|
||||
type SwarmConfig struct {
|
||||
MaxTeamSize int `json:"maxTeamSize,omitempty"`
|
||||
}
|
||||
|
||||
// ToolsOverride builds a ToolsConfig that explicitly overrides the deferred-tool
|
||||
// threshold (including to 0, which disables deferral). Use this for programmatic
|
||||
// Overrides — a bare ToolsConfig{DeferThreshold: 0} is indistinguishable from
|
||||
@@ -129,6 +148,7 @@ type FileConfig struct {
|
||||
Sandbox SandboxConfig `json:"sandbox,omitempty"`
|
||||
Notify NotifyConfig `json:"notify,omitempty"`
|
||||
Tools ToolsConfig `json:"tools,omitempty"`
|
||||
Swarm SwarmConfig `json:"swarm,omitempty"`
|
||||
Preferences PreferencesConfig `json:"preferences,omitempty"`
|
||||
}
|
||||
|
||||
@@ -160,6 +180,7 @@ type ResolvedConfig struct {
|
||||
Sandbox SandboxConfig
|
||||
Notify NotifyConfig
|
||||
Tools ToolsConfig
|
||||
Swarm SwarmConfig
|
||||
Preferences PreferencesConfig
|
||||
}
|
||||
|
||||
@@ -204,6 +225,7 @@ func (cfg *FileConfig) UnmarshalJSON(data []byte) error {
|
||||
Sandbox SandboxConfig `json:"sandbox"`
|
||||
Notify NotifyConfig `json:"notify"`
|
||||
Tools ToolsConfig `json:"tools"`
|
||||
Swarm SwarmConfig `json:"swarm"`
|
||||
Preferences PreferencesConfig `json:"preferences"`
|
||||
MCPServers map[string]MCPServerConfig `json:"mcpServers"`
|
||||
MCPServersSnake map[string]MCPServerConfig `json:"mcp_servers"`
|
||||
@@ -228,6 +250,7 @@ func (cfg *FileConfig) UnmarshalJSON(data []byte) error {
|
||||
cfg.Sandbox = raw.Sandbox
|
||||
cfg.Notify = raw.Notify
|
||||
cfg.Tools = raw.Tools
|
||||
cfg.Swarm = raw.Swarm
|
||||
cfg.Preferences = raw.Preferences
|
||||
if cfg.MCP.Servers == nil && (len(raw.MCPServers) > 0 || len(raw.MCPServersSnake) > 0) {
|
||||
cfg.MCP.Servers = map[string]MCPServerConfig{}
|
||||
@@ -304,6 +327,8 @@ func (profile *ProviderProfile) UnmarshalJSON(data []byte) error {
|
||||
CustomHeadersSnake map[string]string `json:"custom_headers"`
|
||||
Model string `json:"model"`
|
||||
ModelID string `json:"model_id"`
|
||||
ParseThinkTags *bool `json:"parseThinkTags"`
|
||||
ParseThinkTagsSnake *bool `json:"parse_think_tags"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
@@ -325,6 +350,7 @@ func (profile *ProviderProfile) UnmarshalJSON(data []byte) error {
|
||||
profile.AuthHeaderValue = firstNonEmpty(raw.AuthHeaderValue, raw.AuthHeaderValueSnake)
|
||||
profile.CustomHeaders = firstNonNilMap(raw.CustomHeaders, raw.CustomHeadersSnake)
|
||||
profile.Model = strings.TrimSpace(firstNonEmpty(raw.Model, raw.ModelID))
|
||||
profile.ParseThinkTags = firstNonNilBool(raw.ParseThinkTags, raw.ParseThinkTagsSnake)
|
||||
profile.Description = strings.TrimSpace(raw.Description)
|
||||
return nil
|
||||
}
|
||||
@@ -348,6 +374,15 @@ func firstNonNilMap(values ...map[string]string) map[string]string {
|
||||
return nil
|
||||
}
|
||||
|
||||
func firstNonNilBool(values ...*bool) *bool {
|
||||
for _, value := range values {
|
||||
if value != nil {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func copyStringMap(values map[string]string) map[string]string {
|
||||
if values == nil {
|
||||
return nil
|
||||
|
||||
@@ -72,6 +72,43 @@ func SetActiveProvider(path string, name string) (FileConfig, error) {
|
||||
return FileConfig{}, fmt.Errorf("provider %q not found", name)
|
||||
}
|
||||
|
||||
func SetProviderModel(path string, name string, model string) (FileConfig, error) {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return FileConfig{}, fmt.Errorf("config path is required")
|
||||
}
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return FileConfig{}, fmt.Errorf("provider name is required")
|
||||
}
|
||||
model = strings.TrimSpace(model)
|
||||
if model == "" {
|
||||
return FileConfig{}, fmt.Errorf("model is required")
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return FileConfig{}, fmt.Errorf("read config %s: %w", path, err)
|
||||
}
|
||||
|
||||
cfg := FileConfig{}
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
return FileConfig{}, fmt.Errorf("invalid config JSON %s: %w", path, err)
|
||||
}
|
||||
|
||||
for index := range cfg.Providers {
|
||||
if strings.EqualFold(cfg.Providers[index].Name, name) {
|
||||
cfg.Providers[index].Model = model
|
||||
if err := writeConfigFile(path, cfg); err != nil {
|
||||
return FileConfig{}, err
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
}
|
||||
|
||||
return FileConfig{}, fmt.Errorf("provider %q not found", name)
|
||||
}
|
||||
|
||||
func SetFavoriteModels(path string, models []string) (FileConfig, error) {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
|
||||
@@ -150,6 +150,72 @@ func TestSetActiveProviderTightensExistingConfigFilePermissions(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetProviderModelUpdatesConfiguredProvider(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "zero.json")
|
||||
writeConfigFixture(t, path, FileConfig{
|
||||
ActiveProvider: "openai",
|
||||
Providers: []ProviderProfile{
|
||||
{
|
||||
Name: "openai",
|
||||
ProviderKind: ProviderKindOpenAI,
|
||||
APIKey: "sk-test",
|
||||
Model: "gpt-4.1",
|
||||
},
|
||||
{
|
||||
Name: "anthropic",
|
||||
ProviderKind: ProviderKindAnthropic,
|
||||
Model: "claude-sonnet-4.5",
|
||||
},
|
||||
},
|
||||
}, 0o600)
|
||||
|
||||
cfg, err := SetProviderModel(path, " OpenAI ", " gpt-4.1-mini ")
|
||||
if err != nil {
|
||||
t.Fatalf("SetProviderModel() error = %v", err)
|
||||
}
|
||||
|
||||
if cfg.Providers[0].Model != "gpt-4.1-mini" {
|
||||
t.Fatalf("updated provider model = %q, want gpt-4.1-mini", cfg.Providers[0].Model)
|
||||
}
|
||||
if cfg.Providers[0].APIKey != "sk-test" {
|
||||
t.Fatalf("provider credential was not preserved: %#v", cfg.Providers[0])
|
||||
}
|
||||
if cfg.Providers[1].Model != "claude-sonnet-4.5" {
|
||||
t.Fatalf("unrelated provider changed: %#v", cfg.Providers[1])
|
||||
}
|
||||
|
||||
persisted := readConfigFixture(t, path)
|
||||
if persisted.Providers[0].Model != "gpt-4.1-mini" {
|
||||
t.Fatalf("persisted provider model = %q, want gpt-4.1-mini", persisted.Providers[0].Model)
|
||||
}
|
||||
if persisted.ActiveProvider != "openai" {
|
||||
t.Fatalf("active provider changed to %q", persisted.ActiveProvider)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetProviderModelRejectsUnknownProviderWithoutRewriting(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "zero.json")
|
||||
before := writeConfigFixture(t, path, FileConfig{
|
||||
ActiveProvider: "openai",
|
||||
Providers: []ProviderProfile{
|
||||
{Name: "openai", ProviderKind: ProviderKindOpenAI, Model: "gpt-4.1"},
|
||||
},
|
||||
}, 0o600)
|
||||
|
||||
_, err := SetProviderModel(path, "anthropic", "claude-sonnet-4.5")
|
||||
if err == nil {
|
||||
t.Fatal("SetProviderModel() error = nil, want error")
|
||||
}
|
||||
|
||||
after, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read config: %v", err)
|
||||
}
|
||||
if string(after) != string(before) {
|
||||
t.Fatalf("config was rewritten for unknown provider\nbefore: %s\nafter: %s", before, after)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpsertProviderTightensExistingConfigFilePermissions(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("Windows does not expose POSIX mode bits reliably")
|
||||
|
||||
@@ -261,7 +261,10 @@ func estimateTextTokens(value string) int {
|
||||
if value == "" {
|
||||
return 0
|
||||
}
|
||||
tokens := len(value) / 4
|
||||
// Share the agent's heuristic (non-whitespace bytes / 4) so the context
|
||||
// preview matches the scale the compaction loop uses and tracks the real
|
||||
// tokenizer far better than naive len/4. Non-empty text reports at least 1.
|
||||
tokens := agent.ApproxTextTokens(value)
|
||||
if tokens == 0 {
|
||||
return 1
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"math/rand"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Backoff bounds. Mirrors reference-daemon-code-agent-js/worker-manager.js:
|
||||
// backoffMs(consecutive) = jitter(min(1000 * 2**consecutive, 300000)).
|
||||
const (
|
||||
backoffUnit = time.Second
|
||||
backoffCap = 5 * time.Minute
|
||||
)
|
||||
|
||||
// backoffBase returns the un-jittered exponential delay for a 1-based restart
|
||||
// count: min(1s * 2^attempt, 5m). attempt<=0 yields the unit delay.
|
||||
func backoffBase(attempt int) time.Duration {
|
||||
if attempt <= 0 {
|
||||
return backoffUnit
|
||||
}
|
||||
// Shift carefully: cap before overflow. 2^attempt * 1s exceeds the cap well
|
||||
// before any int64 overflow (cap is 5m), so clamp once it would exceed it.
|
||||
d := backoffUnit
|
||||
for i := 0; i < attempt; i++ {
|
||||
d *= 2
|
||||
if d >= backoffCap {
|
||||
return backoffCap
|
||||
}
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// BackoffWithJitter returns backoffBase(attempt) scaled by a uniform random
|
||||
// factor in [0.5, 1.5), matching worker-manager.js jitter(base) =
|
||||
// base*(0.5 + random()). The jitter de-synchronizes simultaneous restarts so a
|
||||
// fleet of workers does not thunder back at the same instant.
|
||||
func BackoffWithJitter(attempt int) time.Duration {
|
||||
base := backoffBase(attempt)
|
||||
factor := 0.5 + rand.Float64() //nolint:gosec // jitter, not security-sensitive
|
||||
return time.Duration(float64(base) * factor)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestBackoffBaseExponentialAndCap(t *testing.T) {
|
||||
cases := []struct {
|
||||
attempt int
|
||||
want time.Duration
|
||||
}{
|
||||
{0, 1 * time.Second},
|
||||
{1, 2 * time.Second},
|
||||
{2, 4 * time.Second},
|
||||
{3, 8 * time.Second},
|
||||
{20, backoffCap}, // far past the cap
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := backoffBase(tc.attempt); got != tc.want {
|
||||
t.Fatalf("backoffBase(%d) = %s, want %s", tc.attempt, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackoffWithJitterBounds(t *testing.T) {
|
||||
for attempt := 1; attempt <= 6; attempt++ {
|
||||
base := backoffBase(attempt)
|
||||
lo := time.Duration(float64(base) * 0.5)
|
||||
hi := time.Duration(float64(base) * 1.5)
|
||||
for i := 0; i < 200; i++ {
|
||||
got := BackoffWithJitter(attempt)
|
||||
if got < lo || got >= hi {
|
||||
t.Fatalf("BackoffWithJitter(%d) = %s, want in [%s,%s)", attempt, got, lo, hi)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Client is a control-socket client used by the `zero daemon run|attach|status|
|
||||
// stop` subcommands. It performs the version handshake on Dial.
|
||||
type Client struct {
|
||||
conn net.Conn
|
||||
}
|
||||
|
||||
// Dial connects to the daemon control socket and completes the handshake.
|
||||
func Dial(socketPath string) (*Client, error) {
|
||||
conn, err := net.DialTimeout("unix", socketPath, 5*time.Second)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c := &Client{conn: conn}
|
||||
if err := c.handshake(); err != nil {
|
||||
_ = conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// NewClientConn completes the control handshake on an already-connected
|
||||
// transport (e.g. a TLS connection the remote bridge has authenticated) and
|
||||
// returns a Client ready for Run/Attach/Status. It takes ownership of conn and
|
||||
// closes it on handshake failure.
|
||||
func NewClientConn(conn net.Conn) (*Client, error) {
|
||||
c := &Client{conn: conn}
|
||||
if err := c.handshake(); err != nil {
|
||||
_ = conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (c *Client) handshake() error {
|
||||
if err := WriteControl(c.conn, Ctrl{Type: CtrlHello, Version: ProtoVersion}); err != nil {
|
||||
return err
|
||||
}
|
||||
reply, err := ReadControl(c.conn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if reply.Type != CtrlHelloOK {
|
||||
return fmt.Errorf("daemon: handshake rejected: %s", reply.Message)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close closes the connection.
|
||||
func (c *Client) Close() error { return c.conn.Close() }
|
||||
|
||||
// Run starts/routes a session and streams its stream-json output lines to onLine
|
||||
// until the session ends. It returns the session's terminal error, if any.
|
||||
func (c *Client) Run(session, cwd, prompt string, args []string, onLine func(string)) error {
|
||||
if err := WriteControl(c.conn, Ctrl{Type: CtrlRun, Session: session, Cwd: cwd, Prompt: prompt, Args: args}); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.streamLoop(onLine)
|
||||
}
|
||||
|
||||
// Attach streams a running session's output (buffered history + live) to onLine.
|
||||
func (c *Client) Attach(session string, onLine func(string)) error {
|
||||
if err := WriteControl(c.conn, Ctrl{Type: CtrlAttach, Session: session}); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.streamLoop(onLine)
|
||||
}
|
||||
|
||||
func (c *Client) streamLoop(onLine func(string)) error {
|
||||
for {
|
||||
msg, err := ReadControl(c.conn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch msg.Type {
|
||||
case CtrlAck:
|
||||
// dispatch acknowledged; keep reading
|
||||
case CtrlData:
|
||||
if onLine != nil {
|
||||
onLine(msg.Line)
|
||||
}
|
||||
case CtrlEnd:
|
||||
return nil
|
||||
case CtrlError:
|
||||
return fmt.Errorf("daemon: %s", msg.Message)
|
||||
default:
|
||||
return fmt.Errorf("daemon: unexpected message %q", msg.Type)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Status requests the daemon/worker/session status report.
|
||||
func (c *Client) Status() (*StatusReport, error) {
|
||||
if err := WriteControl(c.conn, Ctrl{Type: CtrlStatus}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
msg, err := ReadControl(c.conn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if msg.Type == CtrlError {
|
||||
return nil, fmt.Errorf("daemon: %s", msg.Message)
|
||||
}
|
||||
if msg.Type != CtrlStatusResult || msg.Status == nil {
|
||||
return nil, fmt.Errorf("daemon: unexpected status reply %q", msg.Type)
|
||||
}
|
||||
return msg.Status, nil
|
||||
}
|
||||
|
||||
// Shutdown asks the daemon to drain and stop.
|
||||
func (c *Client) Shutdown() error {
|
||||
if err := WriteControl(c.conn, Ctrl{Type: CtrlShutdown}); err != nil {
|
||||
return err
|
||||
}
|
||||
msg, err := ReadControl(c.conn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if msg.Type == CtrlError {
|
||||
return fmt.Errorf("daemon: %s", msg.Message)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/background"
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
)
|
||||
|
||||
// reentrancyMarkers are the env vars zero sets on a command it has ALREADY
|
||||
// wrapped in a sandbox. The daemon MUST strip them from every worker's
|
||||
// environment: a worker that inherited them would trip zero's re-entrancy guard
|
||||
// (sandbox.IsAlreadySandboxed needs BOTH) and run with a pass-through plan —
|
||||
// i.e. UNSANDBOXED — silently defeating the sandbox. Stripping them forces each
|
||||
// worker to (re)establish its own sandbox. This is the daemon's #1 security
|
||||
// invariant: it must NOT bypass the sandbox.
|
||||
var reentrancyMarkers = []string{sandbox.EnvSandboxed, sandbox.EnvSandboxBackend}
|
||||
|
||||
// scrubWorkerEnv returns env with the sandbox re-entrancy markers removed. Every
|
||||
// other variable is preserved — including the ZERO_SANDBOX_* policy config the
|
||||
// worker reads to rebuild its own policy — so the sandbox policy is PROPAGATED,
|
||||
// not bypassed. The caller's slice is never mutated.
|
||||
func scrubWorkerEnv(env []string) []string {
|
||||
out := make([]string, 0, len(env))
|
||||
for _, kv := range env {
|
||||
drop := false
|
||||
for _, marker := range reentrancyMarkers {
|
||||
if strings.HasPrefix(kv, marker+"=") {
|
||||
drop = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !drop {
|
||||
out = append(out, kv)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// execWorker is a WorkerHandle backed by a `zero exec` child process speaking
|
||||
// stream-json on stdout.
|
||||
type execWorker struct {
|
||||
cmd *exec.Cmd
|
||||
stdout io.ReadCloser
|
||||
lines *scannerLines
|
||||
pid int
|
||||
}
|
||||
|
||||
func (w *execWorker) Stdout() Lines { return w.lines }
|
||||
func (w *execWorker) Pid() int { return w.pid }
|
||||
|
||||
func (w *execWorker) Wait() (int, error) {
|
||||
err := w.cmd.Wait()
|
||||
if err == nil {
|
||||
return 0, nil
|
||||
}
|
||||
var exitErr *exec.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
return exitErr.ExitCode(), nil
|
||||
}
|
||||
return -1, err
|
||||
}
|
||||
|
||||
func (w *execWorker) Kill() error {
|
||||
if w.cmd.Process == nil {
|
||||
return nil
|
||||
}
|
||||
// background.TerminateProcess is the cross-platform terminate (kills the
|
||||
// process group on POSIX, taskkill /T on Windows).
|
||||
return background.TerminateProcess(w.cmd.Process.Pid)
|
||||
}
|
||||
|
||||
// scannerLines adapts a bufio.Scanner to the Lines interface.
|
||||
type scannerLines struct {
|
||||
sc *bufio.Scanner
|
||||
}
|
||||
|
||||
func (s *scannerLines) Next() (string, bool, error) {
|
||||
if s.sc.Scan() {
|
||||
return s.sc.Text(), true, nil
|
||||
}
|
||||
if err := s.sc.Err(); err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
return "", false, nil
|
||||
}
|
||||
|
||||
// ExecLauncherConfig configures the production worker launcher.
|
||||
type ExecLauncherConfig struct {
|
||||
// Executable is the zero binary to spawn (defaults to os.Executable()).
|
||||
Executable string
|
||||
// BaseArgs are prepended before the per-session args (defaults to the headless
|
||||
// stream-json exec invocation `exec --output-format stream-json`).
|
||||
BaseArgs []string
|
||||
// Env is the base environment for workers (defaults to os.Environ()). The
|
||||
// re-entrancy markers are always scrubbed from it.
|
||||
Env []string
|
||||
}
|
||||
|
||||
// NewExecLauncher builds a Launcher that spawns `zero exec` workers which speak
|
||||
// stream-json on stdout. Each worker:
|
||||
// - runs with the re-entrancy markers SCRUBBED from its env (so it establishes
|
||||
// its own sandbox; the daemon never bypasses the sandbox), while the rest of
|
||||
// the policy config/env is propagated;
|
||||
// - is placed in its own process group (background.ConfigureChildProcessGroup)
|
||||
// so Kill / drain can terminate it and any children cleanly;
|
||||
// - has its per-session working directory (spec.Cwd) and flags (spec.Args)
|
||||
// applied. The pool does the os/exec itself and uses internal/background only
|
||||
// for process-group setup + cross-platform terminate.
|
||||
func NewExecLauncher(cfg ExecLauncherConfig) (Launcher, error) {
|
||||
exe := cfg.Executable
|
||||
if exe == "" {
|
||||
resolved, err := os.Executable()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("daemon: locate zero executable: %w", err)
|
||||
}
|
||||
exe = resolved
|
||||
}
|
||||
baseArgs := cfg.BaseArgs
|
||||
if baseArgs == nil {
|
||||
baseArgs = []string{"exec", "--output-format", "stream-json"}
|
||||
}
|
||||
baseEnv := cfg.Env
|
||||
if baseEnv == nil {
|
||||
baseEnv = os.Environ()
|
||||
}
|
||||
env := scrubWorkerEnv(baseEnv)
|
||||
|
||||
return func(ctx context.Context, spec WorkerSpec) (WorkerHandle, error) {
|
||||
args := make([]string, 0, len(baseArgs)+len(spec.Args))
|
||||
args = append(args, baseArgs...)
|
||||
args = append(args, spec.Args...)
|
||||
|
||||
cmd := exec.CommandContext(ctx, exe, args...)
|
||||
cmd.Dir = spec.Cwd
|
||||
cmd.Env = env
|
||||
cmd.Stdin = nil // the prompt is passed via flags; no streamed stdin input
|
||||
background.ConfigureChildProcessGroup(cmd)
|
||||
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("daemon: worker stdout pipe: %w", err)
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
return nil, fmt.Errorf("daemon: start worker: %w", err)
|
||||
}
|
||||
|
||||
scanner := bufio.NewScanner(stdout)
|
||||
// Allow long stream-json lines up to the control-frame cap.
|
||||
scanner.Buffer(make([]byte, 0, 64*1024), MaxFrameSize)
|
||||
return &execWorker{
|
||||
cmd: cmd,
|
||||
stdout: stdout,
|
||||
lines: &scannerLines{sc: scanner},
|
||||
pid: cmd.Process.Pid,
|
||||
}, nil
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"context"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
)
|
||||
|
||||
func TestScrubWorkerEnvRemovesReentrancyMarkers(t *testing.T) {
|
||||
in := []string{
|
||||
"PATH=/bin",
|
||||
sandbox.EnvSandboxed + "=1",
|
||||
"ZERO_SANDBOX_AUTO_ALLOW_BASH=1", // policy config — must be PRESERVED
|
||||
sandbox.EnvSandboxBackend + "=bubblewrap",
|
||||
"HOME=/home/u",
|
||||
}
|
||||
out := scrubWorkerEnv(in)
|
||||
|
||||
for _, kv := range out {
|
||||
if strings.HasPrefix(kv, sandbox.EnvSandboxed+"=") || strings.HasPrefix(kv, sandbox.EnvSandboxBackend+"=") {
|
||||
t.Fatalf("re-entrancy marker not scrubbed: %q", kv)
|
||||
}
|
||||
}
|
||||
want := map[string]bool{"PATH=/bin": true, "ZERO_SANDBOX_AUTO_ALLOW_BASH=1": true, "HOME=/home/u": true}
|
||||
got := map[string]bool{}
|
||||
for _, kv := range out {
|
||||
got[kv] = true
|
||||
}
|
||||
for w := range want {
|
||||
if !got[w] {
|
||||
t.Fatalf("scrub dropped a non-marker var: %q", w)
|
||||
}
|
||||
}
|
||||
if len(out) != len(want) {
|
||||
t.Fatalf("scrub result = %v, want exactly %d preserved vars", out, len(want))
|
||||
}
|
||||
if len(in) != 5 {
|
||||
t.Fatal("scrubWorkerEnv must not mutate the caller's slice")
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecLauncherScrubsEnvEndToEnd proves the spawned WORKER PROCESS actually
|
||||
// receives an environment without the re-entrancy markers — the security
|
||||
// invariant. It uses /bin/sh to dump the worker env, so it is POSIX-only.
|
||||
func TestExecLauncherScrubsEnvEndToEnd(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("uses the POSIX env dumper /bin/sh -c env")
|
||||
}
|
||||
launcher, err := NewExecLauncher(ExecLauncherConfig{
|
||||
Executable: "/bin/sh",
|
||||
BaseArgs: []string{"-c", "env"},
|
||||
Env: append([]string{"KEEP=yes"},
|
||||
sandbox.EnvSandboxed+"=1",
|
||||
sandbox.EnvSandboxBackend+"=bubblewrap"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewExecLauncher: %v", err)
|
||||
}
|
||||
h, err := launcher(context.Background(), WorkerSpec{})
|
||||
if err != nil {
|
||||
t.Fatalf("launch: %v", err)
|
||||
}
|
||||
lines := h.Stdout()
|
||||
var got []string
|
||||
for {
|
||||
line, ok, lerr := lines.Next()
|
||||
if lerr != nil {
|
||||
t.Fatalf("read worker stdout: %v", lerr)
|
||||
}
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
got = append(got, line)
|
||||
}
|
||||
if _, err := h.Wait(); err != nil {
|
||||
t.Fatalf("worker wait: %v", err)
|
||||
}
|
||||
|
||||
keepSeen := false
|
||||
for _, l := range got {
|
||||
if strings.HasPrefix(l, sandbox.EnvSandboxed+"=") || strings.HasPrefix(l, sandbox.EnvSandboxBackend+"=") {
|
||||
t.Fatalf("worker process still has a re-entrancy marker in its env: %q", l)
|
||||
}
|
||||
if l == "KEEP=yes" {
|
||||
keepSeen = true
|
||||
}
|
||||
}
|
||||
if !keepSeen {
|
||||
t.Fatalf("worker env lost a non-marker var (KEEP=yes); got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewExecLauncherDefaults(t *testing.T) {
|
||||
// With no Executable it resolves the test binary's own path (os.Executable),
|
||||
// and constructs without error.
|
||||
if _, err := NewExecLauncher(ExecLauncherConfig{}); err != nil {
|
||||
t.Fatalf("NewExecLauncher with defaults: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Single-instance lock. Mirrors reference-daemon-code-agent-js/supervisor.js's
|
||||
// lock file: a PID file created with O_EXCL. A second start fails; a STALE lock
|
||||
// left by a dead daemon (the recorded PID is no longer alive) is reclaimed so the
|
||||
// daemon recovers from an unclean shutdown without manual cleanup.
|
||||
|
||||
// ErrAlreadyRunning is returned when a live daemon already holds the lock.
|
||||
var ErrAlreadyRunning = errors.New("daemon: another instance is already running")
|
||||
|
||||
// fileLock is an acquired single-instance lock.
|
||||
type fileLock struct {
|
||||
path string
|
||||
}
|
||||
|
||||
// processAlive reports whether pid is a live process. Implemented per-platform
|
||||
// (lock_posix.go / lock_windows.go). It is a package var so tests can stub it.
|
||||
var processAlive = osProcessAlive
|
||||
|
||||
// acquireLock takes the single-instance lock at path, reclaiming a stale lock
|
||||
// whose recorded PID is dead. isAlive overrides the liveness check (tests pass a
|
||||
// stub); nil uses the real processAlive.
|
||||
func acquireLock(path string, isAlive func(pid int) bool) (*fileLock, error) {
|
||||
if isAlive == nil {
|
||||
isAlive = processAlive
|
||||
}
|
||||
// At most two passes: create, or detect-stale-then-retry once.
|
||||
for attempt := 0; attempt < 2; attempt++ {
|
||||
f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
|
||||
if err == nil {
|
||||
// A failed PID write would leave a malformed lock file that another
|
||||
// process reads as stale (unparsable PID) and wrongly reclaims, breaking
|
||||
// the single-instance guarantee — so on write failure, remove it and fail.
|
||||
if _, werr := fmt.Fprintf(f, "%d\n", os.Getpid()); werr != nil {
|
||||
_ = f.Close()
|
||||
_ = os.Remove(path)
|
||||
return nil, werr
|
||||
}
|
||||
if cerr := f.Close(); cerr != nil {
|
||||
_ = os.Remove(path)
|
||||
return nil, cerr
|
||||
}
|
||||
return &fileLock{path: path}, nil
|
||||
}
|
||||
if !errors.Is(err, fs.ErrExist) {
|
||||
return nil, err
|
||||
}
|
||||
pid, perr := readPidFile(path)
|
||||
if perr == nil && pid > 0 && isAlive(pid) {
|
||||
return nil, fmt.Errorf("%w (pid %d)", ErrAlreadyRunning, pid)
|
||||
}
|
||||
// Stale lock (dead PID or unreadable) — remove and retry once.
|
||||
if rmErr := os.Remove(path); rmErr != nil && !errors.Is(rmErr, fs.ErrNotExist) {
|
||||
return nil, rmErr
|
||||
}
|
||||
}
|
||||
return nil, ErrAlreadyRunning
|
||||
}
|
||||
|
||||
// release removes the lock file. Safe to call once.
|
||||
func (l *fileLock) release() error {
|
||||
if l == nil || l.path == "" {
|
||||
return nil
|
||||
}
|
||||
if err := os.Remove(l.path); err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// readPidFile reads and parses the PID recorded in a lock file.
|
||||
func readPidFile(path string) (int, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return strconv.Atoi(strings.TrimSpace(string(data)))
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
//go:build !windows
|
||||
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// osProcessAlive reports whether pid is a live process on POSIX. Signal 0 does
|
||||
// not deliver a signal; it only checks existence/permission. ESRCH means no such
|
||||
// process (dead); EPERM means it exists but we may not signal it (alive).
|
||||
func osProcessAlive(pid int) bool {
|
||||
if pid <= 0 {
|
||||
return false
|
||||
}
|
||||
err := syscall.Kill(pid, 0)
|
||||
if err == nil {
|
||||
return true
|
||||
}
|
||||
return errors.Is(err, syscall.EPERM)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLockSingleInstance(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "daemon.lock")
|
||||
alive := func(int) bool { return true }
|
||||
|
||||
l1, err := acquireLock(path, alive)
|
||||
if err != nil {
|
||||
t.Fatalf("first acquireLock: %v", err)
|
||||
}
|
||||
// Second acquire while the holder is "alive" must be refused.
|
||||
if _, err := acquireLock(path, alive); !errors.Is(err, ErrAlreadyRunning) {
|
||||
t.Fatalf("second acquireLock err = %v, want ErrAlreadyRunning", err)
|
||||
}
|
||||
// After release, a new acquire succeeds.
|
||||
if err := l1.release(); err != nil {
|
||||
t.Fatalf("release: %v", err)
|
||||
}
|
||||
l2, err := acquireLock(path, alive)
|
||||
if err != nil {
|
||||
t.Fatalf("acquire after release: %v", err)
|
||||
}
|
||||
_ = l2.release()
|
||||
}
|
||||
|
||||
func TestLockStaleRecovery(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "daemon.lock")
|
||||
// Simulate a stale lock from a crashed daemon: a PID file whose process is
|
||||
// dead.
|
||||
if err := os.WriteFile(path, []byte("4242\n"), 0o600); err != nil {
|
||||
t.Fatalf("write stale lock: %v", err)
|
||||
}
|
||||
dead := func(int) bool { return false }
|
||||
l, err := acquireLock(path, dead)
|
||||
if err != nil {
|
||||
t.Fatalf("stale-lock recovery failed: %v", err)
|
||||
}
|
||||
// The lock now records OUR pid, not the stale one.
|
||||
data, _ := os.ReadFile(path)
|
||||
if strings.TrimSpace(string(data)) != strconv.Itoa(os.Getpid()) {
|
||||
t.Fatalf("reclaimed lock pid = %q, want %d", strings.TrimSpace(string(data)), os.Getpid())
|
||||
}
|
||||
_ = l.release()
|
||||
}
|
||||
|
||||
func TestLockReleaseRemovesFile(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "daemon.lock")
|
||||
l, err := acquireLock(path, func(int) bool { return true })
|
||||
if err != nil {
|
||||
t.Fatalf("acquireLock: %v", err)
|
||||
}
|
||||
if err := l.release(); err != nil {
|
||||
t.Fatalf("release: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("lock file still present after release: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessAliveSelfAndDead(t *testing.T) {
|
||||
if !osProcessAlive(os.Getpid()) {
|
||||
t.Fatal("osProcessAlive(self) = false, want true")
|
||||
}
|
||||
// PID 0 / negative are never live.
|
||||
if osProcessAlive(0) || osProcessAlive(-1) {
|
||||
t.Fatal("osProcessAlive must reject non-positive pids")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
//go:build windows
|
||||
|
||||
package daemon
|
||||
|
||||
import "golang.org/x/sys/windows"
|
||||
|
||||
// osProcessAlive reports whether pid is a live process on Windows. It opens the
|
||||
// process for limited query access; a successful open with a non-exited status
|
||||
// means the PID is live. x/sys is already a module dependency.
|
||||
func osProcessAlive(pid int) bool {
|
||||
if pid <= 0 {
|
||||
return false
|
||||
}
|
||||
handle, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, uint32(pid))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer windows.CloseHandle(handle)
|
||||
var code uint32
|
||||
if err := windows.GetExitCodeProcess(handle, &code); err != nil {
|
||||
// Could not query; treat the open as proof of life (conservative: do not
|
||||
// reclaim a lock we are unsure about).
|
||||
return true
|
||||
}
|
||||
const stillActive = 259 // STILL_ACTIVE
|
||||
return code == stillActive
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// DefaultDir returns the per-user directory for the daemon's runtime files:
|
||||
// $XDG_RUNTIME_DIR/zero when set (a tmpfs owned by the user on Linux), otherwise
|
||||
// ~/.zero. Mirrors supervisor.js / config.js choosing a per-user runtime dir.
|
||||
func DefaultDir() (string, error) {
|
||||
if rt := strings.TrimSpace(os.Getenv("XDG_RUNTIME_DIR")); rt != "" {
|
||||
return filepath.Join(rt, "zero"), nil
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(home, ".zero"), nil
|
||||
}
|
||||
|
||||
// Paths bundles the daemon's socket, lock, and status file paths.
|
||||
type Paths struct {
|
||||
Socket string
|
||||
Lock string
|
||||
Status string
|
||||
}
|
||||
|
||||
// DefaultPaths returns the daemon control-socket, lock, and status file paths
|
||||
// under DefaultDir.
|
||||
func DefaultPaths() (Paths, error) {
|
||||
dir, err := DefaultDir()
|
||||
if err != nil {
|
||||
return Paths{}, err
|
||||
}
|
||||
return Paths{
|
||||
Socket: filepath.Join(dir, "daemon.sock"),
|
||||
Lock: filepath.Join(dir, "daemon.lock"),
|
||||
Status: filepath.Join(dir, "daemon.status"),
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Exit codes a worker may use to signal restart policy. Mirrors
|
||||
// reference-daemon-code-agent-js/worker-manager.js EXIT_TEMPFAIL/EXIT_PERMANENT.
|
||||
const (
|
||||
// ExitTempfail asks for a retry after a fixed cool-off (transient failure).
|
||||
ExitTempfail = 75
|
||||
// ExitPermanent asks the daemon to STOP retrying this work (fatal).
|
||||
ExitPermanent = 76
|
||||
)
|
||||
|
||||
// defaultPoolSize, defaultMaxAttempts and defaultKillTimeout are used when a
|
||||
// PoolOptions field is left zero.
|
||||
const (
|
||||
defaultPoolSize = 4
|
||||
defaultMaxAttempts = 5
|
||||
defaultKillTimeout = 5 * time.Second
|
||||
)
|
||||
|
||||
// ErrPoolDraining is returned by Run once the pool is shutting down.
|
||||
var ErrPoolDraining = errors.New("daemon: pool is draining")
|
||||
|
||||
// ErrPermanent is returned when a worker exits ExitPermanent or the attempt cap
|
||||
// is exhausted; the request must not be retried.
|
||||
var ErrPermanent = errors.New("daemon: worker failed permanently")
|
||||
|
||||
// WorkerSpec describes the worker to launch for one session attempt.
|
||||
type WorkerSpec struct {
|
||||
Session string
|
||||
Cwd string
|
||||
// Args are the per-session exec flags (e.g. --prompt ...), parsed by the CLI
|
||||
// and appended after `exec` by the production launcher.
|
||||
Args []string
|
||||
}
|
||||
|
||||
// WorkerHandle is a launched worker process the pool supervises. The production
|
||||
// launcher wraps an exec.Cmd running `zero exec -i/-o stream-json`; tests inject
|
||||
// a fake. Stdout yields the worker's stream-json event lines.
|
||||
type WorkerHandle interface {
|
||||
Stdout() Lines
|
||||
// Wait blocks until the worker exits and returns its process exit code.
|
||||
Wait() (int, error)
|
||||
// Kill force-terminates the worker (used on drain timeout).
|
||||
Kill() error
|
||||
Pid() int
|
||||
}
|
||||
|
||||
// Lines is a stream of stream-json output lines from a worker. Next returns the
|
||||
// next line and io-EOF-style ok=false when the stream ends.
|
||||
type Lines interface {
|
||||
Next() (line string, ok bool, err error)
|
||||
}
|
||||
|
||||
// Launcher spawns a worker for a single attempt. It must apply the sandbox/env
|
||||
// policy (the production launcher scrubs the re-entrancy markers so the worker
|
||||
// re-establishes its own sandbox — see newExecLauncher).
|
||||
type Launcher func(ctx context.Context, spec WorkerSpec) (WorkerHandle, error)
|
||||
|
||||
// PoolOptions configures a Pool. Zero fields take documented defaults.
|
||||
type PoolOptions struct {
|
||||
// Size is the max number of concurrent workers (lease slots). Sessions beyond
|
||||
// this queue until a slot frees.
|
||||
Size int
|
||||
// Launcher spawns a worker. Required.
|
||||
Launcher Launcher
|
||||
// MaxAttempts caps the total attempts per request (first + retries). When
|
||||
// exhausted, Run returns ErrPermanent (mirrors capping consecutive restarts).
|
||||
MaxAttempts int
|
||||
// KillTimeout bounds graceful termination before SIGKILL-equivalent on drain.
|
||||
KillTimeout time.Duration
|
||||
// Backoff returns the delay before retry attempt n (1-based crash count).
|
||||
// Defaults to BackoffWithJitter; tests inject a deterministic function.
|
||||
Backoff func(attempt int) time.Duration
|
||||
// TempfailDelay is the cool-off after an ExitTempfail exit. Defaults to 30s.
|
||||
TempfailDelay time.Duration
|
||||
// Log, when set, receives one line per lifecycle event.
|
||||
Log func(string)
|
||||
}
|
||||
|
||||
// Pool is a bounded, self-healing worker pool. A crashed worker never takes down
|
||||
// the pool: Run retries on a fresh worker with backoff up to MaxAttempts, and an
|
||||
// ExitPermanent worker stops retries. Drain stops new work and kills stragglers.
|
||||
type Pool struct {
|
||||
opts PoolOptions
|
||||
slots chan struct{}
|
||||
|
||||
mu sync.Mutex
|
||||
draining bool
|
||||
active map[int]WorkerHandle // pid -> handle, for drain/kill + status
|
||||
nextID int
|
||||
|
||||
drainOnce sync.Once
|
||||
drained chan struct{}
|
||||
}
|
||||
|
||||
// workerStat tracks one in-flight request's restart count (local to Run).
|
||||
type workerStat struct {
|
||||
id int
|
||||
restarts int
|
||||
}
|
||||
|
||||
// NewPool builds a pool from opts, filling defaults.
|
||||
func NewPool(opts PoolOptions) (*Pool, error) {
|
||||
if opts.Launcher == nil {
|
||||
return nil, errors.New("daemon: pool requires a Launcher")
|
||||
}
|
||||
if opts.Size <= 0 {
|
||||
opts.Size = defaultPoolSize
|
||||
}
|
||||
if opts.MaxAttempts <= 0 {
|
||||
opts.MaxAttempts = defaultMaxAttempts
|
||||
}
|
||||
if opts.KillTimeout <= 0 {
|
||||
opts.KillTimeout = defaultKillTimeout
|
||||
}
|
||||
if opts.TempfailDelay <= 0 {
|
||||
opts.TempfailDelay = 30 * time.Second
|
||||
}
|
||||
if opts.Backoff == nil {
|
||||
opts.Backoff = BackoffWithJitter
|
||||
}
|
||||
return &Pool{
|
||||
opts: opts,
|
||||
slots: make(chan struct{}, opts.Size),
|
||||
active: map[int]WorkerHandle{},
|
||||
drained: make(chan struct{}),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Size returns the configured max concurrency.
|
||||
func (p *Pool) Size() int { return p.opts.Size }
|
||||
|
||||
func (p *Pool) logf(format string, args ...any) {
|
||||
if p.opts.Log != nil {
|
||||
p.opts.Log(fmt.Sprintf(format, args...))
|
||||
}
|
||||
}
|
||||
|
||||
// acquire takes a lease slot, queuing until one frees or ctx is cancelled / the
|
||||
// pool drains.
|
||||
func (p *Pool) acquire(ctx context.Context) error {
|
||||
p.mu.Lock()
|
||||
draining := p.draining
|
||||
p.mu.Unlock()
|
||||
if draining {
|
||||
return ErrPoolDraining
|
||||
}
|
||||
select {
|
||||
case p.slots <- struct{}{}:
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-p.drained:
|
||||
return ErrPoolDraining
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Pool) release() { <-p.slots }
|
||||
|
||||
// QueueDepth reports how many lease slots are currently in use.
|
||||
func (p *Pool) QueueDepth() int { return len(p.slots) }
|
||||
|
||||
// Sink receives a worker's stream-json output lines for one request.
|
||||
type Sink interface {
|
||||
Line(line string)
|
||||
}
|
||||
|
||||
// Run leases a worker slot and dispatches spec to a worker, streaming its
|
||||
// stream-json lines to sink. It is at-least-once with bounded retries: a worker
|
||||
// that crashes (non-zero, non-permanent) is retried on a fresh worker after a
|
||||
// backoff; ExitPermanent or exhausting MaxAttempts returns ErrPermanent. Run
|
||||
// queues when all slots are busy. The returned int is the final worker exit code.
|
||||
func (p *Pool) Run(ctx context.Context, spec WorkerSpec, sink Sink) (int, error) {
|
||||
if err := p.acquire(ctx); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer p.release()
|
||||
|
||||
// Lease hook: a sink that tracks queue->running state is notified the instant
|
||||
// a worker slot is leased (mirrors lease.js acquiring a worker for a session).
|
||||
if s, ok := sink.(interface{ Started() }); ok {
|
||||
s.Started()
|
||||
}
|
||||
|
||||
stat := p.newStat()
|
||||
var lastErr error
|
||||
for attempt := 1; attempt <= p.opts.MaxAttempts; attempt++ {
|
||||
if p.isDraining() {
|
||||
return 0, ErrPoolDraining
|
||||
}
|
||||
code, err := p.runOnce(ctx, stat.id, spec, sink)
|
||||
switch {
|
||||
case err != nil:
|
||||
lastErr = err
|
||||
if ctx.Err() != nil {
|
||||
return 0, ctx.Err()
|
||||
}
|
||||
p.logf("worker %d launch/run error: %v", stat.id, err)
|
||||
case code == 0:
|
||||
return 0, nil // clean success
|
||||
case code == ExitPermanent:
|
||||
p.logf("worker %d exited permanently (code=%d) — not retrying", stat.id, code)
|
||||
return code, ErrPermanent
|
||||
case code == ExitTempfail:
|
||||
lastErr = fmt.Errorf("worker %d tempfail (code=%d)", stat.id, code)
|
||||
p.logf("worker %d tempfail — retry after %s", stat.id, p.opts.TempfailDelay)
|
||||
if !p.sleep(ctx, p.opts.TempfailDelay) {
|
||||
return 0, ctx.Err()
|
||||
}
|
||||
continue // tempfail retries do not count against the crash backoff
|
||||
default:
|
||||
lastErr = fmt.Errorf("worker %d exited code=%d", stat.id, code)
|
||||
}
|
||||
|
||||
if attempt == p.opts.MaxAttempts {
|
||||
break
|
||||
}
|
||||
stat.restarts++
|
||||
delay := p.opts.Backoff(stat.restarts)
|
||||
p.logf("worker %d restart %d after backoff %s", stat.id, stat.restarts, delay)
|
||||
if !p.sleep(ctx, delay) {
|
||||
return 0, ctx.Err()
|
||||
}
|
||||
}
|
||||
if lastErr == nil {
|
||||
lastErr = ErrPermanent
|
||||
}
|
||||
return ExitPermanent, fmt.Errorf("%w: %v", ErrPermanent, lastErr)
|
||||
}
|
||||
|
||||
// runOnce launches a single worker, pumps its output to sink, and returns its
|
||||
// exit code. The worker handle is tracked so Drain can kill it.
|
||||
func (p *Pool) runOnce(ctx context.Context, id int, spec WorkerSpec, sink Sink) (int, error) {
|
||||
handle, err := p.opts.Launcher(ctx, spec)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
p.track(handle)
|
||||
defer p.untrack(handle)
|
||||
|
||||
// Pump stdout lines until the stream ends.
|
||||
lines := handle.Stdout()
|
||||
for {
|
||||
line, ok, lerr := lines.Next()
|
||||
if lerr != nil {
|
||||
break
|
||||
}
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
if sink != nil {
|
||||
sink.Line(line)
|
||||
}
|
||||
}
|
||||
return handle.Wait()
|
||||
}
|
||||
|
||||
func (p *Pool) newStat() *workerStat {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.nextID++
|
||||
return &workerStat{id: p.nextID}
|
||||
}
|
||||
|
||||
func (p *Pool) track(h WorkerHandle) {
|
||||
p.mu.Lock()
|
||||
p.active[h.Pid()] = h
|
||||
p.mu.Unlock()
|
||||
}
|
||||
|
||||
func (p *Pool) untrack(h WorkerHandle) {
|
||||
p.mu.Lock()
|
||||
delete(p.active, h.Pid())
|
||||
p.mu.Unlock()
|
||||
}
|
||||
|
||||
func (p *Pool) isDraining() bool {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
return p.draining
|
||||
}
|
||||
|
||||
// sleep waits d, returning false if ctx is cancelled first. A non-positive d
|
||||
// returns immediately.
|
||||
func (p *Pool) sleep(ctx context.Context, d time.Duration) bool {
|
||||
if d <= 0 {
|
||||
return ctx.Err() == nil
|
||||
}
|
||||
timer := time.NewTimer(d)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-timer.C:
|
||||
return true
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Drain stops accepting new work, gives in-flight workers a grace window
|
||||
// (KillTimeout) to finish on their own, then force-kills any straggler. It
|
||||
// returns as soon as the pool is idle (graceful) or the window elapses. Safe to
|
||||
// call once; subsequent calls are no-ops.
|
||||
func (p *Pool) Drain() {
|
||||
p.drainOnce.Do(func() {
|
||||
p.mu.Lock()
|
||||
p.draining = true
|
||||
p.mu.Unlock()
|
||||
close(p.drained)
|
||||
|
||||
// Grace window: poll until idle or the deadline.
|
||||
deadline := time.Now().Add(p.opts.KillTimeout)
|
||||
for time.Now().Before(deadline) {
|
||||
p.mu.Lock()
|
||||
n := len(p.active)
|
||||
p.mu.Unlock()
|
||||
if n == 0 {
|
||||
return // all workers drained gracefully
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
|
||||
// Timed out: force-kill remaining workers.
|
||||
p.mu.Lock()
|
||||
handles := make([]WorkerHandle, 0, len(p.active))
|
||||
for _, h := range p.active {
|
||||
handles = append(handles, h)
|
||||
}
|
||||
p.mu.Unlock()
|
||||
for _, h := range handles {
|
||||
p.logf("drain: killing straggler worker pid=%d", h.Pid())
|
||||
_ = h.Kill()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// WorkerStats returns a snapshot of the currently-active (busy) workers, bounded
|
||||
// by the pool size. The on-demand pool keeps no idle workers, so an empty result
|
||||
// means the pool is idle.
|
||||
func (p *Pool) WorkerStats() []WorkerStatus {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
out := make([]WorkerStatus, 0, len(p.active))
|
||||
for pid := range p.active {
|
||||
out = append(out, WorkerStatus{PID: pid, State: "busy"})
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// --- test doubles ---------------------------------------------------------
|
||||
|
||||
type fakeLines struct {
|
||||
lines []string
|
||||
i int
|
||||
}
|
||||
|
||||
func (f *fakeLines) Next() (string, bool, error) {
|
||||
if f.i >= len(f.lines) {
|
||||
return "", false, nil
|
||||
}
|
||||
l := f.lines[f.i]
|
||||
f.i++
|
||||
return l, true, nil
|
||||
}
|
||||
|
||||
type fakeWorker struct {
|
||||
pid int
|
||||
out []string
|
||||
exitCode int
|
||||
killed int32
|
||||
waitCh chan struct{} // when non-nil, Wait blocks until closed (drain tests)
|
||||
}
|
||||
|
||||
func (w *fakeWorker) Stdout() Lines { return &fakeLines{lines: w.out} }
|
||||
func (w *fakeWorker) Wait() (int, error) {
|
||||
if w.waitCh != nil {
|
||||
<-w.waitCh
|
||||
}
|
||||
return w.exitCode, nil
|
||||
}
|
||||
func (w *fakeWorker) Kill() error {
|
||||
atomic.StoreInt32(&w.killed, 1)
|
||||
if w.waitCh != nil {
|
||||
select {
|
||||
case <-w.waitCh:
|
||||
default:
|
||||
close(w.waitCh)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (w *fakeWorker) Pid() int { return w.pid }
|
||||
|
||||
// seqLauncher hands out the given workers in order, then errors.
|
||||
func seqLauncher(workers ...*fakeWorker) (Launcher, *int32) {
|
||||
var calls int32
|
||||
l := func(_ context.Context, _ WorkerSpec) (WorkerHandle, error) {
|
||||
n := atomic.AddInt32(&calls, 1)
|
||||
idx := int(n) - 1
|
||||
if idx >= len(workers) {
|
||||
return nil, errors.New("no more fake workers")
|
||||
}
|
||||
return workers[idx], nil
|
||||
}
|
||||
return l, &calls
|
||||
}
|
||||
|
||||
type collectSink struct {
|
||||
mu sync.Mutex
|
||||
lines []string
|
||||
}
|
||||
|
||||
func (c *collectSink) Line(line string) {
|
||||
c.mu.Lock()
|
||||
c.lines = append(c.lines, line)
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
// --- tests ----------------------------------------------------------------
|
||||
|
||||
func TestPoolRunStreamsAndSucceeds(t *testing.T) {
|
||||
launcher, calls := seqLauncher(&fakeWorker{pid: 1, out: []string{"a", "b"}, exitCode: 0})
|
||||
pool, err := NewPool(PoolOptions{Size: 2, Launcher: launcher})
|
||||
if err != nil {
|
||||
t.Fatalf("NewPool: %v", err)
|
||||
}
|
||||
sink := &collectSink{}
|
||||
code, err := pool.Run(context.Background(), WorkerSpec{Session: "s"}, sink)
|
||||
if err != nil || code != 0 {
|
||||
t.Fatalf("Run = (%d,%v), want (0,nil)", code, err)
|
||||
}
|
||||
if len(sink.lines) != 2 || sink.lines[0] != "a" || sink.lines[1] != "b" {
|
||||
t.Fatalf("sink lines = %v, want [a b]", sink.lines)
|
||||
}
|
||||
if *calls != 1 {
|
||||
t.Fatalf("launcher calls = %d, want 1", *calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPoolRunRetriesWithBackoff(t *testing.T) {
|
||||
launcher, calls := seqLauncher(
|
||||
&fakeWorker{pid: 1, exitCode: 1}, // crash
|
||||
&fakeWorker{pid: 2, exitCode: 1}, // crash
|
||||
&fakeWorker{pid: 3, exitCode: 0, out: []string{"ok"}}, // success
|
||||
)
|
||||
var backoffArgs []int
|
||||
pool, _ := NewPool(PoolOptions{
|
||||
Size: 1, Launcher: launcher, MaxAttempts: 5,
|
||||
Backoff: func(attempt int) time.Duration { backoffArgs = append(backoffArgs, attempt); return 0 },
|
||||
})
|
||||
sink := &collectSink{}
|
||||
code, err := pool.Run(context.Background(), WorkerSpec{Session: "s"}, sink)
|
||||
if err != nil || code != 0 {
|
||||
t.Fatalf("Run = (%d,%v), want (0,nil)", code, err)
|
||||
}
|
||||
if *calls != 3 {
|
||||
t.Fatalf("launcher calls = %d, want 3 (2 crashes + success)", *calls)
|
||||
}
|
||||
// Backoff invoked between attempts with an increasing 1-based restart count.
|
||||
if len(backoffArgs) != 2 || backoffArgs[0] != 1 || backoffArgs[1] != 2 {
|
||||
t.Fatalf("backoff attempts = %v, want [1 2]", backoffArgs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPoolRunPermanentStopsImmediately(t *testing.T) {
|
||||
launcher, calls := seqLauncher(&fakeWorker{pid: 1, exitCode: ExitPermanent})
|
||||
pool, _ := NewPool(PoolOptions{Size: 1, Launcher: launcher, Backoff: func(int) time.Duration { return 0 }})
|
||||
code, err := pool.Run(context.Background(), WorkerSpec{Session: "s"}, &collectSink{})
|
||||
if !errors.Is(err, ErrPermanent) {
|
||||
t.Fatalf("Run err = %v, want ErrPermanent", err)
|
||||
}
|
||||
if code != ExitPermanent {
|
||||
t.Fatalf("Run code = %d, want %d", code, ExitPermanent)
|
||||
}
|
||||
if *calls != 1 {
|
||||
t.Fatalf("permanent exit must not retry; launcher calls = %d, want 1", *calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPoolRunCapExhausted(t *testing.T) {
|
||||
launcher, calls := seqLauncher(
|
||||
&fakeWorker{pid: 1, exitCode: 1},
|
||||
&fakeWorker{pid: 2, exitCode: 1},
|
||||
&fakeWorker{pid: 3, exitCode: 1},
|
||||
)
|
||||
pool, _ := NewPool(PoolOptions{Size: 1, Launcher: launcher, MaxAttempts: 3, Backoff: func(int) time.Duration { return 0 }})
|
||||
_, err := pool.Run(context.Background(), WorkerSpec{Session: "s"}, &collectSink{})
|
||||
if !errors.Is(err, ErrPermanent) {
|
||||
t.Fatalf("Run err = %v, want ErrPermanent after cap", err)
|
||||
}
|
||||
if *calls != 3 {
|
||||
t.Fatalf("launcher calls = %d, want 3 (== MaxAttempts)", *calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPoolRunTempfailRetries(t *testing.T) {
|
||||
launcher, calls := seqLauncher(
|
||||
&fakeWorker{pid: 1, exitCode: ExitTempfail},
|
||||
&fakeWorker{pid: 2, exitCode: 0, out: []string{"ok"}},
|
||||
)
|
||||
pool, _ := NewPool(PoolOptions{Size: 1, Launcher: launcher, TempfailDelay: time.Millisecond})
|
||||
code, err := pool.Run(context.Background(), WorkerSpec{Session: "s"}, &collectSink{})
|
||||
if err != nil || code != 0 {
|
||||
t.Fatalf("Run = (%d,%v), want (0,nil)", code, err)
|
||||
}
|
||||
if *calls != 2 {
|
||||
t.Fatalf("launcher calls = %d, want 2 (tempfail + success)", *calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPoolQueuesWhenFull(t *testing.T) {
|
||||
block := make(chan struct{})
|
||||
first := &fakeWorker{pid: 1, waitCh: block}
|
||||
second := &fakeWorker{pid: 2, exitCode: 0}
|
||||
launcher, _ := seqLauncher(first, second)
|
||||
pool, _ := NewPool(PoolOptions{Size: 1, Launcher: launcher})
|
||||
|
||||
started := make(chan struct{})
|
||||
go func() {
|
||||
close(started)
|
||||
_, _ = pool.Run(context.Background(), WorkerSpec{Session: "a"}, &collectSink{})
|
||||
}()
|
||||
<-started
|
||||
// Wait until the first run holds the only slot.
|
||||
waitFor(t, func() bool { return pool.QueueDepth() == 1 })
|
||||
|
||||
secondDone := make(chan struct{})
|
||||
go func() {
|
||||
_, _ = pool.Run(context.Background(), WorkerSpec{Session: "b"}, &collectSink{})
|
||||
close(secondDone)
|
||||
}()
|
||||
// The second run must NOT complete while the slot is held.
|
||||
select {
|
||||
case <-secondDone:
|
||||
t.Fatal("second run completed while the only slot was busy (no queueing)")
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
}
|
||||
// Free the slot; the queued run now proceeds.
|
||||
close(block)
|
||||
select {
|
||||
case <-secondDone:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("queued run did not proceed after slot freed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPoolDrainKillsStraggler(t *testing.T) {
|
||||
block := make(chan struct{})
|
||||
straggler := &fakeWorker{pid: 1, waitCh: block}
|
||||
launcher, _ := seqLauncher(straggler)
|
||||
pool, _ := NewPool(PoolOptions{Size: 1, Launcher: launcher, MaxAttempts: 1, KillTimeout: 10 * time.Millisecond})
|
||||
|
||||
runDone := make(chan struct{})
|
||||
go func() {
|
||||
_, _ = pool.Run(context.Background(), WorkerSpec{Session: "a"}, &collectSink{})
|
||||
close(runDone)
|
||||
}()
|
||||
waitFor(t, func() bool { return pool.QueueDepth() == 1 })
|
||||
|
||||
pool.Drain() // KillTimeout elapses, straggler is force-killed
|
||||
if atomic.LoadInt32(&straggler.killed) != 1 {
|
||||
t.Fatal("Drain must kill a straggler still running after KillTimeout")
|
||||
}
|
||||
select {
|
||||
case <-runDone:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("run did not finish after drain kill")
|
||||
}
|
||||
|
||||
// A new Run after drain is refused.
|
||||
if _, err := pool.Run(context.Background(), WorkerSpec{Session: "b"}, &collectSink{}); !errors.Is(err, ErrPoolDraining) {
|
||||
t.Fatalf("Run after drain err = %v, want ErrPoolDraining", err)
|
||||
}
|
||||
}
|
||||
|
||||
func waitFor(t *testing.T, cond func() bool) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if cond() {
|
||||
return
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
t.Fatal("condition not met within timeout")
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
// Package daemon implements zero's long-running daemon/server mode: a local
|
||||
// control server that supervises a pool of headless `zero exec` worker processes
|
||||
// and routes multiple agent sessions to them over an owner-only local socket.
|
||||
//
|
||||
// It reuses zero's existing building blocks rather than inventing new ones:
|
||||
// - internal/background : child-process group setup + cross-platform terminate.
|
||||
// - internal/streamjson : the line-based agent event protocol on worker stdio.
|
||||
// - internal/cli (exec) : a worker is a `zero exec -i/-o stream-json` process.
|
||||
//
|
||||
// The control plane (this file) is a small framed codec mirrored from the
|
||||
// reference daemon's protocol.js: a 4-byte big-endian length prefix, a 1-byte
|
||||
// frame kind, then a JSON control payload, with a 1 MiB frame cap and a
|
||||
// version-negotiation handshake. The agent event stream itself stays stream-json.
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
)
|
||||
|
||||
// Wire constants. Mirrors reference-daemon-code-agent-js/protocol.js
|
||||
// (FRAME_DATA/FRAME_CTRL, HEADER_SIZE, MAX_FRAME_SIZE, PROTO_VERSION).
|
||||
const (
|
||||
// ProtoVersion is the control-protocol version advertised in the handshake.
|
||||
ProtoVersion = 1
|
||||
// MaxFrameSize caps a single control frame payload at 1 MiB. A larger
|
||||
// advertised length is rejected before any allocation (fail closed).
|
||||
MaxFrameSize = 1 << 20
|
||||
// frameHeaderSize is 4 bytes big-endian length + 1 byte kind.
|
||||
frameHeaderSize = 5
|
||||
)
|
||||
|
||||
// FrameKind tags a frame's payload. Mirrors protocol.js FRAME_DATA/FRAME_CTRL.
|
||||
type FrameKind uint8
|
||||
|
||||
const (
|
||||
// KindData carries an opaque payload (e.g. a stream-json line). Reserved for
|
||||
// future use; the control plane uses KindCtrl.
|
||||
KindData FrameKind = 0
|
||||
// KindCtrl carries a JSON-encoded control message (see Ctrl).
|
||||
KindCtrl FrameKind = 1
|
||||
)
|
||||
|
||||
// ErrFrameTooLarge is returned when a frame's advertised payload length exceeds
|
||||
// MaxFrameSize. The reader rejects it without allocating the buffer.
|
||||
var ErrFrameTooLarge = errors.New("daemon: control frame exceeds size cap")
|
||||
|
||||
// ErrUnknownFrameKind is returned for a frame whose kind byte is not recognized.
|
||||
var ErrUnknownFrameKind = errors.New("daemon: unknown control frame kind")
|
||||
|
||||
// WriteFrame writes a single framed message: [uint32BE len][uint8 kind][payload].
|
||||
// It rejects an over-cap payload before writing anything so a buggy/hostile caller
|
||||
// cannot emit a frame a conforming reader would refuse.
|
||||
func WriteFrame(w io.Writer, kind FrameKind, payload []byte) error {
|
||||
if len(payload) > MaxFrameSize {
|
||||
return fmt.Errorf("%w (%d > %d)", ErrFrameTooLarge, len(payload), MaxFrameSize)
|
||||
}
|
||||
header := make([]byte, frameHeaderSize)
|
||||
binary.BigEndian.PutUint32(header[:4], uint32(len(payload)))
|
||||
header[4] = byte(kind)
|
||||
if _, err := w.Write(header); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(payload) == 0 {
|
||||
return nil
|
||||
}
|
||||
_, err := w.Write(payload)
|
||||
return err
|
||||
}
|
||||
|
||||
// ReadFrame reads a single framed message. It validates the advertised length
|
||||
// against MaxFrameSize BEFORE allocating, so an oversize or hostile frame is
|
||||
// rejected (ErrFrameTooLarge) rather than triggering a huge allocation. An
|
||||
// unrecognized kind byte yields ErrUnknownFrameKind after the payload is drained
|
||||
// so the stream stays in sync.
|
||||
func ReadFrame(r io.Reader) (FrameKind, []byte, error) {
|
||||
header := make([]byte, frameHeaderSize)
|
||||
if _, err := io.ReadFull(r, header); err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
length := binary.BigEndian.Uint32(header[:4])
|
||||
if length > MaxFrameSize {
|
||||
return 0, nil, fmt.Errorf("%w (%d > %d)", ErrFrameTooLarge, length, MaxFrameSize)
|
||||
}
|
||||
kind := FrameKind(header[4])
|
||||
payload := make([]byte, length)
|
||||
if length > 0 {
|
||||
if _, err := io.ReadFull(r, payload); err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
}
|
||||
if kind != KindData && kind != KindCtrl {
|
||||
return kind, nil, ErrUnknownFrameKind
|
||||
}
|
||||
return kind, payload, nil
|
||||
}
|
||||
|
||||
// CtrlType is the discriminator for a control message.
|
||||
type CtrlType string
|
||||
|
||||
const (
|
||||
// CtrlHello is the client's opening handshake; Version is the highest control
|
||||
// version it speaks.
|
||||
CtrlHello CtrlType = "hello"
|
||||
// CtrlHelloOK is the daemon's handshake reply with the negotiated Version.
|
||||
CtrlHelloOK CtrlType = "hello_ok"
|
||||
// CtrlRun asks the daemon to create/route a session and stream its output.
|
||||
CtrlRun CtrlType = "run"
|
||||
// CtrlAttach asks the daemon to attach the client to a running session's stream.
|
||||
CtrlAttach CtrlType = "attach"
|
||||
// CtrlStatus requests daemon/worker/session status.
|
||||
CtrlStatus CtrlType = "status"
|
||||
// CtrlStatusResult carries the status payload (see StatusReport in Status).
|
||||
CtrlStatusResult CtrlType = "status_result"
|
||||
// CtrlData carries one stream-json line of agent output back to the client.
|
||||
CtrlData CtrlType = "data"
|
||||
// CtrlAck acknowledges receipt/dispatch of a request (at-least-once dispatch).
|
||||
CtrlAck CtrlType = "ack"
|
||||
// CtrlEnd marks the end of a session's stream.
|
||||
CtrlEnd CtrlType = "end"
|
||||
// CtrlShutdown asks the daemon to drain and stop.
|
||||
CtrlShutdown CtrlType = "shutdown"
|
||||
// CtrlError carries a human-readable error for the client.
|
||||
CtrlError CtrlType = "error"
|
||||
)
|
||||
|
||||
// Ctrl is a control message carried in a KindCtrl frame as JSON. Unused fields
|
||||
// are omitted so frames stay small.
|
||||
type Ctrl struct {
|
||||
Type CtrlType `json:"type"`
|
||||
Version int `json:"version,omitempty"`
|
||||
Session string `json:"session,omitempty"`
|
||||
Cwd string `json:"cwd,omitempty"`
|
||||
Args []string `json:"args,omitempty"`
|
||||
Prompt string `json:"prompt,omitempty"`
|
||||
// Line is one stream-json event line (for CtrlData).
|
||||
Line string `json:"line,omitempty"`
|
||||
// Message is a human-readable detail (for CtrlError / CtrlAck).
|
||||
Message string `json:"message,omitempty"`
|
||||
// Status is populated on CtrlStatusResult.
|
||||
Status *StatusReport `json:"status,omitempty"`
|
||||
}
|
||||
|
||||
// WriteControl marshals a control message into a KindCtrl frame.
|
||||
func WriteControl(w io.Writer, msg Ctrl) error {
|
||||
payload, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return WriteFrame(w, KindCtrl, payload)
|
||||
}
|
||||
|
||||
// ReadControl reads the next frame and decodes it as a control message. A
|
||||
// non-control frame yields ErrUnknownFrameKind so the caller fails closed.
|
||||
func ReadControl(r io.Reader) (Ctrl, error) {
|
||||
kind, payload, err := ReadFrame(r)
|
||||
if err != nil {
|
||||
return Ctrl{}, err
|
||||
}
|
||||
if kind != KindCtrl {
|
||||
return Ctrl{}, ErrUnknownFrameKind
|
||||
}
|
||||
var msg Ctrl
|
||||
if err := json.Unmarshal(payload, &msg); err != nil {
|
||||
return Ctrl{}, fmt.Errorf("daemon: decode control message: %w", err)
|
||||
}
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
// NegotiateVersion picks the protocol version both ends can speak: the lower of
|
||||
// the client's advertised version and ProtoVersion. A non-positive client
|
||||
// version is invalid and yields ok=false so the daemon can reject the connection.
|
||||
func NegotiateVersion(clientVersion int) (int, bool) {
|
||||
if clientVersion <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
if clientVersion < ProtoVersion {
|
||||
return clientVersion, true
|
||||
}
|
||||
return ProtoVersion, true
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFrameRoundTrip(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
payloads := [][]byte{[]byte("hello"), {}, []byte(strings.Repeat("x", 4096))}
|
||||
for _, p := range payloads {
|
||||
if err := WriteFrame(&buf, KindCtrl, p); err != nil {
|
||||
t.Fatalf("WriteFrame: %v", err)
|
||||
}
|
||||
}
|
||||
for i, want := range payloads {
|
||||
kind, got, err := ReadFrame(&buf)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFrame[%d]: %v", i, err)
|
||||
}
|
||||
if kind != KindCtrl {
|
||||
t.Fatalf("frame[%d] kind = %d, want KindCtrl", i, kind)
|
||||
}
|
||||
if !bytes.Equal(got, want) {
|
||||
t.Fatalf("frame[%d] payload = %q, want %q", i, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestControlRoundTrip(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
msg := Ctrl{Type: CtrlRun, Session: "s1", Cwd: "/tmp", Args: []string{"--prompt", "hi"}}
|
||||
if err := WriteControl(&buf, msg); err != nil {
|
||||
t.Fatalf("WriteControl: %v", err)
|
||||
}
|
||||
got, err := ReadControl(&buf)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadControl: %v", err)
|
||||
}
|
||||
if got.Type != CtrlRun || got.Session != "s1" || got.Cwd != "/tmp" || len(got.Args) != 2 {
|
||||
t.Fatalf("round-trip mismatch: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteFrameRejectsOversize(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
err := WriteFrame(&buf, KindCtrl, make([]byte, MaxFrameSize+1))
|
||||
if !errors.Is(err, ErrFrameTooLarge) {
|
||||
t.Fatalf("WriteFrame oversize err = %v, want ErrFrameTooLarge", err)
|
||||
}
|
||||
if buf.Len() != 0 {
|
||||
t.Fatalf("oversize WriteFrame must not emit any bytes, wrote %d", buf.Len())
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadFrameRejectsOversizeBeforeAlloc(t *testing.T) {
|
||||
// A header advertising a 4 GiB payload must be rejected without reading or
|
||||
// allocating that payload.
|
||||
header := make([]byte, frameHeaderSize)
|
||||
binary.BigEndian.PutUint32(header[:4], MaxFrameSize+1)
|
||||
header[4] = byte(KindCtrl)
|
||||
_, _, err := ReadFrame(bytes.NewReader(header))
|
||||
if !errors.Is(err, ErrFrameTooLarge) {
|
||||
t.Fatalf("ReadFrame oversize err = %v, want ErrFrameTooLarge", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadFrameUnknownKind(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
// Hand-write a frame with an unknown kind byte (7) and a small payload.
|
||||
header := make([]byte, frameHeaderSize)
|
||||
binary.BigEndian.PutUint32(header[:4], 2)
|
||||
header[4] = 7
|
||||
buf.Write(header)
|
||||
buf.Write([]byte("ab"))
|
||||
_, _, err := ReadFrame(&buf)
|
||||
if !errors.Is(err, ErrUnknownFrameKind) {
|
||||
t.Fatalf("unknown kind err = %v, want ErrUnknownFrameKind", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadFrameTruncated(t *testing.T) {
|
||||
// Header promises 10 bytes but only 3 follow → unexpected EOF.
|
||||
var buf bytes.Buffer
|
||||
header := make([]byte, frameHeaderSize)
|
||||
binary.BigEndian.PutUint32(header[:4], 10)
|
||||
header[4] = byte(KindCtrl)
|
||||
buf.Write(header)
|
||||
buf.Write([]byte("abc"))
|
||||
_, _, err := ReadFrame(&buf)
|
||||
if err == nil || errors.Is(err, ErrFrameTooLarge) {
|
||||
t.Fatalf("truncated frame err = %v, want a read error", err)
|
||||
}
|
||||
if !errors.Is(err, io.ErrUnexpectedEOF) {
|
||||
t.Fatalf("truncated frame err = %v, want io.ErrUnexpectedEOF", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNegotiateVersion(t *testing.T) {
|
||||
cases := []struct {
|
||||
client int
|
||||
want int
|
||||
wantOK bool
|
||||
}{
|
||||
{client: 1, want: 1, wantOK: true},
|
||||
{client: 99, want: ProtoVersion, wantOK: true}, // clamp down to ours
|
||||
{client: 0, want: 0, wantOK: false},
|
||||
{client: -3, want: 0, wantOK: false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got, ok := NegotiateVersion(tc.client)
|
||||
if got != tc.want || ok != tc.wantOK {
|
||||
t.Fatalf("NegotiateVersion(%d) = (%d,%v), want (%d,%v)", tc.client, got, ok, tc.want, tc.wantOK)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
// Package remote adds an OPT-IN, TLS-only network bridge on top of zero's local
|
||||
// daemon (internal/daemon). It lets a remote client drive the SAME daemon
|
||||
// SessionManager/Pool over the network, behind bearer-token authentication and a
|
||||
// protocol-version floor. The local Unix-socket daemon is unchanged and remains
|
||||
// the default; nothing here activates unless `zero daemon serve-remote` is run.
|
||||
//
|
||||
// Security (fail closed):
|
||||
// - TLS is mandatory; the bridge refuses to serve without a cert/key.
|
||||
// - A connection is authenticated BEFORE any control frame is dispatched; a
|
||||
// failed/absent token closes the connection (after a small backoff) and no
|
||||
// session frame is ever processed.
|
||||
// - Tokens are compared in constant time and are never logged.
|
||||
// - The control-frame size cap and version negotiation from internal/daemon are
|
||||
// reused unchanged; oversize/old-version handshakes are rejected.
|
||||
// - A remote-driven session runs through the same daemon dispatch, so it stays
|
||||
// under the same sandbox + risk model — remote never bypasses local controls.
|
||||
package remote
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/daemon"
|
||||
)
|
||||
|
||||
// Env vars the bridge reads for its bearer token.
|
||||
const (
|
||||
EnvToken = "ZERO_DAEMON_REMOTE_TOKEN"
|
||||
EnvTokenFile = "ZERO_DAEMON_REMOTE_TOKEN_FILE"
|
||||
)
|
||||
|
||||
// ErrUnauthorized is returned when a token does not match.
|
||||
var ErrUnauthorized = errors.New("remote: unauthorized")
|
||||
|
||||
// Authenticator verifies a bearer token presented by a remote client.
|
||||
type Authenticator interface {
|
||||
Authenticate(token string) error
|
||||
}
|
||||
|
||||
// TokenAuthenticator compares a presented token against a fixed secret in
|
||||
// constant time.
|
||||
type TokenAuthenticator struct {
|
||||
token string
|
||||
}
|
||||
|
||||
// NewTokenAuthenticator builds a token authenticator, refusing an empty secret
|
||||
// (fail closed — a bridge must never accept everyone).
|
||||
func NewTokenAuthenticator(token string) (*TokenAuthenticator, error) {
|
||||
token = strings.TrimSpace(token)
|
||||
if token == "" {
|
||||
return nil, errors.New("remote: a non-empty auth token is required")
|
||||
}
|
||||
return &TokenAuthenticator{token: token}, nil
|
||||
}
|
||||
|
||||
// Authenticate reports nil when token matches the configured secret.
|
||||
func (a *TokenAuthenticator) Authenticate(token string) error {
|
||||
if subtle.ConstantTimeCompare([]byte(token), []byte(a.token)) == 1 {
|
||||
return nil
|
||||
}
|
||||
return ErrUnauthorized
|
||||
}
|
||||
|
||||
// TokenFromEnv resolves the bridge token from EnvToken, or a file named by
|
||||
// EnvTokenFile. It never logs the token.
|
||||
func TokenFromEnv() (string, error) {
|
||||
if t := strings.TrimSpace(os.Getenv(EnvToken)); t != "" {
|
||||
return t, nil
|
||||
}
|
||||
if file := strings.TrimSpace(os.Getenv(EnvTokenFile)); file != "" {
|
||||
data, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("remote: read token file: %w", err)
|
||||
}
|
||||
t := strings.TrimSpace(string(data))
|
||||
if t == "" {
|
||||
return "", errors.New("remote: token file is empty")
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
return "", fmt.Errorf("remote: set %s or %s", EnvToken, EnvTokenFile)
|
||||
}
|
||||
|
||||
// Attestation is an optional post-token hook (e.g. workload attestation). The
|
||||
// default is a no-op; a deployment can supply a stricter implementation.
|
||||
type Attestation interface {
|
||||
Verify(meta map[string]string) error
|
||||
}
|
||||
|
||||
type noopAttestation struct{}
|
||||
|
||||
func (noopAttestation) Verify(map[string]string) error { return nil }
|
||||
|
||||
// Connection modes negotiated in the auth handshake. The default (empty) is a
|
||||
// daemon session, preserving the original behavior; "bundle" requests a one-shot
|
||||
// git-bundle upload instead of a session.
|
||||
const (
|
||||
ModeSession = "session"
|
||||
ModeBundle = "bundle"
|
||||
)
|
||||
|
||||
// authRequest is the first frame a remote client sends (before the daemon
|
||||
// hello). Token is never logged.
|
||||
type authRequest struct {
|
||||
Token string `json:"token"`
|
||||
Version int `json:"version"`
|
||||
Meta map[string]string `json:"meta,omitempty"`
|
||||
// Mode selects the connection's purpose: "" / "session" => a daemon session
|
||||
// (default), "bundle" => a git-bundle upload. Unknown modes are rejected.
|
||||
Mode string `json:"mode,omitempty"`
|
||||
}
|
||||
|
||||
// authResponse is the bridge's reply to the auth handshake.
|
||||
type authResponse struct {
|
||||
OK bool `json:"ok"`
|
||||
Version int `json:"version,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
func writeAuthRequest(w io.Writer, req authRequest) error {
|
||||
payload, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return daemon.WriteFrame(w, daemon.KindCtrl, payload)
|
||||
}
|
||||
|
||||
func readAuthRequest(r io.Reader) (authRequest, error) {
|
||||
kind, payload, err := daemon.ReadFrame(r)
|
||||
if err != nil {
|
||||
return authRequest{}, err
|
||||
}
|
||||
if kind != daemon.KindCtrl {
|
||||
return authRequest{}, errors.New("remote: expected auth frame")
|
||||
}
|
||||
var req authRequest
|
||||
if err := json.Unmarshal(payload, &req); err != nil {
|
||||
return authRequest{}, fmt.Errorf("remote: decode auth request: %w", err)
|
||||
}
|
||||
return req, nil
|
||||
}
|
||||
|
||||
func writeAuthResponse(w io.Writer, resp authResponse) error {
|
||||
payload, err := json.Marshal(resp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return daemon.WriteFrame(w, daemon.KindCtrl, payload)
|
||||
}
|
||||
|
||||
func readAuthResponse(r io.Reader) (authResponse, error) {
|
||||
kind, payload, err := daemon.ReadFrame(r)
|
||||
if err != nil {
|
||||
return authResponse{}, err
|
||||
}
|
||||
if kind != daemon.KindCtrl {
|
||||
return authResponse{}, errors.New("remote: expected auth response frame")
|
||||
}
|
||||
var resp authResponse
|
||||
if err := json.Unmarshal(payload, &resp); err != nil {
|
||||
return authResponse{}, fmt.Errorf("remote: decode auth response: %w", err)
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package remote
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTokenAuthenticator(t *testing.T) {
|
||||
if _, err := NewTokenAuthenticator(" "); err == nil {
|
||||
t.Fatal("empty token must be rejected (fail closed)")
|
||||
}
|
||||
a, err := NewTokenAuthenticator("s3cret")
|
||||
if err != nil {
|
||||
t.Fatalf("NewTokenAuthenticator: %v", err)
|
||||
}
|
||||
if err := a.Authenticate("s3cret"); err != nil {
|
||||
t.Fatalf("matching token should authenticate: %v", err)
|
||||
}
|
||||
if err := a.Authenticate("wrong"); !errors.Is(err, ErrUnauthorized) {
|
||||
t.Fatalf("mismatch err = %v, want ErrUnauthorized", err)
|
||||
}
|
||||
if err := a.Authenticate(""); !errors.Is(err, ErrUnauthorized) {
|
||||
t.Fatalf("empty presented token err = %v, want ErrUnauthorized", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenFromEnv(t *testing.T) {
|
||||
// Clear both so the no-config path errors.
|
||||
t.Setenv(EnvToken, "")
|
||||
t.Setenv(EnvTokenFile, "")
|
||||
if _, err := TokenFromEnv(); err == nil {
|
||||
t.Fatal("TokenFromEnv with neither var set must error")
|
||||
}
|
||||
// Direct env.
|
||||
t.Setenv(EnvToken, "from-env")
|
||||
if tok, err := TokenFromEnv(); err != nil || tok != "from-env" {
|
||||
t.Fatalf("TokenFromEnv(env) = %q, %v", tok, err)
|
||||
}
|
||||
// File (env takes precedence, so clear env first).
|
||||
t.Setenv(EnvToken, "")
|
||||
file := filepath.Join(t.TempDir(), "tok")
|
||||
if err := os.WriteFile(file, []byte(" from-file\n"), 0o600); err != nil {
|
||||
t.Fatalf("write token file: %v", err)
|
||||
}
|
||||
t.Setenv(EnvTokenFile, file)
|
||||
if tok, err := TokenFromEnv(); err != nil || tok != "from-file" {
|
||||
t.Fatalf("TokenFromEnv(file) = %q, %v", tok, err)
|
||||
}
|
||||
// Empty file fails closed.
|
||||
if err := os.WriteFile(file, []byte(" \n"), 0o600); err != nil {
|
||||
t.Fatalf("rewrite token file: %v", err)
|
||||
}
|
||||
if _, err := TokenFromEnv(); err == nil {
|
||||
t.Fatal("empty token file must error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerTLSConfigRequiresCertKey(t *testing.T) {
|
||||
if _, err := ServerTLSConfig("", ""); err == nil {
|
||||
t.Fatal("ServerTLSConfig must require a cert and key (TLS mandatory)")
|
||||
}
|
||||
if _, err := ServerTLSConfig("/nope/cert.pem", "/nope/key.pem"); err == nil {
|
||||
t.Fatal("ServerTLSConfig must error on unreadable cert/key")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
package remote
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/daemon"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultMaxConnections = 32
|
||||
defaultHandshakeTimeout = 10 * time.Second
|
||||
defaultAuthFailDelay = 250 * time.Millisecond
|
||||
// defaultMaxBundleBytes caps a single uploaded git bundle (256 MiB) so a
|
||||
// remote peer cannot exhaust the host's disk.
|
||||
defaultMaxBundleBytes = 256 << 20
|
||||
)
|
||||
|
||||
// Bridge serves authenticated remote connections and drives the local daemon's
|
||||
// control dispatch for each one.
|
||||
type Bridge struct {
|
||||
server *daemon.Server
|
||||
auth Authenticator
|
||||
attest Attestation
|
||||
minVersion int
|
||||
handshakeTimeout time.Duration
|
||||
authFailDelay time.Duration
|
||||
bundleDir string
|
||||
maxBundleBytes int64
|
||||
log func(string)
|
||||
sem chan struct{}
|
||||
|
||||
mu sync.Mutex
|
||||
listener net.Listener
|
||||
}
|
||||
|
||||
// BridgeOptions configures a Bridge.
|
||||
type BridgeOptions struct {
|
||||
// Server is the local daemon to drive (required).
|
||||
Server *daemon.Server
|
||||
// Authenticator verifies bearer tokens (required).
|
||||
Authenticator Authenticator
|
||||
// Attestation is an optional post-token hook; nil => no-op.
|
||||
Attestation Attestation
|
||||
// MinVersion is the lowest control-protocol version accepted; 0 =>
|
||||
// daemon.ProtoVersion.
|
||||
MinVersion int
|
||||
// MaxConnections bounds concurrent remote connections; 0 => default.
|
||||
MaxConnections int
|
||||
// HandshakeTimeout bounds the auth handshake; 0 => default.
|
||||
HandshakeTimeout time.Duration
|
||||
// AuthFailDelay slows brute-force attempts; <0 => none, 0 => default.
|
||||
AuthFailDelay time.Duration
|
||||
// BundleDir is the directory under which uploaded git bundles are extracted
|
||||
// into per-link working trees. Empty disables bundle uploads entirely (a
|
||||
// bundle-mode connection is then refused — opt-in, fail closed).
|
||||
BundleDir string
|
||||
// MaxBundleBytes caps a single uploaded bundle; 0 => default.
|
||||
MaxBundleBytes int64
|
||||
Log func(string)
|
||||
}
|
||||
|
||||
// NewBridge validates options and builds a Bridge.
|
||||
func NewBridge(opts BridgeOptions) (*Bridge, error) {
|
||||
if opts.Server == nil {
|
||||
return nil, errors.New("remote: bridge requires a daemon server")
|
||||
}
|
||||
if opts.Authenticator == nil {
|
||||
return nil, errors.New("remote: bridge requires an authenticator")
|
||||
}
|
||||
minVersion := opts.MinVersion
|
||||
if minVersion <= 0 {
|
||||
minVersion = daemon.ProtoVersion
|
||||
}
|
||||
maxConns := opts.MaxConnections
|
||||
if maxConns <= 0 {
|
||||
maxConns = defaultMaxConnections
|
||||
}
|
||||
handshakeTimeout := opts.HandshakeTimeout
|
||||
if handshakeTimeout <= 0 {
|
||||
handshakeTimeout = defaultHandshakeTimeout
|
||||
}
|
||||
authFailDelay := opts.AuthFailDelay
|
||||
switch {
|
||||
case authFailDelay < 0:
|
||||
authFailDelay = 0
|
||||
case authFailDelay == 0:
|
||||
authFailDelay = defaultAuthFailDelay
|
||||
}
|
||||
attest := opts.Attestation
|
||||
if attest == nil {
|
||||
attest = noopAttestation{}
|
||||
}
|
||||
maxBundleBytes := opts.MaxBundleBytes
|
||||
if maxBundleBytes <= 0 {
|
||||
maxBundleBytes = defaultMaxBundleBytes
|
||||
}
|
||||
return &Bridge{
|
||||
server: opts.Server,
|
||||
auth: opts.Authenticator,
|
||||
attest: attest,
|
||||
minVersion: minVersion,
|
||||
handshakeTimeout: handshakeTimeout,
|
||||
authFailDelay: authFailDelay,
|
||||
bundleDir: strings.TrimSpace(opts.BundleDir),
|
||||
maxBundleBytes: maxBundleBytes,
|
||||
log: opts.Log,
|
||||
sem: make(chan struct{}, maxConns),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (b *Bridge) logf(format string, args ...any) {
|
||||
if b.log != nil {
|
||||
b.log(fmt.Sprintf(format, args...))
|
||||
}
|
||||
}
|
||||
|
||||
// ListenAndServeTLS binds a TLS listener on addr and serves until the listener
|
||||
// is closed (via Close). It refuses to serve without a TLS config (no plaintext
|
||||
// remote bridge).
|
||||
func (b *Bridge) ListenAndServeTLS(addr string, tlsConfig *tls.Config) error {
|
||||
if tlsConfig == nil {
|
||||
return errors.New("remote: TLS config is required; refusing to serve a plaintext remote bridge")
|
||||
}
|
||||
listener, err := tls.Listen("tcp", addr, tlsConfig)
|
||||
if err != nil {
|
||||
return fmt.Errorf("remote: listen %s: %w", addr, err)
|
||||
}
|
||||
b.mu.Lock()
|
||||
b.listener = listener
|
||||
b.mu.Unlock()
|
||||
return b.Serve(listener)
|
||||
}
|
||||
|
||||
// Close stops the listener started by ListenAndServeTLS, causing Serve to
|
||||
// return. Safe to call before serving or more than once.
|
||||
func (b *Bridge) Close() error {
|
||||
b.mu.Lock()
|
||||
listener := b.listener
|
||||
b.listener = nil // clear so a repeat Close is a no-op, not a closed-listener error
|
||||
b.mu.Unlock()
|
||||
if listener != nil {
|
||||
return listener.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Serve accepts connections from listener until it returns an error (e.g. it is
|
||||
// closed). Each connection is authenticated, then handed to the daemon's control
|
||||
// dispatch. Connections beyond MaxConnections are refused immediately.
|
||||
func (b *Bridge) Serve(listener net.Listener) error {
|
||||
for {
|
||||
conn, err := listener.Accept()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
select {
|
||||
case b.sem <- struct{}{}:
|
||||
default:
|
||||
// At capacity: refuse fast (fail closed) rather than queueing.
|
||||
b.logf("remote: refused connection (at capacity)")
|
||||
_ = conn.Close()
|
||||
continue
|
||||
}
|
||||
go func() {
|
||||
defer func() { <-b.sem }()
|
||||
b.handle(conn)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
// handle authenticates conn then drives the daemon dispatch. It always closes
|
||||
// conn (directly on a failed handshake, or via ServeConn on success).
|
||||
func (b *Bridge) handle(conn net.Conn) {
|
||||
// Bound the auth handshake so a stalled peer cannot hold a connection slot.
|
||||
_ = conn.SetDeadline(time.Now().Add(b.handshakeTimeout))
|
||||
req, err := readAuthRequest(conn)
|
||||
if err != nil {
|
||||
_ = conn.Close()
|
||||
return
|
||||
}
|
||||
if req.Version < b.minVersion {
|
||||
b.deny(conn, "unsupported protocol version")
|
||||
return
|
||||
}
|
||||
if err := b.auth.Authenticate(req.Token); err != nil {
|
||||
b.deny(conn, "unauthorized")
|
||||
return
|
||||
}
|
||||
if err := b.attest.Verify(req.Meta); err != nil {
|
||||
b.deny(conn, "attestation failed")
|
||||
return
|
||||
}
|
||||
// Resolve the connection mode before accepting it. Validating here (before the
|
||||
// success response) lets the bridge fail closed: an unknown mode, or a bundle
|
||||
// upload when bundle transfer is disabled, is denied without side effects.
|
||||
mode := req.Mode
|
||||
if mode == "" {
|
||||
mode = ModeSession
|
||||
}
|
||||
switch mode {
|
||||
case ModeSession:
|
||||
case ModeBundle:
|
||||
if b.bundleDir == "" {
|
||||
b.deny(conn, "bundle transfer not enabled")
|
||||
return
|
||||
}
|
||||
default:
|
||||
b.deny(conn, "unsupported mode")
|
||||
return
|
||||
}
|
||||
if err := writeAuthResponse(conn, authResponse{OK: true, Version: daemon.ProtoVersion}); err != nil {
|
||||
_ = conn.Close()
|
||||
return
|
||||
}
|
||||
// Clear the handshake deadline: a session may stream, and a bundle upload may
|
||||
// transfer many megabytes, for a long time.
|
||||
_ = conn.SetDeadline(time.Time{})
|
||||
switch mode {
|
||||
case ModeBundle:
|
||||
b.handleBundle(conn) // receives + extracts the bundle, then closes conn
|
||||
default:
|
||||
b.server.ServeConn(conn) // performs the daemon handshake + one command, then closes conn
|
||||
}
|
||||
}
|
||||
|
||||
// deny rejects an unauthenticated connection after a small backoff (to slow
|
||||
// brute force). The reason is generic and never includes any token material.
|
||||
func (b *Bridge) deny(conn net.Conn, message string) {
|
||||
if b.authFailDelay > 0 {
|
||||
time.Sleep(b.authFailDelay)
|
||||
}
|
||||
_ = writeAuthResponse(conn, authResponse{OK: false, Message: message})
|
||||
_ = conn.Close()
|
||||
}
|
||||
|
||||
// ServerTLSConfig builds a server TLS config from a cert/key pair, refusing if
|
||||
// either is missing (TLS is mandatory for the remote bridge).
|
||||
func ServerTLSConfig(certFile, keyFile string) (*tls.Config, error) {
|
||||
certFile = strings.TrimSpace(certFile)
|
||||
keyFile = strings.TrimSpace(keyFile)
|
||||
if certFile == "" || keyFile == "" {
|
||||
return nil, errors.New("remote: a TLS cert and key are required for serve-remote")
|
||||
}
|
||||
cert, err := tls.LoadX509KeyPair(certFile, keyFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("remote: load TLS keypair: %w", err)
|
||||
}
|
||||
return &tls.Config{Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS12}, nil
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
package remote
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"math/big"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/daemon"
|
||||
)
|
||||
|
||||
// ---- fake daemon worker (the daemon's test fakes are unexported) -------------
|
||||
|
||||
type fakeLines struct{ ch <-chan string }
|
||||
|
||||
func (l fakeLines) Next() (string, bool, error) {
|
||||
line, ok := <-l.ch
|
||||
return line, ok, nil
|
||||
}
|
||||
|
||||
type fakeWorker struct {
|
||||
ch chan string
|
||||
pid int
|
||||
}
|
||||
|
||||
func (w *fakeWorker) Stdout() daemon.Lines { return fakeLines{ch: w.ch} }
|
||||
func (w *fakeWorker) Wait() (int, error) { return 0, nil }
|
||||
func (w *fakeWorker) Kill() error { return nil }
|
||||
func (w *fakeWorker) Pid() int { return w.pid }
|
||||
|
||||
// staticLauncher emits the given lines then ends the stream.
|
||||
func staticLauncher(lines ...string) daemon.Launcher {
|
||||
return func(_ context.Context, _ daemon.WorkerSpec) (daemon.WorkerHandle, error) {
|
||||
ch := make(chan string, len(lines))
|
||||
for _, l := range lines {
|
||||
ch <- l
|
||||
}
|
||||
close(ch)
|
||||
return &fakeWorker{ch: ch, pid: 1}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// blockingLauncher returns a worker whose stream stays open until release is
|
||||
// closed, so a session (and its bridge connection slot) is held open.
|
||||
func blockingLauncher(release <-chan struct{}) daemon.Launcher {
|
||||
return func(_ context.Context, _ daemon.WorkerSpec) (daemon.WorkerHandle, error) {
|
||||
ch := make(chan string)
|
||||
go func() {
|
||||
<-release
|
||||
close(ch)
|
||||
}()
|
||||
return &fakeWorker{ch: ch, pid: 1}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func newBridgeServer(t *testing.T, launcher daemon.Launcher) *daemon.Server {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
pool, err := daemon.NewPool(daemon.PoolOptions{Size: 4, Launcher: launcher, KillTimeout: 200 * time.Millisecond})
|
||||
if err != nil {
|
||||
t.Fatalf("NewPool: %v", err)
|
||||
}
|
||||
mgr, err := daemon.NewSessionManager(daemon.SessionManagerOptions{Pool: pool})
|
||||
if err != nil {
|
||||
t.Fatalf("NewSessionManager: %v", err)
|
||||
}
|
||||
srv, err := daemon.NewServer(daemon.ServerOptions{
|
||||
Paths: daemon.Paths{Socket: filepath.Join(dir, "s.sock"), Lock: filepath.Join(dir, "s.lock"), Status: filepath.Join(dir, "s.status")},
|
||||
Manager: mgr,
|
||||
Pool: pool,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewServer: %v", err)
|
||||
}
|
||||
t.Cleanup(srv.Shutdown)
|
||||
return srv
|
||||
}
|
||||
|
||||
// genTestCert writes a self-signed cert/key (valid for 127.0.0.1) to temp files.
|
||||
func genTestCert(t *testing.T) (certFile, keyFile string) {
|
||||
t.Helper()
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("genkey: %v", err)
|
||||
}
|
||||
tmpl := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{CommonName: "zero-remote-test"},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(time.Hour),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
IsCA: true,
|
||||
IPAddresses: []net.IP{net.ParseIP("127.0.0.1"), net.ParseIP("::1")},
|
||||
DNSNames: []string{"localhost"},
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
t.Fatalf("create cert: %v", err)
|
||||
}
|
||||
dir := t.TempDir()
|
||||
certFile = filepath.Join(dir, "cert.pem")
|
||||
keyFile = filepath.Join(dir, "key.pem")
|
||||
certOut, _ := os.Create(certFile)
|
||||
_ = pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: der})
|
||||
_ = certOut.Close()
|
||||
keyDER, _ := x509.MarshalECPrivateKey(key)
|
||||
keyOut, _ := os.Create(keyFile)
|
||||
_ = pem.Encode(keyOut, &pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
|
||||
_ = keyOut.Close()
|
||||
return certFile, keyFile
|
||||
}
|
||||
|
||||
// startBridge starts a TLS bridge on 127.0.0.1:0 and returns its address + cert.
|
||||
func startBridge(t *testing.T, srv *daemon.Server, opts BridgeOptions) (addr, caCert string) {
|
||||
t.Helper()
|
||||
certFile, keyFile := genTestCert(t)
|
||||
tlsConfig, err := ServerTLSConfig(certFile, keyFile)
|
||||
if err != nil {
|
||||
t.Fatalf("ServerTLSConfig: %v", err)
|
||||
}
|
||||
opts.Server = srv
|
||||
bridge, err := NewBridge(opts)
|
||||
if err != nil {
|
||||
t.Fatalf("NewBridge: %v", err)
|
||||
}
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
tlsLn := tls.NewListener(ln, tlsConfig)
|
||||
go func() { _ = bridge.Serve(tlsLn) }()
|
||||
t.Cleanup(func() { _ = tlsLn.Close() })
|
||||
return ln.Addr().String(), certFile
|
||||
}
|
||||
|
||||
func TestBridgeRunStatusRoundTrip(t *testing.T) {
|
||||
srv := newBridgeServer(t, staticLauncher(`{"type":"event","seq":1}`, `{"type":"event","seq":2}`))
|
||||
auth, _ := NewTokenAuthenticator("tok")
|
||||
addr, ca := startBridge(t, srv, BridgeOptions{Authenticator: auth})
|
||||
|
||||
client, err := DialRemote(RemoteConfig{Address: addr, Token: "tok", CACertFile: ca})
|
||||
if err != nil {
|
||||
t.Fatalf("DialRemote: %v", err)
|
||||
}
|
||||
var got []string
|
||||
if err := client.Run("sess-1", "", "hello", nil, func(l string) { got = append(got, l) }); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
_ = client.Close()
|
||||
if len(got) != 2 || got[0] != `{"type":"event","seq":1}` {
|
||||
t.Fatalf("run output = %v", got)
|
||||
}
|
||||
|
||||
// Status over a fresh remote connection (same protocol as local).
|
||||
statusClient, err := DialRemote(RemoteConfig{Address: addr, Token: "tok", CACertFile: ca})
|
||||
if err != nil {
|
||||
t.Fatalf("DialRemote(status): %v", err)
|
||||
}
|
||||
report, err := statusClient.Status()
|
||||
if err != nil {
|
||||
t.Fatalf("Status: %v", err)
|
||||
}
|
||||
_ = statusClient.Close()
|
||||
if report == nil || report.PID == 0 {
|
||||
t.Fatalf("status report = %+v", report)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBridgeRejectsBadToken(t *testing.T) {
|
||||
srv := newBridgeServer(t, staticLauncher())
|
||||
auth, _ := NewTokenAuthenticator("correct")
|
||||
addr, ca := startBridge(t, srv, BridgeOptions{Authenticator: auth, AuthFailDelay: -1})
|
||||
|
||||
_, err := DialRemote(RemoteConfig{Address: addr, Token: "wrong", CACertFile: ca})
|
||||
if !errors.Is(err, ErrUnauthorized) {
|
||||
t.Fatalf("DialRemote(bad token) err = %v, want ErrUnauthorized", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBridgeRejectsLowVersion(t *testing.T) {
|
||||
srv := newBridgeServer(t, staticLauncher())
|
||||
auth, _ := NewTokenAuthenticator("tok")
|
||||
// Require a version higher than the client speaks (daemon.ProtoVersion).
|
||||
addr, ca := startBridge(t, srv, BridgeOptions{Authenticator: auth, MinVersion: daemon.ProtoVersion + 1, AuthFailDelay: -1})
|
||||
|
||||
_, err := DialRemote(RemoteConfig{Address: addr, Token: "tok", CACertFile: ca})
|
||||
if err == nil {
|
||||
t.Fatal("DialRemote with a below-min version must be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBridgeRequiresVerifiableCert(t *testing.T) {
|
||||
srv := newBridgeServer(t, staticLauncher())
|
||||
auth, _ := NewTokenAuthenticator("tok")
|
||||
addr, _ := startBridge(t, srv, BridgeOptions{Authenticator: auth})
|
||||
// No CA configured + a self-signed server cert => verification must fail
|
||||
// (NEVER InsecureSkipVerify).
|
||||
if _, err := DialRemote(RemoteConfig{Address: addr, Token: "tok"}); err == nil {
|
||||
t.Fatal("DialRemote must reject an untrusted self-signed cert")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListenAndServeTLSRequiresConfig(t *testing.T) {
|
||||
srv := newBridgeServer(t, staticLauncher())
|
||||
auth, _ := NewTokenAuthenticator("tok")
|
||||
bridge, _ := NewBridge(BridgeOptions{Server: srv, Authenticator: auth})
|
||||
if err := bridge.ListenAndServeTLS("127.0.0.1:0", nil); err == nil {
|
||||
t.Fatal("ListenAndServeTLS(nil) must refuse to serve plaintext")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListenAndServeTLSServesAndCloses(t *testing.T) {
|
||||
srv := newBridgeServer(t, staticLauncher(`{"type":"event","seq":1}`))
|
||||
auth, _ := NewTokenAuthenticator("tok")
|
||||
certFile, keyFile := genTestCert(t)
|
||||
tlsConfig, err := ServerTLSConfig(certFile, keyFile)
|
||||
if err != nil {
|
||||
t.Fatalf("ServerTLSConfig: %v", err)
|
||||
}
|
||||
bridge, err := NewBridge(BridgeOptions{Server: srv, Authenticator: auth})
|
||||
if err != nil {
|
||||
t.Fatalf("NewBridge: %v", err)
|
||||
}
|
||||
// Bind an ephemeral port, discover it, then serve via ListenAndServeTLS by
|
||||
// passing the same addr (it rebinds) — instead we bind here to learn the port
|
||||
// and serve through the public entry on that exact address.
|
||||
probe, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("probe listen: %v", err)
|
||||
}
|
||||
addr := probe.Addr().String()
|
||||
_ = probe.Close()
|
||||
|
||||
serveErr := make(chan error, 1)
|
||||
go func() { serveErr <- bridge.ListenAndServeTLS(addr, tlsConfig) }()
|
||||
|
||||
// Wait until it accepts a connection, then drive one run.
|
||||
var client *daemon.Client
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for {
|
||||
client, err = DialRemote(RemoteConfig{Address: addr, Token: "tok", CACertFile: certFile, Timeout: 300 * time.Millisecond})
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("bridge never came up: %v", err)
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
var got []string
|
||||
if err := client.Run("s", "", "hi", nil, func(l string) { got = append(got, l) }); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
_ = client.Close()
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("got %d lines, want 1", len(got))
|
||||
}
|
||||
// Close stops the accept loop; a second Close is a no-op (not an error).
|
||||
if err := bridge.Close(); err != nil {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
if err := bridge.Close(); err != nil {
|
||||
t.Fatalf("second Close should be a no-op, got: %v", err)
|
||||
}
|
||||
select {
|
||||
case <-serveErr: // ListenAndServeTLS returned after Close
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("ListenAndServeTLS did not return after Close")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBridgeResumeAfterReconnect(t *testing.T) {
|
||||
srv := newBridgeServer(t, staticLauncher(`{"type":"event","seq":1}`, `{"type":"event","seq":2}`))
|
||||
auth, _ := NewTokenAuthenticator("tok")
|
||||
addr, ca := startBridge(t, srv, BridgeOptions{Authenticator: auth})
|
||||
|
||||
// First connection runs the session to completion.
|
||||
c1, err := DialRemote(RemoteConfig{Address: addr, Token: "tok", CACertFile: ca})
|
||||
if err != nil {
|
||||
t.Fatalf("DialRemote 1: %v", err)
|
||||
}
|
||||
if err := c1.Run("sess-resume", "", "hi", nil, func(string) {}); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
_ = c1.Close()
|
||||
|
||||
// A new connection re-attaches and replays the buffered session output —
|
||||
// the dropped first connection did not lose the session.
|
||||
c2, err := DialRemote(RemoteConfig{Address: addr, Token: "tok", CACertFile: ca})
|
||||
if err != nil {
|
||||
t.Fatalf("DialRemote 2: %v", err)
|
||||
}
|
||||
defer c2.Close()
|
||||
var replayed []string
|
||||
if err := c2.Attach("sess-resume", func(l string) { replayed = append(replayed, l) }); err != nil {
|
||||
t.Fatalf("Attach: %v", err)
|
||||
}
|
||||
if len(replayed) != 2 {
|
||||
t.Fatalf("re-attach replayed %d lines, want 2", len(replayed))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBridgeConnectionCap(t *testing.T) {
|
||||
release := make(chan struct{})
|
||||
defer close(release)
|
||||
srv := newBridgeServer(t, blockingLauncher(release))
|
||||
auth, _ := NewTokenAuthenticator("tok")
|
||||
addr, ca := startBridge(t, srv, BridgeOptions{Authenticator: auth, MaxConnections: 1})
|
||||
|
||||
// First connection holds the only slot with a blocking run.
|
||||
c1, err := DialRemote(RemoteConfig{Address: addr, Token: "tok", CACertFile: ca})
|
||||
if err != nil {
|
||||
t.Fatalf("DialRemote 1: %v", err)
|
||||
}
|
||||
defer c1.Close()
|
||||
runErr := make(chan error, 1)
|
||||
go func() { runErr <- c1.Run("sess-block", "", "hi", nil, func(string) {}) }()
|
||||
|
||||
// Wait until the slot is in use, then a second dial must be refused.
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
c2, err := DialRemote(RemoteConfig{Address: addr, Token: "tok", CACertFile: ca, Timeout: 500 * time.Millisecond})
|
||||
if err != nil {
|
||||
return // refused as expected
|
||||
}
|
||||
_ = c2.Close()
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
t.Fatal("second connection was not refused at the connection cap")
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
package remote
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/daemon"
|
||||
)
|
||||
|
||||
// gitTimeout bounds a single git invocation (bundle create/verify, clone) so a
|
||||
// hung git process cannot pin a connection or the upload path indefinitely.
|
||||
const gitTimeout = 2 * time.Minute
|
||||
|
||||
// bundleChunkSize is the per-frame payload when streaming a bundle file. It is
|
||||
// kept comfortably under daemon.MaxFrameSize (1 MiB).
|
||||
const bundleChunkSize = 512 << 10
|
||||
|
||||
// bundleHeader is the first control frame of a bundle upload (after the auth
|
||||
// handshake): it declares the link id and the exact byte size that follows.
|
||||
type bundleHeader struct {
|
||||
LinkID string `json:"link_id"`
|
||||
Size int64 `json:"size"`
|
||||
}
|
||||
|
||||
// bundleResult is the bridge's reply once the bundle is received and extracted.
|
||||
type bundleResult struct {
|
||||
OK bool `json:"ok"`
|
||||
Path string `json:"path,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
func writeBundleHeader(w io.Writer, h bundleHeader) error {
|
||||
payload, err := json.Marshal(h)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return daemon.WriteFrame(w, daemon.KindCtrl, payload)
|
||||
}
|
||||
|
||||
func readBundleHeader(r io.Reader) (bundleHeader, error) {
|
||||
kind, payload, err := daemon.ReadFrame(r)
|
||||
if err != nil {
|
||||
return bundleHeader{}, err
|
||||
}
|
||||
if kind != daemon.KindCtrl {
|
||||
return bundleHeader{}, errors.New("remote: expected bundle header frame")
|
||||
}
|
||||
var h bundleHeader
|
||||
if err := json.Unmarshal(payload, &h); err != nil {
|
||||
return bundleHeader{}, fmt.Errorf("remote: decode bundle header: %w", err)
|
||||
}
|
||||
return h, nil
|
||||
}
|
||||
|
||||
func writeBundleResult(w io.Writer, res bundleResult) error {
|
||||
payload, err := json.Marshal(res)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return daemon.WriteFrame(w, daemon.KindCtrl, payload)
|
||||
}
|
||||
|
||||
func readBundleResult(r io.Reader) (bundleResult, error) {
|
||||
kind, payload, err := daemon.ReadFrame(r)
|
||||
if err != nil {
|
||||
return bundleResult{}, err
|
||||
}
|
||||
if kind != daemon.KindCtrl {
|
||||
return bundleResult{}, errors.New("remote: expected bundle result frame")
|
||||
}
|
||||
var res bundleResult
|
||||
if err := json.Unmarshal(payload, &res); err != nil {
|
||||
return bundleResult{}, fmt.Errorf("remote: decode bundle result: %w", err)
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// ---- server side -----------------------------------------------------------
|
||||
|
||||
// handleBundle receives an uploaded bundle, extracts it into a per-link working
|
||||
// tree, and reports the outcome. It always closes conn.
|
||||
func (b *Bridge) handleBundle(conn net.Conn) {
|
||||
defer func() { _ = conn.Close() }()
|
||||
res := b.receiveBundle(conn)
|
||||
if !res.OK {
|
||||
b.logf("remote: bundle upload rejected: %s", res.Message)
|
||||
}
|
||||
_ = writeBundleResult(conn, res)
|
||||
}
|
||||
|
||||
// receiveBundle reads the header + framed bundle bytes, verifies the bundle, and
|
||||
// extracts it under bundleDir. Every failure returns a non-OK result rather than
|
||||
// panicking, and the staged temp file is always removed.
|
||||
func (b *Bridge) receiveBundle(conn net.Conn) bundleResult {
|
||||
hdr, err := readBundleHeader(conn)
|
||||
if err != nil {
|
||||
return bundleResult{Message: "read bundle header: " + err.Error()}
|
||||
}
|
||||
id, err := sanitizeLinkID(hdr.LinkID)
|
||||
if err != nil {
|
||||
return bundleResult{Message: err.Error()}
|
||||
}
|
||||
if hdr.Size <= 0 || hdr.Size > b.maxBundleBytes {
|
||||
return bundleResult{Message: fmt.Sprintf("invalid bundle size %d (max %d)", hdr.Size, b.maxBundleBytes)}
|
||||
}
|
||||
|
||||
tmp, err := os.CreateTemp("", "zero-remote-*.bundle")
|
||||
if err != nil {
|
||||
return bundleResult{Message: "stage bundle: " + err.Error()}
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
defer func() { _ = os.Remove(tmpName) }()
|
||||
if err := streamFramesToFile(conn, tmp, hdr.Size); err != nil {
|
||||
_ = tmp.Close()
|
||||
return bundleResult{Message: "receive bundle: " + err.Error()}
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return bundleResult{Message: "stage bundle: " + err.Error()}
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), gitTimeout)
|
||||
defer cancel()
|
||||
if err := gitBundleVerify(ctx, tmpName); err != nil {
|
||||
return bundleResult{Message: "bundle verify: " + err.Error()}
|
||||
}
|
||||
dest := filepath.Join(b.bundleDir, id)
|
||||
if !withinDir(b.bundleDir, dest) {
|
||||
return bundleResult{Message: "invalid link id"}
|
||||
}
|
||||
if err := extractBundle(ctx, tmpName, dest); err != nil {
|
||||
return bundleResult{Message: "extract bundle: " + err.Error()}
|
||||
}
|
||||
return bundleResult{OK: true, Path: dest}
|
||||
}
|
||||
|
||||
// streamFramesToFile copies exactly size bytes from KindData frames on r into w.
|
||||
// A non-data frame, or any frame that would overrun the declared size, is an
|
||||
// error (fail closed) so a peer cannot write past the cap.
|
||||
func streamFramesToFile(r io.Reader, w io.Writer, size int64) error {
|
||||
remaining := size
|
||||
for remaining > 0 {
|
||||
kind, payload, err := daemon.ReadFrame(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if kind != daemon.KindData {
|
||||
return errors.New("expected bundle data frame")
|
||||
}
|
||||
if int64(len(payload)) > remaining {
|
||||
return errors.New("bundle exceeds declared size")
|
||||
}
|
||||
if _, err := w.Write(payload); err != nil {
|
||||
return err
|
||||
}
|
||||
remaining -= int64(len(payload))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// extractBundle clones bundleFile into a staging dir, then atomically renames it
|
||||
// over dest (replacing any prior extraction for this link id). git clone needs a
|
||||
// non-existent target, so the staging+rename keeps the live dest intact on error.
|
||||
func extractBundle(ctx context.Context, bundleFile, dest string) error {
|
||||
parent := filepath.Dir(dest)
|
||||
if err := os.MkdirAll(parent, 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
staging, err := os.MkdirTemp(parent, ".staging-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = os.RemoveAll(staging) }()
|
||||
cloneDest := filepath.Join(staging, "repo")
|
||||
if err := gitClone(ctx, bundleFile, cloneDest); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.RemoveAll(dest); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(cloneDest, dest)
|
||||
}
|
||||
|
||||
// ---- client side -----------------------------------------------------------
|
||||
|
||||
// UploadRepoBundle creates a git bundle of repoDir's full history and uploads it
|
||||
// to the remote bridge over an authenticated, bundle-mode TLS connection. The
|
||||
// bridge extracts it into a per-link working tree and returns its path, captured
|
||||
// in the returned SessionLink. repoDir must be a git work tree.
|
||||
func UploadRepoBundle(cfg RemoteConfig, repoDir, linkID string) (*SessionLink, error) {
|
||||
id, err := sanitizeLinkID(linkID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
repoDir = strings.TrimSpace(repoDir)
|
||||
if repoDir == "" {
|
||||
return nil, errors.New("remote: repo dir is required")
|
||||
}
|
||||
if !isGitWorktree(repoDir) {
|
||||
return nil, fmt.Errorf("remote: %s is not a git repository", repoDir)
|
||||
}
|
||||
|
||||
// Reserve a unique temp name, then let git create the bundle fresh at it.
|
||||
tmp, err := os.CreateTemp("", "zero-bundle-*.bundle")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("remote: stage bundle: %w", err)
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
_ = tmp.Close()
|
||||
_ = os.Remove(tmpName)
|
||||
defer func() { _ = os.Remove(tmpName) }()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), gitTimeout)
|
||||
defer cancel()
|
||||
if err := gitBundleCreate(ctx, repoDir, tmpName); err != nil {
|
||||
return nil, fmt.Errorf("remote: create bundle: %w", err)
|
||||
}
|
||||
sum, size, err := hashFile(tmpName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("remote: hash bundle: %w", err)
|
||||
}
|
||||
|
||||
conn, err := dialAuthenticated(cfg, ModeBundle)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = conn.Close() }()
|
||||
|
||||
if err := writeBundleHeader(conn, bundleHeader{LinkID: id, Size: size}); err != nil {
|
||||
return nil, fmt.Errorf("remote: send bundle header: %w", err)
|
||||
}
|
||||
if err := streamFileFrames(conn, tmpName); err != nil {
|
||||
return nil, fmt.Errorf("remote: send bundle: %w", err)
|
||||
}
|
||||
res, err := readBundleResult(conn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("remote: bundle result: %w", err)
|
||||
}
|
||||
if !res.OK {
|
||||
return nil, fmt.Errorf("remote: bundle rejected: %s", res.Message)
|
||||
}
|
||||
|
||||
return &SessionLink{
|
||||
Address: strings.TrimSpace(cfg.Address),
|
||||
ServerName: strings.TrimSpace(cfg.ServerName),
|
||||
CACertFile: strings.TrimSpace(cfg.CACertFile),
|
||||
LinkID: id,
|
||||
RemotePath: res.Path,
|
||||
BundleSHA256: sum,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// streamFileFrames writes the file at path to w as a sequence of KindData frames.
|
||||
func streamFileFrames(w io.Writer, path string) error {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
buf := make([]byte, bundleChunkSize)
|
||||
for {
|
||||
n, err := f.Read(buf)
|
||||
if n > 0 {
|
||||
if werr := daemon.WriteFrame(w, daemon.KindData, buf[:n]); werr != nil {
|
||||
return werr
|
||||
}
|
||||
}
|
||||
if err == io.EOF {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- git + path helpers ----------------------------------------------------
|
||||
|
||||
func gitBundleCreate(ctx context.Context, repoDir, outFile string) error {
|
||||
return runGit(ctx, repoDir, "bundle", "create", outFile, "--all")
|
||||
}
|
||||
|
||||
func gitBundleVerify(ctx context.Context, bundleFile string) error {
|
||||
return runGit(ctx, "", "bundle", "verify", bundleFile)
|
||||
}
|
||||
|
||||
func gitClone(ctx context.Context, bundleFile, destDir string) error {
|
||||
return runGit(ctx, "", "clone", "--quiet", bundleFile, destDir)
|
||||
}
|
||||
|
||||
// isGitWorktree reports whether dir is inside a git work tree.
|
||||
func isGitWorktree(dir string) bool {
|
||||
cmd := exec.Command("git", "-C", dir, "rev-parse", "--is-inside-work-tree")
|
||||
out, err := cmd.CombinedOutput()
|
||||
return err == nil && strings.TrimSpace(string(out)) == "true"
|
||||
}
|
||||
|
||||
// runGit runs a git subcommand, returning a concise single-line error on failure.
|
||||
func runGit(ctx context.Context, dir string, args ...string) error {
|
||||
cmd := exec.CommandContext(ctx, "git", args...)
|
||||
if dir != "" {
|
||||
cmd.Dir = dir
|
||||
}
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
msg := firstLine(strings.TrimSpace(string(out)))
|
||||
if msg == "" {
|
||||
msg = err.Error()
|
||||
}
|
||||
return fmt.Errorf("git %s: %s", args[0], msg)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// sanitizeLinkID validates a link id used as a single path component under the
|
||||
// bundle dir. It allows letters, digits, '-', '_', '.', forbids the traversal
|
||||
// names, and caps the length — so it can never escape the bundle dir.
|
||||
func sanitizeLinkID(id string) (string, error) {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
return "", errors.New("remote: link id is required")
|
||||
}
|
||||
if len(id) > 128 {
|
||||
return "", errors.New("remote: link id too long (max 128)")
|
||||
}
|
||||
if id == "." || id == ".." {
|
||||
return "", errors.New("remote: invalid link id")
|
||||
}
|
||||
for _, r := range id {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_', r == '.':
|
||||
default:
|
||||
return "", errors.New("remote: link id may only contain letters, digits, '-', '_', '.'")
|
||||
}
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// withinDir reports whether target resolves to a path inside root.
|
||||
func withinDir(root, target string) bool {
|
||||
rel, err := filepath.Rel(root, target)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return rel != ".." && !strings.HasPrefix(rel, ".."+string(os.PathSeparator))
|
||||
}
|
||||
|
||||
// hashFile returns the hex SHA-256 and byte size of the file at path.
|
||||
func hashFile(path string) (sum string, size int64, err error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
h := sha256.New()
|
||||
n, err := io.Copy(h, f)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil)), n, nil
|
||||
}
|
||||
|
||||
func firstLine(s string) string {
|
||||
if i := strings.IndexByte(s, '\n'); i >= 0 {
|
||||
return s[:i]
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
package remote
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// initTestRepo creates a temp git work tree with one committed file and returns
|
||||
// its path. It sets a deterministic identity so it does not depend on global git
|
||||
// config.
|
||||
func initTestRepo(t *testing.T, file, content string) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
run := func(args ...string) {
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = dir
|
||||
cmd.Env = append(os.Environ(),
|
||||
"GIT_AUTHOR_NAME=t", "[email protected]",
|
||||
"GIT_COMMITTER_NAME=t", "[email protected]")
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("git %v: %v\n%s", args, err, out)
|
||||
}
|
||||
}
|
||||
run("init", "-q")
|
||||
run("config", "user.email", "[email protected]")
|
||||
run("config", "user.name", "t")
|
||||
if err := os.WriteFile(filepath.Join(dir, file), []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
run("add", ".")
|
||||
run("commit", "-q", "-m", "init")
|
||||
return dir
|
||||
}
|
||||
|
||||
func TestGitBundleRoundTrip(t *testing.T) {
|
||||
repo := initTestRepo(t, "a.txt", "content")
|
||||
ctx := context.Background()
|
||||
bundle := filepath.Join(t.TempDir(), "x.bundle")
|
||||
if err := gitBundleCreate(ctx, repo, bundle); err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
if err := gitBundleVerify(ctx, bundle); err != nil {
|
||||
t.Fatalf("verify: %v", err)
|
||||
}
|
||||
dest := filepath.Join(t.TempDir(), "clone")
|
||||
if err := gitClone(ctx, bundle, dest); err != nil {
|
||||
t.Fatalf("clone: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dest, "a.txt")); err != nil {
|
||||
t.Fatalf("cloned tree missing file: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsGitWorktree(t *testing.T) {
|
||||
repo := initTestRepo(t, "f", "x")
|
||||
if !isGitWorktree(repo) {
|
||||
t.Fatal("a git repo should be detected")
|
||||
}
|
||||
if isGitWorktree(t.TempDir()) {
|
||||
t.Fatal("a plain dir should not be detected as a git repo")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeLinkID(t *testing.T) {
|
||||
for _, ok := range []string{"proj", "proj-1", "a_b.c", "ABC123"} {
|
||||
if _, err := sanitizeLinkID(ok); err != nil {
|
||||
t.Fatalf("sanitizeLinkID(%q) unexpected error: %v", ok, err)
|
||||
}
|
||||
}
|
||||
for _, bad := range []string{"", " ", ".", "..", "a/b", "a\\b", "a b", "a$b", string(make([]byte, 200))} {
|
||||
if _, err := sanitizeLinkID(bad); err == nil {
|
||||
t.Fatalf("sanitizeLinkID(%q) should error", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithinDir(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if !withinDir(root, filepath.Join(root, "child")) {
|
||||
t.Fatal("child should be within root")
|
||||
}
|
||||
if withinDir(root, filepath.Dir(root)) {
|
||||
t.Fatal("parent should not be within root")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionLinkSaveLoad(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "link.json")
|
||||
link := SessionLink{Address: "host:9000", ServerName: "host", LinkID: "proj-1", RemotePath: "/bundles/proj-1", BundleSHA256: "deadbeef"}
|
||||
if err := link.Save(path); err != nil {
|
||||
t.Fatalf("Save: %v", err)
|
||||
}
|
||||
if runtime.GOOS != "windows" {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatalf("stat: %v", err)
|
||||
}
|
||||
if perm := info.Mode().Perm(); perm != 0o600 {
|
||||
t.Fatalf("link file perm = %v, want 0600", perm)
|
||||
}
|
||||
}
|
||||
got, err := LoadSessionLink(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if *got != link {
|
||||
t.Fatalf("roundtrip mismatch: %+v != %+v", *got, link)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionLinkValidate(t *testing.T) {
|
||||
for _, bad := range []SessionLink{
|
||||
{LinkID: "p", RemotePath: "/r"}, // no address
|
||||
{Address: "h:1", RemotePath: "/r"}, // no link id
|
||||
{Address: "h:1", LinkID: "p"}, // no remote path
|
||||
} {
|
||||
if err := bad.Validate(); err == nil {
|
||||
t.Fatalf("Validate(%+v) should error", bad)
|
||||
}
|
||||
if err := bad.Save(filepath.Join(t.TempDir(), "x.json")); err == nil {
|
||||
t.Fatalf("Save of invalid link %+v should error", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadRepoBundleRejectsNonRepo(t *testing.T) {
|
||||
// A non-git dir is rejected before any dial, so the bogus address is never used.
|
||||
_, err := UploadRepoBundle(RemoteConfig{Address: "127.0.0.1:1", Token: "t"}, t.TempDir(), "p")
|
||||
if err == nil {
|
||||
t.Fatal("a non-git dir should be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBridgeBundleUploadRoundTrip(t *testing.T) {
|
||||
srv := newBridgeServer(t, staticLauncher())
|
||||
auth, _ := NewTokenAuthenticator("tok")
|
||||
bundleRoot := t.TempDir()
|
||||
addr, ca := startBridge(t, srv, BridgeOptions{Authenticator: auth, BundleDir: bundleRoot})
|
||||
|
||||
repo := initTestRepo(t, "hello.txt", "hi there")
|
||||
link, err := UploadRepoBundle(RemoteConfig{Address: addr, Token: "tok", CACertFile: ca}, repo, "proj-1")
|
||||
if err != nil {
|
||||
t.Fatalf("UploadRepoBundle: %v", err)
|
||||
}
|
||||
wantPath := filepath.Join(bundleRoot, "proj-1")
|
||||
if link.RemotePath != wantPath {
|
||||
t.Fatalf("remote path = %q, want %q", link.RemotePath, wantPath)
|
||||
}
|
||||
if link.BundleSHA256 == "" {
|
||||
t.Fatal("link should carry a bundle sha256")
|
||||
}
|
||||
// The extracted work tree should contain the committed file.
|
||||
data, err := os.ReadFile(filepath.Join(link.RemotePath, "hello.txt"))
|
||||
if err != nil {
|
||||
t.Fatalf("extracted tree missing file: %v", err)
|
||||
}
|
||||
if string(data) != "hi there" {
|
||||
t.Fatalf("extracted file content = %q", data)
|
||||
}
|
||||
|
||||
// A second upload to the same link id replaces the prior extraction.
|
||||
if _, err := UploadRepoBundle(RemoteConfig{Address: addr, Token: "tok", CACertFile: ca}, repo, "proj-1"); err != nil {
|
||||
t.Fatalf("re-upload: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBridgeBundleDisabledByDefault(t *testing.T) {
|
||||
srv := newBridgeServer(t, staticLauncher())
|
||||
auth, _ := NewTokenAuthenticator("tok")
|
||||
addr, ca := startBridge(t, srv, BridgeOptions{Authenticator: auth, AuthFailDelay: -1}) // no BundleDir
|
||||
|
||||
repo := initTestRepo(t, "f", "x")
|
||||
_, err := UploadRepoBundle(RemoteConfig{Address: addr, Token: "tok", CACertFile: ca}, repo, "p")
|
||||
if err == nil {
|
||||
t.Fatal("bundle upload must be refused when --bundle-dir is unset")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBridgeBundleRejectsBadToken(t *testing.T) {
|
||||
srv := newBridgeServer(t, staticLauncher())
|
||||
auth, _ := NewTokenAuthenticator("correct")
|
||||
addr, ca := startBridge(t, srv, BridgeOptions{Authenticator: auth, BundleDir: t.TempDir(), AuthFailDelay: -1})
|
||||
|
||||
repo := initTestRepo(t, "f", "x")
|
||||
_, err := UploadRepoBundle(RemoteConfig{Address: addr, Token: "wrong", CACertFile: ca}, repo, "p")
|
||||
if err == nil {
|
||||
t.Fatal("bundle upload with a bad token must be refused")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package remote
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/daemon"
|
||||
)
|
||||
|
||||
const defaultDialTimeout = 15 * time.Second
|
||||
|
||||
// RemoteConfig configures a remote dial.
|
||||
type RemoteConfig struct {
|
||||
// Address is host:port of the remote bridge (required).
|
||||
Address string
|
||||
// Token is the bearer token (required).
|
||||
Token string
|
||||
// CACertFile, when set, is the only CA trusted for the server cert (use for a
|
||||
// self-signed bridge). Empty => system roots. InsecureSkipVerify is never used.
|
||||
CACertFile string
|
||||
// ServerName overrides the TLS/SNI verification name; empty => host of Address.
|
||||
ServerName string
|
||||
// Timeout bounds the dial + auth handshake; 0 => default.
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
// DialRemote establishes a verified TLS connection to a remote bridge,
|
||||
// authenticates with the bearer token, and returns a daemon.Client ready for
|
||||
// Run/Attach/Status — the same client the local socket uses, so remote and local
|
||||
// share one protocol. The server certificate is always verified (never
|
||||
// InsecureSkipVerify).
|
||||
func DialRemote(cfg RemoteConfig) (*daemon.Client, error) {
|
||||
conn, err := dialAuthenticated(cfg, ModeSession)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
client, err := daemon.NewClientConn(conn)
|
||||
if err != nil {
|
||||
_ = conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
return client, nil
|
||||
}
|
||||
|
||||
// dialAuthenticated establishes a verified TLS connection, performs the
|
||||
// bearer-token auth handshake for the given mode, and returns the live conn with
|
||||
// its deadline cleared (ready for the subsequent daemon handshake or bundle
|
||||
// stream). The server certificate is always verified (never InsecureSkipVerify).
|
||||
func dialAuthenticated(cfg RemoteConfig, mode string) (net.Conn, error) {
|
||||
address := strings.TrimSpace(cfg.Address)
|
||||
if address == "" {
|
||||
return nil, errors.New("remote: address is required")
|
||||
}
|
||||
token := strings.TrimSpace(cfg.Token)
|
||||
if token == "" {
|
||||
return nil, errors.New("remote: a non-empty token is required")
|
||||
}
|
||||
tlsConfig, err := clientTLSConfig(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
timeout := cfg.Timeout
|
||||
if timeout <= 0 {
|
||||
timeout = defaultDialTimeout
|
||||
}
|
||||
// Context-aware TLS dial (tls.DialWithDialer is deprecated and not
|
||||
// cancellation-aware). The context bounds the dial + TLS handshake only; the
|
||||
// returned conn is independent of it for the subsequent auth/stream I/O.
|
||||
dialCtx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
dialer := &tls.Dialer{NetDialer: &net.Dialer{}, Config: tlsConfig}
|
||||
conn, err := dialer.DialContext(dialCtx, "tcp", address)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("remote: dial %s: %w", address, err)
|
||||
}
|
||||
// Bound the auth handshake; the daemon handshake + stream run without a deadline.
|
||||
_ = conn.SetDeadline(time.Now().Add(timeout))
|
||||
if err := writeAuthRequest(conn, authRequest{Token: token, Version: daemon.ProtoVersion, Mode: mode}); err != nil {
|
||||
_ = conn.Close()
|
||||
return nil, fmt.Errorf("remote: send auth: %w", err)
|
||||
}
|
||||
resp, err := readAuthResponse(conn)
|
||||
if err != nil {
|
||||
_ = conn.Close()
|
||||
return nil, fmt.Errorf("remote: auth handshake: %w", err)
|
||||
}
|
||||
if !resp.OK {
|
||||
_ = conn.Close()
|
||||
return nil, fmt.Errorf("%w: %s", ErrUnauthorized, resp.Message)
|
||||
}
|
||||
_ = conn.SetDeadline(time.Time{})
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
// clientTLSConfig builds a verifying client TLS config. It never disables
|
||||
// verification; for a self-signed bridge, set CACertFile to the bridge's cert.
|
||||
func clientTLSConfig(cfg RemoteConfig) (*tls.Config, error) {
|
||||
tc := &tls.Config{MinVersion: tls.VersionTLS12}
|
||||
serverName := strings.TrimSpace(cfg.ServerName)
|
||||
if serverName == "" {
|
||||
if host, _, err := net.SplitHostPort(cfg.Address); err == nil {
|
||||
serverName = host
|
||||
}
|
||||
}
|
||||
tc.ServerName = serverName
|
||||
if ca := strings.TrimSpace(cfg.CACertFile); ca != "" {
|
||||
pem, err := os.ReadFile(ca)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("remote: read CA cert: %w", err)
|
||||
}
|
||||
pool := x509.NewCertPool()
|
||||
if !pool.AppendCertsFromPEM(pem) {
|
||||
return nil, errors.New("remote: CA cert file contained no certificates")
|
||||
}
|
||||
tc.RootCAs = pool
|
||||
}
|
||||
return tc, nil
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package remote
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// SessionLink records the association between a local repo and the remote
|
||||
// working tree a bundle upload produced. It carries everything needed to reach
|
||||
// the linked repo again — the bridge address, TLS verification details, the link
|
||||
// id, and the extracted remote path — except the bearer token, which is always
|
||||
// supplied separately (never persisted to the link file).
|
||||
type SessionLink struct {
|
||||
// Address is host:port of the remote bridge.
|
||||
Address string `json:"address"`
|
||||
// ServerName overrides TLS/SNI verification; empty => host of Address.
|
||||
ServerName string `json:"server_name,omitempty"`
|
||||
// CACertFile is the CA trusted for the bridge cert (for a self-signed bridge).
|
||||
CACertFile string `json:"ca_cert_file,omitempty"`
|
||||
// LinkID is the per-link identifier (a single path component on the remote).
|
||||
LinkID string `json:"link_id"`
|
||||
// RemotePath is the extracted working tree on the remote; use it as --cwd for
|
||||
// a remote run/attach against the linked repo.
|
||||
RemotePath string `json:"remote_path"`
|
||||
// BundleSHA256 is the hex SHA-256 of the uploaded bundle, for verification.
|
||||
BundleSHA256 string `json:"bundle_sha256,omitempty"`
|
||||
}
|
||||
|
||||
// Validate checks the fields required to use a link.
|
||||
func (l SessionLink) Validate() error {
|
||||
if strings.TrimSpace(l.Address) == "" {
|
||||
return errors.New("remote: session link address is required")
|
||||
}
|
||||
if strings.TrimSpace(l.LinkID) == "" {
|
||||
return errors.New("remote: session link id is required")
|
||||
}
|
||||
if strings.TrimSpace(l.RemotePath) == "" {
|
||||
return errors.New("remote: session link remote path is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Save writes the link to path as pretty JSON with 0600 permissions, atomically
|
||||
// (write-temp-then-rename) so a reader never sees a partial file.
|
||||
func (l SessionLink) Save(path string) error {
|
||||
if err := l.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := json.MarshalIndent(l, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return atomicWriteFile(path, append(data, '\n'), 0o600)
|
||||
}
|
||||
|
||||
// LoadSessionLink reads and validates a link file written by Save.
|
||||
func LoadSessionLink(path string) (*SessionLink, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var l SessionLink
|
||||
if err := json.Unmarshal(data, &l); err != nil {
|
||||
return nil, fmt.Errorf("remote: decode session link: %w", err)
|
||||
}
|
||||
if err := l.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &l, nil
|
||||
}
|
||||
|
||||
// atomicWriteFile writes data to path via a temp file in the same directory,
|
||||
// chmod-ed to perm, then renamed into place.
|
||||
func atomicWriteFile(path string, data []byte, perm os.FileMode) error {
|
||||
dir := filepath.Dir(path)
|
||||
tmp, err := os.CreateTemp(dir, ".link-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
cleanup := true
|
||||
defer func() {
|
||||
if cleanup {
|
||||
_ = os.Remove(tmpName)
|
||||
}
|
||||
}()
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
_ = tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Chmod(perm); err != nil {
|
||||
_ = tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tmpName, path); err != nil {
|
||||
return err
|
||||
}
|
||||
cleanup = false
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Server is the daemon control plane. Mirrors reference-daemon-code-agent-js/
|
||||
// supervisor.js (single-instance lock, status file, lifecycle) + the accept loop
|
||||
// that routes framed control requests to the SessionManager/Pool. It listens on
|
||||
// an owner-only local Unix socket and NEVER binds a TCP port.
|
||||
type Server struct {
|
||||
opts ServerOptions
|
||||
startedAt time.Time
|
||||
|
||||
listener net.Listener
|
||||
lock *fileLock
|
||||
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
|
||||
wg sync.WaitGroup
|
||||
shutdownOnce sync.Once
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
// ServerOptions configures a Server.
|
||||
type ServerOptions struct {
|
||||
Paths Paths
|
||||
Manager *SessionManager
|
||||
Pool *Pool
|
||||
Version int
|
||||
Now func() time.Time
|
||||
Log func(string)
|
||||
isAlive func(int) bool // test hook for the single-instance lock
|
||||
}
|
||||
|
||||
// NewServer validates options and builds a Server.
|
||||
func NewServer(opts ServerOptions) (*Server, error) {
|
||||
if opts.Manager == nil || opts.Pool == nil {
|
||||
return nil, errors.New("daemon: server requires a Pool and SessionManager")
|
||||
}
|
||||
if opts.Paths.Socket == "" || opts.Paths.Lock == "" || opts.Paths.Status == "" {
|
||||
return nil, errors.New("daemon: server requires socket, lock, and status paths")
|
||||
}
|
||||
if opts.Version <= 0 {
|
||||
opts.Version = ProtoVersion
|
||||
}
|
||||
if opts.Now == nil {
|
||||
opts.Now = time.Now
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
return &Server{
|
||||
opts: opts,
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
done: make(chan struct{}),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Server) logf(format string, args ...any) {
|
||||
if s.opts.Log != nil {
|
||||
s.opts.Log(fmt.Sprintf(format, args...))
|
||||
}
|
||||
}
|
||||
|
||||
// Serve acquires the single-instance lock, binds the owner-only control socket,
|
||||
// writes the status file, and serves connections until Shutdown. It blocks. On
|
||||
// return it has released the lock and removed the socket/status files.
|
||||
func (s *Server) Serve() error {
|
||||
if err := checkSocketPathLength(s.opts.Paths.Socket); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := secureSocketParent(s.opts.Paths.Socket); err != nil {
|
||||
return err
|
||||
}
|
||||
lock, err := acquireLock(s.opts.Paths.Lock, s.opts.isAlive)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.lock = lock
|
||||
defer s.cleanup()
|
||||
|
||||
// A leftover socket file from an unclean exit would make Listen fail with
|
||||
// "address already in use"; we hold the lock, so any socket here is stale.
|
||||
_ = os.Remove(s.opts.Paths.Socket)
|
||||
|
||||
listener, err := net.Listen("unix", s.opts.Paths.Socket)
|
||||
if err != nil {
|
||||
return fmt.Errorf("daemon: bind control socket: %w", err)
|
||||
}
|
||||
s.listener = listener
|
||||
if err := hardenSocketFile(s.opts.Paths.Socket); err != nil {
|
||||
return fmt.Errorf("daemon: harden control socket: %w", err)
|
||||
}
|
||||
s.startedAt = s.opts.Now()
|
||||
if err := s.writeStatusFile(); err != nil {
|
||||
return err
|
||||
}
|
||||
s.logf("daemon listening on %s (pid %d)", s.opts.Paths.Socket, os.Getpid())
|
||||
|
||||
for {
|
||||
conn, err := listener.Accept()
|
||||
if err != nil {
|
||||
select {
|
||||
case <-s.done:
|
||||
s.wg.Wait()
|
||||
return nil // clean shutdown
|
||||
default:
|
||||
// Transient accept error during normal operation.
|
||||
s.logf("accept error: %v", err)
|
||||
return fmt.Errorf("daemon: accept: %w", err)
|
||||
}
|
||||
}
|
||||
s.wg.Add(1)
|
||||
go func() {
|
||||
defer s.wg.Done()
|
||||
s.handleConn(conn)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
// Shutdown stops accepting connections, cancels in-flight runs, drains the pool,
|
||||
// and removes the socket/lock/status files. Safe to call multiple times.
|
||||
func (s *Server) Shutdown() {
|
||||
s.shutdownOnce.Do(func() {
|
||||
close(s.done)
|
||||
s.cancel() // stop in-flight pool runs
|
||||
if s.listener != nil {
|
||||
_ = s.listener.Close()
|
||||
}
|
||||
s.opts.Pool.Drain()
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) cleanup() {
|
||||
if s.listener != nil {
|
||||
_ = s.listener.Close()
|
||||
}
|
||||
_ = os.Remove(s.opts.Paths.Socket)
|
||||
_ = os.Remove(s.opts.Paths.Status)
|
||||
if s.lock != nil {
|
||||
_ = s.lock.release()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) writeStatusFile() error {
|
||||
status := StatusFile{
|
||||
PID: os.Getpid(),
|
||||
Socket: s.opts.Paths.Socket,
|
||||
Version: s.opts.Version,
|
||||
StartedAt: s.startedAt,
|
||||
}
|
||||
data, err := json.MarshalIndent(status, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(s.opts.Paths.Status, data, 0o600); err != nil {
|
||||
return fmt.Errorf("daemon: write status file: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ServeConn runs the control protocol (handshake + one command) on an
|
||||
// already-established connection, reusing the exact local dispatch path. The
|
||||
// remote bridge calls it AFTER authenticating a TLS connection, so a remote
|
||||
// session is handled identically to a local one (same SessionManager/Pool, same
|
||||
// sandbox/risk model) — remote never bypasses the local controls. It closes conn.
|
||||
func (s *Server) ServeConn(conn net.Conn) { s.handleConn(conn) }
|
||||
|
||||
// handleConn performs the handshake then dispatches a single control command.
|
||||
func (s *Server) handleConn(conn net.Conn) {
|
||||
defer conn.Close()
|
||||
|
||||
hello, err := ReadControl(conn)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if hello.Type != CtrlHello {
|
||||
_ = WriteControl(conn, Ctrl{Type: CtrlError, Message: "expected hello"})
|
||||
return
|
||||
}
|
||||
version, ok := NegotiateVersion(hello.Version)
|
||||
if !ok {
|
||||
_ = WriteControl(conn, Ctrl{Type: CtrlError, Message: "unsupported protocol version"})
|
||||
return
|
||||
}
|
||||
if err := WriteControl(conn, Ctrl{Type: CtrlHelloOK, Version: version}); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
cmd, err := ReadControl(conn)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
switch cmd.Type {
|
||||
case CtrlRun:
|
||||
s.handleRun(conn, cmd)
|
||||
case CtrlAttach:
|
||||
s.handleAttach(conn, cmd)
|
||||
case CtrlStatus:
|
||||
s.handleStatus(conn)
|
||||
case CtrlShutdown:
|
||||
_ = WriteControl(conn, Ctrl{Type: CtrlAck, Message: "shutting down"})
|
||||
s.Shutdown()
|
||||
default:
|
||||
_ = WriteControl(conn, Ctrl{Type: CtrlError, Message: fmt.Sprintf("unknown command %q", cmd.Type)})
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleRun(conn net.Conn, cmd Ctrl) {
|
||||
if cmd.Session == "" {
|
||||
_ = WriteControl(conn, Ctrl{Type: CtrlError, Message: "run requires a session id"})
|
||||
return
|
||||
}
|
||||
args := cmd.Args
|
||||
if cmd.Prompt != "" {
|
||||
args = append(args, "--prompt", cmd.Prompt)
|
||||
}
|
||||
sess, err := s.opts.Manager.Start(s.ctx, WorkerSpec{Session: cmd.Session, Cwd: cmd.Cwd, Args: args})
|
||||
if err != nil {
|
||||
_ = WriteControl(conn, Ctrl{Type: CtrlError, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
_ = WriteControl(conn, Ctrl{Type: CtrlAck, Session: sess.ID()})
|
||||
buffered, live, cancel := sess.Subscribe()
|
||||
defer cancel()
|
||||
s.streamToClient(conn, sess, buffered, live)
|
||||
}
|
||||
|
||||
func (s *Server) handleAttach(conn net.Conn, cmd Ctrl) {
|
||||
if cmd.Session == "" {
|
||||
_ = WriteControl(conn, Ctrl{Type: CtrlError, Message: "attach requires a session id"})
|
||||
return
|
||||
}
|
||||
buffered, live, cancel, err := s.opts.Manager.Attach(cmd.Session)
|
||||
if err != nil {
|
||||
_ = WriteControl(conn, Ctrl{Type: CtrlError, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
defer cancel()
|
||||
sess, _ := s.opts.Manager.Get(cmd.Session)
|
||||
_ = WriteControl(conn, Ctrl{Type: CtrlAck, Session: cmd.Session})
|
||||
s.streamToClient(conn, sess, buffered, live)
|
||||
}
|
||||
|
||||
// streamToClient writes the buffered history then live lines as CtrlData frames,
|
||||
// finishing with CtrlEnd (or CtrlError if the session failed). A write error
|
||||
// (client disconnected) ends the stream without affecting the session.
|
||||
func (s *Server) streamToClient(conn net.Conn, sess *Session, buffered []string, live <-chan string) {
|
||||
for _, line := range buffered {
|
||||
if err := WriteControl(conn, Ctrl{Type: CtrlData, Line: line}); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
for {
|
||||
select {
|
||||
case line, ok := <-live:
|
||||
if !ok {
|
||||
s.finishStream(conn, sess)
|
||||
return
|
||||
}
|
||||
if err := WriteControl(conn, Ctrl{Type: CtrlData, Line: line}); err != nil {
|
||||
return
|
||||
}
|
||||
case <-s.done:
|
||||
_ = WriteControl(conn, Ctrl{Type: CtrlEnd, Message: "daemon shutting down"})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) finishStream(conn net.Conn, sess *Session) {
|
||||
if sess != nil {
|
||||
if err := sess.Err(); err != nil {
|
||||
_ = WriteControl(conn, Ctrl{Type: CtrlError, Session: sess.ID(), Message: err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
_ = WriteControl(conn, Ctrl{Type: CtrlEnd})
|
||||
}
|
||||
|
||||
func (s *Server) handleStatus(conn net.Conn) {
|
||||
report := StatusReport{
|
||||
PID: os.Getpid(),
|
||||
Version: s.opts.Version,
|
||||
Socket: s.opts.Paths.Socket,
|
||||
StartedAt: s.startedAt,
|
||||
PoolSize: s.opts.Pool.Size(),
|
||||
Workers: s.opts.Pool.WorkerStats(),
|
||||
Sessions: s.opts.Manager.Statuses(),
|
||||
QueueDepth: s.opts.Pool.QueueDepth(),
|
||||
}
|
||||
_ = WriteControl(conn, Ctrl{Type: CtrlStatusResult, Status: &report})
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func newTestServer(t *testing.T, launcher Launcher) (*Server, Paths) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
paths := Paths{
|
||||
Socket: filepath.Join(dir, "d.sock"),
|
||||
Lock: filepath.Join(dir, "d.lock"),
|
||||
Status: filepath.Join(dir, "d.status"),
|
||||
}
|
||||
pool, err := NewPool(PoolOptions{Size: 2, Launcher: launcher, KillTimeout: 200 * time.Millisecond})
|
||||
if err != nil {
|
||||
t.Fatalf("NewPool: %v", err)
|
||||
}
|
||||
mgr, err := NewSessionManager(SessionManagerOptions{Pool: pool})
|
||||
if err != nil {
|
||||
t.Fatalf("NewSessionManager: %v", err)
|
||||
}
|
||||
srv, err := NewServer(ServerOptions{Paths: paths, Manager: mgr, Pool: pool})
|
||||
if err != nil {
|
||||
t.Fatalf("NewServer: %v", err)
|
||||
}
|
||||
return srv, paths
|
||||
}
|
||||
|
||||
func waitForFile(t *testing.T, path string) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return
|
||||
}
|
||||
time.Sleep(2 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("file %s did not appear within timeout", path)
|
||||
}
|
||||
|
||||
func TestServerEndToEnd(t *testing.T) {
|
||||
out := []string{`{"type":"event","seq":1}`, `{"type":"event","seq":2}`}
|
||||
launcher, _ := seqLauncher(&fakeWorker{pid: 1, out: out, exitCode: 0})
|
||||
srv, paths := newTestServer(t, launcher)
|
||||
|
||||
serveErr := make(chan error, 1)
|
||||
go func() { serveErr <- srv.Serve() }()
|
||||
waitForFile(t, paths.Status)
|
||||
|
||||
// --- run a session and collect its stream-json output ---
|
||||
runClient, err := Dial(paths.Socket)
|
||||
if err != nil {
|
||||
t.Fatalf("Dial(run): %v", err)
|
||||
}
|
||||
var got []string
|
||||
if err := runClient.Run("s1", "", "hello", nil, func(line string) { got = append(got, line) }); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
runClient.Close()
|
||||
if len(got) != 2 || got[0] != out[0] || got[1] != out[1] {
|
||||
t.Fatalf("streamed lines = %v, want %v", got, out)
|
||||
}
|
||||
|
||||
// --- status reflects the session ---
|
||||
statusClient, err := Dial(paths.Socket)
|
||||
if err != nil {
|
||||
t.Fatalf("Dial(status): %v", err)
|
||||
}
|
||||
report, err := statusClient.Status()
|
||||
statusClient.Close()
|
||||
if err != nil {
|
||||
t.Fatalf("Status: %v", err)
|
||||
}
|
||||
if report.PoolSize != 2 {
|
||||
t.Fatalf("status PoolSize = %d, want 2", report.PoolSize)
|
||||
}
|
||||
foundSession := false
|
||||
for _, s := range report.Sessions {
|
||||
if s.ID == "s1" {
|
||||
foundSession = true
|
||||
if s.State != string(SessionDone) {
|
||||
t.Fatalf("session s1 state = %s, want done", s.State)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !foundSession {
|
||||
t.Fatalf("status missing session s1: %+v", report.Sessions)
|
||||
}
|
||||
|
||||
// --- attach to the finished session sees the buffered history ---
|
||||
attachClient, err := Dial(paths.Socket)
|
||||
if err != nil {
|
||||
t.Fatalf("Dial(attach): %v", err)
|
||||
}
|
||||
var attached []string
|
||||
if err := attachClient.Attach("s1", func(line string) { attached = append(attached, line) }); err != nil {
|
||||
t.Fatalf("Attach: %v", err)
|
||||
}
|
||||
attachClient.Close()
|
||||
if len(attached) != 2 {
|
||||
t.Fatalf("attach lines = %v, want 2 buffered", attached)
|
||||
}
|
||||
|
||||
// --- graceful stop, daemon cleans up ---
|
||||
stopClient, err := Dial(paths.Socket)
|
||||
if err != nil {
|
||||
t.Fatalf("Dial(stop): %v", err)
|
||||
}
|
||||
if err := stopClient.Shutdown(); err != nil {
|
||||
t.Fatalf("Shutdown: %v", err)
|
||||
}
|
||||
stopClient.Close()
|
||||
|
||||
select {
|
||||
case err := <-serveErr:
|
||||
if err != nil {
|
||||
t.Fatalf("Serve returned error: %v", err)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("Serve did not return after shutdown")
|
||||
}
|
||||
// Socket, status, and lock files are removed on exit.
|
||||
for _, p := range []string{paths.Socket, paths.Status, paths.Lock} {
|
||||
if _, err := os.Stat(p); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("file %s not cleaned up after shutdown: %v", p, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerSecondInstanceFails(t *testing.T) {
|
||||
block := make(chan struct{})
|
||||
defer close(block)
|
||||
launcher, _ := seqLauncher(&fakeWorker{pid: 1, waitCh: block})
|
||||
srv1, paths := newTestServer(t, launcher)
|
||||
|
||||
serveErr := make(chan error, 1)
|
||||
go func() { serveErr <- srv1.Serve() }()
|
||||
waitForFile(t, paths.Status)
|
||||
|
||||
// A second server on the same paths must refuse to start (single instance).
|
||||
pool2, _ := NewPool(PoolOptions{Size: 1, Launcher: launcher})
|
||||
mgr2, _ := NewSessionManager(SessionManagerOptions{Pool: pool2})
|
||||
srv2, _ := NewServer(ServerOptions{Paths: paths, Manager: mgr2, Pool: pool2})
|
||||
if err := srv2.Serve(); !errors.Is(err, ErrAlreadyRunning) {
|
||||
t.Fatalf("second Serve err = %v, want ErrAlreadyRunning", err)
|
||||
}
|
||||
|
||||
srv1.Shutdown()
|
||||
<-serveErr
|
||||
}
|
||||
|
||||
func TestServerRejectsUnknownCommand(t *testing.T) {
|
||||
block := make(chan struct{})
|
||||
defer close(block)
|
||||
launcher, _ := seqLauncher(&fakeWorker{pid: 1, waitCh: block})
|
||||
srv, paths := newTestServer(t, launcher)
|
||||
go func() { _ = srv.Serve() }()
|
||||
defer srv.Shutdown()
|
||||
waitForFile(t, paths.Status)
|
||||
|
||||
client, err := Dial(paths.Socket)
|
||||
if err != nil {
|
||||
t.Fatalf("Dial: %v", err)
|
||||
}
|
||||
defer client.Close()
|
||||
// Run with an empty session id is rejected with an error frame.
|
||||
err = client.Run("", "", "hi", nil, nil)
|
||||
if err == nil {
|
||||
t.Fatal("run with empty session id must return an error")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sort"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Session/lease/roster model. Mirrors reference-daemon-code-agent-js/
|
||||
// session-manager.js (sessionID -> worker + metrics), lease.js (one active
|
||||
// request per worker) and roster.js (the live session registry). Because zero's
|
||||
// `exec` worker is one-shot, each session is served by exactly one worker via
|
||||
// Pool.Run, so "one active request per worker" holds inherently and the pool's
|
||||
// bounded slots provide the queue-when-full behavior.
|
||||
|
||||
// SessionState is a session's lifecycle phase.
|
||||
type SessionState string
|
||||
|
||||
const (
|
||||
// SessionQueued: created, waiting for a free pool slot.
|
||||
SessionQueued SessionState = "queued"
|
||||
// SessionRunning: a worker is leased and producing output.
|
||||
SessionRunning SessionState = "running"
|
||||
// SessionDone: the worker finished cleanly.
|
||||
SessionDone SessionState = "done"
|
||||
// SessionFailed: the worker failed permanently / the run errored.
|
||||
SessionFailed SessionState = "failed"
|
||||
)
|
||||
|
||||
// ErrSessionExists is returned when starting a session ID already in use.
|
||||
var ErrSessionExists = errors.New("daemon: session already exists")
|
||||
|
||||
// ErrSessionNotFound is returned by Get/Attach for an unknown session ID.
|
||||
var ErrSessionNotFound = errors.New("daemon: session not found")
|
||||
|
||||
const defaultSessionBuffer = 1024
|
||||
|
||||
// Session is one routed agent run. It buffers recent output (so a late `attach`
|
||||
// sees history) and fans live lines out to subscribers. It implements the pool's
|
||||
// Sink (Line) and the lease "started" hook (Started).
|
||||
type Session struct {
|
||||
id string
|
||||
cwd string
|
||||
|
||||
mu sync.Mutex
|
||||
state SessionState
|
||||
buffer []string
|
||||
maxBuffer int
|
||||
lines int
|
||||
finished bool
|
||||
err error
|
||||
exitCode int
|
||||
subscribers map[int]chan string
|
||||
nextSub int
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
func newSession(id, cwd string, maxBuffer int) *Session {
|
||||
if maxBuffer <= 0 {
|
||||
maxBuffer = defaultSessionBuffer
|
||||
}
|
||||
return &Session{
|
||||
id: id,
|
||||
cwd: cwd,
|
||||
state: SessionQueued,
|
||||
maxBuffer: maxBuffer,
|
||||
subscribers: map[int]chan string{},
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// ID returns the session ID.
|
||||
func (s *Session) ID() string { return s.id }
|
||||
|
||||
// Done is closed when the session finishes (success or failure).
|
||||
func (s *Session) Done() <-chan struct{} { return s.done }
|
||||
|
||||
// Err returns the terminal error (nil on clean completion); valid after Done.
|
||||
func (s *Session) Err() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.err
|
||||
}
|
||||
|
||||
// State returns the current lifecycle phase.
|
||||
func (s *Session) State() SessionState {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.state
|
||||
}
|
||||
|
||||
// Started marks the session running once the pool leases a worker (lease hook).
|
||||
func (s *Session) Started() {
|
||||
s.mu.Lock()
|
||||
if s.state == SessionQueued {
|
||||
s.state = SessionRunning
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// Line records and broadcasts one stream-json output line.
|
||||
func (s *Session) Line(line string) {
|
||||
s.mu.Lock()
|
||||
s.lines++
|
||||
s.buffer = append(s.buffer, line)
|
||||
if len(s.buffer) > s.maxBuffer {
|
||||
// Drop the oldest line; the ring keeps only the most recent maxBuffer.
|
||||
s.buffer = s.buffer[len(s.buffer)-s.maxBuffer:]
|
||||
}
|
||||
subs := make([]chan string, 0, len(s.subscribers))
|
||||
for _, ch := range s.subscribers {
|
||||
subs = append(subs, ch)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
for _, ch := range subs {
|
||||
// Non-blocking: a slow/stalled subscriber must not stall the worker pump.
|
||||
// Dropped live lines remain available via the buffer for a fresh attach.
|
||||
select {
|
||||
case ch <- line:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Session) finish(exitCode int, err error) {
|
||||
s.mu.Lock()
|
||||
s.finished = true
|
||||
s.exitCode = exitCode
|
||||
s.err = err
|
||||
if err != nil {
|
||||
s.state = SessionFailed
|
||||
} else {
|
||||
s.state = SessionDone
|
||||
}
|
||||
subs := s.subscribers
|
||||
s.subscribers = map[int]chan string{}
|
||||
s.mu.Unlock()
|
||||
for _, ch := range subs {
|
||||
close(ch)
|
||||
}
|
||||
close(s.done)
|
||||
}
|
||||
|
||||
// Subscribe returns the buffered history plus a channel of subsequent live lines
|
||||
// and a cancel func. If the session has already finished, the live channel is
|
||||
// returned already closed. Mirrors attach.js.
|
||||
func (s *Session) Subscribe() (buffered []string, live <-chan string, cancel func()) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
buffered = append([]string(nil), s.buffer...)
|
||||
if s.finished {
|
||||
ch := make(chan string)
|
||||
close(ch)
|
||||
return buffered, ch, func() {}
|
||||
}
|
||||
id := s.nextSub
|
||||
s.nextSub++
|
||||
ch := make(chan string, 256)
|
||||
s.subscribers[id] = ch
|
||||
cancel = func() {
|
||||
s.mu.Lock()
|
||||
if c, ok := s.subscribers[id]; ok {
|
||||
delete(s.subscribers, id)
|
||||
close(c)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
return buffered, ch, cancel
|
||||
}
|
||||
|
||||
func (s *Session) status() SessionStatus {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return SessionStatus{ID: s.id, State: string(s.state), Lines: s.lines}
|
||||
}
|
||||
|
||||
func (s *Session) isFinished() bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.finished
|
||||
}
|
||||
|
||||
// defaultMaxSessions bounds the retained session registry so a long-running
|
||||
// daemon does not accumulate finished sessions without limit.
|
||||
const defaultMaxSessions = 256
|
||||
|
||||
// SessionManager owns the live session registry and routes each session to a
|
||||
// pool worker. Mirrors session-manager.js + roster.js. Finished sessions are
|
||||
// retained (so a late `attach` sees history) up to MaxSessions, past which the
|
||||
// oldest FINISHED ones are evicted; running/queued sessions are never evicted.
|
||||
type SessionManager struct {
|
||||
pool *Pool
|
||||
maxBuffer int
|
||||
maxSessions int
|
||||
|
||||
mu sync.Mutex
|
||||
sessions map[string]*Session
|
||||
order []string // creation order, for FIFO eviction of finished sessions
|
||||
}
|
||||
|
||||
// SessionManagerOptions configures a SessionManager.
|
||||
type SessionManagerOptions struct {
|
||||
Pool *Pool
|
||||
MaxBuffer int // per-session output ring size; 0 => default
|
||||
// MaxSessions caps the retained session registry; 0 => default. The oldest
|
||||
// FINISHED sessions are evicted once the cap is exceeded.
|
||||
MaxSessions int
|
||||
}
|
||||
|
||||
// NewSessionManager builds a manager over pool.
|
||||
func NewSessionManager(opts SessionManagerOptions) (*SessionManager, error) {
|
||||
if opts.Pool == nil {
|
||||
return nil, errors.New("daemon: session manager requires a Pool")
|
||||
}
|
||||
maxSessions := opts.MaxSessions
|
||||
if maxSessions <= 0 {
|
||||
maxSessions = defaultMaxSessions
|
||||
}
|
||||
return &SessionManager{
|
||||
pool: opts.Pool,
|
||||
maxBuffer: opts.MaxBuffer,
|
||||
maxSessions: maxSessions,
|
||||
sessions: map[string]*Session{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Start creates a session for spec and dispatches it to the pool. It returns the
|
||||
// Session immediately (non-blocking); the run proceeds in the background and the
|
||||
// session transitions queued -> running -> done/failed. A duplicate ID is
|
||||
// rejected with ErrSessionExists.
|
||||
func (m *SessionManager) Start(ctx context.Context, spec WorkerSpec) (*Session, error) {
|
||||
m.mu.Lock()
|
||||
if _, exists := m.sessions[spec.Session]; exists {
|
||||
m.mu.Unlock()
|
||||
return nil, ErrSessionExists
|
||||
}
|
||||
sess := newSession(spec.Session, spec.Cwd, m.maxBuffer)
|
||||
m.sessions[spec.Session] = sess
|
||||
m.order = append(m.order, spec.Session)
|
||||
m.pruneLocked()
|
||||
m.mu.Unlock()
|
||||
|
||||
go func() {
|
||||
code, err := m.pool.Run(ctx, spec, sess)
|
||||
sess.finish(code, err)
|
||||
}()
|
||||
return sess, nil
|
||||
}
|
||||
|
||||
// pruneLocked evicts the oldest FINISHED sessions while the registry exceeds
|
||||
// maxSessions, so a long-running daemon does not retain finished sessions without
|
||||
// bound. Running/queued sessions are never evicted (a transient burst can exceed
|
||||
// the cap until those finish). Caller must hold m.mu.
|
||||
func (m *SessionManager) pruneLocked() {
|
||||
if len(m.sessions) <= m.maxSessions {
|
||||
return
|
||||
}
|
||||
kept := m.order[:0]
|
||||
for _, id := range m.order {
|
||||
s, ok := m.sessions[id]
|
||||
if !ok {
|
||||
continue // already removed
|
||||
}
|
||||
if len(m.sessions) > m.maxSessions && s.isFinished() {
|
||||
delete(m.sessions, id)
|
||||
continue
|
||||
}
|
||||
kept = append(kept, id)
|
||||
}
|
||||
m.order = kept
|
||||
}
|
||||
|
||||
// Get returns a session by ID.
|
||||
func (m *SessionManager) Get(id string) (*Session, bool) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
s, ok := m.sessions[id]
|
||||
return s, ok
|
||||
}
|
||||
|
||||
// Attach returns the buffered history + live stream for an existing session.
|
||||
func (m *SessionManager) Attach(id string) (buffered []string, live <-chan string, cancel func(), err error) {
|
||||
s, ok := m.Get(id)
|
||||
if !ok {
|
||||
return nil, nil, nil, ErrSessionNotFound
|
||||
}
|
||||
b, l, c := s.Subscribe()
|
||||
return b, l, c, nil
|
||||
}
|
||||
|
||||
// Statuses returns a snapshot of all sessions, sorted by ID for stable output.
|
||||
func (m *SessionManager) Statuses() []SessionStatus {
|
||||
m.mu.Lock()
|
||||
sessions := make([]*Session, 0, len(m.sessions))
|
||||
for _, s := range m.sessions {
|
||||
sessions = append(sessions, s)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
out := make([]SessionStatus, 0, len(sessions))
|
||||
for _, s := range sessions {
|
||||
out = append(out, s.status())
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func drain(t *testing.T, buffered []string, live <-chan string) []string {
|
||||
t.Helper()
|
||||
got := append([]string(nil), buffered...)
|
||||
timeout := time.After(2 * time.Second)
|
||||
for {
|
||||
select {
|
||||
case line, ok := <-live:
|
||||
if !ok {
|
||||
return got
|
||||
}
|
||||
got = append(got, line)
|
||||
case <-timeout:
|
||||
t.Fatal("timed out draining session stream")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionStartRoutesAndStreams(t *testing.T) {
|
||||
launcher, _ := seqLauncher(&fakeWorker{pid: 1, out: []string{"e1", "e2", "e3"}, exitCode: 0})
|
||||
pool, _ := NewPool(PoolOptions{Size: 2, Launcher: launcher})
|
||||
mgr, err := NewSessionManager(SessionManagerOptions{Pool: pool})
|
||||
if err != nil {
|
||||
t.Fatalf("NewSessionManager: %v", err)
|
||||
}
|
||||
sess, err := mgr.Start(context.Background(), WorkerSpec{Session: "s1"})
|
||||
if err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
buffered, live, cancel := sess.Subscribe()
|
||||
defer cancel()
|
||||
got := drain(t, buffered, live)
|
||||
if len(got) != 3 || got[0] != "e1" || got[2] != "e3" {
|
||||
t.Fatalf("streamed lines = %v, want [e1 e2 e3]", got)
|
||||
}
|
||||
<-sess.Done()
|
||||
if sess.State() != SessionDone {
|
||||
t.Fatalf("state = %s, want done", sess.State())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionDuplicateIDRejected(t *testing.T) {
|
||||
block := make(chan struct{})
|
||||
defer close(block)
|
||||
launcher, _ := seqLauncher(&fakeWorker{pid: 1, waitCh: block})
|
||||
pool, _ := NewPool(PoolOptions{Size: 2, Launcher: launcher})
|
||||
mgr, _ := NewSessionManager(SessionManagerOptions{Pool: pool})
|
||||
if _, err := mgr.Start(context.Background(), WorkerSpec{Session: "dup"}); err != nil {
|
||||
t.Fatalf("first Start: %v", err)
|
||||
}
|
||||
if _, err := mgr.Start(context.Background(), WorkerSpec{Session: "dup"}); !errors.Is(err, ErrSessionExists) {
|
||||
t.Fatalf("second Start err = %v, want ErrSessionExists", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionLeaseQueuesWhenPoolFull(t *testing.T) {
|
||||
block := make(chan struct{})
|
||||
a := &fakeWorker{pid: 1, waitCh: block}
|
||||
b := &fakeWorker{pid: 2, out: []string{"b1"}, exitCode: 0}
|
||||
launcher, calls := seqLauncher(a, b)
|
||||
pool, _ := NewPool(PoolOptions{Size: 1, Launcher: launcher})
|
||||
mgr, _ := NewSessionManager(SessionManagerOptions{Pool: pool})
|
||||
|
||||
sa, _ := mgr.Start(context.Background(), WorkerSpec{Session: "a"})
|
||||
waitFor(t, func() bool { return sa.State() == SessionRunning })
|
||||
|
||||
sb, _ := mgr.Start(context.Background(), WorkerSpec{Session: "b"})
|
||||
// b must stay queued (its worker not yet launched) while a holds the slot.
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
if sb.State() != SessionQueued {
|
||||
t.Fatalf("session b state = %s, want queued while pool full", sb.State())
|
||||
}
|
||||
if n := atomic.LoadInt32(calls); n != 1 {
|
||||
t.Fatalf("launcher calls = %d, want 1 (b not launched yet)", n)
|
||||
}
|
||||
|
||||
close(block) // a finishes, slot frees, b proceeds
|
||||
<-sb.Done()
|
||||
if sb.State() != SessionDone {
|
||||
t.Fatalf("session b state = %s, want done", sb.State())
|
||||
}
|
||||
if n := atomic.LoadInt32(calls); n != 2 {
|
||||
t.Fatalf("launcher calls = %d, want 2 after slot freed", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionAttachAfterFinishSeesBuffer(t *testing.T) {
|
||||
launcher, _ := seqLauncher(&fakeWorker{pid: 1, out: []string{"x", "y"}, exitCode: 0})
|
||||
pool, _ := NewPool(PoolOptions{Size: 1, Launcher: launcher})
|
||||
mgr, _ := NewSessionManager(SessionManagerOptions{Pool: pool})
|
||||
sess, _ := mgr.Start(context.Background(), WorkerSpec{Session: "s"})
|
||||
<-sess.Done()
|
||||
|
||||
buffered, live, cancel, err := mgr.Attach("s")
|
||||
if err != nil {
|
||||
t.Fatalf("Attach: %v", err)
|
||||
}
|
||||
defer cancel()
|
||||
got := drain(t, buffered, live)
|
||||
if len(got) != 2 || got[0] != "x" || got[1] != "y" {
|
||||
t.Fatalf("attach buffered = %v, want [x y]", got)
|
||||
}
|
||||
if _, _, _, err := mgr.Attach("missing"); !errors.Is(err, ErrSessionNotFound) {
|
||||
t.Fatalf("Attach(missing) err = %v, want ErrSessionNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionManagerEvictsFinishedOverCap(t *testing.T) {
|
||||
launcher, _ := seqLauncher(
|
||||
&fakeWorker{pid: 1, out: []string{"a"}, exitCode: 0},
|
||||
&fakeWorker{pid: 2, out: []string{"b"}, exitCode: 0},
|
||||
&fakeWorker{pid: 3, out: []string{"c"}, exitCode: 0},
|
||||
)
|
||||
pool, _ := NewPool(PoolOptions{Size: 2, Launcher: launcher})
|
||||
mgr, _ := NewSessionManager(SessionManagerOptions{Pool: pool, MaxSessions: 2})
|
||||
|
||||
for _, id := range []string{"s1", "s2", "s3"} {
|
||||
s, err := mgr.Start(context.Background(), WorkerSpec{Session: id})
|
||||
if err != nil {
|
||||
t.Fatalf("Start(%s): %v", id, err)
|
||||
}
|
||||
<-s.Done() // finish before starting the next so prune can evict it
|
||||
}
|
||||
|
||||
// Past the cap of 2, the oldest FINISHED session (s1) is evicted; the two
|
||||
// newest are retained.
|
||||
if _, ok := mgr.Get("s1"); ok {
|
||||
t.Fatal("oldest finished session must be evicted past the cap")
|
||||
}
|
||||
if _, ok := mgr.Get("s2"); !ok {
|
||||
t.Fatal("s2 must be retained")
|
||||
}
|
||||
if _, ok := mgr.Get("s3"); !ok {
|
||||
t.Fatal("s3 (newest) must be retained")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionManagerKeepsRunningOverCap(t *testing.T) {
|
||||
// All sessions are running (workers block), so none are evicted even past cap.
|
||||
block := make(chan struct{})
|
||||
defer close(block)
|
||||
launcher, _ := seqLauncher(
|
||||
&fakeWorker{pid: 1, waitCh: block},
|
||||
&fakeWorker{pid: 2, waitCh: block},
|
||||
)
|
||||
pool, _ := NewPool(PoolOptions{Size: 2, Launcher: launcher})
|
||||
mgr, _ := NewSessionManager(SessionManagerOptions{Pool: pool, MaxSessions: 1})
|
||||
|
||||
s1, _ := mgr.Start(context.Background(), WorkerSpec{Session: "r1"})
|
||||
s2, _ := mgr.Start(context.Background(), WorkerSpec{Session: "r2"})
|
||||
waitFor(t, func() bool { return s1.State() == SessionRunning && s2.State() == SessionRunning })
|
||||
if _, ok := mgr.Get("r1"); !ok {
|
||||
t.Fatal("a running session must never be evicted, even past the cap")
|
||||
}
|
||||
if _, ok := mgr.Get("r2"); !ok {
|
||||
t.Fatal("a running session must never be evicted, even past the cap")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionStatuses(t *testing.T) {
|
||||
launcher, _ := seqLauncher(&fakeWorker{pid: 1, out: []string{"l"}, exitCode: 0})
|
||||
pool, _ := NewPool(PoolOptions{Size: 1, Launcher: launcher})
|
||||
mgr, _ := NewSessionManager(SessionManagerOptions{Pool: pool})
|
||||
sess, _ := mgr.Start(context.Background(), WorkerSpec{Session: "only"})
|
||||
<-sess.Done()
|
||||
st := mgr.Statuses()
|
||||
if len(st) != 1 || st[0].ID != "only" || st[0].State != string(SessionDone) || st[0].Lines != 1 {
|
||||
t.Fatalf("statuses = %+v, want one done session with 1 line", st)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// maxUnixSocketPath bounds the socket path to the smallest platform sun_path
|
||||
// limit so we surface a clear error instead of a cryptic bind failure. macOS
|
||||
// allows 104 bytes incl. the NUL (so 103 chars); Linux allows 108. 103 is the
|
||||
// safe cross-platform ceiling.
|
||||
const maxUnixSocketPath = 103
|
||||
|
||||
// secureSocketParent creates the socket's parent directory owner-only (0700 on
|
||||
// POSIX; on Windows the per-user profile directory is already ACL-restricted).
|
||||
func secureSocketParent(socketPath string) error {
|
||||
return os.MkdirAll(filepath.Dir(socketPath), 0o700)
|
||||
}
|
||||
|
||||
// checkSocketPathLength rejects an over-long unix socket path before bind.
|
||||
func checkSocketPathLength(socketPath string) error {
|
||||
if len(socketPath) > maxUnixSocketPath {
|
||||
return fmt.Errorf("daemon: socket path too long (%d > %d): %s", len(socketPath), maxUnixSocketPath, socketPath)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
//go:build !windows
|
||||
|
||||
package daemon
|
||||
|
||||
import "os"
|
||||
|
||||
// hardenSocketFile restricts the control socket to its owner (0600) so no other
|
||||
// local user can connect. Combined with the 0700 parent directory this makes the
|
||||
// control plane owner-only, as required by the security model.
|
||||
func hardenSocketFile(path string) error {
|
||||
return os.Chmod(path, 0o600)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
//go:build windows
|
||||
|
||||
package daemon
|
||||
|
||||
// hardenSocketFile is a no-op on Windows: AF_UNIX socket files do not honor POSIX
|
||||
// mode bits, and the daemon places the socket under the per-user profile
|
||||
// directory, which is already ACL-restricted to the owner. A dedicated restricted
|
||||
// ACL (or a named pipe with an explicit DACL) is a documented follow-up.
|
||||
func hardenSocketFile(string) error { return nil }
|
||||
@@ -0,0 +1,43 @@
|
||||
package daemon
|
||||
|
||||
import "time"
|
||||
|
||||
// StatusReport is the daemon/worker/session snapshot returned by `zero daemon
|
||||
// status` (CtrlStatusResult). Mirrors reference-daemon-code-agent-js/status.js.
|
||||
type StatusReport struct {
|
||||
PID int `json:"pid"`
|
||||
Version int `json:"version"`
|
||||
Socket string `json:"socket"`
|
||||
StartedAt time.Time `json:"startedAt"`
|
||||
PoolSize int `json:"poolSize"`
|
||||
Workers []WorkerStatus `json:"workers"`
|
||||
Sessions []SessionStatus `json:"sessions"`
|
||||
QueueDepth int `json:"queueDepth"`
|
||||
}
|
||||
|
||||
// WorkerStatus is one worker's lifecycle snapshot.
|
||||
type WorkerStatus struct {
|
||||
ID int `json:"id"`
|
||||
PID int `json:"pid"`
|
||||
State string `json:"state"`
|
||||
ConsecutiveCrashes int `json:"consecutiveCrashes"`
|
||||
Restarts int `json:"restarts"`
|
||||
Session string `json:"session,omitempty"`
|
||||
}
|
||||
|
||||
// SessionStatus is one session's routing/metrics snapshot.
|
||||
type SessionStatus struct {
|
||||
ID string `json:"id"`
|
||||
State string `json:"state"`
|
||||
Lines int `json:"lines"`
|
||||
}
|
||||
|
||||
// StatusFile is the on-disk daemon status document (pid, socket, version,
|
||||
// started-at), written next to the lock so `status`/`stop` work without a live
|
||||
// connection. Mirrors supervisor.js's status file.
|
||||
type StatusFile struct {
|
||||
PID int `json:"pid"`
|
||||
Socket string `json:"socket"`
|
||||
Version int `json:"version"`
|
||||
StartedAt time.Time `json:"startedAt"`
|
||||
}
|
||||
@@ -43,6 +43,8 @@ type Options struct {
|
||||
UserConfig string
|
||||
ProjectConfig string
|
||||
Provider config.ProviderProfile
|
||||
WorkspaceRoot string
|
||||
Sandbox config.SandboxConfig
|
||||
Connectivity bool
|
||||
ProviderHealth *providerhealth.Result
|
||||
// GOOS overrides the platform used to resolve the sandbox backend. Empty
|
||||
@@ -69,7 +71,7 @@ func Run(options Options) Report {
|
||||
modelCheck := providerModelCheck(options.Provider)
|
||||
checks = append(checks, modelCheck)
|
||||
checks = append(checks, connectivityCheck(options.Provider, options.Connectivity, modelCheck.Status, options.ProviderHealth))
|
||||
checks = append(checks, sandboxBackendCheck(options.GOOS, options.LookupExecutable))
|
||||
checks = append(checks, sandboxBackendCheck(options.GOOS, options.LookupExecutable, options.WorkspaceRoot, options.Sandbox))
|
||||
checks = append(checks, lspServersCheck(options.LookupExecutable))
|
||||
|
||||
report := Report{
|
||||
@@ -158,6 +160,9 @@ func providerModelCheck(profile config.ProviderProfile) Check {
|
||||
if emptyProviderProfile(profile) {
|
||||
return check("provider.model", "Provider model", StatusWarn, "Model validity was skipped because provider config is unavailable.", nil)
|
||||
}
|
||||
if strings.TrimSpace(profile.Model) == "" {
|
||||
return check("provider.model", "Provider model", StatusFail, "Provider model is required.", map[string]any{"provider": providerName(profile)})
|
||||
}
|
||||
registry, err := modelregistry.DefaultRegistry()
|
||||
if err != nil {
|
||||
return check("provider.model", "Provider model", StatusFail, "Model registry could not be loaded: "+err.Error(), nil)
|
||||
@@ -165,7 +170,7 @@ func providerModelCheck(profile config.ProviderProfile) Check {
|
||||
model, err := registry.Require(profile.Model)
|
||||
if err != nil {
|
||||
if profile.ProviderKind == config.ProviderKindOpenAICompatible || profile.ProviderKind == config.ProviderKindAnthropicCompat {
|
||||
return check("provider.model", "Provider model", StatusWarn, fmt.Sprintf("Custom %s model was not found in the Zero registry; runtime will pass it through to the configured provider.", profile.ProviderKind), map[string]any{"model": profile.Model, "provider": providerName(profile)})
|
||||
return check("provider.model", "Provider model", StatusWarn, fmt.Sprintf("Custom %s model was not found in the Zero registry; runtime will pass it through to the configured provider. Run `zero doctor --connectivity` to validate the endpoint and auth.", profile.ProviderKind), map[string]any{"model": profile.Model, "provider": providerName(profile)})
|
||||
}
|
||||
return check("provider.model", "Provider model", StatusFail, "Provider model is invalid: "+err.Error(), map[string]any{"model": profile.Model})
|
||||
}
|
||||
|
||||
Loaded 100 of 390 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user