@luxorai/mcp 0.0.0-stage → 0.4.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 +83 -0
- package/LICENSE +21 -0
- package/README.md +221 -2
- package/SECURITY.md +50 -0
- package/dist/client.d.ts +192 -0
- package/dist/client.js +621 -0
- package/dist/env.d.ts +6 -0
- package/dist/env.js +25 -0
- package/dist/http.d.ts +17 -0
- package/dist/http.js +297 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +61 -0
- package/dist/oauth.d.ts +39 -0
- package/dist/oauth.js +166 -0
- package/dist/server.d.ts +50 -0
- package/dist/server.js +238 -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 +108 -0
- package/dist/tools/common.d.ts +70 -0
- package/dist/tools/common.js +138 -0
- package/dist/tools/egress.d.ts +38 -0
- package/dist/tools/egress.js +153 -0
- package/dist/tools/exec.d.ts +4 -0
- package/dist/tools/exec.js +120 -0
- package/dist/tools/files.d.ts +4 -0
- package/dist/tools/files.js +55 -0
- package/dist/tools/files_ops.d.ts +12 -0
- package/dist/tools/files_ops.js +62 -0
- package/dist/tools/git.d.ts +3 -0
- package/dist/tools/git.js +92 -0
- package/dist/tools/identity.d.ts +4 -0
- package/dist/tools/identity.js +5 -0
- package/dist/tools/ports.d.ts +11 -0
- package/dist/tools/ports.js +27 -0
- package/dist/tools/previews.d.ts +18 -0
- package/dist/tools/previews.js +117 -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 +388 -0
- package/dist/tools/sessions_admin.d.ts +9 -0
- package/dist/tools/sessions_admin.js +102 -0
- package/dist/tools/snapshots.d.ts +11 -0
- package/dist/tools/snapshots.js +98 -0
- package/dist/tools/ssh.d.ts +15 -0
- package/dist/tools/ssh.js +43 -0
- package/dist/tools/templates.d.ts +15 -0
- package/dist/tools/templates.js +162 -0
- package/dist/tools/titles.d.ts +8 -0
- package/dist/tools/titles.js +102 -0
- package/dist/tools/volumes.d.ts +16 -0
- package/dist/tools/volumes.js +102 -0
- package/dist/tools/workspace.d.ts +12 -0
- package/dist/tools/workspace.js +68 -0
- package/package.json +60 -4
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { listQuery, ok, portSchema, protoEnum, searchSchema, sessionIdSchema, slugSchema } from "./common.js";
|
|
3
|
+
/** The sandbox web terminal (ttyd) port — tenki-app previewports.WebTerminalPort. */
|
|
4
|
+
const WEB_TERMINAL_PORT = 7681;
|
|
5
|
+
export function registerPreviews(server, client) {
|
|
6
|
+
// ── Unexpose a port (tear down its exposure + preview) ────────────────────────
|
|
7
|
+
server.tool("tenki_unexpose_port", "Remove an inbound port exposure from a sandbox, unbinding EVERY preview URL on that port and taking them offline. DEPRECATED upstream (UnexposePort): to take one specific preview URL offline use tenki_unbind_preview_url or tenki_delete_preview_url with its id.", {
|
|
8
|
+
session_id: sessionIdSchema.describe("The sandbox session whose port to unexpose."),
|
|
9
|
+
port: portSchema.describe("The TCP port inside the sandbox to unexpose (1-65535)."),
|
|
10
|
+
}, async ({ session_id, port }) => ok(await client.control("UnexposePort", { sessionId: session_id, port })));
|
|
11
|
+
// ── Create a shareable preview URL for a port ─────────────────────────────────
|
|
12
|
+
server.tool("tenki_create_preview_url", "Create a shareable public preview URL for a port in a sandbox. The sandbox must have inbound networking enabled (create it with allow_inbound).", {
|
|
13
|
+
session_id: sessionIdSchema.describe("The sandbox session serving the port."),
|
|
14
|
+
port: portSchema.describe("The TCP port inside the sandbox to create a preview URL for (1-65535)."),
|
|
15
|
+
slug: slugSchema,
|
|
16
|
+
expires_at: z
|
|
17
|
+
.string()
|
|
18
|
+
.optional()
|
|
19
|
+
.describe("Optional RFC-3339 timestamp at which the preview URL auto-expires. Omit to keep it until the sandbox ends."),
|
|
20
|
+
}, async ({ session_id, port, slug, expires_at }) => ok(await client.control("CreatePreviewUrl", {
|
|
21
|
+
sessionId: session_id,
|
|
22
|
+
port,
|
|
23
|
+
slug,
|
|
24
|
+
...(expires_at !== undefined ? { expiresAt: expires_at } : {}),
|
|
25
|
+
})));
|
|
26
|
+
// ── Open a viewer-token-gated (authenticated) preview of a port ──────────────
|
|
27
|
+
// OpenPreview creates an AUTHENTICATED-mode preview. The API resolves a
|
|
28
|
+
// regional edge URL for the web terminal port (7681); any other port, and any
|
|
29
|
+
// sandbox with runtime secrets, gets the authenticated fallback domain served
|
|
30
|
+
// by the sandbox gateway, for which it registers a gateway route
|
|
31
|
+
// (resolveAuthenticatedPreviewURLForPort / registerAuthenticatedGatewayPreview
|
|
32
|
+
// in tenki-app backend/internal/domain/sandbox/service/port.go). An earlier
|
|
33
|
+
// description claimed those fallback URLs 404; that was not reproducible
|
|
34
|
+
// from the current code or production config and was dropped — confirm with
|
|
35
|
+
// a live OpenPreview on a non-terminal port. It replaces any earlier
|
|
36
|
+
// authenticated preview on the same port (deleteAuthenticatedPreviewExposures).
|
|
37
|
+
server.tool("tenki_open_preview", `Open a viewer-token-gated preview of a port in a sandbox and return its URL and viewer token; unlike tenki_expose_port and tenki_create_preview_url, the URL is not public. Port ${WEB_TERMINAL_PORT} is the sandbox's browser web terminal (ttyd, in the standard images). Requires a sandbox created with allow_inbound; port 22 is reserved. Opening the same port again replaces its earlier preview URL.`, {
|
|
38
|
+
session_id: sessionIdSchema.describe("The sandbox serving the port."),
|
|
39
|
+
port: portSchema.describe(`The TCP port inside the sandbox to open a preview for (1-65535, not 22). ${WEB_TERMINAL_PORT} opens the web terminal.`),
|
|
40
|
+
expires_at: z
|
|
41
|
+
.string()
|
|
42
|
+
.optional()
|
|
43
|
+
.describe("Optional RFC-3339 timestamp at which the preview auto-expires. Omit to keep it until the sandbox ends."),
|
|
44
|
+
}, async ({ session_id, port, expires_at }) => ok(await client.control("OpenPreview", {
|
|
45
|
+
sessionId: session_id,
|
|
46
|
+
port,
|
|
47
|
+
...(expires_at !== undefined ? { expiresAt: expires_at } : {}),
|
|
48
|
+
})));
|
|
49
|
+
// ── List the preview URLs bound to a sandbox / project ────────────────────────
|
|
50
|
+
server.tool("tenki_list_preview_urls", "List the workspace's preview URLs with server-side search, binding-state filter, sorting and optional state-count facets; each row carries the bound sandbox's sessionState and sessionName. Pass session_id to list only the ones bound to that sandbox. Paginated: a nextPageToken in the response means more pages exist.", {
|
|
51
|
+
session_id: sessionIdSchema.optional().describe("Only preview URLs bound to this sandbox."),
|
|
52
|
+
search: searchSchema.describe("Free-text search over slug/id (max 256 chars)."),
|
|
53
|
+
states: z
|
|
54
|
+
.array(z.enum(["UNBOUND", "BOUND", "OFFLINE", "ORPHANED"]))
|
|
55
|
+
.max(16)
|
|
56
|
+
.optional()
|
|
57
|
+
.describe("Only preview URLs in any of these binding states (OFFLINE = bound to a non-running sandbox, ORPHANED = bound to a sandbox that no longer exists)."),
|
|
58
|
+
sort_by: z.enum(["SLUG", "STATE", "CREATED_AT"]).optional().describe("Column to sort by."),
|
|
59
|
+
sort_desc: z.boolean().optional().describe("Sort descending (default ascending)."),
|
|
60
|
+
include_facets: z.boolean().optional().describe("Also return per-state counts for the same filter scope."),
|
|
61
|
+
workspace_id: z.string().optional().describe("Workspace to list (defaults to the key's first workspace)."),
|
|
62
|
+
page_size: z.number().int().min(1).max(100).optional().describe("Rows per page (server default 20, max 100)."),
|
|
63
|
+
page_token: z.string().optional().describe("Cursor from a previous response's nextPageToken."),
|
|
64
|
+
}, async ({ session_id, search, states, sort_by, sort_desc, include_facets, workspace_id, page_size, page_token }) => {
|
|
65
|
+
// ListPreviewUrlsRequest.session_id (field 5) filters server-side and already
|
|
66
|
+
// scopes the query, so the WhoAmI lookup for a default workspace is skipped then.
|
|
67
|
+
const workspaceId = workspace_id ?? (session_id ? undefined : (await client.resolveOwner()).workspaceId);
|
|
68
|
+
const resp = await client.control("ListPreviewUrls", {
|
|
69
|
+
...(workspaceId ? { workspaceId } : {}),
|
|
70
|
+
...(session_id ? { sessionId: session_id } : {}),
|
|
71
|
+
...(states && states.length ? { states: states.map((s) => protoEnum("PREVIEW_URL_BINDING_STATE", s)) } : {}),
|
|
72
|
+
...(sort_by ? { sortBy: protoEnum("PREVIEW_URL_SORT_FIELD", sort_by) } : {}),
|
|
73
|
+
...listQuery(search, include_facets, sort_desc),
|
|
74
|
+
...(page_size !== undefined ? { pageSize: page_size } : {}),
|
|
75
|
+
...(page_token ? { pageToken: page_token } : {}),
|
|
76
|
+
});
|
|
77
|
+
// proto3 omits an empty repeated field; normalize so an empty page is an array.
|
|
78
|
+
return ok({
|
|
79
|
+
previewUrls: Array.isArray(resp.previewUrls) ? resp.previewUrls : [],
|
|
80
|
+
...(resp.nextPageToken ? { nextPageToken: resp.nextPageToken } : {}),
|
|
81
|
+
...(resp.facets ? { facets: resp.facets } : {}),
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
// ── Get / delete a specific preview URL ───────────────────────────────────────
|
|
85
|
+
server.tool("tenki_get_preview_url", "Fetch a specific preview URL's details by id OR by slug (pass exactly one).", {
|
|
86
|
+
preview_url_id: z.string().min(1).optional().describe("The preview URL id."),
|
|
87
|
+
slug: slugSchema.optional().describe("The preview URL's slug (alternative to preview_url_id)."),
|
|
88
|
+
}, async ({ preview_url_id, slug }) => {
|
|
89
|
+
if ((preview_url_id === undefined) === (slug === undefined)) {
|
|
90
|
+
throw new Error("tenki_get_preview_url: pass exactly one of preview_url_id or slug.");
|
|
91
|
+
}
|
|
92
|
+
return ok(await client.control("GetPreviewUrl", preview_url_id ? { previewUrlId: preview_url_id } : { slug }));
|
|
93
|
+
});
|
|
94
|
+
server.tool("tenki_delete_preview_url", "Delete a preview URL by id, taking it permanently offline.", { preview_url_id: z.string().describe("The preview URL id to delete.") }, async ({ preview_url_id }) => ok(await client.control("DeletePreviewUrl", { previewUrlId: preview_url_id })));
|
|
95
|
+
// ── Touch (keep-alive) a preview ──────────────────────────────────────────────
|
|
96
|
+
server.tool("tenki_touch_preview", "Refresh (keep-alive) a live preview by its preview token so it isn't torn down as idle.", { preview_token: z.string().describe("The preview token (from create_preview_url / open_preview).") },
|
|
97
|
+
// TouchPreview takes a previewToken, not session/port (live-verified).
|
|
98
|
+
async ({ preview_token }) => ok(await client.control("TouchPreview", { previewToken: preview_token })));
|
|
99
|
+
// ── Bind / unbind a named preview URL to a session+port ───────────────────────
|
|
100
|
+
// Advanced routing primitives (shapes SDK-name-verified; not exercised end-to-end here).
|
|
101
|
+
server.tool("tenki_bind_preview_url", "Bind a named preview URL to a sandbox session and port (advanced routing). A URL whose expiry has lapsed can only be re-bound with a new expires_at.", {
|
|
102
|
+
preview_url_id: z.string().describe("The preview URL id to bind."),
|
|
103
|
+
session_id: sessionIdSchema,
|
|
104
|
+
port: portSchema.describe("The port to route the preview URL to."),
|
|
105
|
+
expires_at: z
|
|
106
|
+
.string()
|
|
107
|
+
.optional()
|
|
108
|
+
.describe("Optional RFC-3339 timestamp replacing the URL's expiry deadline (required when re-binding a lapsed URL)."),
|
|
109
|
+
}, async ({ preview_url_id, session_id, port, expires_at }) => ok(await client.control("BindPreviewUrl", {
|
|
110
|
+
previewUrlId: preview_url_id,
|
|
111
|
+
sessionId: session_id,
|
|
112
|
+
port,
|
|
113
|
+
...(expires_at !== undefined ? { expiresAt: expires_at } : {}),
|
|
114
|
+
})));
|
|
115
|
+
server.tool("tenki_unbind_preview_url", "Unbind a named preview URL from its current session/port (advanced routing).", { preview_url_id: z.string().describe("The preview URL id to unbind.") }, async ({ preview_url_id }) => ok(await client.control("UnbindPreviewUrl", { previewUrlId: preview_url_id })));
|
|
116
|
+
server.tool("tenki_resolve_preview_token", "Resolve a preview token to the sandbox/port it points at (advanced).", { token: z.string().describe("The preview token to resolve.") }, async ({ token }) => ok(await client.control("ResolvePreviewToken", { token })));
|
|
117
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { ok, envSchema } from "./common.js";
|
|
3
|
+
/** The headline one-shot execution tool. */
|
|
4
|
+
export function registerRun(server, client) {
|
|
5
|
+
server.tool("tenki_run_code", "Boot a throwaway microVM, run a snippet (shell/python/javascript), return its stdout/stderr/exit code, and tear the sandbox down. Cost-guarded and self-terminating. Use this for one-shot execution when you don't need a persistent sandbox. Output over ~64KB per stream is truncated head+tail — and the sandbox is gone, so for large output use tenki_create_sandbox + tenki_exec and page through the retained file.", {
|
|
6
|
+
language: z.enum(["shell", "python", "javascript"]).describe("Interpreter for the snippet."),
|
|
7
|
+
code: z.string().describe("The code to run."),
|
|
8
|
+
env: envSchema,
|
|
9
|
+
timeout_seconds: z.number().int().positive().optional().describe("Max seconds for the run (default 30)."),
|
|
10
|
+
}, async ({ language, code, env, timeout_seconds }) => ok(await client.runCode(language, code, { env, timeoutSeconds: timeout_seconds })));
|
|
11
|
+
}
|
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { isClientAbort } from "../client.js";
|
|
3
|
+
import { ok, envSchema, pathSchema, protoEnum, sessionIdSchema, tagsSchema } from "./common.js";
|
|
4
|
+
import { allowedPortsSchema, egressBodyToWire, httpRulesSchema, interceptHttpsSchema, policyNameSchema } from "./egress.js";
|
|
5
|
+
/**
|
|
6
|
+
* Tailnet attachment: join the sandbox to a Tailscale tailnet with a direct auth key (tenki-app #5818)
|
|
7
|
+
* or a Tailscale connection saved in the dashboard (wire `profileId`, #6348).
|
|
8
|
+
*/
|
|
9
|
+
const unique = (xs) => new Set(xs).size === xs.length;
|
|
10
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
11
|
+
// wake_on_connect and ephemeral_pause_policy=RECREATE_ON_RESUME exist in the proto but the API
|
|
12
|
+
// rejects them as Unimplemented ("requires milestone 2"), so they are not exposed yet.
|
|
13
|
+
const tailnetSchema = z.object({
|
|
14
|
+
auth_key: z.string().min(1).max(4096).optional().describe("Tailscale auth key (write-only; sent once to the API, never returned). Pass this or connection_id."),
|
|
15
|
+
connection_id: z
|
|
16
|
+
.string()
|
|
17
|
+
.regex(UUID, "connection_id is a UUID")
|
|
18
|
+
.optional()
|
|
19
|
+
.describe("ID of a Tailscale connection saved in the Tenki dashboard, instead of auth_key (permission_denied unless Tailscale connections are enabled for the workspace). Tags default to the connection's authorized tags and must be a subset of them; the node is always ephemeral; control_url and exit nodes are not allowed."),
|
|
20
|
+
connection_revision: z
|
|
21
|
+
.number()
|
|
22
|
+
.int()
|
|
23
|
+
.min(1)
|
|
24
|
+
.max(4294967295)
|
|
25
|
+
.optional()
|
|
26
|
+
.describe("Pin the saved connection's revision; the create is refused (aborted) if the connection changed since. Omit to use its current revision."),
|
|
27
|
+
provider: z.enum(["tailscale"]).optional().describe("Tailnet provider (default tailscale)."),
|
|
28
|
+
hostname: z
|
|
29
|
+
.string()
|
|
30
|
+
.max(63)
|
|
31
|
+
.regex(/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/, "lowercase letters, digits and hyphens")
|
|
32
|
+
.optional()
|
|
33
|
+
.describe("Node hostname on the tailnet (default derived from the sandbox)."),
|
|
34
|
+
tags: z
|
|
35
|
+
.array(z.string().regex(/^tag:[a-z0-9-]+$/, "tags look like tag:name").max(128))
|
|
36
|
+
.max(16)
|
|
37
|
+
.refine(unique, "tags must be unique")
|
|
38
|
+
.optional()
|
|
39
|
+
.describe("ACL tags to advertise (tag:… form, ≤16, unique)."),
|
|
40
|
+
ephemeral: z.boolean().optional().describe("Register as an ephemeral node (removed from the tailnet when the sandbox ends)."),
|
|
41
|
+
control_url: z
|
|
42
|
+
.string()
|
|
43
|
+
.max(2048)
|
|
44
|
+
.regex(/^https:\/\/[^\s#@]+$/, "must be an https URL without userinfo or fragment")
|
|
45
|
+
.optional()
|
|
46
|
+
.describe("Alternative control server URL. Must be https and on the operator-approved control-URL list, otherwise the API returns permission_denied."),
|
|
47
|
+
exit_node: z.string().max(256).optional().describe("Exit node to route through."),
|
|
48
|
+
accept_routes: z.boolean().optional().describe("Accept subnet routes advertised on the tailnet."),
|
|
49
|
+
expose_ports: z
|
|
50
|
+
.array(z.number().int().min(1).max(65535))
|
|
51
|
+
.max(32)
|
|
52
|
+
.refine(unique, "ports must be unique")
|
|
53
|
+
.optional()
|
|
54
|
+
.describe("Sandbox ports reachable from the tailnet (≤32, unique)."),
|
|
55
|
+
wait_for_online: z.boolean().optional().describe("Hold sandbox creation until the node is online on the tailnet."),
|
|
56
|
+
exit_policy: z
|
|
57
|
+
.enum(["TENKI_ALLOWLIST", "EXIT_NODE_MANAGED"])
|
|
58
|
+
.optional()
|
|
59
|
+
.describe("Egress policy while attached: TENKI_ALLOWLIST keeps Tenki's egress rules; EXIT_NODE_MANAGED routes all egress through exit_node (requires exit_node and must be enabled for the workspace)."),
|
|
60
|
+
})
|
|
61
|
+
.refine((t) => (t.auth_key !== undefined) !== (t.connection_id !== undefined), "pass exactly one of auth_key or connection_id")
|
|
62
|
+
.refine((t) => t.connection_revision === undefined || t.connection_id !== undefined, "connection_revision requires connection_id")
|
|
63
|
+
.refine((t) => t.connection_id === undefined || (!t.control_url && !t.exit_node && t.exit_policy !== "EXIT_NODE_MANAGED"), "a saved Tailscale connection does not take control_url, exit_node or EXIT_NODE_MANAGED")
|
|
64
|
+
.refine((t) => t.exit_policy !== "EXIT_NODE_MANAGED" || !!t.exit_node, "exit_policy EXIT_NODE_MANAGED requires exit_node");
|
|
65
|
+
/** Secret files: materialize a workspace secret (or inline text) as a file in the guest at runtime. */
|
|
66
|
+
const secretFileSchema = z
|
|
67
|
+
.object({
|
|
68
|
+
path: pathSchema.describe("Absolute guest path to write, e.g. /home/tenki/.npmrc."),
|
|
69
|
+
secret_name: z.string().min(1).optional().describe("Workspace secret whose value becomes the file content."),
|
|
70
|
+
content: z.string().optional().describe("Inline (unresolved) text to write instead of a secret."),
|
|
71
|
+
})
|
|
72
|
+
.refine((f) => (f.secret_name !== undefined) !== (f.content !== undefined), "pass exactly one of secret_name or content");
|
|
73
|
+
/** Non-secret/secret request-scoped env maps share the API's key/value bounds. */
|
|
74
|
+
const MAX_ENV_PAIRS = 64;
|
|
75
|
+
const boundedEnvSchema = z
|
|
76
|
+
.record(z.string().regex(/^[A-Za-z_][A-Za-z0-9_]*$/).max(128), z.string().max(8192))
|
|
77
|
+
.refine((o) => Object.keys(o).length <= MAX_ENV_PAIRS, `at most ${MAX_ENV_PAIRS} pairs`)
|
|
78
|
+
.optional();
|
|
79
|
+
/** Sandbox (session) lifecycle. */
|
|
80
|
+
export function registerSandboxes(server, client) {
|
|
81
|
+
server.registerTool("tenki_create_sandbox", {
|
|
82
|
+
description: "Create a persistent sandbox microVM, optionally from a snapshot or template image. Returns the session (id, state), its data-plane endpoint, and any `warnings` the API attached (e.g. a max duration that was capped or overridden by sticky). Boots in ~2s. Use tenki_exec / tenki_read_file / tenki_write_file against the returned session id.",
|
|
83
|
+
inputSchema: z
|
|
84
|
+
.object({
|
|
85
|
+
name: z.string().max(64).optional().describe("Human-readable name."),
|
|
86
|
+
cpu_cores: z.number().int().min(1).max(128).optional().describe("vCPUs (default 2; API ceiling 128, your workspace plan may cap lower)."),
|
|
87
|
+
memory_mb: z
|
|
88
|
+
.number()
|
|
89
|
+
.int()
|
|
90
|
+
.min(128)
|
|
91
|
+
.max(524288)
|
|
92
|
+
.refine((n) => n % 2 === 0, "memory_mb must be aligned to 2 MiB (even)")
|
|
93
|
+
.optional()
|
|
94
|
+
.describe("Memory in MB (default 4096; must be even; API ceiling 524288, your workspace plan may cap lower)."),
|
|
95
|
+
disk_size_gb: z.number().int().min(5).max(100).optional().describe("Disk in GB (default 5; 5-100)."),
|
|
96
|
+
max_duration_seconds: z.number().int().positive().optional().describe("Hard lifetime cap in seconds."),
|
|
97
|
+
idle_timeout_minutes: z
|
|
98
|
+
.number()
|
|
99
|
+
.int()
|
|
100
|
+
.min(0)
|
|
101
|
+
.optional()
|
|
102
|
+
.describe("Reap after N idle minutes (0 disables the idle reaper). Not changeable after creation."),
|
|
103
|
+
sticky: z
|
|
104
|
+
.boolean()
|
|
105
|
+
.optional()
|
|
106
|
+
.describe("Keep the sandbox alive with no hard lifetime cap (overrides max_duration/idle timeout with a warning). Not allowed for service credentials."),
|
|
107
|
+
clone_repo_url: z.string().optional().describe("Git URL to clone into the sandbox on boot."),
|
|
108
|
+
allow_outbound: z.boolean().optional().describe("Allow outbound networking (on by default; a sandbox booted from a snapshot or template image inherits its parent's setting). false blocks all egress, including any egress allowlist."),
|
|
109
|
+
allow_inbound: z.boolean().optional().describe("Allow inbound networking (off by default)."),
|
|
110
|
+
egress_allow_domains: z
|
|
111
|
+
.array(z.string().max(253))
|
|
112
|
+
.max(256)
|
|
113
|
+
.optional()
|
|
114
|
+
.describe("Egress allowlist: exact hostnames or single-label '*.example.com' wildcards. Omit for unrestricted egress. Cannot be combined with allow_outbound: false. Any egress setting (this, egress_allow_cidrs, egress_intercept_https, egress_http_rules, egress_allowed_ports, egress_policies) is refused with failed_precondition if session egress policies are disabled for the workspace (on by default; Tenki staff can turn it off per workspace)."),
|
|
115
|
+
egress_allow_cidrs: z
|
|
116
|
+
.array(z.string().max(18))
|
|
117
|
+
.max(64)
|
|
118
|
+
.optional()
|
|
119
|
+
.describe("Egress allowlist: IPv4 prefixes reachable without a name lookup."),
|
|
120
|
+
egress_intercept_https: interceptHttpsSchema.optional(),
|
|
121
|
+
egress_http_rules: httpRulesSchema
|
|
122
|
+
.optional()
|
|
123
|
+
.describe("Per-host HTTP method/path rules (≤64). Need egress_intercept_https unless a pinned egress policy turns interception on. On ports 80 and 443 a request matching no rule is refused with 403."),
|
|
124
|
+
egress_allowed_ports: allowedPortsSchema
|
|
125
|
+
.optional()
|
|
126
|
+
.describe("Extra public TCP ports to open besides 80/443 (≤64). Need egress_intercept_https unless a pinned egress policy turns interception on. They skip HTTP inspection but still reach only allowed destinations, so 22 permits git over SSH, including pushes."),
|
|
127
|
+
egress_policies: z
|
|
128
|
+
.array(policyNameSchema)
|
|
129
|
+
.max(16)
|
|
130
|
+
.optional()
|
|
131
|
+
.describe("Names of saved workspace egress policies to pin (≤16; see tenki_list_egress_policies). Merged with the inline egress_* fields at create; later edits or deletes of a policy do not change this sandbox. Cannot be combined with allow_outbound: false."),
|
|
132
|
+
snapshot_id: z
|
|
133
|
+
.string()
|
|
134
|
+
.optional()
|
|
135
|
+
.describe("Boot from a snapshot. Expired snapshots and pause snapshots are refused (failed_precondition); resume a paused sandbox with tenki_resume_sandbox instead."),
|
|
136
|
+
image: z
|
|
137
|
+
.string()
|
|
138
|
+
.optional()
|
|
139
|
+
.describe("Boot from a template image, passed as a reference STRING: the imageDigestRef of a READY build from tenki_build_template / tenki_get_template_build (e.g. 'ws/name@sha256:...'), or 'workspace/name' for its latest version."),
|
|
140
|
+
template_spec_id: z
|
|
141
|
+
.string()
|
|
142
|
+
.optional()
|
|
143
|
+
.describe("Run a typed template's current spec directly in the new sandbox (no build / no published image)."),
|
|
144
|
+
setup_env: boundedEnvSchema.describe("Non-secret request-scoped env for template setup steps (≤64 pairs)."),
|
|
145
|
+
setup_secrets: boundedEnvSchema.describe("Secret request-scoped env for checkout/setup only; never persisted (≤64 pairs)."),
|
|
146
|
+
secret_overrides: z
|
|
147
|
+
.record(z.string(), z.string())
|
|
148
|
+
.refine((o) => Object.keys(o).length <= MAX_ENV_PAIRS, `at most ${MAX_ENV_PAIRS} pairs`)
|
|
149
|
+
.optional()
|
|
150
|
+
.describe("Map of a declared runtime secret name to the workspace secret to use instead (≤64 pairs). Booting another workspace's image that declares runtime secrets needs an entry for each of them (map a name to itself to keep it), otherwise failed_precondition."),
|
|
151
|
+
secret_files: z
|
|
152
|
+
.array(secretFileSchema)
|
|
153
|
+
.max(32)
|
|
154
|
+
.optional()
|
|
155
|
+
.describe("Files to write into the guest at boot from workspace secrets or inline text (≤32)."),
|
|
156
|
+
secret_policies: z
|
|
157
|
+
.array(policyNameSchema)
|
|
158
|
+
.max(64)
|
|
159
|
+
.optional()
|
|
160
|
+
.describe("Names of saved secret access policies to attach (≤64). A policy holds the request rules that inject a workspace secret into matching outbound HTTPS requests, so the value never enters the sandbox; policies are managed in the Tenki dashboard / workspace secrets API, not through this server."),
|
|
161
|
+
tailnet: tailnetSchema
|
|
162
|
+
.optional()
|
|
163
|
+
.describe("Join the sandbox to a Tailscale tailnet at boot with an auth key or a saved connection (requires tailnet attachments to be enabled for the workspace; otherwise the API returns permission_denied). The session then carries a tailnetStatus (state, fqdn, ips). A tailnet-attached sandbox cannot be paused yet. Without this, a sandbox joins the workspace's default Tailscale connection if one is set, unless disable_tailnet is true."),
|
|
164
|
+
disable_tailnet: z
|
|
165
|
+
.boolean()
|
|
166
|
+
.optional()
|
|
167
|
+
.describe("Do not join the workspace's default Tailscale connection (a tailnet-attached sandbox cannot be paused yet). Cannot be combined with tailnet."),
|
|
168
|
+
volumes: z
|
|
169
|
+
.array(z.object({
|
|
170
|
+
volume_id: z.string().describe("Volume id to mount."),
|
|
171
|
+
mount_path: pathSchema.describe("Absolute mount path inside the sandbox, e.g. /mnt/data."),
|
|
172
|
+
read_only: z.boolean().optional().describe("Mount read-only (default read-write)."),
|
|
173
|
+
}))
|
|
174
|
+
.optional()
|
|
175
|
+
.describe("Volumes to attach at boot (also attachable later with tenki_attach_volume)."),
|
|
176
|
+
ssh_authorized_keys: z
|
|
177
|
+
.array(z.string().min(1))
|
|
178
|
+
.optional()
|
|
179
|
+
.describe("SSH public keys to authorize at boot (also settable later with tenki_update_ssh_keys)."),
|
|
180
|
+
metadata: z.record(z.string(), z.string()).optional().describe("Free-form key→value metadata stored on the session."),
|
|
181
|
+
tags: tagsSchema.describe("Tags for later filtering (≤20, each ≤32 chars of a-z 0-9 _ : . -)."),
|
|
182
|
+
workspace_id: z.string().optional().describe("Workspace to create in (defaults to the key's first workspace)."),
|
|
183
|
+
env: envSchema,
|
|
184
|
+
wait_ready: z
|
|
185
|
+
.boolean()
|
|
186
|
+
.optional()
|
|
187
|
+
.describe("Wait until the sandbox is RUNNING before returning (default true). The server holds the request for a bounded time; the tool then polls for the remainder."),
|
|
188
|
+
})
|
|
189
|
+
.strict(),
|
|
190
|
+
}, async (a) => {
|
|
191
|
+
const owner = await client.resolveOwner();
|
|
192
|
+
const workspaceId = a.workspace_id ?? owner.workspaceId;
|
|
193
|
+
const wait = a.wait_ready !== false;
|
|
194
|
+
const egressPolicies = a.egress_policies?.length ? a.egress_policies : undefined;
|
|
195
|
+
// Named policies are merged with the inline fields server-side, so standalone checks only apply without them.
|
|
196
|
+
const egress = egressBodyToWire("tenki_create_sandbox", {
|
|
197
|
+
allow_domains: a.egress_allow_domains,
|
|
198
|
+
allow_cidrs: a.egress_allow_cidrs,
|
|
199
|
+
intercept_https: a.egress_intercept_https,
|
|
200
|
+
http_rules: a.egress_http_rules,
|
|
201
|
+
allowed_ports: a.egress_allowed_ports,
|
|
202
|
+
}, !egressPolicies);
|
|
203
|
+
// allow_outbound: false blocks all egress, so an allowlist alongside it is a contradiction.
|
|
204
|
+
if ((egress || egressPolicies) && a.allow_outbound === false) {
|
|
205
|
+
throw new Error("tenki_create_sandbox: egress_* fields and egress_policies cannot be combined with allow_outbound: false — that blocks all egress, so the allowlist would never apply.");
|
|
206
|
+
}
|
|
207
|
+
if (a.disable_tailnet && a.tailnet) {
|
|
208
|
+
throw new Error("tenki_create_sandbox: pass tailnet or disable_tailnet, not both.");
|
|
209
|
+
}
|
|
210
|
+
// allow_inbound / allow_outbound are `optional bool` with presence semantics on
|
|
211
|
+
// the wire, so an explicit false is sent, not dropped (sticky is a plain bool;
|
|
212
|
+
// sending false there is harmless).
|
|
213
|
+
const body = {
|
|
214
|
+
...(owner.ownerType ? { ownerType: owner.ownerType } : {}),
|
|
215
|
+
...(owner.ownerId ? { ownerId: owner.ownerId } : {}),
|
|
216
|
+
...(workspaceId ? { workspaceId } : {}),
|
|
217
|
+
...(a.name ? { name: a.name } : {}),
|
|
218
|
+
...(a.cpu_cores !== undefined ? { cpuCores: a.cpu_cores } : {}),
|
|
219
|
+
...(a.memory_mb !== undefined ? { memoryMb: a.memory_mb } : {}),
|
|
220
|
+
...(a.disk_size_gb !== undefined ? { diskSizeGb: a.disk_size_gb } : {}),
|
|
221
|
+
...(a.max_duration_seconds ? { maxDuration: `${a.max_duration_seconds}s` } : {}),
|
|
222
|
+
...(a.idle_timeout_minutes !== undefined ? { idleTimeoutMinutes: a.idle_timeout_minutes } : {}),
|
|
223
|
+
...(a.sticky !== undefined ? { sticky: a.sticky } : {}),
|
|
224
|
+
...(a.clone_repo_url ? { cloneRepoUrl: a.clone_repo_url } : {}),
|
|
225
|
+
...(a.allow_outbound !== undefined ? { allowOutbound: a.allow_outbound } : {}),
|
|
226
|
+
...(a.allow_inbound !== undefined ? { allowInbound: a.allow_inbound } : {}),
|
|
227
|
+
...(egress ? { egress } : {}),
|
|
228
|
+
...(egressPolicies ? { egressPolicies } : {}),
|
|
229
|
+
...(a.snapshot_id ? { snapshotId: a.snapshot_id } : {}),
|
|
230
|
+
// The API still uses registryRef internally for template-image launches.
|
|
231
|
+
...(a.image ? { registryRef: a.image } : {}),
|
|
232
|
+
...(a.template_spec_id ? { templateSpecId: a.template_spec_id } : {}),
|
|
233
|
+
...(a.setup_env && Object.keys(a.setup_env).length ? { setupEnv: a.setup_env } : {}),
|
|
234
|
+
...(a.setup_secrets && Object.keys(a.setup_secrets).length ? { setupSecrets: a.setup_secrets } : {}),
|
|
235
|
+
...(a.secret_overrides && Object.keys(a.secret_overrides).length ? { secretOverrides: a.secret_overrides } : {}),
|
|
236
|
+
...(a.secret_files && a.secret_files.length
|
|
237
|
+
? {
|
|
238
|
+
secretFiles: a.secret_files.map((f) => ({
|
|
239
|
+
path: f.path,
|
|
240
|
+
...(f.secret_name !== undefined ? { raw: { name: f.secret_name } } : { content: f.content }),
|
|
241
|
+
})),
|
|
242
|
+
}
|
|
243
|
+
: {}),
|
|
244
|
+
...(a.secret_policies && a.secret_policies.length ? { secretPolicies: a.secret_policies } : {}),
|
|
245
|
+
...(a.tailnet
|
|
246
|
+
? {
|
|
247
|
+
tailnet: {
|
|
248
|
+
...(a.tailnet.auth_key !== undefined
|
|
249
|
+
? { authKey: a.tailnet.auth_key }
|
|
250
|
+
: {
|
|
251
|
+
profileId: a.tailnet.connection_id,
|
|
252
|
+
...(a.tailnet.connection_revision ? { profileRevision: a.tailnet.connection_revision } : {}),
|
|
253
|
+
}),
|
|
254
|
+
...(a.tailnet.provider ? { provider: a.tailnet.provider } : {}),
|
|
255
|
+
...(a.tailnet.hostname ? { hostname: a.tailnet.hostname } : {}),
|
|
256
|
+
...(a.tailnet.tags?.length ? { tags: a.tailnet.tags } : {}),
|
|
257
|
+
...(a.tailnet.ephemeral ? { ephemeral: true } : {}),
|
|
258
|
+
...(a.tailnet.control_url ? { controlUrl: a.tailnet.control_url } : {}),
|
|
259
|
+
...(a.tailnet.exit_node ? { exitNode: a.tailnet.exit_node } : {}),
|
|
260
|
+
...(a.tailnet.accept_routes ? { acceptRoutes: true } : {}),
|
|
261
|
+
...(a.tailnet.expose_ports?.length ? { exposePorts: a.tailnet.expose_ports } : {}),
|
|
262
|
+
...(a.tailnet.wait_for_online ? { waitForOnline: true } : {}),
|
|
263
|
+
...(a.tailnet.exit_policy ? { exitPolicy: protoEnum("TAILNET_EXIT_POLICY", a.tailnet.exit_policy) } : {}),
|
|
264
|
+
},
|
|
265
|
+
}
|
|
266
|
+
: {}),
|
|
267
|
+
...(a.disable_tailnet ? { disableTailnet: true } : {}),
|
|
268
|
+
...(a.volumes && a.volumes.length
|
|
269
|
+
? {
|
|
270
|
+
volumes: a.volumes.map((v) => ({
|
|
271
|
+
volumeId: v.volume_id,
|
|
272
|
+
mountPath: v.mount_path,
|
|
273
|
+
// proto field is `readonly` (one word) — `readOnly` is discarded by the API.
|
|
274
|
+
...(v.read_only !== undefined ? { readonly: v.read_only } : {}),
|
|
275
|
+
})),
|
|
276
|
+
}
|
|
277
|
+
: {}),
|
|
278
|
+
...(a.ssh_authorized_keys && a.ssh_authorized_keys.length ? { sshAuthorizedKeys: a.ssh_authorized_keys } : {}),
|
|
279
|
+
...(a.metadata && Object.keys(a.metadata).length ? { metadata: a.metadata } : {}),
|
|
280
|
+
...(a.tags && a.tags.length ? { tags: a.tags } : {}),
|
|
281
|
+
...(a.env && Object.keys(a.env).length ? { env: a.env } : {}),
|
|
282
|
+
// Server-side bounded wait (CreateSessionRequest.wait_ready); on expiry the
|
|
283
|
+
// session comes back in its current state and we fall back to polling.
|
|
284
|
+
...(wait ? { waitReady: true } : {}),
|
|
285
|
+
};
|
|
286
|
+
const resp = await client.control("CreateSession", body);
|
|
287
|
+
const session = resp.session ?? resp;
|
|
288
|
+
const sessionId = session.id ?? resp.sessionId;
|
|
289
|
+
const dataPlaneEndpoint = resp.dataPlaneEndpoint ?? resp.data_plane_endpoint;
|
|
290
|
+
const warnings = Array.isArray(resp.warnings) ? resp.warnings : [];
|
|
291
|
+
const finalSession = wait && sessionId && !String(session.state ?? "").includes("RUNNING") ? await client.waitForState(sessionId, "RUNNING") : session;
|
|
292
|
+
return ok({ session: finalSession, dataPlaneEndpoint, ...(warnings.length ? { warnings } : {}) });
|
|
293
|
+
});
|
|
294
|
+
server.tool("tenki_get_sandbox", "Fetch a sandbox's current state and metadata.", { session_id: sessionIdSchema }, async ({ session_id }) => ok(await client.control("GetSession", { sessionId: session_id })));
|
|
295
|
+
server.tool("tenki_get_sandbox_metrics", "Get a sandbox's CPU and memory usage averaged over a recent window (default 5 minutes; 1 minute to 30 days): average cores / percent of limit, average bytes / percent of limit, sample count and coverage. Use to right-size a sandbox or spot a runaway process.", {
|
|
296
|
+
session_id: sessionIdSchema,
|
|
297
|
+
window_seconds: z
|
|
298
|
+
.number()
|
|
299
|
+
.int()
|
|
300
|
+
.min(60)
|
|
301
|
+
.max(30 * 24 * 3600)
|
|
302
|
+
.optional()
|
|
303
|
+
.describe("Averaging window in seconds ending now: 60 (1 minute) to 2592000 (30 days). Omit for the API default (5 minutes)."),
|
|
304
|
+
}, async ({ session_id, window_seconds }) => ok(await client.control("GetSessionMetrics", {
|
|
305
|
+
sessionId: session_id,
|
|
306
|
+
...(window_seconds ? { window: `${window_seconds}s` } : {}),
|
|
307
|
+
})));
|
|
308
|
+
server.tool("tenki_list_sandboxes", "List the credential's sandboxes (workspace scope is inferred from the API key), optionally filtered by tags or stickiness. Paginated: a nextPageToken in the response means more pages exist.", {
|
|
309
|
+
include_terminated: z.boolean().optional().describe("Include terminated sandboxes (default false)."),
|
|
310
|
+
tags: z.array(z.string()).optional().describe("Only sandboxes carrying all of these tags."),
|
|
311
|
+
sticky: z.boolean().optional().describe("Only sticky (true) or only non-sticky (false) sandboxes."),
|
|
312
|
+
page_size: z.number().int().min(1).max(100).optional().describe("Rows per page (max 100)."),
|
|
313
|
+
page_token: z.string().optional().describe("Cursor from a previous response's nextPageToken."),
|
|
314
|
+
}, async ({ include_terminated, tags, sticky, page_size, page_token }) => ok(await client.control("ListSessions", {
|
|
315
|
+
...(include_terminated ? { includeTerminated: true } : {}),
|
|
316
|
+
...(tags && tags.length ? { tags } : {}),
|
|
317
|
+
...(sticky !== undefined ? { sticky } : {}),
|
|
318
|
+
...(page_size ? { pageSize: page_size } : {}),
|
|
319
|
+
...(page_token ? { pageToken: page_token } : {}),
|
|
320
|
+
})));
|
|
321
|
+
server.tool("tenki_terminate_sandbox", "Terminate (destroy) a sandbox. The microVM and its filesystem are gone after this.", { session_id: sessionIdSchema }, async ({ session_id }) => ok(await client.control("TerminateSession", { sessionId: session_id })));
|
|
322
|
+
server.tool("tenki_pause_sandbox", "Pause a sandbox (snapshot + suspend) so it can be resumed later. By default the call blocks until the pause checkpoint is saved on the sandbox's host (replication to storage continues in the background); pass async to return as soon as the pause is accepted and poll tenki_get_sandbox for PAUSED. A sandbox attached to a tailnet cannot be paused yet (failed_precondition).", {
|
|
323
|
+
session_id: sessionIdSchema,
|
|
324
|
+
async: z.boolean().optional().describe("Return once the pause is accepted instead of waiting for it to finish (default false)."),
|
|
325
|
+
}, async ({ session_id, async: isAsync }) => ok(await client.control("PauseSession", { sessionId: session_id, ...(isAsync ? { async: true } : {}) })));
|
|
326
|
+
server.tool("tenki_resume_sandbox", "Resume a previously paused sandbox. By default the call blocks until the sandbox is RUNNING (or the resume fails, as aborted with the reason); pass async to return as soon as the resume is accepted and poll tenki_get_sandbox for RUNNING. Calling it while a resume is already in progress joins that resume instead of failing.", {
|
|
327
|
+
session_id: sessionIdSchema,
|
|
328
|
+
async: z.boolean().optional().describe("Return once the resume is accepted instead of waiting for RUNNING (default false)."),
|
|
329
|
+
}, async ({ session_id, async: isAsync }, extra) => {
|
|
330
|
+
try {
|
|
331
|
+
return ok(await client.control("ResumeSession", { sessionId: session_id, ...(isAsync ? { async: true } : {}) }, undefined, extra?.signal));
|
|
332
|
+
}
|
|
333
|
+
catch (e) {
|
|
334
|
+
// The deadline ends the wait for RUNNING, not the resume itself (it runs detached server-side).
|
|
335
|
+
// Only a client-side abort qualifies: a server "resume failed" stays as the server said it.
|
|
336
|
+
if (!isAsync && e instanceof Error && isClientAbort(e)) {
|
|
337
|
+
throw new Error(`${e.message} — the sandbox may still be resuming; check tenki_get_sandbox, or call tenki_resume_sandbox again (it joins the resume in progress).`);
|
|
338
|
+
}
|
|
339
|
+
throw e;
|
|
340
|
+
}
|
|
341
|
+
});
|
|
342
|
+
server.tool("tenki_fork_sandbox", "Fork a RUNNING sandbox: snapshot its memory and disk, then start child sandboxes that continue from that state while the source keeps running. Children get the source's size, lifetime limits, inbound/outbound and egress settings and tags, plus fork_of_session_id / fork_snapshot_id metadata; they do not copy its tailnet attachment (they join the workspace's default Tailscale connection, if one is set). Returns snapshotId and the child sessions. Refused while volumes are attached (failed_precondition; detach them first) or when forking is disabled for the workspace (permission_denied). The fork snapshot stays in tenki_list_snapshots, named after the prefix, and counts toward snapshot storage until you delete it. If the fork fails after the snapshot exists, the error names the snapshot and any children already created; if the call times out or is cancelled, check tenki_list_snapshots and tenki_list_sandboxes for what it left behind.", {
|
|
343
|
+
session_id: sessionIdSchema.describe("The RUNNING sandbox to fork."),
|
|
344
|
+
count: z
|
|
345
|
+
.number()
|
|
346
|
+
.int()
|
|
347
|
+
.min(1)
|
|
348
|
+
.max(2147483647)
|
|
349
|
+
.optional()
|
|
350
|
+
.describe("Children to create (default 1). The workspace caps children per request (10 by default; Tenki staff can change it; more is resource_exhausted) and each child counts toward the concurrent-sandbox limit."),
|
|
351
|
+
name_prefix: z
|
|
352
|
+
.string()
|
|
353
|
+
.max(64)
|
|
354
|
+
.optional()
|
|
355
|
+
.describe("Children are named <name_prefix>-1 … <name_prefix>-<count> (default '<source name>-fork', or 'fork-<first 8 characters of its id>' for an unnamed sandbox)."),
|
|
356
|
+
wait: z.boolean().optional().describe("Return only once every child is RUNNING (or has ended) instead of as soon as each is created (default false)."),
|
|
357
|
+
}, async ({ session_id, count, name_prefix, wait }, extra) => {
|
|
358
|
+
try {
|
|
359
|
+
return ok(await client.control("ForkSession", {
|
|
360
|
+
sessionId: session_id,
|
|
361
|
+
...(count ? { count } : {}),
|
|
362
|
+
...(name_prefix ? { namePrefix: name_prefix } : {}),
|
|
363
|
+
...(wait ? { wait: true } : {}),
|
|
364
|
+
}, undefined,
|
|
365
|
+
// A dropped request makes the API terminate the children it could not return.
|
|
366
|
+
extra?.signal));
|
|
367
|
+
}
|
|
368
|
+
catch (e) {
|
|
369
|
+
throw forkFailure(e);
|
|
370
|
+
}
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
/** A failed fork that got as far as its snapshot attaches the partial ForkSessionResponse as an error detail. */
|
|
374
|
+
function forkFailure(e) {
|
|
375
|
+
const details = e?.details;
|
|
376
|
+
const detail = details?.find((d) => d?.type === "tenki.sandbox.v1.ForkSessionResponse");
|
|
377
|
+
if (!detail && e instanceof Error && isClientAbort(e)) {
|
|
378
|
+
return new Error(`${e.message} — the fork may have left a snapshot (named after the prefix) and children behind; check tenki_list_snapshots and tenki_list_sandboxes.`);
|
|
379
|
+
}
|
|
380
|
+
if (!detail || !(e instanceof Error))
|
|
381
|
+
return e;
|
|
382
|
+
const snapshotId = detail.debug?.snapshotId;
|
|
383
|
+
const children = (detail.debug?.sessions ?? []).map((s) => s.id).filter(Boolean);
|
|
384
|
+
const created = snapshotId
|
|
385
|
+
? `snapshot ${snapshotId}${children.length ? ` and child sandboxes ${children.join(", ")}` : ""}`
|
|
386
|
+
: "a snapshot and possibly child sandboxes (check tenki_list_snapshots and tenki_list_sandboxes)";
|
|
387
|
+
return new Error(`${e.message} — the fork had already created ${created}; terminate or delete them if they are not needed.`);
|
|
388
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import type { TenkiClient } from "../client.js";
|
|
3
|
+
/**
|
|
4
|
+
* Extended sandbox (session) admin ops on the control plane: wall-clock lifetime
|
|
5
|
+
* extension, mutable-field updates, bulk termination, activity heartbeats, and
|
|
6
|
+
* workspace-scoped listing. The lifecycle basics
|
|
7
|
+
* (create/get/list/terminate/pause/resume) live in sandboxes.ts.
|
|
8
|
+
*/
|
|
9
|
+
export declare function registerSessionsAdmin(server: McpServer, client: TenkiClient): void;
|