@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
package/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.2
4
+
5
+ - Use shared plugin identity metadata with a required `@jini-ai/core` peer at `^0.4.2`. Repair identity-lock publication syntax and isolate unreadable or invalid installed manifests with warnings so one damaged install cannot block healthy installs or bundled seeding.
6
+
7
+ ### Patch Changes
8
+
9
+ - Tovu clean-up release (2026-10-08): code Tovu moved into Jini, plus the Jini clean-up (see the commit log since 0.4.1).
10
+
3
11
  ## 0.4.1 — 2026-10-04
4
12
 
5
13
  - Layout B per-plugin state with a build-free `@jini-ai/agent-plugins/persistent-state` entry; migration completes after quarantining rejected entries.
@@ -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,260 @@
1
+ /** Build-free Node entry: effects and policy belong to the host; no product namespaces here. */
2
+ import path from 'node:path';
3
+ import { randomUUID } from 'node:crypto';
4
+
5
+ const SAFE_ID = /^[a-z0-9]+(?:[-.][a-z0-9]+)*$/;
6
+ const DIGEST = /^[a-f0-9]{64}$/;
7
+ export const DEFAULT_PLUGIN_MEMORY_LIMITS = Object.freeze({ learned: 1024 * 1024, notes: 16 * 1024, files: 128 });
8
+ export function pluginStatePaths({ workspaceRoot, pluginId }, _optional = {}) {
9
+ if (!path.isAbsolute(workspaceRoot) || !SAFE_ID.test(pluginId) || pluginId.length > 64 || ['staging', 'packages', 'memory', 'data'].includes(pluginId)) throw new Error('Invalid plugin state root or id');
10
+ const root = path.join(workspaceRoot, pluginId);
11
+ return { root, packages: path.join(root, 'package', 'sha256'), data: path.join(root, 'data'),
12
+ learned: path.join(root, 'memory', 'learned'), notes: path.join(root, 'memory', 'notes') };
13
+ }
14
+ /** Containment plus ownership: a sibling plugin inside the workspace is still outside this one. */
15
+ export async function assertPluginStatePath({ filesystem: fs, contain, workspaceRoot, entryPath }, _optional = {}) {
16
+ const absolute = await contain({ root: workspaceRoot, entryPath });
17
+ const realWorkspace = await fs.realpath(workspaceRoot);
18
+ const relative = path.relative(realWorkspace, absolute);
19
+ let current = realWorkspace;
20
+ for (const segment of relative.split(path.sep)) {
21
+ current = path.join(current, segment);
22
+ try { if (await fs.realpath(current) !== current) throw new Error('Plugin state ancestors must not be symlinks'); }
23
+ catch (error) { if (code(error) === 'ENOENT') break; throw error; }
24
+ }
25
+ return absolute;
26
+ }
27
+ function code(error) { return error && typeof error === 'object' ? error.code : undefined; }
28
+
29
+ /** Lock files are workspace scratch so uninstall cannot delete a lock another process holds. */
30
+ export async function withPluginStateLock({ filesystem: fs, contain, workspaceRoot, pluginId, withLock, run }, _optional = {}) {
31
+ pluginStatePaths({ workspaceRoot, pluginId });
32
+ await fs.mkdir(workspaceRoot, { recursive: true, mode: 0o700 });
33
+ const lockPath = await assertPluginStatePath({ filesystem: fs, contain, workspaceRoot, entryPath: `staging/.plugin-state-${pluginId}.lock` });
34
+ return withLock({ lockPath, run });
35
+ }
36
+ /** Each instance is bound to one plugin. No read/write method accepts an id or arbitrary root. */
37
+ export function createPluginMemory({ filesystem: fs, contain, workspaceRoot, pluginId, withLock }, optional = {}) {
38
+ const paths = pluginStatePaths({ workspaceRoot, pluginId });
39
+ const limits = { ...DEFAULT_PLUGIN_MEMORY_LIMITS, ...optional.limits };
40
+ for (const kind of ['learned', 'notes', 'files']) {
41
+ if (!Number.isSafeInteger(limits[kind]) || limits[kind] <= 0 || limits[kind] > DEFAULT_PLUGIN_MEMORY_LIMITS[kind]) throw new Error('Memory caps must be positive and cannot exceed host ceilings');
42
+ }
43
+ function checkKind(kind) { if (kind !== 'learned' && kind !== 'notes') throw new Error('Invalid memory kind'); }
44
+ async function root(kind, create = false) {
45
+ checkKind(kind);
46
+ if (create) await fs.mkdir(workspaceRoot, { recursive: true, mode: 0o700 });
47
+ await assertPluginStatePath({ filesystem: fs, contain, workspaceRoot, entryPath: path.relative(workspaceRoot, paths[kind]) });
48
+ if (create) await fs.mkdir(paths[kind], { recursive: true, mode: 0o700 });
49
+ return paths[kind];
50
+ }
51
+ async function readText(filename, cap) {
52
+ // A bounded read protects against a concurrent file growing after stat. Invalid UTF-8 is refused.
53
+ const handle = await fs.open(filename, 'r');
54
+ try {
55
+ if (!(await handle.stat()).isFile()) throw new Error('Memory must contain regular UTF-8 files');
56
+ const bytes = Buffer.alloc(cap + 1);
57
+ let size = 0;
58
+ while (size < bytes.length) {
59
+ const result = await handle.read(bytes, size, bytes.length - size, size);
60
+ if (result.bytesRead === 0) break;
61
+ size += result.bytesRead;
62
+ }
63
+ if (size > cap) throw new Error('Memory byte cap exceeded');
64
+ const text = new TextDecoder('utf-8', { fatal: true }).decode(bytes.subarray(0, size));
65
+ if (text.includes('\0')) throw new Error('Memory must be UTF-8 text');
66
+ return text;
67
+ } finally { await handle.close(); }
68
+ }
69
+ async function list({ kind }, _optional = {}) {
70
+ const base = await root(kind).catch(error => { if (code(error) === 'ENOENT') return undefined; throw error; });
71
+ if (!base) return [];
72
+ const files = [];
73
+ let total = 0, entriesSeen = 0;
74
+ async function walk(prefix) {
75
+ let entries;
76
+ try { entries = await fs.readdir(path.join(base, prefix), { withFileTypes: true }); }
77
+ catch (error) { if (code(error) === 'ENOENT' && prefix === '') return; throw error; }
78
+ for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
79
+ if (++entriesSeen > limits.files * 4) throw new Error('Memory entry cap exceeded');
80
+ const relativePath = prefix ? `${prefix}/${entry.name}` : entry.name;
81
+ const filename = await contain({ root: base, entryPath: relativePath });
82
+ if (entry.isDirectory()) await walk(relativePath);
83
+ else if (entry.isFile()) {
84
+ if (files.length >= limits.files) throw new Error('Memory file cap exceeded');
85
+ const text = await readText(filename, limits[kind] - total);
86
+ total += Buffer.byteLength(text, 'utf8');
87
+ files.push({ relativePath, text });
88
+ } else throw new Error('Memory symlinks and special files are refused');
89
+ }
90
+ }
91
+ await walk('');
92
+ return files;
93
+ }
94
+ async function read({ kind, entryPath }, _optional = {}) {
95
+ const base = await root(kind);
96
+ return readText(await contain({ root: base, entryPath }), limits[kind]);
97
+ }
98
+ async function write({ kind, entryPath, text }, _optional = {}) {
99
+ checkKind(kind);
100
+ if (typeof text !== 'string' || text.includes('\0') || Buffer.from(text, 'utf8').toString('utf8') !== text) throw new Error('Memory must be valid UTF-8 text');
101
+ if (Buffer.byteLength(text, 'utf8') > limits[kind]) throw new Error('Memory byte cap exceeded');
102
+ // The same lock protects total-size accounting for all processes using this plugin.
103
+ return withPluginStateLock({ filesystem: fs, contain, workspaceRoot, pluginId, withLock, run: async () => {
104
+ const base = await root(kind, true);
105
+ const destination = await contain({ root: base, entryPath });
106
+ const normalized = path.relative(base, destination).split(path.sep).join('/');
107
+ const files = await list({ kind });
108
+ const existing = files.find(file => file.relativePath === normalized);
109
+ const total = files.reduce((sum, file) => sum + Buffer.byteLength(file.text, 'utf8'), 0);
110
+ if ((!existing && files.length >= limits.files) || total - Buffer.byteLength(existing?.text ?? '', 'utf8') + Buffer.byteLength(text, 'utf8') > limits[kind]) throw new Error('Memory cap exceeded');
111
+ await fs.mkdir(path.dirname(destination), { recursive: true, mode: 0o700 });
112
+ await contain({ root: base, entryPath });
113
+ const temporary = path.join(path.dirname(destination), `.memory-${randomUUID()}.tmp`);
114
+ const handle = await fs.open(temporary, 'wx', 0o600);
115
+ try {
116
+ try { await handle.writeFile(text, 'utf8'); await handle.sync(); } finally { await handle.close(); }
117
+ await fs.rename(temporary, destination);
118
+ } finally { await fs.rm(temporary, { force: true }); }
119
+ return { relativePath: normalized, bytes: Buffer.byteLength(text, 'utf8') };
120
+ } });
121
+ }
122
+ // The plugin-facing capability has no notes writer, even if an input tries to supply kind/id.
123
+ return { list, read, writeNote: ({ entryPath, text }, optional = {}) => write({ kind: 'notes', entryPath, text }, optional),
124
+ learned: { read: ({ entryPath }, optional = {}) => read({ kind: 'learned', entryPath }, optional),
125
+ write: ({ entryPath, text }, optional = {}) => write({ kind: 'learned', entryPath, text }, optional) } };
126
+ }
127
+
128
+ /** Interrupted moves are resumable; refused legacy entries are kept in quarantine and reported. */
129
+ export async function migratePluginLayout({ filesystem: fs, contain, workspaceRoot, parsePluginId, withLock, onEvent }, _optional = {}) {
130
+ await fs.mkdir(workspaceRoot, { recursive: true, mode: 0o700 });
131
+ // Containment returns canonical paths; relative moves must use the same root (e.g. /var -> /private/var).
132
+ workspaceRoot = await fs.realpath(workspaceRoot);
133
+ const marker = path.join(workspaceRoot, '.agent-plugin-layout-migrated');
134
+ const emit = (event, message) => onEvent?.({ event, message });
135
+ return withLock({ lockPath: `${marker}.lock`, run: async () => {
136
+ await assertPluginStatePath({ filesystem: fs, contain, workspaceRoot, entryPath: path.basename(marker) });
137
+ try { if (JSON.parse(await fs.readFile(marker, 'utf8')).layoutVersion === 2) return { complete: true, moved: 0 }; }
138
+ catch (error) { if (code(error) !== 'ENOENT') emit('migration-marker-invalid', String(error)); }
139
+ let complete = true, moved = 0;
140
+ let quarantineRoot;
141
+ async function entries(directory) {
142
+ try { return await fs.readdir(directory, { withFileTypes: true }); }
143
+ catch (error) { if (code(error) === 'ENOENT') return []; throw error; }
144
+ }
145
+ async function renameEntry({ source, destination, frozen }, _optional = {}) {
146
+ await fs.mkdir(path.dirname(destination), { recursive: true, mode: 0o700 });
147
+ const mode = frozen ? (await fs.stat(source)).mode & 0o777 : undefined;
148
+ if (frozen) await fs.chmod(source, mode | 0o700);
149
+ try { await fs.rename(source, destination); }
150
+ catch (error) { if (frozen) await fs.chmod(source, mode).catch(() => undefined); throw error; }
151
+ if (frozen) await fs.chmod(destination, mode);
152
+ }
153
+ async function quarantine({ source, reason, frozen = false }, _optional = {}) {
154
+ // Validate ancestors, then rename the entry itself: even an unsafe symlink is preserved,
155
+ // never followed into another plugin or outside the workspace.
156
+ await assertPluginStatePath({ filesystem: fs, contain, workspaceRoot, entryPath: path.relative(workspaceRoot, path.dirname(source)) });
157
+ if (!quarantineRoot) {
158
+ const base = await assertPluginStatePath({ filesystem: fs, contain, workspaceRoot, entryPath: 'staging/legacy-quarantine' });
159
+ await fs.mkdir(base, { recursive: true, mode: 0o700 });
160
+ const candidate = path.join(base, `${new Date().toISOString().replaceAll(':', '-')}-${randomUUID()}`);
161
+ // Exclusive creation prevents a retry or another boot from overwriting saved bytes.
162
+ await fs.mkdir(candidate, { mode: 0o700 });
163
+ quarantineRoot = candidate;
164
+ }
165
+ const destinationParent = await assertPluginStatePath({ filesystem: fs, contain, workspaceRoot,
166
+ entryPath: path.relative(workspaceRoot, path.join(quarantineRoot, path.relative(workspaceRoot, path.dirname(source)))) });
167
+ // Keep the native directory-entry name verbatim, even if it is not a valid portable package
168
+ // path (e.g. a literal backslash on POSIX). The fresh quarantine tree has no existing leaf.
169
+ const destination = path.join(destinationParent, path.basename(source));
170
+ await renameEntry({ source, destination, frozen });
171
+ emit('migration-quarantined', `${source} -> ${destination}: ${reason}`);
172
+ }
173
+ async function move(source, destination, frozen = false) {
174
+ await contain({ root: workspaceRoot, entryPath: path.relative(workspaceRoot, source) });
175
+ await assertPluginStatePath({ filesystem: fs, contain, workspaceRoot, entryPath: path.relative(workspaceRoot, destination) });
176
+ let exists = false;
177
+ try { await fs.stat(destination); exists = true; }
178
+ catch (error) { if (code(error) !== 'ENOENT') throw error; }
179
+ if (exists) {
180
+ // The Layout B copy wins. This runs under the plugin lock, so no in-flight move is discarded.
181
+ await quarantine({ source, frozen, reason: 'Migration destination already exists; Layout B copy kept' });
182
+ return;
183
+ }
184
+ await renameEntry({ source, destination, frozen });
185
+ moved++;
186
+ emit('migration-moved', `${source} -> ${destination}`);
187
+ }
188
+ const oldPackages = path.join(workspaceRoot, 'packages', 'sha256');
189
+ for (const entry of await entries(oldPackages)) {
190
+ const source = path.join(oldPackages, entry.name);
191
+ try {
192
+ if (!DIGEST.test(entry.name) || !entry.isDirectory()) {
193
+ await quarantine({ source, frozen: entry.isDirectory(), reason: 'Unrecognized legacy package entry' });
194
+ continue;
195
+ }
196
+ let pluginId, paths;
197
+ try {
198
+ await contain({ root: workspaceRoot, entryPath: `packages/sha256/${entry.name}` });
199
+ const manifestPath = await contain({ root: source, entryPath: 'plugin.json' });
200
+ pluginId = parsePluginId({ value: JSON.parse(await fs.readFile(manifestPath, 'utf8')) });
201
+ paths = pluginStatePaths({ workspaceRoot, pluginId });
202
+ } catch (error) {
203
+ // Missing/invalid manifests are refused entries; filesystem failures remain resumable.
204
+ if (code(error) && !['ENOENT', 'EISDIR'].includes(code(error))) throw error;
205
+ await quarantine({ source, frozen: true, reason: String(error) });
206
+ continue;
207
+ }
208
+ await withPluginStateLock({ filesystem: fs, contain, workspaceRoot, pluginId, withLock, run: () => move(source, path.join(paths.packages, entry.name), true) });
209
+ } catch (error) { complete = false; emit('migration-failed', `${entry.name}: ${String(error)}`); }
210
+ }
211
+ // Layout A memory and PLUGIN_DATA may exist without an installed package; migrate them too.
212
+ for (const bucket of ['memory', 'data']) {
213
+ for (const entry of await entries(path.join(workspaceRoot, bucket))) {
214
+ const source = path.join(workspaceRoot, bucket, entry.name);
215
+ try {
216
+ if (!entry.isDirectory()) {
217
+ await quarantine({ source, reason: 'Legacy state is not a directory' });
218
+ continue;
219
+ }
220
+ let paths;
221
+ try { paths = pluginStatePaths({ workspaceRoot, pluginId: entry.name }); }
222
+ catch (error) { await quarantine({ source, reason: String(error) }); continue; }
223
+ await withPluginStateLock({ filesystem: fs, contain, workspaceRoot, pluginId: entry.name, withLock, run: () => move(source, bucket === 'data' ? paths.data : path.join(paths.root, 'memory')) });
224
+ } catch (error) { complete = false; emit('migration-failed', `${bucket}/${entry.name}: ${String(error)}`); }
225
+ }
226
+ }
227
+ for (const folder of await entries(workspaceRoot)) {
228
+ if (!folder.isDirectory() || !SAFE_ID.test(folder.name) || ['staging', 'packages', 'memory', 'data'].includes(folder.name)) continue;
229
+ try {
230
+ await withPluginStateLock({ filesystem: fs, contain, workspaceRoot, pluginId: folder.name, withLock, run: async () => {
231
+ const paths = pluginStatePaths({ workspaceRoot, pluginId: folder.name });
232
+ await assertPluginStatePath({ filesystem: fs, contain, workspaceRoot, entryPath: path.relative(workspaceRoot, paths.packages) });
233
+ const digests = (await entries(paths.packages)).filter(digest => digest.isDirectory() && DIGEST.test(digest.name));
234
+ for (const digest of digests) {
235
+ const packageRoot = await assertPluginStatePath({ filesystem: fs, contain, workspaceRoot, entryPath: path.relative(workspaceRoot, path.join(paths.packages, digest.name)) });
236
+ // Legacy install froze every package; a crash after rename can leave only its root writable.
237
+ await fs.chmod(packageRoot, 0o555);
238
+ }
239
+ // Wait until legacy memory/data moves have finished before creating destinations for them.
240
+ if (complete && digests.length) for (const directory of [paths.learned, paths.notes, paths.data]) {
241
+ await assertPluginStatePath({ filesystem: fs, contain, workspaceRoot, entryPath: path.relative(workspaceRoot, directory) });
242
+ await fs.mkdir(directory, { recursive: true, mode: 0o700 });
243
+ }
244
+ } });
245
+ } catch (error) { complete = false; emit('migration-failed', `${folder.name}: ${String(error)}`); }
246
+ }
247
+ if (complete) {
248
+ for (const directory of [oldPackages, path.dirname(oldPackages), path.join(workspaceRoot, 'memory'), path.join(workspaceRoot, 'data')]) {
249
+ try { await fs.rmdir(directory); } catch (error) { if (!['ENOENT', 'ENOTEMPTY', 'EEXIST'].includes(code(error))) throw error; }
250
+ }
251
+ const temporary = `${marker}.${randomUUID()}.tmp`;
252
+ const handle = await fs.open(temporary, 'wx', 0o600);
253
+ try { await handle.writeFile(JSON.stringify({ layoutVersion: 2 }), 'utf8'); await handle.sync(); }
254
+ finally { await handle.close(); }
255
+ try { await fs.rename(temporary, marker); } finally { await fs.rm(temporary, { force: true }); }
256
+ emit('migration-complete', workspaceRoot);
257
+ }
258
+ return { complete, moved };
259
+ } });
260
+ }
@@ -1,153 +1,37 @@
1
1
  /**
2
- * Historical lifecycle rationale, carried with the implementation during consumer adoption.
3
- * @file Per-workspace activation state for installed Agent Plugins — the record that makes
4
- * "bundled but inactive" a real, enforced condition rather than a note in a README.
2
+ * @file Per-workspace Agent Plugin activation, separate from the host-plugin runtime's table.
3
+ * Package presence is consent only for operator installs. The seeder records bundled packages
4
+ * disabled before they may be spent; no-overwrite preserves an operator's decision across boots.
5
+ * No activation entry means active for operator-installed packages so upgrades do not revoke
6
+ * consent already given. Removing a bundled record recreates its disabled seed on the next boot.
5
7
  *
6
- * ---------------------------------------------------------------------------
7
- * Why this exists at all
8
- * ---------------------------------------------------------------------------
9
- * Until now there was no such record anywhere: `resolve-agent-plugin-refs.ts`'s own header says so
10
- * ("There is no record anywhere — confirmed 2026-08-21, zero hits in `src/platform/db/` and `src/server/` —
11
- * of which installed digest is 'the' current install"), and a package's mere presence under
12
- * `packages/sha256/*` was the whole of its authorization to be discovered, registered as a tool,
13
- * and injected into a run's prompt.
8
+ * Discovery may collapse unreadable/malformed records to empty so one corrupt byte cannot hide
9
+ * every installed plugin. Spending a capability (tool invocation or prompt injection) instead uses
10
+ * resolveAgentPluginActivation: no file/missing entry is active, but an unreadable file or malformed
11
+ * target entry is undetermined and denied. The caller records/logs that denial so it is visible.
12
+ * Writers never rewrite what they could not read: read the strict RAW bag, refuse unreadable files
13
+ * and malformed target entries, and preserve every unrelated entry, including malformed ones.
14
+ * Normalizing before a rewrite could erase a disabled/undetermined decision and silently reactivate
15
+ * its plugin. A corrupt activation file makes seeding fail rather than install around the fault.
14
16
  *
15
- * That was fine while every package on disk got there because an operator ran an install command.
16
- * It stops being fine the moment the host itself pre-places a package (`seed-bundled.ts`), because then
17
- * presence-implies-consent silently converts a vendor decision into an operator one.
17
+ * Filesystem state stays under layout.ts's tenant-isolated workspace root. Discovery, registration
18
+ * and injection already receive that layout; adding a database handle would add a repository and
19
+ * cross-process freshness dependency. The other plugin system has a distinct format and lifecycle.
18
20
  *
19
- * ---------------------------------------------------------------------------
20
- * The default is "absent means active", and that is deliberate
21
- * ---------------------------------------------------------------------------
22
- * {@link isAgentPluginActive} treats a plugin with NO record as active. This looks backwards for a
23
- * feature whose point is inactivity, so the reasoning is stated plainly:
21
+ * Concurrency: serialize workspace writes in-process for FIFO order, then take the shared platform
22
+ * exclusive file lock before the strict read and hold it through publication. Exported functions
23
+ * must not re-enter a chain/lock callback: they would wait on authority they already hold.
24
+ * Private writeActivationDecision permits bundled-record writes without that reentrancy hazard.
25
+ * Unique temp files plus write/rename prevent partial reads; fsync before rename and best-effort
26
+ * directory fsync keep a power loss from publishing an empty activation file.
24
27
  *
25
- * - Every plugin already installed on every existing host instance got there by an operator
26
- * explicitly invoking the host installer. That IS the consent. Defaulting those to
27
- * inactive would silently break every current install on upgrade, to re-collect consent that was
28
- * already given.
29
- * - A bundled package has no such act behind it. So the seeder writes an explicit
30
- * `{ enabled: false, origin: "bundled" }` record AT SEED TIME, before anything can read it — and
31
- * because seeding is idempotent and re-runs on every boot, deleting `activations.json` re-creates
32
- * the disabled record rather than promoting the bundled plugin to active. The unsafe direction is
33
- * closed; the safe one (an operator's own install keeps working) is left open.
34
- *
35
- * The net rule, then: **presence implies consent only for packages an operator put there.** The
36
- * record exists to say "the host put this here, and nobody has said yes yet."
37
- *
38
- * ---------------------------------------------------------------------------
39
- * ...but "absent" only means active when the file itself was READ successfully
40
- * ---------------------------------------------------------------------------
41
- * {@link readAgentPluginActivations} collapses a missing file, an unreadable one, a malformed one,
42
- * and one with the wrong envelope into the same empty record — fail-OPEN, for the reason its own
43
- * doc gives. That is right for the two surfaces that only DISCOVER plugins (a corrupt byte must not
44
- * silently hide every plugin an operator installed), and wrong for the one surface that decides
45
- * whether a capability may be SPENT: there, "I could not read the operator's decisions" is not a
46
- * statement that everything is permitted.
47
- *
48
- * {@link resolveAgentPluginActivation} is that stricter reader, added 2026-09-16 for
49
- * `tool-registrations.ts`'s per-call tool gate. It separates the two things
50
- * {@link readAgentPluginActivations} folds together:
51
- *
52
- * - **no file, or a well-formed file with no entry for this plugin** — nothing was recorded, so the
53
- * "absent means active" rule above applies unchanged, and it answers `active`;
54
- * - **a file that exists but could not be read, parsed, or shape-checked, or an entry for THIS
55
- * plugin that fails normalization** — a decision may well have been recorded and cannot be read,
56
- * so it answers `undetermined`, and the gate denies.
57
- *
58
- * The header's own objection to fail-CLOSED ("no error surface to notice it by") is answered at that
59
- * call site and only there: a denied invocation is recorded as `denied` in the daemon's audit trail
60
- * and the gate logs the reason, so a corrupt file is loud rather than silent. Nothing about what
61
- * "active" MEANS differs between the two readers — only whether a fault is allowed to masquerade as
62
- * an answer.
63
- *
64
- * ---------------------------------------------------------------------------
65
- * Why a file and not a table
66
- * ---------------------------------------------------------------------------
67
- * All three places that must consult it — `capability-source.ts` (discovery),
68
- * `tool-registrations.ts` (tool registration), `resolve-agent-plugin-refs.ts` (run-start injection)
69
- * — receive an `AgentPluginWorkspaceLayout` or a `workspaceId`, and NONE of them has a database
70
- * handle. Threading one through would mean giving three pure-filesystem call paths a repo
71
- * dependency (and the agent daemon is a second OS process with its own handle over the same WAL —
72
- * `agent-daemon-server.ts:298-299` — so the two would also have to agree about freshness).
73
- *
74
- * Everything else about an installed Agent Plugin already lives on disk under the workspace's own
75
- * tenant-isolated root; its activation living beside it needs no new seam, no migration, and no
76
- * cross-process cache-coherence argument. `plugin_activations` (`db/schema.sqlite.ts`) is the SPEC-005
77
- * `.host-plugin` runtime's table for a different plugin system with a different lifecycle — reusing
78
- * it would conflate two unrelated things that merely share a word.
79
- *
80
- * ---------------------------------------------------------------------------
81
- * Writers never rewrite what they could not read (2026-09-16, t91 F1.1)
82
- * ---------------------------------------------------------------------------
83
- * Every writer below used to build its next state from {@link readAgentPluginActivations}'s LENIENT
84
- * view — the same "unreadable folds to empty" read that is correct for discovery — and then wrote
85
- * that view straight back out. Against a corrupt file, that laundered every operator decision inside
86
- * it into a fresh, well-formed file recording NOTHING: a disabled plugin's `enabled: false` record
87
- * simply vanished, and "absent means active" (this header, above) turned it active again. A second,
88
- * narrower arm of the same bug survived even a whole-file fix: the writers built their next state
89
- * from the NORMALIZED bag, which silently drops any single malformed entry — so toggling one
90
- * unrelated plugin was enough to erase a neighboring entry the gate was correctly treating as
91
- * `undetermined` (denied), re-admitting it as `active` the moment the rewrite landed.
92
- *
93
- * The rule now: every writer reads the RAW `plugins` bag with {@link readPluginsBagStrict} — which
94
- * throws {@link AgentPluginActivationsUnreadableError} on an unreadable file instead of returning an
95
- * empty one — and edits that raw bag directly, preserving byte-for-byte every entry it is not the
96
- * one changing (malformed or not). `absent` (no file yet) still starts empty, so a first boot seeds
97
- * exactly as before; only a file that EXISTS and cannot be read, parsed, or shape-checked refuses.
98
- *
99
- * The boot consequence is `seed-bundled.ts`'s to own (see that file's header): a corrupt file makes
100
- * the seeder install nothing and report every bundled plugin `failed`, rather than write around the
101
- * fault. Nothing here throws on the read `readAgentPluginActivations` itself still uses for
102
- * discovery — that reader, and its "absent/unreadable both fold to empty" contract, are unchanged.
103
- *
104
- * ---------------------------------------------------------------------------
105
- * Concurrency
106
- * ---------------------------------------------------------------------------
107
- * Writes are write-temp-then-`rename`, with a uniquely named temp file per write, so a reader never
108
- * observes a half-written file.
109
- *
110
- * Within ONE process every read-modify-write of a workspace's file is serialized
111
- * ({@link serializeActivationsWrite}). This used to be waved off as "a race nobody has, whose worst
112
- * case is a visibly wrong checkbox". Both halves were false (t91 review, 2026-09-16): the admin
113
- * Agent Plugins screen deliberately lets two rows toggle at once, and each row renders its own
114
- * PATCH response — so when two writes both read the same file and the second `rename` erased the
115
- * first one's `enabled: false`, the operator saw the plugin OFF while "absent means active" had
116
- * already turned it back ON. Two writes in the same millisecond also shared one temp path, and one
117
- * of them failed with `ENOENT`.
118
- *
119
- * ACROSS processes (t91, 2026-09-16, plan `agent-reports/2026-09-16-t91-plan-activations-lock.md`):
120
- * every writer below also takes an exclusive, cross-process lock (`exclusive-file-lock.ts`) on
121
- * `<root>/activations.json.lock` before its strict read, and holds it until the rename lands. The
122
- * in-process chain above stays in FRONT of that lock — it still gives same-process writers FIFO
123
- * order with no polling — and the lock is not reentrant, so the invariant is: **no exported function
124
- * in this file may be called from inside a chain turn or a lock callback.** Such a call would wait on
125
- * a lock (or a chain slot) it is already holding, and hang exactly like re-entering the chain would.
126
- * `recordBundledAgentPluginIfAbsent` keeps using the private `writeActivationDecision` rather than
127
- * calling `setAgentPluginActivation`, for this reason.
128
- *
129
- * A lock is judged stale — and broken, unblocking every waiter — if its holder is a dead process on
130
- * this same host, or if it is simply older than 10s; see `exclusive-file-lock.ts`'s own header for
131
- * the full protocol and every failure mode it closes. A writer that cannot take the lock within 15s,
132
- * or that loses it (a stalled holder judged stale by someone else) right before its own rename,
133
- * throws {@link AgentPluginActivationsBusyError} and writes NOTHING — never a partial or stale write.
134
- *
135
- * Residuals this does NOT close, stated rather than hidden:
136
- * - R6: the boot seeder still installs a package before recording it inactive; a crash between the
137
- * two leaves an unrecorded (and therefore active) bundled package. Pre-flighting the lock clears
138
- * any stale lock before anything is installed, which narrows but does not remove this window.
139
- * - The tiny gap between a holder's own `assertHeld()` and its `rename` — closed to "never a stale
140
- * write survives it", not to "zero probability another process ever judged this lock stale in that
141
- * instant".
142
- * - A network filesystem, or a lock left by a crash that is later swept into site source control by
143
- * `duplicate-site.ts`'s `cpSync` (this repo does not touch `sites/**` here to fix that).
144
- * - A killed writer can leave a stray `activations.json.tmp-*` file; nothing currently sweeps those.
145
- *
146
- * Architectural role:
147
- * Filesystem state for one workspace, reached only through `layout.ts`'s `forWorkspace()` root — the
148
- * same tenant-isolation guarantee every other path in this feature goes through.
28
+ * Residual limits: installing before the inactive record is committed leaves a crash window; lock
29
+ * preflight narrows it but cannot remove it. The assertHeld/rename gap is not an atomic ownership
30
+ * check. Network filesystems and copied lock files need host handling; killed writers can leave
31
+ * unswept activations.json.tmp-* files. See the lock adapter below for stale/timeout budgets and
32
+ * authority checks.
149
33
  */
