Compare commits

..
Author SHA1 Message Date
Vasanthdev2004 c4de52a5c5 Address CodeRabbit re-review: anti-averaging README + fixture-isolation test
Re-review at d0615249 asked for the pass-rate contract to be loud to
downstream consumers and for the fixture-isolation property to be asserted,
not just read from code.

- reports/README.md gains a prominent 'do not average the tier pass rates /
  do not report a single pass rate' warning. The schema offers no headline
  pass rate on purpose; a consumer must name the tier it is reporting. This
  is the durability fix for the misread the tier split exists to prevent.
- turn_bench_test.go gains TestCopyFixtureIsolatesSourceFromMutation: copies
  a fixture, mutates the copy, and asserts the source is byte-identical
  (and that a file added to the copy never appears in the source). Proves the
  'fixtures stay clean across runs' property without needing the binary.

Items the re-review also raised that were already satisfied: the
oracle-presence-over-declared-list precedence is documented on the
BuildOnlyClasses field (taskbench.go), and the PR body already says
'48-task manifest'. The Share->exclusiveShare rename is tracked in #701.

go build/vet/gofmt clean; go test -race green for perfbench.
2026-07-16 14:52:24 +05:30
Vasanthdev2004 d061524989 Address CodeRabbit review on 2bfb3406: omitempty + crash visibility
Three findings from CodeRabbit's review of the 3-tier pass-rate fix:

- BuildPassRate dropped its omitempty so an explicit 0.0 (all refactor
  tasks fail to compile) is no longer indistinguishable from 'no build
  tasks ran' in the JSON. Matches CorrectnessPassRate's always-serialize
  behavior, which is the whole point of the tier split: the report
  cannot be misread.
- A crashed sample (outcome.Err) now emits a Warning mirroring the
  TraceIssue path, so a run that died every iteration can't look like a
  normal benchmark with fewer measurements instead of a failure.
- cmd.Run()'s returned error is preserved on the no-run_end path: when
  stderr is empty and Run failed (binary missing, exec permission
  denied), the actual OS error is reported instead of collapsing to the
  generic 'missing terminal run_end event'.

go build/vet/gofmt clean; go test -race green for perfbench/agent/trace.
2026-07-16 14:46:42 +05:30
Vasanthdev2004 2bfb340668 perf(turn-bench): split pass/fail into oracle tiers so the pass rate can't be misread as correctness
The turn benchmark counted any exit-0 task as passed, including the 24
read-only tasks (nav/longproc/longctx/parallel) that carry no verification
oracle — so the JSON tasksPassed / "N/M passed" headline could read as a
correctness verdict it never was. Split pass/fail into three oracle tiers:

- correctness (edit grep + fix go test): the only pass rate that can move
  with model quality (tasksVerified / tasksPassed / correctnessPassRate)
- build-only (refactor go build): a non-positive oracle, reported as
  buildPassRate and excluded from correctnessPassRate; the manifest declares
  buildOnlyClasses: ["refactor"]
- latency-only (no oracle): counted in latencyOnlyTasks, never in any pass
  rate

NewTurnExecRunner no longer sets Passed=true for tasks with no
verificationCommand, and the summary headline names each tier explicitly so
an exit-0 read-only run cannot inflate a correctness number. Bumps
TurnSchemaVersion to 2. Class breakdown and tier contract documented in
internal/perfbench/MANIFEST.md and reports/README.md.

Also install the trace first-token/visible-event forwarding handlers in
agent.Run whenever options.Trace is set, not only when a UI OnText /
OnReasoning callback is set, so a headless traced run (zero exec --trace)
still captures FirstTokenAt. forwardedVisibleText stays tied to the user
callback so stall-retry semantics are unchanged. Adds a regression
assertion to the existing traced-run test.
2026-07-16 14:32:13 +05:30
Vasanthdev2004 40806aa4b1 Fix exclusive-time round-trip, identical-interval nesting, and empty-trace parse
Three correctness bugs in the attribution model found by an adversarial review
of the prior commit, all undermining the no-double-count guarantee:

1. exclusive_ms:0 was overwritten with the inclusive Duration on re-parse. A
   parent whose children tile its interval legitimately has exclusive time 0;
   WriteNDJSON emits exclusive_ms:0, but ReadNDJSON's "<= 0 means missing"
   fallback turned that back into the full duration, so every re-parsed trace
   re-introduced the double-counting the model exists to prevent (the benchmark
   harness, which ranks re-parsed traces, inherits it). Gate the fallback on
   key ABSENCE, so a written 0 is preserved.

2. deriveNesting formed a 2-cycle for two spans with identical [start, end]
   intervals: the symmetric containment check made each parent the other,
   dropping both from top-level and zeroing AttributedDuration. Break the tie
   deterministically — strict containment is an unambiguous parent; for
   identical intervals, only the lower-indexed span may parent the other, so
   one stays top-level.

3. ReadNDJSON returned a valid empty TurnTrace for empty/blank-only input, so a
   run that crashed before emitting a trace (or where --trace was not honored)
   masqueraded as a clean zero-attribution sample. Empty input now errors, so
   the harness records a TraceIssue.

Also: decode counters via json.Number so int64 values above 2^53 round-trip
exactly instead of losing precision through float64; and drop the harness's
own "exclusive <= 0 -> Duration" ranking fallback, which was the same
double-count on the re-parsed path. Added regression tests covering the
zero-exclusive round-trip, the identical-interval nesting, empty-input
rejection, and counter precision.
2026-07-16 13:56:46 +05:30
Vasanthdev2004 0f372f3844 Address review feedback: exclusive-time attribution + wired spans + fix-suite compile
Rewrite the trace attribution model so concurrent/nested spans no longer
double-count. Spans record wall intervals; the recorder derives a parent for
each by interval containment and computes exclusive time (duration minus the
union of its children). AttributedDuration sums only top-level spans; Coverage
is the union of intervals over wall, capped at 1, the honest ">=95%" metric.
Top-latency ranking uses exclusive time so shares sum to ~1 instead of being
inflated by overlapping provider_connect/generation and nested
permission_wait/tool_execution.

Wire the spans that were named but never stamped: verification around the
self-correct verify pass, and StampFirstVisibleEvent on the first forwarded
text event. Drop the tool_queue/process_wait/persistence constants that had no
stamps, and rename the misnamed near-noise prompt_build span to tool_partition.
Trim RequiredEventKeys to events a healthy short run actually emits; move
conditional phases (tools, permission, compaction, verification) to
OptionalEventKeys so a short trace does not false-fail.

Make ReadNDJSON fail loudly on a corrupt file: require a type:trace header,
error on span/counter lines that appear before it, and error when a header
yields no spans or counters.

Fix the blocking fix-suite compile error: Label's %d format verb on a string
tripped go test's vet before any fix task could verify. Make it a runtime
wrong-prefix bug ("admin-" vs "user-") that still type-checks, so each fix
task is independent again.

Manifest: correct refactor-04's stats.go to main.go, reword fix-07 to the
wrong-prefix bug, add a latency-only description (read-only tasks carry no
verification oracle by design), and frame the report README on exclusive time
and coverage.

Tests updated for the occurrence/exclusive/coverage model; trace, perfbench,
and agent green under -race.
2026-07-16 13:37:13 +05:30
Vasanthdev2004 b3e6ef1f58 Address review feedback on the per-turn tracer + benchmark harness
Benchmark runner (internal/perfbench):
- A nonzero agent exit code no longer counts as Passed. The runner set
  VerifyErr but then fell through to outcome.Passed = true, so a crashed
  run could be recorded as a pass. Return before the verification block
  when VerifyErr is already set.
- A task now passes only when every iteration passes. The previous
  "any iteration passes" rule let a flaky fail hide behind one good run.
- Each invocation copies its workspace fixture to a fresh temp dir and
  runs the agent + verifier there, so mutating tasks (edit/fix/refactor)
  can't dirty the checked-in fixtures or bleed into the next iteration.
- Trace load failures (missing/malformed trace file) are surfaced as
  result warnings via a new TraceIssue field instead of being swallowed.

Tracer (internal/trace):
- Finish freezes the recorder: Counter, addSpan, and the first-event
  stamps are dropped after Finish returns, so a snapshot captures exactly
  one turn and late stamps can't mutate it.
- WriteText propagates the first write error instead of silently
  swallowing sink failures.

Provider seam (internal/providers/providerio):
- FirstTokenAt is stamped only after a payload is accepted as real model
  output, not before handle() decides to drop it as an error payload.

Agent loop (internal/agent):
- The compaction counter increments only when the history actually
  shrinks, in both the proactive and reactive paths — paid no-ops no
  longer inflate the compaction total.
- Document on Options.Trace that a fresh Recorder is required per Run.

CLI (internal/cli/exec):
- Finish the trace at the agent.Run boundary and defer only the
  serialization, so the snapshot covers exactly the turn.
- Reject --trace / ZERO_TRACE for spec-draft runs with a clear error
  instead of silently accepting and writing nothing.

Manifest + fixtures:
- Strengthen the edit verifiers: rename and value-bump tasks now assert
  the old symbol is gone, and the getter task greps the method signature.
- Add the missing testdata/edit fixture (10 edit tasks referenced a
  directory that was never committed, so the whole edit class errored
  out at run time).
- The manifest test now stats each fixture so a missing dir fails at
  load time instead of at every run.
- longproc benchmark keeps Process live across the loop; README code
  fence gets a language tag and documents the per-invocation fixture copy.
- Add tests for the tracing wrapper (nil vs wired recorder, OnUsage
  forwarding, token aggregation) and the frozen-recorder contract.
2026-07-16 12:45:15 +05:30
Vasanthdev2004 6896fee475 test(trace): make TestAttributionRatio clock-independent
The test recorded two synthetic 10ms spans with no real delay and asserted
the attribution ratio stayed <= 1.0. On a precise clock (Linux/macOS CI) the
wall is microseconds while attributed is 20ms, so attributed/wall blows past
1.0 and the test failed — even though the ratio legitimately exceeds 1.0
when spans overlap (parallel tool execution). On Windows the coarse timer
masked it, so the failure only surfaced in CI.

Assert the contract instead: attributed is the deterministic sum of span
durations, and the ratio equals attributed/wall (0 when wall is zero). Add a
zero-wall case for the divide-by-zero guard.
2026-07-16 12:19:17 +05:30
Vasanthdev2004 2ec91f4493 Add per-turn tracing and a turn-benchmark harness (Phase 0 baseline)
Introduce an opt-in `internal/trace` package that attributes a run's wall
time to named spans — prompt build, provider connect/queue, generation,
tool queue/execution, permission wait, verification, compaction,
persistence — plus counters for model requests, tool calls, retries,
reconnects, compactions, completion nudges, acceptance checks, model
switches, and input/cached/output tokens. The recorder is nil-safe (a nil
*Recorder is a no-op), so every stamp site can call it unguarded; an
untraced run is byte-identical to today.

The recorder threads into agent.Run via a new `Options.Trace` field and a
single `trace.WithContext` assignment, so the shared providerio seam
(connect around client.Do, queue around OAuth resolve, first token at the
first non-keepalive SSE payload) reaches it through `trace.FromContext`
without any provider-interface change. All instrumentation is opt-in: when
`Options.Trace` is nil the hot path is untouched.

Headless `zero exec` gains `--trace <path>` (and `ZERO_TRACE=<path>`),
writing an agenteval-contract-compatible NDJSON trace (`-` for stderr).
The trace is pure observation; enabling it does not change behavior.

A new per-turn benchmark harness extends `internal/perfbench` (reusing the
existing TaskSet/NumericStats/subprocess runner shapes): `RunTurnBench`
aggregates per-span median/P95 across iterations, ranks the top three
controllable latency sources by share of attributed time, rolls up per
task class, and totals tokens/counts. A checked-in 42-task manifest across
seven classes (nav, edit, fix, refactor, longproc, longctx, parallel) and
local offline fixtures back it, and `make baseline` runs it against the
built `zero`, writing `internal/perfbench/reports/baseline.json`. The
report is machine-specific and regenerated, not hand-edited.

The manifest is JSON rather than YAML to avoid adding a dependency;
per-task verification targets the specific test (`go test -run <name>`)
so fix tasks are independent. Each task is a fresh `zero exec` process, so
iterations are cold-start samples; a warm in-process path is left for
follow-up.

Verified: `go build ./...`, `go vet ./...`, `gofmt -l` clean on touched
files; `go test -race` green for internal/trace, internal/agent,
internal/perfbench, and cmd/zero-perf-bench.
2026-07-16 12:04:21 +05:30
54 changed files with 144 additions and 2148 deletions

No files matched your search

