返回 DeepSeek-Reasonix
apply.go
1 package installsource
2
3 import (
4 "context"
5 "errors"
6 "fmt"
7 "os"
8 "path/filepath"
9 "reflect"
10 "slices"
11 "strings"
12
13 "reasonix/internal/config"
14 "reasonix/internal/skill"
15 )
16
17 // apply dispatches to the per-action implementation. Each branch is
18 // responsible for setting act.Status / act.Error / act.Next and for
19 // cleaning up any partial side effects it left behind.
20 func (t *installSourceTool) apply(ctx context.Context, req request, act *action) error {
21 switch act.Kind {
22 case "skill":
23 switch act.Action {
24 case "register_skill_root":
25 return t.applySkillRoot(req, act)
26 case "copy_skill":
27 return t.applyCopySkill(req, act)
28 case "link_skill":
29 return t.applyLinkSkill(req, act)
30 case "remove_skill":
31 return t.applyRemoveSkill(req, act)
32 case "remove_skill_root":
33 return t.applyRemoveSkillRoot(req, act)
34 default:
35 return fmt.Errorf("unknown skill action %q", act.Action)
36 }
37 case "mcp":
38 switch act.Action {
39 case "install_mcp_server":
40 return t.applyInstallMCP(ctx, req, act)
41 case "remove_mcp_server":
42 return t.applyRemoveMCP(req, act)
43 default:
44 return fmt.Errorf("unknown mcp action %q", act.Action)
45 }
46 case "plugin":
47 switch act.Action {
48 case "install_plugin_package":
49 return t.applyInstallPluginPackage(ctx, req, act)
50 case "remove_plugin_package":
51 return t.applyRemovePluginPackage(req, act)
52 default:
53 return fmt.Errorf("unknown plugin action %q", act.Action)
54 }
55 default:
56 return fmt.Errorf("unknown install action kind %q", act.Kind)
57 }
58 }
59
60 // applySkillRoot appends the path to the active config's [skills].paths and
61 // re-builds the Store to confirm the listed skills are discoverable.
62 func (t *installSourceTool) applySkillRoot(req request, act *action) error {
63 var cfg *config.Config
64 if err := config.EditConfigFile(act.ConfigPath, func(fresh *config.Config) error {
65 if err := fresh.AddSkillPath(act.Source); err != nil {
66 return err
67 }
68 cfg = fresh
69 return nil
70 }); err != nil {
71 return err
72 }
73 store := skill.New(skill.Options{HomeDir: t.home, ReasonixHomeDir: t.reasonixHome, ProjectRoot: t.root, CustomPaths: append(cfg.SkillCustomPaths(), act.Source)})
74 for _, name := range act.Skills {
75 sk, ok := store.Read(name)
76 if !ok {
77 return newErr(ErrSourceUnreadable, "skill %q was registered but is not discoverable", name)
78 }
79 act.Discoverable = true
80 if act.CanonicalPath == "" && sk.Path != "" {
81 act.CanonicalPath = sk.Path
82 }
83 if strings.TrimSpace(sk.Description) == "" {
84 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))
85 }
86 }
87 for _, listed := range store.List() {
88 if slices.Contains(act.Skills, listed.Name) {
89 act.Indexed = true
90 }
91 }
92 act.Target = act.Source
93 return nil
94 }
95
96 // applyCopySkill copies a single skill into the project/global skills dir.
97 // We refuse to overwrite any existing canonical directory or legacy flat file.
98 // copyDir uses O_EXCL so any race that slips through the Lstat check still
99 // loses atomically.
100 func (t *installSourceTool) applyCopySkill(req request, act *action) error {
101 canonical, err := t.skillCanonicalPath(act.skill.Name, act.Scope)
102 if err != nil {
103 return err
104 }
105 targetDir := filepath.Dir(canonical)
106 conflicts, err := t.skillConflictTargets(act.skill.Name, act.Scope)
107 if err != nil {
108 return err
109 }
110 for _, conflict := range conflicts {
111 if _, err := os.Lstat(conflict); err == nil {
112 return newErr(ErrAlreadyExists, "skill %q already exists at %s", act.skill.Name, conflict)
113 }
114 }
115 if act.skill.IsDir {
116 if err := copyDir(act.skill.SourcePath, targetDir); err != nil {
117 return err
118 }
119 } else {
120 if err := os.MkdirAll(targetDir, 0o755); err != nil {
121 return err
122 }
123 if err := writeNewFile(canonical, []byte(act.skill.Content)); err != nil {
124 return err
125 }
126 }
127 act.Target = canonical
128 act.CanonicalPath = canonical
129 return t.verifySkill(act.Scope, act.skill.Name, act)
130 }
131
132 // applyLinkSkill creates a symlink in the skills dir pointing at the source.
133 // Absolute sources outside the project or home root are blocked even when the
134 // plan was approved: a link-mode skill should not become a backdoor to arbitrary
135 // host files.
136 func (t *installSourceTool) applyLinkSkill(req request, act *action) error {
137 canonical, err := t.skillCanonicalPath(act.skill.Name, act.Scope)
138 if err != nil {
139 return err
140 }
141 target := canonical
142 if act.skill.IsDir {
143 target = filepath.Dir(canonical)
144 }
145 conflicts, err := t.skillConflictTargets(act.skill.Name, act.Scope)
146 if err != nil {
147 return err
148 }
149 for _, conflict := range conflicts {
150 if _, err := os.Lstat(conflict); err == nil {
151 return newErr(ErrAlreadyExists, "skill %q already exists at %s", act.skill.Name, conflict)
152 }
153 }
154 if !isLinkTargetSafe(act.skill.SourcePath, t.home, t.root) {
155 act.RiskLevel = RiskHigh
156 act.RiskReasons = append(act.RiskReasons, "link target is an absolute path outside the project or home root")
157 return newErr(ErrUnsafeLinkTarget, "skill %q source %s is outside %s and %s", act.skill.Name, act.skill.SourcePath, t.root, t.home)
158 }
159 if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
160 return err
161 }
162 if err := os.Symlink(act.skill.SourcePath, target); err != nil {
163 return err
164 }
165 act.Target = target
166 act.CanonicalPath = canonical
167 return t.verifySkill(act.Scope, act.skill.Name, act)
168 }
169
170 // isLinkTargetSafe reports whether a symlink source is allowed. The link
171 // target is safe when:
172 // - it is a relative path (we never follow the parent of a relative link),
173 // - or its absolute form is contained within the user's home or the
174 // project root.
175 //
176 // Absolute paths outside both scopes are rejected with ErrUnsafeLinkTarget
177 // so a SKILL.md that points at /etc/passwd does not silently succeed.
178 func isLinkTargetSafe(source, home, projectRoot string) bool {
179 if source == "" {
180 return false
181 }
182 if !filepath.IsAbs(source) {
183 return true
184 }
185 clean := filepath.Clean(source)
186 for _, root := range []string{home, projectRoot} {
187 if root == "" {
188 continue
189 }
190 base := filepath.Clean(root)
191 if clean == base {
192 return true
193 }
194 if strings.HasPrefix(clean, base+string(filepath.Separator)) {
195 return true
196 }
197 }
198 return false
199 }
200
201 // applyInstallMCP connects an MCP server and persists its config. The order
202 // is deliberate: connect first (so the user can use the tools immediately),
203 // then SaveTo (so a persistence failure is detectable). If SaveTo fails, we
204 // roll back the connection and any tools the caller already registered, so
205 // the live session is not out of sync with the on-disk config.
206 func (t *installSourceTool) applyInstallMCP(ctx context.Context, req request, act *action) error {
207 if act.entry.Name == "" {
208 return newErr(ErrInvalidManifest, "MCP action has no server entry")
209 }
210 cfg, err := config.LoadForEditReadOnlyStrict(act.ConfigPath)
211 if err != nil {
212 return err
213 }
214 var previous config.PluginEntry
215 hadPrevious := false
216 for _, existing := range cfg.Plugins {
217 if existing.Name == act.entry.Name {
218 previous = existing
219 if act.Scope == "project" {
220 previous.Source = config.MCPSourceProjectConfig
221 } else {
222 previous.Source = config.MCPSourceUserConfig
223 }
224 hadPrevious = true
225 break
226 }
227 }
228 if !req.Replace {
229 if hadPrevious {
230 return newErr(ErrAlreadyExists, "MCP server %q already exists in %s; retry with replace=true to update it", act.entry.Name, act.ConfigPath)
231 }
232 }
233
234 var connected bool
235 oldDisconnected := false
236 if req.Replace && hadPrevious && t.onDisconnect != nil {
237 oldDisconnected = t.onDisconnect(act.entry.Name)
238 }
239 if t.connectMCP != nil {
240 res, err := t.connectMCP(act.entry)
241 if err != nil {
242 if oldDisconnected {
243 if rbErr := t.restoreMCP(previous); rbErr != nil {
244 return fmt.Errorf("%w; reconnect previous server failed: %w", err, rbErr)
245 }
246 }
247 return err
248 }
249 act.ToolCount = res.ToolCount
250 connected = res.Disconnect != nil || res.ToolCount >= 0
251 // Stash the disconnect on the action so a later SaveTo failure can
252 // undo the connect.
253 act.disconnect = res.Disconnect
254 }
255 probe := config.Default()
256 if err := probe.UpsertPlugin(act.entry); err != nil {
257 if rbErr := t.rollbackMCPReplace(act, previous, oldDisconnected, connected); rbErr != nil {
258 return fmt.Errorf("%w; rollback failed: %w", err, rbErr)
259 }
260 return err
261 }
262 if err := config.EditConfigFile(act.ConfigPath, func(fresh *config.Config) error {
263 current, currentFound := pluginEntryNamed(fresh.Plugins, act.entry.Name, act.Scope)
264 switch {
265 case !req.Replace && currentFound:
266 return newErr(ErrAlreadyExists, "MCP server %q already exists in %s; retry with replace=true to update it", act.entry.Name, act.ConfigPath)
267 case req.Replace && currentFound != hadPrevious:
268 return fmt.Errorf("MCP server %q changed while it was connecting", act.entry.Name)
269 case req.Replace && currentFound && !reflect.DeepEqual(current, previous):
270 return fmt.Errorf("MCP server %q changed while it was connecting", act.entry.Name)
271 }
272 return fresh.UpsertPlugin(act.entry)
273 }); err != nil {
274 if rbErr := t.rollbackMCPReplace(act, previous, oldDisconnected, connected); rbErr != nil {
275 return fmt.Errorf("%w; rollback failed: %w", err, rbErr)
276 }
277 return err
278 }
279 return nil
280 }
281
282 func pluginEntryNamed(entries []config.PluginEntry, name, scope string) (config.PluginEntry, bool) {
283 for _, entry := range entries {
284 if entry.Name != name {
285 continue
286 }
287 if scope == "project" {
288 entry.Source = config.MCPSourceProjectConfig
289 } else {
290 entry.Source = config.MCPSourceUserConfig
291 }
292 return entry, true
293 }
294 return config.PluginEntry{}, false
295 }
296
297 func (t *installSourceTool) rollbackMCPReplace(act *action, previous config.PluginEntry, oldDisconnected, connected bool) error {
298 if connected && act.disconnect != nil {
299 act.disconnect()
300 act.disconnect = nil
301 }
302 if oldDisconnected {
303 return t.restoreMCP(previous)
304 }
305 return nil
306 }
307
308 func (t *installSourceTool) restoreMCP(previous config.PluginEntry) error {
309 if t.connectMCP == nil || previous.Name == "" {
310 return nil
311 }
312 _, err := t.connectMCP(previous)
313 return err
314 }
315
316 // applyRemoveSkill deletes a previously installed skill file or directory.
317 // We only touch the project/global skills dir directly; the .mcp.json /
318 // config file is not modified.
319 func (t *installSourceTool) applyRemoveSkill(_ request, act *action) error {
320 target := act.Target
321 if target == "" {
322 return newErr(ErrInvalidManifest, "remove_skill action is missing target")
323 }
324 if _, err := os.Lstat(target); err != nil {
325 if errors.Is(err, os.ErrNotExist) {
326 act.Target = ""
327 return nil
328 }
329 return err
330 }
331 if err := os.RemoveAll(target); err != nil {
332 return err
333 }
334 act.Target = ""
335 return nil
336 }
337
338 func (t *installSourceTool) applyRemoveSkillRoot(_ request, act *action) error {
339 target := act.Target
340 if target == "" {
341 return newErr(ErrInvalidManifest, "remove_skill_root action is missing target")
342 }
343 unlock, err := config.LockConfigFileEdits(act.ConfigPath)
344 if err != nil {
345 return err
346 }
347 defer unlock()
348 cfg, err := config.LoadForEditReadOnlyStrict(act.ConfigPath)
349 if err != nil {
350 return err
351 }
352 removed, err := cfg.RemoveSkillPath(target)
353 if err != nil {
354 return err
355 }
356 if !removed {
357 return nil
358 }
359 if err := cfg.SaveTo(act.ConfigPath); err != nil {
360 return err
361 }
362 return nil
363 }
364
365 // applyRemoveMCP removes an MCP server entry from the active config and
366 // asks the host to disconnect it (if a connector is wired).
367 func (t *installSourceTool) applyRemoveMCP(_ request, act *action) error {
368 unlock, err := config.LockConfigFileEdits(act.ConfigPath)
369 if err != nil {
370 return err
371 }
372 defer unlock()
373 cfg, err := config.LoadForEditReadOnlyStrict(act.ConfigPath)
374 if err != nil {
375 return err
376 }
377 if !cfg.RemovePlugin(act.Name) {
378 return nil
379 }
380 if err := cfg.SaveTo(act.ConfigPath); err != nil {
381 return err
382 }
383 if t.onDisconnect != nil {
384 t.onDisconnect(act.Name)
385 }
386 return nil
387 }
388
388 lines GO