150
- /** Extracted from the host plugin lifecycle. Effects are supplied per host context.
34
+ /** Effects are supplied per host context.
151
35
  * Kernel exclusive create chooses one writer. The shared platform lock binds ownership to
152
36
  * a unique token and inode/device, verifies unchanged bytes/mtime before stale removal, and
153
37
  * never treats a permission failure as proof of a dead pid. No heartbeat extends the explicit
@@ -155,7 +39,8 @@
155
39
  * before publication. The shared fixed polling replaces local jitter; activation keeps its
156
40
  * 15-second timeout, timed on the clock port's monotonic reading so a wall-clock jump can neither
157
41
  * end nor stretch the wait (stale age stays on wall time, matching lock-file mtimes), and
158
- * translates shared errors into busy.
42
+ * translates shared errors into busy. A stale/dead holder can lose authority; a writer that cannot
43
+ * acquire within 15 seconds or loses the lock before rename reports busy and publishes nothing.
159
44
  */
160
45
  import path from "node:path";
161
46
  import { FileLockLostError, FileLockTimeoutError } from "@jini-ai/platform/fs/file-lock";
@@ -271,7 +156,7 @@ function buildModule(ports) {
271
156
  * @throws Nothing for a missing, unreadable, malformed, or wrong-version file: all of those return
272
157
  * an empty record. That is fail-OPEN, and it is the correct direction here specifically because this
273
158
  * reader is used for DISCOVERY only — nothing that SPENDS a capability or WRITES the file uses it
274
- * any more (t91 F1.1, 2026-09-16): the per-call tool gate and run-start injection both use the
159
+ * — the per-call tool gate and run-start injection both use the
275
160
  * fail-CLOSED {@link resolveAgentPluginActivation}, and every writer below reads the strict raw bag
276
161
  * and refuses outright on an unreadable file (see this file's header, "Writers never rewrite what
277
162
  * they could not read").
@@ -565,24 +450,11 @@ function buildModule(ports) {
565
450
  return normalizePluginsBag(plugins);
566
451
  }
567
452
  /**
568
- * N1 (reviewer finding, 2026-09-16, `agent-reports/2026-09-16-t91-review-plugin-part2.md`): refuses
569
- * a write that would derive `origin` from a MALFORMED entry for the plugin actually being toggled,
570
- * rather than silently "fixing" it as `operator-installed`.
571
- *
572
- * Without this, toggling a plugin whose OWN entry is present but malformed (e.g. its raw value is
573
- * the bare string `"bundled"`, not `{ origin: "bundled", ... }`) rewrote that entry as a well-formed
574
- * `operator-installed` record via {@link isRecordedAsBundled}'s `false` fallback — which both defeats
575
- * `uninstall.ts`'s bundled-refusal for malformed entries (`583d2f5e`) and re-seeds the plugin as
576
- * bundled-and-inactive on the next boot, since {@link recordBundledAgentPluginIfAbsent}'s own
577
- * `Object.hasOwn` no-overwrite check can no longer tell "an operator's real decision" apart from "a
578
- * garbled record this write invented". Consistent with this file's "undetermined means refuse" rule
579
- * (see the header and {@link resolveAgentPluginActivation}): an existing entry that cannot be read is
580
- * left exactly as it is, never repaired by a guess. `recordBundledAgentPluginIfAbsent` never reaches
581
- * this — it only calls {@link writeActivationDecision} when `Object.hasOwn(current, pluginId)` is
582
- * already `false`.
583
- *
584
- * @throws {AgentPluginActivationsUnreadableError} `pluginId`'s own entry exists but does not
585
- * normalize.
453
+ * Refuse a malformed target entry rather than guessing operator-installed origin. Guessing would
454
+ * defeat bundled uninstall refusal and make seeding confuse a fabricated record with consent.
455
+ * An undetermined entry stays intact. recordBundledAgentPluginIfAbsent reaches this writer only
456
+ * when the target key is genuinely absent, so it does not repair a malformed decision either.
457
+ * @throws {AgentPluginActivationsUnreadableError} The target entry exists but cannot normalize.
586
458
  * @complexity O(1).
587
459
  */
588
460
  function assertTargetEntryReadable(workspaceRoot, current, pluginId) {
@@ -676,7 +548,7 @@ function buildModule(ports) {
676
548
  *
677
549
  * Callers: `uninstall.ts`'s `uninstallAgentPlugin()`, on a successful uninstall (see that file's own
678
550
  * header for the full decision), and `retire-bundled.ts`, for a retired bundled plugin's leftover
679
- * record when no package of it is installed (2026-09-29).
551
+ * record when no package of it is installed.
680
552
  * Short version: a tombstone (`enabled: false` left behind after the bytes are gone) would invert
681
553
  * this file's own "absent means active, because an operator's own install IS the consent" rule
682
554
  * (this file's header, above) the moment the SAME plugin id is ever reinstalled — the fresh install
@@ -779,7 +651,7 @@ function buildModule(ports) {
779
651
  * read"), so this is the one place that wraps it back in the `{ schemaVersion: 1, plugins }`
780
652
  * envelope for serialization.
781
653
  *
782
- * Durability (R5, t91 2026-09-16): the temp file is fsync'd before the rename, and the workspace
654
+ * Durability: the temp file is fsync'd before the rename, and the workspace
783
655
  * directory is best-effort fsync'd after — see this file's header and `syncDirectoryBestEffort`'s
784
656
  * own doc for why a power loss must never leave a 0-byte `activations.json` behind.
785
657
  *
@@ -1,10 +1,16 @@
1
1
  import type { AgentPluginLifecyclePorts } from "./ports.js";
2
2
  export declare const BUNDLED_DIGESTS_FILENAME = "bundled-digests.json";
3
+ /** Plugin id -> the archive digest this build seeded for it. Empty whenever nothing is recorded, or
4
+ * whenever the record cannot be read — see this file's header, consequence 3. */
3
5
  export type BundledAgentPluginDigests = ReadonlyMap<string, string>;
6
+ /** One plugin's seed, as {@link recordBundledAgentPluginDigests} takes it — the pair `seed-
7
+ * bundled.ts` already holds after a successful install. */
4
8
  export interface SeededBundledAgentPluginDigest {
5
9
  readonly pluginId: string;
6
10
  readonly archiveDigest: string;
7
11
  }
12
+ /** The narrow slice of an `InstalledAgentPlugin` {@link preferBundledAgentPluginDigests} reads, so
13
+ * callers holding richer shapes keep their own types through it. */
8
14
  export interface InstalledDigestIdentity {
9
15
  readonly pluginId: string;
10
16
  readonly archiveDigest: string;