-3
View File
@@ -127,9 +127,6 @@ tagged. Until then, source builds report the version `dev`.
## [Unreleased]
### Added
- Shared multi-agent skills discovery: when present, `~/.agents/skills` is searched after the primary
Zero skills dir (and before plugin skill roots). `zero skills list` / `info` and the runtime `skill`
tool share one multi-root discovery path; install/remove/lock still target only the Zero skills directory.
- `SECURITY.md` with a private vulnerability-reporting path, `CODE_OF_CONDUCT.md`, this changelog, and
GitHub issue/PR templates.
- Interactive `/theme` picker: bare `/theme` opens a popup that live-previews each palette as you move
-7
View File
@@ -129,18 +129,11 @@ the key in the wizard:
export OPENAI_API_KEY=sk-...
export ANTHROPIC_API_KEY=...
export GEMINI_API_KEY=...
export AIMLAPI_API_KEY=...
export LONGCAT_API_KEY=...
export MINIMAX_API_KEY=...
export MINIMAXI_API_KEY=...
```
To configure AI/ML API directly, run:
```bash
zero providers setup aimlapi --set-active
```
To configure Meituan LongCat (LongCat-2.0) directly, run:
```bash
+4 -14
View File
@@ -102,24 +102,14 @@ The full format spec (frontmatter fields, tool scopes, prompt conventions) is in
Skills are markdown instruction packs the agent can pull in on demand. Each skill is a directory containing a `SKILL.md`. Standalone project skill directories are not supported in this version (shared project-wide skills must go in `AGENTS.md` or as a hook). However, project plugins (section 6) may bundle skills, which are merged into the active run.
Discovery roots (earlier wins on name collisions):
1. **Primary Zero dir** — `$ZERO_SKILLS_DIR` if set, else `$XDG_DATA_HOME/zero/skills`, else `~/.local/share/zero/skills/`
2. **Shared multi-agent dir** — `~/.agents/skills/` when present (read-only discovery; never an install target)
3. **Plugin skill roots** — skills bundled by active plugins (section 6)
A missing directory is fine — Zero just omits it. Management commands (`zero skills add` / `remove` / `lock`) always write to the primary Zero dir only; `list` / `info` search primary + `~/.agents/skills`.
Discovery root: `$ZERO_SKILLS_DIR` → `$XDG_DATA_HOME/zero/skills` → `~/.local/share/zero/skills/`. A missing directory is fine — Zero just reports "no skills".
```text
~/.local/share/zero/skills/ # primary (install/remove/lock target)
~/.local/share/zero/skills/
run-benchmarks/
SKILL.md
write-changelog/
SKILL.md
~/.agents/skills/ # optional shared multi-agent root
shared-review/
SKILL.md
```
`SKILL.md` format:
@@ -136,9 +126,9 @@ description: Run the project's benchmark suite and summarize the deltas.
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. Within a single skills root, duplicate names are resolved by lexicographic directory order. Across roots, Zero loads the primary directory first, then `~/.agents/skills`, then plugin skill roots; earlier roots win name collisions. Plugin-declared skills (section 6) are merged into the active agent run at plugin activation time, so bundled skills appear in the available skills list and can be loaded with the `skill` tool.
Only `name` and `description` are recognized in the frontmatter today. The `name` defaults to the directory name. Within a single skills root, duplicate names are resolved by lexicographic directory order. During an agent run, Zero loads the default skills directory before plugin skill roots; earlier roots win name collisions silently. Plugin-declared skills (section 6) are merged into the active agent run at plugin activation time, so bundled skills appear in the available skills list and can be loaded with the `skill` tool.
The `skill` tool lets the agent load any discovered skill by name (primary, agents, or plugin).
The `skill` core tool lets the agent load any discovered skill by name.
## 4. Hooks
+3 -4
View File
@@ -733,10 +733,9 @@ flowchart TD
- **Skills** are instruction packs the model can load on demand.
- **Specialists** are sub-agents callable through the `Task` tool.
- **MCP servers** contribute external tools.
- **Plugins** can add tools, hooks, and skill roots. Bootstrap always registers a
multi-root skill tool: primary Zero skills dir, optional `~/.agents/skills`,
then plugin skill roots (earlier wins). `internal/skills` owns that merge via
`LoadFromRoots` / `DiscoveryRoots`; single-root `Load` remains for install/write.
- **Plugins** can add tools, hooks, and skill roots. Bootstrap merges plugin skill
roots into the runtime skill tool and prompt-visible skill list; the base
`internal/skills` scanner still scans one root at a time.
- **Hooks** can observe or block tool lifecycle events.
## End-to-End Data Flow
+1 -23
View File
@@ -42,7 +42,6 @@ import (
"github.com/Gitlawb/zero/internal/worktrees"
"github.com/Gitlawb/zero/internal/zerogit"
"github.com/Gitlawb/zero/internal/zeroruntime"
"github.com/charmbracelet/x/term"
)
var version = "dev"
@@ -340,17 +339,7 @@ func runWithDeps(args []string, stdout io.Writer, stderr io.Writer, deps appDeps
return 0
}
}
// Pipes and redirects keep the machine-readable contract exactly as it
// was: a single "zero <version>" line. Scripts, command substitutions,
// and the NPM wrapper smoke check all parse that record, so the banner
// is strictly a TTY affordance.
if !stdoutIsTerminal(stdout) {
if _, err := fmt.Fprintf(stdout, "zero %s\n", version); err != nil {
return 1
}
return 0
}
if _, err := fmt.Fprintf(stdout, "%s\n\nzero %s\n", tui.Wordmark(), version); err != nil {
if _, err := fmt.Fprintf(stdout, "zero %s\n", version); err != nil {
return 1
}
return 0
@@ -1126,17 +1115,6 @@ func closeSpecialistRuntime(stderr io.Writer, runtime *agentToolRuntime) {
}
}
// stdoutIsTerminal reports whether w is an interactive terminal. Tests pass
// bytes.Buffer writers and pipes/redirects fail term.IsTerminal, so both take
// the machine-readable path.
func stdoutIsTerminal(w io.Writer) bool {
f, ok := w.(*os.File)
if !ok {
return false
}
return term.IsTerminal(f.Fd())
}
func writeAppError(stderr io.Writer, message string, exitCode int) int {
if _, err := fmt.Fprintf(stderr, "[zero] %s\n", message); err != nil {
return 1
-29
View File
@@ -41,8 +41,6 @@ func TestRunPrintsVersion(t *testing.T) {
if exitCode != 0 {
t.Fatalf("expected exit code 0, got %d", exitCode)
}
// Non-terminal stdout keeps the machine-readable contract: exactly one
// "zero <version>" record, no wordmark banner, no ANSI.
if got := stdout.String(); got != "zero dev\n" {
t.Fatalf("expected version output, got %q", got)
}
@@ -51,33 +49,6 @@ func TestRunPrintsVersion(t *testing.T) {
}
}
// TestRunVersionRedirectedFile covers the `zero --version > file` path: stdout
// is a real *os.File but not a terminal, so the single-line contract must hold.
func TestRunVersionRedirectedFile(t *testing.T) {
stdout, err := os.CreateTemp(t.TempDir(), "version-stdout")
if err != nil {
t.Fatalf("create temp stdout: %v", err)
}
defer stdout.Close()
var stderr bytes.Buffer
exitCode := Run([]string{"--version"}, stdout, &stderr)
if exitCode != 0 {
t.Fatalf("expected exit code 0, got %d", exitCode)
}
content, err := os.ReadFile(stdout.Name())
if err != nil {
t.Fatalf("read temp stdout: %v", err)
}
if got := string(content); got != "zero dev\n" {
t.Fatalf("expected version output, got %q", got)
}
if stderr.Len() != 0 {
t.Fatalf("expected empty stderr, got %q", stderr.String())
}
}
func TestRunPrintsHelp(t *testing.T) {
for _, args := range [][]string{
{"--help"},
+1 -2
View File
@@ -118,8 +118,7 @@ func runSkillInfo(args []string, dir string, stdout io.Writer, stderr io.Writer)
if name == "" {
return writeExecUsageError(stderr, "usage: zero skill info <name> [--json]")
}
// Resolve across primary + ~/.agents/skills; lock metadata only from primary.
info, ok := skills.InfoFromRoots(dir, skills.GlobalRoots(dir), name)
info, ok := skills.Info(dir, name)
if !ok {
return writeAppError(stderr, fmt.Sprintf("skill %q not found", name), exitUsage)
}
+15 -15
View File
@@ -15,11 +15,11 @@ import (
// pluginActivation holds what plugin activation contributed to a bootstrap so the
// later dispatcher + skill wiring can consume it: the plugin hook definitions and
// the plugin skill search roots. The zero value (no plugins) still overlays the
// multi-root skill tool (primary + ~/.agents/skills) so discovery is identical
// with or without plugins. The embedded trustSkip reports whether the project
// plugin layer was dropped for an untrusted workspace, so the caller can fold it
// into the single combined trust notice.
// the plugin skill search roots. The zero value (no plugins) is inert — the
// dispatcher gets no extra hooks and the skill tool keeps only the default dir.
// The embedded trustSkip reports whether the project plugin layer was dropped for
// an untrusted workspace, so the caller can fold it into the single combined
// trust notice.
type pluginActivation struct {
hooks []hooks.Definition
skillRoots []string
@@ -52,9 +52,6 @@ func activatePlugins(workspaceRoot string, registry *tools.Registry, deps appDep
loaded, err := deps.loadPlugins(plugins.LoadOptions{Cwd: workspaceRoot, ExcludeProject: excludeProject})
if err != nil {
writePluginActivationWarning(stderr, "failed to load plugins: "+err.Error())
// Still overlay multi-root discovery (primary + agents) so a plugin load
// failure cannot hide shared skills.
registry.Register(plugins.NewSkillTool(deps.skillsDir(), nil))
return pluginActivation{trustSkip: skip}
}
@@ -70,11 +67,14 @@ func activatePlugins(workspaceRoot string, registry *tools.Registry, deps appDep
writePluginActivationWarning(stderr, warning)
}
// Always overlay the multi-root skill tool so discovery is identical with or
// without plugins: primary DefaultDir + optional ~/.agents/skills + plugin
// roots (earlier wins). With empty SkillRoots and no agents dir this is
// byte-equivalent to the core single-dir skill surface.
registry.Register(plugins.NewSkillTool(deps.skillsDir(), result.SkillRoots))
// Re-register the skill tool to also resolve plugin-declared skills. The core
// skill tool only reads the default skills dir; the plugin-aware replacement
// merges the default dir with the plugin skill roots (default dir wins a name
// clash), so plugin skills appear in the agent's skill list. With no plugin
// skill roots this is byte-equivalent to the default skills surface.
if len(result.SkillRoots) > 0 {
registry.Register(plugins.NewSkillTool(deps.skillsDir(), result.SkillRoots))
}
return pluginActivation{hooks: result.Hooks, skillRoots: result.SkillRoots, trustSkip: skip}
}
@@ -91,8 +91,8 @@ func projectPluginsDirExists(workspaceRoot string) bool {
}
// skillInfos resolves the reusable skills the model can load via the skill tool —
// primary dir + optional ~/.agents/skills + plugin skill roots, the same set the
// multi-root skill tool resolves against — as plain data for the agent's system
// the default skills dir merged with any plugin skill roots, the same set the
// plugin-aware skill tool resolves against — as plain data for the agent's system
// prompt. It returns nil when no skills are installed, so a skill-less run leaves
// the prompt byte-identical.
func (a pluginActivation) skillInfos(defaultDir string) []agent.SkillInfo {
-41
View File
@@ -69,11 +69,6 @@ func TestActivatePluginsRegistersToolAndCollectsHooks(t *testing.T) {
}
func TestActivatePluginsRegistersPluginSkillTool(t *testing.T) {
// Isolate host ~/.agents/skills so it cannot interfere with this test.
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
pluginDir := t.TempDir()
skillDir := filepath.Join(pluginDir, "skills", "demo-skill")
if err := os.MkdirAll(skillDir, 0o755); err != nil {
@@ -113,42 +108,6 @@ func TestActivatePluginsRegistersPluginSkillTool(t *testing.T) {
}
}
func TestActivatePluginsAlwaysRegistersMultiRootSkillTool(t *testing.T) {
// Even with zero plugins / zero plugin skill roots, activation must overlay
// the multi-root skill tool so ~/.agents/skills is loadable on bare runs.
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
agents := filepath.Join(home, ".agents", "skills")
if err := os.MkdirAll(agents, 0o755); err != nil {
t.Fatal(err)
}
skillDir := filepath.Join(agents, "agents-skill")
if err := os.MkdirAll(skillDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("---\nname: agents-skill\n---\nagents body"), 0o644); err != nil {
t.Fatal(err)
}
registry := tools.NewRegistry()
registry.Register(tools.NewSkillTool(t.TempDir())) // core single-dir tool
var stderr bytes.Buffer
workspace := t.TempDir()
activation := activatePlugins(workspace, registry, fakePluginDeps(t, nil), &stderr, workspace)
if len(activation.skillRoots) != 0 {
t.Fatalf("expected no plugin skill roots, got %#v", activation.skillRoots)
}
skillTool, ok := registry.Get("skill")
if !ok {
t.Fatal("skill tool missing after activation")
}
res := skillTool.Run(context.Background(), map[string]any{"name": "agents-skill"})
if res.Status != tools.StatusOK || !bytes.Contains([]byte(res.Output), []byte("agents body")) {
t.Fatalf("agents skill must load with zero plugin roots: %q (%s)", res.Status, res.Output)
}
}
func TestActivatePluginsSurfacesLoadDiagnostics(t *testing.T) {
// Load succeeds (no top-level error) but reports a per-plugin diagnostic for a
// plugin it had to skip. activatePlugins must forward that diagnostic to stderr
@@ -17,7 +17,6 @@ func TestProviderCatalogRuntimeProvidersShowOnboardingSetupHints(t *testing.T) {
defaultModel string
}{
{id: "openai", name: "OpenAI", transport: "openai", defaultModel: "gpt-4.1"},
{id: "aimlapi", name: "AI/ML API", transport: "openai-compatible", defaultModel: "openai/gpt-5-chat"},
{id: "groq", name: "Groq", transport: "openai-compatible", defaultModel: "llama-3.3-70b-versatile"},
{id: "longcat", name: "LongCat", transport: "openai-compatible", defaultModel: "LongCat-2.0"},
{id: "ollama-cloud", name: "Ollama Cloud", transport: "openai-compatible", defaultModel: "qwen3-coder:480b"},
@@ -48,21 +47,6 @@ func TestProviderCatalogRuntimeProvidersShowOnboardingSetupHints(t *testing.T) {
}
}
func TestProviderCatalogAIMLAPIRequiresAPIKey(t *testing.T) {
output := runProviderCatalogOnboarding(t)
block := providerCatalogOnboardingBlock(t, output, "aimlapi")
for _, want := range []string{
"requiresAuth=true",
"authEnvVars=AIMLAPI_API_KEY",
"setup: zero providers setup aimlapi --set-active",
} {
if !strings.Contains(block, want) {
t.Fatalf("expected AIMLAPI catalog block to contain %q, got:\n%s", want, block)
}
}
}
func TestProviderCatalogLongCatRequiresAPIKey(t *testing.T) {
output := runProviderCatalogOnboarding(t)
block := providerCatalogOnboardingBlock(t, output, "longcat")
+22 -21
View File
@@ -59,21 +59,25 @@ func runSkills(args []string, stdout io.Writer, stderr io.Writer, deps appDeps)
}
func runSkillsList(dir string, options skillListOptions, stdout io.Writer, stderr io.Writer) int {
// Management CLI lists global roots only (primary + ~/.agents/skills); plugin
// skills stay out of `zero skills list` and surface via the agent skill tool.
roots := skills.GlobalRoots(dir)
discovered, dups, err := skills.ListFromRoots(roots)
discovered, err := skills.List(dir)
if err != nil {
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
}
// Surface name collisions that ListFromRoots silently resolved (earlier root
// wins), so a shadowed same-named skill is reported instead of just
// disappearing. Warnings go to stderr, keeping stdout (including --json) clean.
for _, dup := range dups {
fmt.Fprintf(stderr, "warning: duplicate skill %q: using %s, ignoring %s\n",
redaction.RedactString(dup.Name, redaction.Options{}),
redaction.RedactString(dup.Winner, redaction.Options{}),
redaction.RedactString(dup.Loser, redaction.Options{}))
// Surface name collisions that List silently resolved (first directory wins),
// so a shadowed same-named skill is reported instead of just disappearing.
// Warnings go to stderr, keeping stdout (including --json) clean.
if dups, derr := skills.Duplicates(dir); derr == nil {
for _, dup := range dups {
fmt.Fprintf(stderr, "warning: duplicate skill %q: using %s, ignoring %s\n",
redaction.RedactString(dup.Name, redaction.Options{}),
redaction.RedactString(dup.Winner, redaction.Options{}),
redaction.RedactString(dup.Loser, redaction.Options{}))
}
} else {
// Don't silently swallow a scan failure: "no warnings" would then be
// ambiguous (no duplicates vs. detection broke). Surface it on stderr.
fmt.Fprintf(stderr, "warning: could not check for duplicate skills: %s\n",
redaction.ErrorMessage(derr, redaction.Options{}))
}
if options.json {
payload := struct {
@@ -84,16 +88,16 @@ func runSkillsList(dir string, options skillListOptions, stdout io.Writer, stder
}
return exitSuccess
}
output := redaction.RedactString(formatSkillList(discovered), redaction.Options{})
output := redaction.RedactString(formatSkillList(discovered, dir), redaction.Options{})
if _, err := fmt.Fprintln(stdout, output); err != nil {
return exitCrash
}
return exitSuccess
}
func formatSkillList(discovered []skills.Skill) string {
func formatSkillList(discovered []skills.Skill, dir string) string {
if len(discovered) == 0 {
return "No skills found."
return fmt.Sprintf("No Zero skills found in %s.", dir)
}
lines := []string{"Zero Skills:"}
for _, skill := range discovered {
@@ -127,13 +131,10 @@ func writeSkillsHelp(w io.Writer) error {
zero skills <command>
Commands:
list List discovered skills (Zero dir + ~/.agents/skills)
add <git-url|path> Install a skill into the Zero skills dir (checksum-pinned in skills.lock)
list List discovered Zero skills
add <git-url|path> Install a skill (checksum-pinned in skills.lock)
info <name> Show a skill's frontmatter, source, and pinned hash
remove <name> Remove an installed skill and its lockfile entry from the Zero skills dir
list and info also search ~/.agents/skills when present (read-only).
add and remove always target the Zero-specific skills directory.
remove <name> Remove an installed skill and its lockfile entry
`)
return err
}
+1 -91
View File
@@ -23,15 +23,7 @@ func writeSkillFixture(t *testing.T, dir string, name string, content string) {
}
}
func isolateCLIAgentsHome(t *testing.T) {
t.Helper()
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
}
func TestRunSkillsListText(t *testing.T) {
isolateCLIAgentsHome(t)
dir := t.TempDir()
writeSkillFixture(t, dir, "confirmation-policy", "---\nname: confirmation-policy\ndescription: Ask before risky actions.\n---\nbody")
@@ -51,7 +43,6 @@ func TestRunSkillsListText(t *testing.T) {
}
func TestRunSkillsListWarnsOnDuplicateNames(t *testing.T) {
isolateCLIAgentsHome(t)
dir := t.TempDir()
// Two directories declare the same frontmatter name; List keeps one and the
// other is shadowed. The command must warn instead of silently dropping it.
@@ -71,7 +62,6 @@ func TestRunSkillsListWarnsOnDuplicateNames(t *testing.T) {
}
func TestRunSkillsDefaultsToList(t *testing.T) {
isolateCLIAgentsHome(t)
dir := t.TempDir()
writeSkillFixture(t, dir, "demo", "body")
@@ -88,7 +78,6 @@ func TestRunSkillsDefaultsToList(t *testing.T) {
}
func TestRunSkillsListJSON(t *testing.T) {
isolateCLIAgentsHome(t)
dir := t.TempDir()
writeSkillFixture(t, dir, "demo", "---\nname: demo\ndescription: a demo\n---\nbody")
@@ -122,7 +111,6 @@ func TestRunSkillsListJSON(t *testing.T) {
}
func TestRunSkillsEmptyDir(t *testing.T) {
isolateCLIAgentsHome(t)
var stdout, stderr bytes.Buffer
exit := runWithDeps([]string{"skills", "list"}, &stdout, &stderr, appDeps{
skillsDir: func() string { return filepath.Join(t.TempDir(), "missing") },
@@ -130,89 +118,11 @@ func TestRunSkillsEmptyDir(t *testing.T) {
if exit != 0 {
t.Fatalf("exit = %d, stderr = %s", exit, stderr.String())
}
if !strings.Contains(strings.ToLower(stdout.String()), "no skills found") {
if !strings.Contains(strings.ToLower(stdout.String()), "no") {
t.Fatalf("expected a no-skills message, got:\n%s", stdout.String())
}
}
func TestRunSkillsListIncludesAgentsOnlySkill(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
agents := filepath.Join(home, ".agents", "skills")
writeSkillFixture(t, agents, "agents-only", "---\nname: agents-only\ndescription: Shared multi-agent skill.\n---\nbody")
primary := t.TempDir()
var stdout, stderr bytes.Buffer
exit := runWithDeps([]string{"skills", "list"}, &stdout, &stderr, appDeps{
skillsDir: func() string { return primary },
})
if exit != 0 {
t.Fatalf("exit = %d, stderr = %s", exit, stderr.String())
}
out := stdout.String()
if !strings.Contains(out, "agents-only") {
t.Fatalf("list should include agents-only skill:\n%s", out)
}
if !strings.Contains(out, agents) {
t.Fatalf("list should show agents path:\n%s", out)
}
}
func TestRunSkillsListPrimaryShadowsAgents(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
agents := filepath.Join(home, ".agents", "skills")
primary := t.TempDir()
writeSkillFixture(t, primary, "shared", "---\nname: shared\ndescription: From primary.\n---\nbody")
writeSkillFixture(t, agents, "shared", "---\nname: shared\ndescription: From agents.\n---\nbody")
var stdout, stderr bytes.Buffer
exit := runWithDeps([]string{"skills", "list"}, &stdout, &stderr, appDeps{
skillsDir: func() string { return primary },
})
if exit != 0 {
t.Fatalf("exit = %d, stderr = %s", exit, stderr.String())
}
if !strings.Contains(stdout.String(), "From primary") {
t.Fatalf("primary should win list description:\n%s", stdout.String())
}
if !strings.Contains(stdout.String(), primary) {
t.Fatalf("primary path should be shown:\n%s", stdout.String())
}
if !strings.Contains(stderr.String(), `duplicate skill "shared"`) {
t.Fatalf("expected cross-root duplicate warning, got: %q", stderr.String())
}
}
func TestRunSkillInfoResolvesAgentsOnly(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
agents := filepath.Join(home, ".agents", "skills")
writeSkillFixture(t, agents, "agents-info", "---\nname: agents-info\ndescription: Agents info.\n---\nbody")
primary := t.TempDir()
var stdout, stderr bytes.Buffer
exit := runWithDeps([]string{"skill", "info", "agents-info"}, &stdout, &stderr, appDeps{
skillsDir: func() string { return primary },
})
if exit != 0 {
t.Fatalf("exit = %d, stderr = %s", exit, stderr.String())
}
out := stdout.String()
if !strings.Contains(out, "agents-info") || !strings.Contains(out, "Agents info.") {
t.Fatalf("info missing agents skill:\n%s", out)
}
if strings.Contains(out, "source:") || strings.Contains(out, "hash:") {
t.Fatalf("agents-only info must not invent lock metadata:\n%s", out)
}
if !strings.Contains(out, agents) {
t.Fatalf("info should show agents path:\n%s", out)
}
}
func TestRunSkillsDoesNotLaunchTUI(t *testing.T) {
var stdout, stderr bytes.Buffer
launchCalled := false
+50 -33
View File
@@ -271,55 +271,72 @@ func skillSearchRoot(path string) string {
// recognize a manifest path that points at the file rather than the directory.
const skillFileName = "SKILL.md"
// MergedSkills loads the default skills directory, the shared ~/.agents/skills
// root when present, plus the supplied plugin skill roots and returns one
// merged, name-deduplicated list (Content stripped, like skills.List) alongside
// the duplicate-name collisions across all roots. Earlier roots win a name
// clash, matching skills.Load's first-wins rule; the default dir is always
// considered first so a user skill shadows agents and plugins. A bad root simply
// yields no skills rather than failing the merge.
// MergedSkills loads the default skills directory plus the supplied plugin skill
// roots and returns one merged, name-deduplicated list (Content stripped, like
// skills.List) alongside the duplicate-name collisions across all roots. Earlier
// roots win a name clash, matching skills.Load's first-wins rule; the default dir
// is always considered first so a user skill shadows a same-named plugin skill.
// A bad root simply yields no skills rather than failing the merge.
func MergedSkills(defaultDir string, pluginRoots []string) ([]skills.Skill, []skills.DuplicateName) {
return mergeSkills(defaultDir, pluginRoots, false)
}
// mergeSkills merges the primary skills dir, optional ~/.agents/skills, and
// plugin roots into one sorted, name-deduplicated list via skills.LoadFromRoots /
// ListFromRoots. keepContent retains each skill's body versus stripping it.
// Roots are considered in order with the default dir first, so an earlier root
// wins a name clash; collisions are recorded rather than crashing.
// mergeSkills merges the default skills dir and plugin roots into one sorted,
// name-deduplicated list. keepContent retains each skill's body (skills.Load)
// versus stripping it (skills.List). Roots are considered in order with the
// default dir first, so an earlier root wins a name clash; collisions are recorded
// rather than crashing, preserving the skills package's Duplicates behaviour.
func mergeSkills(defaultDir string, pluginRoots []string, keepContent bool) ([]skills.Skill, []skills.DuplicateName) {
// Keep defaultDir injectable for tests rather than always calling DefaultDir.
roots := skills.GlobalRoots(defaultDir)
// GlobalRoots already includes AgentsDir; append plugin roots only.
for _, root := range pluginRoots {
if root = strings.TrimSpace(root); root != "" {
roots = append(roots, root)
merged := []skills.Skill{}
dups := []skills.DuplicateName{}
byName := map[string]skills.Skill{}
roots := append([]string{defaultDir}, pluginRoots...)
for _, root := range roots {
if strings.TrimSpace(root) == "" {
continue
}
var loaded []skills.Skill
var err error
if keepContent {
loaded, err = skills.Load(root)
} else {
loaded, err = skills.List(root)
}
if err != nil {
continue
}
for _, skill := range loaded {
if winner, clash := byName[skill.Name]; clash {
dups = append(dups, skills.DuplicateName{Name: skill.Name, Winner: winner.Path, Loser: skill.Path})
continue
}
byName[skill.Name] = skill
merged = append(merged, skill)
}
}
if keepContent {
merged, dups, _ := skills.LoadFromRoots(roots)
return merged, dups
}
merged, dups, _ := skills.ListFromRoots(roots)
sort.Slice(merged, func(left int, right int) bool {
return merged[left].Name < merged[right].Name
})
return merged, dups
}
// skillTool is a drop-in replacement for the core skill tool that resolves a
// named skill across the default skills directory, the shared ~/.agents/skills
// root, and plugin skill roots, so agents- and plugin-declared skills surface in
// the agent's skill list. It keeps the core tool's name, schema, and
// read-only/allow safety so registering it simply overlays multi-root discovery
// onto the existing surface.
// named skill across the default skills directory PLUS the plugin skill roots, so
// plugin-declared skills surface in the agent's skill list. It keeps the core
// tool's name, schema, and read-only/allow safety so registering it simply
// overlays plugin skills onto the existing surface.
type skillTool struct {
defaultDir string
pluginRoots []string
}
// NewSkillTool builds the multi-root skill tool. defaultDir is the standard
// NewSkillTool builds the plugin-aware skill tool. defaultDir is the standard
// skills directory (skills.DefaultDir); pluginRoots are the plugin skill search
// roots from an ActivationResult. The returned tool merges primary + agents +
// plugins, deterministically deduplicating by name (default dir wins a clash)
// and listing all available skills when an unknown name is requested.
// roots from an ActivationResult. The returned tool merges both, deterministically
// deduplicating by name (default dir wins a clash) and listing all available
// skills when an unknown name is requested.
func NewSkillTool(defaultDir string, pluginRoots []string) tools.Tool {
return skillTool{defaultDir: defaultDir, pluginRoots: append([]string{}, pluginRoots...)}
}
@@ -328,7 +345,7 @@ func (tool skillTool) Name() string { return "skill" }
func (tool skillTool) Description() string {
return "Load a named Zero skill and return its instructions as the tool output. " +
"Skills are reusable, on-demand instruction sets (including shared ~/.agents/skills and any contributed by plugins). " +
"Skills are reusable, on-demand instruction sets (including any contributed by plugins). " +
"Call this when a relevant skill exists; an unknown name returns the list of available skills."
}
-89
View File
@@ -350,7 +350,6 @@ func TestActivateResolvesManifestRelativeSkillRoot(t *testing.T) {
}
func TestNewSkillToolSurfacesPluginSkillInList(t *testing.T) {
isolateAgentsHome(t)
defaultDir := t.TempDir()
writeTestSkill(t, defaultDir, "core-skill", "core body")
pluginRoot := t.TempDir()
@@ -385,7 +384,6 @@ func TestNewSkillToolSurfacesPluginSkillInList(t *testing.T) {
}
func TestMergeSkillRootsListsPluginSkillInAgentList(t *testing.T) {
isolateAgentsHome(t)
defaultDir := t.TempDir()
writeTestSkill(t, defaultDir, "core-skill", "core")
@@ -406,9 +404,6 @@ func TestMergeSkillRootsListsPluginSkillInAgentList(t *testing.T) {
}
func TestMergedSkillsRecordsDuplicatesWithoutCrashing(t *testing.T) {
// Isolate AgentsDir so a host ~/.agents/skills cannot add extra collisions.
isolateAgentsHome(t)
defaultDir := t.TempDir()
writeTestSkill(t, defaultDir, "shared", "default copy")
@@ -431,90 +426,6 @@ func TestMergedSkillsRecordsDuplicatesWithoutCrashing(t *testing.T) {
}
}
// isolateAgentsHome points HOME/USERPROFILE at an empty temp home so AgentsDir
// does not pick up the developer's real ~/.agents/skills during unit tests.
func isolateAgentsHome(t *testing.T) {
t.Helper()
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
}
func TestMergedSkillsIncludesAgentsWithoutPlugins(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
agents := filepath.Join(home, ".agents", "skills")
if err := os.MkdirAll(agents, 0o755); err != nil {
t.Fatal(err)
}
writeTestSkill(t, agents, "agents-skill", "from agents")
defaultDir := t.TempDir()
listed, dups := MergedSkills(defaultDir, nil)
if len(dups) != 0 {
t.Fatalf("unexpected dups: %#v", dups)
}
if len(listed) != 1 || listed[0].Name != "agents-skill" {
t.Fatalf("agents skill should surface with no plugins, got %#v", listed)
}
}
func TestMergedSkillsPriorityPrimaryAgentsPlugins(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
agents := filepath.Join(home, ".agents", "skills")
if err := os.MkdirAll(agents, 0o755); err != nil {
t.Fatal(err)
}
primary := t.TempDir()
pluginRoot := t.TempDir()
writeTestSkill(t, primary, "shared", "primary body")
writeTestSkill(t, agents, "shared", "agents body")
writeTestSkill(t, agents, "agents-plugin", "agents body")
writeTestSkill(t, pluginRoot, "shared", "plugin body")
writeTestSkill(t, pluginRoot, "agents-plugin", "plugin body")
writeTestSkill(t, pluginRoot, "plugin-only", "plugin only")
listed, dups := MergedSkills(primary, []string{pluginRoot})
byName := map[string]string{}
for _, skill := range listed {
// Content is stripped by MergedSkills; use path origin instead.
byName[skill.Name] = skill.Path
}
if !strings.Contains(byName["shared"], primary) {
t.Fatalf("primary should win shared, path=%q", byName["shared"])
}
if !strings.Contains(byName["agents-plugin"], agents) {
t.Fatalf("agents should win over plugin for agents-plugin, path=%q", byName["agents-plugin"])
}
if !strings.Contains(byName["plugin-only"], pluginRoot) {
t.Fatalf("plugin-only missing, path=%q", byName["plugin-only"])
}
if len(dups) < 2 {
t.Fatalf("expected cross-layer dups, got %#v", dups)
}
}
func TestNewSkillToolLoadsAgentsSkillWithoutPluginRoots(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
agents := filepath.Join(home, ".agents", "skills")
if err := os.MkdirAll(agents, 0o755); err != nil {
t.Fatal(err)
}
writeTestSkill(t, agents, "agents-skill", "agents body")
tool := NewSkillTool(t.TempDir(), nil)
got := tool.Run(context.Background(), map[string]any{"name": "agents-skill"})
if got.Status != tools.StatusOK || !strings.Contains(got.Output, "agents body") {
t.Fatalf("expected agents skill body with zero plugin roots, got %q (%s)", got.Status, got.Output)
}
}
func TestActivateSkipsMalformedPluginAndContinues(t *testing.T) {
registry := tools.NewRegistry()
good := toolPlugin(t.TempDir(), ToolExtension{Name: "good", Command: "good", Permission: PermissionPrompt})
-4
View File
@@ -34,9 +34,6 @@ const (
var ErrUnknownProvider = errors.New("unknown provider")
// AIMLAPIID is the provider catalog identifier for the AI/ML API preset.
const AIMLAPIID = "aimlapi"
type Descriptor struct {
ID string
Name string
@@ -110,7 +107,6 @@ var descriptors = []Descriptor{
localOpenAI("ollama", "Ollama Local", "http://localhost:11434/v1", "llama3.1", "ollama local"),
localOpenAI("lmstudio", "LM Studio", "http://localhost:1234/v1", "local-model", "lm-studio", "lm studio"),
oauthProvider(openAICompat("openrouter", "OpenRouter", "https://openrouter.ai/api/v1", "openai/gpt-4.1", []string{"OPENROUTER_API_KEY"}), true, false),
openAICompat(AIMLAPIID, "AI/ML API", "https://api.aimlapi.com/v1", "openai/gpt-5-chat", []string{"AIMLAPI_API_KEY"}, "aiml api", "ai/ml api"),
// Hugging Face Inference Providers — OpenAI-compatible router at
// https://router.huggingface.co/v1 exposes hundreds of OSS models. OAuth
// requires a one-time app registration at huggingface.co/settings/applications/new
+1 -24
View File
@@ -16,7 +16,6 @@ var expectedCatalogIDs = []string{
"ollama",
"lmstudio",
"openrouter",
"aimlapi",
"huggingface",
"chatgpt",
"groq",
@@ -104,28 +103,6 @@ func TestRecommendedProviderEndpoint(t *testing.T) {
}
}
func TestAIMLAPIDescriptor(t *testing.T) {
descriptor, err := Require("aimlapi")
if err != nil {
t.Fatalf("Require(aimlapi) error = %v", err)
}
if descriptor.Name != "AI/ML API" {
t.Fatalf("Name = %q, want AI/ML API", descriptor.Name)
}
if descriptor.DefaultBaseURL != "https://api.aimlapi.com/v1" {
t.Fatalf("DefaultBaseURL = %q, want AI/ML API endpoint", descriptor.DefaultBaseURL)
}
if descriptor.DefaultModel != "openai/gpt-5-chat" {
t.Fatalf("DefaultModel = %q, want openai/gpt-5-chat", descriptor.DefaultModel)
}
if descriptor.Transport != TransportOpenAICompatible {
t.Fatalf("Transport = %q, want %q", descriptor.Transport, TransportOpenAICompatible)
}
if !reflect.DeepEqual(descriptor.AuthEnvVars, []string{"AIMLAPI_API_KEY"}) {
t.Fatalf("AuthEnvVars = %#v, want AIMLAPI_API_KEY", descriptor.AuthEnvVars)
}
}
func TestLongCatDescriptor(t *testing.T) {
descriptor, err := Require("longcat")
if err != nil {
@@ -316,7 +293,7 @@ func TestListByTransportPreservesCatalogOrder(t *testing.T) {
TransportBedrock: {"bedrock"},
TransportVertex: {"vertex"},
TransportAnthropicCompat: {"minimax", "minimaxi-cn", "opencode-go-anthropic-compatible", "custom-anthropic-compatible"},
TransportOpenAICompat: {"gitlawb-opengateway", "ollama-cloud", "ollama", "lmstudio", "openrouter", "aimlapi", "huggingface", "chatgpt", "groq", "deepseek", "together", "dashscope", "moonshot", "longcat", "nvidia-nim", "mistral", "github", "xai", "venice", "xiaomi-mimo", "bankr", "zai", "zai-cn", "kilocode", "opencode", "opencode-go", "atomic-chat", "chatgpt-proxy", "custom-openai-compatible"},
TransportOpenAICompat: {"gitlawb-opengateway", "ollama-cloud", "ollama", "lmstudio", "openrouter", "huggingface", "chatgpt", "groq", "deepseek", "together", "dashscope", "moonshot", "longcat", "nvidia-nim", "mistral", "github", "xai", "venice", "xiaomi-mimo", "bankr", "zai", "zai-cn", "kilocode", "opencode", "opencode-go", "atomic-chat", "chatgpt-proxy", "custom-openai-compatible"},
}
for transport, wantIDs := range cases {
+1 -1
View File
@@ -195,7 +195,7 @@ func discoverOpenAIModels(ctx context.Context, profile config.ProviderProfile, o
AuthHeader: profile.AuthHeader,
AuthScheme: profile.AuthScheme,
AuthHeaderValue: profile.AuthHeaderValue,
CustomHeaders: providerio.CopyHeaders(profile.CustomHeaders),
CustomHeaders: profile.CustomHeaders,
}, nil)
}
@@ -52,44 +52,6 @@ func TestDiscoverOpenAICompatibleModelsFetchesModelsEndpoint(t *testing.T) {
}
}
func TestDiscoverAIMLAPIModelsSendsAuthAndCustomHeadersWithoutAttribution(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
for header, want := range map[string]string{
"Authorization": "Bearer test-key",
"X-Trace": "test",
} {
if got := r.Header.Get(header); got != want {
t.Errorf("%s = %q, want %q", header, got, want)
}
}
// No first-party referral/attribution headers are injected for catalog
// presets; aimlapi rides through CopyHeaders like every other provider.
for _, header := range []string{
"X-AIMLAPI-Partner-ID",
"X-AIMLAPI-Integration-Repo",
"X-AIMLAPI-Integration-Version",
} {
if got := r.Header.Get(header); got != "" {
t.Errorf("%s = %q, want no attribution header", header, got)
}
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"data":[{"id":"openai/gpt-5-chat"}]}`))
}))
defer server.Close()
_, err := Discover(context.Background(), config.ProviderProfile{
CatalogID: "aimlapi",
ProviderKind: config.ProviderKindOpenAICompatible,
BaseURL: server.URL + "/v1",
APIKey: "test-key",
CustomHeaders: map[string]string{"X-Trace": "test"},
}, Options{HTTPClient: server.Client()})
if err != nil {
t.Fatalf("Discover returned error: %v", err)
}
}
func TestDiscoverOpenAICompatibleModelsHonorsAuthHeaderValue(t *testing.T) {
// A profile can authenticate via a raw auth-header value instead of APIKey;
// discovery must send it rather than probe unauthenticated.
+3 -3
View File
@@ -67,7 +67,7 @@ func New(profile config.ProviderProfile, options Options) (zeroruntime.Provider,
AuthHeader: profile.AuthHeader,
AuthScheme: profile.AuthScheme,
AuthHeaderValue: profile.AuthHeaderValue,
CustomHeaders: providerio.CopyHeaders(profile.CustomHeaders),
CustomHeaders: profile.CustomHeaders,
OAuthResolver: options.OAuthResolver,
MaxTokens: resolved.maxOutputTokens,
HTTPClient: options.HTTPClient,
@@ -83,7 +83,7 @@ func New(profile config.ProviderProfile, options Options) (zeroruntime.Provider,
AuthHeader: profile.AuthHeader,
AuthScheme: profile.AuthScheme,
AuthHeaderValue: profile.AuthHeaderValue,
CustomHeaders: providerio.CopyHeaders(profile.CustomHeaders),
CustomHeaders: profile.CustomHeaders,
OAuthResolver: options.OAuthResolver,
MaxTokens: resolved.maxOutputTokens,
HTTPClient: options.HTTPClient,
@@ -97,7 +97,7 @@ func New(profile config.ProviderProfile, options Options) (zeroruntime.Provider,
AuthHeader: profile.AuthHeader,
AuthScheme: profile.AuthScheme,
AuthHeaderValue: profile.AuthHeaderValue,
CustomHeaders: providerio.CopyHeaders(profile.CustomHeaders),
CustomHeaders: profile.CustomHeaders,
OAuthResolver: options.OAuthResolver,
MaxTokens: resolved.maxOutputTokens,
HTTPClient: options.HTTPClient,
-49
View File
@@ -220,55 +220,6 @@ func TestNewThreadsCustomProviderHeaders(t *testing.T) {
}
}
func TestNewAIMLAPIProviderSendsEndpointAndAuthWithoutAttribution(t *testing.T) {
transport := &captureTransport{responseBody: "data: [DONE]\n\n"}
provider, err := New(config.ProviderProfile{
Name: "aimlapi",
CatalogID: "aimlapi",
ProviderKind: config.ProviderKindOpenAICompatible,
APIKey: "aimlapi-test-key",
Model: "openai/gpt-5-chat",
CustomHeaders: map[string]string{"X-Trace": "test"},
}, Options{HTTPClient: &http.Client{Transport: transport}})
if err != nil {
t.Fatalf("New() error = %v", err)
}
stream, err := provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "hello"}},
})
if err != nil {
t.Fatalf("StreamCompletion() error = %v", err)
}
for range stream {
}
if transport.request == nil {
t.Fatal("HTTP client was not used")
}
if got := transport.request.URL.String(); got != "https://api.aimlapi.com/v1/chat/completions" {
t.Fatalf("request URL = %q, want AI/ML API endpoint", got)
}
for header, want := range map[string]string{
"Authorization": "Bearer aimlapi-test-key",
"X-Trace": "test",
} {
if got := transport.request.Header.Get(header); got != want {
t.Fatalf("%s = %q, want %q", header, got, want)
}
}
// No first-party referral/attribution headers are injected for catalog
// presets; aimlapi rides through CopyHeaders like every other provider.
for _, header := range []string{
"X-AIMLAPI-Partner-ID",
"X-AIMLAPI-Integration-Repo",
"X-AIMLAPI-Integration-Version",
} {
if got := transport.request.Header.Get(header); got != "" {
t.Fatalf("%s = %q, want no attribution header", header, got)
}
}
}
func TestNewSupportsOpenAIProviderKind(t *testing.T) {
provider, err := New(config.ProviderProfile{
Name: "openai",
-46
View File
@@ -221,52 +221,6 @@ func Info(dir string, name string) (SkillInfo, bool) {
return info, true
}
// InfoFromRoots resolves the named skill across discovery roots (earlier roots
// win). Lock source/hash are attached only when the winning skill lives under
// primaryDir and that dir's lockfile has an entry — agents-only skills return
// frontmatter + path with empty Source/Hash. primaryDir is typically the Zero
// write root (DefaultDir / skillsDir).
func InfoFromRoots(primaryDir string, roots []string, name string) (SkillInfo, bool) {
loaded, _, err := LoadFromRoots(roots)
if err != nil {
return SkillInfo{}, false
}
target := strings.TrimSpace(name)
var skill Skill
found := false
for _, candidate := range loaded {
if candidate.Name == target {
skill = candidate
found = true
break
}
}
if !found {
return SkillInfo{}, false
}
info := SkillInfo{Skill: skill}
primaryDir = strings.TrimSpace(primaryDir)
if primaryDir == "" {
return info, true
}
// Only attach lock metadata when the winner is from the primary write root.
// Compare path prefixes after cleaning so agents-only skills never pick up a
// Zero lock entry by name coincidence.
skillPath := filepath.Clean(skill.Path)
primaryRoot := filepath.Clean(primaryDir)
rel, err := filepath.Rel(primaryRoot, skillPath)
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || filepath.IsAbs(rel) {
return info, true
}
if lock, err := ReadLock(primaryDir); err == nil {
if entry, found := lock[skill.Name]; found {
info.Source = entry.Source
info.Hash = entry.Hash
}
}
return info, true
}
// ReadLock loads the lockfile from dir. A missing lockfile yields an empty map
// with no error so callers can treat "no lockfile" as "nothing installed".
func ReadLock(dir string) (map[string]LockEntry, error) {
+4 -158
View File
@@ -28,18 +28,10 @@ type Skill struct {
const skillFileName = "SKILL.md"
// errNotDirectory is returned when a skills root path exists but is not a
// directory. On Windows, os.ReadDir reports that case as ErrNotExist
// (ENOTDIR aliases ERROR_PATH_NOT_FOUND), so load reclassifies it explicitly.
var errNotDirectory = errors.New("not a directory")
// DefaultDir resolves the skills directory, mirroring sessions.DefaultRoot. An
// explicit ZERO_SKILLS_DIR override wins; otherwise it is
// $XDG_DATA_HOME/zero/skills or ~/.local/share/zero/skills. The directory is
// NOT created — a missing directory simply yields no skills.
//
// DefaultDir is the primary write root for install/remove/lock. Runtime discovery
// also considers AgentsDir and plugin skill roots via DiscoveryRoots / LoadFromRoots.
func DefaultDir(env map[string]string) string {
if override := strings.TrimSpace(envValue(env, "ZERO_SKILLS_DIR")); override != "" {
return override
@@ -64,66 +56,6 @@ func DefaultDir(env map[string]string) string {
return filepath.Join(base, "zero", "skills")
}
// AgentsDir returns ~/.agents/skills when that path exists and is a directory.
// It is a shared, read-only multi-agent skills root (Zero, Hermes, Claude Code,
// etc.) and is never the target of install/remove/lock. Missing, non-directory,
// or unresolvable home yields "" with no error and no directory creation.
//
// Home resolution matches other packages: HOME, then USERPROFILE, then
// os.UserHomeDir(). ZERO_SKILLS_DIR is intentionally ignored — agents is a
// pure convention path, not a Zero-specific override.
func AgentsDir(env map[string]string) string {
home := strings.TrimSpace(firstNonEmpty(
envValue(env, "HOME"),
envValue(env, "USERPROFILE"),
))
if home == "" {
userHome, err := os.UserHomeDir()
if err != nil || strings.TrimSpace(userHome) == "" {
return ""
}
home = userHome
}
dir := filepath.Join(home, ".agents", "skills")
info, err := os.Stat(dir)
if err != nil || !info.IsDir() {
return ""
}
return dir
}
// DiscoveryRoots returns ordered skill roots for runtime discovery: primary
// DefaultDir, optional AgentsDir when present, then pluginRoots. Empty strings
// are omitted. Earlier entries win on name clashes.
func DiscoveryRoots(env map[string]string, pluginRoots []string) []string {
return collectRoots(DefaultDir(env), AgentsDir(env), pluginRoots)
}
// collectRoots assembles ordered non-empty skill roots. primary is typically
// DefaultDir (or an injected test dir); agents is typically AgentsDir's result.
func collectRoots(primary string, agents string, pluginRoots []string) []string {
roots := make([]string, 0, 2+len(pluginRoots))
if primary = strings.TrimSpace(primary); primary != "" {
roots = append(roots, primary)
}
if agents = strings.TrimSpace(agents); agents != "" {
roots = append(roots, agents)
}
for _, root := range pluginRoots {
if root = strings.TrimSpace(root); root != "" {
roots = append(roots, root)
}
}
return roots
}
// GlobalRoots returns discovery roots for management CLI list/info: an explicit
// primary write/root dir (usually skillsDir / DefaultDir) plus AgentsDir when
// present. Plugin roots are excluded from management UX.
func GlobalRoots(primary string) []string {
return collectRoots(primary, AgentsDir(nil), nil)
}
// DuplicateName records two skills that resolved to the same frontmatter name.
// Winner is the SKILL.md path of the skill that was kept (the one in the
// lexicographically-first directory); Loser is the path that was dropped.
@@ -145,81 +77,14 @@ type DuplicateName struct {
// winner regardless of sort stability. Use Duplicates to surface a warning about
// any such collisions.
//
// NOTE: Load scans one root. Runtime discovery uses LoadFromRoots /
// DiscoveryRoots (primary DefaultDir, optional ~/.agents/skills, then plugin
// skill roots). Prefer those multi-root helpers for agent/CLI discovery; keep
// Load for single-dir install/write call sites.
// NOTE: Load scans one root. Agent startup merges plugin-declared skill roots
// separately during plugin activation, so the runtime skill surface can include
// both the default directory and skills bundled by active plugins.
func Load(dir string) ([]Skill, error) {
skills, _, err := load(dir)
return skills, err
}
// LoadFromRoots loads and merges skills from the provided directories (earlier
// entries win on name clashes). Empty roots are skipped. Missing directories are
// treated as empty (same as Load). Intra-root and cross-root collisions are
// reported as DuplicateName.
//
// The first non-empty root is the required primary: non-missing load failures
// (permission, I/O, not a directory, etc.) are returned so callers do not
// confuse a broken primary skills dir with "no skills". Later optional roots
// (e.g. ~/.agents/skills, plugin roots) fail open so one bad optional directory
// does not hide the rest.
func LoadFromRoots(dirs []string) ([]Skill, []DuplicateName, error) {
merged := make([]Skill, 0)
duplicates := []DuplicateName{}
byName := map[string]int{}
primary := true
for _, dir := range dirs {
if strings.TrimSpace(dir) == "" {
continue
}
loaded, rootDups, err := load(dir)
if err != nil {
if primary {
// Required primary root: surface real I/O failures instead of
// collapsing to an empty skill list.
return nil, nil, err
}
// Optional roots fail open: one bad directory must not hide the rest.
continue
}
primary = false
duplicates = append(duplicates, rootDups...)
for _, skill := range loaded {
if winnerIdx, clash := byName[skill.Name]; clash {
duplicates = append(duplicates, DuplicateName{
Name: skill.Name,
Winner: merged[winnerIdx].Path,
Loser: skill.Path,
})
continue
}
byName[skill.Name] = len(merged)
merged = append(merged, skill)
}
}
sort.Slice(merged, func(left int, right int) bool {
return merged[left].Name < merged[right].Name
})
return merged, duplicates, nil
}
// ListFromRoots is like LoadFromRoots but strips Content (like List).
func ListFromRoots(dirs []string) ([]Skill, []DuplicateName, error) {
loaded, dups, err := LoadFromRoots(dirs)
if err != nil {
return nil, dups, err
}
listed := make([]Skill, 0, len(loaded))
for _, skill := range loaded {
skill.Content = ""
listed = append(listed, skill)
}
return listed, dups, nil
}
// Duplicates returns the duplicate-name collisions Load resolved by the
// first-directory-wins rule, so a caller can warn the user that a shadowed skill
// was dropped. A missing directory yields no duplicates and no error.
@@ -266,16 +131,6 @@ func load(dir string) ([]Skill, []DuplicateName, error) {
entries, err := os.ReadDir(dir)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
// Windows maps ENOTDIR to ERROR_PATH_NOT_FOUND, which is also
// ErrNotExist. A primary skills path that exists but is not a
// directory must still surface as a load error, not "no skills".
if info, statErr := os.Stat(dir); statErr == nil && !info.IsDir() {
return nil, nil, &os.PathError{
Op: "readdir",
Path: dir,
Err: errNotDirectory,
}
}
return []Skill{}, nil, nil
}
return nil, nil, err
@@ -426,7 +281,7 @@ func splitFrontmatter(normalized string) (string, string, bool) {
// Matching is case-insensitive on the key; the first occurrence wins.
func frontmatterValue(frontmatter string, key string) string {
prefix := strings.ToLower(key) + ":"
for line := range strings.SplitSeq(frontmatter, "\n") {
for _, line := range strings.Split(frontmatter, "\n") {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(strings.ToLower(trimmed), prefix) {
value := strings.TrimSpace(trimmed[len(prefix):])
@@ -442,12 +297,3 @@ func envValue(env map[string]string, key string) string {
}
return os.Getenv(key)
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return value
}
}
return ""
}
-290
View File
@@ -1,7 +1,6 @@
package skills
import (
"errors"
"os"
"path/filepath"
"strings"
@@ -148,25 +147,6 @@ func TestLoadMissingDirYieldsEmpty(t *testing.T) {
}
}
func TestLoadNotDirectoryErrors(t *testing.T) {
// Portable across Unix and Windows: a regular file is not a skills root.
// Windows reports ReadDir(file) as ErrNotExist; load must reclassify it.
notDir := filepath.Join(t.TempDir(), "not-a-dir")
if err := os.WriteFile(notDir, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
loaded, err := Load(notDir)
if err == nil {
t.Fatalf("expected error for non-directory skills root, got loaded=%#v", loaded)
}
if errors.Is(err, os.ErrNotExist) && !errors.Is(err, errNotDirectory) {
t.Fatalf("non-directory skills root must not look missing: %v", err)
}
if loaded != nil {
t.Fatalf("expected nil skills on non-directory root, got %#v", loaded)
}
}
func TestLoadSortsByName(t *testing.T) {
dir := t.TempDir()
writeSkill(t, dir, "zeta", "body")
@@ -312,273 +292,3 @@ func TestDefaultDirFallsBackToHome(t *testing.T) {
t.Fatalf("DefaultDir = %q, want %q", got, want)
}
}
func TestAgentsDirReturnsExistingDirectory(t *testing.T) {
home := t.TempDir()
agents := filepath.Join(home, ".agents", "skills")
if err := os.MkdirAll(agents, 0o755); err != nil {
t.Fatal(err)
}
got := AgentsDir(map[string]string{"HOME": home})
if got != agents {
t.Fatalf("AgentsDir = %q, want %q", got, agents)
}
}
func TestAgentsDirMissingIsEmpty(t *testing.T) {
home := t.TempDir()
got := AgentsDir(map[string]string{"HOME": home})
if got != "" {
t.Fatalf("AgentsDir for missing path = %q, want empty", got)
}
}
func TestAgentsDirFileNotDirIsEmpty(t *testing.T) {
home := t.TempDir()
agentsParent := filepath.Join(home, ".agents")
if err := os.MkdirAll(agentsParent, 0o755); err != nil {
t.Fatal(err)
}
// skills is a file, not a directory
if err := os.WriteFile(filepath.Join(agentsParent, "skills"), []byte("not a dir"), 0o644); err != nil {
t.Fatal(err)
}
got := AgentsDir(map[string]string{"HOME": home})
if got != "" {
t.Fatalf("AgentsDir for file = %q, want empty", got)
}
}
func TestAgentsDirHonorsUserProfile(t *testing.T) {
home := t.TempDir()
agents := filepath.Join(home, ".agents", "skills")
if err := os.MkdirAll(agents, 0o755); err != nil {
t.Fatal(err)
}
got := AgentsDir(map[string]string{"USERPROFILE": home})
if got != agents {
t.Fatalf("AgentsDir via USERPROFILE = %q, want %q", got, agents)
}
}
func TestAgentsDirIgnoresZeroSkillsDir(t *testing.T) {
home := t.TempDir()
agents := filepath.Join(home, ".agents", "skills")
if err := os.MkdirAll(agents, 0o755); err != nil {
t.Fatal(err)
}
// ZERO_SKILLS_DIR must not redirect or suppress AgentsDir.
got := AgentsDir(map[string]string{
"HOME": home,
"ZERO_SKILLS_DIR": filepath.Join(home, "zero-only"),
})
if got != agents {
t.Fatalf("AgentsDir with ZERO_SKILLS_DIR set = %q, want %q", got, agents)
}
}
func TestAgentsDirUnresolvableHomeIsEmpty(t *testing.T) {
// Empty env map values + no real UserHomeDir fallback is hard to force, but
// empty HOME/USERPROFILE with an empty home should still not panic.
// When UserHomeDir works this may return a host path; only assert no panic
// and that a deliberately empty-looking override path is not invented from ZERO.
_ = AgentsDir(map[string]string{"HOME": "", "USERPROFILE": ""})
}
func TestDiscoveryRootsOrderAndOmission(t *testing.T) {
home := t.TempDir()
agents := filepath.Join(home, ".agents", "skills")
if err := os.MkdirAll(agents, 0o755); err != nil {
t.Fatal(err)
}
primary := filepath.Join(home, "zero-skills")
env := map[string]string{
"HOME": home,
"ZERO_SKILLS_DIR": primary,
}
roots := DiscoveryRoots(env, []string{"", " /plugin/a ", "plugin/b"})
want := []string{primary, agents, "/plugin/a", "plugin/b"}
if len(roots) != len(want) {
t.Fatalf("DiscoveryRoots = %#v, want %#v", roots, want)
}
for i := range want {
if roots[i] != want[i] {
t.Fatalf("DiscoveryRoots[%d] = %q, want %q (full %#v)", i, roots[i], want[i], roots)
}
}
}
func TestDiscoveryRootsOmitsMissingAgents(t *testing.T) {
home := t.TempDir()
primary := filepath.Join(home, "zero-skills")
env := map[string]string{
"HOME": home,
"ZERO_SKILLS_DIR": primary,
}
roots := DiscoveryRoots(env, nil)
if len(roots) != 1 || roots[0] != primary {
t.Fatalf("DiscoveryRoots without agents = %#v, want only primary", roots)
}
}
func TestLoadFromRootsPrimaryWinsOverAgents(t *testing.T) {
primary := t.TempDir()
agents := t.TempDir()
writeSkill(t, primary, "shared", "---\nname: shared\n---\nprimary body\n")
writeSkill(t, agents, "shared", "---\nname: shared\n---\nagents body\n")
writeSkill(t, agents, "agents-only", "---\nname: agents-only\n---\nagents only\n")
loaded, dups, err := LoadFromRoots([]string{primary, agents})
if err != nil {
t.Fatalf("LoadFromRoots: %v", err)
}
byName := map[string]Skill{}
for _, skill := range loaded {
byName[skill.Name] = skill
}
if byName["shared"].Content != "primary body" {
t.Fatalf("primary should win shared, got %q", byName["shared"].Content)
}
if byName["agents-only"].Content != "agents only" {
t.Fatalf("agents-only missing: %#v", loaded)
}
if len(dups) != 1 || dups[0].Name != "shared" {
t.Fatalf("expected one shared duplicate, got %#v", dups)
}
}
func TestLoadFromRootsSkipsEmptyAndMissing(t *testing.T) {
primary := t.TempDir()
writeSkill(t, primary, "solo", "---\nname: solo\n---\nbody\n")
loaded, dups, err := LoadFromRoots([]string{"", filepath.Join(t.TempDir(), "missing"), primary})
if err != nil {
t.Fatalf("LoadFromRoots: %v", err)
}
if len(loaded) != 1 || loaded[0].Name != "solo" {
t.Fatalf("expected only solo, got %#v", loaded)
}
if len(dups) != 0 {
t.Fatalf("unexpected dups: %#v", dups)
}
}
func TestLoadFromRootsBubblesPrimaryError(t *testing.T) {
// A regular file is not a directory. On Unix ReadDir fails with ENOTDIR; on
// Windows that case is aliased to ErrNotExist, so load must reclassify an
// existing non-directory and bubble it from the first non-empty root.
notDir := filepath.Join(t.TempDir(), "not-a-dir")
if err := os.WriteFile(notDir, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
optional := t.TempDir()
writeSkill(t, optional, "fallback", "---\nname: fallback\n---\nbody\n")
loaded, dups, err := LoadFromRoots([]string{notDir, optional})
if err == nil {
t.Fatalf("expected primary root error, got loaded=%#v dups=%#v", loaded, dups)
}
// Unix returns ENOTDIR; Windows reclassifies via errNotDirectory because
// ENOTDIR aliases ErrNotExist there. Either way it must not look missing.
if errors.Is(err, os.ErrNotExist) && !errors.Is(err, errNotDirectory) {
t.Fatalf("primary non-directory must not look missing: %v", err)
}
if loaded != nil {
t.Fatalf("expected nil skills on primary error, got %#v", loaded)
}
if dups != nil {
t.Fatalf("expected nil dups on primary error, got %#v", dups)
}
}
func TestLoadFromRootsOptionalRootFailOpen(t *testing.T) {
primary := t.TempDir()
writeSkill(t, primary, "keep", "---\nname: keep\n---\nbody\n")
notDir := filepath.Join(t.TempDir(), "not-a-dir")
if err := os.WriteFile(notDir, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
agents := t.TempDir()
writeSkill(t, agents, "agents-only", "---\nname: agents-only\n---\nbody\n")
loaded, dups, err := LoadFromRoots([]string{primary, notDir, agents})
if err != nil {
t.Fatalf("optional root failure must not fail merge: %v", err)
}
byName := map[string]Skill{}
for _, skill := range loaded {
byName[skill.Name] = skill
}
if byName["keep"].Name != "keep" {
t.Fatalf("primary skill missing: %#v", loaded)
}
if byName["agents-only"].Name != "agents-only" {
t.Fatalf("later optional skill should still load: %#v", loaded)
}
if len(dups) != 0 {
t.Fatalf("unexpected dups: %#v", dups)
}
}
func TestListFromRootsStripsContent(t *testing.T) {
dir := t.TempDir()
writeSkill(t, dir, "demo", "---\nname: demo\n---\nbody content\n")
listed, _, err := ListFromRoots([]string{dir})
if err != nil {
t.Fatalf("ListFromRoots: %v", err)
}
if len(listed) != 1 || listed[0].Name != "demo" {
t.Fatalf("unexpected listed: %#v", listed)
}
if listed[0].Content != "" {
t.Fatalf("ListFromRoots must strip Content, got %q", listed[0].Content)
}
}
func TestGlobalRootsIncludesAgents(t *testing.T) {
home := t.TempDir()
agents := filepath.Join(home, ".agents", "skills")
if err := os.MkdirAll(agents, 0o755); err != nil {
t.Fatal(err)
}
// Point HOME so AgentsDir finds the temp agents root.
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
primary := filepath.Join(home, "primary")
roots := GlobalRoots(primary)
if len(roots) != 2 || roots[0] != primary || roots[1] != agents {
t.Fatalf("GlobalRoots = %#v, want [primary, agents]", roots)
}
}
func TestInfoFromRootsAgentsOnlyHasNoLock(t *testing.T) {
primary := t.TempDir()
agents := t.TempDir()
writeSkill(t, agents, "shared-agents", "---\nname: agents-skill\ndescription: from agents\n---\nbody\n")
info, ok := InfoFromRoots(primary, []string{primary, agents}, "agents-skill")
if !ok {
t.Fatal("expected agents skill to resolve")
}
if info.Skill.Name != "agents-skill" || info.Skill.Description != "from agents" {
t.Fatalf("unexpected skill: %#v", info.Skill)
}
if info.Source != "" || info.Hash != "" {
t.Fatalf("agents-only skill must not carry lock metadata, got source=%q hash=%q", info.Source, info.Hash)
}
}
func TestInfoFromRootsPrimaryLockMetadata(t *testing.T) {
primary := t.TempDir()
writeSkill(t, primary, "demo", "---\nname: demo\n---\nbody\n")
// Write a lockfile entry the way install would.
lockPath := filepath.Join(primary, LockFileName)
if err := os.WriteFile(lockPath, []byte(`{"demo":{"source":"file:///src","hash":"sha256:abc"}}`), 0o644); err != nil {
t.Fatal(err)
}
info, ok := InfoFromRoots(primary, []string{primary}, "demo")
if !ok {
t.Fatal("expected primary skill")
}
if info.Source != "file:///src" || info.Hash != "sha256:abc" {
t.Fatalf("lock metadata missing: %#v", info)
}
}
+1 -2
View File
@@ -34,8 +34,7 @@ func NewScopedApplyPatchTool(workspaceRoot string, scope PathScope) Tool {
Required: []string{"patch"},
AdditionalProperties: false,
},
safety: promptSafety(SideEffectWrite, "Applies patch hunks that can create, edit, or delete files."),
capabilities: ToolCapabilities{Effect: EffectWorkspaceWrite, ThreadSafe: false, ResourceKeys: applyPatchResourceKeys},
safety: promptSafety(SideEffectWrite, "Applies patch hunks that can create, edit, or delete files."),
},
workspaceRoot: normalizeWorkspaceRoot(workspaceRoot),
scope: scope,
-2
View File
@@ -88,8 +88,6 @@ func NewAskUserTool() *askUserTool {
AdditionalProperties: false,
},
safety: readOnlySafety("Asks the user clarifying questions; gathers input only."),
// EffectInteractive: blocks on user input; ordering matters.
capabilities: ToolCapabilities{Effect: EffectInteractive, ThreadSafe: false},
},
}
}
-4
View File
@@ -52,10 +52,6 @@ func NewScopedBashTool(workspaceRoot string, scope PathScope) Tool {
AdditionalProperties: false,
},
safety: promptSafety(SideEffectShell, "Shell commands can read, write, or execute programs."),
// One-shot shell can rewrite the workspace (and more). Serialized
// as WorkspaceWrite so external-mutator audits include bash; not
// session-bound Interactive (that is reserved for PTY/terminal tools).
capabilities: ToolCapabilities{Effect: EffectWorkspaceWrite, ThreadSafe: false, ResourceKeys: directoryResourceKeys},
},
workspaceRoot: normalizeWorkspaceRoot(workspaceRoot),
scope: scope,
-94
View File
@@ -1,94 +0,0 @@
package tools
import "github.com/Gitlawb/zero/internal/localcontrol"
// BuiltinCatalog returns every built-in tool that Zero can expose to the model
// from this package (core + control + optional local-control helpers).
//
// It is the source of truth for metadata coverage tests: every tool returned
// here must declare an explicit Effect other than EffectUnknown.
//
// Not included (fail-closed EffectUnknown by design):
// - MCP tools (internal/mcp registryTool)
// - Plugin tools (internal/plugins pluginTool)
// - Specialist tools registered outside this package (Task, Generate, …)
//
// workspaceRoot is used only to construct path-scoped tools; it need not exist.
func BuiltinCatalog(workspaceRoot string) []Tool {
if workspaceRoot == "" {
workspaceRoot = "."
}
var all []Tool
all = append(all, CoreReadOnlyTools(workspaceRoot)...)
all = append(all, CoreWriteTools(workspaceRoot)...)
all = append(all, CoreShellTools(workspaceRoot)...)
// Network tools: always include web_fetch; always include web_search for
// classification even when no backend is configured at runtime (registration
// remains conditional in CoreNetworkTools).
all = append(all, NewWebFetchTool(), NewWebSearchTool())
all = append(all, NewEscalateModelTool())
// tool_search needs a live registry of the tools above.
reg := NewRegistry()
for _, tool := range all {
reg.Register(tool)
}
search := NewToolSearchTool(reg)
reg.Register(search)
all = append(all, search)
// Local-control tools are feature-gated at registration time but always
// exist as built-in implementations with fixed classifications.
all = append(all, NewLocalBrowserTools(localcontrol.BrowserOptions{})...)
all = append(all, NewLocalDesktopTools(localcontrol.DesktopOptions{})...)
all = append(all, NewLocalTerminalTools(localcontrol.TerminalOptions{})...)
all = append(all, NewLocalControlArtifactTools(LocalControlArtifactOptions{})...)
return all
}
// ValidateBuiltinCatalog returns diagnostics for every catalog tool that is
// missing or inconsistently classified. Empty means full coverage.
//
// Duplicate builtin names are always rejected: ResourceKeys is a function and
// cannot be compared, so partial metadata comparison would miss conflict-scope
// drift between two same-name tools.
//
// Validation uses constructor-declared capabilities (before ThreadSafe
// normalization) so a mutator wired with ThreadSafe=true is still reported.
// Runtime callers continue to use CapabilitiesOf, which normalizes.
func ValidateBuiltinCatalog(workspaceRoot string) []string {
var problems []string
seen := map[string]struct{}{}
for _, tool := range BuiltinCatalog(workspaceRoot) {
name := tool.Name()
if _, ok := seen[name]; ok {
problems = append(problems, name+": duplicate builtin name in catalog")
continue
}
seen[name] = struct{}{}
// Runtime view: must not remain Unknown after normalization.
runtimeCaps := CapabilitiesOf(tool)
if runtimeCaps.Effect == EffectUnknown {
problems = append(problems, name+": built-in tool remains EffectUnknown (metadata required)")
}
// Declaration view: catch invalid ThreadSafe on mutators that
// CapabilitiesOf would silently clear.
declared := declaredCapabilitiesOf(tool)
problems = append(problems, ValidateCapabilities(name, declared)...)
}
return problems
}
// declaredCapabilitiesOf returns constructor metadata when available; otherwise
// falls back to CapabilitiesOf (already normalized).
func declaredCapabilitiesOf(tool Tool) ToolCapabilities {
if tool == nil {
return UnknownCapabilities()
}
if d, ok := tool.(declaredCapabilityProvider); ok {
return d.declaredCapabilities()
}
return CapabilitiesOf(tool)
}
-172
View File
@@ -1,172 +0,0 @@
package tools
import "strings"
// EffectClass classifies a tool's side effects for concurrency safety.
// Concurrent execution (a later PR) must only consider tools that are both
// ThreadSafe and EffectReadOnly; this package only declares metadata.
// ThreadSafe is force-cleared for every non-ReadOnly effect at read time
// (see normalizeCapabilities).
type EffectClass int
const (
// EffectUnknown is the fail-closed default for unclassified tools.
// Never concurrency-eligible; always serialized.
EffectUnknown EffectClass = iota
// EffectReadOnly reads local or remote state without mutating it.
// The only effect class that may set ThreadSafe=true after audit.
EffectReadOnly
// EffectProcessRead observes a retained process, PTY, or terminal.
EffectProcessRead
// EffectWorkspaceWrite mutates the local repository or workspace.
// Includes one-shot shell commands that can rewrite the workspace.
EffectWorkspaceWrite
// EffectExternalWrite mutates state outside the local workspace.
EffectExternalWrite
// EffectInteractive uses browser, PTY/terminal, prompts, or other
// session-bound interaction where ordering matters.
EffectInteractive
)
// String returns a stable diagnostic name for the effect class.
func (e EffectClass) String() string {
switch e {
case EffectUnknown:
return "unknown"
case EffectReadOnly:
return "read_only"
case EffectProcessRead:
return "process_read"
case EffectWorkspaceWrite:
return "workspace_write"
case EffectExternalWrite:
return "external_write"
case EffectInteractive:
return "interactive"
default:
return "invalid"
}
}
// Valid reports whether e is one of the defined effect classes.
func (e EffectClass) Valid() bool {
return e >= EffectUnknown && e <= EffectInteractive
}
// ToolCapabilities is the concurrency-safety contract for a tool.
// ResourceKeys, when set, returns deterministic conflict keys for a call's
// arguments. Returning no keys does NOT make a tool parallel-safe.
type ToolCapabilities struct {
Effect EffectClass
ThreadSafe bool
ResourceKeys func(args map[string]any) []string
}
// CapabilityProvider is the optional interface tools implement to declare
// effect metadata. Tools that do not implement it are treated as EffectUnknown
// and not thread-safe (fail-closed).
type CapabilityProvider interface {
Capabilities() ToolCapabilities
}
// UnknownCapabilities is the fail-closed default for tools without metadata.
func UnknownCapabilities() ToolCapabilities {
return ToolCapabilities{
Effect: EffectUnknown,
ThreadSafe: false,
}
}
// normalizeCapabilities is the single read path for capability metadata.
// ThreadSafe is cleared unless Effect is a valid EffectReadOnly — mutators,
// interactive tools, process readers, unknown, and invalid effects must never
// appear concurrency-eligible to a future batch planner that only checks
// ThreadSafe && Effect.
func normalizeCapabilities(caps ToolCapabilities) ToolCapabilities {
if !caps.Effect.Valid() || caps.Effect != EffectReadOnly {
caps.ThreadSafe = false
}
return caps
}
// CapabilitiesOf returns a tool's declared capabilities, or the fail-closed
// unknown default when the tool does not implement CapabilityProvider.
func CapabilitiesOf(tool Tool) ToolCapabilities {
if tool == nil {
return UnknownCapabilities()
}
if provider, ok := tool.(CapabilityProvider); ok {
return normalizeCapabilities(provider.Capabilities())
}
return UnknownCapabilities()
}
// (baseTool).Capabilities exposes the capabilities field set at construction,
// normalized so ThreadSafe cannot surface on non-ReadOnly effects.
// Tools that embed baseTool inherit this method.
func (tool baseTool) Capabilities() ToolCapabilities {
return normalizeCapabilities(tool.capabilities)
}
// declaredCapabilities returns the constructor-set metadata without runtime
// normalization. Catalog validation uses this so a mis-declared
// ThreadSafe=true on a mutator is still reported even though CapabilitiesOf
// clears it for callers.
func (tool baseTool) declaredCapabilities() ToolCapabilities {
return tool.capabilities
}
// declaredCapabilityProvider is implemented by tools that can report their
// raw constructor metadata (baseTool embedders).
type declaredCapabilityProvider interface {
declaredCapabilities() ToolCapabilities
}
// Registry.Capabilities looks up a registered tool's capabilities by name.
// Unknown tools return the fail-closed default (not an error) so callers can
// treat missing tools as non-concurrent without panicking.
func (registry *Registry) Capabilities(name string) ToolCapabilities {
if registry == nil {
return UnknownCapabilities()
}
tool, ok := registry.Get(name)
if !ok {
return UnknownCapabilities()
}
return CapabilitiesOf(tool)
}
// ValidateCapabilities checks a single capability record for consistency.
// It returns a non-empty slice of human-readable problems when invalid.
// Note: CapabilitiesOf already normalizes ThreadSafe away for non-ReadOnly
// effects; validation still rejects the raw combination so catalog tests
// catch mis-declared constructors.
func ValidateCapabilities(name string, caps ToolCapabilities) []string {
var problems []string
name = strings.TrimSpace(name)
if name == "" {
name = "<unnamed>"
}
if !caps.Effect.Valid() {
problems = append(problems, name+": invalid effect value")
}
if caps.Effect == EffectUnknown && caps.ThreadSafe {
problems = append(problems, name+": ThreadSafe=true is forbidden with EffectUnknown")
}
// Mutating / interactive / process-read effects are never concurrency-eligible.
switch caps.Effect {
case EffectWorkspaceWrite, EffectExternalWrite, EffectInteractive, EffectProcessRead:
if caps.ThreadSafe {
problems = append(problems, name+": ThreadSafe=true is not permitted for effect "+caps.Effect.String())
}
}
return problems
}
// ValidateToolCapabilities validates CapabilitiesOf(tool) for a registered tool.
func ValidateToolCapabilities(tool Tool) []string {
if tool == nil {
return []string{"<nil tool>"}
}
return ValidateCapabilities(tool.Name(), CapabilitiesOf(tool))
}
-449
View File
@@ -1,449 +0,0 @@
package tools
import (
"context"
"path/filepath"
"runtime"
"strings"
"sync"
"testing"
)
// plainTool implements Tool without CapabilityProvider — fail-closed default.
type plainTool struct {
name string
}
func (t plainTool) Name() string { return t.name }
func (t plainTool) Description() string { return "plain" }
func (t plainTool) Parameters() Schema { return Schema{Type: "object"} }
func (t plainTool) Safety() Safety { return readOnlySafety("plain") }
func (t plainTool) Run(context.Context, map[string]any) Result {
return okResult("ok")
}
func TestCapabilitiesOfDefaultsUnknown(t *testing.T) {
caps := CapabilitiesOf(plainTool{name: "mystery"})
if caps.Effect != EffectUnknown {
t.Fatalf("Effect = %v, want Unknown", caps.Effect)
}
if caps.ThreadSafe {
t.Fatal("ThreadSafe must be false for unclassified tools")
}
}
func TestCapabilitiesOfNilTool(t *testing.T) {
caps := CapabilitiesOf(nil)
if caps.Effect != EffectUnknown || caps.ThreadSafe {
t.Fatalf("nil tool = %+v, want fail-closed unknown", caps)
}
}
func TestUnknownNotThreadSafeEvenIfProviderLies(t *testing.T) {
tool := baseTool{
name: "liar",
capabilities: ToolCapabilities{
Effect: EffectUnknown,
ThreadSafe: true, // invalid — Capabilities() must clear it
},
}
caps := tool.Capabilities()
if caps.ThreadSafe {
t.Fatal("Capabilities() must force ThreadSafe=false for EffectUnknown")
}
}
func TestNormalizeClearsThreadSafeOnMutators(t *testing.T) {
tool := baseTool{
name: "writer",
capabilities: ToolCapabilities{
Effect: EffectWorkspaceWrite,
ThreadSafe: true, // must be cleared on read
},
}
if tool.Capabilities().ThreadSafe {
t.Fatal("WorkspaceWrite must never surface ThreadSafe=true via Capabilities()")
}
// Wrap with Run so CapabilitiesOf's CapabilityProvider path is exercised.
wrapped := deferredCapableTool{baseTool: tool}
if CapabilitiesOf(wrapped).ThreadSafe {
t.Fatal("CapabilitiesOf must normalize mutator ThreadSafe away")
}
}
func TestValidateRejectsThreadSafeUnknown(t *testing.T) {
problems := ValidateCapabilities("x", ToolCapabilities{Effect: EffectUnknown, ThreadSafe: true})
if len(problems) == 0 {
t.Fatal("expected validation problems for ThreadSafe+Unknown")
}
}
func TestValidateRejectsThreadSafeMutators(t *testing.T) {
for _, effect := range []EffectClass{EffectWorkspaceWrite, EffectExternalWrite, EffectInteractive, EffectProcessRead} {
problems := ValidateCapabilities("m", ToolCapabilities{Effect: effect, ThreadSafe: true})
if len(problems) == 0 {
t.Fatalf("effect %s: expected ThreadSafe rejection", effect)
}
}
}
func TestEveryBuiltinHasExplicitMetadata(t *testing.T) {
problems := ValidateBuiltinCatalog(t.TempDir())
if len(problems) > 0 {
t.Fatalf("builtin metadata coverage failed:\n %s", strings.Join(problems, "\n "))
}
}
func TestBuiltinCatalogNoUnknown(t *testing.T) {
counts := map[EffectClass]int{}
for _, tool := range BuiltinCatalog(t.TempDir()) {
caps := CapabilitiesOf(tool)
if caps.Effect == EffectUnknown {
t.Errorf("tool %q is EffectUnknown", tool.Name())
}
counts[caps.Effect]++
}
if counts[EffectUnknown] != 0 {
t.Fatalf("Unknown built-ins = %d, want 0", counts[EffectUnknown])
}
t.Logf("classification counts: %+v", counts)
}
func TestRegistryCapabilitiesLookup(t *testing.T) {
reg := NewRegistry()
reg.Register(NewScopedReadFileTool(t.TempDir(), nil))
caps := reg.Capabilities("read_file")
if caps.Effect != EffectReadOnly {
t.Fatalf("read_file effect = %v, want ReadOnly", caps.Effect)
}
// Missing tool → fail-closed.
missing := reg.Capabilities("does_not_exist")
if missing.Effect != EffectUnknown || missing.ThreadSafe {
t.Fatalf("missing tool = %+v", missing)
}
}
func TestFileResourceKeysNormalized(t *testing.T) {
keys := fileResourceKeys(map[string]any{"path": `./src/../src/main.go`})
if len(keys) != 1 {
t.Fatalf("keys = %v", keys)
}
want := ResourceKeyFile + NormalizeResourcePath("./src/../src/main.go")
if keys[0] != want {
t.Fatalf("key = %q, want %q", keys[0], want)
}
// Missing args → nil, no panic.
if fileResourceKeys(nil) != nil {
t.Fatal("nil args should yield nil keys")
}
if fileResourceKeys(map[string]any{}) != nil {
t.Fatal("empty args should yield nil keys")
}
}
func TestResourcePathNormalizePlatform(t *testing.T) {
// Unix-style clean
got := NormalizeResourcePath("a//b/../c")
if got != "a/c" && got != filepath.ToSlash(filepath.Clean("a//b/../c")) {
// After ToSlash clean should be a/c
if NormalizeResourcePath("a//b/../c") != "a/c" {
t.Fatalf("unix clean = %q", got)
}
}
if runtime.GOOS == "windows" {
// Case-insensitive
if NormalizeResourcePath(`C:\Foo\Bar`) != NormalizeResourcePath(`c:\foo\bar`) {
t.Fatalf("windows case fold failed: %q vs %q",
NormalizeResourcePath(`C:\Foo\Bar`), NormalizeResourcePath(`c:\foo\bar`))
}
}
// Empty / URL-shaped
if NormalizeResourcePath("") != "" {
t.Fatal("empty path")
}
if NormalizeResourcePath("https://user:pass@host/path") != "" {
t.Fatal("URL must not become a file resource path")
}
}
func TestEndpointResourceKeysStripSecrets(t *testing.T) {
keys := endpointResourceKeys(map[string]any{
"url": "https://user:[email protected]:443/v1?token=abc",
})
if len(keys) != 1 {
t.Fatalf("keys = %v", keys)
}
if strings.Contains(keys[0], "secret") || strings.Contains(keys[0], "token") || strings.Contains(keys[0], "user") {
t.Fatalf("endpoint key leaked secret material: %q", keys[0])
}
if !strings.Contains(keys[0], "api.example.com") {
t.Fatalf("endpoint key missing host: %q", keys[0])
}
}
func TestEndpointResourceKeysAtInQueryDoesNotStealHost(t *testing.T) {
// Path/query must be stripped BEFORE userinfo so ?x=@bad cannot become the host.
keys := endpointResourceKeys(map[string]any{
"url": "https://api.example.com/v1?token=@secret",
})
if len(keys) != 1 || keys[0] != ResourceKeyEndpoint+"api.example.com" {
t.Fatalf("keys = %v, want endpoint:api.example.com", keys)
}
}
func TestProcessResourceKeysNumericSessionID(t *testing.T) {
// write_stdin declares session_id as integer; JSON decodes as float64.
keys := processResourceKeys(map[string]any{"session_id": float64(42)})
if len(keys) != 1 || keys[0] != ResourceKeyProcess+"42" {
t.Fatalf("keys = %v, want process:42", keys)
}
}
func TestProcessResourceKeysSessionArg(t *testing.T) {
// terminal_session requires "session", not session_id.
keys := processResourceKeys(map[string]any{"session": "term-7"})
if len(keys) != 1 || keys[0] != ResourceKeyProcess+"term-7" {
t.Fatalf("keys = %v, want process:term-7", keys)
}
}
func TestApplyPatchResourceKeysFromDiffAndCwd(t *testing.T) {
patch := "--- a/old.go\n+++ b/new.go\n@@ -1 +1 @@\n-x\n+y\n"
keys := applyPatchResourceKeys(map[string]any{
"patch": patch,
"cwd": "pkg",
})
// Diff paths must be joined under cwd so the same basename under different
// cwds does not collide for the future conflict planner.
want := map[string]bool{
ResourceKeyDirectory + "pkg": true,
ResourceKeyFile + "pkg/old.go": true,
ResourceKeyFile + "pkg/new.go": true,
}
for _, k := range keys {
if !want[k] {
t.Fatalf("unexpected key %q in %v", k, keys)
}
delete(want, k)
}
if len(want) != 0 {
t.Fatalf("missing keys %v from %v", want, keys)
}
}
func TestDeclaredMutatorThreadSafeIsReportedByCatalogValidation(t *testing.T) {
// Runtime CapabilitiesOf clears ThreadSafe on mutators; catalog validation
// must still see the raw declaration so miswired constructors fail the gate.
tool := deferredCapableTool{baseTool: baseTool{
name: "bad_writer",
capabilities: ToolCapabilities{
Effect: EffectWorkspaceWrite,
ThreadSafe: true,
},
}}
declared := declaredCapabilitiesOf(tool)
if !declared.ThreadSafe || declared.Effect != EffectWorkspaceWrite {
t.Fatalf("declared = %+v, want raw WorkspaceWrite+ThreadSafe", declared)
}
problems := ValidateCapabilities(tool.Name(), declared)
if len(problems) == 0 {
t.Fatal("expected ValidateCapabilities to reject raw WorkspaceWrite+ThreadSafe")
}
// Runtime view stays safe.
if CapabilitiesOf(tool).ThreadSafe {
t.Fatal("runtime CapabilitiesOf must still clear ThreadSafe")
}
}
func TestApplyPatchResourceKeysFallbackWorkspace(t *testing.T) {
keys := applyPatchResourceKeys(map[string]any{"patch": "not a real diff"})
if len(keys) != 1 || keys[0] != ResourceKeyWorkspace+"root" {
t.Fatalf("keys = %v, want workspace:root fallback", keys)
}
}
func TestWorkspaceWriteClassifications(t *testing.T) {
root := t.TempDir()
for _, tool := range []Tool{
NewScopedWriteFileTool(root, nil),
NewScopedEditFileTool(root, nil),
NewScopedApplyPatchTool(root, nil),
NewScopedBashTool(root, nil),
} {
if got := CapabilitiesOf(tool).Effect; got != EffectWorkspaceWrite {
t.Errorf("%s effect = %v, want WorkspaceWrite", tool.Name(), got)
}
if CapabilitiesOf(tool).ThreadSafe {
t.Errorf("%s must not be ThreadSafe", tool.Name())
}
}
}
func TestInteractiveClassifications(t *testing.T) {
root := t.TempDir()
for _, name := range []string{"ask_user", "exec_command", "write_stdin", "update_plan", "escalate_model", "request_permissions", "browser_open", "terminal_session", "desktop_action"} {
var found Tool
for _, tool := range BuiltinCatalog(root) {
if tool.Name() == name {
found = tool
break
}
}
if found == nil {
t.Fatalf("tool %q not in catalog", name)
}
if got := CapabilitiesOf(found).Effect; got != EffectInteractive {
t.Errorf("%s effect = %v, want Interactive", name, got)
}
}
}
func TestReadOnlyClassifications(t *testing.T) {
root := t.TempDir()
for _, name := range []string{"read_file", "read_minified_file", "list_directory", "glob", "grep", "lsp_navigate", "skill", "web_fetch", "web_search", "tool_search"} {
var found Tool
for _, tool := range BuiltinCatalog(root) {
if tool.Name() == name {
found = tool
break
}
}
if found == nil {
t.Fatalf("tool %q not in catalog", name)
}
caps := CapabilitiesOf(found)
if caps.Effect != EffectReadOnly {
t.Errorf("%s effect = %v, want ReadOnly", name, caps.Effect)
}
// Current catalog intentionally keeps read tools non-thread-safe
// until each is audited for shared mutable state (FileTracker, etc.).
if caps.ThreadSafe {
t.Errorf("%s ThreadSafe=true without explicit audit", name)
}
}
}
func TestValidateBuiltinCatalogRejectsUnknownRaw(t *testing.T) {
// Prove the gate is not a rubber stamp: raw Unknown+ThreadSafe is rejected.
problems := ValidateCapabilities("ghost", ToolCapabilities{Effect: EffectUnknown, ThreadSafe: true})
if len(problems) == 0 {
t.Fatal("expected ValidateCapabilities to reject Unknown+ThreadSafe")
}
}
func TestCaptureArtifactIsWorkspaceWrite(t *testing.T) {
for _, tool := range NewLocalControlArtifactTools(LocalControlArtifactOptions{}) {
if CapabilitiesOf(tool).Effect != EffectWorkspaceWrite {
t.Fatalf("%s effect = %v, want WorkspaceWrite", tool.Name(), CapabilitiesOf(tool).Effect)
}
}
}
func TestCapabilitiesLookupRace(t *testing.T) {
reg := NewRegistry()
for _, tool := range BuiltinCatalog(t.TempDir()) {
reg.Register(tool)
}
var wg sync.WaitGroup
for i := 0; i < 32; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for _, tool := range reg.All() {
_ = reg.Capabilities(tool.Name())
_ = CapabilitiesOf(tool)
caps := CapabilitiesOf(tool)
if caps.ResourceKeys != nil {
_ = caps.ResourceKeys(map[string]any{"path": "a/b.go", "url": "https://example.com"})
}
}
}()
}
wg.Wait()
}
func TestEffectStringAndValid(t *testing.T) {
for _, e := range []EffectClass{EffectUnknown, EffectReadOnly, EffectProcessRead, EffectWorkspaceWrite, EffectExternalWrite, EffectInteractive} {
if !e.Valid() {
t.Fatalf("%v should be valid", e)
}
if e.String() == "invalid" || e.String() == "" {
t.Fatalf("String for %d", e)
}
}
if EffectClass(99).Valid() {
t.Fatal("invalid class reported valid")
}
}
// deferredCapableTool models a deferred-eligible built-in that still carries
// explicit capabilities (MCP tools do not implement CapabilityProvider and
// stay Unknown by design).
type deferredCapableTool struct {
baseTool
}
func (t deferredCapableTool) Deferred() bool { return true }
func (t deferredCapableTool) Run(context.Context, map[string]any) Result {
return okResult("ok")
}
func TestDeferredToolPreservesCapabilities(t *testing.T) {
tool := deferredCapableTool{baseTool: baseTool{
name: "deferred_reader",
capabilities: ToolCapabilities{
Effect: EffectReadOnly,
ThreadSafe: false,
ResourceKeys: fileResourceKeys,
},
}}
if !IsDeferred(tool) {
t.Fatal("expected deferred")
}
reg := NewRegistry()
reg.Register(tool)
got, ok := reg.Get("deferred_reader")
if !ok {
t.Fatal("missing registration")
}
caps := CapabilitiesOf(got)
if caps.Effect != EffectReadOnly {
t.Fatalf("effect = %v after deferred register", caps.Effect)
}
keys := caps.ResourceKeys(map[string]any{"path": "pkg/a.go"})
if len(keys) != 1 || keys[0] != ResourceKeyFile+NormalizeResourcePath("pkg/a.go") {
t.Fatalf("resource keys = %v", keys)
}
}
func TestPluginAndMCPStyleDefaultUnknown(t *testing.T) {
// Plugin and MCP adapters do not implement CapabilityProvider; they must
// remain fail-closed (serialized) until they opt into a trusted contract.
for _, name := range []string{"mcp_github_create_issue", "plugin_custom_tool"} {
caps := CapabilitiesOf(plainTool{name: name})
if caps.Effect != EffectUnknown || caps.ThreadSafe {
t.Fatalf("%s = %+v, want Unknown/not-thread-safe", name, caps)
}
}
}
func TestSchemasUnchangedForCoreTools(t *testing.T) {
// Metadata must not alter tool names or parameter shapes.
root := t.TempDir()
read := NewScopedReadFileTool(root, nil)
if read.Name() != "read_file" {
t.Fatalf("name = %q", read.Name())
}
params := read.Parameters()
if params.Type != "object" {
t.Fatalf("params type = %q", params.Type)
}
if _, ok := params.Properties["path"]; !ok {
t.Fatal("path property missing")
}
// Safety path unchanged.
if read.Safety().SideEffect != SideEffectRead {
t.Fatalf("safety side effect changed: %v", read.Safety().SideEffect)
}
}
+1 -2
View File
@@ -34,8 +34,7 @@ func NewScopedEditFileTool(workspaceRoot string, scope PathScope) Tool {
Required: []string{"path", "old_string", "new_string"},
AdditionalProperties: false,
},
safety: promptSafety(SideEffectWrite, "Edits files in place."),
capabilities: ToolCapabilities{Effect: EffectWorkspaceWrite, ThreadSafe: false, ResourceKeys: fileResourceKeys},
safety: promptSafety(SideEffectWrite, "Edits files in place."),
},
workspaceRoot: normalizeWorkspaceRoot(workspaceRoot),
scope: scope,
-2
View File
@@ -57,8 +57,6 @@ func newEscalateModelTool(registry modelregistry.Registry) escalateModelTool {
Reason: "Requests a mid-run switch to a stronger model; the agent loop performs the switch.",
AdvertiseInAuto: true,
},
// Requests mid-run model switch; session-bound.
capabilities: ToolCapabilities{Effect: EffectInteractive, ThreadSafe: false},
},
registry: registry,
}
-4
View File
@@ -465,8 +465,6 @@ func NewScopedExecCommandTool(workspaceRoot string, scope PathScope, manager *ex
AdditionalProperties: false,
},
safety: promptSafety(SideEffectShell, "Shell commands can read, write, or execute programs."),
// PTY/shell session — never concurrent.
capabilities: ToolCapabilities{Effect: EffectInteractive, ThreadSafe: false, ResourceKeys: processResourceKeys},
},
workspaceRoot: normalizeWorkspaceRoot(workspaceRoot),
scope: scope,
@@ -719,8 +717,6 @@ func NewWriteStdinTool(manager *execSessionManager) Tool {
Reason: "Sending stdin can drive an existing shell process beyond the original command; empty polling and Ctrl-C interrupts are allowed automatically.",
AdvertiseInAuto: true,
},
// Writes to a retained process stdin — process interaction.
capabilities: ToolCapabilities{Effect: EffectInteractive, ThreadSafe: false, ResourceKeys: processResourceKeys},
},
manager: manager,
}
+1 -2
View File
@@ -38,8 +38,7 @@ func NewScopedGlobTool(workspaceRoot string, scope PathScope) Tool {
Required: []string{"pattern"},
AdditionalProperties: false,
},
safety: readOnlySafety("Finds matching paths without reading contents or modifying files."),
capabilities: ToolCapabilities{Effect: EffectReadOnly, ThreadSafe: false, ResourceKeys: workspaceResourceKeys},
safety: readOnlySafety("Finds matching paths without reading contents or modifying files."),
},
workspaceRoot: normalizeWorkspaceRoot(workspaceRoot),
scope: scope,
+1 -2
View File
@@ -53,8 +53,7 @@ func NewScopedGrepTool(workspaceRoot string, scope PathScope) Tool {
Required: []string{"pattern"},
AdditionalProperties: false,
},
safety: readOnlySafety("Searches file paths and matching lines without modifying files."),
capabilities: ToolCapabilities{Effect: EffectReadOnly, ThreadSafe: false, ResourceKeys: workspaceResourceKeys},
safety: readOnlySafety("Searches file paths and matching lines without modifying files."),
},
workspaceRoot: normalizeWorkspaceRoot(workspaceRoot),
scope: scope,
+1 -2
View File
@@ -33,8 +33,7 @@ func NewScopedListDirectoryTool(workspaceRoot string, scope PathScope) Tool {
},
AdditionalProperties: false,
},
safety: readOnlySafety("Lists directory entries without modifying files."),
capabilities: ToolCapabilities{Effect: EffectReadOnly, ThreadSafe: false, ResourceKeys: directoryResourceKeys},
safety: readOnlySafety("Lists directory entries without modifying files."),
},
workspaceRoot: normalizeWorkspaceRoot(workspaceRoot),
scope: scope,
+9 -18
View File
@@ -53,8 +53,7 @@ func newBrowserInstallTool(options localcontrol.BrowserOptions) Tool {
},
AdditionalProperties: false,
},
safety: localControlSafety(options.Enabled, SideEffectLocalBrowser, "Downloads browser runtime files for local browser automation."),
capabilities: ToolCapabilities{Effect: EffectInteractive, ThreadSafe: false, ResourceKeys: sessionResourceKeys},
safety: localControlSafety(options.Enabled, SideEffectLocalBrowser, "Downloads browser runtime files for local browser automation."),
},
browser: localcontrol.NewBrowser(options),
}
@@ -99,8 +98,7 @@ func newBrowserLaunchToolWithLauncher(options localcontrol.BrowserOptions, launc
Required: []string{"app"},
AdditionalProperties: false,
},
safety: localControlSafety(options.Enabled, SideEffectLocalBrowser, "Launches a supported local Chromium/Electron app with DevTools enabled and attaches local browser automation."),
capabilities: ToolCapabilities{Effect: EffectInteractive, ThreadSafe: false, ResourceKeys: sessionResourceKeys},
safety: localControlSafety(options.Enabled, SideEffectLocalBrowser, "Launches a supported local Chromium/Electron app with DevTools enabled and attaches local browser automation."),
},
browser: localcontrol.NewBrowser(options),
launcher: launcher,
@@ -174,8 +172,7 @@ func newBrowserConnectTool(options localcontrol.BrowserOptions) Tool {
Required: []string{"target"},
AdditionalProperties: false,
},
safety: localControlSafety(options.Enabled, SideEffectLocalBrowser, "Attaches to a local browser or Electron app exposed over the Chrome DevTools Protocol."),
capabilities: ToolCapabilities{Effect: EffectInteractive, ThreadSafe: false, ResourceKeys: sessionResourceKeys},
safety: localControlSafety(options.Enabled, SideEffectLocalBrowser, "Attaches to a local browser or Electron app exposed over the Chrome DevTools Protocol."),
},
browser: localcontrol.NewBrowser(options),
}
@@ -214,8 +211,7 @@ func newBrowserOpenTool(options localcontrol.BrowserOptions) Tool {
Required: []string{"url"},
AdditionalProperties: false,
},
safety: localControlSafety(options.Enabled, SideEffectNetwork, "Opens a URL through the local browser automation helper. For already-running Chrome/Electron apps, use browser_connect instead. If first use reports a missing browser runtime, run browser_install once."),
capabilities: ToolCapabilities{Effect: EffectInteractive, ThreadSafe: false, ResourceKeys: sessionResourceKeys},
safety: localControlSafety(options.Enabled, SideEffectNetwork, "Opens a URL through the local browser automation helper. For already-running Chrome/Electron apps, use browser_connect instead. If first use reports a missing browser runtime, run browser_install once."),
},
browser: localcontrol.NewBrowser(options),
}
@@ -257,8 +253,7 @@ func newBrowserSnapshotTool(options localcontrol.BrowserOptions) Tool {
},
AdditionalProperties: false,
},
safety: localControlSafety(options.Enabled, SideEffectLocalBrowser, "Reads the current local browser automation session state."),
capabilities: ToolCapabilities{Effect: EffectInteractive, ThreadSafe: false, ResourceKeys: sessionResourceKeys},
safety: localControlSafety(options.Enabled, SideEffectLocalBrowser, "Reads the current local browser automation session state."),
},
browser: localcontrol.NewBrowser(options),
}
@@ -297,8 +292,7 @@ func newBrowserClickTool(options localcontrol.BrowserOptions) Tool {
Required: []string{"ref"},
AdditionalProperties: false,
},
safety: localControlSafety(options.Enabled, SideEffectLocalBrowser, "Clicks an element in the local browser automation session."),
capabilities: ToolCapabilities{Effect: EffectInteractive, ThreadSafe: false, ResourceKeys: sessionResourceKeys},
safety: localControlSafety(options.Enabled, SideEffectLocalBrowser, "Clicks an element in the local browser automation session."),
},
browser: localcontrol.NewBrowser(options),
}
@@ -338,8 +332,7 @@ func newBrowserTypeTool(options localcontrol.BrowserOptions) Tool {
Required: []string{"ref", "text"},
AdditionalProperties: false,
},
safety: localControlSafety(options.Enabled, SideEffectLocalBrowser, "Types text into an element in the local browser automation session."),
capabilities: ToolCapabilities{Effect: EffectInteractive, ThreadSafe: false, ResourceKeys: sessionResourceKeys},
safety: localControlSafety(options.Enabled, SideEffectLocalBrowser, "Types text into an element in the local browser automation session."),
},
browser: localcontrol.NewBrowser(options),
}
@@ -378,8 +371,7 @@ func newBrowserPressTool(options localcontrol.BrowserOptions) Tool {
Required: []string{"key"},
AdditionalProperties: false,
},
safety: localControlSafety(options.Enabled, SideEffectLocalBrowser, "Presses a key in the local browser automation session."),
capabilities: ToolCapabilities{Effect: EffectInteractive, ThreadSafe: false, ResourceKeys: sessionResourceKeys},
safety: localControlSafety(options.Enabled, SideEffectLocalBrowser, "Presses a key in the local browser automation session."),
},
browser: localcontrol.NewBrowser(options),
}
@@ -464,8 +456,7 @@ func newBrowserActionTool(options localcontrol.BrowserOptions) Tool {
Required: []string{"command"},
AdditionalProperties: false,
},
safety: localControlSafety(options.Enabled, SideEffectLocalBrowser, "Interacts with the local browser automation session."),
capabilities: ToolCapabilities{Effect: EffectInteractive, ThreadSafe: false, ResourceKeys: sessionResourceKeys},
safety: localControlSafety(options.Enabled, SideEffectLocalBrowser, "Interacts with the local browser automation session."),
},
browser: localcontrol.NewBrowser(options),
}
-2
View File
@@ -58,8 +58,6 @@ func newCaptureArtifactTool(options LocalControlArtifactOptions) Tool {
AdditionalProperties: false,
},
safety: localControlSafety(enabled, SideEffectLocalControl, "Captures local browser, desktop, or terminal state into the configured artifact directory."),
// Captures write artifacts under the configured directory.
capabilities: ToolCapabilities{Effect: EffectWorkspaceWrite, ThreadSafe: false},
},
browser: localcontrol.NewBrowser(options.Browser),
desktop: localcontrol.NewDesktop(options.Desktop),
+3 -6
View File
@@ -35,8 +35,7 @@ func newDesktopWindowsTool(options localcontrol.DesktopOptions) Tool {
},
AdditionalProperties: false,
},
safety: localControlSafety(options.Enabled, SideEffectLocalDesktop, "Reads native desktop window metadata."),
capabilities: ToolCapabilities{Effect: EffectInteractive, ThreadSafe: false},
safety: localControlSafety(options.Enabled, SideEffectLocalDesktop, "Reads native desktop window metadata."),
},
desktop: localcontrol.NewDesktop(options),
}
@@ -82,8 +81,7 @@ func newDesktopSnapshotTool(options localcontrol.DesktopOptions) Tool {
Required: []string{"pid", "window_id"},
AdditionalProperties: false,
},
safety: localControlSafety(options.Enabled, SideEffectLocalDesktop, "Reads a native desktop window state."),
capabilities: ToolCapabilities{Effect: EffectInteractive, ThreadSafe: false},
safety: localControlSafety(options.Enabled, SideEffectLocalDesktop, "Reads a native desktop window state."),
},
desktop: localcontrol.NewDesktop(options),
}
@@ -127,8 +125,7 @@ func newDesktopActionTool(options localcontrol.DesktopOptions) Tool {
Required: []string{"command", "input"},
AdditionalProperties: false,
},
safety: localControlSafety(options.Enabled, SideEffectLocalDesktop, "Interacts with native desktop windows."),
capabilities: ToolCapabilities{Effect: EffectInteractive, ThreadSafe: false},
safety: localControlSafety(options.Enabled, SideEffectLocalDesktop, "Interacts with native desktop windows."),
},
desktop: localcontrol.NewDesktop(options),
}
+1 -2
View File
@@ -45,8 +45,7 @@ func newTerminalSessionTool(options localcontrol.TerminalOptions) Tool {
Required: []string{"action", "session"},
AdditionalProperties: false,
},
safety: localControlSafety(options.Enabled, SideEffectLocalTerminal, "Launches or interacts with a local virtual terminal session."),
capabilities: ToolCapabilities{Effect: EffectInteractive, ThreadSafe: false, ResourceKeys: processResourceKeys},
safety: localControlSafety(options.Enabled, SideEffectLocalTerminal, "Launches or interacts with a local virtual terminal session."),
},
terminal: localcontrol.NewTerminal(options),
}
+1 -2
View File
@@ -58,8 +58,7 @@ func NewScopedLSPNavigateTool(workspaceRoot string, scope PathScope) Tool {
Required: []string{"op", "path"},
AdditionalProperties: false,
},
safety: readOnlySafety("Queries the language server for code navigation; reads files, modifies nothing."),
capabilities: ToolCapabilities{Effect: EffectReadOnly, ThreadSafe: false, ResourceKeys: fileResourceKeys},
safety: readOnlySafety("Queries the language server for code navigation; reads files, modifies nothing."),
},
workspaceRoot: root,
scope: scope,
-2
View File
@@ -39,8 +39,6 @@ func NewScopedReadFileTool(workspaceRoot string, scope PathScope) Tool {
AdditionalProperties: false,
},
safety: readOnlySafety("Reads file contents without modifying files."),
// ThreadSafe=false: may update FileTracker session state on read.
capabilities: ToolCapabilities{Effect: EffectReadOnly, ThreadSafe: false, ResourceKeys: fileResourceKeys},
},
workspaceRoot: normalizeWorkspaceRoot(workspaceRoot),
scope: scope,
+1 -2
View File
@@ -33,8 +33,7 @@ func NewScopedReadMinifiedFileTool(workspaceRoot string, scope PathScope) Tool {
Required: []string{"path"},
AdditionalProperties: false,
},
safety: readOnlySafety("Reads a minified view of file contents without modifying files."),
capabilities: ToolCapabilities{Effect: EffectReadOnly, ThreadSafe: false, ResourceKeys: fileResourceKeys},
safety: readOnlySafety("Reads a minified view of file contents without modifying files."),
},
workspaceRoot: normalizeWorkspaceRoot(workspaceRoot),
scope: scope,
-2
View File
@@ -34,8 +34,6 @@ func NewRequestPermissionsTool() Tool {
Permission: PermissionAllow,
Reason: "Requests a user permission decision but does not read, write, run commands, or use the network.",
},
// Interactive permission escalation.
capabilities: ToolCapabilities{Effect: EffectInteractive, ThreadSafe: false},
},
}
}
-319
View File
@@ -1,319 +0,0 @@
package tools
import (
"fmt"
"path/filepath"
"runtime"
"strings"
)
// Resource-key prefixes used for future conflict detection. Keys are pure
// metadata in this PR — no locks or scheduling are applied.
//
// Unused prefixes (repository/pty/browser) are reserved for follow-up
// classifiers so PR6 conflict keys stay vocabulary-stable.
const (
ResourceKeyFile = "file:"
ResourceKeyDirectory = "directory:"
ResourceKeyRepository = "repository:" // reserved
ResourceKeyProcess = "process:"
ResourceKeyPTY = "pty:" // reserved
ResourceKeyBrowser = "browser:" // reserved
ResourceKeyEndpoint = "endpoint:"
ResourceKeySession = "session:"
ResourceKeyWorkspace = "workspace:"
)
// NormalizeResourcePath produces a deterministic path token for resource keys.
// It never touches the filesystem (no EvalSymlinks / Stat) so it cannot cause
// side effects or panic on missing paths. Empty input returns "".
//
// Rules:
// - Trim space
// - filepath.Clean
// - Convert separators to '/'
// - On Windows, lower-case for case-insensitive comparison
// - Strip a leading "./"
// - Never include credentials or query fragments (paths only)
func NormalizeResourcePath(path string) string {
path = strings.TrimSpace(path)
if path == "" {
return ""
}
// Reject URL-shaped values — those use endpoint: keys.
if strings.Contains(path, "://") {
return ""
}
cleaned := filepath.Clean(path)
if cleaned == "." {
return "."
}
normalized := filepath.ToSlash(cleaned)
normalized = strings.TrimPrefix(normalized, "./")
if runtime.GOOS == "windows" {
normalized = strings.ToLower(normalized)
}
return normalized
}
// fileResourceKeys extracts a file: resource key from common path argument names.
// Missing or empty path returns nil (not an error); never panics.
func fileResourceKeys(args map[string]any) []string {
path := firstStringArg(args, "path", "file", "file_path", "filepath", "filename", "target")
normalized := NormalizeResourcePath(path)
if normalized == "" {
return nil
}
return []string{ResourceKeyFile + normalized}
}
// directoryResourceKeys extracts a directory: key from path/directory/cwd args.
func directoryResourceKeys(args map[string]any) []string {
path := firstStringArg(args, "path", "directory", "dir", "cwd", "workdir")
normalized := NormalizeResourcePath(path)
if normalized == "" {
return nil
}
return []string{ResourceKeyDirectory + normalized}
}
// endpointResourceKeys extracts an endpoint: key from url/endpoint args.
// Host only is kept when a full URL is provided (no secrets/query/userinfo).
func endpointResourceKeys(args map[string]any) []string {
raw := firstStringArg(args, "url", "endpoint", "uri")
raw = strings.TrimSpace(raw)
if raw == "" {
return nil
}
host := resourceHost(raw)
if host == "" {
return nil
}
return []string{ResourceKeyEndpoint + host}
}
// sessionResourceKeys extracts a session: key from session / session_id args.
// Numeric session ids (JSON numbers) are coerced to strings.
func sessionResourceKeys(args map[string]any) []string {
return scopedIDResourceKeys(ResourceKeySession, args, "session", "session_id", "sessionId")
}
// processResourceKeys extracts process: keys for retained process / terminal
// tools. Accepts session, session_id, pid, and numeric JSON values so
// write_stdin (integer session_id) and terminal_session (string session)
// both produce keys.
func processResourceKeys(args map[string]any) []string {
return scopedIDResourceKeys(ResourceKeyProcess, args,
"session", "session_id", "sessionId", "process_id", "pid", "id")
}
// scopedIDResourceKeys is the shared extract/trim/bound/prefix path for
// session- and process-scoped resource keys.
func scopedIDResourceKeys(prefix string, args map[string]any, keys ...string) []string {
id := strings.TrimSpace(firstStringArg(args, keys...))
if id == "" {
return nil
}
// Never put free-form content in keys — IDs only, bounded length.
if len(id) > 128 {
id = id[:128]
}
return []string{prefix + id}
}
// workspaceResourceKeys returns a single workspace: root marker when the call
// affects the whole workspace (e.g. glob without a path root).
func workspaceResourceKeys(_ map[string]any) []string {
return []string{ResourceKeyWorkspace + "root"}
}
// multiFileResourceKeys collects path and paths[] arguments into file: keys.
func multiFileResourceKeys(args map[string]any) []string {
var keys []string
keys = append(keys, fileResourceKeys(args)...)
if raw, ok := args["paths"]; ok {
switch typed := raw.(type) {
case []string:
for _, p := range typed {
if n := NormalizeResourcePath(p); n != "" {
keys = append(keys, ResourceKeyFile+n)
}
}
case []any:
for _, item := range typed {
if s, ok := item.(string); ok {
if n := NormalizeResourcePath(s); n != "" {
keys = append(keys, ResourceKeyFile+n)
}
}
}
}
}
return uniqueKeys(keys)
}
// applyPatchResourceKeys derives conflict keys for apply_patch: the cwd as a
// directory key, optional path/paths args, plus any +++/--- file paths
// parseable from the unified diff. Relative diff paths are joined under cwd
// so the same file under different cwd values does not collide (e.g. cwd=pkg
// + +++ b/new.go → file:pkg/new.go). Falls back to workspace:root when nothing
// is available so a patch call is never "keyless" for the future conflict planner.
func applyPatchResourceKeys(args map[string]any) []string {
var keys []string
keys = append(keys, directoryResourceKeys(args)...)
keys = append(keys, multiFileResourceKeys(args)...)
cwd := firstStringArg(args, "cwd", "workdir")
patch := firstStringArg(args, "patch", "diff")
for _, p := range pathsFromUnifiedDiff(patch) {
joined := joinUnderResourceCwd(cwd, p)
if n := NormalizeResourcePath(joined); n != "" {
keys = append(keys, ResourceKeyFile+n)
}
}
keys = uniqueKeys(keys)
if len(keys) == 0 {
return workspaceResourceKeys(args)
}
return keys
}
// joinUnderResourceCwd joins a relative path under cwd for resource keys only.
// Absolute paths are left unchanged. Empty cwd or "." leaves rel as-is.
// Never touches the filesystem.
func joinUnderResourceCwd(cwd, rel string) string {
rel = strings.TrimSpace(rel)
if rel == "" {
return ""
}
// Absolute (Unix or Windows) paths are already workspace-rooted or outside;
// do not re-prefix.
if filepath.IsAbs(rel) {
return rel
}
cwd = strings.TrimSpace(cwd)
if cwd == "" || cwd == "." {
return rel
}
return filepath.ToSlash(filepath.Join(cwd, rel))
}
// pathsFromUnifiedDiff extracts a/ and b/ file paths from unified-diff headers.
// Malformed input returns nil; never panics.
func pathsFromUnifiedDiff(patch string) []string {
if strings.TrimSpace(patch) == "" {
return nil
}
var out []string
for _, line := range strings.Split(patch, "\n") {
line = strings.TrimSpace(line)
switch {
case strings.HasPrefix(line, "+++ ") || strings.HasPrefix(line, "--- "):
rest := strings.TrimSpace(line[4:])
// Drop trailing tab timestamp if present.
if i := strings.IndexByte(rest, '\t'); i >= 0 {
rest = rest[:i]
}
if rest == "/dev/null" || rest == "" {
continue
}
// Strip a/ or b/ prefix common in git diffs.
if len(rest) > 2 && (rest[0] == 'a' || rest[0] == 'b') && rest[1] == '/' {
rest = rest[2:]
}
out = append(out, rest)
}
}
return out
}
// firstStringArg returns the first present argument as a string. Numeric JSON
// values (float64/int from encoding/json) are coerced so tools that declare
// integer session ids still yield resource keys.
func firstStringArg(args map[string]any, keys ...string) string {
if args == nil {
return ""
}
for _, key := range keys {
value, ok := args[key]
if !ok || value == nil {
continue
}
switch typed := value.(type) {
case string:
return typed
case float64:
// JSON numbers decode as float64. Prefer integer form when exact.
if typed == float64(int64(typed)) {
return fmt.Sprintf("%d", int64(typed))
}
return fmt.Sprintf("%v", typed)
case float32:
return fmt.Sprintf("%v", typed)
case int:
return fmt.Sprintf("%d", typed)
case int64:
return fmt.Sprintf("%d", typed)
case int32:
return fmt.Sprintf("%d", typed)
case jsonNumber:
return typed.String()
}
}
return ""
}
// jsonNumber matches encoding/json.Number without importing encoding/json here
// for a one-line type assert (tests may inject it).
type jsonNumber interface {
String() string
}
// resourceHost returns a lower-cased host for endpoint keys, stripping
// userinfo, path, query, and fragment. Returns "" when not parseable safely.
//
// Order matters: path/query/fragment are removed BEFORE userinfo so a query
// value containing "@" cannot steal the authority (e.g.
// https://api.example.com/v1?token=@secret → endpoint:api.example.com).
// host:port is preserved so two services on different ports do not collide.
func resourceHost(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
// Strip scheme.
if idx := strings.Index(raw, "://"); idx >= 0 {
raw = raw[idx+3:]
}
// Isolate authority: strip path/query/fragment first.
if slash := strings.IndexAny(raw, "/?#"); slash >= 0 {
raw = raw[:slash]
}
// Strip userinfo from the authority only.
if at := strings.LastIndex(raw, "@"); at >= 0 {
raw = raw[at+1:]
}
raw = strings.TrimSpace(raw)
if raw == "" || strings.Contains(raw, "@") {
return ""
}
return strings.ToLower(raw)
}
func uniqueKeys(keys []string) []string {
if len(keys) == 0 {
return nil
}
seen := make(map[string]struct{}, len(keys))
out := make([]string, 0, len(keys))
for _, k := range keys {
if k == "" {
continue
}
if _, ok := seen[k]; ok {
continue
}
seen[k] = struct{}{}
out = append(out, k)
}
return out
}
+1 -2
View File
@@ -48,8 +48,7 @@ func NewSkillTool(dir string) *skillTool {
// unknown keys (AdditionalProperties:false).
AdditionalProperties: false,
},
safety: readOnlySafety("Reads a local skill file; gathers reusable instructions only."),
capabilities: ToolCapabilities{Effect: EffectReadOnly, ThreadSafe: false},
safety: readOnlySafety("Reads a local skill file; gathers reusable instructions only."),
},
}
}
-2
View File
@@ -51,8 +51,6 @@ func NewToolSearchTool(registry *Registry) Tool {
Reason: "Lists and loads already-registered tool schemas; performs no side effects.",
AdvertiseInAuto: true,
},
// Reads registry schemas only; no workspace mutation.
capabilities: ToolCapabilities{Effect: EffectReadOnly, ThreadSafe: false},
},
registry: registry,
}
+4 -5
View File
@@ -143,11 +143,10 @@ type PrePermissionRejecter interface {
}
type baseTool struct {
name string
description string
parameters Schema
safety Safety
capabilities ToolCapabilities // zero value = EffectUnknown, not thread-safe
name string
description string
parameters Schema
safety Safety
}
func (tool baseTool) Name() string {
-2
View File
@@ -63,8 +63,6 @@ func NewUpdatePlanTool() *updatePlanTool {
AdditionalProperties: false,
},
safety: readOnlySafety("Updates in-memory planning state only."),
// Session plan state — ordering matters across turns.
capabilities: ToolCapabilities{Effect: EffectInteractive, ThreadSafe: false, ResourceKeys: sessionResourceKeys},
},
}
}
-2
View File
@@ -141,8 +141,6 @@ func newWebFetchToolWithClientAndResolver(client *http.Client, resolver webFetch
Reason: "Fetches remote URL content over the network.",
AdvertiseInAuto: true,
},
// Network read; ThreadSafe=false until client proven concurrent-safe.
capabilities: ToolCapabilities{Effect: EffectReadOnly, ThreadSafe: false, ResourceKeys: endpointResourceKeys},
},
client: client,
resolver: resolver,
-2
View File
@@ -90,8 +90,6 @@ func newWebSearchToolWithBackend(backend searchBackend) Tool {
Reason: "Sends model-provided search query text to the configured web search backend.",
AdvertiseInAuto: true,
},
// Search backends may share non-thread-safe clients.
capabilities: ToolCapabilities{Effect: EffectReadOnly, ThreadSafe: false},
},
backend: backend,
}
+1 -2
View File
@@ -33,8 +33,7 @@ func NewScopedWriteFileTool(workspaceRoot string, scope PathScope) Tool {
Required: []string{"path", "content"},
AdditionalProperties: false,
},
safety: promptSafety(SideEffectWrite, "Creates or overwrites files."),
capabilities: ToolCapabilities{Effect: EffectWorkspaceWrite, ThreadSafe: false, ResourceKeys: fileResourceKeys},
safety: promptSafety(SideEffectWrite, "Creates or overwrites files."),
},
workspaceRoot: normalizeWorkspaceRoot(workspaceRoot),
scope: scope,
+12 -6
View File
@@ -172,14 +172,18 @@ func TestProviderWizardAdvancesProviderAPIKeyAndModelSteps(t *testing.T) {
m := newModel(context.Background(), Options{})
m = openProviderWizardForTest(t, m)
m.providerWizard.selectedProvider = providerWizardProviderIndex(t, m.providerWizard, "anthropic")
next := m
// Index 0 is the recommended OpenGateway entry, so two downs land on anthropic
// (opengateway → openai → anthropic).
updated, _ := m.Update(testKey(tea.KeyDown))
next := updated.(model)
updated, _ = next.Update(testKey(tea.KeyDown))
next = updated.(model)
if got := next.providerWizard.currentProvider().ID; got != "anthropic" {
t.Fatalf("after down, selected provider = %q, want anthropic", got)
}
clearProviderAuthEnvForTest(t, next.providerWizard.currentProvider())
updated, _ := next.Update(testKey(tea.KeyEnter))
updated, _ = next.Update(testKey(tea.KeyEnter))
next = updated.(model)
if next.providerWizard.step != providerWizardStepCredential {
t.Fatalf("wizard step = %v, want credential", next.providerWizard.step)
@@ -310,12 +314,14 @@ func TestProviderWizardRightAllowsExistingCredentialEnv(t *testing.T) {
m := newModel(context.Background(), Options{})
m = openProviderWizardForTest(t, m)
m.providerWizard.selectedProvider = providerWizardProviderIndex(t, m.providerWizard, "openai")
next := m
// Move off the recommended OpenGateway entry (index 0) onto openai so the
// OPENAI_API_KEY env credential is the one in play.
updated, _ := m.Update(testKey(tea.KeyDown))
next := updated.(model)
if got := next.providerWizard.currentProvider().ID; got != "openai" {
t.Fatalf("after down, selected provider = %q, want openai", got)
}
updated, _ := next.Update(testKey(tea.KeyEnter))
updated, _ = next.Update(testKey(tea.KeyEnter))
next = updated.(model)
if next.providerWizard.step != providerWizardStepCredential {
t.Fatalf("enter from provider step = %v, want credential", next.providerWizard.step)
-13
View File
@@ -137,19 +137,6 @@ func zeroWordmarkLines() []string {
return lines
}
// Wordmark renders the brand ASCII art shown on the TUI's empty state, for
// reuse outside the TUI (e.g. `zero --version`). It is deliberately
// uncolored: only newModel resolves --theme/ZERO_THEME, so painting the
// global palette here would ignore a selected light theme and be unreadable
// on light terminals. The terminal's default foreground works everywhere.
func Wordmark() string {
lines := make([]string, 0, minInt(len(zeroWordmarkPrefixLines), len(zeroWordmarkOLines)))
for index := 0; index < len(zeroWordmarkPrefixLines) && index < len(zeroWordmarkOLines); index++ {
lines = append(lines, zeroWordmarkPrefixLines[index]+zeroWordmarkOLines[index])
}
return strings.Join(lines, "\n")
}
func borderedBlock(width int, lines []string) string {
return styledBlock(width, lines, zeroTheme.line)
}
-19
View File
@@ -21,25 +21,6 @@ func TestEmptyStateShowsBrandAndTaglineOnly(t *testing.T) {
assertNotContains(t, view, "fix the failing test in internal/tools")
}
// TestWordmarkIsPlain guards the --version banner: it must carry no ANSI
// escapes, because this renderer never resolves --theme/ZERO_THEME and any
// palette color could be unreadable on the user's background.
func TestWordmarkIsPlain(t *testing.T) {
wordmark := Wordmark()
if strings.Contains(wordmark, "\x1b") {
t.Fatalf("expected uncolored wordmark, got %q", wordmark)
}
lines := strings.Split(wordmark, "\n")
if len(lines) != len(zeroWordmarkPrefixLines) {
t.Fatalf("expected %d wordmark lines, got %d", len(zeroWordmarkPrefixLines), len(lines))
}
for index, line := range lines {
if want := zeroWordmarkPrefixLines[index] + zeroWordmarkOLines[index]; line != want {
t.Fatalf("wordmark line %d: expected %q, got %q", index, want, line)
}
}
}
func TestEmptyStateShowsVersion(t *testing.T) {
m := newModel(context.Background(), Options{Version: "0.2.0"})
m.width, m.height = 100, 30