UNPKG

@sellable/install

Version:

One-command installer for Sellable MCP in Claude Code, Codex, and Hermes

443 lines (423 loc) • 21 kB
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" }; } }