@jini-ai/agentic 0.4.4 → 0.4.5

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.
@@ -3,46 +3,57 @@ import { decodeSkillBase64, MAX_SKILL_BYTES, MAX_SKILL_FILES, MAX_SKILL_FILE_BYT
3
3
  export async function readSkillArchive({ base64, archiveReader }, _optional = {}) {
4
4
  try {
5
5
  const reader = await archiveReader.open({ bytes: decodeSkillBase64({ value: base64 }) });
6
- const files = [];
7
- let total = 0;
8
- let count = 0;
9
6
  try {
10
- for await (const entry of reader.entries) {
11
- if (++count > MAX_SKILL_FILES)
12
- throw new SkillInputError({ message: "A skill archive may contain at most 256 entries." });
13
- validateSkillPath({ filePath: entry.path.replace(/\/$/, "") });
14
- if (entry.kind !== "file" && entry.kind !== "directory")
15
- throw new SkillInputError({ message: "Skill archives may contain only regular files and directories." });
16
- if (entry.kind === "directory")
17
- continue;
18
- if (!Number.isSafeInteger(entry.size) || entry.size < 0 || entry.size > MAX_SKILL_FILE_BYTES)
19
- throw new SkillInputError({ message: "Skill archive file exceeds 1 MiB." });
20
- const chunks = [];
21
- let size = 0;
22
- {
23
- for await (const chunk of entry.read({})) {
24
- size += chunk.length;
25
- total += chunk.length;
26
- if (size > MAX_SKILL_FILE_BYTES || total > MAX_SKILL_BYTES)
27
- throw new SkillInputError({ message: "Skill archive exceeds its decompressed size limit." });
28
- chunks.push(Buffer.from(chunk));
29
- }
30
- }
31
- if (size !== entry.size)
32
- throw new SkillInputError({ message: "Skill archive file size does not match its metadata." });
33
- files.push({ path: entry.path, contentBase64: Buffer.concat(chunks).toString("base64") });
34
- }
35
- return files;
7
+ return await collectArchiveFiles({ entries: reader.entries });
36
8
  }
37
9
  finally {
38
10
  reader.close({});
39
11
  }
40
- /** Converts malformed archive parser failures into a client-actionable validation refusal. */
41
12
  }
42
13
  catch (error) {
14
+ // Converts malformed archive parser failures into a client-actionable validation refusal.
43
15
  if (error instanceof SkillInputError)
44
16
  throw error;
45
- throw new SkillInputError({ message: "Could not read skill ZIP. Use a valid ZIP with regular files only." });
17
+ throw new SkillInputError({ message: "Could not read skill ZIP. Use a valid ZIP with regular files only." }, { cause: error });
46
18
  }
47
19
  }
20
+ /** Walks every entry (directories count toward the entry limit) and reads the regular files.
21
+ * @complexity O(entries + decompressed bytes), bounded to 256 entries and 8 MiB.
22
+ */
23
+ async function collectArchiveFiles({ entries }) {
24
+ const files = [];
25
+ const budget = { total: 0 };
26
+ let count = 0;
27
+ for await (const entry of entries) {
28
+ if (++count > MAX_SKILL_FILES)
29
+ throw new SkillInputError({ message: "A skill archive may contain at most 256 entries." });
30
+ if (isRegularFileEntry({ entry }))
31
+ files.push(await readArchiveFile({ entry, budget }));
32
+ }
33
+ return files;
34
+ }
35
+ /** Validates the entry path before its kind; refuses anything but files and directories. */
36
+ function isRegularFileEntry({ entry }) {
37
+ validateSkillPath({ filePath: entry.path.replace(/\/$/, "") });
38
+ if (entry.kind !== "file" && entry.kind !== "directory")
39
+ throw new SkillInputError({ message: "Skill archives may contain only regular files and directories." });
40
+ return entry.kind === "file";
41
+ }
42
+ /** Streams one file under its own 1 MiB cap and the archive-wide budget, then checks metadata. */
43
+ async function readArchiveFile({ entry, budget }) {
44
+ if (!Number.isSafeInteger(entry.size) || entry.size < 0 || entry.size > MAX_SKILL_FILE_BYTES)
45
+ throw new SkillInputError({ message: "Skill archive file exceeds 1 MiB." });
46
+ const chunks = [];
47
+ let size = 0;
48
+ for await (const chunk of entry.read({})) {
49
+ size += chunk.length;
50
+ budget.total += chunk.length;
51
+ if (size > MAX_SKILL_FILE_BYTES || budget.total > MAX_SKILL_BYTES)
52
+ throw new SkillInputError({ message: "Skill archive exceeds its decompressed size limit." });
53
+ chunks.push(Buffer.from(chunk));
54
+ }
55
+ if (size !== entry.size)
56
+ throw new SkillInputError({ message: "Skill archive file size does not match its metadata." });
57
+ return { path: entry.path, contentBase64: Buffer.concat(chunks).toString("base64") };
58
+ }
48
59
  //# sourceMappingURL=archive.js.map
@@ -1,92 +1,131 @@
1
1
  /** Commit-pinned public GitHub import with one request/body deadline, byte caps and refused redirects. */
2
2
  import { MAX_SKILL_BYTES, MAX_SKILL_FILES, MAX_SKILL_FILE_BYTES, decodeSkillBase64, SkillInputError } from "./validation.js";
3
+ const URL_HELP = "Use an HTTPS GitHub repository URL, optionally ending in /tree/ref/skill-folder.";
4
+ const SHA_PATTERN = /^[a-f0-9]{40}$/;
5
+ const SEGMENT_PATTERN = /^[a-zA-Z0-9_.-]+$/;
6
+ const SKILL_FILE_PATTERN = /^(SKILL\.md|README\.md|LICENSE|(?:references|scripts|assets)\/.*)$/;
7
+ const REGULAR_FILE_MODES = ["100644", "100755"];
3
8
  /** Restricts every request to api.github.com, disallows redirects, pins one immutable commit.
4
9
  * @complexity O(files + downloaded bytes), at most 256 file requests and 8 MiB decoded content.
5
10
  */
6
11
  export async function fetchGitHubSkill({ githubUrl, fetch: fetchImpl }, _optional = {}) {
12
+ const { owner, repo, ref, prefix } = parseGitHubSkillUrl({ githubUrl });
13
+ const base = `https://api.github.com/repos/${owner}/${repo}`;
14
+ // One deadline for the whole fetch, including streaming response bodies.
15
+ const json = createGitHubJsonReader({ fetch: fetchImpl, signal: AbortSignal.timeout(30_000) });
16
+ const commit = (await json(`${base}/commits/${encodeURIComponent(ref)}`)).sha;
17
+ if (typeof commit !== "string" || !SHA_PATTERN.test(commit))
18
+ throw new SkillInputError({ message: "GitHub returned an invalid commit." });
19
+ const tree = await json(`${base}/git/trees/${commit}?recursive=1`);
20
+ if (tree.truncated || !Array.isArray(tree.tree))
21
+ throw new SkillInputError({ message: "GitHub repository tree is incomplete. Choose a smaller skill repository." });
22
+ const files = await downloadSkillFiles({ rows: tree.tree, prefix, base, json });
23
+ return { files, source: { githubUrl, commit } };
24
+ }
25
+ /** Accepts only https://github.com/owner/repo[/tree/ref[/folder…]] with plain segments; refuses before any request. */
26
+ function parseGitHubSkillUrl({ githubUrl }) {
7
27
  let url;
8
- try {
9
- url = new URL(githubUrl);
10
- }
11
- catch {
12
- throw new SkillInputError({ message: "Use an HTTPS GitHub repository URL, optionally ending in /tree/ref/skill-folder." });
13
- }
14
28
  let parts;
15
29
  try {
30
+ url = new URL(githubUrl);
16
31
  parts = url.pathname.replace(/\/$/, "").split("/").slice(1).map(p => decodeURIComponent(p));
17
32
  }
18
- catch {
19
- throw new SkillInputError({ message: "Use an HTTPS GitHub repository URL, optionally ending in /tree/ref/skill-folder." });
33
+ catch (error) {
34
+ throw new SkillInputError({ message: URL_HELP }, { cause: error });
20
35
  }
21
- if (url.protocol !== "https:" || url.hostname !== "github.com" || url.port || url.username || url.password || url.search || url.hash || parts.length < 2 || (parts.length > 2 && (parts[2] !== "tree" || parts.length < 4)) || parts.some(p => !/^[a-zA-Z0-9_.-]+$/.test(p) || p === "." || p === ".."))
22
- throw new SkillInputError({ message: "Use an HTTPS GitHub repository URL, optionally ending in /tree/ref/skill-folder." });
23
- const owner = parts[0];
36
+ if (!isGitHubOrigin({ url }) || !isSkillRepoPath({ parts }))
37
+ throw new SkillInputError({ message: URL_HELP });
24
38
  const repo = parts[1].replace(/\.git$/, "");
25
39
  if (!repo)
26
- throw new SkillInputError({ message: "Use an HTTPS GitHub repository URL, optionally ending in /tree/ref/skill-folder." });
27
- const base = `https://api.github.com/repos/${owner}/${repo}`;
28
- // One deadline for the whole fetch, including streaming response bodies.
29
- const signal = AbortSignal.timeout(30_000);
30
- async function json(endpoint) {
40
+ throw new SkillInputError({ message: URL_HELP });
41
+ return { owner: parts[0], repo, ref: parts[3] ?? "HEAD", prefix: parts.length > 4 ? parts.slice(4).join("/") + "/" : "" };
42
+ }
43
+ /** Plain https://github.com with no port, credentials, query or fragment. */
44
+ function isGitHubOrigin({ url }) {
45
+ return url.protocol === "https:" && url.hostname === "github.com" && !url.port && !url.username && !url.password && !url.search && !url.hash;
46
+ }
47
+ /** owner/repo, or owner/repo/tree/ref[/folder…]; every decoded segment is plain and never `.`/`..`. */
48
+ function isSkillRepoPath({ parts }) {
49
+ const shapeOk = parts.length === 2 || (parts.length >= 4 && parts[2] === "tree");
50
+ return shapeOk && parts.every(p => SEGMENT_PATTERN.test(p) && p !== "." && p !== "..");
51
+ }
52
+ /** One JSON endpoint reader sharing the redirect policy, accept header and deadline. */
53
+ function createGitHubJsonReader({ fetch: fetchImpl, signal }) {
54
+ return async (endpoint) => {
31
55
  const response = await fetchImpl({ url: endpoint }, { redirect: "error", signal, headers: { Accept: "application/vnd.github+json" } });
32
56
  if (!response.ok)
33
57
  throw new SkillInputError({ message: `GitHub could not fetch this skill (HTTP ${response.status}). Use a public repository URL.` });
34
58
  if (!response.body)
35
59
  throw new SkillInputError({ message: "GitHub returned an empty response." });
36
- const reader = response.body.getReader();
37
- let bytes = 0;
38
- const chunks = [];
39
- try {
40
- for (;;) {
41
- const part = await reader.read();
42
- if (part.done)
43
- break;
44
- bytes += part.value.length;
45
- if (bytes > MAX_SKILL_BYTES * 2)
46
- throw new SkillInputError({ message: "GitHub skill response exceeds its size limit." });
47
- chunks.push(part.value);
48
- }
49
- }
50
- finally {
51
- await reader.cancel();
52
- }
53
- const result = JSON.parse(Buffer.concat(chunks).toString("utf8"));
60
+ const result = JSON.parse((await readBoundedBody({ body: response.body })).toString("utf8"));
54
61
  if (!result || typeof result !== "object" || Array.isArray(result))
55
62
  throw new SkillInputError({ message: "GitHub returned an invalid skill response." });
56
63
  return result;
64
+ };
65
+ }
66
+ /** Streams a response body up to 16 MiB and always releases the reader. */
67
+ async function readBoundedBody({ body }) {
68
+ const reader = body.getReader();
69
+ let bytes = 0;
70
+ const chunks = [];
71
+ try {
72
+ for (;;) {
73
+ const part = await reader.read();
74
+ if (part.done)
75
+ break;
76
+ bytes += part.value.length;
77
+ if (bytes > MAX_SKILL_BYTES * 2)
78
+ throw new SkillInputError({ message: "GitHub skill response exceeds its size limit." });
79
+ chunks.push(part.value);
80
+ }
57
81
  }
58
- const commit = (await json(`${base}/commits/${encodeURIComponent(parts[3] ?? "HEAD")}`)).sha;
59
- if (typeof commit !== "string" || !/^[a-f0-9]{40}$/.test(commit))
60
- throw new SkillInputError({ message: "GitHub returned an invalid commit." });
61
- const tree = await json(`${base}/git/trees/${commit}?recursive=1`);
62
- if (tree.truncated || !Array.isArray(tree.tree))
63
- throw new SkillInputError({ message: "GitHub repository tree is incomplete. Choose a smaller skill repository." });
64
- const prefix = parts.length > 4 ? parts.slice(4).join("/") + "/" : "";
82
+ finally {
83
+ await reader.cancel();
84
+ }
85
+ return Buffer.concat(chunks);
86
+ }
87
+ /** Checks each selected row, then fetches its blob, in tree order, under declared and decoded budgets. */
88
+ async function downloadSkillFiles({ rows, prefix, base, json }) {
65
89
  const files = [];
66
- let total = 0;
67
- let actualBytes = 0;
68
- for (const row of tree.tree) {
69
- if (typeof row.path !== "string" || !row.path.startsWith(prefix))
90
+ const budget = { declared: 0, actual: 0 };
91
+ for (const row of rows) {
92
+ const relative = selectSkillRow({ row, prefix });
93
+ if (relative === undefined)
70
94
  continue;
71
- const relative = row.path.slice(prefix.length);
72
- if (!/^(SKILL\.md|README\.md|LICENSE|(?:references|scripts|assets)\/.*)$/.test(relative) || row.type === "tree")
73
- continue;
74
- if (row.type !== "blob" || !["100644", "100755"].includes(String(row.mode)))
75
- throw new SkillInputError({ message: "GitHub skills may contain only regular files." });
76
- if (typeof row.size !== "number" || row.size < 0 || row.size > MAX_SKILL_FILE_BYTES || typeof row.sha !== "string" || !/^[a-f0-9]{40}$/.test(row.sha))
77
- throw new SkillInputError({ message: "GitHub skill file exceeds its size limit or has invalid metadata." });
78
- total += row.size;
79
- if (total > MAX_SKILL_BYTES || files.length >= MAX_SKILL_FILES)
80
- throw new SkillInputError({ message: "GitHub skill exceeds 8 MiB or 256 files." });
81
- const blob = await json(`${base}/git/blobs/${row.sha}`);
82
- if (blob.encoding !== "base64" || typeof blob.content !== "string")
83
- throw new SkillInputError({ message: "GitHub returned an invalid skill file." });
84
- const contentBase64 = blob.content.replace(/\s/g, "");
85
- actualBytes += decodeSkillBase64({ value: contentBase64 }, { maxBytes: MAX_SKILL_FILE_BYTES }).length;
86
- if (actualBytes > MAX_SKILL_BYTES)
95
+ const { size, sha } = readRegularBlobRow({ row });
96
+ budget.declared += size;
97
+ if (budget.declared > MAX_SKILL_BYTES || files.length >= MAX_SKILL_FILES)
87
98
  throw new SkillInputError({ message: "GitHub skill exceeds 8 MiB or 256 files." });
88
- files.push({ path: relative, contentBase64 });
99
+ files.push({ path: relative, contentBase64: await downloadBlob({ url: `${base}/git/blobs/${sha}`, json, budget }) });
89
100
  }
90
- return { files, source: { githubUrl, commit } };
101
+ return files;
102
+ }
103
+ /** Path relative to the skill folder when the row is an allowed skill file, else undefined (skipped). */
104
+ function selectSkillRow({ row, prefix }) {
105
+ if (typeof row.path !== "string" || !row.path.startsWith(prefix))
106
+ return undefined;
107
+ const relative = row.path.slice(prefix.length);
108
+ if (!SKILL_FILE_PATTERN.test(relative) || row.type === "tree")
109
+ return undefined;
110
+ return relative;
111
+ }
112
+ /** Refuses non-regular entries (submodules, symlinks) and missing or oversized metadata. */
113
+ function readRegularBlobRow({ row }) {
114
+ if (row.type !== "blob" || !REGULAR_FILE_MODES.includes(String(row.mode)))
115
+ throw new SkillInputError({ message: "GitHub skills may contain only regular files." });
116
+ if (typeof row.size !== "number" || row.size < 0 || row.size > MAX_SKILL_FILE_BYTES || typeof row.sha !== "string" || !SHA_PATTERN.test(row.sha))
117
+ throw new SkillInputError({ message: "GitHub skill file exceeds its size limit or has invalid metadata." });
118
+ return { size: row.size, sha: row.sha };
119
+ }
120
+ /** Fetches one base64 blob, enforcing the 1 MiB file cap and 8 MiB decoded total. */
121
+ async function downloadBlob({ url, json, budget }) {
122
+ const blob = await json(url);
123
+ if (blob.encoding !== "base64" || typeof blob.content !== "string")
124
+ throw new SkillInputError({ message: "GitHub returned an invalid skill file." });
125
+ const contentBase64 = blob.content.replace(/\s/g, "");
126
+ budget.actual += decodeSkillBase64({ value: contentBase64 }, { maxBytes: MAX_SKILL_FILE_BYTES }).length;
127
+ if (budget.actual > MAX_SKILL_BYTES)
128
+ throw new SkillInputError({ message: "GitHub skill exceeds 8 MiB or 256 files." });
129
+ return contentBase64;
91
130
  }
92
131
  //# sourceMappingURL=github.js.map
@@ -9,8 +9,8 @@ import path from 'node:path';
9
9
  const SAFE_ID_SEGMENT_PATTERN = /^[a-z0-9]+(?:[-.][a-z0-9]+)*$/;
10
10
  /** Bounds the segment so a caller cannot drive a pathological path length. */
11
11
  const MAX_ID_SEGMENT_LENGTH = 64;
12
- // The segment grammar above moved from Tovu features/skills/layout.ts; the sibling path in its
13
- // historical rationale refers to Tovu features/agent-plugins/layout.ts.
12
+ // The segment grammar above moved from host features/skills/layout.ts; the sibling path in its
13
+ // historical rationale refers to host features/agent-plugins/layout.ts.
14
14
  /**
15
15
  * Deliberately does NOT import anything from `src/features/agent-plugins/` — Agent Skills and Agent
16
16
  * Plugins are different things with their own directories (owner decision, above), and this file's
@@ -19,7 +19,7 @@ const MAX_ID_SEGMENT_LENGTH = 64;
19
19
  * between themselves: a private guard belonging to one small module, not a promise to keep two
20
20
  * independent features' internals in sync.
21
21
  */
22
- // This paragraph records the original Tovu module boundary; the private segment guard now
22
+ // This paragraph records the original host module boundary; the private segment guard now
23
23
  // belongs to this Jini layout owner, and the host keeps only the site-directory adapter.
24
24
  /** Every storage location is host-owned; this adapter has no product or cwd defaults.
25
25
  * Layout resolves the same required root/stagingRoot/workspaceDirectory/stateFileName
@@ -33,7 +33,9 @@ export function createNodeSkillFilesystem(_required, _optional = {}) {
33
33
  await handle.close();
34
34
  }
35
35
  },
36
- writeFile: ({ path, bytes, exclusive, mode }) => writeFile(path, bytes, { flag: exclusive ? 'wx' : 'w', mode }),
36
+ // The port types `exclusive` as the literal `true`: skill files and state temporaries are only
37
+ // ever created, never overwritten in place, so the flag is always exclusive-create.
38
+ writeFile: ({ path, bytes, mode }) => writeFile(path, bytes, { flag: 'wx', mode }),
37
39
  rename: ({ from, to }) => rename(from, to),
38
40
  remove: ({ path }, options = {}) => rm(path, options),
39
41
  };
@@ -1,17 +1,19 @@
1
1
  import path from "node:path";
2
+ const MAX_STATE_BYTES = 8192;
3
+ const INVALID_STATE = "Invalid skill installation record.";
2
4
  /** Missing legacy records default to enabled; malformed records and symlinks fail closed. */
3
5
  export async function readSkillState(required, _optional = {}) {
4
6
  const statePath = path.join(required.directory, required.stateFileName);
5
7
  try {
6
8
  const info = await required.filesystem.lstat({ path: statePath });
7
- if (info.kind !== "file" || info.size > 8192)
8
- throw new Error("Invalid skill installation record.");
9
- const bytes = await required.filesystem.readFile({ path: statePath, maxBytes: 8192 });
10
- if (bytes.length > 8192)
11
- throw new Error("Invalid skill installation record.");
9
+ if (info.kind !== "file" || info.size > MAX_STATE_BYTES)
10
+ throw new Error(INVALID_STATE);
11
+ const bytes = await required.filesystem.readFile({ path: statePath, maxBytes: MAX_STATE_BYTES });
12
+ if (bytes.length > MAX_STATE_BYTES)
13
+ throw new Error(INVALID_STATE);
12
14
  const state = JSON.parse(Buffer.from(bytes).toString("utf8"));
13
- if (!state || typeof state.enabled !== "boolean" || (state.source !== "uploaded" && (!state.source || typeof state.source.githubUrl !== "string" || !/^[a-f0-9]{40}$/.test(state.source.commit))))
14
- throw new Error("Invalid skill installation record.");
15
+ if (!isSkillState(state))
16
+ throw new Error(INVALID_STATE);
15
17
  return state;
16
18
  }
17
19
  catch (error) {
@@ -20,4 +22,13 @@ export async function readSkillState(required, _optional = {}) {
20
22
  throw error;
21
23
  }
22
24
  }
25
+ /** A boolean `enabled` plus either "uploaded" or a GitHub source pinned to a 40-hex commit. */
26
+ function isSkillState(value) {
27
+ const state = value;
28
+ if (!state || typeof state.enabled !== "boolean")
29
+ return false;
30
+ if (state.source === "uploaded")
31
+ return true;
32
+ return !!state.source && typeof state.source.githubUrl === "string" && /^[a-f0-9]{40}$/.test(state.source.commit);
33
+ }
23
34
  //# sourceMappingURL=state.js.map
@@ -10,6 +10,10 @@ export const MAX_SKILL_FILES = 256;
10
10
  export const MAX_SKILL_FILE_BYTES = 1024 * 1024;
11
11
  const TEXT_EXTENSIONS = new Set([".md", ".txt", ".json", ".yaml", ".yml", ".csv", ".svg", ".sh", ".py", ".js", ".ts", ".mjs"]);
12
12
  const ASSET_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".webp"]);
13
+ const ROOT_FILES = new Set(["SKILL.md", "README.md", "LICENSE"]);
14
+ const SKILL_FOLDERS = new Set(["references", "scripts", "assets"]);
15
+ const PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
16
+ const MISSING_FRONTMATTER = "SKILL.md must have YAML frontmatter with a name and description.";
13
17
  /** Decodes bounded canonical base64; malformed encodings never silently turn into empty files. */
14
18
  export function decodeSkillBase64({ value }, { maxBytes = MAX_SKILL_BYTES } = {}) {
15
19
  if (typeof value !== "string" || value.length > Math.ceil(maxBytes / 3) * 4)
@@ -27,24 +31,28 @@ export function validateSkillPath({ filePath }, _optional = {}) {
27
31
  }
28
32
  /** Strict frontmatter for new installs; the legacy disk loader remains backwards compatible. */
29
33
  export function validateSkillMarkdown({ markdown, yamlReader }, _optional = {}) {
34
+ const { name, description } = readFrontmatterFields({ markdown, yamlReader });
35
+ if (typeof name !== "string" || typeof description !== "string" || !description.trim())
36
+ throw new SkillInputError({ message: MISSING_FRONTMATTER });
37
+ if (name.length > 64 || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name))
38
+ throw new SkillInputError({ message: "Skill name must use lowercase letters, digits and single hyphens (1–64 characters)." });
39
+ if (description.length > 1024)
40
+ throw new SkillInputError({ message: "Skill description exceeds 1024 characters." });
41
+ return { name, description: description.replace(/\s+/g, " ").trim() };
42
+ }
43
+ /** Parses the leading `---` block through the host YAML port; a non-object result has no fields. */
44
+ function readFrontmatterFields({ markdown, yamlReader }) {
30
45
  const frontmatter = markdown.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
31
46
  if (!frontmatter)
32
- throw new SkillInputError({ message: "SKILL.md must have YAML frontmatter with a name and description." });
47
+ throw new SkillInputError({ message: MISSING_FRONTMATTER });
33
48
  let fields;
34
49
  try {
35
50
  fields = yamlReader.read({ yaml: frontmatter[1] });
36
51
  }
37
- catch {
38
- throw new SkillInputError({ message: "SKILL.md must have valid YAML frontmatter." });
52
+ catch (error) {
53
+ throw new SkillInputError({ message: "SKILL.md must have valid YAML frontmatter." }, { cause: error });
39
54
  }
40
- const { name, description } = (fields && typeof fields === "object" ? fields : {});
41
- if (typeof name !== "string" || typeof description !== "string" || !description.trim())
42
- throw new SkillInputError({ message: "SKILL.md must have YAML frontmatter with a name and description." });
43
- if (name.length > 64 || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name))
44
- throw new SkillInputError({ message: "Skill name must use lowercase letters, digits and single hyphens (1–64 characters)." });
45
- if (description.length > 1024)
46
- throw new SkillInputError({ message: "Skill description exceeds 1024 characters." });
47
- return { name, description: description.replace(/\s+/g, " ").trim() };
55
+ return (fields && typeof fields === "object" ? fields : {});
48
56
  }
49
57
  /** Validates the whole package before writes, including cross-file collisions and combined limits.
50
58
  * @complexity O(total bytes + files × path length), bounded to 8 MiB / 256 files.
@@ -54,51 +62,76 @@ export function validateSkillFiles({ files: upload, yamlReader }, _optional = {}
54
62
  throw new SkillInputError({ message: "A skill must contain 1–256 files." });
55
63
  for (const file of upload)
56
64
  validateSkillPath({ filePath: file.path });
65
+ const prefix = selectSkillFolder({ upload });
66
+ const files = new Map();
67
+ const budget = { total: 0 };
68
+ for (const file of upload) {
69
+ const { relative, bytes } = validateBundleFile({ file, prefix, seen: files, budget });
70
+ files.set(relative, bytes);
71
+ }
72
+ return { ...validateSkillMarkdown({ markdown: files.get("SKILL.md").toString("utf8"), yamlReader }), files };
73
+ }
74
+ /** The folder holding the bundle's only SKILL.md (empty string at the root). */
75
+ function selectSkillFolder({ upload }) {
57
76
  const skillFiles = upload.filter(f => f.path === "SKILL.md" || f.path.endsWith("/SKILL.md"));
58
77
  if (skillFiles.length !== 1) {
59
78
  if (skillFiles.length > 1 && new Set(skillFiles.map(f => f.path)).size === 1)
60
79
  throw new SkillInputError({ message: `Duplicate skill file path: ${skillFiles[0].path}` });
61
80
  throw new SkillInputError({ message: "Choose one skill folder containing a single SKILL.md." });
62
81
  }
63
- const prefix = skillFiles[0].path.slice(0, -"SKILL.md".length);
64
- const files = new Map();
65
- let total = 0;
66
- for (const file of upload) {
67
- if (!file.path.startsWith(prefix))
68
- throw new SkillInputError({ message: "All files must belong to the selected skill folder." });
69
- const relative = file.path.slice(prefix.length);
70
- validateSkillPath({ filePath: relative });
71
- if (files.has(relative))
72
- throw new SkillInputError({ message: `Duplicate skill file path: ${relative}` });
73
- const extension = path.posix.extname(relative).toLowerCase();
74
- const top = relative.split("/")[0];
75
- if (relative !== "SKILL.md" && relative !== "README.md" && relative !== "LICENSE" && !["references", "scripts", "assets"].includes(top))
76
- throw new SkillInputError({ message: `Unsupported skill file location: ${relative}` });
77
- if (relative !== "LICENSE" && !TEXT_EXTENSIONS.has(extension) && !(top === "assets" && ASSET_EXTENSIONS.has(extension)))
78
- throw new SkillInputError({ message: `Unsupported skill file type: ${relative}` });
79
- const bytes = decodeSkillBase64({ value: file.contentBase64 }, { maxBytes: MAX_SKILL_FILE_BYTES });
80
- if (relative === "SKILL.md" && bytes.length > 128 * 1024)
81
- throw new SkillInputError({ message: "SKILL.md exceeds 128 KiB." });
82
- total += bytes.length;
83
- if (total > MAX_SKILL_BYTES)
84
- throw new SkillInputError({ message: "Skill files exceed 8 MiB combined." });
85
- if (TEXT_EXTENSIONS.has(extension) || relative === "LICENSE") {
86
- try {
87
- new TextDecoder("utf-8", { fatal: true }).decode(bytes);
88
- }
89
- catch {
90
- throw new SkillInputError({ message: `Skill text file must be valid UTF-8 without null bytes: ${relative}` });
91
- }
92
- if (bytes.includes(0))
93
- throw new SkillInputError({ message: `Skill text file must be valid UTF-8 without null bytes: ${relative}` });
94
- }
95
- else {
96
- const valid = extension === ".png" ? bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])) : extension === ".webp" ? bytes.toString("ascii", 0, 4) === "RIFF" && bytes.toString("ascii", 8, 12) === "WEBP" : bytes[0] === 255 && bytes[1] === 216 && bytes[2] === 255;
97
- if (!valid)
98
- throw new SkillInputError({ message: `Skill asset content does not match its file type: ${relative}` });
99
- }
100
- files.set(relative, bytes);
82
+ return skillFiles[0].path.slice(0, -"SKILL.md".length);
83
+ }
84
+ /** Checks one upload's folder, path, location, type, sizes and content; returns its folder-relative key. */
85
+ function validateBundleFile({ file, prefix, seen, budget }) {
86
+ if (!file.path.startsWith(prefix))
87
+ throw new SkillInputError({ message: "All files must belong to the selected skill folder." });
88
+ const relative = file.path.slice(prefix.length);
89
+ validateSkillPath({ filePath: relative });
90
+ if (seen.has(relative))
91
+ throw new SkillInputError({ message: `Duplicate skill file path: ${relative}` });
92
+ const { extension, isText } = classifySkillFile({ relative });
93
+ const bytes = decodeSkillBase64({ value: file.contentBase64 }, { maxBytes: MAX_SKILL_FILE_BYTES });
94
+ if (relative === "SKILL.md" && bytes.length > 128 * 1024)
95
+ throw new SkillInputError({ message: "SKILL.md exceeds 128 KiB." });
96
+ budget.total += bytes.length;
97
+ if (budget.total > MAX_SKILL_BYTES)
98
+ throw new SkillInputError({ message: "Skill files exceed 8 MiB combined." });
99
+ if (isText)
100
+ assertTextContent({ relative, bytes });
101
+ else if (!matchesAssetSignature({ extension, bytes }))
102
+ throw new SkillInputError({ message: `Skill asset content does not match its file type: ${relative}` });
103
+ return { relative, bytes };
104
+ }
105
+ /** Allowed locations are SKILL.md, README.md, LICENSE and the references/scripts/assets folders;
106
+ * text extensions anywhere there, image extensions only under assets/. */
107
+ function classifySkillFile({ relative }) {
108
+ const extension = path.posix.extname(relative).toLowerCase();
109
+ const top = relative.split("/")[0];
110
+ if (!ROOT_FILES.has(relative) && !SKILL_FOLDERS.has(top))
111
+ throw new SkillInputError({ message: `Unsupported skill file location: ${relative}` });
112
+ const isText = relative === "LICENSE" || TEXT_EXTENSIONS.has(extension);
113
+ if (!isText && !(top === "assets" && ASSET_EXTENSIONS.has(extension)))
114
+ throw new SkillInputError({ message: `Unsupported skill file type: ${relative}` });
115
+ return { extension, isText };
116
+ }
117
+ /** Text files must be strict UTF-8 with no NUL bytes. */
118
+ function assertTextContent({ relative, bytes }) {
119
+ const message = `Skill text file must be valid UTF-8 without null bytes: ${relative}`;
120
+ try {
121
+ new TextDecoder("utf-8", { fatal: true }).decode(bytes);
101
122
  }
102
- return { ...validateSkillMarkdown({ markdown: files.get("SKILL.md").toString("utf8"), yamlReader }), files };
123
+ catch (error) {
124
+ throw new SkillInputError({ message }, { cause: error });
125
+ }
126
+ if (bytes.includes(0))
127
+ throw new SkillInputError({ message });
128
+ }
129
+ /** Magic-byte sniffing for the allowed image types (.png, .webp, otherwise JPEG). */
130
+ function matchesAssetSignature({ extension, bytes }) {
131
+ if (extension === ".png")
132
+ return bytes.subarray(0, 8).equals(PNG_SIGNATURE);
133
+ if (extension === ".webp")
134
+ return bytes.toString("ascii", 0, 4) === "RIFF" && bytes.toString("ascii", 8, 12) === "WEBP";
135
+ return bytes[0] === 255 && bytes[1] === 216 && bytes[2] === 255;
103
136
  }
104
137
  //# sourceMappingURL=validation.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jini-ai/agentic",
3
- "version": "0.4.4",
3
+ "version": "0.4.5",
4
4
  "description": "Framework-free agent-control layer: page-capability vocabulary and manifests, the data-agent-* markup convention, AG-UI projection, generative-UI streams, and A2UI.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -77,7 +77,7 @@
77
77
  "jsdom": "^25.0.1",
78
78
  "zod": "^3.25.76",
79
79
  "@jini-ai/core": "^0.4.0",
80
- "@jini-ai/protocol": "^0.4.4"
80
+ "@jini-ai/protocol": "^0.4.5"
81
81
  },
82
82
  "peerDependencies": {
83
83
  "@jini-ai/protocol": "^0.4.4",