From 30e08ffed70bd9bcf62fe07341e04d20f594e4ec Mon Sep 17 00:00:00 2001 From: Reidho Satria Date: Wed, 16 Sep 2026 18:20:23 +0700 Subject: [PATCH 1/3] fix(skills): persist enablement in agent configuration --- cmd/antares/main.go | 8 + cmd/antares/skills.go | 24 ++ cmd/antares/skills_migration_test.go | 241 +++++++++++++ docs/configuration.md | 6 + docs/skills.md | 11 +- internal/agent/agent.go | 8 + internal/config/config.go | 2 + internal/config/schema.go | 1 + internal/config/skills.go | 105 ++++++ internal/config/skills_test.go | 314 +++++++++++++++++ internal/server/handlers_commands.go | 2 +- internal/server/handlers_config.go | 20 +- internal/server/handlers_hub.go | 8 +- internal/server/handlers_subsystems.go | 59 ++-- internal/server/server.go | 9 + internal/server/skill_toggle_test.go | 450 +++++++++++++++++++++++++ internal/skills/skills.go | 96 +++--- internal/skills/state_test.go | 219 ++++++++++++ 18 files changed, 1500 insertions(+), 83 deletions(-) create mode 100644 cmd/antares/skills.go create mode 100644 cmd/antares/skills_migration_test.go create mode 100644 internal/config/skills.go create mode 100644 internal/config/skills_test.go create mode 100644 internal/server/skill_toggle_test.go create mode 100644 internal/skills/state_test.go diff --git a/cmd/antares/main.go b/cmd/antares/main.go index 4468e1c..0c3e1c4 100644 --- a/cmd/antares/main.go +++ b/cmd/antares/main.go @@ -215,6 +215,10 @@ func bootstrap(ctx context.Context) (*runtimeServices, error) { if err != nil { return nil, err } + cfg, err = migrateSkillState(cfg) + if err != nil { + return nil, err + } if err := logx.Setup(cfg.Logging.Level, cfg.Logging.File, cfg.Logging.JSON); err != nil { return nil, fmt.Errorf("setting up logging: %w", err) } @@ -697,6 +701,10 @@ func (rt *runtimeServices) reload() error { if err != nil { return err } + cfg, err = migrateSkillState(cfg) + if err != nil { + return err + } cfg, _ = config.Effective(rt.cfg, cfg) previous := rt.cfg if err := cfg.Server.ValidateListen(); err != nil { diff --git a/cmd/antares/skills.go b/cmd/antares/skills.go new file mode 100644 index 0000000..c0c0b0e --- /dev/null +++ b/cmd/antares/skills.go @@ -0,0 +1,24 @@ +package main + +import ( + "fmt" + + "github.com/enowdev/antares/internal/config" + "github.com/enowdev/antares/internal/skills" +) + +// migrateSkillState imports only the selected configured sources, once per profile. +func migrateSkillState(cfg *config.Config) (*config.Config, error) { + if cfg.Skills.FrontmatterMigrated { + return cfg, nil + } + manager := skills.NewManager(expandAll(cfg.Skills.Dirs)) + if err := manager.Reload(); err != nil { + return nil, fmt.Errorf("migrate skill preferences: %w", err) + } + fresh, err := config.MigrateSkillState(manager.LegacyDisabled()) + if err != nil { + return nil, fmt.Errorf("migrate skill preferences: %w", err) + } + return fresh, nil +} diff --git a/cmd/antares/skills_migration_test.go b/cmd/antares/skills_migration_test.go new file mode 100644 index 0000000..0d7d2dc --- /dev/null +++ b/cmd/antares/skills_migration_test.go @@ -0,0 +1,241 @@ +package main + +import ( + "bytes" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/enowdev/antares/internal/config" + "github.com/enowdev/antares/internal/skills" +) + +func skillMigrationConfig(t *testing.T) *config.Config { + t.Helper() + home := t.TempDir() + t.Setenv("ANTARES_HOME", home) + t.Setenv("ANTARES_CONFIG", filepath.Join(home, "config.yaml")) + t.Setenv("ANTARES_PROFILE", "default") + cfg := config.Default() + cfg.Skills.Dirs = []string{filepath.Join(home, "skills")} + if err := os.MkdirAll(cfg.Skills.Dirs[0], 0o700); err != nil { + t.Fatal(err) + } + if err := config.Save(cfg); err != nil { + t.Fatal(err) + } + return cfg +} + +func migrationSource(t *testing.T, dir, file, name, enabled string) string { + t.Helper() + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + path := filepath.Join(dir, file) + if err := os.WriteFile(path, []byte("---\nname: "+name+"\ndescription: fixture\nenabled: "+enabled+"\n---\nBODY\n"), 0o600); err != nil { + t.Fatal(err) + } + return path +} + +func TestMigrateSkillStateOnceAndSelectedSources(t *testing.T) { + cfg := skillMigrationConfig(t) + cfg.Skills.Disabled = []string{"missing"} + second := t.TempDir() + cfg.Skills.Dirs = append(cfg.Skills.Dirs, second) + path := migrationSource(t, cfg.Skills.Dirs[0], "legacy.md", "legacy", "false") + migrationSource(t, cfg.Skills.Dirs[0], "duplicate.md", "duplicate", "false") + migrationSource(t, second, "duplicate.md", "duplicate", "true") + migrationSource(t, config.Path("security-skills"), "pack.md", "automatic-pack", "false") + before, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if err := config.Save(cfg); err != nil { + t.Fatal(err) + } + cfg, err = migrateSkillState(cfg) + if err != nil { + t.Fatal(err) + } + if !cfg.Skills.FrontmatterMigrated || !reflect.DeepEqual(cfg.Skills.Disabled, []string{"legacy", "missing"}) { + t.Fatalf("migration = %+v", cfg.Skills) + } + after, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(before, after) { + t.Fatal("migration rewrote legacy source") + } + cfg, err = config.SetSkillEnabled("legacy", true) + if err != nil { + t.Fatal(err) + } + migrationSource(t, cfg.Skills.Dirs[0], "later.md", "later", "false") + cfg, err = config.Reload() + if err != nil { + t.Fatal(err) + } + cfg, err = migrateSkillState(cfg) + if err != nil { + t.Fatal(err) + } + m := skills.NewManager(expandAll(cfg.Skills.Dirs)) + if err := m.Reload(); err != nil { + t.Fatal(err) + } + m.SetDisabled(cfg.Skills.Disabled) + for _, name := range []string{"legacy", "later"} { + s, ok := m.Get(name) + if !ok || !s.Enabled { + t.Fatalf("%s disabled by stale header after migration: %+v", name, s) + } + } + // The completed marker bypasses even a now-malformed configured source. + if err := os.WriteFile(path, []byte("---\nenabled: [\n---\n"), 0o600); err != nil { + t.Fatal(err) + } + if _, err := migrateSkillState(cfg); err != nil { + t.Fatalf("completed migration rescanned: %v", err) + } +} + +func TestMigrateSkillStateEmptyAndExplicitPack(t *testing.T) { + cfg := skillMigrationConfig(t) + cfg.Skills.Dirs = append(cfg.Skills.Dirs, filepath.Join(t.TempDir(), "missing")) + if err := config.Save(cfg); err != nil { + t.Fatal(err) + } + fresh, err := migrateSkillState(cfg) + if err != nil { + t.Fatal(err) + } + if !fresh.Skills.FrontmatterMigrated || len(fresh.Skills.Disabled) != 0 { + t.Fatalf("empty migration = %+v", fresh.Skills) + } + // Explicitly configured bundled paths are configured sources, not excluded by location. + cfg.Skills.Dirs = []string{config.Path("security-skills")} + migrationSource(t, cfg.Skills.Dirs[0], "explicit.md", "explicit", "false") + if err := config.Save(cfg); err != nil { + t.Fatal(err) + } + fresh, err = migrateSkillState(cfg) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(fresh.Skills.Disabled, []string{"explicit"}) { + t.Fatalf("explicit import = %v", fresh.Skills.Disabled) + } +} + +func TestMigrateSkillStateIncompleteRetry(t *testing.T) { + cfg := skillMigrationConfig(t) + migrationSource(t, cfg.Skills.Dirs[0], "good.md", "good", "false") + broken := filepath.Join(cfg.Skills.Dirs[0], "broken.md") + if err := os.WriteFile(broken, []byte("---\nenabled: [\n---\n"), 0o600); err != nil { + t.Fatal(err) + } + if _, err := migrateSkillState(cfg); err == nil || !strings.Contains(err.Error(), "migrate skill preferences:") { + t.Fatalf("incomplete migration error = %v", err) + } + fresh, err := config.Reload() + if err != nil { + t.Fatal(err) + } + if fresh.Skills.FrontmatterMigrated { + t.Fatal("partial migration marked complete") + } + migrationSource(t, cfg.Skills.Dirs[0], "broken.md", "repaired", "false") + fresh, err = migrateSkillState(fresh) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(fresh.Skills.Disabled, []string{"good", "repaired"}) { + t.Fatalf("retry import = %v", fresh.Skills.Disabled) + } +} + +func TestMigrateSkillStateSaveFailureLeavesMarkerUnset(t *testing.T) { + cfg := skillMigrationConfig(t) + migrationSource(t, cfg.Skills.Dirs[0], "legacy.md", "legacy", "false") + // The file remains readable, but atomic replacement needs directory write permission. + original, err := os.ReadFile(config.ConfigFile()) + if err != nil { + t.Fatal(err) + } + if err := os.Chmod(filepath.Dir(config.ConfigFile()), 0o500); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chmod(filepath.Dir(config.ConfigFile()), 0o700) }) + if os.Geteuid() == 0 { + t.Skip("permission failure requires an unprivileged process; config package covers deterministic write failure") + } + if _, err := migrateSkillState(cfg); err == nil { + t.Fatal("migration succeeded despite unwritable config directory") + } + after, err := os.ReadFile(config.ConfigFile()) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(original, after) || cfg.Skills.FrontmatterMigrated { + t.Fatal("failed migration changed persisted marker") + } +} + +func TestMigrateSkillStateUnreadableSourceRetry(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("permission semantics require an unprivileged process") + } + for _, rootUnreadable := range []bool{false, true} { + t.Run(map[bool]string{false: "file", true: "root"}[rootUnreadable], func(t *testing.T) { + cfg := skillMigrationConfig(t) + p := migrationSource(t, cfg.Skills.Dirs[0], "legacy.md", "legacy", "false") + if rootUnreadable { + p = cfg.Skills.Dirs[0] + } + if err := os.Chmod(p, 0); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chmod(p, 0o700) }) + if _, err := migrateSkillState(cfg); err == nil { + t.Fatal("unreadable source completed migration") + } + fresh, err := config.Reload() + if err != nil { + t.Fatal(err) + } + if fresh.Skills.FrontmatterMigrated { + t.Fatal("incomplete import marked complete") + } + if err := os.Chmod(p, 0o700); err != nil { + t.Fatal(err) + } + fresh, err = migrateSkillState(fresh) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(fresh.Skills.Disabled, []string{"legacy"}) { + t.Fatalf("retry lost opt-out: %v", fresh.Skills.Disabled) + } + }) + } +} + +func TestRuntimeReloadAbortsIncompleteSkillMigration(t *testing.T) { + cfg := skillMigrationConfig(t) + if err := os.WriteFile(filepath.Join(cfg.Skills.Dirs[0], "bad.md"), []byte("---\nenabled: [\n---\n"), 0o600); err != nil { + t.Fatal(err) + } + rt := &runtimeServices{cfg: cfg.Clone()} + rt.cfg.Skills.FrontmatterMigrated = true + if err := rt.reload(); err == nil || !strings.Contains(err.Error(), "migrate skill preferences:") { + t.Fatalf("reload migration error = %v", err) + } + if !rt.cfg.Skills.FrontmatterMigrated { + t.Fatal("failed migration published replacement config") + } +} diff --git a/docs/configuration.md b/docs/configuration.md index aff74cd..a855969 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -234,6 +234,7 @@ which would leave the model reading a call with no answer. skills: enabled: true dirs: [~/.antares/skills] + disabled: [] auto_create: true creation_nudge_interval: 20 ``` @@ -241,6 +242,11 @@ skills: `dirs` is searched in order and later directories win, so a personal copy can override a shared one. See [Skills](skills.md). +`disabled` contains exact, case-sensitive names turned off for this profile, +including names whose files are temporarily absent. Dashboard switches update +this list without changing source files. Legacy configured opt-outs are imported +once; see [Managing skills](skills.md#managing-them). + ## Server ```yaml diff --git a/docs/skills.md b/docs/skills.md index 3afddb5..50396af 100644 --- a/docs/skills.md +++ b/docs/skills.md @@ -36,7 +36,6 @@ Port 8787 already in use usually means the old process did not exit. Check | `description` | **The most important line.** How the agent decides whether this is relevant | | `tags` | For your own browsing | | `triggers` | Words that make it more likely to surface | -| `enabled` | `false` keeps it on disk but out of the prompt | The description does the work. "Deployment stuff" will not get picked; "Deploy this project to the home server. Use when asked to deploy, ship, or release." @@ -100,6 +99,16 @@ learned it says so and writes nothing. The dashboard's Skills page lists them with a switch each, shows the body inline, and has a Browse button for the hub. +Switches save exact, case-sensitive skill names in `skills.disabled` in the active +profile's configuration; they never rewrite skill files. Preferences remain when +a file is removed or reinstalled. `skills.enabled` is the separate global gate. + +On the first startup with this setting, Antares imports `enabled: false` from +selected files in configured skill directories. It records completion in +`skills.frontmatter_migrated`. Later header changes do not affect enablement. +An unreadable or malformed configured source aborts that initial import; repair +the source and restart to retry. The dashboard and `/skills` still show off entries. + ```yaml skills: auto_create: true diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 00f7025..25236ae 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -248,8 +248,13 @@ func (a *Agent) SetConfig(cfg *config.Config) { if cfg == nil { return } + a.servicesMu.Lock() + if a.skills != nil { + a.skills.SetDisabled(cfg.Skills.Disabled) + } prev := a.cfg.Load() a.cfg.Store(cfg) + a.servicesMu.Unlock() // A raised MaxConcurrentSessions makes room for parked RunQueued // waiters immediately; without a wake here they would sit on the old // channel until an unrelated turn ended. @@ -274,6 +279,9 @@ func (a *Agent) SetRAG(p tools.RAGProvider) { // SetSkills attaches the skill library. Publishes under servicesMu. func (a *Agent) SetSkills(m *skills.Manager) { a.servicesMu.Lock() + if cfg := a.cfg.Load(); m != nil && cfg != nil { + m.SetDisabled(cfg.Skills.Disabled) + } a.skills = m a.servicesMu.Unlock() } diff --git a/internal/config/config.go b/internal/config/config.go index e2e4555..5c04d98 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -643,6 +643,8 @@ type Plugins struct { type Skills struct { Enabled bool `yaml:"enabled" json:"enabled"` Dirs []string `yaml:"dirs" json:"dirs"` + Disabled []string `yaml:"disabled" json:"disabled"` + FrontmatterMigrated bool `yaml:"frontmatter_migrated" json:"frontmatter_migrated"` CreationNudgeInterval int `yaml:"creation_nudge_interval" json:"creation_nudge_interval"` AutoCreate bool `yaml:"auto_create" json:"auto_create"` HubSources []string `yaml:"hub_sources" json:"hub_sources"` diff --git a/internal/config/schema.go b/internal/config/schema.go index a815675..9e6ff7f 100644 --- a/internal/config/schema.go +++ b/internal/config/schema.go @@ -105,6 +105,7 @@ func tierFor(path string) string { // never match a login (a non-bcrypt string fails every comparison). var hidden = map[string]bool{ "server.dashboard_password_hash": true, + "skills.frontmatter_migrated": true, } var enums = map[string][]string{ diff --git a/internal/config/skills.go b/internal/config/skills.go new file mode 100644 index 0000000..be0c42d --- /dev/null +++ b/internal/config/skills.go @@ -0,0 +1,105 @@ +package config + +import ( + "errors" + "fmt" + "os" + "sort" + "strings" + "sync" + + "gopkg.in/yaml.v3" +) + +var skillStateMu sync.Mutex + +// MigrateSkillState imports legacy frontmatter opt-outs exactly once. Imported +// names are merged with preferences already present in the active profile. +func MigrateSkillState(disabled []string) (*Config, error) { + skillStateMu.Lock() + defer skillStateMu.Unlock() + + cfg, err := readPersistedConfig() + if err != nil { + return nil, err + } + if cfg.Skills.FrontmatterMigrated { + return Reload() + } + + cfg.Skills.Disabled = sortedUniqueNames(append(cfg.Skills.Disabled, disabled...)) + cfg.Skills.FrontmatterMigrated = true + return persistSkillState(cfg) +} + +// SetSkillEnabled updates one exact skill name in the active profile. Skill +// state must be migrated before requests can alter it, so a later migration +// cannot unexpectedly reapply stale frontmatter state. +func SetSkillEnabled(name string, enabled bool) (*Config, error) { + if strings.TrimSpace(name) == "" { + return nil, errors.New("skill name is required") + } + + skillStateMu.Lock() + defer skillStateMu.Unlock() + + cfg, err := readPersistedConfig() + if err != nil { + return nil, err + } + if !cfg.Skills.FrontmatterMigrated { + return nil, errors.New("skill preferences have not been migrated") + } + + if enabled { + kept := cfg.Skills.Disabled[:0] + for _, disabled := range cfg.Skills.Disabled { + if disabled != name { + kept = append(kept, disabled) + } + } + cfg.Skills.Disabled = kept + } else { + cfg.Skills.Disabled = append(cfg.Skills.Disabled, name) + } + cfg.Skills.Disabled = sortedUniqueNames(cfg.Skills.Disabled) + return persistSkillState(cfg) +} + +// readPersistedConfig deliberately bypasses Load and Reload: their result has +// environment overrides and normalized paths that must never be written back. +func readPersistedConfig() (*Config, error) { + path := ConfigFile() + raw, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read %s: %w", path, err) + } + cfg := Default() + if err := yaml.Unmarshal(raw, cfg); err != nil { + return nil, fmt.Errorf("parse %s: %w", path, err) + } + return cfg, nil +} + +func persistSkillState(cfg *Config) (*Config, error) { + if err := writeFile(ConfigFile(), cfg); err != nil { + return nil, err + } + return Reload() +} + +func sortedUniqueNames(names []string) []string { + if len(names) == 0 { + return nil + } + sort.Strings(names) + write := 1 + for _, name := range names[1:] { + if name == names[write-1] { + continue + } + names[write] = name + write++ + } + return names[:write] +} diff --git a/internal/config/skills_test.go b/internal/config/skills_test.go new file mode 100644 index 0000000..c2a9c9d --- /dev/null +++ b/internal/config/skills_test.go @@ -0,0 +1,314 @@ +package config + +import ( + "errors" + "os" + "path/filepath" + "reflect" + "strings" + "sync" + "testing" + + "gopkg.in/yaml.v3" +) + +func TestMigrateSkillStateUnionsOnceWithoutPersistingRuntimeValues(t *testing.T) { + home := isolateConfigHome(t) + path := filepath.Join(home, "config.yaml") + writeSkillConfigFixture(t, path, `server: + host: 127.0.0.1 +agent: + workspace: $SKILL_WORKSPACE +skills: + enabled: false + disabled: [zeta, existing, zeta] + frontmatter_migrated: false +`) + t.Setenv("ANTARES_HOST", "0.0.0.0") + t.Setenv("SKILL_WORKSPACE", filepath.Join(home, "runtime-workspace")) + + cfg, err := MigrateSkillState([]string{"legacy", "existing", " Alpha ", "legacy"}) + if err != nil { + t.Fatalf("MigrateSkillState: %v", err) + } + wantDisabled := []string{" Alpha ", "existing", "legacy", "zeta"} + if !reflect.DeepEqual(cfg.Skills.Disabled, wantDisabled) { + t.Fatalf("runtime disabled = %#v, want %#v", cfg.Skills.Disabled, wantDisabled) + } + if !cfg.Skills.FrontmatterMigrated { + t.Fatal("runtime migration marker is false") + } + if cfg.Skills.Enabled { + t.Fatal("migration changed the independent global skills.enabled gate") + } + if cfg.Server.Host != "0.0.0.0" { + t.Fatalf("runtime server.host = %q, want environment override", cfg.Server.Host) + } + if cfg.Agent.Workspace != filepath.Join(home, "runtime-workspace") { + t.Fatalf("runtime agent.workspace = %q, want expanded environment path", cfg.Agent.Workspace) + } + + disk := readSkillConfigFixture(t, path) + if disk.Server.Host != "127.0.0.1" { + t.Fatalf("persisted server.host = %q, want operator value", disk.Server.Host) + } + if disk.Agent.Workspace != "$SKILL_WORKSPACE" { + t.Fatalf("persisted agent.workspace = %q, want unnormalized input", disk.Agent.Workspace) + } + if disk.Skills.Enabled { + t.Fatal("persisted migration changed skills.enabled") + } + if !disk.Skills.FrontmatterMigrated { + t.Fatal("persisted migration marker is false") + } + if !reflect.DeepEqual(disk.Skills.Disabled, wantDisabled) { + t.Fatalf("persisted disabled = %#v, want %#v", disk.Skills.Disabled, wantDisabled) + } + + before, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read migrated config: %v", err) + } + if err := os.Chmod(home, 0o500); err != nil { + t.Fatalf("make config directory read-only: %v", err) + } + t.Cleanup(func() { _ = os.Chmod(home, 0o700) }) + + cfg, err = MigrateSkillState([]string{"later"}) + if err != nil { + t.Fatalf("second MigrateSkillState should only reload: %v", err) + } + if containsExact(cfg.Skills.Disabled, "later") { + t.Fatal("one-time migration imported a name after the marker was set") + } + after, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read config after second migration: %v", err) + } + if !reflect.DeepEqual(after, before) { + t.Fatal("already-completed migration rewrote config") + } +} + +func TestSetSkillEnabledPreservesExactAndStaleNames(t *testing.T) { + home := isolateConfigHome(t) + path := filepath.Join(home, "config.yaml") + writeSkillConfigFixture(t, path, `server: + host: 127.0.0.1 +skills: + enabled: false + disabled: [stale, Skill, " Skill ", Skill] + frontmatter_migrated: true +`) + t.Setenv("ANTARES_HOST", "0.0.0.0") + + cfg, err := SetSkillEnabled("Skill", true) + if err != nil { + t.Fatalf("enable Skill: %v", err) + } + want := []string{" Skill ", "stale"} + if !reflect.DeepEqual(cfg.Skills.Disabled, want) { + t.Fatalf("disabled after exact enable = %#v, want %#v", cfg.Skills.Disabled, want) + } + + cfg, err = SetSkillEnabled(" exact name ", false) + if err != nil { + t.Fatalf("disable exact spaced name: %v", err) + } + want = []string{" exact name ", " Skill ", "stale"} + if !reflect.DeepEqual(cfg.Skills.Disabled, want) { + t.Fatalf("disabled after exact disable = %#v, want %#v", cfg.Skills.Disabled, want) + } + if cfg.Skills.Enabled { + t.Fatal("per-skill update changed the independent global gate") + } + + cfg, err = SetSkillEnabled("stale", false) + if err != nil { + t.Fatalf("disable stale name again: %v", err) + } + if !reflect.DeepEqual(cfg.Skills.Disabled, want) { + t.Fatalf("duplicate disable changed set = %#v, want %#v", cfg.Skills.Disabled, want) + } + + disk := readSkillConfigFixture(t, path) + if !reflect.DeepEqual(disk.Skills.Disabled, want) { + t.Fatalf("persisted disabled = %#v, want %#v", disk.Skills.Disabled, want) + } + if disk.Skills.Enabled { + t.Fatal("persisted per-skill update changed skills.enabled") + } + if disk.Server.Host != "127.0.0.1" { + t.Fatalf("persisted server.host = %q, want operator value", disk.Server.Host) + } +} + +func TestSetSkillEnabledSerializesConcurrentUpdates(t *testing.T) { + home := isolateConfigHome(t) + path := filepath.Join(home, "config.yaml") + writeSkillConfigFixture(t, path, `skills: + frontmatter_migrated: true +`) + + start := make(chan struct{}) + errCh := make(chan error, 2) + var wg sync.WaitGroup + for _, name := range []string{"bravo", "alpha"} { + name := name + wg.Add(1) + go func() { + defer wg.Done() + <-start + _, err := SetSkillEnabled(name, false) + errCh <- err + }() + } + close(start) + wg.Wait() + close(errCh) + for err := range errCh { + if err != nil { + t.Fatalf("concurrent SetSkillEnabled: %v", err) + } + } + + disk := readSkillConfigFixture(t, path) + want := []string{"alpha", "bravo"} + if !reflect.DeepEqual(disk.Skills.Disabled, want) { + t.Fatalf("concurrent disabled updates = %#v, want %#v", disk.Skills.Disabled, want) + } +} + +func TestSkillStateMutationErrors(t *testing.T) { + t.Run("missing config", func(t *testing.T) { + isolateConfigHome(t) + if _, err := MigrateSkillState(nil); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("MigrateSkillState error = %v, want os.ErrNotExist", err) + } + if _, err := SetSkillEnabled("known", false); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("SetSkillEnabled error = %v, want os.ErrNotExist", err) + } + }) + + t.Run("malformed config", func(t *testing.T) { + home := isolateConfigHome(t) + path := filepath.Join(home, "config.yaml") + writeSkillConfigFixture(t, path, "skills: [not: valid\n") + if _, err := MigrateSkillState(nil); err == nil || !strings.Contains(err.Error(), "parse "+path) { + t.Fatalf("MigrateSkillState error = %v, want parse error for path", err) + } + if _, err := SetSkillEnabled("known", false); err == nil || !strings.Contains(err.Error(), "parse "+path) { + t.Fatalf("SetSkillEnabled error = %v, want parse error for path", err) + } + }) + + t.Run("unmigrated", func(t *testing.T) { + home := isolateConfigHome(t) + writeSkillConfigFixture(t, filepath.Join(home, "config.yaml"), `skills: + frontmatter_migrated: false +`) + if _, err := SetSkillEnabled("known", false); err == nil || err.Error() != "skill preferences have not been migrated" { + t.Fatalf("SetSkillEnabled error = %v, want unmigrated error", err) + } + }) + + t.Run("blank name", func(t *testing.T) { + isolateConfigHome(t) + if _, err := SetSkillEnabled(" \t\n ", false); err == nil || err.Error() != "skill name is required" { + t.Fatalf("SetSkillEnabled error = %v, want required-name error", err) + } + }) +} + +func TestMigrateSkillStateAtomicSaveFailureLeavesMarkerUnset(t *testing.T) { + home := isolateConfigHome(t) + path := filepath.Join(home, "config.yaml") + writeSkillConfigFixture(t, path, `skills: + disabled: [existing] + frontmatter_migrated: false +`) + if err := os.Chmod(home, 0o500); err != nil { + t.Fatalf("make config directory read-only: %v", err) + } + t.Cleanup(func() { _ = os.Chmod(home, 0o700) }) + + if _, err := MigrateSkillState([]string{"legacy"}); err == nil { + t.Skip("filesystem bypasses directory write permissions") + } + + disk := readSkillConfigFixture(t, path) + if disk.Skills.FrontmatterMigrated { + t.Fatal("failed atomic save persisted the migration marker") + } + if !reflect.DeepEqual(disk.Skills.Disabled, []string{"existing"}) { + t.Fatalf("failed atomic save changed disabled names to %#v", disk.Skills.Disabled) + } +} + +func TestSkillsCloneAndSchema(t *testing.T) { + cfg := Default() + cfg.Skills.Disabled = []string{"one", "two"} + cfg.Skills.FrontmatterMigrated = true + clone := cfg.Clone() + clone.Skills.Disabled[0] = "changed" + clone.Skills.FrontmatterMigrated = false + if !reflect.DeepEqual(cfg.Skills.Disabled, []string{"one", "two"}) { + t.Fatalf("Clone shares skills.disabled backing storage: %#v", cfg.Skills.Disabled) + } + if !cfg.Skills.FrontmatterMigrated { + t.Fatal("changing clone migration marker changed original") + } + + paths := make(map[string]bool) + for _, field := range Schema() { + paths[field.Path] = true + } + if !paths["skills.disabled"] { + t.Fatal("skills.disabled is absent from editable schema") + } + if paths["skills.frontmatter_migrated"] { + t.Fatal("migration bookkeeping marker is exposed in editable schema") + } +} + +type skillConfigFixture struct { + Server struct { + Host string `yaml:"host"` + } `yaml:"server"` + Agent struct { + Workspace string `yaml:"workspace"` + } `yaml:"agent"` + Skills Skills `yaml:"skills"` +} + +func writeSkillConfigFixture(t *testing.T, path, body string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatalf("create config directory: %v", err) + } + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatalf("write config fixture: %v", err) + } +} + +func readSkillConfigFixture(t *testing.T, path string) skillConfigFixture { + t.Helper() + raw, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read config fixture: %v", err) + } + var cfg skillConfigFixture + if err := yaml.Unmarshal(raw, &cfg); err != nil { + t.Fatalf("parse config fixture: %v", err) + } + return cfg +} + +func containsExact(names []string, want string) bool { + for _, name := range names { + if name == want { + return true + } + } + return false +} diff --git a/internal/server/handlers_commands.go b/internal/server/handlers_commands.go index dd795a2..e2d5af2 100644 --- a/internal/server/handlers_commands.go +++ b/internal/server/handlers_commands.go @@ -14,7 +14,7 @@ func (s *Server) commandDeps() commands.Deps { Config: s.config, Agent: s.agent, Store: s.db, - Skills: s.skills, + Skills: s.currentSkills(), MCP: s.mcp, Reload: s.applyReload, Version: version.Version, diff --git a/internal/server/handlers_config.go b/internal/server/handlers_config.go index aa4e10d..6403c23 100644 --- a/internal/server/handlers_config.go +++ b/internal/server/handlers_config.go @@ -121,9 +121,17 @@ func (s *Server) handleSaveRawConfig(w http.ResponseWriter, r *http.Request) { // applyReload rebuilds services that depend on configuration. func (s *Server) applyReload() error { if s.reloadFn == nil { - cfg := config.Get() + cfg, err := config.Reload() + if err != nil { + return err + } s.SetConfig(cfg) - s.agent.SetConfig(cfg) + if s.agent != nil { + s.agent.SetConfig(cfg) + } + if manager := s.currentSkills(); manager != nil { + manager.SetDisabled(cfg.Skills.Disabled) + } if s.gateway != nil { s.gateway.SetConfig(cfg) } @@ -132,10 +140,10 @@ func (s *Server) applyReload() error { if err := s.reloadFn(); err != nil { return err } - s.SetConfig(config.Get()) - // The agent owns the rebuilt skill library after a reload. - if m := s.agent.Skills(); m != nil { - s.skills = m + cfg := config.Get() + s.SetConfig(cfg) + if manager := s.currentSkills(); manager != nil { + manager.SetDisabled(cfg.Skills.Disabled) } return nil } diff --git a/internal/server/handlers_hub.go b/internal/server/handlers_hub.go index 77abc39..c32d40f 100644 --- a/internal/server/handlers_hub.go +++ b/internal/server/handlers_hub.go @@ -31,8 +31,8 @@ func (s *Server) handleHubSkills(w http.ResponseWriter, r *http.Request) { // Mark what is already on disk so the UI can offer the right action. installed := map[string]bool{} - if s.skills != nil { - for _, sk := range s.skills.List() { + if manager := s.currentSkills(); manager != nil { + for _, sk := range manager.List() { installed[sk.Name] = true } } @@ -57,8 +57,8 @@ func (s *Server) handleHubInstallSkill(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"ok": false, "error": err.Error()}) return } - if s.skills != nil { - _ = s.skills.Reload() + if manager := s.currentSkills(); manager != nil { + _ = manager.Reload() } writeJSON(w, http.StatusOK, map[string]any{ "ok": true, "name": entry.Name, "path": path, "summary": entry.Summary, diff --git a/internal/server/handlers_subsystems.go b/internal/server/handlers_subsystems.go index 65aa03c..1e3eedf 100644 --- a/internal/server/handlers_subsystems.go +++ b/internal/server/handlers_subsystems.go @@ -3,6 +3,7 @@ package server import ( "context" "errors" + "fmt" "net/http" "strings" "time" @@ -22,7 +23,8 @@ var ( // ---- skills ----------------------------------------------------------------- func (s *Server) handleListSkills(w http.ResponseWriter, r *http.Request) { - if s.skills == nil { + manager := s.currentSkills() + if manager == nil { writeJSON(w, http.StatusOK, map[string]any{"skills": []any{}}) return } @@ -36,23 +38,19 @@ func (s *Server) handleListSkills(w http.ResponseWriter, r *http.Request) { category := strings.TrimSpace(r.URL.Query().Get("category")) if q != "" || cwe != "" || tech != "" || category != "" { writeJSON(w, http.StatusOK, map[string]any{ - "skills": s.skills.SearchFiltered(q, skills.Filter{CWE: cwe, Tech: tech, Category: category}, 100), + "skills": manager.SearchFiltered(q, skills.Filter{CWE: cwe, Tech: tech, Category: category}, 100), "searching": true, - "library": s.skills.PackCount(), + "library": manager.PackCount(), }) return } writeJSON(w, http.StatusOK, map[string]any{ - "skills": s.skills.Everyday(), - "library": s.skills.PackCount(), + "skills": manager.Everyday(), + "library": manager.PackCount(), }) } func (s *Server) handleToggleSkill(w http.ResponseWriter, r *http.Request) { - if s.skills == nil { - writeError(w, http.StatusServiceUnavailable, errSkillsOff) - return - } var body struct { Name string `json:"name"` Enabled bool `json:"enabled"` @@ -61,19 +59,37 @@ func (s *Server) handleToggleSkill(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, err) return } - if err := s.skills.SetEnabled(body.Name, body.Enabled); err != nil { - writeError(w, http.StatusBadRequest, err) + + s.skillsConfigMu.Lock() + defer s.skillsConfigMu.Unlock() + + manager := s.currentSkills() + if manager == nil { + writeError(w, http.StatusServiceUnavailable, errSkillsOff) + return + } + if _, ok := manager.Get(body.Name); !ok { + writeError(w, http.StatusBadRequest, fmt.Errorf("skill %q not found", body.Name)) + return + } + if _, err := config.SetSkillEnabled(body.Name, body.Enabled); err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } + if err := s.applyReload(); err != nil { + writeError(w, http.StatusInternalServerError, err) return } writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) } func (s *Server) handleGetSkill(w http.ResponseWriter, r *http.Request) { - if s.skills == nil { + manager := s.currentSkills() + if manager == nil { writeError(w, http.StatusServiceUnavailable, errSkillsOff) return } - sk, ok := s.skills.Get(r.PathValue("name")) + sk, ok := manager.Get(r.PathValue("name")) if !ok { writeError(w, http.StatusNotFound, errNotFound) return @@ -82,7 +98,8 @@ func (s *Server) handleGetSkill(w http.ResponseWriter, r *http.Request) { } func (s *Server) handleSaveSkill(w http.ResponseWriter, r *http.Request) { - if s.skills == nil { + manager := s.currentSkills() + if manager == nil { writeError(w, http.StatusServiceUnavailable, errSkillsOff) return } @@ -96,7 +113,7 @@ func (s *Server) handleSaveSkill(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, err) return } - sk, err := s.skills.Save(body.Name, body.Description, body.Body, body.Tags) + sk, err := manager.Save(body.Name, body.Description, body.Body, body.Tags) if err != nil { writeError(w, http.StatusBadRequest, err) return @@ -105,11 +122,12 @@ func (s *Server) handleSaveSkill(w http.ResponseWriter, r *http.Request) { } func (s *Server) handleDeleteSkill(w http.ResponseWriter, r *http.Request) { - if s.skills == nil { + manager := s.currentSkills() + if manager == nil { writeError(w, http.StatusServiceUnavailable, errSkillsOff) return } - if err := s.skills.Delete(r.PathValue("name")); err != nil { + if err := manager.Delete(r.PathValue("name")); err != nil { writeError(w, http.StatusBadRequest, err) return } @@ -501,19 +519,20 @@ func (s *Server) refreshMCP(w http.ResponseWriter, r *http.Request, refresher mc // handleSkillLibrary browses the bundled security skill library — paged, by // category — so thousands of skills are explorable without searching blind. func (s *Server) handleSkillLibrary(w http.ResponseWriter, r *http.Request) { - if s.skills == nil { + manager := s.currentSkills() + if manager == nil { writeJSON(w, http.StatusOK, map[string]any{"skills": []any{}, "categories": map[string]int{}, "total": 0}) return } category := r.URL.Query().Get("category") offset := queryInt(r, "offset", 0) limit := queryInt(r, "limit", 50) - page, total := s.skills.Library(category, offset, limit) + page, total := manager.Library(category, offset, limit) writeJSON(w, http.StatusOK, map[string]any{ "skills": page, "total": total, "offset": offset, "limit": limit, - "categories": s.skills.Categories(), + "categories": manager.Categories(), }) } diff --git a/internal/server/server.go b/internal/server/server.go index 226965f..c925b56 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -55,6 +55,9 @@ type Server struct { mu sync.RWMutex reloadFn func() error + // skillsConfigMu serializes skill preference writes through live publication, + // so concurrent toggles cannot overwrite one another or publish out of order. + skillsConfigMu sync.Mutex // dashSessions holds active dashboard login session tokens (cookie value → // expiry). Guarded by its own mutex; cleared when the password changes. @@ -107,6 +110,12 @@ func New(o Options) *Server { dashSessions: map[string]time.Time{}, } + // An embedded server may have no agent-owned manager. Seed its fallback + // manager from the supplied config so administrative reads are correct from + // the first request rather than only after a reload. + if s.skills != nil && (s.agent == nil || s.agent.Skills() == nil) && s.cfg != nil { + s.skills.SetDisabled(s.cfg.Skills.Disabled) + } // Restore dashboard logins so a daemon restart does not break EventSource // reattach (/api/chat/attach) for browsers that still hold a valid cookie. s.loadDashSessions() diff --git a/internal/server/skill_toggle_test.go b/internal/server/skill_toggle_test.go new file mode 100644 index 0000000..9cc12bc --- /dev/null +++ b/internal/server/skill_toggle_test.go @@ -0,0 +1,450 @@ +package server + +import ( + "bytes" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "reflect" + "strconv" + "strings" + "sync" + "testing" + "time" + + "github.com/enowdev/antares/internal/agent" + "github.com/enowdev/antares/internal/config" + "github.com/enowdev/antares/internal/skills" +) + +func TestSkillToggleDoesNotMutateWritableSource(t *testing.T) { + s, manager, _, sources := newSkillToggleServer(t, []string{"writable"}, nil) + path := sources["writable"] + before := snapshotSkillSource(t, path) + + if rr := postSkillToggle(s, "writable", false); rr.Code != http.StatusOK { + t.Fatalf("disable status = %d, want 200 (body=%s)", rr.Code, rr.Body.String()) + } + if sk, ok := manager.Get("writable"); !ok || sk.Enabled { + t.Fatalf("live skill after disable = %#v, found=%v", sk, ok) + } + assertSkillSourceUnchanged(t, path, before) +} + +func TestSkillTogglePersistsWithoutMutatingReadOnlySourceAndRestarts(t *testing.T) { + s, manager, cfgPath, sources := newSkillToggleServer(t, []string{"toggle-me"}, nil) + path := sources["toggle-me"] + if err := os.Chmod(path, 0o444); err != nil { + t.Fatal(err) + } + if err := os.Chmod(filepath.Dir(path), 0o555); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + _ = os.Chmod(filepath.Dir(path), 0o755) + _ = os.Chmod(path, 0o644) + }) + before := snapshotSkillSource(t, path) + + if rr := postSkillToggle(s, "toggle-me", false); rr.Code != http.StatusOK { + t.Fatalf("disable status = %d, want 200 (body=%s)", rr.Code, rr.Body.String()) + } + if sk, ok := manager.Get("toggle-me"); !ok || sk.Enabled { + t.Fatalf("live skill after disable = %#v, found=%v", sk, ok) + } + persisted, err := config.Reload() + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(persisted.Skills.Disabled, []string{"toggle-me"}) { + t.Fatalf("persisted disabled = %#v, want [toggle-me]", persisted.Skills.Disabled) + } + assertSkillSourceUnchanged(t, path, before) + + restartedManager := skills.NewManager(persisted.Skills.Dirs) + if err := restartedManager.Reload(); err != nil { + t.Fatal(err) + } + restarted := New(Options{Config: persisted, Skills: restartedManager}) + if sk, ok := restarted.currentSkills().Get("toggle-me"); !ok || sk.Enabled { + t.Fatalf("restarted skill = %#v, found=%v; want disabled", sk, ok) + } + if rr := postSkillToggle(restarted, "toggle-me", true); rr.Code != http.StatusOK { + t.Fatalf("re-enable status = %d, want 200 (body=%s)", rr.Code, rr.Body.String()) + } + if sk, ok := restarted.currentSkills().Get("toggle-me"); !ok || !sk.Enabled { + t.Fatalf("live skill after re-enable = %#v, found=%v", sk, ok) + } + persisted, err = config.Reload() + if err != nil { + t.Fatal(err) + } + if len(persisted.Skills.Disabled) != 0 { + t.Fatalf("persisted disabled after re-enable = %#v, want empty", persisted.Skills.Disabled) + } + if cfgPath != config.ConfigFile() { + t.Fatalf("fixture config path changed from %q to %q", cfgPath, config.ConfigFile()) + } + assertSkillSourceUnchanged(t, path, before) +} + +func TestSkillToggleConcurrentDifferentNamesPersistsUnion(t *testing.T) { + s, manager, _, _ := newSkillToggleServer(t, []string{"alpha", "bravo"}, nil) + + var wg sync.WaitGroup + results := make(chan *httptest.ResponseRecorder, 2) + for _, name := range []string{"alpha", "bravo"} { + name := name + wg.Add(1) + go func() { + defer wg.Done() + results <- postSkillToggle(s, name, false) + }() + } + wg.Wait() + close(results) + for rr := range results { + if rr.Code != http.StatusOK { + t.Fatalf("concurrent toggle status = %d, want 200 (body=%s)", rr.Code, rr.Body.String()) + } + } + persisted, err := config.Reload() + if err != nil { + t.Fatal(err) + } + want := []string{"alpha", "bravo"} + if !reflect.DeepEqual(persisted.Skills.Disabled, want) { + t.Fatalf("persisted disabled = %#v, want %#v", persisted.Skills.Disabled, want) + } + for _, name := range want { + if sk, ok := manager.Get(name); !ok || sk.Enabled { + t.Fatalf("live %s = %#v, found=%v; want disabled", name, sk, ok) + } + } +} + +func TestSkillToggleRejectsUnknownAndUnavailableManager(t *testing.T) { + s, _, _, _ := newSkillToggleServer(t, []string{"known"}, []string{"existing"}) + if rr := postSkillToggle(s, "missing", false); rr.Code != http.StatusBadRequest { + t.Fatalf("unknown skill status = %d, want 400 (body=%s)", rr.Code, rr.Body.String()) + } + persisted, err := config.Reload() + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(persisted.Skills.Disabled, []string{"existing"}) { + t.Fatalf("unknown skill created preference: %#v", persisted.Skills.Disabled) + } + + nilServer := &Server{} + if rr := postSkillToggle(nilServer, "known", false); rr.Code != http.StatusServiceUnavailable { + t.Fatalf("nil manager status = %d, want 503 (body=%s)", rr.Code, rr.Body.String()) + } +} + +func TestSkillToggleSaveFailureDoesNotPublish(t *testing.T) { + _, manager, cfgPath, _ := newSkillToggleServer(t, []string{"known"}, nil) + before, err := os.ReadFile(cfgPath) + if err != nil { + t.Fatal(err) + } + file, err := os.Open(cfgPath) + if err != nil { + t.Fatal(err) + } + defer file.Close() + procPath := "/proc/self/fd/" + strconv.Itoa(int(file.Fd())) + if _, err := os.Stat(procPath); err != nil { + t.Skipf("proc fd paths unavailable: %v", err) + } + t.Setenv("ANTARES_CONFIG", procPath) + cfg := config.Get() + s := New(Options{Config: cfg, Skills: manager}) + + rr := postSkillToggle(s, "known", false) + if rr.Code != http.StatusInternalServerError { + t.Fatalf("save failure status = %d, want 500 (body=%s)", rr.Code, rr.Body.String()) + } + if sk, ok := manager.Get("known"); !ok || !sk.Enabled { + t.Fatalf("failed save published disabled state: %#v, found=%v", sk, ok) + } + after, err := os.ReadFile(cfgPath) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(after, before) { + t.Fatal("failed config save changed the persisted file") + } +} + +func TestSkillToggleReloadFailurePersistsWithoutPublishing(t *testing.T) { + _, manager, _, _ := newSkillToggleServer(t, []string{"known"}, nil) + cfg := config.Get() + s := New(Options{ + Config: cfg, + Skills: manager, + Reload: func() error { return errors.New("reload failed") }, + }) + + rr := postSkillToggle(s, "known", false) + if rr.Code != http.StatusInternalServerError { + t.Fatalf("reload failure status = %d, want 500 (body=%s)", rr.Code, rr.Body.String()) + } + persisted, err := config.Reload() + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(persisted.Skills.Disabled, []string{"known"}) { + t.Fatalf("saved preference after reload failure = %#v, want [known]", persisted.Skills.Disabled) + } + if len(s.config().Skills.Disabled) != 0 { + t.Fatalf("reload failure published server config: %#v", s.config().Skills.Disabled) + } + if sk, ok := manager.Get("known"); !ok || !sk.Enabled { + t.Fatalf("reload failure published state despite callback error: %#v, found=%v", sk, ok) + } +} + +func TestApplyReloadWithoutCallbackReturnsErrorsAndToleratesNilAgent(t *testing.T) { + s, manager, cfgPath, _ := newSkillToggleServer(t, []string{"known"}, nil) + if err := os.WriteFile(cfgPath, []byte("skills: [unterminated\n"), 0o600); err != nil { + t.Fatal(err) + } + before := s.config() + if err := s.applyReload(); err == nil { + t.Fatal("applyReload succeeded with malformed persisted configuration") + } + if s.config() != before { + t.Fatal("failed applyReload replaced the server config") + } + if sk, ok := manager.Get("known"); !ok || !sk.Enabled { + t.Fatalf("failed applyReload changed manager state: %#v, found=%v", sk, ok) + } +} + +func TestNewInitializesFallbackSkillPreferences(t *testing.T) { + s, manager, _, _ := newSkillToggleServer(t, []string{"known"}, []string{"known"}) + if s.agent != nil { + t.Fatal("fixture unexpectedly has an agent") + } + if sk, ok := manager.Get("known"); !ok || sk.Enabled { + t.Fatalf("fallback skill = %#v, found=%v; want disabled at construction", sk, ok) + } +} + +func TestSkillHandlersObserveManagerReplacedDuringReload(t *testing.T) { + home := t.TempDir() + t.Setenv("ANTARES_HOME", home) + t.Setenv("ANTARES_PROFILE", "default") + t.Setenv("ANTARES_CONFIG", filepath.Join(home, "config.yaml")) + oldDir := filepath.Join(home, "old-skills") + newDir := filepath.Join(home, "new-skills") + packDir := filepath.Join(home, "pack-skills") + writeServerSkill(t, oldDir, "replace-me") + writeServerSkill(t, oldDir, "old-only") + writeServerSkill(t, newDir, "replace-me") + writeServerSkill(t, newDir, "replacement-only") + writeServerSkill(t, packDir, "code-review") + + cfg := config.Default() + cfg.Skills.Enabled = true + cfg.Skills.FrontmatterMigrated = true + cfg.Skills.Dirs = []string{oldDir} + if err := config.SaveAt(config.ConfigFile(), cfg); err != nil { + t.Fatal(err) + } + oldManager := skills.NewManager([]string{oldDir}) + if err := oldManager.Reload(); err != nil { + t.Fatal(err) + } + a := &agent.Agent{} + a.SetConfig(cfg) + a.SetSkills(oldManager) + var replacement *skills.Manager + s := New(Options{ + Config: cfg, + Agent: a, + Skills: oldManager, + Reload: func() error { + reloaded, err := config.Reload() + if err != nil { + return err + } + replacement = skills.NewManager([]string{newDir, packDir}) + replacement.SetPackDirs([]string{packDir}) + if err := replacement.Reload(); err != nil { + return err + } + a.SetConfig(reloaded) + a.SetSkills(replacement) + return nil + }, + }) + + if rr := postSkillToggle(s, "replace-me", false); rr.Code != http.StatusOK { + t.Fatalf("toggle status = %d, want 200 (body=%s)", rr.Code, rr.Body.String()) + } + if replacement == nil || s.currentSkills() != replacement { + t.Fatal("reload replacement is not the authoritative manager") + } + if s.skills != oldManager { + t.Fatal("applyReload copied the agent manager into the fallback field") + } + if s.commandDeps().Skills != replacement { + t.Fatal("command dependencies retained the stale skill manager") + } + if sk, ok := replacement.Get("replace-me"); !ok || sk.Enabled { + t.Fatalf("replacement manager preference = %#v, found=%v; want disabled", sk, ok) + } + + getReq := httptest.NewRequest(http.MethodGet, "/api/skills/replacement-only", nil) + getReq.SetPathValue("name", "replacement-only") + getRR := httptest.NewRecorder() + s.handleGetSkill(getRR, getReq) + if getRR.Code != http.StatusOK { + t.Fatalf("replacement get status = %d, want 200 (body=%s)", getRR.Code, getRR.Body.String()) + } + + listRR := httptest.NewRecorder() + s.handleListSkills(listRR, httptest.NewRequest(http.MethodGet, "/api/skills", nil)) + if !strings.Contains(listRR.Body.String(), "replacement-only") || strings.Contains(listRR.Body.String(), "old-only") { + t.Fatalf("list did not use replacement manager: %s", listRR.Body.String()) + } + + hubRR := httptest.NewRecorder() + s.handleHubSkills(hubRR, httptest.NewRequest(http.MethodGet, "/api/hub/skills", nil)) + var hubBody struct { + Skills []struct { + Name string `json:"name"` + Installed bool `json:"installed"` + } `json:"skills"` + } + if err := json.Unmarshal(hubRR.Body.Bytes(), &hubBody); err != nil { + t.Fatalf("decode hub response: %v (body=%s)", err, hubRR.Body.String()) + } + hubInstalled := false + for _, entry := range hubBody.Skills { + if entry.Name == "code-review" { + hubInstalled = entry.Installed + break + } + } + if !hubInstalled { + t.Fatalf("hub did not mark replacement-manager skill installed: %s", hubRR.Body.String()) + } + + saveRR := httptest.NewRecorder() + s.handleSaveSkill(saveRR, httptest.NewRequest(http.MethodPost, "/api/skills", strings.NewReader(`{"name":"saved-on-replacement","description":"new","body":"body"}`))) + if saveRR.Code != http.StatusOK { + t.Fatalf("replacement save status = %d, want 200 (body=%s)", saveRR.Code, saveRR.Body.String()) + } + if _, err := os.Stat(filepath.Join(newDir, "saved-on-replacement.md")); err != nil { + t.Fatalf("replacement manager did not receive save: %v", err) + } + if _, err := os.Stat(filepath.Join(oldDir, "saved-on-replacement.md")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("stale manager received save: %v", err) + } + + libraryRR := httptest.NewRecorder() + s.handleSkillLibrary(libraryRR, httptest.NewRequest(http.MethodGet, "/api/skills/library", nil)) + if !strings.Contains(libraryRR.Body.String(), "code-review") || strings.Contains(libraryRR.Body.String(), "old-only") { + t.Fatalf("library did not use replacement manager: %s", libraryRR.Body.String()) + } + + deleteReq := httptest.NewRequest(http.MethodDelete, "/api/skills/replacement-only", nil) + deleteReq.SetPathValue("name", "replacement-only") + deleteRR := httptest.NewRecorder() + s.handleDeleteSkill(deleteRR, deleteReq) + if deleteRR.Code != http.StatusOK { + t.Fatalf("replacement delete status = %d, want 200 (body=%s)", deleteRR.Code, deleteRR.Body.String()) + } + if _, err := os.Stat(filepath.Join(newDir, "replacement-only.md")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("replacement manager did not delete its source: %v", err) + } +} + +func newSkillToggleServer(t *testing.T, names, disabled []string) (*Server, *skills.Manager, string, map[string]string) { + t.Helper() + home := t.TempDir() + t.Setenv("ANTARES_HOME", home) + t.Setenv("ANTARES_PROFILE", "default") + cfgPath := filepath.Join(home, "config.yaml") + t.Setenv("ANTARES_CONFIG", cfgPath) + dir := filepath.Join(home, "skills") + sources := make(map[string]string, len(names)) + for _, name := range names { + sources[name] = writeServerSkill(t, dir, name) + } + cfg := config.Default() + cfg.Skills.Enabled = true + cfg.Skills.Dirs = []string{dir} + cfg.Skills.Disabled = append([]string(nil), disabled...) + cfg.Skills.FrontmatterMigrated = true + if err := config.SaveAt(cfgPath, cfg); err != nil { + t.Fatalf("seed config: %v", err) + } + manager := skills.NewManager([]string{dir}) + if err := manager.Reload(); err != nil { + t.Fatalf("load skills: %v", err) + } + return New(Options{Config: cfg, Skills: manager}), manager, cfgPath, sources +} + +func writeServerSkill(t *testing.T, dir, name string) string { + t.Helper() + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + path := filepath.Join(dir, name+".md") + body := "---\nname: " + name + "\ndescription: " + name + " description\n---\n\n" + name + " body\n" + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + fixed := time.Unix(1_700_000_000, 0) + if err := os.Chtimes(path, fixed, fixed); err != nil { + t.Fatal(err) + } + return path +} + +func postSkillToggle(s *Server, name string, enabled bool) *httptest.ResponseRecorder { + body := `{"name":"` + name + `","enabled":` + strconv.FormatBool(enabled) + `}` + rr := httptest.NewRecorder() + s.handleToggleSkill(rr, httptest.NewRequest(http.MethodPost, "/api/skills/toggle", strings.NewReader(body))) + return rr +} + +type skillSourceSnapshot struct { + body []byte + mode os.FileMode + size int64 + modTime time.Time +} + +func snapshotSkillSource(t *testing.T, path string) skillSourceSnapshot { + t.Helper() + body, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + return skillSourceSnapshot{body: body, mode: info.Mode(), size: info.Size(), modTime: info.ModTime()} +} + +func assertSkillSourceUnchanged(t *testing.T, path string, before skillSourceSnapshot) { + t.Helper() + after := snapshotSkillSource(t, path) + if !bytes.Equal(after.body, before.body) { + t.Fatalf("skill source bytes changed during config toggle:\nbefore: %q\nafter: %q", before.body, after.body) + } + if after.mode != before.mode || after.size != before.size || !after.modTime.Equal(before.modTime) { + t.Fatalf("skill source stat changed: before={mode:%v size:%d mtime:%s} after={mode:%v size:%d mtime:%s}", + before.mode, before.size, before.modTime, after.mode, after.size, after.modTime) + } +} diff --git a/internal/skills/skills.go b/internal/skills/skills.go index f29e755..628221c 100644 --- a/internal/skills/skills.go +++ b/internal/skills/skills.go @@ -38,14 +38,15 @@ type Skill struct { // Pack marks a skill from the bundled security library: searchable and // loadable, but kept out of the prompt catalogue so thousands of them do // not bury the conversation. - Pack bool `json:"pack,omitempty"` + Pack bool `json:"pack,omitempty"` + legacyDisabled bool } // frontMatter is the YAML header of a skill file. type frontMatter struct { Name string `yaml:"name"` Description string `yaml:"description"` - Enabled *bool `yaml:"enabled"` + Enabled *bool `yaml:"enabled,omitempty"` Source string `yaml:"source"` Category string `yaml:"category"` Tags []string `yaml:"tags"` @@ -63,6 +64,7 @@ type Manager struct { packDirs []string skills map[string]*Skill usage map[string]int + disabled map[string]struct{} } // NewManager builds a manager over the given directories. @@ -97,11 +99,14 @@ func (m *Manager) Reload() error { if strings.TrimSpace(dir) == "" { continue } - if err := os.MkdirAll(dir, 0o755); err != nil && firstErr == nil { + if err := os.MkdirAll(dir, 0o755); err != nil && firstErr == nil && !errors.Is(err, os.ErrNotExist) { firstErr = err } err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error { if err != nil { + if firstErr == nil && !errors.Is(err, os.ErrNotExist) { + firstErr = err + } return nil } if d.IsDir() { @@ -115,7 +120,7 @@ func (m *Manager) Reload() error { } s, err := parseFile(path) if err != nil { - if firstErr == nil { + if firstErr == nil && !errors.Is(err, os.ErrNotExist) { firstErr = err } return nil @@ -124,7 +129,7 @@ func (m *Manager) Reload() error { found[s.Name] = s return nil }) - if err != nil && firstErr == nil { + if err != nil && firstErr == nil && !errors.Is(err, os.ErrNotExist) { firstErr = err } } @@ -178,8 +183,8 @@ func parseFile(path string) (*Skill, error) { if fm.Source != "" { s.Source = fm.Source } - if fm.Enabled != nil { - s.Enabled = *fm.Enabled + if fm.Enabled != nil && !*fm.Enabled { + s.legacyDisabled = true } } } @@ -217,7 +222,7 @@ func (m *Manager) List() []Skill { defer m.mu.RUnlock() out := make([]Skill, 0, len(m.skills)) for _, s := range m.skills { - out = append(out, *s) + out = append(out, m.effectiveSkillLocked(s)) } sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) return out @@ -231,54 +236,43 @@ func (m *Manager) Get(name string) (*Skill, bool) { if !ok { return nil, false } - cp := *s + cp := m.effectiveSkillLocked(s) return &cp, true } -// SetEnabled toggles a skill by rewriting its front matter. -func (m *Manager) SetEnabled(name string, enabled bool) error { - s, ok := m.Get(name) - if !ok { - return fmt.Errorf("skill %q not found", name) +// SetDisabled replaces the profile-wide set of disabled logical skill names. +// Names are matched exactly after source precedence has selected a winner. +func (m *Manager) SetDisabled(names []string) { + disabled := make(map[string]struct{}, len(names)) + for _, name := range names { + disabled[name] = struct{}{} } - raw, err := os.ReadFile(s.Path) - if err != nil { - return err - } - text := strings.ReplaceAll(string(raw), "\r\n", "\n") + m.mu.Lock() + m.disabled = disabled + m.mu.Unlock() +} - value := "false" - if enabled { - value = "true" - } - switch { - case strings.HasPrefix(text, "---\n"): - end := strings.Index(text[4:], "\n---") - if end < 0 { - return errors.New("front matter is not terminated") - } - header := text[4 : 4+end] - rest := text[4+end:] - if strings.Contains(header, "enabled:") { - lines := strings.Split(header, "\n") - for i, l := range lines { - if strings.HasPrefix(strings.TrimSpace(l), "enabled:") { - lines[i] = "enabled: " + value - } - } - header = strings.Join(lines, "\n") - } else { - header += "\nenabled: " + value +// LegacyDisabled returns the selected source names carrying the retired +// enabled: false header. The header is migration input only and never affects +// the manager's effective state. +func (m *Manager) LegacyDisabled() []string { + m.mu.RLock() + defer m.mu.RUnlock() + out := make([]string, 0) + for name, s := range m.skills { + if s.legacyDisabled { + out = append(out, name) } - text = "---\n" + header + rest - default: - text = "---\nname: " + s.Name + "\nenabled: " + value + "\n---\n\n" + text } + sort.Strings(out) + return out +} - if err := os.WriteFile(s.Path, []byte(text), 0o644); err != nil { - return err - } - return m.Reload() +func (m *Manager) effectiveSkillLocked(s *Skill) Skill { + cp := *s + _, disabled := m.disabled[s.Name] + cp.Enabled = !disabled + return cp } // Save writes (or overwrites) a skill file in the first configured directory. @@ -556,7 +550,7 @@ func (m *Manager) Library(category string, offset, limit int) ([]Skill, int) { if category != "" && !strings.EqualFold(s.Category, category) { continue } - all = append(all, *s) + all = append(all, m.effectiveSkillLocked(s)) } m.mu.RUnlock() @@ -590,8 +584,8 @@ func (m *Manager) Count() int { m.mu.RLock() defer m.mu.RUnlock() n := 0 - for _, s := range m.skills { - if s.Enabled { + for name := range m.skills { + if _, disabled := m.disabled[name]; !disabled { n++ } } diff --git a/internal/skills/state_test.go b/internal/skills/state_test.go new file mode 100644 index 0000000..fcf43d9 --- /dev/null +++ b/internal/skills/state_test.go @@ -0,0 +1,219 @@ +package skills + +import ( + "bytes" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + "time" +) + +func writeStateSkill(t *testing.T, dir, file, name, header, body string) string { + t.Helper() + path := filepath.Join(dir, file) + content := "---\nname: " + name + "\n" + header + "---\n\n" + body + "\n" + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + return path +} + +func TestDisabledStateIsEffectiveWithoutMutatingSources(t *testing.T) { + dir := t.TempDir() + packDir := t.TempDir() + legacyPath := writeStateSkill(t, dir, "legacy.md", "legacy", "description: legacy description\nenabled: false\n", "legacy body") + writeStateSkill(t, dir, "active.md", "active", "description: active description\n", "active body") + writeStateSkill(t, packDir, "pack.md", "pack", "description: pack description\ncategory: web\n", "pack body") + + fixedTime := time.Unix(1_700_000_000, 0) + if err := os.Chtimes(legacyPath, fixedTime, fixedTime); err != nil { + t.Fatal(err) + } + beforeBytes, err := os.ReadFile(legacyPath) + if err != nil { + t.Fatal(err) + } + beforeInfo, err := os.Stat(legacyPath) + if err != nil { + t.Fatal(err) + } + + m := NewManager([]string{dir, packDir}) + m.SetPackDirs([]string{packDir}) + if err := m.Reload(); err != nil { + t.Fatal(err) + } + if got := m.LegacyDisabled(); !reflect.DeepEqual(got, []string{"legacy"}) { + t.Fatalf("LegacyDisabled() = %v, want [legacy]", got) + } + if legacy, ok := m.Get("legacy"); !ok || !legacy.Enabled { + t.Fatalf("legacy enabled state before config overlay = (%+v, %v), want enabled", legacy, ok) + } + + m.SetDisabled([]string{"legacy", "pack"}) + + listed := m.List() + if got := enabledByName(listed); !reflect.DeepEqual(got, map[string]bool{"active": true, "legacy": false, "pack": false}) { + t.Fatalf("List enabled state = %v", got) + } + legacy, ok := m.Get("legacy") + if !ok || legacy.Enabled { + t.Fatalf("Get(legacy) = (%+v, %v), want disabled", legacy, ok) + } + library, total := m.Library("web", 0, 10) + if total != 1 || len(library) != 1 || library[0].Name != "pack" || library[0].Enabled { + t.Fatalf("Library(web) = (%+v, %d), want disabled pack", library, total) + } + if got := m.Count(); got != 1 { + t.Fatalf("Count() = %d, want only active enabled", got) + } + prompt := m.PromptBlock(0) + if !strings.Contains(prompt, "active: active description") || strings.Contains(prompt, "legacy") || strings.Contains(prompt, "pack") { + t.Fatalf("PromptBlock() did not reflect effective state: %q", prompt) + } + + afterBytes, err := os.ReadFile(legacyPath) + if err != nil { + t.Fatal(err) + } + afterInfo, err := os.Stat(legacyPath) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(afterBytes, beforeBytes) { + t.Fatalf("SetDisabled changed source bytes:\n%s", afterBytes) + } + if afterInfo.Mode() != beforeInfo.Mode() || afterInfo.Size() != beforeInfo.Size() || !afterInfo.ModTime().Equal(beforeInfo.ModTime()) { + t.Fatalf("SetDisabled changed source stat: before=%+v after=%+v", beforeInfo, afterInfo) + } +} + +func TestLegacyDisabledUsesSelectedWinnerAndSortsNames(t *testing.T) { + first := t.TempDir() + second := t.TempDir() + writeStateSkill(t, first, "duplicate.md", "duplicate", "enabled: false\n", "losing legacy body") + writeStateSkill(t, second, "duplicate.md", "duplicate", "enabled: true\n", "winning body") + writeStateSkill(t, second, "z.md", "z-name", "enabled: false\n", "z body") + writeStateSkill(t, second, "a.md", "a-name", "enabled: false\n", "a body") + + m := NewManager([]string{first, second}) + if err := m.Reload(); err != nil { + t.Fatal(err) + } + if got := m.LegacyDisabled(); !reflect.DeepEqual(got, []string{"a-name", "z-name"}) { + t.Fatalf("LegacyDisabled() = %v, want only sorted selected legacy-disabled names", got) + } + winner, ok := m.Get("duplicate") + if !ok || winner.Body != "winning body" || !winner.Enabled { + t.Fatalf("selected duplicate = (%+v, %v), want enabled later-directory winner", winner, ok) + } +} + +func TestDisabledPreferenceSurvivesReloadSaveDeleteAndRecreate(t *testing.T) { + dir := t.TempDir() + path := writeStateSkill(t, dir, "persistent.md", "persistent", "description: old\nenabled: false\n", "old body") + m := NewManager([]string{dir}) + if err := m.Reload(); err != nil { + t.Fatal(err) + } + m.SetDisabled([]string{"persistent"}) + + if err := m.Reload(); err != nil { + t.Fatal(err) + } + assertSkillDisabled(t, m, "persistent") + + saved, err := m.Save("persistent", "saved", "saved body", []string{"state"}) + if err != nil { + t.Fatal(err) + } + if saved.Enabled { + t.Fatalf("Save returned enabled skill despite retained preference: %+v", saved) + } + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(raw), "enabled:") { + t.Fatalf("Save persisted retired enabled header:\n%s", raw) + } + + if err := m.Delete("persistent"); err != nil { + t.Fatal(err) + } + if _, ok := m.Get("persistent"); ok { + t.Fatal("deleted skill remains present") + } + writeStateSkill(t, dir, "persistent.md", "persistent", "description: recreated\n", "recreated body") + if err := m.Reload(); err != nil { + t.Fatal(err) + } + assertSkillDisabled(t, m, "persistent") +} + +func TestSetDisabledClonesInputAndReplacesState(t *testing.T) { + dir := t.TempDir() + writeStateSkill(t, dir, "one.md", "one", "", "one body") + writeStateSkill(t, dir, "two.md", "two", "", "two body") + m := NewManager([]string{dir}) + if err := m.Reload(); err != nil { + t.Fatal(err) + } + + names := []string{"one"} + m.SetDisabled(names) + names[0] = "two" + assertSkillDisabled(t, m, "one") + if two, _ := m.Get("two"); !two.Enabled { + t.Fatal("mutating SetDisabled input changed manager state") + } + + m.SetDisabled(nil) + if m.Count() != 2 { + t.Fatalf("SetDisabled(nil) did not clear state: count=%d", m.Count()) + } +} + +func TestReloadReportsErrorAndPublishesReadableSkills(t *testing.T) { + badRoot := filepath.Join(t.TempDir(), "not-a-directory") + if err := os.WriteFile(badRoot, []byte("blocking file"), 0o644); err != nil { + t.Fatal(err) + } + readable := t.TempDir() + writeStateSkill(t, readable, "good.md", "good", "description: readable\n", "good body") + writeStateSkill(t, readable, "malformed.md", "malformed", "tags: [unterminated\n", "bad body") + + m := NewManager([]string{badRoot, readable}) + err := m.Reload() + if err == nil { + t.Fatal("Reload() error = nil, want first configured-root error") + } + if !strings.Contains(err.Error(), badRoot) { + t.Fatalf("Reload() error = %q, want first error for %q", err, badRoot) + } + good, ok := m.Get("good") + if !ok || good.Body != "good body" { + t.Fatalf("readable skill was not published with partial scan: (%+v, %v)", good, ok) + } + if _, ok := m.Get("malformed"); ok { + t.Fatal("malformed skill was published") + } +} + +func enabledByName(skills []Skill) map[string]bool { + out := make(map[string]bool, len(skills)) + for _, skill := range skills { + out[skill.Name] = skill.Enabled + } + return out +} + +func assertSkillDisabled(t *testing.T, m *Manager, name string) { + t.Helper() + skill, ok := m.Get(name) + if !ok || skill.Enabled { + t.Fatalf("Get(%q) = (%+v, %v), want disabled", name, skill, ok) + } +} From 0980eb5705aad02916f87688d088ffe3e410ad21 Mon Sep 17 00:00:00 2001 From: Reidho Satria Date: Wed, 16 Sep 2026 18:23:34 +0700 Subject: [PATCH 2/3] fix(agent): block disabled skills across skill operations --- docs/skills.md | 5 + internal/agent/skill_state_test.go | 241 +++++++++++++++++++++++++++++ internal/agent/skills.go | 19 ++- internal/skills/skills.go | 4 +- internal/tools/skill.go | 2 +- 5 files changed, 265 insertions(+), 6 deletions(-) create mode 100644 internal/agent/skill_state_test.go diff --git a/docs/skills.md b/docs/skills.md index 50396af..0923c53 100644 --- a/docs/skills.md +++ b/docs/skills.md @@ -46,6 +46,11 @@ will. Only names and descriptions go into the system prompt — the catalogue. Bodies are fetched on demand with the `skill` tool. + +Disabled names are omitted from new prompts and from the skill tool's list, +search, read, and chain results. Re-enabling restores access. Already-sent model +context cannot be retracted, and this preference does not restrict generic +filesystem tools. Saving skill content does not enable a disabled name. Twenty skills therefore cost a few hundred tokens per turn rather than tens of thousands, and adding more does not degrade the conversation. diff --git a/internal/agent/skill_state_test.go b/internal/agent/skill_state_test.go new file mode 100644 index 0000000..c4d54aa --- /dev/null +++ b/internal/agent/skill_state_test.go @@ -0,0 +1,241 @@ +package agent + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "testing" + + "github.com/enowdev/antares/internal/config" + "github.com/enowdev/antares/internal/llm" + "github.com/enowdev/antares/internal/skills" + "github.com/enowdev/antares/internal/store" + "github.com/enowdev/antares/internal/tools" +) + +func skillStateAgent(t *testing.T) (*Agent, *skills.Manager, string) { + t.Helper() + home := t.TempDir() + t.Setenv("ANTARES_HOME", home) + t.Setenv("ANTARES_CONFIG", filepath.Join(home, "config.yaml")) + t.Setenv("ANTARES_PROFILE", "default") + cfg := config.Default() + cfg.Skills.Enabled = true + cfg.Skills.FrontmatterMigrated = true + cfg.Memory.Enabled = false + cfg.Memory.UserProfileEnabled = false + cfg.RAG.Enabled = false + cfg.Agent.Workspace = home + cfg.Tools.ApprovalMode = "auto" + m := skills.NewManager([]string{home}) + a := agentWithConfig(cfg) + a.SetSkills(m) + return a, m, home +} + +func agentSkillSource(t *testing.T, dir, name, description, extra, body string) { + t.Helper() + if err := os.WriteFile(filepath.Join(dir, name+".md"), []byte("---\nname: "+name+"\ndescription: "+description+"\n"+extra+"---\n"+body+"\n"), 0o600); err != nil { + t.Fatal(err) + } +} + +func runStateSkill(t *testing.T, a *Agent, args map[string]any) (string, bool) { + t.Helper() + tool, ok := tools.Default().Get("skill") + if !ok { + t.Fatal("skill tool unavailable") + } + raw, err := json.Marshal(args) + if err != nil { + t.Fatal(err) + } + out := a.executeTools(context.Background(), []llm.ToolCall{{ID: "state", Name: "skill", Arguments: string(raw)}}, map[string]tools.Tool{"skill": tool}, Request{Platform: "web"}, &store.Session{ID: "state-session", Workspace: a.Config().Agent.Workspace}, func(Event) error { return nil }) + if len(out) != 1 { + t.Fatalf("tool outcomes = %d", len(out)) + } + return out[0].message.Content, out[0].isError +} + +func TestDisabledSkillReadAndUsage(t *testing.T) { + a, m, dir := skillStateAgent(t) + agentSkillSource(t, dir, "secret", "SECRET_DESCRIPTION", "", "SECRET_BODY") + if err := m.Reload(); err != nil { + t.Fatal(err) + } + cfg := a.Config().Clone() + cfg.Skills.Disabled = []string{"secret"} + a.SetConfig(cfg) + body, isError := runStateSkill(t, a, map[string]any{"action": "read", "name": "secret"}) + if !isError || strings.Contains(body, "SECRET_BODY") || !strings.Contains(body, "not found") { + t.Fatalf("disabled read leaked or succeeded: error=%v body=%q", isError, body) + } + if s, _ := m.Get("secret"); s.UsageCount != 0 { + t.Fatalf("disabled read incremented usage to %d", s.UsageCount) + } + cfg = cfg.Clone() + cfg.Skills.Disabled = nil + a.SetConfig(cfg) + body, isError = runStateSkill(t, a, map[string]any{"action": "read", "name": "secret"}) + if isError || !strings.Contains(body, "SECRET_BODY") { + t.Fatalf("re-enabled read failed: error=%v body=%q", isError, body) + } + if s, _ := m.Get("secret"); s.UsageCount != 1 { + t.Fatalf("enabled read usage = %d", s.UsageCount) + } +} + +func TestDisabledSkillSearchBeforeLimit(t *testing.T) { + a, m, dir := skillStateAgent(t) + cfg := a.Config().Clone() + for i := range 35 { + name := fmt.Sprintf("needle-%02d", i) + agentSkillSource(t, dir, name, "DISABLED_DESCRIPTION", "tech_stack: [web]\ncwe_ids: [CWE-89]\n", "DISABLED_BODY") + cfg.Skills.Disabled = append(cfg.Skills.Disabled, name) + } + agentSkillSource(t, dir, "available", "needle ENABLED_DESCRIPTION", "tech_stack: [web]\ncwe_ids: [CWE-89]\n", "ENABLED_BODY") + if err := m.Reload(); err != nil { + t.Fatal(err) + } + a.SetConfig(cfg) + for _, args := range []map[string]any{{"action": "search", "name": "needle"}, {"action": "search", "name": "needle", "tech": "web", "cwe": "89"}, {"action": "list"}} { + body, isError := runStateSkill(t, a, args) + if isError || strings.Contains(body, "DISABLED_DESCRIPTION") || !strings.Contains(body, "ENABLED_DESCRIPTION") { + t.Fatalf("disabled search/list displaced enabled match: args=%v error=%v body=%q", args, isError, body) + } + } + handle := a.skillLibrary() + hits := handle.Search("needle", 30) + if len(hits) != 1 || hits[0].Name != "available" { + t.Fatalf("adapter search failed enabled-only limit: %+v", hits) + } + if hits := m.Search("needle", 100); len(hits) != 36 { + t.Fatalf("administrative search omitted disabled entries: %d", len(hits)) + } + if prompt := m.PromptBlock(0); strings.Contains(prompt, "DISABLED_DESCRIPTION") || !strings.Contains(prompt, "ENABLED_DESCRIPTION") { + t.Fatalf("prompt exposed disabled names: %s", prompt) + } +} + +func TestDisabledSkillChainsAndPackAccess(t *testing.T) { + a, m, dir := skillStateAgent(t) + packDir := t.TempDir() + m = skills.NewManager([]string{dir, packDir}) + m.SetPackDirs([]string{packDir}) + a.SetSkills(m) + agentSkillSource(t, dir, "origin", "ORIGIN_DESCRIPTION", "chains_with: [off, on, pack]\n", "ORIGIN_BODY") + agentSkillSource(t, dir, "off", "OFF_DESCRIPTION", "", "OFF_BODY") + agentSkillSource(t, dir, "on", "ON_DESCRIPTION", "", "ON_BODY") + agentSkillSource(t, packDir, "pack", "PACK_DESCRIPTION", "", "PACK_BODY") + if err := m.Reload(); err != nil { + t.Fatal(err) + } + cfg := a.Config().Clone() + cfg.Skills.Disabled = []string{"off"} + a.SetConfig(cfg) + body, isError := runStateSkill(t, a, map[string]any{"action": "chains", "name": "origin"}) + if isError || strings.Contains(body, "OFF_DESCRIPTION") || !strings.Contains(body, "ON_DESCRIPTION") || !strings.Contains(body, "PACK_DESCRIPTION") { + t.Fatalf("chain target filtering: error=%v body=%q", isError, body) + } + cfg = cfg.Clone() + cfg.Skills.Disabled = append(cfg.Skills.Disabled, "origin") + a.SetConfig(cfg) + body, _ = runStateSkill(t, a, map[string]any{"action": "chains", "name": "origin"}) + if strings.Contains(body, "ON_DESCRIPTION") || strings.Contains(body, "PACK_DESCRIPTION") { + t.Fatalf("disabled origin exposed targets: %q", body) + } + if len(m.Chains("origin")) != 3 { + t.Fatal("administrative chain targets were filtered") + } + for _, action := range []string{"read", "search"} { + body, isError = runStateSkill(t, a, map[string]any{"action": action, "name": "pack"}) + if isError || !strings.Contains(body, "PACK_") { + t.Fatalf("enabled pack %s failed: %q", action, body) + } + } + body, _ = runStateSkill(t, a, map[string]any{"action": "list"}) + if strings.Contains(body, "PACK_DESCRIPTION") { + t.Fatalf("pack leaked into everyday list: %q", body) + } +} + +func TestSkillHandleObservesConfigAndReplacement(t *testing.T) { + a, m, dir := skillStateAgent(t) + agentSkillSource(t, dir, "same", "DESCRIPTION", "", "ORIGINAL_BODY") + if err := m.Reload(); err != nil { + t.Fatal(err) + } + handle := a.skillLibrary() + cfg := a.Config().Clone() + cfg.Skills.Disabled = []string{"same"} + a.SetConfig(cfg) + if _, body, ok := handle.Read("same"); ok || body != "" { + t.Fatalf("retained handle missed config: ok=%v body=%q", ok, body) + } + cfg = cfg.Clone() + cfg.Skills.Disabled = nil + a.SetConfig(cfg) + if _, body, ok := handle.Read("same"); !ok || body != "ORIGINAL_BODY" { + t.Fatalf("retained handle did not re-enable: ok=%v body=%q", ok, body) + } + replacementDir := t.TempDir() + agentSkillSource(t, replacementDir, "same", "DESCRIPTION", "", "REPLACEMENT_BODY") + replacement := skills.NewManager([]string{replacementDir}) + if err := replacement.Reload(); err != nil { + t.Fatal(err) + } + a.SetSkills(replacement) + body, isError := runStateSkill(t, a, map[string]any{"action": "read", "name": "same"}) + if isError || !strings.Contains(body, "REPLACEMENT_BODY") { + t.Fatalf("new tool retained retired manager: %q", body) + } + cfg = cfg.Clone() + cfg.Skills.Disabled = []string{"same"} + a.SetConfig(cfg) + body, isError = runStateSkill(t, a, map[string]any{"action": "save", "name": "same", "description": "edited", "body": "EDITED_BODY with enough procedure details to be accepted by the real tool."}) + if isError { + t.Fatalf("content editing disabled skill failed: %q", body) + } + if s, _ := replacement.Get("same"); s.Enabled || !strings.Contains(s.Body, "EDITED_BODY") { + t.Fatalf("save reset preference or lost edit: %+v", s) + } +} + +func TestConcurrentSkillReadsAndConfigPublication(t *testing.T) { + a, m, dir := skillStateAgent(t) + agentSkillSource(t, dir, "same", "DESCRIPTION", "", "BODY") + if err := m.Reload(); err != nil { + t.Fatal(err) + } + handle := a.skillLibrary() + on := a.Config().Clone() + off := on.Clone() + off.Skills.Disabled = []string{"same"} + var wg sync.WaitGroup + wg.Add(2) + go func() { + defer wg.Done() + for range 200 { + a.SetConfig(off) + a.SetConfig(on) + } + }() + go func() { + defer wg.Done() + for range 200 { + handle.List() + handle.Read("same") + handle.Search("same", 1) + handle.Chains("same") + } + }() + wg.Wait() + a.SetConfig(off) + if _, _, ok := handle.Read("same"); ok { + t.Fatal("final disabled publication invisible to retained handle") + } +} diff --git a/internal/agent/skills.go b/internal/agent/skills.go index 8963a30..b40f15c 100644 --- a/internal/agent/skills.go +++ b/internal/agent/skills.go @@ -27,20 +27,31 @@ func (a skillAdapter) List() []tools.SkillInfo { } func (a skillAdapter) Search(query string, limit int) []tools.SkillInfo { - return infos(a.m.Search(query, limit)) + return infos(a.m.SearchFiltered(query, skills.Filter{EnabledOnly: true}, limit)) } func (a skillAdapter) SearchFiltered(query, cwe, tech, category string, limit int) []tools.SkillInfo { - return infos(a.m.SearchFiltered(query, skills.Filter{CWE: cwe, Tech: tech, Category: category}, limit)) + return infos(a.m.SearchFiltered(query, skills.Filter{CWE: cwe, Tech: tech, Category: category, EnabledOnly: true}, limit)) } func (a skillAdapter) Chains(name string) []tools.SkillInfo { - return infos(a.m.Chains(name)) + origin, ok := a.m.Get(name) + if !ok || !origin.Enabled { + return nil + } + items := a.m.Chains(name) + out := make([]tools.SkillInfo, 0, len(items)) + for _, s := range items { + if s.Enabled { + out = append(out, toInfo(s)) + } + } + return out } func (a skillAdapter) Read(name string) (tools.SkillInfo, string, bool) { s, ok := a.m.Get(name) - if !ok { + if !ok || !s.Enabled { return tools.SkillInfo{}, "", false } return toInfo(*s), s.Body, true diff --git a/internal/skills/skills.go b/internal/skills/skills.go index 628221c..4b00a65 100644 --- a/internal/skills/skills.go +++ b/internal/skills/skills.go @@ -361,6 +361,8 @@ type Filter struct { Tech string // Category matches the skill category exactly. Category string + // EnabledOnly excludes disabled names before ranking and limiting results. + EnabledOnly bool } // Search finds skills by keyword, ranked by relevance. It matches across the @@ -386,7 +388,7 @@ func (m *Manager) SearchFiltered(query string, f Filter, limit int) []Skill { } var hits []scored for _, s := range list { - if !passesFilter(s, f) { + if (f.EnabledOnly && !s.Enabled) || !passesFilter(s, f) { continue } hay := strings.ToLower(strings.Join([]string{ diff --git a/internal/tools/skill.go b/internal/tools/skill.go index bfd5963..fc89a6c 100644 --- a/internal/tools/skill.go +++ b/internal/tools/skill.go @@ -138,7 +138,7 @@ func (skillTool) Execute(_ context.Context, in Input) Result { if err := lib.Write(name, args.Description, body, args.Tags); err != nil { return Errorf("save failed: %v", err) } - return Text(fmt.Sprintf("Saved skill %q. It will appear in your catalogue on the next turn.", name)) + return Text(fmt.Sprintf("Saved skill %q.", name)) default: return Errorf("unknown action %q (want list, search, read, chains, or save)", args.Action) From a380abd9f69029d7322a0ff1fd18b327af4aa1eb Mon Sep 17 00:00:00 2001 From: Reidho Satria Date: Wed, 16 Sep 2026 18:30:29 +0700 Subject: [PATCH 3/3] fix(web): report skill toggle persistence failures --- web/src/pages/SkillsPage.tsx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/web/src/pages/SkillsPage.tsx b/web/src/pages/SkillsPage.tsx index e80074e..18ebe9f 100644 --- a/web/src/pages/SkillsPage.tsx +++ b/web/src/pages/SkillsPage.tsx @@ -56,10 +56,11 @@ export default function SkillsPage() { const [filter, setFilter] = useState('') const [query, setQuery] = useState('') const endpoint = query ? `/skills?q=${encodeURIComponent(query)}` : '/skills' - const { data, loading, reload } = useApi<{ skills: Skill[]; library?: number }>(endpoint, [ + const { data, loading, reload, setData } = useApi<{ skills: Skill[]; library?: number }>(endpoint, [ endpoint, ]) const [busy, setBusy] = useState('') + const [toggleError, setToggleError] = useState('') const [browsing, setBrowsing] = useState(false) const [editing, setEditing] = useState(null) const [creating, setCreating] = useState(false) @@ -86,8 +87,13 @@ export default function SkillsPage() { const toggle = async (name: string, enabled: boolean) => { setBusy(name) + setToggleError('') try { await post('/skills/toggle', { name, enabled }) + if (data) setData({ ...data, skills: data.skills.map((s) => s.name === name ? { ...s, enabled } : s) }) + reload() + } catch (e) { + setToggleError(`${name}: ${(e as Error).message}`) reload() } finally { setBusy('') @@ -124,6 +130,7 @@ export default function SkillsPage() { /> ) : null} + {toggleError ?

{toggleError}

: null} ) @@ -193,7 +200,7 @@ export default function SkillsPage() {