Files
gnoma/internal/plugin/loader.go
T
vikingowl a9bba42c3d fix(config): stop generating zero-spam on setConfig; add Resolved mirror
The 2026-05-24 silent-corruption symptom: a `gnoma config set
provider.default anthropic` call read the existing TOML into a
zero-valued Config, set one field, then wrote the entire struct
back. Every untouched field was serialized at its Go zero value
(`mode = ""`, `max_tokens = 0`, etc.), and on the next layered
load those present-but-zero fields silently shadowed higher-
priority layers per TOML's "present field wins" semantics.

This is Phase 1 of the 2026-05-24 config-migration plan:
encoder-side only. Phases 2-5 (registry, doctor, upgrade-config,
auto-migration) follow in subsequent slices.

The fix is the hybrid approach the plan chose:

- `,omitempty` on every string / map / slice field so absent keys
  aren't re-emitted.
- Pointer conversion for the seven fields where the Go zero
  (`0`, `false`, `0.0`) is a legitimate user choice and the
  absent-vs-explicit-zero distinction matters: Provider.MaxTokens,
  Tools.MaxFileSize, Security.EntropyThreshold,
  Security.RedactHighEntropy, Router.ForceTwoStage, Session.MaxKeep,
  HookConfig.FailOpen. nil (absent) and *zero (explicit) are now
  distinguishable; the new Resolved() mirror substitutes Defaults()
  for nil so consumers see a clean concrete value.
- Defaults() populates the new pointer fields with their default
  values so the resolver substitution is a no-op for the common
  case of "user didn't set it".
- ResolvedConfig + Resolved() follow the ResolvedSafetySection
  precedent: a separate mirror type, constructed at the end of
  Load, with the boundary rule "raw cfg.X is internal; readers go
  through cfg.Resolved().X for pointer-converted fields".
- setConfig now uses an atomic temp+rename write (writeAtomicTOML)
  so a crash mid-write can't leave a half-written config file.

CLI surface: `gnoma config set [--global] <key> <value>` and
`gnoma config keys` replace the dead help-string reference at
cmd/gnoma/main.go:1538.

All consumers of pointer-converted fields (cmd/gnoma/main.go,
cmd/gnoma/profile_cmd.go, internal/hook/, internal/plugin/) move
to the Resolved mirror.

Test coverage: 6 resolver tests + 7 write tests + 3 CLI tests in
the affected packages. Full go test ./... is green except for a
pre-existing llamafile health-check timeout in
internal/slm/backend_test.go that's environmental and unrelated
to this change.

Caveats (carried as follow-up work, not blockers):

1. Duration-typed fields (SLM.StartupTimeout, SLM.ClassifyTimeout)
   still emit as raw int64 even at zero. BurntSushi's encoder
   doesn't honor omitempty on the custom Duration type without a
   MarshalText method, and the existing MarshalText-less Duration
   type predates this fix. Cosmetic-only: 0 is the documented
   "use default" sentinel for both fields, so the value is
   semantically correct. Fix is a separate pointer-conversion PR
   on those two fields.

2. Pre-existing zero-spam in user config files is not auto-cleaned
   by a setConfig call on a different key. The user's recovery
   path remains: re-set the affected key (which the new omitempty
   + pointer semantics now rewrite correctly), or run
   `gnoma upgrade-config` (Phase 4).

3. BanditSection keeps the documented 0-sentinel pattern
   (0 = "use built-in default"). Pointer conversion was
   deliberately out of scope per the plan.

Refs: docs/superpowers/plans/2026-05-24-config-migration.md
2026-06-04 12:52:55 +02:00

225 lines
6.0 KiB
Go

