返回 DeepSeek-Reasonix
manifest_v1.go
根目录 / internal / pluginpkg / manifest_v1.go
1 package pluginpkg
2
3 import (
4 "bytes"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "os"
9 "path"
10 "path/filepath"
11 "reflect"
12 "regexp"
13 "sort"
14 "strconv"
15 "strings"
16 )
17
18 // ManifestAPIVersionV1 is the only plugin manifest apiVersion this Reasonix
19 // parses. A native manifest (reasonix-plugin.json) WITHOUT an apiVersion
20 // takes the legacy path and parses exactly as it always has; any manifest
21 // that declares one is routed here.
22 const ManifestAPIVersionV1 = "reasonix.io/plugin/v1"
23
24 // PluginRootEnvVar is the variable a v1 runtime command may use to address
25 // files inside its own installed package. It expands at launch time, never
26 // through a shell.
27 const PluginRootEnvVar = "${REASONIX_PLUGIN_ROOT}"
28
29 // apiVersionPattern matches reasonix.io/plugin/v<major>[.<minor>].
30 var apiVersionPattern = regexp.MustCompile(`^reasonix\.io/plugin/v([0-9]+)(?:\.([0-9]+))?$`)
31
32 // RuntimeSpec declares a plugin-owned runtime process (Manifest v1). The
33 // command is exec form only: Reasonix never runs it through a shell, so
34 // pipes, && and ; carry no special meaning. Command may start with
35 // ${REASONIX_PLUGIN_ROOT} to address a binary inside the installed package;
36 // the expansion happens at launch time (see ExpandRuntimeCommand for the
37 // diagnostics-time equivalent).
38 type RuntimeSpec struct {
39 Command string `json:"command"`
40 Args []string `json:"args,omitempty"`
41 Env map[string]string `json:"env,omitempty"`
42 Required bool `json:"required,omitempty"`
43 Priority int `json:"priority,omitempty"`
44 Intercepts []string `json:"intercepts,omitempty"`
45 Replaces []string `json:"replaces,omitempty"`
46 Capabilities []string `json:"capabilities,omitempty"`
47 // TimeoutMillis optionally tunes this runtime's synchronous intercept
48 // budget. Zero keeps the host's per-point defaults; the host clamps any
49 // value to its 60s ceiling at dispatch time.
50 TimeoutMillis int `json:"timeoutMillis,omitempty"`
51 }
52
53 // sniffManifestAPIVersion extracts just the apiVersion field so parseNative
54 // can route between the legacy parser (absent) and the v1 parser (present)
55 // without a full decode.
56 func sniffManifestAPIVersion(b []byte) (string, error) {
57 var sniff struct {
58 APIVersion json.RawMessage `json:"apiVersion"`
59 }
60 if err := json.Unmarshal(b, &sniff); err != nil {
61 return "", err
62 }
63 if len(sniff.APIVersion) == 0 || string(sniff.APIVersion) == "null" {
64 return "", nil
65 }
66 var v string
67 if err := json.Unmarshal(sniff.APIVersion, &v); err != nil {
68 return "", fmt.Errorf("%s: apiVersion must be a string", NativeManifest)
69 }
70 return strings.TrimSpace(v), nil
71 }
72
73 // checkAPIVersion gates the v1 parser. The exact v1 string parses; a known
74 // major with a minor (v1.1) also parses as v1 — strict field rejection fails
75 // loudly on anything the minor revision actually added. An unknown major is
76 // unsupported; anything not matching the version shape is malformed.
77 func checkAPIVersion(v string) error {
78 if v == ManifestAPIVersionV1 {
79 return nil
80 }
81 m := apiVersionPattern.FindStringSubmatch(v)
82 if m == nil {
83 return fmt.Errorf("%s: invalid apiVersion %q: want reasonix.io/plugin/v<major>[.<minor>] (this Reasonix supports %s)", NativeManifest, v, ManifestAPIVersionV1)
84 }
85 if major, _ := strconv.Atoi(m[1]); major != 1 {
86 return fmt.Errorf("%s: unsupported apiVersion %q (this Reasonix supports %s)", NativeManifest, v, ManifestAPIVersionV1)
87 }
88 return nil
89 }
90
91 // strictDecode decodes one manifest object with unknown-field rejection
92 // (json.Decoder.DisallowUnknownFields). path prefixes the error so a typo
93 // names where it happened — root keys report under the manifest name,
94 // nested keys under their container ("contributes", "runtime",
95 // "hooks.<event>[i]", "mcpServers.<name>").
96 func strictDecode(data []byte, v any, path string) error {
97 dec := json.NewDecoder(bytes.NewReader(data))
98 dec.DisallowUnknownFields()
99 if err := dec.Decode(v); err != nil {
100 return fmt.Errorf("%s: %w", path, err)
101 }
102 return nil
103 }
104
105 // v1Root is the strict-decoded shape of a Manifest v1 document. Nested
106 // objects stay raw at this level so each one can be decoded with its own
107 // field-path context.
108 type v1Root struct {
109 APIVersion string `json:"apiVersion"`
110 Name string `json:"name"`
111 Version string `json:"version"`
112 Description string `json:"description"`
113 Homepage string `json:"homepage"`
114 Repository string `json:"repository"`
115 Contributes json.RawMessage `json:"contributes"`
116 Runtime json.RawMessage `json:"runtime"`
117 // Legacy top-level fields, unioned with their contributes counterparts.
118 Skills json.RawMessage `json:"skills"`
119 Commands json.RawMessage `json:"commands"`
120 Hooks map[string][]json.RawMessage `json:"hooks"`
121 MCPServers map[string]json.RawMessage `json:"mcpServers"`
122 }
123
124 // v1Contributes is the strict-decoded contributes object. Agents, prompts,
125 // and themes exist ONLY here — the legacy top level has no such keys.
126 type v1Contributes struct {
127 Skills json.RawMessage `json:"skills"`
128 Agents json.RawMessage `json:"agents"`
129 Commands json.RawMessage `json:"commands"`
130 Prompts json.RawMessage `json:"prompts"`
131 Themes json.RawMessage `json:"themes"`
132 Hooks map[string][]json.RawMessage `json:"hooks"`
133 MCPServers map[string]json.RawMessage `json:"mcpServers"`
134 }
135
136 func parseNativeV1(b []byte, root, apiVersion string) (Package, []string, error) {
137 if err := checkAPIVersion(apiVersion); err != nil {
138 return Package{}, nil, err
139 }
140 var raw v1Root
141 if err := strictDecode(b, &raw, NativeManifest); err != nil {
142 return Package{}, nil, err
143 }
144 var contrib v1Contributes
145 if len(raw.Contributes) > 0 && string(raw.Contributes) != "null" {
146 if err := strictDecode(raw.Contributes, &contrib, "contributes"); err != nil {
147 return Package{}, nil, err
148 }
149 }
150
151 legacySkills, err := parseV1PathList(raw.Skills, "skills")
152 if err != nil {
153 return Package{}, nil, err
154 }
155 legacyCommands, err := parseV1PathList(raw.Commands, "commands")
156 if err != nil {
157 return Package{}, nil, err
158 }
159 contribSkills, err := parseV1PathList(contrib.Skills, "contributes.skills")
160 if err != nil {
161 return Package{}, nil, err
162 }
163 contribAgents, err := parseV1PathList(contrib.Agents, "contributes.agents")
164 if err != nil {
165 return Package{}, nil, err
166 }
167 contribCommands, err := parseV1PathList(contrib.Commands, "contributes.commands")
168 if err != nil {
169 return Package{}, nil, err
170 }
171 contribPrompts, err := parseV1PathList(contrib.Prompts, "contributes.prompts")
172 if err != nil {
173 return Package{}, nil, err
174 }
175 contribThemes, err := parseV1PathList(contrib.Themes, "contributes.themes")
176 if err != nil {
177 return Package{}, nil, err
178 }
179 legacyHooks, err := parseV1HookMap(raw.Hooks, "hooks")
180 if err != nil {
181 return Package{}, nil, err
182 }
183 contribHooks, err := parseV1HookMap(contrib.Hooks, "contributes.hooks")
184 if err != nil {
185 return Package{}, nil, err
186 }
187 legacyMCP, err := parseV1MCPServerMap(raw.MCPServers, "mcpServers")
188 if err != nil {
189 return Package{}, nil, err
190 }
191 contribMCP, err := parseV1MCPServerMap(contrib.MCPServers, "contributes.mcpServers")
192 if err != nil {
193 return Package{}, nil, err
194 }
195 hooks, err := mergeV1Hooks(legacyHooks, contribHooks)
196 if err != nil {
197 return Package{}, nil, err
198 }
199 mcpServers, err := mergeV1MCPServers(legacyMCP, contribMCP)
200 if err != nil {
201 return Package{}, nil, err
202 }
203 runtime, err := parseV1Runtime(raw.Runtime)
204 if err != nil {
205 return Package{}, nil, err
206 }
207
208 manifest := Manifest{
209 Name: strings.TrimSpace(raw.Name),
210 Version: strings.TrimSpace(raw.Version),
211 Description: strings.TrimSpace(raw.Description),
212 Homepage: strings.TrimSpace(raw.Homepage),
213 Repository: strings.TrimSpace(raw.Repository),
214 Skills: unionPathLists(legacySkills, contribSkills),
215 Commands: unionPathLists(legacyCommands, contribCommands),
216 Agents: contribAgents,
217 Prompts: contribPrompts,
218 Themes: contribThemes,
219 Hooks: hooks,
220 MCPServers: mcpServers,
221 Runtime: runtime,
222 }
223 if err := validateManifest(root, &manifest); err != nil {
224 return Package{}, nil, err
225 }
226 warnings, issues := applyClaudeCompatibility(root, &manifest)
227 if err := validateManifest(root, &manifest); err != nil {
228 return Package{}, warnings, err
229 }
230 v1Warnings, err := validateV1Paths(root, &manifest)
231 warnings = append(warnings, v1Warnings...)
232 if err != nil {
233 return Package{}, warnings, err
234 }
235 pkg := Package{Root: root, ManifestKind: "reasonix", Manifest: manifest}
236 pkg.Compatibility = compatibilityFor(pkg, issues)
237 return pkg, warnings, nil
238 }
239
240 // parseV1PathList parses a v1 path list. The flexible string | []string |
241 // [{path}] forms are kept from the legacy parser, but unknown keys inside
242 // the object form are rejected — a typo like {"paht": "skills"} must fail,
243 // not silently contribute nothing.
244 func parseV1PathList(raw json.RawMessage, path string) ([]string, error) {
245 if len(raw) == 0 || string(raw) == "null" {
246 return nil, nil
247 }
248 var one string
249 if err := json.Unmarshal(raw, &one); err == nil {
250 return cleanPathList([]string{one})
251 }
252 var manyStrings []string
253 if err := json.Unmarshal(raw, &manyStrings); err == nil {
254 return cleanPathList(manyStrings)
255 }
256 var items []json.RawMessage
257 if err := json.Unmarshal(raw, &items); err == nil {
258 paths := make([]string, 0, len(items))
259 for i, item := range items {
260 var obj struct {
261 Path string `json:"path"`
262 }
263 if err := strictDecode(item, &obj, fmt.Sprintf("%s[%d]", path, i)); err != nil {
264 return nil, err
265 }
266 paths = append(paths, obj.Path)
267 }
268 return cleanPathList(paths)
269 }
270 return nil, fmt.Errorf("%s must be a path string, string array, or object array", path)
271 }
272
273 // parseV1HookMap strict-decodes a hooks map. Each entry is decoded
274 // individually so an unknown key reports its full event/index path.
275 func parseV1HookMap(raw map[string][]json.RawMessage, path string) (map[string][]Hook, error) {
276 if len(raw) == 0 {
277 return nil, nil
278 }
279 out := make(map[string][]Hook, len(raw))
280 for _, event := range sortedKeys(raw) {
281 entries := raw[event]
282 hooks := make([]Hook, 0, len(entries))
283 for i, entry := range entries {
284 h, err := parseV1Hook(entry, fmt.Sprintf("%s.%s[%d]", path, event, i))
285 if err != nil {
286 return nil, err
287 }
288 hooks = append(hooks, h)
289 }
290 out[event] = hooks
291 }
292 return out, nil
293 }
294
295 // parseV1Hook strict-decodes one hook entry, preserving the args presence
296 // bit exactly like Hook.UnmarshalJSON (exec form vs shell form depends on
297 // it). Decoding goes through a method-free alias so the lenient legacy
298 // unmarshaler cannot weaken v1 strictness.
299 func parseV1Hook(data json.RawMessage, path string) (Hook, error) {
300 type hookJSON Hook
301 var decoded hookJSON
302 if err := strictDecode(data, &decoded, path); err != nil {
303 return Hook{}, err
304 }
305 h := Hook(decoded)
306 var fields map[string]json.RawMessage
307 if err := json.Unmarshal(data, &fields); err != nil {
308 return Hook{}, err
309 }
310 for name := range fields {
311 if strings.EqualFold(name, "args") {
312 h.ArgsSet = true
313 break
314 }
315 }
316 return h, nil
317 }
318
319 func parseV1MCPServerMap(raw map[string]json.RawMessage, path string) (map[string]MCPServer, error) {
320 if len(raw) == 0 {
321 return nil, nil
322 }
323 out := make(map[string]MCPServer, len(raw))
324 for _, name := range sortedKeys(raw) {
325 type mcpJSON MCPServer
326 var decoded mcpJSON
327 if err := strictDecode(raw[name], &decoded, fmt.Sprintf("%s.%s", path, name)); err != nil {
328 return nil, err
329 }
330 out[name] = MCPServer(decoded)
331 }
332 return out, nil
333 }
334
335 func sortedKeys[V any](m map[string]V) []string {
336 keys := make([]string, 0, len(m))
337 for k := range m {
338 keys = append(keys, k)
339 }
340 sort.Strings(keys)
341 return keys
342 }
343
344 // unionPathLists merges a legacy top-level path list with its contributes
345 // counterpart. Both inputs are already cleaned (slash-normalized, deduped,
346 // sorted); identical paths dedupe across the two and the result stays
347 // sorted. A path listed under both contributes.prompts and
348 // contributes.commands is NOT deduped across those sets — prompts and
349 // commands are separate semantic sets, and the path intentionally joins both.
350 func unionPathLists(legacy, contrib []string) []string {
351 if len(legacy) == 0 {
352 return contrib
353 }
354 if len(contrib) == 0 {
355 return legacy
356 }
357 seen := make(map[string]bool, len(legacy)+len(contrib))
358 out := make([]string, 0, len(legacy)+len(contrib))
359 for _, list := range [][]string{legacy, contrib} {
360 for _, p := range list {
361 if !seen[p] {
362 seen[p] = true
363 out = append(out, p)
364 }
365 }
366 }
367 sort.Strings(out)
368 return out
369 }
370
371 // hookIdentity is the merge key for a hook entry: the event (applied by the
372 // caller) plus what the entry runs. Two entries with the same identity but
373 // different remaining fields are a conflict, not two hooks.
374 func hookIdentity(h Hook) string {
375 return h.Command + "\x00" + h.ContextFile
376 }
377
378 // mergeV1Hooks unions legacy top-level hooks with contributes.hooks. Both
379 // sides are normalized first (trimmed, shell inferred, empty entries
380 // dropped); entries are keyed by event plus executable identity. The same
381 // key with a different definition is a manifest error naming the key;
382 // byte-identical entries dedupe.
383 func mergeV1Hooks(legacy, contrib map[string][]Hook) (map[string][]Hook, error) {
384 legacy = normalizeHooks(legacy)
385 contrib = normalizeHooks(contrib)
386 if len(legacy) == 0 {
387 return contrib, nil
388 }
389 if len(contrib) == 0 {
390 return legacy, nil
391 }
392 out := make(map[string][]Hook, len(legacy))
393 for event, hooks := range legacy {
394 out[event] = append([]Hook(nil), hooks...)
395 }
396 for _, event := range sortedKeys(contrib) {
397 for _, h := range contrib[event] {
398 duplicate := false
399 for _, existing := range out[event] {
400 if hookIdentity(existing) != hookIdentity(h) {
401 continue
402 }
403 if reflect.DeepEqual(existing, h) {
404 duplicate = true
405 break
406 }
407 return nil, fmt.Errorf("hook %q (event %s) is defined differently in hooks and contributes.hooks", firstNonEmpty(h.Command, h.ContextFile), event)
408 }
409 if !duplicate {
410 out[event] = append(out[event], h)
411 }
412 }
413 }
414 return out, nil
415 }
416
417 // mergeV1MCPServers unions legacy top-level mcpServers with
418 // contributes.mcpServers, keyed by server name. The same name with a
419 // different definition is a manifest error naming the server; identical
420 // definitions dedupe.
421 func mergeV1MCPServers(legacy, contrib map[string]MCPServer) (map[string]MCPServer, error) {
422 if len(legacy) == 0 {
423 return contrib, nil
424 }
425 if len(contrib) == 0 {
426 return legacy, nil
427 }
428 out := make(map[string]MCPServer, len(legacy))
429 for name, server := range legacy {
430 out[name] = server
431 }
432 for _, name := range sortedKeys(contrib) {
433 server := contrib[name]
434 if existing, ok := out[name]; ok {
435 if reflect.DeepEqual(existing, server) {
436 continue
437 }
438 return nil, fmt.Errorf("MCP server %q is defined differently in mcpServers and contributes.mcpServers", name)
439 }
440 out[name] = server
441 }
442 return out, nil
443 }
444
445 // The interceptor points, replacement slots, and priority bounds below
446 // duplicate internal/extension (intercept.go, replace.go). They are NOT
447 // imported: extension depends on pluginpkg transitively
448 // (extension -> hook -> pluginpkg), so pluginpkg importing extension would
449 // create an import cycle. Keep these lists in sync with extension — the
450 // adapter tests in the extension package pin them together by parsing a
451 // manifest that exercises every value.
452
453 const (
454 minRuntimePriority = -1000 // mirrors extension.MinInterceptorPriority
455 maxRuntimePriority = 1000 // mirrors extension.MaxInterceptorPriority
456 )
457
458 var runtimeInterceptorPoints = map[string]bool{
459 "session.start": true,
460 "session.end": true,
461 "session.load": true,
462 "session.save": true,
463 "session.rotate": true,
464 "input.receive": true,
465 "agent.before_start": true,
466 "system_prompt.build": true,
467 "context.prepare": true,
468 "provider.request": true,
469 "provider.response": true,
470 "tool.before": true,
471 "tool.after": true,
472 "permission.decision": true,
473 "compaction.prepare": true,
474 "compaction.complete": true,
475 "frontend.event": true,
476 }
477
478 var runtimeNamedSlots = map[string]bool{
479 "system_prompt": true,
480 "context": true,
481 "provider_request": true,
482 "provider_response": true,
483 "compaction": true,
484 "session_policy": true,
485 "permission": true,
486 "frontend_events": true,
487 }
488
489 var runtimeCapabilities = []string{"interceptors", "strategies", "providers", "ui"}
490
491 // validateRuntimeSlot mirrors extension.ParseSlot: bare names must be
492 // declared slots; tool:/provider: forms must carry a well-formed target.
493 // Provider targets are <name>/<model>, or plugin/<pluginID>/<name>/<model>
494 // for extension-hosted providers (stage 7).
495 func validateRuntimeSlot(s string) error {
496 if runtimeNamedSlots[s] {
497 return nil
498 }
499 if rest, ok := strings.CutPrefix(s, "tool:"); ok {
500 if rest == "" || strings.ContainsAny(rest, " \t\n") {
501 return fmt.Errorf("runtime.replaces: invalid tool slot %q: empty or whitespace tool name", s)
502 }
503 return nil
504 }
505 if rest, ok := strings.CutPrefix(s, "provider:"); ok {
506 if !validRuntimeProviderRef(rest) {
507 return fmt.Errorf("runtime.replaces: invalid provider slot %q: want provider:<name>/<model> or provider:plugin/<plugin>/<name>/<model>", s)
508 }
509 return nil
510 }
511 return fmt.Errorf("runtime.replaces: unknown slot %q", s)
512 }
513
514 // validRuntimeProviderRef mirrors the kernel's provider-slot target rule
515 // (extension.validProviderSlotTarget): an ordinary <name>/<model> ref, or an
516 // extension-hosted plugin/<pluginID>/<name>/<model> ref.
517 func validRuntimeProviderRef(ref string) bool {
518 name, model, found := strings.Cut(ref, "/")
519 if found && name != "" && model != "" && !strings.Contains(model, "/") {
520 return true
521 }
522 rest, ok := strings.CutPrefix(ref, "plugin/")
523 if !ok {
524 return false
525 }
526 pluginID, nameModel, ok := strings.Cut(rest, "/")
527 if !ok || pluginID == "" || strings.ContainsAny(pluginID, " \t\n") {
528 return false
529 }
530 name, model, found = strings.Cut(nameModel, "/")
531 return found && name != "" && model != "" && !strings.Contains(model, "/")
532 }
533
534 // parseV1Runtime strict-decodes and validates the runtime block. Every
535 // validation error names the offending value so a manifest author can find
536 // it without a second lookup.
537 func parseV1Runtime(raw json.RawMessage) (*RuntimeSpec, error) {
538 if len(raw) == 0 || string(raw) == "null" {
539 return nil, nil
540 }
541 var rt RuntimeSpec
542 if err := strictDecode(raw, &rt, "runtime"); err != nil {
543 return nil, err
544 }
545 rt.Command = strings.TrimSpace(rt.Command)
546 if rt.Command == "" {
547 return nil, errors.New("runtime.command is required when a runtime is declared")
548 }
549 for i, arg := range rt.Args {
550 if strings.TrimSpace(arg) == "" {
551 return nil, fmt.Errorf("runtime.args[%d] must not be empty", i)
552 }
553 }
554 for key := range rt.Env {
555 if strings.TrimSpace(key) == "" {
556 return nil, errors.New("runtime.env contains an empty key")
557 }
558 }
559 if rt.Priority < minRuntimePriority || rt.Priority > maxRuntimePriority {
560 return nil, fmt.Errorf("runtime.priority %d out of range [%d, %d]", rt.Priority, minRuntimePriority, maxRuntimePriority)
561 }
562 if rt.TimeoutMillis < 0 {
563 return nil, fmt.Errorf("runtime.timeoutMillis %d must not be negative", rt.TimeoutMillis)
564 }
565 for _, point := range rt.Intercepts {
566 if !runtimeInterceptorPoints[point] {
567 return nil, fmt.Errorf("runtime.intercepts: unknown interceptor point %q", point)
568 }
569 }
570 for _, slot := range rt.Replaces {
571 if err := validateRuntimeSlot(slot); err != nil {
572 return nil, err
573 }
574 }
575 for _, capability := range rt.Capabilities {
576 known := false
577 for _, k := range runtimeCapabilities {
578 if capability == k {
579 known = true
580 break
581 }
582 }
583 if !known {
584 return nil, fmt.Errorf("runtime.capabilities: unknown capability %q (want one of: %s)", capability, strings.Join(runtimeCapabilities, ", "))
585 }
586 }
587 return &rt, nil
588 }
589
590 // validateV1Paths enforces the v1 on-disk path contract — stronger than the
591 // legacy lexical checks. Every contributed path that EXISTS must resolve
592 // inside the plugin root, so a symlink cannot smuggle outside content into
593 // the session; theme paths must be regular files. Missing paths (and
594 // theme globs that match nothing) are warnings, not parse failures: an
595 // optional asset must not disable the whole package, but doctor reports it.
596 func validateV1Paths(root string, m *Manifest) ([]string, error) {
597 var warnings []string
598 resolvedRoot, err := filepath.EvalSymlinks(root)
599 if err != nil {
600 resolvedRoot = filepath.Clean(root)
601 }
602 checkResidency := func(kind, rel string) error {
603 abs := filepath.Join(root, filepath.FromSlash(rel))
604 if _, err := os.Lstat(abs); err != nil {
605 if errors.Is(err, os.ErrNotExist) {
606 warnings = append(warnings, fmt.Sprintf("%s path %q does not exist", kind, rel))
607 } else {
608 warnings = append(warnings, fmt.Sprintf("%s path %q is not readable: %v", kind, rel, err))
609 }
610 return nil
611 }
612 resolved, err := filepath.EvalSymlinks(abs)
613 if err != nil {
614 warnings = append(warnings, fmt.Sprintf("%s path %q cannot be resolved: %v", kind, rel, err))
615 return nil
616 }
617 if !pathWithinRoot(resolvedRoot, resolved) {
618 return fmt.Errorf("%s path %q escapes the plugin root through a symlink", kind, rel)
619 }
620 return nil
621 }
622 for _, rel := range m.Skills {
623 if err := checkResidency("skills", rel); err != nil {
624 return warnings, err
625 }
626 }
627 for _, rel := range m.Agents {
628 if err := checkResidency("agents", rel); err != nil {
629 return warnings, err
630 }
631 }
632 for _, rel := range m.Commands {
633 if err := checkResidency("commands", rel); err != nil {
634 return warnings, err
635 }
636 }
637 for _, rel := range m.Prompts {
638 if err := checkResidency("prompts", rel); err != nil {
639 return warnings, err
640 }
641 }
642 for _, pattern := range m.Themes {
643 if !hasGlobMeta(pattern) {
644 abs := filepath.Join(root, filepath.FromSlash(pattern))
645 if _, err := os.Lstat(abs); err != nil {
646 if errors.Is(err, os.ErrNotExist) {
647 warnings = append(warnings, fmt.Sprintf("themes path %q does not exist", pattern))
648 } else {
649 warnings = append(warnings, fmt.Sprintf("themes path %q is not readable: %v", pattern, err))
650 }
651 continue
652 }
653 if err := checkThemeFile(resolvedRoot, abs, pattern); err != nil {
654 return warnings, err
655 }
656 continue
657 }
658 matches, err := globThemePattern(root, pattern)
659 if err != nil {
660 return warnings, err
661 }
662 if len(matches) == 0 {
663 warnings = append(warnings, fmt.Sprintf("theme glob %q matched no files", pattern))
664 continue
665 }
666 for _, match := range matches {
667 if err := checkThemeFile(resolvedRoot, match, pattern); err != nil {
668 return warnings, err
669 }
670 }
671 }
672 return warnings, nil
673 }
674
675 func checkThemeFile(resolvedRoot, abs, pattern string) error {
676 resolved, err := filepath.EvalSymlinks(abs)
677 if err != nil {
678 return fmt.Errorf("theme %q cannot be resolved: %v", pattern, err)
679 }
680 if !pathWithinRoot(resolvedRoot, resolved) {
681 return fmt.Errorf("theme %q escapes the plugin root through a symlink", pattern)
682 }
683 info, err := os.Stat(abs)
684 if err != nil {
685 return fmt.Errorf("theme %q is not readable: %v", pattern, err)
686 }
687 if !info.Mode().IsRegular() {
688 return fmt.Errorf("theme %q is not a regular file", pattern)
689 }
690 return nil
691 }
692
693 func pathWithinRoot(root, p string) bool {
694 rel, err := filepath.Rel(root, p)
695 if err != nil {
696 return false
697 }
698 return rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)))
699 }
700
701 func hasGlobMeta(p string) bool { return strings.ContainsAny(p, "*?[") }
702
703 // globThemePattern expands a theme glob one path segment at a time, so the
704 // plugin root itself is never interpreted as pattern syntax. Each segment
705 // supports path.Match wildcards and never crosses directory boundaries.
706 func globThemePattern(root, pattern string) ([]string, error) {
707 segs := strings.Split(pattern, "/")
708 for _, seg := range segs {
709 if hasGlobMeta(seg) {
710 if _, err := path.Match(seg, ""); err != nil {
711 return nil, fmt.Errorf("invalid theme glob %q: %v", pattern, err)
712 }
713 }
714 }
715 matches := []string{filepath.Clean(root)}
716 for _, seg := range segs {
717 var next []string
718 if !hasGlobMeta(seg) {
719 for _, base := range matches {
720 next = append(next, filepath.Join(base, seg))
721 }
722 } else {
723 for _, base := range matches {
724 entries, err := os.ReadDir(base)
725 if err != nil {
726 continue
727 }
728 for _, entry := range entries {
729 if ok, _ := path.Match(seg, entry.Name()); ok {
730 next = append(next, filepath.Join(base, entry.Name()))
731 }
732 }
733 }
734 }
735 matches = next
736 }
737 sort.Strings(matches)
738 return matches, nil
739 }
740
741 // ExpandRuntimeCommand substitutes the ${REASONIX_PLUGIN_ROOT} prefix with
742 // the package root. Launch-time expansion lives with the runtime supervisor
743 // (a later stage); this exists so diagnostics can resolve the on-disk path.
744 func ExpandRuntimeCommand(command, root string) string {
745 if rest, ok := strings.CutPrefix(command, PluginRootEnvVar); ok {
746 return filepath.Join(root, filepath.FromSlash(rest))
747 }
748 return command
749 }
750
750 lines GO