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