@jini-ai/agent-plugins 0.4.1 → 0.4.3

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.
Files changed (48) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/core/persistent-state.d.ts +25 -0
  3. package/core/persistent-state.js +260 -0
  4. package/dist/lifecycle/activation.js +38 -166
  5. package/dist/lifecycle/bundled-digests.d.ts +6 -0
  6. package/dist/lifecycle/bundled-digests.js +160 -0
  7. package/dist/lifecycle/bundled-source-archive.d.ts +3 -0
  8. package/dist/lifecycle/bundled-source-archive.js +74 -0
  9. package/dist/lifecycle/contribution-loader.d.ts +102 -0
  10. package/dist/lifecycle/contribution-loader.js +92 -0
  11. package/dist/lifecycle/fetch-archive.d.ts +1 -2
  12. package/dist/lifecycle/index.d.ts +24 -4
  13. package/dist/lifecycle/index.js +11 -0
  14. package/dist/lifecycle/install-from-url.d.ts +2 -5
  15. package/dist/lifecycle/install-from-url.js +2 -5
  16. package/dist/lifecycle/install.d.ts +58 -3
  17. package/dist/lifecycle/install.js +301 -23
  18. package/dist/lifecycle/installed-package-roots.d.ts +16 -0
  19. package/dist/lifecycle/installed-package-roots.js +55 -0
  20. package/dist/lifecycle/layout.js +1 -1
  21. package/dist/lifecycle/manifest.d.ts +5 -60
  22. package/dist/lifecycle/manifest.js +12 -20
  23. package/dist/lifecycle/package-paths.d.ts +2 -0
  24. package/dist/lifecycle/package-paths.js +62 -0
  25. package/dist/lifecycle/persistent-state.d.ts +5 -1
  26. package/dist/lifecycle/persistent-state.js +3 -1
  27. package/dist/lifecycle/ports.d.ts +20 -2
  28. package/dist/lifecycle/resolve-agent-plugin-refs.d.ts +13 -1
  29. package/dist/lifecycle/resolve-agent-plugin-refs.js +150 -40
  30. package/dist/lifecycle/retire-bundled.d.ts +50 -1
  31. package/dist/lifecycle/retire-bundled.js +9 -2
  32. package/dist/lifecycle/search.d.ts +65 -0
  33. package/dist/lifecycle/search.js +31 -2
  34. package/dist/lifecycle/seed-bundled.d.ts +18 -1
  35. package/dist/lifecycle/seed-bundled.js +131 -3
  36. package/dist/lifecycle/set-enabled.d.ts +7 -0
  37. package/dist/lifecycle/set-enabled.js +5 -0
  38. package/dist/lifecycle/trusted-plugin-files.d.ts +38 -0
  39. package/dist/lifecycle/trusted-plugin-files.js +18 -0
  40. package/dist/lifecycle/types.d.ts +14 -0
  41. package/dist/lifecycle/uninstall.d.ts +29 -0
  42. package/dist/lifecycle/uninstall.js +204 -4
  43. package/dist/lifecycle/yauzl-archive-reader.d.ts +0 -1
  44. package/dist/manifest.d.ts +3 -0
  45. package/dist/manifest.js +3 -2
  46. package/package.json +22 -8
  47. package/persistent-state.js +2 -260
  48. package/ui-ux-design/plugin.json +15 -1
