@tenkicloud/mcp 0.1.0

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.
Files changed (51) hide show
  1. package/CHANGELOG.md +161 -0
  2. package/LICENSE +21 -0
  3. package/README.md +192 -0
  4. package/SECURITY.md +50 -0
  5. package/dist/client.d.ts +152 -0
  6. package/dist/client.js +499 -0
  7. package/dist/http.d.ts +19 -0
  8. package/dist/http.js +234 -0
  9. package/dist/index.d.ts +2 -0
  10. package/dist/index.js +59 -0
  11. package/dist/server.d.ts +30 -0
  12. package/dist/server.js +205 -0
  13. package/dist/tools/artifacts.d.ts +16 -0
  14. package/dist/tools/artifacts.js +19 -0
  15. package/dist/tools/auth_status.d.ts +35 -0
  16. package/dist/tools/auth_status.js +104 -0
  17. package/dist/tools/common.d.ts +33 -0
  18. package/dist/tools/common.js +42 -0
  19. package/dist/tools/exec.d.ts +4 -0
  20. package/dist/tools/exec.js +88 -0
  21. package/dist/tools/files.d.ts +4 -0
  22. package/dist/tools/files.js +22 -0
  23. package/dist/tools/files_ops.d.ts +12 -0
  24. package/dist/tools/files_ops.js +54 -0
  25. package/dist/tools/git.d.ts +4 -0
  26. package/dist/tools/git.js +30 -0
  27. package/dist/tools/identity.d.ts +4 -0
  28. package/dist/tools/identity.js +5 -0
  29. package/dist/tools/ports.d.ts +4 -0
  30. package/dist/tools/ports.js +6 -0
  31. package/dist/tools/previews.d.ts +18 -0
  32. package/dist/tools/previews.js +102 -0
  33. package/dist/tools/registry.d.ts +18 -0
  34. package/dist/tools/registry.js +98 -0
  35. package/dist/tools/run.d.ts +4 -0
  36. package/dist/tools/run.js +11 -0
  37. package/dist/tools/sandboxes.d.ts +4 -0
  38. package/dist/tools/sandboxes.js +66 -0
  39. package/dist/tools/sessions_admin.d.ts +9 -0
  40. package/dist/tools/sessions_admin.js +76 -0
  41. package/dist/tools/snapshots.d.ts +11 -0
  42. package/dist/tools/snapshots.js +91 -0
  43. package/dist/tools/ssh.d.ts +15 -0
  44. package/dist/tools/ssh.js +17 -0
  45. package/dist/tools/templates.d.ts +14 -0
  46. package/dist/tools/templates.js +151 -0
  47. package/dist/tools/volumes.d.ts +16 -0
  48. package/dist/tools/volumes.js +94 -0
  49. package/dist/tools/workspace.d.ts +4 -0
  50. package/dist/tools/workspace.js +129 -0
  51. package/package.json +61 -0
