返回 DeepSeek-Reasonix
plugin_package.go
根目录 / internal / installsource / plugin_package.go
1 package installsource
2
3 import (
4 "context"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "os"
9 "os/exec"
10 "path/filepath"
11 "regexp"
12 "slices"
13 "sort"
14 "strings"
15
16 "reasonix/internal/gitcmd"
17 "reasonix/internal/pluginpkg"
18 )
19
20 const (
21 claudeMarketplaceManifest = ".claude-plugin/marketplace.json"
22 maxMarketplacePlugins = 64
23 )
24
25 type claudeMarketplace struct {
26 Name string `json:"name"`
27 Metadata struct {
28 PluginRoot string `json:"pluginRoot"`
29 } `json:"metadata"`
30 Plugins []struct {
31 Name string `json:"name"`
32 Source json.RawMessage `json:"source"`
33 } `json:"plugins"`
34 }
35
36 type claudeMarketplaceURLSource struct {
37 Source string `json:"source"`
38 URL string `json:"url"`
39 SHA string `json:"sha"`
40 }
41
42 var fullGitSHA = regexp.MustCompile(`^[0-9a-fA-F]{40}$`)
43
44 func (t *installSourceTool) localPluginPackageAction(req request, root string) (action, []string, error) {
45 pkg, warnings, err := pluginpkg.ParseDir(root)
46 if err != nil {
47 return action{}, warnings, newErr(ErrManifestMissing, "%v", err)
48 }
49 act, err := t.pluginPackageAction(req, pkg, root)
50 return act, warnings, err
51 }
52
53 func (t *installSourceTool) planGitHubPluginPackage(ctx context.Context, req request) ([]action, []string, error) {
54 src, ok := parseGitHubRepoSource(req.Source)
55 if !ok {
56 return nil, nil, newErr(ErrUnsupportedKind, "plugin URL %q is not a GitHub repository", req.Source)
57 }
58 // Plan against the same source tree apply will install (a shallow clone
59 // via pluginSource). A manifest-only fetch cannot see conventional
60 // capability directories (skills/, commands/) or their warnings, so it
61 // under-reports the capability set — and the plan the user approves must
62 // describe exactly what apply installs.
63 root, commit, cleanup, err := t.pluginSource(ctx, req.Source, modeForPlugin(req.Mode))
64 if err != nil {
65 return nil, nil, err
66 }
67 pkg, warnings, err := pluginpkg.ParseDir(root)
68 if err == nil {
69 defer cleanup()
70 act, actionErr := t.pluginPackageAction(req, pkg, req.Source)
71 if actionErr != nil {
72 return nil, warnings, actionErr
73 }
74 act.Source = req.Source
75 // The commit joins the action and therefore the plan ID, so the approval
76 // fingerprints the exact snapshot; apply pins to it.
77 act.Commit = commit
78 return []action{act}, warnings, nil
79 }
80
81 actions, marketplaceWarnings, marketplaceErr := t.planClaudeMarketplace(ctx, req, src, root, commit)
82 warnings = append(warnings, marketplaceWarnings...)
83 if marketplaceErr != nil {
84 cleanup()
85 if errors.Is(marketplaceErr, ErrNoCompatibleCapabilities) {
86 return nil, warnings, marketplaceErr
87 }
88 return nil, warnings, newErr(ErrManifestMissing, "no plugin manifest or supported Claude marketplace found in GitHub repository %s/%s: plugin: %v; marketplace: %v", src.Owner, src.Repo, err, marketplaceErr)
89 }
90 if req.Apply {
91 // All marketplace entries come from this one immutable clone. Reusing it
92 // keeps a 12-plugin marketplace at one clone during apply and guarantees
93 // every copied plugin is the snapshot represented by act.Commit.
94 previousCleanup := actions[0].cleanup
95 actions[0].cleanup = func() {
96 if previousCleanup != nil {
97 previousCleanup()
98 }
99 cleanup()
100 }
101 return actions, warnings, nil
102 }
103 cleanup()
104 return actions, warnings, nil
105 }
106
107 func (t *installSourceTool) planClaudeMarketplace(ctx context.Context, req request, src githubRepoSource, root, commit string) ([]action, []string, error) {
108 manifestPath := filepath.Join(root, filepath.FromSlash(claudeMarketplaceManifest))
109 body, err := os.ReadFile(manifestPath)
110 if err != nil {
111 return nil, nil, err
112 }
113 var marketplace claudeMarketplace
114 if err := json.Unmarshal(body, &marketplace); err != nil {
115 return nil, nil, fmt.Errorf("parse %s: %w", claudeMarketplaceManifest, err)
116 }
117 if strings.TrimSpace(marketplace.Name) == "" {
118 return nil, nil, fmt.Errorf("%s has no marketplace name", claudeMarketplaceManifest)
119 }
120 if len(marketplace.Plugins) == 0 {
121 return nil, nil, fmt.Errorf("%s contains no plugins", claudeMarketplaceManifest)
122 }
123 if len(marketplace.Plugins) > maxMarketplacePlugins {
124 return nil, nil, fmt.Errorf("%s contains %d plugins; limit is %d", claudeMarketplaceManifest, len(marketplace.Plugins), maxMarketplacePlugins)
125 }
126
127 branch := strings.TrimSpace(src.Branch)
128 if branch == "" {
129 branch = currentPluginGitBranch(ctx, root)
130 }
131 if branch == "" {
132 branch = src.branches()[0]
133 }
134
135 selected := strings.TrimSpace(req.Name)
136 foundSelected := selected == ""
137 seen := make(map[string]bool, len(marketplace.Plugins))
138 var actions []action
139 keepActionResources := false
140 defer func() {
141 if !keepActionResources {
142 cleanupActionResources(actions)
143 }
144 }()
145 var warnings []string
146 for _, entry := range marketplace.Plugins {
147 entryName := strings.TrimSpace(entry.Name)
148 if selected != "" && entryName != selected {
149 continue
150 }
151 foundSelected = true
152 if entryName == "" {
153 warnings = append(warnings, "skipped Claude marketplace entry with an empty name")
154 continue
155 }
156 // Validate the name at plan time so a broken entry surfaces in the
157 // preview instead of failing its action mid-apply.
158 if !pluginpkg.IsValidName(entryName) {
159 if selected != "" {
160 return nil, warnings, fmt.Errorf("marketplace plugin %q is not a valid plugin name", entryName)
161 }
162 warnings = append(warnings, fmt.Sprintf("skipped Claude marketplace plugin %q: not a valid plugin name", entryName))
163 continue
164 }
165 if seen[entryName] {
166 return nil, warnings, fmt.Errorf("%s contains duplicate plugin name %q", claudeMarketplaceManifest, entryName)
167 }
168 seen[entryName] = true
169
170 var source string
171 var pluginRoot, pluginSource, actionCommit string
172 var entryCleanup func()
173 if err := json.Unmarshal(entry.Source, &source); err == nil {
174 if marketplaceSourceIsExternal(source) {
175 if selected != "" {
176 return nil, warnings, fmt.Errorf("marketplace plugin %q: external source %q must use a pinned URL object", entryName, source)
177 }
178 warnings = append(warnings, fmt.Sprintf("skipped Claude marketplace plugin %q: external source %q must use a pinned URL object", entryName, source))
179 continue
180 }
181 rel, relErr := claudeMarketplaceRelativePath(marketplace.Metadata.PluginRoot, source)
182 if relErr != nil {
183 return nil, warnings, fmt.Errorf("plugin %q: %w", entryName, relErr)
184 }
185 pluginRoot = filepath.Join(root, filepath.FromSlash(rel))
186 repoPath := joinURLPath(src.Path, rel)
187 pluginSource = fmt.Sprintf("https://github.com/%s/%s/tree/%s/%s", src.Owner, src.Repo, branch, repoPath)
188 actionCommit = commit
189 } else {
190 var pinned claudeMarketplaceURLSource
191 if objectErr := json.Unmarshal(entry.Source, &pinned); objectErr != nil || pinned.Source != "url" || !fullGitSHA.MatchString(strings.TrimSpace(pinned.SHA)) {
192 if selected != "" {
193 return nil, warnings, fmt.Errorf("marketplace plugin %q: object source requires source=url, a GitHub URL, and a full 40-character SHA", entryName)
194 }
195 warnings = append(warnings, fmt.Sprintf("skipped Claude marketplace plugin %q: object source is not a pinned GitHub URL", entryName))
196 continue
197 }
198 if _, ok := parseGitHubRepoSource(strings.TrimSpace(pinned.URL)); !ok {
199 if selected != "" {
200 return nil, warnings, fmt.Errorf("marketplace plugin %q: pinned URL %q is not a GitHub repository", entryName, pinned.URL)
201 }
202 warnings = append(warnings, fmt.Sprintf("skipped Claude marketplace plugin %q: pinned URL is not a GitHub repository", entryName))
203 continue
204 }
205 var resolvedCommit string
206 pluginRoot, resolvedCommit, entryCleanup, err = t.pluginSource(ctx, pinned.URL, "copy")
207 if err != nil {
208 return nil, warnings, fmt.Errorf("marketplace plugin %q: %w", entryName, err)
209 }
210 if !strings.EqualFold(resolvedCommit, pinned.SHA) {
211 if err := checkoutPluginCommit(ctx, pluginRoot, pinned.SHA); err != nil {
212 entryCleanup()
213 return nil, warnings, fmt.Errorf("marketplace plugin %q: %w", entryName, err)
214 }
215 }
216 pluginSource, actionCommit = strings.TrimSpace(pinned.URL), strings.ToLower(strings.TrimSpace(pinned.SHA))
217 }
218 pkg, pkgWarnings, err := pluginpkg.ParseDir(pluginRoot)
219 warnings = append(warnings, pkgWarnings...)
220 if err != nil {
221 if entryCleanup != nil {
222 entryCleanup()
223 }
224 return nil, warnings, fmt.Errorf("plugin %q: %w", entryName, err)
225 }
226 if pkg.Manifest.Name != entryName {
227 if entryCleanup != nil {
228 entryCleanup()
229 }
230 return nil, warnings, fmt.Errorf("marketplace plugin %q points to manifest named %q", entryName, pkg.Manifest.Name)
231 }
232 actionReq := req
233 actionReq.Name = ""
234 act, actionErr := t.pluginPackageAction(actionReq, pkg, pluginSource)
235 if actionErr != nil {
236 if entryCleanup != nil {
237 entryCleanup()
238 }
239 return nil, warnings, actionErr
240 }
241 act.Source = pluginSource
242 act.Commit = actionCommit
243 act.preparedRoot = pluginRoot
244 if entryCleanup != nil {
245 if req.Apply {
246 act.cleanup = entryCleanup
247 } else {
248 entryCleanup()
249 act.preparedRoot = ""
250 }
251 }
252 actions = append(actions, act)
253 }
254 if !foundSelected {
255 return nil, warnings, fmt.Errorf("%s does not contain plugin %q", claudeMarketplaceManifest, selected)
256 }
257 if len(actions) == 0 {
258 return nil, warnings, fmt.Errorf("%s contains no supported plugins", claudeMarketplaceManifest)
259 }
260 sort.Slice(actions, func(i, j int) bool { return actions[i].Name < actions[j].Name })
261 sort.Strings(warnings)
262 warnings = slices.Compact(warnings)
263 keepActionResources = true
264 return actions, warnings, nil
265 }
266
267 func claudeMarketplaceRelativePath(pluginRoot, source string) (string, error) {
268 pluginRoot = strings.TrimSpace(pluginRoot)
269 if pluginRoot == "" {
270 pluginRoot = "."
271 }
272 cleanRoot, err := cleanMarketplaceRelPath("metadata.pluginRoot", pluginRoot)
273 if err != nil {
274 return "", err
275 }
276 cleanSource, err := cleanMarketplaceRelPath("source", source)
277 if err != nil {
278 return "", err
279 }
280 rel := filepath.Clean(filepath.Join(cleanRoot, cleanSource))
281 if rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
282 return "", fmt.Errorf("source %q escapes or does not identify a plugin subdirectory", source)
283 }
284 return filepath.ToSlash(rel), nil
285 }
286
287 // cleanMarketplaceRelPath normalizes one relative-path field of a marketplace
288 // entry. Real marketplaces spell paths both as "./plugins/example" and as the
289 // bare "plugins/example", so both are accepted; absolute and drive-qualified
290 // paths are rejected before the join so they can never re-anchor the lookup
291 // outside the clone.
292 func cleanMarketplaceRelPath(label, value string) (string, error) {
293 value = strings.TrimSpace(value)
294 if value == "" {
295 return "", fmt.Errorf("%s is empty", label)
296 }
297 cleaned := filepath.Clean(filepath.FromSlash(strings.TrimPrefix(value, "./")))
298 if filepath.IsAbs(cleaned) || filepath.VolumeName(cleaned) != "" {
299 return "", fmt.Errorf("%s %q must be a relative path inside the marketplace repository", label, value)
300 }
301 return cleaned, nil
302 }
303
304 // marketplaceSourceIsExternal reports whether a string source points outside
305 // the marketplace repository (a URL or scp-like git address) rather than at a
306 // relative path inside it.
307 func marketplaceSourceIsExternal(source string) bool {
308 source = strings.TrimSpace(source)
309 return strings.Contains(source, "://") || strings.HasPrefix(source, "git@")
310 }
311
312 func currentPluginGitBranch(ctx context.Context, root string) string {
313 cmd := pluginGitCommand(ctx, "-C", root, "branch", "--show-current")
314 out, err := cmd.Output()
315 if err != nil {
316 return ""
317 }
318 return strings.TrimSpace(string(out))
319 }
320
321 // pluginSource resolves a plugin source to an on-disk tree. Both the plan and
322 // apply phases go through this single function so their views can never
323 // diverge (the approval-contract guarantee); for git sources it also reports
324 // the resolved commit SHA ("" for local directories).
325 func (t *installSourceTool) pluginSource(ctx context.Context, source, mode string) (string, string, func(), error) {
326 if t.preparePlugin != nil {
327 return t.preparePlugin(ctx, source, mode)
328 }
329 return t.preparePluginSource(ctx, source, mode)
330 }
331
332 func (t *installSourceTool) pluginPackageAction(req request, pkg pluginpkg.Package, source string) (action, error) {
333 name := strings.TrimSpace(req.Name)
334 if name == "" {
335 name = pkg.Manifest.Name
336 }
337 root := ""
338 if t.reasonixHome != "" {
339 root = pluginpkg.InstallRoot(t.reasonixHome, name)
340 }
341 skills, commands, hooks, mcp := pkg.CapabilityCounts()
342 agents := pkg.Inventory().Agents
343 if pkg.ManifestKind != "reasonix" && skills+commands+hooks+mcp+len(agents) == 0 {
344 return action{}, newErr(ErrNoCompatibleCapabilities, "plugin %q has no Reasonix-compatible capabilities; skipped: %v", name, pkg.Compatibility.Skipped)
345 }
346 agentNames := make([]string, 0, len(agents))
347 for _, agent := range agents {
348 agentNames = append(agentNames, agent.Name)
349 }
350 a := action{
351 Kind: "plugin",
352 Action: "install_plugin_package",
353 Name: name,
354 Source: source,
355 Target: root,
356 Scope: "global",
357 Mode: modeForPlugin(req.Mode),
358 ConfigPath: pluginpkg.StatePath(t.reasonixHome),
359 Skills: pkg.Manifest.Skills,
360 SkillCount: skills,
361 Agents: agentNames,
362 AgentCount: len(agentNames),
363 Commands: pkg.Manifest.Commands,
364 CommandCount: commands,
365 ManifestKind: pkg.ManifestKind,
366 HookCount: hooks,
367 ToolCount: mcp,
368 Compatibility: pkg.Compatibility.Status,
369 MappedCapabilities: append([]string(nil), pkg.Compatibility.Mapped...),
370 SkippedCapabilities: append([]pluginpkg.CompatibilityIssue(nil), pkg.Compatibility.Skipped...),
371 Version: pkg.Manifest.Version,
372 PromptCount: pkg.PromptCount(),
373 ThemeCount: pkg.ThemeCount(),
374 Runtime: runtimePlanInfo(pkg.Manifest.Runtime),
375 RiskLevel: RiskMedium,
376 RiskReasons: []string{"installs a plugin package that can add skills, commands, hooks, and MCP servers"},
377 }
378 if a.Mode == "link" {
379 a.RiskReasons = append(a.RiskReasons, "links a plugin package from a mutable local directory")
380 }
381 if hooks > 0 {
382 a.RiskLevel = RiskHigh
383 a.RiskReasons = append(a.RiskReasons, "registers shell hooks that execute during Reasonix sessions")
384 }
385 if mcp > 0 {
386 a.RiskLevel = RiskHigh
387 a.RiskReasons = append(a.RiskReasons, "adds MCP servers that can change provider-visible tool schemas")
388 }
389 if a.Runtime != nil {
390 a.RiskLevel = RiskHigh
391 a.RiskReasons = append(a.RiskReasons, "FULL TRUST: declares a runtime process ("+pluginpkg.RuntimeCommandLine(pkg.Manifest.Runtime)+") that runs inside Reasonix — it can read the full session and environment, bypass permissions, and operate this machine directly")
392 }
393 sort.Strings(a.Skills)
394 sort.Strings(a.Agents)
395 return a, nil
396 }
397
398 // runtimePlanInfo converts a manifest runtime declaration into its plan
399 // form. nil in, nil out: legacy packages carry no runtime field at all.
400 func runtimePlanInfo(rt *pluginpkg.RuntimeSpec) *RuntimePlanInfo {
401 if rt == nil {
402 return nil
403 }
404 return &RuntimePlanInfo{
405 Command: rt.Command,
406 Args: append([]string(nil), rt.Args...),
407 Intercepts: append([]string(nil), rt.Intercepts...),
408 Replaces: append([]string(nil), rt.Replaces...),
409 Capabilities: append([]string(nil), rt.Capabilities...),
410 FullTrust: true,
411 }
412 }
413
414 func modeForPlugin(mode string) string {
415 if mode == "link" {
416 return "link"
417 }
418 return "copy"
419 }
420
421 func (t *installSourceTool) applyInstallPluginPackage(ctx context.Context, req request, act *action) error {
422 if t.reasonixHome == "" {
423 return newErr(ErrSourceUnreadable, "plugin install requires a Reasonix home directory")
424 }
425 if !pluginpkg.IsValidName(act.Name) {
426 return newErr(ErrInvalidManifest, "invalid plugin name %q", act.Name)
427 }
428 target := pluginpkg.InstallRoot(t.reasonixHome, act.Name)
429 sourceRoot, commit, cleanup := act.preparedRoot, act.Commit, func() {}
430 if sourceRoot == "" {
431 var err error
432 sourceRoot, commit, cleanup, err = t.pluginSource(ctx, act.Source, act.Mode)
433 if err != nil {
434 return err
435 }
436 }
437 defer cleanup()
438 if act.Commit != "" && commit != act.Commit {
439 // The source moved between the approved plan and this resolution; pin
440 // the clone back to the approved snapshot so what installs is exactly
441 // what was reviewed.
442 if err := checkoutPluginCommit(ctx, sourceRoot, act.Commit); err != nil {
443 return newErr(ErrApprovalDenied, "plugin source changed since the approved plan (approved commit %s, found %s) and the approved snapshot could not be restored: %v; re-run without apply to review the new plan", act.Commit, commit, err)
444 }
445 }
446 pkg, warnings, err := pluginpkg.ParseDir(sourceRoot)
447 if err != nil {
448 return newErr(ErrInvalidManifest, "%v", err)
449 }
450 if pkg.ManifestKind != "reasonix" {
451 skills, commands, hooks, mcp := pkg.CapabilityCounts()
452 if skills+commands+hooks+mcp+pkg.AgentCount() == 0 {
453 return newErr(ErrInvalidManifest, "plugin %q no longer has any Reasonix-compatible capabilities", act.Name)
454 }
455 }
456 act.Warnings = append(act.Warnings, warnings...)
457 if pkg.Manifest.Name != act.Name && strings.TrimSpace(req.Name) == "" {
458 return newErr(ErrInvalidManifest, "planned plugin name %q but source now reports %q", act.Name, pkg.Manifest.Name)
459 }
460 if act.Mode == "link" {
461 if !isLinkTargetSafe(sourceRoot, t.home, t.root) {
462 return newErr(ErrUnsafeLinkTarget, "plugin source %s is outside %s and %s", sourceRoot, t.root, t.home)
463 }
464 if err := replaceSymlink(target, sourceRoot, req.Replace); err != nil {
465 return err
466 }
467 } else {
468 if err := installCopiedPlugin(pkg, sourceRoot, target, req.Replace); err != nil {
469 return err
470 }
471 }
472 installed := pluginpkg.InstalledPlugin{
473 Name: act.Name,
474 Source: act.Source,
475 Root: pluginpkg.RelativeRoot(t.reasonixHome, target),
476 Version: pkg.Manifest.Version,
477 Description: pkg.Manifest.Description,
478 ManifestKind: pkg.ManifestKind,
479 Enabled: true,
480 Commit: strings.ToLower(strings.TrimSpace(commit)),
481 }
482 if act.Mode == "link" {
483 installed.Root = sourceRoot
484 }
485 if err := pluginpkg.Upsert(t.reasonixHome, installed); err != nil {
486 return err
487 }
488 act.Target = target
489 act.ManifestKind = pkg.ManifestKind
490 act.Version = pkg.Manifest.Version
491 act.SkillCount, act.CommandCount, act.HookCount, act.ToolCount = pkg.CapabilityCounts()
492 act.AgentCount = pkg.AgentCount()
493 act.PromptCount, act.ThemeCount = pkg.PromptCount(), pkg.ThemeCount()
494 act.Runtime = runtimePlanInfo(pkg.Manifest.Runtime)
495 act.Compatibility = pkg.Compatibility.Status
496 act.MappedCapabilities = append([]string(nil), pkg.Compatibility.Mapped...)
497 act.SkippedCapabilities = append([]pluginpkg.CompatibilityIssue(nil), pkg.Compatibility.Skipped...)
498 return nil
499 }
500
501 func (t *installSourceTool) preparePluginSource(ctx context.Context, source, mode string) (string, string, func(), error) {
502 source = strings.TrimSpace(source)
503 if strings.HasPrefix(source, "git:github.com/") {
504 source = "https://github.com/" + strings.TrimPrefix(source, "git:github.com/")
505 }
506 if isURL(source) {
507 src, ok := parseGitHubRepoSource(source)
508 if !ok {
509 return "", "", func() {}, newErr(ErrUnsupportedKind, "plugin URL %q is not a GitHub repository", source)
510 }
511 tmp, err := os.MkdirTemp("", "reasonix-plugin-*")
512 if err != nil {
513 return "", "", func() {}, err
514 }
515 cloneURL := fmt.Sprintf("https://github.com/%s/%s.git", src.Owner, src.Repo)
516 args := []string{"clone", "--depth=1"}
517 if src.Branch != "" {
518 args = append(args, "--branch", src.Branch)
519 }
520 args = append(args, cloneURL, tmp)
521 cmd := pluginGitCommand(ctx, args...)
522 if out, err := cmd.CombinedOutput(); err != nil {
523 _ = os.RemoveAll(tmp)
524 return "", "", func() {}, newErr(ErrSourceUnreadable, "git clone failed: %v: %s", err, strings.TrimSpace(string(out)))
525 }
526 commit := ""
527 rev := pluginGitCommand(ctx, "-C", tmp, "rev-parse", "HEAD")
528 if out, err := rev.Output(); err == nil {
529 commit = strings.TrimSpace(string(out))
530 }
531 root, err := pluginRootFromClone(tmp, src.Path)
532 if err != nil {
533 _ = os.RemoveAll(tmp)
534 return "", "", func() {}, err
535 }
536 return root, commit, func() { _ = os.RemoveAll(tmp) }, nil
537 }
538 path := t.resolvePath(source)
539 if mode == "link" {
540 return path, "", func() {}, nil
541 }
542 return path, "", func() {}, nil
543 }
544
545 func pluginRootFromClone(cloneRoot, repoPath string) (string, error) {
546 cloneRoot = filepath.Clean(cloneRoot)
547 if repoPath == "" {
548 return cloneRoot, nil
549 }
550 if strings.Contains(repoPath, "\\") {
551 return "", newErr(ErrUnsupportedKind, "plugin repository path %q is not a safe relative path", repoPath)
552 }
553 rel := filepath.FromSlash(repoPath)
554 if !filepath.IsLocal(rel) {
555 return "", newErr(ErrUnsupportedKind, "plugin repository path %q escapes the cloned repository", repoPath)
556 }
557 root := filepath.Join(cloneRoot, rel)
558 resolvedClone, err := filepath.EvalSymlinks(cloneRoot)
559 if err != nil {
560 return "", newErr(ErrSourceUnreadable, "cannot resolve cloned plugin repository: %v", err)
561 }
562 resolvedRoot, err := filepath.EvalSymlinks(root)
563 if err != nil {
564 return "", newErr(ErrSourceUnreadable, "plugin repository path %q is not readable: %v", repoPath, err)
565 }
566 within, err := filepath.Rel(resolvedClone, resolvedRoot)
567 if err != nil || !filepath.IsLocal(within) {
568 return "", newErr(ErrUnsupportedKind, "plugin repository path %q escapes the cloned repository", repoPath)
569 }
570 return resolvedRoot, nil
571 }
572
573 // verifyCopiedCapabilities re-parses the installed copy and requires its
574 // capability counts to match the source tree the plan described. Discovery
575 // follows symlinks but copy mode can only materialize links that stay inside
576 // the package, so an unmaterializable link would otherwise silently install
577 // fewer skills/commands than the approval covered.
578 func verifyCopiedCapabilities(src pluginpkg.Package, target string) error {
579 installed, _, err := pluginpkg.ParseDir(target)
580 if err != nil {
581 return newErr(ErrInvalidManifest, "installed plugin tree failed to re-parse: %v", err)
582 }
583 ss, sc, sh, sm := src.CapabilityCounts()
584 is, ic, ih, im := installed.CapabilityCounts()
585 sa, ia := src.AgentCount(), installed.AgentCount()
586 if ss != is || sc != ic || sh != ih || sm != im || sa != ia {
587 return newErr(ErrInvalidManifest,
588 "installed copy resolves to %d skills / %d agents / %d commands / %d hooks / %d MCP servers but the approved plan counted %d/%d/%d/%d/%d — the package likely uses symlinks copy mode cannot materialize safely; retry with mode=link or fix the package layout",
589 is, ia, ic, ih, im, ss, sa, sc, sh, sm)
590 }
591 return nil
592 }
593
594 // checkoutPluginCommit pins a fresh clone to the approved commit when its HEAD
595 // has moved past it. GitHub serves full-SHA fetches, so the approved snapshot
596 // stays reachable after ordinary pushes; a history rewrite that discarded it
597 // fails here — exactly the case where the user must re-review the plan.
598 func checkoutPluginCommit(ctx context.Context, cloneRoot, commit string) error {
599 fetch := pluginGitCommand(ctx, "-C", cloneRoot, "fetch", "--depth=1", "origin", commit)
600 if out, err := fetch.CombinedOutput(); err != nil {
601 return fmt.Errorf("fetch approved commit %s: %v: %s", commit, err, strings.TrimSpace(string(out)))
602 }
603 co := pluginGitCommand(ctx, "-C", cloneRoot, "checkout", "--detach", commit)
604 if out, err := co.CombinedOutput(); err != nil {
605 return fmt.Errorf("checkout approved commit %s: %v: %s", commit, err, strings.TrimSpace(string(out)))
606 }
607 return nil
608 }
609
610 func pluginGitCommand(ctx context.Context, args ...string) *exec.Cmd {
611 // Preserve repository bytes across platforms. A user's global autocrlf
612 // setting must not rewrite JSON/scripts on Windows after the user approved
613 // the exact source commit.
614 return gitcmd.CommandWithConfig(ctx, "", []string{"core.autocrlf=false"}, args...)
615 }
616
617 // installCopiedPlugin copies sourceRoot into a staging directory next to
618 // target, verifies the staged tree resolves to the capability set the plan
619 // approved, and only then swaps it into place with a backup-protected rename.
620 // Any failure before the swap — copy error, capability mismatch — leaves an
621 // existing installation completely intact, so a bad update can never destroy
622 // the working version it was meant to replace.
623 func installCopiedPlugin(pkg pluginpkg.Package, sourceRoot, target string, replace bool) error {
624 if _, err := os.Lstat(target); err == nil && !replace {
625 return newErr(ErrAlreadyExists, "plugin package already exists at %s; retry with replace=true to update it", target)
626 }
627 if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
628 return err
629 }
630 staging, err := os.MkdirTemp(filepath.Dir(target), "."+filepath.Base(target)+".staging-")
631 if err != nil {
632 return err
633 }
634 defer os.RemoveAll(staging)
635 if err := copyDir(sourceRoot, staging); err != nil {
636 return err
637 }
638 // Fail closed when the copied tree resolves to a different capability set
639 // than the plan the user approved — e.g. a symlink copyDir could not
640 // materialize safely. A silent gap here would install less than reviewed.
641 if err := verifyCopiedCapabilities(pkg, staging); err != nil {
642 return err
643 }
644 if err := os.Chmod(staging, 0o755); err != nil { // MkdirTemp creates 0700
645 return err
646 }
647 // Swap staged tree into place. The backup rename keeps the previous
648 // install restorable until the new tree has landed; both renames stay on
649 // one filesystem (same parent dir), so each is atomic. The backup name
650 // derives from the staging dir: dot-prefixed and randomized, it can never
651 // pass IsValidName, so it cannot collide with a sibling plugin's install
652 // dir (plugin names may legally contain dots, e.g. "foo.pre-replace") and
653 // needs no pre-cleanup that could delete such a neighbor.
654 backup := staging + ".old"
655 hadOld := false
656 if _, err := os.Lstat(target); err == nil {
657 hadOld = true
658 if err := os.Rename(target, backup); err != nil {
659 return err
660 }
661 }
662 if err := os.Rename(staging, target); err != nil {
663 if hadOld {
664 _ = os.Rename(backup, target) // restore the previous install
665 }
666 return err
667 }
668 if hadOld {
669 _ = os.RemoveAll(backup)
670 }
671 return nil
672 }
673
674 func replaceSymlink(target, sourceRoot string, replace bool) error {
675 if _, err := os.Lstat(target); err == nil {
676 if !replace {
677 return newErr(ErrAlreadyExists, "plugin package already exists at %s; retry with replace=true to update it", target)
678 }
679 if err := os.RemoveAll(target); err != nil {
680 return err
681 }
682 }
683 if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
684 return err
685 }
686 return os.Symlink(sourceRoot, target)
687 }
688
689 func (t *installSourceTool) applyRemovePluginPackage(_ request, act *action) error {
690 installed, ok, err := pluginpkg.Remove(t.reasonixHome, act.Name)
691 if err != nil || !ok {
692 return err
693 }
694 root := pluginpkg.ResolveRoot(t.reasonixHome, installed.Root)
695 if t.onDisconnect != nil {
696 if pkg, _, err := pluginpkg.ParseDir(root); err == nil {
697 names := make([]string, 0, len(pkg.Manifest.MCPServers))
698 for name := range pkg.Manifest.MCPServers {
699 names = append(names, name)
700 }
701 sort.Strings(names)
702 for _, name := range names {
703 t.onDisconnect(name)
704 }
705 }
706 }
707 pluginsDir := pluginpkg.PluginsDir(t.reasonixHome)
708 if rel, err := filepath.Rel(pluginsDir, root); err == nil && rel != "." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) && rel != ".." {
709 if err := os.RemoveAll(root); err != nil {
710 return err
711 }
712 }
713 return nil
714 }
715
715 lines GO