@sellable/install 0.1.579 → 0.1.582

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,977 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { spawnSync } from "node:child_process";
4
+ import { createHash, randomUUID } from "node:crypto";
5
+ import {
6
+ closeSync,
7
+ constants,
8
+ existsSync,
9
+ fstatSync,
10
+ lstatSync,
11
+ mkdirSync,
12
+ openSync,
13
+ readFileSync,
14
+ readdirSync,
15
+ realpathSync,
16
+ renameSync,
17
+ rmSync,
18
+ writeFileSync,
19
+ } from "node:fs";
20
+ import { dirname, isAbsolute, join, relative, sep } from "node:path";
21
+
22
+ import {
23
+ SOUL_BRIDGE_DATA_ROOT,
24
+ discoverLiveHermesGateway,
25
+ revalidateLiveHermesGateway,
26
+ soulProfilePaths,
27
+ withProfileSoulLock,
28
+ } from "./fly-soul-bridge.mjs";
29
+ import { markHermesSnapshotDirty } from "./hermes-memory-snapshot.mjs";
30
+
31
+ export const SKILLS_BRIDGE_SCHEMA_VERSION = "sellable-agent-skills-bridge/v1";
32
+ export const SKILLS_PROOF_SCHEMA_VERSION = "sellable-agent-skills-proof/v1";
33
+ export const SKILLS_MAX_COUNT = 256;
34
+ export const SKILL_MAX_FILES = 256;
35
+ export const SKILL_MAX_TOTAL_BYTES = 2 * 1024 * 1024;
36
+ export const SKILL_MAX_TEXT_BYTES = 256 * 1024;
37
+ export const SKILLS_REQUEST_MAX_BYTES = 512 * 1024;
38
+
39
+ const PROFILE_ID = /^[a-z0-9][a-z0-9_-]{0,127}$/;
40
+ const SHA256 = /^[a-f0-9]{64}$/;
41
+ const SAFE_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
42
+ const PYTHON = "/opt/hermes/.venv/bin/python3";
43
+ const SUPPORT_ROOTS = new Set(["references", "templates"]);
44
+ const SOURCE_MANAGED = new Set(["bundled", "hub", "plugin", "org", "external"]);
45
+
46
+ const sha256 = (value) => createHash("sha256").update(value).digest("hex");
47
+ const stableJson = (value) => {
48
+ if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
49
+ if (value && typeof value === "object") {
50
+ return `{${Object.keys(value)
51
+ .sort()
52
+ .map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`)
53
+ .join(",")}}`;
54
+ }
55
+ return JSON.stringify(value);
56
+ };
57
+
58
+ export class FlySkillsBridgeError extends Error {
59
+ constructor(code) {
60
+ super(code);
61
+ this.name = "FlySkillsBridgeError";
62
+ this.code = code;
63
+ }
64
+ }
65
+
66
+ function fail(code) {
67
+ throw new FlySkillsBridgeError(code);
68
+ }
69
+
70
+ function validProfileId(value) {
71
+ if (typeof value !== "string" || !PROFILE_ID.test(value)) {
72
+ fail("skills_profile_id_rejected");
73
+ }
74
+ return value;
75
+ }
76
+
77
+ function utf8(bytes, code = "skills_utf8_rejected") {
78
+ try {
79
+ return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
80
+ } catch {
81
+ fail(code);
82
+ }
83
+ }
84
+
85
+ function safeRelativePath(value, { supporting = false } = {}) {
86
+ if (
87
+ typeof value !== "string" ||
88
+ value.length < 1 ||
89
+ value.length > 256 ||
90
+ value.includes("\\") ||
91
+ value.includes("\0") ||
92
+ isAbsolute(value)
93
+ ) {
94
+ fail("skills_file_path_rejected");
95
+ }
96
+ const parts = value.split("/");
97
+ if (parts.some((part) => !part || part === "." || part === "..")) {
98
+ fail("skills_file_path_rejected");
99
+ }
100
+ if (supporting && (!SUPPORT_ROOTS.has(parts[0]) || parts.length < 2)) {
101
+ fail("skills_support_path_rejected");
102
+ }
103
+ return parts.join("/");
104
+ }
105
+
106
+ function regularFile(path, maxBytes = SKILL_MAX_TOTAL_BYTES) {
107
+ const link = lstatSync(path);
108
+ if (
109
+ link.isSymbolicLink() ||
110
+ !link.isFile() ||
111
+ link.nlink !== 1 ||
112
+ link.size > maxBytes
113
+ ) {
114
+ fail("skills_file_rejected");
115
+ }
116
+ const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
117
+ try {
118
+ const opened = fstatSync(fd);
119
+ if (
120
+ !opened.isFile() ||
121
+ opened.dev !== link.dev ||
122
+ opened.ino !== link.ino ||
123
+ opened.nlink !== 1 ||
124
+ opened.size !== link.size
125
+ ) {
126
+ fail("skills_file_changed");
127
+ }
128
+ return readFileSync(fd);
129
+ } finally {
130
+ closeSync(fd);
131
+ }
132
+ }
133
+
134
+ function contained(root, candidate) {
135
+ const rel = relative(root, candidate);
136
+ return (
137
+ rel !== "" &&
138
+ rel !== ".." &&
139
+ !rel.startsWith(`..${sep}`) &&
140
+ !isAbsolute(rel)
141
+ );
142
+ }
143
+
144
+ function inspectTree(skillRoot, limits) {
145
+ const entries = [];
146
+ let totalBytes = 0;
147
+ let incompleteReason = null;
148
+ const walk = (directory) => {
149
+ for (const name of readdirSync(directory).sort()) {
150
+ const path = join(directory, name);
151
+ const link = lstatSync(path);
152
+ if (link.isSymbolicLink()) {
153
+ incompleteReason ??= "symlink";
154
+ continue;
155
+ }
156
+ if (link.isDirectory()) {
157
+ walk(path);
158
+ continue;
159
+ }
160
+ if (!link.isFile() || link.nlink !== 1) {
161
+ incompleteReason ??= "non_regular";
162
+ continue;
163
+ }
164
+ if (entries.length >= limits.maxFiles) {
165
+ incompleteReason ??= "file_count";
166
+ continue;
167
+ }
168
+ if (totalBytes + link.size > limits.maxTotalBytes) {
169
+ incompleteReason ??= "total_bytes";
170
+ continue;
171
+ }
172
+ const bytes = regularFile(path, limits.maxTotalBytes);
173
+ totalBytes += bytes.byteLength;
174
+ entries.push({
175
+ relativePath: relative(skillRoot, path).split(sep).join("/"),
176
+ byteLength: bytes.byteLength,
177
+ fileSha256: sha256(bytes),
178
+ });
179
+ }
180
+ };
181
+ walk(skillRoot);
182
+ return { entries, totalBytes, incompleteReason };
183
+ }
184
+
185
+ function normalizedProvenance(item, editable) {
186
+ const upstream = String(
187
+ item.provenance ?? item.source ?? "unknown"
188
+ ).toLowerCase();
189
+ if (!editable) {
190
+ if (String(item.name).toLowerCase() === "sellable") return "sellable";
191
+ return SOURCE_MANAGED.has(upstream) ? upstream : "external";
192
+ }
193
+ return "profile";
194
+ }
195
+
196
+ function skillId(item, provenance, relativeDirectory) {
197
+ return sha256(
198
+ stableJson({
199
+ name: String(item.name),
200
+ provenance,
201
+ relativeDirectory,
202
+ })
203
+ );
204
+ }
205
+
206
+ function disabledBytes(native) {
207
+ if (typeof native.disabledConfig === "string") {
208
+ return Buffer.from(native.disabledConfig, "utf8");
209
+ }
210
+ return Buffer.from(
211
+ `${stableJson([...new Set(native.disabledNames ?? [])].sort())}\n`,
212
+ "utf8"
213
+ );
214
+ }
215
+
216
+ function normalizeEnumeration(native, paths, limits = {}) {
217
+ const maxSkills = limits.maxSkills ?? SKILLS_MAX_COUNT;
218
+ const treeLimits = {
219
+ maxFiles: limits.maxFiles ?? SKILL_MAX_FILES,
220
+ maxTotalBytes: limits.maxTotalBytes ?? SKILL_MAX_TOTAL_BYTES,
221
+ };
222
+ if (
223
+ !native ||
224
+ !Array.isArray(native.skills) ||
225
+ !Array.isArray(native.disabledNames)
226
+ ) {
227
+ fail("skills_native_enumeration_rejected");
228
+ }
229
+ const disabled = new Set(native.disabledNames.map(String));
230
+ const profileSkillsRoot = join(paths.profileRoot, "skills");
231
+ const canonicalProfileSkillsRoot = existsSync(profileSkillsRoot)
232
+ ? realpathSync(profileSkillsRoot)
233
+ : profileSkillsRoot;
234
+ const skills = [];
235
+ let incompleteReason =
236
+ native.complete === false ? String(native.reason ?? "native") : null;
237
+ for (const item of native.skills.slice(0, maxSkills)) {
238
+ if (
239
+ !item ||
240
+ !SAFE_NAME.test(String(item.name ?? "")) ||
241
+ typeof item.path !== "string"
242
+ ) {
243
+ incompleteReason ??= "native_entry";
244
+ continue;
245
+ }
246
+ let canonicalRoot;
247
+ try {
248
+ const link = lstatSync(item.path);
249
+ if (link.isSymbolicLink() || !link.isDirectory()) {
250
+ incompleteReason ??= "symlink";
251
+ continue;
252
+ }
253
+ canonicalRoot = realpathSync(item.path);
254
+ } catch {
255
+ incompleteReason ??= "missing";
256
+ continue;
257
+ }
258
+ const relativeDirectory = contained(
259
+ canonicalProfileSkillsRoot,
260
+ canonicalRoot
261
+ )
262
+ ? relative(canonicalProfileSkillsRoot, canonicalRoot).split(sep).join("/")
263
+ : null;
264
+ const upstream = String(
265
+ item.provenance ?? item.source ?? "unknown"
266
+ ).toLowerCase();
267
+ const editable = Boolean(
268
+ relativeDirectory &&
269
+ !relativeDirectory.split("/").includes(".org") &&
270
+ !SOURCE_MANAGED.has(upstream) &&
271
+ String(item.name).toLowerCase() !== "sellable"
272
+ );
273
+ const provenance = normalizedProvenance(item, editable);
274
+ const tree = inspectTree(canonicalRoot, treeLimits);
275
+ incompleteReason ??= tree.incompleteReason;
276
+ const skillMd = tree.entries.find(
277
+ (entry) => entry.relativePath === "SKILL.md"
278
+ );
279
+ if (!skillMd) {
280
+ incompleteReason ??= "manifest_missing";
281
+ continue;
282
+ }
283
+ let manifestContent = null;
284
+ if (skillMd.byteLength <= SKILL_MAX_TEXT_BYTES) {
285
+ try {
286
+ manifestContent = utf8(
287
+ regularFile(join(canonicalRoot, "SKILL.md"), SKILL_MAX_TEXT_BYTES)
288
+ );
289
+ } catch {
290
+ incompleteReason ??= "manifest_utf8";
291
+ }
292
+ } else {
293
+ incompleteReason ??= "manifest_bytes";
294
+ }
295
+ const manifestIdentity = {
296
+ provenance,
297
+ relativeDirectory,
298
+ entries: tree.entries,
299
+ };
300
+ skills.push({
301
+ skillId: skillId(item, provenance, relativeDirectory),
302
+ name: String(item.name),
303
+ description: String(item.description ?? "").slice(0, 2000),
304
+ category:
305
+ item.category == null ? null : String(item.category).slice(0, 128),
306
+ provenance,
307
+ editable,
308
+ enabled: !disabled.has(String(item.name)),
309
+ usage:
310
+ Number.isSafeInteger(item.usage) && item.usage >= 0 ? item.usage : 0,
311
+ manifestContent,
312
+ fileSha256: skillMd.fileSha256,
313
+ skillManifestSha256: sha256(stableJson(manifestIdentity)),
314
+ manifest: tree.entries,
315
+ _canonicalRoot: canonicalRoot,
316
+ });
317
+ }
318
+ if (native.skills.length > maxSkills) incompleteReason ??= "skill_count";
319
+ skills.sort((left, right) => left.skillId.localeCompare(right.skillId));
320
+ const disabledConfigSha256 = sha256(disabledBytes(native));
321
+ const inventorySha256 = sha256(
322
+ stableJson(
323
+ skills.map(
324
+ ({ _canonicalRoot: _private, manifestContent: _content, ...skill }) =>
325
+ skill
326
+ )
327
+ )
328
+ );
329
+ return {
330
+ complete: incompleteReason === null,
331
+ ...(incompleteReason === null
332
+ ? {}
333
+ : {
334
+ code: "inventory_incomplete",
335
+ truncation: {
336
+ reason: incompleteReason,
337
+ observedSkillCount: native.skills.length,
338
+ returnedSkillCount: skills.length,
339
+ },
340
+ }),
341
+ disabledConfigSha256,
342
+ inventorySha256,
343
+ skills,
344
+ };
345
+ }
346
+
347
+ function publicInventory(inventory) {
348
+ return {
349
+ ...inventory,
350
+ skills: inventory.skills.map(({ _canonicalRoot, ...skill }) => skill),
351
+ };
352
+ }
353
+
354
+ const NATIVE_SCRIPT = String.raw`
355
+ import json, os, sys
356
+ request = json.loads(sys.stdin.read())
357
+ from tools.skills_tool import _find_all_skills, skill_view
358
+ from tools.skill_manager_tool import _find_skill, _create_skill, _edit_skill, _delete_skill, _write_file, _remove_file
359
+ from hermes_cli.config import load_config
360
+ from hermes_cli.skills_config import get_disabled_skills, save_disabled_skills
361
+
362
+ def inventory(read_name=None, read_file=None):
363
+ rows = _find_all_skills(skip_disabled=True)
364
+ config = load_config()
365
+ disabled = sorted(get_disabled_skills(config))
366
+ result = []
367
+ try:
368
+ from tools.skill_usage import _read_bundled_manifest_names, _read_hub_installed_names, activity_count, load_usage
369
+ bundled, hub, usage = _read_bundled_manifest_names(), _read_hub_installed_names(), load_usage()
370
+ except Exception:
371
+ bundled, hub, usage = set(), set(), {}
372
+ for row in rows:
373
+ name = str(row.get("name") or "")
374
+ found = _find_skill(name)
375
+ if not found:
376
+ continue
377
+ provenance = "hub" if name in hub else "bundled" if name in bundled else "agent"
378
+ item = dict(row)
379
+ item.update({"path": str(found["path"]), "provenance": provenance, "usage": activity_count(usage.get(name, {})) if usage else 0})
380
+ result.append(item)
381
+ viewed = None
382
+ if read_name:
383
+ viewed = skill_view(read_name, file_path=read_file) if read_file else skill_view(read_name)
384
+ return {"complete": True, "skills": result, "disabledNames": disabled, "disabledConfig": json.dumps(disabled, separators=(",", ":")), "nativeView": viewed}
385
+
386
+ op = request.get("operation")
387
+ if op == "enumerate":
388
+ output = inventory(request.get("readName"), request.get("readFile"))
389
+ elif op == "read":
390
+ output = {"nativeView": skill_view(request["readName"], file_path=request.get("readFile")) if request.get("readFile") else skill_view(request["readName"])}
391
+ elif op == "scan":
392
+ from tools.skills_guard import scan_skill, should_allow_install
393
+ path = request["path"]
394
+ scan = scan_skill(path, source="agent-created")
395
+ allowed, reason = should_allow_install(scan)
396
+ output = {"ok": allowed is True, "reason": str(reason)}
397
+ elif op == "mutate":
398
+ action = request["action"]
399
+ name = request["name"]
400
+ if action == "create": output = _create_skill(name, request["content"], request.get("category"))
401
+ elif action == "edit": output = _edit_skill(name, request["content"])
402
+ elif action == "delete": output = _delete_skill(name, "")
403
+ elif action == "write_file": output = _write_file(name, request["filePath"], request["content"])
404
+ elif action == "remove_file": output = _remove_file(name, request["filePath"])
405
+ elif action == "toggle":
406
+ config = load_config(); disabled = get_disabled_skills(config)
407
+ if request["enabled"]: disabled.discard(name)
408
+ else: disabled.add(name)
409
+ save_disabled_skills(config, disabled)
410
+ output = {"success": True}
411
+ else: raise RuntimeError("operation")
412
+ else:
413
+ raise RuntimeError("operation")
414
+ sys.stdout.write(json.dumps(output, separators=(",", ":")))
415
+ `;
416
+
417
+ function runNative(payload, paths, { python = PYTHON } = {}) {
418
+ const child = spawnSync(python, ["-c", NATIVE_SCRIPT], {
419
+ input: JSON.stringify(payload),
420
+ encoding: "utf8",
421
+ timeout: 20_000,
422
+ maxBuffer: 4 * 1024 * 1024,
423
+ env: {
424
+ HOME: paths.dataRoot,
425
+ HERMES_HOME: paths.profileRoot,
426
+ HERMES_PROFILE: paths.profileId,
427
+ SELLABLE_HERMES_PROFILE_ID: paths.profileId,
428
+ SELLABLE_AGENT_RUNTIME: "1",
429
+ PYTHONDONTWRITEBYTECODE: "1",
430
+ PATH: "/opt/hermes/.venv/bin:/usr/local/bin:/usr/bin:/bin",
431
+ },
432
+ });
433
+ if (child.status !== 0 || child.signal || child.error)
434
+ fail("skills_native_call_failed");
435
+ try {
436
+ return JSON.parse(child.stdout);
437
+ } catch {
438
+ fail("skills_native_output_rejected");
439
+ }
440
+ }
441
+
442
+ function defaultAdapter(paths) {
443
+ return {
444
+ enumerate: ({ readName, readFile } = {}) =>
445
+ runNative({ operation: "enumerate", readName, readFile }, paths),
446
+ read: ({ readName, readFile } = {}) =>
447
+ runNative({ operation: "read", readName, readFile }, paths),
448
+ scan: (path) => runNative({ operation: "scan", path }, paths),
449
+ mutate: (mutation) =>
450
+ runNative({ operation: "mutate", ...mutation }, paths),
451
+ };
452
+ }
453
+
454
+ function assertExactKeys(value, allowed) {
455
+ if (!value || typeof value !== "object" || Array.isArray(value))
456
+ fail("skills_request_rejected");
457
+ if (Object.keys(value).some((key) => !allowed.has(key)))
458
+ fail("skills_request_rejected");
459
+ }
460
+
461
+ function assertRequest(request) {
462
+ const common = ["operation"];
463
+ const shapes = {
464
+ list: common,
465
+ read: [...common, "skillId", "filePath"],
466
+ edit: [...common, "skillId", "content", "expectedFileSha256"],
467
+ toggle: [...common, "skillId", "enabled", "disabledConfigSha256"],
468
+ create: [
469
+ ...common,
470
+ "name",
471
+ "content",
472
+ "category",
473
+ "expectedFileAbsent",
474
+ "inventorySha256",
475
+ ],
476
+ delete: [...common, "skillId", "skillManifestSha256"],
477
+ write_file: [
478
+ ...common,
479
+ "skillId",
480
+ "filePath",
481
+ "content",
482
+ "expectedFileSha256",
483
+ "expectedFileAbsent",
484
+ "skillManifestSha256",
485
+ ],
486
+ remove_file: [
487
+ ...common,
488
+ "skillId",
489
+ "filePath",
490
+ "expectedFileSha256",
491
+ "skillManifestSha256",
492
+ ],
493
+ };
494
+ const keys = shapes[request?.operation];
495
+ if (!keys) fail("skills_operation_rejected");
496
+ assertExactKeys(request, new Set(keys));
497
+ if (
498
+ request.operation === "create" &&
499
+ (!SAFE_NAME.test(request.name ?? "") ||
500
+ (request.category != null && !SAFE_NAME.test(request.category)))
501
+ ) {
502
+ fail("skills_category_rejected");
503
+ }
504
+ if (request.skillId !== undefined && !SHA256.test(request.skillId))
505
+ fail("skills_identity_rejected");
506
+ if (request.content !== undefined) {
507
+ if (
508
+ typeof request.content !== "string" ||
509
+ Buffer.byteLength(request.content) > SKILL_MAX_TEXT_BYTES
510
+ ) {
511
+ fail("skills_content_rejected");
512
+ }
513
+ }
514
+ if (request.filePath !== undefined)
515
+ safeRelativePath(request.filePath, {
516
+ supporting: request.operation !== "read",
517
+ });
518
+ for (const field of [
519
+ "expectedFileSha256",
520
+ "disabledConfigSha256",
521
+ "inventorySha256",
522
+ "skillManifestSha256",
523
+ ]) {
524
+ if (request[field] !== undefined && !SHA256.test(request[field]))
525
+ fail("skills_cas_rejected");
526
+ }
527
+ return request;
528
+ }
529
+
530
+ function findSkill(inventory, id, { editable = false } = {}) {
531
+ const skill = inventory.skills.find((entry) => entry.skillId === id);
532
+ if (!skill) fail("skills_identity_not_found");
533
+ if (editable && (!inventory.complete || !skill.editable))
534
+ fail("skills_mutation_refused");
535
+ return skill;
536
+ }
537
+
538
+ function backupTree(skillRoot) {
539
+ const snapshot = [];
540
+ const walk = (directory) => {
541
+ for (const name of readdirSync(directory).sort()) {
542
+ const path = join(directory, name);
543
+ const stat = lstatSync(path);
544
+ if (stat.isSymbolicLink()) fail("skills_backup_rejected");
545
+ if (stat.isDirectory()) walk(path);
546
+ else if (stat.isFile())
547
+ snapshot.push({
548
+ relativePath: relative(skillRoot, path),
549
+ bytes: regularFile(path),
550
+ mode: stat.mode & 0o777,
551
+ });
552
+ else fail("skills_backup_rejected");
553
+ }
554
+ };
555
+ walk(skillRoot);
556
+ return snapshot;
557
+ }
558
+
559
+ function restoreTree(skillRoot, snapshot) {
560
+ rmSync(skillRoot, { recursive: true, force: true });
561
+ mkdirSync(skillRoot, { recursive: true, mode: 0o700 });
562
+ for (const entry of snapshot) {
563
+ const path = join(skillRoot, entry.relativePath);
564
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
565
+ writeFileSync(path, entry.bytes, { mode: entry.mode });
566
+ }
567
+ }
568
+
569
+ function atomicProof(paths, inventory) {
570
+ const root = join(paths.profileRoot, ".sellable-agent");
571
+ mkdirSync(root, { recursive: true, mode: 0o700 });
572
+ const path = join(root, "skills-proof.json");
573
+ const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
574
+ const value = {
575
+ schemaVersion: SKILLS_PROOF_SCHEMA_VERSION,
576
+ inventorySha256: inventory.inventorySha256,
577
+ disabledConfigSha256: inventory.disabledConfigSha256,
578
+ };
579
+ writeFileSync(temporary, `${JSON.stringify(value)}\n`, { mode: 0o600 });
580
+ renameSync(temporary, path);
581
+ }
582
+
583
+ function executeLocked(request, paths, adapter, options) {
584
+ const beforeNative = adapter.enumerate();
585
+ const before = normalizeEnumeration(beforeNative, paths, options.limits);
586
+ if (request.operation === "list") {
587
+ atomicProof(paths, before);
588
+ return {
589
+ ok: true,
590
+ schemaVersion: SKILLS_BRIDGE_SCHEMA_VERSION,
591
+ status: "proven",
592
+ ...publicInventory(before),
593
+ };
594
+ }
595
+ if (request.operation === "read") {
596
+ const skill = findSkill(before, request.skillId);
597
+ const relativePath = request.filePath
598
+ ? safeRelativePath(request.filePath)
599
+ : "SKILL.md";
600
+ const manifest = skill.manifest.find(
601
+ (entry) => entry.relativePath === relativePath
602
+ );
603
+ if (!manifest)
604
+ return {
605
+ ok: false,
606
+ code: before.complete
607
+ ? "skills_file_not_found"
608
+ : "inventory_incomplete",
609
+ };
610
+ if (manifest.byteLength > SKILL_MAX_TEXT_BYTES)
611
+ return { ok: false, code: "skills_file_too_large" };
612
+ const content = utf8(
613
+ regularFile(
614
+ join(skill._canonicalRoot, relativePath),
615
+ SKILL_MAX_TEXT_BYTES
616
+ )
617
+ );
618
+ const proof = adapter.read({
619
+ readName: skill.name,
620
+ readFile: relativePath === "SKILL.md" ? undefined : relativePath,
621
+ });
622
+ if (typeof proof.nativeView !== "string")
623
+ fail("skills_native_readback_failed");
624
+ atomicProof(paths, before);
625
+ return {
626
+ ok: true,
627
+ schemaVersion: SKILLS_BRIDGE_SCHEMA_VERSION,
628
+ status: "proven",
629
+ skill: publicInventory({ ...before, skills: [skill] }).skills[0],
630
+ filePath: relativePath,
631
+ content,
632
+ fileSha256: sha256(Buffer.from(content, "utf8")),
633
+ inventorySha256: before.inventorySha256,
634
+ disabledConfigSha256: before.disabledConfigSha256,
635
+ };
636
+ }
637
+
638
+ if (!before.complete)
639
+ return {
640
+ ok: false,
641
+ code: "inventory_incomplete",
642
+ inventorySha256: before.inventorySha256,
643
+ };
644
+ let skill =
645
+ request.operation === "create"
646
+ ? null
647
+ : findSkill(before, request.skillId, { editable: true });
648
+ if (request.operation === "create") {
649
+ if (
650
+ !SAFE_NAME.test(request.name ?? "") ||
651
+ request.expectedFileAbsent !== true ||
652
+ request.inventorySha256 !== before.inventorySha256
653
+ ) {
654
+ return {
655
+ ok: false,
656
+ code: "skills_conflict",
657
+ inventorySha256: before.inventorySha256,
658
+ };
659
+ }
660
+ if (before.skills.some((entry) => entry.name === request.name))
661
+ return { ok: false, code: "skills_absent_conflict" };
662
+ }
663
+ if (
664
+ request.operation === "edit" &&
665
+ request.expectedFileSha256 !== skill.fileSha256
666
+ )
667
+ return {
668
+ ok: false,
669
+ code: "skills_file_conflict",
670
+ fileSha256: skill.fileSha256,
671
+ };
672
+ if (
673
+ request.operation === "toggle" &&
674
+ request.disabledConfigSha256 !== before.disabledConfigSha256
675
+ )
676
+ return {
677
+ ok: false,
678
+ code: "skills_disabled_config_conflict",
679
+ disabledConfigSha256: before.disabledConfigSha256,
680
+ };
681
+ if (
682
+ ["delete", "write_file", "remove_file"].includes(request.operation) &&
683
+ request.skillManifestSha256 !== skill.skillManifestSha256
684
+ )
685
+ return {
686
+ ok: false,
687
+ code: "skills_manifest_conflict",
688
+ skillManifestSha256: skill.skillManifestSha256,
689
+ };
690
+ let supportingExisting = null;
691
+ if (["write_file", "remove_file"].includes(request.operation)) {
692
+ const relativePath = safeRelativePath(request.filePath, {
693
+ supporting: true,
694
+ });
695
+ supportingExisting =
696
+ skill.manifest.find((entry) => entry.relativePath === relativePath) ??
697
+ null;
698
+ if (request.operation === "remove_file" || supportingExisting) {
699
+ if (
700
+ !supportingExisting ||
701
+ request.expectedFileSha256 !== supportingExisting.fileSha256
702
+ )
703
+ return {
704
+ ok: false,
705
+ code: "skills_file_conflict",
706
+ fileSha256: supportingExisting?.fileSha256 ?? null,
707
+ };
708
+ } else if (request.expectedFileAbsent !== true)
709
+ return { ok: false, code: "skills_absent_conflict" };
710
+ }
711
+
712
+ const skillRoot =
713
+ skill?._canonicalRoot ??
714
+ join(
715
+ paths.profileRoot,
716
+ "skills",
717
+ request.category ? join(request.category, request.name) : request.name
718
+ );
719
+ const skillExisted = existsSync(skillRoot);
720
+ const treeBackup = skillExisted ? backupTree(skillRoot) : [];
721
+ const disabledPath = join(paths.profileRoot, "config.yaml");
722
+ const disabledBackup = existsSync(disabledPath)
723
+ ? regularFile(disabledPath)
724
+ : null;
725
+ const proofPath = join(
726
+ paths.profileRoot,
727
+ ".sellable-agent",
728
+ "skills-proof.json"
729
+ );
730
+ const proofBackup = existsSync(proofPath)
731
+ ? regularFile(proofPath, 4096)
732
+ : null;
733
+ const rollback = () => {
734
+ if (skillExisted) restoreTree(skillRoot, treeBackup);
735
+ else rmSync(skillRoot, { recursive: true, force: true });
736
+ if (disabledBackup !== null)
737
+ writeFileSync(disabledPath, disabledBackup, { mode: 0o600 });
738
+ if (proofBackup !== null)
739
+ writeFileSync(proofPath, proofBackup, { mode: 0o600 });
740
+ else rmSync(proofPath, { force: true });
741
+ };
742
+ try {
743
+ const scanTarget = skillRoot;
744
+ if (skillExisted) {
745
+ const scan = adapter.scan(scanTarget);
746
+ if (scan?.ok !== true) fail("skills_scan_refused");
747
+ }
748
+ const noEffect =
749
+ (request.operation === "edit" &&
750
+ sha256(Buffer.from(request.content, "utf8")) === skill.fileSha256) ||
751
+ (request.operation === "toggle" && request.enabled === skill.enabled) ||
752
+ (request.operation === "write_file" &&
753
+ supportingExisting !== null &&
754
+ sha256(Buffer.from(request.content, "utf8")) ===
755
+ supportingExisting.fileSha256);
756
+ if (noEffect) {
757
+ atomicProof(paths, before);
758
+ return {
759
+ ok: true,
760
+ schemaVersion: SKILLS_BRIDGE_SCHEMA_VERSION,
761
+ status: "noop",
762
+ operation: request.operation,
763
+ inventory: publicInventory(before),
764
+ detail: publicInventory({ ...before, skills: [skill] }).skills[0],
765
+ };
766
+ }
767
+ const mutation = {
768
+ action: request.operation,
769
+ name: skill?.name ?? request.name,
770
+ ...(request.content !== undefined ? { content: request.content } : {}),
771
+ ...(request.category !== undefined ? { category: request.category } : {}),
772
+ ...(request.filePath !== undefined ? { filePath: request.filePath } : {}),
773
+ ...(request.enabled !== undefined ? { enabled: request.enabled } : {}),
774
+ };
775
+ const result = adapter.mutate(mutation);
776
+ if (result?.success !== true)
777
+ return { ok: false, code: "skills_native_refused" };
778
+ if (existsSync(skillRoot)) {
779
+ const scan = adapter.scan(skillRoot);
780
+ if (scan?.ok !== true) fail("skills_scan_refused");
781
+ }
782
+ const afterNative = adapter.enumerate({
783
+ readName: request.operation === "delete" ? undefined : mutation.name,
784
+ });
785
+ const after = normalizeEnumeration(afterNative, paths, options.limits);
786
+ if (
787
+ !after.complete ||
788
+ (request.operation !== "delete" &&
789
+ typeof afterNative.nativeView !== "string")
790
+ )
791
+ fail("skills_readback_failed");
792
+ const afterSkill =
793
+ after.skills.find((entry) => entry.name === mutation.name) ?? null;
794
+ if (
795
+ request.operation === "delete" ? afterSkill !== null : afterSkill === null
796
+ )
797
+ fail("skills_readback_failed");
798
+ if (
799
+ (request.operation === "edit" &&
800
+ afterSkill.fileSha256 !==
801
+ sha256(Buffer.from(request.content, "utf8"))) ||
802
+ (request.operation === "toggle" &&
803
+ (afterSkill.enabled !== request.enabled ||
804
+ after.disabledConfigSha256 === before.disabledConfigSha256)) ||
805
+ (request.operation === "create" &&
806
+ afterSkill.fileSha256 !==
807
+ sha256(Buffer.from(request.content, "utf8"))) ||
808
+ (request.operation === "write_file" &&
809
+ !afterSkill.manifest.some(
810
+ (entry) =>
811
+ entry.relativePath === request.filePath &&
812
+ entry.fileSha256 === sha256(Buffer.from(request.content, "utf8"))
813
+ )) ||
814
+ (request.operation === "remove_file" &&
815
+ afterSkill.manifest.some(
816
+ (entry) => entry.relativePath === request.filePath
817
+ ))
818
+ ) {
819
+ fail("skills_readback_failed");
820
+ }
821
+ atomicProof(paths, after);
822
+ options.markDirty(paths.profileRoot);
823
+ return {
824
+ ok: true,
825
+ schemaVersion: SKILLS_BRIDGE_SCHEMA_VERSION,
826
+ status: "applied",
827
+ operation: request.operation,
828
+ effectId: randomUUID(),
829
+ inventory: publicInventory(after),
830
+ detail: afterSkill
831
+ ? publicInventory({ ...after, skills: [afterSkill] }).skills[0]
832
+ : null,
833
+ };
834
+ } catch (error) {
835
+ rollback();
836
+ try {
837
+ adapter.enumerate();
838
+ } catch {
839
+ return { ok: false, code: "skills_rollback_unproven" };
840
+ }
841
+ return {
842
+ ok: false,
843
+ code: "skills_apply_failed_rolled_back",
844
+ reason:
845
+ error instanceof FlySkillsBridgeError
846
+ ? error.code
847
+ : "skills_bridge_failed",
848
+ };
849
+ }
850
+ }
851
+
852
+ /**
853
+ * @param {any} request
854
+ * @param {{
855
+ * profileId?: string,
856
+ * dataRoot?: string,
857
+ * nativeAdapter?: {enumerate: Function, read: Function, scan: Function, mutate: Function} | null,
858
+ * markDirty?: (profileRoot: string) => void,
859
+ * lockHeld?: boolean,
860
+ * limits?: any,
861
+ * }} [options]
862
+ */
863
+ export function executeSkillsBridgeOperation(
864
+ request,
865
+ {
866
+ profileId = process.env.SELLABLE_HERMES_PROFILE_ID,
867
+ dataRoot = SOUL_BRIDGE_DATA_ROOT,
868
+ nativeAdapter = null,
869
+ markDirty = markHermesSnapshotDirty,
870
+ lockHeld = false,
871
+ limits = {},
872
+ } = {}
873
+ ) {
874
+ assertRequest(request);
875
+ const id = validProfileId(profileId);
876
+ const paths = soulProfilePaths(id, { dataRoot });
877
+ const adapter = nativeAdapter ?? defaultAdapter(paths);
878
+ if (
879
+ !adapter ||
880
+ !["enumerate", "read", "scan", "mutate"].every(
881
+ (name) => typeof adapter[name] === "function"
882
+ )
883
+ )
884
+ fail("skills_native_adapter_rejected");
885
+ if (typeof markDirty !== "function") fail("skills_dirty_marker_rejected");
886
+ const run = () =>
887
+ executeLocked(request, paths, adapter, { markDirty, limits });
888
+ return lockHeld ? run() : withProfileSoulLock(id, run, { dataRoot });
889
+ }
890
+
891
+ /**
892
+ * @param {any} request
893
+ * @param {{
894
+ * procRoot?: string,
895
+ * dataRoot?: string,
896
+ * nativeAdapter?: {enumerate: Function, read: Function, scan: Function, mutate: Function} | null,
897
+ * markDirty?: (profileRoot: string) => void,
898
+ * callerProfileId?: string,
899
+ * limits?: any,
900
+ * }} [options]
901
+ */
902
+ export function executeExternalSkillsBridgeOperation(
903
+ request,
904
+ {
905
+ procRoot = "/proc",
906
+ dataRoot = SOUL_BRIDGE_DATA_ROOT,
907
+ nativeAdapter = null,
908
+ markDirty = markHermesSnapshotDirty,
909
+ callerProfileId = process.env.SELLABLE_HERMES_PROFILE_ID,
910
+ limits = {},
911
+ } = {}
912
+ ) {
913
+ const gateway = discoverLiveHermesGateway({ procRoot, dataRoot });
914
+ if (callerProfileId && callerProfileId !== gateway.profileId)
915
+ fail("skills_gateway_caller_profile_mismatch");
916
+ revalidateLiveHermesGateway(gateway, { procRoot, dataRoot });
917
+ const result = executeSkillsBridgeOperation(request, {
918
+ profileId: gateway.profileId,
919
+ dataRoot,
920
+ nativeAdapter,
921
+ markDirty,
922
+ limits,
923
+ });
924
+ revalidateLiveHermesGateway(gateway, { procRoot, dataRoot });
925
+ return Object.freeze({
926
+ ...result,
927
+ profileId: gateway.profileId,
928
+ gatewayPid: gateway.pid,
929
+ gatewayStartTicks: gateway.startTicks,
930
+ });
931
+ }
932
+
933
+ export function computeSkillsConfigIdentity(profileRoot) {
934
+ if (!isAbsolute(profileRoot) || !existsSync(profileRoot))
935
+ fail("skills_profile_root_rejected");
936
+ const canonical = realpathSync(profileRoot);
937
+ const skillsRoot = join(canonical, "skills");
938
+ const tree = existsSync(skillsRoot)
939
+ ? inspectTree(skillsRoot, {
940
+ maxFiles: 4096,
941
+ maxTotalBytes: 64 * 1024 * 1024,
942
+ })
943
+ : { entries: [], incompleteReason: null };
944
+ if (tree.incompleteReason) fail("skills_profile_identity_incomplete");
945
+ const configPath = join(canonical, "config.yaml");
946
+ const configSha256 = existsSync(configPath)
947
+ ? sha256(regularFile(configPath, 2 * 1024 * 1024))
948
+ : sha256("");
949
+ return Object.freeze({
950
+ inventorySha256: sha256(stableJson(tree.entries)),
951
+ disabledConfigSha256: configSha256,
952
+ });
953
+ }
954
+
955
+ export function readSkillsProofObservation(profileRoot) {
956
+ try {
957
+ const path = join(
958
+ realpathSync(profileRoot),
959
+ ".sellable-agent",
960
+ "skills-proof.json"
961
+ );
962
+ const value = JSON.parse(regularFile(path, 4096).toString("utf8"));
963
+ if (
964
+ value.schemaVersion !== SKILLS_PROOF_SCHEMA_VERSION ||
965
+ !SHA256.test(value.inventorySha256 ?? "") ||
966
+ !SHA256.test(value.disabledConfigSha256 ?? "") ||
967
+ Object.keys(value).length !== 3
968
+ )
969
+ return null;
970
+ return Object.freeze({
971
+ inventorySha256: value.inventorySha256,
972
+ disabledConfigSha256: value.disabledConfigSha256,
973
+ });
974
+ } catch {
975
+ return null;
976
+ }
977
+ }