@@ -1,9 +1,82 @@
1
+ import { listInstalledPackageRoots } from "./installed-package-roots.js";
2
+ import { createUninstallModule } from "./uninstall.js";
3
+ import { readPluginIdentity, assertPluginIdentityAvailable } from "@jini-ai/core/plugin-directory";
4
+ /**
5
+ * @file `installAgentPlugin()` — content-addressed, adversarially-hardened extraction of one Agent
6
+ * Plugin (agent-plugins.org) archive into one workspace's own `AgentPluginWorkspaceLayout.packages`.
7
+ *
8
+ * This is the highest-risk unit in the whole feature. Extraction is where containment actually
9
+ * breaks — everything downstream (capability projection, a future MCP admission gate) trusts that a
10
+ * "package root" is exactly the bytes the archive declared, laid out exactly where the archive said,
11
+ * with nothing outside it touched. Two independently-cited attack classes drive the checks here:
12
+ *
13
+ * - **Zip-slip / archive-extraction path traversal** (Snyk's zip-slip research; JFrog's
14
+ * `mholt/archiver` writeup; the `node-tar` GHSA-8qq5-rm4j-mr97 advisory) has two vectors: a
15
+ * crafted entry NAME containing `../` (caught by {@link normalizePackageEntryPath}'s lexical
16
+ * check), and a crafted SYMLINK entry whose target a later, innocent-looking entry name walks
17
+ * through. The convergent ecosystem fix for the second vector — and the one this module takes — is
18
+ * to refuse symlink entries outright rather than attempt to validate where they point: a plugin
19
+ * has no legitimate reason to ship a symlink inside its own package, and the Agent Plugins spec's
20
+ * own path-safety clause only ever promises symlinks MAY resolve inside the root, never that a
21
+ * client must accept one.
22
+ * - **Decompression bombs**, in both the classic "one file lies about its size" shape (caught by
23
+ * bounding bytes actually observed leaving `openReadStream()`, not the archive's own declared
24
+ * size) and the "many small files" shape (caught by a running total across the whole archive).
25
+ *
26
+ * Why this is a SEPARATE format from the host runtime-plugin format's loader (`plugin-runtime/loader.ts`), not a
27
+ * shared one: verified against that file directly. `loadPlugin()`'s BR-01 pipeline requires
28
+ * `manifest.integrity` (a per-file SHA-256 map), `manifest.sdkRange` checked against a runtime
29
+ * the host plugin SDK version, and a `server/index.mjs` ESM entry point it `import()`s. An Agent Plugin has
30
+ * none of those — no integrity map, no SDK range, no single entry point, and (per this feature's own
31
+ * FINAL decision) no code the host ever imports or executes at all in v1: Skills are markdown read for
32
+ * context injection, and MCP servers are preview-only (`capability-projection.ts`). Adapting Agent
33
+ * Plugins into `loadPlugin()`'s pipeline would mean inventing values for fields the open standard
34
+ * does not define, which is precisely the "package format vs. execution/trust model" conflation this
35
+ * feature's own owner-locked decisions (see `CTX-AGENTPLUGINS-2026-08-12.md` F2) rule out.
36
+ *
37
+ * Why the archive itself is a `ArchiveReaderPort`, not a concrete zip/tar library call: this
38
+ * codebase's own port+adapter discipline (`mcp-federation/ports.ts`'s `McpSessionPort`/
39
+ * `McpStdioChannel` split is the direct precedent) — and, concretely, no zip/tar dependency exists
40
+ * in this repo's `package.json` today. Choosing and vetting one (license, maintenance, streaming
41
+ * support) is a real decision outside a Programmer's scope to make silently; this module declares
42
+ * the seam and is exercised end-to-end by a scripted double, so a real adapter is a pure addition
43
+ * with zero change to the hardening logic here. See the handoff's REMAINING section.
44
+ *
45
+ * Content-addressing (`ws/<workspaceId>/packages/sha256/<archiveDigest>/`): the digest is verified
46
+ * BEFORE extraction ever touches the archive reader, so an install with a mismatched digest never
47
+ * even attempts to unpack — the digest is the trust boundary between "bytes a marketplace claimed"
48
+ * and "bytes this process is willing to run a parser over". A second install of byte-identical
49
+ * content BY THE SAME WORKSPACE is recognized from the digest alone and short-circuits without a
50
+ * second extraction. Deliberately NOT shared across workspaces — see `layout.ts`'s header (owner
51
+ * decision, tenant-grade isolation, 2026-08-12): this function resolves the workspace-scoped layout
52
+ * itself, exactly once, from an instance-level `AgentPluginLayout` plus one `workspaceId` (see the
53
+ * SECURITY note on {@link InstallAgentPluginRequired} below) — the caller is what makes two
54
+ * different workspaces' installs land in disjoint trees, simply by supplying a different
55
+ * `workspaceId` for each; this function's own internal `forWorkspace()` call is what turns that into
56
+ * disjoint, internally-consistent paths.
57
+ *
58
+ * Architectural role:
59
+ * The one place this feature performs filesystem writes for installed package bytes. No network I/O
60
+ * (archive bytes and their expected digest are caller-supplied — see the module header above for why
61
+ * "how does the archive get here" is out of this slice) and no execution of anything extracted.
62
+ */
63
+ /** One trimmed, non-blank string field of the host-supplied extension namespace, capped at `maxLength`, else
64
+ * `undefined` — a malformed value falls back rather than failing the whole index, since every
65
+ * field read this way is cosmetic. The cap stops a hostile package flooding the admin row.
66
+ * @complexity O(maxLength). */
67
+ /** `{ displayName }` when the host namespace's `displayName` is a non-blank string, else `{}` (callers
68
+ * title-case the id).
69
+ * @complexity O(1). */
70
+ /** `{ summary }` when the host namespace's `summary` is a non-blank string, else `{}` (the admin shows
71
+ * the spec `description` instead). Inner newlines are kept: blank lines are the paragraph breaks
72
+ * the admin renders.
73
+ * @complexity O(1). */
1
74
  import { createPersistentStateModule } from "./persistent-state.js";
2
75
  /** Extracted from the host plugin lifecycle. Effects are supplied per host context. */
3
76
  import { createHash } from "node:crypto";
4
77
  import { constants } from "node:fs";
5
78
  import path from "node:path";
6
- import { parseAgentPluginManifest as parseAgentPluginManifestValue } from "./manifest.js";
79
+ import { readAgentPluginExtension, parseAgentPluginManifest as parseAgentPluginManifestValue } from "./manifest.js";
7
80
  import { PackagePathViolation } from "./package-paths.js";
8
81
  import { createPackagePathsModule } from "./package-paths.js";
