pilotswarm-sdk 0.5.28 → 0.5.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/api/src/api-client.js +10 -1
  2. package/api/src/http-api-transport.js +22 -2
  3. package/api/src/protocol.js +25 -0
  4. package/dist/agent-loader.d.ts +8 -0
  5. package/dist/agent-loader.d.ts.map +1 -1
  6. package/dist/agent-loader.js +23 -0
  7. package/dist/agent-loader.js.map +1 -1
  8. package/dist/agent-package-format.d.ts +175 -0
  9. package/dist/agent-package-format.d.ts.map +1 -0
  10. package/dist/agent-package-format.js +812 -0
  11. package/dist/agent-package-format.js.map +1 -0
  12. package/dist/agent-package-installer.d.ts +64 -0
  13. package/dist/agent-package-installer.d.ts.map +1 -0
  14. package/dist/agent-package-installer.js +148 -0
  15. package/dist/agent-package-installer.js.map +1 -0
  16. package/dist/agent-package-service.d.ts +61 -0
  17. package/dist/agent-package-service.d.ts.map +1 -0
  18. package/dist/agent-package-service.js +181 -0
  19. package/dist/agent-package-service.js.map +1 -0
  20. package/dist/client.d.ts +6 -0
  21. package/dist/client.d.ts.map +1 -1
  22. package/dist/client.js +28 -0
  23. package/dist/client.js.map +1 -1
  24. package/dist/cms-migrations.d.ts.map +1 -1
  25. package/dist/cms-migrations.js +799 -0
  26. package/dist/cms-migrations.js.map +1 -1
  27. package/dist/cms.d.ts +224 -0
  28. package/dist/cms.d.ts.map +1 -1
  29. package/dist/cms.js +262 -0
  30. package/dist/cms.js.map +1 -1
  31. package/dist/index.d.ts +12 -2
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.js +11 -2
  34. package/dist/index.js.map +1 -1
  35. package/dist/management-client.d.ts +15 -0
  36. package/dist/management-client.d.ts.map +1 -1
  37. package/dist/management-client.js +38 -3
  38. package/dist/management-client.js.map +1 -1
  39. package/dist/mcp-loader.d.ts.map +1 -1
  40. package/dist/mcp-loader.js +17 -1
  41. package/dist/mcp-loader.js.map +1 -1
  42. package/dist/session-manager.d.ts +2 -0
  43. package/dist/session-manager.d.ts.map +1 -1
  44. package/dist/session-manager.js +11 -1
  45. package/dist/session-manager.js.map +1 -1
  46. package/dist/tool-pinning.d.ts +27 -0
  47. package/dist/tool-pinning.d.ts.map +1 -0
  48. package/dist/tool-pinning.js +31 -0
  49. package/dist/tool-pinning.js.map +1 -0
  50. package/dist/types.d.ts +27 -0
  51. package/dist/types.d.ts.map +1 -1
  52. package/dist/types.js.map +1 -1
  53. package/dist/web/api-connection.d.ts +8 -0
  54. package/dist/web/api-connection.d.ts.map +1 -1
  55. package/dist/web/api-connection.js +2 -0
  56. package/dist/web/api-connection.js.map +1 -1
  57. package/dist/web/generated-op-methods.d.ts +917 -0
  58. package/dist/web/generated-op-methods.d.ts.map +1 -0
  59. package/dist/web/generated-op-methods.js +255 -0
  60. package/dist/web/generated-op-methods.js.map +1 -0
  61. package/dist/web/web-management-client.d.ts +191 -8
  62. package/dist/web/web-management-client.d.ts.map +1 -1
  63. package/dist/web/web-management-client.js +164 -12
  64. package/dist/web/web-management-client.js.map +1 -1
  65. package/dist/worker.d.ts +69 -0
  66. package/dist/worker.d.ts.map +1 -1
  67. package/dist/worker.js +319 -11
  68. package/dist/worker.js.map +1 -1
  69. package/package.json +7 -6
