@jini-ai/agent-plugins 0.4.0 → 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 (47) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/README.md +19 -0
  3. package/core/persistent-state.d.ts +25 -0
  4. package/core/persistent-state.js +260 -0
  5. package/dist/lifecycle/activation.js +38 -166
  6. package/dist/lifecycle/bundled-digests.d.ts +6 -0
  7. package/dist/lifecycle/bundled-digests.js +156 -0
  8. package/dist/lifecycle/bundled-source-archive.d.ts +3 -0
  9. package/dist/lifecycle/bundled-source-archive.js +74 -0
  10. package/dist/lifecycle/contribution-loader.d.ts +102 -0
  11. package/dist/lifecycle/contribution-loader.js +92 -0
  12. package/dist/lifecycle/fetch-archive.d.ts +1 -2
  13. package/dist/lifecycle/index.d.ts +34 -4
  14. package/dist/lifecycle/index.js +19 -1
  15. package/dist/lifecycle/install-from-url.d.ts +2 -5
  16. package/dist/lifecycle/install-from-url.js +2 -5
  17. package/dist/lifecycle/install.d.ts +57 -2
  18. package/dist/lifecycle/install.js +228 -13
  19. package/dist/lifecycle/layout.d.ts +10 -0
  20. package/dist/lifecycle/layout.js +9 -1
  21. package/dist/lifecycle/manifest.d.ts +3 -9
  22. package/dist/lifecycle/manifest.js +4 -11
  23. package/dist/lifecycle/package-paths.d.ts +2 -0
  24. package/dist/lifecycle/package-paths.js +62 -0
  25. package/dist/lifecycle/persistent-state.d.ts +24 -0
  26. package/dist/lifecycle/persistent-state.js +32 -0
  27. package/dist/lifecycle/ports.d.ts +15 -3
  28. package/dist/lifecycle/resolve-agent-plugin-refs.d.ts +14 -2
  29. package/dist/lifecycle/resolve-agent-plugin-refs.js +173 -10
  30. package/dist/lifecycle/retire-bundled.d.ts +50 -1
  31. package/dist/lifecycle/retire-bundled.js +10 -3
  32. package/dist/lifecycle/search.d.ts +63 -0
  33. package/dist/lifecycle/search.js +29 -0
  34. package/dist/lifecycle/seed-bundled.d.ts +18 -1
  35. package/dist/lifecycle/seed-bundled.js +139 -1
  36. package/dist/lifecycle/set-enabled.d.ts +7 -0
  37. package/dist/lifecycle/set-enabled.js +6 -1
  38. package/dist/lifecycle/trusted-plugin-files.d.ts +38 -0
  39. package/dist/lifecycle/trusted-plugin-files.js +19 -1
  40. package/dist/lifecycle/types.d.ts +12 -0
  41. package/dist/lifecycle/uninstall.d.ts +30 -0
  42. package/dist/lifecycle/uninstall.js +232 -4
  43. package/dist/lifecycle/yauzl-archive-reader.d.ts +0 -1
  44. package/dist/lifecycle/yauzl-archive-reader.js +5 -4
  45. package/package.json +34 -9
  46. package/persistent-state.d.ts +25 -0
  47. package/persistent-state.js +2 -0
