@sellable/install
Version:
One-command installer for Sellable MCP in Claude Code, Codex, and Hermes
443 lines (423 loc) • 21 kB
JavaScript
import { createHash, randomUUID } from "node:crypto";
import { spawnSync } from "node:child_process";
import {
chmodSync, chownSync, closeSync, existsSync, fsyncSync, lstatSync, mkdirSync,
openSync, readFileSync, realpathSync, renameSync, writeFileSync,
} from "node:fs";
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { validateRuntimeEgressProxyConfig } from "./runtime-egress-proxy.mjs";
import {
EXTERNAL_HERMES_ENTRYPOINT,
EXTERNAL_MCP_ENTRYPOINT,
EXTERNAL_RUNTIME_CLOSURE_ROOT,
EXTERNAL_RUNTIME_MANIFEST_PATH,
observeExternalRuntimeClosure,
} from "./runtime-helper.mjs";
const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
const IMMUTABLE_PACKAGE_ROOT = "/usr/local/lib/sellable-agent/package";
const IMMUTABLE_MANIFEST_PATH = `${IMMUTABLE_PACKAGE_ROOT}/manifest.json`;
const IMMUTABLE_MANIFEST_VERSION = "sellable-agent-installed-closure/v1";
const AGENT_BIN_FILES = Object.freeze([
"sellable-agent-egress-proxy.mjs",
"sellable-agent-host-worker.mjs",
"sellable-agent-runtime-helper.mjs",
"sellable-agent-runtime-launcher.mjs",
"sellable-agent-runtime-probe.mjs",
"sellable-agent-sandbox-init.mjs",
]);
const AGENT_LIB_FILES = Object.freeze([
"containment-contract.mjs",
"hermes-bridge.mjs",
"host-worker.mjs",
"mcp-runtime-binding.mjs",
"profile-materializer.mjs",
"provisioning-adapter.mjs",
"runtime-boundary.mjs",
"runtime-egress-proxy.mjs",
"runtime-helper.mjs",
"runtime-reconciler.mjs",
"service-installer.mjs",
]);
const SAFE_ID = /^[A-Za-z0-9_-]{1,128}$/;
const SHA256 = /^[a-f0-9]{64}$/;
const SECRET = /xox[bap]-|sat_[A-Za-z0-9_-]+|asec\.v1\.|access[_-]?token|refresh[_-]?token/i;
function sha256(value) {
return createHash("sha256").update(value).digest("hex");
}
function inside(root, target) {
const value = relative(root, target);
return value === "" || (!value.startsWith("..") && !isAbsolute(value));
}
function ensureTrustedParents(root, pathValue, uid, gid) {
const parent = dirname(pathValue);
if (!inside(root, parent)) throw new Error("service parent confinement failed");
const relativeParent = relative(root, parent);
let current = root;
for (const component of relativeParent.split(/[/\\]+/).filter(Boolean)) {
current = join(current, component);
if (!existsSync(current)) mkdirSync(current, { mode: 0o755 });
const link = lstatSync(current);
if (
link.isSymbolicLink() || !link.isDirectory() || link.uid !== uid || link.gid !== gid ||
(link.mode & 0o022) !== 0 || realpathSync(current) !== current
) throw new Error("service parent rejected");
}
if (existsSync(pathValue) && lstatSync(pathValue).isSymbolicLink()) {
throw new Error("service target symlink rejected");
}
}
function atomicOwnedFile(root, pathValue, bytes, mode, uid, gid) {
ensureTrustedParents(root, pathValue, uid, gid);
const temp = join(dirname(pathValue), `.${randomUUID()}.tmp`);
writeFileSync(temp, bytes, { flag: "wx", mode });
chmodSync(temp, mode);
if (lstatSync(temp).uid !== uid || lstatSync(temp).gid !== gid) chownSync(temp, uid, gid);
const descriptor = openSync(temp, "r");
try { fsyncSync(descriptor); } finally { closeSync(descriptor); }
renameSync(temp, pathValue);
const parent = openSync(dirname(pathValue), "r");
try { fsyncSync(parent); } finally { closeSync(parent); }
}
function accountDatabaseState(targetRoot, expected, ownerUid, ownerGid) {
const passwdPath = target(targetRoot, "/etc/passwd");
const groupPath = target(targetRoot, "/etc/group");
const passwdInfo = lstatSync(passwdPath);
const groupInfo = lstatSync(groupPath);
if (
passwdInfo.isSymbolicLink() || !passwdInfo.isFile() || (passwdInfo.mode & 0o022) !== 0 ||
passwdInfo.uid !== ownerUid || passwdInfo.gid !== ownerGid ||
groupInfo.isSymbolicLink() || !groupInfo.isFile() || (groupInfo.mode & 0o022) !== 0 ||
groupInfo.uid !== ownerUid || groupInfo.gid !== ownerGid
) throw new Error("service account database rejected");
const passwd = readFileSync(passwdPath, "utf8").split(/\r?\n/).filter(Boolean).map((line) => line.split(":"));
const groups = readFileSync(groupPath, "utf8").split(/\r?\n/).filter(Boolean).map((line) => line.split(":"));
const state = expected.map((account) => {
const users = passwd.filter((fields) => fields[0] === account.name);
const groupRows = groups.filter((fields) => fields[0] === account.name);
const uidRows = passwd.filter((fields) => Number(fields[2]) === account.uid);
const gidRows = groups.filter((fields) => Number(fields[2]) === account.gid);
const userPresent = users.length === 1 && users[0].length >= 7 &&
Number(users[0][2]) === account.uid && Number(users[0][3]) === account.gid &&
users[0][5] === account.home && users[0][6] === "/usr/sbin/nologin" &&
uidRows.length === 1 && uidRows[0] === users[0];
const groupPresent = groupRows.length === 1 && groupRows[0].length >= 4 &&
Number(groupRows[0][2]) === account.gid && gidRows.length === 1 && gidRows[0] === groupRows[0];
if ((!userPresent && (users.length > 0 || uidRows.length > 0)) ||
(!groupPresent && (groupRows.length > 0 || gidRows.length > 0))) {
throw new Error("service account identity collision");
}
return { ...account, userPresent, groupPresent };
});
return state;
}
function readAccountDatabase(targetRoot, expected, ownerUid, ownerGid) {
const state = accountDatabaseState(targetRoot, expected, ownerUid, ownerGid);
if (state.some((account) => !account.userPresent || !account.groupPresent)) {
throw new Error("service account identity rejected");
}
return state.map(({ name, uid, gid }) => ({ name, uid, gid }));
}
function runAccountCommand(command, args) {
const result = spawnSync(command, args, {
encoding: "utf8",
env: { PATH: "/usr/sbin:/usr/bin:/sbin:/bin" },
shell: false,
stdio: ["ignore", "pipe", "pipe"],
});
if (result.error || result.status !== 0 || result.signal || result.stderr?.trim()) {
throw new Error("service account provisioning failed");
}
}
export function buildServiceAccountProvisioningCommands(accounts) {
if (!Array.isArray(accounts) || accounts.some((account) =>
!SAFE_ID.test(account?.name ?? "") || !Number.isSafeInteger(account?.uid) || account.uid < 1 ||
!Number.isSafeInteger(account?.gid) || account.gid < 1 ||
!["/var/lib/sellable-agent-worker", "/var/lib/sellable-agent-runtime", "/var/empty"].includes(account?.home) ||
typeof account.createUser !== "boolean" || typeof account.createGroup !== "boolean"
)) throw new Error("service account command input rejected");
return [
...accounts.filter((account) => account.createGroup).map((account) => ({
command: "/usr/sbin/groupadd",
args: ["--gid", String(account.gid), account.name],
})),
...accounts.filter((account) => account.createUser).map((account) => ({
command: "/usr/sbin/useradd",
args: [
"--uid", String(account.uid), "--gid", account.name,
"--home-dir", account.home, "--shell", "/usr/sbin/nologin", "--no-create-home", account.name,
],
})),
];
}
function provisionServiceAccounts(input, checked) {
const initial = accountDatabaseState(
checked.targetRoot, checked.expectedAccounts, checked.uid, checked.gid,
);
const missing = initial.filter((account) => !account.userPresent || !account.groupPresent);
if (missing.length === 0) {
return readAccountDatabase(checked.targetRoot, checked.expectedAccounts, checked.uid, checked.gid);
}
if (checked.targetRoot !== "/") {
if (input.disposableFixture !== true || typeof input.accountProvisioner !== "function") {
throw new Error("fixture service account provisioner missing");
}
input.accountProvisioner({
targetRoot: checked.targetRoot,
accounts: missing.map(({ name, uid, gid, userPresent, groupPresent, home }) => ({
name, uid, gid, home, createUser: !userPresent, createGroup: !groupPresent,
})),
});
} else {
const commands = buildServiceAccountProvisioningCommands(missing.map((account) => ({
name: account.name,
uid: account.uid,
gid: account.gid,
home: account.home,
createUser: !account.userPresent,
createGroup: !account.groupPresent,
})));
for (const command of commands) {
runAccountCommand(command.command, command.args);
}
}
return readAccountDatabase(checked.targetRoot, checked.expectedAccounts, checked.uid, checked.gid);
}
function validateInstallInput(input) {
const targetRoot = resolve(input.targetRoot ?? "/");
if (!isAbsolute(targetRoot) || !existsSync(targetRoot) || lstatSync(targetRoot).isSymbolicLink()) {
throw new Error("service install root rejected");
}
if (targetRoot !== "/" && input.disposableFixture !== true) throw new Error("non-host install root rejected");
const packageRoot = realpathSync(input.packageRoot ?? PACKAGE_ROOT);
if (
packageRoot !== realpathSync(PACKAGE_ROOT) &&
!(input.disposableFixture === true && targetRoot !== "/")
) throw new Error("service package root pin rejected");
const uid = input.ownerUid ?? process.getuid?.();
const gid = input.ownerGid ?? process.getgid?.();
if (!Number.isSafeInteger(uid) || uid < 0 || !Number.isSafeInteger(gid) || gid < 0) {
throw new Error("service install owner rejected");
}
if (targetRoot === "/" && (uid !== 0 || gid !== 0 || process.getuid?.() !== 0)) {
throw new Error("live service install requires root");
}
const targetRootInfo = lstatSync(targetRoot);
if (
!targetRootInfo.isDirectory() || targetRootInfo.uid !== uid || targetRootInfo.gid !== gid ||
(targetRootInfo.mode & 0o022) !== 0
) throw new Error("service install root ownership rejected");
const proxy = validateRuntimeEgressProxyConfig(input.egressProxyConfig);
if (!input.workerConfig || !input.helperConfig || !proxy.ok || SECRET.test(JSON.stringify({
workerConfig: input.workerConfig,
helperConfig: input.helperConfig,
egressProxyConfig: input.egressProxyConfig,
}))) throw new Error("service config rejected");
if (
!SAFE_ID.test(input.workerConfig.workerId ?? "") || !SAFE_ID.test(input.workerConfig.hostId ?? "") ||
!Number.isSafeInteger(input.workerConfig.workerUid) || input.workerConfig.workerUid < 1 ||
!Number.isSafeInteger(input.workerConfig.workerGid) || input.workerConfig.workerGid < 1
) {
throw new Error("worker config identity rejected");
}
const profiles = Object.entries(input.helperConfig.profiles ?? {});
if (profiles.length !== 1 || profiles.some(([profileId, profile]) =>
!SAFE_ID.test(profileId) || !Number.isSafeInteger(profile.runtimeUid) || profile.runtimeUid < 1 ||
!Number.isSafeInteger(profile.runtimeGid) || profile.runtimeGid < 1 ||
profile.workerUid !== input.workerConfig.workerUid || profile.workerGid !== input.workerConfig.workerGid ||
profile.proxyUid !== proxy.config.uid || profile.proxyGid !== proxy.config.gid ||
profile.proxyExecutablePath !== "/usr/local/lib/sellable-agent/package/bin/sellable-agent-egress-proxy.mjs" ||
profile.installedPackageManifestPath !== IMMUTABLE_MANIFEST_PATH ||
profile.externalRuntimeManifestPath !== EXTERNAL_RUNTIME_MANIFEST_PATH ||
profile.externalRuntimeClosureRoot !== EXTERNAL_RUNTIME_CLOSURE_ROOT ||
profile.externalRuntimeTrustRoot !== dirname(EXTERNAL_RUNTIME_CLOSURE_ROOT) ||
profile.externalRuntimeOwnerUid !== uid || profile.externalRuntimeOwnerGid !== gid ||
profile.mcpCommand !== EXTERNAL_MCP_ENTRYPOINT ||
profile.hermesExecutablePath !== EXTERNAL_HERMES_ENTRYPOINT ||
profile.hermesSourceRoot !== `${EXTERNAL_RUNTIME_CLOSURE_ROOT}/hermes` ||
profile.proxyConfigPath !== "/etc/sellable-agent/egress-proxy.json" ||
profile.runtimeBoundaryConfigPath !== "/etc/sellable-agent/runtime-boundary.json" ||
profile.providerCredentialSourcePath !== "/var/lib/sellable-agent-bootstrap/provider-auth" ||
!SHA256.test(profile.providerCredentialFingerprint ?? "") ||
profile.runtimeServicePath !== "/etc/services.d/sellable-agent-runtime" ||
profile.runtimeServiceEnvRoot !== "/etc/services.d/sellable-agent-runtime/env" ||
new Set([profile.runtimeUid, profile.proxyUid, profile.workerUid]).size !== 3
)) throw new Error("helper profile map rejected");
const profile = profiles[0][1];
const externalRuntime = observeExternalRuntimeClosure({
manifestPath: profile.externalRuntimeManifestPath,
pathRoot: targetRoot,
trustRoot: profile.externalRuntimeTrustRoot,
expectedClosureRoot: profile.externalRuntimeClosureRoot,
ownerUid: uid,
ownerGid: gid,
expectedEntrypoints: { mcp: profile.mcpCommand, hermes: profile.hermesExecutablePath },
});
const expectedAccounts = [
{
name: "sellable-agent-worker", uid: input.workerConfig.workerUid,
gid: input.workerConfig.workerGid, home: "/var/lib/sellable-agent-worker",
},
{
name: "sellable-agent-runtime", uid: profile.runtimeUid,
gid: profile.runtimeGid, home: "/var/lib/sellable-agent-runtime",
},
{
name: "sellable-agent-egress-proxy", uid: proxy.config.uid,
gid: proxy.config.gid, home: "/var/empty",
},
];
return {
targetRoot: realpathSync(targetRoot), packageRoot, uid, gid, proxyConfig: proxy.config,
expectedAccounts, externalRuntime,
};
}
function target(root, absolutePath) {
const value = join(root, absolutePath.replace(/^\/+/, ""));
if (!inside(root, value)) throw new Error("service target confinement failed");
return value;
}
function installedRuntimeLauncher(name) {
return `#!/bin/sh\nexec /usr/bin/node '${IMMUTABLE_PACKAGE_ROOT}/bin/${name}.mjs' "$@"\n`;
}
function configurationFiles(input, checked) {
const helperRequestRoot = input.workerConfig.helperRequestRoot;
if (!isAbsolute(helperRequestRoot) || /[\s,*?\[\]{}]/.test(helperRequestRoot)) {
throw new Error("helper request sudoers path rejected");
}
const sudoers = [
"Defaults:sellable-agent-worker !setenv,secure_path=/usr/local/bin:/usr/bin:/bin",
`sellable-agent-worker ALL=(root) NOPASSWD: /usr/local/bin/sellable-agent-runtime-helper --request ${helperRequestRoot}/*`,
"",
].join("\n");
return [
["/etc/sellable-agent/egress-proxy.json", `${JSON.stringify(checked.proxyConfig, null, 2)}\n`, 0o444],
["/etc/sellable-agent/worker.json", `${JSON.stringify(input.workerConfig, null, 2)}\n`, 0o444],
["/etc/sellable-agent/runtime-helper.json", `${JSON.stringify(input.helperConfig, null, 2)}\n`, 0o600],
["/etc/sudoers.d/sellable-agent-runtime-helper", sudoers, 0o440],
].map(([logicalPath, bytes, mode]) => ({ logicalPath, bytes, mode }));
}
function installationFiles(input, checked) {
return [
...AGENT_BIN_FILES.map((name) => [
`${IMMUTABLE_PACKAGE_ROOT}/bin/${name}`, readFileSync(join(checked.packageRoot, "bin", name)), 0o555,
]),
...AGENT_LIB_FILES.map((name) => [
`${IMMUTABLE_PACKAGE_ROOT}/lib/sellable-agent/${name}`,
readFileSync(join(checked.packageRoot, "lib/sellable-agent", name)), 0o555,
]),
[`${IMMUTABLE_PACKAGE_ROOT}/package.json`, readFileSync(join(checked.packageRoot, "package.json")), 0o444],
["/usr/local/bin/sellable-agent-egress-proxy", installedRuntimeLauncher("sellable-agent-egress-proxy"), 0o555],
["/usr/local/bin/sellable-agent-host-worker", installedRuntimeLauncher("sellable-agent-host-worker"), 0o555],
["/usr/local/bin/sellable-agent-runtime-helper", installedRuntimeLauncher("sellable-agent-runtime-helper"), 0o555],
["/usr/local/bin/sellable-agent-runtime-launcher", installedRuntimeLauncher("sellable-agent-runtime-launcher"), 0o555],
["/usr/local/bin/sellable-agent-runtime-probe", installedRuntimeLauncher("sellable-agent-runtime-probe"), 0o555],
["/usr/local/bin/sellable-agent-sandbox-init", installedRuntimeLauncher("sellable-agent-sandbox-init"), 0o555],
...configurationFiles(input, checked).map((file) => [file.logicalPath, file.bytes, file.mode]),
["/etc/services.d/sellable-agent-runtime/down", "", 0o444],
["/etc/services.d/sellable-agent-egress-proxy/run", readFileSync(join(checked.packageRoot, "services/s6/sellable-agent-egress-proxy/run")), 0o555],
["/etc/services.d/sellable-agent-runtime/run", readFileSync(join(checked.packageRoot, "services/s6/sellable-agent-runtime/run")), 0o555],
["/etc/services.d/sellable-agent-host-worker/run", readFileSync(join(checked.packageRoot, "services/s6/sellable-agent-host-worker/run")), 0o555],
].map(([logicalPath, bytes, mode]) => ({ logicalPath, bytes, mode }));
}
function manifestFile(files) {
const manifest = {
version: IMMUTABLE_MANIFEST_VERSION,
files: files.map((file) => ({
path: file.logicalPath,
mode: file.mode,
digest: sha256(Buffer.from(file.bytes)),
})),
};
return {
logicalPath: IMMUTABLE_MANIFEST_PATH,
bytes: `${JSON.stringify(manifest, null, 2)}\n`,
mode: 0o444,
};
}
function readInstalledManifest(checked) {
const pathValue = target(checked.targetRoot, IMMUTABLE_MANIFEST_PATH);
const link = lstatSync(pathValue);
const expectedPaths = [
...AGENT_BIN_FILES.map((name) => `${IMMUTABLE_PACKAGE_ROOT}/bin/${name}`),
...AGENT_LIB_FILES.map((name) => `${IMMUTABLE_PACKAGE_ROOT}/lib/sellable-agent/${name}`),
`${IMMUTABLE_PACKAGE_ROOT}/package.json`,
...AGENT_BIN_FILES.map((name) => `/usr/local/bin/${name.replace(/\.mjs$/, "")}`),
"/etc/sellable-agent/egress-proxy.json",
"/etc/sellable-agent/worker.json",
"/etc/sellable-agent/runtime-helper.json",
"/etc/sudoers.d/sellable-agent-runtime-helper",
"/etc/services.d/sellable-agent-runtime/down",
"/etc/services.d/sellable-agent-egress-proxy/run",
"/etc/services.d/sellable-agent-runtime/run",
"/etc/services.d/sellable-agent-host-worker/run",
].sort();
if (
link.isSymbolicLink() || !link.isFile() || link.uid !== checked.uid || link.gid !== checked.gid ||
(link.mode & 0o777) !== 0o444
) throw new Error("installed manifest rejected");
const bytes = readFileSync(pathValue);
const parsed = JSON.parse(bytes.toString("utf8"));
if (
parsed?.version !== IMMUTABLE_MANIFEST_VERSION || !Array.isArray(parsed.files) ||
parsed.files.some((file) =>
typeof file?.path !== "string" || !file.path.startsWith("/") ||
!Number.isSafeInteger(file.mode) || !SHA256.test(file.digest ?? "")
) || new Set(parsed.files.map((file) => file.path)).size !== parsed.files.length ||
JSON.stringify(parsed.files.map((file) => file.path).sort()) !== JSON.stringify(expectedPaths)
) throw new Error("installed manifest schema rejected");
return { bytes, files: parsed.files };
}
export function observeAgentHostServices(input) {
try {
const checked = validateInstallInput(input);
const accounts = readAccountDatabase(
checked.targetRoot, checked.expectedAccounts, checked.uid, checked.gid,
);
const installedManifest = readInstalledManifest(checked);
const files = installedManifest.files.map((file) => {
const pathValue = target(checked.targetRoot, file.path);
const link = lstatSync(pathValue);
const bytes = readFileSync(pathValue);
if (
link.isSymbolicLink() || !link.isFile() || link.uid !== checked.uid || link.gid !== checked.gid ||
(link.mode & 0o777) !== file.mode || sha256(bytes) !== file.digest
) throw new Error("installed service readback rejected");
return { path: file.path, mode: file.mode, uid: link.uid, gid: link.gid, digest: sha256(bytes) };
});
const configuration = configurationFiles(input, checked);
for (const file of configuration) {
const bytes = readFileSync(target(checked.targetRoot, file.logicalPath));
if (!bytes.equals(Buffer.from(file.bytes))) throw new Error("installed configuration drift rejected");
}
return {
ok: true,
producer: "sellable-agent-service-installer",
accounts,
manifestDigest: sha256(installedManifest.bytes),
externalRuntimeClosureDigest: checked.externalRuntime.closureDigest,
files: [
{ path: IMMUTABLE_MANIFEST_PATH, mode: 0o444, uid: checked.uid, gid: checked.gid, digest: sha256(installedManifest.bytes) },
...files,
],
};
} catch {
return { ok: false, code: "service_readback_rejected" };
}
}
export function installAgentHostServices(input) {
try {
const checked = validateInstallInput(input);
provisionServiceAccounts(input, checked);
const files = installationFiles(input, checked);
for (const file of [...files, manifestFile(files)]) {
atomicOwnedFile(
checked.targetRoot, target(checked.targetRoot, file.logicalPath),
file.bytes, file.mode, checked.uid, checked.gid,
);
}
return observeAgentHostServices(input);
} catch {
return { ok: false, code: "service_install_rejected" };
}
}