@jini-ai/agent-plugins 0.4.5 → 0.4.7
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.
- package/CHANGELOG.md +20 -0
- package/README.md +2 -0
- package/dist/folder-zip-node.d.ts +11 -0
- package/dist/folder-zip-node.js +74 -0
- package/dist/folder-zip.d.ts +38 -0
- package/dist/folder-zip.js +132 -0
- package/dist/lifecycle/capability-projection.d.ts +7 -0
- package/dist/lifecycle/capability-projection.js +14 -1
- package/dist/lifecycle/index.d.ts +8 -0
- package/dist/lifecycle/index.js +2 -0
- package/dist/lifecycle/install-errors.d.ts +14 -0
- package/dist/lifecycle/install-errors.js +9 -0
- package/dist/lifecycle/install.d.ts +2 -13
- package/dist/lifecycle/install.js +5 -123
- package/dist/lifecycle/installed-package-index.d.ts +15 -0
- package/dist/lifecycle/installed-package-index.js +116 -0
- package/dist/lifecycle/manifest.d.ts +1 -0
- package/dist/lifecycle/manifest.js +1 -0
- package/dist/lifecycle/package-paths.d.ts +1 -1
- package/dist/lifecycle/resolve-agent-plugin-refs.d.ts +2 -2
- package/dist/lifecycle/resolve-agent-plugin-refs.js +21 -20
- package/dist/lifecycle/skill-file-reader.d.ts +23 -0
- package/dist/lifecycle/skill-file-reader.js +130 -0
- package/dist/manifest.d.ts +2 -1
- package/dist/manifest.js +2 -1
- package/package.json +14 -2
- package/ui-ux-design/plugin.json +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.4.7 — 2026-10-15 — folder uploads share one ZIP encoder
|
|
4
|
+
|
|
5
|
+
- New `./folder-zip` (universal): `zipFolderFiles()`, the binary-safe stored-mode folder encoder,
|
|
6
|
+
moved here from Tovu's admin so browser and server folder installs pack identically.
|
|
7
|
+
- New `./folder-zip/node`: `zipLocalFolder()` reads a local folder into that same encoder. Refuses
|
|
8
|
+
symlinks and special files, bounds entries (4096) and bytes before reading, and sorts paths so the
|
|
9
|
+
same folder always packs to the same digest.
|
|
10
|
+
- The bundled `ui-ux-design/plugin.json` declares its version (abb0009e).
|
|
11
|
+
|
|
12
|
+
## 0.4.6 — 2026-10-13 — skills' supporting files are readable
|
|
13
|
+
|
|
14
|
+
- New `createSkillFileReader()` and lifecycle `readInstalledSkillFile()`: read `SKILL.md` or a
|
|
15
|
+
supporting text file (`references/…`, `examples/…`, …) relative to one skill folder, with a sorted
|
|
16
|
+
`files` inventory. Refused: `..`, absolute or drive paths, symlinks resolving outside the skill
|
|
17
|
+
(including sibling skills), non-text extensions, binary or invalid UTF-8, files over 256 KiB.
|
|
18
|
+
Refusals return a note naming the valid files; no host path ever appears in output.
|
|
19
|
+
- Run-start injection lists supporting files relative to the skill folder instead of absolute host
|
|
20
|
+
paths, and points at the plugin tool's `file` argument. Activation-read refusals no longer echo
|
|
21
|
+
the native error (which could name a host path).
|
|
22
|
+
|
|
3
23
|
## 0.4.5 — 2026-10-12 — retirement waits for usable successors
|
|
4
24
|
|
|
5
25
|
- `retireBundledAgentPlugins()` (and so the boot seeder) keeps a retired bundled plugin until EVERY
|
package/README.md
CHANGED
|
@@ -106,6 +106,8 @@ Catalog format) that this package does not implement.
|
|
|
106
106
|
| `./lifecycle` | node | Host-injected installation, activation, locks, digests, bundles, trusted files, reference resolution, ranking and MCP provisioning helpers. |
|
|
107
107
|
| `./lifecycle/node` | node | `createNodeAgentPluginEffects({})` opt-in native effects. |
|
|
108
108
|
| `./lifecycle/yauzl` | node | `createYauzlAgentPluginArchiveReader({ yauzl })`; the host supplies the optional peer library. |
|
|
109
|
+
| `./folder-zip` | universal | `zipFolderFiles({ files, maxBytes })`: packs picked folder `File`s into one stored-mode, binary-safe `.zip` `File` (the admin "Upload a folder" encoder). |
|
|
110
|
+
| `./folder-zip/node` | node | `zipLocalFolder({ path, maxBytes })`: the same encoder over a local folder; refuses symlinks/special files, bounds entries and bytes. |
|
|
109
111
|
| `./ui-ux-design/*` | — | Raw files of the bundled `ui-ux-design` plugin — static JSON/Markdown, not run through the TS build. |
|
|
110
112
|
|
|
111
113
|
The universal root handles structural validation. The separate Node lifecycle entry implements
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** Reads a local folder into the browser upload encoder. Refuses links/special files and bounds
|
|
2
|
+
* entries and bytes before allocation; the resulting ZIP still requires the install validator.
|
|
3
|
+
* @throws {FolderZipError} Empty, unreadable, linked, or over-limit folders.
|
|
4
|
+
* @complexity O(e log e + b) time for e entries and b bytes; O(e + b) space,
|
|
5
|
+
* bounded to 4096 entries and maxBytes. No network I/O.
|
|
6
|
+
*/
|
|
7
|
+
export declare function zipLocalFolder(required: {
|
|
8
|
+
path: string;
|
|
9
|
+
maxBytes: number;
|
|
10
|
+
}, _optional?: Record<string, never>): Promise<Uint8Array>;
|
|
11
|
+
//# sourceMappingURL=folder-zip-node.d.ts.map
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import * as fs from "node:fs/promises";
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { FOLDER_MAX_FILES, FOLDER_UNREADABLE, FolderZipError, zipFolderFiles } from "./folder-zip.js";
|
|
5
|
+
/** Reads a local folder into the browser upload encoder. Refuses links/special files and bounds
|
|
6
|
+
* entries and bytes before allocation; the resulting ZIP still requires the install validator.
|
|
7
|
+
* @throws {FolderZipError} Empty, unreadable, linked, or over-limit folders.
|
|
8
|
+
* @complexity O(e log e + b) time for e entries and b bytes; O(e + b) space,
|
|
9
|
+
* bounded to 4096 entries and maxBytes. No network I/O.
|
|
10
|
+
*/
|
|
11
|
+
export async function zipLocalFolder(required, _optional = {}) {
|
|
12
|
+
const files = [];
|
|
13
|
+
let entries = 0;
|
|
14
|
+
let total = 0;
|
|
15
|
+
try {
|
|
16
|
+
const root = await fs.realpath(required.path);
|
|
17
|
+
const pending = [root];
|
|
18
|
+
while (pending.length) {
|
|
19
|
+
const directory = pending.pop();
|
|
20
|
+
if (!(await fs.lstat(directory)).isDirectory() || await fs.realpath(directory) !== directory)
|
|
21
|
+
throw new FolderZipError("unreadable", FOLDER_UNREADABLE);
|
|
22
|
+
const handle = await fs.opendir(directory);
|
|
23
|
+
for await (const entry of handle) {
|
|
24
|
+
if (++entries > FOLDER_MAX_FILES)
|
|
25
|
+
throw new FolderZipError("too-many", `More than ${FOLDER_MAX_FILES} entries.`);
|
|
26
|
+
const filename = path.join(directory, entry.name);
|
|
27
|
+
if (entry.isDirectory()) {
|
|
28
|
+
pending.push(filename);
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
if (!entry.isFile())
|
|
32
|
+
throw new FolderZipError("unreadable", FOLDER_UNREADABLE);
|
|
33
|
+
if (await fs.realpath(filename) !== filename)
|
|
34
|
+
throw new FolderZipError("unreadable", FOLDER_UNREADABLE);
|
|
35
|
+
// O_NOFOLLOW refuses a file replaced with a symlink between enumeration and opening.
|
|
36
|
+
const file = await fs.open(filename, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
37
|
+
try {
|
|
38
|
+
const info = await file.stat();
|
|
39
|
+
if (!info.isFile())
|
|
40
|
+
throw new FolderZipError("unreadable", FOLDER_UNREADABLE);
|
|
41
|
+
total += info.size;
|
|
42
|
+
if (total > required.maxBytes)
|
|
43
|
+
throw new FolderZipError("too-large", "Folder is over the upload limit.");
|
|
44
|
+
// One extra byte detects growth without an unbounded readFile allocation.
|
|
45
|
+
const bytes = Buffer.alloc(info.size + 1);
|
|
46
|
+
let length = 0;
|
|
47
|
+
while (length < bytes.length) {
|
|
48
|
+
const read = await file.read(bytes, length, bytes.length - length, null);
|
|
49
|
+
if (read.bytesRead === 0)
|
|
50
|
+
break;
|
|
51
|
+
length += read.bytesRead;
|
|
52
|
+
}
|
|
53
|
+
if (length !== info.size)
|
|
54
|
+
throw new FolderZipError("unreadable", FOLDER_UNREADABLE);
|
|
55
|
+
const picked = new File([new Uint8Array(bytes.subarray(0, length))], entry.name);
|
|
56
|
+
Object.defineProperty(picked, "webkitRelativePath", { value: `folder/${path.relative(root, filename).split(path.sep).join("/")}` });
|
|
57
|
+
files.push(picked);
|
|
58
|
+
}
|
|
59
|
+
finally {
|
|
60
|
+
await file.close();
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
// Stable order gives repeat local-folder installs identical ZIP digests.
|
|
65
|
+
files.sort((a, b) => a.webkitRelativePath < b.webkitRelativePath ? -1 : a.webkitRelativePath > b.webkitRelativePath ? 1 : 0);
|
|
66
|
+
return new Uint8Array(await (await zipFolderFiles({ files, maxBytes: required.maxBytes })).arrayBuffer());
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
if (error instanceof FolderZipError)
|
|
70
|
+
throw error;
|
|
71
|
+
throw new FolderZipError("unreadable", FOLDER_UNREADABLE);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
//# sourceMappingURL=folder-zip-node.js.map
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Packs a folder the operator picked (`<input type=file webkitdirectory>`) into one `.zip`
|
|
3
|
+
* `File`, so "Upload a folder" travels the exact path a `.zip` upload does: same size limit, same
|
|
4
|
+
* server-side archive checks, same Preview trust review. The install routes only take
|
|
5
|
+
* `application/zip`, so zipping here keeps one server path instead of adding a second upload format.
|
|
6
|
+
*
|
|
7
|
+
* Stored mode (no compression): the upload limit is on the archive, and a plugin folder is small
|
|
8
|
+
* text, so deflate would add a dependency for little gain. `@jini-ai/ui` has a stored-mode
|
|
9
|
+
* `buildZip`, but it is text-only (it would corrupt an image) and not exported, so this is the
|
|
10
|
+
* binary-safe twin, extracted to Jini so browser and local-folder uploads use the same encoder.
|
|
11
|
+
*/
|
|
12
|
+
/** Refusals, as dictionary keys the caller translates. */
|
|
13
|
+
export declare const FOLDER_EMPTY = "That folder is empty.";
|
|
14
|
+
export declare const FOLDER_UNREADABLE = "Could not read that folder. Try again.";
|
|
15
|
+
/** The archive readers' entry cap (`install-archive.ts`, the Agent Plugins reader): refused here
|
|
16
|
+
* before any bytes are read. */
|
|
17
|
+
export declare const FOLDER_MAX_FILES = 4096;
|
|
18
|
+
export declare class FolderZipError extends Error {
|
|
19
|
+
readonly reason: "empty" | "too-large" | "too-many" | "unreadable";
|
|
20
|
+
constructor(reason: "empty" | "too-large" | "too-many" | "unreadable", message: string);
|
|
21
|
+
}
|
|
22
|
+
/** @complexity O(n) in the byte count. */
|
|
23
|
+
export declare function crc32(bytes: Uint8Array): number;
|
|
24
|
+
/**
|
|
25
|
+
* Zips the files of one picked folder.
|
|
26
|
+
*
|
|
27
|
+
* @param required.files - Every file the folder picker returned.
|
|
28
|
+
* @param required.maxBytes - The upload limit; the folder's raw size alone over it is refused before
|
|
29
|
+
* reading, and the finished zip is checked again (headers add a little).
|
|
30
|
+
* @returns `<folder>.zip`, typed `application/zip`.
|
|
31
|
+
* @throws {FolderZipError} empty folder, too many files, too large, or a file that could not be read.
|
|
32
|
+
* @complexity O(total bytes).
|
|
33
|
+
*/
|
|
34
|
+
export declare function zipFolderFiles(required: {
|
|
35
|
+
files: readonly File[];
|
|
36
|
+
maxBytes: number;
|
|
37
|
+
}, _optional?: Record<string, never>): Promise<File>;
|
|
38
|
+
//# sourceMappingURL=folder-zip.d.ts.map
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Packs a folder the operator picked (`<input type=file webkitdirectory>`) into one `.zip`
|
|
3
|
+
* `File`, so "Upload a folder" travels the exact path a `.zip` upload does: same size limit, same
|
|
4
|
+
* server-side archive checks, same Preview trust review. The install routes only take
|
|
5
|
+
* `application/zip`, so zipping here keeps one server path instead of adding a second upload format.
|
|
6
|
+
*
|
|
7
|
+
* Stored mode (no compression): the upload limit is on the archive, and a plugin folder is small
|
|
8
|
+
* text, so deflate would add a dependency for little gain. `@jini-ai/ui` has a stored-mode
|
|
9
|
+
* `buildZip`, but it is text-only (it would corrupt an image) and not exported, so this is the
|
|
10
|
+
* binary-safe twin, extracted to Jini so browser and local-folder uploads use the same encoder.
|
|
11
|
+
*/
|
|
12
|
+
/** Refusals, as dictionary keys the caller translates. */
|
|
13
|
+
export const FOLDER_EMPTY = "That folder is empty.";
|
|
14
|
+
export const FOLDER_UNREADABLE = "Could not read that folder. Try again.";
|
|
15
|
+
/** The archive readers' entry cap (`install-archive.ts`, the Agent Plugins reader): refused here
|
|
16
|
+
* before any bytes are read. */
|
|
17
|
+
export const FOLDER_MAX_FILES = 4096;
|
|
18
|
+
export class FolderZipError extends Error {
|
|
19
|
+
reason;
|
|
20
|
+
constructor(reason, message) {
|
|
21
|
+
super(message);
|
|
22
|
+
this.reason = reason;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/** 1980-01-01, the earliest DOS date: a fixed stamp, so the same folder always zips to the same
|
|
26
|
+
* bytes (an all-zero date is month 0, which some readers refuse). */
|
|
27
|
+
const DOS_DATE_1980 = (1 << 5) | 1;
|
|
28
|
+
const CRC_TABLE = (() => {
|
|
29
|
+
const table = new Uint32Array(256);
|
|
30
|
+
for (let n = 0; n < 256; n++) {
|
|
31
|
+
let c = n;
|
|
32
|
+
for (let k = 0; k < 8; k++)
|
|
33
|
+
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
34
|
+
table[n] = c >>> 0;
|
|
35
|
+
}
|
|
36
|
+
return table;
|
|
37
|
+
})();
|
|
38
|
+
/** @complexity O(n) in the byte count. */
|
|
39
|
+
export function crc32(bytes) {
|
|
40
|
+
let c = 0xffffffff;
|
|
41
|
+
for (let i = 0; i < bytes.length; i++)
|
|
42
|
+
c = CRC_TABLE[(c ^ bytes[i]) & 0xff] ^ (c >>> 8);
|
|
43
|
+
return (c ^ 0xffffffff) >>> 0;
|
|
44
|
+
}
|
|
45
|
+
/** A picked file's path inside the picked folder: `webkitRelativePath` minus the folder's own name,
|
|
46
|
+
* so the package's files sit at the top of the zip — what a server-path install of that same
|
|
47
|
+
* folder reads, and where both install routes look for the manifest. */
|
|
48
|
+
function entryPath(file) {
|
|
49
|
+
const relative = (file.webkitRelativePath || file.name).replaceAll("\\", "/");
|
|
50
|
+
const slash = relative.indexOf("/");
|
|
51
|
+
return slash < 0 ? { folder: "", path: relative } : { folder: relative.slice(0, slash), path: relative.slice(slash + 1) };
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Zips the files of one picked folder.
|
|
55
|
+
*
|
|
56
|
+
* @param required.files - Every file the folder picker returned.
|
|
57
|
+
* @param required.maxBytes - The upload limit; the folder's raw size alone over it is refused before
|
|
58
|
+
* reading, and the finished zip is checked again (headers add a little).
|
|
59
|
+
* @returns `<folder>.zip`, typed `application/zip`.
|
|
60
|
+
* @throws {FolderZipError} empty folder, too many files, too large, or a file that could not be read.
|
|
61
|
+
* @complexity O(total bytes).
|
|
62
|
+
*/
|
|
63
|
+
export async function zipFolderFiles(required, _optional = {}) {
|
|
64
|
+
const { files, maxBytes } = required;
|
|
65
|
+
if (files.length === 0)
|
|
66
|
+
throw new FolderZipError("empty", FOLDER_EMPTY);
|
|
67
|
+
if (files.length > FOLDER_MAX_FILES)
|
|
68
|
+
throw new FolderZipError("too-many", `More than ${FOLDER_MAX_FILES} files.`);
|
|
69
|
+
if (files.reduce((sum, file) => sum + file.size, 0) > maxBytes)
|
|
70
|
+
throw new FolderZipError("too-large", "Folder is over the upload limit.");
|
|
71
|
+
const encoder = new TextEncoder();
|
|
72
|
+
const chunks = [];
|
|
73
|
+
const central = [];
|
|
74
|
+
let offset = 0;
|
|
75
|
+
let folder = "";
|
|
76
|
+
for (const file of files) {
|
|
77
|
+
const entry = entryPath(file);
|
|
78
|
+
folder ||= entry.folder;
|
|
79
|
+
let data;
|
|
80
|
+
try {
|
|
81
|
+
data = new Uint8Array(await file.arrayBuffer());
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
throw new FolderZipError("unreadable", FOLDER_UNREADABLE);
|
|
85
|
+
}
|
|
86
|
+
const name = encoder.encode(entry.path);
|
|
87
|
+
const crc = crc32(data);
|
|
88
|
+
// Bit 11: the name is UTF-8, so a reader never decodes it as CP437.
|
|
89
|
+
const flags = name.some((b) => b > 0x7f) ? 0x0800 : 0;
|
|
90
|
+
const local = new Uint8Array(30 + name.length);
|
|
91
|
+
const lv = new DataView(local.buffer);
|
|
92
|
+
lv.setUint32(0, 0x04034b50, true);
|
|
93
|
+
lv.setUint16(4, 20, true);
|
|
94
|
+
lv.setUint16(6, flags, true);
|
|
95
|
+
lv.setUint16(12, DOS_DATE_1980, true);
|
|
96
|
+
lv.setUint32(14, crc, true);
|
|
97
|
+
lv.setUint32(18, data.length, true);
|
|
98
|
+
lv.setUint32(22, data.length, true);
|
|
99
|
+
lv.setUint16(26, name.length, true);
|
|
100
|
+
local.set(name, 30);
|
|
101
|
+
// Version-made-by 20 (MS-DOS, external attributes 0): a plain file to every reader, so the
|
|
102
|
+
// server's Unix special-entry guard never sees a mode to refuse.
|
|
103
|
+
const header = new Uint8Array(46 + name.length);
|
|
104
|
+
const cv = new DataView(header.buffer);
|
|
105
|
+
cv.setUint32(0, 0x02014b50, true);
|
|
106
|
+
cv.setUint16(4, 20, true);
|
|
107
|
+
cv.setUint16(6, 20, true);
|
|
108
|
+
cv.setUint16(8, flags, true);
|
|
109
|
+
cv.setUint16(14, DOS_DATE_1980, true);
|
|
110
|
+
cv.setUint32(16, crc, true);
|
|
111
|
+
cv.setUint32(20, data.length, true);
|
|
112
|
+
cv.setUint32(24, data.length, true);
|
|
113
|
+
cv.setUint16(28, name.length, true);
|
|
114
|
+
cv.setUint32(42, offset, true);
|
|
115
|
+
header.set(name, 46);
|
|
116
|
+
chunks.push(local, data);
|
|
117
|
+
central.push(header);
|
|
118
|
+
offset += local.length + data.length;
|
|
119
|
+
}
|
|
120
|
+
const centralSize = central.reduce((sum, part) => sum + part.length, 0);
|
|
121
|
+
const end = new Uint8Array(22);
|
|
122
|
+
const ev = new DataView(end.buffer);
|
|
123
|
+
ev.setUint32(0, 0x06054b50, true);
|
|
124
|
+
ev.setUint16(8, files.length, true);
|
|
125
|
+
ev.setUint16(10, files.length, true);
|
|
126
|
+
ev.setUint32(12, centralSize, true);
|
|
127
|
+
ev.setUint32(16, offset, true);
|
|
128
|
+
if (offset + centralSize + end.length > maxBytes)
|
|
129
|
+
throw new FolderZipError("too-large", "Folder is over the upload limit.");
|
|
130
|
+
return new File([...chunks, ...central, end], `${folder || "folder"}.zip`, { type: "application/zip" });
|
|
131
|
+
}
|
|
132
|
+
//# sourceMappingURL=folder-zip.js.map
|
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
/** Extracted from the host plugin lifecycle. Effects are supplied per host context. */
|
|
2
2
|
import type { McpServerConfig } from "./manifest.js";
|
|
3
3
|
import type { AgentPluginLifecyclePorts } from "./ports.js";
|
|
4
|
+
import { type SkillFileResult } from "./skill-file-reader.js";
|
|
4
5
|
/** Internal composition; one state/queue instance per injected host context. */
|
|
5
6
|
export declare function createCapabilityProjectionModule(ports: AgentPluginLifecyclePorts): {
|
|
6
7
|
classifyAgentPluginMcpServerTrust: (server: Pick<McpServerConfig, "type">) => "auto-admit" | "requires-confirmation";
|
|
7
8
|
readInstalledSkillMarkdown: (packageRoot: string, skillPath: string) => Promise<string>;
|
|
9
|
+
readInstalledSkillFile: ({ packageRoot, skillPath }: {
|
|
10
|
+
readonly packageRoot: string;
|
|
11
|
+
readonly skillPath: string;
|
|
12
|
+
}, options?: {
|
|
13
|
+
readonly file?: string;
|
|
14
|
+
}) => Promise<SkillFileResult>;
|
|
8
15
|
readInstalledMcpServerIds: (packageRoot: string) => Promise<readonly string[]>;
|
|
9
16
|
readInstalledMcpServers: (packageRoot: string) => Promise<Readonly<Record<string, McpServerConfig>>>;
|
|
10
17
|
};
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { parseAgentPluginMcpConfig as parseAgentPluginMcpConfigValue } from "./manifest.js";
|
|
2
2
|
import { createPackagePathsModule } from "./package-paths.js";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { createSkillFileReader } from "./skill-file-reader.js";
|
|
3
5
|
function buildModule(ports) {
|
|
4
6
|
const readFile = ports.filesystem.readFile.bind(ports.filesystem);
|
|
5
7
|
const parseAgentPluginMcpConfig = (value, pluginManifest) => parseAgentPluginMcpConfigValue({ value, extensionNamespace: ports.extensionNamespace }, { pluginManifest, readServerMetadata: ports.readServerMetadata });
|
|
@@ -11,6 +13,17 @@ function buildModule(ports) {
|
|
|
11
13
|
const absolute = await assertContainedOnDisk(packageRoot, skillPath);
|
|
12
14
|
return readFile(absolute, "utf8");
|
|
13
15
|
}
|
|
16
|
+
/** One shared progressive-disclosure read; the install's skillPath, never caller file input,
|
|
17
|
+
* selects the skill folder. Unknown/refused files produce a recoverable model-facing note. */
|
|
18
|
+
async function readInstalledSkillFile({ packageRoot, skillPath }, options = {}) {
|
|
19
|
+
try {
|
|
20
|
+
const skillRoot = path.dirname(await assertContainedOnDisk(packageRoot, skillPath));
|
|
21
|
+
return await createSkillFileReader({ skillRoot, filesystem: ports.filesystem }).read({}, options);
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return { text: "", files: [], note: "File request refused: the installed skill folder is missing, unreadable, or outside the package. Valid supporting files: (none)." };
|
|
25
|
+
}
|
|
26
|
+
}
|
|
14
27
|
async function readInstalledMcpConfig(packageRoot) {
|
|
15
28
|
let raw;
|
|
16
29
|
try {
|
|
@@ -45,7 +58,7 @@ function buildModule(ports) {
|
|
|
45
58
|
const config = await readInstalledMcpConfig(packageRoot);
|
|
46
59
|
return config?.servers ?? {};
|
|
47
60
|
}
|
|
48
|
-
return { classifyAgentPluginMcpServerTrust, readInstalledSkillMarkdown, readInstalledMcpServerIds, readInstalledMcpServers };
|
|
61
|
+
return { classifyAgentPluginMcpServerTrust, readInstalledSkillMarkdown, readInstalledSkillFile, readInstalledMcpServerIds, readInstalledMcpServers };
|
|
49
62
|
}
|
|
50
63
|
const instances = new WeakMap();
|
|
51
64
|
/** Internal composition; one state/queue instance per injected host context. */
|
|
@@ -6,6 +6,8 @@ import type { InstalledDigestIdentity } from './bundled-digests.js';
|
|
|
6
6
|
export type { SeededAgentPluginOutcome, SeedBundledAgentPluginsRequired, SeedBundledAgentPluginsResult } from './seed-bundled.js';
|
|
7
7
|
export type { RetireBundledAgentPluginsRequired, RetireBundledAgentPluginsOptional, RetiredAgentPluginOutcome, RetiredAgentPluginSuccessorOutcome } from './retire-bundled.js';
|
|
8
8
|
export * from './ports.js';
|
|
9
|
+
export { createSkillFileReader } from './skill-file-reader.js';
|
|
10
|
+
export type { SkillFileReader, SkillFileResult } from './skill-file-reader.js';
|
|
9
11
|
export * from './layout.js';
|
|
10
12
|
export * from './manifest.js';
|
|
11
13
|
export * from './fetch-archive.js';
|
|
@@ -137,6 +139,12 @@ export declare function createAgentPluginLifecycle(required: AgentPluginLifecycl
|
|
|
137
139
|
readonly packageRoot: string;
|
|
138
140
|
readonly skillPath: string;
|
|
139
141
|
}) => Promise<string>;
|
|
142
|
+
readInstalledSkillFile: ({ packageRoot, skillPath }: {
|
|
143
|
+
readonly packageRoot: string;
|
|
144
|
+
readonly skillPath: string;
|
|
145
|
+
}, options?: {
|
|
146
|
+
readonly file?: string;
|
|
147
|
+
}) => Promise<import("./skill-file-reader.js").SkillFileResult>;
|
|
140
148
|
readInstalledMcpServerIds: (input: {
|
|
141
149
|
readonly packageRoot: string;
|
|
142
150
|
}) => Promise<readonly string[]>;
|
package/dist/lifecycle/index.js
CHANGED
|
@@ -16,6 +16,7 @@ import { createTrustedPluginFilesModule } from './trusted-plugin-files.js';
|
|
|
16
16
|
import { createPluginContributionLoader } from './contribution-loader.js';
|
|
17
17
|
import { createUninstallModule } from './uninstall.js';
|
|
18
18
|
export * from './ports.js';
|
|
19
|
+
export { createSkillFileReader } from './skill-file-reader.js';
|
|
19
20
|
export * from './layout.js';
|
|
20
21
|
export * from './manifest.js';
|
|
21
22
|
export * from './fetch-archive.js';
|
|
@@ -89,6 +90,7 @@ export function createAgentPluginLifecycle(required, optional = {}) {
|
|
|
89
90
|
assertContainedOnDisk: (input) => paths.assertContainedOnDisk(input.packageRoot, input.entryPath),
|
|
90
91
|
classifyAgentPluginMcpServerTrust: (input) => capabilities.classifyAgentPluginMcpServerTrust(input.server),
|
|
91
92
|
readInstalledSkillMarkdown: (input) => capabilities.readInstalledSkillMarkdown(input.packageRoot, input.skillPath),
|
|
93
|
+
readInstalledSkillFile: capabilities.readInstalledSkillFile,
|
|
92
94
|
readInstalledMcpServerIds: (input) => capabilities.readInstalledMcpServerIds(input.packageRoot),
|
|
93
95
|
readInstalledMcpServers: (input) => capabilities.readInstalledMcpServers(input.packageRoot),
|
|
94
96
|
resolveAgentPluginRefs: (input, optional = {}) => refs.resolveAgentPluginRefs(input.pluginRefIds, input.workspaceLayout, optional.deliveryMode ?? context.deliveryMode),
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** One extraction/install failure reason. A caller (an admin route, a future marketplace installer)
|
|
2
|
+
* branches on `code` rather than parsing `message` — this codebase's own convention
|
|
3
|
+
* (`PluginLoadError.reason` in `plugin-runtime/loader.ts` is the direct precedent). */
|
|
4
|
+
export type AgentPluginInstallErrorCode = "PLUGIN_ID_CONFLICT" | "ARCHIVE_TOO_LARGE" | "DIGEST_MISMATCH" | "TOO_MANY_ENTRIES" | "UNSAFE_ENTRY_PATH" | "SYMLINK_ENTRY_REJECTED" | "UNSUPPORTED_ENTRY_KIND" | "DUPLICATE_ENTRY" | "FILE_TOO_LARGE" | "DECOMPRESSION_BOMB" | "TOTAL_SIZE_EXCEEDED" | "MANIFEST_MISSING" | "MANIFEST_INVALID" | "PUBLISH_FAILED";
|
|
5
|
+
export declare class AgentPluginInstallError extends Error {
|
|
6
|
+
readonly code: AgentPluginInstallErrorCode;
|
|
7
|
+
constructor({ code, message }: {
|
|
8
|
+
readonly code: AgentPluginInstallErrorCode;
|
|
9
|
+
readonly message: string;
|
|
10
|
+
}, options?: {
|
|
11
|
+
cause?: unknown;
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
//# sourceMappingURL=install-errors.d.ts.map
|
|
@@ -2,19 +2,8 @@ import type { AgentPluginLayout } from "./layout.js";
|
|
|
2
2
|
import type { AgentPluginLifecyclePorts } from "./ports.js";
|
|
3
3
|
import type { InstalledAgentPlugin } from "./types.js";
|
|
4
4
|
export type { InstalledAgentPlugin, InstalledAgentPluginSkill } from "./types.js";
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
* (`PluginLoadError.reason` in `plugin-runtime/loader.ts` is the direct precedent). */
|
|
8
|
-
export type AgentPluginInstallErrorCode = "PLUGIN_ID_CONFLICT" | "ARCHIVE_TOO_LARGE" | "DIGEST_MISMATCH" | "TOO_MANY_ENTRIES" | "UNSAFE_ENTRY_PATH" | "SYMLINK_ENTRY_REJECTED" | "UNSUPPORTED_ENTRY_KIND" | "DUPLICATE_ENTRY" | "FILE_TOO_LARGE" | "DECOMPRESSION_BOMB" | "TOTAL_SIZE_EXCEEDED" | "MANIFEST_MISSING" | "MANIFEST_INVALID" | "PUBLISH_FAILED";
|
|
9
|
-
export declare class AgentPluginInstallError extends Error {
|
|
10
|
-
readonly code: AgentPluginInstallErrorCode;
|
|
11
|
-
constructor({ code, message }: {
|
|
12
|
-
readonly code: AgentPluginInstallErrorCode;
|
|
13
|
-
readonly message: string;
|
|
14
|
-
}, options?: {
|
|
15
|
-
cause?: unknown;
|
|
16
|
-
});
|
|
17
|
-
}
|
|
5
|
+
export { AgentPluginInstallError } from "./install-errors.js";
|
|
6
|
+
export type { AgentPluginInstallErrorCode } from "./install-errors.js";
|
|
18
7
|
/** One archive entry. A symlink is modeled as its OWN kind rather than folded into "file" — the
|
|
19
8
|
* zip-slip research this module's header cites is explicit that the symlink-entry vector is
|
|
20
9
|
* distinct from the lexical-traversal one, and the two need independent tests and independent
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { listInstalledPackageRoots } from "./installed-package-roots.js";
|
|
2
2
|
import { createUninstallModule } from "./uninstall.js";
|
|
3
|
+
import { AgentPluginInstallError } from "./install-errors.js";
|
|
4
|
+
import { createInstalledPackageIndex } from "./installed-package-index.js";
|
|
3
5
|
import { readPluginIdentity, assertPluginIdentityAvailable } from "@jini-ai/core/plugin-directory";
|
|
4
6
|
/**
|
|
5
7
|
* @file `installAgentPlugin()` — content-addressed, adversarially-hardened extraction of one Agent
|
|
@@ -60,33 +62,14 @@ import { readPluginIdentity, assertPluginIdentityAvailable } from "@jini-ai/core
|
|
|
60
62
|
* (archive bytes and their expected digest are caller-supplied — see the module header above for why
|
|
61
63
|
* "how does the archive get here" is out of this slice) and no execution of anything extracted.
|
|
62
64
|
*/
|
|
63
|
-
/** One trimmed, non-blank string field of the host-supplied extension namespace, capped at `maxLength`, else
|
|
64
|
-
* `undefined` — a malformed value falls back rather than failing the whole index, since every
|
|
65
|
-
* field read this way is cosmetic. The cap stops a hostile package flooding the admin row.
|
|
66
|
-
* @complexity O(maxLength). */
|
|
67
|
-
/** `{ displayName }` when the host namespace's `displayName` is a non-blank string, else `{}` (callers
|
|
68
|
-
* title-case the id).
|
|
69
|
-
* @complexity O(1). */
|
|
70
|
-
/** `{ summary }` when the host namespace's `summary` is a non-blank string, else `{}` (the admin shows
|
|
71
|
-
* the spec `description` instead). Inner newlines are kept: blank lines are the paragraph breaks
|
|
72
|
-
* the admin renders.
|
|
73
|
-
* @complexity O(1). */
|
|
74
65
|
import { createPersistentStateModule } from "./persistent-state.js";
|
|
75
66
|
/** Extracted from the host plugin lifecycle. Effects are supplied per host context. */
|
|
76
67
|
import { createHash } from "node:crypto";
|
|
77
68
|
import { constants } from "node:fs";
|
|
78
69
|
import path from "node:path";
|
|
79
|
-
import { readAgentPluginExtension, parseAgentPluginManifest as parseAgentPluginManifestValue } from "./manifest.js";
|
|
80
70
|
import { PackagePathViolation } from "./package-paths.js";
|
|
81
71
|
import { createPackagePathsModule } from "./package-paths.js";
|
|
82
|
-
export
|
|
83
|
-
code;
|
|
84
|
-
constructor({ code, message }, options = {}) {
|
|
85
|
-
super(message, options);
|
|
86
|
-
this.name = "AgentPluginInstallError";
|
|
87
|
-
this.code = code;
|
|
88
|
-
}
|
|
89
|
-
}
|
|
72
|
+
export { AgentPluginInstallError } from "./install-errors.js";
|
|
90
73
|
function buildModule(ports) {
|
|
91
74
|
const persistentState = createPersistentStateModule(ports);
|
|
92
75
|
const chmod = ports.filesystem.chmod.bind(ports.filesystem);
|
|
@@ -98,7 +81,8 @@ function buildModule(ports) {
|
|
|
98
81
|
const rename = ports.filesystem.rename.bind(ports.filesystem);
|
|
99
82
|
const rm = ports.filesystem.rm.bind(ports.filesystem);
|
|
100
83
|
const stat = ports.filesystem.stat.bind(ports.filesystem);
|
|
101
|
-
const
|
|
84
|
+
const index = createInstalledPackageIndex({ ports });
|
|
85
|
+
const indexInstalledRoot = (packageRoot, archiveDigest) => index.indexInstalledRoot({ packageRoot, archiveDigest });
|
|
102
86
|
const { assertContainedOnDisk, normalizePackageEntryPath } = createPackagePathsModule(ports);
|
|
103
87
|
const LIMITS = {
|
|
104
88
|
maxArchiveBytes: 32 * 1024 * 1024,
|
|
@@ -145,7 +129,6 @@ function buildModule(ports) {
|
|
|
145
129
|
*/
|
|
146
130
|
async function pruneSupersededRevisions({ workspaceRoot, installed }, _optional = {}) {
|
|
147
131
|
try {
|
|
148
|
-
// Resolve lazily: uninstall's read-side index uses this install module for manifest indexing.
|
|
149
132
|
const removal = createUninstallModule(ports);
|
|
150
133
|
for (const root of await listInstalledPackageRoots({ ports, workspaceRoot })) {
|
|
151
134
|
if (root.localId !== installed.pluginId || root.archiveDigest === installed.archiveDigest)
|
|
@@ -400,107 +383,6 @@ function buildModule(ports) {
|
|
|
400
383
|
}
|
|
401
384
|
return totalBytes;
|
|
402
385
|
}
|
|
403
|
-
/** Cosmetic fields are trimmed and capped rather than invalidating the package. The cap stops
|
|
404
|
-
* a hostile package flooding an admin row. Inner newlines remain paragraph breaks in summaries.
|
|
405
|
-
* @complexity O(n) in the supplied text length; output is capped at maxLength. */
|
|
406
|
-
function readDisplayText(required, _optional = {}) {
|
|
407
|
-
const text = readAgentPluginExtension({ manifest: required.manifest, namespace: ports.extensionNamespace,
|
|
408
|
-
read: ({ value }) => {
|
|
409
|
-
const field = value[required.key];
|
|
410
|
-
return typeof field === 'string' ? field.trim() : undefined;
|
|
411
|
-
} });
|
|
412
|
-
return text ? { [required.key]: text.slice(0, required.maxLength) } : {};
|
|
413
|
-
}
|
|
414
|
-
/**
|
|
415
|
-
* Reads the (already-extracted-and-trusted, or already-published) `plugin.json` at `packageRoot`
|
|
416
|
-
* and walks the tree to build the `files`/`skills` index. Shared between a fresh install and the
|
|
417
|
-
* content-addressed dedup fast path so both return an identically-shaped result.
|
|
418
|
-
*
|
|
419
|
-
* Exported (2026-08-21) for a second caller outside this module:
|
|
420
|
-
* `resolve-agent-plugin-refs.ts`'s run-time resolution of a pinned `pluginRefId` against the
|
|
421
|
-
* packages already on disk needs the SAME `plugin.json`-parse-plus-tree-walk this function
|
|
422
|
-
* already does correctly (manifest validation via `parseAgentPluginManifest`, sorted `files`), so
|
|
423
|
-
* it re-derives an `InstalledAgentPlugin` per installed digest rather than hand-rolling a second,
|
|
424
|
-
* less-validated reader.
|
|
425
|
-
*/
|
|
426
|
-
// Conditional spreads, not `field: parsed.manifest.field` directly: an `exactOptionalPropertyTypes`-
|
|
427
|
-
// style caller (and this codebase's own convention elsewhere — see `buildToolSource`'s identical
|
|
428
|
-
// `defaultSkillReason` spread in `tool-registrations.ts`) must see an ABSENT key, not a key present
|
|
429
|
-
// with value `undefined`, when the manifest declared none of these — `deepEqual` fixtures across
|
|
430
|
-
// this feature's own tests already assert exact object shape.
|
|
431
|
-
async function indexInstalledRoot(packageRoot, /** SHA-256 of the raw archive bytes — the content-addressing key and the descriptor `revision`
|
|
432
|
-
* a future capability projection pins invocation to (`capability-projection.ts`). */ archiveDigest) {
|
|
433
|
-
let manifestRaw;
|
|
434
|
-
try {
|
|
435
|
-
manifestRaw = await readFile(path.join(packageRoot, "plugin.json"), "utf8");
|
|
436
|
-
}
|
|
437
|
-
catch (error) {
|
|
438
|
-
if (isErrnoException(error) && error.code === "ENOENT") {
|
|
439
|
-
throw new AgentPluginInstallError({ code: "MANIFEST_MISSING", message: "the archive does not contain a plugin.json at its root" });
|
|
440
|
-
}
|
|
441
|
-
throw error;
|
|
442
|
-
}
|
|
443
|
-
let manifestValue;
|
|
444
|
-
try {
|
|
445
|
-
manifestValue = JSON.parse(manifestRaw);
|
|
446
|
-
}
|
|
447
|
-
catch (error) {
|
|
448
|
-
throw new AgentPluginInstallError({ code: "MANIFEST_INVALID", message: "plugin.json is not valid JSON" }, { cause: error });
|
|
449
|
-
}
|
|
450
|
-
const parsed = parseAgentPluginManifest(manifestValue);
|
|
451
|
-
if (!parsed.ok) {
|
|
452
|
-
throw new AgentPluginInstallError({ code: "MANIFEST_INVALID", message: `plugin.json failed validation: ${parsed.errors.join("; ")}` });
|
|
453
|
-
}
|
|
454
|
-
const files = [];
|
|
455
|
-
for (const message of parsed.warnings)
|
|
456
|
-
ports.onEvent?.({ event: 'agent-plugin-identity-warning', message });
|
|
457
|
-
const skills = [];
|
|
458
|
-
async function walk(absolute, prefix) {
|
|
459
|
-
for (const entry of await readdir(absolute, { withFileTypes: true })) {
|
|
460
|
-
const relative = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
461
|
-
if (entry.isDirectory()) {
|
|
462
|
-
await walk(path.join(absolute, entry.name), relative);
|
|
463
|
-
}
|
|
464
|
-
else if (entry.isFile()) {
|
|
465
|
-
files.push(relative);
|
|
466
|
-
const match = relative.match(/^skills\/([^/]+)\/SKILL\.md$/);
|
|
467
|
-
if (match)
|
|
468
|
-
skills.push({ name: match[1], skillPath: relative });
|
|
469
|
-
}
|
|
470
|
-
// Anything that is neither a directory nor a regular file (a symlink somehow already present,
|
|
471
|
-
// a device node) is skipped rather than indexed — extraction itself never creates one, so
|
|
472
|
-
// encountering one here would mean the filesystem changed out from under an already-published,
|
|
473
|
-
// supposedly-frozen package, which this function has no business acting on either way.
|
|
474
|
-
}
|
|
475
|
-
}
|
|
476
|
-
await walk(packageRoot, "");
|
|
477
|
-
return {
|
|
478
|
-
pluginId: parsed.manifest.name,
|
|
479
|
-
id: parsed.manifest.id,
|
|
480
|
-
provides: parsed.manifest.provides,
|
|
481
|
-
version: parsed.manifest.version,
|
|
482
|
-
...( /**
|
|
483
|
-
* `plugin.json`'s own `description`/`keywords`/`author`/`license` (`manifest.ts`'s
|
|
484
|
-
* `AgentPluginManifest`) — parsed at index time but, before 2026-09-09, discarded immediately
|
|
485
|
-
* after: only `name`/`version` ever reached `InstalledAgentPlugin`. Added for
|
|
486
|
-
* `search_agent_plugin_local` (`tool-registrations.ts`), which needs the description and the
|
|
487
|
-
* author-curated keywords to rank installed plugins against a free-text query — the manifest is
|
|
488
|
-
* already read and parsed here on every call, so carrying these through is zero additional I/O,
|
|
489
|
-
* not a new read path. All four are optional because the spec itself makes them optional (the real
|
|
490
|
-
* installed `ui-ux-design` fixture ships `description` but no `keywords`; a bare-minimum manifest
|
|
491
|
-
* ships neither) — a caller must not assume any of them are present.
|
|
492
|
-
*/parsed.manifest.description !== undefined ? { description: parsed.manifest.description } : {}),
|
|
493
|
-
...(parsed.manifest.keywords !== undefined ? { keywords: parsed.manifest.keywords } : {}),
|
|
494
|
-
...(parsed.manifest.author !== undefined ? { author: parsed.manifest.author } : {}),
|
|
495
|
-
...(parsed.manifest.license !== undefined ? { license: parsed.manifest.license } : {}),
|
|
496
|
-
...readDisplayText({ manifest: parsed.manifest, key: "displayName", maxLength: 64 }),
|
|
497
|
-
...readDisplayText({ manifest: parsed.manifest, key: "summary", maxLength: 1200 }),
|
|
498
|
-
archiveDigest,
|
|
499
|
-
packageRoot,
|
|
500
|
-
files: files.sort(),
|
|
501
|
-
skills: skills.sort((a, b) => a.name.localeCompare(b.name)),
|
|
502
|
-
};
|
|
503
|
-
}
|
|
504
386
|
/** Makes every file/directory under `root` read-only (executables keep `+x`, per the Agent Plugins
|
|
505
387
|
* spec's own MCP `command` needing to launch a `./`-relative script — not exercised by anything in
|
|
506
388
|
* this slice, since MCP execution is out of scope for v1, but the bit is preserved now rather than
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { AgentPluginLifecyclePorts } from "./ports.js";
|
|
2
|
+
import type { InstalledAgentPlugin } from "./types.js";
|
|
3
|
+
/** Indexes manifests and files with the same injected effects used by installation and resolution.
|
|
4
|
+
* @complexity O(f) reads/walk work for f package files; no mutation or executable imports. */
|
|
5
|
+
export declare function createInstalledPackageIndex(required: {
|
|
6
|
+
readonly ports: AgentPluginLifecyclePorts;
|
|
7
|
+
}, _optional?: Record<string, never>): {
|
|
8
|
+
indexInstalledRoot: ({ packageRoot, archiveDigest }: {
|
|
9
|
+
readonly packageRoot: string;
|
|
10
|
+
/** SHA-256 of the raw archive bytes — the content-addressing key and the descriptor `revision`
|
|
11
|
+
* a future capability projection pins invocation to (`capability-projection.ts`). */
|
|
12
|
+
readonly archiveDigest: string;
|
|
13
|
+
}, _optional?: Record<string, never>) => Promise<InstalledAgentPlugin>;
|
|
14
|
+
};
|
|
15
|
+
//# sourceMappingURL=installed-package-index.d.ts.map
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/** Read-side owner of installed package metadata; never constructs a mutation lifecycle. */
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { AgentPluginInstallError } from "./install-errors.js";
|
|
4
|
+
import { readAgentPluginExtension, parseAgentPluginManifest as parseAgentPluginManifestValue } from "./manifest.js";
|
|
5
|
+
/** Indexes manifests and files with the same injected effects used by installation and resolution.
|
|
6
|
+
* @complexity O(f) reads/walk work for f package files; no mutation or executable imports. */
|
|
7
|
+
export function createInstalledPackageIndex(required, _optional = {}) {
|
|
8
|
+
const { ports } = required;
|
|
9
|
+
const readFile = ports.filesystem.readFile.bind(ports.filesystem);
|
|
10
|
+
const readdir = ports.filesystem.readdir.bind(ports.filesystem);
|
|
11
|
+
const parseAgentPluginManifest = (value) => parseAgentPluginManifestValue({ value }, { identityNamespace: ports.extensionNamespace });
|
|
12
|
+
/** Cosmetic fields are trimmed and capped rather than invalidating the package. The cap stops
|
|
13
|
+
* a hostile package flooding an admin row. Malformed values fall back because these fields are
|
|
14
|
+
* cosmetic: callers title-case the id and show the spec description when these keys are absent.
|
|
15
|
+
* Inner newlines remain paragraph breaks in summaries.
|
|
16
|
+
* @complexity O(n) in the supplied text length; output is capped at maxLength. */
|
|
17
|
+
function readDisplayText(required, _optional = {}) {
|
|
18
|
+
const text = readAgentPluginExtension({ manifest: required.manifest, namespace: ports.extensionNamespace,
|
|
19
|
+
read: ({ value }) => {
|
|
20
|
+
const field = value[required.key];
|
|
21
|
+
return typeof field === 'string' ? field.trim() : undefined;
|
|
22
|
+
} });
|
|
23
|
+
return text ? { [required.key]: text.slice(0, required.maxLength) } : {};
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Reads the (already-extracted-and-trusted, or already-published) `plugin.json` at `packageRoot`
|
|
27
|
+
* and walks the tree to build the `files`/`skills` index. Shared between a fresh install and the
|
|
28
|
+
* content-addressed dedup fast path so both return an identically-shaped result.
|
|
29
|
+
*
|
|
30
|
+
* Exported (2026-08-21) for a second caller outside this module:
|
|
31
|
+
* `resolve-agent-plugin-refs.ts`'s run-time resolution of a pinned `pluginRefId` against the
|
|
32
|
+
* packages already on disk needs the SAME `plugin.json`-parse-plus-tree-walk this function
|
|
33
|
+
* already does correctly (manifest validation via `parseAgentPluginManifest`, sorted `files`), so
|
|
34
|
+
* it re-derives an `InstalledAgentPlugin` per installed digest rather than hand-rolling a second,
|
|
35
|
+
* less-validated reader.
|
|
36
|
+
*/
|
|
37
|
+
// Conditional spreads, not `field: parsed.manifest.field` directly: an `exactOptionalPropertyTypes`-
|
|
38
|
+
// style caller (and this codebase's own convention elsewhere — see `buildToolSource`'s identical
|
|
39
|
+
// `defaultSkillReason` spread in `tool-registrations.ts`) must see an ABSENT key, not a key present
|
|
40
|
+
// with value `undefined`, when the manifest declared none of these — `deepEqual` fixtures across
|
|
41
|
+
// this feature's own tests already assert exact object shape.
|
|
42
|
+
async function indexInstalledRoot({ packageRoot, archiveDigest }, _optional = {}) {
|
|
43
|
+
let manifestRaw;
|
|
44
|
+
try {
|
|
45
|
+
manifestRaw = await readFile(path.join(packageRoot, "plugin.json"), "utf8");
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
if (typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT") {
|
|
49
|
+
throw new AgentPluginInstallError({ code: "MANIFEST_MISSING", message: "the archive does not contain a plugin.json at its root" });
|
|
50
|
+
}
|
|
51
|
+
throw error;
|
|
52
|
+
}
|
|
53
|
+
let manifestValue;
|
|
54
|
+
try {
|
|
55
|
+
manifestValue = JSON.parse(manifestRaw);
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
throw new AgentPluginInstallError({ code: "MANIFEST_INVALID", message: "plugin.json is not valid JSON" }, { cause: error });
|
|
59
|
+
}
|
|
60
|
+
const parsed = parseAgentPluginManifest(manifestValue);
|
|
61
|
+
if (!parsed.ok) {
|
|
62
|
+
throw new AgentPluginInstallError({ code: "MANIFEST_INVALID", message: `plugin.json failed validation: ${parsed.errors.join("; ")}` });
|
|
63
|
+
}
|
|
64
|
+
const files = [];
|
|
65
|
+
for (const message of parsed.warnings)
|
|
66
|
+
ports.onEvent?.({ event: 'agent-plugin-identity-warning', message });
|
|
67
|
+
const skills = [];
|
|
68
|
+
async function walk(absolute, prefix) {
|
|
69
|
+
for (const entry of await readdir(absolute, { withFileTypes: true })) {
|
|
70
|
+
const relative = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
71
|
+
if (entry.isDirectory()) {
|
|
72
|
+
await walk(path.join(absolute, entry.name), relative);
|
|
73
|
+
}
|
|
74
|
+
else if (entry.isFile()) {
|
|
75
|
+
files.push(relative);
|
|
76
|
+
const match = relative.match(/^skills\/([^/]+)\/SKILL\.md$/);
|
|
77
|
+
if (match)
|
|
78
|
+
skills.push({ name: match[1], skillPath: relative });
|
|
79
|
+
}
|
|
80
|
+
// Anything that is neither a directory nor a regular file (a symlink somehow already present,
|
|
81
|
+
// a device node) is skipped rather than indexed — extraction itself never creates one, so
|
|
82
|
+
// encountering one here would mean the filesystem changed out from under an already-published,
|
|
83
|
+
// supposedly-frozen package, which this function has no business acting on either way.
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
await walk(packageRoot, "");
|
|
87
|
+
return {
|
|
88
|
+
pluginId: parsed.manifest.name,
|
|
89
|
+
id: parsed.manifest.id,
|
|
90
|
+
provides: parsed.manifest.provides,
|
|
91
|
+
version: parsed.manifest.version,
|
|
92
|
+
...( /**
|
|
93
|
+
* `plugin.json`'s own `description`/`keywords`/`author`/`license` (`manifest.ts`'s
|
|
94
|
+
* `AgentPluginManifest`) — parsed at index time but, before 2026-09-09, discarded immediately
|
|
95
|
+
* after: only `name`/`version` ever reached `InstalledAgentPlugin`. Added for
|
|
96
|
+
* `search_agent_plugin_local` (`tool-registrations.ts`), which needs the description and the
|
|
97
|
+
* author-curated keywords to rank installed plugins against a free-text query — the manifest is
|
|
98
|
+
* already read and parsed here on every call, so carrying these through is zero additional I/O,
|
|
99
|
+
* not a new read path. All four are optional because the spec itself makes them optional (the real
|
|
100
|
+
* installed `ui-ux-design` fixture ships `description` but no `keywords`; a bare-minimum manifest
|
|
101
|
+
* ships neither) — a caller must not assume any of them are present.
|
|
102
|
+
*/parsed.manifest.description !== undefined ? { description: parsed.manifest.description } : {}),
|
|
103
|
+
...(parsed.manifest.keywords !== undefined ? { keywords: parsed.manifest.keywords } : {}),
|
|
104
|
+
...(parsed.manifest.author !== undefined ? { author: parsed.manifest.author } : {}),
|
|
105
|
+
...(parsed.manifest.license !== undefined ? { license: parsed.manifest.license } : {}),
|
|
106
|
+
...readDisplayText({ manifest: parsed.manifest, key: "displayName", maxLength: 64 }),
|
|
107
|
+
...readDisplayText({ manifest: parsed.manifest, key: "summary", maxLength: 1200 }),
|
|
108
|
+
archiveDigest,
|
|
109
|
+
packageRoot,
|
|
110
|
+
files: files.sort(),
|
|
111
|
+
skills: skills.sort((a, b) => a.name.localeCompare(b.name)),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
return { indexInstalledRoot };
|
|
115
|
+
}
|
|
116
|
+
//# sourceMappingURL=installed-package-index.js.map
|
|
@@ -30,6 +30,7 @@ export type ParseAgentPluginManifestResult = {
|
|
|
30
30
|
};
|
|
31
31
|
/**
|
|
32
32
|
* Validates an already-`JSON.parse`d `plugin.json` value against the 1.0.0/1.1.0 grammar.
|
|
33
|
+
* Hosts inject identityNamespace; the default retains the existing plugin extension format.
|
|
33
34
|
*
|
|
34
35
|
* @throws Nothing — every failure is reported via the returned `errors`, matching this codebase's
|
|
35
36
|
* own convention for expected validation outcomes (`plugin-runtime/manifest.ts`,
|
|
@@ -150,6 +150,7 @@ function resolveManifestName(raw) {
|
|
|
150
150
|
}
|
|
151
151
|
/**
|
|
152
152
|
* Validates an already-`JSON.parse`d `plugin.json` value against the 1.0.0/1.1.0 grammar.
|
|
153
|
+
* Hosts inject identityNamespace; the default retains the existing plugin extension format.
|
|
153
154
|
*
|
|
154
155
|
* @throws Nothing — every failure is reported via the returned `errors`, matching this codebase's
|
|
155
156
|
* own convention for expected validation outcomes (`plugin-runtime/manifest.ts`,
|
|
@@ -7,7 +7,7 @@ export declare class PackagePathViolation extends Error {
|
|
|
7
7
|
}, optional?: ErrorOptions);
|
|
8
8
|
}
|
|
9
9
|
/** Internal composition; one state/queue instance per injected host context. */
|
|
10
|
-
export declare function createPackagePathsModule(ports: AgentPluginLifecyclePorts): {
|
|
10
|
+
export declare function createPackagePathsModule(ports: Pick<AgentPluginLifecyclePorts, "filesystem">): {
|
|
11
11
|
normalizePackageEntryPath: (rawEntryPath: string) => string;
|
|
12
12
|
assertContainedOnDisk: (packageRoot: string, entryPath: string) => Promise<string>;
|
|
13
13
|
};
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import type { /**
|
|
2
2
|
* How a pinned ref's content reaches the agent.
|
|
3
3
|
*
|
|
4
|
-
* - `inject` — the whole eponymous SKILL.md (~15KB for `ui-ux-design`) plus
|
|
5
|
-
* listed
|
|
4
|
+
* - `inject` — the whole eponymous SKILL.md (~15KB for `ui-ux-design`) plus its readable supporting-file inventory
|
|
5
|
+
* listed relative to the skill folder, in the run's prompt prefix. Today's shipped behaviour, and the default.
|
|
6
6
|
* - `pointer` — ~400 bytes naming the exact tool call that returns that SAME SKILL.md, and nothing
|
|
7
7
|
* else. The content is identical; only the delivery differs, which is what makes the two a
|
|
8
8
|
* controlled comparison rather than two different experiments.
|
|
@@ -45,13 +45,12 @@ import { listInstalledPackageRoots, INSTALLED_PLUGIN_DIGEST_PATTERN } from "./in
|
|
|
45
45
|
* examples, scripts) is supporting material a plugin's own SKILL.md is written to point an agent
|
|
46
46
|
* at when it needs more depth (the Agent Plugins spec's own convention every skill in this package
|
|
47
47
|
* follows). Injecting the FULL package indiscriminately would balloon every run's prompt with
|
|
48
|
-
* pages this specific task usually never needs.
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
* an explicit follow-up read.
|
|
48
|
+
* pages this specific task usually never needs. Readable supporting files are listed relative to
|
|
49
|
+
* the chosen skill folder and loaded through the same plugin tool used by browser-facing assistants
|
|
50
|
+
* and CLI agents — supporting content is deferred to an explicit follow-up read.
|
|
52
51
|
*
|
|
53
52
|
* Architectural role:
|
|
54
|
-
* Server-side only (reads
|
|
53
|
+
* Server-side only (reads injected filesystem ports through the installed-package index) — never
|
|
55
54
|
* imported into a browser bundle. Returns a discriminated result rather than throwing, so
|
|
56
55
|
* `agent-daemon-server.ts`'s `onStarted` gets one place to branch on success/failure without a
|
|
57
56
|
* second try/catch layer duplicating this module's own error classification.
|
|
@@ -61,7 +60,7 @@ import path from "node:path";
|
|
|
61
60
|
import { createPackagePathsModule } from "./package-paths.js";
|
|
62
61
|
import { createActivationModule } from "./activation.js";
|
|
63
62
|
import { createBundledDigestsModule } from "./bundled-digests.js";
|
|
64
|
-
import {
|
|
63
|
+
import { createInstalledPackageIndex } from "./installed-package-index.js";
|
|
65
64
|
import { createCapabilityProjectionModule } from "./capability-projection.js";
|
|
66
65
|
function buildModule(ports) {
|
|
67
66
|
const persistentState = createPersistentStateModule(ports);
|
|
@@ -69,8 +68,8 @@ function buildModule(ports) {
|
|
|
69
68
|
const stat = ports.filesystem.stat.bind(ports.filesystem);
|
|
70
69
|
const { resolveAgentPluginActivation } = createActivationModule(ports);
|
|
71
70
|
const { preferBundledAgentPluginDigests, readBundledAgentPluginDigests } = createBundledDigestsModule(ports);
|
|
72
|
-
const { indexInstalledRoot } =
|
|
73
|
-
const {
|
|
71
|
+
const { indexInstalledRoot } = createInstalledPackageIndex({ ports });
|
|
72
|
+
const { readInstalledSkillFile } = createCapabilityProjectionModule(ports);
|
|
74
73
|
/** Reads the delivery mode for this process. An unset or unrecognised value is `inject` — an A/B
|
|
75
74
|
* affordance must never be able to change production behaviour by typo. */
|
|
76
75
|
function resolveAgentPluginDeliveryMode() { return ports.deliveryMode; }
|
|
@@ -126,8 +125,9 @@ function buildModule(ports) {
|
|
|
126
125
|
if (verdict.verdict === "active")
|
|
127
126
|
return undefined;
|
|
128
127
|
if (verdict.verdict === "undetermined") {
|
|
128
|
+
// Native activation-read diagnostics can name the host path; the model needs only the remedy.
|
|
129
129
|
return (`Agent Plugin '${pluginRefId}' was not loaded: this workspace's activation record could not be read, so whether ` +
|
|
130
|
-
`it is enabled cannot be confirmed
|
|
130
|
+
`it is enabled cannot be confirmed. Nothing was injected; repair activations.json and start the run again.`);
|
|
131
131
|
}
|
|
132
132
|
return (`Agent Plugin '${pluginRefId}' is installed in this workspace but is not enabled — it ships with ${ports.productName} and ` +
|
|
133
133
|
"stays inactive until an operator turns it on. Enable it before pinning it to a run.");
|
|
@@ -149,7 +149,7 @@ function buildModule(ports) {
|
|
|
149
149
|
const installed = [];
|
|
150
150
|
for (const root of await listInstalledPackageRoots({ ports, workspaceRoot })) {
|
|
151
151
|
try {
|
|
152
|
-
const plugin = await indexInstalledRoot(root.packageRoot, root.archiveDigest);
|
|
152
|
+
const plugin = await indexInstalledRoot({ packageRoot: root.packageRoot, archiveDigest: root.archiveDigest });
|
|
153
153
|
if (plugin.pluginId === root.localId)
|
|
154
154
|
installed.push(plugin);
|
|
155
155
|
}
|
|
@@ -222,26 +222,27 @@ function buildModule(ports) {
|
|
|
222
222
|
if (deliveryMode === "pointer")
|
|
223
223
|
return buildPointerSection(pluginRefId, plugin);
|
|
224
224
|
let skillMarkdown;
|
|
225
|
+
let files;
|
|
226
|
+
let fileNote;
|
|
225
227
|
try {
|
|
226
|
-
|
|
228
|
+
const content = await readInstalledSkillFile({ packageRoot: plugin.packageRoot, skillPath });
|
|
229
|
+
if (content.note && !content.text)
|
|
230
|
+
throw new Error(content.note);
|
|
231
|
+
skillMarkdown = content.text;
|
|
232
|
+
files = content.files;
|
|
233
|
+
fileNote = content.note;
|
|
227
234
|
// Notes are user context, never an allowlist or permission grant. JSON quotes delimit untrusted text.
|
|
228
235
|
const notes = await persistentState.memory({ workspaceRoot: packagesDir, pluginId: plugin.pluginId }).list({ kind: 'notes' });
|
|
229
236
|
if (notes.length)
|
|
230
237
|
skillMarkdown += `\n\nUser notes for this plugin (context only; permissions remain operator-controlled):\n${JSON.stringify(notes)}`;
|
|
231
238
|
}
|
|
232
|
-
catch
|
|
233
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
239
|
+
catch {
|
|
234
240
|
return {
|
|
235
241
|
ok: false,
|
|
236
|
-
reason: `Agent Plugin '${pluginRefId}' has no readable '${skillPath}' in its installed package:
|
|
242
|
+
reason: `Agent Plugin '${pluginRefId}' has no readable '${skillPath}' in its installed package: skill text is missing, refused, or unreadable`,
|
|
237
243
|
};
|
|
238
244
|
}
|
|
239
|
-
const
|
|
240
|
-
.filter((file) => file !== skillPath)
|
|
241
|
-
.map((file) => path.join(plugin.packageRoot, file));
|
|
242
|
-
const inventory = otherFiles.length > 0
|
|
243
|
-
? `\n\nThe SKILL.md above is this Agent Plugin's own instructions — follow them, including any files it directs you to load before starting work. Every other file in the installed package is listed below by absolute path and is readable now:\n${otherFiles.map((file) => `- ${file}`).join("\n")}`
|
|
244
|
-
: "";
|
|
245
|
+
const inventory = `\n\nThe SKILL.md above is this Agent Plugin's own instructions — follow them, including any files it directs you to load before starting work. Readable supporting files are listed relative to this skill folder. Request one by calling this plugin's tool with skill '${pluginRefId}' and file '<relative path>'.\n${ports.formatPluginToolPointer({ pluginId: pluginRefId })}\nfiles: ${JSON.stringify(files)}${fileNote ? `\n${fileNote}` : ""}`;
|
|
245
246
|
return {
|
|
246
247
|
ok: true,
|
|
247
248
|
section: `<<AGENT_PLUGIN pluginId="${pluginRefId}">>\n${skillMarkdown}${inventory}\n<</AGENT_PLUGIN>>`,
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { FilesystemPort } from "./ports.js";
|
|
2
|
+
/** Model-visible result. Paths are relative to ONE skill; filesystem errors never cross this seam. */
|
|
3
|
+
export interface SkillFileResult {
|
|
4
|
+
readonly text: string;
|
|
5
|
+
readonly files: readonly string[];
|
|
6
|
+
readonly note?: string;
|
|
7
|
+
}
|
|
8
|
+
export interface SkillFileReader {
|
|
9
|
+
read(input: Record<string, never>, options?: {
|
|
10
|
+
readonly file?: string;
|
|
11
|
+
}): Promise<SkillFileResult>;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Progressive disclosure for plugin AND standalone skills, with a skill-folder trust boundary.
|
|
15
|
+
* Uses the lifecycle containment owner, native bounded reads and injected async filesystem effects.
|
|
16
|
+
* The default file is SKILL.md; supporting files are discovered recursively, never inlined.
|
|
17
|
+
* @complexity O(entries + readable bytes), bounded by 4096 entries, depth 32 and 256 KiB per file.
|
|
18
|
+
*/
|
|
19
|
+
export declare function createSkillFileReader({ skillRoot, filesystem }: {
|
|
20
|
+
readonly skillRoot: string;
|
|
21
|
+
readonly filesystem: FilesystemPort;
|
|
22
|
+
}, _options?: Record<string, never>): SkillFileReader;
|
|
23
|
+
//# sourceMappingURL=skill-file-reader.d.ts.map
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { TextDecoder } from "node:util";
|
|
4
|
+
import { createPackagePathsModule } from "./package-paths.js";
|
|
5
|
+
const TEXT_EXTENSIONS = new Set([".md", ".mdx", ".txt", ".json", ".yaml", ".yml", ".ts", ".tsx", ".js", ".jsx", ".css", ".html", ".csv", ".svg", ".py", ".sh"]);
|
|
6
|
+
const MAX_FILE_BYTES = 256 * 1024;
|
|
7
|
+
const MAX_WALK_ENTRIES = 4096;
|
|
8
|
+
const MAX_WALK_DEPTH = 32;
|
|
9
|
+
/**
|
|
10
|
+
* Progressive disclosure for plugin AND standalone skills, with a skill-folder trust boundary.
|
|
11
|
+
* Uses the lifecycle containment owner, native bounded reads and injected async filesystem effects.
|
|
12
|
+
* The default file is SKILL.md; supporting files are discovered recursively, never inlined.
|
|
13
|
+
* @complexity O(entries + readable bytes), bounded by 4096 entries, depth 32 and 256 KiB per file.
|
|
14
|
+
*/
|
|
15
|
+
export function createSkillFileReader({ skillRoot, filesystem }, _options = {}) {
|
|
16
|
+
const { assertContainedOnDisk } = createPackagePathsModule({ filesystem });
|
|
17
|
+
/** Reject traversal even when normalization would leave it inside the folder. */
|
|
18
|
+
function validateFile(file) {
|
|
19
|
+
if (!file || file.includes("\0") || file.includes("\\") || path.posix.isAbsolute(file) || /^[a-zA-Z]:/.test(file) || file.split("/").some(segment => segment === "..")) {
|
|
20
|
+
throw new Error("File request refused: use a path relative to this skill folder, without traversal or absolute paths.");
|
|
21
|
+
}
|
|
22
|
+
if (!TEXT_EXTENSIONS.has(path.extname(file).toLowerCase())) {
|
|
23
|
+
throw new Error("File request refused: only supported text extensions are readable.");
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/** Open the resolved target with no-follow and read at most cap + 1, including growing files. */
|
|
27
|
+
async function readText(file) {
|
|
28
|
+
validateFile(file);
|
|
29
|
+
let absolute;
|
|
30
|
+
try {
|
|
31
|
+
absolute = await filesystem.realpath(await assertContainedOnDisk(skillRoot, file));
|
|
32
|
+
// Recheck the canonical target through the same owner before opening it.
|
|
33
|
+
const relative = path.relative(await filesystem.realpath(skillRoot), absolute);
|
|
34
|
+
await assertContainedOnDisk(skillRoot, relative);
|
|
35
|
+
validateFile(relative.split(path.sep).join("/"));
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
throw new Error("File request refused: file is missing, unreadable, or resolves outside this skill folder.");
|
|
39
|
+
}
|
|
40
|
+
let handle;
|
|
41
|
+
try {
|
|
42
|
+
handle = await filesystem.open(absolute, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
43
|
+
const stat = await handle.stat();
|
|
44
|
+
if (!stat.isFile())
|
|
45
|
+
throw new Error("File request refused: only regular text files are readable.");
|
|
46
|
+
if (stat.size > MAX_FILE_BYTES)
|
|
47
|
+
throw new Error("File request refused: exceeds the 256 KiB text limit; no content returned.");
|
|
48
|
+
const buffer = Buffer.alloc(MAX_FILE_BYTES + 1);
|
|
49
|
+
let length = 0;
|
|
50
|
+
while (length < buffer.length) {
|
|
51
|
+
const { bytesRead } = await handle.read(buffer, length, buffer.length - length, null);
|
|
52
|
+
if (!bytesRead)
|
|
53
|
+
break;
|
|
54
|
+
length += bytesRead;
|
|
55
|
+
}
|
|
56
|
+
if (length > MAX_FILE_BYTES)
|
|
57
|
+
throw new Error("File request refused: exceeds the 256 KiB text limit; no content returned.");
|
|
58
|
+
const bytes = buffer.subarray(0, length);
|
|
59
|
+
if (bytes.some(byte => (byte < 32 && byte !== 9 && byte !== 10 && byte !== 13) || byte === 127))
|
|
60
|
+
throw new Error("File request refused: binary content cannot be read as text.");
|
|
61
|
+
try {
|
|
62
|
+
return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes);
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
throw new Error("File request refused: binary or invalid UTF-8 content cannot be read as text.");
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
// Never reflect native errors or caller paths: either can contain an absolute host path.
|
|
70
|
+
if (error instanceof Error && error.message.startsWith("File request refused:"))
|
|
71
|
+
throw error;
|
|
72
|
+
throw new Error("File request refused: file is missing or unreadable.");
|
|
73
|
+
}
|
|
74
|
+
finally {
|
|
75
|
+
await handle?.close().catch(() => { });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/** Skip directory symlinks (including cycles); contained file symlinks use the same read guard. */
|
|
79
|
+
async function listFiles() {
|
|
80
|
+
const files = [];
|
|
81
|
+
let remaining = MAX_WALK_ENTRIES;
|
|
82
|
+
let incomplete = false;
|
|
83
|
+
async function walk(relative, depth) {
|
|
84
|
+
if (depth > MAX_WALK_DEPTH || remaining <= 0) {
|
|
85
|
+
incomplete = true;
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
let entries;
|
|
89
|
+
try {
|
|
90
|
+
const directory = relative ? await assertContainedOnDisk(skillRoot, relative) : await filesystem.realpath(skillRoot);
|
|
91
|
+
entries = await filesystem.readdir(directory, { withFileTypes: true });
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
incomplete = true;
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
for (const entry of entries.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0)) {
|
|
98
|
+
if (remaining-- <= 0) {
|
|
99
|
+
incomplete = true;
|
|
100
|
+
break;
|
|
101
|
+
}
|
|
102
|
+
const file = relative ? `${relative}/${entry.name}` : entry.name;
|
|
103
|
+
if (entry.isDirectory())
|
|
104
|
+
await walk(file, depth + 1);
|
|
105
|
+
else if (file !== "SKILL.md" && (entry.isFile() || entry.isSymbolicLink())) {
|
|
106
|
+
try {
|
|
107
|
+
await readText(file);
|
|
108
|
+
files.push(file);
|
|
109
|
+
}
|
|
110
|
+
catch { /* Refused files must not be advertised as readable. */ }
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
await walk("", 0);
|
|
115
|
+
return { files: files.sort(), ...(incomplete ? { note: "Supporting-file inventory is incomplete: traversal limit reached or a directory is unreadable." } : {}) };
|
|
116
|
+
}
|
|
117
|
+
return {
|
|
118
|
+
async read(_input, options = {}) {
|
|
119
|
+
const listing = await listFiles();
|
|
120
|
+
try {
|
|
121
|
+
return { ...listing, text: await readText(options.file ?? "SKILL.md") };
|
|
122
|
+
}
|
|
123
|
+
catch (error) {
|
|
124
|
+
const refusal = error instanceof Error ? error.message : "File request refused.";
|
|
125
|
+
return { files: listing.files, text: "", note: [refusal, `Valid supporting files: ${listing.files.join(", ") || "(none)"}. Omit file to request SKILL.md.`, listing.note].filter(Boolean).join(" ") };
|
|
126
|
+
}
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
//# sourceMappingURL=skill-file-reader.js.map
|
package/dist/manifest.d.ts
CHANGED
|
@@ -58,7 +58,8 @@ export interface McpManifest {
|
|
|
58
58
|
readonly mcpServers: Readonly<Record<string, McpServerEntry>>;
|
|
59
59
|
}
|
|
60
60
|
export declare function isMcpManifest(value: unknown): value is McpManifest;
|
|
61
|
-
/** Object-argument facade for the legacy structural validator.
|
|
61
|
+
/** Object-argument facade for the legacy structural validator.
|
|
62
|
+
* Hosts inject identityNamespace; the default retains the existing plugin extension format. */
|
|
62
63
|
export declare function validatePluginManifest(required: {
|
|
63
64
|
readonly value: unknown;
|
|
64
65
|
}, optional?: {
|
package/dist/manifest.js
CHANGED
|
@@ -28,7 +28,8 @@ export function isMcpManifest(value) {
|
|
|
28
28
|
const servers = value.mcpServers;
|
|
29
29
|
return typeof servers === 'object' && servers !== null && !Array.isArray(servers);
|
|
30
30
|
}
|
|
31
|
-
/** Object-argument facade for the legacy structural validator.
|
|
31
|
+
/** Object-argument facade for the legacy structural validator.
|
|
32
|
+
* Hosts inject identityNamespace; the default retains the existing plugin extension format. */
|
|
32
33
|
export function validatePluginManifest(required, optional = {}) {
|
|
33
34
|
return isPluginManifest(required.value) && validatePluginIdentity({ manifest: required.value, namespace: optional.identityNamespace ?? "tovu" }).errors.length === 0;
|
|
34
35
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jini-ai/agent-plugins",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.7",
|
|
4
4
|
"description": "Support for Agent Plugins, the open, vendor-neutral spec (v1.0.0, published 2026-08-06; v1.1.0 and later 1.x manifests are also accepted; 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": {
|
|
@@ -19,7 +19,9 @@
|
|
|
19
19
|
"./lifecycle/node": "node",
|
|
20
20
|
"./lifecycle/yauzl": "node",
|
|
21
21
|
"./manifest": "universal",
|
|
22
|
-
"./persistent-state": "node"
|
|
22
|
+
"./persistent-state": "node",
|
|
23
|
+
"./folder-zip": "universal",
|
|
24
|
+
"./folder-zip/node": "node"
|
|
23
25
|
},
|
|
24
26
|
"isolation": {
|
|
25
27
|
"entries": {
|
|
@@ -65,6 +67,16 @@
|
|
|
65
67
|
"types": "./persistent-state.d.ts",
|
|
66
68
|
"import": "./persistent-state.js",
|
|
67
69
|
"default": "./persistent-state.js"
|
|
70
|
+
},
|
|
71
|
+
"./folder-zip": {
|
|
72
|
+
"types": "./dist/folder-zip.d.ts",
|
|
73
|
+
"import": "./dist/folder-zip.js",
|
|
74
|
+
"default": "./dist/folder-zip.js"
|
|
75
|
+
},
|
|
76
|
+
"./folder-zip/node": {
|
|
77
|
+
"types": "./dist/folder-zip-node.d.ts",
|
|
78
|
+
"import": "./dist/folder-zip-node.js",
|
|
79
|
+
"default": "./dist/folder-zip-node.js"
|
|
68
80
|
}
|
|
69
81
|
},
|
|
70
82
|
"files": [
|
package/ui-ux-design/plugin.json
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
|
|
3
3
|
"name": "ui-ux-design",
|
|
4
|
+
"version": "0.4.6",
|
|
4
5
|
"description": "UI/UX design, interface-design-system, accessibility, and shadcn/ui component skills bundled as one portable Agent Plugin.",
|
|
5
6
|
"extensions": {
|
|
6
7
|
"tovu": {
|