@jini-ai/agent-plugins 0.4.1 → 0.4.2

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 (43) hide show
  1. package/CHANGELOG.md +6 -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 +156 -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 +57 -2
  17. package/dist/lifecycle/install.js +222 -21
  18. package/dist/lifecycle/layout.js +1 -1
  19. package/dist/lifecycle/manifest.d.ts +3 -9
  20. package/dist/lifecycle/manifest.js +4 -11
  21. package/dist/lifecycle/package-paths.d.ts +2 -0
  22. package/dist/lifecycle/package-paths.js +62 -0
  23. package/dist/lifecycle/persistent-state.d.ts +1 -1
  24. package/dist/lifecycle/persistent-state.js +1 -1
  25. package/dist/lifecycle/ports.d.ts +14 -2
  26. package/dist/lifecycle/resolve-agent-plugin-refs.d.ts +13 -1
  27. package/dist/lifecycle/resolve-agent-plugin-refs.js +143 -4
  28. package/dist/lifecycle/retire-bundled.d.ts +50 -1
  29. package/dist/lifecycle/retire-bundled.js +9 -2
  30. package/dist/lifecycle/search.d.ts +63 -0
  31. package/dist/lifecycle/search.js +29 -0
  32. package/dist/lifecycle/seed-bundled.d.ts +18 -1
  33. package/dist/lifecycle/seed-bundled.js +131 -3
  34. package/dist/lifecycle/set-enabled.d.ts +7 -0
  35. package/dist/lifecycle/set-enabled.js +5 -0
  36. package/dist/lifecycle/trusted-plugin-files.d.ts +38 -0
  37. package/dist/lifecycle/trusted-plugin-files.js +18 -0
  38. package/dist/lifecycle/types.d.ts +12 -0
  39. package/dist/lifecycle/uninstall.d.ts +29 -0
  40. package/dist/lifecycle/uninstall.js +204 -4
  41. package/dist/lifecycle/yauzl-archive-reader.d.ts +0 -1
  42. package/package.json +25 -8
  43. package/persistent-state.js +2 -260
@@ -13,12 +13,14 @@ import { createResolveAgentPluginRefsModule } from './resolve-agent-plugin-refs.
13
13
  import { createSearchModule } from './search.js';
14
14
  import { createSetEnabledModule } from './set-enabled.js';
15
15
  import { createTrustedPluginFilesModule } from './trusted-plugin-files.js';
16
+ import { createPluginContributionLoader } from './contribution-loader.js';
16
17
  import { createUninstallModule } from './uninstall.js';
17
18
  export * from './ports.js';
18
19
  export * from './layout.js';
19
20
  export * from './manifest.js';
20
21
  export * from './fetch-archive.js';
21
22
  export * from './mcp-provisioning.js';
23
+ export * from './contribution-loader.js';
22
24
  export { createAgentPluginActivations, ACTIVATIONS_FILENAME, ACTIVATIONS_LOCK_FILENAME, AgentPluginActivationsBusyError, AgentPluginActivationsUnreadableError } from './activation.js';
23
25
  export { BUNDLED_DIGESTS_FILENAME } from './bundled-digests.js';
24
26
  export { AgentPluginInstallError } from './install.js';
