@jini-ai/agent-plugins 0.4.6 → 0.4.8

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 (152) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +48 -0
  3. package/dist/admin-surfaces/action-dispatcher.d.ts +37 -0
  4. package/dist/admin-surfaces/action-dispatcher.js +69 -0
  5. package/dist/admin-surfaces/action-schema.d.ts +56 -0
  6. package/dist/admin-surfaces/action-schema.js +167 -0
  7. package/dist/admin-surfaces/action-tools.d.ts +40 -0
  8. package/dist/admin-surfaces/action-tools.js +52 -0
  9. package/dist/admin-surfaces/callback-field-inbox.d.ts +35 -0
  10. package/dist/admin-surfaces/callback-field-inbox.js +53 -0
  11. package/dist/admin-surfaces/capability-declarations.d.ts +41 -0
  12. package/dist/admin-surfaces/capability-declarations.js +102 -0
  13. package/dist/admin-surfaces/contribution.d.ts +49 -0
  14. package/dist/admin-surfaces/contribution.js +30 -0
  15. package/dist/admin-surfaces/delivery-bindings.d.ts +76 -0
  16. package/dist/admin-surfaces/delivery-bindings.js +99 -0
  17. package/dist/admin-surfaces/descriptor.d.ts +69 -0
  18. package/dist/admin-surfaces/descriptor.js +196 -0
  19. package/dist/admin-surfaces/external-links.d.ts +45 -0
  20. package/dist/admin-surfaces/external-links.js +70 -0
  21. package/dist/admin-surfaces/host-validator.d.ts +62 -0
  22. package/dist/admin-surfaces/host-validator.js +87 -0
  23. package/dist/admin-surfaces/index.d.ts +23 -0
  24. package/dist/admin-surfaces/index.js +21 -0
  25. package/dist/admin-surfaces/protocol.d.ts +103 -0
  26. package/dist/admin-surfaces/protocol.js +25 -0
  27. package/dist/admin-surfaces/sandbox.d.ts +78 -0
  28. package/dist/admin-surfaces/sandbox.js +92 -0
  29. package/dist/admin-surfaces/settings-value.d.ts +28 -0
  30. package/dist/admin-surfaces/settings-value.js +45 -0
  31. package/dist/admin-surfaces/surface-capabilities.d.ts +167 -0
  32. package/dist/admin-surfaces/surface-capabilities.js +215 -0
  33. package/dist/admin-surfaces/surface-client.d.ts +116 -0
  34. package/dist/admin-surfaces/surface-client.js +219 -0
  35. package/dist/admin-surfaces/surface-handlers.d.ts +111 -0
  36. package/dist/admin-surfaces/surface-handlers.js +172 -0
  37. package/dist/admin-surfaces/theme.d.ts +59 -0
  38. package/dist/admin-surfaces/theme.js +57 -0
  39. package/dist/folder-zip-node.d.ts +11 -0
  40. package/dist/folder-zip-node.js +84 -0
  41. package/dist/folder-zip.d.ts +38 -0
  42. package/dist/folder-zip.js +134 -0
  43. package/dist/lifecycle/automatic-use.d.ts +79 -0
  44. package/dist/lifecycle/automatic-use.js +165 -0
  45. package/dist/lifecycle/compatibility-metadata.d.ts +19 -0
  46. package/dist/lifecycle/compatibility-metadata.js +45 -0
  47. package/dist/lifecycle/compatibility.d.ts +52 -0
  48. package/dist/lifecycle/compatibility.js +60 -0
  49. package/dist/lifecycle/fetch-archive.js +26 -24
  50. package/dist/lifecycle/index.d.ts +14 -2
  51. package/dist/lifecycle/index.js +8 -1
  52. package/dist/lifecycle/install-errors.d.ts +14 -0
  53. package/dist/lifecycle/install-errors.js +9 -0
  54. package/dist/lifecycle/install.d.ts +2 -13
  55. package/dist/lifecycle/install.js +12 -123
  56. package/dist/lifecycle/installed-package-index.d.ts +15 -0
  57. package/dist/lifecycle/installed-package-index.js +128 -0
  58. package/dist/lifecycle/installed-package-roots.js +28 -20
  59. package/dist/lifecycle/manifest.d.ts +14 -0
  60. package/dist/lifecycle/manifest.js +115 -100
  61. package/dist/lifecycle/package-paths.js +3 -3
  62. package/dist/lifecycle/ports.d.ts +1 -1
  63. package/dist/lifecycle/provider-contribution.d.ts +65 -0
  64. package/dist/lifecycle/provider-contribution.js +93 -0
  65. package/dist/lifecycle/resolve-agent-plugin-refs.js +19 -6
  66. package/dist/lifecycle/retire-bundled.js +20 -18
  67. package/dist/lifecycle/search.d.ts +6 -1
  68. package/dist/lifecycle/search.js +45 -7
  69. package/dist/lifecycle/set-enabled.d.ts +2 -1
  70. package/dist/lifecycle/set-enabled.js +52 -8
  71. package/dist/lifecycle/skill-file-reader.js +85 -71
  72. package/dist/lifecycle/tools/capabilities.d.ts +199 -0
  73. package/dist/lifecycle/tools/capabilities.js +213 -0
  74. package/dist/lifecycle/tools/definition.d.ts +32 -0
  75. package/dist/lifecycle/tools/definition.js +33 -0
  76. package/dist/lifecycle/tools/index.d.ts +10 -0
  77. package/dist/lifecycle/tools/index.js +10 -0
  78. package/dist/lifecycle/tools/manifest.d.ts +33 -0
  79. package/dist/lifecycle/tools/manifest.js +89 -0
  80. package/dist/lifecycle/tools/projection.d.ts +90 -0
  81. package/dist/lifecycle/tools/projection.js +283 -0
  82. package/dist/lifecycle/types.d.ts +2 -0
  83. package/dist/lifecycle/uninstall.d.ts +3 -1
  84. package/dist/lifecycle/uninstall.js +51 -27
  85. package/dist/lifecycle/update-plan.d.ts +18 -0
  86. package/dist/lifecycle/update-plan.js +22 -0
  87. package/dist/lifecycle/update-store.d.ts +71 -0
  88. package/dist/lifecycle/update-store.js +150 -0
  89. package/dist/lifecycle/update.d.ts +55 -0
  90. package/dist/lifecycle/update.js +321 -0
  91. package/dist/manifest.d.ts +2 -1
  92. package/dist/manifest.js +2 -1
  93. package/package.json +50 -6
  94. package/ui-ux-design/plugin.json +8 -2
  95. package/dist/index.d.ts.map +0 -1
  96. package/dist/index.js.map +0 -1
  97. package/dist/lifecycle/activation.d.ts.map +0 -1
  98. package/dist/lifecycle/activation.js.map +0 -1
  99. package/dist/lifecycle/bundled-digests.d.ts.map +0 -1
  100. package/dist/lifecycle/bundled-digests.js.map +0 -1
  101. package/dist/lifecycle/bundled-source-archive.d.ts.map +0 -1
  102. package/dist/lifecycle/bundled-source-archive.js.map +0 -1
  103. package/dist/lifecycle/capability-projection.d.ts.map +0 -1
  104. package/dist/lifecycle/capability-projection.js.map +0 -1
  105. package/dist/lifecycle/contribution-loader.d.ts.map +0 -1
  106. package/dist/lifecycle/contribution-loader.js.map +0 -1
  107. package/dist/lifecycle/fetch-archive.d.ts.map +0 -1
  108. package/dist/lifecycle/fetch-archive.js.map +0 -1
  109. package/dist/lifecycle/index.d.ts.map +0 -1
  110. package/dist/lifecycle/index.js.map +0 -1
  111. package/dist/lifecycle/install-from-url.d.ts.map +0 -1
  112. package/dist/lifecycle/install-from-url.js.map +0 -1
  113. package/dist/lifecycle/install.d.ts.map +0 -1
  114. package/dist/lifecycle/install.js.map +0 -1
  115. package/dist/lifecycle/installed-package-roots.d.ts.map +0 -1
  116. package/dist/lifecycle/installed-package-roots.js.map +0 -1
  117. package/dist/lifecycle/layout.d.ts.map +0 -1
  118. package/dist/lifecycle/layout.js.map +0 -1
  119. package/dist/lifecycle/manifest.d.ts.map +0 -1
  120. package/dist/lifecycle/manifest.js.map +0 -1
  121. package/dist/lifecycle/mcp-provisioning.d.ts.map +0 -1
  122. package/dist/lifecycle/mcp-provisioning.js.map +0 -1
  123. package/dist/lifecycle/node.d.ts.map +0 -1
  124. package/dist/lifecycle/node.js.map +0 -1
  125. package/dist/lifecycle/package-paths.d.ts.map +0 -1
  126. package/dist/lifecycle/package-paths.js.map +0 -1
  127. package/dist/lifecycle/persistent-state.d.ts.map +0 -1
  128. package/dist/lifecycle/persistent-state.js.map +0 -1
  129. package/dist/lifecycle/ports.d.ts.map +0 -1
  130. package/dist/lifecycle/ports.js.map +0 -1
  131. package/dist/lifecycle/resolve-agent-plugin-refs.d.ts.map +0 -1
  132. package/dist/lifecycle/resolve-agent-plugin-refs.js.map +0 -1
  133. package/dist/lifecycle/retire-bundled.d.ts.map +0 -1
  134. package/dist/lifecycle/retire-bundled.js.map +0 -1
  135. package/dist/lifecycle/search.d.ts.map +0 -1
  136. package/dist/lifecycle/search.js.map +0 -1
  137. package/dist/lifecycle/seed-bundled.d.ts.map +0 -1
  138. package/dist/lifecycle/seed-bundled.js.map +0 -1
  139. package/dist/lifecycle/set-enabled.d.ts.map +0 -1
  140. package/dist/lifecycle/set-enabled.js.map +0 -1
  141. package/dist/lifecycle/skill-file-reader.d.ts.map +0 -1
  142. package/dist/lifecycle/skill-file-reader.js.map +0 -1
  143. package/dist/lifecycle/trusted-plugin-files.d.ts.map +0 -1
  144. package/dist/lifecycle/trusted-plugin-files.js.map +0 -1
  145. package/dist/lifecycle/types.d.ts.map +0 -1
  146. package/dist/lifecycle/types.js.map +0 -1
  147. package/dist/lifecycle/uninstall.d.ts.map +0 -1
  148. package/dist/lifecycle/uninstall.js.map +0 -1
  149. package/dist/lifecycle/yauzl-archive-reader.d.ts.map +0 -1
  150. package/dist/lifecycle/yauzl-archive-reader.js.map +0 -1
  151. package/dist/manifest.d.ts.map +0 -1
  152. package/dist/manifest.js.map +0 -1
