Skip to content

Commit 4e2513f

Browse files
committed
skillinject: multi-plugin support per tool
ManifestTool gains a Plugins []ManifestPlugin slot. The existing single Plugin field is preserved for backwards compatibility (any external manifest still referencing it parses unchanged). The reconcile loop iterates both Plugin (if set) and every entry in Plugins, so a single harness can install N plugins from one manifest entry — needed for openclaw which will carry both the existing prompt-injector and the new webhook-receiver. Tests pin both shapes: the new plugins[] array round-trips with allowList nested correctly, and a legacy single plugin entry still unmarshals without the Plugins slot being populated.
1 parent f7e83ff commit 4e2513f

3 files changed

Lines changed: 128 additions & 1 deletion

File tree

internal/skillinject/manifest.go

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,15 @@ type ManifestTool struct {
6262
HeartbeatTemplate string `json:"heartbeatTemplate,omitempty"`
6363
SkillNaming string `json:"skillNaming,omitempty"` // "" = "directory" (default), "flat" = single-file
6464
SelfHeartbeat bool `json:"selfHeartbeat,omitempty"`
65-
Plugin *ManifestPlugin `json:"plugin,omitempty"`
65+
// Plugin is the single-plugin slot. Kept for backwards compat with
66+
// pre-multi-plugin manifests. Prefer Plugins for new entries.
67+
Plugin *ManifestPlugin `json:"plugin,omitempty"`
68+
// Plugins is the multi-plugin slot — one tool can install N plugins
69+
// (e.g. openclaw gets both pilotprotocol-prompt-injector and
70+
// pilotprotocol-webhook-receiver). Each entry reconciles identically
71+
// to a single Plugin block. If both Plugin and Plugins are set, the
72+
// daemon reconciles Plugin first then iterates Plugins.
73+
Plugins []ManifestPlugin `json:"plugins,omitempty"`
6674
}
6775

6876
// ManifestPlugin describes a per-tool plugin that the daemon writes

internal/skillinject/skillinject.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,18 @@ func Tick(ctx context.Context, cfg Config) (*Report, error) {
225225
reconcilePluginAllowList(mt.Plugin, home))
226226
}
227227
}
228+
// Multi-plugin slot — same reconcile shape, repeated per entry.
229+
// A tool can declare both `plugin` (legacy single) and `plugins`
230+
// (the array); the daemon installs all of them.
231+
for i := range mt.Plugins {
232+
p := &mt.Plugins[i]
233+
report.Outcomes = append(report.Outcomes,
234+
reconcilePluginFiles(f, ctx, p, home)...)
235+
if p.AllowList != nil {
236+
report.Outcomes = append(report.Outcomes,
237+
reconcilePluginAllowList(p, home))
238+
}
239+
}
228240
}
229241

230242
return report, nil
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+
package skillinject
4+
5+
import (
6+
"encoding/json"
7+
"testing"
8+
)
9+
10+
// TestManifest_MultiPluginUnmarshal pins the schema extension: a tool
11+
// entry can declare an array of plugins under `plugins`, parallel to
12+
// the legacy single-plugin `plugin` slot. Older manifests using
13+
// only `plugin` must continue to parse unchanged.
14+
func TestManifest_MultiPluginUnmarshal(t *testing.T) {
15+
t.Parallel()
16+
17+
const body = `{
18+
"version": 1,
19+
"entrypoint": "pilotctl",
20+
"tools": [
21+
{
22+
"name": "openclaw",
23+
"rootDir": "~/.openclaw",
24+
"skillsDir": "~/.openclaw/skills",
25+
"plugins": [
26+
{
27+
"id": "pilotprotocol-webhook-receiver",
28+
"installPath": "~/.openclaw/extensions/pilotprotocol-webhook-receiver",
29+
"files": [
30+
{"name": "openclaw.plugin.json", "src": "wf/openclaw/webhook/openclaw.plugin.json"},
31+
{"name": "index.mjs", "src": "wf/openclaw/webhook/index.mjs"}
32+
],
33+
"allowList": {
34+
"configPath": "~/.openclaw/openclaw.json",
35+
"allowListJsonPath": "plugins.allow",
36+
"entriesJsonPath": "plugins.entries"
37+
}
38+
}
39+
]
40+
}
41+
]
42+
}`
43+
44+
var m Manifest
45+
if err := json.Unmarshal([]byte(body), &m); err != nil {
46+
t.Fatalf("unmarshal: %v", err)
47+
}
48+
if len(m.Tools) != 1 {
49+
t.Fatalf("Tools len=%d want=1", len(m.Tools))
50+
}
51+
tool := m.Tools[0]
52+
if tool.Plugin != nil {
53+
t.Fatalf("Plugin (legacy slot) should be nil when only `plugins` is set, got %+v", tool.Plugin)
54+
}
55+
if len(tool.Plugins) != 1 {
56+
t.Fatalf("Plugins len=%d want=1", len(tool.Plugins))
57+
}
58+
p := tool.Plugins[0]
59+
if p.ID != "pilotprotocol-webhook-receiver" {
60+
t.Fatalf("plugin id=%q want=%q", p.ID, "pilotprotocol-webhook-receiver")
61+
}
62+
if len(p.Files) != 2 {
63+
t.Fatalf("plugin Files len=%d want=2", len(p.Files))
64+
}
65+
if p.AllowList == nil || p.AllowList.AllowListJsonPath != "plugins.allow" {
66+
t.Fatalf("AllowList not parsed: %+v", p.AllowList)
67+
}
68+
}
69+
70+
// TestManifest_LegacySinglePluginStillParses guards backwards-compat
71+
// for the existing `plugin` (singular) slot, which currently appears
72+
// nowhere in the canonical manifest but is documented as the v1
73+
// pre-multi schema. Removing it would silently break any external
74+
// fork that depended on the older shape.
75+
func TestManifest_LegacySinglePluginStillParses(t *testing.T) {
76+
t.Parallel()
77+
const body = `{
78+
"version": 1,
79+
"entrypoint": "pilotctl",
80+
"tools": [
81+
{
82+
"name": "openclaw",
83+
"rootDir": "~/.openclaw",
84+
"skillsDir": "~/.openclaw/skills",
85+
"plugin": {
86+
"id": "legacy-singleton",
87+
"installPath": "~/.openclaw/extensions/legacy-singleton",
88+
"files": [{"name": "index.mjs", "src": "wf/legacy.mjs"}]
89+
}
90+
}
91+
]
92+
}`
93+
var m Manifest
94+
if err := json.Unmarshal([]byte(body), &m); err != nil {
95+
t.Fatal(err)
96+
}
97+
tool := m.Tools[0]
98+
if tool.Plugin == nil {
99+
t.Fatal("legacy `plugin` slot must still parse")
100+
}
101+
if tool.Plugin.ID != "legacy-singleton" {
102+
t.Fatalf("legacy plugin id=%q", tool.Plugin.ID)
103+
}
104+
if len(tool.Plugins) != 0 {
105+
t.Fatalf("new Plugins[] should be empty when only legacy slot is used, got %d", len(tool.Plugins))
106+
}
107+
}

0 commit comments

Comments
 (0)