@sellable/install
Version:
One-command installer for Sellable MCP in Claude Code, Codex, and Hermes
1,192 lines (1,150 loc) • 37.3 kB
JavaScript
#!/usr/bin/env node
import { spawnSync } from "node:child_process";
import { createHash, randomUUID } from "node:crypto";
import {
closeSync,
constants,
existsSync,
fstatSync,
lstatSync,
mkdirSync,
openSync,
readFileSync,
readdirSync,
realpathSync,
renameSync,
rmSync,
writeFileSync,
} from "node:fs";
import { dirname, isAbsolute, join, relative, sep } from "node:path";
import {
SOUL_BRIDGE_DATA_ROOT,
discoverLiveHermesGateway,
revalidateLiveHermesGateway,
soulProfilePaths,
withProfileSoulLock,
} from "./fly-soul-bridge.mjs";
import { markHermesSnapshotDirty } from "./hermes-memory-snapshot.mjs";
export const SKILLS_BRIDGE_SCHEMA_VERSION = "sellable-agent-skills-bridge/v1";
export const SKILLS_PROOF_SCHEMA_VERSION = "sellable-agent-skills-proof/v1";
export const SKILLS_MAX_COUNT = 256;
export const SKILL_MAX_FILES = 256;
export const SKILL_MAX_TOTAL_BYTES = 2 * 1024 * 1024;
export const SKILL_MAX_TEXT_BYTES = 256 * 1024;
export const SKILLS_REQUEST_MAX_BYTES = 512 * 1024;
const PROFILE_ID = /^[a-z0-9][a-z0-9_-]{0,127}$/;
const SHA256 = /^[a-f0-9]{64}$/;
const SAFE_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
const PYTHON = "/opt/hermes/.venv/bin/python3";
const SUPPORT_ROOTS = new Set(["references", "templates"]);
const SOURCE_MANAGED = new Set(["bundled", "hub", "plugin", "org", "external"]);
const sha256 = (value) => createHash("sha256").update(value).digest("hex");
const stableJson = (value) => {
if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
if (value && typeof value === "object") {
return `{${Object.keys(value)
.sort()
.map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`)
.join(",")}}`;
}
return JSON.stringify(value);
};
export class FlySkillsBridgeError extends Error {
constructor(code) {
super(code);
this.name = "FlySkillsBridgeError";
this.code = code;
}
}
function fail(code) {
throw new FlySkillsBridgeError(code);
}
function validProfileId(value) {
if (typeof value !== "string" || !PROFILE_ID.test(value)) {
fail("skills_profile_id_rejected");
}
return value;
}
function utf8(bytes, code = "skills_utf8_rejected") {
try {
return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
} catch {
fail(code);
}
}
function safeRelativePath(value, { supporting = false } = {}) {
if (
typeof value !== "string" ||
value.length < 1 ||
value.length > 256 ||
value.includes("\\") ||
value.includes("\0") ||
isAbsolute(value)
) {
fail("skills_file_path_rejected");
}
const parts = value.split("/");
if (parts.some((part) => !part || part === "." || part === "..")) {
fail("skills_file_path_rejected");
}
if (supporting && (!SUPPORT_ROOTS.has(parts[0]) || parts.length < 2)) {
fail("skills_support_path_rejected");
}
return parts.join("/");
}
function regularFile(path, maxBytes = SKILL_MAX_TOTAL_BYTES) {
const link = lstatSync(path);
if (
link.isSymbolicLink() ||
!link.isFile() ||
link.nlink !== 1 ||
link.size > maxBytes
) {
fail("skills_file_rejected");
}
const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
try {
const opened = fstatSync(fd);
if (
!opened.isFile() ||
opened.dev !== link.dev ||
opened.ino !== link.ino ||
opened.nlink !== 1 ||
opened.size !== link.size
) {
fail("skills_file_changed");
}
return readFileSync(fd);
} finally {
closeSync(fd);
}
}
function contained(root, candidate) {
const rel = relative(root, candidate);
return (
rel !== "" &&
rel !== ".." &&
!rel.startsWith(`..${sep}`) &&
!isAbsolute(rel)
);
}
function inspectTree(skillRoot, limits) {
const entries = [];
let totalBytes = 0;
let incompleteReason = null;
const walk = (directory) => {
for (const name of readdirSync(directory).sort()) {
const path = join(directory, name);
const link = lstatSync(path);
if (link.isSymbolicLink()) {
incompleteReason ??= "symlink";
continue;
}
if (link.isDirectory()) {
walk(path);
continue;
}
if (!link.isFile() || link.nlink !== 1) {
incompleteReason ??= "non_regular";
continue;
}
if (entries.length >= limits.maxFiles) {
incompleteReason ??= "file_count";
continue;
}
if (totalBytes + link.size > limits.maxTotalBytes) {
incompleteReason ??= "total_bytes";
continue;
}
const bytes = regularFile(path, limits.maxTotalBytes);
totalBytes += bytes.byteLength;
entries.push({
relativePath: relative(skillRoot, path).split(sep).join("/"),
byteLength: bytes.byteLength,
fileSha256: sha256(bytes),
});
}
};
walk(skillRoot);
return { entries, totalBytes, incompleteReason };
}
function normalizedProvenance(item, editable) {
const upstream = String(
item.provenance ?? item.source ?? "unknown"
).toLowerCase();
if (!editable) {
if (String(item.name).toLowerCase() === "sellable") return "sellable";
return SOURCE_MANAGED.has(upstream) ? upstream : "external";
}
return "profile";
}
function skillId(item, provenance, relativeDirectory) {
return sha256(
stableJson({
name: String(item.name),
provenance,
relativeDirectory,
})
);
}
function disabledBytes(native) {
if (typeof native.disabledConfig === "string") {
return Buffer.from(native.disabledConfig, "utf8");
}
return Buffer.from(
`${stableJson([...new Set(native.disabledNames ?? [])].sort())}\n`,
"utf8"
);
}
function normalizeEnumeration(native, paths, limits = {}) {
const maxSkills = limits.maxSkills ?? SKILLS_MAX_COUNT;
const treeLimits = {
maxFiles: limits.maxFiles ?? SKILL_MAX_FILES,
maxTotalBytes: limits.maxTotalBytes ?? SKILL_MAX_TOTAL_BYTES,
};
if (
!native ||
!Array.isArray(native.skills) ||
!Array.isArray(native.disabledNames)
) {
fail("skills_native_enumeration_rejected");
}
const disabled = new Set(native.disabledNames.map(String));
const profileSkillsRoot = join(paths.profileRoot, "skills");
const canonicalProfileSkillsRoot = existsSync(profileSkillsRoot)
? realpathSync(profileSkillsRoot)
: profileSkillsRoot;
const skills = [];
let incompleteReason =
native.complete === false ? String(native.reason ?? "native") : null;
for (const item of native.skills.slice(0, maxSkills)) {
if (
!item ||
!SAFE_NAME.test(String(item.name ?? "")) ||
typeof item.path !== "string"
) {
incompleteReason ??= "native_entry";
continue;
}
let canonicalRoot;
try {
const link = lstatSync(item.path);
if (link.isSymbolicLink() || !link.isDirectory()) {
incompleteReason ??= "symlink";
continue;
}
canonicalRoot = realpathSync(item.path);
} catch {
incompleteReason ??= "missing";
continue;
}
const relativeDirectory = contained(
canonicalProfileSkillsRoot,
canonicalRoot
)
? relative(canonicalProfileSkillsRoot, canonicalRoot).split(sep).join("/")
: null;
const upstream = String(
item.provenance ?? item.source ?? "unknown"
).toLowerCase();
const editable = Boolean(
relativeDirectory &&
!relativeDirectory.split("/").includes(".org") &&
!SOURCE_MANAGED.has(upstream) &&
String(item.name).toLowerCase() !== "sellable"
);
const provenance = normalizedProvenance(item, editable);
const tree = inspectTree(canonicalRoot, treeLimits);
incompleteReason ??= tree.incompleteReason;
const skillMd = tree.entries.find(
(entry) => entry.relativePath === "SKILL.md"
);
if (!skillMd) {
incompleteReason ??= "manifest_missing";
continue;
}
let manifestContent = null;
if (skillMd.byteLength <= SKILL_MAX_TEXT_BYTES) {
try {
manifestContent = utf8(
regularFile(join(canonicalRoot, "SKILL.md"), SKILL_MAX_TEXT_BYTES)
);
} catch {
incompleteReason ??= "manifest_utf8";
}
} else {
incompleteReason ??= "manifest_bytes";
}
const manifestIdentity = {
provenance,
relativeDirectory,
entries: tree.entries,
};
skills.push({
skillId: skillId(item, provenance, relativeDirectory),
name: String(item.name),
description: String(item.description ?? "").slice(0, 2000),
category:
item.category == null ? null : String(item.category).slice(0, 128),
provenance,
editable,
enabled: !disabled.has(String(item.name)),
usage:
Number.isSafeInteger(item.usage) && item.usage >= 0 ? item.usage : 0,
manifestContent,
fileSha256: skillMd.fileSha256,
skillManifestSha256: sha256(stableJson(manifestIdentity)),
manifest: tree.entries,
_canonicalRoot: canonicalRoot,
});
}
if (native.skills.length > maxSkills) incompleteReason ??= "skill_count";
skills.sort((left, right) => left.skillId.localeCompare(right.skillId));
const disabledConfigSha256 = sha256(disabledBytes(native));
const inventorySha256 = sha256(
stableJson(
skills.map(
({ _canonicalRoot: _private, manifestContent: _content, ...skill }) =>
skill
)
)
);
return {
complete: incompleteReason === null,
...(incompleteReason === null
? {}
: {
code: "inventory_incomplete",
truncation: {
reason: incompleteReason,
observedSkillCount: native.skills.length,
returnedSkillCount: skills.length,
},
}),
disabledConfigSha256,
inventorySha256,
skills,
};
}
function publicInventory(inventory) {
return {
...inventory,
skills: inventory.skills.map(({ _canonicalRoot, ...skill }) => skill),
};
}
const NATIVE_SCRIPT = String.raw`
import hashlib, json, os, sys
from pathlib import Path
request = json.loads(sys.stdin.read())
from tools.skills_tool import _find_all_skills, skill_view
from tools.skill_manager_tool import _find_skill, _create_skill, _edit_skill, _delete_skill, _write_file, _remove_file
from hermes_cli.config import load_config
from hermes_cli.skills_config import get_disabled_skills, save_disabled_skills
def native_view(read_name, read_file=None):
viewed = skill_view(read_name, file_path=read_file, preprocess=False) if read_file else skill_view(read_name, preprocess=False)
return {"requestedName": read_name, "raw": viewed}
def inventory(read_name=None, read_file=None):
rows = _find_all_skills(skip_disabled=True)
config = load_config()
disabled = sorted(get_disabled_skills(config))
result = []
try:
from tools.skill_usage import _read_bundled_manifest_names, _read_hub_installed_names, activity_count, load_usage
bundled, hub, usage = _read_bundled_manifest_names(), _read_hub_installed_names(), load_usage()
except Exception:
bundled, hub, usage = set(), set(), {}
for row in rows:
name = str(row.get("name") or "")
found = _find_skill(name)
if not found:
continue
provenance = "hub" if name in hub else "bundled" if name in bundled else "agent"
item = dict(row)
item.update({"path": str(found["path"]), "provenance": provenance, "usage": activity_count(usage.get(name, {})) if usage else 0})
result.append(item)
viewed = None
if read_name:
viewed = native_view(read_name, read_file)
return {"complete": True, "skills": result, "disabledNames": disabled, "disabledConfig": json.dumps(disabled, separators=(",", ":")), "nativeView": viewed}
op = request.get("operation")
if op == "enumerate":
output = inventory(request.get("readName"), request.get("readFile"))
if "readNames" in request:
output["nativeViews"] = [native_view(name) for name in request["readNames"]]
elif op == "read":
output = {"nativeView": native_view(request["readName"], request.get("readFile"))}
elif op == "scan":
from tools.skills_guard import scan_skill, should_allow_install
# Phase 170 production receipt skills_apply_failed_rolled_back proved that
# Hermes 0.20.0 rejects a string here; keep the native boundary path-typed.
path = Path(request["path"])
scan = scan_skill(path, source="agent-created")
allowed, reason = should_allow_install(scan)
output = {"ok": allowed is True, "reason": str(reason)}
elif op == "mutate":
action = request["action"]
name = request["name"]
if action == "create": output = _create_skill(name, request["content"], request.get("category"))
elif action == "edit": output = _edit_skill(name, request["content"])
elif action == "delete": output = _delete_skill(name, "")
elif action == "write_file": output = _write_file(name, request["filePath"], request["content"])
elif action == "remove_file": output = _remove_file(name, request["filePath"])
elif action == "toggle":
config = load_config(); disabled = get_disabled_skills(config)
if request["enabled"]: disabled.discard(name)
else: disabled.add(name)
save_disabled_skills(config, disabled)
output = {"success": True}
else: raise RuntimeError("operation")
else:
raise RuntimeError("operation")
sys.stdout.write(json.dumps(output, separators=(",", ":")))
`;
function runNative(
payload,
paths,
{ python = PYTHON, timeoutMs = 20_000 } = {}
) {
const child = spawnSync(python, ["-c", NATIVE_SCRIPT], {
input: JSON.stringify(payload),
encoding: "utf8",
timeout: timeoutMs,
maxBuffer: 4 * 1024 * 1024,
env: {
HOME: paths.dataRoot,
HERMES_HOME: paths.profileRoot,
HERMES_PROFILE: paths.profileId,
SELLABLE_HERMES_PROFILE_ID: paths.profileId,
SELLABLE_AGENT_RUNTIME: "1",
PYTHONDONTWRITEBYTECODE: "1",
PATH: "/opt/hermes/.venv/bin:/usr/local/bin:/usr/bin:/bin",
},
});
if (child.error?.code === "ETIMEDOUT") fail("skills_native_call_timeout");
if (child.status !== 0 || child.signal || child.error)
fail("skills_native_call_failed");
try {
return JSON.parse(child.stdout);
} catch {
fail("skills_native_output_rejected");
}
}
export function normalizeHermesNativeSkillView(value) {
const requestedName =
typeof value?.requestedName === "string" ? value.requestedName : "";
let viewed = value?.raw;
if (typeof viewed === "string") {
try {
const decoded = JSON.parse(viewed);
viewed = decoded;
} catch {
return {
success: true,
name: requestedName,
content: viewed,
contentSha256: sha256(Buffer.from(viewed, "utf8")),
};
}
if (typeof viewed === "string") {
return {
success: true,
name: requestedName,
content: viewed,
contentSha256: sha256(Buffer.from(viewed, "utf8")),
};
}
}
const content =
viewed && typeof viewed === "object" && !Array.isArray(viewed)
? viewed.content
: null;
return {
success:
Boolean(viewed) &&
typeof viewed === "object" &&
!Array.isArray(viewed) &&
viewed.success === true,
name:
viewed && typeof viewed === "object" && !Array.isArray(viewed)
? String(viewed.name ?? "")
: "",
content,
contentSha256:
typeof content === "string" ? sha256(Buffer.from(content, "utf8")) : null,
};
}
function normalizeNativeResult(result) {
return {
...result,
...(result?.nativeView
? { nativeView: normalizeHermesNativeSkillView(result.nativeView) }
: {}),
...(Array.isArray(result?.nativeViews)
? { nativeViews: result.nativeViews.map(normalizeHermesNativeSkillView) }
: {}),
};
}
function defaultAdapter(paths, timeoutMs) {
return {
enumerate: ({ readName, readFile, readNames } = {}) =>
normalizeNativeResult(
runNative(
{ operation: "enumerate", readName, readFile, readNames },
paths,
{
timeoutMs,
}
)
),
read: ({ readName, readFile } = {}) =>
normalizeNativeResult(
runNative({ operation: "read", readName, readFile }, paths, {
timeoutMs,
})
),
scan: (path) =>
runNative({ operation: "scan", path }, paths, { timeoutMs }),
mutate: (mutation) =>
runNative({ operation: "mutate", ...mutation }, paths, { timeoutMs }),
};
}
function assertExactKeys(value, allowed) {
if (!value || typeof value !== "object" || Array.isArray(value))
fail("skills_request_rejected");
if (Object.keys(value).some((key) => !allowed.has(key)))
fail("skills_request_rejected");
}
function assertRequest(request) {
const common = ["operation"];
const shapes = {
list: common,
verify: [...common, "names"],
read: [...common, "skillId", "filePath"],
edit: [...common, "skillId", "content", "expectedFileSha256"],
toggle: [...common, "skillId", "enabled", "disabledConfigSha256"],
create: [
...common,
"name",
"content",
"category",
"expectedFileAbsent",
"inventorySha256",
],
delete: [...common, "skillId", "skillManifestSha256"],
write_file: [
...common,
"skillId",
"filePath",
"content",
"expectedFileSha256",
"expectedFileAbsent",
"skillManifestSha256",
],
remove_file: [
...common,
"skillId",
"filePath",
"expectedFileSha256",
"skillManifestSha256",
],
};
const keys = shapes[request?.operation];
if (!keys) fail("skills_operation_rejected");
assertExactKeys(request, new Set(keys));
if (
request.operation === "verify" &&
(!Array.isArray(request.names) ||
request.names.length < 1 ||
request.names.length > SKILLS_MAX_COUNT ||
request.names.some(
(name) => typeof name !== "string" || !SAFE_NAME.test(name)
) ||
new Set(request.names).size !== request.names.length)
)
fail("skills_names_rejected");
if (
request.operation === "create" &&
(!SAFE_NAME.test(request.name ?? "") ||
(request.category != null && !SAFE_NAME.test(request.category)))
) {
fail("skills_category_rejected");
}
if (request.skillId !== undefined && !SHA256.test(request.skillId))
fail("skills_identity_rejected");
if (request.content !== undefined) {
if (
typeof request.content !== "string" ||
Buffer.byteLength(request.content) > SKILL_MAX_TEXT_BYTES
) {
fail("skills_content_rejected");
}
}
if (request.filePath !== undefined)
safeRelativePath(request.filePath, {
supporting: request.operation !== "read",
});
for (const field of [
"expectedFileSha256",
"disabledConfigSha256",
"inventorySha256",
"skillManifestSha256",
]) {
if (request[field] !== undefined && !SHA256.test(request[field]))
fail("skills_cas_rejected");
}
return request;
}
function findSkill(inventory, id, { editable = false } = {}) {
const skill = inventory.skills.find((entry) => entry.skillId === id);
if (!skill) fail("skills_identity_not_found");
if (editable && (!inventory.complete || !skill.editable))
fail("skills_mutation_refused");
return skill;
}
function assertNativeView(proof, name, content, code) {
if (
proof?.success !== true ||
proof.name !== name ||
typeof proof.content !== "string" ||
proof.content !== content ||
proof.contentSha256 !== sha256(Buffer.from(content, "utf8"))
) {
fail(code);
}
}
function backupTree(skillRoot) {
const snapshot = [];
const walk = (directory) => {
for (const name of readdirSync(directory).sort()) {
const path = join(directory, name);
const stat = lstatSync(path);
if (stat.isSymbolicLink()) fail("skills_backup_rejected");
if (stat.isDirectory()) walk(path);
else if (stat.isFile())
snapshot.push({
relativePath: relative(skillRoot, path),
bytes: regularFile(path),
mode: stat.mode & 0o777,
});
else fail("skills_backup_rejected");
}
};
walk(skillRoot);
return snapshot;
}
function restoreTree(skillRoot, snapshot) {
rmSync(skillRoot, { recursive: true, force: true });
mkdirSync(skillRoot, { recursive: true, mode: 0o700 });
for (const entry of snapshot) {
const path = join(skillRoot, entry.relativePath);
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
writeFileSync(path, entry.bytes, { mode: entry.mode });
}
}
function atomicProof(paths, inventory) {
const root = join(paths.profileRoot, ".sellable-agent");
mkdirSync(root, { recursive: true, mode: 0o700 });
const path = join(root, "skills-proof.json");
const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
const value = {
schemaVersion: SKILLS_PROOF_SCHEMA_VERSION,
inventorySha256: inventory.inventorySha256,
disabledConfigSha256: inventory.disabledConfigSha256,
};
writeFileSync(temporary, `${JSON.stringify(value)}\n`, { mode: 0o600 });
renameSync(temporary, path);
}
function executeLocked(request, paths, adapter, options) {
// Startup proves the entire managed bundle in one Python invocation and one
// profile lock. Re-importing Hermes for list + each read made the second live
// serving pass exceed its deadline even after every duplicate was retired.
const beforeNative =
request.operation === "verify"
? adapter.enumerate({ readNames: request.names })
: adapter.enumerate();
const before = normalizeEnumeration(beforeNative, paths, options.limits);
if (request.operation === "verify") {
if (
!Array.isArray(beforeNative.nativeViews) ||
beforeNative.nativeViews.length !== request.names.length
)
fail("skills_native_readback_failed");
const proofs = request.names.map((name, index) => {
const matches = before.skills.filter((skill) => skill.name === name);
if (matches.length !== 1 || !matches[0].enabled)
fail("skills_native_readback_failed");
const skill = matches[0];
assertNativeView(
beforeNative.nativeViews[index],
name,
skill.manifestContent,
"skills_native_readback_failed"
);
return {
name,
fileSha256: beforeNative.nativeViews[index].contentSha256,
};
});
atomicProof(paths, before);
return { ok: true, proofs };
}
if (request.operation === "list") {
atomicProof(paths, before);
return {
ok: true,
schemaVersion: SKILLS_BRIDGE_SCHEMA_VERSION,
status: "proven",
...publicInventory(before),
};
}
if (request.operation === "read") {
const skill = findSkill(before, request.skillId);
const relativePath = request.filePath
? safeRelativePath(request.filePath)
: "SKILL.md";
const manifest = skill.manifest.find(
(entry) => entry.relativePath === relativePath
);
if (!manifest)
return {
ok: false,
code: before.complete
? "skills_file_not_found"
: "inventory_incomplete",
};
if (manifest.byteLength > SKILL_MAX_TEXT_BYTES)
return { ok: false, code: "skills_file_too_large" };
const content = utf8(
regularFile(
join(skill._canonicalRoot, relativePath),
SKILL_MAX_TEXT_BYTES
)
);
const proof = adapter.read({
readName: skill.name,
readFile: relativePath === "SKILL.md" ? undefined : relativePath,
});
assertNativeView(
proof.nativeView,
skill.name,
content,
"skills_native_readback_failed"
);
atomicProof(paths, before);
return {
ok: true,
schemaVersion: SKILLS_BRIDGE_SCHEMA_VERSION,
status: "proven",
skill: publicInventory({ ...before, skills: [skill] }).skills[0],
filePath: relativePath,
content,
fileSha256: sha256(Buffer.from(content, "utf8")),
inventorySha256: before.inventorySha256,
disabledConfigSha256: before.disabledConfigSha256,
};
}
if (!before.complete)
return {
ok: false,
code: "inventory_incomplete",
inventorySha256: before.inventorySha256,
};
let skill =
request.operation === "create"
? null
: findSkill(before, request.skillId, { editable: true });
if (request.operation === "create") {
if (
!SAFE_NAME.test(request.name ?? "") ||
request.expectedFileAbsent !== true ||
request.inventorySha256 !== before.inventorySha256
) {
return {
ok: false,
code: "skills_conflict",
inventorySha256: before.inventorySha256,
};
}
if (before.skills.some((entry) => entry.name === request.name))
return { ok: false, code: "skills_absent_conflict" };
}
if (
request.operation === "edit" &&
request.expectedFileSha256 !== skill.fileSha256
)
return {
ok: false,
code: "skills_file_conflict",
fileSha256: skill.fileSha256,
};
if (
request.operation === "toggle" &&
request.disabledConfigSha256 !== before.disabledConfigSha256
)
return {
ok: false,
code: "skills_disabled_config_conflict",
disabledConfigSha256: before.disabledConfigSha256,
};
if (
["delete", "write_file", "remove_file"].includes(request.operation) &&
request.skillManifestSha256 !== skill.skillManifestSha256
)
return {
ok: false,
code: "skills_manifest_conflict",
skillManifestSha256: skill.skillManifestSha256,
};
let supportingExisting = null;
if (["write_file", "remove_file"].includes(request.operation)) {
const relativePath = safeRelativePath(request.filePath, {
supporting: true,
});
supportingExisting =
skill.manifest.find((entry) => entry.relativePath === relativePath) ??
null;
if (request.operation === "remove_file" || supportingExisting) {
if (
!supportingExisting ||
request.expectedFileSha256 !== supportingExisting.fileSha256
)
return {
ok: false,
code: "skills_file_conflict",
fileSha256: supportingExisting?.fileSha256 ?? null,
};
} else if (request.expectedFileAbsent !== true)
return { ok: false, code: "skills_absent_conflict" };
}
const skillRoot =
skill?._canonicalRoot ??
join(
paths.profileRoot,
"skills",
request.category ? join(request.category, request.name) : request.name
);
const skillExisted = existsSync(skillRoot);
const treeBackup = skillExisted ? backupTree(skillRoot) : [];
const disabledPath = join(paths.profileRoot, "config.yaml");
const disabledBackup = existsSync(disabledPath)
? regularFile(disabledPath)
: null;
const proofPath = join(
paths.profileRoot,
".sellable-agent",
"skills-proof.json"
);
const proofBackup = existsSync(proofPath)
? regularFile(proofPath, 4096)
: null;
const rollback = () => {
if (skillExisted) restoreTree(skillRoot, treeBackup);
else rmSync(skillRoot, { recursive: true, force: true });
if (disabledBackup !== null)
writeFileSync(disabledPath, disabledBackup, { mode: 0o600 });
if (proofBackup !== null)
writeFileSync(proofPath, proofBackup, { mode: 0o600 });
else rmSync(proofPath, { force: true });
};
try {
const scanTarget = skillRoot;
if (skillExisted) {
const scan = adapter.scan(scanTarget);
if (scan?.ok !== true) fail("skills_scan_refused");
}
const noEffect =
(request.operation === "edit" &&
sha256(Buffer.from(request.content, "utf8")) === skill.fileSha256) ||
(request.operation === "toggle" && request.enabled === skill.enabled) ||
(request.operation === "write_file" &&
supportingExisting !== null &&
sha256(Buffer.from(request.content, "utf8")) ===
supportingExisting.fileSha256);
if (noEffect) {
atomicProof(paths, before);
return {
ok: true,
schemaVersion: SKILLS_BRIDGE_SCHEMA_VERSION,
status: "noop",
operation: request.operation,
inventory: publicInventory(before),
detail: publicInventory({ ...before, skills: [skill] }).skills[0],
};
}
const mutation = {
action: request.operation,
name: skill?.name ?? request.name,
...(request.content !== undefined ? { content: request.content } : {}),
...(request.category !== undefined ? { category: request.category } : {}),
...(request.filePath !== undefined ? { filePath: request.filePath } : {}),
...(request.enabled !== undefined ? { enabled: request.enabled } : {}),
};
const result = adapter.mutate(mutation);
if (result?.success !== true)
return { ok: false, code: "skills_native_refused" };
if (existsSync(skillRoot)) {
const scan = adapter.scan(skillRoot);
if (scan?.ok !== true) fail("skills_scan_refused");
}
const afterNative = adapter.enumerate({
readName: request.operation === "delete" ? undefined : mutation.name,
});
const after = normalizeEnumeration(afterNative, paths, options.limits);
if (
!after.complete ||
(request.operation !== "delete" && !afterNative.nativeView)
)
fail("skills_readback_failed");
const afterSkill =
after.skills.find((entry) => entry.name === mutation.name) ?? null;
if (
request.operation === "delete" ? afterSkill !== null : afterSkill === null
)
fail("skills_readback_failed");
if (afterSkill) {
assertNativeView(
afterNative.nativeView,
afterSkill.name,
afterSkill.manifestContent,
"skills_readback_failed"
);
}
if (
(request.operation === "edit" &&
afterSkill.fileSha256 !==
sha256(Buffer.from(request.content, "utf8"))) ||
(request.operation === "toggle" &&
(afterSkill.enabled !== request.enabled ||
after.disabledConfigSha256 === before.disabledConfigSha256)) ||
(request.operation === "create" &&
afterSkill.fileSha256 !==
sha256(Buffer.from(request.content, "utf8"))) ||
(request.operation === "write_file" &&
!afterSkill.manifest.some(
(entry) =>
entry.relativePath === request.filePath &&
entry.fileSha256 === sha256(Buffer.from(request.content, "utf8"))
)) ||
(request.operation === "remove_file" &&
afterSkill.manifest.some(
(entry) => entry.relativePath === request.filePath
))
) {
fail("skills_readback_failed");
}
atomicProof(paths, after);
options.markDirty(paths.profileRoot);
return {
ok: true,
schemaVersion: SKILLS_BRIDGE_SCHEMA_VERSION,
status: "applied",
operation: request.operation,
effectId: randomUUID(),
inventory: publicInventory(after),
detail: afterSkill
? publicInventory({ ...after, skills: [afterSkill] }).skills[0]
: null,
};
} catch (error) {
rollback();
try {
adapter.enumerate();
} catch {
return { ok: false, code: "skills_rollback_unproven" };
}
return {
ok: false,
code: "skills_apply_failed_rolled_back",
reason:
error instanceof FlySkillsBridgeError
? error.code
: "skills_bridge_failed",
};
}
}
/**
* @param {any} request
* @param {{
* profileId?: string,
* dataRoot?: string,
* nativeAdapter?: {enumerate: Function, read: Function, scan: Function, mutate: Function} | null,
* markDirty?: (profileRoot: string) => void,
* lockHeld?: boolean,
* nativeCallTimeoutMs?: number,
* limits?: any,
* }} [options]
*/
export function executeSkillsBridgeOperation(
request,
{
profileId = process.env.SELLABLE_HERMES_PROFILE_ID,
dataRoot = SOUL_BRIDGE_DATA_ROOT,
nativeAdapter = null,
markDirty = markHermesSnapshotDirty,
lockHeld = false,
nativeCallTimeoutMs = 20_000,
limits = {},
} = {}
) {
assertRequest(request);
if (
!Number.isInteger(nativeCallTimeoutMs) ||
nativeCallTimeoutMs < 1 ||
nativeCallTimeoutMs > 60_000
)
fail("skills_native_timeout_rejected");
const id = validProfileId(profileId);
const paths = soulProfilePaths(id, { dataRoot });
const adapter = nativeAdapter ?? defaultAdapter(paths, nativeCallTimeoutMs);
if (
!adapter ||
!["enumerate", "read", "scan", "mutate"].every(
(name) => typeof adapter[name] === "function"
)
)
fail("skills_native_adapter_rejected");
if (typeof markDirty !== "function") fail("skills_dirty_marker_rejected");
const run = () =>
executeLocked(request, paths, adapter, { markDirty, limits });
return lockHeld ? run() : withProfileSoulLock(id, run, { dataRoot });
}
/**
* @param {any} request
* @param {{
* procRoot?: string,
* dataRoot?: string,
* nativeAdapter?: {enumerate: Function, read: Function, scan: Function, mutate: Function} | null,
* markDirty?: (profileRoot: string) => void,
* callerProfileId?: string,
* nativeCallTimeoutMs?: number,
* limits?: any,
* }} [options]
*/
export function executeExternalSkillsBridgeOperation(
request,
{
procRoot = "/proc",
dataRoot = SOUL_BRIDGE_DATA_ROOT,
nativeAdapter = null,
markDirty = markHermesSnapshotDirty,
callerProfileId = process.env.SELLABLE_HERMES_PROFILE_ID,
nativeCallTimeoutMs,
limits = {},
} = {}
) {
const gateway = discoverLiveHermesGateway({ procRoot, dataRoot });
if (callerProfileId && callerProfileId !== gateway.profileId)
fail("skills_gateway_caller_profile_mismatch");
revalidateLiveHermesGateway(gateway, { procRoot, dataRoot });
const result = executeSkillsBridgeOperation(request, {
profileId: gateway.profileId,
dataRoot,
nativeAdapter,
nativeCallTimeoutMs,
markDirty,
limits,
});
revalidateLiveHermesGateway(gateway, { procRoot, dataRoot });
return Object.freeze({
...result,
profileId: gateway.profileId,
gatewayPid: gateway.pid,
gatewayStartTicks: gateway.startTicks,
});
}
/**
* @param {{
* actions?: Array<{type: string, id: string, nativeName?: string}>,
* profileId?: string,
* dataRoot?: string,
* bridge?: typeof executeExternalSkillsBridgeOperation,
* nativeCallTimeoutMs?: number,
* }} [options]
*/
export function proveDefaultProfileNativeSkills({
actions,
profileId,
dataRoot = SOUL_BRIDGE_DATA_ROOT,
bridge = executeExternalSkillsBridgeOperation,
nativeCallTimeoutMs,
} = {}) {
const managed = (actions ?? []).filter((action) => action.type === "skill");
if (managed.length === 0) return { status: "EMPTY", digest: sha256("[]") };
const options = {
dataRoot,
callerProfileId: profileId,
...(nativeCallTimeoutMs === undefined ? {} : { nativeCallTimeoutMs }),
};
for (const action of managed) {
if (!SAFE_NAME.test(action.nativeName ?? ""))
fail("default_profile_native_skill_name_missing");
}
const verified = bridge(
{ operation: "verify", names: managed.map((action) => action.nativeName) },
options
);
if (
!verified.ok ||
!Array.isArray(verified.proofs) ||
verified.proofs.length !== managed.length
)
fail("default_profile_native_skill_readback_rejected");
const proofs = managed.map((action, index) => {
const viewed = verified.proofs[index];
if (
viewed?.name !== action.nativeName ||
!SHA256.test(viewed.fileSha256 ?? "")
)
fail("default_profile_native_skill_readback_rejected");
return {
id: action.id,
nativeName: action.nativeName,
fileSha256: viewed.fileSha256,
};
});
return { status: "PROVEN", digest: sha256(stableJson(proofs)) };
}
export function computeSkillsConfigIdentity(profileRoot) {
if (!isAbsolute(profileRoot) || !existsSync(profileRoot))
fail("skills_profile_root_rejected");
const canonical = realpathSync(profileRoot);
const skillsRoot = join(canonical, "skills");
const tree = existsSync(skillsRoot)
? inspectTree(skillsRoot, {
maxFiles: 4096,
maxTotalBytes: 64 * 1024 * 1024,
})
: { entries: [], incompleteReason: null };
if (tree.incompleteReason) fail("skills_profile_identity_incomplete");
const configPath = join(canonical, "config.yaml");
const configSha256 = existsSync(configPath)
? sha256(regularFile(configPath, 2 * 1024 * 1024))
: sha256("");
return Object.freeze({
inventorySha256: sha256(stableJson(tree.entries)),
disabledConfigSha256: configSha256,
});
}
export function readSkillsProofObservation(profileRoot) {
try {
const path = join(
realpathSync(profileRoot),
".sellable-agent",
"skills-proof.json"
);
const value = JSON.parse(regularFile(path, 4096).toString("utf8"));
if (
value.schemaVersion !== SKILLS_PROOF_SCHEMA_VERSION ||
!SHA256.test(value.inventorySha256 ?? "") ||
!SHA256.test(value.disabledConfigSha256 ?? "") ||
Object.keys(value).length !== 3
)
return null;
return Object.freeze({
inventorySha256: value.inventorySha256,
disabledConfigSha256: value.disabledConfigSha256,
});
} catch {
return null;
}
}