@morit/cli 1.0.0

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.
@@ -0,0 +1,1630 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import {
3
+ chmod,
4
+ copyFile,
5
+ mkdir,
6
+ readFile,
7
+ readdir,
8
+ rename,
9
+ rm,
10
+ writeFile,
11
+ } from "node:fs/promises";
12
+ import { readFileSync } from "node:fs";
13
+ import { homedir } from "node:os";
14
+ import { dirname, extname, join, relative, resolve, sep } from "node:path";
15
+ import { pathToFileURL } from "node:url";
16
+ import {
17
+ canonicalJson,
18
+ createZip,
19
+ generateSigningKey,
20
+ readZip,
21
+ sha256,
22
+ signEntries,
23
+ verifySignedEntries,
24
+ } from "./archive.js";
25
+
26
+ const contract = JSON.parse(
27
+ readFileSync(new URL("../assets/plugin_contract.json", import.meta.url), "utf8"),
28
+ );
29
+
30
+ const MAX_FILES = 64;
31
+ const MAX_FILE_BYTES = 512 * 1024;
32
+ const MAX_PROJECT_BYTES = 1024 * 1024;
33
+ const MAX_PACKAGE_BYTES = 2 * 1024 * 1024;
34
+ const PLUGIN_ID = /^[a-z][a-z0-9]*(?:\.[a-z][a-z0-9_-]*)+$/;
35
+ const IDENTIFIER = /^[a-z][a-z0-9_.-]{1,79}$/;
36
+ const PUBLISHER = /^[A-Za-z0-9][A-Za-z0-9._-]{1,119}$/;
37
+ const VERSION = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/;
38
+ const SAFE_SEGMENT = /^[A-Za-z0-9._-]+$/;
39
+ const WINDOWS_RESERVED = /^(?:con|prn|aux|nul|clock\$|com[1-9]|lpt[1-9])(?:\.|$)/i;
40
+ const UI_IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
41
+ const UI_STATE_KEY = /^[A-Za-z][A-Za-z0-9_]{0,63}$/;
42
+ const UI_BINDING = /^(?:state|data|item)(?:\.[A-Za-z0-9_-]+){0,12}$/;
43
+ const UI_TEMPLATE_BINDING = /\{\{\s*([^{}]+?)\s*\}\}/g;
44
+ const MANIFEST_FIELDS = new Set(contract.manifest_fields);
45
+ const CAPABILITY_KINDS = new Set(contract.capability_kinds);
46
+ const CAPABILITY_FIELDS = new Set(contract.object_fields.capability);
47
+ const UI_EXTENSION_FIELDS = new Set(contract.object_fields.ui_extension);
48
+ const CREDENTIAL_FIELDS = new Set(contract.object_fields.credential);
49
+ const SLASH_COMMAND_FIELDS = new Set(contract.object_fields.slash_command);
50
+ const CONNECTOR_FIELDS = new Set(contract.object_fields.connector);
51
+ const DEPENDENCY_FIELDS = new Set(contract.object_fields.dependency);
52
+ const DEVELOPER_FIELDS = new Set(contract.object_fields.developer);
53
+ const REQUIRED_SECRET_FIELDS = new Set(contract.object_fields.required_secret);
54
+ const CAPABILITY_RUNTIME_SHORTHAND = contract.capability_runtime_shorthand;
55
+ const UI_POINTS = new Set(contract.ui_points);
56
+ const RUNTIME_ADAPTERS = new Set(contract.runtime_adapters);
57
+ const PERMISSIONS = new Set(contract.permissions);
58
+ const CREDENTIAL_KINDS = new Set(contract.credential_kinds);
59
+ const CONNECTOR_KINDS = new Set(contract.connector_kinds);
60
+ const FRAGMENTS = new Map(
61
+ Object.entries(contract.fragment_directories)
62
+ .map(([directory, value]) => [directory, [value.target, value.kind]]),
63
+ );
64
+ const SAFE_ASSET = new Set([".gif", ".jpeg", ".jpg", ".json", ".md", ".png", ".txt", ".webp"]);
65
+ const BINARY_ASSET = new Set([".gif", ".jpeg", ".jpg", ".png", ".webp"]);
66
+ const BINARY_SOURCE_PREFIX = "morit-base64-v1:";
67
+ const IGNORED_DIRECTORIES = new Set([
68
+ ".git", ".morit", ".morit-plugin-mcp", ".mypy_cache", ".pytest_cache", ".ruff_cache",
69
+ ".venv", "__pycache__", "dist", "node_modules", "venv",
70
+ ]);
71
+ const IGNORED_FILES = new Set(["morit-plugin.json"]);
72
+ const SAFE_ENV_TEMPLATES = new Set([".env.example", ".env.sample", ".env.template"]);
73
+
74
+ export const SDK_CONTRACT = Object.freeze(contract);
75
+
76
+ export class LocalWorkspace {
77
+ constructor(root = process.env.MORIT_PLUGIN_WORKSPACE || process.cwd()) {
78
+ this.root = resolve(root);
79
+ this.stateDirectory = join(this.root, ".morit");
80
+ this.statePath = join(this.stateDirectory, "state.json");
81
+ this.keyDirectory = resolve(
82
+ process.env.MORIT_PLUGIN_KEY_DIR
83
+ || process.env.MORIT_PLUGIN_MCP_KEY_DIR
84
+ || join(homedir(), ".morit", "plugin", "keys"),
85
+ );
86
+ this.state = null;
87
+ }
88
+
89
+ async initialize() {
90
+ await mkdir(this.root, { recursive: true });
91
+ await mkdir(this.stateDirectory, { recursive: true });
92
+ try {
93
+ this.state = JSON.parse(await readFile(this.statePath, "utf8"));
94
+ } catch (error) {
95
+ if (error.code !== "ENOENT") throw error;
96
+ try {
97
+ this.state = JSON.parse(
98
+ await readFile(join(this.root, ".morit-plugin-mcp", "state.json"), "utf8"),
99
+ );
100
+ } catch (legacyError) {
101
+ if (legacyError.code !== "ENOENT") throw legacyError;
102
+ this.state = { version: 1, projects: {}, jobs: {}, artifacts: {} };
103
+ }
104
+ await this.save();
105
+ }
106
+ if (this.state?.version !== 1) throw new Error("Unsupported Morit MCP workspace state");
107
+ return this;
108
+ }
109
+
110
+ async save() {
111
+ const temporary = `${this.statePath}.${randomUUID()}.tmp`;
112
+ await writeFile(temporary, `${JSON.stringify(this.state, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
113
+ await rename(temporary, this.statePath);
114
+ }
115
+
116
+ async createProject({ plugin_id, name, publisher, description = "", advanced = true, directory }) {
117
+ validateMetadata(plugin_id, name, publisher, description);
118
+ const relativePath = safeProjectPath(directory || join("morit-plugins", plugin_id.split(".").at(-1)));
119
+ const projectRoot = this.inside(relativePath);
120
+ await mkdir(projectRoot, { recursive: true });
121
+ if ((await readDirectory(projectRoot)).length > 0) throw new Error("Project directory is not empty");
122
+ const manifest = {
123
+ schema_version: advanced ? 2 : 1,
124
+ id: plugin_id,
125
+ name: name.trim(),
126
+ icon: null,
127
+ short_description: description.slice(0, 160),
128
+ description,
129
+ category: "other",
130
+ keywords: [],
131
+ developer: { name: publisher, url: null },
132
+ homepage_url: null,
133
+ privacy_policy_url: null,
134
+ publisher,
135
+ version: "1.0.0",
136
+ cloud_project_id: null,
137
+ required_secrets: [],
138
+ min_morit_version: "1.7.5",
139
+ max_morit_version: "1.999.999",
140
+ permissions: [],
141
+ capabilities: [],
142
+ ui_extensions: [],
143
+ credentials: [],
144
+ connectors: [],
145
+ dependencies: [],
146
+ slash_commands: [],
147
+ data_policy: "purge",
148
+ };
149
+ await writeFile(join(projectRoot, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
150
+ await writeFile(
151
+ join(projectRoot, "README.md"),
152
+ `# ${name.trim()}\n\nCreated with @morit/cli.\n`,
153
+ "utf8",
154
+ );
155
+ if (advanced) {
156
+ for (const folder of [...FRAGMENTS.keys(), "src", "assets", "children", "dist"]) {
157
+ await mkdir(join(projectRoot, folder), { recursive: true });
158
+ }
159
+ } else {
160
+ await mkdir(join(projectRoot, "dist"), { recursive: true });
161
+ }
162
+ const projectId = randomUUID();
163
+ const files = await readProjectFiles(projectRoot);
164
+ this.state.projects[projectId] = {
165
+ id: projectId,
166
+ path: toPosix(relative(this.root, projectRoot)),
167
+ revision: 1,
168
+ digest: projectDigest(files),
169
+ plugin_id,
170
+ name: name.trim(),
171
+ publisher,
172
+ description,
173
+ updated_at: new Date().toISOString(),
174
+ };
175
+ await this.save();
176
+ return projectResult(this.state.projects[projectId], projectRoot, files, false);
177
+ }
178
+
179
+ async openProject({ path }) {
180
+ const relativePath = safeProjectPath(path);
181
+ const projectRoot = this.inside(relativePath);
182
+ const files = await readProjectFiles(projectRoot);
183
+ const manifest = compileManifest(files);
184
+ validateManifest(manifest, files);
185
+ const existing = Object.values(this.state.projects).find((value) => value.path === toPosix(relativePath));
186
+ if (existing) return this.getProject(existing.id, true);
187
+ const projectId = randomUUID();
188
+ this.state.projects[projectId] = {
189
+ id: projectId,
190
+ path: toPosix(relativePath),
191
+ revision: 1,
192
+ digest: projectDigest(files),
193
+ plugin_id: manifest.id,
194
+ name: manifest.name,
195
+ publisher: manifest.publisher,
196
+ description: manifest.description || "",
197
+ updated_at: new Date().toISOString(),
198
+ };
199
+ await this.save();
200
+ return projectResult(this.state.projects[projectId], projectRoot, files, true);
201
+ }
202
+
203
+ async getProject(projectId, includeFiles = true) {
204
+ const project = this.requireProject(projectId);
205
+ const projectRoot = this.inside(project.path);
206
+ const files = await readProjectFiles(projectRoot);
207
+ const digest = projectDigest(files);
208
+ if (digest !== project.digest) {
209
+ project.digest = digest;
210
+ project.revision += 1;
211
+ project.updated_at = new Date().toISOString();
212
+ const manifest = compileManifest(files);
213
+ project.plugin_id = String(manifest.id || project.plugin_id);
214
+ project.name = String(manifest.name || project.name);
215
+ project.publisher = String(manifest.publisher || project.publisher);
216
+ project.description = String(manifest.description || "");
217
+ await this.save();
218
+ }
219
+ return projectResult(project, projectRoot, files, includeFiles);
220
+ }
221
+
222
+ async putFiles(projectId, changes, revision) {
223
+ const current = await this.getProject(projectId, true);
224
+ if (current.revision !== revision) throw new Error("project revision changed; reload the project and retry");
225
+ if (!changes || typeof changes !== "object" || Array.isArray(changes) || Object.keys(changes).length === 0) {
226
+ throw new Error("files must be a non-empty object");
227
+ }
228
+ const project = this.requireProject(projectId);
229
+ const projectRoot = this.inside(project.path);
230
+ for (const [rawPath, content] of Object.entries(changes)) {
231
+ const path = safeFilePath(rawPath);
232
+ const target = resolve(projectRoot, ...path.split("/"));
233
+ if (content === null) {
234
+ if (path === "manifest.json") throw new Error("manifest.json cannot be deleted");
235
+ await rm(target, { force: true });
236
+ } else {
237
+ const bytes = projectFileBytes(path, content);
238
+ await mkdir(dirname(target), { recursive: true });
239
+ await writeFile(target, bytes);
240
+ }
241
+ }
242
+ const files = await readProjectFiles(projectRoot);
243
+ if (!("manifest.json" in files)) throw new Error("manifest.json cannot be deleted");
244
+ project.revision += 1;
245
+ project.digest = projectDigest(files);
246
+ project.updated_at = new Date().toISOString();
247
+ await this.save();
248
+ return projectResult(project, projectRoot, files, false);
249
+ }
250
+
251
+ async validate(projectId) {
252
+ const project = await this.getProject(projectId, true);
253
+ const manifest = compileManifest(project.files);
254
+ const packageEntries = validateManifest(manifest, project.files);
255
+ return {
256
+ project_id: projectId,
257
+ revision: project.revision,
258
+ valid: true,
259
+ message: `validated ${manifest.id} ${manifest.version} (${packageEntries.size} package entries)`,
260
+ workspace_path: project.workspace_path,
261
+ };
262
+ }
263
+
264
+ async preview(projectId) {
265
+ const project = await this.getProject(projectId, true);
266
+ const manifest = compileManifest(project.files);
267
+ validateManifest(manifest, project.files);
268
+ const fileName = `${manifest.id}-preview.html`;
269
+ const target = join(project.workspace_path, "dist", fileName);
270
+ await mkdir(dirname(target), { recursive: true });
271
+ const html = previewHtml(manifest);
272
+ await writeFile(target, html);
273
+ return this.recordArtifact(projectId, "preview", fileName, "text/html", target, html, null);
274
+ }
275
+
276
+ async sourceDownload(projectId, requestedFileName) {
277
+ const project = await this.getProject(projectId, true);
278
+ const manifest = compileManifest(project.files);
279
+ validateManifest(manifest, project.files);
280
+ const fileName = artifactFileName(
281
+ requestedFileName,
282
+ ".zip",
283
+ `${manifest.id}-${manifest.version}-source.zip`,
284
+ );
285
+ const entries = new Map(Object.entries(project.files).map(([name, value]) => [name, projectFileBytes(name, value)]));
286
+ const content = createZip(entries);
287
+ const target = join(project.workspace_path, "dist", fileName);
288
+ await mkdir(dirname(target), { recursive: true });
289
+ await writeFile(target, content);
290
+ return this.recordArtifact(projectId, "source", fileName, "application/zip", target, content, null);
291
+ }
292
+
293
+ async build(projectId, requestedFileName) {
294
+ const project = await this.getProject(projectId, true);
295
+ const manifest = compileManifest(project.files);
296
+ const entries = validateManifest(manifest, project.files);
297
+ const fileName = artifactFileName(
298
+ requestedFileName,
299
+ ".mplg",
300
+ `${manifest.id}-${manifest.version}.mplg`,
301
+ );
302
+ const privatePem = await this.signingKey(manifest.publisher);
303
+ const signed = signEntries(entries, manifest.publisher, privatePem);
304
+ const content = createZip(signed);
305
+ if (content.length > MAX_PACKAGE_BYTES) throw new Error("package exceeds Morit's 2 MiB upload limit");
306
+ verifyPackageContent(content);
307
+ const target = join(project.workspace_path, "dist", fileName);
308
+ await mkdir(dirname(target), { recursive: true });
309
+ await writeFile(target, content);
310
+ const jobId = randomUUID();
311
+ const artifact = await this.recordArtifact(
312
+ projectId,
313
+ "package",
314
+ fileName,
315
+ "application/vnd.morit.plugin+zip",
316
+ target,
317
+ content,
318
+ jobId,
319
+ );
320
+ const now = new Date().toISOString();
321
+ this.state.jobs[jobId] = {
322
+ id: jobId,
323
+ project_id: projectId,
324
+ file_name: fileName,
325
+ state: "completed",
326
+ progress: 100,
327
+ artifact_id: artifact.artifact_id,
328
+ error_code: null,
329
+ error_message: null,
330
+ created_at: now,
331
+ started_at: now,
332
+ finished_at: now,
333
+ updated_at: now,
334
+ };
335
+ await this.save();
336
+ return jobResult(this.state.jobs[jobId]);
337
+ }
338
+
339
+ buildStatus(jobId) {
340
+ const job = this.state.jobs[jobId];
341
+ if (!job) throw new Error("job_id is not available in this local workspace");
342
+ return jobResult(job);
343
+ }
344
+
345
+ async artifactDownload(artifactId, requestedFileName) {
346
+ const artifact = this.state.artifacts[artifactId];
347
+ if (!artifact) throw new Error("artifact_id is not available in this local workspace");
348
+ const suffix = { preview: ".html", package: ".mplg", source: ".zip" }[artifact.kind];
349
+ const fileName = artifactFileName(requestedFileName, suffix, artifact.file_name);
350
+ let target = this.inside(artifact.path);
351
+ if (fileName !== artifact.file_name) {
352
+ const renamed = join(dirname(target), fileName);
353
+ await copyFile(target, renamed);
354
+ target = renamed;
355
+ }
356
+ return artifactResult(artifact, target, fileName);
357
+ }
358
+
359
+ async recordArtifact(projectId, kind, fileName, mimeType, absolutePath, content, jobId) {
360
+ const artifactId = randomUUID();
361
+ const artifact = {
362
+ id: artifactId,
363
+ project_id: projectId,
364
+ job_id: jobId,
365
+ kind,
366
+ file_name: fileName,
367
+ mime_type: mimeType,
368
+ size_bytes: content.length,
369
+ sha256: sha256(content),
370
+ path: toPosix(relative(this.root, absolutePath)),
371
+ created_at: new Date().toISOString(),
372
+ };
373
+ this.state.artifacts[artifactId] = artifact;
374
+ await this.save();
375
+ return artifactResult(artifact, absolutePath, fileName);
376
+ }
377
+
378
+ async signingKey(publisher) {
379
+ return signingKeyForPublisher(this.keyDirectory, publisher);
380
+ }
381
+
382
+ requireProject(projectId) {
383
+ if (typeof projectId !== "string" || !/^[0-9a-f-]{36}$/i.test(projectId)) {
384
+ throw new Error("project_id must be a UUID");
385
+ }
386
+ const project = this.state.projects[projectId];
387
+ if (!project) throw new Error("project_id is not available in this local workspace");
388
+ return project;
389
+ }
390
+
391
+ inside(relativePath) {
392
+ const target = resolve(this.root, relativePath);
393
+ if (target !== this.root && !target.startsWith(`${this.root}${sep}`)) {
394
+ throw new Error("Path escapes the configured local workspace");
395
+ }
396
+ return target;
397
+ }
398
+ }
399
+
400
+ export async function validateProjectDirectory(source) {
401
+ const loaded = await loadProjectDirectory(source);
402
+ return projectDirectoryResult(loaded);
403
+ }
404
+
405
+ export async function readProjectDirectory(source) {
406
+ const root = resolve(source);
407
+ const files = await readProjectFiles(root);
408
+ const manifest = compileManifest(files);
409
+ validateManifest(manifest, files);
410
+ return { root, files, manifest, digest: projectDigest(files) };
411
+ }
412
+
413
+ export async function scaffoldProjectDirectory(source, options) {
414
+ const root = resolve(source);
415
+ const workspace = await new LocalWorkspace(dirname(root)).initialize();
416
+ return workspace.createProject({
417
+ ...options,
418
+ directory: relative(dirname(root), root),
419
+ });
420
+ }
421
+
422
+ export async function previewProjectDirectory(source, output) {
423
+ const loaded = await loadProjectDirectory(source);
424
+ const target = outputPath(
425
+ output,
426
+ join(loaded.root, "dist", `${loaded.manifest.id}-preview.html`),
427
+ ".html",
428
+ );
429
+ const content = previewHtml(loaded.manifest);
430
+ await mkdir(dirname(target), { recursive: true });
431
+ await writeFile(target, content);
432
+ return {
433
+ ...projectDirectoryResult(loaded),
434
+ output_path: target,
435
+ size_bytes: content.length,
436
+ sha256: sha256(content),
437
+ };
438
+ }
439
+
440
+ export async function buildProjectDirectory(source, { output, keyDirectory } = {}) {
441
+ const loaded = await loadProjectDirectory(source);
442
+ const target = outputPath(
443
+ output,
444
+ join(loaded.root, "dist", `${loaded.manifest.id}-${loaded.manifest.version}.mplg`),
445
+ ".mplg",
446
+ );
447
+ const privatePem = await signingKeyForPublisher(
448
+ resolve(
449
+ keyDirectory
450
+ || process.env.MORIT_PLUGIN_MCP_KEY_DIR
451
+ || join(homedir(), ".morit", "plugin-mcp", "keys"),
452
+ ),
453
+ loaded.manifest.publisher,
454
+ );
455
+ const content = createZip(signEntries(loaded.entries, loaded.manifest.publisher, privatePem));
456
+ if (content.length > MAX_PACKAGE_BYTES) throw new Error("package exceeds Morit's 2 MiB upload limit");
457
+ verifyPackageContent(content);
458
+ await mkdir(dirname(target), { recursive: true });
459
+ await writeFile(target, content);
460
+ return {
461
+ ...projectDirectoryResult(loaded),
462
+ output_path: target,
463
+ size_bytes: content.length,
464
+ sha256: sha256(content),
465
+ signed: true,
466
+ signature_algorithm: "ed25519",
467
+ };
468
+ }
469
+
470
+ export async function sourceZipProjectDirectory(source, output) {
471
+ const loaded = await loadProjectDirectory(source);
472
+ const target = outputPath(
473
+ output,
474
+ join(loaded.root, "dist", `${loaded.manifest.id}-${loaded.manifest.version}-source.zip`),
475
+ ".zip",
476
+ );
477
+ const entries = new Map(
478
+ Object.entries(loaded.files).map(([name, content]) => [name, projectFileBytes(name, content)]),
479
+ );
480
+ const content = createZip(entries);
481
+ await mkdir(dirname(target), { recursive: true });
482
+ await writeFile(target, content);
483
+ return {
484
+ ...projectDirectoryResult(loaded),
485
+ output_path: target,
486
+ size_bytes: content.length,
487
+ sha256: sha256(content),
488
+ };
489
+ }
490
+
491
+ export async function verifyPackageFile(packagePath) {
492
+ const target = resolve(packagePath);
493
+ const content = await readFile(target);
494
+ const verified = verifyPackageContent(content);
495
+ return {
496
+ valid: true,
497
+ package_path: target,
498
+ plugin_id: verified.manifest.id,
499
+ name: verified.manifest.name,
500
+ publisher: verified.manifest.publisher,
501
+ version: verified.manifest.version,
502
+ package_entries: verified.entries.size,
503
+ size_bytes: content.length,
504
+ sha256: sha256(content),
505
+ signature: verified.signature,
506
+ };
507
+ }
508
+
509
+ async function loadProjectDirectory(source) {
510
+ const root = resolve(source || ".");
511
+ const files = await readStandaloneProjectFiles(root);
512
+ const manifest = compileManifest(files);
513
+ const entries = validateManifest(manifest, files);
514
+ return { root, files, manifest, entries };
515
+ }
516
+
517
+ function projectDirectoryResult({ root, manifest, entries }) {
518
+ return {
519
+ valid: true,
520
+ source_path: root,
521
+ plugin_id: manifest.id,
522
+ name: manifest.name,
523
+ publisher: manifest.publisher,
524
+ version: manifest.version,
525
+ package_entries: entries.size + 1,
526
+ };
527
+ }
528
+
529
+ async function readStandaloneProjectFiles(root) {
530
+ const files = {};
531
+ async function visit(directory, prefix = "") {
532
+ let contents;
533
+ try {
534
+ contents = await readdir(directory, { withFileTypes: true });
535
+ } catch (error) {
536
+ if (error.code === "ENOENT") throw new Error(`plugin source directory was not found: ${root}`);
537
+ throw error;
538
+ }
539
+ for (const entry of contents) {
540
+ if (entry.isSymbolicLink()) throw new Error("Project may not contain symbolic links");
541
+ if (IGNORED_FILES.has(entry.name)) continue;
542
+ const name = prefix ? `${prefix}/${entry.name}` : entry.name;
543
+ if (entry.isDirectory()) {
544
+ if (!IGNORED_DIRECTORIES.has(entry.name)) await visit(join(directory, entry.name), name);
545
+ continue;
546
+ }
547
+ if (!entry.isFile()) continue;
548
+ const safeName = safeFilePath(name);
549
+ assertNonSensitiveSource(safeName);
550
+ const content = await readFile(join(directory, entry.name));
551
+ if (content.length > MAX_FILE_BYTES) throw new Error(`${safeName} exceeds the 512 KiB file limit`);
552
+ files[safeName] = projectFileSource(safeName, content);
553
+ }
554
+ }
555
+ await visit(root);
556
+ const size = Object.entries(files).reduce(
557
+ (total, [name, content]) => total + Buffer.byteLength(name) + projectFileBytes(name, content).length,
558
+ 0,
559
+ );
560
+ if (Object.keys(files).length < 1 || Object.keys(files).length > MAX_FILES) {
561
+ throw new Error(`project must contain 1 to ${MAX_FILES} files`);
562
+ }
563
+ if (size > MAX_PROJECT_BYTES) throw new Error("project exceeds the 1 MiB source limit");
564
+ return files;
565
+ }
566
+
567
+ function binarySourcePath(name) {
568
+ const parts = name.split("/");
569
+ return (parts[0] === "assets" && parts.length >= 2 && BINARY_ASSET.has(extname(name).toLowerCase()))
570
+ || (parts[0] === "children" && parts.length === 2 && name.toLowerCase().endsWith(".mplg"));
571
+ }
572
+
573
+ function strictUtf8(content, name) {
574
+ try {
575
+ return new TextDecoder("utf-8", { fatal: true }).decode(content);
576
+ } catch {
577
+ throw new Error(`${name} must be UTF-8 text`);
578
+ }
579
+ }
580
+
581
+ function validateBinaryMagic(name, content) {
582
+ const extension = extname(name).toLowerCase();
583
+ const matches = extension === ".png"
584
+ ? content.length >= 8 && content.subarray(0, 8).equals(Buffer.from("89504e470d0a1a0a", "hex"))
585
+ : [".jpg", ".jpeg"].includes(extension)
586
+ ? content.length >= 3 && content[0] === 0xff && content[1] === 0xd8 && content[2] === 0xff
587
+ : extension === ".webp"
588
+ ? content.length >= 12 && content.subarray(0, 4).toString("ascii") === "RIFF" && content.subarray(8, 12).toString("ascii") === "WEBP"
589
+ : extension === ".gif"
590
+ ? ["GIF87a", "GIF89a"].includes(content.subarray(0, 6).toString("ascii"))
591
+ : extension === ".mplg"
592
+ ? content.length >= 4 && content.subarray(0, 4).equals(Buffer.from([0x50, 0x4b, 0x03, 0x04]))
593
+ : false;
594
+ if (!matches) throw new Error(`${name} does not match its declared binary file type`);
595
+ }
596
+
597
+ function projectFileSource(name, content) {
598
+ if (!Buffer.isBuffer(content)) throw new Error(`${name} must be file bytes`);
599
+ if (content.length > MAX_FILE_BYTES) throw new Error(`${name} exceeds the 512 KiB file limit`);
600
+ if (binarySourcePath(name)) {
601
+ validateBinaryMagic(name, content);
602
+ return `${BINARY_SOURCE_PREFIX}${content.toString("base64")}`;
603
+ }
604
+ const text = strictUtf8(content, name);
605
+ if (text.startsWith(BINARY_SOURCE_PREFIX)) {
606
+ throw new Error(`${name} cannot use the Morit binary envelope because it is a text source path`);
607
+ }
608
+ return text;
609
+ }
610
+
611
+ function projectFileBytes(name, content) {
612
+ if (typeof content !== "string") throw new Error(`${name} must contain source text or a Morit binary envelope`);
613
+ if (!content.startsWith(BINARY_SOURCE_PREFIX)) {
614
+ if (binarySourcePath(name)) throw new Error(`${name} must use the Morit binary envelope`);
615
+ const bytes = Buffer.from(content, "utf8");
616
+ if (bytes.length > MAX_FILE_BYTES) throw new Error(`${name} exceeds the 512 KiB file limit`);
617
+ return bytes;
618
+ }
619
+ if (!binarySourcePath(name)) {
620
+ throw new Error(`${name} cannot use the Morit binary envelope because it is a text source path`);
621
+ }
622
+ const encoded = content.slice(BINARY_SOURCE_PREFIX.length);
623
+ if (!encoded || encoded.length % 4 !== 0 || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(encoded)) {
624
+ throw new Error(`${name} contains an invalid Morit binary envelope`);
625
+ }
626
+ const bytes = Buffer.from(encoded, "base64");
627
+ if (bytes.toString("base64") !== encoded) throw new Error(`${name} contains a non-canonical Morit binary envelope`);
628
+ if (bytes.length > MAX_FILE_BYTES) throw new Error(`${name} exceeds the 512 KiB file limit`);
629
+ validateBinaryMagic(name, bytes);
630
+ return bytes;
631
+ }
632
+
633
+ function assertNonSensitiveSource(name) {
634
+ const base = name.split("/").at(-1).toLowerCase();
635
+ if (SAFE_ENV_TEMPLATES.has(base)) return;
636
+ if (base === ".env" || base.startsWith(".env.") || /\.(?:key|p12|pem|pfx)$/.test(base)) {
637
+ throw new Error(`project contains a sensitive file that cannot be packaged: ${name}`);
638
+ }
639
+ }
640
+
641
+ function verifyPackageContent(content) {
642
+ if (!content.length || content.length > MAX_PACKAGE_BYTES) {
643
+ throw new Error("package must be a non-empty file within Morit's 2 MiB upload limit");
644
+ }
645
+ const entries = readZip(content);
646
+ if (entries.size > MAX_FILES) throw new Error(`package has more than ${MAX_FILES} entries`);
647
+ let total = 0;
648
+ const sourceFiles = {};
649
+ for (const [name, value] of entries) {
650
+ const normalized = safeFilePath(name);
651
+ if (normalized !== name || !installablePackagePath(name)) {
652
+ throw new Error(`unsupported install package entry: ${name}`);
653
+ }
654
+ total += value.length;
655
+ if (total > MAX_PACKAGE_BYTES * 2) throw new Error("package expands beyond the 4 MiB safety limit");
656
+ if (name === "signature.json") continue;
657
+ sourceFiles[name] = projectFileSource(name, value);
658
+ }
659
+ let manifest;
660
+ try {
661
+ manifest = JSON.parse(sourceFiles["manifest.json"] || "null");
662
+ } catch {
663
+ throw new Error("manifest.json must contain a JSON object");
664
+ }
665
+ if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) {
666
+ throw new Error("manifest.json must contain a JSON object");
667
+ }
668
+ validateManifest(manifest, sourceFiles);
669
+ const verified = verifySignedEntries(entries);
670
+ return { ...verified, entries };
671
+ }
672
+
673
+ function installablePackagePath(name) {
674
+ if (["manifest.json", "signature.json", "README.md"].includes(name)) return true;
675
+ const parts = name.split("/");
676
+ if (parts[0] === "assets" && parts.length >= 2) return SAFE_ASSET.has(extname(name).toLowerCase());
677
+ if (parts[0] === "src" && parts.length >= 2) return name.toLowerCase().endsWith(".py");
678
+ return parts[0] === "children" && parts.length === 2 && name.toLowerCase().endsWith(".mplg");
679
+ }
680
+
681
+ function outputPath(value, fallback, suffix) {
682
+ let candidate = resolve(value || fallback);
683
+ if (!candidate.toLowerCase().endsWith(suffix)) {
684
+ if (extname(candidate)) throw new Error(`output file must end with ${suffix}`);
685
+ candidate += suffix;
686
+ }
687
+ return candidate;
688
+ }
689
+
690
+ async function signingKeyForPublisher(keyDirectory, publisher) {
691
+ await mkdir(keyDirectory, { recursive: true, mode: 0o700 });
692
+ const keyName = `${createHash("sha256").update(publisher).digest("hex")}.pem`;
693
+ const path = join(keyDirectory, keyName);
694
+ try {
695
+ return await readFile(path, "utf8");
696
+ } catch (error) {
697
+ if (error.code !== "ENOENT") throw error;
698
+ const generated = generateSigningKey();
699
+ try {
700
+ await writeFile(path, generated.privatePem, { encoding: "utf8", mode: 0o600, flag: "wx" });
701
+ } catch (writeError) {
702
+ if (writeError.code === "EEXIST") return readFile(path, "utf8");
703
+ throw writeError;
704
+ }
705
+ await chmod(path, 0o600).catch(() => {});
706
+ return generated.privatePem;
707
+ }
708
+ }
709
+
710
+ function previewHtml(manifest) {
711
+ const serialized = JSON.stringify(manifest, null, 2).replaceAll("<", "\\u003c");
712
+ return Buffer.from(
713
+ `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>${escapeHtml(manifest.name)} preview</title><style>body{font:15px system-ui;margin:0;background:#f5f7fb;color:#182033}.wrap{max-width:960px;margin:auto;padding:32px}.card{background:white;border-radius:18px;padding:24px;box-shadow:0 8px 30px #17203a18}pre{white-space:pre-wrap;overflow-wrap:anywhere;background:#101727;color:#e8eefc;padding:18px;border-radius:12px}</style></head><body><main class="wrap"><section class="card"><h1>${escapeHtml(manifest.name)}</h1><p>${escapeHtml(manifest.description || "")}</p><p>${manifest.capabilities.length} capabilities · ${manifest.ui_extensions.length} UI extensions</p><pre>${escapeHtml(serialized)}</pre></section></main></body></html>`,
714
+ "utf8",
715
+ );
716
+ }
717
+
718
+ function validateMetadata(pluginId, name, publisher, description) {
719
+ if (!PLUGIN_ID.test(pluginId) || pluginId.length > 120) throw new Error("plugin_id must be a reverse-domain lowercase identifier");
720
+ if (typeof name !== "string" || name.trim().length < 1 || name.trim().length > 80) throw new Error("name must contain 1 to 80 characters");
721
+ if (!PUBLISHER.test(publisher)) throw new Error("publisher contains unsupported characters");
722
+ if (typeof description !== "string" || description.length > 4000) throw new Error("description must not exceed 4000 characters");
723
+ }
724
+
725
+ function validateManifest(manifest, files) {
726
+ assertObject(manifest, "manifest");
727
+ rejectUnknownFields(manifest, MANIFEST_FIELDS, "manifest");
728
+ if (![1, 2].includes(manifest.schema_version)) throw new Error("manifest.schema_version must be 1 or 2");
729
+ validateMetadata(manifest.id, manifest.name, manifest.publisher, manifest.description || "");
730
+ if (manifest.icon !== undefined && manifest.icon !== null && (
731
+ typeof manifest.icon !== "string"
732
+ || !/^assets\/[A-Za-z0-9._/-]+\.(?:png|jpe?g|webp)$/i.test(manifest.icon)
733
+ || manifest.icon.split("/").includes("..")
734
+ || !(manifest.icon in files)
735
+ )) throw new Error("manifest.icon must reference a packaged PNG, JPEG, or WebP asset");
736
+ if (manifest.short_description !== undefined && (
737
+ typeof manifest.short_description !== "string" || manifest.short_description.length > 160
738
+ )) throw new Error("manifest.short_description must not exceed 160 characters");
739
+ if (manifest.category !== undefined && !/^[a-z][a-z0-9_-]{1,39}$/.test(String(manifest.category))) {
740
+ throw new Error("manifest.category is invalid");
741
+ }
742
+ const keywords = manifest.keywords || [];
743
+ if (!Array.isArray(keywords) || keywords.length > 12 || new Set(keywords).size !== keywords.length || keywords.some(
744
+ (value) => typeof value !== "string" || !value.trim() || value.length > 40
745
+ )) throw new Error("manifest.keywords must contain up to 12 unique short strings");
746
+ const developer = manifest.developer || {};
747
+ assertObject(developer, "manifest.developer");
748
+ rejectUnknownFields(developer, DEVELOPER_FIELDS, "manifest.developer");
749
+ if (developer.name !== undefined && (typeof developer.name !== "string" || developer.name.length > 120)) {
750
+ throw new Error("manifest.developer.name is invalid");
751
+ }
752
+ for (const [field, value] of [
753
+ ["developer.url", developer.url],
754
+ ["homepage_url", manifest.homepage_url],
755
+ ["privacy_policy_url", manifest.privacy_policy_url],
756
+ ]) {
757
+ if (value !== undefined && value !== null) validateHttpsUrl(value, `manifest.${field}`);
758
+ }
759
+ if (manifest.cloud_project_id !== undefined && manifest.cloud_project_id !== null && !isUuid(manifest.cloud_project_id)) {
760
+ throw new Error("manifest.cloud_project_id must be a UUID");
761
+ }
762
+ for (const field of ["version", "min_morit_version", "max_morit_version"]) {
763
+ if (!VERSION.test(String(manifest[field] || ""))) throw new Error(`manifest.${field} must be a semantic version`);
764
+ }
765
+ const requestedPermissions = validatePermissions(manifest.permissions || [], "manifest.permissions");
766
+ if (!["purge", "retain"].includes(manifest.data_policy)) throw new Error("manifest.data_policy must be purge or retain");
767
+ const capabilities = objectCollection(manifest.capabilities, "manifest.capabilities", 64);
768
+ const uiExtensions = objectCollection(manifest.ui_extensions, "manifest.ui_extensions", 64);
769
+ const credentials = objectCollection(manifest.credentials || [], "manifest.credentials", 16);
770
+ const slashCommands = objectCollection(manifest.slash_commands || [], "manifest.slash_commands", 32);
771
+ const connectors = objectCollection(manifest.connectors || [], "manifest.connectors", 32);
772
+ const dependencies = objectCollection(manifest.dependencies || [], "manifest.dependencies", 16);
773
+ const requiredSecrets = objectCollection(manifest.required_secrets || [], "manifest.required_secrets", 32);
774
+ if (!capabilities.length && !uiExtensions.length) throw new Error("plugin does not declare any extension");
775
+
776
+ const capabilityIds = new Set();
777
+ const sandboxEntrypoints = new Map();
778
+ for (const capability of capabilities) {
779
+ rejectUnknownFields(capability, CAPABILITY_FIELDS, "capability");
780
+ requireUniqueIdentifier(capability.id, capabilityIds, "capability");
781
+ capabilityIds.add(capability.id);
782
+ if (!CAPABILITY_KINDS.has(capability.kind)) throw new Error(`unknown capability kind: ${capability.kind}`);
783
+ requireText(capability.title, 80, `capability ${capability.id} title`);
784
+ requireText(capability.description, 500, `capability ${capability.id} description`);
785
+ const permissions = validatePermissions(capability.permissions || [], `capability ${capability.id} permissions`);
786
+ requireSubset(permissions, requestedPermissions, `capability ${capability.id} permissions`);
787
+ const timeout = capability.timeout_seconds ?? 10;
788
+ if (typeof timeout !== "number" || !Number.isFinite(timeout) || timeout < 0.1 || timeout > 30) {
789
+ throw new Error(`capability ${capability.id} timeout must be between 0.1 and 30 seconds`);
790
+ }
791
+ const runtime = capability.runtime || {};
792
+ assertObject(runtime, `capability ${capability.id} runtime`);
793
+ assertJsonSize(runtime, 32 * 1024, `capability ${capability.id} runtime`);
794
+ const adapter = runtime.adapter;
795
+ if ((["tool", "skill"].includes(capability.kind) || adapter) && !RUNTIME_ADAPTERS.has(adapter)) {
796
+ throw new Error(`unsupported runtime adapter: ${adapter || "missing"}`);
797
+ }
798
+ validateRuntime(capability, runtime, permissions, credentials, connectors, dependencies);
799
+ if (adapter === "sandbox_python") {
800
+ const entrypoint = normalizeEntrypointPath(runtime.entrypoint);
801
+ if (!entrypoint.startsWith("src/") || !entrypoint.endsWith(".py")) throw new Error("sandbox_python entrypoint must be src/*.py");
802
+ runtime.entrypoint = entrypoint;
803
+ sandboxEntrypoints.set(entrypoint, (sandboxEntrypoints.get(entrypoint) || 0) + 1);
804
+ }
805
+ }
806
+
807
+ const uiIds = new Set();
808
+ for (const extension of uiExtensions) {
809
+ rejectUnknownFields(extension, UI_EXTENSION_FIELDS, "UI extension");
810
+ requireUniqueIdentifier(extension.id, uiIds, "UI extension");
811
+ if (!UI_POINTS.has(extension.point)) throw new Error(`unknown UI extension point: ${extension.point}`);
812
+ requireText(extension.title, 80, `UI ${extension.id} title`);
813
+ const order = extension.order ?? 0;
814
+ if (!Number.isInteger(order) || order < -10000 || order > 10000) throw new Error(`UI ${extension.id} order is invalid`);
815
+ const permissions = validatePermissions(extension.permissions || [], `UI ${extension.id} permissions`);
816
+ requireSubset(permissions, requestedPermissions, `UI ${extension.id} permissions`);
817
+ assertObject(extension.config || {}, `UI ${extension.id} config`);
818
+ assertJsonSize(extension.config || {}, 32 * 1024, `UI ${extension.id} config`);
819
+ }
820
+ for (const extension of uiExtensions) validateUiConfig(extension, capabilities, uiIds);
821
+
822
+ const credentialIds = validateCredentials(credentials, requestedPermissions);
823
+ const secretIds = validateRequiredSecrets(requiredSecrets);
824
+ const connectorIds = validateConnectors(connectors, credentialIds, secretIds);
825
+ if ((requiredSecrets.length || connectors.some((value) => value.cloud_connection_id)) && !manifest.cloud_project_id) {
826
+ throw new Error("Cloud features require manifest.cloud_project_id");
827
+ }
828
+ validateDependencies(dependencies, manifest.id);
829
+ validateSlashCommands(slashCommands, capabilities);
830
+ validateRuntimeReferences(capabilities, connectors, connectorIds, dependencies);
831
+
832
+ const sourceFiles = Object.keys(files).filter((name) => name.startsWith("src/") && name.endsWith(".py"));
833
+ const duplicateEntrypoints = [...sandboxEntrypoints].filter(([, count]) => count !== 1).map(([name]) => name);
834
+ const missingEntrypoints = [...sandboxEntrypoints.keys()].filter((name) => !(name in files));
835
+ const undeclaredSources = sourceFiles.filter((name) => !sandboxEntrypoints.has(name));
836
+ if (duplicateEntrypoints.length || missingEntrypoints.length || undeclaredSources.length) {
837
+ const details = [
838
+ duplicateEntrypoints.length ? `duplicate: ${duplicateEntrypoints.join(", ")}` : "",
839
+ missingEntrypoints.length ? `not found: ${missingEntrypoints.join(", ")}` : "",
840
+ undeclaredSources.length ? `undeclared: ${undeclaredSources.join(", ")}` : "",
841
+ ].filter(Boolean).join("; ");
842
+ throw new Error(`every src/*.py file must be declared by exactly one sandbox_python entrypoint (${details})`);
843
+ }
844
+ const entries = new Map([["manifest.json", canonicalJson(manifest)]]);
845
+ for (const [name, content] of Object.entries(files)) {
846
+ if (name === "README.md") entries.set(name, projectFileBytes(name, content));
847
+ else if (name.startsWith("assets/") && SAFE_ASSET.has(extname(name).toLowerCase())) entries.set(name, projectFileBytes(name, content));
848
+ else if (name.startsWith("src/") && name.endsWith(".py")) entries.set(name, projectFileBytes(name, content));
849
+ else if (name.startsWith("children/") && name.endsWith(".mplg")) entries.set(name, projectFileBytes(name, content));
850
+ }
851
+ const declaredChildren = new Set(dependencies.filter((value) => value.package_path).map((value) => value.package_path));
852
+ const packagedChildren = new Set([...entries.keys()].filter((name) => name.startsWith("children/")));
853
+ if (!equalSets(declaredChildren, packagedChildren)) {
854
+ throw new Error("manifest dependencies must exactly match bundled children/*.mplg files");
855
+ }
856
+ if (entries.size > MAX_FILES) throw new Error(`package has more than ${MAX_FILES} entries`);
857
+ return entries;
858
+ }
859
+
860
+ function validatePermissions(values, label) {
861
+ if (!Array.isArray(values) || values.some((value) => typeof value !== "string" || !PERMISSIONS.has(value))) {
862
+ throw new Error(`${label} contains an unknown permission`);
863
+ }
864
+ return new Set(values);
865
+ }
866
+
867
+ function assertObject(value, label) {
868
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
869
+ throw new Error(`${label} must be an object`);
870
+ }
871
+ }
872
+
873
+ function rejectUnknownFields(value, allowed, label) {
874
+ assertObject(value, label);
875
+ const unknown = Object.keys(value).filter((name) => !allowed.has(name)).sort();
876
+ if (unknown.length) throw new Error(`${label} contains unknown fields: ${unknown.join(", ")}`);
877
+ }
878
+
879
+ function objectCollection(value, label, maximum) {
880
+ if (!Array.isArray(value) || value.length > maximum || value.some((item) => !item || typeof item !== "object" || Array.isArray(item))) {
881
+ throw new Error(`${label} must be an array of at most ${maximum} objects`);
882
+ }
883
+ return value;
884
+ }
885
+
886
+ function requireUniqueIdentifier(value, seen, label) {
887
+ if (!IDENTIFIER.test(String(value || "")) || seen.has(value)) {
888
+ throw new Error(`${label} ids must be unique valid identifiers`);
889
+ }
890
+ seen.add(value);
891
+ }
892
+
893
+ function requireText(value, maximum, label) {
894
+ if (typeof value !== "string" || !value.trim() || value.length > maximum) {
895
+ throw new Error(`${label} must contain 1 to ${maximum} characters`);
896
+ }
897
+ }
898
+
899
+ function assertJsonSize(value, maximum, label) {
900
+ validateJsonValue(value, 0, label);
901
+ if (Buffer.byteLength(JSON.stringify(value), "utf8") > maximum) {
902
+ throw new Error(`${label} is too large`);
903
+ }
904
+ }
905
+
906
+ function validateJsonValue(value, depth, label) {
907
+ if (depth > 12) throw new Error(`${label} is too deeply nested`);
908
+ if (value === null || typeof value === "boolean" || typeof value === "string") return;
909
+ if (typeof value === "number") {
910
+ if (!Number.isFinite(value)) throw new Error(`${label} contains a non-finite number`);
911
+ return;
912
+ }
913
+ if (Array.isArray(value)) {
914
+ if (value.length > 256) throw new Error(`${label} contains an oversized array`);
915
+ for (const item of value) validateJsonValue(item, depth + 1, label);
916
+ return;
917
+ }
918
+ if (value && typeof value === "object") {
919
+ if (Object.keys(value).length > 256) throw new Error(`${label} contains an oversized object`);
920
+ for (const [key, item] of Object.entries(value)) {
921
+ if (key.length > 256) throw new Error(`${label} contains an oversized key`);
922
+ validateJsonValue(item, depth + 1, label);
923
+ }
924
+ return;
925
+ }
926
+ throw new Error(`${label} must be JSON serializable`);
927
+ }
928
+
929
+ function requireSubset(values, allowed, label) {
930
+ const missing = [...values].filter((value) => !allowed.has(value));
931
+ if (missing.length) throw new Error(`${label} were not requested by the plugin: ${missing.join(", ")}`);
932
+ }
933
+
934
+ function validateCredentials(credentials, requestedPermissions) {
935
+ const ids = new Set();
936
+ for (const credential of credentials) {
937
+ rejectUnknownFields(credential, CREDENTIAL_FIELDS, "credential");
938
+ requireUniqueIdentifier(credential.id, ids, "credential");
939
+ requireText(credential.label, 80, `credential ${credential.id} label`);
940
+ requireText(credential.description, 500, `credential ${credential.id} description`);
941
+ const kind = credential.kind || "api_token";
942
+ if (!CREDENTIAL_KINDS.has(kind)) throw new Error(`credential ${credential.id} kind is invalid`);
943
+ for (const field of ["required", "allow_multiple"]) {
944
+ if (credential[field] !== undefined && typeof credential[field] !== "boolean") {
945
+ throw new Error(`credential ${credential.id} ${field} must be boolean`);
946
+ }
947
+ }
948
+ if (credential.oauth_provider !== undefined && credential.oauth_provider !== null) {
949
+ if (!IDENTIFIER.test(String(credential.oauth_provider)) || kind !== "oauth_access_token") {
950
+ throw new Error(`credential ${credential.id} OAuth provider is invalid`);
951
+ }
952
+ }
953
+ }
954
+ if (credentials.length && !requestedPermissions.has("credentials")) {
955
+ throw new Error("credential declarations require credentials permission");
956
+ }
957
+ return ids;
958
+ }
959
+
960
+ function validateRequiredSecrets(requiredSecrets) {
961
+ const ids = new Set();
962
+ for (const secret of requiredSecrets) {
963
+ rejectUnknownFields(secret, REQUIRED_SECRET_FIELDS, "required secret");
964
+ if (!/^[A-Z][A-Z0-9_]{1,79}$/.test(String(secret.id || "")) || ids.has(secret.id)) {
965
+ throw new Error("required secret ids must be unique environment identifiers");
966
+ }
967
+ ids.add(secret.id);
968
+ requireText(secret.label, 80, `required secret ${secret.id} label`);
969
+ if (secret.description !== undefined && (typeof secret.description !== "string" || secret.description.length > 500)) {
970
+ throw new Error(`required secret ${secret.id} description is invalid`);
971
+ }
972
+ if (secret.required !== undefined && typeof secret.required !== "boolean") {
973
+ throw new Error(`required secret ${secret.id} policy is invalid`);
974
+ }
975
+ }
976
+ return ids;
977
+ }
978
+
979
+ function validateConnectors(connectors, credentialIds, secretIds) {
980
+ const ids = new Set();
981
+ for (const connector of connectors) {
982
+ rejectUnknownFields(connector, CONNECTOR_FIELDS, "connector");
983
+ requireUniqueIdentifier(connector.id, ids, "connector");
984
+ if (!CONNECTOR_KINDS.has(connector.kind)) throw new Error(`connector ${connector.id} kind is invalid`);
985
+ requireText(connector.label, 80, `connector ${connector.id} label`);
986
+ requireText(connector.description, 500, `connector ${connector.id} description`);
987
+ const credentialId = connector.credential_id;
988
+ if (credentialId !== undefined && credentialId !== null && !credentialIds.has(credentialId)) {
989
+ throw new Error(`connector ${connector.id} references an unknown credential`);
990
+ }
991
+ const cloudConnectionId = connector.cloud_connection_id;
992
+ if (cloudConnectionId !== undefined && cloudConnectionId !== null && !IDENTIFIER.test(String(cloudConnectionId))) {
993
+ throw new Error(`connector ${connector.id} Cloud connection is invalid`);
994
+ }
995
+ const cloudSecretId = connector.cloud_secret_id;
996
+ if (cloudSecretId !== undefined && cloudSecretId !== null && !secretIds.has(cloudSecretId)) {
997
+ throw new Error(`connector ${connector.id} references an undeclared Cloud secret`);
998
+ }
999
+ if (connector.kind === "oauth" && !credentialId) {
1000
+ throw new Error(`connector ${connector.id} requires a credential`);
1001
+ }
1002
+ if (connector.kind === "api_key" && !credentialId && !cloudSecretId) {
1003
+ throw new Error(`connector ${connector.id} requires a user credential or Cloud secret`);
1004
+ }
1005
+ const endpoint = connector.endpoint;
1006
+ if (["https", "mcp"].includes(connector.kind) && typeof endpoint !== "string") {
1007
+ throw new Error(`connector ${connector.id} requires an HTTPS endpoint`);
1008
+ }
1009
+ if (endpoint !== undefined && endpoint !== null && (
1010
+ typeof endpoint !== "string" || !endpoint.startsWith("https://") || endpoint.length > 2048 || /[\r\n@]/.test(endpoint)
1011
+ )) throw new Error(`connector ${connector.id} endpoint is invalid`);
1012
+ const timeout = connector.timeout_seconds ?? 10;
1013
+ if (typeof timeout !== "number" || !Number.isFinite(timeout) || timeout < 0.1 || timeout > 30) {
1014
+ throw new Error(`connector ${connector.id} timeout is invalid`);
1015
+ }
1016
+ const retry = connector.retry || {};
1017
+ assertObject(retry, `connector ${connector.id} retry`);
1018
+ rejectUnknownFields(retry, new Set(["max_attempts"]), `connector ${connector.id} retry`);
1019
+ const attempts = retry.max_attempts ?? 2;
1020
+ if (!Number.isInteger(attempts) || attempts < 1 || attempts > 4) throw new Error(`connector ${connector.id} retry count is invalid`);
1021
+ const rate = connector.rate_limit || {};
1022
+ assertObject(rate, `connector ${connector.id} rate_limit`);
1023
+ rejectUnknownFields(rate, new Set(["requests", "period_seconds"]), `connector ${connector.id} rate_limit`);
1024
+ const requests = rate.requests ?? 60;
1025
+ const period = rate.period_seconds ?? 60;
1026
+ if (!Number.isInteger(requests) || requests < 1 || requests > 10000 || !Number.isInteger(period) || period < 1 || period > 3600) {
1027
+ throw new Error(`connector ${connector.id} rate limit is invalid`);
1028
+ }
1029
+ }
1030
+ return ids;
1031
+ }
1032
+
1033
+ function isUuid(value) {
1034
+ return typeof value === "string" && /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value);
1035
+ }
1036
+
1037
+ function validateHttpsUrl(value, label) {
1038
+ if (typeof value !== "string" || value.length > 2048 || /[\r\n]/.test(value)) throw new Error(`${label} is invalid`);
1039
+ let parsed;
1040
+ try { parsed = new URL(value); } catch { throw new Error(`${label} is invalid`); }
1041
+ if (parsed.protocol !== "https:" || parsed.username || parsed.password || !parsed.hostname) {
1042
+ throw new Error(`${label} is invalid`);
1043
+ }
1044
+ }
1045
+
1046
+ function validateDependencies(dependencies, pluginId) {
1047
+ const ids = new Set();
1048
+ for (const dependency of dependencies) {
1049
+ rejectUnknownFields(dependency, DEPENDENCY_FIELDS, "dependency");
1050
+ requireUniqueIdentifier(dependency.id, ids, "dependency");
1051
+ if (!IDENTIFIER.test(String(dependency.plugin_id || "")) || dependency.plugin_id === pluginId) {
1052
+ throw new Error(`dependency ${dependency.id} plugin_id is invalid`);
1053
+ }
1054
+ if (dependency.required !== undefined && typeof dependency.required !== "boolean") throw new Error(`dependency ${dependency.id} required must be boolean`);
1055
+ for (const [field, fallback] of [["min_version", "0.0.0"], ["max_version", "999.999.999"]]) {
1056
+ if (!VERSION.test(String(dependency[field] || fallback))) throw new Error(`dependency ${dependency.id} ${field} is invalid`);
1057
+ }
1058
+ if (dependency.package_path !== undefined && dependency.package_path !== null) {
1059
+ const path = String(dependency.package_path).replaceAll("\\", "/");
1060
+ if (!/^children\/[A-Za-z0-9._-]+\.mplg$/.test(path) || path.length > 240) throw new Error(`dependency ${dependency.id} package_path is invalid`);
1061
+ dependency.package_path = path;
1062
+ }
1063
+ const exposed = dependency.exposed_capabilities || [];
1064
+ if (!Array.isArray(exposed) || exposed.length > 32 || new Set(exposed).size !== exposed.length || exposed.some((value) => !IDENTIFIER.test(String(value)))) {
1065
+ throw new Error(`dependency ${dependency.id} exposed_capabilities are invalid`);
1066
+ }
1067
+ }
1068
+ }
1069
+
1070
+ function validateSlashCommands(commands, capabilities) {
1071
+ const ids = new Set();
1072
+ const names = new Set();
1073
+ const executable = new Set(capabilities.filter((value) => ["tool", "skill"].includes(value.kind)).map((value) => value.id));
1074
+ for (const command of commands) {
1075
+ rejectUnknownFields(command, SLASH_COMMAND_FIELDS, "slash command");
1076
+ requireUniqueIdentifier(command.id, ids, "slash command");
1077
+ if (!/^[a-z0-9][a-z0-9_-]{0,31}$/.test(String(command.command || "")) || names.has(command.command)) {
1078
+ throw new Error(`slash command ${command.id} command is invalid or duplicate`);
1079
+ }
1080
+ names.add(command.command);
1081
+ requireText(command.title, 80, `slash command ${command.id} title`);
1082
+ requireText(command.description, 500, `slash command ${command.id} description`);
1083
+ if (!executable.has(command.capability)) throw new Error(`slash command ${command.id} targets a non-executable capability`);
1084
+ assertObject(command.argument_template || {}, `slash command ${command.id} argument_template`);
1085
+ assertJsonSize(command.argument_template || {}, 16 * 1024, `slash command ${command.id} argument_template`);
1086
+ }
1087
+ }
1088
+
1089
+ function validateRuntime(capability, runtime, permissions, credentials, connectors, dependencies) {
1090
+ const adapter = runtime.adapter;
1091
+ const credentialIds = new Set(credentials.map((value) => value.id));
1092
+ const dependencyById = new Map(dependencies.map((value) => [value.id, value]));
1093
+ const connector = connectors.find((value) => value.id === runtime.connector_id);
1094
+ const effectiveCredential = runtime.credential_id || connector?.credential_id;
1095
+ const effectiveEndpoint = runtime.endpoint || connector?.endpoint;
1096
+ if (adapter === "text_template" && (typeof runtime.template !== "string" || !runtime.template.trim() || runtime.template.length > 2000)) {
1097
+ throw new Error("text_template requires a bounded template");
1098
+ }
1099
+ if (adapter === "http_json" && effectiveCredential && (
1100
+ !credentialIds.has(effectiveCredential) || !permissions.has("network") || !permissions.has("credentials")
1101
+ )) throw new Error("http_json requires a declared credential and network/credentials permissions");
1102
+ if (adapter === "mcp_http") {
1103
+ if (!permissions.has("network")) throw new Error("mcp_http requires network permission");
1104
+ if (effectiveCredential && (!credentialIds.has(effectiveCredential) || !permissions.has("credentials"))) throw new Error("mcp_http requires a declared credential");
1105
+ if (typeof effectiveEndpoint !== "string" || !effectiveEndpoint.startsWith("https://") || effectiveEndpoint.includes("@")) throw new Error("mcp_http requires a valid HTTPS endpoint");
1106
+ if (!/^[A-Za-z0-9_.:/-]{1,160}$/.test(String(runtime.tool_name || ""))) throw new Error("mcp_http requires a valid tool name");
1107
+ }
1108
+ if (adapter === "child_plugin") {
1109
+ const dependency = dependencyById.get(runtime.dependency_id);
1110
+ if (!dependency || !(dependency.exposed_capabilities || []).includes(runtime.capability)) throw new Error("child_plugin requires an exposed dependency capability");
1111
+ }
1112
+ if (adapter === "calendar_store") {
1113
+ if (!permissions.has("storage")) throw new Error("calendar_store requires storage permission");
1114
+ if (runtime.operation === "reminder" && !permissions.has("notifications")) throw new Error("calendar reminder requires notifications permission");
1115
+ }
1116
+ if (adapter === "neis_school") {
1117
+ if (!permissions.has("network") || !permissions.has("storage")) throw new Error("neis_school requires network and storage permissions");
1118
+ if (!["setup", "lookup", "overview", "search", "reminder", "briefing"].includes(runtime.operation)) throw new Error("invalid neis_school operation");
1119
+ if (["reminder", "briefing"].includes(runtime.operation) && !permissions.has("notifications")) throw new Error("NEIS reminder requires notifications permission");
1120
+ }
1121
+ if (capability.kind === "provider" && (runtime.role !== "search" || !adapter)) throw new Error("provider capabilities must declare a search runtime");
1122
+ if (capability.kind === "background" && Object.keys(runtime).length) {
1123
+ if (!Number.isInteger(runtime.interval_minutes) || runtime.interval_minutes < 15 || runtime.interval_minutes > 10080) {
1124
+ throw new Error("background interval must be between 15 and 10080 minutes");
1125
+ }
1126
+ }
1127
+ }
1128
+
1129
+ function validateRuntimeReferences(capabilities, connectors, connectorIds, dependencies) {
1130
+ const connectorById = new Map(connectors.map((value) => [value.id, value]));
1131
+ const dependencyIds = new Set(dependencies.map((value) => value.id));
1132
+ for (const capability of capabilities) {
1133
+ const runtime = capability.runtime || {};
1134
+ if (runtime.connector_id) {
1135
+ if (!connectorIds.has(runtime.connector_id)) throw new Error(`capability ${capability.id} references an unknown connector`);
1136
+ if (!["http_json", "mcp_http"].includes(runtime.adapter)) throw new Error(`capability ${capability.id} connector is unsupported by its runtime`);
1137
+ const connector = connectorById.get(runtime.connector_id);
1138
+ if (connector.endpoint && runtime.endpoint !== undefined && runtime.endpoint !== connector.endpoint) throw new Error(`capability ${capability.id} connector endpoint conflicts with runtime`);
1139
+ if (connector.credential_id && runtime.credential_id !== undefined && runtime.credential_id !== connector.credential_id) throw new Error(`capability ${capability.id} connector credential conflicts with runtime`);
1140
+ }
1141
+ if (runtime.adapter === "child_plugin" && !dependencyIds.has(runtime.dependency_id)) throw new Error(`capability ${capability.id} references an unknown dependency`);
1142
+ }
1143
+ }
1144
+
1145
+ function validateUiConfig(extension, capabilities, routeIds) {
1146
+ const config = extension.config || {};
1147
+ const executable = new Set(capabilities.filter((value) => ["tool", "skill", "provider", "notification"].includes(value.kind)).map((value) => value.id));
1148
+ if (config.ui_schema === 2) {
1149
+ if (config.placement !== undefined && !["card", "action"].includes(extension.point)) throw new Error("UI placement is only valid for home extensions");
1150
+ validateUiRuntimeV2(config, executable, routeIds);
1151
+ return;
1152
+ }
1153
+ const allowed = new Set(["icon", "description", "component", "sections", "actions", "capability", "label", "form"]);
1154
+ rejectUnknownFields(config, allowed, `UI ${extension.id} config`);
1155
+ if (config.icon !== undefined && !IDENTIFIER.test(String(config.icon))) throw new Error(`UI ${extension.id} icon is invalid`);
1156
+ if (config.description !== undefined) requireText(config.description, 240, `UI ${extension.id} description`);
1157
+ if (config.component !== undefined && !["action", "card", "settings_action"].includes(config.component)) throw new Error(`UI ${extension.id} component is invalid`);
1158
+ const sections = config.sections || [];
1159
+ if (!Array.isArray(sections) || sections.length > 12) throw new Error(`UI ${extension.id} sections are invalid`);
1160
+ for (const section of sections) {
1161
+ rejectUnknownFields(section, new Set(["heading", "body"]), `UI ${extension.id} section`);
1162
+ if (section.heading !== undefined) requireText(section.heading, 80, `UI ${extension.id} section heading`);
1163
+ requireText(section.body, 1200, `UI ${extension.id} section body`);
1164
+ }
1165
+ const actions = config.actions || [];
1166
+ if (!Array.isArray(actions) || actions.length > 6) throw new Error(`UI ${extension.id} actions are invalid`);
1167
+ const actionIds = new Set();
1168
+ for (const action of actions) {
1169
+ rejectUnknownFields(action, new Set(["id", "label", "capability", "style"]), `UI ${extension.id} action`);
1170
+ requireUniqueIdentifier(action.id, actionIds, `UI ${extension.id} action`);
1171
+ requireText(action.label, 60, `UI ${extension.id} action label`);
1172
+ if (!executable.has(action.capability) || !["primary", "secondary"].includes(action.style || "secondary")) throw new Error(`UI ${extension.id} action is invalid`);
1173
+ }
1174
+ if ((config.capability === undefined) !== (config.label === undefined)) throw new Error(`UI ${extension.id} shorthand action is incomplete`);
1175
+ if (config.capability !== undefined && (actions.length || !executable.has(config.capability))) throw new Error(`UI ${extension.id} shorthand action is invalid`);
1176
+ if (config.label !== undefined) requireText(config.label, 60, `UI ${extension.id} shorthand label`);
1177
+ if (config.form !== undefined) validateLegacyUiForm(config.form, executable, extension.id);
1178
+ }
1179
+
1180
+ function validateLegacyUiForm(form, executable, extensionId) {
1181
+ rejectUnknownFields(form, new Set(["submit_capability", "submit_label", "fields"]), `UI ${extensionId} form`);
1182
+ if (!executable.has(form.submit_capability)) throw new Error(`UI ${extensionId} form targets an unknown capability`);
1183
+ requireText(form.submit_label, 60, `UI ${extensionId} form submit label`);
1184
+ if (!Array.isArray(form.fields) || form.fields.length < 1 || form.fields.length > 12) throw new Error(`UI ${extensionId} form fields are invalid`);
1185
+ const ids = new Set();
1186
+ for (const field of form.fields) {
1187
+ rejectUnknownFields(field, new Set(["id", "type", "label", "description", "placeholder", "required", "min", "max", "options"]), `UI ${extensionId} form field`);
1188
+ requireUniqueIdentifier(field.id, ids, `UI ${extensionId} form field`);
1189
+ if (!["text", "integer", "select"].includes(field.type)) throw new Error(`UI ${extensionId} form field type is invalid`);
1190
+ requireText(field.label, 80, `UI ${extensionId} form field label`);
1191
+ if (field.description !== undefined) requireText(field.description, 240, `UI ${extensionId} form field description`);
1192
+ if (field.placeholder !== undefined) requireText(field.placeholder, 120, `UI ${extensionId} form field placeholder`);
1193
+ if (field.required !== undefined && typeof field.required !== "boolean") throw new Error(`UI ${extensionId} form field required must be boolean`);
1194
+ if (field.type === "integer") {
1195
+ for (const value of [field.min, field.max]) if (value !== undefined && (!Number.isInteger(value) || Math.abs(value) > 1000000)) throw new Error(`UI ${extensionId} integer range is invalid`);
1196
+ if (Number.isInteger(field.min) && Number.isInteger(field.max) && field.min > field.max) throw new Error(`UI ${extensionId} integer range is invalid`);
1197
+ } else if (field.min !== undefined || field.max !== undefined) throw new Error(`UI ${extensionId} range is only valid for integers`);
1198
+ if (field.type === "select") {
1199
+ if (!Array.isArray(field.options) || field.options.length < 1 || field.options.length > 50) throw new Error(`UI ${extensionId} select options are invalid`);
1200
+ for (const option of field.options) {
1201
+ if (!option || typeof option !== "object" || Array.isArray(option) || new Set(Object.keys(option)).size !== 2 || !("value" in option) || !("label" in option)) throw new Error(`UI ${extensionId} select option is invalid`);
1202
+ requireText(option.value, 120, `UI ${extensionId} option value`);
1203
+ requireText(option.label, 120, `UI ${extensionId} option label`);
1204
+ }
1205
+ } else if (field.options !== undefined) throw new Error(`UI ${extensionId} options are only valid for selects`);
1206
+ }
1207
+ }
1208
+
1209
+ function validateUiRuntimeV2(config, executable, routes) {
1210
+ rejectUnknownFields(config, new Set(["ui_schema", "icon", "description", "placement", "initial_state", "data_sources", "view"]), "UI Runtime v2");
1211
+ if (config.ui_schema !== 2) throw new Error("UI Runtime version must be 2");
1212
+ optionalUiText(config.icon, 128, { identifier: true });
1213
+ optionalUiText(config.description, 240);
1214
+ if (config.placement !== undefined) {
1215
+ const placement = config.placement;
1216
+ rejectUnknownFields(placement, new Set(["section_id", "section_title", "section_order", "layout", "show_header"]), "UI placement");
1217
+ optionalUiText(placement.section_id, 128, { required: true, identifier: true });
1218
+ optionalUiText(placement.section_title, 80, { required: true });
1219
+ const order = placement.section_order ?? 0;
1220
+ if (!Number.isInteger(order) || order < -10000 || order > 10000) throw new Error("UI placement order is invalid");
1221
+ if (!["stack", "horizontal", "grid"].includes(placement.layout || "stack")) throw new Error("UI placement layout is invalid");
1222
+ if (typeof (placement.show_header ?? true) !== "boolean") throw new Error("UI placement show_header must be boolean");
1223
+ }
1224
+
1225
+ const state = config.initial_state || {};
1226
+ assertObject(state, "UI initial_state");
1227
+ if (Object.keys(state).length > 32) throw new Error("UI initial_state is too large");
1228
+ for (const [key, value] of Object.entries(state)) {
1229
+ if (!UI_STATE_KEY.test(key)) throw new Error("UI initial_state contains an invalid key");
1230
+ validateUiJson(value, 0);
1231
+ }
1232
+ const stateKeys = new Set(Object.keys(state));
1233
+
1234
+ const sources = config.data_sources || [];
1235
+ if (!Array.isArray(sources) || sources.length > 8) throw new Error("UI data_sources are invalid");
1236
+ const sourceIds = new Set();
1237
+ for (const source of sources) {
1238
+ rejectUnknownFields(source, new Set(["id", "capability", "trigger", "query", "arguments", "refresh_seconds"]), "UI data source");
1239
+ if (!UI_IDENTIFIER.test(String(source.id || "")) || sourceIds.has(source.id) || !executable.has(source.capability) || !["load", "manual"].includes(source.trigger || "load")) {
1240
+ throw new Error("UI data source reference is invalid");
1241
+ }
1242
+ sourceIds.add(source.id);
1243
+ }
1244
+ for (const source of sources) {
1245
+ optionalUiTemplate(source.query, 2000);
1246
+ validateTemplateReferences(source.query, stateKeys, sourceIds);
1247
+ assertObject(source.arguments || {}, "UI data source arguments");
1248
+ validateUiBindingValue(source.arguments || {}, 0, stateKeys, sourceIds);
1249
+ if (source.refresh_seconds !== undefined && (!Number.isInteger(source.refresh_seconds) || source.refresh_seconds < 30 || source.refresh_seconds > 86400)) {
1250
+ throw new Error("UI refresh must be between 30 and 86400 seconds");
1251
+ }
1252
+ }
1253
+ assertObject(config.view, "UI Runtime v2 view");
1254
+ validateUiNode(config.view, 0, { value: 0 }, executable, routes, sourceIds, stateKeys);
1255
+ }
1256
+
1257
+ function validateUiNode(node, depth, counter, executable, routes, sources, stateKeys) {
1258
+ const nodeKeys = new Set(["id", "type", "props", "children", "action", "visible_when"]);
1259
+ rejectUnknownFields(node, nodeKeys, "UI component");
1260
+ counter.value += 1;
1261
+ if (depth > 12 || counter.value > 160) throw new Error("UI component tree is too large");
1262
+ if (!new Set(contract.ui_nodes).has(node.type)) throw new Error(`unknown UI component type: ${node.type}`);
1263
+ if (node.id !== undefined && !UI_IDENTIFIER.test(String(node.id))) throw new Error("UI component id is invalid");
1264
+ const props = node.props || {};
1265
+ validateUiProps(node.type, props, sources, stateKeys);
1266
+ validateUiCondition(node.visible_when, stateKeys, sources);
1267
+ validateUiAction(node.action, executable, routes, sources, stateKeys);
1268
+ const children = node.children || [];
1269
+ if (!Array.isArray(children) || children.length > 32) throw new Error("UI component children are invalid");
1270
+ const leaves = new Set(["text", "icon", "divider", "spacer", "button", "chip", "metric", "progress", "calendar", "chart", "field", "select", "switch", "empty"]);
1271
+ if (leaves.has(node.type) && children.length) throw new Error("leaf UI component cannot have children");
1272
+ if (["list", "timeline"].includes(node.type) && children.length !== 1) throw new Error("list UI component requires one item template");
1273
+ for (const child of children) {
1274
+ assertObject(child, "UI component child");
1275
+ validateUiNode(child, depth + 1, counter, executable, routes, sources, stateKeys);
1276
+ }
1277
+ }
1278
+
1279
+ function validateUiProps(nodeType, props, sources, stateKeys) {
1280
+ const allowed = new Set([
1281
+ "text", "title", "subtitle", "label", "supporting", "icon", "style", "tone", "align",
1282
+ "max_lines", "spacing", "padding", "columns", "stack_at", "min_item_width", "size", "value",
1283
+ "source", "empty_text", "limit", "state_key", "placeholder", "input_type", "options", "persist",
1284
+ "selected", "dense", "chart_type", "x_key", "y_key", "date_key", "title_key", "show_legend", "full_width",
1285
+ ]);
1286
+ rejectUnknownFields(props, allowed, "UI component props");
1287
+ for (const key of ["text", "title", "subtitle", "label", "supporting", "placeholder", "empty_text", "value"]) {
1288
+ if (props[key] !== undefined) {
1289
+ optionalUiTemplate(props[key], 1200, true);
1290
+ validateTemplateReferences(props[key], stateKeys, sources);
1291
+ }
1292
+ }
1293
+ for (const key of ["icon", "style", "tone", "align", "input_type", "chart_type", "x_key", "y_key", "date_key", "title_key"]) {
1294
+ if (props[key] !== undefined) optionalUiText(props[key], 80, { required: true, identifier: true });
1295
+ }
1296
+ for (const key of ["max_lines", "spacing", "padding", "columns", "size", "limit"]) {
1297
+ if (props[key] !== undefined && (!Number.isInteger(props[key]) || props[key] < 0 || props[key] > 100)) throw new Error(`UI numeric property ${key} is invalid`);
1298
+ }
1299
+ if (props.stack_at !== undefined && (!Number.isInteger(props.stack_at) || props.stack_at < 0 || props.stack_at > 1200)) throw new Error("UI stack_at is invalid");
1300
+ if (props.min_item_width !== undefined && (!Number.isInteger(props.min_item_width) || props.min_item_width < 96 || props.min_item_width > 600)) throw new Error("UI min_item_width is invalid");
1301
+ for (const key of ["persist", "selected", "dense", "show_legend", "full_width"]) if (props[key] !== undefined && typeof props[key] !== "boolean") throw new Error(`UI boolean property ${key} is invalid`);
1302
+ if (["list", "timeline", "calendar", "chart"].includes(nodeType)) {
1303
+ if (typeof props.source !== "string" || !UI_BINDING.test(props.source) || !props.source.startsWith("data.") || !sources.has(props.source.split(".")[1])) throw new Error("UI list source is invalid");
1304
+ }
1305
+ if (nodeType === "chart" && !["bar", "line", "donut"].includes(props.chart_type || "bar")) throw new Error("UI chart type is invalid");
1306
+ if (nodeType === "calendar" && props.state_key !== undefined && !stateKeys.has(props.state_key)) throw new Error("UI calendar state is unknown");
1307
+ if (["field", "select", "switch"].includes(nodeType) && !stateKeys.has(props.state_key)) throw new Error("UI state field is unknown");
1308
+ if (props.options !== undefined) {
1309
+ if (nodeType !== "select" || !Array.isArray(props.options) || props.options.length < 1 || props.options.length > 32) throw new Error("UI select options are invalid");
1310
+ for (const option of props.options) {
1311
+ if (!option || typeof option !== "object" || Array.isArray(option) || Object.keys(option).sort().join(",") !== "label,value") throw new Error("UI select option is invalid");
1312
+ validateUiJson(option.value, 0);
1313
+ optionalUiText(option.label, 80, { required: true });
1314
+ }
1315
+ }
1316
+ }
1317
+
1318
+ function validateUiCondition(condition, stateKeys, sources) {
1319
+ if (condition === undefined) return;
1320
+ rejectUnknownFields(condition, new Set(["path", "equals", "not_equals", "exists"]), "UI visibility condition");
1321
+ if (typeof condition.path !== "string" || !UI_BINDING.test(condition.path)) throw new Error("UI visibility path is invalid");
1322
+ validateKnownBinding(condition.path, stateKeys, sources);
1323
+ const checks = ["equals", "not_equals", "exists"].filter((key) => key in condition);
1324
+ if (checks.length !== 1 || (checks[0] === "exists" && typeof condition.exists !== "boolean")) throw new Error("UI visibility comparison is invalid");
1325
+ if (checks[0] !== "exists") validateUiJson(condition[checks[0]], 0);
1326
+ }
1327
+
1328
+ function validateUiAction(action, executable, routes, sources, stateKeys) {
1329
+ if (action === undefined) return;
1330
+ rejectUnknownFields(action, new Set(["type", "capability", "query", "arguments", "store", "target", "source", "values", "persist"]), "UI action");
1331
+ if (!["invoke", "navigate", "set_state", "refresh", "back"].includes(action.type)) throw new Error("UI action type is invalid");
1332
+ if (action.type === "invoke") {
1333
+ if (!executable.has(action.capability)) throw new Error("UI action targets a non-executable capability");
1334
+ optionalUiTemplate(action.query, 2000);
1335
+ validateTemplateReferences(action.query, stateKeys, sources);
1336
+ assertObject(action.arguments || {}, "UI action arguments");
1337
+ validateUiBindingValue(action.arguments || {}, 0, stateKeys, sources);
1338
+ if (action.store !== undefined && !sources.has(action.store)) throw new Error("UI action store is unknown");
1339
+ } else if (action.type === "navigate") {
1340
+ if (!routes.has(action.target)) throw new Error("UI navigation target is unknown");
1341
+ } else if (action.type === "set_state") {
1342
+ assertObject(action.values, "UI state action values");
1343
+ if (!Object.keys(action.values).length || Object.keys(action.values).some((key) => !stateKeys.has(key))) throw new Error("UI state action targets an unknown state key");
1344
+ validateUiBindingValue(action.values, 0, stateKeys, sources);
1345
+ if (typeof (action.persist ?? false) !== "boolean") throw new Error("UI state persistence flag is invalid");
1346
+ } else if (action.type === "refresh") {
1347
+ if (!sources.has(action.source)) throw new Error("UI refresh source is unknown");
1348
+ } else if (Object.keys(action).length !== 1) throw new Error("back UI action cannot contain arguments");
1349
+ }
1350
+
1351
+ function validateUiBindingValue(value, depth, stateKeys, sources) {
1352
+ if (depth > 8) throw new Error("UI binding value is too deep");
1353
+ if (typeof value === "string") {
1354
+ optionalUiTemplate(value, 2000, true);
1355
+ validateTemplateReferences(value, stateKeys, sources);
1356
+ } else if (value === null || typeof value === "boolean" || typeof value === "number") {
1357
+ validateUiJson(value, depth);
1358
+ } else if (Array.isArray(value)) {
1359
+ if (value.length > 64) throw new Error("UI binding list is too large");
1360
+ for (const item of value) validateUiBindingValue(item, depth + 1, stateKeys, sources);
1361
+ } else {
1362
+ assertObject(value, "UI binding object");
1363
+ if (Object.keys(value).length > 64 || Object.keys(value).some((key) => !UI_STATE_KEY.test(key))) throw new Error("UI binding object is invalid");
1364
+ for (const item of Object.values(value)) validateUiBindingValue(item, depth + 1, stateKeys, sources);
1365
+ }
1366
+ }
1367
+
1368
+ function validateUiJson(value, depth) {
1369
+ if (depth > 8) throw new Error("UI JSON value is too deep");
1370
+ if (value === null || typeof value === "boolean" || Number.isInteger(value)) return;
1371
+ if (typeof value === "number") {
1372
+ if (!Number.isFinite(value)) throw new Error("UI number is non-finite");
1373
+ return;
1374
+ }
1375
+ if (typeof value === "string") {
1376
+ if (value.length > 2000) throw new Error("UI string is too long");
1377
+ return;
1378
+ }
1379
+ if (Array.isArray(value)) {
1380
+ if (value.length > 64) throw new Error("UI list is too large");
1381
+ for (const item of value) validateUiJson(item, depth + 1);
1382
+ return;
1383
+ }
1384
+ assertObject(value, "UI JSON object");
1385
+ if (Object.keys(value).length > 64 || Object.keys(value).some((key) => key.length > 64 || key.startsWith("_"))) throw new Error("UI JSON object is invalid");
1386
+ for (const item of Object.values(value)) validateUiJson(item, depth + 1);
1387
+ }
1388
+
1389
+ function optionalUiTemplate(value, maximum, required = false) {
1390
+ if (value === undefined || value === null) {
1391
+ if (required) throw new Error("UI text template is required");
1392
+ return;
1393
+ }
1394
+ if (typeof value !== "string" || (required && !value.trim()) || value.length > maximum) throw new Error("UI text template is invalid");
1395
+ for (const match of value.matchAll(UI_TEMPLATE_BINDING)) if (!UI_BINDING.test(match[1].trim())) throw new Error("UI template binding is invalid");
1396
+ }
1397
+
1398
+ function validateTemplateReferences(value, stateKeys, sources) {
1399
+ if (typeof value !== "string") return;
1400
+ for (const match of value.matchAll(UI_TEMPLATE_BINDING)) validateKnownBinding(match[1].trim(), stateKeys, sources);
1401
+ }
1402
+
1403
+ function validateKnownBinding(path, stateKeys, sources) {
1404
+ const parts = path.split(".");
1405
+ if (parts[0] === "state" && (parts.length < 2 || !stateKeys.has(parts[1]))) throw new Error("UI state binding is unknown");
1406
+ if (parts[0] === "data" && (parts.length < 2 || !sources.has(parts[1]))) throw new Error("UI data binding is unknown");
1407
+ }
1408
+
1409
+ function optionalUiText(value, maximum, { required = false, identifier = false } = {}) {
1410
+ if (value === undefined || value === null) {
1411
+ if (required) throw new Error("UI text is required");
1412
+ return;
1413
+ }
1414
+ if (typeof value !== "string" || (required && !value.trim()) || value.length > maximum || (identifier && value && !UI_IDENTIFIER.test(value))) {
1415
+ throw new Error("UI text is invalid");
1416
+ }
1417
+ }
1418
+
1419
+ function equalSets(left, right) {
1420
+ return left.size === right.size && [...left].every((value) => right.has(value));
1421
+ }
1422
+
1423
+ function compileManifest(files) {
1424
+ let manifest;
1425
+ try {
1426
+ manifest = JSON.parse(files["manifest.json"]);
1427
+ } catch {
1428
+ throw new Error("manifest.json must contain a JSON object");
1429
+ }
1430
+ if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) throw new Error("manifest.json must contain a JSON object");
1431
+ manifest = structuredClone(manifest);
1432
+ for (const [folder, [target, kind]] of FRAGMENTS) {
1433
+ const fragments = Object.keys(files).filter((name) => name.startsWith(`${folder}/`) && name.endsWith(".json")).sort();
1434
+ if (!Array.isArray(manifest[target])) manifest[target] = [];
1435
+ for (const name of fragments) {
1436
+ let value;
1437
+ try {
1438
+ value = JSON.parse(files[name]);
1439
+ } catch {
1440
+ throw new Error(`${name} must contain valid JSON`);
1441
+ }
1442
+ for (const item of Array.isArray(value) ? value : [value]) {
1443
+ if (!item || typeof item !== "object" || Array.isArray(item)) throw new Error(`${name} must contain an object or array of objects`);
1444
+ const fragment = kind ? { ...item, kind } : { ...item };
1445
+ if (folder === "search") {
1446
+ if (!fragment.runtime || typeof fragment.runtime !== "object" || Array.isArray(fragment.runtime)) {
1447
+ fragment.runtime = {};
1448
+ }
1449
+ if (fragment.runtime.role === undefined) fragment.runtime.role = "search";
1450
+ }
1451
+ manifest[target].push(fragment);
1452
+ }
1453
+ }
1454
+ }
1455
+ if (Array.isArray(manifest.capabilities)) {
1456
+ manifest.capabilities = manifest.capabilities.map((capability, index) => (
1457
+ normalizeCapabilitySource(capability, `manifest.capabilities[${index}]`)
1458
+ ));
1459
+ }
1460
+ return manifest;
1461
+ }
1462
+
1463
+ function normalizeCapabilitySource(value, label) {
1464
+ const capability = structuredClone(value);
1465
+ const shorthand = CAPABILITY_RUNTIME_SHORTHAND.filter((name) => Object.hasOwn(capability, name));
1466
+ if (capability.runtime !== undefined && (!capability.runtime || typeof capability.runtime !== "object" || Array.isArray(capability.runtime))) {
1467
+ throw new Error(`${label}.runtime must be an object`);
1468
+ }
1469
+ if (shorthand.length) {
1470
+ const runtime = { ...(capability.runtime || {}) };
1471
+ for (const name of shorthand) {
1472
+ if (Object.hasOwn(runtime, name) && !canonicalJson(runtime[name]).equals(canonicalJson(capability[name]))) {
1473
+ throw new Error(`${label}.${name} conflicts with ${label}.runtime.${name}`);
1474
+ }
1475
+ runtime[name] = capability[name];
1476
+ delete capability[name];
1477
+ }
1478
+ capability.runtime = runtime;
1479
+ }
1480
+ if (capability.runtime?.entrypoint !== undefined) {
1481
+ capability.runtime.entrypoint = normalizeEntrypointPath(capability.runtime.entrypoint);
1482
+ }
1483
+ return capability;
1484
+ }
1485
+
1486
+ async function readProjectFiles(root) {
1487
+ const files = {};
1488
+ async function visit(directory, prefix = "") {
1489
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
1490
+ if (entry.isSymbolicLink()) throw new Error("Project may not contain symbolic links");
1491
+ const name = prefix ? `${prefix}/${entry.name}` : entry.name;
1492
+ if (entry.isDirectory()) {
1493
+ if (!IGNORED_DIRECTORIES.has(entry.name)) await visit(join(directory, entry.name), name);
1494
+ continue;
1495
+ }
1496
+ if (!entry.isFile()) continue;
1497
+ const safeName = safeFilePath(name);
1498
+ assertNonSensitiveSource(safeName);
1499
+ const content = await readFile(join(directory, entry.name));
1500
+ if (content.length > MAX_FILE_BYTES) throw new Error(`${safeName} exceeds the 512 KiB file limit`);
1501
+ files[safeName] = projectFileSource(safeName, content);
1502
+ }
1503
+ }
1504
+ await visit(root);
1505
+ const size = Object.entries(files).reduce((total, [name, content]) => total + Buffer.byteLength(name) + projectFileBytes(name, content).length, 0);
1506
+ if (Object.keys(files).length < 1 || Object.keys(files).length > MAX_FILES) throw new Error(`project must contain 1 to ${MAX_FILES} files`);
1507
+ if (size > MAX_PROJECT_BYTES) throw new Error("project exceeds the 1 MiB source limit");
1508
+ return files;
1509
+ }
1510
+
1511
+ async function readDirectory(path) {
1512
+ try {
1513
+ return await readdir(path);
1514
+ } catch (error) {
1515
+ if (error.code === "ENOENT") return [];
1516
+ throw error;
1517
+ }
1518
+ }
1519
+
1520
+ function safeFilePath(value) {
1521
+ if (typeof value !== "string" || value.length < 1 || value.length > 240 || value.includes("\\") || value.includes("\0")) throw new Error("file path is invalid");
1522
+ const parts = value.split("/");
1523
+ if (parts.some((part) => !part || part === "." || part === ".." || !SAFE_SEGMENT.test(part) || WINDOWS_RESERVED.test(part) || part.trim() !== part)) {
1524
+ throw new Error("file path must be a safe relative POSIX path");
1525
+ }
1526
+ return parts.join("/");
1527
+ }
1528
+
1529
+ function normalizeEntrypointPath(value) {
1530
+ return safeFilePath(String(value || "").replaceAll("\\", "/"));
1531
+ }
1532
+
1533
+ function safeProjectPath(value) {
1534
+ if (typeof value !== "string" || !value.trim() || resolve(value) === resolve(".")) throw new Error("project path must be a non-empty relative path");
1535
+ if (resolve(value) === value) throw new Error("project path must be relative to the configured workspace");
1536
+ return value.replaceAll("\\", "/").split("/").map((part) => {
1537
+ if (!part || part === "." || part === ".." || WINDOWS_RESERVED.test(part)) throw new Error("project path is unsafe");
1538
+ return part;
1539
+ }).join("/");
1540
+ }
1541
+
1542
+ function artifactFileName(value, suffix, fallback) {
1543
+ let candidate = String(value || fallback).trim();
1544
+ if (!candidate || candidate === "." || candidate === ".." || candidate.includes("/") || candidate.includes("\\") || [...candidate].some((character) => character.charCodeAt(0) < 32 || character.charCodeAt(0) === 127)) {
1545
+ throw new Error("artifact file_name must be a safe file name without a directory");
1546
+ }
1547
+ if (!candidate.toLowerCase().endsWith(suffix)) {
1548
+ if (extname(candidate)) throw new Error(`artifact file_name must end with ${suffix}`);
1549
+ candidate += suffix;
1550
+ }
1551
+ if (candidate.length > 180) throw new Error("artifact file_name is too long");
1552
+ return candidate;
1553
+ }
1554
+
1555
+ function projectDigest(files) {
1556
+ const digest = createHash("sha256");
1557
+ for (const name of Object.keys(files).sort()) {
1558
+ digest.update(name);
1559
+ digest.update("\0");
1560
+ digest.update(files[name]);
1561
+ digest.update("\0");
1562
+ }
1563
+ return digest.digest("hex");
1564
+ }
1565
+
1566
+ function projectResult(project, root, files, includeFiles) {
1567
+ return {
1568
+ project_id: project.id,
1569
+ plugin_id: project.plugin_id,
1570
+ name: project.name,
1571
+ description: project.description,
1572
+ publisher: project.publisher,
1573
+ revision: project.revision,
1574
+ updated_at: project.updated_at,
1575
+ workspace_path: root,
1576
+ ...(includeFiles ? { files } : { file_names: Object.keys(files).sort() }),
1577
+ };
1578
+ }
1579
+
1580
+ function artifactResult(artifact, absolutePath, downloadFileName) {
1581
+ return {
1582
+ artifact_id: artifact.id,
1583
+ project_id: artifact.project_id,
1584
+ job_id: artifact.job_id,
1585
+ kind: artifact.kind,
1586
+ file_name: artifact.file_name,
1587
+ download_file_name: downloadFileName,
1588
+ mime_type: artifact.mime_type,
1589
+ size_bytes: artifact.size_bytes,
1590
+ sha256: artifact.sha256,
1591
+ local_path: absolutePath,
1592
+ download_url: pathToFileURL(absolutePath).href,
1593
+ expires_in_seconds: null,
1594
+ };
1595
+ }
1596
+
1597
+ function jobResult(job) {
1598
+ return {
1599
+ job_id: job.id,
1600
+ project_id: job.project_id,
1601
+ file_name: job.file_name,
1602
+ state: job.state,
1603
+ progress: job.progress,
1604
+ artifact_id: job.artifact_id,
1605
+ error_code: job.error_code,
1606
+ error_message: job.error_message,
1607
+ created_at: job.created_at,
1608
+ started_at: job.started_at,
1609
+ finished_at: job.finished_at,
1610
+ updated_at: job.updated_at,
1611
+ };
1612
+ }
1613
+
1614
+ function toPosix(value) {
1615
+ return value.split(sep).join("/");
1616
+ }
1617
+
1618
+ function escapeHtml(value) {
1619
+ return String(value).replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;");
1620
+ }
1621
+
1622
+ export const internal = {
1623
+ artifactFileName,
1624
+ compileManifest,
1625
+ normalizeEntrypointPath,
1626
+ projectFileBytes,
1627
+ safeFilePath,
1628
+ signatureContract: SDK_CONTRACT.signature,
1629
+ validateManifest,
1630
+ };