@jini-ai/agent-plugins 0.4.1 → 0.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/CHANGELOG.md +8 -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 +160 -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 +58 -3
  17. package/dist/lifecycle/install.js +301 -23
  18. package/dist/lifecycle/installed-package-roots.d.ts +16 -0
  19. package/dist/lifecycle/installed-package-roots.js +55 -0
  20. package/dist/lifecycle/layout.js +1 -1
  21. package/dist/lifecycle/manifest.d.ts +5 -60
  22. package/dist/lifecycle/manifest.js +12 -20
  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 +5 -1
  26. package/dist/lifecycle/persistent-state.js +3 -1
  27. package/dist/lifecycle/ports.d.ts +20 -2
  28. package/dist/lifecycle/resolve-agent-plugin-refs.d.ts +13 -1
  29. package/dist/lifecycle/resolve-agent-plugin-refs.js +150 -40
  30. package/dist/lifecycle/retire-bundled.d.ts +50 -1
  31. package/dist/lifecycle/retire-bundled.js +9 -2
  32. package/dist/lifecycle/search.d.ts +65 -0
  33. package/dist/lifecycle/search.js +31 -2
  34. package/dist/lifecycle/seed-bundled.d.ts +18 -1
  35. package/dist/lifecycle/seed-bundled.js +131 -3
  36. package/dist/lifecycle/set-enabled.d.ts +7 -0
  37. package/dist/lifecycle/set-enabled.js +5 -0
  38. package/dist/lifecycle/trusted-plugin-files.d.ts +38 -0
  39. package/dist/lifecycle/trusted-plugin-files.js +18 -0
  40. package/dist/lifecycle/types.d.ts +14 -0
  41. package/dist/lifecycle/uninstall.d.ts +29 -0
  42. package/dist/lifecycle/uninstall.js +204 -4
  43. package/dist/lifecycle/yauzl-archive-reader.d.ts +0 -1
  44. package/dist/manifest.d.ts +3 -0
  45. package/dist/manifest.js +3 -2
  46. package/package.json +22 -8
  47. package/persistent-state.js +2 -260
  48. package/ui-ux-design/plugin.json +15 -1
@@ -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).
@@ -58,11 +52,11 @@
58
52
  * own "receives a JS value, never a file path or raw bytes" contract. `install.ts` is the only
59
53
  * caller that reads the file and hands the parsed value here.
60
54
  */
55
+ import { readPluginIdentity, validatePluginIdentity } from "@jini-ai/core/plugin-directory";
61
56
  export { validatePluginManifest, validateMcpManifest } from '../manifest.js';
62
57
  /**
63
58
  * 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.
59
+ * value is rejected outright rather than guessed at: pinning the grammar must survive spec churn.
66
60
  */
67
61
  const PLUGIN_SCHEMA_1_0_0 = "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json";
68
62
  const MCP_SCHEMA_1_0_0 = "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json";
