@jini-ai/agent-plugins 0.4.2 → 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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  ## 0.4.2
4
4
 
5
+ - Use shared plugin identity metadata with a required `@jini-ai/core` peer at `^0.4.2`. Repair identity-lock publication syntax and isolate unreadable or invalid installed manifests with warnings so one damaged install cannot block healthy installs or bundled seeding.
6
+
5
7
  ### Patch Changes
6
8
 
7
9
  - Tovu clean-up release (2026-10-08): code Tovu moved into Jini, plus the Jini clean-up (see the commit log since 0.4.1).
@@ -3,6 +3,10 @@
3
3
  * ships as — written by `seed-bundled.ts` on every boot, read by the surfaces that have to pick one
4
4
  * installed package for a plugin id.
5
5
  *
6
+ * Current install policy (frpid, 2026-10-09): successful publication prunes superseded revisions.
7
+ * The rationale below describes retained legacy stores and interrupted cleanup. This ledger still
8
+ * selects the build's authoritative digest there; the ledger itself never deletes package bytes.
9
+ *
6
10
  * ---------------------------------------------------------------------------
7
11
  * The gap this closes
8
12
  * ---------------------------------------------------------------------------
@@ -5,7 +5,7 @@ export type { InstalledAgentPlugin, InstalledAgentPluginSkill } from "./types.js
5
5
  /** One extraction/install failure reason. A caller (an admin route, a future marketplace installer)
6
6
  * branches on `code` rather than parsing `message` — this codebase's own convention
7
7
  * (`PluginLoadError.reason` in `plugin-runtime/loader.ts` is the direct precedent). */
