opencode-skill-autodiscovery 2.0.0 → 2.1.0
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 +161 -0
- package/dist/discovery-cache.js.map +1 -0
- package/dist/discovery.d.ts +18 -6
- package/dist/discovery.d.ts.map +1 -1
- package/dist/discovery.js +373 -98
- 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,
|
|
@@ -186,7 +252,12 @@ function findSkillDirsUnder(realRoot, dir, out, seen, depth = 0) {
|
|
|
186
252
|
log(`skipping skill dir "${child}": resolves outside "${realRoot}"`);
|
|
187
253
|
continue;
|
|
188
254
|
}
|
|
189
|
-
|
|
255
|
+
const childSkillMd = join(child, "SKILL.md");
|
|
256
|
+
if (isRegularFile(childSkillMd)) {
|
|
257
|
+
// A skill dir whose SKILL.md symlinks outside the walk root must not be
|
|
258
|
+
// emitted: readSkillInfo (and opencode) would read the outside bytes.
|
|
259
|
+
if (!resolveContained(realRoot, childSkillMd))
|
|
260
|
+
continue;
|
|
190
261
|
out.add(child);
|
|
191
262
|
}
|
|
192
263
|
else {
|
|
@@ -201,7 +272,10 @@ function hasPluginLayout(root) {
|
|
|
201
272
|
const skillsRoot = join(root, "skills");
|
|
202
273
|
if (isDirectory(skillsRoot)) {
|
|
203
274
|
try {
|
|
204
|
-
if (readdirSync(skillsRoot).some((e) =>
|
|
275
|
+
if (readdirSync(skillsRoot).some((e) => {
|
|
276
|
+
const skillMd = join(skillsRoot, e, "SKILL.md");
|
|
277
|
+
return resolveContained(root, skillMd) !== null && isRegularFile(skillMd);
|
|
278
|
+
})) {
|
|
205
279
|
return true;
|
|
206
280
|
}
|
|
207
281
|
}
|
|
@@ -228,28 +302,86 @@ function hasRootAgentFiles(root) {
|
|
|
228
302
|
return entries.some((e) => {
|
|
229
303
|
if (!e.endsWith(".md"))
|
|
230
304
|
return false;
|
|
231
|
-
const
|
|
232
|
-
if (!isRegularFile(
|
|
305
|
+
const contained = resolveContained(root, join(root, e));
|
|
306
|
+
if (!contained || !isRegularFile(contained))
|
|
233
307
|
return false;
|
|
234
|
-
|
|
235
|
-
|
|
308
|
+
try {
|
|
309
|
+
return cachedRead("agent-frontmatter-check", contained, hasAgentFrontmatter);
|
|
310
|
+
}
|
|
311
|
+
catch {
|
|
236
312
|
return false;
|
|
237
|
-
|
|
313
|
+
}
|
|
238
314
|
});
|
|
239
315
|
}
|
|
316
|
+
function hasAgentFrontmatter(content) {
|
|
317
|
+
const m = /^---\s*\n([\s\S]*?)\n---/.exec(content)?.[1];
|
|
318
|
+
if (!m)
|
|
319
|
+
return false;
|
|
320
|
+
return /^name:[ \t]/.test(m) || /^description:[ \t]/.test(m);
|
|
321
|
+
}
|
|
240
322
|
// Returns directories under `root` that are individual plugin roots. This
|
|
241
323
|
// mirrors what VS Code's installed.json points at (each {host}/{org}/{repo}
|
|
242
324
|
// clone, and inside a marketplace like agency-agents each
|
|
243
325
|
// ref_plugins/plugins/<division> dir), so a clone on disk is registered
|
|
244
326
|
// per-plugin instead of as one opaque tree that hides its agents.
|
|
327
|
+
//
|
|
328
|
+
// Hardened like findSkillDirs: traversal and emitted roots are keyed on real
|
|
329
|
+
// (symlink-resolved) paths, so symlink cycles — self-referential or
|
|
330
|
+
// ancestor-pointing — terminate instead of growing ever-longer lexical paths
|
|
331
|
+
// until stack exhaustion, and nothing that resolves outside the starting root
|
|
332
|
+
// is emitted. `isDirectory` uses statSync, which follows symlinks, so the
|
|
333
|
+
// realpath dedupe below is what bounds the walk.
|
|
245
334
|
export function findPluginRoots(root, out) {
|
|
246
|
-
|
|
247
|
-
|
|
335
|
+
let resolved;
|
|
336
|
+
try {
|
|
337
|
+
resolved = realpathSync(root);
|
|
338
|
+
}
|
|
339
|
+
catch {
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
findPluginRootsUnder(resolved, resolved, out, new Set());
|
|
343
|
+
}
|
|
344
|
+
// Equivalent to a caller doing `findPluginRoots(root, roots)` and converting
|
|
345
|
+
// each hit with `packageFromDir(pluginRoot, source, trusted)` — the exact
|
|
346
|
+
// pattern collectAgentPluginRoot's fallback and collectClaude's remote walk
|
|
347
|
+
// both used before discovery-cache.ts existed — except a cheap fingerprint
|
|
348
|
+
// check of `root` (see fingerprintTree) can skip the real walk, and every
|
|
349
|
+
// file read inside it, entirely when the subtree hasn't changed since the
|
|
350
|
+
// last run. A miss falls back to exactly the walk+convert callers did
|
|
351
|
+
// before, so this is never slower than the un-cached baseline.
|
|
352
|
+
//
|
|
353
|
+
// `exclude` is deliberately the caller's job, applied identically on a hit
|
|
354
|
+
// or a miss, so changing the exclude list never has to invalidate the cache.
|
|
355
|
+
function findPluginPackagesCached(root, source, trusted) {
|
|
356
|
+
const fingerprint = fingerprintTree(root);
|
|
357
|
+
const key = JSON.stringify([source, root]);
|
|
358
|
+
const cached = getCachedPackages(key, fingerprint);
|
|
359
|
+
if (cached)
|
|
360
|
+
return cached;
|
|
361
|
+
const roots = [];
|
|
362
|
+
findPluginRoots(root, roots);
|
|
363
|
+
const packages = [];
|
|
364
|
+
for (const pluginRoot of roots) {
|
|
365
|
+
const pkg = packageFromDir(pluginRoot, source, trusted);
|
|
366
|
+
if (pkg)
|
|
367
|
+
packages.push(pkg);
|
|
368
|
+
}
|
|
369
|
+
setCachedPackages(key, fingerprint, packages);
|
|
370
|
+
return packages;
|
|
371
|
+
}
|
|
372
|
+
function findPluginRootsUnder(realRoot, dir, out, seen, depth = 0) {
|
|
373
|
+
if (depth > MAX_WALK_DEPTH)
|
|
374
|
+
return;
|
|
375
|
+
if (seen.has(dir))
|
|
376
|
+
return;
|
|
377
|
+
seen.add(dir);
|
|
378
|
+
if (hasPluginLayout(dir)) {
|
|
379
|
+
out.push(dir);
|
|
248
380
|
return;
|
|
249
381
|
}
|
|
250
382
|
let entries;
|
|
251
383
|
try {
|
|
252
|
-
entries = readdirSync(
|
|
384
|
+
entries = readdirSync(dir);
|
|
253
385
|
}
|
|
254
386
|
catch {
|
|
255
387
|
return;
|
|
@@ -257,17 +389,31 @@ export function findPluginRoots(root, out) {
|
|
|
257
389
|
for (const entry of entries) {
|
|
258
390
|
if (entry === ".git" || entry === "node_modules")
|
|
259
391
|
continue;
|
|
260
|
-
const full = join(
|
|
392
|
+
const full = join(dir, entry);
|
|
261
393
|
if (!isDirectory(full))
|
|
262
394
|
continue;
|
|
263
|
-
|
|
395
|
+
let child;
|
|
396
|
+
try {
|
|
397
|
+
child = realpathSync(full);
|
|
398
|
+
}
|
|
399
|
+
catch {
|
|
400
|
+
continue;
|
|
401
|
+
}
|
|
402
|
+
if (!contains(realRoot, child)) {
|
|
403
|
+
log(`skipping plugin dir "${child}": resolves outside "${realRoot}"`);
|
|
404
|
+
continue;
|
|
405
|
+
}
|
|
406
|
+
findPluginRootsUnder(realRoot, child, out, seen, depth + 1);
|
|
264
407
|
}
|
|
265
408
|
}
|
|
266
409
|
// True when a legacy Claude Code plugin declares at least one agent, so an
|
|
267
410
|
// agents-only plugin is still discovered even though it ships no skills.
|
|
268
411
|
function hasClaudeAgents(root) {
|
|
412
|
+
const manifestPath = resolveContained(root, join(root, ".claude-plugin", "plugin.json"));
|
|
413
|
+
if (!manifestPath)
|
|
414
|
+
return false;
|
|
269
415
|
try {
|
|
270
|
-
const manifest =
|
|
416
|
+
const manifest = cachedRead("claude-legacy-manifest", manifestPath, (content) => JSON.parse(content));
|
|
271
417
|
if (typeof manifest !== "object" || manifest === null)
|
|
272
418
|
return false;
|
|
273
419
|
const agents = manifest.agents;
|
|
@@ -285,8 +431,9 @@ function hasClaudeAgents(root) {
|
|
|
285
431
|
function hasFlatAgents(root) {
|
|
286
432
|
if (hasRootAgentFiles(root))
|
|
287
433
|
return true;
|
|
434
|
+
const agentsDir = join(root, "agents");
|
|
288
435
|
try {
|
|
289
|
-
return readdirSync(
|
|
436
|
+
return readdirSync(agentsDir).some((e) => e.endsWith(".md") && resolveContained(root, join(agentsDir, e)) !== null);
|
|
290
437
|
}
|
|
291
438
|
catch {
|
|
292
439
|
return false;
|
|
@@ -328,15 +475,9 @@ export function packageFromDir(root, source, trusted = false) {
|
|
|
328
475
|
skillDirs: [...skillDirs],
|
|
329
476
|
};
|
|
330
477
|
}
|
|
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
478
|
function vsCodeDataRoots(extra) {
|
|
338
479
|
const home = homedir();
|
|
339
|
-
const
|
|
480
|
+
const builtIn = new Set([
|
|
340
481
|
join(home, ".vscode"),
|
|
341
482
|
// Linux
|
|
342
483
|
join(home, ".config", "Code"),
|
|
@@ -356,36 +497,63 @@ function vsCodeDataRoots(extra) {
|
|
|
356
497
|
join(home, ".vscode-server-insiders"),
|
|
357
498
|
join(home, ".vscode-remote"),
|
|
358
499
|
]);
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
500
|
+
const roots = [...builtIn].map((root) => ({ root, trusted: true }));
|
|
501
|
+
const seen = new Set(builtIn);
|
|
502
|
+
for (const root of extra) {
|
|
503
|
+
if (seen.has(root))
|
|
504
|
+
continue;
|
|
505
|
+
seen.add(root);
|
|
506
|
+
roots.push({ root, trusted: false });
|
|
507
|
+
}
|
|
508
|
+
return roots;
|
|
362
509
|
}
|
|
363
510
|
function agentPluginDirs(roots) {
|
|
364
|
-
const
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
511
|
+
const seen = new Set();
|
|
512
|
+
const dirs = [];
|
|
513
|
+
for (const { root, trusted } of roots) {
|
|
514
|
+
for (const dir of [
|
|
515
|
+
join(root, "agent-plugins"),
|
|
516
|
+
join(root, "agentPlugins"),
|
|
517
|
+
join(root, "data", "agent-plugins"),
|
|
518
|
+
join(root, "data", "agentPlugins"),
|
|
519
|
+
]) {
|
|
520
|
+
if (seen.has(dir))
|
|
521
|
+
continue;
|
|
522
|
+
seen.add(dir);
|
|
523
|
+
dirs.push({ root: dir, trusted });
|
|
524
|
+
}
|
|
370
525
|
}
|
|
371
|
-
return
|
|
526
|
+
return dirs;
|
|
372
527
|
}
|
|
528
|
+
// Resolves an `installed.json` `pluginUri` to an on-disk plugin root. Only
|
|
529
|
+
// `file:` URLs name a local directory: non-file schemes and malformed URLs are
|
|
530
|
+
// dropped (with a log) rather than echoed into the filesystem, and the
|
|
531
|
+
// resolved target must be an existing directory. `fileURLToPath` owns the
|
|
532
|
+
// slash/percent/drive-letter normalization; `resolve` then canonicalizes the
|
|
533
|
+
// result so a technically valid URL like `file:////abs` yields the same root
|
|
534
|
+
// string as its `file:///abs` spelling.
|
|
373
535
|
function vscodePluginPath(pluginUri) {
|
|
374
|
-
|
|
375
|
-
if (!m)
|
|
376
|
-
return pluginUri;
|
|
377
|
-
let raw;
|
|
536
|
+
let resolved;
|
|
378
537
|
try {
|
|
379
|
-
|
|
538
|
+
resolved = resolve(fileURLToPath(pluginUri));
|
|
380
539
|
}
|
|
381
540
|
catch {
|
|
382
|
-
|
|
541
|
+
log(`ignoring plugin URI "${pluginUri}": not a resolvable file: URL`);
|
|
542
|
+
return null;
|
|
543
|
+
}
|
|
544
|
+
// `file://` (and `file:///`) resolve to the filesystem root; a plugin root
|
|
545
|
+
// is never the root itself, and walking it would scan the whole disk.
|
|
546
|
+
if (dirname(resolved) === resolved) {
|
|
547
|
+
log(`ignoring plugin URI "${pluginUri}": filesystem root is not a plugin root`);
|
|
548
|
+
return null;
|
|
549
|
+
}
|
|
550
|
+
if (!isDirectory(resolved)) {
|
|
551
|
+
log(`ignoring plugin URI "${pluginUri}": "${resolved}" is not a directory`);
|
|
552
|
+
return null;
|
|
383
553
|
}
|
|
384
|
-
|
|
385
|
-
raw = raw.slice(1);
|
|
386
|
-
return raw;
|
|
554
|
+
return resolved;
|
|
387
555
|
}
|
|
388
|
-
export function collectVscodeManifest(out, installedJson, exclude = []) {
|
|
556
|
+
export function collectVscodeManifest(out, installedJson, exclude = [], trusted = true) {
|
|
389
557
|
if (!existsSync(installedJson))
|
|
390
558
|
return;
|
|
391
559
|
let manifest;
|
|
@@ -395,15 +563,37 @@ export function collectVscodeManifest(out, installedJson, exclude = []) {
|
|
|
395
563
|
catch {
|
|
396
564
|
return;
|
|
397
565
|
}
|
|
398
|
-
|
|
399
|
-
|
|
566
|
+
// installed.json is manifest input: fail closed on a malformed shape rather
|
|
567
|
+
// than iterating a non-array (which throws) or trusting entry types.
|
|
568
|
+
const installed = isPlainObject(manifest) ? manifest.installed : undefined;
|
|
569
|
+
if (!Array.isArray(installed)) {
|
|
570
|
+
log(`ignoring "${installedJson}": "installed" is not an array`);
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
const root = dirname(installedJson);
|
|
574
|
+
for (const plugin of installed) {
|
|
575
|
+
if (!isPlainObject(plugin)) {
|
|
576
|
+
log(`ignoring entry in "${installedJson}": entry is not an object`);
|
|
577
|
+
continue;
|
|
578
|
+
}
|
|
579
|
+
const pluginUri = plugin.pluginUri;
|
|
580
|
+
if (typeof pluginUri !== "string") {
|
|
581
|
+
log(`ignoring entry in "${installedJson}": "pluginUri" is not a string`);
|
|
582
|
+
continue;
|
|
583
|
+
}
|
|
584
|
+
const dir = vscodePluginPath(pluginUri);
|
|
585
|
+
if (!dir)
|
|
586
|
+
continue;
|
|
587
|
+
// installed.json is manifest input: under an untrusted root the recorded
|
|
588
|
+
// install location must resolve inside that root. A trusted home root is
|
|
589
|
+
// allowed to point at a global extension directory outside the data root.
|
|
590
|
+
if (!trusted && !contains(root, dir)) {
|
|
591
|
+
log(`skipping plugin URI "${pluginUri}": "${dir}" is outside untrusted root "${root}"`);
|
|
400
592
|
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
593
|
}
|
|
594
|
+
const pkg = packageFromDir(dir, "vscode", trusted);
|
|
595
|
+
if (pkg && !isExcluded(pkg, exclude))
|
|
596
|
+
out.push(pkg);
|
|
407
597
|
}
|
|
408
598
|
}
|
|
409
599
|
// Remote hosts: VS Code syncs client skills into
|
|
@@ -411,7 +601,7 @@ export function collectVscodeManifest(out, installedJson, exclude = []) {
|
|
|
411
601
|
// cache.json. Resolve each entry so the materialized synced-customization
|
|
412
602
|
// bundle ("VS Code Synced Data" Open Plugin, skills/<name>/SKILL.md) is
|
|
413
603
|
// discovered.
|
|
414
|
-
export function collectVscodeCache(out, cacheJson, exclude = []) {
|
|
604
|
+
export function collectVscodeCache(out, cacheJson, exclude = [], trusted = true) {
|
|
415
605
|
if (!existsSync(cacheJson))
|
|
416
606
|
return;
|
|
417
607
|
let entries;
|
|
@@ -431,20 +621,22 @@ export function collectVscodeCache(out, cacheJson, exclude = []) {
|
|
|
431
621
|
const nonce = typeof entry.nonce === "string" && entry.nonce
|
|
432
622
|
? sanitizeKey(entry.nonce)
|
|
433
623
|
: "default";
|
|
624
|
+
// Layouts without the {nonce} subdirectory materialize the bundle
|
|
625
|
+
// directly under {key}; falling back only when the nonce dir is absent
|
|
626
|
+
// avoids re-descending into it.
|
|
434
627
|
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);
|
|
628
|
+
const dir = isDirectory(nonceDir) ? nonceDir : join(parent, key);
|
|
629
|
+
if (!isDirectory(dir))
|
|
630
|
+
continue;
|
|
631
|
+
// cache.json is manifest input: under an untrusted root the bundle must
|
|
632
|
+
// resolve inside that root, not be redirected elsewhere on disk.
|
|
633
|
+
if (!trusted && !contains(parent, dir)) {
|
|
634
|
+
log(`skipping cache entry "${entry.uri}": "${dir}" is outside untrusted root "${parent}"`);
|
|
635
|
+
continue;
|
|
447
636
|
}
|
|
637
|
+
const pkg = packageFromDir(dir, "vscode", trusted);
|
|
638
|
+
if (pkg && !isExcluded(pkg, exclude))
|
|
639
|
+
out.push(pkg);
|
|
448
640
|
}
|
|
449
641
|
}
|
|
450
642
|
// Mirrors the server-side AgentPluginManager sanitizer so we can resolve the
|
|
@@ -456,11 +648,11 @@ function sanitizeKey(value) {
|
|
|
456
648
|
.replace(/^-|-$/g, "")
|
|
457
649
|
.substring(0, 128);
|
|
458
650
|
}
|
|
459
|
-
function collectAgentPluginRoot(out, root, exclude = []) {
|
|
651
|
+
function collectAgentPluginRoot(out, root, exclude = [], trusted = true) {
|
|
460
652
|
const installedJson = join(root, "installed.json");
|
|
461
653
|
const cacheJson = join(root, "cache.json");
|
|
462
|
-
collectVscodeManifest(out, installedJson, exclude);
|
|
463
|
-
collectVscodeCache(out, cacheJson, exclude);
|
|
654
|
+
collectVscodeManifest(out, installedJson, exclude, trusted);
|
|
655
|
+
collectVscodeCache(out, cacheJson, exclude, trusted);
|
|
464
656
|
// `installed.json` is the authoritative record of what VS Code installed;
|
|
465
657
|
// when it exists, respect it exactly and skip the fallback walk so
|
|
466
658
|
// cloned-but-not-installed marketplaces stay hidden.
|
|
@@ -471,22 +663,23 @@ function collectAgentPluginRoot(out, root, exclude = []) {
|
|
|
471
663
|
// clone) has no manifest entry at all. Only suppress the walk when
|
|
472
664
|
// installed.json exists, so any clone actually on disk is still discovered.
|
|
473
665
|
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))
|
|
666
|
+
for (const pkg of findPluginPackagesCached(root, "vscode", false)) {
|
|
667
|
+
if (!isExcluded(pkg, exclude))
|
|
479
668
|
out.push(pkg);
|
|
480
669
|
}
|
|
481
670
|
}
|
|
482
671
|
}
|
|
483
672
|
export function collectVscode(out, extra, exclude = []) {
|
|
484
|
-
for (const
|
|
485
|
-
collectAgentPluginRoot(out,
|
|
673
|
+
for (const { root, trusted } of agentPluginDirs(vsCodeDataRoots(extra))) {
|
|
674
|
+
collectAgentPluginRoot(out, root, exclude, trusted);
|
|
486
675
|
}
|
|
487
676
|
}
|
|
488
677
|
// --- Claude Code plugin discovery ------------------------------------------
|
|
489
|
-
|
|
678
|
+
// `trusted` defaults to true: every Claude root is home-scoped and
|
|
679
|
+
// host-managed (`~/.claude/...`), so it stays trusted. The parameter exists
|
|
680
|
+
// for symmetry with the VS Code collectors, letting a caller mark a non-home
|
|
681
|
+
// manifest untrusted.
|
|
682
|
+
export function collectClaudeManifest(out, installedJson, exclude = [], trusted = true) {
|
|
490
683
|
if (!existsSync(installedJson))
|
|
491
684
|
return;
|
|
492
685
|
let manifest;
|
|
@@ -496,13 +689,32 @@ export function collectClaudeManifest(out, installedJson, exclude = []) {
|
|
|
496
689
|
catch {
|
|
497
690
|
return;
|
|
498
691
|
}
|
|
499
|
-
|
|
692
|
+
// installed_plugins.json is manifest input: fail closed when `plugins` is not
|
|
693
|
+
// a plain object, when a version bucket is not an array, or when an entry
|
|
694
|
+
// lacks a string installPath.
|
|
695
|
+
const plugins = isPlainObject(manifest) ? manifest.plugins : undefined;
|
|
696
|
+
if (!isPlainObject(plugins)) {
|
|
697
|
+
log(`ignoring "${installedJson}": "plugins" is not an object`);
|
|
698
|
+
return;
|
|
699
|
+
}
|
|
700
|
+
for (const versions of Object.values(plugins)) {
|
|
701
|
+
if (!Array.isArray(versions)) {
|
|
702
|
+
log(`ignoring entry in "${installedJson}": plugin versions are not an array`);
|
|
703
|
+
continue;
|
|
704
|
+
}
|
|
500
705
|
for (const plugin of versions) {
|
|
501
|
-
if (plugin
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
out.push(pkg);
|
|
706
|
+
if (!isPlainObject(plugin)) {
|
|
707
|
+
log(`ignoring entry in "${installedJson}": entry is not an object`);
|
|
708
|
+
continue;
|
|
505
709
|
}
|
|
710
|
+
const installPath = plugin.installPath;
|
|
711
|
+
if (typeof installPath !== "string") {
|
|
712
|
+
log(`ignoring entry in "${installedJson}": "installPath" is not a string`);
|
|
713
|
+
continue;
|
|
714
|
+
}
|
|
715
|
+
const pkg = packageFromDir(installPath, "claude", trusted);
|
|
716
|
+
if (pkg && !isExcluded(pkg, exclude))
|
|
717
|
+
out.push(pkg);
|
|
506
718
|
}
|
|
507
719
|
}
|
|
508
720
|
}
|
|
@@ -522,11 +734,8 @@ export function collectClaude(out, exclude = []) {
|
|
|
522
734
|
// the same-source mirror dedup in planConfig collapses any overlap.
|
|
523
735
|
const remoteRoot = join(home, ".claude", "remote", "plugins");
|
|
524
736
|
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))
|
|
737
|
+
for (const pkg of findPluginPackagesCached(remoteRoot, "claude", true)) {
|
|
738
|
+
if (!isExcluded(pkg, exclude))
|
|
530
739
|
out.push(pkg);
|
|
531
740
|
}
|
|
532
741
|
}
|
|
@@ -626,16 +835,30 @@ export function planConfig(packages, taken = {},
|
|
|
626
835
|
// Mirrors the plugin options: when mcp is false the MCP subsystem is
|
|
627
836
|
// skipped entirely — no mcp.json parsing, no filesystem side effects.
|
|
628
837
|
// Same for agents: when agents is false readAgents is never invoked.
|
|
629
|
-
enabled = {}
|
|
838
|
+
enabled = {},
|
|
839
|
+
// Per-package consent for untrusted packages: a package discovered as a
|
|
840
|
+
// side effect contributes nothing to plan.mcp or plan.agents unless its
|
|
841
|
+
// name is listed in the matching consent list.
|
|
842
|
+
consent = {}) {
|
|
630
843
|
packages = dedupePackages(packages);
|
|
631
844
|
const skillPaths = [];
|
|
845
|
+
const skillTrust = [];
|
|
632
846
|
const seenDir = new Set();
|
|
633
847
|
for (const pkg of packages) {
|
|
848
|
+
// Graduated default, permissive half: skills and slash commands are
|
|
849
|
+
// read-only content registration and stay allowed for every trust tier —
|
|
850
|
+
// surfacing them is the plugin's job. An untrusted package registers
|
|
851
|
+
// exactly like a trusted one, but emits one info line so side-effect
|
|
852
|
+
// content entering the session stays visible to the user.
|
|
853
|
+
if (!pkg.trusted && pkg.skillDirs.length > 0) {
|
|
854
|
+
log(`registering skills and slash commands for untrusted package "${pkg.name}" (${pkg.source})`);
|
|
855
|
+
}
|
|
634
856
|
for (const dir of pkg.skillDirs) {
|
|
635
857
|
if (seenDir.has(dir))
|
|
636
858
|
continue;
|
|
637
859
|
seenDir.add(dir);
|
|
638
860
|
skillPaths.push(dir);
|
|
861
|
+
skillTrust.push({ dir, trusted: pkg.trusted });
|
|
639
862
|
}
|
|
640
863
|
}
|
|
641
864
|
const commands = [];
|
|
@@ -685,17 +908,44 @@ enabled = {}) {
|
|
|
685
908
|
`Load the ${JSON.stringify(info.name)} skill and follow its instructions.`,
|
|
686
909
|
`Context: $ARGUMENTS`,
|
|
687
910
|
].join("\n"),
|
|
911
|
+
trusted: pkg.trusted,
|
|
688
912
|
});
|
|
689
913
|
}
|
|
690
914
|
}
|
|
691
915
|
const mcp = [];
|
|
692
916
|
if (enabled.mcp !== false) {
|
|
917
|
+
const consentedMcp = new Set(consent.mcp ?? []);
|
|
693
918
|
const usedMcp = new Set(taken.mcp ?? []);
|
|
694
919
|
const mcpOwner = new Map();
|
|
695
920
|
for (const name of taken.mcp ?? [])
|
|
696
921
|
mcpOwner.set(name, "user");
|
|
697
922
|
const seenMcpEntry = new Set();
|
|
698
923
|
for (const pkg of packages) {
|
|
924
|
+
// Trust gate: MCP servers are both powerful and opaque to opencode.
|
|
925
|
+
// Untrusted packages are present merely as side effects, so they never
|
|
926
|
+
// contribute servers unless the user consented to the package by name.
|
|
927
|
+
// Host-vouched installs (trusted: true) pass through unchanged.
|
|
928
|
+
if (!pkg.trusted && !consentedMcp.has(pkg.name)) {
|
|
929
|
+
// Slice the credential signal out of the untrusted package's mcp.json
|
|
930
|
+
// so entries carrying headers/env/url credentials get a per-entry
|
|
931
|
+
// refusal line. readMcp runs with warnings off: a refused credential
|
|
932
|
+
// must never be told it "will be stored in opencode's config".
|
|
933
|
+
if (pkg.mcpPath) {
|
|
934
|
+
const entries = [];
|
|
935
|
+
readMcp(pkg, entries, { warn: false });
|
|
936
|
+
let named = 0;
|
|
937
|
+
for (const { key, credentialReason } of entries) {
|
|
938
|
+
if (!credentialReason)
|
|
939
|
+
continue;
|
|
940
|
+
named++;
|
|
941
|
+
log(`skipping MCP server "${pkg.name}/${key}" (${pkg.source}): ${credentialReason}; add it to consent.mcp to admit its servers`);
|
|
942
|
+
}
|
|
943
|
+
if (named === 0) {
|
|
944
|
+
log(`skipping MCP server for untrusted package "${pkg.name}" (${pkg.source}): add it to consent.mcp to admit its servers`);
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
continue;
|
|
948
|
+
}
|
|
699
949
|
const entries = [];
|
|
700
950
|
readMcp(pkg, entries);
|
|
701
951
|
for (const { key, entry } of entries) {
|
|
@@ -717,19 +967,33 @@ enabled = {}) {
|
|
|
717
967
|
}
|
|
718
968
|
mcpOwner.set(k, pkg.source);
|
|
719
969
|
usedMcp.add(k);
|
|
720
|
-
mcp.push({ key: k, entry });
|
|
970
|
+
mcp.push({ key: k, entry, trusted: pkg.trusted });
|
|
721
971
|
}
|
|
722
972
|
}
|
|
723
973
|
}
|
|
724
974
|
const agents = [];
|
|
725
975
|
if (enabled.agents !== false) {
|
|
976
|
+
const consentedAgents = new Set(consent.agents ?? []);
|
|
726
977
|
const usedAgents = new Set(taken.agents ?? []);
|
|
727
978
|
const agentOwner = new Map();
|
|
728
979
|
for (const name of taken.agents ?? [])
|
|
729
980
|
agentOwner.set(name, "user");
|
|
730
981
|
const seenAgent = new Set();
|
|
731
982
|
for (const pkg of packages) {
|
|
732
|
-
|
|
983
|
+
// Trust gate: agents are prompt-and-behavior material that a package
|
|
984
|
+
// registers into opencode config slots. Untrusted packages are present
|
|
985
|
+
// merely as side effects, so they contribute agents only when the user
|
|
986
|
+
// consented to the package by name. Host-vouched installs (trusted:
|
|
987
|
+
// true) pass through. Note the readAgents call is hoisted so name
|
|
988
|
+
// validation logs behave identically for every admitted package.
|
|
989
|
+
const pkgAgents = readAgents(pkg);
|
|
990
|
+
if (!pkg.trusted && !consentedAgents.has(pkg.name)) {
|
|
991
|
+
if (pkgAgents.length > 0) {
|
|
992
|
+
log(`skipping agents for untrusted package "${pkg.name}" (${pkg.source}): add it to consent.agents to admit its agents`);
|
|
993
|
+
}
|
|
994
|
+
continue;
|
|
995
|
+
}
|
|
996
|
+
for (const { name, agent } of pkgAgents) {
|
|
733
997
|
const dedupeKey = `${pkg.root}\u0000${name}`;
|
|
734
998
|
if (seenAgent.has(dedupeKey))
|
|
735
999
|
continue;
|
|
@@ -748,11 +1012,11 @@ enabled = {}) {
|
|
|
748
1012
|
}
|
|
749
1013
|
agentOwner.set(agentName, pkg.source);
|
|
750
1014
|
usedAgents.add(agentName);
|
|
751
|
-
agents.push({ name: agentName, agent });
|
|
1015
|
+
agents.push({ name: agentName, agent, trusted: pkg.trusted });
|
|
752
1016
|
}
|
|
753
1017
|
}
|
|
754
1018
|
}
|
|
755
|
-
return { skillPaths, commands, mcp, agents };
|
|
1019
|
+
return { skillPaths, skillTrust, commands, mcp, agents };
|
|
756
1020
|
}
|
|
757
1021
|
// Applies a computed plan to the resolved config. Never overwrites user-defined
|
|
758
1022
|
// entries; de-duplicates paths against what the user already configured.
|
|
@@ -793,6 +1057,17 @@ export function applyConfigPatch(config, plan, enabled) {
|
|
|
793
1057
|
if (config.mcp[key])
|
|
794
1058
|
continue;
|
|
795
1059
|
config.mcp[key] = entry;
|
|
1060
|
+
// A package stdio server runs with PLUGIN_DATA pointing at its data
|
|
1061
|
+
// dir. The dir is created only here, when the server is actually
|
|
1062
|
+
// applied -- never at plan time, so planning leaves no trace on disk.
|
|
1063
|
+
if (entry.type === "local" && entry.environment?.PLUGIN_DATA) {
|
|
1064
|
+
try {
|
|
1065
|
+
mkdirSync(entry.environment.PLUGIN_DATA, { recursive: true });
|
|
1066
|
+
}
|
|
1067
|
+
catch {
|
|
1068
|
+
// Non-fatal: the subprocess env still points at the (uncreated) dir.
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
796
1071
|
}
|
|
797
1072
|
}
|
|
798
1073
|
if (doAgents) {
|