@luxorai/mcp 0.0.0-stage → 0.3.1

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 +46 -0
  2. package/LICENSE +21 -0
  3. package/README.md +216 -2
  4. package/SECURITY.md +50 -0
  5. package/dist/client.d.ts +177 -0
  6. package/dist/client.js +575 -0
  7. package/dist/http.d.ts +17 -0
  8. package/dist/http.js +294 -0
  9. package/dist/index.d.ts +2 -0
  10. package/dist/index.js +60 -0
  11. package/dist/oauth.d.ts +33 -0
  12. package/dist/oauth.js +146 -0
  13. package/dist/server.d.ts +30 -0
  14. package/dist/server.js +203 -0
  15. package/dist/tools/artifacts.d.ts +16 -0
  16. package/dist/tools/artifacts.js +19 -0
  17. package/dist/tools/auth_status.d.ts +35 -0
  18. package/dist/tools/auth_status.js +109 -0
  19. package/dist/tools/common.d.ts +35 -0
  20. package/dist/tools/common.js +60 -0
  21. package/dist/tools/exec.d.ts +4 -0
  22. package/dist/tools/exec.js +120 -0
  23. package/dist/tools/files.d.ts +4 -0
  24. package/dist/tools/files.js +22 -0
  25. package/dist/tools/files_ops.d.ts +12 -0
  26. package/dist/tools/files_ops.js +54 -0
  27. package/dist/tools/git.d.ts +4 -0
  28. package/dist/tools/git.js +30 -0
  29. package/dist/tools/identity.d.ts +4 -0
  30. package/dist/tools/identity.js +5 -0
  31. package/dist/tools/ports.d.ts +4 -0
  32. package/dist/tools/ports.js +6 -0
  33. package/dist/tools/previews.d.ts +18 -0
  34. package/dist/tools/previews.js +80 -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 +72 -0
  39. package/dist/tools/sessions_admin.d.ts +9 -0
  40. package/dist/tools/sessions_admin.js +62 -0
  41. package/dist/tools/snapshots.d.ts +11 -0
  42. package/dist/tools/snapshots.js +79 -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 +142 -0
  47. package/dist/tools/volumes.d.ts +16 -0
  48. package/dist/tools/volumes.js +77 -0
  49. package/dist/tools/workspace.d.ts +4 -0
  50. package/dist/tools/workspace.js +129 -0
  51. package/package.json +59 -4
