Files
2026-07-13 13:00:08 +08:00

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
}