@@ -0,0 +1,128 @@
1
+ /** Read-side owner of installed package metadata; never constructs a mutation lifecycle. */
2
+ import path from "node:path";
3
+ import { AgentPluginInstallError } from "./install-errors.js";
4
+ import { readAgentPluginExtension, parseAgentPluginManifest as parseAgentPluginManifestValue } from "./manifest.js";
5
+ /** Indexes manifests and files with the same injected effects used by installation and resolution.
6
+ * @complexity O(f) reads/walk work for f package files; no mutation or executable imports. */
7
+ export function createInstalledPackageIndex(required, _optional = {}) {
8
+ const { ports } = required;
9
+ const readFile = ports.filesystem.readFile.bind(ports.filesystem);
10
+ const readdir = ports.filesystem.readdir.bind(ports.filesystem);
11
+ const parseAgentPluginManifest = (value) => parseAgentPluginManifestValue({ value }, { identityNamespace: ports.extensionNamespace });
12
+ /** Cosmetic fields are trimmed and capped rather than invalidating the package. The cap stops
13
+ * a hostile package flooding an admin row. Malformed values fall back because these fields are
14
+ * cosmetic: callers title-case the id and show the spec description when these keys are absent.
15
+ * Inner newlines remain paragraph breaks in summaries.
16
+ * @complexity O(n) in the supplied text length; output is capped at maxLength. */
17
+ function readDisplayText(required, _optional = {}) {
18
+ const text = readAgentPluginExtension({ manifest: required.manifest, namespace: ports.extensionNamespace,
19
+ read: ({ value }) => {
20
+ const field = value[required.key];
21
+ return typeof field === 'string' ? field.trim() : undefined;
22
+ } });
23
+ return text ? { [required.key]: text.slice(0, required.maxLength) } : {};
24
+ }
25
+ /** Reads and validates `packageRoot`'s `plugin.json`, refusing a missing, unparseable or invalid one. */
26
+ async function readInstalledManifest({ packageRoot }) {
27
+ let manifestRaw;
28
+ try {
29
+ manifestRaw = await readFile(path.join(packageRoot, "plugin.json"), "utf8");
30
+ }
31
+ catch (error) {
32
+ if (typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT") {
33
+ throw new AgentPluginInstallError({ code: "MANIFEST_MISSING", message: "the archive does not contain a plugin.json at its root" });
34
+ }
35
+ throw error;
36
+ }
37
+ let manifestValue;
38
+ try {
39
+ manifestValue = JSON.parse(manifestRaw);
40
+ }
41
+ catch (error) {
42
+ throw new AgentPluginInstallError({ code: "MANIFEST_INVALID", message: "plugin.json is not valid JSON" }, { cause: error });
43
+ }
44
+ const parsed = parseAgentPluginManifest(manifestValue);
45
+ if (!parsed.ok) {
46
+ throw new AgentPluginInstallError({ code: "MANIFEST_INVALID", message: `plugin.json failed validation: ${parsed.errors.join("; ")}` });
47
+ }
48
+ return parsed;
49
+ }
50
+ /** Every regular file under `packageRoot` (sorted) and every `skills/<name>/SKILL.md` (sorted by name). */
51
+ async function walkPackageTree({ packageRoot }) {
52
+ const files = [];
53
+ const skills = [];
54
+ async function walk(absolute, prefix) {
55
+ for (const entry of await readdir(absolute, { withFileTypes: true })) {
56
+ const relative = prefix ? `${prefix}/${entry.name}` : entry.name;
57
+ if (entry.isDirectory())
58
+ await walk(path.join(absolute, entry.name), relative);
59
+ else if (entry.isFile())
60
+ files.push(relative);
61
+ // Anything that is neither a directory nor a regular file (a symlink somehow already present,
62
+ // a device node) is skipped rather than indexed — extraction itself never creates one, so
63
+ // encountering one here would mean the filesystem changed out from under an already-published,
64
+ // supposedly-frozen package, which this function has no business acting on either way.
65
+ }
66
+ }
67
+ await walk(packageRoot, "");
68
+ for (const relative of files) {
69
+ const match = relative.match(/^skills\/([^/]+)\/SKILL\.md$/);
70
+ if (match)
71
+ skills.push({ name: match[1], skillPath: relative });
72
+ }
73
+ return { files: files.sort(), skills: skills.sort((a, b) => a.name.localeCompare(b.name)) };
74
+ }
75
+ /**
76
+ * Reads the (already-extracted-and-trusted, or already-published) `plugin.json` at `packageRoot`
77
+ * and walks the tree to build the `files`/`skills` index. Shared between a fresh install and the
78
+ * content-addressed dedup fast path so both return an identically-shaped result.
79
+ *
80
+ * Exported (2026-08-21) for a second caller outside this module:
81
+ * `resolve-agent-plugin-refs.ts`'s run-time resolution of a pinned `pluginRefId` against the
82
+ * packages already on disk needs the SAME `plugin.json`-parse-plus-tree-walk this function
83
+ * already does correctly (manifest validation via `parseAgentPluginManifest`, sorted `files`), so
84
+ * it re-derives an `InstalledAgentPlugin` per installed digest rather than hand-rolling a second,
85
+ * less-validated reader.
86
+ */
87
+ // Conditional spreads, not `field: parsed.manifest.field` directly: an `exactOptionalPropertyTypes`-
88
+ // style caller (and this codebase's own convention elsewhere — see `buildToolSource`'s identical
89
+ // `defaultSkillReason` spread in `tool-registrations.ts`) must see an ABSENT key, not a key present
90
+ // with value `undefined`, when the manifest declared none of these — `deepEqual` fixtures across
91
+ // this feature's own tests already assert exact object shape.
92
+ async function indexInstalledRoot({ packageRoot, archiveDigest }, _optional = {}) {
93
+ const parsed = await readInstalledManifest({ packageRoot });
94
+ for (const message of parsed.warnings)
95
+ ports.onEvent?.({ event: 'agent-plugin-identity-warning', message });
96
+ const { files, skills } = await walkPackageTree({ packageRoot });
97
+ return {
98
+ pluginId: parsed.manifest.name,
99
+ id: parsed.manifest.id,
100
+ provides: parsed.manifest.provides,
101
+ ...(parsed.manifest.requires === undefined ? {} : { requires: parsed.manifest.requires }),
102
+ ...(parsed.manifest.hostRange === undefined ? {} : { hostRange: parsed.manifest.hostRange }),
103
+ version: parsed.manifest.version,
104
+ ...( /**
105
+ * `plugin.json`'s own `description`/`keywords`/`author`/`license` (`manifest.ts`'s
106
+ * `AgentPluginManifest`) — parsed at index time but, before 2026-09-09, discarded immediately
107
+ * after: only `name`/`version` ever reached `InstalledAgentPlugin`. Added for
108
+ * `search_agent_plugin_local` (`tool-registrations.ts`), which needs the description and the
109
+ * author-curated keywords to rank installed plugins against a free-text query — the manifest is
110
+ * already read and parsed here on every call, so carrying these through is zero additional I/O,
111
+ * not a new read path. All four are optional because the spec itself makes them optional (the real
112
+ * installed `ui-ux-design` fixture ships `description` but no `keywords`; a bare-minimum manifest
113
+ * ships neither) — a caller must not assume any of them are present.
114
+ */parsed.manifest.description !== undefined ? { description: parsed.manifest.description } : {}),
115
+ ...(parsed.manifest.keywords !== undefined ? { keywords: parsed.manifest.keywords } : {}),
116
+ ...(parsed.manifest.author !== undefined ? { author: parsed.manifest.author } : {}),
117
+ ...(parsed.manifest.license !== undefined ? { license: parsed.manifest.license } : {}),
118
+ ...readDisplayText({ manifest: parsed.manifest, key: "displayName", maxLength: 64 }),
119
+ ...readDisplayText({ manifest: parsed.manifest, key: "summary", maxLength: 1200 }),
120
+ archiveDigest,
121
+ packageRoot,
122
+ files,
123
+ skills,
124
+ };
125
+ }
126
+ return { indexInstalledRoot };
127
+ }
128
+ //# sourceMappingURL=installed-package-index.js.map
@@ -9,12 +9,14 @@ export const INSTALLED_PLUGIN_DIGEST_PATTERN = /^[a-f0-9]{64}$/;
9
9
  * @complexity O(d) directory/containment operations for d revisions; no executable imports.