8
- export type AgentPluginInstallErrorCode = "ARCHIVE_TOO_LARGE" | "DIGEST_MISMATCH" | "TOO_MANY_ENTRIES" | "UNSAFE_ENTRY_PATH" | "SYMLINK_ENTRY_REJECTED" | "UNSUPPORTED_ENTRY_KIND" | "DUPLICATE_ENTRY" | "FILE_TOO_LARGE" | "DECOMPRESSION_BOMB" | "TOTAL_SIZE_EXCEEDED" | "MANIFEST_MISSING" | "MANIFEST_INVALID" | "PUBLISH_FAILED";
8
+ export type AgentPluginInstallErrorCode = "PLUGIN_ID_CONFLICT" | "ARCHIVE_TOO_LARGE" | "DIGEST_MISMATCH" | "TOO_MANY_ENTRIES" | "UNSAFE_ENTRY_PATH" | "SYMLINK_ENTRY_REJECTED" | "UNSUPPORTED_ENTRY_KIND" | "DUPLICATE_ENTRY" | "FILE_TOO_LARGE" | "DECOMPRESSION_BOMB" | "TOTAL_SIZE_EXCEEDED" | "MANIFEST_MISSING" | "MANIFEST_INVALID" | "PUBLISH_FAILED";
9
9
  export declare class AgentPluginInstallError extends Error {
10
10
  readonly code: AgentPluginInstallErrorCode;
11
11
  constructor({ code, message }: {
@@ -1,3 +1,6 @@
1
+ import { listInstalledPackageRoots } from "./installed-package-roots.js";
2
+ import { createUninstallModule } from "./uninstall.js";
3
+ import { readPluginIdentity, assertPluginIdentityAvailable } from "@jini-ai/core/plugin-directory";
1
4
  /**
2
5
  * @file `installAgentPlugin()` — content-addressed, adversarially-hardened extraction of one Agent
3
6
  * Plugin (agent-plugins.org) archive into one workspace's own `AgentPluginWorkspaceLayout.packages`.
@@ -95,7 +98,7 @@ function buildModule(ports) {
95
98
  const rename = ports.filesystem.rename.bind(ports.filesystem);
96
99
  const rm = ports.filesystem.rm.bind(ports.filesystem);
97
100
  const stat = ports.filesystem.stat.bind(ports.filesystem);
98
- const parseAgentPluginManifest = (value) => parseAgentPluginManifestValue({ value });
101
+ const parseAgentPluginManifest = (value) => parseAgentPluginManifestValue({ value }, { identityNamespace: ports.extensionNamespace });
99
102
  const { assertContainedOnDisk, normalizePackageEntryPath } = createPackagePathsModule(ports);
100
103
  const LIMITS = {
101
104
  maxArchiveBytes: 32 * 1024 * 1024,
@@ -103,6 +106,63 @@ function buildModule(ports) {
103
106
  maxFileBytes: 16 * 1024 * 1024,
104
107
  maxTotalExtractedBytes: 64 * 1024 * 1024,
105
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
+ }
106
166
  /** Exposes `LIMITS.maxArchiveBytes` for a caller outside this module — concretely, a test asserting
107
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
108
168
  * for why it duplicates the number instead of importing it (no runtime dependency on `install.ts`);
@@ -150,43 +210,56 @@ function buildModule(ports) {
150
210
  const indexed = await indexInstalledRoot(extractionRoot, digest);
151
211
  if (optional.previewOnly)
152
212
  return indexed;
153
- return await persistentState.withPluginLock({ workspaceRoot: workspaceLayout.root, pluginId: indexed.pluginId, run: async () => {
154
- // Layout B needs the manifest id before choosing the package destination. Dedup still
155
- // skips publication and freezing, after one bounded staging extraction.
156
- // Owner decision 2026-10-04: learned facts begin empty for each site; author guidance stays
157
- // in package docs. Package extension files never seed learned memory or the user's notes.
158
- // Content-addressed dedup, scoped to THIS workspace's own tree (`workspaceLayout` was just
159
- // resolved above, atomically, from this same `workspaceId`): identical bytes were already
160
- // extracted, verified, and frozen by a prior install of this same workspace's — never
161
- // re-extracted for a different workspace, by construction, since a different workspace's
162
- // `workspaceLayout.packages` is a different path entirely. one bounded staging extraction precedes this check.
163
- // Publish BEFORE freezing permissions, not after: `rename()` moves `extractionRoot` as a single
164
- // directory-entry operation, and on this filesystem renaming an already-read-only (0o555)
165
- // directory itself fails EACCES (observed, not merely theoretical — see the regression test this
166
- // ordering fixed). Freezing after publish also closes a smaller race for free: nothing outside
167
- // this function can observe the package at its live path while it is still writable, because the
168
- // live path does not exist until `publish` returns.
169
- const publishPackage = async () => {
170
- // The manifest identifies the plugin only after bounded staging extraction (Layout B).
171
- const pluginId = indexed.pluginId;
172
- const packagesDir = workspaceLayout.pluginPackagesDir({ pluginId });
173
- await persistentState.assertOwnedPath({ workspaceRoot: workspaceLayout.root, entryPath: path.relative(workspaceLayout.root, packagesDir) });
174
- await mkdir(packagesDir, { recursive: true, mode: 0o700 });
175
- const finalRoot = await assertContainedOnDisk(packagesDir, digest);
176
- for (const directory of [workspaceLayout.pluginDataDir({ pluginId }),
177
- workspaceLayout.pluginMemoryDir({ pluginId, kind: 'learned' }),
178
- workspaceLayout.pluginMemoryDir({ pluginId, kind: 'notes' })]) {
179
- await persistentState.assertOwnedPath({ workspaceRoot: workspaceLayout.root, entryPath: path.relative(workspaceLayout.root, directory) });
180
- await mkdir(directory, { recursive: true, mode: 0o700 });
181
- }
182
- if (await isRealDirectory(finalRoot))
183
- return indexInstalledRoot(finalRoot, digest);
184
- await publish(extractionRoot, finalRoot);
185
- await freezeTree(finalRoot, executablePaths);
186
- /** Absolute, read-only (frozen) path to the extracted package root. */
187
- return { ...indexed, packageRoot: finalRoot };
188
- };
189
- return optional.publishGuard ? optional.publishGuard({ plugin: indexed, publish: publishPackage }, {}) : publishPackage();
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();
190
263
  } });
191
264
  }
