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