10
10
  */
11
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);
12
+ const effects = {
13
+ readdir: ports.filesystem.readdir.bind(ports.filesystem),
14
+ assertOwnedPath: createPersistentStateModule(ports).assertOwnedPath,
15
+ assertContainedOnDisk: createPackagePathsModule(ports).assertContainedOnDisk,
16
+ };
15
17
  let folders;
16
18
  try {
17
- folders = await readdir(workspaceRoot, { withFileTypes: true });
19
+ folders = await effects.readdir(workspaceRoot, { withFileTypes: true });
18
20
  }
19
21
  catch (error) {
20
22
  if (isEnoent(error))
@@ -25,25 +27,31 @@ export async function listInstalledPackageRoots({ ports, workspaceRoot }, _optio
25
27
  for (const folder of folders) {
26
28
  if (!folder.isDirectory() || !/^[a-z0-9]+(?:[-.][a-z0-9]+)*$/.test(folder.name) || folder.name.length > 64)
27
29
  continue;
28
- const packagesDir = path.join(workspaceRoot, folder.name, 'package', 'sha256');
29
- let entries;
30
+ roots.push(...await listFolderRevisions({ effects, workspaceRoot, localId: folder.name }));
31
+ }
32
+ return roots;
33
+ }
34
+ /** One plugin folder's contained digest revisions; a folder with no owned package store has none. */
35
+ async function listFolderRevisions({ effects, workspaceRoot, localId }) {
36
+ const packagesDir = path.join(workspaceRoot, localId, 'package', 'sha256');
37
+ let entries;
38
+ try {
39
+ await effects.assertOwnedPath({ workspaceRoot, entryPath: path.relative(workspaceRoot, packagesDir) });
40
+ entries = await effects.readdir(packagesDir, { withFileTypes: true });
41
+ }
42
+ catch {
43
+ return [];
44
+ }
45
+ const roots = [];
46
+ for (const entry of entries) {
47
+ if (!entry.isDirectory() || !INSTALLED_PLUGIN_DIGEST_PATTERN.test(entry.name))
48
+ continue;
30
49
  try {
31
- await persistentState.assertOwnedPath({ workspaceRoot, entryPath: path.relative(workspaceRoot, packagesDir) });
32
- entries = await readdir(packagesDir, { withFileTypes: true });
50
+ const packageRoot = await effects.assertContainedOnDisk(workspaceRoot, path.relative(workspaceRoot, path.join(packagesDir, entry.name)));
51
+ roots.push({ localId, archiveDigest: entry.name, packageRoot });
33
52
  }
34
53
  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
- }
54
+ // One corrupt package never hides another plugin. Memory-only folders are not installs.
47
55
  }
48
56
  }
