Skip to content

Commit b6e648e

Browse files
committed
feat(skillinject): manage per-tool plugins on the 15-min reconcile
Adds two new managed surfaces alongside the existing skill-copy + heartbeat-marker rows: plugin-file (source code on disk) and plugin-allowlist (JSON-merge into the tool's plugin config so the plugin id is trusted + enabled). Today only openclaw exercises this — the manifest will ship a `plugin: { id, installPath, files[], allowList }` block under the openclaw tool entry. The plugin itself is a ~80-line ESM module registering openclaw's `before_prompt_build` hook, returning `prependSystemContext` with the pilot directive on every turn. That hook is the only per-prompt injection surface in openclaw (SKILL.md loads on tool startup, HEARTBEAT.md loads only on the heartbeat lifecycle — neither fires per-turn). Reconcile semantics mirror the existing skill+marker pattern: - classifyPluginFile: SHA-256 vs canonical → Identical / Drifted / Absent. Same noop / create / rewrite vocabulary. - classifyPluginAllowList: walks plugins.allow + plugins.entries.<id> dotted JSON paths and flags drift unless both contain our id and enabled=true. Missing config file is treated as Drifted (will be created on next write) rather than Absent (which would skip). - mergePluginAllowList: read-parse-merge-write via .tmp + rename. Refuses to overwrite a malformed config (user mid-edit). Idempotent by design — running it twice produces byte-identical output. State surfaces via `pilotctl skills` with two new row labels: "plugin file:" and "plugin allow-list:". Width widened from 17→19 to accommodate the longest label. Tests (10 new in zz_plugin_allowlist_test.go): - 4 classify states (missing config, both present, id missing from allow, entry disabled) - 5 merge properties (creates file, preserves other keys, idempotent, refuses malformed config, creates nested paths via walkObject) - 1 read-only walk bail-out No production-side wiring of an actual plugin yet — that ships alongside the inject-manifest.json update in the pilot-skills repo. This change adds the daemon-side capability and exits without effect until the manifest declares a plugin block. Older daemons silently ignore the new field (encoding/json unknown-field default).
1 parent 2785246 commit b6e648e

6 files changed

Lines changed: 680 additions & 16 deletions

File tree

cmd/pilotctl/skills.go

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -112,14 +112,21 @@ func cmdSkillsStatus(_ []string) {
112112
for _, tool := range tools {
113113
fmt.Printf("[%s]\n", tool)
114114
for _, o := range byTool[tool] {
115-
label := "skill copy: "
116-
if o.Kind == skillinject.KindMarker {
117-
label = "heartbeat ref: "
115+
label := "skill copy: "
116+
switch o.Kind {
117+
case skillinject.KindMarker:
118+
label = "heartbeat ref: "
119+
case skillinject.KindHelper:
120+
label = "helper: "
121+
case skillinject.KindPluginFile:
122+
label = "plugin file: "
123+
case skillinject.KindPluginAllowList:
124+
label = "plugin allow-list: "
118125
}
119126
fmt.Printf(" %s%s\n", label, o.Path)
120-
fmt.Printf(" state=%s next_action=%s\n", o.State, o.Action)
127+
fmt.Printf(" state=%s next_action=%s\n", o.State, o.Action)
121128
if o.Err != "" {
122-
fmt.Printf(" ERROR: %s\n", o.Err)
129+
fmt.Printf(" ERROR: %s\n", o.Err)
123130
}
124131
}
125132
fmt.Println()

internal/skillinject/manifest.go

Lines changed: 61 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -55,13 +55,67 @@ type ManifestHelper struct {
5555

5656
// ManifestTool is one tool target row.
5757
type ManifestTool struct {
58-
Name string `json:"name"`
59-
RootDir string `json:"rootDir"`
60-
SkillsDir string `json:"skillsDir"`
61-
HeartbeatPath string `json:"heartbeatPath,omitempty"`
62-
HeartbeatTemplate string `json:"heartbeatTemplate,omitempty"`
63-
SkillNaming string `json:"skillNaming,omitempty"` // "" = "directory" (default), "flat" = single-file
64-
SelfHeartbeat bool `json:"selfHeartbeat,omitempty"`
58+
Name string `json:"name"`
59+
RootDir string `json:"rootDir"`
60+
SkillsDir string `json:"skillsDir"`
61+
HeartbeatPath string `json:"heartbeatPath,omitempty"`
62+
HeartbeatTemplate string `json:"heartbeatTemplate,omitempty"`
63+
SkillNaming string `json:"skillNaming,omitempty"` // "" = "directory" (default), "flat" = single-file
64+
SelfHeartbeat bool `json:"selfHeartbeat,omitempty"`
65+
Plugin *ManifestPlugin `json:"plugin,omitempty"`
66+
}
67+
68+
// ManifestPlugin describes a per-tool plugin that the daemon writes
69+
// onto disk (alongside the heartbeat + skill copy) and tracks in the
70+
// tool's own plugin allow-list. Today this is openclaw-only — the
71+
// plugin registers a `before_prompt_build` hook that prepends the
72+
// pilot directive into the system prompt on every turn. SKILL.md and
73+
// the heartbeat file are loaded by their tools' own lifecycles
74+
// (workspace bootstrap / periodic), neither fires per-turn, so the
75+
// plugin is the only reliable per-prompt injection surface.
76+
type ManifestPlugin struct {
77+
// ID matches openclaw.plugin.json's "id" field. Used as the
78+
// allow-list key + directory name.
79+
ID string `json:"id"`
80+
// InstallPath is where the plugin directory is written
81+
// (e.g. "~/.openclaw/extensions/pilotprotocol-prompt-injector").
82+
InstallPath string `json:"installPath"`
83+
// Files lists the plugin source files the daemon copies in.
84+
// Order doesn't matter — each file is reconciled independently.
85+
Files []ManifestPluginFile `json:"files"`
86+
// AllowList, if set, tells the daemon to ensure the plugin id
87+
// appears in the tool's plugin allow-list + entries map. Nil
88+
// disables the JSON-merge step (e.g. for tools without an
89+
// explicit allow-list concept).
90+
AllowList *ManifestPluginAllowList `json:"allowList,omitempty"`
91+
}
92+
93+
// ManifestPluginFile is one file the daemon writes into the plugin
94+
// install directory. Mirrors ManifestHelper but scoped to a plugin.
95+
type ManifestPluginFile struct {
96+
// Name is the filename relative to InstallPath (e.g.
97+
// "openclaw.plugin.json", "index.mjs").
98+
Name string `json:"name"`
99+
// Src is a repo-relative path fetched via fetchRepoFile
100+
// (e.g. "workflow-injection/openclaw-plugin/index.mjs").
101+
Src string `json:"src"`
102+
}
103+
104+
// ManifestPluginAllowList describes how the daemon merges its plugin id
105+
// into a tool's configuration to mark the plugin as trusted/enabled.
106+
// Today targets openclaw.json with paths `plugins.allow` (string array)
107+
// and `plugins.entries.<id>.enabled` (bool).
108+
type ManifestPluginAllowList struct {
109+
// ConfigPath is the JSON file the daemon merges into
110+
// (e.g. "~/.openclaw/openclaw.json").
111+
ConfigPath string `json:"configPath"`
112+
// AllowListJsonPath is a dotted path to the trust array. Created
113+
// if absent. Daemon appends the plugin id iff not already present.
114+
AllowListJsonPath string `json:"allowListJsonPath"`
115+
// EntriesJsonPath is a dotted path to the per-plugin entries
116+
// object (e.g. "plugins.entries"). The daemon ensures
117+
// `entries.<id>.enabled` is `true`.
118+
EntriesJsonPath string `json:"entriesJsonPath"`
65119
}
66120

67121
// fetcher is a small wrapper around http.Client that returns response
Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+
package skillinject
4+
5+
import (
6+
"encoding/json"
7+
"fmt"
8+
"os"
9+
"path/filepath"
10+
"strings"
11+
)
12+
13+
// pluginAllowListState describes what the daemon would do to the tool's
14+
// plugin config JSON to ensure our plugin is trusted + enabled.
15+
//
16+
// Three states:
17+
// - StateIdentical: allow-list contains our id AND entries.<id>.enabled
18+
// is true. No write needed.
19+
// - StateAbsent: config file doesn't exist on disk. Tool isn't
20+
// installed → skip (caller handles this).
21+
// - StateDrifted: config exists but the id is missing from allow-list
22+
// OR entries.<id>.enabled isn't true. Daemon merges + rewrites.
23+
//
24+
// Same idiom as classifySkill / classifyMarker — read-only inspection
25+
// here, the actual mutation lives in mergePluginAllowList.
26+
func classifyPluginAllowList(configPath, allowJsonPath, entriesJsonPath, pluginID string) State {
27+
raw, err := os.ReadFile(configPath)
28+
if err != nil {
29+
// Don't conflate "config file missing" with "needs writing" —
30+
// if the tool isn't installed at all, the caller's dirExists
31+
// check on rootDir already skipped this whole tool. A missing
32+
// config file at this point means the tool installed but
33+
// hasn't run yet; treat as drifted so the daemon creates it.
34+
if os.IsNotExist(err) {
35+
return StateDrifted
36+
}
37+
return StateDrifted
38+
}
39+
var obj map[string]any
40+
if err := json.Unmarshal(raw, &obj); err != nil {
41+
// Unparseable config = something the user is editing or that
42+
// belongs to a future version we don't recognise. Refuse to
43+
// rewrite; treat as drifted so the next tick re-checks and
44+
// the caller surfaces an error in the outcome.
45+
return StateDrifted
46+
}
47+
inAllow := allowListContains(obj, allowJsonPath, pluginID)
48+
enabled := entryEnabled(obj, entriesJsonPath, pluginID)
49+
if inAllow && enabled {
50+
return StateIdentical
51+
}
52+
return StateDrifted
53+
}
54+
55+
// mergePluginAllowList atomically rewrites the tool's plugin config
56+
// JSON so that the allow-list at allowJsonPath contains pluginID and
57+
// the entries map at entriesJsonPath has {pluginID: {"enabled": true}}.
58+
// Preserves all other keys byte-for-byte modulo Go's JSON
59+
// re-serialization (consistent 2-space indent, no key reordering
60+
// beyond what encoding/json guarantees — alphabetical for map keys).
61+
//
62+
// If the config file is missing, it is created with only the managed
63+
// keys. The daemon does NOT create the parent directory tree beyond
64+
// the file itself; the tool's own install is expected to provide it.
65+
//
66+
// Failure semantics: any read/parse/write error returns the error
67+
// without partial writes (uses .tmp + rename).
68+
func mergePluginAllowList(configPath, allowJsonPath, entriesJsonPath, pluginID string) error {
69+
var obj map[string]any
70+
raw, err := os.ReadFile(configPath)
71+
switch {
72+
case err != nil && os.IsNotExist(err):
73+
obj = map[string]any{}
74+
case err != nil:
75+
return fmt.Errorf("read plugin config: %w", err)
76+
default:
77+
if uerr := json.Unmarshal(raw, &obj); uerr != nil {
78+
return fmt.Errorf("parse plugin config (refusing to overwrite a malformed user config): %w", uerr)
79+
}
80+
if obj == nil {
81+
obj = map[string]any{}
82+
}
83+
}
84+
85+
if err := ensureAllowListEntry(obj, allowJsonPath, pluginID); err != nil {
86+
return err
87+
}
88+
if err := ensureEntryEnabled(obj, entriesJsonPath, pluginID); err != nil {
89+
return err
90+
}
91+
92+
next, err := json.MarshalIndent(obj, "", " ")
93+
if err != nil {
94+
return fmt.Errorf("marshal merged plugin config: %w", err)
95+
}
96+
next = append(next, '\n')
97+
98+
if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
99+
return fmt.Errorf("ensure parent dir for plugin config: %w", err)
100+
}
101+
tmp := configPath + ".tmp"
102+
if err := os.WriteFile(tmp, next, 0o644); err != nil {
103+
return fmt.Errorf("write tmp plugin config: %w", err)
104+
}
105+
if err := os.Rename(tmp, configPath); err != nil {
106+
_ = os.Remove(tmp)
107+
return fmt.Errorf("rename plugin config into place: %w", err)
108+
}
109+
return nil
110+
}
111+
112+
// walkObject traverses obj along a dotted path, materializing missing
113+
// nested objects when create is true. Returns the final container and
114+
// the leaf key. Returns nil + empty string if any intermediate node is
115+
// not a JSON object and create is false.
116+
func walkObject(obj map[string]any, jsonPath string, create bool) (map[string]any, string) {
117+
parts := strings.Split(jsonPath, ".")
118+
if len(parts) == 0 {
119+
return nil, ""
120+
}
121+
cur := obj
122+
for i := 0; i < len(parts)-1; i++ {
123+
p := parts[i]
124+
next, ok := cur[p].(map[string]any)
125+
if !ok {
126+
if !create {
127+
return nil, ""
128+
}
129+
next = map[string]any{}
130+
cur[p] = next
131+
}
132+
cur = next
133+
}
134+
return cur, parts[len(parts)-1]
135+
}
136+
137+
func allowListContains(obj map[string]any, jsonPath, id string) bool {
138+
parent, leaf := walkObject(obj, jsonPath, false)
139+
if parent == nil {
140+
return false
141+
}
142+
raw, ok := parent[leaf]
143+
if !ok {
144+
return false
145+
}
146+
// JSON arrays unmarshal as []any
147+
arr, ok := raw.([]any)
148+
if !ok {
149+
return false
150+
}
151+
for _, v := range arr {
152+
if s, ok := v.(string); ok && s == id {
153+
return true
154+
}
155+
}
156+
return false
157+
}
158+
159+
func ensureAllowListEntry(obj map[string]any, jsonPath, id string) error {
160+
parent, leaf := walkObject(obj, jsonPath, true)
161+
if parent == nil {
162+
return fmt.Errorf("walk allow-list path %q: parent missing", jsonPath)
163+
}
164+
cur, _ := parent[leaf].([]any)
165+
for _, v := range cur {
166+
if s, ok := v.(string); ok && s == id {
167+
return nil
168+
}
169+
}
170+
parent[leaf] = append(cur, id)
171+
return nil
172+
}
173+
174+
func entryEnabled(obj map[string]any, jsonPath, id string) bool {
175+
parent, leaf := walkObject(obj, jsonPath, false)
176+
if parent == nil {
177+
return false
178+
}
179+
entries, ok := parent[leaf].(map[string]any)
180+
if !ok {
181+
return false
182+
}
183+
entry, ok := entries[id].(map[string]any)
184+
if !ok {
185+
return false
186+
}
187+
enabled, _ := entry["enabled"].(bool)
188+
return enabled
189+
}
190+
191+
func ensureEntryEnabled(obj map[string]any, jsonPath, id string) error {
192+
parent, leaf := walkObject(obj, jsonPath, true)
193+
if parent == nil {
194+
return fmt.Errorf("walk entries path %q: parent missing", jsonPath)
195+
}
196+
entries, ok := parent[leaf].(map[string]any)
197+
if !ok {
198+
entries = map[string]any{}
199+
parent[leaf] = entries
200+
}
201+
entry, ok := entries[id].(map[string]any)
202+
if !ok {
203+
entry = map[string]any{}
204+
entries[id] = entry
205+
}
206+
entry["enabled"] = true
207+
return nil
208+
}

0 commit comments

Comments
 (0)