@@ -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";
@@ -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)];
@@ -0,0 +1,24 @@
1
+ import type { AgentPluginLifecyclePorts } from './ports.js';
2
+ /** Reuses the platform lock with injected effects, never age-stealing a living migration. */
3
+ export declare function createPersistentStateModule(ports: AgentPluginLifecyclePorts, _optional?: Record<string, never>): {
4
+ withPluginLock: <T>(required: {
5
+ workspaceRoot: string;
6
+ pluginId: string;
7
+ run(): Promise<T>;
8
+ }, optional?: {}) => Promise<T>;
9
+ memory: (required: {
10
+ workspaceRoot: string;
11
+ pluginId: string;
12
+ }, optional?: {}) => import("../../core/persistent-state.js").PluginMemory;
13
+ assertOwnedPath: (required: {
14
+ workspaceRoot: string;
15
+ entryPath: string;
16
+ }, optional?: {}) => Promise<string>;
17
+ migrate: (required: {
18
+ workspaceRoot: string;
19
+ }, optional?: {}) => Promise<{
20
+ complete: boolean;
21
+ moved: number;
22
+ }>;
23
+ };
24
+ //# sourceMappingURL=persistent-state.d.ts.map
@@ -0,0 +1,32 @@
1
+ import { createPluginMemory, migratePluginLayout, assertPluginStatePath, withPluginStateLock } from '../../core/persistent-state.js';
2
+ import { withFileLock } from '@jini-ai/platform/fs/file-lock';
3
+ import { createPackagePathsModule } from './package-paths.js';
4
+ import { parseAgentPluginManifest } from './manifest.js';
5
+ /** Reuses the platform lock with injected effects, never age-stealing a living migration. */
6
+ export function createPersistentStateModule(ports, _optional = {}) {
7
+ const { assertContainedOnDisk } = createPackagePathsModule(ports);
8
+ const effects = {
9
+ filesystem: ports.filesystem,
10
+ contain: ({ root, entryPath }) => assertContainedOnDisk(root, entryPath),
11
+ withLock: ({ lockPath, run }) => withFileLock({ lockPath, run }, {
12
+ filesystem: ports.filesystem, clock: ports.clock, process: ports.process,
13
+ hostname: () => ports.process.hostname({}), token: () => ports.ids.newId(),
14
+ monotonicMs: () => ports.clock.monotonicMs(), sleep: ({ durationMs }) => ports.clock.sleep({ ms: durationMs }),
15
+ staleMs: Infinity, createParent: true,
16
+ }),
17
+ };
18
+ return {
19
+ withPluginLock: (required, optional = {}) => withPluginStateLock({ ...effects, ...required }, optional),
20
+ memory: (required, optional = {}) => createPluginMemory({ ...effects, ...required }, optional),
21
+ assertOwnedPath: (required, optional = {}) => assertPluginStatePath({ ...effects, ...required }, optional),
22
+ migrate: (required, optional = {}) => migratePluginLayout({ ...effects, ...required,
23
+ parsePluginId: ({ value }) => {
24
+ const parsed = parseAgentPluginManifest({ value });
25
+ if (!parsed.ok)
26
+ throw new Error(parsed.errors.join('; '));
27
+ return parsed.manifest.name;
28
+ }, ...(ports.onEvent ? { onEvent: ports.onEvent } : {}),
29
+ }, optional),
30
+ };
31
+ }
32
+ //# sourceMappingURL=persistent-state.js.map
@@ -4,7 +4,7 @@ import type { AgentPluginLayoutPort } from './layout.js';
4
4
  import type { AgentPluginDeliveryMode, InstalledAgentPlugin } from './types.js';
5
5
  /** Native Node filesystem ABI, narrowed to the operations used by lifecycle.
6
6
  * Inject instrumented fs/promises effects directly; native handles are never rewrapped. */
7
- export type FilesystemPort = Pick<typeof import('node:fs/promises'), 'chmod' | 'mkdir' | 'mkdtemp' | 'open' | 'readdir' | 'readFile' | 'rename' | 'rm' | 'stat' | 'realpath' | 'unlink'>;
7
+ export type FilesystemPort = Pick<typeof import('node:fs/promises'), 'chmod' | 'mkdir' | 'mkdtemp' | 'open' | 'readdir' | 'readFile' | 'rename' | 'rm' | 'rmdir' | 'stat' | 'realpath' | 'unlink'>;
8
8
  export type AgentPluginFetchPort = (required: {
9
9
  readonly url: string;
10
10
  }, optional?: RequestInit) => Promise<Response>;
