@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,17 +1,135 @@
1
+ /**
2
+ * @file `uninstallAgentPlugin()` — permanently removes one workspace's installed Agent Plugin: its
3
+ * on-disk package(s) under `packages/sha256/*` for this `pluginId`, then its activation record.
4
+ * `previewAgentPluginUninstall()` runs the same refusals without removing anything.
5
+ *
6
+ * `features/plugin-runtime/uninstall.ts` already has this exact feature for the OTHER plugin family
7
+ * (the host runtime-plugin format site/runtime plugins) — this is "the same feature, one family over", mirroring its
8
+ * shape: the same `<Domain>NotFoundError`/`<Domain>NotUninstallableError` pair, thrown for the same
9
+ * two reasons (unknown id; nothing an operator can actually remove). Two deliberate divergences from
10
+ * that file, both because the underlying mechanics genuinely differ, not by oversight:
11
+ *
12
+ * 1. **No third `PluginEnabledError`-equivalent precondition.** Plugin-runtime refuses to uninstall a
13
+ * plugin that is enabled in ANY workspace, because its on-disk artifact is INSTANCE-WIDE — shared
14
+ * across every workspace via one `installDir` — so removing it while a DIFFERENT workspace still
15
+ * has it enabled would silently break that workspace's runtime. Agent Plugins have no such
16
+ * hazard: `layout.ts`'s own header (owner decision, tenant-grade isolation, 2026-08-12) states
17
+ * plainly that each workspace gets its own `ws/<workspaceId>/packages/sha256/` tree, and "Uninstall
18
+ * is `rm -rf ws/<workspaceId>/packages/sha256/<digest>/`, full stop" — there is no OTHER workspace
19
+ * for this operation to endanger. Whether the CALLING workspace has this plugin currently enabled
20
+ * is not gated on either: disabling first buys no safety here (unlike plugin-runtime, disabling
21
+ * would not protect anyone else's runtime), and gating on it would only add friction with no
22
+ * corresponding risk removed.
23
+ *
24
+ * 2. **Bundled is refused as UNINSTALLABLE, not as NOT-FOUND — and for a different reason than
25
+ * plugin-runtime's built-ins.** Plugin-runtime's `PluginNotUninstallableError` covers a `"built-in"`
26
+ * discovery record that was never written to `installDir` at all — there is nothing on disk to
27
+ * remove. A bundled Agent Plugin is NOT that case: `seed-bundled.ts` genuinely extracts it onto
28
+ * disk at boot, so it IS `listInstalledPlugins`-visible and this function's own "not found" check
29
+ * would pass. The refusal here is about PERMANENCE, not presence: `recordBundledAgentPluginIfAbsent`
30
+ * re-seeds both the package and its (absent-record-only) disabled activation entry on every boot
31
+ * (`activation.ts`'s header), so deleting the files now would appear to succeed and then silently
32
+ * reappear on the next restart — a worse outcome than refusing outright, because it would look like
33
+ * uninstall lied. `AgentPluginNotUninstallableError`'s message says this plainly and names the
34
+ * levers that DO work: `plugins_set_enabled` with family `agent-plugin` (the assistant's own tool
35
+ * since a1abe2bb, so the model can act on the refusal itself) or the Agent Plugins admin screen's
36
+ * switch. Either one turns a bundled plugin off, even though nothing can make the host stop shipping it.
37
+ *
38
+ * ---------------------------------------------------------------------------
39
+ * Preview, then uninstall — both run the refusals
40
+ * ---------------------------------------------------------------------------
41
+ * `agent_plugins_uninstall` asks a human before it removes anything. A refusal has to come back
42
+ * BEFORE that dialog, or a human is asked to confirm something that cannot happen, so the tool calls
43
+ * `previewAgentPluginUninstall()` first. The preview carries only what a dialog may show (id,
44
+ * versions, digests) — never `packageRoot`, which is a host path. After the answer the tool calls
45
+ * `uninstallAgentPlugin()`, which runs both refusals again against the disk as it is then: the
46
+ * dialog can stay open for minutes. The tool also passes the confirmed preview back in: if the
47
+ * installed digests for the id are no longer exactly the previewed ones (an archive added or removed
48
+ * meanwhile), `uninstallAgentPlugin()` removes nothing and throws `AgentPluginChangedSincePreviewError`,
49
+ * which the tool reports as a result.
50
+ *
51
+ * ---------------------------------------------------------------------------
52
+ * Tombstone vs. delete on the activation record — DELETE, settled
53
+ * ---------------------------------------------------------------------------
54
+ * See `activation.ts`'s `deleteAgentPluginActivation()` for the mechanism; the decision itself
55
+ * belongs here, next to the only call site that makes it. A tombstone (leaving `enabled: false` on
56
+ * disk after the bytes are gone) would invert `activation.ts`'s own stated rule — "absent means
57
+ * active, because an operator's own install IS the consent" — the moment this same plugin id is ever
58
+ * reinstalled: the fresh install would inherit a stale disabled flag about bytes that no longer exist,
59
+ * which is a worse UX than starting over. Deleting the record makes a future reinstall of this id
60
+ * begin exactly where a first-ever install begins.
61
+ *
62
+ * ---------------------------------------------------------------------------
63
+ * Why this module resolves its own workspace layout, rather than accepting one
64
+ * ---------------------------------------------------------------------------
65
+ * Same SECURITY discipline `install.ts`'s `InstallAgentPluginRequired.layout` documents in full: this
66
+ * takes the INSTANCE-level `AgentPluginLayout` plus one `workspaceId`, and calls
67
+ * `layout.forWorkspace(workspaceId)` itself, exactly once per call, so the packages directory it scans
68
+ * and the activation file it edits can never come from two different, hand-stitched sources that
69
+ * quietly disagree about which workspace they belong to.
70
+ *
71
+ * ---------------------------------------------------------------------------
72
+ * Why removal isn't a plain `fs.rm(recursive)`
73
+ * ---------------------------------------------------------------------------
74
+ * `install.ts`'s `freezeTree` chmods every published package directory to `0o555` (no write bit) —
75
+ * that's what makes the package genuinely read-only after install. A plain recursive `rm` on a frozen
76
+ * tree fails EACCES trying to unlink/rmdir inside it (the same fact `__tests__/fixtures/force-remove.ts`
77
+ * exists to work around in tests). `removeFrozenPackageTree` below is that same "restore write
78
+ * permission, then remove" logic as real production code — this is the first production caller that
79
+ * ever needs to remove a package root that install-time freezing already made.
80
+ *
81
+ * ---------------------------------------------------------------------------
82
+ * Why removal stages first — and why it stages NEXT TO the package, not under `staging/`
83
+ * ---------------------------------------------------------------------------
84
+ * Removing a multi-digest plugin is several irreversible `rm`s followed by one activation-record
85
+ * write. Done directly, a failure anywhere after the first `rm` leaves an installation that is
86
+ * half-deleted and an activation record describing bytes that no longer exist — recoverable by
87
+ * nobody. So every package root is first `rename`d aside (cheap, atomic within one filesystem, and
88
+ * reversible), the activation record is deleted, and only then are the renamed trees actually
89
+ * removed. Any failure before that last step puts every renamed tree straight back.
90
+ *
91
+ * Renaming a frozen package root needs its write bit back FIRST. `install.ts`'s own publish comment
92
+ * records the measured fact this depends on: on this filesystem renaming an already-`0o555`
93
+ * directory fails EACCES — and, re-measured here, that holds even when the rename stays inside the
94
+ * same parent, so there is no permission-free way to move a frozen tree. `stageForRemoval` therefore
95
+ * restores the owner write bit on the package ROOT only (never its contents), renames, and — on any
96
+ * failure, including a rollback — puts the exact original mode back, so a failed uninstall leaves a
97
+ * tree that is still frozen. That briefly-writable window is the same "accidental same-process
98
+ * write" surface `freezeTree`'s own header scopes itself to, not a security boundary.
99
+ *
100
+ * The staged name is a SIBLING inside the same `packages/sha256/` directory rather than
101
+ * `<workspaceRoot>/staging/` (which install uses for its own transactions): same parent means the
102
+ * rename is guaranteed to be within one filesystem, and therefore atomic, without depending on a
103
+ * second directory existing. The names begin with a dot and carry a random suffix, so they can never
104
+ * be mistaken for an installed digest (`resolve-agent-plugin-refs.ts` matches `^[a-f0-9]{64}$`) nor
105
+ * collide with a concurrent uninstall. A crash between the rename and the final remove strands a
106
+ * `.uninstalling-*` directory an operator can delete — strictly better than a half-deleted install.
107
+ */
108
+ /** Prefix for a staged (renamed-aside) package root. Leading dot plus a random suffix: never a
109
+ * valid installed-digest directory name, never colliding with a concurrent uninstall. */
110
+ import { createPersistentStateModule } from "./persistent-state.js";
1
111
  import path from "node:path";