package/dist/client.js ADDED
@@ -0,0 +1,575 @@
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
+ // Inline-output budget per stream (stdout / stderr), in bytes. Outputs at or
75
+ // under the cap return whole; larger ones return a head+tail preview and the
76
+ // full capture file is KEPT in the sandbox so the caller can page through it.
77
+ // The cap exists for the consumer (an LLM context) and for this process: the
78
+ // data plane's ReadFile has no range support, so the only alternative to a
79
+ // server-side preview would be pulling the entire body over the wire.
80
+ const DEFAULT_MAX_OUTPUT_BYTES = 65_536;
81
+ const MIN_OUTPUT_BYTES = 1_024;
82
+ // Head-heavy split: the start of a log names the command/failure context; the
83
+ // tail carries the final error. 3:1 mirrors how people read build output.
84
+ const TRUNCATE_HEAD_FRACTION = 0.75;
85
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
86
+ function safeJson(text) {
87
+ try {
88
+ const p = JSON.parse(text);
89
+ return typeof p === "object" && p !== null ? p : undefined;
90
+ }
91
+ catch {
92
+ return undefined;
93
+ }
94
+ }
95
+ /** POSIX single-quote a string for safe use inside `sh -c`. */
96
+ function shellQuote(value) {
97
+ return `'${value.replace(/'/g, "'\\''")}'`;
98
+ }
99
+ /**
100
+ * Tenki's auth header is chosen by token prefix (verbatim from the SDK's auth.ts):
101
+ * `tk_` → Bearer, `ory_st_` → X-Session-Token, otherwise a `tenki_session` cookie.
102
+ */
103
+ function authHeaders(token) {
104
+ const t = token.trim();
105
+ if (t.startsWith("tk_"))
106
+ return { Authorization: `Bearer ${t}` };
107
+ if (t.startsWith("ory_st_"))
108
+ return { "X-Session-Token": t };
109
+ return { Cookie: `tenki_session=${t}` };
110
+ }
111
+ function interpreterFor(language) {
112
+ switch (language) {
113
+ case "python":
114
+ return { file: `${SANDBOX_HOME}/main.py`, command: "python3", args: [`${SANDBOX_HOME}/main.py`] };
115
+ case "javascript":
116
+ return { file: `${SANDBOX_HOME}/main.js`, command: "node", args: [`${SANDBOX_HOME}/main.js`] };
117
+ case "shell":
118
+ default:
119
+ return { file: `${SANDBOX_HOME}/main.sh`, command: "sh", args: [`${SANDBOX_HOME}/main.sh`] };
120
+ }
121
+ }
122
+ export class TenkiClient {
123
+ token;
124
+ baseUrl;
125
+ credCache = new Map();
126
+ credInflight = new Map();
127
+ timeoutMs;
128
+ execTimeoutMs;
129
+ slowTimeoutMs;
130
+ credTtlMs;
131
+ workspaceId;
132
+ bearerTokenProvider;
133
+ constructor(token, baseUrl = DEFAULT_BASE_URL, opts = {}) {
134
+ this.token = token;
135
+ this.baseUrl = baseUrl.replace(/\/+$/, "");
136
+ this.timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
137
+ this.execTimeoutMs = opts.execTimeoutMs ?? DEFAULT_EXEC_TIMEOUT_MS;
138
+ this.slowTimeoutMs = opts.slowTimeoutMs ?? DEFAULT_SLOW_TIMEOUT_MS;
139
+ this.credTtlMs = opts.credTtlMs ?? DEFAULT_CRED_TTL_MS;
140
+ this.workspaceId = opts.workspaceId?.trim() || undefined;
141
+ this.bearerTokenProvider = opts.bearerTokenProvider;
142
+ }
143
+ controlAuthHeaders() {
144
+ if (this.bearerTokenProvider)
145
+ return { Authorization: `Bearer ${this.bearerTokenProvider()}` };
146
+ return authHeaders(this.token);
147
+ }
148
+ /**
149
+ * ExecuteCommand blocks until the command finishes, so its timeout follows
150
+ * the command's own timeout (plus margin) instead of the unary default.
151
+ */
152
+ timeoutFor(method, body) {
153
+ if (SLOW_METHOD.test(method))
154
+ return this.slowTimeoutMs;
155
+ if (method !== "ExecuteCommand")
156
+ return this.timeoutMs;
157
+ const t = typeof body.timeout === "string" ? Number.parseInt(body.timeout, 10) : Number.NaN; // "30s"
158
+ return Number.isFinite(t) && t > 0 ? t * 1000 + EXEC_TIMEOUT_MARGIN_MS : this.execTimeoutMs;
159
+ }
160
+ /**
161
+ * fetch + full body read under ONE timeout, with a readable error instead of
162
+ * a bare AbortError. Reading the body inside the guarded section matters: a
163
+ * response whose headers arrive but whose body stalls must fail with the
164
+ * same friendly, retryable timeout as a connection that never responds.
165
+ */
166
+ async fetchTextWithTimeout(url, init, timeoutMs, what) {
167
+ const budget = Math.max(1, Math.floor(timeoutMs));
168
+ try {
169
+ const res = await fetch(url, { ...init, signal: AbortSignal.timeout(budget) });
170
+ const text = await res.text();
171
+ return { ok: res.ok, status: res.status, headers: res.headers, text };
172
+ }
173
+ catch (e) {
174
+ const name = e?.name;
175
+ if (name === "TimeoutError" || name === "AbortError") {
176
+ throw new Error(`Tenki ${what} timed out after ${budget}ms with no response.`);
177
+ }
178
+ throw e;
179
+ }
180
+ }
181
+ /**
182
+ * Retry policy: rate limits always; `unavailable` and gateway-shaped
183
+ * 502/503/504 (load balancers and CDNs return these with an HTML or empty
184
+ * body, no ConnectRPC code) for every method that cannot double-apply.
185
+ */
186
+ shouldRetry(method, status, code) {
187
+ if (status === 429 || RATE_LIMIT_CODES.has(code))
188
+ return true;
189
+ const transient = code === "unavailable" || status === 502 || status === 503 || status === 504;
190
+ return transient && !NON_IDEMPOTENT_METHOD.test(method);
191
+ }
192
+ /** Exponential backoff with half-jitter; a Retry-After header (seconds) wins, capped. */
193
+ backoffMs(attempt, retryAfter) {
194
+ const ra = retryAfter ? Number.parseInt(retryAfter, 10) : Number.NaN;
195
+ if (Number.isFinite(ra) && ra >= 0)
196
+ return Math.min(ra * 1000, RETRY_AFTER_CAP_MS);
197
+ const base = Math.min(BACKOFF_BASE_MS * 2 ** attempt, BACKOFF_CAP_MS);
198
+ return base / 2 + Math.random() * (base / 2);
199
+ }
200
+ /**
201
+ * Unary control-plane call. The WHOLE call — every attempt plus every
202
+ * backoff sleep — draws from one shared deadline (the method's timeout), so
203
+ * retries can never stack past it: an MCP client's own 60s default would
204
+ * otherwise give up before our per-attempt timeouts did. Rate-limit
205
+ * rejections retry with jittered backoff (honoring Retry-After); transient
206
+ * `unavailable`/gateway errors AND transport-level failures (timeout,
207
+ * connection reset, DNS) retry for every method that cannot double-apply.
208
+ * `service` defaults to SandboxService; pass another fully-qualified ConnectRPC
209
+ * service (e.g. the SSH gateway service) for methods hosted elsewhere.
210
+ */
211
+ async control(method, body = {}, service = CONTROL_SERVICE) {
212
+ const url = `${this.baseUrl}/${service}/${method}`;
213
+ const deadline = Date.now() + this.timeoutFor(method, body ?? {});
214
+ for (let attempt = 0;; attempt++) {
215
+ let res;
216
+ try {
217
+ res = await this.fetchTextWithTimeout(url, {
218
+ method: "POST",
219
+ headers: { "Content-Type": "application/json", "Connect-Protocol-Version": "1", ...this.controlAuthHeaders() },
220
+ body: JSON.stringify(body ?? {}),
221
+ }, deadline - Date.now(), method);
222
+ }
223
+ catch (e) {
224
+ // Transport failure: the request may or may not have reached the
225
+ // server, so only methods that cannot double-apply are retried.
226
+ if (attempt < MAX_RETRIES && !NON_IDEMPOTENT_METHOD.test(method)) {
227
+ const wait = this.backoffMs(attempt, null);
228
+ if (Date.now() + wait < deadline) {
229
+ await sleep(wait);
230
+ continue;
231
+ }
232
+ }
233
+ throw e;
234
+ }
235
+ if (res.ok)
236
+ return JSON.parse(res.text);
237
+ const parsed = safeJson(res.text);
238
+ const code = typeof parsed?.code === "string" ? parsed.code.toLowerCase() : "";
239
+ if (attempt < MAX_RETRIES && this.shouldRetry(method, res.status, code)) {
240
+ const wait = this.backoffMs(attempt, res.headers.get("retry-after"));
241
+ if (Date.now() + wait < deadline) {
242
+ await sleep(wait);
243
+ continue;
244
+ }
245
+ }
246
+ const msg = parsed?.message || res.text || `HTTP ${res.status}`;
247
+ throw new Error(`Tenki ${method} failed (${res.status}${code ? ` ${code}` : ""}): ${msg}`);
248
+ }
249
+ }
250
+ /**
251
+ * Mint (and cache per session) the data-plane endpoint + session certificate.
252
+ * A credential with no parseable expiry is cached for credTtlMs — never
253
+ * forever — and a known expiry is shortened by a skew so an in-flight call
254
+ * can't straddle the real expiry. Minting is single-flight: concurrent data
255
+ * calls on a cold cache share one mint instead of each paying a
256
+ * control-plane round trip for its own certificate.
257
+ */
258
+ async credentialFor(sessionId) {
259
+ const cached = this.credCache.get(sessionId);
260
+ if (cached && cached.expiresAt > Date.now())
261
+ return cached;
262
+ const inflight = this.credInflight.get(sessionId);
263
+ if (inflight)
264
+ return inflight;
265
+ const mint = this.mintCredential(sessionId).finally(() => this.credInflight.delete(sessionId));
266
+ this.credInflight.set(sessionId, mint);
267
+ return mint;
268
+ }
269
+ async mintCredential(sessionId) {
270
+ const resp = await this.control("CreateSessionCredential", { sessionId });
271
+ const cred = resp.credential ?? resp;
272
+ const token = (cred.credential ?? cred.token);
273
+ const endpoint = (resp.dataPlaneEndpoint ?? resp.data_plane_endpoint ?? resp.routeStatus?.endpoint);
274
+ let expiresAt = Date.now() + this.credTtlMs;
275
+ const raw = cred.expiresAt ?? cred.expires_at;
276
+ if (typeof raw === "string") {
277
+ const p = Date.parse(raw);
278
+ if (!Number.isNaN(p)) {
279
+ // Floor the skewed expiry so a short-lived credential (< skew) is
280
+ // still cached briefly instead of the cache being disabled outright.
281
+ // An ALREADY-expired credential is not cached at all (expiresAt 0):
282
+ // flooring it would serve a known-dead cert from cache, turning
283
+ // every data call into a guaranteed 401 → re-mint round trip.
284
+ expiresAt = p > Date.now() ? Math.max(p - CRED_EXPIRY_SKEW_MS, Date.now() + MIN_CRED_TTL_MS) : 0;
285
+ }
286
+ }
287
+ const entry = { endpoint: endpoint ?? "", token, expiresAt };
288
+ this.credCache.set(sessionId, entry);
289
+ return entry;
290
+ }
291
+ /**
292
+ * Unary data-plane call. The inner request is wrapped as
293
+ * `{ request: { sessionId, ...request } }`. This is the path behind every
294
+ * file tool AND how tenki_exec reads back its own output, so it gets the
295
+ * same treatment as the control plane: one shared deadline, rate limits
296
+ * retried for every method, transient/gateway/transport failures retried
297
+ * for read-shaped methods. A stale-certificate failure (401/unauthenticated
298
+ * — the cert may have expired server-side regardless of its stated expiry)
299
+ * invalidates the cache and re-mints once; permission_denied is NOT an auth
300
+ * failure — a denied path is a normal user error a fresh cert can never
301
+ * fix, so re-minting would waste a control-plane round trip per denied call.
302
+ */
303
+ async data(sessionId, method, request = {}) {
304
+ const deadline = Date.now() + this.timeoutMs;
305
+ let remintedAuth = false;
306
+ for (let attempt = 0;;) {
307
+ const cred = await this.credentialFor(sessionId);
308
+ if (!cred.endpoint) {
309
+ throw new Error(`Could not resolve the data-plane endpoint for session ${sessionId}.`);
310
+ }
311
+ const url = `${cred.endpoint.replace(/\/+$/, "")}/${DATA_SERVICE}/${method}`;
312
+ let res;
313
+ try {
314
+ res = await this.fetchTextWithTimeout(url, {
315
+ method: "POST",
316
+ headers: {
317
+ "Content-Type": "application/json",
318
+ "Connect-Protocol-Version": "1",
319
+ "x-tenki-session-cert": cred.token,
320
+ },
321
+ body: JSON.stringify({ request: { sessionId, ...request } }),
322
+ }, deadline - Date.now(), `data ${method}`);
323
+ }
324
+ catch (e) {
325
+ if (attempt < MAX_RETRIES && DATA_RETRYABLE_METHOD.test(method)) {
326
+ const wait = this.backoffMs(attempt++, null);
327
+ if (Date.now() + wait < deadline) {
328
+ await sleep(wait);
329
+ continue;
330
+ }
331
+ }
332
+ throw e;
333
+ }
334
+ if (res.ok) {
335
+ const wrapped = JSON.parse(res.text);
336
+ return wrapped.response ?? wrapped;
337
+ }
338
+ const parsed = safeJson(res.text);
339
+ const code = typeof parsed?.code === "string" ? parsed.code.toLowerCase() : "";
340
+ if ((res.status === 401 || code === "unauthenticated") && !remintedAuth) {
341
+ remintedAuth = true;
342
+ this.credCache.delete(sessionId);
343
+ continue; // re-mint doesn't consume a retry attempt
344
+ }
345
+ const transient = code === "unavailable" || res.status === 502 || res.status === 503 || res.status === 504;
346
+ const retryable = res.status === 429 || RATE_LIMIT_CODES.has(code) || (transient && DATA_RETRYABLE_METHOD.test(method));
347
+ if (attempt < MAX_RETRIES && retryable) {
348
+ const wait = this.backoffMs(attempt++, res.headers.get("retry-after"));
349
+ if (Date.now() + wait < deadline) {
350
+ await sleep(wait);
351
+ continue;
352
+ }
353
+ }
354
+ throw new Error(`Tenki data ${method} failed (${res.status}): ${parsed?.message || res.text}`);
355
+ }
356
+ }
357
+ /**
358
+ * Resolve the calling identity + a default workspace for CreateSession and
359
+ * other workspace-scoped calls. (Projects were removed from the API — the
360
+ * proto reserves every project_id field and WhoAmI no longer lists projects —
361
+ * so there is nothing narrower than a workspace to resolve.)
362
+ *
363
+ * CreateSession validates owner_type ∈ {SERVICE, USER} and requires a
364
+ * non-empty owner_id, but derives the real owner from the authenticated
365
+ * identity server-side. WhoAmI can return other owner types (e.g. WORKSPACE
366
+ * for workspace-scoped keys), which the validator rejects — so for those we
367
+ * send the same placeholder the first-party SDKs hardcode ("SERVICE"/"self").
368
+ */
369
+ async resolveOwner() {
370
+ const resp = await this.control("WhoAmI", {});
371
+ const workspaces = Array.isArray(resp.workspaces) ? resp.workspaces : [];
372
+ const ws = this.workspaceId
373
+ ? workspaces.find((candidate) => (candidate?.workspaceId ?? candidate?.id) === this.workspaceId)
374
+ : workspaces[0];
375
+ let ownerType = resp.ownerType;
376
+ let ownerId = resp.ownerId;
377
+ // Substitute the placeholder only when WhoAmI returned a type CreateSession
378
+ // rejects. When ownerType is absent entirely, leave it absent — callers omit
379
+ // the owner fields and the server derives the owner from the identity.
380
+ if (ownerType && ownerType !== "USER" && ownerType !== "SERVICE") {
381
+ ownerType = "SERVICE";
382
+ ownerId = "self";
383
+ }
384
+ return {
385
+ ownerType,
386
+ ownerId,
387
+ workspaceId: this.workspaceId ?? ws?.workspaceId ?? ws?.id,
388
+ };
389
+ }
390
+ /** Poll GetSession until it reaches (or passes into) the target state. */
391
+ async waitForState(sessionId, target = "RUNNING", { timeoutMs = 180000, intervalMs = 1000 } = {}) {
392
+ const deadline = Date.now() + timeoutMs;
393
+ for (;;) {
394
+ const resp = await this.control("GetSession", { sessionId });
395
+ const session = resp.session ?? resp;
396
+ const state = String(session.state ?? "");
397
+ if (state.includes(target))
398
+ return session;
399
+ if (["TERMINATED", "ERROR", "FAILED"].some((s) => state.includes(s))) {
400
+ throw new Error(`Session ${sessionId} entered ${state} while waiting for ${target}.`);
401
+ }
402
+ if (Date.now() > deadline) {
403
+ throw new Error(`Timed out waiting for session ${sessionId} to reach ${target} (last state: ${state}).`);
404
+ }
405
+ await sleep(intervalMs);
406
+ }
407
+ }
408
+ async readTextFile(sessionId, path) {
409
+ const resp = await this.data(sessionId, "ReadFile", { path });
410
+ const content = (resp.content ?? resp.data ?? resp.file?.content ?? "");
411
+ return content ? Buffer.from(content, "base64").toString("utf8") : "";
412
+ }
413
+ async writeTextFile(sessionId, path, text) {
414
+ const content = Buffer.from(text, "utf8").toString("base64");
415
+ return this.data(sessionId, "WriteFile", { path, content });
416
+ }
417
+ /**
418
+ * Read a capture file back, capping what crosses the wire. Stat first: a
419
+ * file at or under the cap is read whole; a larger one gets a server-side
420
+ * head+tail preview (assembled by `sh` in the sandbox, so the full body
421
+ * never leaves it — ReadFile has no range support). The preview file is the
422
+ * caller's to clean up (returned as previewPath); the ORIGINAL is theirs to
423
+ * keep, so the user can page through the full output afterwards.
424
+ */
425
+ async readCaptureFile(sessionId, path, capBytes) {
426
+ // Stat is an optimization (skip pulling a body we'd mostly discard), not a
427
+ // gate: if it fails, fall back to the plain whole read — whose own failure
428
+ // still surfaces as captureError in the caller.
429
+ let size;
430
+ try {
431
+ const stat = await this.data(sessionId, "Stat", { path });
432
+ size = Number(stat.size ?? 0); // int64 arrives as a JSON string
433
+ }
434
+ catch {
435
+ size = undefined;
436
+ }
437
+ if (size === undefined || !Number.isFinite(size) || size <= capBytes) {
438
+ return { text: await this.readTextFile(sessionId, path), truncated: false };
439
+ }
440
+ const head = Math.floor(capBytes * TRUNCATE_HEAD_FRACTION);
441
+ const tail = capBytes - head;
442
+ const previewPath = `${path}.preview`;
443
+ const marker = `\n[... output truncated: ${size} bytes total, showing the first ${head} and last ${tail}. Full output kept in the sandbox at ${path} ...]\n`;
444
+ const script = `{ head -c ${head} ${shellQuote(path)}; printf %s ${shellQuote(marker)}; tail -c ${tail} ${shellQuote(path)}; } > ${shellQuote(previewPath)}`;
445
+ await this.control("ExecuteCommand", { sessionId, command: "sh", args: ["-c", script] });
446
+ return { text: await this.readTextFile(sessionId, previewPath), truncated: true, previewPath };
447
+ }
448
+ /**
449
+ * Run a command in a session and return stdout/stderr inline.
450
+ *
451
+ * We wrap the command in `sh -c '<cmd> > out 2> err'`, then read the capture
452
+ * files back over the data plane, so output is available through a plain-HTTP
453
+ * client. Capture-read failures degrade gracefully into `captureError` rather
454
+ * than losing the run — but the result is marked NOT ok, because empty
455
+ * stdout/stderr next to a zero exit code would otherwise read as a clean,
456
+ * silent success. Streams over maxOutputBytes come back truncated
457
+ * (head+tail) with the full capture file retained in the sandbox — see
458
+ * readCaptureFile; truncation does not affect `ok`.
459
+ */
460
+ async execCaptured(sessionId, command, opts = {}) {
461
+ const suffix = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
462
+ const outPath = `${SANDBOX_HOME}/.mcp-exec-${suffix}.out`;
463
+ const errPath = `${SANDBOX_HOME}/.mcp-exec-${suffix}.err`;
464
+ const execLine = [command, ...(opts.args ?? [])].map(shellQuote).join(" ");
465
+ // .trim() only decides WHETHER a cd is emitted; the quoted value passes
466
+ // through verbatim — trailing/leading whitespace is legal in dir names.
467
+ // `--` is required: quoting does not stop `cd` from reading a leading-hyphen
468
+ // value as an option, and `cd -L` silently succeeds into HOME instead of
469
+ // failing, so the command would then run in the wrong directory.
470
+ const cd = opts.cwd && opts.cwd.trim() ? `cd -- ${shellQuote(opts.cwd)} && ` : "";
471
+ const script = `${cd}${execLine} > ${outPath} 2> ${errPath}`;
472
+ const body = { sessionId, command: "sh", args: ["-c", script] };
473
+ if (opts.env && Object.keys(opts.env).length)
474
+ body.env = opts.env;
475
+ if (opts.timeoutSeconds && opts.timeoutSeconds > 0)
476
+ body.timeout = `${opts.timeoutSeconds}s`;
477
+ const resp = await this.control("ExecuteCommand", body);
478
+ const execution = resp.execution ?? resp;
479
+ // proto3 omits zero-valued fields: an absent exitCode means 0 (success).
480
+ // A non-numeric shape coerces to NaN, which the exec outputSchema rejects,
481
+ // failing the whole call and discarding the run's output — normalize an
482
+ // unparseable code to -1 so the user still gets their stdout/stderr.
483
+ const rawExit = typeof execution.exitCode === "number" ? execution.exitCode : Number(execution.exitCode ?? 0);
484
+ const exitCode = Number.isFinite(rawExit) ? Math.trunc(rawExit) : -1;
485
+ const cap = Math.max(MIN_OUTPUT_BYTES, Math.floor(opts.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES));
486
+ let stdout = "";
487
+ let stderr = "";
488
+ let captureError;
489
+ let stdoutTruncated = false;
490
+ let stderrTruncated = false;
491
+ // Truncated originals are KEPT (their path is returned) so the caller can
492
+ // page through them; everything else — untruncated captures, previews — is
493
+ // removed.
494
+ const toRemove = [];
495
+ try {
496
+ const [out, err] = await Promise.all([
497
+ this.readCaptureFile(sessionId, outPath, cap),
498
+ this.readCaptureFile(sessionId, errPath, cap),
499
+ ]);
500
+ stdout = out.text;
501
+ stderr = err.text;
502
+ stdoutTruncated = out.truncated;
503
+ stderrTruncated = err.truncated;
504
+ if (out.previewPath)
505
+ toRemove.push(out.previewPath);
506
+ if (err.previewPath)
507
+ toRemove.push(err.previewPath);
508
+ }
509
+ catch (e) {
510
+ captureError = e.message;
511
+ }
512
+ if (!stdoutTruncated)
513
+ toRemove.push(outPath);
514
+ if (!stderrTruncated)
515
+ toRemove.push(errPath);
516
+ try {
517
+ await this.control("ExecuteCommand", { sessionId, command: "rm", args: ["-f", ...toRemove] });
518
+ }
519
+ catch {
520
+ // Session may have gone away; capture files die with it.
521
+ }
522
+ return {
523
+ command,
524
+ args: opts.args ?? [],
525
+ stdout,
526
+ stderr,
527
+ exitCode,
528
+ ok: exitCode === 0 && !captureError,
529
+ ...(captureError ? { captureError } : {}),
530
+ ...(stdoutTruncated ? { stdoutTruncated: true, stdoutPath: outPath } : {}),
531
+ ...(stderrTruncated ? { stderrTruncated: true, stderrPath: errPath } : {}),
532
+ };
533
+ }
534
+ /**
535
+ * One-shot: boot a throwaway sandbox, run code (shell / python / javascript),
536
+ * return its output, and terminate the sandbox. Cost-guarded (1 vCPU, 1 GB,
537
+ * 10-min cap, 5-min idle) so an ephemeral run can never leak a billing session.
538
+ */
539
+ async runCode(language, code, opts = {}) {
540
+ const owner = await this.resolveOwner();
541
+ const create = await this.control("CreateSession", {
542
+ cpuCores: 1,
543
+ memoryMb: 1024,
544
+ maxDuration: "600s",
545
+ idleTimeoutMinutes: 5,
546
+ ...(owner.ownerType ? { ownerType: owner.ownerType } : {}),
547
+ ...(owner.ownerId ? { ownerId: owner.ownerId } : {}),
548
+ ...(owner.workspaceId ? { workspaceId: owner.workspaceId } : {}),
549
+ ...(opts.env && Object.keys(opts.env).length ? { env: opts.env } : {}),
550
+ });
551
+ const session = create.session ?? create;
552
+ const sessionId = (session.id ?? create.sessionId ?? create.id);
553
+ if (!sessionId)
554
+ throw new Error("runCode: could not read the created session id from CreateSession.");
555
+ try {
556
+ await this.waitForState(sessionId, "RUNNING");
557
+ const { file, command, args } = interpreterFor(language);
558
+ await this.writeTextFile(sessionId, file, code);
559
+ const result = await this.execCaptured(sessionId, command, {
560
+ args,
561
+ env: opts.env,
562
+ timeoutSeconds: opts.timeoutSeconds,
563
+ });
564
+ return { sessionId, language, ...result };
565
+ }
566
+ finally {
567
+ try {
568
+ await this.control("TerminateSession", { sessionId });
569
+ }
570
+ catch {
571
+ // Best-effort teardown; the idle/max-duration guards reap it regardless.
572
+ }
573
+ }
574
+ }
575
+ }
package/dist/http.d.ts ADDED
@@ -0,0 +1,17 @@
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: loopback-only by default; DNS-rebinding protection on
12
+ * (Host allowlist); static bearer auth or OAuth required for non-loopback
13
+ * binds; and per-session/global DoS caps are applied.
14
+ */
15
+ import http from "node:http";
16
+ import { TenkiClient } from "./client.js";
17
+ export declare function startHttp(client: TenkiClient | null, port: number): http.Server;