@sellable/install 0.1.360-phase111.20260720061815 → 0.1.360
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.
- package/bin/sellable-agent-egress-proxy.mjs +31 -0
- package/bin/sellable-agent-host-bootstrap.mjs +26 -0
- package/bin/sellable-agent-host-worker.mjs +9 -0
- package/bin/sellable-agent-runtime-helper.mjs +37 -0
- package/bin/sellable-agent-runtime-launcher.mjs +151 -0
- package/bin/sellable-agent-runtime-probe.mjs +163 -0
- package/bin/sellable-agent-sandbox-init.mjs +93 -0
- package/bin/sellable-install.mjs +2 -0
- package/container/Dockerfile +41 -0
- package/container/README.md +15 -0
- package/container/compose.yaml +22 -0
- package/container/entrypoint.sh +16 -0
- package/lib/sellable-agent/containment-contract.mjs +472 -0
- package/lib/sellable-agent/external-runtime-builder.mjs +272 -0
- package/lib/sellable-agent/hermes-bridge.mjs +497 -0
- package/lib/sellable-agent/host-bootstrap.mjs +458 -0
- package/lib/sellable-agent/host-worker.mjs +2067 -0
- package/lib/sellable-agent/profile-materializer.mjs +1410 -0
- package/lib/sellable-agent/provisioning-adapter.mjs +992 -0
- package/lib/sellable-agent/runtime-boundary.mjs +635 -0
- package/lib/sellable-agent/runtime-egress-proxy.mjs +241 -0
- package/lib/sellable-agent/runtime-helper.mjs +1428 -0
- package/lib/sellable-agent/runtime-reconciler.mjs +683 -0
- package/lib/sellable-agent/service-installer.mjs +441 -0
- package/package.json +11 -2
- package/services/s6/sellable-agent-egress-proxy/run +15 -0
- package/services/s6/sellable-agent-host-worker/run +4 -0
- package/services/s6/sellable-agent-runtime/run +19 -0
- package/skill-templates/refill-sends-evergreen.md +0 -5
- package/skill-templates/refill-sends-v2.md +1 -23
- package/skill-templates/refill-sends.md +1 -23
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { lstatSync, readFileSync } from "node:fs";
|
|
4
|
+
import {
|
|
5
|
+
createRuntimeEgressProxy,
|
|
6
|
+
validateRuntimeEgressProxyConfig,
|
|
7
|
+
} from "../lib/sellable-agent/runtime-egress-proxy.mjs";
|
|
8
|
+
|
|
9
|
+
const CONFIG_PATH = "/etc/sellable-agent/egress-proxy.json";
|
|
10
|
+
|
|
11
|
+
try {
|
|
12
|
+
if (process.argv.length !== 2) throw new Error("arguments rejected");
|
|
13
|
+
const link = lstatSync(CONFIG_PATH);
|
|
14
|
+
if (link.isSymbolicLink() || !link.isFile() || link.uid !== 0 || (link.mode & 0o777) !== 0o444) {
|
|
15
|
+
throw new Error("proxy config ownership rejected");
|
|
16
|
+
}
|
|
17
|
+
const parsed = validateRuntimeEgressProxyConfig(JSON.parse(readFileSync(CONFIG_PATH, "utf8")));
|
|
18
|
+
if (!parsed.ok || process.getuid?.() !== parsed.config.uid || process.getgid?.() !== parsed.config.gid) {
|
|
19
|
+
throw new Error("proxy identity rejected");
|
|
20
|
+
}
|
|
21
|
+
const proxy = createRuntimeEgressProxy(parsed.config);
|
|
22
|
+
await proxy.listen();
|
|
23
|
+
const stop = async () => {
|
|
24
|
+
try { await proxy.close(); } finally { process.exit(0); }
|
|
25
|
+
};
|
|
26
|
+
process.once("SIGTERM", stop);
|
|
27
|
+
process.once("SIGINT", stop);
|
|
28
|
+
} catch {
|
|
29
|
+
process.stderr.write("sellable-agent egress proxy failed closed\n");
|
|
30
|
+
process.exitCode = 1;
|
|
31
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { lstatSync, readFileSync } from "node:fs";
|
|
4
|
+
import { isAbsolute } from "node:path";
|
|
5
|
+
import { bootstrapAgentHost } from "../lib/sellable-agent/host-bootstrap.mjs";
|
|
6
|
+
|
|
7
|
+
function configPath(argv) {
|
|
8
|
+
if (argv.length !== 2 || argv[0] !== "--config" || !isAbsolute(argv[1])) {
|
|
9
|
+
throw new Error("usage: --config /absolute/path");
|
|
10
|
+
}
|
|
11
|
+
const link = lstatSync(argv[1]);
|
|
12
|
+
if (link.isSymbolicLink() || !link.isFile() || (link.mode & 0o777) !== 0o600 || link.uid !== 0 || link.gid !== 0) {
|
|
13
|
+
throw new Error("host bootstrap config rejected");
|
|
14
|
+
}
|
|
15
|
+
return argv[1];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
try {
|
|
19
|
+
if (process.getuid?.() !== 0 || process.getgid?.() !== 0) throw new Error("host bootstrap requires root");
|
|
20
|
+
const pathValue = configPath(process.argv.slice(2));
|
|
21
|
+
const result = bootstrapAgentHost({ config: JSON.parse(readFileSync(pathValue, "utf8")) });
|
|
22
|
+
process.stdout.write(`${JSON.stringify(result.receipt)}\n`);
|
|
23
|
+
} catch {
|
|
24
|
+
process.stderr.write("sellable-agent host bootstrap failed closed\n");
|
|
25
|
+
process.exitCode = 1;
|
|
26
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { runHostWorkerMain } from "../lib/sellable-agent/host-worker.mjs";
|
|
4
|
+
|
|
5
|
+
const result = await runHostWorkerMain({ argv: process.argv.slice(2) });
|
|
6
|
+
if (!result.ok) {
|
|
7
|
+
process.stderr.write(`${result.code}\n`);
|
|
8
|
+
process.exitCode = 1;
|
|
9
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync } from "node:fs";
|
|
4
|
+
import { runRuntimeHelperRequest } from "../lib/sellable-agent/runtime-helper.mjs";
|
|
5
|
+
|
|
6
|
+
const requestIndex = process.argv.indexOf("--request");
|
|
7
|
+
const requestPath = requestIndex >= 0 ? process.argv[requestIndex + 1] : null;
|
|
8
|
+
if (!requestPath || process.argv.length !== 4) {
|
|
9
|
+
process.stderr.write("usage: sellable-agent-runtime-helper --request /absolute/path\n");
|
|
10
|
+
process.exitCode = 64;
|
|
11
|
+
} else {
|
|
12
|
+
try {
|
|
13
|
+
const configPath = "/etc/sellable-agent/runtime-helper.json";
|
|
14
|
+
const before = lstatSync(configPath);
|
|
15
|
+
if (before.isSymbolicLink() || !before.isFile() || before.uid !== 0 || (before.mode & 0o022) !== 0) {
|
|
16
|
+
throw new Error("runtime helper config rejected");
|
|
17
|
+
}
|
|
18
|
+
const descriptor = openSync(configPath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
19
|
+
let bytes;
|
|
20
|
+
try {
|
|
21
|
+
const after = fstatSync(descriptor);
|
|
22
|
+
if (!after.isFile() || after.uid !== 0 || after.dev !== before.dev || after.ino !== before.ino || (after.mode & 0o022) !== 0) {
|
|
23
|
+
throw new Error("runtime helper config changed during open");
|
|
24
|
+
}
|
|
25
|
+
bytes = readFileSync(descriptor, "utf8");
|
|
26
|
+
} finally {
|
|
27
|
+
closeSync(descriptor);
|
|
28
|
+
}
|
|
29
|
+
const config = JSON.parse(bytes);
|
|
30
|
+
const result = await runRuntimeHelperRequest({ requestPath, config });
|
|
31
|
+
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
32
|
+
if (!result.ok) process.exitCode = 1;
|
|
33
|
+
} catch {
|
|
34
|
+
process.stdout.write(`${JSON.stringify({ ok: false, code: "runtime_helper_config_rejected" })}\n`);
|
|
35
|
+
process.exitCode = 1;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
4
|
+
import {
|
|
5
|
+
existsSync,
|
|
6
|
+
lstatSync,
|
|
7
|
+
mkdirSync,
|
|
8
|
+
readFileSync,
|
|
9
|
+
writeFileSync,
|
|
10
|
+
} from "node:fs";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import {
|
|
13
|
+
buildRuntimeBoundaryContract,
|
|
14
|
+
buildRuntimeBoundaryLaunchSpec,
|
|
15
|
+
buildRuntimeBoundaryNftProgram,
|
|
16
|
+
expectedRuntimeCgroup,
|
|
17
|
+
inspectRuntimeBoundaryInputs,
|
|
18
|
+
runtimeBoundaryNftRulesMatch,
|
|
19
|
+
} from "../lib/sellable-agent/runtime-boundary.mjs";
|
|
20
|
+
|
|
21
|
+
const CONFIG_PATH = "/etc/sellable-agent/runtime-boundary.json";
|
|
22
|
+
const SECRET = /xox[bap]-|sat_[A-Za-z0-9_-]+|asec\.v1\.|access[_-]?token|refresh[_-]?token/i;
|
|
23
|
+
|
|
24
|
+
function fixedRootConfig() {
|
|
25
|
+
const link = lstatSync(CONFIG_PATH);
|
|
26
|
+
if (link.isSymbolicLink() || !link.isFile() || link.uid !== 0 || link.gid !== 0 || (link.mode & 0o777) !== 0o400) {
|
|
27
|
+
throw new Error("runtime boundary config rejected");
|
|
28
|
+
}
|
|
29
|
+
const bytes = readFileSync(CONFIG_PATH, "utf8");
|
|
30
|
+
if (bytes.length > 64 * 1024 || SECRET.test(bytes)) throw new Error("runtime boundary config rejected");
|
|
31
|
+
const built = buildRuntimeBoundaryContract(JSON.parse(bytes));
|
|
32
|
+
if (!built.ok) throw new Error("runtime boundary contract rejected");
|
|
33
|
+
return built.contract;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function provisionCgroup(contract) {
|
|
37
|
+
const parent = contract.paths.cgroupRoot;
|
|
38
|
+
const systemRoot = "/sys/fs/cgroup";
|
|
39
|
+
const rootInfo = lstatSync(systemRoot);
|
|
40
|
+
if (rootInfo.isSymbolicLink() || !rootInfo.isDirectory()) throw new Error("cgroup v2 root rejected");
|
|
41
|
+
const controllers = readFileSync(join(systemRoot, "cgroup.controllers"), "utf8").trim().split(/\s+/);
|
|
42
|
+
if (!["cpu", "memory", "pids"].every((name) => controllers.includes(name))) {
|
|
43
|
+
throw new Error("required cgroup controllers unavailable");
|
|
44
|
+
}
|
|
45
|
+
const requiredControllers = ["cpu", "memory", "pids"];
|
|
46
|
+
const enableControllers = (path) => {
|
|
47
|
+
const controlPath = join(path, "cgroup.subtree_control");
|
|
48
|
+
const enabled = new Set(readFileSync(controlPath, "utf8").trim().split(/\s+/).filter(Boolean));
|
|
49
|
+
const missing = requiredControllers.filter((name) => !enabled.has(name));
|
|
50
|
+
if (missing.length) writeFileSync(controlPath, `${missing.map((name) => `+${name}`).join(" ")}\n`);
|
|
51
|
+
const observed = new Set(readFileSync(controlPath, "utf8").trim().split(/\s+/).filter(Boolean));
|
|
52
|
+
if (requiredControllers.some((name) => !observed.has(name))) throw new Error("cgroup controller enablement failed");
|
|
53
|
+
};
|
|
54
|
+
enableControllers(systemRoot);
|
|
55
|
+
if (!existsSync(parent)) mkdirSync(parent, { mode: 0o755 });
|
|
56
|
+
const parentInfo = lstatSync(parent);
|
|
57
|
+
if (parentInfo.isSymbolicLink() || !parentInfo.isDirectory() || parentInfo.uid !== 0 || (parentInfo.mode & 0o022) !== 0) {
|
|
58
|
+
throw new Error("runtime cgroup parent rejected");
|
|
59
|
+
}
|
|
60
|
+
enableControllers(parent);
|
|
61
|
+
const expected = expectedRuntimeCgroup(contract);
|
|
62
|
+
if (!existsSync(expected.path)) mkdirSync(expected.path, { mode: 0o755 });
|
|
63
|
+
const groupInfo = lstatSync(expected.path);
|
|
64
|
+
if (groupInfo.isSymbolicLink() || !groupInfo.isDirectory() || groupInfo.uid !== 0 || (groupInfo.mode & 0o022) !== 0) {
|
|
65
|
+
throw new Error("runtime cgroup rejected");
|
|
66
|
+
}
|
|
67
|
+
if (readFileSync(join(expected.path, "cgroup.procs"), "utf8").trim()) {
|
|
68
|
+
throw new Error("runtime cgroup still has processes");
|
|
69
|
+
}
|
|
70
|
+
for (const [name, value] of Object.entries(expected.files)) {
|
|
71
|
+
writeFileSync(join(expected.path, name), `${value}\n`);
|
|
72
|
+
if (readFileSync(join(expected.path, name), "utf8").trim() !== value) throw new Error("runtime cgroup limit drift");
|
|
73
|
+
}
|
|
74
|
+
writeFileSync(join(expected.path, "cgroup.procs"), `${process.pid}\n`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function provisionFirewall(contract) {
|
|
78
|
+
const policy = buildRuntimeBoundaryNftProgram(contract);
|
|
79
|
+
if (!policy.ok) throw new Error("runtime firewall contract rejected");
|
|
80
|
+
const listed = spawnSync(contract.paths.nftPath, ["-j", "list", "tables"], {
|
|
81
|
+
encoding: "utf8",
|
|
82
|
+
env: { PATH: "/usr/sbin:/usr/bin:/sbin:/bin" },
|
|
83
|
+
timeout: 10_000,
|
|
84
|
+
});
|
|
85
|
+
if (listed.status !== 0) throw new Error("runtime firewall inventory failed");
|
|
86
|
+
let inventory;
|
|
87
|
+
try { inventory = JSON.parse(listed.stdout); } catch { throw new Error("runtime firewall inventory rejected"); }
|
|
88
|
+
const exists = Array.isArray(inventory?.nftables) && inventory.nftables.some((entry) =>
|
|
89
|
+
entry?.table?.family === "inet" && entry.table.name === policy.table);
|
|
90
|
+
const program = `${exists ? `delete table inet ${policy.table}\n` : ""}${policy.program}`;
|
|
91
|
+
const applied = spawnSync(contract.paths.nftPath, policy.applyArgs, {
|
|
92
|
+
input: program,
|
|
93
|
+
encoding: "utf8",
|
|
94
|
+
env: { PATH: "/usr/sbin:/usr/bin:/sbin:/bin" },
|
|
95
|
+
timeout: 10_000,
|
|
96
|
+
maxBuffer: 64 * 1024,
|
|
97
|
+
});
|
|
98
|
+
if (applied.status !== 0 || SECRET.test(`${applied.stdout}${applied.stderr}`)) {
|
|
99
|
+
throw new Error("runtime firewall apply failed");
|
|
100
|
+
}
|
|
101
|
+
const observed = spawnSync(contract.paths.nftPath, ["list", "table", "inet", policy.table], {
|
|
102
|
+
encoding: "utf8",
|
|
103
|
+
env: { PATH: "/usr/sbin:/usr/bin:/sbin:/bin" },
|
|
104
|
+
timeout: 10_000,
|
|
105
|
+
});
|
|
106
|
+
if (observed.status !== 0 || !runtimeBoundaryNftRulesMatch(contract, observed.stdout)) {
|
|
107
|
+
throw new Error("runtime firewall readback failed");
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function run() {
|
|
112
|
+
if (process.argv.length !== 2 || process.getuid?.() !== 0 || process.getgid?.() !== 0) {
|
|
113
|
+
throw new Error("runtime launcher identity rejected");
|
|
114
|
+
}
|
|
115
|
+
for (const fd of [8, 9]) {
|
|
116
|
+
const link = lstatSync(`/proc/self/fd/${fd}`);
|
|
117
|
+
if (!link.isSymbolicLink()) throw new Error("runtime Slack descriptor rejected");
|
|
118
|
+
}
|
|
119
|
+
const contract = fixedRootConfig();
|
|
120
|
+
if (!inspectRuntimeBoundaryInputs(contract).ok) throw new Error("runtime boundary inputs rejected");
|
|
121
|
+
provisionCgroup(contract);
|
|
122
|
+
provisionFirewall(contract);
|
|
123
|
+
const spec = buildRuntimeBoundaryLaunchSpec(contract);
|
|
124
|
+
if (!spec.ok) throw new Error("runtime launch spec rejected");
|
|
125
|
+
const stdio = Array(10).fill("ignore");
|
|
126
|
+
stdio[1] = "inherit";
|
|
127
|
+
stdio[2] = "inherit";
|
|
128
|
+
stdio[8] = 8;
|
|
129
|
+
stdio[9] = 9;
|
|
130
|
+
const child = spawn(spec.command, spec.args, {
|
|
131
|
+
env: spec.env,
|
|
132
|
+
shell: false,
|
|
133
|
+
stdio,
|
|
134
|
+
});
|
|
135
|
+
const forwardTerm = () => { if (!child.killed) child.kill("SIGTERM"); };
|
|
136
|
+
const forwardInt = () => { if (!child.killed) child.kill("SIGINT"); };
|
|
137
|
+
process.on("SIGTERM", forwardTerm);
|
|
138
|
+
process.on("SIGINT", forwardInt);
|
|
139
|
+
const exitCode = await new Promise((resolveExit, rejectExit) => {
|
|
140
|
+
child.once("error", rejectExit);
|
|
141
|
+
child.once("exit", (code, signal) => resolveExit(code ?? (signal ? 1 : 0)));
|
|
142
|
+
});
|
|
143
|
+
process.exitCode = exitCode;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
try {
|
|
147
|
+
await run();
|
|
148
|
+
} catch {
|
|
149
|
+
process.stderr.write("sellable-agent runtime boundary failed closed\n");
|
|
150
|
+
process.exitCode = 1;
|
|
151
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import { lstatSync, readFileSync } from "node:fs";
|
|
5
|
+
import { createInterface } from "node:readline";
|
|
6
|
+
|
|
7
|
+
const SAFE_ID = /^[A-Za-z0-9_-]{1,128}$/;
|
|
8
|
+
const profileIndex = process.argv.indexOf("--profile-id");
|
|
9
|
+
const profileId = profileIndex >= 0 ? process.argv[profileIndex + 1] : null;
|
|
10
|
+
|
|
11
|
+
function requiredEnv(name) {
|
|
12
|
+
const value = process.env[name]?.trim();
|
|
13
|
+
if (!value) throw new Error(`missing ${name}`);
|
|
14
|
+
return value;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function withClient(lockWorkspaceId, run) {
|
|
18
|
+
const command = requiredEnv("SELLABLE_AGENT_MCP_COMMAND");
|
|
19
|
+
if (command !== "/usr/local/lib/sellable-agent/external-runtime/mcp/bin/sellable-mcp") {
|
|
20
|
+
throw new Error("MCP command pin rejected");
|
|
21
|
+
}
|
|
22
|
+
const env = {
|
|
23
|
+
PATH: "/usr/local/bin:/usr/bin:/bin",
|
|
24
|
+
HOME: requiredEnv("HOME"),
|
|
25
|
+
NODE_ENV: "production",
|
|
26
|
+
SELLABLE_CONFIG_PATH: requiredEnv("SELLABLE_CONFIG_PATH"),
|
|
27
|
+
SELLABLE_AGENT_RUNTIME_SECRET_ROOT: requiredEnv("SELLABLE_AGENT_RUNTIME_SECRET_ROOT"),
|
|
28
|
+
SELLABLE_AGENT_PROFILE_ID: profileId,
|
|
29
|
+
SELLABLE_AGENT_SERVICE_CREDENTIAL_FILE: requiredEnv("SELLABLE_AGENT_SERVICE_CREDENTIAL_FILE"),
|
|
30
|
+
SELLABLE_AGENT_WORKSPACE_ID: requiredEnv("SELLABLE_AGENT_WORKSPACE_ID"),
|
|
31
|
+
SELLABLE_AGENT_ID: requiredEnv("SELLABLE_AGENT_ID"),
|
|
32
|
+
SELLABLE_AGENT_CREDENTIAL_GENERATION: requiredEnv("SELLABLE_AGENT_CREDENTIAL_GENERATION"),
|
|
33
|
+
SELLABLE_AGENT_POLICY_HASH: requiredEnv("SELLABLE_AGENT_POLICY_HASH"),
|
|
34
|
+
SELLABLE_AGENT_SELECTED_CHANNEL_IDS: requiredEnv("SELLABLE_AGENT_SELECTED_CHANNEL_IDS"),
|
|
35
|
+
SELLABLE_LOCK_WORKSPACE_ID: lockWorkspaceId,
|
|
36
|
+
SELLABLE_REQUIRE_WORKSPACE_LOCK: "1",
|
|
37
|
+
};
|
|
38
|
+
const child = spawn(command, [], { env, shell: false, stdio: ["pipe", "pipe", "ignore"] });
|
|
39
|
+
const pending = new Map();
|
|
40
|
+
let nextId = 1;
|
|
41
|
+
let failed = null;
|
|
42
|
+
const lines = createInterface({ input: child.stdout, crlfDelay: Infinity });
|
|
43
|
+
const failAll = (error) => {
|
|
44
|
+
failed = error;
|
|
45
|
+
for (const waiter of pending.values()) waiter.reject(error);
|
|
46
|
+
pending.clear();
|
|
47
|
+
};
|
|
48
|
+
lines.on("line", (line) => {
|
|
49
|
+
try {
|
|
50
|
+
const message = JSON.parse(line);
|
|
51
|
+
if (!Number.isSafeInteger(message.id) || !pending.has(message.id)) return;
|
|
52
|
+
const waiter = pending.get(message.id);
|
|
53
|
+
pending.delete(message.id);
|
|
54
|
+
if (message.error) waiter.reject(new Error("MCP request rejected"));
|
|
55
|
+
else waiter.resolve(message.result);
|
|
56
|
+
} catch {
|
|
57
|
+
failAll(new Error("MCP response rejected"));
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
child.once("error", failAll);
|
|
61
|
+
child.once("exit", () => failAll(new Error("MCP process exited")));
|
|
62
|
+
const request = (method, params = {}) => new Promise((resolveRequest, rejectRequest) => {
|
|
63
|
+
if (failed) return rejectRequest(failed);
|
|
64
|
+
const id = nextId++;
|
|
65
|
+
const timer = setTimeout(() => {
|
|
66
|
+
pending.delete(id);
|
|
67
|
+
rejectRequest(new Error("MCP request timed out"));
|
|
68
|
+
}, 10_000);
|
|
69
|
+
pending.set(id, {
|
|
70
|
+
resolve(value) { clearTimeout(timer); resolveRequest(value); },
|
|
71
|
+
reject(error) { clearTimeout(timer); rejectRequest(error); },
|
|
72
|
+
});
|
|
73
|
+
child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id, method, params })}\n`);
|
|
74
|
+
});
|
|
75
|
+
const notify = (method, params = {}) => {
|
|
76
|
+
child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", method, params })}\n`);
|
|
77
|
+
};
|
|
78
|
+
const initialized = await request("initialize", {
|
|
79
|
+
protocolVersion: "2025-06-18",
|
|
80
|
+
capabilities: {},
|
|
81
|
+
clientInfo: { name: "sellable-agent-runtime-probe", version: "1.0.0" },
|
|
82
|
+
});
|
|
83
|
+
if (!initialized?.serverInfo) throw new Error("MCP initialization rejected");
|
|
84
|
+
notify("notifications/initialized");
|
|
85
|
+
const client = {
|
|
86
|
+
listTools: () => request("tools/list", {}),
|
|
87
|
+
callTool: (params) => request("tools/call", params),
|
|
88
|
+
};
|
|
89
|
+
try {
|
|
90
|
+
return await run(client);
|
|
91
|
+
} finally {
|
|
92
|
+
lines.close();
|
|
93
|
+
child.stdin.end();
|
|
94
|
+
child.kill("SIGTERM");
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function toolDenied(client, name, channelId, requesterId, providerRequestId) {
|
|
99
|
+
try {
|
|
100
|
+
const result = await client.callTool({
|
|
101
|
+
name,
|
|
102
|
+
arguments: {},
|
|
103
|
+
_meta: { sellableAgent: { channelId, requesterId, providerRequestId } },
|
|
104
|
+
}, undefined, { timeout: 10_000, maxTotalTimeout: 10_000 });
|
|
105
|
+
return result?.isError === true;
|
|
106
|
+
} catch {
|
|
107
|
+
return true;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
try {
|
|
112
|
+
if (!SAFE_ID.test(profileId ?? "") || process.argv.length !== 4) throw new Error("probe arguments rejected");
|
|
113
|
+
const configPath = requiredEnv("SELLABLE_CONFIG_PATH");
|
|
114
|
+
const link = lstatSync(configPath);
|
|
115
|
+
if (link.isSymbolicLink() || !link.isFile() || (link.mode & 0o077) !== 0) throw new Error("profile config rejected");
|
|
116
|
+
const config = JSON.parse(readFileSync(configPath, "utf8"));
|
|
117
|
+
const workspaceId = requiredEnv("SELLABLE_AGENT_WORKSPACE_ID");
|
|
118
|
+
const channelIds = config.requestChannelPolicy?.selectedChannelIds;
|
|
119
|
+
const requesterIds = config.requestChannelPolicy?.memberAllowlist;
|
|
120
|
+
const tools = config.tools?.include;
|
|
121
|
+
if (
|
|
122
|
+
config.workspaceLockId !== workspaceId || !Array.isArray(channelIds) || channelIds.length === 0 ||
|
|
123
|
+
!Array.isArray(requesterIds) || requesterIds.length === 0 || !Array.isArray(tools) ||
|
|
124
|
+
!tools.includes("get_company_info")
|
|
125
|
+
) throw new Error("profile MCP contract rejected");
|
|
126
|
+
const channelId = channelIds[0];
|
|
127
|
+
const requesterId = requesterIds[0];
|
|
128
|
+
const providerRequestId = `runtime-probe-${profileId}`;
|
|
129
|
+
const main = await withClient(workspaceId, async (client) => {
|
|
130
|
+
const listed = (await client.listTools(undefined, { timeout: 10_000, maxTotalTimeout: 10_000 })).tools
|
|
131
|
+
.map((tool) => tool.name).sort();
|
|
132
|
+
const safe = await client.callTool({
|
|
133
|
+
name: "get_company_info",
|
|
134
|
+
arguments: {},
|
|
135
|
+
_meta: { sellableAgent: { channelId, requesterId, providerRequestId } },
|
|
136
|
+
}, undefined, { timeout: 10_000, maxTotalTimeout: 10_000 });
|
|
137
|
+
return {
|
|
138
|
+
tools: listed,
|
|
139
|
+
safeRead: safe?.isError !== true,
|
|
140
|
+
unexpectedToolDenied: await toolDenied(client, "__sellable_unapproved_probe__", channelId, requesterId, providerRequestId),
|
|
141
|
+
wrongChannelDenied: await toolDenied(client, "get_company_info", "C0000000000", requesterId, providerRequestId),
|
|
142
|
+
};
|
|
143
|
+
});
|
|
144
|
+
const wrongWorkspaceDenied = await withClient(`wrong-${workspaceId}`, (client) =>
|
|
145
|
+
toolDenied(client, "get_company_info", channelId, requesterId, providerRequestId));
|
|
146
|
+
const ok =
|
|
147
|
+
main.safeRead && main.unexpectedToolDenied && main.wrongChannelDenied && wrongWorkspaceDenied &&
|
|
148
|
+
JSON.stringify(main.tools) === JSON.stringify([...tools].sort());
|
|
149
|
+
process.stdout.write(`${JSON.stringify({
|
|
150
|
+
ok,
|
|
151
|
+
profileId,
|
|
152
|
+
workspaceId,
|
|
153
|
+
tools: main.tools,
|
|
154
|
+
safeRead: main.safeRead,
|
|
155
|
+
unexpectedToolDenied: main.unexpectedToolDenied,
|
|
156
|
+
wrongChannelDenied: main.wrongChannelDenied,
|
|
157
|
+
wrongWorkspaceDenied,
|
|
158
|
+
})}\n`);
|
|
159
|
+
if (!ok) process.exitCode = 1;
|
|
160
|
+
} catch {
|
|
161
|
+
process.stdout.write(`${JSON.stringify({ ok: false, code: "runtime_probe_rejected" })}\n`);
|
|
162
|
+
process.exitCode = 1;
|
|
163
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import { closeSync, constants, fstatSync, openSync, unlinkSync } from "node:fs";
|
|
5
|
+
|
|
6
|
+
const APP_PATH = "/run/bootstrap/slack-app";
|
|
7
|
+
const BOT_PATH = "/run/bootstrap/slack-bot";
|
|
8
|
+
const SAFE_ID = /^[A-Za-z0-9_-]{1,128}$/;
|
|
9
|
+
|
|
10
|
+
function required(name) {
|
|
11
|
+
const value = process.env[name];
|
|
12
|
+
if (typeof value !== "string" || !value) throw new Error(`missing ${name}`);
|
|
13
|
+
return value;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function identity(name) {
|
|
17
|
+
const value = required(name);
|
|
18
|
+
if (!/^[1-9][0-9]{0,9}$/.test(value)) throw new Error("runtime identity rejected");
|
|
19
|
+
return value;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function inheritedSecret(path) {
|
|
23
|
+
const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
24
|
+
const info = fstatSync(fd);
|
|
25
|
+
if (!info.isFile() || info.uid !== 0 || info.gid !== 0 || (info.mode & 0o777) !== 0o600 || info.size < 6 || info.size > 4096) {
|
|
26
|
+
closeSync(fd);
|
|
27
|
+
throw new Error("runtime Slack descriptor source rejected");
|
|
28
|
+
}
|
|
29
|
+
unlinkSync(path);
|
|
30
|
+
return fd;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function run() {
|
|
34
|
+
if (process.argv.length !== 2 || process.getuid?.() !== 0 || process.getgid?.() !== 0) {
|
|
35
|
+
throw new Error("sandbox init identity rejected");
|
|
36
|
+
}
|
|
37
|
+
const uid = identity("SELLABLE_AGENT_RUNTIME_UID");
|
|
38
|
+
const gid = identity("SELLABLE_AGENT_RUNTIME_GID");
|
|
39
|
+
const profileId = required("SELLABLE_AGENT_PROFILE_ID");
|
|
40
|
+
if (!SAFE_ID.test(profileId)) throw new Error("sandbox profile rejected");
|
|
41
|
+
const hermes = required("SELLABLE_AGENT_HERMES_EXECUTABLE");
|
|
42
|
+
const setpriv = required("SELLABLE_AGENT_SETPRIV_EXECUTABLE");
|
|
43
|
+
if (!hermes.startsWith("/") || !setpriv.startsWith("/") || setpriv !== "/usr/bin/setpriv") {
|
|
44
|
+
throw new Error("sandbox executable rejected");
|
|
45
|
+
}
|
|
46
|
+
const appFd = inheritedSecret(APP_PATH);
|
|
47
|
+
const botFd = inheritedSecret(BOT_PATH);
|
|
48
|
+
try {
|
|
49
|
+
const args = [
|
|
50
|
+
"--reuid", uid,
|
|
51
|
+
"--regid", gid,
|
|
52
|
+
"--clear-groups",
|
|
53
|
+
"--no-new-privs",
|
|
54
|
+
"--bounding-set=-all",
|
|
55
|
+
"--inh-caps=-all",
|
|
56
|
+
"--ambient-caps=-all",
|
|
57
|
+
hermes,
|
|
58
|
+
"--profile", profileId,
|
|
59
|
+
"gateway", "run",
|
|
60
|
+
];
|
|
61
|
+
const stdio = Array(10).fill("ignore");
|
|
62
|
+
stdio[1] = "inherit";
|
|
63
|
+
stdio[2] = "inherit";
|
|
64
|
+
stdio[8] = appFd;
|
|
65
|
+
stdio[9] = botFd;
|
|
66
|
+
const child = spawn(setpriv, args, {
|
|
67
|
+
env: process.env,
|
|
68
|
+
shell: false,
|
|
69
|
+
stdio,
|
|
70
|
+
});
|
|
71
|
+
closeSync(appFd);
|
|
72
|
+
closeSync(botFd);
|
|
73
|
+
const forwardTerm = () => { if (!child.killed) child.kill("SIGTERM"); };
|
|
74
|
+
const forwardInt = () => { if (!child.killed) child.kill("SIGINT"); };
|
|
75
|
+
process.once("SIGTERM", forwardTerm);
|
|
76
|
+
process.once("SIGINT", forwardInt);
|
|
77
|
+
process.exitCode = await new Promise((resolveExit, rejectExit) => {
|
|
78
|
+
child.once("error", rejectExit);
|
|
79
|
+
child.once("exit", (code, signal) => resolveExit(code ?? (signal ? 1 : 0)));
|
|
80
|
+
});
|
|
81
|
+
} catch (error) {
|
|
82
|
+
try { closeSync(appFd); } catch {}
|
|
83
|
+
try { closeSync(botFd); } catch {}
|
|
84
|
+
throw error;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
await run();
|
|
90
|
+
} catch {
|
|
91
|
+
process.stderr.write("sellable-agent sandbox init failed closed\n");
|
|
92
|
+
process.exitCode = 1;
|
|
93
|
+
}
|
package/bin/sellable-install.mjs
CHANGED
|
@@ -401,6 +401,8 @@ function parseArgs(argv) {
|
|
|
401
401
|
if (opts.sellableConfigPath && !opts.sellableConfigsDir) {
|
|
402
402
|
opts.sellableConfigsDir = join(dirname(opts.sellableConfigPath), "configs");
|
|
403
403
|
}
|
|
404
|
+
opts.lockWorkspaceId = opts.workspaceId;
|
|
405
|
+
opts.requireWorkspaceLock = Boolean(opts.workspaceId);
|
|
404
406
|
|
|
405
407
|
return opts;
|
|
406
408
|
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
ARG HERMES_IMAGE=ghcr.io/hostinger/hvps-hermes-agent@sha256:f048663be9ba564de795df5f2a8184f32b92561a1f053c179dca3b45fa5bc8b9
|
|
2
|
+
FROM ${HERMES_IMAGE}
|
|
3
|
+
|
|
4
|
+
USER root
|
|
5
|
+
|
|
6
|
+
ARG INSTALLER_PACKAGE=@sellable/install@0.1.360
|
|
7
|
+
ARG MCP_PACKAGE=@sellable/mcp@0.1.620
|
|
8
|
+
ARG INSTALLER_INTEGRITY
|
|
9
|
+
ARG MCP_INTEGRITY=sha512-N+2clnLHWAlvoXCjX10HaeEUMPLqZQHIX+x/2e1CSJbqb56CU1SAtVdwmhXyha7+MQT0DZF7pm2+WnoZKFEzOw==
|
|
10
|
+
|
|
11
|
+
RUN test "${INSTALLER_PACKAGE}" = "@sellable/install@0.1.360" \
|
|
12
|
+
&& test "${MCP_PACKAGE}" = "@sellable/mcp@0.1.620" \
|
|
13
|
+
&& test -n "${INSTALLER_INTEGRITY}" \
|
|
14
|
+
&& test "$(npm view "${INSTALLER_PACKAGE}" dist.integrity)" = "${INSTALLER_INTEGRITY}" \
|
|
15
|
+
&& test "$(npm view "${MCP_PACKAGE}" dist.integrity)" = "${MCP_INTEGRITY}" \
|
|
16
|
+
&& apt-get update \
|
|
17
|
+
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
|
18
|
+
bubblewrap \
|
|
19
|
+
ca-certificates \
|
|
20
|
+
nftables \
|
|
21
|
+
sudo \
|
|
22
|
+
&& rm -rf /var/lib/apt/lists/* \
|
|
23
|
+
&& mkdir -p /opt/sellable-agent/install-root /opt/sellable-agent/mcp-root \
|
|
24
|
+
&& npm install --prefix /opt/sellable-agent/install-root --omit=dev --ignore-scripts --no-audit --no-fund "${INSTALLER_PACKAGE}" \
|
|
25
|
+
&& npm install --prefix /opt/sellable-agent/mcp-root --omit=dev --ignore-scripts --no-audit --no-fund "${MCP_PACKAGE}" \
|
|
26
|
+
&& node --input-type=module -e "import { readFileSync } from 'node:fs'; const install = JSON.parse(readFileSync('/opt/sellable-agent/install-root/node_modules/@sellable/install/package.json')); const mcp = JSON.parse(readFileSync('/opt/sellable-agent/mcp-root/node_modules/@sellable/mcp/package.json')); if (install.name !== '@sellable/install' || install.version !== '0.1.360' || mcp.name !== '@sellable/mcp' || mcp.version !== '0.1.620') throw new Error('package identity rejected');" \
|
|
27
|
+
&& node --input-type=module -e "import { buildExternalRuntimeClosure } from '/opt/sellable-agent/install-root/node_modules/@sellable/install/lib/sellable-agent/external-runtime-builder.mjs'; const built = buildExternalRuntimeClosure({ mcpPackageRoot: '/opt/sellable-agent/mcp-root/node_modules/@sellable/mcp', mcpNodeModulesRoot: '/opt/sellable-agent/mcp-root/node_modules', hermesSourceRoot: '/opt/hermes', ownerUid: 0, ownerGid: 0 }); if (!built.closureDigest) throw new Error('runtime closure rejected');" \
|
|
28
|
+
&& node --input-type=module -e "import { chmodSync, writeFileSync } from 'node:fs'; const release = { installerPackage: '@sellable/install@0.1.360', installerIntegrity: process.argv[1], mcpPackage: '@sellable/mcp@0.1.620', mcpIntegrity: process.argv[2] }; writeFileSync('/opt/sellable-agent/release.json', JSON.stringify(release) + '\\n', { mode: 0o444 }); chmodSync('/opt/sellable-agent/release.json', 0o444);" "${INSTALLER_INTEGRITY}" "${MCP_INTEGRITY}" \
|
|
29
|
+
&& npm cache clean --force
|
|
30
|
+
|
|
31
|
+
COPY entrypoint.sh /usr/local/bin/sellable-agent-container-entrypoint
|
|
32
|
+
RUN test -x /usr/local/bin/node \
|
|
33
|
+
&& test ! -e /usr/bin/node \
|
|
34
|
+
&& ln -s /usr/local/bin/node /usr/bin/node \
|
|
35
|
+
&& test "$(readlink -f /usr/bin/node)" = "/usr/local/bin/node" \
|
|
36
|
+
&& chmod 0555 /usr/local/bin/sellable-agent-container-entrypoint
|
|
37
|
+
|
|
38
|
+
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
|
|
39
|
+
CMD /command/s6-svstat /etc/services.d/sellable-agent-host-worker | grep -q '^up' || exit 1
|
|
40
|
+
|
|
41
|
+
ENTRYPOINT ["/usr/local/bin/sellable-agent-container-entrypoint"]
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Sellable Agent host container
|
|
2
|
+
|
|
3
|
+
This image is a dedicated Agent worker/runtime boundary beside an existing Hermes dashboard. It never replaces or reconfigures the dashboard container.
|
|
4
|
+
|
|
5
|
+
Build from this directory after `@sellable/install@0.1.360` is published:
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
docker build --platform linux/amd64 \
|
|
9
|
+
--build-arg INSTALLER_INTEGRITY='sha512-…' \
|
|
10
|
+
--tag sellable-agent-host:0.1.360 .
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Create a private `bootstrap/` directory owned by root with mode `0700`. It must contain `bootstrap.json`, `provider-auth`, and `slack-app`, each owned by root with mode `0600`. `bootstrap.json` contains only non-secret identities, hashes, package integrities, and the control-plane URL. The other two files contain the provider JSON and global Slack app token whose hashes are pinned by `bootstrap.json`.
|
|
14
|
+
|
|
15
|
+
Set `SELLABLE_AGENT_IMAGE` to the exact built image digest and start `compose.yaml`. The container bootstraps idempotently, writes `bootstrap/registration-receipt.json`, and starts the worker and isolated runtime under s6. Do not mount a Docker socket.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
services:
|
|
2
|
+
sellable-agent-host:
|
|
3
|
+
image: ${SELLABLE_AGENT_IMAGE:?set exact Sellable Agent image}
|
|
4
|
+
container_name: ${SELLABLE_AGENT_CONTAINER_NAME:-sellable-agent-host-11201}
|
|
5
|
+
hostname: ${SELLABLE_AGENT_HOSTNAME:-hermes-hostinger-1807396}
|
|
6
|
+
restart: unless-stopped
|
|
7
|
+
cap_add:
|
|
8
|
+
- NET_ADMIN
|
|
9
|
+
- SYS_ADMIN
|
|
10
|
+
cgroup: host
|
|
11
|
+
pid: host
|
|
12
|
+
volumes:
|
|
13
|
+
- ./bootstrap:/var/lib/sellable-agent-bootstrap:rw
|
|
14
|
+
- sellable-agent-worker:/var/lib/sellable-agent-worker
|
|
15
|
+
- sellable-agent-runtime:/var/lib/sellable-agent-runtime
|
|
16
|
+
- sellable-agent-profiles:/var/lib/sellable-agent-profiles
|
|
17
|
+
- /sys/fs/cgroup:/sys/fs/cgroup:rw
|
|
18
|
+
|
|
19
|
+
volumes:
|
|
20
|
+
sellable-agent-worker:
|
|
21
|
+
sellable-agent-runtime:
|
|
22
|
+
sellable-agent-profiles:
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
set -eu
|
|
3
|
+
|
|
4
|
+
PATH="/command:${PATH}"
|
|
5
|
+
export PATH
|
|
6
|
+
|
|
7
|
+
BOOTSTRAP_CONFIG=/var/lib/sellable-agent-bootstrap/bootstrap.json
|
|
8
|
+
BOOTSTRAP_BIN=/opt/sellable-agent/install-root/node_modules/.bin/sellable-agent-host-bootstrap
|
|
9
|
+
|
|
10
|
+
test "$(id -u)" = "0"
|
|
11
|
+
test "$(id -g)" = "0"
|
|
12
|
+
test -x "${BOOTSTRAP_BIN}"
|
|
13
|
+
test -f "${BOOTSTRAP_CONFIG}"
|
|
14
|
+
|
|
15
|
+
"${BOOTSTRAP_BIN}" --config "${BOOTSTRAP_CONFIG}"
|
|
16
|
+
exec /command/s6-svscan /etc/services.d
|