// plugins.go loads and persists the runtime-installed external tool providers: // the MCP server list in plugins.json. Unlike models.json/channels.json there is // no env fallback — a plugin only exists because the owner installed it through // the admin API, so an absent file simply means "none installed". // // plugins.json is gitignored (local runtime config) and may hold a server API // key, so this package never logs a key and MaskedPlugin redacts every secret // before a caller returns it to a client. package runtimeconfig import ( "encoding/json" "errors" "fmt" "os" "path/filepath" "sort" "strings" ) // pluginsFileName is the plugins.json basename stored under the data dir. const pluginsFileName = "plugins.json" // PluginKindMCP is the only installable plugin kind in v1: an external MCP // server whose tools are hot-registered into the capability registry. A compiled // Go plugin cannot be hot-installed in a single static binary (the project red // line), so an MCP server is the runtime-installable unit. const PluginKindMCP = "mcp" // Transport names accepted in a PluginConfig.Transport. const ( TransportStdio = "stdio" TransportHTTP = "http" TransportSSE = "sse" ) // ErrInvalidPlugin is returned when a plugins.json entry fails validation. var ErrInvalidPlugin = errors.New("runtimeconfig: invalid plugin config") // PluginConfig describes one runtime-installed external provider. It is the // on-disk plugins.json shape and the admin install body's config block. type PluginConfig struct { // ID is a stable identifier, generated when the panel omits one. ID string `json:"id"` // Name is the unique plugin label; it names the MCP server and prefixes its // KindMCP capability ("mcp:"). Name string `json:"name"` // Kind classifies the provider. Only "mcp" is installable in v1. Kind string `json:"kind"` // Transport selects the wire transport: "stdio", "http" (Streamable HTTP) or // "sse". Empty is inferred from Command/Endpoint. Transport string `json:"transport,omitempty"` // Endpoint is the server URL for http/sse transports. Endpoint string `json:"endpoint,omitempty"` // Command is the executable path for a stdio transport. Command string `json:"command,omitempty"` // Args are the stdio command arguments. Args []string `json:"args,omitempty"` // APIKey is the bearer credential sent to an http/sse server. It is // persisted but never returned or logged verbatim (MaskedPlugin). APIKey string `json:"apiKey,omitempty"` // Env is extra environment for a stdio subprocess. Values under a sensitive // key (token/secret/password/key...) are redacted by MaskedPlugin. Env map[string]string `json:"env,omitempty"` // Enabled is the per-server on/off switch. It is a pointer so that "the file // has no enabled key" (every plugins.json written before this field existed) // stays distinguishable from an explicit false: absent means enabled // (IsEnabled), and only an explicit disable is persisted. // // Disabled is NOT uninstalled: the entry stays in plugins.json (so it survives // a restart as disabled) and only its live connection + tools go away. Enabled *bool `json:"enabled,omitempty"` // CreatedAt is the RFC3339 install time. CreatedAt string `json:"createdAt,omitempty"` } // IsEnabled reports whether the server should be connected. A missing enabled key // (older plugins.json / an install that never mentioned it) counts as enabled. func (p PluginConfig) IsEnabled() bool { return p.Enabled == nil || *p.Enabled } // WithEnabled returns a copy of p with an explicit enabled flag. func (p PluginConfig) WithEnabled(on bool) PluginConfig { p.Enabled = &on return p } // DefaultPluginsPath returns the plugins.json path under dataDir. func DefaultPluginsPath(dataDir string) string { return filepath.Join(dataDir, pluginsFileName) } // Normalize fills the defaults an install request may omit: kind=mcp and the // transport inferred from command/endpoint (stdio when a command is set, // otherwise http when an endpoint is set). func (p PluginConfig) Normalize() PluginConfig { if strings.TrimSpace(p.Kind) == "" { p.Kind = PluginKindMCP } if strings.TrimSpace(p.Transport) == "" { switch { case strings.TrimSpace(p.Command) != "": p.Transport = TransportStdio case strings.TrimSpace(p.Endpoint) != "": p.Transport = TransportHTTP } } return p } // Validate checks the plugin's required fields. It validates the normalized // form so a caller that only fills endpoint or command still gets a coherent // transport. It never includes a secret in the returned error. func (p PluginConfig) Validate() error { p = p.Normalize() if strings.TrimSpace(p.Name) == "" { return fmt.Errorf("%w: name is required", ErrInvalidPlugin) } if p.Kind != PluginKindMCP { return fmt.Errorf("%w: kind %q must be %s", ErrInvalidPlugin, p.Kind, PluginKindMCP) } switch p.Transport { case TransportStdio: if strings.TrimSpace(p.Command) == "" { return fmt.Errorf("%w: command is required for %s transport", ErrInvalidPlugin, TransportStdio) } case TransportHTTP, "streamable", TransportSSE: if strings.TrimSpace(p.Endpoint) == "" { return fmt.Errorf("%w: endpoint is required for %s transport", ErrInvalidPlugin, p.Transport) } default: return fmt.Errorf("%w: transport %q must be stdio, http, streamable or sse", ErrInvalidPlugin, p.Transport) } return nil } // LoadPlugins returns the persisted plugin list. A missing file is the normal // "nothing installed" case and yields an empty list with no error. A // present-but-invalid file returns a non-nil error so the caller can warn // instead of silently dropping installed plugins. func LoadPlugins(path string) ([]PluginConfig, error) { raw, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { return []PluginConfig{}, nil } return nil, fmt.Errorf("runtimeconfig: read %s: %w", path, err) } if len(strings.TrimSpace(string(raw))) == 0 { return nil, fmt.Errorf("%w: empty file", ErrInvalidPlugin) } var plugins []PluginConfig if err := json.Unmarshal(raw, &plugins); err != nil { return nil, fmt.Errorf("%w: decode: %w", ErrInvalidPlugin, err) } for i, p := range plugins { if err := p.Validate(); err != nil { return nil, fmt.Errorf("%w: entry %d: %w", ErrInvalidPlugin, i, err) } } if plugins == nil { plugins = []PluginConfig{} } return plugins, nil } // SavePlugins writes plugins as a pretty-printed JSON array to path with mode // 0600 (the file may hold an API key). It writes to a temp file then renames so // a concurrent reader never sees a partial file. Every entry is validated first. func SavePlugins(path string, plugins []PluginConfig) error { dir := filepath.Dir(path) if dir != "" && dir != "." { if err := os.MkdirAll(dir, 0o755); err != nil { return fmt.Errorf("runtimeconfig: mkdir: %w", err) } } out := make([]PluginConfig, 0, len(plugins)) for i, p := range plugins { p = p.Normalize() if err := p.Validate(); err != nil { return fmt.Errorf("runtimeconfig: save: entry %d: %w", i, err) } out = append(out, p) } raw, err := json.MarshalIndent(out, "", " ") if err != nil { return fmt.Errorf("runtimeconfig: marshal: %w", err) } tmp := path + ".tmp" if err := os.WriteFile(tmp, raw, 0o600); err != nil { return fmt.Errorf("runtimeconfig: write %s: %w", tmp, err) } if err := os.Rename(tmp, path); err != nil { return fmt.Errorf("runtimeconfig: rename: %w", err) } return nil } // UpsertPlugin returns plugins with p inserted or replaced in place by name // (falling back to id), preserving order. A blank id is assigned a generated one // so a panel save without an explicit id still produces a stable, addressable // entry. func UpsertPlugin(plugins []PluginConfig, p PluginConfig) []PluginConfig { p = p.Normalize() if strings.TrimSpace(p.ID) == "" { p.ID = NewID() } out := make([]PluginConfig, 0, len(plugins)+1) replaced := false for _, cur := range plugins { if samePlugin(cur, p) { out = append(out, p) replaced = true continue } out = append(out, cur) } if !replaced { out = append(out, p) } return out } // DeletePlugin returns plugins with the entry matching name (by name or id) // removed. The bool reports whether an entry was actually removed. func DeletePlugin(plugins []PluginConfig, name string) ([]PluginConfig, bool) { out := make([]PluginConfig, 0, len(plugins)) removed := false for _, cur := range plugins { if cur.Name == name || cur.ID == name { removed = true continue } out = append(out, cur) } return out, removed } // MaskedPlugin returns a copy of p whose API key and sensitive env values are // redacted, for a wire response or a log line. The on-disk plugins.json is // untouched. func MaskedPlugin(p PluginConfig) PluginConfig { out := p if strings.TrimSpace(p.APIKey) != "" { out.APIKey = maskToken(strings.TrimSpace(p.APIKey)) } out.Env = maskEnv(p.Env) return out } // maskEnv redacts the values of sensitive env keys, passing non-sensitive keys, // empty values and non-string values through unchanged. func maskEnv(env map[string]string) map[string]string { if env == nil { return nil } out := make(map[string]string, len(env)) for k, v := range env { if isSensitiveKey(k) && strings.TrimSpace(v) != "" { out[k] = maskToken(v) continue } out[k] = v } return out } // secretMask is what a redacted secret (or a fragment of one) collapses to inside // free text. It is deliberately not maskToken: free text needs a marker that // cannot be mistaken for a real tail of the credential. const secretMask = "***" // minRedactFragment is the shortest run of a secret that is still scrubbed from // free text. Anything shorter is not evidence of that credential (a 2-3 char run // of a hex key shows up in ordinary prose by chance), and keeping the floor low // enough that a leaked *middle* of a key (an error printing a substring) is still // caught is the point of scanning fragments at all. const minRedactFragment = 6 // RedactPluginSecrets scrubs every configured credential out of text, returning a // string that is safe to show in a status snapshot or a log line. // // Why this exists (and why MaskedPlugin is not enough): a connect failure's error // text is written by the MCP client, the HTTP stack or the server itself, so it // can carry the bearer token (a 401 echoing the Authorization header, a URL with // `?token=...`, a stdio subprocess dumping its env). The snapshot exposes that // text as `lastError`, therefore the text — not just the config — has to be // redacted. Replacement is by whole value first, then by every fragment of at // least minRedactFragment characters, so a partially printed key is caught too. func RedactPluginSecrets(p PluginConfig, text string) string { if text == "" { return "" } out := redactSecret(text, p.APIKey) keys := make([]string, 0, len(p.Env)) for k := range p.Env { if isSensitiveKey(k) { keys = append(keys, k) } } // 排序只为结果稳定:Env 是 map,不排序会让同一份输入在不同运行里产出不同文本。 sort.Strings(keys) for _, k := range keys { out = redactSecret(out, p.Env[k]) } return out } // redactSecret replaces secret and every fragment of it (longest first, down to // minRedactFragment runes-worth of bytes) with secretMask. func redactSecret(text, secret string) string { secret = strings.TrimSpace(secret) if secret == "" || text == "" { return text } out := strings.ReplaceAll(text, secret, secretMask) // 长片段优先:先抹掉最长的,剩下的短片段才不会被抹成一片 *** 后漏掉边界。 for n := len(secret) - 1; n >= minRedactFragment; n-- { for i := 0; i+n <= len(secret); i++ { out = strings.ReplaceAll(out, secret[i:i+n], secretMask) } } return out } // samePlugin reports whether cur is the entry p replaces: same name when p has // one, otherwise same id. func samePlugin(cur, p PluginConfig) bool { if p.Name != "" && cur.Name == p.Name { return true } return p.ID != "" && cur.ID == p.ID }