112
+ import { createPackagePathsModule } from "./package-paths.js";
2
113
  import { createActivationModule } from "./activation.js";
3
114
  import { createResolveAgentPluginRefsModule } from "./resolve-agent-plugin-refs.js";
115
+ /** The plugin id has no installed package on disk for this workspace. */
4
116
  export class AgentPluginNotFoundError extends Error {
5
117
  constructor({ message }, optional = {}) { super(message, optional); }
6
118
  }
119
+ /** The plugin is bundled with the host — see this file's header for why that is permanent, not merely
120
+ * "nothing to remove". */
7
121
  export class AgentPluginNotUninstallableError extends Error {
8
122
  constructor({ message }, optional = {}) { super(message, optional); }
9
123
  }
124
+ /** The installed archives for this plugin id are no longer exactly the ones a confirmed preview listed —
125
+ * see `UninstallAgentPluginOptional.confirmedPreview`. Nothing was removed. */
10
126
  export class AgentPluginChangedSincePreviewError extends Error {
11
127
  constructor({ message }, optional = {}) { super(message, optional); }
12
128
  }
13
129
  function buildModule(ports) {
14
130
  const randomUUID = () => ports.ids.newId();
131
+ const { assertContainedOnDisk } = createPackagePathsModule(ports);
132
+ const rmdir = ports.filesystem.rmdir.bind(ports.filesystem);
15
133
  const chmod = ports.filesystem.chmod.bind(ports.filesystem);
16
134
  const readdir = ports.filesystem.readdir.bind(ports.filesystem);
17
135
  const rename = ports.filesystem.rename.bind(ports.filesystem);
@@ -20,14 +138,56 @@ function buildModule(ports) {
20
138
  const { deleteAgentPluginActivation, isAgentPluginRecordedAsBundled, resolveAgentPluginActivation } = createActivationModule(ports);
21
139
  const { listInstalledPlugins } = createResolveAgentPluginRefsModule(ports);
22
140
  const STAGED_DIRNAME_PREFIX = ".uninstalling-";
141
+ /**
142
+ * Reports what uninstalling `pluginId` would remove, without removing anything.
143
+ *
144
+ * @throws {AgentPluginNotFoundError} No installed package for `pluginId` exists in this workspace.
145
+ * @throws {AgentPluginNotUninstallableError} The plugin's activation record has `origin: "bundled"`, or a malformed
146
+ * entry for it (t91 R4).
147
+ * @throws {AgentPluginActivationsUnreadableError} The activation record could not be read at all, so
148
+ * whether it says "bundled" cannot be established — see `activation.ts`'s
149
+ * `isAgentPluginRecordedAsBundled`.
150
+ * @complexity O(d) in this workspace's installed-digest count (one `listInstalledPlugins` walk).
151
+ */
23
152
  async function previewAgentPluginUninstall(required) {
24
153
  const { matches } = await resolveUninstallTargets(required);
25
154
  const versions = [...new Set(matches.flatMap((plugin) => (plugin.version === undefined ? [] : [plugin.version])))];
26
155
  return { pluginId: required.pluginId, versions, archiveDigests: matches.map((plugin) => plugin.archiveDigest) };
27
156
  }
157
+ /**
158
+ * Uninstalls one workspace's installed Agent Plugin: removes every installed archive digest for
159
+ * `pluginId` under this workspace's own `packages/sha256/`, then deletes its activation record.
160
+ *
161
+ * @throws {AgentPluginNotFoundError} No installed package for `pluginId` exists in this workspace.
162
+ * @throws {AgentPluginNotUninstallableError} The plugin's activation record has `origin: "bundled"`, or a malformed
163
+ * entry for it (t91 R4).
164
+ * @throws {AgentPluginActivationsUnreadableError} The activation record could not be read — refused
165
+ * before anything is staged, so nothing is removed.
166
+ * @throws {AgentPluginChangedSincePreviewError} `optional.confirmedPreview` was given and the installed digests differ from it.
167
+ * @throws Whatever the filesystem raised, after every staged tree has been put back — or, if a tree
168
+ * could not be put back, an error naming the staged paths an operator must recover by hand.
169
+ * @complexity O(d) in this workspace's installed-digest count (one `listInstalledPlugins` walk) plus
170
+ * O(f) in the total file count under the matching digest(s) being removed.
171
+ */
28
172
  async function uninstallAgentPlugin(required, optional = {}) {
173
+ const workspaceRoot = required.layout.forWorkspace({ workspaceId: required.workspaceId }).root;
174
+ return createPersistentStateModule(ports).withPluginLock({ workspaceRoot, pluginId: required.pluginId,
175
+ run: () => uninstallAgentPluginUnlocked(required, optional) });
176
+ }
177
+ // Reversible work first — see this file's header, "Why removal stages first". Nothing on this
178
+ // side of the try is destructive: every step up to and including the activation-record delete can
179
+ // be undone by renaming the staged trees back where they came from.
180
+ // Past this point the live state is already settled: the packages are no longer reachable under
181
+ // any installed-digest name and the activation record is gone. These removes are the only
182
+ // irreversible step, and nothing observable depends on them finishing.
183
+ // Remove only empty package ancestors: a concurrently-installed digest must never be deleted.
184
+ // No package may have appeared since this uninstall's preview. Preserve it on a race.
185
+ async function uninstallAgentPluginUnlocked(required, optional = {}) {
29
186
  const { workspaceRoot, packagesDir, matches } = await resolveUninstallTargets(required, optional.retiredBundled === true);
30
187
  assertUnchangedSincePreview(required.pluginId, optional.confirmedPreview, matches);
188
+ if (optional.deleteMemory && (!optional.confirmedPreview || optional.confirmedPreview.pluginId !== required.pluginId)) {
189
+ throw new Error('Deleting plugin memory requires a confirmed preview for this plugin');
190
+ }
31
191
  const staged = [];
32
192
  try {
33
193
  for (const plugin of matches) {
@@ -41,8 +201,32 @@ function buildModule(ports) {
41
201
  for (const tree of staged) {
42
202
  await removeFrozenPackageTree(tree.quarantined);
43
203
  }
204
+ for (const directory of [packagesDir, path.dirname(packagesDir)]) {
205
+ try {
206
+ await rmdir(directory);
207
+ }
208
+ catch (error) {
209
+ if (!error || typeof error !== 'object' || !('code' in error) || !['ENOENT', 'ENOTEMPTY', 'EEXIST'].includes(String(error.code)))
210
+ throw error;
211
+ }
212
+ }
213
+ if (optional.deleteMemory) {
214
+ const pluginRoot = required.layout.forWorkspace({ workspaceId: required.workspaceId }).pluginRootDir({ pluginId: required.pluginId });
215
+ await assertContainedOnDisk(workspaceRoot, path.relative(workspaceRoot, pluginRoot));
216
+ if ((await listInstalledPlugins(workspaceRoot)).some(plugin => plugin.pluginId === required.pluginId))
217
+ throw new Error('Plugin was reinstalled during uninstall; persistent memory was kept');
218
+ await rm(pluginRoot, { recursive: true, force: true });
219
+ }
44
220
  return { pluginId: required.pluginId, removedDigests: matches.map((plugin) => plugin.archiveDigest) };
45
221
  }
222
+ /**
223
+ * Refuses when the digests resolved now are not exactly the set a confirmed preview listed — an archive added
224
+ * OR removed for this id while the dialog was open. Runs in the same call that stages the removal, so it opens
225
+ * no new check-then-act gap of its own.
226
+ *
227
+ * @throws {AgentPluginChangedSincePreviewError} The two digest sets differ.
228
+ * @complexity O(d log d) in the digest count.
229
+ */
46
230
  function assertUnchangedSincePreview(pluginId, confirmedPreview, matches) {
47
231
  if (confirmedPreview === undefined)
48
232
  return;
@@ -53,6 +237,14 @@ function buildModule(ports) {
53
237
  throw new AgentPluginChangedSincePreviewError({ message: `Agent Plugin '${pluginId}' changed after its uninstall was previewed (previewed archive digests: ${previewed.join(", ")}; ` +
54
238
  `installed now: ${installedNow.join(", ")}) — nothing was removed` });
55
239
  }
240
+ /**
241
+ * Renames one package root aside, inside its own parent directory, so removing it becomes
242
+ * reversible. Unfreezes the root directory itself first — a frozen directory cannot be renamed at
243
+ * all (see this file's header) — and re-freezes it if the rename fails, so a package this function
244
+ * could not stage is left exactly as it found it.
245
+ *
246
+ * @complexity One `stat`, one or two `chmod`s, one `rename`.
247
+ */
56
248
  async function stageForRemoval(packagesDir, packageRoot) {
57
249
  const quarantined = path.join(packagesDir, `${STAGED_DIRNAME_PREFIX}${randomUUID()}`);
58
250
  const originalMode = (await stat(packageRoot)).mode & 0o777;
@@ -66,6 +258,13 @@ function buildModule(ports) {
66
258
  }
67
259
  return { quarantined, original: packageRoot, originalMode };
68
260
  }
261
+ /**
262
+ * Puts every staged tree back, newest first, and returns the error `uninstallAgentPlugin` should
263
+ * throw: `cause` when everything was restored, or — when some tree could not be — an error naming
264
+ * the staged paths, since those directories hold the only remaining copy of that package.
265
+ *
266
+ * @complexity O(s) renames in the staged-tree count.
267
+ */
69
268
  async function restoreStagedTrees(staged, cause) {
70
269
  const stranded = [];
71
270
  for (const tree of [...staged].reverse()) {
@@ -83,10 +282,27 @@ function buildModule(ports) {
83
282
  return new Error(`Agent Plugin uninstall failed (${reason}) and ${stranded.length} package tree(s) could not be put back. ` +
84
283
  `They still hold the package bytes and were left in place for recovery: ${stranded.join(", ")}`, { cause });
85
284
  }
285
+ /**
286
+ * Finds `pluginId`'s installed digests in this workspace and applies both refusals.
287
+ *
288
+ * @throws {AgentPluginNotFoundError} No match on disk.
289
+ * @throws {AgentPluginNotUninstallableError} The activation record says `origin: "bundled"`, or holds a malformed
290
+ * entry for this plugin.
291
+ * @throws {AgentPluginActivationsUnreadableError} The activation record exists but could not be
292
+ * read, so whether it says `origin: "bundled"` cannot be established.
293
+ * @complexity O(d) in the installed-digest count.
294
+ */
295
+ // The one call that turns (instance layout, workspaceId) into real, internally-consistent
296
+ // packages/root paths — see this file's header, "Why this module resolves its own workspace
297
+ // layout". `forWorkspace` itself still throws for a syntactically invalid `workspaceId`.
298
+ // The bundled read above answers `false` for a present-but-malformed entry (a non-object, a non-boolean
299
+ // `enabled`), which says nothing about provenance. The gate reads that entry as `undetermined` and denies; an
300
+ // uninstall does the same rather than remove what may be a bundled plugin the next boot re-seeds (t91 R4). A
301
+ // file that became unreadable between the two reads also lands here, and is refused the same way.
86
302
  async function resolveUninstallTargets(required, retiredBundled = false) {
87
303
  const { layout, workspaceId, pluginId } = required;
88
304
  const workspaceLayout = layout.forWorkspace({ workspaceId });
89
- const installed = await listInstalledPlugins(workspaceLayout.packages);
305
+ const installed = await listInstalledPlugins(workspaceLayout.root);
90
306
  const matches = installed.filter((plugin) => plugin.pluginId === pluginId);
91
307
  if (matches.length === 0) {
92
308
  throw new AgentPluginNotFoundError({ message: `Agent Plugin '${pluginId}' is not installed in this workspace — nothing to uninstall` });
@@ -97,19 +313,31 @@ function buildModule(ports) {
97
313
  if ((await resolveAgentPluginActivation(workspaceLayout.root, pluginId)).verdict === "undetermined") {
98
314
  throw new AgentPluginNotUninstallableError({ message: malformedEntryRefusalMessage(pluginId) });
99
315
  }
100
- return { workspaceRoot: workspaceLayout.root, packagesDir: workspaceLayout.packages, matches };
316
+ return { workspaceRoot: workspaceLayout.root, packagesDir: workspaceLayout.pluginPackagesDir({ pluginId }), matches };
101
317
  }
318
+ /** Names the levers that do work for a bundled plugin — see this file's header, item 2. */
102
319
  function bundledRefusalMessage(pluginId) {
103
320
  return (`Agent Plugin '${pluginId}' is bundled with ${ports.productName} and cannot be uninstalled — it is re-seeded on every boot ` +
104
321
  "(seed-bundled.ts), so removing its files now would silently reappear on the next restart. To stop it being " +
105
- "used, disable it through the host's activation control.");
322
+ (ports.formatBundledUninstallRecovery?.({ pluginId }, {}) ?? "used, disable it through the host's activation control."));
106
323
  }
324
+ /** Path-free: says why provenance is unknown and what repairs it. Does not suggest toggling the plugin, which would
325
+ * rewrite a garbled bundled entry as `operator-installed`. */
107
326
  function malformedEntryRefusalMessage(pluginId) {
108
327
  return (`Agent Plugin '${pluginId}' cannot be uninstalled: its entry in this workspace's activation record is malformed, so ` +
109
328
  `whether it is bundled with ${ports.productName} (and would be re-seeded on the next boot) cannot be established. Nothing was ` +
110
329
  "removed. Tell the user an operator has to repair that entry in activations.json first; until then this " +
111
330
  "plugin's tool calls and plugin-pinned runs are refused.");
112
331
  }
332
+ /**
333
+ * Restores write permission through `root`'s whole tree, then removes it — the production
334
+ * counterpart of `__tests__/fixtures/force-remove.ts` (that file's own header explains why a plain
335
+ * recursive `rm` cannot work here: `install.ts`'s `freezeTree` leaves every directory `0o555`).
336
+ * Deliberately not shared code with that test fixture: the fixture is intentionally test-only and
337
+ * this is the first production need for the same operation, not a refactor opportunity forced by it.
338
+ *
339
+ * @complexity O(f) in the file/directory count under `root`.
340
+ */
113
341
  async function removeFrozenPackageTree(root) {
114
342
  await makeTreeWritable(root);
115
343
  await rm(root, { recursive: true, force: true });
@@ -133,7 +361,7 @@ function buildModule(ports) {
133
361
  }
134
362
  }
135
363
  }
136
- return { previewAgentPluginUninstall, uninstallAgentPlugin };
364
+ return { previewAgentPluginUninstall, uninstallAgentPlugin, stageForRemoval, restoreStagedTrees, removeFrozenPackageTree };
137
365
  }
138
366
  const instances = new WeakMap();
139
367
  /** Internal composition; one state/queue instance per injected host context. */
@@ -1,5 +1,4 @@
1
1
  /**
2
- * Historical lifecycle rationale, carried with the implementation during consumer adoption.
3
2
  * @file The real `AgentPluginArchiveReaderPort` adapter, backed by `yauzl`.
4
3
  *
5
4
  * Library choice (owner-decided, recorded so it is not re-litigated): `yauzl` — streaming,
@@ -30,14 +30,15 @@ export function createYauzlAgentPluginArchiveReader({ yauzl }) {
30
30
  * module's header. `install.ts` rejects every symlink entry unconditionally.
31
31
  */
32
32
  const unixMode = readUnixMode(entry);
33
- if (entry.fileName.endsWith("/")) {
34
- yield { kind: "directory", entryPath: entry.fileName };
35
- continue;
36
- }
37
33
  if (unixMode !== null && (unixMode & S_IFMT) === S_IFLNK) {
38
34
  yield { kind: "symlink", entryPath: entry.fileName };
39
35
  continue;
40
36
  }
37
+ // File mode defines links even when a hostile filename masquerades as a directory.
38
+ if (entry.fileName.endsWith("/")) {
39
+ yield { kind: "directory", entryPath: entry.fileName };
40
+ continue;
41
+ }
41
42
  yield {
42
43
  kind: "file",
43
44
  entryPath: entry.fileName,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jini-ai/agent-plugins",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "description": "Support for Agent Plugins, the open, vendor-neutral spec (v1.0.0, published 2026-08-06, Technical Steering Committee with maintainers from Amazon, Cursor, Google, Microsoft, OpenAI, and Vercel) for bundling Agent Skills and MCP servers into one portable directory: PluginManifest/McpManifest types + structural validators on the package root, plus the bundled plugins themselves, each on a subpath named for itself (./ui-ux-design). Extracted 2026-08-18 from @jini-ai/plugins, which now only reserves the unrelated, unimplemented ./host format.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -18,7 +18,18 @@
18
18
  "./lifecycle": "node",
19
19
  "./lifecycle/node": "node",
20
20
  "./lifecycle/yauzl": "node",
21
- "./manifest": "universal"
21
+ "./manifest": "universal",
22
+ "./persistent-state": "node"
23
+ },
24
+ "isolation": {
25
+ "entries": {
26
+ "./manifest": "lifecycle",
27
+ "./persistent-state": "core"
28
+ },
29
+ "shared": [
30
+ "core/persistent-state.js",
31
+ "persistent-state.js"
32
+ ]
22
33
  }
23
34
  },
24
35
  "type": "module",
@@ -49,6 +60,11 @@
49
60
  "types": "./dist/lifecycle/manifest.d.ts",
50
61
  "import": "./dist/lifecycle/manifest.js",
51
62
  "default": "./dist/lifecycle/manifest.js"
63
+ },
64
+ "./persistent-state": {
65
+ "types": "./persistent-state.d.ts",
66
+ "import": "./persistent-state.js",
67
+ "default": "./persistent-state.js"
52
68
  }
53
69
  },
54
70
  "files": [
@@ -61,7 +77,10 @@
61
77
  "!dist/**/*.test.d.ts",
62
78
  "!dist/**/*.test.js.map",
63
79
  "!dist/**/*.test.d.ts.map",
64
- "!dist/**/__tests__/**"
80
+ "!dist/**/__tests__/**",
81
+ "persistent-state.js",
82
+ "persistent-state.d.ts",
83
+ "core"
65
84
  ],
66
85
  "publishConfig": {
67
86
  "access": "public",
@@ -69,20 +88,26 @@
69
88
  },
70
89
  "devDependencies": {
71
90
  "@vitest/coverage-v8": "^2.1.9",
72
- "yazl": "^3.3.1"
91
+ "yazl": "^3.3.1",
92
+ "@jini-ai/core": "^0.4.0",
93
+ "@jini-ai/platform": "^0.5.0"
73
94
  },
74
95
  "peerDependencies": {
75
- "yauzl": "^3.4.0"
96
+ "yauzl": "^3.4.0",
97
+ "@jini-ai/core": "^0.4.0",
98
+ "@jini-ai/platform": "^0.5.0"
76
99
  },
77
100
  "peerDependenciesMeta": {
78
101
  "yauzl": {
79
102
  "optional": true
103
+ },
104
+ "@jini-ai/core": {
105
+ "optional": true
106
+ },
107
+ "@jini-ai/platform": {
108
+ "optional": true
80
109
  }
81
110
  },
82
- "dependencies": {
83
- "@jini-ai/core": "^0.4.0",
84
- "@jini-ai/platform": "^0.5.0"
85
- },
86
111
  "scripts": {
87
112
  "build": "tsc -p tsconfig.json",
88
113
  "typecheck": "tsc -p tsconfig.json --noEmit",
@@ -0,0 +1,25 @@
1
+ export type PluginMemoryKind = 'learned' | 'notes';
2
+ export interface PluginStatePaths { root: string; packages: string; data: string; learned: string; notes: string; }
3
+ export const DEFAULT_PLUGIN_MEMORY_LIMITS: Readonly<{ learned: number; notes: number; files: number }>;
4
+ export interface PluginStateEffects {
5
+ filesystem: Pick<typeof import('node:fs/promises'), 'mkdir' | 'rmdir' | 'readdir' | 'open' | 'readFile' | 'rename' | 'rm' | 'stat' | 'chmod' | 'realpath'>;
6
+ contain(required: { root: string; entryPath: string }): Promise<string>;
7
+ withLock<T>(required: { lockPath: string; run(): Promise<T> }): Promise<T>;
8
+ }
9
+ export interface PluginMemoryFile { relativePath: string; text: string; }
10
+ export interface PluginMemory {
11
+ list(required: { kind: PluginMemoryKind }, optional?: {}): Promise<PluginMemoryFile[]>;
12
+ read(required: { kind: PluginMemoryKind; entryPath: string }, optional?: {}): Promise<string>;
13
+ writeNote(required: { entryPath: string; text: string }, optional?: {}): Promise<{ relativePath: string; bytes: number }>;
14
+ learned: {
15
+ read(required: { entryPath: string }, optional?: {}): Promise<string>;
16
+ write(required: { entryPath: string; text: string }, optional?: {}): Promise<{ relativePath: string; bytes: number }>;
17
+ };
18
+ }
19
+ export function pluginStatePaths(required: { workspaceRoot: string; pluginId: string }, optional?: {}): PluginStatePaths;
20
+ export function createPluginMemory(required: PluginStateEffects & { workspaceRoot: string; pluginId: string }, optional?: { limits?: Partial<typeof DEFAULT_PLUGIN_MEMORY_LIMITS> }): PluginMemory;
21
+ export function migratePluginLayout(required: PluginStateEffects & { workspaceRoot: string; parsePluginId(required: { value: unknown }): string; onEvent?(required: { event: string; message: string }): void }, optional?: {}): Promise<{ complete: boolean; moved: number }>;
22
+
23
+ export function assertPluginStatePath(required: Pick<PluginStateEffects, "filesystem" | "contain"> & { workspaceRoot: string; entryPath: string }, optional?: {}): Promise<string>;
24
+
25
+ export function withPluginStateLock<T>(required: PluginStateEffects & { workspaceRoot: string; pluginId: string; run(): Promise<T> }, optional?: {}): Promise<T>;
@@ -0,0 +1,2 @@
1
+ // Build-free compatibility entry; lifecycle shares the core implementation.
2
+ export * from './core/persistent-state.js';