@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
@@ -1,4 +1,6 @@
1
1
  import type { AgentPluginLifecyclePorts } from "./ports.js";
2
+ /** Raised for any path a package entry (or a later read against an already-installed package) is
3
+ * not allowed to reach — lexically invalid, or resolving outside the package root. */
2
4
  export declare class PackagePathViolation extends Error {
3
5
  constructor({ message }: {
4
6
  readonly message: string;
@@ -1,10 +1,45 @@
1
+ /**
2
+ * @file The one path-safety primitive every Agent Plugin package read goes through — extraction
3
+ * (`install.ts`) today, any future preview/read path tomorrow. One implementation, not two, so the
4
+ * containment guarantee cannot drift between "how a package is written" and "how it is later read".
5
+ *
6
+ * The rule is not invented here. The Agent Plugins spec (v1.0.0, "Path safety") states it as a MUST:
7
+ * "the filesystem-resolved path MUST remain within the filesystem-resolved plugin root... symlinks,
8
+ * junctions, reparse points, and equivalent filesystem mechanisms MAY resolve to targets within the
9
+ * plugin root, but clients MUST reject package paths that resolve outside it." The word
10
+ * "filesystem-resolved" is exactly what a lexical `../`-string check alone does not satisfy — a
11
+ * hostile archive can plant a symlinked directory at a lexically-clean path and let a later,
12
+ * innocent-looking entry walk through it (the two-step zip-slip vector; see `install.ts`'s header
13
+ * for the extraction-time mitigation, which is to refuse symlink ENTRIES outright rather than ever
14
+ * relying on this function to catch one after the fact).
15
+ *
16
+ * Architectural role:
17
+ * Pure path logic plus the minimum filesystem calls (`realpath`) the spec's own wording requires.
18
+ * No policy about what happens after containment is established — that is every caller's own job.
19
+ */
1
20
  /** Extracted from the host plugin lifecycle. Effects are supplied per host context. */
2
21
  import path from "node:path";
22
+ /** Raised for any path a package entry (or a later read against an already-installed package) is
23
+ * not allowed to reach — lexically invalid, or resolving outside the package root. */
3
24
  export class PackagePathViolation extends Error {
4
25
  constructor({ message }, optional = {}) { super(message, optional); }
5
26
  }
6
27
  function buildModule(ports) {
7
28
  const realpath = ports.filesystem.realpath.bind(ports.filesystem);
29
+ /**
30
+ * Rejects an absolute path, a NUL byte, a Windows drive prefix, or a lexical `..`/`.` segment —
31
+ * BEFORE any filesystem call. This is the cheap majority of attempts; {@link assertContainedOnDisk}
32
+ * catches the rest (an entry that is lexically clean but is, or passes through, a symlink).
33
+ *
34
+ * Backslashes are normalized to `/` rather than rejected: the spec models paths as POSIX-style
35
+ * (`./`-prefixed), and an archive built on Windows may use them as its own separator without any
36
+ * hostile intent — the traversal segments that actually matter (`..`, an absolute prefix) are
37
+ * checked after normalization either way.
38
+ *
39
+ * @throws {PackagePathViolation} If the path is unsafe by construction, independent of any
40
+ * filesystem state.
41
+ * @complexity O(n) in the path's own length.
42
+ */
8
43
  function normalizePackageEntryPath(rawEntryPath) {
9
44
  if (rawEntryPath.includes("\0")) {
10
45
  throw new PackagePathViolation({ message: `package entry path contains a NUL byte: '${rawEntryPath}'` });
@@ -19,6 +54,30 @@ function buildModule(ports) {
19
54
  }
20
55
  return normalized;
21
56
  }
57
+ /**
58
+ * Resolves `entryPath` against `packageRoot` and asserts the REAL, symlink-resolved result is still
59
+ * inside the REAL, symlink-resolved root — the spec's "filesystem-resolved" requirement, not a
60
+ * string comparison.
61
+ *
62
+ * `packageRoot` is `realpath`'d fresh on every call rather than accepted pre-resolved from the
63
+ * caller, so a root whose own directory entry is later replaced by a symlink between two calls
64
+ * cannot smuggle a stale trusted root in.
65
+ *
66
+ * Handles the case extraction needs and a plain read doesn't: the target may not exist yet (mid-
67
+ * extraction, before this entry's own write), in which case the deepest EXISTING ancestor is
68
+ * `realpath`'d instead — so a symlinked PARENT directory planted by an earlier archive entry is
69
+ * still caught even though the leaf hasn't been created yet.
70
+ *
71
+ * @param packageRoot - Absolute path to the package root (extraction destination, or an already-
72
+ * installed package's root).
73
+ * @param entryPath - A package-relative path, as found in an archive entry name or a caller's own
74
+ * read request. Lexically validated internally via {@link normalizePackageEntryPath}.
75
+ * @returns The resolved (lexical, not yet realpath'd) absolute destination — safe for the caller to
76
+ * `open`/`mkdir`/`stat` against.
77
+ * @throws {PackagePathViolation} If the path is lexically unsafe, or the filesystem-resolved result
78
+ * (direct or via a symlinked ancestor) falls outside `packageRoot`.
79
+ * @complexity O(d) in path depth — one `realpath` per unresolved ancestor in the worst case.
80
+ */
22
81
  async function assertContainedOnDisk(packageRoot, entryPath) {
23
82
  const normalized = normalizePackageEntryPath(entryPath);
24
83
  const realRoot = await realpath(packageRoot);
@@ -42,6 +101,9 @@ function buildModule(ports) {
42
101
  const relative = path.relative(root, candidate);
43
102
  return relative === "" || (!path.isAbsolute(relative) && relative !== ".." && !relative.startsWith(`..${path.sep}`));
44
103
  }
104
+ /** Walks up from `candidate` until an existing ancestor is found, `realpath`s that ancestor, and
105
+ * reattaches the non-existent tail — so a symlinked ancestor is caught even for a path whose leaf
106
+ * (and possibly several parent segments) have not been created yet. */
45
107
  async function realpathDeepestExistingAncestor(candidate) {
46
108
  let current = path.dirname(candidate);
47
109
  const tail = [path.basename(candidate)];
@@ -9,7 +9,7 @@ export declare function createPersistentStateModule(ports: AgentPluginLifecycleP
9
9
  memory: (required: {
10
10
  workspaceRoot: string;
11
11
  pluginId: string;
12
- }, optional?: {}) => import("../../persistent-state.js").PluginMemory;
12
+ }, optional?: {}) => import("../../core/persistent-state.js").PluginMemory;
13
13
  assertOwnedPath: (required: {
14
14
  workspaceRoot: string;
15
15
  entryPath: string;
@@ -1,4 +1,4 @@
1
- import { createPluginMemory, migratePluginLayout, assertPluginStatePath, withPluginStateLock } from '../../persistent-state.js';
1
+ import { createPluginMemory, migratePluginLayout, assertPluginStatePath, withPluginStateLock } from '../../core/persistent-state.js';
2
2
  import { withFileLock } from '@jini-ai/platform/fs/file-lock';
3
3
  import { createPackagePathsModule } from './package-paths.js';
4
4
  import { parseAgentPluginManifest } from './manifest.js';
@@ -69,6 +69,18 @@ export interface AgentPluginLifecyclePorts {
69
69
  readonly layout: AgentPluginLayoutPort;
70
70
  readonly productName: string;
71
71
  readonly extensionNamespace: string;
72
+ /** Host boot policy may migrate every workspace before seeding the requested workspace.
73
+ * A failure refuses all publication; absent uses the owner's single-workspace migration. */
74
+ readonly migrateBeforeSeed?: ((required: {
75
+ readonly layout: AgentPluginLayoutPort;
76
+ readonly workspaceId: string;
77
+ }, optional: Record<string, never>) => Promise<{
78
+ readonly complete: boolean;
79
+ }>) | undefined;
80
+ /** Host-facing recovery instructions; the bundled refusal and its security gate remain owned here. */
81
+ readonly formatBundledUninstallRecovery?: ((required: {
82
+ readonly pluginId: string;
83
+ }, optional: Record<string, never>) => string) | undefined;
72
84
  readonly readServerMetadata?: AgentPluginServerMetadataReader | undefined;
73
85
  readonly deliveryMode: AgentPluginDeliveryMode;
74
86
  readonly mcpProvisioning: AgentPluginMcpProvisioningPort;
@@ -76,6 +88,6 @@ export interface AgentPluginLifecyclePorts {
76
88
  readonly outboundGuard: AgentPluginOutboundGuardPort;
77
89
  }
78
90
  /** Application policy/effects are required; observers and metadata translators are optional. */
79
- export type AgentPluginLifecycleRequired = Omit<AgentPluginLifecyclePorts, 'onEvent' | 'readServerMetadata'>;
80
- export type AgentPluginLifecycleOptional = Pick<AgentPluginLifecyclePorts, 'onEvent' | 'readServerMetadata'>;
91
+ export type AgentPluginLifecycleRequired = Omit<AgentPluginLifecyclePorts, 'onEvent' | 'readServerMetadata' | 'migrateBeforeSeed' | 'formatBundledUninstallRecovery'>;
92
+ export type AgentPluginLifecycleOptional = Pick<AgentPluginLifecyclePorts, 'onEvent' | 'readServerMetadata' | 'migrateBeforeSeed' | 'formatBundledUninstallRecovery'>;
81
93
  //# sourceMappingURL=ports.d.ts.map
@@ -1,4 +1,16 @@
1
- import type { AgentPluginDeliveryMode, InstalledAgentPlugin } from "./types.js";
1
+ import type { /**
2
+ * How a pinned ref's content reaches the agent.
3
+ *
4
+ * - `inject` — the whole eponymous SKILL.md (~15KB for `ui-ux-design`) plus every other package file
5
+ * listed by absolute path, in the run's prompt prefix. Today's shipped behaviour, and the default.
6
+ * - `pointer` — ~400 bytes naming the exact tool call that returns that SAME SKILL.md, and nothing
7
+ * else. The content is identical; only the delivery differs, which is what makes the two a
8
+ * controlled comparison rather than two different experiments.
9
+ *
10
+ * Selected per-process by the host-supplied `deliveryMode` port. This is a MEASUREMENT AFFORDANCE, not a
11
+ * migration flag: pointer and inject must coexist to compare delivery of identical content.
12
+ * Selecting an unconditional mode requires the measurement result.
13
+ */ AgentPluginDeliveryMode, InstalledAgentPlugin } from "./types.js";
2
14
  import type { AgentPluginWorkspaceLayout } from "./layout.js";
3
15
  import type { AgentPluginLifecyclePorts } from "./ports.js";
4
16
  export type { AgentPluginDeliveryMode } from "./types.js";
@@ -1,10 +1,64 @@
1
+ /**
2
+ * @file Resolves a run's pinned Agent Plugin refs (`pluginRefIds` — `run-start-context.ts`'s
3
+ * decoded `contextRef` field, itself sourced from the composer's `pluginRefId` chips,
4
+ * `composer-capabilities.ts`'s own doc) into the real prompt-prefix text `agent-daemon-server.ts`'s
5
+ * `onStarted` prepends to the run's prompt.
6
+ *
7
+ * ---------------------------------------------------------------------------
8
+ * The activation-record gap this function works around (KNOWN GAP, not silently papered over)
9
+ * ---------------------------------------------------------------------------
10
+ * `activation.ts` owns the per-workspace enabled decision; a disabled or undetermined ref is
11
+ * refused before resolution.
12
+ * What that record deliberately does NOT carry is which installed DIGEST is current for a plugin id.
13
+ * For a BUNDLED plugin, `bundled-digests.ts`'s ledger answers that: the boot seeder
14
+ * records which digest the running build published, so a workspace holding an upgraded plugin's old
15
+ * and new package is no longer ambiguous — the build's own record names the winner. For every other
16
+ * plugin id, closing this needs the operator-facing upgrade/pin flow (and, alongside it, the
17
+ * admission gate for MCP servers `capability-projection.ts`'s own header names) and remains a
18
+ * separate, later decision.
19
+ *
20
+ * So this function still scans every digest this workspace has ever installed under
21
+ * `packages/sha256/*` and matches on the installed `plugin.json`'s own `name` field, after dropping
22
+ * any digest of a bundled plugin the ledger records as superseded:
23
+ *
24
+ * - Exactly one match: resolves normally.
25
+ * - Zero matches: fails with a "not installed" reason — the operator pinned a chip for a plugin
26
+ * that is not (or no longer) actually on disk for this workspace.
27
+ * - More than one match: fails with an explicit ambiguity reason naming every matching digest.
28
+ * Silently picking one (e.g. the lexicographically-last, or "the newest") would be a WORSE
29
+ * failure mode than an explicit error — a wrong plugin's content would reach the agent, and
30
+ * nothing about the run would look wrong until someone noticed the agent's advice didn't match
31
+ * what was supposedly pinned. An explicit error is loud and immediately actionable instead. That
32
+ * refusal is UNCHANGED and still the default: the ledger narrows a plugin id only when the build
33
+ * itself published one of the installed digests, which is authority rather than a guess, and it
34
+ * has no say at all over packages an operator installed (`bundled-digests.ts`, consequence 1).
35
+ *
36
+ * ---------------------------------------------------------------------------
37
+ * What gets injected, and why not the whole 280K package
38
+ * ---------------------------------------------------------------------------
39
+ * Only the resolved plugin's own eponymous skill — `skills/<pluginRefId>/SKILL.md` (the skill
40
+ * folder whose name equals the plugin's own id; for `ui-ux-design` specifically, this is real,
41
+ * ~5.5KB content, not all seven of that plugin's skill folders). The reasoning mirrors
42
+ * `capability-projection.ts`'s own "Skills get a real executable binding" rule: a Skill's markdown
43
+ * IS the plugin's real, agent-facing content, while the rest of the package (references,
44
+ * examples, scripts) is supporting material a plugin's own SKILL.md is written to point an agent
45
+ * at when it needs more depth (the Agent Plugins spec's own convention every skill in this package
46
+ * follows). Injecting the FULL package indiscriminately would balloon every run's prompt with
47
+ * pages this specific task usually never needs. Every OTHER file in the resolved package is still
48
+ * listed by absolute path — genuinely `Read`-able by the spawned CLI agent, which runs with real
49
+ * filesystem access unlike a browser-facing preview — so nothing is unreachable, only deferred to
50
+ * an explicit follow-up read.
51
+ *
52
+ * Architectural role:
53
+ * Server-side only (reads `node:fs/promises`, imports `install.ts`'s `indexInstalledRoot`) — never
54
+ * imported into a browser bundle. Returns a discriminated result rather than throwing, so
55
+ * `agent-daemon-server.ts`'s `onStarted` gets one place to branch on success/failure without a
56
+ * second try/catch layer duplicating this module's own error classification.
57
+ */
1
58
  import { createPersistentStateModule } from "./persistent-state.js";
2
- /** Extracted from the host plugin lifecycle. Effects are supplied per host context. */
3
59
  import path from "node:path";
4
60
  import { createPackagePathsModule } from "./package-paths.js";
5
- import {} from "./activation.js";
6
61
  import { createActivationModule } from "./activation.js";
7
- import {} from "./bundled-digests.js";
8
62
  import { createBundledDigestsModule } from "./bundled-digests.js";
9
63
  import { createInstallModule } from "./install.js";
10
64
  import { createCapabilityProjectionModule } from "./capability-projection.js";
@@ -18,7 +72,35 @@ function buildModule(ports) {
18
72
  const { indexInstalledRoot } = createInstallModule(ports);
19
73
  const { readInstalledSkillMarkdown } = createCapabilityProjectionModule(ports);
20
74
  const SHA256_DIGEST_DIRNAME_PATTERN = /^[a-f0-9]{64}$/;
75
+ /** Reads the delivery mode for this process. An unset or unrecognised value is `inject` — an A/B
76
+ * affordance must never be able to change production behaviour by typo. */
21
77
  function resolveAgentPluginDeliveryMode() { return ports.deliveryMode; }
78
+ /**
79
+ * Resolves every pinned `pluginRefId` into real on-disk text and joins the results into one
80
+ * prompt-prefix block, in the order the refs were pinned.
81
+ *
82
+ * @param pluginRefIds - This run's pinned Agent Plugin ids (`run-start-context.ts`'s decoded
83
+ * `pluginRefIds` — already filtered to non-empty strings by that point). An empty array resolves
84
+ * to an empty prefix with no filesystem access at all — the common case (no plugin pinned).
85
+ * @param workspaceLayout - This run's own workspace's Agent Plugin layout
86
+ * (`resolveAgentPluginLayout().forWorkspace(workspaceId)`), never a shared/instance-level one —
87
+ * see `layout.ts`'s own tenant-isolation header for why a workspace's installed packages must
88
+ * never be resolved against another workspace's tree.
89
+ * @returns `{ ok: true, promptPrefix }` once every ref resolves, or the FIRST `{ ok: false, reason }`
90
+ * encountered — a run pinning two refs where the second is ambiguous still fails clearly, rather
91
+ * than partially augmenting the prompt with only the first ref's content.
92
+ * @complexity O(r * d) where r is `pluginRefIds.length` and d is the number of installed digests in
93
+ * this workspace — each ref independently re-scans the (typically small) digest list, since a
94
+ * different ref may resolve to a different digest.
95
+ */
96
+ // Read ONCE for the whole call, not per ref: it is one small file, and every ref resolves against
97
+ // the same workspace's ledger. See `bundled-digests.ts` for why this can only ever RESOLVE an
98
+ // ambiguity below, never create one.
99
+ // See activation.ts for the shared activation rule. One small file read per pinned ref; the
100
+ // fail-CLOSED `resolveAgentPluginActivation` (the same reader the per-call tool gate uses)
101
+ // refuses on a corrupt/unreadable file instead of reading it as "nothing recorded" — injecting a
102
+ // plugin's guidance SPENDS it exactly like a tool call, so this surface must refuse rather than
103
+ // read a fault as consent.
22
104
  async function resolveAgentPluginRefs(pluginRefIds, workspaceLayout, deliveryMode = resolveAgentPluginDeliveryMode()) {
23
105
  if (pluginRefIds.length === 0)
24
106
  return { ok: true, promptPrefix: "" };
@@ -35,6 +117,12 @@ function buildModule(ports) {
35
117
  }
36
118
  return { ok: true, promptPrefix: sections.join("\n\n") };
37
119
  }
120
+ /** The run-start refusal for one ref's activation verdict, or `undefined` when it may be injected.
121
+ * @complexity O(1). */
122
+ // A distinct reason from "not installed", because the operator's remedy is different: the bytes
123
+ // ARE here and the fix is to enable the plugin, not to install it. Telling them to install
124
+ // something already present is the kind of wrong-but-plausible error message that costs an
125
+ // afternoon.
38
126
  function activationRefusal(pluginRefId, verdict) {
39
127
  if (verdict.verdict === "active")
40
128
  return undefined;
@@ -45,6 +133,19 @@ function buildModule(ports) {
45
133
  return (`Agent Plugin '${pluginRefId}' is installed in this workspace but is not enabled — it ships with ${ports.productName} and ` +
46
134
  "stays inactive until an operator turns it on. Enable it before pinning it to a run.");
47
135
  }
136
+ /** Every installed digest's `InstalledAgentPlugin`, indexed once per call to
137
+ * {@link resolveAgentPluginRefs} — one `readdir` plus one `indexInstalledRoot` walk per digest.
138
+ * Digest directory names that do not match the expected 64-hex-character shape are skipped rather
139
+ * than passed to `indexInstalledRoot` — `packages/sha256/` is not asserted empty of anything else
140
+ * a future tool might place there, and a non-digest entry is not this function's to interpret.
141
+ *
142
+ * Exported for a second caller outside this module: `tool-registrations.ts`'s
143
+ * `loadInstalledAgentPluginToolSources` needs the SAME per-digest walk (every installed digest,
144
+ * whichever skills each one carries) to build one `agent_plugin_<pluginId>` tool per installed
145
+ * plugin — the exact shape this function already builds, just consumed differently than
146
+ * `resolveOnePluginRef`'s own "resolve one pinned ref" use below. Reusing this rather than
147
+ * re-walking `packages/sha256/*` a second, less-validated way keeps the digest-directory-name check
148
+ * and the per-digest failure isolation in exactly one place. */
48
149
  async function listInstalledPlugins(workspaceRoot) {
49
150
  let folders;
50
151
  try {
@@ -84,6 +185,28 @@ function buildModule(ports) {
84
185
  }
85
186
  return installed;
86
187
  }
188
+ /**
189
+ * Whether one installed archive digest still has its package directory under `packagesDir` — the
190
+ * cheap, per-call "is this still installed" signal `tool-registrations.ts`'s tool gate needs.
191
+ *
192
+ * Lives here, beside {@link listInstalledPlugins}, because it must agree with that walk about two
193
+ * things a second implementation would eventually get wrong: that an installed package root IS
194
+ * `<packagesDir>/<digest>` and nothing else, and that a directory name is only a digest when it
195
+ * matches the 64-hex shape. It deliberately does NOT re-index the package (`indexInstalledRoot`
196
+ * reads and validates a manifest, which is far too much per tool call): directory presence is
197
+ * exactly what `uninstall.ts` removes, and it removes it by `rename` BEFORE it touches the
198
+ * activation record, so the gate observes a removal at least as early as it observes the record's
199
+ * deletion, never later.
200
+ *
201
+ * Follows symlinks (plain `stat`, not `lstat`) on purpose — `listInstalledPlugins` does too, so a
202
+ * deployment whose digest directory is a symlink registers a tool that this would otherwise refuse
203
+ * to authorize.
204
+ *
205
+ * @returns `false` for a digest that does not match the installed-directory grammar, for a missing
206
+ * directory, for a non-directory at that path, and for ANY filesystem fault — every answer this
207
+ * cannot establish positively is `false`, because its one caller uses it to authorize.
208
+ * @complexity One `stat`.
209
+ */
87
210
  async function isInstalledDigestPresent(packagesDir, archiveDigest) {
88
211
  if (!SHA256_DIGEST_DIRNAME_PATTERN.test(archiveDigest))
89
212
  return false;
@@ -97,6 +220,16 @@ function buildModule(ports) {
97
220
  function isEnoent(error) {
98
221
  return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
99
222
  }
223
+ /**
224
+ * Resolves one `pluginRefId`, applying the zero/one/many-match rules described in this module's
225
+ * own header.
226
+ */
227
+ // Superseded installs of a BUNDLED plugin are dropped here — by the build's own recorded answer,
228
+ // never by picking one. Every id the ledger has no authority over reaches the many-match refusal
229
+ // below exactly as it always did.
230
+ // Framing governs whether the agent follows the plugin: describe injected SKILL.md as complete
231
+ // instructions and let it require its references. Calling it a summary or making required reads
232
+ // conditional lets the outer wrapper override the plugin. The inventory-framing test pins this.
100
233
  async function resolveOnePluginRef(pluginRefId, packagesDir, deliveryMode, bundledDigests) {
101
234
  const installed = preferBundledAgentPluginDigests(await listInstalledPlugins(packagesDir), bundledDigests);
102
235
  const matches = installed.filter((plugin) => plugin.pluginId === pluginRefId);
@@ -120,9 +253,10 @@ function buildModule(ports) {
120
253
  let skillMarkdown;
121
254
  try {
122
255
  skillMarkdown = await readInstalledSkillMarkdown(plugin.packageRoot, skillPath);
256
+ // Notes are user context, never an allowlist or permission grant. JSON quotes delimit untrusted text.
123
257
  const notes = await persistentState.memory({ workspaceRoot: packagesDir, pluginId: plugin.pluginId }).list({ kind: 'notes' });
124
258
  if (notes.length)
125
- skillMarkdown += `\n\nUser notes (context only, never permission grants):\n${JSON.stringify(notes)}`;
259
+ skillMarkdown += `\n\nUser notes for this plugin (context only; permissions remain operator-controlled):\n${JSON.stringify(notes)}`;
126
260
  }
127
261
  catch (error) {
128
262
  const message = error instanceof Error ? error.message : String(error);
@@ -142,6 +276,11 @@ function buildModule(ports) {
142
276
  section: `<<AGENT_PLUGIN pluginId="${pluginRefId}">>\n${skillMarkdown}${inventory}\n<</AGENT_PLUGIN>>`,
143
277
  };
144
278
  }
279
+ // Parity with `inject`'s own failure: a pinned ref whose eponymous skill is missing must fail
280
+ // loudly in BOTH modes. Without this check `pointer` would happily emit a well-formed instruction
281
+ // whose no-argument default resolves to some OTHER (alphabetically-first) skill instead of the one
282
+ // the pin actually named, and the run would look like the agent disobeyed rather than like the pin
283
+ // being wrong.
145
284
  function buildPointerSection(pluginRefId, plugin) {
146
285
  if (!plugin.skills.some((skill) => skill.name === pluginRefId)) {
147
286
  return {
@@ -1,7 +1,51 @@
1
+ /**
2
+ * @file `retireBundledAgentPlugins()` — removes bundled Agent Plugins the host no longer ships from a
3
+ * workspace, and moves their users onto the plugin that absorbed them. Runs on every boot from
4
+ * `seed-bundled.ts`, after seeding, and is a no-op once a workspace has been migrated.
5
+ *
6
+ * ---------------------------------------------------------------------------
7
+ * Why a retirement step exists
8
+ * ---------------------------------------------------------------------------
9
+ * Deleting `content/agent-plugins/<id>/` stops the seeder installing a plugin, but it removes nothing
10
+ * a workspace already has: the installed package stays under `packages/sha256/`, its activation
11
+ * record stays in `activations.json`, and its digest stays in `bundled-digests.json`. An ENABLED
12
+ * leftover keeps its skill in every prompt, now duplicating (and slowly contradicting) the successor
13
+ * that carries the same content. And the operator cannot remove it: `uninstall.ts` refuses a
14
+ * bundled plugin because it would be re-seeded, which is exactly what stops being true here. Owner
15
+ * rule: the user never fixes anything by hand, so the boot does it.
16
+ *
17
+ * ---------------------------------------------------------------------------
18
+ * What happens per retired id (idempotent)
19
+ * ---------------------------------------------------------------------------
20
+ * 1. Nothing installed and no activation record: nothing to do (the second boot after a migration).
21
+ * A leftover ledger entry is still dropped.
22
+ * 2. The retired plugin was ENABLED (explicitly, or by the "absent means active" default while its
23
+ * package is installed): the successor is switched on — unless an operator explicitly turned the
24
+ * successor off, which is left exactly as it is and reported in the outcome. No silent override.
25
+ * 3. The retired plugin's package(s) and activation record are removed through `uninstall.ts`
26
+ * (`retiredBundled: true` skips only its bundled refusal). A record with no package left is
27
+ * deleted directly.
28
+ * 4. The retired id is dropped from `bundled-digests.json`.
29
+ *
30
+ * A malformed or unreadable activation record for the retired id stops that id's retirement with a
31
+ * `failed` outcome and changes nothing: whether the operator had it enabled cannot be established,
32
+ * so neither enabling the successor nor deleting the record is safe. The next boot retries.
33
+ *
34
+ * Out of scope on purpose: nothing a retired plugin could own survives it. Agent Plugins have no
35
+ * per-plugin settings store (a workspace's `data/<pluginId>` directory has no production writer),
36
+ * and custom credentials (a saved `fly.io` token, say) belong to the workspace, not to a plugin.
37
+ *
38
+ * Architectural role:
39
+ * Composition over `activation.ts`, `uninstall.ts`, and `bundled-digests.ts`. Never throws: every
40
+ * failure is captured per id, exactly like `seed-bundled.ts`'s own outcomes, so one stuck retirement
41
+ * cannot stop a workspace from booting.
42
+ */
1
43
  /** Extracted from the host plugin lifecycle. Effects are supplied per host context. */
2
- import { type BundledAgentPluginEnableOutcome } from "./activation.js";
44
+ import type { BundledAgentPluginEnableOutcome } from "./activation.js";
3
45
  import type { AgentPluginLayout } from "./layout.js";
4
46
  import type { AgentPluginLifecyclePorts } from "./ports.js";
47
+ /** What happened to the successor. `not-needed`: the retired plugin was off, so the successor was
48
+ * left as it was. The other three are {@link BundledAgentPluginEnableOutcome}. */
5
49
  export type RetiredAgentPluginSuccessorOutcome = BundledAgentPluginEnableOutcome | "not-needed";
6
50
  export type RetiredAgentPluginOutcome = {
7
51
  readonly pluginId: string;
@@ -11,9 +55,12 @@ export type RetiredAgentPluginOutcome = {
11
55
  readonly pluginId: string;
12
56
  readonly successorId: string;
13
57
  readonly status: "retired";
58
+ /** Every installed archive digest removed; empty when only a record or ledger entry was left. */
14
59
  readonly removedDigests: readonly string[];
15
60
  readonly activationRemoved: boolean;
16
61
  readonly ledgerEntryRemoved: boolean;
62
+ /** `left-disabled-by-operator` is the one case a caller should surface: the retired plugin was
63
+ * on, its successor was explicitly turned off, and this step did not override that. */
17
64
  readonly successor: RetiredAgentPluginSuccessorOutcome;
18
65
  } | {
19
66
  readonly pluginId: string;
@@ -22,10 +69,12 @@ export type RetiredAgentPluginOutcome = {
22
69
  readonly reason: string;
23
70
  };
24
71
  export interface RetireBundledAgentPluginsRequired {
72
+ /** The INSTANCE-level layout, for the same reason `seed-bundled.ts` takes one. */
25
73
  readonly layout: AgentPluginLayout;
26
74
  readonly workspaceId: string;
27
75
  }
28
76
  export interface RetireBundledAgentPluginsOptional {
77
+ /** Defaults to {@link RETIRED_BUNDLED_AGENT_PLUGINS}; tests pass their own. */
29
78
  readonly retired?: (ReadonlyMap<string, string>) | undefined;
30
79
  readonly now?: ((required: Record<string, never>) => Date) | undefined;
31
80
  }
@@ -1,5 +1,3 @@
1
- /** Extracted from the host plugin lifecycle. Effects are supplied per host context. */
2
- import {} from "./activation.js";
3
1
  import { createActivationModule } from "./activation.js";
4
2
  import { createBundledDigestsModule } from "./bundled-digests.js";
5
3
  import { createResolveAgentPluginRefsModule } from "./resolve-agent-plugin-refs.js";
@@ -9,6 +7,13 @@ function buildModule(ports) {
9
7
  const { removeBundledAgentPluginDigest } = createBundledDigestsModule(ports);
10
8
  const { listInstalledPlugins } = createResolveAgentPluginRefsModule(ports);
11
9
  const { uninstallAgentPlugin } = createUninstallModule(ports);
10
+ /**
11
+ * Retires every id in `retired` from one workspace. See this file's header for the steps.
12
+ *
13
+ * @returns One outcome per retired id, in map order.
14
+ * @throws Nothing.
15
+ * @complexity O(r) retired ids, each one `listInstalledPlugins` walk plus a few small file writes.
16
+ */
12
17
  async function retireBundledAgentPlugins(required, optional = {}) {
13
18
  const outcomes = [];
14
19
  for (const [pluginId, successorId] of optional.retired ?? ports.retiredBundledPlugins) {
@@ -21,6 +26,8 @@ function buildModule(ports) {
21
26
  }
22
27
  return outcomes;
23
28
  }
29
+ // The successor first: if a later step fails, the operator keeps the capability, and the next boot
30
+ // (which finds this step already done) retries only what is left.
24
31
  async function retireOne(required, optional, pluginId, successorId) {
25
32
  const workspaceLayout = required.layout.forWorkspace({ workspaceId: required.workspaceId });
26
33
  const workspaceRoot = workspaceLayout.root;
@@ -1,18 +1,81 @@
1
1
  /** Extracted from the host plugin lifecycle. Effects are supplied per host context. */
2
2
  import type { AgentPluginLifecyclePorts } from "./ports.js";
3
+ /**
4
+ * @file Pure relevance ranking for `search_agent_plugin_local` — turns one workspace's already-
5
+ * resolved installed Agent Plugins into scored matches for a free-text query. No filesystem or
6
+ * network I/O; `tool-registrations.ts` is the caller that reads disk and hands this module already-
7
+ * resolved {@link AgentPluginSearchCandidate}s.
8
+ *
9
+ * ---------------------------------------------------------------------------
10
+ * Why field-weighted substring matching, not FTS5/BM25
11
+ * ---------------------------------------------------------------------------
12
+ * `assistant/tool-catalog-query.ts` (the `search_tools` index this tool itself is found through)
13
+ * seeds a real SQLite FTS5 table because its catalog is runtime-registered and can be large (170+
14
+ * tools, changing per boot/workspace). The catalog THIS function ranks over is different in kind:
15
+ * one workspace's own installed Agent Plugins, read fresh off disk on every call, realistically a
16
+ * handful of entries. `assistant/component-catalog-query.ts` (`search_components`) already
17
+ * establishes the house precedent for exactly this shape — "a handful of manifests doesn't warrant
18
+ * a database for substring matching" — and ranks with plain `haystack.includes(term)` scoring. This
19
+ * module reuses that same technique rather than inventing a second one, with one deliberate
20
+ * difference from that precedent: per-field WEIGHTS instead of one flat merged haystack.
21
+ *
22
+ * The weights are not decorative. `plugin.json` (agent-plugins.org) carries a `keywords` array
23
+ * that `component-catalog-query.ts`'s manifests have no equivalent of — author-curated discovery
24
+ * terms, the exact same "operator asks in different words than the code uses" gap
25
+ * `tool-search-keywords.ts`'s own header describes at length for native tools. A keyword hit is a
26
+ * stronger, more deliberate signal than an incidental word landing inside free-form prose, so it is
27
+ * scored higher. The id (`pluginId`) is scored highest of all: it is what a caller actually types
28
+ * back to `plugins_set_enabled`-shaped tools next, so an exact or near match on it (a caller typing
29
+ * "site-compliance" or "compliance") must win outright — mirroring `tool-catalog.ts`'s own
30
+ * documented "id weighted 6x description" ratio for the identical reason (id IS the thing quoted
31
+ * back). Skill name/summary text is weighted lowest: it is the broadest, noisiest vocabulary (every
32
+ * bundled skill's own frontmatter description folded in), valuable for recall on a query that names
33
+ * a capability rather than a plugin ("shadcn components") but not something that should let one
34
+ * verbose plugin outrank a plugin whose own id/keywords/description directly named the query.
35
+ * Plugin `description` sits in between: real author prose, but not curated tags and not the
36
+ * identity string itself.
37
+ *
38
+ * A plugin's own `version`, `enabled` state, and MCP server ids are carried through on every match
39
+ * for the caller to report, but are NOT scored — none of them are text a caller plausibly searches
40
+ * for ("find me the plugin on version 1.1.0" is not a real query), and an MCP server id is often an
41
+ * arbitrary machine-chosen string with no natural-language relationship to what an operator would
42
+ * type.
43
+ *
44
+ * @complexity `rankInstalledAgentPlugins` is O(c * t * f) — c candidates, t query terms, f fields
45
+ * (a fixed constant, 4) — so effectively O(c * t): linear in the installed-plugin count and query
46
+ * length, both small and locally bounded (there is no user-controlled upper bound on installed-
47
+ * plugin count today, matching the same unbounded-but-locally-small shape
48
+ * `loadInstalledAgentPluginToolSources` already accepts for the very same catalog).
49
+ */
50
+ /** One installed plugin's skill, reduced to what search ranks and reports on — never the full
51
+ * SKILL.md body (that stays behind `agent_plugin_<pluginId>`'s own tool, which a caller reaches
52
+ * once search has told it which plugin to ask). */
3
53
  export interface AgentPluginSearchSkill {
4
54
  readonly name: string;
55
+ /** Plain-language explainer from the host extension namespace in `plugin.json`: `summary`, for the admin list
56
+ * route only — not ranked, and the search tool's own response projection leaves it out. */
5
57
  readonly summary: string;
6
58
  }
59
+ /** One installed plugin, already resolved into everything the ranker and the response need — no
60
+ * `packageRoot`/`skillPath`/any other host-filesystem detail (see `tool-registrations.ts`'s own
61
+ * SECURITY note for why: this candidate shape is what actually reaches the model). */
7
62
  export interface AgentPluginSearchCandidate {
8
63
  readonly pluginId: string;
9
64
  readonly version?: (string) | undefined;
65
+ /** Human-facing title from the host extension namespace in `plugin.json`: `displayName`, when the package set one. */
66
+ readonly displayName?: string | undefined;
67
+ readonly summary?: string | undefined;
10
68
  readonly description?: (string) | undefined;
11
69
  readonly keywords: readonly string[];
70
+ /** Whether this workspace currently has the plugin enabled — reflects `activation.ts`'s
71
+ * absent-means-active default, same as everywhere else this codebase reads activation state. */
12
72
  readonly enabled: boolean;
13
73
  readonly skills: readonly AgentPluginSearchSkill[];
74
+ /** Server ids only, from the package's own `mcp.json` — never transport config (`command`/`args`/
75
+ * `env` can carry secrets; see `capability-projection.ts`'s identical restraint). */
14
76
  readonly mcpServerIds: readonly string[];
15
77
  }
78
+ /** One ranked result: a candidate plus the score it earned against a specific query. */
16
79
  export interface AgentPluginSearchMatch extends AgentPluginSearchCandidate {
17
80
  readonly score: number;
18
81
  }
@@ -4,12 +4,18 @@ function buildModule(ports) {
4
4
  const KEYWORD_FIELD_WEIGHT = 4;
5
5
  const DESCRIPTION_FIELD_WEIGHT = 2;
6
6
  const SKILL_FIELD_WEIGHT = 1;
7
+ /** Lowercase, whitespace-split query terms — mirrors `component-catalog-query.ts`'s identical
8
+ * `query.toLowerCase().split(/\s+/).filter(Boolean)`, the house tokenizer for this "small local
9
+ * catalog, plain string matching" shape. Empty/whitespace-only input yields no terms. */
7
10
  function tokenize(query) {
8
11
  return query
9
12
  .toLowerCase()
10
13
  .split(/\s+/)
11
14
  .filter((term) => term.length > 0);
12
15
  }
16
+ /** One candidate's scoreable text, one entry per weighted field — see this file's header for why
17
+ * each weight is what it is. Split out of {@link scoreCandidate} so the field/weight pairing is a
18
+ * plain, independently-readable list rather than interleaved with the scoring loop itself. */
13
19
  function fieldHaystacks(candidate) {
14
20
  return [
15
21
  { text: candidate.pluginId.toLowerCase(), weight: ID_FIELD_WEIGHT },
@@ -21,10 +27,33 @@ function buildModule(ports) {
21
27
  },
22
28
  ];
23
29
  }
30
+ /** Sums, over every query term and every weighted field, the field's weight whenever that field's
31
+ * text contains the term as a substring — the same `includes()` test `component-catalog-query.ts`
32
+ * uses, just per-field instead of one merged haystack. A term can score against more than one field
33
+ * (e.g. a keyword that is also echoed in the description) — that is intentional: a plugin whose
34
+ * author reinforced a concept in both its keywords and its prose is a genuinely stronger match, not
35
+ * a bug to dedupe away. */
24
36
  function scoreCandidate(candidate, terms) {
25
37
  const haystacks = fieldHaystacks(candidate);
26
38
  return terms.reduce((total, term) => total + haystacks.reduce((fieldTotal, haystack) => (haystack.text.includes(term) ? fieldTotal + haystack.weight : fieldTotal), 0), 0);
27
39
  }
40
+ /**
41
+ * Ranks already-resolved installed-plugin candidates against a free-text query.
42
+ *
43
+ * @param query - Free-text search phrase. An empty or whitespace-only query has no terms to match
44
+ * against and always returns no matches — this function never falls back to "return everything",
45
+ * matching `search_components`' own "no query, no hits" behavior rather than the different (and
46
+ * here wrong) choice of treating an empty query as "list all installed".
47
+ * @param candidates - One workspace's installed plugins, already resolved (disk already read).
48
+ * @param limit - Max matches to return, already clamped by the caller (mirrors
49
+ * `component-catalog-tool.ts`'s handler, which owns the clamp so this function can stay a pure
50
+ * "given these inputs, rank them" step with no knowledge of the tool's own schema bounds).
51
+ * @returns Matches with score > 0, sorted by score descending, truncated to `limit`. A candidate
52
+ * that scores 0 (no term matched anything) is dropped, not returned at the bottom — the caller's own
53
+ * "N installed, 0 matched" framing is what tells the model the workspace is not empty even when a
54
+ * query matches nothing.
55
+ * @complexity See this file's header.
56
+ */
28
57
  function rankInstalledAgentPlugins(query, candidates, limit) {
29
58
  const terms = tokenize(query);
30
59
  if (terms.length === 0)