336 lines
10 KiB
Go
336 lines
10 KiB
Go
package installsource
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"reasonix/internal/config"
|
|
"reasonix/internal/skill"
|
|
)
|
|
|
|
// apply dispatches to the per-action implementation. Each branch is
|
|
// responsible for setting act.Status / act.Error / act.Next and for
|
|
// cleaning up any partial side effects it left behind.
|
|
func (t *installSourceTool) apply(ctx context.Context, req request, act *action) error {
|
|
switch act.Kind {
|
|
case "skill":
|
|
switch act.Action {
|
|
case "register_skill_root":
|
|
return t.applySkillRoot(req, act)
|
|
case "copy_skill":
|
|
return t.applyCopySkill(req, act)
|
|
case "link_skill":
|
|
return t.applyLinkSkill(req, act)
|
|
case "remove_skill":
|
|
return t.applyRemoveSkill(req, act)
|
|
case "remove_skill_root":
|
|
return t.applyRemoveSkillRoot(req, act)
|
|
default:
|
|
return fmt.Errorf("unknown skill action %q", act.Action)
|
|
}
|
|
case "mcp":
|
|
switch act.Action {
|
|
case "install_mcp_server":
|
|
return t.applyInstallMCP(ctx, req, act)
|
|
case "remove_mcp_server":
|
|
return t.applyRemoveMCP(req, act)
|
|
default:
|
|
return fmt.Errorf("unknown mcp action %q", act.Action)
|
|
}
|
|
case "plugin":
|
|
switch act.Action {
|
|
case "install_plugin_package":
|
|
return t.applyInstallPluginPackage(ctx, req, act)
|
|
case "remove_plugin_package":
|
|
return t.applyRemovePluginPackage(req, act)
|
|
default:
|
|
return fmt.Errorf("unknown plugin action %q", act.Action)
|
|
}
|
|
default:
|
|
return fmt.Errorf("unknown install action kind %q", act.Kind)
|
|
}
|
|
}
|
|
|
|
// applySkillRoot appends the path to the active config's [skills].paths and
|
|
// re-builds the Store to confirm the listed skills are discoverable.
|
|
func (t *installSourceTool) applySkillRoot(req request, act *action) error {
|
|
cfg := config.LoadForEdit(act.ConfigPath)
|
|
if err := cfg.AddSkillPath(act.Source); err != nil {
|
|
return err
|
|
}
|
|
if err := cfg.SaveTo(act.ConfigPath); err != nil {
|
|
return err
|
|
}
|
|
store := skill.New(skill.Options{HomeDir: t.home, ReasonixHomeDir: t.reasonixHome, ProjectRoot: t.root, CustomPaths: append(cfg.SkillCustomPaths(), act.Source)})
|
|
for _, name := range act.Skills {
|
|
sk, ok := store.Read(name)
|
|
if !ok {
|
|
return newErr(ErrSourceUnreadable, "skill %q was registered but is not discoverable", name)
|
|
}
|
|
act.Discoverable = true
|
|
if act.CanonicalPath == "" && sk.Path != "" {
|
|
act.CanonicalPath = sk.Path
|
|
}
|
|
if strings.TrimSpace(sk.Description) == "" {
|
|
act.Warnings = append(act.Warnings, fmt.Sprintf("skill %q has no description frontmatter; it is installed but the skills index will use a placeholder", name))
|
|
}
|
|
}
|
|
for _, listed := range store.List() {
|
|
for _, name := range act.Skills {
|
|
if listed.Name == name {
|
|
act.Indexed = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
act.Target = act.Source
|
|
return nil
|
|
}
|
|
|
|
// applyCopySkill copies a single skill into the project/global skills dir.
|
|
// We refuse to overwrite any existing canonical directory or legacy flat file.
|
|
// copyDir uses O_EXCL so any race that slips through the Lstat check still
|
|
// loses atomically.
|
|
func (t *installSourceTool) applyCopySkill(req request, act *action) error {
|
|
canonical, err := t.skillCanonicalPath(act.skill.Name, act.Scope)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
targetDir := filepath.Dir(canonical)
|
|
conflicts, err := t.skillConflictTargets(act.skill.Name, act.Scope)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, conflict := range conflicts {
|
|
if _, err := os.Lstat(conflict); err == nil {
|
|
return newErr(ErrAlreadyExists, "skill %q already exists at %s", act.skill.Name, conflict)
|
|
}
|
|
}
|
|
if act.skill.IsDir {
|
|
if err := copyDir(act.skill.SourcePath, targetDir); err != nil {
|
|
return err
|
|
}
|
|
} else {
|
|
if err := os.MkdirAll(targetDir, 0o755); err != nil {
|
|
return err
|
|
}
|
|
if err := writeNewFile(canonical, []byte(act.skill.Content)); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
act.Target = canonical
|
|
act.CanonicalPath = canonical
|
|
return t.verifySkill(act.Scope, act.skill.Name, act)
|
|
}
|
|
|
|
// applyLinkSkill creates a symlink in the skills dir pointing at the source.
|
|
// Absolute sources outside the project or home root are blocked even when the
|
|
// plan was approved: a link-mode skill should not become a backdoor to arbitrary
|
|
// host files.
|
|
func (t *installSourceTool) applyLinkSkill(req request, act *action) error {
|
|
canonical, err := t.skillCanonicalPath(act.skill.Name, act.Scope)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
target := canonical
|
|
if act.skill.IsDir {
|
|
target = filepath.Dir(canonical)
|
|
}
|
|
conflicts, err := t.skillConflictTargets(act.skill.Name, act.Scope)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, conflict := range conflicts {
|
|
if _, err := os.Lstat(conflict); err == nil {
|
|
return newErr(ErrAlreadyExists, "skill %q already exists at %s", act.skill.Name, conflict)
|
|
}
|
|
}
|
|
if !isLinkTargetSafe(act.skill.SourcePath, t.home, t.root) {
|
|
act.RiskLevel = RiskHigh
|
|
act.RiskReasons = append(act.RiskReasons, "link target is an absolute path outside the project or home root")
|
|
return newErr(ErrUnsafeLinkTarget, "skill %q source %s is outside %s and %s", act.skill.Name, act.skill.SourcePath, t.root, t.home)
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
|
return err
|
|
}
|
|
if err := os.Symlink(act.skill.SourcePath, target); err != nil {
|
|
return err
|
|
}
|
|
act.Target = target
|
|
act.CanonicalPath = canonical
|
|
return t.verifySkill(act.Scope, act.skill.Name, act)
|
|
}
|
|
|
|
// isLinkTargetSafe reports whether a symlink source is allowed. The link
|
|
// target is safe when:
|
|
// - it is a relative path (we never follow the parent of a relative link),
|
|
// - or its absolute form is contained within the user's home or the
|
|
// project root.
|
|
//
|
|
// Absolute paths outside both scopes are rejected with ErrUnsafeLinkTarget
|
|
// so a SKILL.md that points at /etc/passwd does not silently succeed.
|
|
func isLinkTargetSafe(source, home, projectRoot string) bool {
|
|
if source == "" {
|
|
return false
|
|
}
|
|
if !filepath.IsAbs(source) {
|
|
return true
|
|
}
|
|
clean := filepath.Clean(source)
|
|
for _, root := range []string{home, projectRoot} {
|
|
if root == "" {
|
|
continue
|
|
}
|
|
base := filepath.Clean(root)
|
|
if clean == base {
|
|
return true
|
|
}
|
|
if strings.HasPrefix(clean, base+string(filepath.Separator)) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// applyInstallMCP connects an MCP server and persists its config. The order
|
|
// is deliberate: connect first (so the user can use the tools immediately),
|
|
// then SaveTo (so a persistence failure is detectable). If SaveTo fails, we
|
|
// roll back the connection and any tools the caller already registered, so
|
|
// the live session is not out of sync with the on-disk config.
|
|
func (t *installSourceTool) applyInstallMCP(ctx context.Context, req request, act *action) error {
|
|
if act.entry.Name == "" {
|
|
return newErr(ErrInvalidManifest, "MCP action has no server entry")
|
|
}
|
|
cfg := config.LoadForEdit(act.ConfigPath)
|
|
var previous config.PluginEntry
|
|
hadPrevious := false
|
|
for _, existing := range cfg.Plugins {
|
|
if existing.Name == act.entry.Name {
|
|
previous = existing
|
|
hadPrevious = true
|
|
break
|
|
}
|
|
}
|
|
if !req.Replace {
|
|
if hadPrevious {
|
|
return newErr(ErrAlreadyExists, "MCP server %q already exists in %s; retry with replace=true to update it", act.entry.Name, act.ConfigPath)
|
|
}
|
|
}
|
|
|
|
var connected bool
|
|
oldDisconnected := false
|
|
if req.Replace && hadPrevious && t.onDisconnect != nil {
|
|
oldDisconnected = t.onDisconnect(act.entry.Name)
|
|
}
|
|
if t.connectMCP != nil {
|
|
res, err := t.connectMCP(act.entry)
|
|
if err != nil {
|
|
if oldDisconnected {
|
|
if rbErr := t.restoreMCP(previous); rbErr != nil {
|
|
return fmt.Errorf("%w; reconnect previous server failed: %v", err, rbErr)
|
|
}
|
|
}
|
|
return err
|
|
}
|
|
act.ToolCount = res.ToolCount
|
|
connected = res.Disconnect != nil || res.ToolCount >= 0
|
|
// Stash the disconnect on the action so a later SaveTo failure can
|
|
// undo the connect.
|
|
act.disconnect = res.Disconnect
|
|
}
|
|
if err := cfg.UpsertPlugin(act.entry); err != nil {
|
|
if rbErr := t.rollbackMCPReplace(act, previous, oldDisconnected, connected); rbErr != nil {
|
|
return fmt.Errorf("%w; rollback failed: %v", err, rbErr)
|
|
}
|
|
return err
|
|
}
|
|
if err := cfg.SaveTo(act.ConfigPath); err != nil {
|
|
if rbErr := t.rollbackMCPReplace(act, previous, oldDisconnected, connected); rbErr != nil {
|
|
return fmt.Errorf("%w; rollback failed: %v", err, rbErr)
|
|
}
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (t *installSourceTool) rollbackMCPReplace(act *action, previous config.PluginEntry, oldDisconnected, connected bool) error {
|
|
if connected && act.disconnect != nil {
|
|
act.disconnect()
|
|
act.disconnect = nil
|
|
}
|
|
if oldDisconnected {
|
|
return t.restoreMCP(previous)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (t *installSourceTool) restoreMCP(previous config.PluginEntry) error {
|
|
if t.connectMCP == nil || previous.Name == "" {
|
|
return nil
|
|
}
|
|
_, err := t.connectMCP(previous)
|
|
return err
|
|
}
|
|
|
|
// applyRemoveSkill deletes a previously installed skill file or directory.
|
|
// We only touch the project/global skills dir directly; the .mcp.json /
|
|
// config file is not modified.
|
|
func (t *installSourceTool) applyRemoveSkill(_ request, act *action) error {
|
|
target := act.Target
|
|
if target == "" {
|
|
return newErr(ErrInvalidManifest, "remove_skill action is missing target")
|
|
}
|
|
if _, err := os.Lstat(target); err != nil {
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
act.Target = ""
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
if err := os.RemoveAll(target); err != nil {
|
|
return err
|
|
}
|
|
act.Target = ""
|
|
return nil
|
|
}
|
|
|
|
func (t *installSourceTool) applyRemoveSkillRoot(_ request, act *action) error {
|
|
target := act.Target
|
|
if target == "" {
|
|
return newErr(ErrInvalidManifest, "remove_skill_root action is missing target")
|
|
}
|
|
cfg := config.LoadForEdit(act.ConfigPath)
|
|
removed, err := cfg.RemoveSkillPath(target)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !removed {
|
|
return nil
|
|
}
|
|
if err := cfg.SaveTo(act.ConfigPath); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// applyRemoveMCP removes an MCP server entry from the active config and
|
|
// asks the host to disconnect it (if a connector is wired).
|
|
func (t *installSourceTool) applyRemoveMCP(_ request, act *action) error {
|
|
cfg := config.LoadForEdit(act.ConfigPath)
|
|
if !cfg.RemovePlugin(act.Name) {
|
|
// Nothing to remove is not a failure: idempotent.
|
|
return nil
|
|
}
|
|
if err := cfg.SaveTo(act.ConfigPath); err != nil {
|
|
return err
|
|
}
|
|
if t.onDisconnect != nil {
|
|
t.onDisconnect(act.Name)
|
|
}
|
|
return nil
|
|
}
|