opencode-skill-autodiscovery 2.0.0 → 2.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +117 -25
- package/dist/agents.d.ts +3 -1
- package/dist/agents.d.ts.map +1 -1
- package/dist/agents.js +86 -27
- package/dist/agents.js.map +1 -1
- package/dist/cache.d.ts +5 -0
- package/dist/cache.d.ts.map +1 -0
- package/dist/cache.js +94 -0
- package/dist/cache.js.map +1 -0
- package/dist/discovery-cache.d.ts +9 -0
- package/dist/discovery-cache.d.ts.map +1 -0
- package/dist/discovery-cache.js +169 -0
- package/dist/discovery-cache.js.map +1 -0
- package/dist/discovery.d.ts +28 -6
- package/dist/discovery.d.ts.map +1 -1
- package/dist/discovery.js +417 -105
- package/dist/discovery.js.map +1 -1
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +62 -11
- package/dist/index.js.map +1 -1
- package/dist/mcp.d.ts +6 -2
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +97 -16
- package/dist/mcp.js.map +1 -1
- package/dist/schemas/mcp-schema-1.0.0.d.ts +117 -0
- package/dist/schemas/mcp-schema-1.0.0.d.ts.map +1 -0
- package/dist/schemas/mcp-schema-1.0.0.js +139 -0
- package/dist/schemas/mcp-schema-1.0.0.js.map +1 -0
- package/dist/schemas/plugin-schema-1.0.0.d.ts +66 -0
- package/dist/schemas/plugin-schema-1.0.0.d.ts.map +1 -0
- package/dist/schemas/plugin-schema-1.0.0.js +72 -0
- package/dist/schemas/plugin-schema-1.0.0.js.map +1 -0
- package/dist/spec-schema.d.ts +12 -0
- package/dist/spec-schema.d.ts.map +1 -0
- package/dist/spec-schema.js +71 -0
- package/dist/spec-schema.js.map +1 -0
- package/package.json +6 -3
package/dist/discovery.js
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
|
-
import { existsSync, readFileSync, readdirSync, realpathSync, statSync, } from "node:fs";
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, statSync, } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
|
-
import { dirname, isAbsolute, join, relative, sep } from "node:path";
|
|
3
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { cachedRead } from "./cache.js";
|
|
6
|
+
import { getCachedPackages, setCachedPackages, fingerprintTree } from "./discovery-cache.js";
|
|
4
7
|
import { log, sanitize } from "./log.js";
|
|
8
|
+
import { PLUGIN_SCHEMA_1_0_0_ID, validatePluginManifest } from "./spec-schema.js";
|
|
5
9
|
import { readAgents } from "./agents.js";
|
|
6
10
|
import { readMcp } from "./mcp.js";
|
|
7
11
|
import { NAME_PATTERN, PLUGIN_SCHEMA, VERSION, validateName } from "./schema.js";
|
|
@@ -23,6 +27,16 @@ function isRegularFile(p) {
|
|
|
23
27
|
return false;
|
|
24
28
|
}
|
|
25
29
|
}
|
|
30
|
+
// True only for a JSON object container: not null, not an array, and not some
|
|
31
|
+
// other object kind. Manifest inputs are parsed JSON, so this rejects every
|
|
32
|
+
// shape other than a plain object before its keys are iterated.
|
|
33
|
+
function isPlainObject(value) {
|
|
34
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
const proto = Object.getPrototypeOf(value);
|
|
38
|
+
return proto === Object.prototype || proto === null;
|
|
39
|
+
}
|
|
26
40
|
// True when `child` resolves (through symlinks) inside `parent`.
|
|
27
41
|
export function contains(parent, child) {
|
|
28
42
|
const p = realpathSync(parent);
|
|
@@ -32,17 +46,42 @@ export function contains(parent, child) {
|
|
|
32
46
|
const rel = relative(p, c);
|
|
33
47
|
return rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
|
|
34
48
|
}
|
|
49
|
+
// Resolves `candidate` to its real path and returns it only when it still
|
|
50
|
+
// lives inside `root` (through symlinks); returns null when it is missing or
|
|
51
|
+
// escapes. This is the read gate for package files whose bytes feed config:
|
|
52
|
+
// a package must not be able to ship a symlink (mcp.json, AGENTS.md, a flat
|
|
53
|
+
// agent .md) that reads a file it never contained. A rejected candidate is
|
|
54
|
+
// logged once, naming the package root and the outside target.
|
|
55
|
+
export function resolveContained(root, candidate) {
|
|
56
|
+
let resolved;
|
|
57
|
+
try {
|
|
58
|
+
resolved = realpathSync(candidate);
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
if (!contains(root, resolved)) {
|
|
64
|
+
log(`skipping "${resolved}": resolves outside "${root}"`);
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
return resolved;
|
|
68
|
+
}
|
|
35
69
|
// Parses the YAML frontmatter of a SKILL.md (name, description). Returns null
|
|
36
70
|
// when the file is unreadable or lacks a valid `name`.
|
|
37
71
|
export function readSkillInfo(dir) {
|
|
38
72
|
const skillMd = join(dir, "SKILL.md");
|
|
39
|
-
let
|
|
73
|
+
let parsed;
|
|
40
74
|
try {
|
|
41
|
-
|
|
75
|
+
parsed = cachedRead("skill-frontmatter", skillMd, parseSkillFrontmatter);
|
|
42
76
|
}
|
|
43
77
|
catch {
|
|
44
78
|
return null;
|
|
45
79
|
}
|
|
80
|
+
if (!parsed)
|
|
81
|
+
return null;
|
|
82
|
+
return { dir, name: parsed.name, description: parsed.description };
|
|
83
|
+
}
|
|
84
|
+
function parseSkillFrontmatter(content) {
|
|
46
85
|
const frontmatter = /^---\s*\n([\s\S]*?)\n---/.exec(content)?.[1];
|
|
47
86
|
if (!frontmatter)
|
|
48
87
|
return null;
|
|
@@ -57,7 +96,7 @@ export function readSkillInfo(dir) {
|
|
|
57
96
|
return null;
|
|
58
97
|
// The description is written into config verbatim: strip ANSI escapes and
|
|
59
98
|
// C0 control characters before it can reach any config surface.
|
|
60
|
-
return {
|
|
99
|
+
return { name, description: sanitize(field("description") ?? "") };
|
|
61
100
|
}
|
|
62
101
|
// Reads a conformant Agent Plugins 1.0.0 package from a directory root.
|
|
63
102
|
// Returns null when the root has no valid plugin.json, so callers can fall
|
|
@@ -71,7 +110,7 @@ export function readPackage(root, source, trusted = false) {
|
|
|
71
110
|
return null;
|
|
72
111
|
let manifest;
|
|
73
112
|
try {
|
|
74
|
-
manifest =
|
|
113
|
+
manifest = cachedRead("plugin-manifest", manifestPath, (content) => JSON.parse(content));
|
|
75
114
|
}
|
|
76
115
|
catch {
|
|
77
116
|
return null;
|
|
@@ -81,12 +120,30 @@ export function readPackage(root, source, trusted = false) {
|
|
|
81
120
|
if (typeof manifest.$schema !== "string" || !PLUGIN_SCHEMA.test(manifest.$schema)) {
|
|
82
121
|
return null;
|
|
83
122
|
}
|
|
84
|
-
|
|
123
|
+
// Rigorous structural validation against the real published schema, when
|
|
124
|
+
// this plugin has vendored a copy matching the declared version (today,
|
|
125
|
+
// only 1.0.0). Any other conformant 1.x.x version falls back to the
|
|
126
|
+
// name-only check below, so a future minor version is never rejected just
|
|
127
|
+
// because this plugin hasn't vendored its schema yet — see spec-schema.ts.
|
|
128
|
+
if (manifest.$schema === PLUGIN_SCHEMA_1_0_0_ID) {
|
|
129
|
+
const result = validatePluginManifest(manifest);
|
|
130
|
+
if (!result.valid) {
|
|
131
|
+
log(`ignoring plugin.json at "${root}": fails Agent Plugins 1.0.0 schema (${result.errors.join("; ")})`);
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
else if (typeof manifest.name !== "string" ||
|
|
85
136
|
manifest.name.length === 0 ||
|
|
86
137
|
manifest.name.length > 64 ||
|
|
87
138
|
!NAME_PATTERN.test(manifest.name)) {
|
|
88
139
|
return null;
|
|
89
140
|
}
|
|
141
|
+
// Narrows `manifest.name` for TS: always true here (either branch above
|
|
142
|
+
// already guarantees it — ajv's `name` schema requires a string, the
|
|
143
|
+
// fallback branch checked it directly) but ajv validation doesn't carry
|
|
144
|
+
// type-level narrowing the way the explicit typeof check does.
|
|
145
|
+
if (typeof manifest.name !== "string")
|
|
146
|
+
return null;
|
|
90
147
|
const skillDirs = [];
|
|
91
148
|
const skillsRoot = join(root, "skills");
|
|
92
149
|
if (isDirectory(skillsRoot)) {
|
|
@@ -101,7 +158,14 @@ export function readPackage(root, source, trusted = false) {
|
|
|
101
158
|
const dir = join(skillsRoot, entry);
|
|
102
159
|
if (!isDirectory(dir))
|
|
103
160
|
continue;
|
|
104
|
-
|
|
161
|
+
const skillMd = join(dir, "SKILL.md");
|
|
162
|
+
if (!isRegularFile(skillMd))
|
|
163
|
+
continue;
|
|
164
|
+
// The directory alone is not enough: a package can keep the skill dir
|
|
165
|
+
// in-root while symlinking its SKILL.md to an outside file, whose bytes
|
|
166
|
+
// would otherwise become prompt material. Require the skill file itself
|
|
167
|
+
// to resolve inside the package too.
|
|
168
|
+
if (!resolveContained(root, skillMd))
|
|
105
169
|
continue;
|
|
106
170
|
let resolved;
|
|
107
171
|
try {
|
|
@@ -115,9 +179,11 @@ export function readPackage(root, source, trusted = false) {
|
|
|
115
179
|
skillDirs.push(resolved);
|
|
116
180
|
}
|
|
117
181
|
}
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
182
|
+
// mcp.json is package-supplied input read into config.mcp: resolve it
|
|
183
|
+
// through the containment gate first, so a symlink cannot point it at a
|
|
184
|
+
// file outside the package.
|
|
185
|
+
const mcpResolved = resolveContained(root, join(root, "mcp.json"));
|
|
186
|
+
const mcpPath = mcpResolved && isRegularFile(mcpResolved) ? mcpResolved : undefined;
|
|
121
187
|
return {
|
|
122
188
|
source,
|
|
123
189
|
trusted,
|
|
@@ -126,6 +192,7 @@ export function readPackage(root, source, trusted = false) {
|
|
|
126
192
|
skillDirs,
|
|
127
193
|
mcpPath,
|
|
128
194
|
schemaVersion: VERSION.exec(manifest.$schema)?.[1],
|
|
195
|
+
manifestName: true,
|
|
129
196
|
};
|
|
130
197
|
}
|
|
131
198
|
// True when a discovered package matches the user's exclude list. Conformant
|
|
@@ -186,7 +253,12 @@ function findSkillDirsUnder(realRoot, dir, out, seen, depth = 0) {
|
|
|
186
253
|
log(`skipping skill dir "${child}": resolves outside "${realRoot}"`);
|
|
187
254
|
continue;
|
|
188
255
|
}
|
|
189
|
-
|
|
256
|
+
const childSkillMd = join(child, "SKILL.md");
|
|
257
|
+
if (isRegularFile(childSkillMd)) {
|
|
258
|
+
// A skill dir whose SKILL.md symlinks outside the walk root must not be
|
|
259
|
+
// emitted: readSkillInfo (and opencode) would read the outside bytes.
|
|
260
|
+
if (!resolveContained(realRoot, childSkillMd))
|
|
261
|
+
continue;
|
|
190
262
|
out.add(child);
|
|
191
263
|
}
|
|
192
264
|
else {
|
|
@@ -201,7 +273,10 @@ function hasPluginLayout(root) {
|
|
|
201
273
|
const skillsRoot = join(root, "skills");
|
|
202
274
|
if (isDirectory(skillsRoot)) {
|
|
203
275
|
try {
|
|
204
|
-
if (readdirSync(skillsRoot).some((e) =>
|
|
276
|
+
if (readdirSync(skillsRoot).some((e) => {
|
|
277
|
+
const skillMd = join(skillsRoot, e, "SKILL.md");
|
|
278
|
+
return resolveContained(root, skillMd) !== null && isRegularFile(skillMd);
|
|
279
|
+
})) {
|
|
205
280
|
return true;
|
|
206
281
|
}
|
|
207
282
|
}
|
|
@@ -228,28 +303,86 @@ function hasRootAgentFiles(root) {
|
|
|
228
303
|
return entries.some((e) => {
|
|
229
304
|
if (!e.endsWith(".md"))
|
|
230
305
|
return false;
|
|
231
|
-
const
|
|
232
|
-
if (!isRegularFile(
|
|
306
|
+
const contained = resolveContained(root, join(root, e));
|
|
307
|
+
if (!contained || !isRegularFile(contained))
|
|
233
308
|
return false;
|
|
234
|
-
|
|
235
|
-
|
|
309
|
+
try {
|
|
310
|
+
return cachedRead("agent-frontmatter-check", contained, hasAgentFrontmatter);
|
|
311
|
+
}
|
|
312
|
+
catch {
|
|
236
313
|
return false;
|
|
237
|
-
|
|
314
|
+
}
|
|
238
315
|
});
|
|
239
316
|
}
|
|
317
|
+
function hasAgentFrontmatter(content) {
|
|
318
|
+
const m = /^---\s*\n([\s\S]*?)\n---/.exec(content)?.[1];
|
|
319
|
+
if (!m)
|
|
320
|
+
return false;
|
|
321
|
+
return /^name:[ \t]/.test(m) || /^description:[ \t]/.test(m);
|
|
322
|
+
}
|
|
240
323
|
// Returns directories under `root` that are individual plugin roots. This
|
|
241
324
|
// mirrors what VS Code's installed.json points at (each {host}/{org}/{repo}
|
|
242
325
|
// clone, and inside a marketplace like agency-agents each
|
|
243
326
|
// ref_plugins/plugins/<division> dir), so a clone on disk is registered
|
|
244
327
|
// per-plugin instead of as one opaque tree that hides its agents.
|
|
328
|
+
//
|
|
329
|
+
// Hardened like findSkillDirs: traversal and emitted roots are keyed on real
|
|
330
|
+
// (symlink-resolved) paths, so symlink cycles — self-referential or
|
|
331
|
+
// ancestor-pointing — terminate instead of growing ever-longer lexical paths
|
|
332
|
+
// until stack exhaustion, and nothing that resolves outside the starting root
|
|
333
|
+
// is emitted. `isDirectory` uses statSync, which follows symlinks, so the
|
|
334
|
+
// realpath dedupe below is what bounds the walk.
|
|
245
335
|
export function findPluginRoots(root, out) {
|
|
246
|
-
|
|
247
|
-
|
|
336
|
+
let resolved;
|
|
337
|
+
try {
|
|
338
|
+
resolved = realpathSync(root);
|
|
339
|
+
}
|
|
340
|
+
catch {
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
findPluginRootsUnder(resolved, resolved, out, new Set());
|
|
344
|
+
}
|
|
345
|
+
// Equivalent to a caller doing `findPluginRoots(root, roots)` and converting
|
|
346
|
+
// each hit with `packageFromDir(pluginRoot, source, trusted)` — the exact
|
|
347
|
+
// pattern collectAgentPluginRoot's fallback and collectClaude's remote walk
|
|
348
|
+
// both used before discovery-cache.ts existed — except a cheap fingerprint
|
|
349
|
+
// check of `root` (see fingerprintTree) can skip the real walk, and every
|
|
350
|
+
// file read inside it, entirely when the subtree hasn't changed since the
|
|
351
|
+
// last run. A miss falls back to exactly the walk+convert callers did
|
|
352
|
+
// before, so this is never slower than the un-cached baseline.
|
|
353
|
+
//
|
|
354
|
+
// `exclude` is deliberately the caller's job, applied identically on a hit
|
|
355
|
+
// or a miss, so changing the exclude list never has to invalidate the cache.
|
|
356
|
+
function findPluginPackagesCached(root, source, trusted) {
|
|
357
|
+
const fingerprint = fingerprintTree(root);
|
|
358
|
+
const key = JSON.stringify([source, root]);
|
|
359
|
+
const cached = getCachedPackages(key, fingerprint);
|
|
360
|
+
if (cached)
|
|
361
|
+
return cached;
|
|
362
|
+
const roots = [];
|
|
363
|
+
findPluginRoots(root, roots);
|
|
364
|
+
const packages = [];
|
|
365
|
+
for (const pluginRoot of roots) {
|
|
366
|
+
const pkg = packageFromDir(pluginRoot, source, trusted);
|
|
367
|
+
if (pkg)
|
|
368
|
+
packages.push(pkg);
|
|
369
|
+
}
|
|
370
|
+
setCachedPackages(key, fingerprint, packages);
|
|
371
|
+
return packages;
|
|
372
|
+
}
|
|
373
|
+
function findPluginRootsUnder(realRoot, dir, out, seen, depth = 0) {
|
|
374
|
+
if (depth > MAX_WALK_DEPTH)
|
|
375
|
+
return;
|
|
376
|
+
if (seen.has(dir))
|
|
377
|
+
return;
|
|
378
|
+
seen.add(dir);
|
|
379
|
+
if (hasPluginLayout(dir)) {
|
|
380
|
+
out.push(dir);
|
|
248
381
|
return;
|
|
249
382
|
}
|
|
250
383
|
let entries;
|
|
251
384
|
try {
|
|
252
|
-
entries = readdirSync(
|
|
385
|
+
entries = readdirSync(dir);
|
|
253
386
|
}
|
|
254
387
|
catch {
|
|
255
388
|
return;
|
|
@@ -257,17 +390,31 @@ export function findPluginRoots(root, out) {
|
|
|
257
390
|
for (const entry of entries) {
|
|
258
391
|
if (entry === ".git" || entry === "node_modules")
|
|
259
392
|
continue;
|
|
260
|
-
const full = join(
|
|
393
|
+
const full = join(dir, entry);
|
|
261
394
|
if (!isDirectory(full))
|
|
262
395
|
continue;
|
|
263
|
-
|
|
396
|
+
let child;
|
|
397
|
+
try {
|
|
398
|
+
child = realpathSync(full);
|
|
399
|
+
}
|
|
400
|
+
catch {
|
|
401
|
+
continue;
|
|
402
|
+
}
|
|
403
|
+
if (!contains(realRoot, child)) {
|
|
404
|
+
log(`skipping plugin dir "${child}": resolves outside "${realRoot}"`);
|
|
405
|
+
continue;
|
|
406
|
+
}
|
|
407
|
+
findPluginRootsUnder(realRoot, child, out, seen, depth + 1);
|
|
264
408
|
}
|
|
265
409
|
}
|
|
266
410
|
// True when a legacy Claude Code plugin declares at least one agent, so an
|
|
267
411
|
// agents-only plugin is still discovered even though it ships no skills.
|
|
268
412
|
function hasClaudeAgents(root) {
|
|
413
|
+
const manifestPath = resolveContained(root, join(root, ".claude-plugin", "plugin.json"));
|
|
414
|
+
if (!manifestPath)
|
|
415
|
+
return false;
|
|
269
416
|
try {
|
|
270
|
-
const manifest =
|
|
417
|
+
const manifest = cachedRead("claude-legacy-manifest", manifestPath, (content) => JSON.parse(content));
|
|
271
418
|
if (typeof manifest !== "object" || manifest === null)
|
|
272
419
|
return false;
|
|
273
420
|
const agents = manifest.agents;
|
|
@@ -279,14 +426,43 @@ function hasClaudeAgents(root) {
|
|
|
279
426
|
return false;
|
|
280
427
|
}
|
|
281
428
|
}
|
|
429
|
+
// Reads the `name` declared by a legacy Claude Code manifest
|
|
430
|
+
// (.claude-plugin/plugin.json without a recognized $schema — the native
|
|
431
|
+
// Claude Code shape, which is what every plugin actually installed via
|
|
432
|
+
// Claude Code ships). readPackage() only trusts a manifest name when
|
|
433
|
+
// $schema is present and matches the Agent Plugins spec, so a plugin
|
|
434
|
+
// installed the native way falls through to packageFromDir's legacy tree
|
|
435
|
+
// walk; without this, that walk names the package after its directory
|
|
436
|
+
// basename instead, which differs at every physical location the same
|
|
437
|
+
// plugin is mirrored to (a top-level clone, the same clone nested again
|
|
438
|
+
// inside a bundled marketplace tree, an SSH-synced remote copy, ...) and
|
|
439
|
+
// defeats dedupePackages entirely, registering every skill in the plugin
|
|
440
|
+
// once per mirror. Shares the "claude-legacy-manifest" cache key with
|
|
441
|
+
// hasClaudeAgents so both reads of the same file cost one parse.
|
|
442
|
+
function legacyManifestName(root) {
|
|
443
|
+
const manifestPath = resolveContained(root, join(root, ".claude-plugin", "plugin.json"));
|
|
444
|
+
if (!manifestPath)
|
|
445
|
+
return null;
|
|
446
|
+
try {
|
|
447
|
+
const manifest = cachedRead("claude-legacy-manifest", manifestPath, (content) => JSON.parse(content));
|
|
448
|
+
if (!isPlainObject(manifest))
|
|
449
|
+
return null;
|
|
450
|
+
const { name } = manifest;
|
|
451
|
+
return typeof name === "string" && validateName(name) ? name : null;
|
|
452
|
+
}
|
|
453
|
+
catch {
|
|
454
|
+
return null;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
282
457
|
// True when a plugin carries flat agent files: agents/<name>.md or bare
|
|
283
458
|
// <name>.md in the root (agency-agents layouts), which readAgents picks up
|
|
284
459
|
// even without a manifest `agents` field.
|
|
285
460
|
function hasFlatAgents(root) {
|
|
286
461
|
if (hasRootAgentFiles(root))
|
|
287
462
|
return true;
|
|
463
|
+
const agentsDir = join(root, "agents");
|
|
288
464
|
try {
|
|
289
|
-
return readdirSync(
|
|
465
|
+
return readdirSync(agentsDir).some((e) => e.endsWith(".md") && resolveContained(root, join(agentsDir, e)) !== null);
|
|
290
466
|
}
|
|
291
467
|
catch {
|
|
292
468
|
return false;
|
|
@@ -320,23 +496,22 @@ export function packageFromDir(root, source, trusted = false) {
|
|
|
320
496
|
findSkillDirs(root, skillDirs, new Set());
|
|
321
497
|
if (skillDirs.size === 0 && !hasClaudeAgents(root) && !hasFlatAgents(root))
|
|
322
498
|
return null;
|
|
499
|
+
// Prefer the plugin's own declared name (native Claude Code manifest, no
|
|
500
|
+
// $schema) over the directory basename, so mirrors of the same plugin at
|
|
501
|
+
// different physical paths share an identity dedupePackages can collapse.
|
|
502
|
+
const declaredName = legacyManifestName(root);
|
|
323
503
|
return {
|
|
324
504
|
source,
|
|
325
505
|
trusted,
|
|
326
|
-
name: legacyFallbackName(root.split(/[\\/]/).pop() ?? ""),
|
|
506
|
+
name: declaredName ?? legacyFallbackName(root.split(/[\\/]/).pop() ?? ""),
|
|
327
507
|
root,
|
|
328
508
|
skillDirs: [...skillDirs],
|
|
509
|
+
manifestName: declaredName !== null,
|
|
329
510
|
};
|
|
330
511
|
}
|
|
331
|
-
// --- VS Code agent plugin discovery ---------------------------------------
|
|
332
|
-
// VS Code keeps agent plugins in a per-platform "data dir". The holding
|
|
333
|
-
// folder is named `agent-plugins` on older builds (e.g. ~/.vscode) and
|
|
334
|
-
// `agentPlugins` on newer builds; remote servers nest theirs under `data/`
|
|
335
|
-
// (~/.vscode-server/data/agentPlugins). We list every known candidate so the
|
|
336
|
-
// discovery works regardless of OS, build channel, or local/remote setup.
|
|
337
512
|
function vsCodeDataRoots(extra) {
|
|
338
513
|
const home = homedir();
|
|
339
|
-
const
|
|
514
|
+
const builtIn = new Set([
|
|
340
515
|
join(home, ".vscode"),
|
|
341
516
|
// Linux
|
|
342
517
|
join(home, ".config", "Code"),
|
|
@@ -356,36 +531,63 @@ function vsCodeDataRoots(extra) {
|
|
|
356
531
|
join(home, ".vscode-server-insiders"),
|
|
357
532
|
join(home, ".vscode-remote"),
|
|
358
533
|
]);
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
534
|
+
const roots = [...builtIn].map((root) => ({ root, trusted: true }));
|
|
535
|
+
const seen = new Set(builtIn);
|
|
536
|
+
for (const root of extra) {
|
|
537
|
+
if (seen.has(root))
|
|
538
|
+
continue;
|
|
539
|
+
seen.add(root);
|
|
540
|
+
roots.push({ root, trusted: false });
|
|
541
|
+
}
|
|
542
|
+
return roots;
|
|
362
543
|
}
|
|
363
544
|
function agentPluginDirs(roots) {
|
|
364
|
-
const
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
545
|
+
const seen = new Set();
|
|
546
|
+
const dirs = [];
|
|
547
|
+
for (const { root, trusted } of roots) {
|
|
548
|
+
for (const dir of [
|
|
549
|
+
join(root, "agent-plugins"),
|
|
550
|
+
join(root, "agentPlugins"),
|
|
551
|
+
join(root, "data", "agent-plugins"),
|
|
552
|
+
join(root, "data", "agentPlugins"),
|
|
553
|
+
]) {
|
|
554
|
+
if (seen.has(dir))
|
|
555
|
+
continue;
|
|
556
|
+
seen.add(dir);
|
|
557
|
+
dirs.push({ root: dir, trusted });
|
|
558
|
+
}
|
|
370
559
|
}
|
|
371
|
-
return
|
|
560
|
+
return dirs;
|
|
372
561
|
}
|
|
562
|
+
// Resolves an `installed.json` `pluginUri` to an on-disk plugin root. Only
|
|
563
|
+
// `file:` URLs name a local directory: non-file schemes and malformed URLs are
|
|
564
|
+
// dropped (with a log) rather than echoed into the filesystem, and the
|
|
565
|
+
// resolved target must be an existing directory. `fileURLToPath` owns the
|
|
566
|
+
// slash/percent/drive-letter normalization; `resolve` then canonicalizes the
|
|
567
|
+
// result so a technically valid URL like `file:////abs` yields the same root
|
|
568
|
+
// string as its `file:///abs` spelling.
|
|
373
569
|
function vscodePluginPath(pluginUri) {
|
|
374
|
-
|
|
375
|
-
if (!m)
|
|
376
|
-
return pluginUri;
|
|
377
|
-
let raw;
|
|
570
|
+
let resolved;
|
|
378
571
|
try {
|
|
379
|
-
|
|
572
|
+
resolved = resolve(fileURLToPath(pluginUri));
|
|
380
573
|
}
|
|
381
574
|
catch {
|
|
382
|
-
|
|
575
|
+
log(`ignoring plugin URI "${pluginUri}": not a resolvable file: URL`);
|
|
576
|
+
return null;
|
|
577
|
+
}
|
|
578
|
+
// `file://` (and `file:///`) resolve to the filesystem root; a plugin root
|
|
579
|
+
// is never the root itself, and walking it would scan the whole disk.
|
|
580
|
+
if (dirname(resolved) === resolved) {
|
|
581
|
+
log(`ignoring plugin URI "${pluginUri}": filesystem root is not a plugin root`);
|
|
582
|
+
return null;
|
|
383
583
|
}
|
|
384
|
-
if (
|
|
385
|
-
|
|
386
|
-
|
|
584
|
+
if (!isDirectory(resolved)) {
|
|
585
|
+
log(`ignoring plugin URI "${pluginUri}": "${resolved}" is not a directory`);
|
|
586
|
+
return null;
|
|
587
|
+
}
|
|
588
|
+
return resolved;
|
|
387
589
|
}
|
|
388
|
-
export function collectVscodeManifest(out, installedJson, exclude = []) {
|
|
590
|
+
export function collectVscodeManifest(out, installedJson, exclude = [], trusted = true) {
|
|
389
591
|
if (!existsSync(installedJson))
|
|
390
592
|
return;
|
|
391
593
|
let manifest;
|
|
@@ -395,15 +597,37 @@ export function collectVscodeManifest(out, installedJson, exclude = []) {
|
|
|
395
597
|
catch {
|
|
396
598
|
return;
|
|
397
599
|
}
|
|
398
|
-
|
|
399
|
-
|
|
600
|
+
// installed.json is manifest input: fail closed on a malformed shape rather
|
|
601
|
+
// than iterating a non-array (which throws) or trusting entry types.
|
|
602
|
+
const installed = isPlainObject(manifest) ? manifest.installed : undefined;
|
|
603
|
+
if (!Array.isArray(installed)) {
|
|
604
|
+
log(`ignoring "${installedJson}": "installed" is not an array`);
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
const root = dirname(installedJson);
|
|
608
|
+
for (const plugin of installed) {
|
|
609
|
+
if (!isPlainObject(plugin)) {
|
|
610
|
+
log(`ignoring entry in "${installedJson}": entry is not an object`);
|
|
611
|
+
continue;
|
|
612
|
+
}
|
|
613
|
+
const pluginUri = plugin.pluginUri;
|
|
614
|
+
if (typeof pluginUri !== "string") {
|
|
615
|
+
log(`ignoring entry in "${installedJson}": "pluginUri" is not a string`);
|
|
400
616
|
continue;
|
|
401
|
-
const dir = vscodePluginPath(plugin.pluginUri);
|
|
402
|
-
if (dir) {
|
|
403
|
-
const pkg = packageFromDir(dir, "vscode", true);
|
|
404
|
-
if (pkg && !isExcluded(pkg, exclude))
|
|
405
|
-
out.push(pkg);
|
|
406
617
|
}
|
|
618
|
+
const dir = vscodePluginPath(pluginUri);
|
|
619
|
+
if (!dir)
|
|
620
|
+
continue;
|
|
621
|
+
// installed.json is manifest input: under an untrusted root the recorded
|
|
622
|
+
// install location must resolve inside that root. A trusted home root is
|
|
623
|
+
// allowed to point at a global extension directory outside the data root.
|
|
624
|
+
if (!trusted && !contains(root, dir)) {
|
|
625
|
+
log(`skipping plugin URI "${pluginUri}": "${dir}" is outside untrusted root "${root}"`);
|
|
626
|
+
continue;
|
|
627
|
+
}
|
|
628
|
+
const pkg = packageFromDir(dir, "vscode", trusted);
|
|
629
|
+
if (pkg && !isExcluded(pkg, exclude))
|
|
630
|
+
out.push(pkg);
|
|
407
631
|
}
|
|
408
632
|
}
|
|
409
633
|
// Remote hosts: VS Code syncs client skills into
|
|
@@ -411,7 +635,7 @@ export function collectVscodeManifest(out, installedJson, exclude = []) {
|
|
|
411
635
|
// cache.json. Resolve each entry so the materialized synced-customization
|
|
412
636
|
// bundle ("VS Code Synced Data" Open Plugin, skills/<name>/SKILL.md) is
|
|
413
637
|
// discovered.
|
|
414
|
-
export function collectVscodeCache(out, cacheJson, exclude = []) {
|
|
638
|
+
export function collectVscodeCache(out, cacheJson, exclude = [], trusted = true) {
|
|
415
639
|
if (!existsSync(cacheJson))
|
|
416
640
|
return;
|
|
417
641
|
let entries;
|
|
@@ -431,20 +655,22 @@ export function collectVscodeCache(out, cacheJson, exclude = []) {
|
|
|
431
655
|
const nonce = typeof entry.nonce === "string" && entry.nonce
|
|
432
656
|
? sanitizeKey(entry.nonce)
|
|
433
657
|
: "default";
|
|
658
|
+
// Layouts without the {nonce} subdirectory materialize the bundle
|
|
659
|
+
// directly under {key}; falling back only when the nonce dir is absent
|
|
660
|
+
// avoids re-descending into it.
|
|
434
661
|
const nonceDir = join(parent, key, nonce);
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
// avoids re-descending into it.
|
|
444
|
-
const pkg = packageFromDir(join(parent, key), "vscode", true);
|
|
445
|
-
if (pkg && !isExcluded(pkg, exclude))
|
|
446
|
-
out.push(pkg);
|
|
662
|
+
const dir = isDirectory(nonceDir) ? nonceDir : join(parent, key);
|
|
663
|
+
if (!isDirectory(dir))
|
|
664
|
+
continue;
|
|
665
|
+
// cache.json is manifest input: under an untrusted root the bundle must
|
|
666
|
+
// resolve inside that root, not be redirected elsewhere on disk.
|
|
667
|
+
if (!trusted && !contains(parent, dir)) {
|
|
668
|
+
log(`skipping cache entry "${entry.uri}": "${dir}" is outside untrusted root "${parent}"`);
|
|
669
|
+
continue;
|
|
447
670
|
}
|
|
671
|
+
const pkg = packageFromDir(dir, "vscode", trusted);
|
|
672
|
+
if (pkg && !isExcluded(pkg, exclude))
|
|
673
|
+
out.push(pkg);
|
|
448
674
|
}
|
|
449
675
|
}
|
|
450
676
|
// Mirrors the server-side AgentPluginManager sanitizer so we can resolve the
|
|
@@ -456,11 +682,11 @@ function sanitizeKey(value) {
|
|
|
456
682
|
.replace(/^-|-$/g, "")
|
|
457
683
|
.substring(0, 128);
|
|
458
684
|
}
|
|
459
|
-
function collectAgentPluginRoot(out, root, exclude = []) {
|
|
685
|
+
function collectAgentPluginRoot(out, root, exclude = [], trusted = true) {
|
|
460
686
|
const installedJson = join(root, "installed.json");
|
|
461
687
|
const cacheJson = join(root, "cache.json");
|
|
462
|
-
collectVscodeManifest(out, installedJson, exclude);
|
|
463
|
-
collectVscodeCache(out, cacheJson, exclude);
|
|
688
|
+
collectVscodeManifest(out, installedJson, exclude, trusted);
|
|
689
|
+
collectVscodeCache(out, cacheJson, exclude, trusted);
|
|
464
690
|
// `installed.json` is the authoritative record of what VS Code installed;
|
|
465
691
|
// when it exists, respect it exactly and skip the fallback walk so
|
|
466
692
|
// cloned-but-not-installed marketplaces stay hidden.
|
|
@@ -471,22 +697,23 @@ function collectAgentPluginRoot(out, root, exclude = []) {
|
|
|
471
697
|
// clone) has no manifest entry at all. Only suppress the walk when
|
|
472
698
|
// installed.json exists, so any clone actually on disk is still discovered.
|
|
473
699
|
if (!existsSync(installedJson)) {
|
|
474
|
-
const
|
|
475
|
-
|
|
476
|
-
for (const pluginRoot of pluginRoots) {
|
|
477
|
-
const pkg = packageFromDir(pluginRoot, "vscode", false);
|
|
478
|
-
if (pkg && !isExcluded(pkg, exclude))
|
|
700
|
+
for (const pkg of findPluginPackagesCached(root, "vscode", false)) {
|
|
701
|
+
if (!isExcluded(pkg, exclude))
|
|
479
702
|
out.push(pkg);
|
|
480
703
|
}
|
|
481
704
|
}
|
|
482
705
|
}
|
|
483
706
|
export function collectVscode(out, extra, exclude = []) {
|
|
484
|
-
for (const
|
|
485
|
-
collectAgentPluginRoot(out,
|
|
707
|
+
for (const { root, trusted } of agentPluginDirs(vsCodeDataRoots(extra))) {
|
|
708
|
+
collectAgentPluginRoot(out, root, exclude, trusted);
|
|
486
709
|
}
|
|
487
710
|
}
|
|
488
711
|
// --- Claude Code plugin discovery ------------------------------------------
|
|
489
|
-
|
|
712
|
+
// `trusted` defaults to true: every Claude root is home-scoped and
|
|
713
|
+
// host-managed (`~/.claude/...`), so it stays trusted. The parameter exists
|
|
714
|
+
// for symmetry with the VS Code collectors, letting a caller mark a non-home
|
|
715
|
+
// manifest untrusted.
|
|
716
|
+
export function collectClaudeManifest(out, installedJson, exclude = [], trusted = true) {
|
|
490
717
|
if (!existsSync(installedJson))
|
|
491
718
|
return;
|
|
492
719
|
let manifest;
|
|
@@ -496,13 +723,32 @@ export function collectClaudeManifest(out, installedJson, exclude = []) {
|
|
|
496
723
|
catch {
|
|
497
724
|
return;
|
|
498
725
|
}
|
|
499
|
-
|
|
726
|
+
// installed_plugins.json is manifest input: fail closed when `plugins` is not
|
|
727
|
+
// a plain object, when a version bucket is not an array, or when an entry
|
|
728
|
+
// lacks a string installPath.
|
|
729
|
+
const plugins = isPlainObject(manifest) ? manifest.plugins : undefined;
|
|
730
|
+
if (!isPlainObject(plugins)) {
|
|
731
|
+
log(`ignoring "${installedJson}": "plugins" is not an object`);
|
|
732
|
+
return;
|
|
733
|
+
}
|
|
734
|
+
for (const versions of Object.values(plugins)) {
|
|
735
|
+
if (!Array.isArray(versions)) {
|
|
736
|
+
log(`ignoring entry in "${installedJson}": plugin versions are not an array`);
|
|
737
|
+
continue;
|
|
738
|
+
}
|
|
500
739
|
for (const plugin of versions) {
|
|
501
|
-
if (plugin
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
out.push(pkg);
|
|
740
|
+
if (!isPlainObject(plugin)) {
|
|
741
|
+
log(`ignoring entry in "${installedJson}": entry is not an object`);
|
|
742
|
+
continue;
|
|
505
743
|
}
|
|
744
|
+
const installPath = plugin.installPath;
|
|
745
|
+
if (typeof installPath !== "string") {
|
|
746
|
+
log(`ignoring entry in "${installedJson}": "installPath" is not a string`);
|
|
747
|
+
continue;
|
|
748
|
+
}
|
|
749
|
+
const pkg = packageFromDir(installPath, "claude", trusted);
|
|
750
|
+
if (pkg && !isExcluded(pkg, exclude))
|
|
751
|
+
out.push(pkg);
|
|
506
752
|
}
|
|
507
753
|
}
|
|
508
754
|
}
|
|
@@ -522,11 +768,8 @@ export function collectClaude(out, exclude = []) {
|
|
|
522
768
|
// the same-source mirror dedup in planConfig collapses any overlap.
|
|
523
769
|
const remoteRoot = join(home, ".claude", "remote", "plugins");
|
|
524
770
|
if (existsSync(remoteRoot)) {
|
|
525
|
-
const
|
|
526
|
-
|
|
527
|
-
for (const pluginRoot of roots) {
|
|
528
|
-
const pkg = packageFromDir(pluginRoot, "claude", true);
|
|
529
|
-
if (pkg && !isExcluded(pkg, exclude))
|
|
771
|
+
for (const pkg of findPluginPackagesCached(remoteRoot, "claude", true)) {
|
|
772
|
+
if (!isExcluded(pkg, exclude))
|
|
530
773
|
out.push(pkg);
|
|
531
774
|
}
|
|
532
775
|
}
|
|
@@ -599,16 +842,19 @@ export function collectNodeModules(nodeModulesRoot, out, trusted = false, exclud
|
|
|
599
842
|
}
|
|
600
843
|
}
|
|
601
844
|
// --- Merge logic ------------------------------------------------------------
|
|
602
|
-
// Collapses mirrors of the same
|
|
603
|
-
//
|
|
604
|
-
//
|
|
605
|
-
//
|
|
845
|
+
// Collapses mirrors of the same package discovered from several places at
|
|
846
|
+
// once (opencode cache + project node_modules + VS Code clone + synced
|
|
847
|
+
// bundle + a bundled marketplace tree that nests a second copy of a plugin
|
|
848
|
+
// already discovered standalone). Any package with a manifest-declared name
|
|
849
|
+
// -- Agent Plugins schema or a native Claude Code plugin.json -- is
|
|
850
|
+
// identified by that name; packages with no manifest at all fall back to
|
|
851
|
+
// source + root, which is unique per location (see PluginPackage.manifestName).
|
|
606
852
|
function dedupePackages(packages) {
|
|
607
853
|
const seen = new Set();
|
|
608
854
|
const out = [];
|
|
609
855
|
for (const pkg of packages) {
|
|
610
|
-
const id = pkg.
|
|
611
|
-
? `
|
|
856
|
+
const id = pkg.manifestName
|
|
857
|
+
? `named:${pkg.name}`
|
|
612
858
|
: `${pkg.source}\u0000${pkg.root}`;
|
|
613
859
|
if (seen.has(id))
|
|
614
860
|
continue;
|
|
@@ -626,16 +872,30 @@ export function planConfig(packages, taken = {},
|
|
|
626
872
|
// Mirrors the plugin options: when mcp is false the MCP subsystem is
|
|
627
873
|
// skipped entirely — no mcp.json parsing, no filesystem side effects.
|
|
628
874
|
// Same for agents: when agents is false readAgents is never invoked.
|
|
629
|
-
enabled = {}
|
|
875
|
+
enabled = {},
|
|
876
|
+
// Per-package consent for untrusted packages: a package discovered as a
|
|
877
|
+
// side effect contributes nothing to plan.mcp or plan.agents unless its
|
|
878
|
+
// name is listed in the matching consent list.
|
|
879
|
+
consent = {}) {
|
|
630
880
|
packages = dedupePackages(packages);
|
|
631
881
|
const skillPaths = [];
|
|
882
|
+
const skillTrust = [];
|
|
632
883
|
const seenDir = new Set();
|
|
633
884
|
for (const pkg of packages) {
|
|
885
|
+
// Graduated default, permissive half: skills and slash commands are
|
|
886
|
+
// read-only content registration and stay allowed for every trust tier —
|
|
887
|
+
// surfacing them is the plugin's job. An untrusted package registers
|
|
888
|
+
// exactly like a trusted one, but emits one info line so side-effect
|
|
889
|
+
// content entering the session stays visible to the user.
|
|
890
|
+
if (!pkg.trusted && pkg.skillDirs.length > 0) {
|
|
891
|
+
log(`registering skills and slash commands for untrusted package "${pkg.name}" (${pkg.source})`);
|
|
892
|
+
}
|
|
634
893
|
for (const dir of pkg.skillDirs) {
|
|
635
894
|
if (seenDir.has(dir))
|
|
636
895
|
continue;
|
|
637
896
|
seenDir.add(dir);
|
|
638
897
|
skillPaths.push(dir);
|
|
898
|
+
skillTrust.push({ dir, trusted: pkg.trusted });
|
|
639
899
|
}
|
|
640
900
|
}
|
|
641
901
|
const commands = [];
|
|
@@ -685,17 +945,44 @@ enabled = {}) {
|
|
|
685
945
|
`Load the ${JSON.stringify(info.name)} skill and follow its instructions.`,
|
|
686
946
|
`Context: $ARGUMENTS`,
|
|
687
947
|
].join("\n"),
|
|
948
|
+
trusted: pkg.trusted,
|
|
688
949
|
});
|
|
689
950
|
}
|
|
690
951
|
}
|
|
691
952
|
const mcp = [];
|
|
692
953
|
if (enabled.mcp !== false) {
|
|
954
|
+
const consentedMcp = new Set(consent.mcp ?? []);
|
|
693
955
|
const usedMcp = new Set(taken.mcp ?? []);
|
|
694
956
|
const mcpOwner = new Map();
|
|
695
957
|
for (const name of taken.mcp ?? [])
|
|
696
958
|
mcpOwner.set(name, "user");
|
|
697
959
|
const seenMcpEntry = new Set();
|
|
698
960
|
for (const pkg of packages) {
|
|
961
|
+
// Trust gate: MCP servers are both powerful and opaque to opencode.
|
|
962
|
+
// Untrusted packages are present merely as side effects, so they never
|
|
963
|
+
// contribute servers unless the user consented to the package by name.
|
|
964
|
+
// Host-vouched installs (trusted: true) pass through unchanged.
|
|
965
|
+
if (!pkg.trusted && !consentedMcp.has(pkg.name)) {
|
|
966
|
+
// Slice the credential signal out of the untrusted package's mcp.json
|
|
967
|
+
// so entries carrying headers/env/url credentials get a per-entry
|
|
968
|
+
// refusal line. readMcp runs with warnings off: a refused credential
|
|
969
|
+
// must never be told it "will be stored in opencode's config".
|
|
970
|
+
if (pkg.mcpPath) {
|
|
971
|
+
const entries = [];
|
|
972
|
+
readMcp(pkg, entries, { warn: false });
|
|
973
|
+
let named = 0;
|
|
974
|
+
for (const { key, credentialReason } of entries) {
|
|
975
|
+
if (!credentialReason)
|
|
976
|
+
continue;
|
|
977
|
+
named++;
|
|
978
|
+
log(`skipping MCP server "${pkg.name}/${key}" (${pkg.source}): ${credentialReason}; add it to consent.mcp to admit its servers`);
|
|
979
|
+
}
|
|
980
|
+
if (named === 0) {
|
|
981
|
+
log(`skipping MCP server for untrusted package "${pkg.name}" (${pkg.source}): add it to consent.mcp to admit its servers`);
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
continue;
|
|
985
|
+
}
|
|
699
986
|
const entries = [];
|
|
700
987
|
readMcp(pkg, entries);
|
|
701
988
|
for (const { key, entry } of entries) {
|
|
@@ -717,19 +1004,33 @@ enabled = {}) {
|
|
|
717
1004
|
}
|
|
718
1005
|
mcpOwner.set(k, pkg.source);
|
|
719
1006
|
usedMcp.add(k);
|
|
720
|
-
mcp.push({ key: k, entry });
|
|
1007
|
+
mcp.push({ key: k, entry, trusted: pkg.trusted });
|
|
721
1008
|
}
|
|
722
1009
|
}
|
|
723
1010
|
}
|
|
724
1011
|
const agents = [];
|
|
725
1012
|
if (enabled.agents !== false) {
|
|
1013
|
+
const consentedAgents = new Set(consent.agents ?? []);
|
|
726
1014
|
const usedAgents = new Set(taken.agents ?? []);
|
|
727
1015
|
const agentOwner = new Map();
|
|
728
1016
|
for (const name of taken.agents ?? [])
|
|
729
1017
|
agentOwner.set(name, "user");
|
|
730
1018
|
const seenAgent = new Set();
|
|
731
1019
|
for (const pkg of packages) {
|
|
732
|
-
|
|
1020
|
+
// Trust gate: agents are prompt-and-behavior material that a package
|
|
1021
|
+
// registers into opencode config slots. Untrusted packages are present
|
|
1022
|
+
// merely as side effects, so they contribute agents only when the user
|
|
1023
|
+
// consented to the package by name. Host-vouched installs (trusted:
|
|
1024
|
+
// true) pass through. Note the readAgents call is hoisted so name
|
|
1025
|
+
// validation logs behave identically for every admitted package.
|
|
1026
|
+
const pkgAgents = readAgents(pkg);
|
|
1027
|
+
if (!pkg.trusted && !consentedAgents.has(pkg.name)) {
|
|
1028
|
+
if (pkgAgents.length > 0) {
|
|
1029
|
+
log(`skipping agents for untrusted package "${pkg.name}" (${pkg.source}): add it to consent.agents to admit its agents`);
|
|
1030
|
+
}
|
|
1031
|
+
continue;
|
|
1032
|
+
}
|
|
1033
|
+
for (const { name, agent } of pkgAgents) {
|
|
733
1034
|
const dedupeKey = `${pkg.root}\u0000${name}`;
|
|
734
1035
|
if (seenAgent.has(dedupeKey))
|
|
735
1036
|
continue;
|
|
@@ -748,11 +1049,11 @@ enabled = {}) {
|
|
|
748
1049
|
}
|
|
749
1050
|
agentOwner.set(agentName, pkg.source);
|
|
750
1051
|
usedAgents.add(agentName);
|
|
751
|
-
agents.push({ name: agentName, agent });
|
|
1052
|
+
agents.push({ name: agentName, agent, trusted: pkg.trusted });
|
|
752
1053
|
}
|
|
753
1054
|
}
|
|
754
1055
|
}
|
|
755
|
-
return { skillPaths, commands, mcp, agents };
|
|
1056
|
+
return { skillPaths, skillTrust, commands, mcp, agents };
|
|
756
1057
|
}
|
|
757
1058
|
// Applies a computed plan to the resolved config. Never overwrites user-defined
|
|
758
1059
|
// entries; de-duplicates paths against what the user already configured.
|
|
@@ -793,6 +1094,17 @@ export function applyConfigPatch(config, plan, enabled) {
|
|
|
793
1094
|
if (config.mcp[key])
|
|
794
1095
|
continue;
|
|
795
1096
|
config.mcp[key] = entry;
|
|
1097
|
+
// A package stdio server runs with PLUGIN_DATA pointing at its data
|
|
1098
|
+
// dir. The dir is created only here, when the server is actually
|
|
1099
|
+
// applied -- never at plan time, so planning leaves no trace on disk.
|
|
1100
|
+
if (entry.type === "local" && entry.environment?.PLUGIN_DATA) {
|
|
1101
|
+
try {
|
|
1102
|
+
mkdirSync(entry.environment.PLUGIN_DATA, { recursive: true });
|
|
1103
|
+
}
|
|
1104
|
+
catch {
|
|
1105
|
+
// Non-fatal: the subprocess env still points at the (uncreated) dir.
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
796
1108
|
}
|
|
797
1109
|
}
|
|
798
1110
|
if (doAgents) {
|