9
82
  export class AgentPluginInstallError extends Error {
@@ -25,7 +98,7 @@ function buildModule(ports) {
25
98
  const rename = ports.filesystem.rename.bind(ports.filesystem);
26
99
  const rm = ports.filesystem.rm.bind(ports.filesystem);
27
100
  const stat = ports.filesystem.stat.bind(ports.filesystem);
28
- const parseAgentPluginManifest = (value) => parseAgentPluginManifestValue({ value });
101
+ const parseAgentPluginManifest = (value) => parseAgentPluginManifestValue({ value }, { identityNamespace: ports.extensionNamespace });
29
102
  const { assertContainedOnDisk, normalizePackageEntryPath } = createPackagePathsModule(ports);
30
103
  const LIMITS = {
31
104
  maxArchiveBytes: 32 * 1024 * 1024,
@@ -33,11 +106,89 @@ function buildModule(ports) {
33
106
  maxFileBytes: 16 * 1024 * 1024,
34
107
  maxTotalExtractedBytes: 64 * 1024 * 1024,
35
108
  };
109
+ /** Publisher ids are metadata, never filesystem segments. The workspace identity lock
110
+ * serializes installs under different local names competing for one qualified id.
111
+ * @complexity O(d) manifest reads for d installed revisions; no executable imports. */
112
+ async function assertIdentityAvailable(workspaceRoot, candidate) {
113
+ // Installed roots are canonical paths; compare against the same root even when the host
114
+ // supplied an OS alias (macOS /var -> /private/var). Containment still rejects escaped links.
115
+ workspaceRoot = await ports.filesystem.realpath(workspaceRoot);
116
+ for (const root of await listInstalledPackageRoots({ ports, workspaceRoot })) {
117
+ const manifestPath = path.join(root.packageRoot, 'plugin.json');
118
+ await persistentState.assertOwnedPath({ workspaceRoot, entryPath: path.relative(workspaceRoot, manifestPath) });
119
+ let raw;
120
+ let identity;
121
+ try {
122
+ raw = JSON.parse(await readFile(manifestPath, 'utf8'));
123
+ identity = readPluginIdentity({ manifest: raw, name: root.localId, namespace: ports.extensionNamespace });
124
+ }
125
+ catch {
126
+ // A damaged unrelated install must not block fresh installs or bundled seeding.
127
+ ports.onEvent?.({ event: 'agent-plugin-identity-warning', message: `Plugin '${root.localId}': installed identity manifest is unreadable or invalid; omitted from identity admission.` });
128
+ continue;
129
+ }
130
+ try {
131
+ assertPluginIdentityAvailable({ candidate: { id: candidate.id, localId: candidate.pluginId, family: 'agent-plugin' },
132
+ installed: [{ ...identity, localId: root.localId, family: 'agent-plugin', version: typeof raw.version === 'string' ? raw.version : null, enabled: false }] });
133
+ }
134
+ catch {
135
+ throw new AgentPluginInstallError({ code: 'PLUGIN_ID_CONFLICT', message: 'The publisher-qualified plugin id conflicts with an installed package.' });
136
+ }
137
+ }
138
+ }
139
+ /** Retires only this plugin's superseded revisions after accepted publication, under the same
140
+ * identity/plugin locks. Persistent data, learned facts and notes never enter this operation.
141
+ * Reuse uninstall's frozen-tree staging so an interrupted removal is no longer a live digest.
142
+ * There is no previous-revision rollback slot in this lifecycle; failed publication keeps all
143
+ * prior bytes. Cleanup faults warn without turning an accepted install into a failed install.
144
+ * @complexity O(d + f), d workspace revisions, f files in this plugin's superseded packages.
145
+ */
146
+ async function pruneSupersededRevisions({ workspaceRoot, installed }, _optional = {}) {
147
+ try {
148
+ // Resolve lazily: uninstall's read-side index uses this install module for manifest indexing.
149
+ const removal = createUninstallModule(ports);
150
+ for (const root of await listInstalledPackageRoots({ ports, workspaceRoot })) {
151
+ if (root.localId !== installed.pluginId || root.archiveDigest === installed.archiveDigest)
152
+ continue;
153
+ try {
154
+ const staged = await removal.stageForRemoval(path.dirname(root.packageRoot), root.packageRoot);
155
+ await removal.removeFrozenPackageTree(staged.quarantined);
156
+ }
157
+ catch {
158
+ ports.onEvent?.({ event: 'agent-plugin-revision-prune-warning', message: `Plugin '${installed.pluginId}': a superseded revision could not be pruned.` });
159
+ }
160
+ }
161
+ }
162
+ catch {
163
+ ports.onEvent?.({ event: 'agent-plugin-revision-prune-warning', message: `Plugin '${installed.pluginId}': superseded revisions could not be scanned for pruning.` });
164
+ }
165
+ }
166
+ /** Exposes `LIMITS.maxArchiveBytes` for a caller outside this module — concretely, a test asserting
167
+ * `@jini-ai/agent-plugins/lifecycle`'s fetch cap constant stays pinned to this one. The local fetch-archive.ts fork was deleted; see the package's fetch module
168
+ * for why it duplicates the number instead of importing it (no runtime dependency on `install.ts`);
169
+ * this accessor is what lets a test assert the pairing without either module importing the other. */
36
170
  function maxAgentPluginInstallArchiveBytes() {
37
171
  return LIMITS.maxArchiveBytes;
38
172
  }
39
173
  const SHA256_HEX_PATTERN = /^[a-f0-9]{64}$/;
40
- async function installAgentPlugin(required, _optional = {}) {
174
+ /**
175
+ * Verifies, extracts, and publishes one Agent Plugin archive.
176
+ *
177
+ * @throws {AgentPluginInstallError} For every expected failure — a hostile or malformed archive, a
178
+ * digest mismatch, an invalid manifest. Never partially publishes: any failure after extraction has
179
+ * begun removes the whole staging directory before rethrowing.
180
+ * @complexity O(e) in archive entry count, O(b) in total extracted bytes — both explicitly bounded
181
+ * by {@link LIMITS}, so this function's cost cannot be driven unbounded by a hostile archive.
182
+ */
183
+ // The ONE call that turns (instance layout, workspaceId) into real, internally-consistent
184
+ // packages/staging/pluginDataDir paths — see the SECURITY note on `InstallAgentPluginRequired.layout`
185
+ // above for why this replaces accepting an already-resolved `AgentPluginWorkspaceLayout` directly.
186
+ // `forWorkspace` itself still throws for a syntactically invalid `workspaceId`, unchanged.
187
+ // Whether this install succeeded (bytes now live at `finalRoot`) or failed, the staging
188
+ // transaction directory itself must never be left behind — on success `rename` already moved
189
+ // `extractionRoot` out of it, so this only ever removes the (now-empty, or failure-abandoned)
190
+ // temp directory, never the published package.
191
+ async function installAgentPlugin(required, optional = {}) {
41
192
  const { archive, expectedSha256, archiveReader, layout, workspaceId } = required;
42
193
  const workspaceLayout = layout.forWorkspace({ workspaceId });
43
194
  if (archive.byteLength > LIMITS.maxArchiveBytes) {
@@ -57,34 +208,73 @@ function buildModule(ports) {
57
208
  try {
58
209
  const executablePaths = await extractEntries(archiveReader.entries({ archive }), extractionRoot);
59
210
  const indexed = await indexInstalledRoot(extractionRoot, digest);
60
- return await persistentState.withPluginLock({ workspaceRoot: workspaceLayout.root, pluginId: indexed.pluginId, run: async () => {
61
- // The manifest identifies the plugin only after bounded staging extraction (Layout B).
62
- const pluginId = indexed.pluginId;
63
- const packagesDir = workspaceLayout.pluginPackagesDir({ pluginId });
64
- await persistentState.assertOwnedPath({ workspaceRoot: workspaceLayout.root, entryPath: path.relative(workspaceLayout.root, packagesDir) });
65
- await mkdir(packagesDir, { recursive: true, mode: 0o700 });
66
- const finalRoot = await assertContainedOnDisk(packagesDir, digest);
67
- for (const directory of [workspaceLayout.pluginDataDir({ pluginId }),
68
- workspaceLayout.pluginMemoryDir({ pluginId, kind: 'learned' }),
69
- workspaceLayout.pluginMemoryDir({ pluginId, kind: 'notes' })]) {
70
- await persistentState.assertOwnedPath({ workspaceRoot: workspaceLayout.root, entryPath: path.relative(workspaceLayout.root, directory) });
71
- await mkdir(directory, { recursive: true, mode: 0o700 });
72
- }
73
- if (await isRealDirectory(finalRoot))
74
- return indexInstalledRoot(finalRoot, digest);
75
- await publish(extractionRoot, finalRoot);
76
- await freezeTree(finalRoot, executablePaths);
77
- return { ...indexed, packageRoot: finalRoot };
211
+ if (optional.previewOnly)
212
+ return indexed;
213
+ return await persistentState.withIdentityLock({ workspaceRoot: workspaceLayout.root, run: async () => {
214
+ await assertIdentityAvailable(workspaceLayout.root, indexed);
215
+ const publishWithinPluginLock = () => persistentState.withPluginLock({ workspaceRoot: workspaceLayout.root, pluginId: indexed.pluginId, run: async () => {
216
+ // Layout B needs the manifest id before choosing the package destination. Dedup still
217
+ // skips publication and freezing, after one bounded staging extraction.
218
+ // Owner decision 2026-10-04: learned facts begin empty for each site; author guidance stays
219
+ // in package docs. Package extension files never seed learned memory or the user's notes.
220
+ // Content-addressed dedup, scoped to THIS workspace's own tree (`workspaceLayout` was just
221
+ // resolved above, atomically, from this same `workspaceId`): identical bytes were already
222
+ // extracted, verified, and frozen by a prior install of this same workspace's — never
223
+ // re-extracted for a different workspace, by construction, since a different workspace's
224
+ // `workspaceLayout.packages` is a different path entirely. one bounded staging extraction precedes this check.
225
+ // Publish BEFORE freezing permissions, not after: `rename()` moves `extractionRoot` as a single
226
+ // directory-entry operation, and on this filesystem renaming an already-read-only (0o555)
227
+ // directory itself fails EACCES (observed, not merely theoretical — see the regression test this
228
+ // ordering fixed). Freezing after publish also closes a smaller race for free: nothing outside
229
+ // this function can observe the package at its live path while it is still writable, because the
230
+ // live path does not exist until `publish` returns.
231
+ let publishedPlugin;
232
+ const publishPackage = async () => {
233
+ // The manifest identifies the plugin only after bounded staging extraction (Layout B).
234
+ const pluginId = indexed.pluginId;
235
+ const packagesDir = workspaceLayout.pluginPackagesDir({ pluginId });
236
+ await persistentState.assertOwnedPath({ workspaceRoot: workspaceLayout.root, entryPath: path.relative(workspaceLayout.root, packagesDir) });
237
+ await mkdir(packagesDir, { recursive: true, mode: 0o700 });
238
+ const finalRoot = await assertContainedOnDisk(packagesDir, digest);
239
+ for (const directory of [workspaceLayout.pluginDataDir({ pluginId }),
240
+ workspaceLayout.pluginMemoryDir({ pluginId, kind: 'learned' }),
241
+ workspaceLayout.pluginMemoryDir({ pluginId, kind: 'notes' })]) {
242
+ await persistentState.assertOwnedPath({ workspaceRoot: workspaceLayout.root, entryPath: path.relative(workspaceLayout.root, directory) });
243
+ await mkdir(directory, { recursive: true, mode: 0o700 });
244
+ }
245
+ if (await isRealDirectory(finalRoot)) {
246
+ publishedPlugin = await indexInstalledRoot(finalRoot, digest);
247
+ return publishedPlugin;
248
+ }
249
+ await publish(extractionRoot, finalRoot);
250
+ await freezeTree(finalRoot, executablePaths);
251
+ /** Absolute, read-only (frozen) path to the extracted package root. */
252
+ publishedPlugin = { ...indexed, packageRoot: finalRoot };
253
+ return publishedPlugin;
254
+ };
255
+ const installed = await (optional.publishGuard ? optional.publishGuard({ plugin: indexed, publish: publishPackage }, {}) : publishPackage());
256
+ if (publishedPlugin)
257
+ await pruneSupersededRevisions({ workspaceRoot: workspaceLayout.root, installed: publishedPlugin });
258
+ return installed;
259
+ } });
260
+ if (ports.identityAdmission)
261
+ return ports.identityAdmission({ id: indexed.id, localId: indexed.pluginId, run: publishWithinPluginLock }, {});
262
+ return publishWithinPluginLock();
78
263
  } });
79
264
  }
80
265
  finally {
81
266
  await rm(transactionRoot, { recursive: true, force: true });
82
267
  }
83
268
  }
269
+ /** The `(target '...')` suffix on the symlink-rejection message, or nothing when the entry carries
270
+ * no `linkTarget` at all — split out purely to keep that `&&`/ternary pair out of
271
+ * {@link extractOneEntry}'s own branch count. */
84
272
  function symlinkRejectionMessage(entry) {
85
273
  const targetSuffix = "linkTarget" in entry && entry.linkTarget ? ` (target '${entry.linkTarget}')` : "";
86
274
  return `archive entry '${entry.entryPath}' has kind '${entry.kind}', which Agent Plugin packages are not permitted to contain${targetSuffix}`;
87
275
  }
276
+ /** `normalizePackageEntryPath()`, with its thrown error re-shaped into the module's own
277
+ * `AgentPluginInstallError` vocabulary. */
88
278
  function normalizeEntryPathOrThrow(entryPath) {
89
279
  try {
90
280
  return normalizePackageEntryPath(entryPath);
@@ -93,6 +283,7 @@ function buildModule(ports) {
93
283
  throw new AgentPluginInstallError({ code: "UNSAFE_ENTRY_PATH", message: `archive entry path is unsafe: '${entryPath}'` }, { cause: error });
94
284
  }
95
285
  }
286
+ /** `assertContainedOnDisk()`, with its thrown error re-shaped the same way. */
96
287
  async function resolveContainedDestinationOrThrow(extractionRoot, normalized) {
97
288
  try {
98
289
  return await assertContainedOnDisk(extractionRoot, normalized);
@@ -103,6 +294,15 @@ function buildModule(ports) {
103
294
  });
104
295
  }
105
296
  }
297
+ /**
298
+ * Applies every hardening rule in this module's header to one archive entry: symlink rejection,
299
+ * path normalization, duplicate detection, containment, the per-file size cap, and (for a file
300
+ * entry) the actual write. Returns the running total-extracted-bytes count, since that is the one
301
+ * piece of `extractEntries`'s loop state a single entry's processing can change.
302
+ */
303
+ // Refused BEFORE path normalization: a symlink entry is never acceptable regardless of how
304
+ // "safe" its own name looks, matching the ecosystem fix this module's header cites — validating
305
+ // where a permitted symlink might point is more attack surface than refusing the entry kind.
106
306
  async function extractOneEntry(args) {
107
307
  const { entry, extractionRoot, seen, executablePaths, totalBytesSoFar } = args;
108
308
  if (entry.kind !== "file" && entry.kind !== "directory") {
@@ -139,6 +339,8 @@ function buildModule(ports) {
139
339
  * its entries from the archive bytes, so a second iteration costs one central-directory walk
140
340
  * and opens no content stream.
141
341
  */
342
+ /** Ported from Jini 78d4b60b: reject from metadata before a staging transaction exists.
343
+ * A second central-directory walk opens no content streams and avoids over-cap disk churn. */
142
344
  async function assertEntryCountWithinCap(entries) {
143
345
  let entryCount = 0;
144
346
  for await (const _entry of entries) {
@@ -150,6 +352,9 @@ function buildModule(ports) {
150
352
  function tooManyEntriesError() {
151
353
  return new AgentPluginInstallError({ code: "TOO_MANY_ENTRIES", message: `archive exceeds the ${LIMITS.maxEntries}-entry cap` });
152
354
  }
355
+ /** Extracts every entry into `extractionRoot`, enforcing every hardening rule in this module's
356
+ * header. Returns the set of archive-relative paths the archive marked executable, for
357
+ * {@link freezeTree} to preserve the `+x` bit on. */
153
358
  async function extractEntries(entries, extractionRoot) {
154
359
  const seen = new Set();
155
360
  const executablePaths = new Set();
@@ -164,6 +369,12 @@ function buildModule(ports) {
164
369
  }
165
370
  return executablePaths;
166
371
  }
372
+ /** Streams one file entry to disk, bounding both its own size and the running archive total against
373
+ * bytes ACTUALLY observed — never the archive format's declared size, which a decompression bomb
374
+ * lies about by construction. `O_EXCL` refuses to write over anything already at `destination`
375
+ * (there cannot legitimately be one — `extractEntries`'s duplicate check already refused a second
376
+ * entry for the same path); `O_NOFOLLOW` refuses to follow a final-component symlink if the
377
+ * filesystem somehow already had one (defense in depth — this module never creates one itself). */
167
378
  async function writeContainedFile(params) {
168
379
  let flags = constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL;
169
380
  if (ports.process.platform !== "win32")
@@ -189,7 +400,36 @@ function buildModule(ports) {
189
400
  }
190
401
  return totalBytes;
191
402
  }
192
- async function indexInstalledRoot(packageRoot, archiveDigest) {
403
+ /** Cosmetic fields are trimmed and capped rather than invalidating the package. The cap stops
404
+ * a hostile package flooding an admin row. Inner newlines remain paragraph breaks in summaries.
405
+ * @complexity O(n) in the supplied text length; output is capped at maxLength. */
406
+ function readDisplayText(required, _optional = {}) {
407
+ const text = readAgentPluginExtension({ manifest: required.manifest, namespace: ports.extensionNamespace,
408
+ read: ({ value }) => {
409
+ const field = value[required.key];
410
+ return typeof field === 'string' ? field.trim() : undefined;
411
+ } });
412
+ return text ? { [required.key]: text.slice(0, required.maxLength) } : {};
413
+ }
414
+ /**
415
+ * Reads the (already-extracted-and-trusted, or already-published) `plugin.json` at `packageRoot`
416
+ * and walks the tree to build the `files`/`skills` index. Shared between a fresh install and the
417
+ * content-addressed dedup fast path so both return an identically-shaped result.
418
+ *
419
+ * Exported (2026-08-21) for a second caller outside this module:
420
+ * `resolve-agent-plugin-refs.ts`'s run-time resolution of a pinned `pluginRefId` against the
421
+ * packages already on disk needs the SAME `plugin.json`-parse-plus-tree-walk this function
422
+ * already does correctly (manifest validation via `parseAgentPluginManifest`, sorted `files`), so
423
+ * it re-derives an `InstalledAgentPlugin` per installed digest rather than hand-rolling a second,
424
+ * less-validated reader.
425
+ */
426
+ // Conditional spreads, not `field: parsed.manifest.field` directly: an `exactOptionalPropertyTypes`-
427
+ // style caller (and this codebase's own convention elsewhere — see `buildToolSource`'s identical
428
+ // `defaultSkillReason` spread in `tool-registrations.ts`) must see an ABSENT key, not a key present
429
+ // with value `undefined`, when the manifest declared none of these — `deepEqual` fixtures across
430
+ // this feature's own tests already assert exact object shape.
431
+ async function indexInstalledRoot(packageRoot, /** SHA-256 of the raw archive bytes — the content-addressing key and the descriptor `revision`
432
+ * a future capability projection pins invocation to (`capability-projection.ts`). */ archiveDigest) {
193
433
  let manifestRaw;
194
434
  try {
195
435
  manifestRaw = await readFile(path.join(packageRoot, "plugin.json"), "utf8");
@@ -212,6 +452,8 @@ function buildModule(ports) {
212
452
  throw new AgentPluginInstallError({ code: "MANIFEST_INVALID", message: `plugin.json failed validation: ${parsed.errors.join("; ")}` });
213
453
  }
214
454
  const files = [];
455
+ for (const message of parsed.warnings)
456
+ ports.onEvent?.({ event: 'agent-plugin-identity-warning', message });
215
457
  const skills = [];
216
458
  async function walk(absolute, prefix) {
217
459
  for (const entry of await readdir(absolute, { withFileTypes: true })) {
@@ -225,22 +467,49 @@ function buildModule(ports) {
225
467
  if (match)
226
468
  skills.push({ name: match[1], skillPath: relative });
227
469
  }
470
+ // Anything that is neither a directory nor a regular file (a symlink somehow already present,
471
+ // a device node) is skipped rather than indexed — extraction itself never creates one, so
472
+ // encountering one here would mean the filesystem changed out from under an already-published,
473
+ // supposedly-frozen package, which this function has no business acting on either way.
228
474
  }
229
475
  }
230
476
  await walk(packageRoot, "");
231
477
  return {
232
478
  pluginId: parsed.manifest.name,
479
+ id: parsed.manifest.id,
480
+ provides: parsed.manifest.provides,
233
481
  version: parsed.manifest.version,
234
- ...(parsed.manifest.description !== undefined ? { description: parsed.manifest.description } : {}),
482
+ ...( /**
483
+ * `plugin.json`'s own `description`/`keywords`/`author`/`license` (`manifest.ts`'s
484
+ * `AgentPluginManifest`) — parsed at index time but, before 2026-09-09, discarded immediately
485
+ * after: only `name`/`version` ever reached `InstalledAgentPlugin`. Added for
486
+ * `search_agent_plugin_local` (`tool-registrations.ts`), which needs the description and the
487
+ * author-curated keywords to rank installed plugins against a free-text query — the manifest is
488
+ * already read and parsed here on every call, so carrying these through is zero additional I/O,
489
+ * not a new read path. All four are optional because the spec itself makes them optional (the real
490
+ * installed `ui-ux-design` fixture ships `description` but no `keywords`; a bare-minimum manifest
491
+ * ships neither) — a caller must not assume any of them are present.
492
+ */parsed.manifest.description !== undefined ? { description: parsed.manifest.description } : {}),
235
493
  ...(parsed.manifest.keywords !== undefined ? { keywords: parsed.manifest.keywords } : {}),
236
494
  ...(parsed.manifest.author !== undefined ? { author: parsed.manifest.author } : {}),
237
495
  ...(parsed.manifest.license !== undefined ? { license: parsed.manifest.license } : {}),
496
+ ...readDisplayText({ manifest: parsed.manifest, key: "displayName", maxLength: 64 }),
497
+ ...readDisplayText({ manifest: parsed.manifest, key: "summary", maxLength: 1200 }),
238
498
  archiveDigest,
239
499
  packageRoot,
240
500
  files: files.sort(),
241
501
  skills: skills.sort((a, b) => a.name.localeCompare(b.name)),
242
502
  };
243
503
  }
504
+ /** Makes every file/directory under `root` read-only (executables keep `+x`, per the Agent Plugins
505
+ * spec's own MCP `command` needing to launch a `./`-relative script — not exercised by anything in
506
+ * this slice, since MCP execution is out of scope for v1, but the bit is preserved now rather than
507
+ * silently dropped for whenever it is).
508
+ *
509
+ * Stated plainly rather than implied (`mcp-federation/trust.ts`'s own "what this tier deliberately
510
+ * does NOT claim" discipline): this is best-effort defense against an ACCIDENTAL same-process write
511
+ * into what is supposed to be a read-only `PLUGIN_ROOT`, not a security boundary against a
512
+ * determined attacker already running code as this same OS user — that user can `chmod` again. */
244
513
  async function freezeTree(root, executables, prefix = "") {
245
514
  for (const entry of await readdir(root, { withFileTypes: true })) {
246
515
  const relative = prefix ? `${prefix}/${entry.name}` : entry.name;
@@ -255,6 +524,13 @@ function buildModule(ports) {
255
524
  }
256
525
  await chmod(root, 0o555);
257
526
  }
527
+ /** Publishes `extractionRoot` to `finalRoot` by rename — atomic on the same filesystem, so a reader
528
+ * can never observe a partially-extracted package at the live path. Tolerates losing a race against
529
+ * a concurrent install of the byte-identical digest (the same content, published a moment earlier by
530
+ * another process) rather than treating that as a failure. */
531
+ // macOS reports EACCES, rather than ENOTEMPTY, when the winning install has already
532
+ // frozen its destination directory. Still require a real directory below: a permission
533
+ // failure with no published winner must remain PUBLISH_FAILED.
258
534
  async function publish(extractionRoot, finalRoot) {
259
535
  try {
260
536
  await rename(extractionRoot, finalRoot);
@@ -266,6 +542,8 @@ function buildModule(ports) {
266
542
  if (!(await isRealDirectory(finalRoot))) {
267
543
  throw new AgentPluginInstallError({ code: "PUBLISH_FAILED", message: `'${finalRoot}' exists but is not a real, published package directory` }, { cause: error });
268
544
  }
545
+ // Lost the race to a concurrent install of identical bytes — the bytes at `finalRoot` are the
546
+ // ones this install itself would have written, so this is not a failure.
269
547
  }
270
548
  }
271
549
  async function isRealDirectory(target) {
@@ -0,0 +1,16 @@
1
+ import type { AgentPluginLifecyclePorts } from './ports.js';
2
+ export declare const INSTALLED_PLUGIN_DIGEST_PATTERN: RegExp;
3
+ export interface InstalledPackageRoot {
4
+ readonly localId: string;
5
+ readonly archiveDigest: string;
6
+ readonly packageRoot: string;
7
+ }
8
+ /** Enumerates contained installed revisions; missing stores are empty. A corrupt slot is
9
+ * isolated, matching the full installed-plugin index's existing recovery contract.
10
+ * @complexity O(d) directory/containment operations for d revisions; no executable imports.
11
+ */
12
+ export declare function listInstalledPackageRoots({ ports, workspaceRoot }: {
13
+ ports: AgentPluginLifecyclePorts;
14
+ workspaceRoot: string;
15
+ }, _optional?: Record<string, never>): Promise<readonly InstalledPackageRoot[]>;
16
+ //# sourceMappingURL=installed-package-roots.d.ts.map
@@ -0,0 +1,55 @@
1
+ /** The installed-package walk shared by full indexing and identity admission. Reusing this
2
+ * traversal preserves the same digest grammar, memory-folder exclusion and containment checks. */
3
+ import path from 'node:path';
4
+ import { createPersistentStateModule } from './persistent-state.js';
5
+ import { createPackagePathsModule } from './package-paths.js';
6
+ export const INSTALLED_PLUGIN_DIGEST_PATTERN = /^[a-f0-9]{64}$/;
7
+ /** Enumerates contained installed revisions; missing stores are empty. A corrupt slot is
8
+ * isolated, matching the full installed-plugin index's existing recovery contract.
9
+ * @complexity O(d) directory/containment operations for d revisions; no executable imports.
10
+ */
11
+ export async function listInstalledPackageRoots({ ports, workspaceRoot }, _optional = {}) {
12
+ const persistentState = createPersistentStateModule(ports);
13
+ const { assertContainedOnDisk } = createPackagePathsModule(ports);
14
+ const readdir = ports.filesystem.readdir.bind(ports.filesystem);
15
+ let folders;
16
+ try {
17
+ folders = await readdir(workspaceRoot, { withFileTypes: true });
18
+ }
19
+ catch (error) {
20
+ if (isEnoent(error))
21
+ return [];
22
+ throw error;
23
+ }
24
+ const roots = [];
25
+ for (const folder of folders) {
26
+ if (!folder.isDirectory() || !/^[a-z0-9]+(?:[-.][a-z0-9]+)*$/.test(folder.name) || folder.name.length > 64)
27
+ continue;
28
+ const packagesDir = path.join(workspaceRoot, folder.name, 'package', 'sha256');
29
+ let entries;
30
+ try {
31
+ await persistentState.assertOwnedPath({ workspaceRoot, entryPath: path.relative(workspaceRoot, packagesDir) });
32
+ entries = await readdir(packagesDir, { withFileTypes: true });
33
+ }
34
+ catch {
35
+ continue;
36
+ }
37
+ for (const entry of entries) {
38
+ if (!entry.isDirectory() || !INSTALLED_PLUGIN_DIGEST_PATTERN.test(entry.name))
39
+ continue;
40
+ try {
41
+ const packageRoot = await assertContainedOnDisk(workspaceRoot, path.relative(workspaceRoot, path.join(packagesDir, entry.name)));
42
+ roots.push({ localId: folder.name, archiveDigest: entry.name, packageRoot });
43
+ }
44
+ catch {
45
+ // One corrupt package never hides another plugin. Memory-only folders are not installs.
46
+ }
47
+ }
48
+ }
49
+ return roots;
50
+ }
51
+ /** Only a genuinely missing store is empty; other filesystem faults propagate unchanged. */
52
+ function isEnoent(error) {
53
+ return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
54
+ }
55
+ //# sourceMappingURL=installed-package-roots.js.map
@@ -1,5 +1,5 @@
1
1
  import path from 'node:path';
2
- import { pluginStatePaths } from '../../persistent-state.js';
2
+ import { pluginStatePaths } from '../../core/persistent-state.js';
3
3
  /** Explicit root only; site-root and environment resolution belong to the consumer. */
4
4
  export function createAgentPluginLayout(required) {
5
5
  if (!path.isAbsolute(required.root))
@@ -1,63 +1,3 @@
1
- /**
2
- * Historical lifecycle rationale, carried with the implementation during consumer adoption.
3
- * @file `parseAgentPluginManifest()` / `parseAgentPluginMcpConfig()` — the Agent Plugins v1.0.0
4
- * `plugin.json` / `mcp.json` grammar (agent-plugins.org/specification), verified against the live
5
- * spec rather than inferred from an example manifest.
6
- *
7
- * Deliberately a SEPARATE, smaller validator from `src/features/plugin-runtime/manifest.ts`'s
8
- * `validateManifest()`. That file validates `.host-plugin`'s own tarball format — `integrity`
9
- * (per-file SHA-256), `sdkRange`, `tier`, an exactly-three-token capability vocabulary, a single
10
- * `content.entry.beforeSave` hook. This file validates the OPEN standard's `plugin.json`, which
11
- * shares none of those fields and defines no execution/trust/capability model at all — see this
12
- * feature's own header comment in `install.ts` for why the two formats cannot be unified.
13
- *
14
- * Spec facts this module encodes, quoted/paraphrased from agent-plugins.org/specification (verified
15
- * 2026-08-12, not carried forward from an earlier round's inference):
16
- * - `plugin.json`'s `name`: 1-64 chars, lowercase alphanumeric/hyphen/period only, must start and
17
- * end alphanumeric, no `--` or `..` consecutive delimiters (§5.5). Valid: `my-plugin`,
18
- * `acme.tools`, `lint3r`, `a`. Invalid: `My-Plugin`, `-start`, `has--double`.
19
- * - `plugin.json`'s `$schema` MUST equal `https://agent-plugins.org/schemas/1.0.0/plugin.schema.json`
20
- * for this pinned version; `mcp.json`'s MUST equal
21
- * `https://agent-plugins.org/schemas/1.0.0/mcp.schema.json`.
22
- * - Unknown top-level `plugin.json` fields produce warnings but do not block loading — this module
23
- * returns them as `warnings`, never as a rejection reason.
24
- * - `mcp.json`'s top-level shape is `{ "$schema": ..., "mcpServers": { "<server-id>": {...} } }` —
25
- * `mcpServers` MUST be an object whose member names identify servers.
26
- * - Each server entry MUST carry a `type` discriminator matching exactly one of three CLOSED
27
- * variants (re-verified live against `agent-plugins.org/schemas/1.0.0/mcp.schema.json` and
28
- * `/specification`, 2026-09-10 — superseding this file's earlier "not validated, no caller needs
29
- * it" note, which is now false: `capability-projection.ts`'s MCP-server descriptors are no longer
30
- * unconditionally `execute: { kind: "unavailable" }`, and launching a server needs its real
31
- * transport config, not just its id):
32
- * - `stdio`: requires `command` (non-empty string); optional `args` (string[]), `env`
33
- * (string-valued object — MUST NOT set `PLUGIN_ROOT`/`PLUGIN_DATA`, which the spec reserves),
34
- * `cwd` (plugin-relative path).
35
- * - `streamable-http` / `sse`: requires `url` (non-empty string); optional `headers`
36
- * (string-valued object).
37
- * - **The spec defines NO auth-related field on a server entry at all.** Verbatim from
38
- * `/specification`: "Agent Plugins v1 defines no OAuth configuration or portable
39
- * credential-reference fields. Authorization discovery, user interaction, and credential storage
40
- * are client-managed." A plugin therefore cannot declare "this server needs OAuth" using any
41
- * spec-defined field — the host is the client the spec defers that decision to. This module accepts
42
- * the host-specific metadata in `plugin.json`'s `extensions.<host-namespace>.mcpServers[serverId]`, keeping
43
- * `mcp.json` transport entries closed per §7.2.1. Hosts may translate legacy auth and sign-in defaults in MCP
44
- * entries through readServerMetadata for existing packages, but those cannot supply operator read trust.
45
- * `authMode: "oauth" | "none"` selects client auth; absent defaults to "none".
46
- * `defaultTools: { allow?, write?, read? }` names sign-in grants and reviewed read tools.
47
- * `write` requires `allow` and must be its subset; `read` must be a subset when `allow` is
48
- * present. A read-only declaration without `allow` leaves sign-in grants unchanged.
49
- * Remote hints may veto a read declaration but never supply it. A third,
50
- * `tokenAuth: { helpUrl, probeUrl }`, offers a pasted access token as the sign-in fallback
51
- * (`access-token-tool.ts`). A fourth, `renamedTools: { oldName: newName }`, carries saved tool
52
- * selections across a vendor's tool rename (`apply-tool-renames.ts`). A malformed per-server entry
53
- * (wrong/missing `type`, missing required field) does not fail the whole file — see
54
- * `ParseAgentPluginMcpConfigResult`'s own doc for the fail-open contract this preserves.
55
- *
56
- * Architectural role:
57
- * Pure, no-I/O parsing over an already-`JSON.parse`d value — mirrors `plugin-runtime/manifest.ts`'s
58
- * own "receives a JS value, never a file path or raw bytes" contract. `install.ts` is the only
59
- * caller that reads the file and hands the parsed value here.
60
- */
61
1
  export { validatePluginManifest, validateMcpManifest } from '../manifest.js';
62
2
  export type { PluginManifest, McpManifest, McpServerEntry } from '../manifest.js';
63
3
  import type { PluginManifestAuthor } from '../manifest.js';
@@ -76,6 +16,9 @@ export interface AgentPluginManifest {
76
16
  readonly license?: (string) | undefined;
77
17
  readonly keywords?: (readonly string[]) | undefined;
78
18
  readonly extensions?: (Readonly<Record<string, unknown>>) | undefined;
19
+ /** Normalized publisher identity, independent of name/storage key and version. */
20
+ readonly id?: string | undefined;
21
+ readonly provides?: readonly string[] | undefined;
79
22
  }
80
23
  export type ParseAgentPluginManifestResult = {
81
24
  readonly ok: true;
@@ -95,6 +38,8 @@ export type ParseAgentPluginManifestResult = {
95
38
  */
96
39
  export declare function parseAgentPluginManifest({ value }: {
97
40
  readonly value: unknown;
41
+ }, { identityNamespace }?: {
42
+ readonly identityNamespace?: string;
98
43
  }): ParseAgentPluginManifestResult;
99
44
  /**
100
45
  * The three closed transport variants `mcp.schema.json` v1.0.0 defines. See this file's header for