package plugin
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"log/slog"
"os"
"path/filepath"
"somegit.dev/Owlibou/gnoma/internal/config"
)
// Plugin is a discovered, parsed plugin.
type Plugin struct {
Manifest Manifest
Dir string // absolute path to plugin directory
Scope string // "user" or "project"
ManifestBytes []byte // raw plugin.json bytes; used for trust pinning
}
// SkillSource is a directory + source tag for skill.Registry.LoadDir.
type SkillSource struct {
Dir string
Source string
}
// LoadResult contains the merged capabilities from all loaded plugins.
type LoadResult struct {
Skills []SkillSource
Hooks []config.HookConfig
MCPServers []config.MCPServerConfig
}
// Loader discovers and loads plugins from directories.
type Loader struct {
logger *slog.Logger
}
// NewLoader creates a plugin loader.
func NewLoader(logger *slog.Logger) *Loader {
return &Loader{logger: logger}
}
// Discover scans global and project plugin directories, returning all valid plugins.
// Project-scoped plugins override same-name global plugins.
func (l *Loader) Discover(globalDir, projectDir string) ([]Plugin, error) {
byName := make(map[string]Plugin)
// Global plugins first (user scope).
l.scanDir(globalDir, "user", byName)
// Project plugins override global.
l.scanDir(projectDir, "project", byName)
plugins := make([]Plugin, 0, len(byName))
for _, p := range byName {
plugins = append(plugins, p)
}
return plugins, nil
}
// Load processes enabled plugins and extracts their capabilities.
//
// If pins is non-nil, each plugin's manifest is hashed (SHA-256 over the raw
// plugin.json bytes) and checked against the pin store:
//
// - Pin missing: TOFU — record the new hash, log a warning, load the plugin.
// - Pin matches: load silently.
// - Pin mismatches: skip the plugin and log an error. The user can remove
// the offending pin to re-enroll.
//
// Pinning failures (file I/O) downgrade to load-without-pin and log a warning;
// they never block startup, since a broken pin file shouldn't lock out plugins
// that were previously working.
func (l *Loader) Load(plugins []Plugin, enabledSet map[string]bool, pins PinStore) (LoadResult, error) {
var result LoadResult
for _, p := range plugins {
if !enabledSet[p.Manifest.Name] {
l.logger.Debug("plugin disabled, skipping", "name", p.Manifest.Name)
continue
}
if pins != nil && !l.verifyPin(p, pins) {
continue
}
l.logger.Debug("loading plugin", "name", p.Manifest.Name, "scope", p.Scope)
// Skills: resolve glob directories.
for _, glob := range p.Manifest.Capabilities.Skills {
// Use the directory portion of the glob as the skill source dir.
skillDir := filepath.Join(p.Dir, filepath.Dir(glob))
result.Skills = append(result.Skills, SkillSource{
Dir: skillDir,
Source: fmt.Sprintf("plugin:%s", p.Manifest.Name),
})
}
// Hooks: convert to config.HookConfig with resolved paths.
for _, h := range p.Manifest.Capabilities.Hooks {
execPath := h.Exec
if execPath != "" && !filepath.IsAbs(execPath) {
execPath = filepath.Join(p.Dir, execPath)
}
var failOpen *bool
if h.FailOpen {
v := true
failOpen = &v
}
result.Hooks = append(result.Hooks, config.HookConfig{
Name: h.Name,
Event: h.Event,
Type: h.Type,
Exec: execPath,
Timeout: h.Timeout,
FailOpen: failOpen,
ToolPattern: h.ToolPattern,
})
}
// MCP servers: convert with resolved command paths.
for _, s := range p.Manifest.Capabilities.MCPServers {
cmd := s.Command
if cmd != "" && !filepath.IsAbs(cmd) {
cmd = filepath.Join(p.Dir, cmd)
}
result.MCPServers = append(result.MCPServers, config.MCPServerConfig{
Name: s.Name,
Command: cmd,
Args: s.Args,
Env: s.Env,
})
}
}
return result, nil
}
func (l *Loader) scanDir(dir, scope string, byName map[string]Plugin) {
entries, err := os.ReadDir(dir)
if err != nil {
// Missing directory is fine (not all users have plugins).
return
}
for _, entry := range entries {
if !entry.IsDir() {
continue
}
pluginDir := filepath.Join(dir, entry.Name())
manifestPath := filepath.Join(pluginDir, "plugin.json")
data, err := os.ReadFile(manifestPath)
if err != nil {
l.logger.Debug("skipping plugin dir (no manifest)", "dir", pluginDir)
continue
}
manifest, err := ParseManifest(data)
if err != nil {
l.logger.Warn("skipping plugin (invalid manifest)", "dir", pluginDir, "error", err)
continue
}
byName[manifest.Name] = Plugin{
Manifest: *manifest,
Dir: pluginDir,
Scope: scope,
ManifestBytes: data,
}
}
}
// marshalJSON is a thin wrapper for tests.
func marshalJSON(v any) ([]byte, error) {
return json.Marshal(v)
}
// hashManifest returns the hex-encoded SHA-256 of the manifest bytes.
func hashManifest(data []byte) string {
sum := sha256.Sum256(data)
return hex.EncodeToString(sum[:])
}
// verifyPin enforces the TOFU trust contract on a single plugin.
// Returns true if the plugin is cleared to load.
func (l *Loader) verifyPin(p Plugin, pins PinStore) bool {
if len(p.ManifestBytes) == 0 {
// Synthetic plugin (e.g. constructed in tests without going through scanDir).
// Treat as trusted; the surface that matters has its own coverage.
return true
}
actual := hashManifest(p.ManifestBytes)
pinned, hasPin := pins.Get(p.Manifest.Name)
if !hasPin {
l.logger.Warn("enrolling new plugin (trust on first use)",
"name", p.Manifest.Name,
"scope", p.Scope,
"sha256", actual,
)
if err := pins.Set(p.Manifest.Name, actual); err != nil {
l.logger.Warn("failed to persist plugin pin; will re-enrol next run",
"name", p.Manifest.Name,
"error", err,
)
}
return true
}
if pinned != actual {
l.logger.Error("refusing plugin — manifest changed since enrolment",
"name", p.Manifest.Name,
"scope", p.Scope,
"pinned", pinned,
"actual", actual,
"hint", "remove the entry from plugins.pins.toml to re-enrol",
)
return false
}
return true
}