192
265
  finally {
@@ -379,6 +452,8 @@ function buildModule(ports) {
379
452
  throw new AgentPluginInstallError({ code: "MANIFEST_INVALID", message: `plugin.json failed validation: ${parsed.errors.join("; ")}` });
380
453
  }
381
454
  const files = [];
455
+ for (const message of parsed.warnings)
456
+ ports.onEvent?.({ event: 'agent-plugin-identity-warning', message });
382
457
  const skills = [];
383
458
  async function walk(absolute, prefix) {
384
459
  for (const entry of await readdir(absolute, { withFileTypes: true })) {
@@ -401,6 +476,8 @@ function buildModule(ports) {
401
476
  await walk(packageRoot, "");
402
477
  return {
403
478
  pluginId: parsed.manifest.name,
479
+ id: parsed.manifest.id,
480
+ provides: parsed.manifest.provides,
404
481
  version: parsed.manifest.version,
405
482
  ...( /**
406
483
  * `plugin.json`'s own `description`/`keywords`/`author`/`license` (`manifest.ts`'s
@@ -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,57 +1,3 @@
1
- /**
2
- * @file `parseAgentPluginManifest()` / `parseAgentPluginMcpConfig()` — the Agent Plugins v1.0.0
3
- * `plugin.json` / `mcp.json` grammar (agent-plugins.org/specification), verified against the live
4
- * spec rather than inferred from an example manifest.
5
- *
6
- * Deliberately a SEPARATE, smaller validator from `src/features/plugin-runtime/manifest.ts`'s
7
- * `validateManifest()`. That file validates `.host-plugin`'s own tarball format — `integrity`
8
- * (per-file SHA-256), `sdkRange`, `tier`, an exactly-three-token capability vocabulary, a single
9
- * `content.entry.beforeSave` hook. This file validates the OPEN standard's `plugin.json`, which
10
- * shares none of those fields and defines no execution/trust/capability model at all — see this
11
- * feature's own header comment in `install.ts` for why the two formats cannot be unified.
12
- *
13
- * Pinned v1.0.0 rules from agent-plugins.org/specification:
14
- * - `plugin.json`'s `name`: 1-64 chars, lowercase alphanumeric/hyphen/period only, must start and
15
- * end alphanumeric, no `--` or `..` consecutive delimiters (§5.5). Valid: `my-plugin`,
16
- * `acme.tools`, `lint3r`, `a`. Invalid: `My-Plugin`, `-start`, `has--double`.
17
- * - `plugin.json`'s `$schema` MUST equal `https://agent-plugins.org/schemas/1.0.0/plugin.schema.json`
18
- * for this pinned version; `mcp.json`'s MUST equal
19
- * `https://agent-plugins.org/schemas/1.0.0/mcp.schema.json`.
20
- * - Unknown top-level `plugin.json` fields produce warnings but do not block loading — this module
21
- * returns them as `warnings`, never as a rejection reason.
22
- * - `mcp.json`'s top-level shape is `{ "$schema": ..., "mcpServers": { "<server-id>": {...} } }` —
23
- * `mcpServers` MUST be an object whose member names identify servers.
24
- * - Each server entry MUST carry a `type` discriminator matching one of three CLOSED variants.
25
- * Launching requires the actual transport config, not just a server id:
26
- * - `stdio`: requires `command` (non-empty string); optional `args` (string[]), `env`
27
- * (string-valued object — MUST NOT set `PLUGIN_ROOT`/`PLUGIN_DATA`, which the spec reserves),
28
- * `cwd` (plugin-relative path).
29
- * - `streamable-http` / `sse`: requires `url` (non-empty string); optional `headers`
30
- * (string-valued object).
31
- * - **The spec defines NO auth-related field on a server entry at all.** Verbatim from
32
- * `/specification`: "Agent Plugins v1 defines no OAuth configuration or portable
33
- * credential-reference fields. Authorization discovery, user interaction, and credential storage
34
- * are client-managed." A plugin therefore cannot declare "this server needs OAuth" using any
35
- * spec-defined field — the host is the client the spec defers that decision to. This module accepts
36
- * the host-specific metadata in `plugin.json`'s `extensions.<host-namespace>.mcpServers[serverId]`, keeping
37
- * `mcp.json` transport entries closed per §7.2.1. Hosts may translate legacy auth and sign-in defaults in MCP
38
- * entries through readServerMetadata for existing packages, but those cannot supply operator read trust.
39
- * `authMode: "oauth" | "none"` selects client auth; absent defaults to "none".
40
- * `defaultTools: { allow?, write?, read? }` names sign-in grants and reviewed read tools.
41
- * `write` requires `allow` and must be its subset; `read` must be a subset when `allow` is
42
- * present. A read-only declaration without `allow` leaves sign-in grants unchanged.
43
- * Remote hints may veto a read declaration but never supply it. A third,
44
- * `tokenAuth: { helpUrl, probeUrl }`, offers a pasted access token as the sign-in fallback
45
- * (`access-token-tool.ts`). A fourth, `renamedTools: { oldName: newName }`, carries saved tool
46
- * selections across a vendor's tool rename (`apply-tool-renames.ts`). A malformed per-server entry
47
- * (wrong/missing `type`, missing required field) does not fail the whole file — see
48
- * `ParseAgentPluginMcpConfigResult`'s own doc for the fail-open contract this preserves.
49
- *
50
- * Architectural role:
51
- * Pure, no-I/O parsing over an already-`JSON.parse`d value — mirrors `plugin-runtime/manifest.ts`'s
52
- * own "receives a JS value, never a file path or raw bytes" contract. `install.ts` is the only
53
- * caller that reads the file and hands the parsed value here.
54
- */
55
1
  export { validatePluginManifest, validateMcpManifest } from '../manifest.js';
56
2
  export type { PluginManifest, McpManifest, McpServerEntry } from '../manifest.js';
57
3
  import type { PluginManifestAuthor } from '../manifest.js';
@@ -70,6 +16,9 @@ export interface AgentPluginManifest {
70
16
  readonly license?: (string) | undefined;
71
17
  readonly keywords?: (readonly string[]) | undefined;
72
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;
73
22
  }
74
23
  export type ParseAgentPluginManifestResult = {
75
24
  readonly ok: true;
@@ -89,6 +38,8 @@ export type ParseAgentPluginManifestResult = {
89
38
  */
90
39
  export declare function parseAgentPluginManifest({ value }: {
91
40
  readonly value: unknown;
41
+ }, { identityNamespace }?: {
42
+ readonly identityNamespace?: string;
92
43
  }): ParseAgentPluginManifestResult;
93
44
  /**
94
45
  * The three closed transport variants `mcp.schema.json` v1.0.0 defines. See this file's header for
@@ -52,6 +52,7 @@
52
52
  * own "receives a JS value, never a file path or raw bytes" contract. `install.ts` is the only
53
53
  * caller that reads the file and hands the parsed value here.
54
54
  */
55
+ import { readPluginIdentity, validatePluginIdentity } from "@jini-ai/core/plugin-directory";
55
56
  export { validatePluginManifest, validateMcpManifest } from '../manifest.js';
56
57
  /**
57
58
  * The one plugin.json schema version this loader understands. A manifest declaring any other
@@ -84,13 +85,6 @@ function isJsonObject(value) {
84
85
  function coerceOptionalString(value) {
85
86
  return typeof value === "string" ? value : undefined;
86
87
  }
87
- /**
88
- * `value` narrowed to a `string[]` (non-string entries dropped), or `undefined` when `value`
89
- * itself isn't an array — `keywords`'s own coercion.
90
- */
91
- function coerceStringArray(value) {
92
- return Array.isArray(value) ? value.filter((entry) => typeof entry === "string") : undefined;
93
- }
94
88
  function parseManifestAuthor(value) {
95
89
  if (typeof value === 'string')
96
90
  return value; // Existing installed manifests may carry a string.
@@ -127,7 +121,7 @@ function resolveManifestName(raw) {
127
121
  * `plugin-runtime/discovery.ts`).
128
122
  * @complexity O(k + n) time and space in the input keys and keyword entries.
129
123
  */
130
- export function parseAgentPluginManifest({ value }) {
124
+ export function parseAgentPluginManifest({ value }, { identityNamespace = "tovu" } = {}) {
131
125
  if (!isJsonObject(value))
132
126
  return { ok: false, errors: ["plugin.json must be a JSON object"] };
133
127
  const raw = value;
@@ -145,15 +139,20 @@ export function parseAgentPluginManifest({ value }) {
145
139
  if (!KNOWN_MANIFEST_KEYS.has(key))
146
140
  warnings.push(`unrecognized plugin.json field '${key}' (ignored per spec)`);
147
141
  }
142
+ errors.push(...validatePluginIdentity({ manifest: raw, namespace: identityNamespace }).errors);
148
143
  if (errors.length > 0)
149
144
  return { ok: false, errors };
145
+ const identity = readPluginIdentity({ manifest: raw, namespace: identityNamespace });
146
+ warnings.push(...identity.warnings);
150
147
  const manifest = {
151
148
  name: nameResult.name,
149
+ id: identity.id,
150
+ provides: identity.provides,
152
151
  version: coerceOptionalString(raw.version),
153
152
  description: coerceOptionalString(raw.description),
154
153
  author: parseManifestAuthor(raw.author),
155
154
  license: coerceOptionalString(raw.license),
156
- keywords: coerceStringArray(raw.keywords),
155
+ keywords: raw.keywords === undefined ? undefined : identity.keywords,
157
156
  ...(isJsonObject(raw.extensions) ? { extensions: raw.extensions } : {}),
158
157
  };
159
158
  return { ok: true, manifest, warnings };
@@ -1,6 +1,10 @@
1
1
  import type { AgentPluginLifecyclePorts } from './ports.js';
2
2
  /** Reuses the platform lock with injected effects, never age-stealing a living migration. */
3
3
  export declare function createPersistentStateModule(ports: AgentPluginLifecyclePorts, _optional?: Record<string, never>): {
4
+ withIdentityLock: <T>(required: {
5
+ workspaceRoot: string;
6
+ run(): Promise<T>;
7
+ }, _optional?: {}) => Promise<T>;
4
8
  withPluginLock: <T>(required: {
5
9
  workspaceRoot: string;
6
10
  pluginId: string;
@@ -1,4 +1,5 @@
1
1
  import { createPluginMemory, migratePluginLayout, assertPluginStatePath, withPluginStateLock } from '../../core/persistent-state.js';
2
+ import path from 'node:path';
2
3
  import { withFileLock } from '@jini-ai/platform/fs/file-lock';
3
4
  import { createPackagePathsModule } from './package-paths.js';
4
5
  import { parseAgentPluginManifest } from './manifest.js';
@@ -16,6 +17,7 @@ export function createPersistentStateModule(ports, _optional = {}) {
16
17
  }),
17
18
  };
18
19
  return {
20
+ withIdentityLock: (required, _optional = {}) => effects.withLock({ lockPath: path.join(required.workspaceRoot, '.publisher-identity.lock'), run: required.run }),
19
21
  withPluginLock: (required, optional = {}) => withPluginStateLock({ ...effects, ...required }, optional),
20
22
  memory: (required, optional = {}) => createPluginMemory({ ...effects, ...required }, optional),
21
23
  assertOwnedPath: (required, optional = {}) => assertPluginStatePath({ ...effects, ...required }, optional),
@@ -52,6 +52,12 @@ export interface AgentPluginOutboundGuardPort {
52
52
  }): Promise<void>;
53
53
  }
54
54
  export interface AgentPluginLifecyclePorts {
55
+ /** Optional host admission serializes cross-kind identity checking and publication. */
56
+ readonly identityAdmission?: (<T>(required: {
57
+ id: string;
58
+ localId: string;
59
+ run: () => Promise<T>;
60
+ }, optional?: Record<string, never>) => Promise<T>) | undefined;
55
61
  readonly onEvent?: ((required: {
56
62
  readonly event: string;
57
63
  readonly message: string;
@@ -88,6 +94,6 @@ export interface AgentPluginLifecyclePorts {
88
94
  readonly outboundGuard: AgentPluginOutboundGuardPort;
89
95
  }
90
96
  /** Application policy/effects are required; observers and metadata translators are optional. */
91
- export type AgentPluginLifecycleRequired = Omit<AgentPluginLifecyclePorts, 'onEvent' | 'readServerMetadata' | 'migrateBeforeSeed' | 'formatBundledUninstallRecovery'>;
92
- export type AgentPluginLifecycleOptional = Pick<AgentPluginLifecyclePorts, 'onEvent' | 'readServerMetadata' | 'migrateBeforeSeed' | 'formatBundledUninstallRecovery'>;
97
+ export type AgentPluginLifecycleRequired = Omit<AgentPluginLifecyclePorts, 'onEvent' | 'readServerMetadata' | 'migrateBeforeSeed' | 'formatBundledUninstallRecovery' | 'identityAdmission'>;
98
+ export type AgentPluginLifecycleOptional = Pick<AgentPluginLifecyclePorts, 'onEvent' | 'readServerMetadata' | 'migrateBeforeSeed' | 'formatBundledUninstallRecovery' | 'identityAdmission'>;
93
99
  //# sourceMappingURL=ports.d.ts.map
@@ -1,3 +1,4 @@
1
+ import { listInstalledPackageRoots, INSTALLED_PLUGIN_DIGEST_PATTERN } from "./installed-package-roots.js";
1
2
  /**
2
3
  * @file Resolves a run's pinned Agent Plugin refs (`pluginRefIds` — `run-start-context.ts`'s
3
4
  * decoded `contextRef` field, itself sourced from the composer's `pluginRefId` chips,
@@ -65,13 +66,11 @@ import { createCapabilityProjectionModule } from "./capability-projection.js";
65
66
  function buildModule(ports) {
66
67
  const persistentState = createPersistentStateModule(ports);
67
68
  const { assertContainedOnDisk } = createPackagePathsModule(ports);
68
- const readdir = ports.filesystem.readdir.bind(ports.filesystem);
69
69
  const stat = ports.filesystem.stat.bind(ports.filesystem);
70
70
  const { resolveAgentPluginActivation } = createActivationModule(ports);
71
71
  const { preferBundledAgentPluginDigests, readBundledAgentPluginDigests } = createBundledDigestsModule(ports);
72
72
  const { indexInstalledRoot } = createInstallModule(ports);
73
73
  const { readInstalledSkillMarkdown } = createCapabilityProjectionModule(ports);
74
- const SHA256_DIGEST_DIRNAME_PATTERN = /^[a-f0-9]{64}$/;
75
74
  /** Reads the delivery mode for this process. An unset or unrecognised value is `inject` — an A/B
76
75
  * affordance must never be able to change production behaviour by typo. */
77
76
  function resolveAgentPluginDeliveryMode() { return ports.deliveryMode; }
@@ -147,40 +146,15 @@ function buildModule(ports) {
147
146
  * re-walking `packages/sha256/*` a second, less-validated way keeps the digest-directory-name check
148
147
  * and the per-digest failure isolation in exactly one place. */
149
148
  async function listInstalledPlugins(workspaceRoot) {
150
- let folders;
151
- try {
152
- folders = await readdir(workspaceRoot, { withFileTypes: true });
153
- }
154
- catch (error) {
155
- if (isEnoent(error))
156
- return [];
157
- throw error;
158
- }
159
149
  const installed = [];
160
- for (const folder of folders) {
161
- if (!folder.isDirectory() || !/^[a-z0-9]+(?:[-.][a-z0-9]+)*$/.test(folder.name) || folder.name.length > 64)
162
- continue;
163
- const packagesDir = path.join(workspaceRoot, folder.name, "package", "sha256");
164
- let entries;
150
+ for (const root of await listInstalledPackageRoots({ ports, workspaceRoot })) {
165
151
  try {
166
- await persistentState.assertOwnedPath({ workspaceRoot, entryPath: path.relative(workspaceRoot, packagesDir) });
167
- entries = await readdir(packagesDir, { withFileTypes: true });
152
+ const plugin = await indexInstalledRoot(root.packageRoot, root.archiveDigest);
153
+ if (plugin.pluginId === root.localId)
154
+ installed.push(plugin);
168
155
  }
169
156
  catch {
170
- continue;
171
- }
172
- for (const entry of entries) {
173
- if (!entry.isDirectory() || !SHA256_DIGEST_DIRNAME_PATTERN.test(entry.name))
174
- continue;
175
- try {
176
- const packageRoot = await assertContainedOnDisk(workspaceRoot, path.relative(workspaceRoot, path.join(packagesDir, entry.name)));
177
- const plugin = await indexInstalledRoot(packageRoot, entry.name);
178
- if (plugin.pluginId === folder.name)
179
- installed.push(plugin);
180
- }
181
- catch {
182
- // One corrupt package never hides another plugin. Memory-only folders are not installs.
183
- }
157
+ // One corrupt package never hides another plugin. Memory-only folders are not installs.
184
158
  }
185
159
  }
186
160
  return installed;
@@ -208,7 +182,7 @@ function buildModule(ports) {
208
182
  * @complexity One `stat`.
209
183
  */
210
184
  async function isInstalledDigestPresent(packagesDir, archiveDigest) {
211
- if (!SHA256_DIGEST_DIRNAME_PATTERN.test(archiveDigest))
185
+ if (!INSTALLED_PLUGIN_DIGEST_PATTERN.test(archiveDigest))
212
186
  return false;
213
187
  try {
214
188
  return (await stat(path.join(packagesDir, archiveDigest))).isDirectory();
@@ -217,9 +191,6 @@ function buildModule(ports) {
217
191
  return false;
218
192
  }
219
193
  }
220
- function isEnoent(error) {
221
- return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
222
- }
223
194
  /**
224
195
  * Resolves one `pluginRefId`, applying the zero/one/many-match rules described in this module's
225
196
  * own header.
@@ -67,6 +67,8 @@ export interface AgentPluginSearchCandidate {
67
67
  readonly summary?: string | undefined;
68
68
  readonly description?: (string) | undefined;
69
69
  readonly keywords: readonly string[];
70
+ readonly id?: string | undefined;
71
+ readonly provides?: readonly string[] | undefined;
70
72
  /** Whether this workspace currently has the plugin enabled — reflects `activation.ts`'s
71
73
  * absent-means-active default, same as everywhere else this codebase reads activation state. */
72
74
  readonly enabled: boolean;
@@ -18,8 +18,8 @@ function buildModule(ports) {
18
18
  * plain, independently-readable list rather than interleaved with the scoring loop itself. */
19
19
  function fieldHaystacks(candidate) {
20
20
  return [
21
- { text: candidate.pluginId.toLowerCase(), weight: ID_FIELD_WEIGHT },
22
- { text: candidate.keywords.join(" ").toLowerCase(), weight: KEYWORD_FIELD_WEIGHT },
21
+ { text: `${candidate.pluginId} ${candidate.id ?? ""}`.toLowerCase(), weight: ID_FIELD_WEIGHT },
22
+ { text: [...candidate.keywords, ...(candidate.provides ?? [])].join(" ").toLowerCase(), weight: KEYWORD_FIELD_WEIGHT },
23
23
  { text: (candidate.description ?? "").toLowerCase(), weight: DESCRIPTION_FIELD_WEIGHT },
24
24
  {
25
25
  text: candidate.skills.map((skill) => `${skill.name} ${skill.summary}`).join(" ").toLowerCase(),
@@ -6,6 +6,8 @@ export interface InstalledAgentPluginSkill {
6
6
  readonly skillPath: string;
7
7
  }
8
8
  export interface InstalledAgentPlugin {
9
+ readonly id?: string | undefined;
10
+ readonly provides?: readonly string[] | undefined;
9
11
  readonly pluginId: string;
10
12
  readonly version?: (string) | undefined;
11
13
  /** the host extension namespace in `plugin.json`: `displayName` — the human-facing title when the id alone reads
@@ -20,6 +20,7 @@ export interface PluginManifestAuthor {
20
20
  readonly email?: string;
21
21
  readonly url?: string;
22
22
  }
23
+ export type { PluginIdentityExtension } from "@jini-ai/core/plugin-directory";
23
24
  export interface PluginManifest {
24
25
  readonly $schema?: string;
25
26
  /** 1-64 chars, lowercase alphanumeric/dots/hyphens, no leading/trailing/doubled separators. */
@@ -60,6 +61,8 @@ export declare function isMcpManifest(value: unknown): value is McpManifest;
60
61
  /** Object-argument facade for the legacy structural validator. */
61
62
  export declare function validatePluginManifest(required: {
62
63
  readonly value: unknown;
64
+ }, optional?: {
65
+ readonly identityNamespace?: string;
63
66
  }): boolean;
64
67
  /** Object-argument facade for the legacy structural validator. */
65
68
  export declare function validateMcpManifest(required: {
package/dist/manifest.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { validatePluginIdentity } from "@jini-ai/core/plugin-directory";
1
2
  /**
2
3
  * Types for the Agent Plugins 1.0.0 open standard (published by a Technical Steering Committee
3
4
  * with maintainers from Amazon, Cursor, Google, Microsoft, OpenAI, and Vercel). A plugin is a
@@ -28,8 +29,8 @@ export function isMcpManifest(value) {
28
29
  return typeof servers === 'object' && servers !== null && !Array.isArray(servers);
29
30
  }
30
31
  /** Object-argument facade for the legacy structural validator. */
31
- export function validatePluginManifest(required) {
32
- return isPluginManifest(required.value);
32
+ export function validatePluginManifest(required, optional = {}) {
33
+ return isPluginManifest(required.value) && validatePluginIdentity({ manifest: required.value, namespace: optional.identityNamespace ?? "tovu" }).errors.length === 0;
33
34
  }
34
35
  /** Object-argument facade for the legacy structural validator. */
35
36
  export function validateMcpManifest(required) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jini-ai/agent-plugins",
3
- "version": "0.4.2",
3
+ "version": "0.4.3",
4
4
  "description": "Support for Agent Plugins, the open, vendor-neutral spec (v1.0.0, published 2026-08-06, Technical Steering Committee with maintainers from Amazon, Cursor, Google, Microsoft, OpenAI, and Vercel) for bundling Agent Skills and MCP servers into one portable directory: PluginManifest/McpManifest types + structural validators on the package root, plus the bundled plugins themselves, each on a subpath named for itself (./ui-ux-design). Extracted 2026-08-18 from @jini-ai/plugins, which now only reserves the unrelated, unimplemented ./host format.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -89,21 +89,18 @@
89
89
  "devDependencies": {
90
90
  "@vitest/coverage-v8": "^2.1.9",
91
91
  "yazl": "^3.3.1",
92
- "@jini-ai/core": "^0.4.0",
92
+ "@jini-ai/core": "^0.4.2",
93
93
  "@jini-ai/platform": "^0.5.0"
94
94
  },
95
95
  "peerDependencies": {
96
96
  "yauzl": "^3.4.0",
97
- "@jini-ai/core": "^0.4.0",
98
- "@jini-ai/platform": "^0.5.0"
97
+ "@jini-ai/core": "^0.4.3",
98
+ "@jini-ai/platform": "^0.5.3"
99
99
  },
100
100
  "peerDependenciesMeta": {
101
101
  "yauzl": {
102
102
  "optional": true
103
103
  },
104
- "@jini-ai/core": {
105
- "optional": true
106
- },
107
104
  "@jini-ai/platform": {
108
105
  "optional": true
109
106
  }
@@ -1,5 +1,19 @@
1
1
  {
2
2
  "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
3
3
  "name": "ui-ux-design",
4
- "description": "UI/UX design, interface-design-system, accessibility, and shadcn/ui component skills bundled as one portable Agent Plugin."
4
+ "description": "UI/UX design, interface-design-system, accessibility, and shadcn/ui component skills bundled as one portable Agent Plugin.",
5
+ "extensions": {
6
+ "tovu": {
7
+ "id": "jini/ui-ux-design",
8
+ "provides": [
9
+ "ui-design"
10
+ ]
11
+ }
12
+ },
13
+ "keywords": [
14
+ "design",
15
+ "ui",
16
+ "ux",
17
+ "ui-design"
18
+ ]
5
19
  }