@@ -46,8 +48,14 @@ export function createAgentPluginLifecycle(required, optional = {}) {
46
48
  const search = createSearchModule(context);
47
49
  const enabled = createSetEnabledModule(context);
48
50
  const trusted = createTrustedPluginFilesModule(context);
51
+ const contributions = createPluginContributionLoader({
52
+ findPackages: input => trusted.findTrustedPluginPackages(input),
53
+ readFile: ({ plugin, filename }) => trusted.readTrustedPluginFile(plugin, filename),
54
+ importModule: ({ plugin, modulePath }) => trusted.importContainedModule(plugin, modulePath),
55
+ });
49
56
  const uninstall = createUninstallModule(context);
50
57
  return {
58
+ ...contributions,
51
59
  fetchAgentPluginArchive: (input, optional = {}) => fetchAgentPluginArchive({ ...input, fetch: context.fetch, outboundGuard: context.outboundGuard }, optional),
52
60
  maxAgentPluginArchiveBytes,
53
61
  seedBundledAgentPlugins: createSeedBundledModule(context).seedBundledAgentPlugins,
@@ -64,6 +72,9 @@ export function createAgentPluginLifecycle(required, optional = {}) {
64
72
  findTrustedPluginPackages: (input, optional = {}) => trusted.findTrustedPluginPackages({ ...input, ...optional }),
65
73
  previewAgentPluginUninstall: uninstall.previewAgentPluginUninstall,
66
74
  uninstallAgentPlugin: uninstall.uninstallAgentPlugin,
75
+ stageForRemoval: (input, _optional = {}) => uninstall.stageForRemoval(input.packagesDir, input.packageRoot),
76
+ restoreStagedTrees: (input, _optional = {}) => uninstall.restoreStagedTrees(input.staged, input.cause),
77
+ removeFrozenPackageTree: (input, _optional = {}) => uninstall.removeFrozenPackageTree(input.root),
67
78
  isAgentPluginActive: (input) => activation.isAgentPluginActive(input.activations, input.pluginId),
68
79
  filterActiveAgentPlugins: (input) => activation.filterActiveAgentPlugins(input.activations, input.items, item => input.pluginIdOf({ item })),
69
80
  readAgentPluginActivations: (input) => activation.readAgentPluginActivations(input.workspaceRoot),
@@ -1,20 +1,17 @@
1
1
  /**
2
- * Historical lifecycle rationale, carried with the implementation during consumer adoption.
3
2
  *
4
- * Host command-line rationale retained when the unused URL-install CLI was removed:
3
+ * Caller integrity constraints:
5
4
  * - A digest-only preview downloads and reports SHA-256 without installing. Hashing those bytes
6
5
  * does not establish provenance; the host must make that distinction visible to the operator.
7
6
  * - A real install requires exactly one explicit integrity decision: an independent pinned digest
8
7
  * or trust-on-first-use. Missing or conflicting choices must be refused before execution, with
9
8
  * diagnostics naming the choice the operator needs to supply.
10
- * - Keep pure token classification separate from argument validation and exit/reporting effects,
11
- * so both parsing and legal combinations are directly testable without a network or process exit.
12
9
  * - The CLI composes shared download/extraction operations; digest verification, expansion bounds,
13
10
  * path containment and tenant isolation belong in those operations rather than a CLI-only guard.
14
11
  * @file The one composition step that turns "here is an address" into "the plugin is on this
15
12
  * machine": {@link fetchAgentPluginArchive} -> `installAgentPlugin`.
16
13
  *
17
- * This is the caller `install.ts` was written for and never had. It adds no extraction, path, or
14
+ * It adds no extraction, path, or
18
15
  * manifest logic of its own — every one of those guarantees still comes from `install.ts`, reached
19
16
  * with bytes instead of a URL.
20
17
  *
@@ -1,20 +1,17 @@
1
1
  /**
2
- * Historical lifecycle rationale, carried with the implementation during consumer adoption.
3
2
  *
4
- * Host command-line rationale retained when the unused URL-install CLI was removed:
3
+ * Caller integrity constraints:
5
4
  * - A digest-only preview downloads and reports SHA-256 without installing. Hashing those bytes
6
5
  * does not establish provenance; the host must make that distinction visible to the operator.
7
6
  * - A real install requires exactly one explicit integrity decision: an independent pinned digest
8
7
  * or trust-on-first-use. Missing or conflicting choices must be refused before execution, with
9
8
  * diagnostics naming the choice the operator needs to supply.
10
- * - Keep pure token classification separate from argument validation and exit/reporting effects,
11
- * so both parsing and legal combinations are directly testable without a network or process exit.
12
9
  * - The CLI composes shared download/extraction operations; digest verification, expansion bounds,
13
10
  * path containment and tenant isolation belong in those operations rather than a CLI-only guard.
14
11
  * @file The one composition step that turns "here is an address" into "the plugin is on this
15
12
  * machine": {@link fetchAgentPluginArchive} -> `installAgentPlugin`.
16
13
  *
17
- * This is the caller `install.ts` was written for and never had. It adds no extraction, path, or
14
+ * It adds no extraction, path, or
18
15
  * manifest logic of its own — every one of those guarantees still comes from `install.ts`, reached
19
16
  * with bytes instead of a URL.
20
17
  *
@@ -2,6 +2,9 @@ import type { AgentPluginLayout } from "./layout.js";
2
2
  import type { AgentPluginLifecyclePorts } from "./ports.js";
3
3
  import type { InstalledAgentPlugin } from "./types.js";
4
4
  export type { InstalledAgentPlugin, InstalledAgentPluginSkill } from "./types.js";
5
+ /** One extraction/install failure reason. A caller (an admin route, a future marketplace installer)
6
+ * branches on `code` rather than parsing `message` — this codebase's own convention
7
+ * (`PluginLoadError.reason` in `plugin-runtime/loader.ts` is the direct precedent). */
5
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";
6
9
  export declare class AgentPluginInstallError extends Error {
7
10
  readonly code: AgentPluginInstallErrorCode;
@@ -12,9 +15,14 @@ export declare class AgentPluginInstallError extends Error {
12
15
  cause?: unknown;
13
16
  });
14
17
  }
18
+ /** One archive entry. A symlink is modeled as its OWN kind rather than folded into "file" — the
19
+ * zip-slip research this module's header cites is explicit that the symlink-entry vector is
20
+ * distinct from the lexical-traversal one, and the two need independent tests and independent
21
+ * handling (see the "symlink entries are rejected outright" rule below). */
15
22
  export type AgentPluginArchiveEntry = {
16
23
  readonly kind: "file";
17
24
  readonly entryPath: string;
25
+ /** The archive format's own claimed size. Never trusted alone — see `DECOMPRESSION_BOMB`. */
18
26
  readonly declaredSize?: (number) | undefined;
19
27
  readonly executable?: (boolean) | undefined;
20
28
  readonly openReadStream: (required: Record<string, never>) => AsyncIterable<Uint8Array>;
@@ -30,6 +38,7 @@ export type AgentPluginArchiveEntry = {
30
38
  * `entries` must be a pure function of the archive bytes: an install iterates it twice, once to
31
39
  * check entry-count limits from metadata alone and once to extract.
32
40
  */
41
+ /** `entries` is a pure function of archive bytes: metadata preflight and extraction iterate it twice. */
33
42
  export interface AgentPluginArchiveReaderPort {
34
43
  entries(required: {
35
44
  readonly archive: Uint8Array;
@@ -37,16 +46,62 @@ export interface AgentPluginArchiveReaderPort {
37
46
  }
38
47
  export interface InstallAgentPluginRequired {
39
48
  readonly archive: Uint8Array;
49
+ /** Lowercase hex SHA-256 the caller expects `archive` to hash to — a real marketplace flow would
50
+ * supply this from the server's own metadata; verified here, never trusted from the archive
51
+ * itself. */
40
52
  readonly expectedSha256: string;
41
53
  readonly archiveReader: AgentPluginArchiveReaderPort;
54
+ /**
55
+ * SECURITY (2026-08-13, security pass Finding 2 — tenant isolation, `ADS-memory/reports/security/
56
+ * 2026-08-13-post-session-security-pass.md`): this field used to be an already-resolved
57
+ * `AgentPluginWorkspaceLayout` (`{ root, packages, staging, pluginDataDir }`) — a plain interface
58
+ * of four independently-typed string/function fields with no tag identifying which workspace it
59
+ * came from. Nothing stopped a caller from hand-assembling one from two DIFFERENT real
60
+ * `forWorkspace()` results (wrong variable capture, a stale cached layout, a future code path that
61
+ * builds one by hand instead of calling `forWorkspace`) — such a value type-checked identically to
62
+ * a correctly-resolved one, and `installAgentPlugin` published into whatever `packages`/`staging`
63
+ * it was handed with no consistency check. Proven at the unit level in `install.unit.test.ts`
64
+ * (a layout stitching workspace A's `root`/`pluginDataDir` onto workspace B's real `packages`/
65
+ * `staging` published into workspace B's real, on-disk store with no error).
66
+ *
67
+ * The fix: `installAgentPlugin` no longer accepts a pre-resolved workspace layout as input AT ALL.
68
+ * It takes the RAW MATERIALS instead — the INSTANCE-level layout (`resolveAgentPluginLayout()`,
69
+ * carries no workspace-scoped path of its own) plus one `workspaceId` — and calls
70
+ * `layout.forWorkspace(workspaceId)` itself, exactly once, internally. There is no longer any
71
+ * `AgentPluginWorkspaceLayout`-shaped parameter here for a caller to stitch fields into: the one
72
+ * and only path from these two inputs to `packages`/`staging`/`pluginDataDir` is the real
73
+ * `forWorkspace()` closure, called atomically, so the four resulting paths can never disagree with
74
+ * each other about which workspace they belong to. Passing a hand-built `{ packages, staging, ... }`
75
+ * literal where this field is expected is now a compile-time type error, not a runtime hazard.
76
+ *
77
+ * `workspaceId` must still come from the authenticated principal's own request context, never
78
+ * derived from `layout` itself (a hand-built or malicious `AgentPluginLayout` could fake a
79
+ * `forWorkspace` implementation identically) — that half of the guarantee is a caller obligation
80
+ * this type cannot enforce, the same way `archiveReader` above is a fully caller-trusted port. What
81
+ * IS closed is the specific, demonstrated bug class: stitching or hand-assembling an
82
+ * already-resolved workspace layout from parts.
83
+ */
42
84
  readonly layout: AgentPluginLayout;
85
+ /** The workspace this install is for. Combined with {@link layout} via `layout.forWorkspace(workspaceId)`
86
+ * — see the SECURITY note on {@link layout} above. */
43
87
  readonly workspaceId: string;
44
88
  }
45
- export type InstallAgentPluginOptional = {};
89
+ export interface InstallAgentPluginOptional {
90
+ /** Full bounded extraction/validation in staging, with no package or memory publication. */
91
+ readonly previewOnly?: boolean;
92
+ /** Host policy transaction inside the per-plugin lock after validation. Call publish exactly
93
+ * once to install; returning an existing package without calling it is a deduplicated no-op. */
94
+ /** Host policy transaction, called inside the existing per-plugin lock after validation.
95
+ * It must invoke publish exactly once to install. Shared lifecycle counterpart is in chat-tools.jini.patch. */
96
+ readonly publishGuard?: (required: {
97
+ readonly plugin: InstalledAgentPlugin;
98
+ readonly publish: () => Promise<InstalledAgentPlugin>;
99
+ }, optional: Record<string, never>) => Promise<InstalledAgentPlugin>;
100
+ }
46
101
  /** Internal composition; one state/queue instance per injected host context. */
47
102
  export declare function createInstallModule(ports: AgentPluginLifecyclePorts): {
48
103
  maxAgentPluginInstallArchiveBytes: () => number;
49
- installAgentPlugin: (required: InstallAgentPluginRequired, _optional?: InstallAgentPluginOptional) => Promise<InstalledAgentPlugin>;
104
+ installAgentPlugin: (required: InstallAgentPluginRequired, optional?: InstallAgentPluginOptional) => Promise<InstalledAgentPlugin>;
50
105
  indexInstalledRoot: (packageRoot: string, archiveDigest: string) => Promise<InstalledAgentPlugin>;
51
106
  };
52
107
  //# sourceMappingURL=install.d.ts.map
@@ -1,9 +1,79 @@
1
+ /**
2
+ * @file `installAgentPlugin()` — content-addressed, adversarially-hardened extraction of one Agent
3
+ * Plugin (agent-plugins.org) archive into one workspace's own `AgentPluginWorkspaceLayout.packages`.
4
+ *
5
+ * This is the highest-risk unit in the whole feature. Extraction is where containment actually
6
+ * breaks — everything downstream (capability projection, a future MCP admission gate) trusts that a
7
+ * "package root" is exactly the bytes the archive declared, laid out exactly where the archive said,
8
+ * with nothing outside it touched. Two independently-cited attack classes drive the checks here:
9
+ *
10
+ * - **Zip-slip / archive-extraction path traversal** (Snyk's zip-slip research; JFrog's
11
+ * `mholt/archiver` writeup; the `node-tar` GHSA-8qq5-rm4j-mr97 advisory) has two vectors: a
12
+ * crafted entry NAME containing `../` (caught by {@link normalizePackageEntryPath}'s lexical
13
+ * check), and a crafted SYMLINK entry whose target a later, innocent-looking entry name walks
14
+ * through. The convergent ecosystem fix for the second vector — and the one this module takes — is
15
+ * to refuse symlink entries outright rather than attempt to validate where they point: a plugin
16
+ * has no legitimate reason to ship a symlink inside its own package, and the Agent Plugins spec's
17
+ * own path-safety clause only ever promises symlinks MAY resolve inside the root, never that a
18
+ * client must accept one.
19
+ * - **Decompression bombs**, in both the classic "one file lies about its size" shape (caught by
20
+ * bounding bytes actually observed leaving `openReadStream()`, not the archive's own declared
21
+ * size) and the "many small files" shape (caught by a running total across the whole archive).
22
+ *
23
+ * Why this is a SEPARATE format from the host runtime-plugin format's loader (`plugin-runtime/loader.ts`), not a
24
+ * shared one: verified against that file directly. `loadPlugin()`'s BR-01 pipeline requires
25
+ * `manifest.integrity` (a per-file SHA-256 map), `manifest.sdkRange` checked against a runtime
26
+ * the host plugin SDK version, and a `server/index.mjs` ESM entry point it `import()`s. An Agent Plugin has
27
+ * none of those — no integrity map, no SDK range, no single entry point, and (per this feature's own
28
+ * FINAL decision) no code the host ever imports or executes at all in v1: Skills are markdown read for
29
+ * context injection, and MCP servers are preview-only (`capability-projection.ts`). Adapting Agent
30
+ * Plugins into `loadPlugin()`'s pipeline would mean inventing values for fields the open standard
31
+ * does not define, which is precisely the "package format vs. execution/trust model" conflation this
32
+ * feature's own owner-locked decisions (see `CTX-AGENTPLUGINS-2026-08-12.md` F2) rule out.
33
+ *
34
+ * Why the archive itself is a `ArchiveReaderPort`, not a concrete zip/tar library call: this
35
+ * codebase's own port+adapter discipline (`mcp-federation/ports.ts`'s `McpSessionPort`/
36
+ * `McpStdioChannel` split is the direct precedent) — and, concretely, no zip/tar dependency exists
37
+ * in this repo's `package.json` today. Choosing and vetting one (license, maintenance, streaming
38
+ * support) is a real decision outside a Programmer's scope to make silently; this module declares
39
+ * the seam and is exercised end-to-end by a scripted double, so a real adapter is a pure addition
40
+ * with zero change to the hardening logic here. See the handoff's REMAINING section.
41
+ *
42
+ * Content-addressing (`ws/<workspaceId>/packages/sha256/<archiveDigest>/`): the digest is verified
43
+ * BEFORE extraction ever touches the archive reader, so an install with a mismatched digest never
44
+ * even attempts to unpack — the digest is the trust boundary between "bytes a marketplace claimed"
45
+ * and "bytes this process is willing to run a parser over". A second install of byte-identical
46
+ * content BY THE SAME WORKSPACE is recognized from the digest alone and short-circuits without a
47
+ * second extraction. Deliberately NOT shared across workspaces — see `layout.ts`'s header (owner
48
+ * decision, tenant-grade isolation, 2026-08-12): this function resolves the workspace-scoped layout
49
+ * itself, exactly once, from an instance-level `AgentPluginLayout` plus one `workspaceId` (see the
50
+ * SECURITY note on {@link InstallAgentPluginRequired} below) — the caller is what makes two
51
+ * different workspaces' installs land in disjoint trees, simply by supplying a different
52
+ * `workspaceId` for each; this function's own internal `forWorkspace()` call is what turns that into
53
+ * disjoint, internally-consistent paths.
54
+ *
55
+ * Architectural role:
56
+ * The one place this feature performs filesystem writes for installed package bytes. No network I/O
57
+ * (archive bytes and their expected digest are caller-supplied — see the module header above for why
58
+ * "how does the archive get here" is out of this slice) and no execution of anything extracted.
59
+ */
60
+ /** One trimmed, non-blank string field of the host-supplied extension namespace, capped at `maxLength`, else
61
+ * `undefined` — a malformed value falls back rather than failing the whole index, since every
62
+ * field read this way is cosmetic. The cap stops a hostile package flooding the admin row.
63
+ * @complexity O(maxLength). */
64
+ /** `{ displayName }` when the host namespace's `displayName` is a non-blank string, else `{}` (callers
65
+ * title-case the id).
66
+ * @complexity O(1). */
67
+ /** `{ summary }` when the host namespace's `summary` is a non-blank string, else `{}` (the admin shows
68
+ * the spec `description` instead). Inner newlines are kept: blank lines are the paragraph breaks
69
+ * the admin renders.
70
+ * @complexity O(1). */
1
71
  import { createPersistentStateModule } from "./persistent-state.js";
2
72
  /** Extracted from the host plugin lifecycle. Effects are supplied per host context. */
3
73
  import { createHash } from "node:crypto";
4
74
  import { constants } from "node:fs";
5
75
  import path from "node:path";
6
- import { parseAgentPluginManifest as parseAgentPluginManifestValue } from "./manifest.js";
76
+ import { readAgentPluginExtension, parseAgentPluginManifest as parseAgentPluginManifestValue } from "./manifest.js";
7
77
  import { PackagePathViolation } from "./package-paths.js";
8
78
  import { createPackagePathsModule } from "./package-paths.js";
9
79
  export class AgentPluginInstallError extends Error {
@@ -33,11 +103,32 @@ function buildModule(ports) {
33
103
  maxFileBytes: 16 * 1024 * 1024,
34
104
  maxTotalExtractedBytes: 64 * 1024 * 1024,
35
105
  };
106
+ /** Exposes `LIMITS.maxArchiveBytes` for a caller outside this module — concretely, a test asserting
107
+ * `@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
+ * for why it duplicates the number instead of importing it (no runtime dependency on `install.ts`);
109
+ * this accessor is what lets a test assert the pairing without either module importing the other. */
36
110
  function maxAgentPluginInstallArchiveBytes() {
37
111
  return LIMITS.maxArchiveBytes;
38
112
  }
39
113
  const SHA256_HEX_PATTERN = /^[a-f0-9]{64}$/;
40
- async function installAgentPlugin(required, _optional = {}) {
114
+ /**
115
+ * Verifies, extracts, and publishes one Agent Plugin archive.
116
+ *
117
+ * @throws {AgentPluginInstallError} For every expected failure — a hostile or malformed archive, a
118
+ * digest mismatch, an invalid manifest. Never partially publishes: any failure after extraction has
119
+ * begun removes the whole staging directory before rethrowing.
120
+ * @complexity O(e) in archive entry count, O(b) in total extracted bytes — both explicitly bounded
121
+ * by {@link LIMITS}, so this function's cost cannot be driven unbounded by a hostile archive.
122
+ */
123
+ // The ONE call that turns (instance layout, workspaceId) into real, internally-consistent
124
+ // packages/staging/pluginDataDir paths — see the SECURITY note on `InstallAgentPluginRequired.layout`
125
+ // above for why this replaces accepting an already-resolved `AgentPluginWorkspaceLayout` directly.
126
+ // `forWorkspace` itself still throws for a syntactically invalid `workspaceId`, unchanged.
127
+ // Whether this install succeeded (bytes now live at `finalRoot`) or failed, the staging
128
+ // transaction directory itself must never be left behind — on success `rename` already moved
129
+ // `extractionRoot` out of it, so this only ever removes the (now-empty, or failure-abandoned)
130
+ // temp directory, never the published package.
131
+ async function installAgentPlugin(required, optional = {}) {
41
132
  const { archive, expectedSha256, archiveReader, layout, workspaceId } = required;
42
133
  const workspaceLayout = layout.forWorkspace({ workspaceId });
43
134
  if (archive.byteLength > LIMITS.maxArchiveBytes) {
@@ -57,34 +148,60 @@ function buildModule(ports) {
57
148
  try {
58
149
  const executablePaths = await extractEntries(archiveReader.entries({ archive }), extractionRoot);
59
150
  const indexed = await indexInstalledRoot(extractionRoot, digest);
151
+ if (optional.previewOnly)
152
+ return indexed;
60
153
  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 };
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();
78
190
  } });
79
191
  }
80
192
  finally {
81
193
  await rm(transactionRoot, { recursive: true, force: true });
82
194
  }
83
195
  }
196
+ /** The `(target '...')` suffix on the symlink-rejection message, or nothing when the entry carries
197
+ * no `linkTarget` at all — split out purely to keep that `&&`/ternary pair out of
198
+ * {@link extractOneEntry}'s own branch count. */
84
199
  function symlinkRejectionMessage(entry) {
85
200
  const targetSuffix = "linkTarget" in entry && entry.linkTarget ? ` (target '${entry.linkTarget}')` : "";
86
201
  return `archive entry '${entry.entryPath}' has kind '${entry.kind}', which Agent Plugin packages are not permitted to contain${targetSuffix}`;
87
202
  }
203
+ /** `normalizePackageEntryPath()`, with its thrown error re-shaped into the module's own
204
+ * `AgentPluginInstallError` vocabulary. */
88
205
  function normalizeEntryPathOrThrow(entryPath) {
89
206
  try {
90
207
  return normalizePackageEntryPath(entryPath);
@@ -93,6 +210,7 @@ function buildModule(ports) {
93
210
  throw new AgentPluginInstallError({ code: "UNSAFE_ENTRY_PATH", message: `archive entry path is unsafe: '${entryPath}'` }, { cause: error });
94
211
  }
95
212
  }
213
+ /** `assertContainedOnDisk()`, with its thrown error re-shaped the same way. */
96
214
  async function resolveContainedDestinationOrThrow(extractionRoot, normalized) {
97
215
  try {
98
216
  return await assertContainedOnDisk(extractionRoot, normalized);
@@ -103,6 +221,15 @@ function buildModule(ports) {
103
221
  });
104
222
  }
105
223
  }
224
+ /**
225
+ * Applies every hardening rule in this module's header to one archive entry: symlink rejection,
226
+ * path normalization, duplicate detection, containment, the per-file size cap, and (for a file
227
+ * entry) the actual write. Returns the running total-extracted-bytes count, since that is the one
228
+ * piece of `extractEntries`'s loop state a single entry's processing can change.
229
+ */
230
+ // Refused BEFORE path normalization: a symlink entry is never acceptable regardless of how
231
+ // "safe" its own name looks, matching the ecosystem fix this module's header cites — validating
232
+ // where a permitted symlink might point is more attack surface than refusing the entry kind.
106
233
  async function extractOneEntry(args) {
107
234
  const { entry, extractionRoot, seen, executablePaths, totalBytesSoFar } = args;
108
235
  if (entry.kind !== "file" && entry.kind !== "directory") {
@@ -139,6 +266,8 @@ function buildModule(ports) {
139
266
  * its entries from the archive bytes, so a second iteration costs one central-directory walk
140
267
  * and opens no content stream.
141
268
  */
269
+ /** Ported from Jini 78d4b60b: reject from metadata before a staging transaction exists.
270
+ * A second central-directory walk opens no content streams and avoids over-cap disk churn. */
142
271
  async function assertEntryCountWithinCap(entries) {
143
272
  let entryCount = 0;
144
273
  for await (const _entry of entries) {
@@ -150,6 +279,9 @@ function buildModule(ports) {
150
279
  function tooManyEntriesError() {
151
280
  return new AgentPluginInstallError({ code: "TOO_MANY_ENTRIES", message: `archive exceeds the ${LIMITS.maxEntries}-entry cap` });
152
281
  }
282
+ /** Extracts every entry into `extractionRoot`, enforcing every hardening rule in this module's
283
+ * header. Returns the set of archive-relative paths the archive marked executable, for
284
+ * {@link freezeTree} to preserve the `+x` bit on. */
153
285
  async function extractEntries(entries, extractionRoot) {
154
286
  const seen = new Set();
155
287
  const executablePaths = new Set();
@@ -164,6 +296,12 @@ function buildModule(ports) {
164
296
  }
165
297
  return executablePaths;
166
298
  }
299
+ /** Streams one file entry to disk, bounding both its own size and the running archive total against
300
+ * bytes ACTUALLY observed — never the archive format's declared size, which a decompression bomb
301
+ * lies about by construction. `O_EXCL` refuses to write over anything already at `destination`
302
+ * (there cannot legitimately be one — `extractEntries`'s duplicate check already refused a second
303
+ * entry for the same path); `O_NOFOLLOW` refuses to follow a final-component symlink if the
304
+ * filesystem somehow already had one (defense in depth — this module never creates one itself). */
167
305
  async function writeContainedFile(params) {
168
306
  let flags = constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL;
169
307
  if (ports.process.platform !== "win32")
@@ -189,7 +327,36 @@ function buildModule(ports) {
189
327
  }
190
328
  return totalBytes;
191
329
  }
192
- async function indexInstalledRoot(packageRoot, archiveDigest) {
330
+ /** Cosmetic fields are trimmed and capped rather than invalidating the package. The cap stops
331
+ * a hostile package flooding an admin row. Inner newlines remain paragraph breaks in summaries.
332
+ * @complexity O(n) in the supplied text length; output is capped at maxLength. */
333
+ function readDisplayText(required, _optional = {}) {
334
+ const text = readAgentPluginExtension({ manifest: required.manifest, namespace: ports.extensionNamespace,
335
+ read: ({ value }) => {
336
+ const field = value[required.key];
337
+ return typeof field === 'string' ? field.trim() : undefined;
338
+ } });
339
+ return text ? { [required.key]: text.slice(0, required.maxLength) } : {};
340
+ }
341
+ /**
342
+ * Reads the (already-extracted-and-trusted, or already-published) `plugin.json` at `packageRoot`
343
+ * and walks the tree to build the `files`/`skills` index. Shared between a fresh install and the
344
+ * content-addressed dedup fast path so both return an identically-shaped result.
345
+ *
346
+ * Exported (2026-08-21) for a second caller outside this module:
347
+ * `resolve-agent-plugin-refs.ts`'s run-time resolution of a pinned `pluginRefId` against the
348
+ * packages already on disk needs the SAME `plugin.json`-parse-plus-tree-walk this function
349
+ * already does correctly (manifest validation via `parseAgentPluginManifest`, sorted `files`), so
350
+ * it re-derives an `InstalledAgentPlugin` per installed digest rather than hand-rolling a second,
351
+ * less-validated reader.
352
+ */
353
+ // Conditional spreads, not `field: parsed.manifest.field` directly: an `exactOptionalPropertyTypes`-
354
+ // style caller (and this codebase's own convention elsewhere — see `buildToolSource`'s identical
355
+ // `defaultSkillReason` spread in `tool-registrations.ts`) must see an ABSENT key, not a key present
356
+ // with value `undefined`, when the manifest declared none of these — `deepEqual` fixtures across
357
+ // this feature's own tests already assert exact object shape.
358
+ async function indexInstalledRoot(packageRoot, /** SHA-256 of the raw archive bytes — the content-addressing key and the descriptor `revision`
359
+ * a future capability projection pins invocation to (`capability-projection.ts`). */ archiveDigest) {
193
360
  let manifestRaw;
194
361
  try {
195
362
  manifestRaw = await readFile(path.join(packageRoot, "plugin.json"), "utf8");
@@ -225,22 +392,47 @@ function buildModule(ports) {
225
392
  if (match)
226
393
  skills.push({ name: match[1], skillPath: relative });
227
394
  }
395
+ // Anything that is neither a directory nor a regular file (a symlink somehow already present,
396
+ // a device node) is skipped rather than indexed — extraction itself never creates one, so
397
+ // encountering one here would mean the filesystem changed out from under an already-published,
398
+ // supposedly-frozen package, which this function has no business acting on either way.
228
399
  }
229
400
  }
230
401
  await walk(packageRoot, "");
231
402
  return {
232
403
  pluginId: parsed.manifest.name,
233
404
  version: parsed.manifest.version,
234
- ...(parsed.manifest.description !== undefined ? { description: parsed.manifest.description } : {}),
405
+ ...( /**
406
+ * `plugin.json`'s own `description`/`keywords`/`author`/`license` (`manifest.ts`'s
407
+ * `AgentPluginManifest`) — parsed at index time but, before 2026-09-09, discarded immediately
408
+ * after: only `name`/`version` ever reached `InstalledAgentPlugin`. Added for
409
+ * `search_agent_plugin_local` (`tool-registrations.ts`), which needs the description and the
410
+ * author-curated keywords to rank installed plugins against a free-text query — the manifest is
411
+ * already read and parsed here on every call, so carrying these through is zero additional I/O,
412
+ * not a new read path. All four are optional because the spec itself makes them optional (the real
413
+ * installed `ui-ux-design` fixture ships `description` but no `keywords`; a bare-minimum manifest
414
+ * ships neither) — a caller must not assume any of them are present.
415
+ */parsed.manifest.description !== undefined ? { description: parsed.manifest.description } : {}),
235
416
  ...(parsed.manifest.keywords !== undefined ? { keywords: parsed.manifest.keywords } : {}),
236
417
  ...(parsed.manifest.author !== undefined ? { author: parsed.manifest.author } : {}),
237
418
  ...(parsed.manifest.license !== undefined ? { license: parsed.manifest.license } : {}),
419
+ ...readDisplayText({ manifest: parsed.manifest, key: "displayName", maxLength: 64 }),
420
+ ...readDisplayText({ manifest: parsed.manifest, key: "summary", maxLength: 1200 }),
238
421
  archiveDigest,
239
422
  packageRoot,
240
423
  files: files.sort(),
241
424
  skills: skills.sort((a, b) => a.name.localeCompare(b.name)),
242
425
  };
243
426
  }
427
+ /** Makes every file/directory under `root` read-only (executables keep `+x`, per the Agent Plugins
428
+ * spec's own MCP `command` needing to launch a `./`-relative script — not exercised by anything in
429
+ * this slice, since MCP execution is out of scope for v1, but the bit is preserved now rather than
430
+ * silently dropped for whenever it is).
431
+ *
432
+ * Stated plainly rather than implied (`mcp-federation/trust.ts`'s own "what this tier deliberately
433
+ * does NOT claim" discipline): this is best-effort defense against an ACCIDENTAL same-process write
434
+ * into what is supposed to be a read-only `PLUGIN_ROOT`, not a security boundary against a
435
+ * determined attacker already running code as this same OS user — that user can `chmod` again. */
244
436
  async function freezeTree(root, executables, prefix = "") {
245
437
  for (const entry of await readdir(root, { withFileTypes: true })) {
246
438
  const relative = prefix ? `${prefix}/${entry.name}` : entry.name;
@@ -255,6 +447,13 @@ function buildModule(ports) {
255
447
  }
256
448
  await chmod(root, 0o555);
257
449
  }
450
+ /** Publishes `extractionRoot` to `finalRoot` by rename — atomic on the same filesystem, so a reader
451
+ * can never observe a partially-extracted package at the live path. Tolerates losing a race against
452
+ * a concurrent install of the byte-identical digest (the same content, published a moment earlier by
453
+ * another process) rather than treating that as a failure. */
454
+ // macOS reports EACCES, rather than ENOTEMPTY, when the winning install has already
455
+ // frozen its destination directory. Still require a real directory below: a permission
456
+ // failure with no published winner must remain PUBLISH_FAILED.
258
457
  async function publish(extractionRoot, finalRoot) {
259
458
  try {
260
459
  await rename(extractionRoot, finalRoot);
@@ -266,6 +465,8 @@ function buildModule(ports) {
266
465
  if (!(await isRealDirectory(finalRoot))) {
267
466
  throw new AgentPluginInstallError({ code: "PUBLISH_FAILED", message: `'${finalRoot}' exists but is not a real, published package directory` }, { cause: error });
268
467
  }
468
+ // Lost the race to a concurrent install of identical bytes — the bytes at `finalRoot` are the
469
+ // ones this install itself would have written, so this is not a failure.
269
470
  }
270
471
  }
271
472
  async function isRealDirectory(target) {
@@ -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,5 +1,4 @@
1
1
  /**
2
- * Historical lifecycle rationale, carried with the implementation during consumer adoption.
3
2
  * @file `parseAgentPluginManifest()` / `parseAgentPluginMcpConfig()` — the Agent Plugins v1.0.0
4
3
  * `plugin.json` / `mcp.json` grammar (agent-plugins.org/specification), verified against the live
5
4
  * spec rather than inferred from an example manifest.
@@ -11,8 +10,7 @@
11
10
  * shares none of those fields and defines no execution/trust/capability model at all — see this
12
11
  * feature's own header comment in `install.ts` for why the two formats cannot be unified.
13
12
  *
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):
13
+ * Pinned v1.0.0 rules from agent-plugins.org/specification:
16
14
  * - `plugin.json`'s `name`: 1-64 chars, lowercase alphanumeric/hyphen/period only, must start and
17
15
  * end alphanumeric, no `--` or `..` consecutive delimiters (§5.5). Valid: `my-plugin`,
18
16
  * `acme.tools`, `lint3r`, `a`. Invalid: `My-Plugin`, `-start`, `has--double`.
@@ -23,12 +21,8 @@
23
21
  * returns them as `warnings`, never as a rejection reason.
24
22
  * - `mcp.json`'s top-level shape is `{ "$schema": ..., "mcpServers": { "<server-id>": {...} } }` —
25
23
  * `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):
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:
32
26
  * - `stdio`: requires `command` (non-empty string); optional `args` (string[]), `env`
33
27
  * (string-valued object — MUST NOT set `PLUGIN_ROOT`/`PLUGIN_DATA`, which the spec reserves),
34
28
  * `cwd` (plugin-relative path).
@@ -1,5 +1,4 @@
1
1
  /**
2
- * Historical lifecycle rationale, carried with the implementation during consumer adoption.
3
2
  * @file `parseAgentPluginManifest()` / `parseAgentPluginMcpConfig()` — the Agent Plugins v1.0.0
4
3
  * `plugin.json` / `mcp.json` grammar (agent-plugins.org/specification), verified against the live
5
4
  * spec rather than inferred from an example manifest.
@@ -11,8 +10,7 @@
11
10
  * shares none of those fields and defines no execution/trust/capability model at all — see this
12
11
  * feature's own header comment in `install.ts` for why the two formats cannot be unified.
13
12
  *
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):
13
+ * Pinned v1.0.0 rules from agent-plugins.org/specification:
16
14
  * - `plugin.json`'s `name`: 1-64 chars, lowercase alphanumeric/hyphen/period only, must start and
17
15
  * end alphanumeric, no `--` or `..` consecutive delimiters (§5.5). Valid: `my-plugin`,
18
16
  * `acme.tools`, `lint3r`, `a`. Invalid: `My-Plugin`, `-start`, `has--double`.
@@ -23,12 +21,8 @@
23
21
  * returns them as `warnings`, never as a rejection reason.
24
22
  * - `mcp.json`'s top-level shape is `{ "$schema": ..., "mcpServers": { "<server-id>": {...} } }` —
25
23
  * `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):
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:
32
26
  * - `stdio`: requires `command` (non-empty string); optional `args` (string[]), `env`
33
27
  * (string-valued object — MUST NOT set `PLUGIN_ROOT`/`PLUGIN_DATA`, which the spec reserves),
34
28
  * `cwd` (plugin-relative path).
@@ -61,8 +55,7 @@
61
55
  export { validatePluginManifest, validateMcpManifest } from '../manifest.js';
62
56
  /**
63
57
  * The one plugin.json schema version this loader understands. A manifest declaring any other
64
- * value is rejected outright (C2 in the debate: "the standard is a Working Draft; the loader pins
65
- * v1 and must survive spec churn") rather than guessed at.
58
+ * value is rejected outright rather than guessed at: pinning the grammar must survive spec churn.
66
59
  */
67
60
  const PLUGIN_SCHEMA_1_0_0 = "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json";
68
61
  const MCP_SCHEMA_1_0_0 = "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json";