mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-10 17:57:54 -10:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c4de52a5c5 | ||
|
|
d061524989 | ||
|
|
2bfb340668 | ||
|
|
40806aa4b1 | ||
|
|
0f372f3844 | ||
|
|
b3e6ef1f58 | ||
|
|
6896fee475 | ||
|
|
2ec91f4493 |
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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 ""
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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."),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user