promigence 0.1.0-beta.12
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/NOTICE +14 -0
- package/README.md +88 -0
- package/dist/account.js +850 -0
- package/dist/agent.js +374 -0
- package/dist/argv.js +159 -0
- package/dist/bin.js +332 -0
- package/dist/bundle.js +133 -0
- package/dist/capacity.js +69 -0
- package/dist/commands.d.ts +110 -0
- package/dist/commands.js +2675 -0
- package/dist/connectreq.js +125 -0
- package/dist/credfile.js +188 -0
- package/dist/credits.js +197 -0
- package/dist/devcontainer.d.ts +99 -0
- package/dist/devcontainer.js +744 -0
- package/dist/device.js +129 -0
- package/dist/dirsnap.js +378 -0
- package/dist/errfix.js +262 -0
- package/dist/generated.d.ts +35 -0
- package/dist/generated.js +307 -0
- package/dist/github.js +307 -0
- package/dist/gitlab.js +210 -0
- package/dist/guide.d.ts +19 -0
- package/dist/guide.js +366 -0
- package/dist/handlers.js +511 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +7 -0
- package/dist/init.d.ts +40 -0
- package/dist/init.js +352 -0
- package/dist/install.d.ts +23 -0
- package/dist/install.js +127 -0
- package/dist/io.d.ts +37 -0
- package/dist/io.js +318 -0
- package/dist/live.js +469 -0
- package/dist/login.js +1063 -0
- package/dist/mcp.js +889 -0
- package/dist/money.js +5 -0
- package/dist/orgs.js +475 -0
- package/dist/pricing.d.ts +52 -0
- package/dist/pricing.js +181 -0
- package/dist/report.js +132 -0
- package/dist/routes.js +135 -0
- package/dist/runners-github.js +257 -0
- package/dist/runners-gitlab.js +125 -0
- package/dist/runners-spec.js +137 -0
- package/dist/runners.js +1458 -0
- package/dist/sdk/blake3.d.ts +10 -0
- package/dist/sdk/blake3.js +111 -0
- package/dist/sdk/bundle.d.ts +96 -0
- package/dist/sdk/bundle.js +227 -0
- package/dist/sdk/ca-guard.js +166 -0
- package/dist/sdk/caller.d.ts +73 -0
- package/dist/sdk/caller.js +47 -0
- package/dist/sdk/client.d.ts +3015 -0
- package/dist/sdk/client.js +2583 -0
- package/dist/sdk/command-errors.d.ts +41 -0
- package/dist/sdk/command-errors.js +36 -0
- package/dist/sdk/context.d.ts +119 -0
- package/dist/sdk/context.js +588 -0
- package/dist/sdk/core.d.ts +285 -0
- package/dist/sdk/core.js +266 -0
- package/dist/sdk/credstore.d.ts +90 -0
- package/dist/sdk/credstore.js +206 -0
- package/dist/sdk/dispatcher.js +86 -0
- package/dist/sdk/github.d.ts +158 -0
- package/dist/sdk/github.js +78 -0
- package/dist/sdk/gitlab.d.ts +104 -0
- package/dist/sdk/gitlab.js +20 -0
- package/dist/sdk/handles.d.ts +82 -0
- package/dist/sdk/handles.js +135 -0
- package/dist/sdk/hint.js +7 -0
- package/dist/sdk/http2.js +1921 -0
- package/dist/sdk/index.d.ts +34 -0
- package/dist/sdk/index.js +15 -0
- package/dist/sdk/keyed.gen.js +7 -0
- package/dist/sdk/models.gen.d.ts +6162 -0
- package/dist/sdk/models.gen.js +1 -0
- package/dist/sdk/proxy.js +249 -0
- package/dist/sdk/pty.d.ts +171 -0
- package/dist/sdk/pty.js +496 -0
- package/dist/sdk/sandbox.d.ts +537 -0
- package/dist/sdk/sandbox.js +725 -0
- package/dist/sdk/ssh.d.ts +25 -0
- package/dist/sdk/ssh.js +114 -0
- package/dist/sdk/trace.js +80 -0
- package/dist/sdk/transport-worker.js +466 -0
- package/dist/sdk/warm.js +40 -0
- package/dist/ssh.js +267 -0
- package/dist/surface.d.ts +95 -0
- package/dist/surface.js +1452 -0
- package/dist/templates.js +53 -0
- package/dist/term.js +50 -0
- package/dist/timing.js +71 -0
- package/dist/untar.js +324 -0
- package/dist/uploadguard.js +75 -0
- package/llms.txt +209 -0
- package/package.json +52 -0
package/dist/mcp.js
ADDED
|
@@ -0,0 +1,889 @@
|
|
|
1
|
+
import { posix } from "node:path";
|
|
2
|
+
import { DEFAULT_SNAPSHOT, PREVIEWS_UNAVAILABLE, PromigenceClient, PromigenceError, Sandbox, previewsFix, readCredentials, resolveSpendCap, } from "./sdk/index.js";
|
|
3
|
+
import { forgetKeychainReads } from "./sdk/credstore.js";
|
|
4
|
+
import { bool, clientOpts, execOutcome, outputTails, str } from "./commands.js";
|
|
5
|
+
import { buildPrice, livePricing } from "./pricing.js";
|
|
6
|
+
import { emit, errorEnvelope, errorTextLines, exitWith, forkSummary, noticeLine, parseUsd, toPromigenceError, usageError, write, } from "./io.js";
|
|
7
|
+
import { VERSION } from "./surface.js";
|
|
8
|
+
import { sh, shellArg } from "./term.js";
|
|
9
|
+
export const MCP_PROTOCOL_VERSIONS = ["2025-06-18", "2025-03-26", "2024-11-05"];
|
|
10
|
+
export const MCP_OUTPUT_CAP = 16 * 1024;
|
|
11
|
+
const READ_DEFAULT = 64 * 1024;
|
|
12
|
+
const READ_CEILING = 1024 * 1024;
|
|
13
|
+
const WAIT_DEFAULT_S = 100;
|
|
14
|
+
const WAIT_CEILING_S = 600;
|
|
15
|
+
const SIGNAL_GRACE_MS = 3_000;
|
|
16
|
+
const PARSE_ERROR = -32700;
|
|
17
|
+
const INVALID_REQUEST = -32600;
|
|
18
|
+
const METHOD_NOT_FOUND = -32601;
|
|
19
|
+
const INVALID_PARAMS = -32602;
|
|
20
|
+
function check(schema, args) {
|
|
21
|
+
for (const k of schema.required ?? []) {
|
|
22
|
+
if (args[k] === undefined)
|
|
23
|
+
return `missing required argument '${k}'`;
|
|
24
|
+
}
|
|
25
|
+
for (const [k, v] of Object.entries(args)) {
|
|
26
|
+
const p = schema.properties[k];
|
|
27
|
+
if (!p)
|
|
28
|
+
return `unknown argument '${k}'`;
|
|
29
|
+
if (v === undefined || v === null)
|
|
30
|
+
continue;
|
|
31
|
+
switch (p.type) {
|
|
32
|
+
case "string":
|
|
33
|
+
if (typeof v !== "string")
|
|
34
|
+
return `'${k}' must be a string`;
|
|
35
|
+
if (p.enum && !p.enum.includes(v))
|
|
36
|
+
return `'${k}' must be one of ${p.enum.join(" | ")}`;
|
|
37
|
+
if (p.pattern && !new RegExp(p.pattern).test(v))
|
|
38
|
+
return `'${k}' is not valid (${p.description})`;
|
|
39
|
+
break;
|
|
40
|
+
case "integer":
|
|
41
|
+
case "number":
|
|
42
|
+
if (typeof v !== "number" || !Number.isFinite(v))
|
|
43
|
+
return `'${k}' must be a number`;
|
|
44
|
+
if (p.type === "integer" && !Number.isInteger(v))
|
|
45
|
+
return `'${k}' must be an integer`;
|
|
46
|
+
if (p.minimum !== undefined && v < p.minimum)
|
|
47
|
+
return `'${k}' must be >= ${p.minimum}`;
|
|
48
|
+
if (p.maximum !== undefined && v > p.maximum)
|
|
49
|
+
return `'${k}' must be <= ${p.maximum}`;
|
|
50
|
+
break;
|
|
51
|
+
case "boolean":
|
|
52
|
+
if (typeof v !== "boolean")
|
|
53
|
+
return `'${k}' must be true or false`;
|
|
54
|
+
break;
|
|
55
|
+
case "array":
|
|
56
|
+
if (!Array.isArray(v) || v.some((x) => typeof x !== "string"))
|
|
57
|
+
return `'${k}' must be an array of strings`;
|
|
58
|
+
break;
|
|
59
|
+
case "object":
|
|
60
|
+
if (typeof v !== "object" || Array.isArray(v) || Object.values(v).some((x) => typeof x !== "string")) {
|
|
61
|
+
return `'${k}' must be an object of string values`;
|
|
62
|
+
}
|
|
63
|
+
break;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
export function insideRoot(root, path, tool) {
|
|
69
|
+
const p = posix.normalize(path);
|
|
70
|
+
if (!path.startsWith("/"))
|
|
71
|
+
throw usageError(`${tool}: ${path} is not an absolute path`, "Pass an absolute path inside the sandbox.");
|
|
72
|
+
if (root === undefined || root === "/" || p === root || p.startsWith(`${root}/`))
|
|
73
|
+
return p;
|
|
74
|
+
throw usageError(`${tool}: ${path} is outside ${root}, the only directory this server's file tools may use (--root)`, `Name a path under ${root}.`);
|
|
75
|
+
}
|
|
76
|
+
const sandboxId = { type: "string", description: "The sandbox_id, e.g. sbx_01 (from sandbox_create or sandbox_list)." };
|
|
77
|
+
const cwd = { type: "string", description: "Working directory inside the sandbox (absolute)." };
|
|
78
|
+
const env = { type: "object", description: "Environment variables for this command.", additionalProperties: { type: "string" } };
|
|
79
|
+
const network = { type: "string", description: "What the sandbox may reach: egress (default) or none.", enum: ["egress", "none"] };
|
|
80
|
+
const allowHosts = { type: "array", description: "Run behind an outbound allow-list of these hosts, e.g. [\"github.com\", \"*.npmjs.org\"]. Not combinable with network=none.", items: { type: "string" } };
|
|
81
|
+
const seconds = (what, extra = "") => ({ type: "integer", description: `${what} in seconds${extra}.`, minimum: 1 });
|
|
82
|
+
function tail(text, cap = MCP_OUTPUT_CAP) {
|
|
83
|
+
return text.length <= cap ? { text, cut: false } : { text: text.slice(text.length - cap), cut: true };
|
|
84
|
+
}
|
|
85
|
+
const secondsToMs = (s) => (typeof s === "number" ? Math.round(s * 1000) : undefined);
|
|
86
|
+
async function readTail(c, sid, eid, stream, have) {
|
|
87
|
+
const from = Math.max(0, have - MCP_OUTPUT_CAP);
|
|
88
|
+
const parts = [];
|
|
89
|
+
let at = from;
|
|
90
|
+
while (at < have) {
|
|
91
|
+
const r = await c.sandboxes.execs.read(sid, eid, stream, { offset: at, len: have - at });
|
|
92
|
+
if (r.data.length === 0)
|
|
93
|
+
break;
|
|
94
|
+
parts.push(r.data);
|
|
95
|
+
at += r.data.length;
|
|
96
|
+
}
|
|
97
|
+
return { text: Buffer.concat(parts).toString("utf8"), cut: from > 0 };
|
|
98
|
+
}
|
|
99
|
+
function execOutputPayload(rec, out, err) {
|
|
100
|
+
return {
|
|
101
|
+
exec_id: rec.exec_id,
|
|
102
|
+
sandbox_id: rec.sandbox_id,
|
|
103
|
+
state: rec.state,
|
|
104
|
+
exit_code: rec.exit?.exit_code ?? null,
|
|
105
|
+
signal: rec.exit?.signal ?? null,
|
|
106
|
+
stdout: out.text,
|
|
107
|
+
stderr: err.text,
|
|
108
|
+
truncated: out.cut || err.cut || Boolean(rec.truncated),
|
|
109
|
+
...(rec.error ? { error: rec.error } : {}),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
function reportSummary(report) {
|
|
113
|
+
const failed = (report.sandboxes ?? []).filter((s) => s.outcome !== "completed");
|
|
114
|
+
return {
|
|
115
|
+
run_id: report.run_id,
|
|
116
|
+
state: report.state,
|
|
117
|
+
...(report.snapshot_id ? { snapshot_id: report.snapshot_id } : {}),
|
|
118
|
+
totals: report.totals,
|
|
119
|
+
...(report.cost ? { cost: report.cost } : {}),
|
|
120
|
+
...(report.grade ? { grade: report.grade } : {}),
|
|
121
|
+
fork: forkSummary(report.fork),
|
|
122
|
+
failed: failed.slice(0, 20).map((s) => ({
|
|
123
|
+
sandbox_id: s.sandbox_id,
|
|
124
|
+
outcome: s.outcome,
|
|
125
|
+
...(s.failure ? { failure: s.failure } : {}),
|
|
126
|
+
...(s.execs?.length ? { exit_code: s.execs[s.execs.length - 1].exit_code } : {}),
|
|
127
|
+
})),
|
|
128
|
+
...(failed.length > 20 ? { failed_more: failed.length - 20 } : {}),
|
|
129
|
+
full_report: sh `promigence run report ${report.run_id} --json`,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
export const MCP_TOOLS = [
|
|
133
|
+
{
|
|
134
|
+
name: "sandbox_create",
|
|
135
|
+
description: "Create a sandbox from a Promigence snapshot (id or alias; default promigence/base: Debian with Python 3.12, Node 24, Go, Docker, git, gcc and the Claude Code, Codex, Gemini and OpenCode CLIs) or from a container image (a tag, which the API pins, or NAME@sha256:…), and return its sandbox_id.",
|
|
136
|
+
readOnly: false,
|
|
137
|
+
inputSchema: {
|
|
138
|
+
type: "object",
|
|
139
|
+
properties: {
|
|
140
|
+
snapshot: { type: "string", description: "snap_<id>, an alias, or alias@commit7. Default promigence/base. Not with image." },
|
|
141
|
+
image: { type: "string", description: "a tag such as python:3.12 (the API pins it to a digest) or registry/name@sha256:<64 hex>. A new image's snapshot is built on first use (minutes, billed per second while it builds at its tier's rate: \`promigence pricing\`) and reused free after." },
|
|
142
|
+
tier: { type: "string", description: "Machine size for an image's snapshot; a snapshot already has one.", enum: ["xsmall", "small", "medium", "large-16", "large"] },
|
|
143
|
+
timeout_s: seconds("Sandbox lifetime", "; it is killed when this expires (default 15 minutes)"),
|
|
144
|
+
network,
|
|
145
|
+
allow_hosts: allowHosts,
|
|
146
|
+
env: { type: "object", description: "Environment variables applied to every command run in this sandbox.", additionalProperties: { type: "string" } },
|
|
147
|
+
cap_usd: { type: "number", description: "Spend cap in USD for this sandbox, a new image's build included; refused above it, nothing starts. Cannot exceed the server's --cap.", minimum: 0 },
|
|
148
|
+
},
|
|
149
|
+
additionalProperties: false,
|
|
150
|
+
},
|
|
151
|
+
async run(a, s) {
|
|
152
|
+
if (a["snapshot"] !== undefined && a["image"] !== undefined)
|
|
153
|
+
throw usageError("sandbox_create takes snapshot or image, not both", "Pass one of them.");
|
|
154
|
+
if (a["tier"] !== undefined && a["image"] === undefined)
|
|
155
|
+
throw usageError("tier applies to image only: a snapshot already has a tier", "Drop tier, or pass image.");
|
|
156
|
+
if (a["network"] === "none" && Array.isArray(a["allow_hosts"]) && a["allow_hosts"].length) {
|
|
157
|
+
throw usageError("allow_hosts cannot be combined with network=none", "Pass allow_hosts on its own: it already runs the sandbox behind an outbound allow-list.");
|
|
158
|
+
}
|
|
159
|
+
const asked = typeof a["cap_usd"] === "number" ? a["cap_usd"] : undefined;
|
|
160
|
+
const capped = asked !== undefined && s.cap !== undefined ? Math.min(asked, s.cap) : asked ?? s.cap;
|
|
161
|
+
const build = typeof a["image"] === "string" && capped !== undefined
|
|
162
|
+
? (await buildPrice(s.client(), typeof a["tier"] === "string" ? a["tier"] : undefined)).most_usd
|
|
163
|
+
: 0;
|
|
164
|
+
let runCost = 0;
|
|
165
|
+
if (typeof a["image"] === "string" && capped !== undefined) {
|
|
166
|
+
const p = await livePricing(s.client());
|
|
167
|
+
const rate = p.tiers.find((t) => t.name === (typeof a["tier"] === "string" ? a["tier"] : "small"))?.per_second_usd ?? 0;
|
|
168
|
+
const seconds = Math.ceil((secondsToMs(a["timeout_s"]) ?? (p.view?.limits.default_timeout_s ?? 900) * 1000) / 1000);
|
|
169
|
+
runCost = rate * seconds;
|
|
170
|
+
}
|
|
171
|
+
if (typeof a["image"] === "string" && capped !== undefined && capped < build + runCost) {
|
|
172
|
+
const which = asked !== undefined && asked === capped ? "cap_usd" : "this server's --cap";
|
|
173
|
+
const needed = (Math.ceil((build + runCost) * 100) / 100).toFixed(2);
|
|
174
|
+
throw new PromigenceError({
|
|
175
|
+
code: "cap_exceeded",
|
|
176
|
+
message: `a new image's snapshot build can cost up to $${build.toFixed(2)}, over ${which} $${capped.toFixed(2)}` +
|
|
177
|
+
`${capped >= build ? ` with the sandbox's own cost (about $${runCost.toFixed(2)})` : ""}; nothing was built or started`,
|
|
178
|
+
fix: `Pass snapshot instead (build it once outside the agent: ${sh `promigence snapshot create --image ${a["image"]} --alias NAME`}), or ` +
|
|
179
|
+
(which === "cap_usd" ? `pass cap_usd ${needed} or more.` : `start the server with --cap ${needed} or more.`),
|
|
180
|
+
exitCode: 5,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
const envs = a["env"];
|
|
184
|
+
const opts = { client: s.client() };
|
|
185
|
+
if (typeof a["snapshot"] === "string")
|
|
186
|
+
opts.snapshot = a["snapshot"];
|
|
187
|
+
if (typeof a["image"] === "string") {
|
|
188
|
+
opts.image = a["image"];
|
|
189
|
+
Object.assign(opts, { allowBuild: true });
|
|
190
|
+
}
|
|
191
|
+
if (typeof a["tier"] === "string")
|
|
192
|
+
opts.tier = a["tier"];
|
|
193
|
+
const timeoutMs = secondsToMs(a["timeout_s"]);
|
|
194
|
+
if (timeoutMs !== undefined)
|
|
195
|
+
opts.timeoutMs = timeoutMs;
|
|
196
|
+
if (a["network"] === "none")
|
|
197
|
+
opts.allowInternetAccess = false;
|
|
198
|
+
if (Array.isArray(a["allow_hosts"]) && a["allow_hosts"].length)
|
|
199
|
+
opts.allowHosts = a["allow_hosts"];
|
|
200
|
+
if (envs && Object.keys(envs).length)
|
|
201
|
+
opts.envs = envs;
|
|
202
|
+
if (capped !== undefined) {
|
|
203
|
+
opts.spendCap = capped;
|
|
204
|
+
if (opts.image !== undefined) {
|
|
205
|
+
opts.onBuild = (b) => {
|
|
206
|
+
const free = b.deduped === true || b.joined === true || b.idempotent_replay === true || b.state === "verified" || b.state === "built";
|
|
207
|
+
const charged = free ? 0 : (typeof b.build_price === "number" ? b.build_price : build);
|
|
208
|
+
opts.spendCap = Math.max(0, Math.round((capped - charged) * 1e9) / 1e9);
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
const sb = await Sandbox.create(opts);
|
|
213
|
+
s.created.set(sb.sandboxId, envs && Object.keys(envs).length ? { envs } : {});
|
|
214
|
+
return {
|
|
215
|
+
sandbox_id: sb.sandboxId,
|
|
216
|
+
run_id: sb.runId,
|
|
217
|
+
snapshot: sb.snapshot,
|
|
218
|
+
state: "ready",
|
|
219
|
+
...(timeoutMs === undefined ? {} : { timeout_s: Math.round(timeoutMs / 1000) }),
|
|
220
|
+
note: s.keep
|
|
221
|
+
? "Billed per second until sandbox_kill, sandbox_pause, or the timeout. Left running when this MCP session ends (--keep)."
|
|
222
|
+
: "Billed per second until sandbox_kill, sandbox_pause, or the timeout. Killed when this MCP session ends.",
|
|
223
|
+
};
|
|
224
|
+
},
|
|
225
|
+
},
|
|
226
|
+
{
|
|
227
|
+
name: "sandbox_exec",
|
|
228
|
+
description: "Run a shell command in a sandbox and return its exit code, stdout and stderr; each stream keeps its last 16 KiB and `truncated` says when something was cut.",
|
|
229
|
+
readOnly: false,
|
|
230
|
+
inputSchema: {
|
|
231
|
+
type: "object",
|
|
232
|
+
properties: {
|
|
233
|
+
sandbox_id: sandboxId,
|
|
234
|
+
command: { type: "string", description: "The command, run by sh -c." },
|
|
235
|
+
cwd,
|
|
236
|
+
env,
|
|
237
|
+
timeout_s: seconds("Bound on the command", " (default 15 minutes)"),
|
|
238
|
+
},
|
|
239
|
+
required: ["sandbox_id", "command"],
|
|
240
|
+
additionalProperties: false,
|
|
241
|
+
},
|
|
242
|
+
async run(a, s) {
|
|
243
|
+
const sid = a["sandbox_id"];
|
|
244
|
+
const merged = { ...(s.created.get(sid)?.envs ?? {}), ...(a["env"] ?? {}) };
|
|
245
|
+
const timeoutMs = secondsToMs(a["timeout_s"]);
|
|
246
|
+
const r = await s.client().sandboxes.exec(sid, {
|
|
247
|
+
sh: a["command"],
|
|
248
|
+
...(typeof a["cwd"] === "string" ? { cwd: a["cwd"] } : {}),
|
|
249
|
+
...(Object.keys(merged).length ? { env: merged } : {}),
|
|
250
|
+
...(timeoutMs === undefined ? {} : { timeoutMs }),
|
|
251
|
+
});
|
|
252
|
+
const out = tail(r.stdout ?? "");
|
|
253
|
+
const err = tail(r.stderr ?? "");
|
|
254
|
+
const truncated = out.cut || err.cut || Boolean(r.truncated);
|
|
255
|
+
const o = execOutcome(r, sid, timeoutMs, "mcp");
|
|
256
|
+
return {
|
|
257
|
+
...o.fields,
|
|
258
|
+
stdout: out.text,
|
|
259
|
+
stderr: err.text,
|
|
260
|
+
truncated,
|
|
261
|
+
exec_id: r.exec_id,
|
|
262
|
+
...(o.error ? { error: { code: o.error.code, message: o.error.message, fix: o.error.fix } } : {}),
|
|
263
|
+
...(truncated ? { full_output: sh `promigence exec output ${r.exec_id} --sandbox ${sid}` } : {}),
|
|
264
|
+
};
|
|
265
|
+
},
|
|
266
|
+
},
|
|
267
|
+
{
|
|
268
|
+
name: "sandbox_exec_background",
|
|
269
|
+
description: "Start a long-running command (a dev server, a watcher) in a sandbox and return its exec_id at once; read its output later with sandbox_exec_output.",
|
|
270
|
+
readOnly: false,
|
|
271
|
+
inputSchema: {
|
|
272
|
+
type: "object",
|
|
273
|
+
properties: {
|
|
274
|
+
sandbox_id: sandboxId,
|
|
275
|
+
command: { type: "string", description: "The command, run by sh -c." },
|
|
276
|
+
cwd,
|
|
277
|
+
env,
|
|
278
|
+
timeout_s: seconds("Bound on the command"),
|
|
279
|
+
},
|
|
280
|
+
required: ["sandbox_id", "command"],
|
|
281
|
+
additionalProperties: false,
|
|
282
|
+
},
|
|
283
|
+
async run(a, s) {
|
|
284
|
+
const sid = a["sandbox_id"];
|
|
285
|
+
const merged = { ...(s.created.get(sid)?.envs ?? {}), ...(a["env"] ?? {}) };
|
|
286
|
+
const timeoutMs = secondsToMs(a["timeout_s"]);
|
|
287
|
+
const rec = await s.client().sandboxes.execBackground(sid, {
|
|
288
|
+
sh: a["command"],
|
|
289
|
+
...(typeof a["cwd"] === "string" ? { cwd: a["cwd"] } : {}),
|
|
290
|
+
...(Object.keys(merged).length ? { env: merged } : {}),
|
|
291
|
+
...(timeoutMs === undefined ? {} : { timeoutMs }),
|
|
292
|
+
});
|
|
293
|
+
return { exec_id: rec.exec_id, sandbox_id: sid, state: rec.state, next: "sandbox_exec_output with this exec_id" };
|
|
294
|
+
},
|
|
295
|
+
},
|
|
296
|
+
{
|
|
297
|
+
name: "sandbox_exec_output",
|
|
298
|
+
description: "Fetch a background command's state, exit code and the last 16 KiB of its stdout and stderr; wait_s parks the call until it ends or the wait is spent.",
|
|
299
|
+
readOnly: true,
|
|
300
|
+
inputSchema: {
|
|
301
|
+
type: "object",
|
|
302
|
+
properties: {
|
|
303
|
+
sandbox_id: sandboxId,
|
|
304
|
+
exec_id: { type: "string", description: "The exec_id sandbox_exec_background returned." },
|
|
305
|
+
wait_s: { type: "integer", description: "Wait up to this long for the command to end before answering (default 0: answer now).", minimum: 0, maximum: 30 },
|
|
306
|
+
},
|
|
307
|
+
required: ["sandbox_id", "exec_id"],
|
|
308
|
+
additionalProperties: false,
|
|
309
|
+
},
|
|
310
|
+
async run(a, s) {
|
|
311
|
+
const c = s.client();
|
|
312
|
+
const sid = a["sandbox_id"];
|
|
313
|
+
const eid = a["exec_id"];
|
|
314
|
+
const waitMs = secondsToMs(a["wait_s"]);
|
|
315
|
+
const rec = await c.sandboxes.execs.get(sid, eid, waitMs ? { waitMs } : {});
|
|
316
|
+
const out = await readTail(c, sid, eid, "stdout", rec.stdout_bytes ?? 0);
|
|
317
|
+
const err = await readTail(c, sid, eid, "stderr", rec.stderr_bytes ?? 0);
|
|
318
|
+
return execOutputPayload(rec, out, err);
|
|
319
|
+
},
|
|
320
|
+
},
|
|
321
|
+
{
|
|
322
|
+
name: "sandbox_write_file",
|
|
323
|
+
description: "Write a file in a sandbox (text, or base64 for binary), creating or replacing it. A file that already exists keeps its mode (an executable script stays executable); a new one is 644 unless mode is given.",
|
|
324
|
+
readOnly: false,
|
|
325
|
+
inputSchema: {
|
|
326
|
+
type: "object",
|
|
327
|
+
properties: {
|
|
328
|
+
sandbox_id: sandboxId,
|
|
329
|
+
path: { type: "string", description: "Absolute path inside the sandbox." },
|
|
330
|
+
content: { type: "string", description: "The file's content." },
|
|
331
|
+
encoding: { type: "string", description: "How content is encoded: utf8 (default) or base64.", enum: ["utf8", "base64"] },
|
|
332
|
+
mode: { type: "string", description: "Permission bits in octal, e.g. 755 or 600. Default: keep an existing file's mode; 644 for a new file.", pattern: "^0?[0-7]{3,4}$" },
|
|
333
|
+
},
|
|
334
|
+
required: ["sandbox_id", "path", "content"],
|
|
335
|
+
additionalProperties: false,
|
|
336
|
+
},
|
|
337
|
+
async run(a, s) {
|
|
338
|
+
const sid = a["sandbox_id"];
|
|
339
|
+
const path = insideRoot(s.root, a["path"], "sandbox_write_file");
|
|
340
|
+
const data = a["encoding"] === "base64" ? Buffer.from(a["content"], "base64") : a["content"];
|
|
341
|
+
const mode = typeof a["mode"] === "string" ? parseInt(a["mode"], 8) : undefined;
|
|
342
|
+
const r = await s.client().sandboxes.files.put(sid, path, data, mode === undefined ? {} : { mode });
|
|
343
|
+
return { sandbox_id: sid, path: r.path ?? path, bytes: r.bytes, mode: r.mode };
|
|
344
|
+
},
|
|
345
|
+
},
|
|
346
|
+
{
|
|
347
|
+
name: "sandbox_read_file",
|
|
348
|
+
description: "Read a file from a sandbox as text (or base64 for binary), up to max_bytes (default 64 KiB, at most 1 MiB); use sandbox_exec with head, tail or grep for anything larger.",
|
|
349
|
+
readOnly: true,
|
|
350
|
+
inputSchema: {
|
|
351
|
+
type: "object",
|
|
352
|
+
properties: {
|
|
353
|
+
sandbox_id: sandboxId,
|
|
354
|
+
path: { type: "string", description: "Absolute path inside the sandbox." },
|
|
355
|
+
encoding: { type: "string", description: "How to return the content: utf8 (default) or base64.", enum: ["utf8", "base64"] },
|
|
356
|
+
max_bytes: { type: "integer", description: "Return at most this many bytes (default 65536, ceiling 1048576); `truncated` says when the file was longer.", minimum: 1, maximum: READ_CEILING },
|
|
357
|
+
},
|
|
358
|
+
required: ["sandbox_id", "path"],
|
|
359
|
+
additionalProperties: false,
|
|
360
|
+
},
|
|
361
|
+
async run(a, s) {
|
|
362
|
+
const c = s.client();
|
|
363
|
+
const sid = a["sandbox_id"];
|
|
364
|
+
const path = insideRoot(s.root, a["path"], "sandbox_read_file");
|
|
365
|
+
const max = typeof a["max_bytes"] === "number" ? a["max_bytes"] : READ_DEFAULT;
|
|
366
|
+
const { stat } = await c.sandboxes.files.stat(sid, path);
|
|
367
|
+
if (stat.is_dir)
|
|
368
|
+
throw usageError(`${path} is a directory`, "Call sandbox_list_files for a directory.");
|
|
369
|
+
if (stat.size > READ_CEILING) {
|
|
370
|
+
throw usageError(`${path} is ${stat.size} bytes; sandbox_read_file returns at most ${READ_CEILING}`, `Read a part of it with sandbox_exec (head, tail, sed -n), or copy it out with \`promigence files get ${shellArg(path)} ./local --sandbox ${shellArg(sid)}\`.`);
|
|
371
|
+
}
|
|
372
|
+
const bytes = await c.sandboxes.files.get(sid, path);
|
|
373
|
+
const slice = bytes.length > max ? bytes.subarray(0, max) : bytes;
|
|
374
|
+
const encoding = a["encoding"] === "base64" ? "base64" : "utf8";
|
|
375
|
+
return {
|
|
376
|
+
sandbox_id: sid,
|
|
377
|
+
path,
|
|
378
|
+
size: stat.size,
|
|
379
|
+
bytes: slice.length,
|
|
380
|
+
truncated: slice.length < bytes.length,
|
|
381
|
+
encoding,
|
|
382
|
+
content: encoding === "base64" ? Buffer.from(slice).toString("base64") : new TextDecoder().decode(slice),
|
|
383
|
+
};
|
|
384
|
+
},
|
|
385
|
+
},
|
|
386
|
+
{
|
|
387
|
+
name: "sandbox_list_files",
|
|
388
|
+
description: "List one directory level in a sandbox with the size and type of each entry.",
|
|
389
|
+
readOnly: true,
|
|
390
|
+
inputSchema: {
|
|
391
|
+
type: "object",
|
|
392
|
+
properties: {
|
|
393
|
+
sandbox_id: sandboxId,
|
|
394
|
+
path: { type: "string", description: "Absolute directory path inside the sandbox (default /)." },
|
|
395
|
+
},
|
|
396
|
+
required: ["sandbox_id"],
|
|
397
|
+
additionalProperties: false,
|
|
398
|
+
},
|
|
399
|
+
async run(a, s) {
|
|
400
|
+
const sid = a["sandbox_id"];
|
|
401
|
+
const path = insideRoot(s.root, typeof a["path"] === "string" ? a["path"] : (s.root ?? "/"), "sandbox_list_files");
|
|
402
|
+
const r = await s.client().sandboxes.files.list(sid, path);
|
|
403
|
+
return {
|
|
404
|
+
sandbox_id: sid,
|
|
405
|
+
path: r.path,
|
|
406
|
+
entries: r.entries.map((e) => ({ name: e.name, size: e.size, is_dir: e.is_dir, is_symlink: e.is_symlink, mode: e.mode })),
|
|
407
|
+
truncated: Boolean(r.truncated),
|
|
408
|
+
};
|
|
409
|
+
},
|
|
410
|
+
},
|
|
411
|
+
{
|
|
412
|
+
name: "sandbox_expose_port",
|
|
413
|
+
description: "Publish a port the sandbox listens on and return its preview URL; anyone holding the URL can reach the port until it is revoked or the sandbox ends. Preview URLs may not be available on this deployment (this beta): the error then names the SSH port forward that reaches the port instead.",
|
|
414
|
+
readOnly: false,
|
|
415
|
+
inputSchema: {
|
|
416
|
+
type: "object",
|
|
417
|
+
properties: {
|
|
418
|
+
sandbox_id: sandboxId,
|
|
419
|
+
port: { type: "integer", description: "The port a process inside the sandbox listens on.", minimum: 1, maximum: 65535 },
|
|
420
|
+
},
|
|
421
|
+
required: ["sandbox_id", "port"],
|
|
422
|
+
additionalProperties: false,
|
|
423
|
+
},
|
|
424
|
+
async run(a, s) {
|
|
425
|
+
const sid = a["sandbox_id"];
|
|
426
|
+
const port = a["port"];
|
|
427
|
+
let p;
|
|
428
|
+
try {
|
|
429
|
+
p = await s.client().sandboxes.ports.expose(sid, port);
|
|
430
|
+
}
|
|
431
|
+
catch (e) {
|
|
432
|
+
if (!(e instanceof PromigenceError) || e.message !== PREVIEWS_UNAVAILABLE)
|
|
433
|
+
throw e;
|
|
434
|
+
throw new PromigenceError({ code: e.code, message: e.message, fix: previewsFix(sid, port), exitCode: e.cliExit });
|
|
435
|
+
}
|
|
436
|
+
return { sandbox_id: sid, port: p.port, url: p.url, revoke: sh `promigence port revoke ${p.port} --sandbox ${sid}` };
|
|
437
|
+
},
|
|
438
|
+
},
|
|
439
|
+
{
|
|
440
|
+
name: "sandbox_pause",
|
|
441
|
+
description: "Park a sandbox: it stops billing and keeps its state until sandbox_resume.",
|
|
442
|
+
readOnly: false,
|
|
443
|
+
inputSchema: { type: "object", properties: { sandbox_id: sandboxId }, required: ["sandbox_id"], additionalProperties: false },
|
|
444
|
+
async run(a, s) {
|
|
445
|
+
const r = await s.client().sandboxes.pause(a["sandbox_id"]);
|
|
446
|
+
return { sandbox_id: r.sandbox_id, state: r.state, ...(r.already_paused ? { already_paused: true } : {}) };
|
|
447
|
+
},
|
|
448
|
+
},
|
|
449
|
+
{
|
|
450
|
+
name: "sandbox_resume",
|
|
451
|
+
description: "Bring a paused sandbox back to ready; billing restarts.",
|
|
452
|
+
readOnly: false,
|
|
453
|
+
inputSchema: { type: "object", properties: { sandbox_id: sandboxId }, required: ["sandbox_id"], additionalProperties: false },
|
|
454
|
+
async run(a, s) {
|
|
455
|
+
const r = await s.client().sandboxes.resume(a["sandbox_id"]);
|
|
456
|
+
return { sandbox_id: r.sandbox_id, state: r.state };
|
|
457
|
+
},
|
|
458
|
+
},
|
|
459
|
+
{
|
|
460
|
+
name: "sandbox_kill",
|
|
461
|
+
description: "End a sandbox now: its files are gone and billing stops.",
|
|
462
|
+
readOnly: false,
|
|
463
|
+
destructive: true,
|
|
464
|
+
inputSchema: { type: "object", properties: { sandbox_id: sandboxId }, required: ["sandbox_id"], additionalProperties: false },
|
|
465
|
+
async run(a, s) {
|
|
466
|
+
const sid = a["sandbox_id"];
|
|
467
|
+
await s.client().sandboxes.kill(sid);
|
|
468
|
+
s.created.delete(sid);
|
|
469
|
+
return { sandbox_id: sid, killed: true };
|
|
470
|
+
},
|
|
471
|
+
},
|
|
472
|
+
{
|
|
473
|
+
name: "sandbox_list",
|
|
474
|
+
description: "List this org's sandboxes, newest first, optionally only those in one state.",
|
|
475
|
+
readOnly: true,
|
|
476
|
+
inputSchema: {
|
|
477
|
+
type: "object",
|
|
478
|
+
properties: {
|
|
479
|
+
limit: { type: "integer", description: "How many (default 50).", minimum: 1, maximum: 500 },
|
|
480
|
+
state: { type: "string", description: "Only this state, e.g. ready | running | paused | ended." },
|
|
481
|
+
},
|
|
482
|
+
additionalProperties: false,
|
|
483
|
+
},
|
|
484
|
+
async run(a, s) {
|
|
485
|
+
const all = await s.client().sandboxes.list(typeof a["limit"] === "number" ? a["limit"] : 50);
|
|
486
|
+
const rows = typeof a["state"] === "string" ? all.filter((x) => x.state === a["state"]) : all;
|
|
487
|
+
return {
|
|
488
|
+
sandboxes: rows.map((x) => ({
|
|
489
|
+
sandbox_id: x.sandbox_id, state: x.state,
|
|
490
|
+
...(x.run_id ? { run_id: x.run_id } : {}),
|
|
491
|
+
...(x.snapshot_id ? { snapshot_id: x.snapshot_id } : {}),
|
|
492
|
+
...(s.created.has(x.sandbox_id) ? { created_here: true } : {}),
|
|
493
|
+
})),
|
|
494
|
+
};
|
|
495
|
+
},
|
|
496
|
+
},
|
|
497
|
+
{
|
|
498
|
+
name: "snapshot_list",
|
|
499
|
+
description: "List the snapshots available to fork: your org's (plus the public ones), or only the public examples with public=true.",
|
|
500
|
+
readOnly: true,
|
|
501
|
+
inputSchema: {
|
|
502
|
+
type: "object",
|
|
503
|
+
properties: { public: { type: "boolean", description: "Only the public example snapshots (no key needed)." } },
|
|
504
|
+
additionalProperties: false,
|
|
505
|
+
},
|
|
506
|
+
async run(a, s) {
|
|
507
|
+
const c = s.client();
|
|
508
|
+
const rows = a["public"] === true ? await c.snapshots.public(50) : await c.snapshots.list();
|
|
509
|
+
return {
|
|
510
|
+
snapshots: rows.map((x) => ({
|
|
511
|
+
snapshot_id: x.snapshot_id,
|
|
512
|
+
...(x.alias ? { alias: x.alias } : {}),
|
|
513
|
+
...(x.visibility ? { visibility: x.visibility } : {}),
|
|
514
|
+
...(x.verified !== undefined ? { verified: x.verified } : {}),
|
|
515
|
+
...(x.tier_assigned ?? x.tier ? { tier: x.tier_assigned ?? x.tier } : {}),
|
|
516
|
+
...(x.repo ? { repo: x.repo } : {}),
|
|
517
|
+
...(x.commit ? { commit: x.commit } : {}),
|
|
518
|
+
...(x.source?.image ? { image: x.source.image } : {}),
|
|
519
|
+
})),
|
|
520
|
+
};
|
|
521
|
+
},
|
|
522
|
+
},
|
|
523
|
+
{
|
|
524
|
+
name: "run_batch",
|
|
525
|
+
description: "Fork a snapshot N times, run one shell command in every copy under a spend cap, wait (bounded by wait_s) and return the run_id with the validity report summary: environment failures (Promigence's, replayable) separated from task failures (the command's).",
|
|
526
|
+
readOnly: false,
|
|
527
|
+
inputSchema: {
|
|
528
|
+
type: "object",
|
|
529
|
+
properties: {
|
|
530
|
+
snapshot: { type: "string", description: "snap_<id>, an alias, or alias@commit7. Default promigence/base, as sandbox_create." },
|
|
531
|
+
command: { type: "string", description: "The command, run by sh -c in every sandbox." },
|
|
532
|
+
count: { type: "integer", description: "How many sandboxes to fork.", minimum: 1, maximum: 1000 },
|
|
533
|
+
cap_usd: { type: "number", description: "Spend cap in USD for the whole run; refused above it, nothing starts. Cannot exceed the server's --cap.", minimum: 0 },
|
|
534
|
+
timeout_s: seconds("Per-sandbox bound", " (default 15 minutes)"),
|
|
535
|
+
network,
|
|
536
|
+
allow_hosts: allowHosts,
|
|
537
|
+
wait_s: { type: "integer", description: `How long to wait for the run to finish before answering with its run_id (default ${WAIT_DEFAULT_S}, at most ${WAIT_CEILING_S}); the run keeps going and run_report reads it later.`, minimum: 1, maximum: WAIT_CEILING_S },
|
|
538
|
+
},
|
|
539
|
+
required: ["command", "count"],
|
|
540
|
+
additionalProperties: false,
|
|
541
|
+
},
|
|
542
|
+
async run(a, s) {
|
|
543
|
+
if (a["network"] === "none" && Array.isArray(a["allow_hosts"]) && a["allow_hosts"].length) {
|
|
544
|
+
throw usageError("allow_hosts cannot be combined with network=none", "Pass allow_hosts on its own: it already runs the sandbox behind an outbound allow-list.");
|
|
545
|
+
}
|
|
546
|
+
const c = s.client();
|
|
547
|
+
const asked = typeof a["cap_usd"] === "number" ? a["cap_usd"] : undefined;
|
|
548
|
+
const ceiling = asked !== undefined && s.cap !== undefined ? Math.min(asked, s.cap) : asked ?? s.cap;
|
|
549
|
+
const spendCap = await resolveSpendCap(c, ceiling);
|
|
550
|
+
const timeoutMs = secondsToMs(a["timeout_s"]);
|
|
551
|
+
const opts = {
|
|
552
|
+
snapshot: typeof a["snapshot"] === "string" ? a["snapshot"] : DEFAULT_SNAPSHOT,
|
|
553
|
+
count: a["count"],
|
|
554
|
+
spendCap,
|
|
555
|
+
exec: { sh: a["command"], killOnExit: true, ...(timeoutMs === undefined ? {} : { timeoutMs }) },
|
|
556
|
+
};
|
|
557
|
+
if (timeoutMs !== undefined)
|
|
558
|
+
opts.timeoutMs = timeoutMs;
|
|
559
|
+
if (a["network"] === "none")
|
|
560
|
+
opts.network = "none";
|
|
561
|
+
if (Array.isArray(a["allow_hosts"]) && a["allow_hosts"].length)
|
|
562
|
+
opts.network = { allow: a["allow_hosts"] };
|
|
563
|
+
const started = await c.runs.start(opts);
|
|
564
|
+
const runId = started.run_id;
|
|
565
|
+
s.runs.add(runId);
|
|
566
|
+
log(`run ${runId} started: ${started.sandboxes.length} sandboxes, cap ${spendCap} USD`);
|
|
567
|
+
const bound = (typeof a["wait_s"] === "number" ? a["wait_s"] : WAIT_DEFAULT_S) * 1000;
|
|
568
|
+
const waited = await c.runs.waitDone(runId, { waitTimeoutMs: bound });
|
|
569
|
+
if (waited.timedOut) {
|
|
570
|
+
return {
|
|
571
|
+
run_id: runId,
|
|
572
|
+
state: waited.run.state,
|
|
573
|
+
spend_cap: spendCap,
|
|
574
|
+
timed_out: true,
|
|
575
|
+
note: `Still running after ${bound / 1000} s; nothing was killed. Call run_report with this run_id later.`,
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
s.runs.delete(runId);
|
|
579
|
+
const report = await c.runs.report(runId);
|
|
580
|
+
const tails = await outputTails(c, report.sandboxes ?? []);
|
|
581
|
+
return {
|
|
582
|
+
...reportSummary(report),
|
|
583
|
+
spend_cap: spendCap,
|
|
584
|
+
outputs: [...tails].map(([sandbox_id, o]) => ({ sandbox_id, ...o })),
|
|
585
|
+
...((report.sandboxes ?? []).filter((x) => x.execs?.length).length > tails.size
|
|
586
|
+
? { outputs_more: "the rest: sandbox_exec_output with the exec_id from run_report" }
|
|
587
|
+
: {}),
|
|
588
|
+
};
|
|
589
|
+
},
|
|
590
|
+
},
|
|
591
|
+
{
|
|
592
|
+
name: "run_report",
|
|
593
|
+
description: "Fetch the validity report summary of a run by run_id: state, totals, cost, and every sandbox that did not complete.",
|
|
594
|
+
readOnly: true,
|
|
595
|
+
inputSchema: {
|
|
596
|
+
type: "object",
|
|
597
|
+
properties: { run_id: { type: "string", description: "The run_id run_batch returned." } },
|
|
598
|
+
required: ["run_id"],
|
|
599
|
+
additionalProperties: false,
|
|
600
|
+
},
|
|
601
|
+
async run(a, s) {
|
|
602
|
+
const report = await s.client().runs.report(a["run_id"]);
|
|
603
|
+
if (report.state !== "running")
|
|
604
|
+
s.runs.delete(report.run_id);
|
|
605
|
+
return reportSummary(report);
|
|
606
|
+
},
|
|
607
|
+
},
|
|
608
|
+
];
|
|
609
|
+
const log = (line) => { void write(process.stderr, `promigence mcp: ${line}\n`); };
|
|
610
|
+
function rpcError(id, code, message, data) {
|
|
611
|
+
return { jsonrpc: "2.0", id, error: { code, message, ...(data === undefined ? {} : { data }) } };
|
|
612
|
+
}
|
|
613
|
+
function toolResult(s, payload, isError = false) {
|
|
614
|
+
const text = isError
|
|
615
|
+
? `${payload["code"]}: ${payload["message"]}\nfix: ${payload["fix"]}`
|
|
616
|
+
: JSON.stringify(payload);
|
|
617
|
+
const structured = isError ? { error: payload } : payload;
|
|
618
|
+
return {
|
|
619
|
+
content: [{ type: "text", text }],
|
|
620
|
+
...(s.protocolVersion === "2025-06-18" ? { structuredContent: structured } : {}),
|
|
621
|
+
...(isError ? { isError: true } : {}),
|
|
622
|
+
};
|
|
623
|
+
}
|
|
624
|
+
function toolFailure(s, err) {
|
|
625
|
+
return toolResult(s, { code: err.code, message: err.message, fix: err.fix, docs: err.docs, guide: err.guide }, true);
|
|
626
|
+
}
|
|
627
|
+
function toolListing(t) {
|
|
628
|
+
return {
|
|
629
|
+
name: t.name,
|
|
630
|
+
description: t.description,
|
|
631
|
+
inputSchema: t.inputSchema,
|
|
632
|
+
annotations: { readOnlyHint: t.readOnly, destructiveHint: Boolean(t.destructive), openWorldHint: true },
|
|
633
|
+
};
|
|
634
|
+
}
|
|
635
|
+
export function instructions(readOnly, keep, root) {
|
|
636
|
+
return [
|
|
637
|
+
"Promigence gives you cloud sandboxes. sandbox_create makes one from a snapshot or a pinned container image; sandbox_exec runs shell commands in it; sandbox_write_file and sandbox_read_file move files; sandbox_kill ends it. A sandbox bills per second until it is killed, paused or times out.",
|
|
638
|
+
keep ? "Sandboxes created here are left running when this session ends." : "Every sandbox created here is killed when this session ends.",
|
|
639
|
+
"run_batch forks a snapshot N times, runs one command in every copy under a spend cap, and returns the run_id with a validity report summary in which environment failures (Promigence's, replayable) are separated from task failures (the command's).",
|
|
640
|
+
...(readOnly ? ["This server is read-only: only the tools that list, read and report are offered; reading is still reading, so what a sandbox holds can be returned to you."] : []),
|
|
641
|
+
...(root ? [`The file tools (sandbox_read_file, sandbox_write_file, sandbox_list_files) work only under ${root}.`] : []),
|
|
642
|
+
"A sandbox reaches the internet unless it was created with network=none or allow_hosts: keep credentials out of files a tool here can read, and create sandboxes that handle them with network=none or allow_hosts.",
|
|
643
|
+
].join(" ");
|
|
644
|
+
}
|
|
645
|
+
const mcp = async (ctx) => {
|
|
646
|
+
let cap;
|
|
647
|
+
let allow;
|
|
648
|
+
let root;
|
|
649
|
+
try {
|
|
650
|
+
const rootFlag = str(ctx, "--root");
|
|
651
|
+
if (rootFlag !== undefined) {
|
|
652
|
+
if (!rootFlag.startsWith("/"))
|
|
653
|
+
throw usageError(`--root ${rootFlag} is not an absolute path`, "Pass a directory inside the sandbox, e.g. --root /work.");
|
|
654
|
+
root = posix.normalize(rootFlag).replace(/(.)\/+$/, "$1");
|
|
655
|
+
}
|
|
656
|
+
const capFlag = str(ctx, "--cap");
|
|
657
|
+
if (capFlag !== undefined)
|
|
658
|
+
cap = parseUsd(capFlag, "--cap");
|
|
659
|
+
const allowFlag = str(ctx, "--allow-tools");
|
|
660
|
+
if (allowFlag !== undefined) {
|
|
661
|
+
allow = new Set(allowFlag.split(",").map((x) => x.trim()).filter(Boolean));
|
|
662
|
+
const known = new Set(MCP_TOOLS.map((t) => t.name));
|
|
663
|
+
const unknown = [...allow].filter((x) => !known.has(x));
|
|
664
|
+
if (unknown.length)
|
|
665
|
+
throw usageError(`--allow-tools: unknown tool ${unknown.join(", ")}`, `Tools: ${[...known].join(", ")}.`);
|
|
666
|
+
if (allow.size === 0)
|
|
667
|
+
throw usageError("--allow-tools names no tool", "Pass --allow-tools sandbox_create,sandbox_exec,sandbox_kill.");
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
catch (err) {
|
|
671
|
+
const e = toPromigenceError(err);
|
|
672
|
+
return emit("text", errorEnvelope(ctx.cmd.name, e, []), errorTextLines(ctx.cmd.name, e, e.cliExit, []), e.cliExit, false);
|
|
673
|
+
}
|
|
674
|
+
const readOnly = bool(ctx, "--read-only");
|
|
675
|
+
const keep = bool(ctx, "--keep");
|
|
676
|
+
const offered = MCP_TOOLS.filter((t) => (!readOnly || t.readOnly) && (!allow || allow.has(t.name)));
|
|
677
|
+
const byName = new Map(offered.map((t) => [t.name, t]));
|
|
678
|
+
const orgs = new WeakMap();
|
|
679
|
+
const orgOf = (c) => {
|
|
680
|
+
let p = orgs.get(c);
|
|
681
|
+
if (!p) {
|
|
682
|
+
p = c.me().then((m) => m.org_id, () => undefined);
|
|
683
|
+
orgs.set(c, p);
|
|
684
|
+
}
|
|
685
|
+
return p;
|
|
686
|
+
};
|
|
687
|
+
const freshClient = () => new PromigenceClient({
|
|
688
|
+
...clientOpts(ctx),
|
|
689
|
+
onNotice: (n) => log(noticeLine(n)),
|
|
690
|
+
});
|
|
691
|
+
let cached;
|
|
692
|
+
let cachedFrom = "";
|
|
693
|
+
const keyGiven = ctx.apiKey !== undefined || Boolean(process.env["PROMIGENCE_API_KEY"]?.trim());
|
|
694
|
+
const session = {
|
|
695
|
+
client: () => {
|
|
696
|
+
if (keyGiven)
|
|
697
|
+
return (cached ??= freshClient());
|
|
698
|
+
forgetKeychainReads();
|
|
699
|
+
const from = JSON.stringify(readCredentials());
|
|
700
|
+
if (cached && from === cachedFrom)
|
|
701
|
+
return cached;
|
|
702
|
+
cached = freshClient();
|
|
703
|
+
cachedFrom = from;
|
|
704
|
+
return cached;
|
|
705
|
+
},
|
|
706
|
+
cap,
|
|
707
|
+
keep,
|
|
708
|
+
created: new Map(),
|
|
709
|
+
runs: new Set(),
|
|
710
|
+
protocolVersion: MCP_PROTOCOL_VERSIONS[0],
|
|
711
|
+
root,
|
|
712
|
+
};
|
|
713
|
+
const inflight = new Set();
|
|
714
|
+
let closing = false;
|
|
715
|
+
const send = (msg) => {
|
|
716
|
+
if (closing)
|
|
717
|
+
return;
|
|
718
|
+
process.stdout.write(JSON.stringify(msg) + "\n");
|
|
719
|
+
};
|
|
720
|
+
const callTool = async (params) => {
|
|
721
|
+
const p = (params ?? {});
|
|
722
|
+
const name = typeof p.name === "string" ? p.name : "";
|
|
723
|
+
const tool = byName.get(name);
|
|
724
|
+
if (!tool) {
|
|
725
|
+
return toolFailure(session, usageError(`unknown tool '${name}'`, `Call tools/list; this server offers ${[...byName.keys()].join(", ")}${readOnly ? " (--read-only)" : ""}.`));
|
|
726
|
+
}
|
|
727
|
+
const c = session.client();
|
|
728
|
+
if (c.authenticated) {
|
|
729
|
+
const org = await orgOf(c);
|
|
730
|
+
if (session.org === undefined)
|
|
731
|
+
session.org = org;
|
|
732
|
+
else if (org !== undefined && org !== session.org) {
|
|
733
|
+
return toolFailure(session, new PromigenceError({
|
|
734
|
+
code: "usage",
|
|
735
|
+
message: `the credentials now belong to another organization (${org}); this MCP session started in ${session.org} and does not follow an org switch`,
|
|
736
|
+
fix: "Ask the user to restart the MCP server to work in the new organization. Sandboxes this session started in the first one keep running until killed or their timeout (`promigence sandbox list` there).",
|
|
737
|
+
exitCode: 2,
|
|
738
|
+
}));
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
if (!c.authenticated) {
|
|
742
|
+
return toolFailure(session, c.credentialsProblem ?? new PromigenceError({
|
|
743
|
+
code: "auth",
|
|
744
|
+
message: "no Promigence credentials: this MCP server found no API key (PROMIGENCE_API_KEY, --api-key or the credentials file)",
|
|
745
|
+
fix: "Ask the user to run `promigence login` in a terminal (or set PROMIGENCE_API_KEY in this server's environment), then call the tool again; no restart is needed.",
|
|
746
|
+
exitCode: 7,
|
|
747
|
+
}));
|
|
748
|
+
}
|
|
749
|
+
const args = (p.arguments && typeof p.arguments === "object" && !Array.isArray(p.arguments) ? p.arguments : {});
|
|
750
|
+
const bad = check(tool.inputSchema, args);
|
|
751
|
+
if (bad)
|
|
752
|
+
return toolFailure(session, usageError(`${name}: ${bad}`, `Follow the tool's inputSchema from tools/list.`));
|
|
753
|
+
try {
|
|
754
|
+
return toolResult(session, await tool.run(args, session));
|
|
755
|
+
}
|
|
756
|
+
catch (err) {
|
|
757
|
+
return toolFailure(session, toPromigenceError(err));
|
|
758
|
+
}
|
|
759
|
+
};
|
|
760
|
+
const dispatch = async (msg) => {
|
|
761
|
+
if (!msg || typeof msg !== "object" || Array.isArray(msg))
|
|
762
|
+
return rpcError(null, INVALID_REQUEST, "Invalid Request: not a JSON-RPC object");
|
|
763
|
+
const m = msg;
|
|
764
|
+
const id = typeof m.id === "string" || typeof m.id === "number" ? m.id : null;
|
|
765
|
+
if (typeof m.method !== "string") {
|
|
766
|
+
if (m.result !== undefined || m.error !== undefined)
|
|
767
|
+
return undefined;
|
|
768
|
+
return rpcError(id, INVALID_REQUEST, "Invalid Request: no method");
|
|
769
|
+
}
|
|
770
|
+
if (m.jsonrpc !== "2.0")
|
|
771
|
+
return rpcError(id, INVALID_REQUEST, "Invalid Request: jsonrpc must be \"2.0\"");
|
|
772
|
+
const notification = m.id === undefined;
|
|
773
|
+
if (notification)
|
|
774
|
+
return undefined;
|
|
775
|
+
switch (m.method) {
|
|
776
|
+
case "initialize": {
|
|
777
|
+
const asked = m.params?.protocolVersion;
|
|
778
|
+
session.protocolVersion = MCP_PROTOCOL_VERSIONS.includes(String(asked))
|
|
779
|
+
? asked
|
|
780
|
+
: MCP_PROTOCOL_VERSIONS[0];
|
|
781
|
+
return {
|
|
782
|
+
jsonrpc: "2.0", id,
|
|
783
|
+
result: {
|
|
784
|
+
protocolVersion: session.protocolVersion,
|
|
785
|
+
capabilities: { tools: { listChanged: false } },
|
|
786
|
+
serverInfo: { name: "promigence", title: "Promigence", version: VERSION },
|
|
787
|
+
instructions: instructions(readOnly, keep, root),
|
|
788
|
+
},
|
|
789
|
+
};
|
|
790
|
+
}
|
|
791
|
+
case "ping":
|
|
792
|
+
return { jsonrpc: "2.0", id, result: {} };
|
|
793
|
+
case "tools/list":
|
|
794
|
+
return { jsonrpc: "2.0", id, result: { tools: offered.map(toolListing) } };
|
|
795
|
+
case "tools/call": {
|
|
796
|
+
const p = m.params;
|
|
797
|
+
if (!p || typeof p !== "object" || typeof p.name !== "string")
|
|
798
|
+
return rpcError(id, INVALID_PARAMS, "Invalid params: tools/call needs { name, arguments }");
|
|
799
|
+
return { jsonrpc: "2.0", id, result: await callTool(p) };
|
|
800
|
+
}
|
|
801
|
+
default:
|
|
802
|
+
return rpcError(id, METHOD_NOT_FOUND, `Method not found: ${m.method}`);
|
|
803
|
+
}
|
|
804
|
+
};
|
|
805
|
+
const handleLine = async (line) => {
|
|
806
|
+
let msg;
|
|
807
|
+
try {
|
|
808
|
+
msg = JSON.parse(line);
|
|
809
|
+
}
|
|
810
|
+
catch {
|
|
811
|
+
send(rpcError(null, PARSE_ERROR, "Parse error: not JSON"));
|
|
812
|
+
return;
|
|
813
|
+
}
|
|
814
|
+
if (Array.isArray(msg)) {
|
|
815
|
+
if (msg.length === 0)
|
|
816
|
+
return send(rpcError(null, INVALID_REQUEST, "Invalid Request: empty batch"));
|
|
817
|
+
const answers = (await Promise.all(msg.map(dispatch))).filter((r) => r !== undefined);
|
|
818
|
+
if (answers.length)
|
|
819
|
+
send(answers);
|
|
820
|
+
return;
|
|
821
|
+
}
|
|
822
|
+
const r = await dispatch(msg);
|
|
823
|
+
if (r)
|
|
824
|
+
send(r);
|
|
825
|
+
};
|
|
826
|
+
const track = (line) => {
|
|
827
|
+
const p = handleLine(line).catch((err) => log(`internal: ${err?.message ?? String(err)}`));
|
|
828
|
+
inflight.add(p);
|
|
829
|
+
void p.finally(() => inflight.delete(p));
|
|
830
|
+
};
|
|
831
|
+
const shutdown = async (reason, code, graceMs) => {
|
|
832
|
+
if (closing)
|
|
833
|
+
return new Promise(() => { });
|
|
834
|
+
closing = true;
|
|
835
|
+
process.stdin.pause();
|
|
836
|
+
const settled = Promise.allSettled([...inflight]);
|
|
837
|
+
await (graceMs === undefined ? settled : Promise.race([settled, new Promise((r) => setTimeout(r, graceMs))]));
|
|
838
|
+
const sandboxes = [...session.created.keys()];
|
|
839
|
+
const runs = [...session.runs];
|
|
840
|
+
if (keep) {
|
|
841
|
+
log(`${reason}; --keep: ${sandboxes.length} sandbox${sandboxes.length === 1 ? "" : "es"} and ${runs.length} run${runs.length === 1 ? "" : "s"} left running`);
|
|
842
|
+
}
|
|
843
|
+
else if (sandboxes.length || runs.length) {
|
|
844
|
+
const killAll = (c, sids, rids) => Promise.allSettled([
|
|
845
|
+
...sids.map((sid) => c.sandboxes.kill(sid)),
|
|
846
|
+
...rids.map((rid) => c.runs.kill(rid)),
|
|
847
|
+
]);
|
|
848
|
+
const first = await killAll(session.client(), sandboxes, runs);
|
|
849
|
+
const again = (xs, off) => xs.filter((_, i) => first[off + i].status === "rejected");
|
|
850
|
+
const [sids, rids] = [again(sandboxes, 0), again(runs, sandboxes.length)];
|
|
851
|
+
const retried = sids.length || rids.length ? await killAll(freshClient(), sids, rids) : [];
|
|
852
|
+
const failed = retried.filter((r) => r.status === "rejected").length;
|
|
853
|
+
log(`${reason}; killed ${sandboxes.length} sandbox${sandboxes.length === 1 ? "" : "es"}, ${runs.length} run${runs.length === 1 ? "" : "s"}${failed ? ` (${failed} kill${failed === 1 ? "" : "s"} failed: promigence sandbox list)` : ""}`);
|
|
854
|
+
}
|
|
855
|
+
else {
|
|
856
|
+
log(reason);
|
|
857
|
+
}
|
|
858
|
+
return exitWith(code);
|
|
859
|
+
};
|
|
860
|
+
let buf = "";
|
|
861
|
+
process.stdin.setEncoding("utf8");
|
|
862
|
+
process.stdin.on("data", (chunk) => {
|
|
863
|
+
buf += String(chunk);
|
|
864
|
+
let nl;
|
|
865
|
+
while ((nl = buf.indexOf("\n")) >= 0) {
|
|
866
|
+
const line = buf.slice(0, nl).trim();
|
|
867
|
+
buf = buf.slice(nl + 1);
|
|
868
|
+
if (line)
|
|
869
|
+
track(line);
|
|
870
|
+
}
|
|
871
|
+
});
|
|
872
|
+
process.stdin.on("end", () => {
|
|
873
|
+
const rest = buf.trim();
|
|
874
|
+
buf = "";
|
|
875
|
+
if (rest)
|
|
876
|
+
track(rest);
|
|
877
|
+
void shutdown("stdin closed", 0);
|
|
878
|
+
});
|
|
879
|
+
process.stdout.on("error", () => { void shutdown("stdout closed", 0, SIGNAL_GRACE_MS); });
|
|
880
|
+
for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"]) {
|
|
881
|
+
process.on(sig, () => { void shutdown(`interrupted by ${sig}`, 130, SIGNAL_GRACE_MS); });
|
|
882
|
+
}
|
|
883
|
+
log(`serving ${offered.length} tool${offered.length === 1 ? "" : "s"} on stdio (promigence@${VERSION}${readOnly ? ", --read-only" : ""}${keep ? ", --keep" : ""}${cap === undefined ? "" : `, --cap ${cap}`})${session.client().authenticated ? "" : "; no credentials: every call will ask for `promigence login`"}`);
|
|
884
|
+
process.stdin.resume();
|
|
885
|
+
return new Promise(() => { });
|
|
886
|
+
};
|
|
887
|
+
export const MCP_HANDLERS = {
|
|
888
|
+
mcp,
|
|
889
|
+
};
|