@@ -91,13 +85,6 @@ function isJsonObject(value) {
91
85
  function coerceOptionalString(value) {
92
86
  return typeof value === "string" ? value : undefined;
93
87
  }
94
- /**
95
- * `value` narrowed to a `string[]` (non-string entries dropped), or `undefined` when `value`
96
- * itself isn't an array — `keywords`'s own coercion.
97
- */
98
- function coerceStringArray(value) {
99
- return Array.isArray(value) ? value.filter((entry) => typeof entry === "string") : undefined;
100
- }
101
88
  function parseManifestAuthor(value) {
102
89
  if (typeof value === 'string')
103
90
  return value; // Existing installed manifests may carry a string.
@@ -134,7 +121,7 @@ function resolveManifestName(raw) {
134
121
  * `plugin-runtime/discovery.ts`).
135
122
  * @complexity O(k + n) time and space in the input keys and keyword entries.
136
123
  */
137
- export function parseAgentPluginManifest({ value }) {
124
+ export function parseAgentPluginManifest({ value }, { identityNamespace = "tovu" } = {}) {
138
125
  if (!isJsonObject(value))
139
126
  return { ok: false, errors: ["plugin.json must be a JSON object"] };
140
127
  const raw = value;
@@ -152,15 +139,20 @@ export function parseAgentPluginManifest({ value }) {
152
139
  if (!KNOWN_MANIFEST_KEYS.has(key))
153
140
  warnings.push(`unrecognized plugin.json field '${key}' (ignored per spec)`);
154
141
  }
142
+ errors.push(...validatePluginIdentity({ manifest: raw, namespace: identityNamespace }).errors);
155
143
  if (errors.length > 0)
156
144
  return { ok: false, errors };
145
+ const identity = readPluginIdentity({ manifest: raw, namespace: identityNamespace });
146
+ warnings.push(...identity.warnings);
157
147
  const manifest = {
158
148
  name: nameResult.name,
149
+ id: identity.id,
150
+ provides: identity.provides,
159
151
  version: coerceOptionalString(raw.version),
160
152
  description: coerceOptionalString(raw.description),
161
153
  author: parseManifestAuthor(raw.author),
162
154
  license: coerceOptionalString(raw.license),
163
- keywords: coerceStringArray(raw.keywords),
155
+ keywords: raw.keywords === undefined ? undefined : identity.keywords,
164
156
  ...(isJsonObject(raw.extensions) ? { extensions: raw.extensions } : {}),
165
157
  };
166
158
  return { ok: true, manifest, warnings };
@@ -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)];
@@ -1,6 +1,10 @@
1
1
  import type { AgentPluginLifecyclePorts } from './ports.js';
2
2
  /** Reuses the platform lock with injected effects, never age-stealing a living migration. */
3
3
  export declare function createPersistentStateModule(ports: AgentPluginLifecyclePorts, _optional?: Record<string, never>): {
4
+ withIdentityLock: <T>(required: {
5
+ workspaceRoot: string;
6
+ run(): Promise<T>;
7
+ }, _optional?: {}) => Promise<T>;
4
8
  withPluginLock: <T>(required: {
5
9
  workspaceRoot: string;
6
10
  pluginId: string;
@@ -9,7 +13,7 @@ export declare function createPersistentStateModule(ports: AgentPluginLifecycleP
9
13
  memory: (required: {
10
14
  workspaceRoot: string;
11
15
  pluginId: string;
12
- }, optional?: {}) => import("../../persistent-state.js").PluginMemory;
16
+ }, optional?: {}) => import("../../core/persistent-state.js").PluginMemory;
13
17
  assertOwnedPath: (required: {
14
18
  workspaceRoot: string;
15
19
  entryPath: string;
@@ -1,4 +1,5 @@
1
- import { createPluginMemory, migratePluginLayout, assertPluginStatePath, withPluginStateLock } from '../../persistent-state.js';
1
+ import { createPluginMemory, migratePluginLayout, assertPluginStatePath, withPluginStateLock } from '../../core/persistent-state.js';
2
+ import path from 'node:path';
2
3
  import { withFileLock } from '@jini-ai/platform/fs/file-lock';
3
4
  import { createPackagePathsModule } from './package-paths.js';
4
5
  import { parseAgentPluginManifest } from './manifest.js';
@@ -16,6 +17,7 @@ export function createPersistentStateModule(ports, _optional = {}) {
16
17
  }),
17
18
  };
18
19
  return {
20
+ withIdentityLock: (required, _optional = {}) => effects.withLock({ lockPath: path.join(required.workspaceRoot, '.publisher-identity.lock'), run: required.run }),
19
21
  withPluginLock: (required, optional = {}) => withPluginStateLock({ ...effects, ...required }, optional),
20
22
  memory: (required, optional = {}) => createPluginMemory({ ...effects, ...required }, optional),
21
23
  assertOwnedPath: (required, optional = {}) => assertPluginStatePath({ ...effects, ...required }, optional),
@@ -52,6 +52,12 @@ export interface AgentPluginOutboundGuardPort {
52
52
  }): Promise<void>;
53
53
  }
54
54
  export interface AgentPluginLifecyclePorts {
55
+ /** Optional host admission serializes cross-kind identity checking and publication. */
56
+ readonly identityAdmission?: (<T>(required: {
57
+ id: string;
58
+ localId: string;
59
+ run: () => Promise<T>;
60
+ }, optional?: Record<string, never>) => Promise<T>) | undefined;
55
61
  readonly onEvent?: ((required: {
56
62
  readonly event: string;
57
63
  readonly message: string;
@@ -69,6 +75,18 @@ export interface AgentPluginLifecyclePorts {
69
75
  readonly layout: AgentPluginLayoutPort;
70
76
  readonly productName: string;
71
77
  readonly extensionNamespace: string;
78
+ /** Host boot policy may migrate every workspace before seeding the requested workspace.
79
+ * A failure refuses all publication; absent uses the owner's single-workspace migration. */
80
+ readonly migrateBeforeSeed?: ((required: {
81
+ readonly layout: AgentPluginLayoutPort;
82
+ readonly workspaceId: string;
83
+ }, optional: Record<string, never>) => Promise<{
84
+ readonly complete: boolean;
85
+ }>) | undefined;
86
+ /** Host-facing recovery instructions; the bundled refusal and its security gate remain owned here. */
87
+ readonly formatBundledUninstallRecovery?: ((required: {
88
+ readonly pluginId: string;
89
+ }, optional: Record<string, never>) => string) | undefined;
72
90
  readonly readServerMetadata?: AgentPluginServerMetadataReader | undefined;
73
91
  readonly deliveryMode: AgentPluginDeliveryMode;
74
92
  readonly mcpProvisioning: AgentPluginMcpProvisioningPort;
@@ -76,6 +94,6 @@ export interface AgentPluginLifecyclePorts {
76
94
  readonly outboundGuard: AgentPluginOutboundGuardPort;
77
95
  }
78
96
  /** 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'>;
97
+ export type AgentPluginLifecycleRequired = Omit<AgentPluginLifecyclePorts, 'onEvent' | 'readServerMetadata' | 'migrateBeforeSeed' | 'formatBundledUninstallRecovery' | 'identityAdmission'>;
98
+ export type AgentPluginLifecycleOptional = Pick<AgentPluginLifecyclePorts, 'onEvent' | 'readServerMetadata' | 'migrateBeforeSeed' | 'formatBundledUninstallRecovery' | 'identityAdmission'>;
81
99
  //# 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,24 +1,105 @@
1
+ import { listInstalledPackageRoots, INSTALLED_PLUGIN_DIGEST_PATTERN } from "./installed-package-roots.js";
2
+ /**
3
+ * @file Resolves a run's pinned Agent Plugin refs (`pluginRefIds` — `run-start-context.ts`'s
4
+ * decoded `contextRef` field, itself sourced from the composer's `pluginRefId` chips,
5
+ * `composer-capabilities.ts`'s own doc) into the real prompt-prefix text `agent-daemon-server.ts`'s
6
+ * `onStarted` prepends to the run's prompt.
7
+ *
8
+ * ---------------------------------------------------------------------------
9
+ * The activation-record gap this function works around (KNOWN GAP, not silently papered over)
10
+ * ---------------------------------------------------------------------------
11
+ * `activation.ts` owns the per-workspace enabled decision; a disabled or undetermined ref is
12
+ * refused before resolution.
13
+ * What that record deliberately does NOT carry is which installed DIGEST is current for a plugin id.
14
+ * For a BUNDLED plugin, `bundled-digests.ts`'s ledger answers that: the boot seeder
15
+ * records which digest the running build published, so a workspace holding an upgraded plugin's old
16
+ * and new package is no longer ambiguous — the build's own record names the winner. For every other
17
+ * plugin id, closing this needs the operator-facing upgrade/pin flow (and, alongside it, the
18
+ * admission gate for MCP servers `capability-projection.ts`'s own header names) and remains a
19
+ * separate, later decision.
20
+ *
21
+ * So this function still scans every digest this workspace has ever installed under
22
+ * `packages/sha256/*` and matches on the installed `plugin.json`'s own `name` field, after dropping
23
+ * any digest of a bundled plugin the ledger records as superseded:
24
+ *
25
+ * - Exactly one match: resolves normally.
26
+ * - Zero matches: fails with a "not installed" reason — the operator pinned a chip for a plugin
27
+ * that is not (or no longer) actually on disk for this workspace.
28
+ * - More than one match: fails with an explicit ambiguity reason naming every matching digest.
29
+ * Silently picking one (e.g. the lexicographically-last, or "the newest") would be a WORSE
30
+ * failure mode than an explicit error — a wrong plugin's content would reach the agent, and
31
+ * nothing about the run would look wrong until someone noticed the agent's advice didn't match
32
+ * what was supposedly pinned. An explicit error is loud and immediately actionable instead. That
33
+ * refusal is UNCHANGED and still the default: the ledger narrows a plugin id only when the build
34
+ * itself published one of the installed digests, which is authority rather than a guess, and it
35
+ * has no say at all over packages an operator installed (`bundled-digests.ts`, consequence 1).
36
+ *
37
+ * ---------------------------------------------------------------------------
38
+ * What gets injected, and why not the whole 280K package
39
+ * ---------------------------------------------------------------------------
40
+ * Only the resolved plugin's own eponymous skill — `skills/<pluginRefId>/SKILL.md` (the skill
41
+ * folder whose name equals the plugin's own id; for `ui-ux-design` specifically, this is real,
42
+ * ~5.5KB content, not all seven of that plugin's skill folders). The reasoning mirrors
43
+ * `capability-projection.ts`'s own "Skills get a real executable binding" rule: a Skill's markdown
44
+ * IS the plugin's real, agent-facing content, while the rest of the package (references,
45
+ * examples, scripts) is supporting material a plugin's own SKILL.md is written to point an agent
46
+ * at when it needs more depth (the Agent Plugins spec's own convention every skill in this package
47
+ * follows). Injecting the FULL package indiscriminately would balloon every run's prompt with
48
+ * pages this specific task usually never needs. Every OTHER file in the resolved package is still
49
+ * listed by absolute path — genuinely `Read`-able by the spawned CLI agent, which runs with real
50
+ * filesystem access unlike a browser-facing preview — so nothing is unreachable, only deferred to
51
+ * an explicit follow-up read.
52
+ *
53
+ * Architectural role:
54
+ * Server-side only (reads `node:fs/promises`, imports `install.ts`'s `indexInstalledRoot`) — never
55
+ * imported into a browser bundle. Returns a discriminated result rather than throwing, so
56
+ * `agent-daemon-server.ts`'s `onStarted` gets one place to branch on success/failure without a
57
+ * second try/catch layer duplicating this module's own error classification.
58
+ */
1
59
  import { createPersistentStateModule } from "./persistent-state.js";
2
- /** Extracted from the host plugin lifecycle. Effects are supplied per host context. */
3
60
  import path from "node:path";
4
61
  import { createPackagePathsModule } from "./package-paths.js";
5
- import {} from "./activation.js";
6
62
  import { createActivationModule } from "./activation.js";
7
- import {} from "./bundled-digests.js";
8
63
  import { createBundledDigestsModule } from "./bundled-digests.js";
9
64
  import { createInstallModule } from "./install.js";
10
65
  import { createCapabilityProjectionModule } from "./capability-projection.js";
11
66
  function buildModule(ports) {
12
67
  const persistentState = createPersistentStateModule(ports);
13
68
  const { assertContainedOnDisk } = createPackagePathsModule(ports);
14
- const readdir = ports.filesystem.readdir.bind(ports.filesystem);
15
69
  const stat = ports.filesystem.stat.bind(ports.filesystem);
16
70
  const { resolveAgentPluginActivation } = createActivationModule(ports);
17
71
  const { preferBundledAgentPluginDigests, readBundledAgentPluginDigests } = createBundledDigestsModule(ports);
18
72
  const { indexInstalledRoot } = createInstallModule(ports);
19
73
  const { readInstalledSkillMarkdown } = createCapabilityProjectionModule(ports);
20
- const SHA256_DIGEST_DIRNAME_PATTERN = /^[a-f0-9]{64}$/;
74
+ /** Reads the delivery mode for this process. An unset or unrecognised value is `inject` — an A/B
75
+ * affordance must never be able to change production behaviour by typo. */
21
76
  function resolveAgentPluginDeliveryMode() { return ports.deliveryMode; }
77
+ /**
78
+ * Resolves every pinned `pluginRefId` into real on-disk text and joins the results into one
79
+ * prompt-prefix block, in the order the refs were pinned.
80
+ *
81
+ * @param pluginRefIds - This run's pinned Agent Plugin ids (`run-start-context.ts`'s decoded
82
+ * `pluginRefIds` — already filtered to non-empty strings by that point). An empty array resolves
83
+ * to an empty prefix with no filesystem access at all — the common case (no plugin pinned).
84
+ * @param workspaceLayout - This run's own workspace's Agent Plugin layout
85
+ * (`resolveAgentPluginLayout().forWorkspace(workspaceId)`), never a shared/instance-level one —
86
+ * see `layout.ts`'s own tenant-isolation header for why a workspace's installed packages must
87
+ * never be resolved against another workspace's tree.
88
+ * @returns `{ ok: true, promptPrefix }` once every ref resolves, or the FIRST `{ ok: false, reason }`
89
+ * encountered — a run pinning two refs where the second is ambiguous still fails clearly, rather
90
+ * than partially augmenting the prompt with only the first ref's content.
91
+ * @complexity O(r * d) where r is `pluginRefIds.length` and d is the number of installed digests in
92
+ * this workspace — each ref independently re-scans the (typically small) digest list, since a
93
+ * different ref may resolve to a different digest.
94
+ */
95
+ // Read ONCE for the whole call, not per ref: it is one small file, and every ref resolves against
96
+ // the same workspace's ledger. See `bundled-digests.ts` for why this can only ever RESOLVE an
97
+ // ambiguity below, never create one.
98
+ // See activation.ts for the shared activation rule. One small file read per pinned ref; the
99
+ // fail-CLOSED `resolveAgentPluginActivation` (the same reader the per-call tool gate uses)
100
+ // refuses on a corrupt/unreadable file instead of reading it as "nothing recorded" — injecting a
101
+ // plugin's guidance SPENDS it exactly like a tool call, so this surface must refuse rather than
102
+ // read a fault as consent.
22
103
  async function resolveAgentPluginRefs(pluginRefIds, workspaceLayout, deliveryMode = resolveAgentPluginDeliveryMode()) {
23
104
  if (pluginRefIds.length === 0)
24
105
  return { ok: true, promptPrefix: "" };
@@ -35,6 +116,12 @@ function buildModule(ports) {
35
116
  }
36
117
  return { ok: true, promptPrefix: sections.join("\n\n") };
37
118
  }
119
+ /** The run-start refusal for one ref's activation verdict, or `undefined` when it may be injected.
120
+ * @complexity O(1). */
121
+ // A distinct reason from "not installed", because the operator's remedy is different: the bytes
122
+ // ARE here and the fix is to enable the plugin, not to install it. Telling them to install
123
+ // something already present is the kind of wrong-but-plausible error message that costs an
124
+ // afternoon.
38
125
  function activationRefusal(pluginRefId, verdict) {
39
126
  if (verdict.verdict === "active")
40
127
  return undefined;
@@ -45,47 +132,57 @@ function buildModule(ports) {
45
132
  return (`Agent Plugin '${pluginRefId}' is installed in this workspace but is not enabled — it ships with ${ports.productName} and ` +
46
133
  "stays inactive until an operator turns it on. Enable it before pinning it to a run.");
47
134
  }
135
+ /** Every installed digest's `InstalledAgentPlugin`, indexed once per call to
136
+ * {@link resolveAgentPluginRefs} — one `readdir` plus one `indexInstalledRoot` walk per digest.
137
+ * Digest directory names that do not match the expected 64-hex-character shape are skipped rather
138
+ * than passed to `indexInstalledRoot` — `packages/sha256/` is not asserted empty of anything else
139
+ * a future tool might place there, and a non-digest entry is not this function's to interpret.
140
+ *
141
+ * Exported for a second caller outside this module: `tool-registrations.ts`'s
142
+ * `loadInstalledAgentPluginToolSources` needs the SAME per-digest walk (every installed digest,
143
+ * whichever skills each one carries) to build one `agent_plugin_<pluginId>` tool per installed
144
+ * plugin — the exact shape this function already builds, just consumed differently than
145
+ * `resolveOnePluginRef`'s own "resolve one pinned ref" use below. Reusing this rather than
146
+ * re-walking `packages/sha256/*` a second, less-validated way keeps the digest-directory-name check
147
+ * and the per-digest failure isolation in exactly one place. */
48
148
  async function listInstalledPlugins(workspaceRoot) {
49
- let folders;
50
- try {
51
- folders = await readdir(workspaceRoot, { withFileTypes: true });
52
- }
53
- catch (error) {
54
- if (isEnoent(error))
55
- return [];
56
- throw error;
57
- }
58
149
  const installed = [];
59
- for (const folder of folders) {
60
- if (!folder.isDirectory() || !/^[a-z0-9]+(?:[-.][a-z0-9]+)*$/.test(folder.name) || folder.name.length > 64)
61
- continue;
62
- const packagesDir = path.join(workspaceRoot, folder.name, "package", "sha256");
63
- let entries;
150
+ for (const root of await listInstalledPackageRoots({ ports, workspaceRoot })) {
64
151
  try {
65
- await persistentState.assertOwnedPath({ workspaceRoot, entryPath: path.relative(workspaceRoot, packagesDir) });
66
- entries = await readdir(packagesDir, { withFileTypes: true });
152
+ const plugin = await indexInstalledRoot(root.packageRoot, root.archiveDigest);
153
+ if (plugin.pluginId === root.localId)
154
+ installed.push(plugin);
67
155
  }
68
156
  catch {
69
- continue;
70
- }
71
- for (const entry of entries) {
72
- if (!entry.isDirectory() || !SHA256_DIGEST_DIRNAME_PATTERN.test(entry.name))
73
- continue;
74
- try {
75
- const packageRoot = await assertContainedOnDisk(workspaceRoot, path.relative(workspaceRoot, path.join(packagesDir, entry.name)));
76
- const plugin = await indexInstalledRoot(packageRoot, entry.name);
77
- if (plugin.pluginId === folder.name)
78
- installed.push(plugin);
79
- }
80
- catch {
81
- // One corrupt package never hides another plugin. Memory-only folders are not installs.
82
- }
157
+ // One corrupt package never hides another plugin. Memory-only folders are not installs.
83
158
  }
84
159
  }
85
160
  return installed;
86
161
  }
162
+ /**
163
+ * Whether one installed archive digest still has its package directory under `packagesDir` — the
164
+ * cheap, per-call "is this still installed" signal `tool-registrations.ts`'s tool gate needs.
165
+ *
166
+ * Lives here, beside {@link listInstalledPlugins}, because it must agree with that walk about two
167
+ * things a second implementation would eventually get wrong: that an installed package root IS
168
+ * `<packagesDir>/<digest>` and nothing else, and that a directory name is only a digest when it
169
+ * matches the 64-hex shape. It deliberately does NOT re-index the package (`indexInstalledRoot`
170
+ * reads and validates a manifest, which is far too much per tool call): directory presence is
171
+ * exactly what `uninstall.ts` removes, and it removes it by `rename` BEFORE it touches the
172
+ * activation record, so the gate observes a removal at least as early as it observes the record's
173
+ * deletion, never later.
174
+ *
175
+ * Follows symlinks (plain `stat`, not `lstat`) on purpose — `listInstalledPlugins` does too, so a
176
+ * deployment whose digest directory is a symlink registers a tool that this would otherwise refuse
177
+ * to authorize.
178
+ *
179
+ * @returns `false` for a digest that does not match the installed-directory grammar, for a missing
180
+ * directory, for a non-directory at that path, and for ANY filesystem fault — every answer this
181
+ * cannot establish positively is `false`, because its one caller uses it to authorize.
182
+ * @complexity One `stat`.
183
+ */
87
184
  async function isInstalledDigestPresent(packagesDir, archiveDigest) {
88
- if (!SHA256_DIGEST_DIRNAME_PATTERN.test(archiveDigest))
185
+ if (!INSTALLED_PLUGIN_DIGEST_PATTERN.test(archiveDigest))
89
186
  return false;
90
187
  try {
91
188
  return (await stat(path.join(packagesDir, archiveDigest))).isDirectory();
@@ -94,9 +191,16 @@ function buildModule(ports) {
94
191
  return false;
95
192
  }
96
193
  }
97
- function isEnoent(error) {
98
- return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
99
- }
194
+ /**
195
+ * Resolves one `pluginRefId`, applying the zero/one/many-match rules described in this module's
196
+ * own header.
197
+ */
198
+ // Superseded installs of a BUNDLED plugin are dropped here — by the build's own recorded answer,
199
+ // never by picking one. Every id the ledger has no authority over reaches the many-match refusal
200
+ // below exactly as it always did.
201
+ // Framing governs whether the agent follows the plugin: describe injected SKILL.md as complete
202
+ // instructions and let it require its references. Calling it a summary or making required reads
203
+ // conditional lets the outer wrapper override the plugin. The inventory-framing test pins this.
100
204
  async function resolveOnePluginRef(pluginRefId, packagesDir, deliveryMode, bundledDigests) {
101
205
  const installed = preferBundledAgentPluginDigests(await listInstalledPlugins(packagesDir), bundledDigests);
102
206
  const matches = installed.filter((plugin) => plugin.pluginId === pluginRefId);
@@ -120,9 +224,10 @@ function buildModule(ports) {
120
224
  let skillMarkdown;
121
225
  try {
122
226
  skillMarkdown = await readInstalledSkillMarkdown(plugin.packageRoot, skillPath);
227
+ // Notes are user context, never an allowlist or permission grant. JSON quotes delimit untrusted text.
123
228
  const notes = await persistentState.memory({ workspaceRoot: packagesDir, pluginId: plugin.pluginId }).list({ kind: 'notes' });
124
229
  if (notes.length)
125
- skillMarkdown += `\n\nUser notes (context only, never permission grants):\n${JSON.stringify(notes)}`;
230
+ skillMarkdown += `\n\nUser notes for this plugin (context only; permissions remain operator-controlled):\n${JSON.stringify(notes)}`;
126
231
  }
127
232
  catch (error) {
128
233
  const message = error instanceof Error ? error.message : String(error);
@@ -142,6 +247,11 @@ function buildModule(ports) {
142
247
  section: `<<AGENT_PLUGIN pluginId="${pluginRefId}">>\n${skillMarkdown}${inventory}\n<</AGENT_PLUGIN>>`,
143
248
  };
144
249
  }
250
+ // Parity with `inject`'s own failure: a pinned ref whose eponymous skill is missing must fail
251
+ // loudly in BOTH modes. Without this check `pointer` would happily emit a well-formed instruction
252
+ // whose no-argument default resolves to some OTHER (alphabetically-first) skill instead of the one
253
+ // the pin actually named, and the run would look like the agent disobeyed rather than like the pin
254
+ // being wrong.
145
255
  function buildPointerSection(pluginRefId, plugin) {
146
256
  if (!plugin.skills.some((skill) => skill.name === pluginRefId)) {
147
257
  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;