| 1 | package installsource |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "path/filepath" |
| 6 | "strings" |
| 7 | "testing" |
| 8 | ) |
| 9 | |
| 10 | // writeRuntimePlugin writes a Manifest v1 plugin package with a runtime |
| 11 | // declaration plus prompt and theme contributions. |
| 12 | func writeRuntimePlugin(t *testing.T, root string) { |
| 13 | t.Helper() |
| 14 | writeFile(t, filepath.Join(root, "reasonix-plugin.json"), `{ |
| 15 | "apiVersion": "reasonix.io/plugin/v1", |
| 16 | "name": "rtsync", |
| 17 | "version": "1.0.0", |
| 18 | "contributes": { |
| 19 | "prompts": ["prompts"], |
| 20 | "themes": ["themes/*.reasonix-theme"] |
| 21 | }, |
| 22 | "runtime": { |
| 23 | "command": "${REASONIX_PLUGIN_ROOT}/bin/rtsync", |
| 24 | "args": ["--serve"], |
| 25 | "required": true, |
| 26 | "intercepts": ["input.receive", "tool.before"], |
| 27 | "replaces": ["system_prompt"], |
| 28 | "capabilities": ["interceptors", "ui"] |
| 29 | } |
| 30 | }`) |
| 31 | writeFile(t, filepath.Join(root, "prompts", "plan.md"), "---\ndescription: plan\n---\nPlan $ARGUMENTS") |
| 32 | writeFile(t, filepath.Join(root, "themes", "neon.reasonix-theme"), "theme bytes") |
| 33 | writeFile(t, filepath.Join(root, "bin", "rtsync"), "#!/bin/sh\n") |
| 34 | } |
| 35 | |
| 36 | func TestPluginRuntimePlanCarriesFullTrust(t *testing.T) { |
| 37 | src := t.TempDir() |
| 38 | writeRuntimePlugin(t, src) |
| 39 | |
| 40 | project := t.TempDir() |
| 41 | home := t.TempDir() |
| 42 | tl := NewTool(Options{ProjectRoot: project, HomeDir: home}) |
| 43 | |
| 44 | resp := execInstall(t, tl, map[string]any{ |
| 45 | "source": src, |
| 46 | "kind": "plugin", |
| 47 | "apply": false, |
| 48 | }) |
| 49 | if !resp.OK || len(resp.Actions) != 1 { |
| 50 | t.Fatalf("response = %+v", resp) |
| 51 | } |
| 52 | act := resp.Actions[0] |
| 53 | if act.RiskLevel != RiskHigh { |
| 54 | t.Fatalf("RiskLevel = %q, want high for a runtime package", act.RiskLevel) |
| 55 | } |
| 56 | fullTrust := false |
| 57 | for _, reason := range act.RiskReasons { |
| 58 | if strings.HasPrefix(reason, "FULL TRUST:") { |
| 59 | fullTrust = true |
| 60 | if !strings.Contains(reason, "${REASONIX_PLUGIN_ROOT}/bin/rtsync --serve") { |
| 61 | t.Fatalf("FULL TRUST reason should describe the runtime command line: %q", reason) |
| 62 | } |
| 63 | } |
| 64 | } |
| 65 | if !fullTrust { |
| 66 | t.Fatalf("RiskReasons = %v, want a FULL TRUST: entry", act.RiskReasons) |
| 67 | } |
| 68 | rt := act.Runtime |
| 69 | if rt == nil { |
| 70 | t.Fatal("action.Runtime is nil, want the runtime plan info") |
| 71 | } |
| 72 | if rt.Command != "${REASONIX_PLUGIN_ROOT}/bin/rtsync" || !rt.FullTrust { |
| 73 | t.Fatalf("Runtime = %+v", rt) |
| 74 | } |
| 75 | if len(rt.Args) != 1 || rt.Args[0] != "--serve" { |
| 76 | t.Fatalf("Runtime.Args = %v", rt.Args) |
| 77 | } |
| 78 | if len(rt.Intercepts) != 2 || len(rt.Replaces) != 1 || len(rt.Capabilities) != 2 { |
| 79 | t.Fatalf("Runtime = %+v", rt) |
| 80 | } |
| 81 | if act.PromptCount != 1 || act.ThemeCount != 1 { |
| 82 | t.Fatalf("PromptCount/ThemeCount = %d/%d, want 1/1", act.PromptCount, act.ThemeCount) |
| 83 | } |
| 84 | |
| 85 | // The plan JSON must carry the runtime block so frontends can render it. |
| 86 | raw, err := json.Marshal(act) |
| 87 | if err != nil { |
| 88 | t.Fatal(err) |
| 89 | } |
| 90 | var encoded map[string]any |
| 91 | if err := json.Unmarshal(raw, &encoded); err != nil { |
| 92 | t.Fatal(err) |
| 93 | } |
| 94 | rtJSON, ok := encoded["runtime"].(map[string]any) |
| 95 | if !ok { |
| 96 | t.Fatalf("plan JSON missing runtime block: %s", raw) |
| 97 | } |
| 98 | if rtJSON["fullTrust"] != true || rtJSON["command"] != "${REASONIX_PLUGIN_ROOT}/bin/rtsync" { |
| 99 | t.Fatalf("runtime JSON = %v", rtJSON) |
| 100 | } |
| 101 | } |
| 102 | |
| 103 | func TestPluginLegacyPlanOmitsRuntimeFields(t *testing.T) { |
| 104 | src := t.TempDir() |
| 105 | writeFile(t, filepath.Join(src, "reasonix-plugin.json"), `{"name": "legacy", "skills": ["skills"]}`) |
| 106 | writeFile(t, filepath.Join(src, "skills", "s", "SKILL.md"), "---\ndescription: s\n---\nS") |
| 107 | |
| 108 | project := t.TempDir() |
| 109 | home := t.TempDir() |
| 110 | tl := NewTool(Options{ProjectRoot: project, HomeDir: home}) |
| 111 | |
| 112 | resp := execInstall(t, tl, map[string]any{ |
| 113 | "source": src, |
| 114 | "kind": "plugin", |
| 115 | "apply": false, |
| 116 | }) |
| 117 | if !resp.OK || len(resp.Actions) != 1 { |
| 118 | t.Fatalf("response = %+v", resp) |
| 119 | } |
| 120 | act := resp.Actions[0] |
| 121 | if act.Runtime != nil { |
| 122 | t.Fatalf("legacy package gained a runtime: %+v", act.Runtime) |
| 123 | } |
| 124 | if act.RiskLevel != RiskMedium { |
| 125 | t.Fatalf("RiskLevel = %q, want medium for a plain legacy package", act.RiskLevel) |
| 126 | } |
| 127 | raw, err := json.Marshal(act) |
| 128 | if err != nil { |
| 129 | t.Fatal(err) |
| 130 | } |
| 131 | for _, key := range []string{`"runtime"`, `"promptCount"`, `"themeCount"`} { |
| 132 | if strings.Contains(string(raw), key) { |
| 133 | t.Fatalf("legacy plan JSON should omit %s: %s", key, raw) |
| 134 | } |
| 135 | } |
| 136 | } |
| 137 |