package/dist/client.js ADDED
@@ -0,0 +1,499 @@
1
+ /**
2
+ * TenkiClient — a dependency-free client for the Tenki Cloud API.
3
+ *
4
+ * Tenki's API is ConnectRPC (JSON over HTTP/1.1), not REST. Every control-plane
5
+ * call is `POST {baseUrl}/tenki.sandbox.v1.SandboxService/{Method}` with a
6
+ * lowerCamelCase JSON body and a JSON response. Per-session file I/O runs on a
7
+ * separate data-plane endpoint returned at session-create time, authenticated
8
+ * with a short-lived session certificate.
9
+ *
10
+ * The wire details here (headers, the control/data-plane split, and capturing
11
+ * command stdout/stderr via an `sh -c` redirect + data-plane ReadFile) are
12
+ * ported from the live-verified n8n community node
13
+ * (github.com/opencolin/n8n-nodes-tenki).
14
+ */
15
+ const CONTROL_SERVICE = "tenki.sandbox.v1.SandboxService";
16
+ const DATA_SERVICE = "tenki.sandbox.v1.SandboxSessionDataPlaneService";
17
+ const DEFAULT_BASE_URL = "https://api.tenki.cloud";
18
+ const MAX_RETRIES = 3;
19
+ const BACKOFF_BASE_MS = 500;
20
+ const BACKOFF_CAP_MS = 8000;
21
+ // Rate-limit rejections happen before the server does any work, so they are
22
+ // safe to retry for EVERY method. (For ExecuteCommand this assumes the 429 is
23
+ // the API's own limiter rejecting the call before it ran — true for a
24
+ // ConnectRPC-shaped 429; an intermediary that already forwarded the request
25
+ // would re-run the command.)
26
+ const RATE_LIMIT_CODES = new Set(["rate_limited", "ratelimited", "resource_exhausted", "resourceexhausted"]);
27
+ // Methods that must never double-apply: retrying one that half-applied could
28
+ // boot and bill a second sandbox, publish twice, or re-run a command. The
29
+ // policy is a deny-list shaped by that risk — everything else (reads, and
30
+ // teardown like Terminate*/Delete*/Detach*, where a repeat lands in the same
31
+ // state, worst case a harmless "not found") retries transient failures.
32
+ // Skipping a teardown retry is the expensive mistake: runCode's finally
33
+ // swallows terminate errors, so a transient blip would silently leave a
34
+ // billing sandbox running.
35
+ const NON_IDEMPOTENT_METHOD = /^(Create|Build|Publish|Resume|Extend|ExecuteCommand)/;
36
+ // Data-plane methods safe to retry: read-shaped only. A retried Remove that
37
+ // had actually applied would surface a spurious "not found" for an op that
38
+ // succeeded.
39
+ const DATA_RETRYABLE_METHOD = /^(Read|Stat|List|Get)/;
40
+ const RETRY_AFTER_CAP_MS = 30_000;
41
+ // Every fetch carries a timeout so a hung connection fails the tool call with a
42
+ // clear error instead of blocking it forever (see TenkiClientOptions).
43
+ const DEFAULT_TIMEOUT_MS = 30_000;
44
+ const DEFAULT_EXEC_TIMEOUT_MS = 630_000; // runCode's 600s hard cap + margin
45
+ const EXEC_TIMEOUT_MARGIN_MS = 30_000; // headroom over a command's own timeout
46
+ /**
47
+ * Methods whose RPC does not return until a long storage/VM operation finishes,
48
+ * so the 30s default is far too short. A timeout on these is worse than slow:
49
+ * the operation completes server-side anyway, so the caller is told it failed
50
+ * while a snapshot (or a paused VM holding a multi-GB pause snapshot) has in
51
+ * fact been created — an orphaned resource whose id the caller never sees.
52
+ *
53
+ * Deliberately limited to the two methods MEASURED to block: CreateSnapshot
54
+ * (47s observed here; 30s+ elsewhere, with the snapshot ready ~7s in) and
55
+ * PauseSession (41s observed; the pause itself finished ~7s in). Async methods
56
+ * that return a handle immediately are NOT listed even though they start long
57
+ * jobs — BuildTemplate returns PENDING in ~535ms and PublishRegistryImage in
58
+ * ~278ms, so giving them a 10-minute budget would only delay a genuinely hung
59
+ * call. Their orphan-on-timeout hazard is real but needs idempotency, not a
60
+ * bigger timeout.
61
+ */
62
+ const SLOW_METHOD = /^(CreateSnapshot|PauseSession)$/;
63
+ const DEFAULT_SLOW_TIMEOUT_MS = 600_000;
64
+ // Session-credential cache lifetime handling: a credential whose expiry the API
65
+ // omits (or that we cannot parse) is assumed valid only briefly — never forever —
66
+ // and a known expiry is refreshed early so an in-flight call cannot straddle it.
67
+ // The assumed lifetime can be generous because a stale certificate is recovered
68
+ // by the 401 invalidate-and-re-mint path rather than by cache expiry alone.
69
+ const DEFAULT_CRED_TTL_MS = 300_000;
70
+ const CRED_EXPIRY_SKEW_MS = 30_000;
71
+ const MIN_CRED_TTL_MS = 5_000; // floor for the skewed expiry of a short-lived credential
72
+ /** Home directory of the sandbox's `tenki` user; run-code scripts and capture files live here. */
73
+ const SANDBOX_HOME = "/home/tenki";
74
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
75
+ function safeJson(text) {
76
+ try {
77
+ const p = JSON.parse(text);
78
+ return typeof p === "object" && p !== null ? p : undefined;
79
+ }
80
+ catch {
81
+ return undefined;
82
+ }
83
+ }
84
+ /** POSIX single-quote a string for safe use inside `sh -c`. */
85
+ function shellQuote(value) {
86
+ return `'${value.replace(/'/g, "'\\''")}'`;
87
+ }
88
+ /**
89
+ * Tenki's auth header is chosen by token prefix (verbatim from the SDK's auth.ts):
90
+ * `tk_` → Bearer, `ory_st_` → X-Session-Token, otherwise a `tenki_session` cookie.
91
+ */
92
+ function authHeaders(token) {
93
+ const t = token.trim();
94
+ if (t.startsWith("tk_"))
95
+ return { Authorization: `Bearer ${t}` };
96
+ if (t.startsWith("ory_st_"))
97
+ return { "X-Session-Token": t };
98
+ return { Cookie: `tenki_session=${t}` };
99
+ }
100
+ function interpreterFor(language) {
101
+ switch (language) {
102
+ case "python":
103
+ return { file: `${SANDBOX_HOME}/main.py`, command: "python3", args: [`${SANDBOX_HOME}/main.py`] };
104
+ case "javascript":
105
+ return { file: `${SANDBOX_HOME}/main.js`, command: "node", args: [`${SANDBOX_HOME}/main.js`] };
106
+ case "shell":
107
+ default:
108
+ return { file: `${SANDBOX_HOME}/main.sh`, command: "sh", args: [`${SANDBOX_HOME}/main.sh`] };
109
+ }
110
+ }
111
+ export class TenkiClient {
112
+ token;
113
+ baseUrl;
114
+ credCache = new Map();
115
+ credInflight = new Map();
116
+ timeoutMs;
117
+ execTimeoutMs;
118
+ slowTimeoutMs;
119
+ credTtlMs;
120
+ constructor(token, baseUrl = DEFAULT_BASE_URL, opts = {}) {
121
+ this.token = token;
122
+ this.baseUrl = baseUrl.replace(/\/+$/, "");
123
+ this.timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
124
+ this.execTimeoutMs = opts.execTimeoutMs ?? DEFAULT_EXEC_TIMEOUT_MS;
125
+ this.slowTimeoutMs = opts.slowTimeoutMs ?? DEFAULT_SLOW_TIMEOUT_MS;
126
+ this.credTtlMs = opts.credTtlMs ?? DEFAULT_CRED_TTL_MS;
127
+ }
128
+ /**
129
+ * ExecuteCommand blocks until the command finishes, so its timeout follows
130
+ * the command's own timeout (plus margin) instead of the unary default.
131
+ */
132
+ timeoutFor(method, body) {
133
+ if (SLOW_METHOD.test(method))
134
+ return this.slowTimeoutMs;
135
+ if (method !== "ExecuteCommand")
136
+ return this.timeoutMs;
137
+ const t = typeof body.timeout === "string" ? Number.parseInt(body.timeout, 10) : Number.NaN; // "30s"
138
+ return Number.isFinite(t) && t > 0 ? t * 1000 + EXEC_TIMEOUT_MARGIN_MS : this.execTimeoutMs;
139
+ }
140
+ /**
141
+ * fetch + full body read under ONE timeout, with a readable error instead of
142
+ * a bare AbortError. Reading the body inside the guarded section matters: a
143
+ * response whose headers arrive but whose body stalls must fail with the
144
+ * same friendly, retryable timeout as a connection that never responds.
145
+ */
146
+ async fetchTextWithTimeout(url, init, timeoutMs, what) {
147
+ const budget = Math.max(1, Math.floor(timeoutMs));
148
+ try {
149
+ const res = await fetch(url, { ...init, signal: AbortSignal.timeout(budget) });
150
+ const text = await res.text();
151
+ return { ok: res.ok, status: res.status, headers: res.headers, text };
152
+ }
153
+ catch (e) {
154
+ const name = e?.name;
155
+ if (name === "TimeoutError" || name === "AbortError") {
156
+ throw new Error(`Tenki ${what} timed out after ${budget}ms with no response.`);
157
+ }
158
+ throw e;
159
+ }
160
+ }
161
+ /**
162
+ * Retry policy: rate limits always; `unavailable` and gateway-shaped
163
+ * 502/503/504 (load balancers and CDNs return these with an HTML or empty
164
+ * body, no ConnectRPC code) for every method that cannot double-apply.
165
+ */
166
+ shouldRetry(method, status, code) {
167
+ if (status === 429 || RATE_LIMIT_CODES.has(code))
168
+ return true;
169
+ const transient = code === "unavailable" || status === 502 || status === 503 || status === 504;
170
+ return transient && !NON_IDEMPOTENT_METHOD.test(method);
171
+ }
172
+ /** Exponential backoff with half-jitter; a Retry-After header (seconds) wins, capped. */
173
+ backoffMs(attempt, retryAfter) {
174
+ const ra = retryAfter ? Number.parseInt(retryAfter, 10) : Number.NaN;
175
+ if (Number.isFinite(ra) && ra >= 0)
176
+ return Math.min(ra * 1000, RETRY_AFTER_CAP_MS);
177
+ const base = Math.min(BACKOFF_BASE_MS * 2 ** attempt, BACKOFF_CAP_MS);
178
+ return base / 2 + Math.random() * (base / 2);
179
+ }
180
+ /**
181
+ * Unary control-plane call. The WHOLE call — every attempt plus every
182
+ * backoff sleep — draws from one shared deadline (the method's timeout), so
183
+ * retries can never stack past it: an MCP client's own 60s default would
184
+ * otherwise give up before our per-attempt timeouts did. Rate-limit
185
+ * rejections retry with jittered backoff (honoring Retry-After); transient
186
+ * `unavailable`/gateway errors AND transport-level failures (timeout,
187
+ * connection reset, DNS) retry for every method that cannot double-apply.
188
+ * `service` defaults to SandboxService; pass another fully-qualified ConnectRPC
189
+ * service (e.g. the SSH gateway service) for methods hosted elsewhere.
190
+ */
191
+ async control(method, body = {}, service = CONTROL_SERVICE) {
192
+ const url = `${this.baseUrl}/${service}/${method}`;
193
+ const deadline = Date.now() + this.timeoutFor(method, body ?? {});
194
+ for (let attempt = 0;; attempt++) {
195
+ let res;
196
+ try {
197
+ res = await this.fetchTextWithTimeout(url, {
198
+ method: "POST",
199
+ headers: { "Content-Type": "application/json", "Connect-Protocol-Version": "1", ...authHeaders(this.token) },
200
+ body: JSON.stringify(body ?? {}),
201
+ }, deadline - Date.now(), method);
202
+ }
203
+ catch (e) {
204
+ // Transport failure: the request may or may not have reached the
205
+ // server, so only methods that cannot double-apply are retried.
206
+ if (attempt < MAX_RETRIES && !NON_IDEMPOTENT_METHOD.test(method)) {
207
+ const wait = this.backoffMs(attempt, null);
208
+ if (Date.now() + wait < deadline) {
209
+ await sleep(wait);
210
+ continue;
211
+ }
212
+ }
213
+ throw e;
214
+ }
215
+ if (res.ok)
216
+ return JSON.parse(res.text);
217
+ const parsed = safeJson(res.text);
218
+ const code = typeof parsed?.code === "string" ? parsed.code.toLowerCase() : "";
219
+ if (attempt < MAX_RETRIES && this.shouldRetry(method, res.status, code)) {
220
+ const wait = this.backoffMs(attempt, res.headers.get("retry-after"));
221
+ if (Date.now() + wait < deadline) {
222
+ await sleep(wait);
223
+ continue;
224
+ }
225
+ }
226
+ const msg = parsed?.message || res.text || `HTTP ${res.status}`;
227
+ throw new Error(`Tenki ${method} failed (${res.status}${code ? ` ${code}` : ""}): ${msg}`);
228
+ }
229
+ }
230
+ /**
231
+ * Mint (and cache per session) the data-plane endpoint + session certificate.
232
+ * A credential with no parseable expiry is cached for credTtlMs — never
233
+ * forever — and a known expiry is shortened by a skew so an in-flight call
234
+ * can't straddle the real expiry. Minting is single-flight: concurrent data
235
+ * calls on a cold cache share one mint instead of each paying a
236
+ * control-plane round trip for its own certificate.
237
+ */
238
+ async credentialFor(sessionId) {
239
+ const cached = this.credCache.get(sessionId);
240
+ if (cached && cached.expiresAt > Date.now())
241
+ return cached;
242
+ const inflight = this.credInflight.get(sessionId);
243
+ if (inflight)
244
+ return inflight;
245
+ const mint = this.mintCredential(sessionId).finally(() => this.credInflight.delete(sessionId));
246
+ this.credInflight.set(sessionId, mint);
247
+ return mint;
248
+ }
249
+ async mintCredential(sessionId) {
250
+ const resp = await this.control("CreateSessionCredential", { sessionId });
251
+ const cred = resp.credential ?? resp;
252
+ const token = (cred.credential ?? cred.token);
253
+ const endpoint = (resp.dataPlaneEndpoint ?? resp.data_plane_endpoint ?? resp.routeStatus?.endpoint);
254
+ let expiresAt = Date.now() + this.credTtlMs;
255
+ const raw = cred.expiresAt ?? cred.expires_at;
256
+ if (typeof raw === "string") {
257
+ const p = Date.parse(raw);
258
+ if (!Number.isNaN(p)) {
259
+ // Floor the skewed expiry so a short-lived credential (< skew) is
260
+ // still cached briefly instead of the cache being disabled outright.
261
+ // An ALREADY-expired credential is not cached at all (expiresAt 0):
262
+ // flooring it would serve a known-dead cert from cache, turning
263
+ // every data call into a guaranteed 401 → re-mint round trip.
264
+ expiresAt = p > Date.now() ? Math.max(p - CRED_EXPIRY_SKEW_MS, Date.now() + MIN_CRED_TTL_MS) : 0;
265
+ }
266
+ }
267
+ const entry = { endpoint: endpoint ?? "", token, expiresAt };
268
+ this.credCache.set(sessionId, entry);
269
+ return entry;
270
+ }
271
+ /**
272
+ * Unary data-plane call. The inner request is wrapped as
273
+ * `{ request: { sessionId, ...request } }`. This is the path behind every
274
+ * file tool AND how tenki_exec reads back its own output, so it gets the
275
+ * same treatment as the control plane: one shared deadline, rate limits
276
+ * retried for every method, transient/gateway/transport failures retried
277
+ * for read-shaped methods. A stale-certificate failure (401/unauthenticated
278
+ * — the cert may have expired server-side regardless of its stated expiry)
279
+ * invalidates the cache and re-mints once; permission_denied is NOT an auth
280
+ * failure — a denied path is a normal user error a fresh cert can never
281
+ * fix, so re-minting would waste a control-plane round trip per denied call.
282
+ */
283
+ async data(sessionId, method, request = {}) {
284
+ const deadline = Date.now() + this.timeoutMs;
285
+ let remintedAuth = false;
286
+ for (let attempt = 0;;) {
287
+ const cred = await this.credentialFor(sessionId);
288
+ if (!cred.endpoint) {
289
+ throw new Error(`Could not resolve the data-plane endpoint for session ${sessionId}.`);
290
+ }
291
+ const url = `${cred.endpoint.replace(/\/+$/, "")}/${DATA_SERVICE}/${method}`;
292
+ let res;
293
+ try {
294
+ res = await this.fetchTextWithTimeout(url, {
295
+ method: "POST",
296
+ headers: {
297
+ "Content-Type": "application/json",
298
+ "Connect-Protocol-Version": "1",
299
+ "x-tenki-session-cert": cred.token,
300
+ },
301
+ body: JSON.stringify({ request: { sessionId, ...request } }),
302
+ }, deadline - Date.now(), `data ${method}`);
303
+ }
304
+ catch (e) {
305
+ if (attempt < MAX_RETRIES && DATA_RETRYABLE_METHOD.test(method)) {
306
+ const wait = this.backoffMs(attempt++, null);
307
+ if (Date.now() + wait < deadline) {
308
+ await sleep(wait);
309
+ continue;
310
+ }
311
+ }
312
+ throw e;
313
+ }
314
+ if (res.ok) {
315
+ const wrapped = JSON.parse(res.text);
316
+ return wrapped.response ?? wrapped;
317
+ }
318
+ const parsed = safeJson(res.text);
319
+ const code = typeof parsed?.code === "string" ? parsed.code.toLowerCase() : "";
320
+ if ((res.status === 401 || code === "unauthenticated") && !remintedAuth) {
321
+ remintedAuth = true;
322
+ this.credCache.delete(sessionId);
323
+ continue; // re-mint doesn't consume a retry attempt
324
+ }
325
+ const transient = code === "unavailable" || res.status === 502 || res.status === 503 || res.status === 504;
326
+ const retryable = res.status === 429 || RATE_LIMIT_CODES.has(code) || (transient && DATA_RETRYABLE_METHOD.test(method));
327
+ if (attempt < MAX_RETRIES && retryable) {
328
+ const wait = this.backoffMs(attempt++, res.headers.get("retry-after"));
329
+ if (Date.now() + wait < deadline) {
330
+ await sleep(wait);
331
+ continue;
332
+ }
333
+ }
334
+ throw new Error(`Tenki data ${method} failed (${res.status}): ${parsed?.message || res.text}`);
335
+ }
336
+ }
337
+ /**
338
+ * Resolve the calling identity + a default workspace/project for CreateSession
339
+ * (which requires a projectId). Picks the first workspace that has a project so
340
+ * the (workspace, project) pair stays consistent.
341
+ *
342
+ * CreateSession validates owner_type ∈ {SERVICE, USER} and requires a
343
+ * non-empty owner_id, but derives the real owner from the authenticated
344
+ * identity server-side. WhoAmI can return other owner types (e.g. WORKSPACE
345
+ * for workspace-scoped keys), which the validator rejects — so for those we
346
+ * send the same placeholder the first-party SDKs hardcode ("SERVICE"/"self").
347
+ */
348
+ async resolveOwner() {
349
+ const resp = await this.control("WhoAmI", {});
350
+ const workspaces = Array.isArray(resp.workspaces) ? resp.workspaces : [];
351
+ const ws = workspaces.find((w) => Array.isArray(w?.projects) && w.projects.length > 0) ?? workspaces[0];
352
+ const proj = Array.isArray(ws?.projects) ? ws.projects[0] : undefined;
353
+ let ownerType = resp.ownerType;
354
+ let ownerId = resp.ownerId;
355
+ // Substitute the placeholder only when WhoAmI returned a type CreateSession
356
+ // rejects. When ownerType is absent entirely, leave it absent — callers omit
357
+ // the owner fields and the server derives the owner from the identity.
358
+ if (ownerType && ownerType !== "USER" && ownerType !== "SERVICE") {
359
+ ownerType = "SERVICE";
360
+ ownerId = "self";
361
+ }
362
+ return {
363
+ ownerType,
364
+ ownerId,
365
+ workspaceId: ws?.workspaceId ?? ws?.id,
366
+ projectId: proj?.projectId ?? proj?.id,
367
+ };
368
+ }
369
+ /** Poll GetSession until it reaches (or passes into) the target state. */
370
+ async waitForState(sessionId, target = "RUNNING", { timeoutMs = 180000, intervalMs = 1000 } = {}) {
371
+ const deadline = Date.now() + timeoutMs;
372
+ for (;;) {
373
+ const resp = await this.control("GetSession", { sessionId });
374
+ const session = resp.session ?? resp;
375
+ const state = String(session.state ?? "");
376
+ if (state.includes(target))
377
+ return session;
378
+ if (["TERMINATED", "ERROR", "FAILED"].some((s) => state.includes(s))) {
379
+ throw new Error(`Session ${sessionId} entered ${state} while waiting for ${target}.`);
380
+ }
381
+ if (Date.now() > deadline) {
382
+ throw new Error(`Timed out waiting for session ${sessionId} to reach ${target} (last state: ${state}).`);
383
+ }
384
+ await sleep(intervalMs);
385
+ }
386
+ }
387
+ async readTextFile(sessionId, path) {
388
+ const resp = await this.data(sessionId, "ReadFile", { path });
389
+ const content = (resp.content ?? resp.data ?? resp.file?.content ?? "");
390
+ return content ? Buffer.from(content, "base64").toString("utf8") : "";
391
+ }
392
+ async writeTextFile(sessionId, path, text) {
393
+ const content = Buffer.from(text, "utf8").toString("base64");
394
+ return this.data(sessionId, "WriteFile", { path, content });
395
+ }
396
+ /**
397
+ * Run a command in a session and return stdout/stderr inline.
398
+ *
399
+ * We wrap the command in `sh -c '<cmd> > out 2> err'`, then read the capture
400
+ * files back over the data plane, so output is available through a plain-HTTP
401
+ * client. Capture-read failures degrade gracefully into `captureError` rather
402
+ * than losing the run — but the result is marked NOT ok, because empty
403
+ * stdout/stderr next to a zero exit code would otherwise read as a clean,
404
+ * silent success.
405
+ */
406
+ async execCaptured(sessionId, command, opts = {}) {
407
+ const suffix = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
408
+ const outPath = `${SANDBOX_HOME}/.mcp-exec-${suffix}.out`;
409
+ const errPath = `${SANDBOX_HOME}/.mcp-exec-${suffix}.err`;
410
+ const execLine = [command, ...(opts.args ?? [])].map(shellQuote).join(" ");
411
+ // .trim() only decides WHETHER a cd is emitted; the quoted value passes
412
+ // through verbatim — trailing/leading whitespace is legal in dir names.
413
+ // `--` is required: quoting does not stop `cd` from reading a leading-hyphen
414
+ // value as an option, and `cd -L` silently succeeds into HOME instead of
415
+ // failing, so the command would then run in the wrong directory.
416
+ const cd = opts.cwd && opts.cwd.trim() ? `cd -- ${shellQuote(opts.cwd)} && ` : "";
417
+ const script = `${cd}${execLine} > ${outPath} 2> ${errPath}`;
418
+ const body = { sessionId, command: "sh", args: ["-c", script] };
419
+ if (opts.env && Object.keys(opts.env).length)
420
+ body.env = opts.env;
421
+ if (opts.timeoutSeconds && opts.timeoutSeconds > 0)
422
+ body.timeout = `${opts.timeoutSeconds}s`;
423
+ const resp = await this.control("ExecuteCommand", body);
424
+ const execution = resp.execution ?? resp;
425
+ // proto3 omits zero-valued fields: an absent exitCode means 0 (success).
426
+ // A non-numeric shape coerces to NaN, which the exec outputSchema rejects,
427
+ // failing the whole call and discarding the run's output — normalize an
428
+ // unparseable code to -1 so the user still gets their stdout/stderr.
429
+ const rawExit = typeof execution.exitCode === "number" ? execution.exitCode : Number(execution.exitCode ?? 0);
430
+ const exitCode = Number.isFinite(rawExit) ? Math.trunc(rawExit) : -1;
431
+ let stdout = "";
432
+ let stderr = "";
433
+ let captureError;
434
+ try {
435
+ stdout = await this.readTextFile(sessionId, outPath);
436
+ stderr = await this.readTextFile(sessionId, errPath);
437
+ }
438
+ catch (e) {
439
+ captureError = e.message;
440
+ }
441
+ try {
442
+ await this.control("ExecuteCommand", { sessionId, command: "rm", args: ["-f", outPath, errPath] });
443
+ }
444
+ catch {
445
+ // Session may have gone away; capture files die with it.
446
+ }
447
+ return {
448
+ command,
449
+ args: opts.args ?? [],
450
+ stdout,
451
+ stderr,
452
+ exitCode,
453
+ ok: exitCode === 0 && !captureError,
454
+ ...(captureError ? { captureError } : {}),
455
+ };
456
+ }
457
+ /**
458
+ * One-shot: boot a throwaway sandbox, run code (shell / python / javascript),
459
+ * return its output, and terminate the sandbox. Cost-guarded (1 vCPU, 1 GB,
460
+ * 10-min cap, 5-min idle) so an ephemeral run can never leak a billing session.
461
+ */
462
+ async runCode(language, code, opts = {}) {
463
+ const owner = await this.resolveOwner();
464
+ const create = await this.control("CreateSession", {
465
+ cpuCores: 1,
466
+ memoryMb: 1024,
467
+ maxDuration: "600s",
468
+ idleTimeoutMinutes: 5,
469
+ ...(owner.ownerType ? { ownerType: owner.ownerType } : {}),
470
+ ...(owner.ownerId ? { ownerId: owner.ownerId } : {}),
471
+ ...(owner.workspaceId ? { workspaceId: owner.workspaceId } : {}),
472
+ ...(owner.projectId ? { projectId: owner.projectId } : {}),
473
+ ...(opts.env && Object.keys(opts.env).length ? { env: opts.env } : {}),
474
+ });
475
+ const session = create.session ?? create;
476
+ const sessionId = (session.id ?? create.sessionId ?? create.id);
477
+ if (!sessionId)
478
+ throw new Error("runCode: could not read the created session id from CreateSession.");
479
+ try {
480
+ await this.waitForState(sessionId, "RUNNING");
481
+ const { file, command, args } = interpreterFor(language);
482
+ await this.writeTextFile(sessionId, file, code);
483
+ const result = await this.execCaptured(sessionId, command, {
484
+ args,
485
+ env: opts.env,
486
+ timeoutSeconds: opts.timeoutSeconds,
487
+ });
488
+ return { sessionId, language, ...result };
489
+ }
490
+ finally {
491
+ try {
492
+ await this.control("TerminateSession", { sessionId });
493
+ }
494
+ catch {
495
+ // Best-effort teardown; the idle/max-duration guards reap it regardless.
496
+ }
497
+ }
498
+ }
499
+ }
package/dist/http.d.ts ADDED
@@ -0,0 +1,19 @@
1
+ /**
2
+ * HTTP/SSE transport for tenki-mcp (v2.0) — makes the server hostable, not just
3
+ * local-stdio. Uses the MCP SDK's StreamableHTTPServerTransport with a stateful
4
+ * per-session model: one server + transport per MCP session.
5
+ *
6
+ * Enable with TENKI_MCP_TRANSPORT=http. Config:
7
+ * PORT — listen port (default 3000)
8
+ * TENKI_MCP_HTTP_HOST — bind host (default 127.0.0.1, loopback-only)
9
+ * TENKI_MCP_HTTP_TOKEN — required Bearer token for the /mcp endpoint
10
+ *
11
+ * Security posture (the process holds one shared TENKI_API_KEY and exposes all
12
+ * tools, incl. arbitrary code execution + credit spend, so the endpoint is a
13
+ * capability): loopback-only by default; DNS-rebinding protection on (Host
14
+ * allowlist); optional bearer auth; and it REFUSES to bind to a non-loopback
15
+ * host without a token set. Per-session/global DoS caps are applied.
16
+ */
17
+ import http from "node:http";
18
+ import type { TenkiClient } from "./client.js";
19
+ export declare function startHttp(client: TenkiClient | null, port: number): http.Server;