49
57
  return roots;
@@ -8,6 +8,15 @@ export type AgentPluginServerMetadataReader = (required: {
8
8
  readonly value: Readonly<Record<string, unknown>>;
9
9
  readonly server: Readonly<Record<string, unknown>>;
10
10
  }) => Readonly<Record<string, unknown>>;
11
+ /**
12
+ * True when `value` is a valid Agent Plugin id: `plugin.json`'s `name` grammar (§5.5, see
13
+ * `NAME_PATTERN`), 1-64 characters. The one owner of that grammar: a host checks an id taken from a
14
+ * URL or a tool input here before it names anything on disk, instead of restating the pattern.
15
+ * @complexity O(n) in the value's length.
16
+ */
17
+ export declare function isAgentPluginName({ value }: {
18
+ readonly value: unknown;
19
+ }, _optional?: Record<string, never>): boolean;
11
20
  export interface AgentPluginManifest {
12
21
  readonly name: string;
13
22
  readonly version?: (string) | undefined;
@@ -19,6 +28,10 @@ export interface AgentPluginManifest {
19
28
  /** Normalized publisher identity, independent of name/storage key and version. */
20
29
  readonly id?: string | undefined;
21
30
  readonly provides?: readonly string[] | undefined;
31
+ /** `extensions.<ns>.requires`: advisory capability ids other active plugins should provide. */
32
+ readonly requires?: readonly string[] | undefined;
33
+ /** `extensions.<ns>.hostRange`: semver range over AGENT_PLUGIN_CONTRACT_VERSION; a malformed value is stored as `<invalid>`. */
34
+ readonly hostRange?: string | undefined;
22
35
  }
23
36
  export type ParseAgentPluginManifestResult = {
24
37
  readonly ok: true;
@@ -30,6 +43,7 @@ export type ParseAgentPluginManifestResult = {
30
43
  };
31
44
  /**
32
45
  * Validates an already-`JSON.parse`d `plugin.json` value against the 1.0.0/1.1.0 grammar.
46
+ * Hosts inject identityNamespace; the default retains the existing plugin extension format.
33
47
  *
34
48
  * @throws Nothing — every failure is reported via the returned `errors`, matching this codebase's
35
49
  * own convention for expected validation outcomes (`plugin-runtime/manifest.ts`,
@@ -54,6 +54,7 @@
54
54
  * own "receives a JS value, never a file path or raw bytes" contract. `install.ts` is the only
55
55
  * caller that reads the file and hands the parsed value here.
56
56
  */
57
+ import { readAgentPluginCompatibilityMetadata } from "./compatibility-metadata.js";
57
58
  import { readPluginIdentity, validatePluginIdentity } from "@jini-ai/core/plugin-directory";
58
59
  export { validatePluginManifest, validateMcpManifest } from '../manifest.js';
59
60
  /**
@@ -104,6 +105,15 @@ function readSpecVersion(value, file) {
104
105
  */
105
106
  const NAME_PATTERN = /^[a-z0-9]+(?:[-.][a-z0-9]+)*$/;
106
107
  const MAX_NAME_LENGTH = 64;
108
+ /**
109
+ * True when `value` is a valid Agent Plugin id: `plugin.json`'s `name` grammar (§5.5, see
110
+ * `NAME_PATTERN`), 1-64 characters. The one owner of that grammar: a host checks an id taken from a
111
+ * URL or a tool input here before it names anything on disk, instead of restating the pattern.
112
+ * @complexity O(n) in the value's length.
113
+ */
114
+ export function isAgentPluginName({ value }, _optional = {}) {
115
+ return typeof value === "string" && value.length <= MAX_NAME_LENGTH && NAME_PATTERN.test(value);
116
+ }
107
117
  const KNOWN_MANIFEST_KEYS = new Set(["$schema", "name", "version", "description", "author", "license", "keywords", "extensions"]);
108
118
  /**
109
119
  * True for a non-null, non-array plain object — the "is this actually a JSON object" gate both
@@ -112,6 +122,17 @@ const KNOWN_MANIFEST_KEYS = new Set(["$schema", "name", "version", "description"
112
122
  function isJsonObject(value) {
113
123
  return typeof value === "object" && value !== null && !Array.isArray(value);
114
124
  }
125
+ /**
126
+ * `undefined` for an absent optional field, otherwise whatever `parse` makes of it — `null` meaning
127
+ * present but malformed, which every caller folds into rejecting the whole entry.
128
+ */
129
+ function parseOptional({ value, parse }) {
130
+ return value === undefined ? undefined : parse(value);
131
+ }
132
+ /** Drops `undefined` members, so an absent optional field stays absent rather than present-as-undefined. */
133
+ function definedFields(fields) {
134
+ return Object.fromEntries(Object.entries(fields).filter(([, value]) => value !== undefined));
135
+ }
115
136
  /**
116
137
  * `value` narrowed to a `string`, or `undefined` — every optional string field below coerces
117
138
  * through this one check instead of repeating its own `typeof ... === "string" ? ... : undefined`
@@ -125,11 +146,7 @@ function parseManifestAuthor(value) {
125
146
  return value; // Existing installed manifests may carry a string.
126
147
  if (!isJsonObject(value))
127
148
  return undefined;
128
- return {
129
- ...(typeof value.name === 'string' ? { name: value.name } : {}),
130
- ...(typeof value.email === 'string' ? { email: value.email } : {}),
131
- ...(typeof value.url === 'string' ? { url: value.url } : {}),
132
- };
149
+ return definedFields({ name: coerceOptionalString(value.name), email: coerceOptionalString(value.email), url: coerceOptionalString(value.url) });
133
150
  }
134
151
  /**
135
152
  * Validates `plugin.json`'s `name` field per §5.5's grammar (see `NAME_PATTERN`'s own header for
@@ -141,25 +158,15 @@ function resolveManifestName(raw) {
141
158
  const name = coerceOptionalString(raw.name);
142
159
  if (name === undefined)
143
160
  return { error: "plugin.json 'name' is required and must be a string" };
144
- if (name.length === 0 || name.length > MAX_NAME_LENGTH || !NAME_PATTERN.test(name)) {
161
+ if (!isAgentPluginName({ value: name })) {
145
162
  return {
146
163
  error: `plugin.json 'name' ('${name}') must be 1-${MAX_NAME_LENGTH} lowercase alphanumeric/hyphen/period characters, start and end alphanumeric, with no consecutive delimiters`,
147
164
  };
148
165
  }
149
166
  return { name };
150
167
  }
151
- /**
152
- * Validates an already-`JSON.parse`d `plugin.json` value against the 1.0.0/1.1.0 grammar.
153
- *
154
- * @throws Nothing — every failure is reported via the returned `errors`, matching this codebase's
155
- * own convention for expected validation outcomes (`plugin-runtime/manifest.ts`,
156
- * `plugin-runtime/discovery.ts`).
157
- * @complexity O(k + n) time and space in the input keys and keyword entries.
158
- */
159
- export function parseAgentPluginManifest({ value }, { identityNamespace = "tovu" } = {}) {
160
- if (!isJsonObject(value))
161
- return { ok: false, errors: ["plugin.json must be a JSON object"] };
162
- const raw = value;
168
+ /** Every `plugin.json` error and warning, in report order: spec version, extensions, name, unknown keys, identity. */
169
+ function collectManifestProblems({ raw, identityNamespace }, { nameError } = {}) {
163
170
  const errors = [];
164
171
  const warnings = [];
165
172
  const specVersion = readSpecVersion(raw.$schema, "plugin");
@@ -169,19 +176,39 @@ export function parseAgentPluginManifest({ value }, { identityNamespace = "tovu"
169
176
  warnings.push(specVersion.warning);
170
177
  if (raw.extensions !== undefined && !isJsonObject(raw.extensions))
171
178
  errors.push("plugin.json extensions must be an object");
172
- const nameResult = resolveManifestName(raw);
173
- if (nameResult.error)
174
- errors.push(nameResult.error);
179
+ if (nameError)
180
+ errors.push(nameError);
175
181
  for (const key of Object.keys(raw)) {
176
182
  if (!KNOWN_MANIFEST_KEYS.has(key))
177
183
  warnings.push(`unrecognized plugin.json field '${key}' (ignored per spec)`);
178
184
  }
179
185
  errors.push(...validatePluginIdentity({ manifest: raw, namespace: identityNamespace }).errors);
186
+ return { errors, warnings };
187
+ }
188
+ /**
189
+ * Validates an already-`JSON.parse`d `plugin.json` value against the 1.0.0/1.1.0 grammar.
190
+ * Hosts inject identityNamespace; the default retains the existing plugin extension format.
191
+ *
192
+ * @throws Nothing — every failure is reported via the returned `errors`, matching this codebase's
193
+ * own convention for expected validation outcomes (`plugin-runtime/manifest.ts`,
194
+ * `plugin-runtime/discovery.ts`).
195
+ * @complexity O(k + n) time and space in the input keys and keyword entries.
196
+ */
197
+ export function parseAgentPluginManifest({ value }, { identityNamespace = "tovu" } = {}) {
198
+ if (!isJsonObject(value))
199
+ return { ok: false, errors: ["plugin.json must be a JSON object"] };
200
+ const raw = value;
201
+ const nameResult = resolveManifestName(raw);
202
+ const { errors, warnings } = collectManifestProblems({ raw, identityNamespace }, { nameError: nameResult.error });
180
203
  if (errors.length > 0)
181
204
  return { ok: false, errors };
182
205
  const identity = readPluginIdentity({ manifest: raw, namespace: identityNamespace });
183
206
  warnings.push(...identity.warnings);
207
+ const extension = isJsonObject(raw.extensions) && isJsonObject(raw.extensions[identityNamespace]) ? raw.extensions[identityNamespace] : {};
208
+ const { warnings: compatibilityWarnings, ...compatibility } = readAgentPluginCompatibilityMetadata({ extension });
209
+ warnings.push(...compatibilityWarnings);
184
210
  const manifest = {
211
+ ...compatibility,
185
212
  name: nameResult.name,
186
213
  id: identity.id,
187
214
  provides: identity.provides,
@@ -223,20 +250,12 @@ function isStringRecord(value) {
223
250
  function parseStdioServerConfig(raw) {
224
251
  if (typeof raw.command !== "string" || raw.command.length === 0)
225
252
  return null;
226
- const { args, env, cwd } = raw;
227
- if (args !== undefined && !(Array.isArray(args) && args.every((entry) => typeof entry === "string")))
228
- return null;
229
- if (env !== undefined && (!isStringRecord(env) || RESERVED_STDIO_ENV_KEYS.some((key) => Object.hasOwn(env, key))))
230
- return null;
231
- if (cwd !== undefined && typeof cwd !== "string")
253
+ const args = parseOptional({ value: raw.args, parse: (value) => Array.isArray(value) && value.every((entry) => typeof entry === "string") ? value : null });
254
+ const env = parseOptional({ value: raw.env, parse: (value) => isStringRecord(value) && !RESERVED_STDIO_ENV_KEYS.some((key) => Object.hasOwn(value, key)) ? value : null });
255
+ const cwd = parseOptional({ value: raw.cwd, parse: (value) => typeof value === "string" ? value : null });
256
+ if (args === null || env === null || cwd === null)
232
257
  return null;
233
- return {
234
- type: "stdio",
235
- command: raw.command,
236
- ...(args !== undefined ? { args: args } : {}),
237
- ...(env !== undefined ? { env } : {}),
238
- ...(cwd !== undefined ? { cwd } : {}),
239
- };
258
+ return { type: "stdio", command: raw.command, ...definedFields({ args, env, cwd }) };
240
259
  }
241
260
  /**
242
261
  * Same bound and grammar as `external-mcp-store.ts`'s `MAX_ALLOWED_TOOLS`/`REMOTE_TOOL_NAME_PATTERN`,
@@ -252,6 +271,10 @@ function parseDefaultToolNames(value) {
252
271
  return null;
253
272
  return value.every((name) => typeof name === "string" && DEFAULT_TOOL_NAME_PATTERN.test(name)) ? value : null;
254
273
  }
274
+ /** `read` and `write` default to `[]` when absent. */
275
+ function parseGrantList(value) {
276
+ return value === undefined ? [] : parseDefaultToolNames(value);
277
+ }
255
278
  /**
256
279
  * Validates bounded sign-in and read lists. `read` and `write` default to `[]`.
257
280
  * A provided `allow` bounds both lists; without it only a read declaration is meaningful,
@@ -263,15 +286,15 @@ function parseDefaultToolNames(value) {
263
286
  function parseDefaultTools(value) {
264
287
  if (!isJsonObject(value))
265
288
  return null;
266
- const allow = value.allow === undefined ? undefined : parseDefaultToolNames(value.allow);
267
- const write = value.write === undefined ? [] : parseDefaultToolNames(value.write);
268
- const read = value.read === undefined ? [] : parseDefaultToolNames(value.read);
289
+ const allow = parseOptional({ value: value.allow, parse: parseDefaultToolNames });
290
+ const write = parseGrantList(value.write);
291
+ const read = parseGrantList(value.read);
269
292
  if (allow === null || write === null || read === null)
270
293
  return null;
271
294
  if (allow === undefined)
272
295
  return value.read !== undefined && write.length === 0 ? { write, read } : null;
273
296
  const allowed = new Set(allow);
274
- return write.every((name) => allowed.has(name)) && read.every((name) => allowed.has(name)) ? { allow, write, read } : null;
297
+ return [...write, ...read].every((name) => allowed.has(name)) ? { allow, write, read } : null;
275
298
  }
276
299
  const MAX_TOKEN_AUTH_URL_LENGTH = 2048;
277
300
  function isHttpsUrl(value) {
@@ -319,18 +342,11 @@ function parseRenamedTools(value) {
319
342
  function parseTokenAuth(value) {
320
343
  if (!isJsonObject(value) || !isHttpsUrl(value.helpUrl) || !isHttpsUrl(value.probeUrl))
321
344
  return null;
322
- const { importFromEnv } = value;
323
- if (importFromEnv !== undefined && !isEnvVarName(importFromEnv))
324
- return null;
325
- const retiredEnv = value.retiredEnv === undefined ? undefined : parseRetiredEnv(value.retiredEnv);
326
- if (retiredEnv === null)
345
+ const importFromEnv = parseOptional({ value: value.importFromEnv, parse: (name) => isEnvVarName(name) ? name : null });
346
+ const retiredEnv = parseOptional({ value: value.retiredEnv, parse: parseRetiredEnv });
347
+ if (importFromEnv === null || retiredEnv === null)
327
348
  return null;
328
- return {
329
- helpUrl: value.helpUrl,
330
- probeUrl: value.probeUrl,
331
- ...(importFromEnv !== undefined ? { importFromEnv } : {}),
332
- ...(retiredEnv !== undefined ? { retiredEnv } : {}),
333
- };
349
+ return { helpUrl: value.helpUrl, probeUrl: value.probeUrl, ...definedFields({ importFromEnv, retiredEnv }) };
334
350
  }
335
351
  /**
336
352
  * Validates one `type: "streamable-http"` or `type: "sse"` server entry — the two remote transports
@@ -345,45 +361,64 @@ function parseTokenAuth(value) {
345
361
  function parseRemoteServerConfig(type, raw, extension) {
346
362
  if (typeof raw.url !== "string" || raw.url.length === 0)
347
363
  return null;
348
- const { headers } = raw;
349
- const authMode = extension?.authMode;
350
- if (headers !== undefined && !isStringRecord(headers))
351
- return null;
352
- if (authMode !== undefined && authMode !== "oauth" && authMode !== "none")
364
+ const headers = parseOptional({ value: raw.headers, parse: (value) => isStringRecord(value) ? value : null });
365
+ const metadata = parseServerMetadata(extension ?? {});
366
+ if (headers === null || metadata === null)
353
367
  return null;
354
- const defaults = extension?.defaultTools;
355
- const defaultTools = defaults === undefined ? undefined : parseDefaultTools(defaults);
356
- if (defaultTools === null)
357
- return null;
358
- const tokenAuth = extension?.tokenAuth === undefined ? undefined : parseTokenAuth(extension?.tokenAuth);
359
- if (tokenAuth === null)
360
- return null;
361
- const renamedTools = extension?.renamedTools === undefined ? undefined : parseRenamedTools(extension?.renamedTools);
362
- if (renamedTools === null)
368
+ return { type, url: raw.url, ...definedFields({ headers }), ...metadata };
369
+ }
370
+ /** A remote entry's extension metadata, or `null` when any declared member is malformed. */
371
+ function parseServerMetadata(extension) {
372
+ const authMode = parseOptional({ value: extension.authMode, parse: (mode) => mode === "oauth" || mode === "none" ? mode : null });
373
+ const defaultTools = parseOptional({ value: extension.defaultTools, parse: parseDefaultTools });
374
+ const tokenAuth = parseOptional({ value: extension.tokenAuth, parse: parseTokenAuth });
375
+ const renamedTools = parseOptional({ value: extension.renamedTools, parse: parseRenamedTools });
376
+ if (authMode === null || defaultTools === null || tokenAuth === null || renamedTools === null)
363
377
  return null;
364
- return {
365
- type,
366
- url: raw.url,
367
- ...(headers !== undefined ? { headers } : {}),
368
- ...(authMode !== undefined ? { authMode } : {}),
369
- ...(defaultTools !== undefined ? { defaultTools } : {}),
370
- ...(tokenAuth !== undefined ? { tokenAuth } : {}),
371
- ...(renamedTools !== undefined ? { renamedTools } : {}),
372
- };
378
+ return definedFields({ authMode, defaultTools, tokenAuth, renamedTools });
373
379
  }
374
380
  /**
375
- * Dispatches one raw `mcpServers` entry to its transport's validator by `type`, or `null` for a
376
- * non-object entry or a `type` outside {@link MCP_SERVER_TRANSPORTS}.
381
+ * Dispatches one `mcpServers` object entry to its transport's validator by `type`, or `null` for a
382
+ * `type` outside {@link MCP_SERVER_TRANSPORTS}.
377
383
  */
378
384
  function parseMcpServerConfig(value, extension) {
379
- if (!isJsonObject(value))
380
- return null;
381
385
  if (value.type === "stdio")
382
386
  return parseStdioServerConfig(value);
383
387
  if (value.type === "streamable-http" || value.type === "sse")
384
388
  return parseRemoteServerConfig(value.type, value, extension);
385
389
  return null;
386
390
  }
391
+ /** One declared server, or `null` for a non-object entry, a malformed extension, or an invalid transport shape. */
392
+ function parseDeclaredServer({ serverId, rawServer, extension }, { readServerMetadata } = {}) {
393
+ // A malformed declared extension excludes the server before the translator can hide it.
394
+ if (!isJsonObject(rawServer) || (extension !== undefined && !isJsonObject(extension)))
395
+ return null;
396
+ const metadata = readServerMetadata?.({ serverId, value: extension ?? {}, server: rawServer }) ?? extension;
397
+ return parseMcpServerConfig(rawServer, metadata);
398
+ }
399
+ /** `mcp.json`'s top-level errors: its own `$schema`, a version mismatch with `plugin.json` (§10), and `mcpServers`' shape. */
400
+ function collectMcpConfigErrors({ raw }, { pluginManifest } = {}) {
401
+ const errors = [];
402
+ // plugin.json already reports an unknown-1.x warning; mcp.json adds none of its own.
403
+ const mcpSpec = readSpecVersion(raw.$schema, "mcp");
404
+ const mcpVersion = mcpSpec.version;
405
+ const pluginVersion = isJsonObject(pluginManifest) ? readSpecVersion(pluginManifest.$schema, "plugin").version : undefined;
406
+ if (mcpSpec.error)
407
+ errors.push(mcpSpec.error);
408
+ else if (pluginVersion !== undefined && pluginVersion !== mcpVersion) {
409
+ errors.push(`mcp.json targets Agent Plugins ${mcpVersion} but plugin.json targets ${pluginVersion}; the versions must match`);
410
+ }
411
+ if (!isJsonObject(raw.mcpServers))
412
+ errors.push("mcp.json 'mcpServers' is required and must be an object");
413
+ return errors;
414
+ }
415
+ /** The per-server metadata `plugin.json` declares under `extensionNamespace`, or `{}` when it declares none. */
416
+ function readExtensionServers({ pluginManifest, extensionNamespace }) {
417
+ const manifest = parseAgentPluginManifest({ value: pluginManifest });
418
+ if (!manifest.ok)
419
+ return {};
420
+ return readAgentPluginExtension({ manifest: manifest.manifest, namespace: extensionNamespace, read: ({ value }) => isJsonObject(value.mcpServers) ? value.mcpServers : {} }) ?? {};
421
+ }
387
422
  /**
388
423
  * Validates an already-`JSON.parse`d `mcp.json` value against the 1.0.0/1.1.0 top-level shape:
389
424
  * `{ "$schema": ..., "mcpServers": { "<server-id>": {...} } }`, then validates each declared
@@ -398,35 +433,15 @@ export function parseAgentPluginMcpConfig({ value, extensionNamespace }, optiona
398
433
  throw new Error("An extension namespace is required");
399
434
  if (!isJsonObject(value))
400
435
  return { ok: false, errors: ["mcp.json must be a JSON object"] };
401
- const raw = value;
402
- const errors = [];
403
- // plugin.json already reports an unknown-1.x warning; mcp.json adds none of its own.
404
- const mcpSpec = readSpecVersion(raw.$schema, "mcp");
405
- const mcpVersion = mcpSpec.version;
406
- const pluginVersion = isJsonObject(optional.pluginManifest) ? readSpecVersion(optional.pluginManifest.$schema, "plugin").version : undefined;
407
- if (mcpSpec.error)
408
- errors.push(mcpSpec.error);
409
- else if (pluginVersion !== undefined && pluginVersion !== mcpVersion) {
410
- errors.push(`mcp.json targets Agent Plugins ${mcpVersion} but plugin.json targets ${pluginVersion}; the versions must match`);
411
- }
412
- const mcpServers = raw.mcpServers;
413
- if (typeof mcpServers !== "object" || mcpServers === null || Array.isArray(mcpServers)) {
414
- errors.push("mcp.json 'mcpServers' is required and must be an object");
415
- }
436
+ const errors = collectMcpConfigErrors({ raw: value }, { pluginManifest: optional.pluginManifest });
416
437
  if (errors.length > 0)
417
438
  return { ok: false, errors };
418
- const manifest = parseAgentPluginManifest({ value: optional.pluginManifest });
419
- const extension = manifest.ok ? readAgentPluginExtension({ manifest: manifest.manifest, namespace: extensionNamespace, read: ({ value }) => isJsonObject(value.mcpServers) ? value.mcpServers : {} }) : undefined;
420
- const extensionServers = extension ?? {};
421
- const entries = Object.entries(mcpServers);
439
+ const extensionServers = readExtensionServers({ pluginManifest: optional.pluginManifest, extensionNamespace });
440
+ const entries = Object.entries(value.mcpServers);
422
441
  const servers = Object.create(null);
423
442
  for (const [serverId, rawServer] of entries) {
424
443
  const extension = Object.hasOwn(extensionServers, serverId) ? extensionServers[serverId] : undefined;
425
- // A malformed declared extension excludes the server before the translator can hide it.
426
- const metadata = isJsonObject(rawServer) && (extension === undefined || isJsonObject(extension))
427
- ? optional.readServerMetadata?.({ serverId, value: extension ?? {}, server: rawServer }) ?? extension
428
- : undefined;
429
- const parsed = extension !== undefined && !isJsonObject(extension) ? null : parseMcpServerConfig(rawServer, metadata);
444
+ const parsed = parseDeclaredServer({ serverId, rawServer, extension }, { readServerMetadata: optional.readServerMetadata });
430
445
  if (parsed)
431
446
  servers[serverId] = parsed;
432
447
  }
@@ -81,10 +81,10 @@ function buildModule(ports) {
81
81
  async function assertContainedOnDisk(packageRoot, entryPath) {
82
82
  const normalized = normalizePackageEntryPath(entryPath);
83
83
  const realRoot = await realpath(packageRoot);
84
+ // No separate lexical-escape check: normalizePackageEntryPath already refused absolute, drive-
85
+ // letter and `..`-leading paths, so resolving its relative output always stays under realRoot,
86
+ // and the filesystem-resolved check below would refuse any escape regardless.
84
87
  const lexicalCandidate = path.resolve(realRoot, normalized);
85
- if (!isWithin(realRoot, lexicalCandidate)) {
86
- throw new PackagePathViolation({ message: `package entry resolves outside the package root: '${entryPath}'` });
87
- }
88
88
  let realCandidate;
89
89
  try {
90
90
  realCandidate = await realpath(lexicalCandidate);
@@ -4,7 +4,7 @@ import type { AgentPluginLayoutPort } from './layout.js';
4
4
  import type { AgentPluginDeliveryMode, InstalledAgentPlugin } from './types.js';
5
5
  /** Native Node filesystem ABI, narrowed to the operations used by lifecycle.
6
6
  * Inject instrumented fs/promises effects directly; native handles are never rewrapped. */
7
- export type FilesystemPort = Pick<typeof import('node:fs/promises'), 'chmod' | 'mkdir' | 'mkdtemp' | 'open' | 'readdir' | 'readFile' | 'rename' | 'rm' | 'rmdir' | 'stat' | 'realpath' | 'unlink'>;
7
+ export type FilesystemPort = Pick<typeof import('node:fs/promises'), 'chmod' | 'mkdir' | 'mkdtemp' | 'open' | 'readdir' | 'readFile' | 'rename' | 'rm' | 'rmdir' | 'stat' | 'lstat' | 'realpath' | 'unlink'>;
8
8
  export type AgentPluginFetchPort = (required: {
9
9
  readonly url: string;
10
10
  }, optional?: RequestInit) => Promise<Response>;