@@ -0,0 +1,812 @@
1
+ /**
2
+ * Agent package format — canonical packing, validation, and identity.
3
+ *
4
+ * An agent package is a plugin directory (plugin.json + agents/ + skills/ +
5
+ * .mcp.json) plus, uniquely, code: tools/worker-module.js and mcp-servers/**.
6
+ * Every source kind (github/ado/url/upload) normalizes through this module:
7
+ * validate → canonical tar.gz → sha256. The tarball is canonical — packing
8
+ * the same content always yields byte-identical output — because the sha256
9
+ * carries no-op elision at publish time and download verification on workers.
10
+ *
11
+ * The tar writer/reader here is deliberately hand-rolled ustar rather than a
12
+ * spawn of system tar: GNU and bsdtar disagree on ordering, mtime, and header
13
+ * details, and canonical bytes are a correctness property, not a convenience.
14
+ * See docs/proposals/agent-packages.md.
15
+ *
16
+ * plugin.json IS the package manifest. Identity (name/version/description)
17
+ * is required; a package may ADDITIONALLY declare its artifact layout —
18
+ * agents/skills/mcpConfig/mcpServers/tools/include, every path relative to
19
+ * the manifest — and lay files out however it likes. Publishing stages the
20
+ * declared artifacts into the canonical layout (agents/, skills/, .mcp.json,
21
+ * mcp-servers/, tools/worker-module.js) and rewrites plugin.json with the
22
+ * canonicalized paths, so the runtime and every downstream consumer see one
23
+ * layout. Packages without layout fields keep today's convention scan,
24
+ * byte-for-byte (no silent sha changes for existing packages).
25
+ */
26
+ import * as fs from "fs";
27
+ import * as path from "path";
28
+ import * as zlib from "zlib";
29
+ import * as os from "os";
30
+ import * as crypto from "crypto";
31
+ import { spawn } from "child_process";
32
+ import { loadAgentFiles } from "./agent-loader.js";
33
+ import { loadSkillsSync } from "./skills.js";
34
+ // ─── Reserved artifact identity ──────────────────────────────────
35
+ /**
36
+ * Deterministic UUID reserving the artifact-store "session" that holds every
37
+ * agent-package tarball. Same derivation shape as systemAgentUUID, distinct
38
+ * prefix so the namespaces can never collide.
39
+ */
40
+ export function agentPackagesArtifactSessionId() {
41
+ const hash = crypto.createHash("sha256")
42
+ .update("pilotswarm-agent-packages:artifacts")
43
+ .digest("hex");
44
+ return [
45
+ hash.slice(0, 8),
46
+ hash.slice(8, 12),
47
+ hash.slice(12, 16),
48
+ hash.slice(16, 20),
49
+ hash.slice(20, 32),
50
+ ].join("-");
51
+ }
52
+ /**
53
+ * Canonical artifact filename for a published package version. The sha12
54
+ * suffix makes the blob content-addressed: a publish that loses the
55
+ * same-semver race wrote a DIFFERENT blob than the winner, so its cleanup
56
+ * can never clobber the winning version's bytes.
57
+ */
58
+ export function agentPackageArtifactFilename(name, semver, sha256) {
59
+ return `${name}@${semver}.${sha256.slice(0, 12)}.tar.gz`;
60
+ }
61
+ // ─── Agent-name normalization (mirror of resolveAgentConfig) ─────
62
+ /**
63
+ * The runtime resolver matches agents case/punctuation-insensitively and
64
+ * also tries a trailing-"agent"-stripped variant (session-proxy.ts
65
+ * resolveAgentConfig). Collision guards MUST use the same normalization or
66
+ * "Swee-per" shadows "sweeper" at runtime while validating clean.
67
+ */
68
+ export function normalizeAgentName(value) {
69
+ return (value || "").toLowerCase().replace(/[^a-z0-9]+/g, "");
70
+ }
71
+ function agentNameVariants(value) {
72
+ const normalized = normalizeAgentName(value);
73
+ const stripped = normalized.replace(/agent$/, "");
74
+ return stripped && stripped !== normalized ? [normalized, stripped] : [normalized];
75
+ }
76
+ // ─── Minimal semver (concrete versions only, full precedence) ────
77
+ const SEMVER_RE = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/;
78
+ export function isValidSemver(version) {
79
+ return typeof version === "string" && SEMVER_RE.test(version);
80
+ }
81
+ /** Standard semver precedence: -1 / 0 / 1. Build metadata ignored. */
82
+ export function compareSemver(a, b) {
83
+ const ma = SEMVER_RE.exec(a);
84
+ const mb = SEMVER_RE.exec(b);
85
+ if (!ma || !mb)
86
+ throw new Error(`compareSemver: invalid semver (${a}, ${b})`);
87
+ for (let i = 1; i <= 3; i++) {
88
+ const na = Number(ma[i]);
89
+ const nb = Number(mb[i]);
90
+ if (na !== nb)
91
+ return na < nb ? -1 : 1;
92
+ }
93
+ const pa = ma[4];
94
+ const pb = mb[4];
95
+ if (pa === undefined && pb === undefined)
96
+ return 0;
97
+ if (pa === undefined)
98
+ return 1; // release > prerelease
99
+ if (pb === undefined)
100
+ return -1;
101
+ const ia = pa.split(".");
102
+ const ib = pb.split(".");
103
+ for (let i = 0; i < Math.max(ia.length, ib.length); i++) {
104
+ const xa = ia[i];
105
+ const xb = ib[i];
106
+ if (xa === undefined)
107
+ return -1; // shorter prerelease sorts lower
108
+ if (xb === undefined)
109
+ return 1;
110
+ const nxa = /^\d+$/.test(xa);
111
+ const nxb = /^\d+$/.test(xb);
112
+ if (nxa && nxb) {
113
+ const da = Number(xa);
114
+ const db = Number(xb);
115
+ if (da !== db)
116
+ return da < db ? -1 : 1;
117
+ }
118
+ else if (nxa !== nxb) {
119
+ return nxa ? -1 : 1; // numeric identifiers sort below alphanumeric
120
+ }
121
+ else if (xa !== xb) {
122
+ return xa < xb ? -1 : 1;
123
+ }
124
+ }
125
+ return 0;
126
+ }
127
+ const TAR_BLOCK = 512;
128
+ function tarHeaderChecksum(header) {
129
+ let sum = 0;
130
+ for (let i = 0; i < TAR_BLOCK; i++) {
131
+ sum += (i >= 148 && i < 156) ? 0x20 : header[i];
132
+ }
133
+ return sum;
134
+ }
135
+ function writeOctal(header, offset, length, value) {
136
+ const text = value.toString(8).padStart(length - 1, "0");
137
+ if (text.length > length - 1) {
138
+ throw new Error(`tar field overflow: ${value} does not fit ${length - 1} octal digits`);
139
+ }
140
+ header.write(text, offset, "ascii");
141
+ header[offset + length - 1] = 0;
142
+ }
143
+ /** Split a path into ustar (prefix, name) fields; throws when unsplittable. */
144
+ function splitUstarName(name) {
145
+ if (Buffer.byteLength(name) <= 100)
146
+ return { prefix: "", name };
147
+ // Split at a "/" so name ≤ 100 and prefix ≤ 155.
148
+ for (let i = name.length - 1; i > 0; i--) {
149
+ if (name[i] !== "/")
150
+ continue;
151
+ const prefix = name.slice(0, i);
152
+ const rest = name.slice(i + 1);
153
+ if (Buffer.byteLength(rest) <= 100 && Buffer.byteLength(prefix) <= 155) {
154
+ return { prefix, name: rest };
155
+ }
156
+ }
157
+ throw new Error(`path too long for ustar: ${name}`);
158
+ }
159
+ function tarHeader(entry) {
160
+ const header = Buffer.alloc(TAR_BLOCK);
161
+ const isDir = entry.name.endsWith("/");
162
+ const { prefix, name } = splitUstarName(entry.name);
163
+ header.write(name, 0, "ascii");
164
+ writeOctal(header, 100, 8, entry.mode); // mode
165
+ writeOctal(header, 108, 8, 0); // uid
166
+ writeOctal(header, 116, 8, 0); // gid
167
+ writeOctal(header, 124, 12, entry.body?.length ?? 0);
168
+ writeOctal(header, 136, 12, 0); // mtime — canonical zero
169
+ header.write(isDir ? "5" : "0", 156, "ascii"); // typeflag
170
+ header.write("ustar", 257, "ascii"); // magic
171
+ header[262] = 0;
172
+ header.write("00", 263, "ascii"); // version
173
+ // uname/gname left empty for determinism.
174
+ if (prefix)
175
+ header.write(prefix, 345, "ascii");
176
+ const checksum = tarHeaderChecksum(header);
177
+ header.write(checksum.toString(8).padStart(6, "0"), 148, "ascii");
178
+ header[154] = 0;
179
+ header[155] = 0x20;
180
+ return header;
181
+ }
182
+ function buildTar(entries) {
183
+ const chunks = [];
184
+ for (const entry of entries) {
185
+ chunks.push(tarHeader(entry));
186
+ if (entry.body && entry.body.length > 0) {
187
+ chunks.push(entry.body);
188
+ const pad = entry.body.length % TAR_BLOCK;
189
+ if (pad !== 0)
190
+ chunks.push(Buffer.alloc(TAR_BLOCK - pad));
191
+ }
192
+ }
193
+ chunks.push(Buffer.alloc(TAR_BLOCK * 2)); // end-of-archive
194
+ return Buffer.concat(chunks);
195
+ }
196
+ /**
197
+ * Read a canonical package tar.gz. Strict by design: only regular files and
198
+ * directories, no links of any kind, no absolute paths, no "..", no pax
199
+ * extensions — canonical tars are produced exclusively by packAgentPackage.
200
+ */
201
+ export function readAgentPackageTarGz(targz) {
202
+ // Bounded decompression: a 16 MB gz of zeros must not OOM the process.
203
+ const tar = zlib.gunzipSync(targz, { maxOutputLength: AGENT_PACKAGE_MAX_RAW_BYTES + 64 * 1024 * 1024 });
204
+ const files = [];
205
+ let offset = 0;
206
+ while (offset + TAR_BLOCK <= tar.length) {
207
+ const header = tar.subarray(offset, offset + TAR_BLOCK);
208
+ if (header.every((b) => b === 0))
209
+ break; // end-of-archive
210
+ const rawName = header.subarray(0, 100).toString("ascii").replace(/\0.*$/, "");
211
+ const prefix = header.subarray(345, 500).toString("ascii").replace(/\0.*$/, "");
212
+ const name = prefix ? `${prefix}/${rawName}` : rawName;
213
+ const size = parseInt(header.subarray(124, 136).toString("ascii").replace(/\0.*$/, "").trim() || "0", 8);
214
+ const typeflag = String.fromCharCode(header[156]);
215
+ const expected = tarHeaderChecksum(header);
216
+ const recorded = parseInt(header.subarray(148, 156).toString("ascii").replace(/[\0 ]*$/, ""), 8);
217
+ if (recorded !== expected)
218
+ throw new Error(`tar checksum mismatch at ${name || offset}`);
219
+ if (!Number.isFinite(size) || size < 0)
220
+ throw new Error(`tar invalid size at ${name}`);
221
+ if (typeflag !== "0" && typeflag !== "5") {
222
+ throw new Error(`tar entry type not allowed in a package (${name})`);
223
+ }
224
+ if (typeflag === "5" && size !== 0) {
225
+ throw new Error(`tar directory entry with nonzero size at ${name}`);
226
+ }
227
+ assertSafeRelativePath(name);
228
+ offset += TAR_BLOCK;
229
+ if (typeflag !== "5") {
230
+ if (offset + size > tar.length) {
231
+ throw new Error(`tar entry body truncated at ${name} (declares ${size} bytes)`);
232
+ }
233
+ files.push({ name, body: Buffer.from(tar.subarray(offset, offset + size)) });
234
+ offset += Math.ceil(size / TAR_BLOCK) * TAR_BLOCK;
235
+ }
236
+ }
237
+ return files;
238
+ }
239
+ function assertSafeRelativePath(name) {
240
+ if (!name || name.startsWith("/") || name.includes("\\") || /^[A-Za-z]:/.test(name)) {
241
+ throw new Error(`tar entry has non-relative path: ${name}`);
242
+ }
243
+ const parts = name.split("/");
244
+ if (parts.some((p) => p === ".."))
245
+ throw new Error(`tar entry escapes package root: ${name}`);
246
+ if (parts.some((p) => p === "" && name.replace(/\/$/, "").split("/").includes(""))) {
247
+ throw new Error(`tar entry has empty path segment: ${name}`);
248
+ }
249
+ }
250
+ /** Extract a canonical package tar.gz under destDir (created if needed). */
251
+ export function extractAgentPackageTarGz(targz, destDir) {
252
+ const entries = readAgentPackageTarGz(targz);
253
+ const root = path.resolve(destDir);
254
+ for (const entry of entries) {
255
+ const target = path.resolve(root, entry.name);
256
+ if (target !== root && !target.startsWith(root + path.sep)) {
257
+ throw new Error(`tar entry escapes destination: ${entry.name}`);
258
+ }
259
+ fs.mkdirSync(path.dirname(target), { recursive: true });
260
+ fs.writeFileSync(target, entry.body);
261
+ }
262
+ }
263
+ // ─── Canonical packing ───────────────────────────────────────────
264
+ /** Fixed ignore list — never packed, never validated. */
265
+ const PACK_IGNORES = new Set([".git", ".DS_Store", ".pilotswarm-meta.json"]);
266
+ export const AGENT_PACKAGE_MAX_COMPRESSED_BYTES = 16 * 1024 * 1024;
267
+ const AGENT_PACKAGE_MAX_RAW_BYTES = 128 * 1024 * 1024;
268
+ const VENDORED_WARN_BYTES = 2 * 1024 * 1024;
269
+ /** Sorted, symlink-rejecting walk. Returns POSIX-relative entries. */
270
+ function walkPackageDir(rootDir) {
271
+ const files = [];
272
+ const symlinks = [];
273
+ const nonAscii = [];
274
+ const visit = (dirAbs, dirRel) => {
275
+ const names = fs.readdirSync(dirAbs).filter((n) => !PACK_IGNORES.has(n)).sort();
276
+ for (const name of names) {
277
+ const abs = path.join(dirAbs, name);
278
+ const rel = dirRel ? `${dirRel}/${name}` : name;
279
+ // The ustar name field is written byte-per-char; non-ASCII names
280
+ // silently corrupt and can COLLIDE after 7-bit masking. Reject
281
+ // loudly instead of certifying garbage with a sha.
282
+ if (/[^\x20-\x7e]/.test(rel))
283
+ nonAscii.push(rel);
284
+ const st = fs.lstatSync(abs);
285
+ if (st.isSymbolicLink()) {
286
+ symlinks.push(rel);
287
+ continue;
288
+ }
289
+ if (st.isDirectory()) {
290
+ files.push({ rel: `${rel}/`, abs, isDir: true, size: 0 });
291
+ visit(abs, rel);
292
+ }
293
+ else if (st.isFile()) {
294
+ files.push({ rel, abs, isDir: false, size: st.size });
295
+ }
296
+ // Sockets/fifos/devices are silently impossible in git checkouts;
297
+ // anything else is skipped by construction.
298
+ }
299
+ };
300
+ visit(rootDir, "");
301
+ files.sort((a, b) => (a.rel < b.rel ? -1 : a.rel > b.rel ? 1 : 0));
302
+ return { files, symlinks, nonAscii };
303
+ }
304
+ /**
305
+ * Pack a validated package directory into the canonical tar.gz.
306
+ * Deterministic: sorted entries, zeroed mtime/uid/gid, normalized modes
307
+ * (0755 dirs, 0644 files), gzip level 9 with a normalized gzip header.
308
+ */
309
+ export function packAgentPackage(rootDir) {
310
+ const { files, symlinks, nonAscii } = walkPackageDir(rootDir);
311
+ if (symlinks.length > 0) {
312
+ throw new Error(`symlinks are not allowed in agent packages: ${symlinks.join(", ")}`);
313
+ }
314
+ if (nonAscii.length > 0) {
315
+ throw new Error(`non-ASCII paths are not allowed in agent packages (rename to ASCII): ${nonAscii.join(", ")}`);
316
+ }
317
+ let rawBytes = 0;
318
+ const entries = files.map((f) => {
319
+ rawBytes += f.size;
320
+ if (rawBytes > AGENT_PACKAGE_MAX_RAW_BYTES) {
321
+ throw new Error(`package exceeds raw size limit (${AGENT_PACKAGE_MAX_RAW_BYTES} bytes)`);
322
+ }
323
+ return {
324
+ name: f.rel,
325
+ body: f.isDir ? undefined : fs.readFileSync(f.abs),
326
+ mode: f.isDir ? 0o755 : 0o644,
327
+ };
328
+ });
329
+ const tar = buildTar(entries);
330
+ const targz = zlib.gzipSync(tar, { level: 9 });
331
+ // Normalize the gzip header so identical content is identical bytes on
332
+ // every platform: zero MTIME (bytes 4-7), fix OS (byte 9) to 0x03 (Unix).
333
+ targz[4] = 0;
334
+ targz[5] = 0;
335
+ targz[6] = 0;
336
+ targz[7] = 0;
337
+ targz[9] = 0x03;
338
+ if (targz.length > AGENT_PACKAGE_MAX_COMPRESSED_BYTES) {
339
+ throw new Error(`package exceeds compressed size limit: ${targz.length} > ${AGENT_PACKAGE_MAX_COMPRESSED_BYTES} bytes`);
340
+ }
341
+ const sha256 = crypto.createHash("sha256").update(tar).digest("hex");
342
+ return { targz, sha256, rawBytes, fileCount: files.filter((f) => !f.isDir).length };
343
+ }
344
+ /** sha256 of the canonical tar inside a package tar.gz (the identity hash). */
345
+ export function agentPackageTarSha256(targz) {
346
+ const tar = zlib.gunzipSync(targz, { maxOutputLength: AGENT_PACKAGE_MAX_RAW_BYTES + 64 * 1024 * 1024 });
347
+ return crypto.createHash("sha256").update(tar).digest("hex");
348
+ }
349
+ export const AGENT_PACKAGE_LAYOUT_FIELDS = ["agents", "skills", "mcpConfig", "mcpServers", "tools", "include"];
350
+ function normalizeLayoutPath(value, field, issues) {
351
+ if (typeof value !== "string" || value.trim() === "") {
352
+ issues.push({ code: "invalid_layout_path", message: `plugin.json "${field}" entries must be non-empty strings (got ${JSON.stringify(value)})`, file: "plugin.json" });
353
+ return null;
354
+ }
355
+ const raw = value.trim().replace(/\\/g, "/");
356
+ const normalized = path.posix.normalize(raw);
357
+ if (path.posix.isAbsolute(normalized) || normalized === ".." || normalized.startsWith("../") || normalized === ".") {
358
+ issues.push({ code: "invalid_layout_path", message: `plugin.json "${field}": ${JSON.stringify(value)} must be a relative path inside the package (no "..", no absolute paths)`, file: "plugin.json" });
359
+ return null;
360
+ }
361
+ return normalized;
362
+ }
363
+ /**
364
+ * Parse the optional layout fields from a parsed plugin.json. Returns null
365
+ * (with no issues) when the manifest declares no layout — convention mode.
366
+ */
367
+ export function parseAgentPackageLayout(pluginJson, issues) {
368
+ if (!pluginJson || typeof pluginJson !== "object")
369
+ return null;
370
+ if (!AGENT_PACKAGE_LAYOUT_FIELDS.some((field) => pluginJson[field] !== undefined))
371
+ return null;
372
+ const list = (field) => {
373
+ const value = pluginJson[field];
374
+ if (value === undefined)
375
+ return [];
376
+ if (!Array.isArray(value)) {
377
+ issues.push({ code: "invalid_layout_field", message: `plugin.json "${field}" must be an array of paths`, file: "plugin.json" });
378
+ return [];
379
+ }
380
+ return value.map((entry) => normalizeLayoutPath(entry, field, issues)).filter((entry) => entry !== null);
381
+ };
382
+ const single = (field) => {
383
+ const value = pluginJson[field];
384
+ if (value === undefined || value === null)
385
+ return null;
386
+ return normalizeLayoutPath(value, field, issues);
387
+ };
388
+ return {
389
+ agents: list("agents"),
390
+ skills: list("skills"),
391
+ mcpConfig: single("mcpConfig"),
392
+ mcpServers: list("mcpServers"),
393
+ tools: single("tools"),
394
+ include: list("include"),
395
+ };
396
+ }
397
+ /** Deterministic manifest serialization for the staged plugin.json. */
398
+ function serializeCanonicalPluginJson(pluginJson, layout) {
399
+ const out = {};
400
+ out.name = pluginJson.name;
401
+ out.version = pluginJson.version;
402
+ if (pluginJson.description !== undefined)
403
+ out.description = pluginJson.description;
404
+ const layoutKeys = new Set([...AGENT_PACKAGE_LAYOUT_FIELDS, "name", "version", "description"]);
405
+ for (const key of Object.keys(pluginJson).sort()) {
406
+ if (!layoutKeys.has(key))
407
+ out[key] = pluginJson[key];
408
+ }
409
+ if (layout.agents.length)
410
+ out.agents = [...layout.agents].sort();
411
+ if (layout.skills.length)
412
+ out.skills = [...layout.skills].sort();
413
+ if (layout.mcpConfig)
414
+ out.mcpConfig = layout.mcpConfig;
415
+ if (layout.mcpServers.length)
416
+ out.mcpServers = [...layout.mcpServers].sort();
417
+ if (layout.tools)
418
+ out.tools = layout.tools;
419
+ if (layout.include.length)
420
+ out.include = [...layout.include].sort();
421
+ return JSON.stringify(out, null, 2) + "\n";
422
+ }
423
+ /**
424
+ * Stage a package directory for validation + packing.
425
+ *
426
+ * Convention mode (no layout fields): returns rootDir untouched.
427
+ * Manifest mode: copies exactly the declared artifacts into a temp dir in
428
+ * the CANONICAL layout — agents/<basename>, skills/<dirname>/, .mcp.json,
429
+ * mcp-servers/<basename>, tools/worker-module.js, include verbatim — and
430
+ * writes plugin.json back with canonicalized layout paths. The staged tree
431
+ * is what gets validated, packed, hashed, and unpacked on workers, so the
432
+ * authored layout is a pure authoring convenience with one canonical truth.
433
+ */
434
+ export function stageAgentPackageDir(rootDir) {
435
+ const issues = [];
436
+ const noop = { dir: rootDir, staged: false, issues, cleanup: () => { } };
437
+ let pluginJson = null;
438
+ try {
439
+ pluginJson = JSON.parse(fs.readFileSync(path.join(rootDir, "plugin.json"), "utf8"));
440
+ }
441
+ catch {
442
+ return noop; // validation reports missing/invalid plugin.json itself
443
+ }
444
+ const layout = parseAgentPackageLayout(pluginJson, issues);
445
+ if (!layout)
446
+ return noop;
447
+ if (issues.length > 0)
448
+ return { ...noop, issues };
449
+ const stagingDir = fs.mkdtempSync(path.join(os.tmpdir(), "ps-agent-pkg-stage-"));
450
+ const cleanup = () => { try {
451
+ fs.rmSync(stagingDir, { recursive: true, force: true });
452
+ }
453
+ catch { /* best-effort */ } };
454
+ const canonical = { agents: [], skills: [], mcpConfig: null, mcpServers: [], tools: null, include: [] };
455
+ const placed = new Set();
456
+ const place = (sourceRel, destRel, opts) => {
457
+ const source = path.resolve(rootDir, sourceRel);
458
+ if (!source.startsWith(path.resolve(rootDir) + path.sep)) {
459
+ issues.push({ code: "invalid_layout_path", message: `plugin.json "${opts.field}": ${sourceRel} escapes the package root`, file: "plugin.json" });
460
+ return false;
461
+ }
462
+ if (!fs.existsSync(source)) {
463
+ issues.push({ code: "layout_file_missing", message: `plugin.json "${opts.field}" lists ${sourceRel}, but it does not exist`, file: sourceRel });
464
+ return false;
465
+ }
466
+ const isDir = fs.statSync(source).isDirectory();
467
+ if (opts.dir === true && !isDir) {
468
+ issues.push({ code: "layout_not_a_directory", message: `plugin.json "${opts.field}" lists ${sourceRel}, which must be a directory`, file: sourceRel });
469
+ return false;
470
+ }
471
+ if (opts.dir === false && isDir) {
472
+ issues.push({ code: "layout_not_a_file", message: `plugin.json "${opts.field}" lists ${sourceRel}, which must be a file`, file: sourceRel });
473
+ return false;
474
+ }
475
+ if (placed.has(destRel)) {
476
+ issues.push({ code: "layout_collision", message: `two manifest entries land on ${destRel} — rename one (canonical paths use basenames)`, file: sourceRel });
477
+ return false;
478
+ }
479
+ placed.add(destRel);
480
+ const dest = path.join(stagingDir, destRel);
481
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
482
+ fs.cpSync(source, dest, { recursive: true });
483
+ return true;
484
+ };
485
+ for (const rel of layout.agents) {
486
+ const destRel = path.posix.join("agents", path.posix.basename(rel));
487
+ if (place(rel, destRel, { dir: false, field: "agents" }))
488
+ canonical.agents.push(destRel);
489
+ }
490
+ for (const rel of layout.skills) {
491
+ const destRel = path.posix.join("skills", path.posix.basename(rel));
492
+ if (place(rel, destRel, { dir: true, field: "skills" }))
493
+ canonical.skills.push(destRel);
494
+ }
495
+ if (layout.mcpConfig) {
496
+ if (place(layout.mcpConfig, ".mcp.json", { dir: false, field: "mcpConfig" }))
497
+ canonical.mcpConfig = ".mcp.json";
498
+ }
499
+ for (const rel of layout.mcpServers) {
500
+ const destRel = path.posix.join("mcp-servers", path.posix.basename(rel));
501
+ if (place(rel, destRel, { field: "mcpServers" }))
502
+ canonical.mcpServers.push(destRel);
503
+ }
504
+ if (layout.tools) {
505
+ if (place(layout.tools, "tools/worker-module.js", { dir: false, field: "tools" }))
506
+ canonical.tools = "tools/worker-module.js";
507
+ }
508
+ for (const rel of layout.include) {
509
+ if (place(rel, rel, { field: "include" }))
510
+ canonical.include.push(rel);
511
+ }
512
+ if (issues.some((issue) => issue.code !== "missing_description")) {
513
+ cleanup();
514
+ return { dir: rootDir, staged: false, issues, cleanup: () => { } };
515
+ }
516
+ fs.writeFileSync(path.join(stagingDir, "plugin.json"), serializeCanonicalPluginJson(pluginJson, canonical));
517
+ return { dir: stagingDir, staged: true, issues, cleanup };
518
+ }
519
+ // ─── Validation ──────────────────────────────────────────────────
520
+ const DNS_LABEL_RE = /^[a-z0-9]([a-z0-9-]{0,62}[a-z0-9])?$/;
521
+ function err(errors, code, message, file) {
522
+ errors.push({ code, message, file });
523
+ }
524
+ // `node --check` is CommonJS-only and silently exits 0 on ESM input, so it
525
+ // cannot gate module syntax. Instead, compile (never evaluate) the source as
526
+ // an ES module via vm.SourceTextModule in a child process — construction
527
+ // compiles and throws SyntaxError; evaluation never runs, so hostile code
528
+ // cannot execute at validation time.
529
+ const SYNTAX_CHECK_SNIPPET = 'const fs=require("fs");const vm=require("vm");' +
530
+ 'let src=fs.readFileSync(process.argv[1],"utf8");' +
531
+ 'if(src.startsWith("#!"))src=src.slice(src.indexOf("\\n")+1);' +
532
+ 'try{new vm.SourceTextModule(src,{identifier:process.argv[1]});}' +
533
+ 'catch(e){console.error(String(e&&e.message||e));process.exit(1)}';
534
+ async function nodeSyntaxCheck(file) {
535
+ return await new Promise((resolve) => {
536
+ const child = spawn(process.execPath, ["--no-warnings", "--experimental-vm-modules", "-e", SYNTAX_CHECK_SNIPPET, file], { stdio: ["ignore", "ignore", "pipe"] });
537
+ let stderr = "";
538
+ child.stderr.on("data", (d) => { stderr += String(d); });
539
+ child.on("error", (e) => resolve(String(e)));
540
+ child.on("close", (code) => resolve(code === 0 ? null : stderr.trim() || `exit ${code}`));
541
+ });
542
+ }
543
+ /**
544
+ * Full registration-time validation. Every rejection here is user-facing UX:
545
+ * messages state the rule and the fix, and the CLI `validate` command prints
546
+ * them verbatim — keep them exact and keep the tests asserting them exact.
547
+ */
548
+ export async function validateAgentPackageDir(rootDir, opts = {}) {
549
+ const errors = [];
550
+ const warnings = [];
551
+ if (!fs.existsSync(rootDir) || !fs.statSync(rootDir).isDirectory()) {
552
+ err(errors, "not_a_directory", `package path is not a directory: ${rootDir}`);
553
+ return { ok: false, errors, warnings };
554
+ }
555
+ // A manifest-declared layout validates in its CANONICAL form: stage
556
+ // first, then run every check below against the staged tree — exactly
557
+ // the bytes that will be packed, hashed, and unpacked on workers.
558
+ if (!opts.preStaged) {
559
+ const staged = stageAgentPackageDir(rootDir);
560
+ if (staged.staged) {
561
+ try {
562
+ const result = await validateAgentPackageDir(staged.dir, { ...opts, preStaged: true });
563
+ return { ...result, warnings: [...staged.issues, ...result.warnings] };
564
+ }
565
+ finally {
566
+ staged.cleanup();
567
+ }
568
+ }
569
+ if (staged.issues.length > 0) {
570
+ return { ok: false, errors: staged.issues, warnings };
571
+ }
572
+ }
573
+ // plugin.json — the manifest anchor.
574
+ const pluginJsonPath = path.join(rootDir, "plugin.json");
575
+ let pluginJson = null;
576
+ if (!fs.existsSync(pluginJsonPath)) {
577
+ err(errors, "missing_plugin_json", "plugin.json is required at the package root", "plugin.json");
578
+ }
579
+ else {
580
+ try {
581
+ pluginJson = JSON.parse(fs.readFileSync(pluginJsonPath, "utf8"));
582
+ }
583
+ catch (e) {
584
+ err(errors, "invalid_plugin_json", `plugin.json is not valid JSON: ${e?.message ?? e}`, "plugin.json");
585
+ }
586
+ }
587
+ if (pluginJson) {
588
+ if (typeof pluginJson.name !== "string" || !DNS_LABEL_RE.test(pluginJson.name)) {
589
+ err(errors, "invalid_name", `plugin.json "name" must be a DNS label (lowercase letters, digits, hyphens; got ${JSON.stringify(pluginJson.name)})`, "plugin.json");
590
+ }
591
+ if (typeof pluginJson.version !== "string" || !isValidSemver(pluginJson.version)) {
592
+ err(errors, "invalid_version", `plugin.json "version" must be a concrete semver like 1.2.3 or 1.2.3-dev.1 (got ${JSON.stringify(pluginJson.version)})`, "plugin.json");
593
+ }
594
+ if (pluginJson.description !== undefined && typeof pluginJson.description !== "string") {
595
+ err(errors, "invalid_description", 'plugin.json "description" must be a string when present', "plugin.json");
596
+ }
597
+ else if (!pluginJson.description) {
598
+ warnings.push({ code: "missing_description", message: 'plugin.json has no "description" — package listings will be blank', file: "plugin.json" });
599
+ }
600
+ }
601
+ // Symlinks, charset, and forbidden files — walk once.
602
+ let walk = null;
603
+ try {
604
+ walk = walkPackageDir(rootDir);
605
+ for (const link of walk.symlinks) {
606
+ err(errors, "symlink", `symlinks are not allowed in agent packages: ${link}`, link);
607
+ }
608
+ for (const bad of walk.nonAscii) {
609
+ err(errors, "non_ascii_path", `path contains non-ASCII characters: ${bad} — rename to ASCII (tar names are byte-encoded)`, bad);
610
+ }
611
+ if (walk.files.some((f) => f.rel === "session-policy.json")) {
612
+ err(errors, "session_policy_forbidden", "session-policy.json is not allowed in packages — session policy belongs to the deployment's baked app, and a package must not override it", "session-policy.json");
613
+ }
614
+ }
615
+ catch (e) {
616
+ err(errors, "walk_failed", `could not read package directory: ${e?.message ?? e}`);
617
+ }
618
+ // Agents.
619
+ const agentsDir = path.join(rootDir, "agents");
620
+ let agents = [];
621
+ if (fs.existsSync(agentsDir)) {
622
+ const agentFiles = fs.readdirSync(agentsDir).filter((f) => f.endsWith(".agent.md")).sort();
623
+ agents = loadAgentFiles(agentsDir);
624
+ if (agents.length < agentFiles.length) {
625
+ // Name the ACTUAL skip reason — these messages are contractual UX
626
+ // and "failed to parse" for an empty body sends users editing the
627
+ // wrong thing.
628
+ const loaded = new Set(agents.map((a) => path.basename(a.sourcePath ?? "")));
629
+ for (const f of agentFiles) {
630
+ if (loaded.has(f))
631
+ continue;
632
+ let raw = "";
633
+ try {
634
+ raw = fs.readFileSync(path.join(agentsDir, f), "utf8");
635
+ }
636
+ catch { /* fallthrough */ }
637
+ const schemaMatch = /(?:^|\n)schemaVersion:\s*(\S+)/.exec(raw);
638
+ const body = raw.replace(/^---\n[\s\S]*?\n---\n?/, "").trim();
639
+ if (schemaMatch && !["1", "2"].includes(schemaMatch[1])) {
640
+ err(errors, "unsupported_agent_schema_version", `agents/${f}: schemaVersion ${schemaMatch[1]} is not supported (use 1 or 2)`, `agents/${f}`);
641
+ }
642
+ else if (!body) {
643
+ err(errors, "empty_agent_body", `agents/${f}: the markdown body is empty — it becomes the agent's prompt and cannot be blank`, `agents/${f}`);
644
+ }
645
+ else {
646
+ err(errors, "invalid_agent", `agents/${f} failed to parse (check frontmatter and schemaVersion)`, `agents/${f}`);
647
+ }
648
+ }
649
+ }
650
+ const reserved = new Set((opts.reservedAgentNames ?? []).flatMap((n) => agentNameVariants(n)));
651
+ const seenNames = new Map();
652
+ for (const agent of agents) {
653
+ const file = `agents/${path.basename(agent.sourcePath ?? `${agent.name}.agent.md`)}`;
654
+ if (agent.system) {
655
+ err(errors, "system_agent_forbidden", `${file}: "system: true" agents are not allowed in packages — background agents belong to the deployment's baked app`, file);
656
+ }
657
+ if (agent.name === "default") {
658
+ err(errors, "default_agent_forbidden", `${file}: an agent named "default" is a prompt overlay, not a selectable agent, and is not allowed in packages`, file);
659
+ }
660
+ // Collision checks use the RESOLVER's normalization: the runtime
661
+ // matches case/punctuation-insensitively with an "agent"-suffix
662
+ // fallback, so "Swee-per" genuinely shadows "sweeper".
663
+ if (agentNameVariants(agent.name).some((variant) => reserved.has(variant))) {
664
+ err(errors, "reserved_agent_name", `${file}: agent name "${agent.name}" collides with a built-in agent`, file);
665
+ }
666
+ const normalized = normalizeAgentName(agent.name);
667
+ const previous = seenNames.get(normalized);
668
+ if (previous) {
669
+ err(errors, "duplicate_agent_name", `${file}: agent name "${agent.name}" duplicates "${previous}" (names are matched case/punctuation-insensitively) — one of them would be unreachable`, file);
670
+ }
671
+ else {
672
+ seenNames.set(normalized, agent.name);
673
+ }
674
+ }
675
+ }
676
+ // Skills — every subdirectory must be a real skill (the runtime loader
677
+ // silently skips SKILL.md-less dirs; registration makes that loud).
678
+ const skillsDir = path.join(rootDir, "skills");
679
+ let skills = [];
680
+ if (fs.existsSync(skillsDir)) {
681
+ skills = loadSkillsSync(skillsDir);
682
+ const skillNames = new Set(skills.map((s) => s.name));
683
+ for (const entry of fs.readdirSync(skillsDir).sort()) {
684
+ const skillPath = path.join(skillsDir, entry);
685
+ if (!fs.statSync(skillPath).isDirectory())
686
+ continue;
687
+ if (!fs.existsSync(path.join(skillPath, "SKILL.md"))) {
688
+ err(errors, "skill_missing_skill_md", `skills/${entry}/ has no SKILL.md — every skill directory needs one`, `skills/${entry}`);
689
+ }
690
+ else if (!skillNames.has(entry)) {
691
+ // SKILL.md may declare a name differing from the dir; loader keys by frontmatter name.
692
+ const declared = skills.find((s) => s.dir && path.basename(s.dir) === entry);
693
+ if (!declared) {
694
+ err(errors, "invalid_skill", `skills/${entry}/SKILL.md failed to parse`, `skills/${entry}/SKILL.md`);
695
+ }
696
+ }
697
+ }
698
+ }
699
+ // .mcp.json — shape only; ${VAR} expansion happens at load, never here.
700
+ const mcpJsonPath = path.join(rootDir, ".mcp.json");
701
+ let mcpServerNames = [];
702
+ if (fs.existsSync(mcpJsonPath)) {
703
+ try {
704
+ const parsed = JSON.parse(fs.readFileSync(mcpJsonPath, "utf8"));
705
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
706
+ err(errors, "invalid_mcp_json", ".mcp.json must be an object mapping server names to configs", ".mcp.json");
707
+ }
708
+ else {
709
+ for (const [serverName, cfg] of Object.entries(parsed)) {
710
+ const hasCommand = typeof cfg?.command === "string";
711
+ const hasUrl = typeof cfg?.url === "string";
712
+ if (!hasCommand && !hasUrl) {
713
+ err(errors, "invalid_mcp_server", `.mcp.json server "${serverName}" needs either "command" (stdio) or "url" (http/sse)`, ".mcp.json");
714
+ continue;
715
+ }
716
+ mcpServerNames.push(serverName);
717
+ }
718
+ }
719
+ }
720
+ catch (e) {
721
+ err(errors, "invalid_mcp_json", `.mcp.json is not valid JSON: ${e?.message ?? e}`, ".mcp.json");
722
+ }
723
+ }
724
+ // Tools + MCP server code — syntax-gate the module entry points.
725
+ const toolsDir = path.join(rootDir, "tools");
726
+ const hasToolsDir = fs.existsSync(toolsDir) && fs.statSync(toolsDir).isDirectory();
727
+ const workerModulePath = path.join(toolsDir, "worker-module.js");
728
+ if (hasToolsDir && !fs.existsSync(workerModulePath)) {
729
+ err(errors, "missing_worker_module", "tools/ exists but tools/worker-module.js is missing — it is the tool entry point", "tools");
730
+ }
731
+ if (!opts.skipSyntaxCheck && walk) {
732
+ const checkable = walk.files.filter((f) => !f.isDir
733
+ && /\.(mjs|cjs|js)$/.test(f.rel)
734
+ && (f.rel.startsWith("tools/") || f.rel.startsWith("mcp-servers/"))
735
+ && !f.rel.includes("node_modules/"));
736
+ for (const f of checkable) {
737
+ const failure = await nodeSyntaxCheck(f.abs);
738
+ if (failure) {
739
+ err(errors, "syntax_error", `${f.rel} failed syntax check:\n${failure}`, f.rel);
740
+ }
741
+ }
742
+ }
743
+ // Bare-specifier heads-up: package modules load from the unpack cache,
744
+ // which has no node_modules above it — `import ... from "some-pkg"`
745
+ // fails at runtime (quarantining the package) unless deps are vendored.
746
+ // Tool modules should export plain tool objects and import nothing.
747
+ if (walk) {
748
+ const moduleFiles = walk.files.filter((f) => !f.isDir && /\.(mjs|cjs|js)$/.test(f.rel)
749
+ && (f.rel === "tools/worker-module.js" || f.rel.startsWith("mcp-servers/"))
750
+ && !f.rel.includes("node_modules/"));
751
+ const bareRe = /(?:from\s+|import\s*\(\s*|require\s*\(\s*)["']([^"'./][^"']*)["']/g;
752
+ for (const f of moduleFiles) {
753
+ let source = "";
754
+ try {
755
+ source = fs.readFileSync(f.abs, "utf8");
756
+ }
757
+ catch {
758
+ continue;
759
+ }
760
+ const bare = new Set();
761
+ for (const match of source.matchAll(bareRe)) {
762
+ if (!match[1].startsWith("node:"))
763
+ bare.add(match[1]);
764
+ }
765
+ if (bare.size > 0) {
766
+ warnings.push({
767
+ code: "bare_imports",
768
+ message: `${f.rel} imports bare specifier(s) ${[...bare].map((b) => `"${b}"`).join(", ")} — these fail to resolve from the install cache unless vendored inside the package; export plain tool objects instead`,
769
+ file: f.rel,
770
+ });
771
+ }
772
+ }
773
+ }
774
+ // Vendored-dependency heads-up (not an error — trusted system, size-capped).
775
+ if (walk) {
776
+ const vendoredBytes = walk.files
777
+ .filter((f) => !f.isDir && f.rel.includes("node_modules/"))
778
+ .reduce((sum, f) => sum + f.size, 0);
779
+ if (vendoredBytes > VENDORED_WARN_BYTES) {
780
+ warnings.push({
781
+ code: "vendored_dependencies",
782
+ message: `package vendors ${(vendoredBytes / (1024 * 1024)).toFixed(1)} MB of node_modules — keep packages dependency-light`,
783
+ });
784
+ }
785
+ }
786
+ const ok = errors.length === 0;
787
+ const manifest = ok && pluginJson
788
+ ? {
789
+ name: pluginJson.name,
790
+ version: pluginJson.version,
791
+ description: typeof pluginJson.description === "string" ? pluginJson.description : undefined,
792
+ agents: agents.map((a) => ({
793
+ name: a.name,
794
+ title: a.title,
795
+ description: a.description,
796
+ tools: a.tools ?? undefined,
797
+ skills: a.skills,
798
+ mcpServers: a.mcpServers,
799
+ splash: a.splash,
800
+ splashMobile: a.splashMobile,
801
+ initialPrompt: a.initialPrompt,
802
+ })),
803
+ skills: skills.map((s) => ({ name: s.name, description: s.description })),
804
+ mcpServers: mcpServerNames,
805
+ hasTools: hasToolsDir,
806
+ fileCount: walk ? walk.files.filter((f) => !f.isDir).length : 0,
807
+ rawBytes: walk ? walk.files.reduce((sum, f) => sum + f.size, 0) : 0,
808
+ }
809
+ : undefined;
810
+ return { ok, errors, warnings, manifest };
811
+ }
812
+ //# sourceMappingURL=agent-package-format.js.map