@@ -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";
@@ -12,7 +24,7 @@ export type ResolveAgentPluginRefsResult = {
12
24
  /** Internal composition; one state/queue instance per injected host context. */
13
25
  export declare function createResolveAgentPluginRefsModule(ports: AgentPluginLifecyclePorts): {
14
26
  resolveAgentPluginRefs: (pluginRefIds: readonly string[], workspaceLayout: Pick<AgentPluginWorkspaceLayout, "packages" | "root">, deliveryMode?: AgentPluginDeliveryMode) => Promise<ResolveAgentPluginRefsResult>;
15
- listInstalledPlugins: (packagesDir: string) => Promise<readonly InstalledAgentPlugin[]>;
27
+ listInstalledPlugins: (workspaceRoot: string) => Promise<readonly InstalledAgentPlugin[]>;
16
28
  isInstalledDigestPresent: (packagesDir: string, archiveDigest: string) => Promise<boolean>;
17
29
  };
18
30
  //# sourceMappingURL=resolve-agent-plugin-refs.d.ts.map
@@ -1,12 +1,70 @@
1
- /** Extracted from the host plugin lifecycle. Effects are supplied per host context. */
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
+ */
58
+ import { createPersistentStateModule } from "./persistent-state.js";
2
59
  import path from "node:path";
3
- import {} from "./activation.js";
60
+ import { createPackagePathsModule } from "./package-paths.js";
4
61
  import { createActivationModule } from "./activation.js";
5
- import {} from "./bundled-digests.js";
6
62
  import { createBundledDigestsModule } from "./bundled-digests.js";
7
63
  import { createInstallModule } from "./install.js";
8
64
  import { createCapabilityProjectionModule } from "./capability-projection.js";
9
65
  function buildModule(ports) {
66
+ const persistentState = createPersistentStateModule(ports);
67
+ const { assertContainedOnDisk } = createPackagePathsModule(ports);
10
68
  const readdir = ports.filesystem.readdir.bind(ports.filesystem);
11
69
  const stat = ports.filesystem.stat.bind(ports.filesystem);
12
70
  const { resolveAgentPluginActivation } = createActivationModule(ports);
@@ -14,7 +72,35 @@ function buildModule(ports) {
14
72
  const { indexInstalledRoot } = createInstallModule(ports);
15
73
  const { readInstalledSkillMarkdown } = createCapabilityProjectionModule(ports);
16
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. */
17
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.
18
104
  async function resolveAgentPluginRefs(pluginRefIds, workspaceLayout, deliveryMode = resolveAgentPluginDeliveryMode()) {
19
105
  if (pluginRefIds.length === 0)
20
106
  return { ok: true, promptPrefix: "" };
@@ -24,13 +110,19 @@ function buildModule(ports) {
24
110
  const refusal = activationRefusal(pluginRefId, await resolveAgentPluginActivation(workspaceLayout.root, pluginRefId));
25
111
  if (refusal !== undefined)
26
112
  return { ok: false, reason: refusal };
27
- const resolved = await resolveOnePluginRef(pluginRefId, workspaceLayout.packages, deliveryMode, bundledDigests);
113
+ const resolved = await resolveOnePluginRef(pluginRefId, workspaceLayout.root, deliveryMode, bundledDigests);
28
114
  if (!resolved.ok)
29
115
  return resolved;
30
116
  sections.push(resolved.section);
31
117
  }
32
118
  return { ok: true, promptPrefix: sections.join("\n\n") };
33
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.
34
126
  function activationRefusal(pluginRefId, verdict) {
35
127
  if (verdict.verdict === "active")
36
128
  return undefined;
@@ -41,10 +133,23 @@ function buildModule(ports) {
41
133
  return (`Agent Plugin '${pluginRefId}' is installed in this workspace but is not enabled — it ships with ${ports.productName} and ` +
42
134
  "stays inactive until an operator turns it on. Enable it before pinning it to a run.");
43
135
  }
44
- async function listInstalledPlugins(packagesDir) {
45
- let entries;
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. */
149
+ async function listInstalledPlugins(workspaceRoot) {
150
+ let folders;
46
151
  try {
47
- entries = await readdir(packagesDir);
152
+ folders = await readdir(workspaceRoot, { withFileTypes: true });
48
153
  }
49
154
  catch (error) {
50
155
  if (isEnoent(error))
@@ -52,17 +157,56 @@ function buildModule(ports) {
52
157
  throw error;
53
158
  }
54
159
  const installed = [];
55
- for (const digest of entries) {
56
- if (!SHA256_DIGEST_DIRNAME_PATTERN.test(digest))
160
+ for (const folder of folders) {
161
+ if (!folder.isDirectory() || !/^[a-z0-9]+(?:[-.][a-z0-9]+)*$/.test(folder.name) || folder.name.length > 64)
57
162
  continue;
163
+ const packagesDir = path.join(workspaceRoot, folder.name, "package", "sha256");
164
+ let entries;
58
165
  try {
59
- installed.push(await indexInstalledRoot(path.join(packagesDir, digest), digest));
166
+ await persistentState.assertOwnedPath({ workspaceRoot, entryPath: path.relative(workspaceRoot, packagesDir) });
167
+ entries = await readdir(packagesDir, { withFileTypes: true });
60
168
  }
61
169
  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
+ }
62
184
  }
63
185
  }
64
186
  return installed;
65
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
+ */
66
210
  async function isInstalledDigestPresent(packagesDir, archiveDigest) {
67
211
  if (!SHA256_DIGEST_DIRNAME_PATTERN.test(archiveDigest))
68
212
  return false;
@@ -76,6 +220,16 @@ function buildModule(ports) {
76
220
  function isEnoent(error) {
77
221
  return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
78
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.
79
233
  async function resolveOnePluginRef(pluginRefId, packagesDir, deliveryMode, bundledDigests) {
80
234
  const installed = preferBundledAgentPluginDigests(await listInstalledPlugins(packagesDir), bundledDigests);
81
235
  const matches = installed.filter((plugin) => plugin.pluginId === pluginRefId);
@@ -99,6 +253,10 @@ function buildModule(ports) {
99
253
  let skillMarkdown;
100
254
  try {
101
255
  skillMarkdown = await readInstalledSkillMarkdown(plugin.packageRoot, skillPath);
256
+ // Notes are user context, never an allowlist or permission grant. JSON quotes delimit untrusted text.
257
+ const notes = await persistentState.memory({ workspaceRoot: packagesDir, pluginId: plugin.pluginId }).list({ kind: 'notes' });
258
+ if (notes.length)
259
+ skillMarkdown += `\n\nUser notes for this plugin (context only; permissions remain operator-controlled):\n${JSON.stringify(notes)}`;
102
260
  }
103
261
  catch (error) {
104
262
  const message = error instanceof Error ? error.message : String(error);
@@ -118,6 +276,11 @@ function buildModule(ports) {
118
276
  section: `<<AGENT_PLUGIN pluginId="${pluginRefId}">>\n${skillMarkdown}${inventory}\n<</AGENT_PLUGIN>>`,
119
277
  };
120
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.
121
284
  function buildPointerSection(pluginRefId, plugin) {
122
285
  if (!plugin.skills.some((skill) => skill.name === pluginRefId)) {
123
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
  }