@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.
- package/CHANGELOG.md +161 -0
- package/LICENSE +21 -0
- package/README.md +192 -0
- package/SECURITY.md +50 -0
- package/dist/client.d.ts +152 -0
- package/dist/client.js +499 -0
- package/dist/http.d.ts +19 -0
- package/dist/http.js +234 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +59 -0
- package/dist/server.d.ts +30 -0
- package/dist/server.js +205 -0
- package/dist/tools/artifacts.d.ts +16 -0
- package/dist/tools/artifacts.js +19 -0
- package/dist/tools/auth_status.d.ts +35 -0
- package/dist/tools/auth_status.js +104 -0
- package/dist/tools/common.d.ts +33 -0
- package/dist/tools/common.js +42 -0
- package/dist/tools/exec.d.ts +4 -0
- package/dist/tools/exec.js +88 -0
- package/dist/tools/files.d.ts +4 -0
- package/dist/tools/files.js +22 -0
- package/dist/tools/files_ops.d.ts +12 -0
- package/dist/tools/files_ops.js +54 -0
- package/dist/tools/git.d.ts +4 -0
- package/dist/tools/git.js +30 -0
- package/dist/tools/identity.d.ts +4 -0
- package/dist/tools/identity.js +5 -0
- package/dist/tools/ports.d.ts +4 -0
- package/dist/tools/ports.js +6 -0
- package/dist/tools/previews.d.ts +18 -0
- package/dist/tools/previews.js +102 -0
- package/dist/tools/registry.d.ts +18 -0
- package/dist/tools/registry.js +98 -0
- package/dist/tools/run.d.ts +4 -0
- package/dist/tools/run.js +11 -0
- package/dist/tools/sandboxes.d.ts +4 -0
- package/dist/tools/sandboxes.js +66 -0
- package/dist/tools/sessions_admin.d.ts +9 -0
- package/dist/tools/sessions_admin.js +76 -0
- package/dist/tools/snapshots.d.ts +11 -0
- package/dist/tools/snapshots.js +91 -0
- package/dist/tools/ssh.d.ts +15 -0
- package/dist/tools/ssh.js +17 -0
- package/dist/tools/templates.d.ts +14 -0
- package/dist/tools/templates.js +151 -0
- package/dist/tools/volumes.d.ts +16 -0
- package/dist/tools/volumes.js +94 -0
- package/dist/tools/workspace.d.ts +4 -0
- package/dist/tools/workspace.js +129 -0
- 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;
|