zidane 6.4.4 → 6.5.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 +1 -0
- package/dist/acp-cli.js +7 -6
- package/dist/acp-cli.js.map +1 -1
- package/dist/{acp-BLO58K3F.js → acp-o-LQAjFw.js} +4 -5
- package/dist/{acp-BLO58K3F.js.map → acp-o-LQAjFw.js.map} +1 -1
- package/dist/acp.d.ts +1 -1
- package/dist/acp.js +1 -1
- package/dist/{agent-8sUadp5M.js → agent-BVNkC1h4.js} +886 -113
- package/dist/agent-BVNkC1h4.js.map +1 -0
- package/dist/agent.d.ts +1 -1
- package/dist/agent.js +1 -2
- package/dist/{anthropic-D3lkw5mv.js → anthropic-Dov2mC5e.js} +3 -3
- package/dist/{anthropic-D3lkw5mv.js.map → anthropic-Dov2mC5e.js.map} +1 -1
- package/dist/{auth-BzYbbdN_.js → auth-B8ZuBcKC.js} +5 -5
- package/dist/{auth-BzYbbdN_.js.map → auth-B8ZuBcKC.js.map} +1 -1
- package/dist/chat/pure.d.ts +3 -3
- package/dist/chat/pure.js +2 -2
- package/dist/chat.d.ts +13 -44
- package/dist/chat.d.ts.map +1 -1
- package/dist/chat.js +8 -7
- package/dist/chat.js.map +1 -1
- package/dist/{edit-utils-EGosADZq.js → context-breakdown-BLF10tOO.js} +154 -2
- package/dist/context-breakdown-BLF10tOO.js.map +1 -0
- package/dist/{context-transport-D29WvHcm.js → context-transport-D-daWOmp.js} +3 -3
- package/dist/{context-transport-D29WvHcm.js.map → context-transport-D-daWOmp.js.map} +1 -1
- package/dist/contexts/daytona.d.ts +1 -1
- package/dist/contexts/e2b.d.ts +1 -1
- package/dist/eval.d.ts +1 -1
- package/dist/eval.js +1 -1
- package/dist/extensions.d.ts +2 -2
- package/dist/extensions.js +2 -2
- package/dist/headless.d.ts +1 -1
- package/dist/headless.js +3 -3
- package/dist/{index-C-Nd2ZbV.d.ts → index-CWBSrqI6.d.ts} +676 -3
- package/dist/index-CWBSrqI6.d.ts.map +1 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +13 -13
- package/dist/{login-CMVVnAHS.js → login-CR3fDHXh.js} +3 -3
- package/dist/{login-CMVVnAHS.js.map → login-CR3fDHXh.js.map} +1 -1
- package/dist/{mcp-3GXUBwur.js → mcp-C6IkXNOS.js} +87 -6
- package/dist/mcp-C6IkXNOS.js.map +1 -0
- package/dist/mcp.d.ts +2 -2
- package/dist/mcp.js +3 -2
- package/dist/{messages-C01kKGj0.js → messages-C_H9soi5.js} +2 -3
- package/dist/{messages-C01kKGj0.js.map → messages-C_H9soi5.js.map} +1 -1
- package/dist/{openai-compat-B3c_hzwf.js → openai-compat-CnHeo4Lz.js} +216 -3
- package/dist/openai-compat-CnHeo4Lz.js.map +1 -0
- package/dist/origin-pinned-fetch-CqG8VZIq.js +159 -0
- package/dist/origin-pinned-fetch-CqG8VZIq.js.map +1 -0
- package/dist/output/stream-json.d.ts +1 -1
- package/dist/output/terminal.d.ts +1 -1
- package/dist/plugins-2PMTVcp-.js +1174 -0
- package/dist/plugins-2PMTVcp-.js.map +1 -0
- package/dist/plugins.d.ts +2 -0
- package/dist/plugins.js +2 -0
- package/dist/presets.d.ts +1 -1
- package/dist/presets.js +1 -1
- package/dist/providers/anthropic.d.ts +1 -1
- package/dist/providers/anthropic.js +1 -1
- package/dist/providers/arcee.d.ts +1 -1
- package/dist/providers/arcee.js +1 -1
- package/dist/providers/baseten.d.ts +1 -1
- package/dist/providers/baseten.js +1 -1
- package/dist/providers/cerebras.d.ts +1 -1
- package/dist/providers/cerebras.js +1 -1
- package/dist/providers/local.d.ts +1 -1
- package/dist/providers/local.js +1 -1
- package/dist/providers/openai-compat.d.ts +1 -1
- package/dist/providers/openai-compat.js +1 -1
- package/dist/providers/openai.d.ts +1 -1
- package/dist/providers/openai.js +2 -2
- package/dist/providers/openrouter.d.ts +1 -1
- package/dist/providers/openrouter.js +1 -1
- package/dist/providers/xai.d.ts +1 -1
- package/dist/providers/xai.js +1 -1
- package/dist/{providers-z1vU0b2Q.js → providers-B6mFe7OD.js} +3 -3
- package/dist/{providers-z1vU0b2Q.js.map → providers-B6mFe7OD.js.map} +1 -1
- package/dist/providers.d.ts +1 -1
- package/dist/providers.js +4 -4
- package/dist/restate.d.ts +178 -2
- package/dist/restate.d.ts.map +1 -1
- package/dist/restate.js +463 -7
- package/dist/restate.js.map +1 -1
- package/dist/session/sqlite.d.ts +1 -1
- package/dist/{session-cr9VdoEO.js → session-VpLrNaFQ.js} +2 -2
- package/dist/{session-cr9VdoEO.js.map → session-VpLrNaFQ.js.map} +1 -1
- package/dist/session.d.ts +1 -1
- package/dist/session.js +2 -2
- package/dist/{resolve-CZJxds85.js → skills-Cp9vnBwd.js} +1430 -1006
- package/dist/skills-Cp9vnBwd.js.map +1 -0
- package/dist/skills.d.ts +1 -1
- package/dist/skills.js +1 -2
- package/dist/{stdio-BvL8HGYW.js → stdio-DzUUkkhS.js} +2 -2
- package/dist/{stdio-BvL8HGYW.js.map → stdio-DzUUkkhS.js.map} +1 -1
- package/dist/{stdio-loader-CjfoqBle.js → stdio-loader-BILyUGmi.js} +2 -2
- package/dist/{stdio-loader-CjfoqBle.js.map → stdio-loader-BILyUGmi.js.map} +1 -1
- package/dist/{tool-formatters-DPQjso_C.d.ts → tool-formatters-gFqQbzPY.d.ts} +2 -2
- package/dist/{tool-formatters-DPQjso_C.d.ts.map → tool-formatters-gFqQbzPY.d.ts.map} +1 -1
- package/dist/tools/fetch-url.d.ts +1 -1
- package/dist/tools/web-search.d.ts +1 -1
- package/dist/tools.d.ts +1 -1
- package/dist/tools.js +1 -2
- package/dist/{transcript-anchors-CgDzYin1.js → transcript-anchors-DEIr0tJC.js} +44 -7
- package/dist/transcript-anchors-DEIr0tJC.js.map +1 -0
- package/dist/{transcript-anchors-BCUpbNxc.d.ts → transcript-anchors-qCs3wZpu.d.ts} +98 -92
- package/dist/transcript-anchors-qCs3wZpu.d.ts.map +1 -0
- package/dist/tui.d.ts +38 -4
- package/dist/tui.d.ts.map +1 -1
- package/dist/tui.js +843 -109
- package/dist/tui.js.map +1 -1
- package/dist/{turn-operations-CAvi8NYt.js → turn-operations-C3IijXMW.js} +2 -2
- package/dist/{turn-operations-CAvi8NYt.js.map → turn-operations-C3IijXMW.js.map} +1 -1
- package/dist/{turn-operations-Bu2Hkinw.d.ts → turn-operations-M9-jUyDd.d.ts} +3 -3
- package/dist/{turn-operations-Bu2Hkinw.d.ts.map → turn-operations-M9-jUyDd.d.ts.map} +1 -1
- package/dist/types-BIarq1qE.js.map +1 -1
- package/dist/types.d.ts +1 -1
- package/dist/{xai-BPwMQSuF.js → xai-Du6Wfnlb.js} +2 -2
- package/dist/{xai-BPwMQSuF.js.map → xai-Du6Wfnlb.js.map} +1 -1
- package/docs/PLUGINS.md +340 -0
- package/docs/RESTATE.md +168 -3
- package/docs/SKILL.md +24 -8
- package/package.json +6 -1
- package/dist/agent-8sUadp5M.js.map +0 -1
- package/dist/aliasing-lVFbm81n.js +0 -217
- package/dist/aliasing-lVFbm81n.js.map +0 -1
- package/dist/context-breakdown-Dzo25N45.js +0 -155
- package/dist/context-breakdown-Dzo25N45.js.map +0 -1
- package/dist/edit-utils-EGosADZq.js.map +0 -1
- package/dist/index-C-Nd2ZbV.d.ts.map +0 -1
- package/dist/mcp-3GXUBwur.js.map +0 -1
- package/dist/openai-compat-B3c_hzwf.js.map +0 -1
- package/dist/resolve-CZJxds85.js.map +0 -1
- package/dist/skills-Dd6XCi4s.js +0 -420
- package/dist/skills-Dd6XCi4s.js.map +0 -1
- package/dist/tool-state-DTs-3ZHH.js +0 -653
- package/dist/tool-state-DTs-3ZHH.js.map +0 -1
- package/dist/transcript-anchors-BCUpbNxc.d.ts.map +0 -1
- package/dist/transcript-anchors-CgDzYin1.js.map +0 -1
|
@@ -0,0 +1,1174 @@
|
|
|
1
|
+
import { T as parseSkillFile } from "./skills-Cp9vnBwd.js";
|
|
2
|
+
import { t as isAllowedRemoteUrl } from "./origin-pinned-fetch-CqG8VZIq.js";
|
|
3
|
+
import { isAbsolute, join, relative, resolve } from "node:path";
|
|
4
|
+
import { existsSync } from "node:fs";
|
|
5
|
+
import { readFile, readdir, realpath, stat } from "node:fs/promises";
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
//#region src/chat/xdg.ts
|
|
8
|
+
/**
|
|
9
|
+
* XDG Base Directory resolution for zidane.
|
|
10
|
+
*
|
|
11
|
+
* The user's storage spreads across four logical slots:
|
|
12
|
+
*
|
|
13
|
+
* - **config** — credentials, mcp-credentials, keybindings, config.json,
|
|
14
|
+
* mcps.json, skills (user-editable surface).
|
|
15
|
+
* - **data** — sessions.db (long-lived chat history).
|
|
16
|
+
* - **state** — state.json (per-machine UI bookkeeping: last provider,
|
|
17
|
+
* last model, last agent, settings).
|
|
18
|
+
* - **cache** — persisted tool results, background-task logs,
|
|
19
|
+
* auto-update registry cache (regenerable, safe to wipe).
|
|
20
|
+
*
|
|
21
|
+
* Historically all four collapsed to `~/.zidane/`. This module adds
|
|
22
|
+
* XDG-Base-Directory-aware defaults so a Linux user with no existing
|
|
23
|
+
* `~/.zidane/` lands in the conventional `~/.config/zidane/`,
|
|
24
|
+
* `~/.local/share/zidane/`, `~/.local/state/zidane/`, `~/.cache/zidane/`
|
|
25
|
+
* trio. Existing setups (anyone with a `~/.zidane/` directory or
|
|
26
|
+
* `ZIDANE_STORAGE_DIR` set) keep the single-dir layout unchanged.
|
|
27
|
+
*
|
|
28
|
+
* Resolution flow — applied in this order:
|
|
29
|
+
*
|
|
30
|
+
* 1. Caller passed an explicit `storageDir` (`ChatOptions.storageDir`)
|
|
31
|
+
* OR `ZIDANE_STORAGE_DIR` is set → **legacy-explicit** single-dir
|
|
32
|
+
* layout under `<storageDir>/<prefix>`. The four slots collapse to
|
|
33
|
+
* the same directory.
|
|
34
|
+
* 2. `~/<prefix>/` already exists on disk → **legacy-existing**
|
|
35
|
+
* single-dir layout under that directory. Upgrades never silently
|
|
36
|
+
* shift files around.
|
|
37
|
+
* 3. Any `XDG_*_HOME` env var is set OR `process.platform === 'linux'`
|
|
38
|
+
* → **XDG split**. Each slot lands in its own dir. Per-slot
|
|
39
|
+
* `ZIDANE_{CONFIG,DATA,CACHE,STATE}_DIR` overrides beat the XDG
|
|
40
|
+
* vars for surgical tweaks.
|
|
41
|
+
* 4. Otherwise (macOS/Windows, no XDG signal, no existing dir) →
|
|
42
|
+
* **default** single-dir layout at `~/<prefix>/`. Day-one behavior
|
|
43
|
+
* on these platforms is unchanged.
|
|
44
|
+
*
|
|
45
|
+
* The `userDir` legacy alias always equals `configDir` so existing code
|
|
46
|
+
* that still references `paths.userDir` keeps reading credentials, mcps,
|
|
47
|
+
* etc. from the right place.
|
|
48
|
+
*/
|
|
49
|
+
/**
|
|
50
|
+
* Resolve the four storage slots according to the precedence above.
|
|
51
|
+
*
|
|
52
|
+
* Pure aside from one filesystem probe (`existsSync(home/<prefix>)`)
|
|
53
|
+
* — the same probe `resolveStoragePaths` would do anyway when
|
|
54
|
+
* computing `paths.userDir`. No directories are created here; the
|
|
55
|
+
* relevant write paths (state store, credentials writer, persist
|
|
56
|
+
* dir) handle their own lazy `mkdirSync` already.
|
|
57
|
+
*/
|
|
58
|
+
function resolveStorageDirs(opts = {}) {
|
|
59
|
+
const env = opts.env ?? process.env;
|
|
60
|
+
const home = opts.home ?? homedir();
|
|
61
|
+
const platform = opts.platform ?? process.platform;
|
|
62
|
+
const rawPrefix = opts.prefix ?? ".zidane";
|
|
63
|
+
const prefixBare = rawPrefix.replace(/^\./, "");
|
|
64
|
+
const explicitStorageDir = opts.storageDir ?? env.ZIDANE_STORAGE_DIR;
|
|
65
|
+
if (explicitStorageDir) return single(resolve(explicitStorageDir, rawPrefix), "legacy-explicit");
|
|
66
|
+
const legacyHomeDir = resolve(home, rawPrefix);
|
|
67
|
+
if (existsSync(legacyHomeDir)) return single(legacyHomeDir, "legacy-existing");
|
|
68
|
+
if (platform === "linux" || !!env.XDG_CONFIG_HOME || !!env.XDG_DATA_HOME || !!env.XDG_CACHE_HOME || !!env.XDG_STATE_HOME || !!env.ZIDANE_CONFIG_DIR || !!env.ZIDANE_DATA_DIR || !!env.ZIDANE_STATE_DIR || !!env.ZIDANE_CACHE_DIR) return {
|
|
69
|
+
configDir: env.ZIDANE_CONFIG_DIR ?? resolve(env.XDG_CONFIG_HOME || resolve(home, ".config"), prefixBare),
|
|
70
|
+
dataDir: env.ZIDANE_DATA_DIR ?? resolve(env.XDG_DATA_HOME || resolve(home, ".local", "share"), prefixBare),
|
|
71
|
+
stateDir: env.ZIDANE_STATE_DIR ?? resolve(env.XDG_STATE_HOME || resolve(home, ".local", "state"), prefixBare),
|
|
72
|
+
cacheDir: env.ZIDANE_CACHE_DIR ?? resolve(env.XDG_CACHE_HOME || resolve(home, ".cache"), prefixBare),
|
|
73
|
+
mode: "xdg"
|
|
74
|
+
};
|
|
75
|
+
return single(legacyHomeDir, "default");
|
|
76
|
+
}
|
|
77
|
+
function single(base, mode) {
|
|
78
|
+
return {
|
|
79
|
+
configDir: base,
|
|
80
|
+
dataDir: base,
|
|
81
|
+
stateDir: base,
|
|
82
|
+
cacheDir: base,
|
|
83
|
+
mode
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
//#endregion
|
|
87
|
+
//#region src/plugins/boundary.ts
|
|
88
|
+
/**
|
|
89
|
+
* The Agent Plugins package boundary.
|
|
90
|
+
*
|
|
91
|
+
* The spec's central filesystem rule: "Every package file a client
|
|
92
|
+
* discovers, reads, or executes must remain inside the filesystem-resolved
|
|
93
|
+
* root after resolving symlinks, junctions, reparse points, and equivalent
|
|
94
|
+
* mechanisms."
|
|
95
|
+
*
|
|
96
|
+
* Lexical containment (`../skills/validate.ts`'s `validateResourcePath`) is
|
|
97
|
+
* NOT sufficient here — a `skills` symlink pointing at `~/.ssh` passes every
|
|
98
|
+
* lexical check. So each package path is resolved through `realpath` and
|
|
99
|
+
* re-tested against the realpath-resolved root, and the required filesystem
|
|
100
|
+
* kind is asserted with a symlink-FOLLOWING `stat` (the spec wants
|
|
101
|
+
* `SKILL.md` to be a regular file, not a fifo or a directory wearing the
|
|
102
|
+
* name).
|
|
103
|
+
*
|
|
104
|
+
* Containment is tested with `path.relative` rather than a string prefix so
|
|
105
|
+
* `/plugin-root` vs `/plugin-root-evil` can't be confused and Windows
|
|
106
|
+
* separators are handled by the platform.
|
|
107
|
+
*/
|
|
108
|
+
/**
|
|
109
|
+
* Resolve the plugin root itself. Returns `null` when the root does not
|
|
110
|
+
* exist or is not a directory — the caller turns that into a plugin-level
|
|
111
|
+
* rejection, since there is nothing to load.
|
|
112
|
+
*/
|
|
113
|
+
async function resolvePluginBoundary(dir) {
|
|
114
|
+
const absolute = resolve(dir);
|
|
115
|
+
try {
|
|
116
|
+
const root = await realpath(absolute);
|
|
117
|
+
if (!(await stat(root)).isDirectory()) return null;
|
|
118
|
+
return {
|
|
119
|
+
dir: absolute,
|
|
120
|
+
root
|
|
121
|
+
};
|
|
122
|
+
} catch {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* True when `real` (an already symlink-resolved path) lives at or under
|
|
128
|
+
* `root` (likewise resolved).
|
|
129
|
+
*/
|
|
130
|
+
function isWithin(root, real) {
|
|
131
|
+
const rel = relative(root, real);
|
|
132
|
+
return rel === "" || !rel.startsWith("..") && !isAbsolute(rel);
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Symlink-resolve `target` and confirm it stays inside the boundary.
|
|
136
|
+
*
|
|
137
|
+
* Use this for paths whose filesystem kind doesn't matter or that may not
|
|
138
|
+
* exist yet (Phase 3's `cwd` handling); otherwise prefer
|
|
139
|
+
* {@link resolvePackagePath}, which also asserts the kind.
|
|
140
|
+
*/
|
|
141
|
+
async function realPathWithin(boundary, target) {
|
|
142
|
+
let real;
|
|
143
|
+
try {
|
|
144
|
+
real = await realpath(target);
|
|
145
|
+
} catch {
|
|
146
|
+
return {
|
|
147
|
+
ok: false,
|
|
148
|
+
reason: "missing"
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
if (!isWithin(boundary.root, real)) return {
|
|
152
|
+
ok: false,
|
|
153
|
+
reason: "escaped"
|
|
154
|
+
};
|
|
155
|
+
return {
|
|
156
|
+
ok: true,
|
|
157
|
+
real
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Resolve a package path and assert its filesystem kind.
|
|
162
|
+
*
|
|
163
|
+
* `stat` (not `lstat`) is deliberate: a symlink to a regular file INSIDE the
|
|
164
|
+
* root is legitimate packaging, and containment is already enforced against
|
|
165
|
+
* the resolved target. What we reject is the kind mismatch the spec calls
|
|
166
|
+
* out — `skills` as a regular file, `SKILL.md` as a directory or a fifo.
|
|
167
|
+
*/
|
|
168
|
+
async function resolvePackagePath(boundary, target, kind) {
|
|
169
|
+
const within = await realPathWithin(boundary, target);
|
|
170
|
+
if (!within.ok) return within;
|
|
171
|
+
try {
|
|
172
|
+
const info = await stat(within.real);
|
|
173
|
+
if (!(kind === "directory" ? info.isDirectory() : info.isFile())) return {
|
|
174
|
+
ok: false,
|
|
175
|
+
reason: "wrong-kind"
|
|
176
|
+
};
|
|
177
|
+
} catch {
|
|
178
|
+
return {
|
|
179
|
+
ok: false,
|
|
180
|
+
reason: "missing"
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
return within;
|
|
184
|
+
}
|
|
185
|
+
//#endregion
|
|
186
|
+
//#region src/plugins/types.ts
|
|
187
|
+
/**
|
|
188
|
+
* Agent Plugins spec versions this loader implements, newest first.
|
|
189
|
+
*
|
|
190
|
+
* The spec requires clients to select validation rules from the manifest's
|
|
191
|
+
* `$schema` WITHOUT retrieving it, and to let plugins keep targeting older
|
|
192
|
+
* versions under a locally supported compatibility policy. Our policy: a
|
|
193
|
+
* version is supported iff it appears in this list. Adding `1.1.0` later
|
|
194
|
+
* means adding it here plus any rule deltas in `./manifest` — never
|
|
195
|
+
* reinterpreting an already-published identifier, which the spec forbids.
|
|
196
|
+
*/
|
|
197
|
+
const SUPPORTED_AGENT_PLUGINS_VERSIONS = ["1.0.0"];
|
|
198
|
+
/**
|
|
199
|
+
* Canonical schema identifier for a given document kind + version. Built
|
|
200
|
+
* rather than hardcoded so the two call sites (manifest, mcp.json) can't
|
|
201
|
+
* drift, and so the version cross-check the spec requires between
|
|
202
|
+
* `plugin.json` and `mcp.json` is a plain string comparison.
|
|
203
|
+
*/
|
|
204
|
+
function agentPluginsSchemaId(kind, version) {
|
|
205
|
+
return `https://agent-plugins.org/schemas/${version}/${kind}.schema.json`;
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Canonical schema identifier grammar. Anchored and exact — a `$schema`
|
|
209
|
+
* that merely looks similar (http, a trailing slash, a different host) is
|
|
210
|
+
* not the canonical identifier and must not select the contract.
|
|
211
|
+
*/
|
|
212
|
+
const SCHEMA_ID_RE = /^https:\/\/agent-plugins\.org\/schemas\/([^/]+)\/(plugin|mcp)\.schema\.json$/;
|
|
213
|
+
/**
|
|
214
|
+
* Split a canonical `$schema` identifier into its document kind + version.
|
|
215
|
+
* Returns `null` for anything that isn't a canonical identifier; the caller
|
|
216
|
+
* decides whether that's fatal (manifest) or component-scoped (mcp.json).
|
|
217
|
+
*
|
|
218
|
+
* Purely syntactic: it never fetches. The spec forbids retrieving a schema
|
|
219
|
+
* during load, so version support is a lookup against
|
|
220
|
+
* {@link SUPPORTED_AGENT_PLUGINS_VERSIONS}, not a download.
|
|
221
|
+
*/
|
|
222
|
+
function parseSchemaId(value) {
|
|
223
|
+
const match = SCHEMA_ID_RE.exec(value);
|
|
224
|
+
if (!match) return null;
|
|
225
|
+
return {
|
|
226
|
+
kind: match[2],
|
|
227
|
+
version: match[1]
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
/** True when this loader implements the given Agent Plugins version. */
|
|
231
|
+
function isSupportedAgentPluginsVersion(version) {
|
|
232
|
+
return SUPPORTED_AGENT_PLUGINS_VERSIONS.includes(version);
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Type label for a rejected JSON value, for diagnostic messages.
|
|
236
|
+
*
|
|
237
|
+
* `typeof` alone is useless in a validator that has to tell an author what
|
|
238
|
+
* they got wrong: `null` and `[]` both report `'object'`, and those are two of
|
|
239
|
+
* the most common mistakes in a hand-written manifest. Shared by the manifest
|
|
240
|
+
* and `mcp.json` validators, which produce the same class of message.
|
|
241
|
+
*
|
|
242
|
+
* Internal — not re-exported from the module barrel.
|
|
243
|
+
*/
|
|
244
|
+
function describeJsonType(value) {
|
|
245
|
+
if (value === null) return "null";
|
|
246
|
+
if (Array.isArray(value)) return "array";
|
|
247
|
+
return typeof value;
|
|
248
|
+
}
|
|
249
|
+
//#endregion
|
|
250
|
+
//#region src/plugins/manifest.ts
|
|
251
|
+
/**
|
|
252
|
+
* Plugin name grammar, lifted from the canonical schema: 1-64 chars,
|
|
253
|
+
* lowercase alphanumerics plus `-` and `.`, alphanumeric at both ends, and
|
|
254
|
+
* no `--` or `..` anywhere (the negative lookahead).
|
|
255
|
+
*
|
|
256
|
+
* Length is checked separately so an over-long name gets a message about
|
|
257
|
+
* length instead of a generic "doesn't match the grammar".
|
|
258
|
+
*/
|
|
259
|
+
const PLUGIN_NAME_RE = /^(?!.*(?:--|\.\.))[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/;
|
|
260
|
+
const NAME_MAX = 64;
|
|
261
|
+
/** Top-level fields the 1.0.0 manifest permits. Anything else is unknown. */
|
|
262
|
+
const KNOWN_FIELDS = /* @__PURE__ */ new Set([
|
|
263
|
+
"$schema",
|
|
264
|
+
"name",
|
|
265
|
+
"version",
|
|
266
|
+
"description",
|
|
267
|
+
"author",
|
|
268
|
+
"homepage",
|
|
269
|
+
"repository",
|
|
270
|
+
"license",
|
|
271
|
+
"keywords",
|
|
272
|
+
"extensions"
|
|
273
|
+
]);
|
|
274
|
+
/** Optional top-level fields whose value must be a string when present. */
|
|
275
|
+
const STRING_FIELDS = [
|
|
276
|
+
"version",
|
|
277
|
+
"description",
|
|
278
|
+
"homepage",
|
|
279
|
+
"repository",
|
|
280
|
+
"license"
|
|
281
|
+
];
|
|
282
|
+
/** Keys the closed `author` object permits. */
|
|
283
|
+
const AUTHOR_FIELDS = /* @__PURE__ */ new Set([
|
|
284
|
+
"name",
|
|
285
|
+
"email",
|
|
286
|
+
"url"
|
|
287
|
+
]);
|
|
288
|
+
/**
|
|
289
|
+
* Parse manifest text, then validate it.
|
|
290
|
+
*
|
|
291
|
+
* A JSON syntax error is fatal — unlike `mcps.json` (where
|
|
292
|
+
* `parseJsonTolerant` repairs copy-paste whitespace artifacts), a plugin
|
|
293
|
+
* manifest is a package file produced by a build or an author's editor, not
|
|
294
|
+
* pasted into a settings box. Repairing it would mean guessing at the
|
|
295
|
+
* contents of a third-party package.
|
|
296
|
+
*/
|
|
297
|
+
function parsePluginManifest(text) {
|
|
298
|
+
let raw;
|
|
299
|
+
try {
|
|
300
|
+
raw = JSON.parse(text);
|
|
301
|
+
} catch (err) {
|
|
302
|
+
return {
|
|
303
|
+
ok: false,
|
|
304
|
+
diagnostics: [{
|
|
305
|
+
severity: "error",
|
|
306
|
+
code: "manifest-invalid-json",
|
|
307
|
+
message: `plugin.json is not valid JSON: ${err instanceof Error ? err.message : String(err)}`
|
|
308
|
+
}]
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
return validatePluginManifest(raw);
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* Validate an already-parsed manifest value against the closed 1.0.0 schema.
|
|
315
|
+
*
|
|
316
|
+
* Fatal violations short-circuit: `$schema` and `name` are checked first
|
|
317
|
+
* because without them there is no contract to validate against and no
|
|
318
|
+
* identity to report problems under.
|
|
319
|
+
*/
|
|
320
|
+
function validatePluginManifest(raw) {
|
|
321
|
+
const diagnostics = [];
|
|
322
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return {
|
|
323
|
+
ok: false,
|
|
324
|
+
diagnostics: [{
|
|
325
|
+
severity: "error",
|
|
326
|
+
code: "manifest-not-object",
|
|
327
|
+
message: `plugin.json must be a JSON object (got ${Array.isArray(raw) ? "array" : typeof raw}).`
|
|
328
|
+
}]
|
|
329
|
+
};
|
|
330
|
+
const obj = raw;
|
|
331
|
+
const schema = obj.$schema;
|
|
332
|
+
if (typeof schema !== "string" || schema.length === 0) return fatal(diagnostics, {
|
|
333
|
+
code: "manifest-schema-missing",
|
|
334
|
+
message: `plugin.json is missing the required "$schema" field. Expected "${agentPluginsSchemaId("plugin", SUPPORTED_AGENT_PLUGINS_VERSIONS[0])}".`,
|
|
335
|
+
field: "$schema"
|
|
336
|
+
});
|
|
337
|
+
const parsed = parseSchemaId(schema);
|
|
338
|
+
if (!parsed || parsed.kind !== "plugin") return fatal(diagnostics, {
|
|
339
|
+
code: "manifest-schema-unsupported",
|
|
340
|
+
message: `plugin.json "$schema" is not a canonical Agent Plugins manifest schema identifier: "${schema}".`,
|
|
341
|
+
field: "$schema"
|
|
342
|
+
});
|
|
343
|
+
if (!isSupportedAgentPluginsVersion(parsed.version)) return fatal(diagnostics, {
|
|
344
|
+
code: "manifest-schema-unsupported",
|
|
345
|
+
message: `plugin.json targets Agent Plugins ${parsed.version}, which this client does not support (supported: ${SUPPORTED_AGENT_PLUGINS_VERSIONS.join(", ")}).`,
|
|
346
|
+
field: "$schema"
|
|
347
|
+
});
|
|
348
|
+
const name = obj.name;
|
|
349
|
+
if (typeof name !== "string") return fatal(diagnostics, {
|
|
350
|
+
code: "manifest-name-missing",
|
|
351
|
+
message: `plugin.json is missing the required "name" field${name === void 0 ? "" : ` (got ${typeof name})`}.`,
|
|
352
|
+
field: "name"
|
|
353
|
+
});
|
|
354
|
+
if (name.length === 0 || name.length > NAME_MAX) return fatal(diagnostics, {
|
|
355
|
+
code: "manifest-name-invalid",
|
|
356
|
+
message: `Plugin name must be 1-${NAME_MAX} characters (got ${name.length}).`,
|
|
357
|
+
field: "name"
|
|
358
|
+
});
|
|
359
|
+
if (!PLUGIN_NAME_RE.test(name)) return fatal(diagnostics, {
|
|
360
|
+
code: "manifest-name-invalid",
|
|
361
|
+
message: `Plugin name "${name}" is invalid — use lowercase letters, digits, hyphens and periods, starting and ending alphanumeric, with no "--" or "..".`,
|
|
362
|
+
field: "name"
|
|
363
|
+
});
|
|
364
|
+
const manifest = {
|
|
365
|
+
specVersion: parsed.version,
|
|
366
|
+
name
|
|
367
|
+
};
|
|
368
|
+
for (const field of STRING_FIELDS) {
|
|
369
|
+
const value = obj[field];
|
|
370
|
+
if (value === void 0) continue;
|
|
371
|
+
if (typeof value !== "string") return fatal(diagnostics, {
|
|
372
|
+
code: "manifest-field-wrong-type",
|
|
373
|
+
message: `plugin.json "${field}" must be a string (got ${describeJsonType(value)}).`,
|
|
374
|
+
field
|
|
375
|
+
});
|
|
376
|
+
manifest[field] = value;
|
|
377
|
+
}
|
|
378
|
+
if (obj.keywords !== void 0) {
|
|
379
|
+
if (!Array.isArray(obj.keywords)) return fatal(diagnostics, {
|
|
380
|
+
code: "manifest-field-wrong-type",
|
|
381
|
+
message: `plugin.json "keywords" must be an array of strings (got ${describeJsonType(obj.keywords)}).`,
|
|
382
|
+
field: "keywords"
|
|
383
|
+
});
|
|
384
|
+
const bad = obj.keywords.findIndex((entry) => typeof entry !== "string");
|
|
385
|
+
if (bad !== -1) return fatal(diagnostics, {
|
|
386
|
+
code: "manifest-field-wrong-type",
|
|
387
|
+
message: `plugin.json "keywords[${bad}]" must be a string (got ${describeJsonType(obj.keywords[bad])}).`,
|
|
388
|
+
field: "keywords"
|
|
389
|
+
});
|
|
390
|
+
manifest.keywords = [...obj.keywords];
|
|
391
|
+
}
|
|
392
|
+
if (obj.author !== void 0) {
|
|
393
|
+
const author = obj.author;
|
|
394
|
+
if (typeof author !== "object" || author === null || Array.isArray(author)) return fatal(diagnostics, {
|
|
395
|
+
code: "manifest-field-wrong-type",
|
|
396
|
+
message: `plugin.json "author" must be an object (got ${describeJsonType(author)}).`,
|
|
397
|
+
field: "author"
|
|
398
|
+
});
|
|
399
|
+
const out = {};
|
|
400
|
+
for (const [key, value] of Object.entries(author)) {
|
|
401
|
+
if (!AUTHOR_FIELDS.has(key)) return fatal(diagnostics, {
|
|
402
|
+
code: "manifest-author-unknown-field",
|
|
403
|
+
message: `plugin.json "author.${key}" is not a permitted field (allowed: ${[...AUTHOR_FIELDS].join(", ")}).`,
|
|
404
|
+
field: "author"
|
|
405
|
+
});
|
|
406
|
+
if (typeof value !== "string") return fatal(diagnostics, {
|
|
407
|
+
code: "manifest-field-wrong-type",
|
|
408
|
+
message: `plugin.json "author.${key}" must be a string (got ${describeJsonType(value)}).`,
|
|
409
|
+
field: "author"
|
|
410
|
+
});
|
|
411
|
+
out[key] = value;
|
|
412
|
+
}
|
|
413
|
+
manifest.author = out;
|
|
414
|
+
}
|
|
415
|
+
if (obj.extensions !== void 0) {
|
|
416
|
+
const ext = obj.extensions;
|
|
417
|
+
if (typeof ext !== "object" || ext === null || Array.isArray(ext)) diagnostics.push({
|
|
418
|
+
severity: "warning",
|
|
419
|
+
code: "manifest-extensions-not-object",
|
|
420
|
+
message: `plugin.json "extensions" must be an object (got ${describeJsonType(ext)}) — ignoring it and continuing to load components.`,
|
|
421
|
+
field: "extensions"
|
|
422
|
+
});
|
|
423
|
+
else manifest.extensions = { ...ext };
|
|
424
|
+
}
|
|
425
|
+
for (const key of Object.keys(obj)) {
|
|
426
|
+
if (KNOWN_FIELDS.has(key)) continue;
|
|
427
|
+
diagnostics.push({
|
|
428
|
+
severity: "warning",
|
|
429
|
+
code: "manifest-unknown-field",
|
|
430
|
+
message: `plugin.json has unknown top-level field "${key}" — ignoring it. Client-specific data belongs under "extensions".`,
|
|
431
|
+
field: key
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
return {
|
|
435
|
+
ok: true,
|
|
436
|
+
manifest,
|
|
437
|
+
diagnostics
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
function fatal(diagnostics, detail) {
|
|
441
|
+
diagnostics.push({
|
|
442
|
+
severity: "error",
|
|
443
|
+
...detail
|
|
444
|
+
});
|
|
445
|
+
return {
|
|
446
|
+
ok: false,
|
|
447
|
+
diagnostics
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
//#endregion
|
|
451
|
+
//#region src/plugins/mcp.ts
|
|
452
|
+
/** Fixed location for the MCP component type. */
|
|
453
|
+
const PLUGIN_MCP_FILE = "mcp.json";
|
|
454
|
+
/** The only placeholders the spec defines. */
|
|
455
|
+
const PLUGIN_ROOT_VAR = "PLUGIN_ROOT";
|
|
456
|
+
const PLUGIN_DATA_VAR = "PLUGIN_DATA";
|
|
457
|
+
/**
|
|
458
|
+
* Placeholder grammar — deliberately narrower than
|
|
459
|
+
* `expandEnvPlaceholders`'s: exactly the two reserved names, no
|
|
460
|
+
* `:-default` form, no `${env:…}` form. Anything else stays literal.
|
|
461
|
+
*/
|
|
462
|
+
const PLUGIN_VAR_RE = /\$\{(PLUGIN_ROOT|PLUGIN_DATA)\}/g;
|
|
463
|
+
/** Closed top-level key set for the document. */
|
|
464
|
+
const DOCUMENT_FIELDS = /* @__PURE__ */ new Set(["$schema", "mcpServers"]);
|
|
465
|
+
/**
|
|
466
|
+
* Closed key set per declared transport.
|
|
467
|
+
*
|
|
468
|
+
* A `Map` rather than an object literal: the keys are attacker-controlled
|
|
469
|
+
* (`type` comes straight out of a third-party `mcp.json`), and `'constructor'
|
|
470
|
+
* in objectLiteral` is `true`, which would sail past the membership check and
|
|
471
|
+
* then blow up on `Object.prototype.constructor.has(...)`.
|
|
472
|
+
*/
|
|
473
|
+
const ENTRY_FIELDS = /* @__PURE__ */ new Map([
|
|
474
|
+
["stdio", /* @__PURE__ */ new Set([
|
|
475
|
+
"type",
|
|
476
|
+
"command",
|
|
477
|
+
"args",
|
|
478
|
+
"env",
|
|
479
|
+
"cwd"
|
|
480
|
+
])],
|
|
481
|
+
["streamable-http", /* @__PURE__ */ new Set([
|
|
482
|
+
"type",
|
|
483
|
+
"url",
|
|
484
|
+
"headers"
|
|
485
|
+
])],
|
|
486
|
+
["sse", /* @__PURE__ */ new Set([
|
|
487
|
+
"type",
|
|
488
|
+
"url",
|
|
489
|
+
"headers"
|
|
490
|
+
])]
|
|
491
|
+
]);
|
|
492
|
+
/**
|
|
493
|
+
* Server-name grammar.
|
|
494
|
+
*
|
|
495
|
+
* `connectMcpServers` namespaces tools as `mcp_<server>_<tool>`, and providers
|
|
496
|
+
* reject a tool name outside the letters / digits / underscore / hyphen shape
|
|
497
|
+
* — rejecting the whole REQUEST, not just the offending tool. A user-authored
|
|
498
|
+
* `mcps.json` has the same sharp edge, but there the user owns the file; a
|
|
499
|
+
* plugin is untrusted input, so an unusable name is rejected per-entry instead
|
|
500
|
+
* of poisoning every run.
|
|
501
|
+
*/
|
|
502
|
+
const SERVER_NAME_RE = /^[a-z0-9][\w-]{0,63}$/i;
|
|
503
|
+
/**
|
|
504
|
+
* Keys that hit a prototype setter instead of creating an own property.
|
|
505
|
+
* Same guard the extension registration paths apply to untrusted keys — see
|
|
506
|
+
* `isSafeRecordKey` in `../extensions/register.ts`. Assigning `__proto__` on
|
|
507
|
+
* an object literal silently DISCARDS the value, so without this an env var
|
|
508
|
+
* would just never reach the child process with nothing explaining why.
|
|
509
|
+
*/
|
|
510
|
+
const DANGEROUS_RECORD_KEYS = /* @__PURE__ */ new Set([
|
|
511
|
+
"__proto__",
|
|
512
|
+
"prototype",
|
|
513
|
+
"constructor"
|
|
514
|
+
]);
|
|
515
|
+
/**
|
|
516
|
+
* Load and validate the plugin's `mcp.json`.
|
|
517
|
+
*
|
|
518
|
+
* Status semantics mirror `loadPluginSkills`: a missing file is `absent`
|
|
519
|
+
* (valid), a wrong-kind / escaping / malformed document is `invalid` (this
|
|
520
|
+
* component only), and anything else is `loaded` — even when individual
|
|
521
|
+
* entries were rejected, because per-entry isolation is the point.
|
|
522
|
+
*/
|
|
523
|
+
async function loadPluginMcp(opts) {
|
|
524
|
+
const { boundary, pluginName, dataDir, specVersion, namePrefix } = opts;
|
|
525
|
+
const diagnostics = [];
|
|
526
|
+
const path = resolve(boundary.root, PLUGIN_MCP_FILE);
|
|
527
|
+
const located = await realPathWithin(boundary, path);
|
|
528
|
+
if (!located.ok) {
|
|
529
|
+
if (located.reason === "missing") return {
|
|
530
|
+
status: "absent",
|
|
531
|
+
servers: [],
|
|
532
|
+
diagnostics
|
|
533
|
+
};
|
|
534
|
+
diagnostics.push({
|
|
535
|
+
severity: "error",
|
|
536
|
+
code: "mcp-file-escapes-root",
|
|
537
|
+
message: `"${PLUGIN_MCP_FILE}" resolves outside the plugin root — the MCP component is disabled for this plugin.`,
|
|
538
|
+
path
|
|
539
|
+
});
|
|
540
|
+
return {
|
|
541
|
+
status: "invalid",
|
|
542
|
+
servers: [],
|
|
543
|
+
diagnostics
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
let raw;
|
|
547
|
+
try {
|
|
548
|
+
raw = JSON.parse(await readFile(located.real, "utf8"));
|
|
549
|
+
} catch (err) {
|
|
550
|
+
diagnostics.push({
|
|
551
|
+
severity: "error",
|
|
552
|
+
code: "mcp-invalid-json",
|
|
553
|
+
message: `"${PLUGIN_MCP_FILE}" could not be read as JSON: ${err instanceof Error ? err.message : String(err)}`,
|
|
554
|
+
path: located.real
|
|
555
|
+
});
|
|
556
|
+
return {
|
|
557
|
+
status: "invalid",
|
|
558
|
+
servers: [],
|
|
559
|
+
diagnostics
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
const document = validateDocument(raw, specVersion);
|
|
563
|
+
if (!document.ok) {
|
|
564
|
+
diagnostics.push({
|
|
565
|
+
severity: "error",
|
|
566
|
+
...document.diagnostic,
|
|
567
|
+
path: located.real
|
|
568
|
+
});
|
|
569
|
+
return {
|
|
570
|
+
status: "invalid",
|
|
571
|
+
servers: [],
|
|
572
|
+
diagnostics
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
const servers = [];
|
|
576
|
+
for (const [name, entry] of Object.entries(document.servers)) {
|
|
577
|
+
const built = await buildServer({
|
|
578
|
+
name,
|
|
579
|
+
entry,
|
|
580
|
+
boundary,
|
|
581
|
+
dataDir,
|
|
582
|
+
pluginName,
|
|
583
|
+
...namePrefix !== void 0 ? { namePrefix } : {}
|
|
584
|
+
});
|
|
585
|
+
if (!built.ok) {
|
|
586
|
+
diagnostics.push({
|
|
587
|
+
severity: "error",
|
|
588
|
+
...built.diagnostic,
|
|
589
|
+
path: located.real
|
|
590
|
+
});
|
|
591
|
+
continue;
|
|
592
|
+
}
|
|
593
|
+
servers.push(built.config);
|
|
594
|
+
}
|
|
595
|
+
return {
|
|
596
|
+
status: "loaded",
|
|
597
|
+
servers,
|
|
598
|
+
diagnostics
|
|
599
|
+
};
|
|
600
|
+
}
|
|
601
|
+
function validateDocument(raw, specVersion) {
|
|
602
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return {
|
|
603
|
+
ok: false,
|
|
604
|
+
diagnostic: {
|
|
605
|
+
code: "mcp-not-object",
|
|
606
|
+
message: `"${PLUGIN_MCP_FILE}" must be a JSON object (got ${describeJsonType(raw)}).`
|
|
607
|
+
}
|
|
608
|
+
};
|
|
609
|
+
const obj = raw;
|
|
610
|
+
const schema = obj.$schema;
|
|
611
|
+
if (typeof schema !== "string") return {
|
|
612
|
+
ok: false,
|
|
613
|
+
diagnostic: {
|
|
614
|
+
code: "mcp-schema-missing",
|
|
615
|
+
message: `"${PLUGIN_MCP_FILE}" is missing the required "$schema" field. Expected "${agentPluginsSchemaId("mcp", specVersion)}".`,
|
|
616
|
+
field: "$schema"
|
|
617
|
+
}
|
|
618
|
+
};
|
|
619
|
+
const parsed = parseSchemaId(schema);
|
|
620
|
+
if (!parsed || parsed.kind !== "mcp") return {
|
|
621
|
+
ok: false,
|
|
622
|
+
diagnostic: {
|
|
623
|
+
code: "mcp-schema-unsupported",
|
|
624
|
+
message: `"${PLUGIN_MCP_FILE}" "$schema" is not a canonical Agent Plugins MCP schema identifier: "${schema}".`,
|
|
625
|
+
field: "$schema"
|
|
626
|
+
}
|
|
627
|
+
};
|
|
628
|
+
if (parsed.version !== specVersion) return {
|
|
629
|
+
ok: false,
|
|
630
|
+
diagnostic: {
|
|
631
|
+
code: "mcp-schema-version-mismatch",
|
|
632
|
+
message: `"${PLUGIN_MCP_FILE}" targets Agent Plugins ${parsed.version} but plugin.json targets ${specVersion} — the versions must match.`,
|
|
633
|
+
field: "$schema"
|
|
634
|
+
}
|
|
635
|
+
};
|
|
636
|
+
for (const key of Object.keys(obj)) if (!DOCUMENT_FIELDS.has(key)) return {
|
|
637
|
+
ok: false,
|
|
638
|
+
diagnostic: {
|
|
639
|
+
code: "mcp-unknown-field",
|
|
640
|
+
message: `"${PLUGIN_MCP_FILE}" has unknown top-level field "${key}".`,
|
|
641
|
+
field: key
|
|
642
|
+
}
|
|
643
|
+
};
|
|
644
|
+
const servers = obj.mcpServers;
|
|
645
|
+
if (typeof servers !== "object" || servers === null || Array.isArray(servers)) return {
|
|
646
|
+
ok: false,
|
|
647
|
+
diagnostic: {
|
|
648
|
+
code: "mcp-servers-wrong-type",
|
|
649
|
+
message: `"${PLUGIN_MCP_FILE}" "mcpServers" must be an object (got ${describeJsonType(servers)}).`,
|
|
650
|
+
field: "mcpServers"
|
|
651
|
+
}
|
|
652
|
+
};
|
|
653
|
+
return {
|
|
654
|
+
ok: true,
|
|
655
|
+
servers
|
|
656
|
+
};
|
|
657
|
+
}
|
|
658
|
+
async function buildServer(opts) {
|
|
659
|
+
const { name, entry, boundary, dataDir, namePrefix } = opts;
|
|
660
|
+
const reject = (code, message) => ({
|
|
661
|
+
ok: false,
|
|
662
|
+
diagnostic: {
|
|
663
|
+
code,
|
|
664
|
+
message: `Skipped MCP server "${name}" — ${message}`
|
|
665
|
+
}
|
|
666
|
+
});
|
|
667
|
+
const serverName = namePrefix ? `${namePrefix}-${name}` : name;
|
|
668
|
+
if (!SERVER_NAME_RE.test(serverName)) return reject("mcp-entry-name-invalid", namePrefix ? `its namespaced name "${serverName}" is not usable as a tool prefix — the "${namePrefix}" namePrefix must be letters, digits, underscores or hyphens, and the two together at most 64 characters.` : "its name must be 1-64 characters of letters, digits, underscores or hyphens, starting alphanumeric.");
|
|
669
|
+
if (typeof entry !== "object" || entry === null || Array.isArray(entry)) return reject("mcp-entry-not-object", `its value must be an object (got ${describeJsonType(entry)}).`);
|
|
670
|
+
const obj = entry;
|
|
671
|
+
const type = typeof obj.type === "string" ? obj.type : void 0;
|
|
672
|
+
const allowed = type === void 0 ? void 0 : ENTRY_FIELDS.get(type);
|
|
673
|
+
if (type === void 0 || !allowed) return reject("mcp-entry-transport-invalid", `"type" must be one of stdio, streamable-http, sse (got ${type === void 0 ? describeJsonType(obj.type) : `"${type}"`}).`);
|
|
674
|
+
for (const key of Object.keys(obj)) if (!allowed.has(key)) return reject("mcp-entry-unknown-field", `"${key}" is not a permitted field for a ${type} server (allowed: ${[...allowed].join(", ")}).`);
|
|
675
|
+
const vars = {
|
|
676
|
+
[PLUGIN_ROOT_VAR]: boundary.root,
|
|
677
|
+
[PLUGIN_DATA_VAR]: dataDir
|
|
678
|
+
};
|
|
679
|
+
if (type === "stdio") {
|
|
680
|
+
const command = obj.command;
|
|
681
|
+
if (typeof command !== "string" || command.length === 0) return reject("mcp-entry-command-invalid", "a stdio server needs a non-empty \"command\".");
|
|
682
|
+
if (PLUGIN_VAR_RE.test(command)) {
|
|
683
|
+
PLUGIN_VAR_RE.lastIndex = 0;
|
|
684
|
+
return reject("mcp-entry-command-invalid", "placeholders are not expanded in \"command\" — use a \"./\"-relative path or a bare executable name.");
|
|
685
|
+
}
|
|
686
|
+
if (/\s/.test(command)) return reject("mcp-entry-command-invalid", `"command" must be a single executable token, not a command line ("${command}").`);
|
|
687
|
+
const resolvedCommand = await resolveCommand(command, boundary);
|
|
688
|
+
if (!resolvedCommand.ok) return reject("mcp-entry-command-invalid", resolvedCommand.error);
|
|
689
|
+
let args;
|
|
690
|
+
if (obj.args !== void 0) {
|
|
691
|
+
if (!Array.isArray(obj.args) || obj.args.some((a) => typeof a !== "string")) return reject("mcp-entry-args-invalid", "\"args\" must be an array of strings.");
|
|
692
|
+
args = obj.args.map((a) => expandPluginVars(a, vars));
|
|
693
|
+
}
|
|
694
|
+
let env;
|
|
695
|
+
if (obj.env !== void 0) {
|
|
696
|
+
if (typeof obj.env !== "object" || obj.env === null || Array.isArray(obj.env)) return reject("mcp-entry-env-invalid", "\"env\" must be an object of strings.");
|
|
697
|
+
env = {};
|
|
698
|
+
for (const [key, value] of Object.entries(obj.env)) {
|
|
699
|
+
if (typeof value !== "string") return reject("mcp-entry-env-invalid", `"env.${key}" must be a string (got ${describeJsonType(value)}).`);
|
|
700
|
+
if (key === PLUGIN_ROOT_VAR || key === PLUGIN_DATA_VAR) return reject("mcp-entry-env-invalid", `"env.${key}" is reserved and cannot be set by a plugin.`);
|
|
701
|
+
if (!isSafeRecordKey(key)) return reject("mcp-entry-env-invalid", `"env.${key}" is not a usable variable name.`);
|
|
702
|
+
env[key] = expandPluginVars(value, vars);
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
const cwdResult = await resolveCwd(obj.cwd, boundary, dataDir, vars);
|
|
706
|
+
if (!cwdResult.ok) return reject("mcp-entry-cwd-invalid", cwdResult.error);
|
|
707
|
+
const config = {
|
|
708
|
+
name: serverName,
|
|
709
|
+
transport: "stdio",
|
|
710
|
+
command: resolvedCommand.command,
|
|
711
|
+
env: {
|
|
712
|
+
...env,
|
|
713
|
+
[PLUGIN_ROOT_VAR]: boundary.root,
|
|
714
|
+
[PLUGIN_DATA_VAR]: dataDir
|
|
715
|
+
},
|
|
716
|
+
cwd: cwdResult.cwd,
|
|
717
|
+
preExpanded: true
|
|
718
|
+
};
|
|
719
|
+
if (args) config.args = args;
|
|
720
|
+
return {
|
|
721
|
+
ok: true,
|
|
722
|
+
config
|
|
723
|
+
};
|
|
724
|
+
}
|
|
725
|
+
const url = obj.url;
|
|
726
|
+
if (typeof url !== "string" || url.length === 0) return reject("mcp-entry-url-invalid", `a ${type} server needs a non-empty "url".`);
|
|
727
|
+
const checked = validateRemoteUrl(url);
|
|
728
|
+
if (!checked.ok) return reject("mcp-entry-url-invalid", checked.error);
|
|
729
|
+
let headers;
|
|
730
|
+
if (obj.headers !== void 0) {
|
|
731
|
+
if (typeof obj.headers !== "object" || obj.headers === null || Array.isArray(obj.headers)) return reject("mcp-entry-headers-invalid", "\"headers\" must be an object of strings.");
|
|
732
|
+
headers = {};
|
|
733
|
+
for (const [key, value] of Object.entries(obj.headers)) {
|
|
734
|
+
if (!isSafeRecordKey(key)) return reject("mcp-entry-headers-invalid", `"headers.${key}" is not a usable header name.`);
|
|
735
|
+
if (typeof value !== "string") return reject("mcp-entry-headers-invalid", `"headers.${key}" must be a string (got ${describeJsonType(value)}).`);
|
|
736
|
+
headers[key] = value;
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
const config = {
|
|
740
|
+
name: serverName,
|
|
741
|
+
transport: type === "sse" ? "sse" : "streamable-http",
|
|
742
|
+
url,
|
|
743
|
+
preExpanded: true,
|
|
744
|
+
pinHeaderOrigin: true
|
|
745
|
+
};
|
|
746
|
+
if (headers) config.headers = headers;
|
|
747
|
+
return {
|
|
748
|
+
ok: true,
|
|
749
|
+
config
|
|
750
|
+
};
|
|
751
|
+
}
|
|
752
|
+
/**
|
|
753
|
+
* Substitute the two reserved placeholders in one string.
|
|
754
|
+
*
|
|
755
|
+
* Single-pass by construction (`String.replace` never re-scans its own
|
|
756
|
+
* output), so a `${PLUGIN_ROOT}` whose value somehow contained
|
|
757
|
+
* `${PLUGIN_DATA}` cannot expand twice. Unknown `${...}` text is left
|
|
758
|
+
* verbatim: the spec defines exactly two placeholders, and silently
|
|
759
|
+
* blanking anything else would corrupt legitimate literals.
|
|
760
|
+
*/
|
|
761
|
+
function expandPluginVars(value, vars) {
|
|
762
|
+
return value.replace(PLUGIN_VAR_RE, (match, name) => vars[name] ?? match);
|
|
763
|
+
}
|
|
764
|
+
/**
|
|
765
|
+
* Resolve the single `command` token.
|
|
766
|
+
*
|
|
767
|
+
* A bare name (`uvx`, `node`) is handed to the OS for PATH lookup — the
|
|
768
|
+
* plugin declares a dependency on the host's toolchain, which is normal. A
|
|
769
|
+
* `./`-relative token is a file the PACKAGE ships, so it resolves against
|
|
770
|
+
* the plugin root and must survive the containment check. An absolute path
|
|
771
|
+
* is refused: it is neither a PATH lookup nor a package file, and it is the
|
|
772
|
+
* shape an escape attempt takes.
|
|
773
|
+
*/
|
|
774
|
+
async function resolveCommand(command, boundary) {
|
|
775
|
+
if (isAbsolute(command)) return {
|
|
776
|
+
ok: false,
|
|
777
|
+
error: `"command" must be a bare executable name or a "./"-relative path inside the plugin, not an absolute path ("${command}").`
|
|
778
|
+
};
|
|
779
|
+
if (!command.includes("/") && !command.includes("\\")) return {
|
|
780
|
+
ok: true,
|
|
781
|
+
command
|
|
782
|
+
};
|
|
783
|
+
const within = await realPathWithin(boundary, resolve(boundary.root, command));
|
|
784
|
+
if (!within.ok) return {
|
|
785
|
+
ok: false,
|
|
786
|
+
error: within.reason === "escaped" ? `"command" resolves outside the plugin root ("${command}").` : `"command" does not exist inside the plugin ("${command}").`
|
|
787
|
+
};
|
|
788
|
+
return {
|
|
789
|
+
ok: true,
|
|
790
|
+
command: within.real
|
|
791
|
+
};
|
|
792
|
+
}
|
|
793
|
+
/**
|
|
794
|
+
* Resolve `cwd`, defaulting to the plugin root.
|
|
795
|
+
*
|
|
796
|
+
* The authored form is constrained to `./…`, `${PLUGIN_ROOT}…` or
|
|
797
|
+
* `${PLUGIN_DATA}…`, and containment is then verified against BOTH permitted
|
|
798
|
+
* roots — the data directory legitimately sits outside the package.
|
|
799
|
+
*
|
|
800
|
+
* Both a lexical and a post-symlink check run, because neither alone is
|
|
801
|
+
* sufficient: `cwd` may not exist yet (a server that creates its own working
|
|
802
|
+
* directory on first run), so realpath cannot be the only gate; but the data
|
|
803
|
+
* directory is writable BY the MCP server, so a server can plant a
|
|
804
|
+
* `${PLUGIN_DATA}/work → /` symlink on one run and get `cwd=/` on the next
|
|
805
|
+
* unless the resolved target is re-checked.
|
|
806
|
+
*/
|
|
807
|
+
async function resolveCwd(raw, boundary, dataDir, vars) {
|
|
808
|
+
if (raw === void 0) return {
|
|
809
|
+
ok: true,
|
|
810
|
+
cwd: boundary.root
|
|
811
|
+
};
|
|
812
|
+
if (typeof raw !== "string" || raw.length === 0) return {
|
|
813
|
+
ok: false,
|
|
814
|
+
error: "\"cwd\" must be a non-empty string."
|
|
815
|
+
};
|
|
816
|
+
const startsWithVar = raw.startsWith(`\${${PLUGIN_ROOT_VAR}}`) || raw.startsWith(`\${${PLUGIN_DATA_VAR}}`);
|
|
817
|
+
if (!raw.startsWith("./") && !startsWithVar) return {
|
|
818
|
+
ok: false,
|
|
819
|
+
error: `"cwd" must start with "./", "\${${PLUGIN_ROOT_VAR}}" or "\${${PLUGIN_DATA_VAR}}" (got "${raw}").`
|
|
820
|
+
};
|
|
821
|
+
const expanded = expandPluginVars(raw, vars);
|
|
822
|
+
const absolute = isAbsolute(expanded) ? expanded : resolve(boundary.root, expanded);
|
|
823
|
+
if (!isWithin(boundary.root, absolute) && !isWithin(dataDir, absolute)) return {
|
|
824
|
+
ok: false,
|
|
825
|
+
error: `"cwd" escapes both the plugin root and the plugin data directory ("${raw}").`
|
|
826
|
+
};
|
|
827
|
+
const real = await realpathOrNull(absolute);
|
|
828
|
+
if (real !== null) {
|
|
829
|
+
const realDataDir = await realpathOrNull(dataDir) ?? dataDir;
|
|
830
|
+
if (!isWithin(boundary.root, real) && !isWithin(realDataDir, real)) return {
|
|
831
|
+
ok: false,
|
|
832
|
+
error: `"cwd" resolves outside the plugin root and the plugin data directory via a symlink ("${raw}").`
|
|
833
|
+
};
|
|
834
|
+
}
|
|
835
|
+
return {
|
|
836
|
+
ok: true,
|
|
837
|
+
cwd: absolute
|
|
838
|
+
};
|
|
839
|
+
}
|
|
840
|
+
/** `realpath`, or `null` when the path doesn't exist yet. */
|
|
841
|
+
async function realpathOrNull(target) {
|
|
842
|
+
try {
|
|
843
|
+
return await realpath(target);
|
|
844
|
+
} catch {
|
|
845
|
+
return null;
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
/**
|
|
849
|
+
* Enforce the spec's remote-endpoint requirements: parseable, HTTPS unless
|
|
850
|
+
* loopback, no embedded credentials, no fragment.
|
|
851
|
+
*
|
|
852
|
+
* Reachability (scheme + host) is decided by {@link isAllowedRemoteUrl}, the
|
|
853
|
+
* single encoding of that policy — `originPinnedFetch` applies the same
|
|
854
|
+
* predicate per redirect hop, so a URL refused here can't be reached by
|
|
855
|
+
* redirect either. Only the message selection lives here; the userinfo and
|
|
856
|
+
* fragment rules are authoring-only and have no redirect equivalent.
|
|
857
|
+
*/
|
|
858
|
+
function validateRemoteUrl(raw) {
|
|
859
|
+
let url;
|
|
860
|
+
try {
|
|
861
|
+
url = new URL(raw);
|
|
862
|
+
} catch {
|
|
863
|
+
return {
|
|
864
|
+
ok: false,
|
|
865
|
+
error: `"url" is not a valid absolute URL ("${raw}").`
|
|
866
|
+
};
|
|
867
|
+
}
|
|
868
|
+
if (!isAllowedRemoteUrl(url)) return {
|
|
869
|
+
ok: false,
|
|
870
|
+
error: url.protocol === "http:" ? `"url" must use https for non-loopback hosts ("${raw}").` : `"url" must use http or https (got "${url.protocol}").`
|
|
871
|
+
};
|
|
872
|
+
if (url.username !== "" || url.password !== "") return {
|
|
873
|
+
ok: false,
|
|
874
|
+
error: "\"url\" must not embed userinfo credentials."
|
|
875
|
+
};
|
|
876
|
+
if (url.hash !== "") return {
|
|
877
|
+
ok: false,
|
|
878
|
+
error: "\"url\" must not contain a fragment."
|
|
879
|
+
};
|
|
880
|
+
return { ok: true };
|
|
881
|
+
}
|
|
882
|
+
/** Reject keys that would hit a prototype setter instead of the record. */
|
|
883
|
+
function isSafeRecordKey(key) {
|
|
884
|
+
return key.length > 0 && !DANGEROUS_RECORD_KEYS.has(key);
|
|
885
|
+
}
|
|
886
|
+
//#endregion
|
|
887
|
+
//#region src/plugins/skills.ts
|
|
888
|
+
/** Fixed location for the skills component type. */
|
|
889
|
+
const PLUGIN_SKILLS_DIR = "skills";
|
|
890
|
+
/** Spec cap on a skill name, re-checked after a `namePrefix` is applied. */
|
|
891
|
+
const SKILL_NAME_MAX = 64;
|
|
892
|
+
/**
|
|
893
|
+
* Discover every skill in the plugin's `skills/` directory.
|
|
894
|
+
*
|
|
895
|
+
* Status semantics follow the spec's failure ladder:
|
|
896
|
+
* - `skills/` missing → `absent` (valid; not an error)
|
|
897
|
+
* - `skills/` not a directory → `invalid` (this component type only)
|
|
898
|
+
* - `skills/` escapes the root → `invalid` (this component type only)
|
|
899
|
+
* - otherwise → `loaded`, possibly with zero skills
|
|
900
|
+
*/
|
|
901
|
+
async function loadPluginSkills(opts) {
|
|
902
|
+
const { boundary, pluginName, source = "project", namePrefix } = opts;
|
|
903
|
+
const diagnostics = [];
|
|
904
|
+
const dir = join(boundary.root, PLUGIN_SKILLS_DIR);
|
|
905
|
+
const located = await resolvePackagePath(boundary, dir, "directory");
|
|
906
|
+
if (!located.ok) {
|
|
907
|
+
if (located.reason === "missing") return {
|
|
908
|
+
status: "absent",
|
|
909
|
+
skills: [],
|
|
910
|
+
diagnostics
|
|
911
|
+
};
|
|
912
|
+
diagnostics.push({
|
|
913
|
+
severity: "error",
|
|
914
|
+
code: located.reason === "escaped" ? "skills-dir-escapes-root" : "skills-dir-wrong-kind",
|
|
915
|
+
message: located.reason === "escaped" ? `"${PLUGIN_SKILLS_DIR}/" resolves outside the plugin root — the skills component is disabled for this plugin.` : `"${PLUGIN_SKILLS_DIR}" exists but is not a directory — the skills component is disabled for this plugin.`,
|
|
916
|
+
path: dir
|
|
917
|
+
});
|
|
918
|
+
return {
|
|
919
|
+
status: "invalid",
|
|
920
|
+
skills: [],
|
|
921
|
+
diagnostics
|
|
922
|
+
};
|
|
923
|
+
}
|
|
924
|
+
let entries;
|
|
925
|
+
try {
|
|
926
|
+
entries = await readdir(located.real);
|
|
927
|
+
} catch (err) {
|
|
928
|
+
diagnostics.push({
|
|
929
|
+
severity: "error",
|
|
930
|
+
code: "skills-dir-unreadable",
|
|
931
|
+
message: `Could not read "${PLUGIN_SKILLS_DIR}/": ${err instanceof Error ? err.message : String(err)}`,
|
|
932
|
+
path: located.real
|
|
933
|
+
});
|
|
934
|
+
return {
|
|
935
|
+
status: "invalid",
|
|
936
|
+
skills: [],
|
|
937
|
+
diagnostics
|
|
938
|
+
};
|
|
939
|
+
}
|
|
940
|
+
const skills = [];
|
|
941
|
+
for (const entry of entries.sort()) {
|
|
942
|
+
const child = join(located.real, entry);
|
|
943
|
+
const childDir = await resolvePackagePath(boundary, child, "directory");
|
|
944
|
+
if (!childDir.ok) {
|
|
945
|
+
if (childDir.reason === "escaped") diagnostics.push({
|
|
946
|
+
severity: "warning",
|
|
947
|
+
code: "skill-dir-escapes-root",
|
|
948
|
+
message: `Skipped "${PLUGIN_SKILLS_DIR}/${entry}" — it resolves outside the plugin root.`,
|
|
949
|
+
path: child
|
|
950
|
+
});
|
|
951
|
+
continue;
|
|
952
|
+
}
|
|
953
|
+
const skillFile = join(childDir.real, "SKILL.md");
|
|
954
|
+
const located2 = await resolvePackagePath(boundary, skillFile, "file");
|
|
955
|
+
if (!located2.ok) {
|
|
956
|
+
if (located2.reason === "escaped") diagnostics.push({
|
|
957
|
+
severity: "error",
|
|
958
|
+
code: "skill-escapes-root",
|
|
959
|
+
message: `Skipped skill "${entry}" — its SKILL.md resolves outside the plugin root.`,
|
|
960
|
+
path: skillFile
|
|
961
|
+
});
|
|
962
|
+
else if (located2.reason === "wrong-kind") diagnostics.push({
|
|
963
|
+
severity: "warning",
|
|
964
|
+
code: "skill-file-wrong-kind",
|
|
965
|
+
message: `Skipped "${PLUGIN_SKILLS_DIR}/${entry}" — its SKILL.md is not a regular file.`,
|
|
966
|
+
path: skillFile
|
|
967
|
+
});
|
|
968
|
+
continue;
|
|
969
|
+
}
|
|
970
|
+
let parsed;
|
|
971
|
+
try {
|
|
972
|
+
parsed = await parseSkillFile(located2.real, { source });
|
|
973
|
+
} catch (err) {
|
|
974
|
+
diagnostics.push({
|
|
975
|
+
severity: "error",
|
|
976
|
+
code: "skill-unreadable",
|
|
977
|
+
message: `Skipped skill "${entry}" — its SKILL.md could not be read: ${err instanceof Error ? err.message : String(err)}`,
|
|
978
|
+
path: located2.real
|
|
979
|
+
});
|
|
980
|
+
continue;
|
|
981
|
+
}
|
|
982
|
+
if (!parsed) {
|
|
983
|
+
diagnostics.push({
|
|
984
|
+
severity: "error",
|
|
985
|
+
code: "skill-invalid",
|
|
986
|
+
message: `Skipped skill "${entry}" — its SKILL.md is not a valid Agent Skill (missing description, or unparseable frontmatter).`,
|
|
987
|
+
path: located2.real
|
|
988
|
+
});
|
|
989
|
+
continue;
|
|
990
|
+
}
|
|
991
|
+
skills.push(stamp(parsed, {
|
|
992
|
+
pluginName,
|
|
993
|
+
namePrefix,
|
|
994
|
+
entry,
|
|
995
|
+
diagnostics
|
|
996
|
+
}));
|
|
997
|
+
}
|
|
998
|
+
return {
|
|
999
|
+
status: "loaded",
|
|
1000
|
+
skills,
|
|
1001
|
+
diagnostics
|
|
1002
|
+
};
|
|
1003
|
+
}
|
|
1004
|
+
/**
|
|
1005
|
+
* Attach plugin provenance and apply the optional name prefix.
|
|
1006
|
+
*
|
|
1007
|
+
* Provenance goes in `metadata` under the `zidane.` prefix the Agent Skills
|
|
1008
|
+
* spec reserves for client-specific keys (see `../skills/types.ts`), so the
|
|
1009
|
+
* skill stays spec-conformant while a host UI can still say which plugin
|
|
1010
|
+
* shipped it.
|
|
1011
|
+
*/
|
|
1012
|
+
function stamp(skill, ctx) {
|
|
1013
|
+
const name = ctx.namePrefix ? `${ctx.namePrefix}-${skill.name}` : skill.name;
|
|
1014
|
+
if (name.length > SKILL_NAME_MAX) ctx.diagnostics.push({
|
|
1015
|
+
severity: "warning",
|
|
1016
|
+
code: "skill-name-too-long",
|
|
1017
|
+
message: `Prefixed skill name "${name}" exceeds the ${SKILL_NAME_MAX}-character spec limit. Loading anyway.`
|
|
1018
|
+
});
|
|
1019
|
+
return {
|
|
1020
|
+
...skill,
|
|
1021
|
+
name,
|
|
1022
|
+
metadata: {
|
|
1023
|
+
...skill.metadata,
|
|
1024
|
+
"zidane.plugin": ctx.pluginName
|
|
1025
|
+
}
|
|
1026
|
+
};
|
|
1027
|
+
}
|
|
1028
|
+
//#endregion
|
|
1029
|
+
//#region src/plugins/load.ts
|
|
1030
|
+
/** Fixed location of the plugin manifest. */
|
|
1031
|
+
const PLUGIN_MANIFEST_FILE = "plugin.json";
|
|
1032
|
+
/**
|
|
1033
|
+
* Default parent of every plugin's `${PLUGIN_DATA}` directory.
|
|
1034
|
+
*
|
|
1035
|
+
* Deliberately OUTSIDE any plugin root: the spec requires the data directory
|
|
1036
|
+
* to persist across plugin updates, and an update replaces the package tree.
|
|
1037
|
+
* Keyed by plugin name so two copies of the same plugin (project + user)
|
|
1038
|
+
* share state, which is what an author storing a cache or a token expects.
|
|
1039
|
+
*
|
|
1040
|
+
* Routed through `resolveStorageDirs` rather than hardcoding `~/.zidane` so it
|
|
1041
|
+
* honours `ZIDANE_STORAGE_DIR` and the XDG dirs like every other persisted
|
|
1042
|
+
* location — otherwise an XDG-mode install would scatter plugin data into a
|
|
1043
|
+
* `~/.zidane` nothing else in the app touches.
|
|
1044
|
+
*
|
|
1045
|
+
* NB: `resolveStorageDirs` does NOT read `ZIDANE_PREFIX`; it defaults `prefix`
|
|
1046
|
+
* to `.zidane`. Callers with a configured prefix must pass it, or plugin data
|
|
1047
|
+
* lands beside a prefix the rest of the install doesn't use.
|
|
1048
|
+
*/
|
|
1049
|
+
function defaultPluginDataRoot(opts = {}) {
|
|
1050
|
+
return join(resolveStorageDirs(opts).dataDir, "plugin-data");
|
|
1051
|
+
}
|
|
1052
|
+
/**
|
|
1053
|
+
* Load an Agent Plugin from `dir`.
|
|
1054
|
+
*
|
|
1055
|
+
* @returns A {@link LoadedPlugin}. Check `ok` before trusting `manifest`;
|
|
1056
|
+
* check `components` to tell "the plugin ships no skills" (`absent`) from
|
|
1057
|
+
* "its skills were rejected" (`invalid`).
|
|
1058
|
+
*/
|
|
1059
|
+
async function loadAgentPlugin(dir, opts = {}) {
|
|
1060
|
+
const diagnostics = [];
|
|
1061
|
+
const boundary = await resolvePluginBoundary(dir);
|
|
1062
|
+
if (!boundary) return rejected(resolve(dir), resolve(dir), [{
|
|
1063
|
+
severity: "error",
|
|
1064
|
+
code: "plugin-root-unreadable",
|
|
1065
|
+
message: `Plugin root "${dir}" does not exist or is not a directory.`,
|
|
1066
|
+
path: resolve(dir)
|
|
1067
|
+
}]);
|
|
1068
|
+
const manifestPath = join(boundary.root, PLUGIN_MANIFEST_FILE);
|
|
1069
|
+
const located = await resolvePackagePath(boundary, manifestPath, "file");
|
|
1070
|
+
if (!located.ok) return rejected(boundary.dir, boundary.root, [{
|
|
1071
|
+
severity: "error",
|
|
1072
|
+
code: located.reason === "escaped" ? "manifest-escapes-root" : "manifest-missing",
|
|
1073
|
+
message: located.reason === "escaped" ? `${PLUGIN_MANIFEST_FILE} resolves outside the plugin root.` : located.reason === "wrong-kind" ? `${PLUGIN_MANIFEST_FILE} exists but is not a regular file.` : `No ${PLUGIN_MANIFEST_FILE} at the plugin root — not an Agent Plugin.`,
|
|
1074
|
+
path: manifestPath
|
|
1075
|
+
}]);
|
|
1076
|
+
let text;
|
|
1077
|
+
try {
|
|
1078
|
+
text = await readFile(located.real, "utf8");
|
|
1079
|
+
} catch (err) {
|
|
1080
|
+
return rejected(boundary.dir, boundary.root, [{
|
|
1081
|
+
severity: "error",
|
|
1082
|
+
code: "manifest-unreadable",
|
|
1083
|
+
message: `Could not read ${PLUGIN_MANIFEST_FILE}: ${err instanceof Error ? err.message : String(err)}`,
|
|
1084
|
+
path: located.real
|
|
1085
|
+
}]);
|
|
1086
|
+
}
|
|
1087
|
+
const validated = parsePluginManifest(text);
|
|
1088
|
+
diagnostics.push(...validated.diagnostics);
|
|
1089
|
+
if (!validated.ok) return rejected(boundary.dir, boundary.root, diagnostics);
|
|
1090
|
+
const { manifest } = validated;
|
|
1091
|
+
const result = {
|
|
1092
|
+
ok: true,
|
|
1093
|
+
dir: boundary.dir,
|
|
1094
|
+
root: boundary.root,
|
|
1095
|
+
manifest,
|
|
1096
|
+
skills: [],
|
|
1097
|
+
mcpServers: [],
|
|
1098
|
+
components: {
|
|
1099
|
+
skills: "unsupported",
|
|
1100
|
+
mcp: "unsupported"
|
|
1101
|
+
},
|
|
1102
|
+
diagnostics
|
|
1103
|
+
};
|
|
1104
|
+
if (opts.skills !== false) await guardComponent(result, "skills", diagnostics, async () => {
|
|
1105
|
+
const skills = await loadPluginSkills({
|
|
1106
|
+
boundary,
|
|
1107
|
+
pluginName: manifest.name,
|
|
1108
|
+
...opts.source !== void 0 ? { source: opts.source } : {},
|
|
1109
|
+
...opts.namePrefix !== void 0 ? { namePrefix: opts.namePrefix } : {}
|
|
1110
|
+
});
|
|
1111
|
+
result.components.skills = skills.status;
|
|
1112
|
+
result.skills = skills.skills;
|
|
1113
|
+
diagnostics.push(...skills.diagnostics);
|
|
1114
|
+
});
|
|
1115
|
+
if (opts.mcp !== false) await guardComponent(result, "mcp", diagnostics, async () => {
|
|
1116
|
+
const mcp = await loadPluginMcp({
|
|
1117
|
+
boundary,
|
|
1118
|
+
pluginName: manifest.name,
|
|
1119
|
+
dataDir: opts.dataDir ?? join(opts.dataRoot ?? defaultPluginDataRoot(), manifest.name),
|
|
1120
|
+
specVersion: manifest.specVersion,
|
|
1121
|
+
...opts.namePrefix !== void 0 ? { namePrefix: opts.namePrefix } : {}
|
|
1122
|
+
});
|
|
1123
|
+
result.components.mcp = mcp.status;
|
|
1124
|
+
result.mcpServers = mcp.servers;
|
|
1125
|
+
diagnostics.push(...mcp.diagnostics);
|
|
1126
|
+
});
|
|
1127
|
+
return result;
|
|
1128
|
+
}
|
|
1129
|
+
/**
|
|
1130
|
+
* Run one component-discovery pass, converting an unexpected throw into an
|
|
1131
|
+
* `invalid` component plus a diagnostic. Partial results the pass already
|
|
1132
|
+
* assigned are dropped, so a half-discovered component never looks loaded.
|
|
1133
|
+
*
|
|
1134
|
+
* No input reachable through this module's public API is known to trigger it:
|
|
1135
|
+
* both component loaders catch their own filesystem and parse failures, which
|
|
1136
|
+
* is exactly the property their tests assert. It is kept deliberately, not
|
|
1137
|
+
* abandoned — `loadAgentPlugin` promises never to throw for plugin-level
|
|
1138
|
+
* problems, and that promise should survive a future component type whose
|
|
1139
|
+
* loader forgets a `try`. The observable half IS tested: see the "never throws"
|
|
1140
|
+
* matrix in `test/plugins/load.test.ts`.
|
|
1141
|
+
*/
|
|
1142
|
+
async function guardComponent(result, component, diagnostics, run) {
|
|
1143
|
+
try {
|
|
1144
|
+
await run();
|
|
1145
|
+
} catch (err) {
|
|
1146
|
+
result.components[component] = "invalid";
|
|
1147
|
+
if (component === "skills") result.skills = [];
|
|
1148
|
+
else result.mcpServers = [];
|
|
1149
|
+
diagnostics.push({
|
|
1150
|
+
severity: "error",
|
|
1151
|
+
code: `${component}-discovery-failed`,
|
|
1152
|
+
message: `Unexpected error while loading the ${component} component: ${err instanceof Error ? err.message : String(err)}`,
|
|
1153
|
+
path: result.root
|
|
1154
|
+
});
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
function rejected(dir, root, diagnostics) {
|
|
1158
|
+
return {
|
|
1159
|
+
ok: false,
|
|
1160
|
+
dir,
|
|
1161
|
+
root,
|
|
1162
|
+
skills: [],
|
|
1163
|
+
mcpServers: [],
|
|
1164
|
+
components: {
|
|
1165
|
+
skills: "unsupported",
|
|
1166
|
+
mcp: "unsupported"
|
|
1167
|
+
},
|
|
1168
|
+
diagnostics
|
|
1169
|
+
};
|
|
1170
|
+
}
|
|
1171
|
+
//#endregion
|
|
1172
|
+
export { PLUGIN_MCP_FILE as a, validatePluginManifest as c, isSupportedAgentPluginsVersion as d, parseSchemaId as f, PLUGIN_SKILLS_DIR as i, SUPPORTED_AGENT_PLUGINS_VERSIONS as l, defaultPluginDataRoot as n, expandPluginVars as o, resolveStorageDirs as p, loadAgentPlugin as r, parsePluginManifest as s, PLUGIN_MANIFEST_FILE as t, agentPluginsSchemaId as u };
|
|
1173
|
+
|
|
1174
|
+
//# sourceMappingURL=plugins-2PMTVcp-.js.map
|