@luxorai/mcp 0.3.1 → 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 +37 -0
- package/README.md +13 -8
- package/SECURITY.md +2 -2
- package/dist/client.d.ts +16 -1
- package/dist/client.js +62 -16
- package/dist/env.d.ts +6 -0
- package/dist/env.js +25 -0
- package/dist/http.js +13 -10
- package/dist/index.js +9 -8
- package/dist/oauth.d.ts +7 -1
- package/dist/oauth.js +29 -9
- package/dist/server.d.ts +25 -5
- package/dist/server.js +88 -53
- package/dist/tools/artifacts.d.ts +3 -3
- package/dist/tools/artifacts.js +6 -6
- package/dist/tools/auth_status.d.ts +3 -3
- package/dist/tools/auth_status.js +8 -9
- package/dist/tools/common.d.ts +35 -0
- package/dist/tools/common.js +78 -0
- package/dist/tools/egress.d.ts +38 -0
- package/dist/tools/egress.js +153 -0
- package/dist/tools/exec.js +1 -1
- package/dist/tools/files.js +36 -3
- package/dist/tools/files_ops.js +9 -1
- package/dist/tools/git.d.ts +0 -1
- package/dist/tools/git.js +85 -23
- package/dist/tools/ports.d.ts +8 -1
- package/dist/tools/ports.js +24 -3
- package/dist/tools/previews.js +59 -22
- package/dist/tools/sandboxes.d.ts +1 -1
- package/dist/tools/sandboxes.js +342 -26
- package/dist/tools/sessions_admin.js +63 -23
- package/dist/tools/snapshots.js +41 -22
- package/dist/tools/ssh.d.ts +3 -3
- package/dist/tools/ssh.js +35 -9
- package/dist/tools/templates.d.ts +2 -1
- package/dist/tools/templates.js +36 -16
- package/dist/tools/titles.d.ts +8 -0
- package/dist/tools/titles.js +102 -0
- package/dist/tools/volumes.js +36 -11
- package/dist/tools/workspace.d.ts +9 -1
- package/dist/tools/workspace.js +29 -90
- package/package.json +5 -4
package/dist/tools/sandboxes.js
CHANGED
|
@@ -1,72 +1,388 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import {
|
|
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();
|
|
3
79
|
/** Sandbox (session) lifecycle. */
|
|
4
80
|
export function registerSandboxes(server, client) {
|
|
5
81
|
server.registerTool("tenki_create_sandbox", {
|
|
6
|
-
description: "Create a persistent sandbox microVM, optionally from a snapshot or template image. Returns the session (id, state)
|
|
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.",
|
|
7
83
|
inputSchema: z
|
|
8
84
|
.object({
|
|
9
|
-
name: z.string().optional().describe("Human-readable name."),
|
|
10
|
-
cpu_cores: z.number().int().min(1).max(
|
|
11
|
-
memory_mb: z
|
|
12
|
-
|
|
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)."),
|
|
13
96
|
max_duration_seconds: z.number().int().positive().optional().describe("Hard lifetime cap in seconds."),
|
|
14
|
-
idle_timeout_minutes: z
|
|
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."),
|
|
15
107
|
clone_repo_url: z.string().optional().describe("Git URL to clone into the sandbox on boot."),
|
|
16
|
-
allow_outbound: z.boolean().optional().describe("Allow outbound networking (
|
|
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."),
|
|
17
109
|
allow_inbound: z.boolean().optional().describe("Allow inbound networking (off by default)."),
|
|
18
|
-
|
|
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."),
|
|
19
136
|
image: z
|
|
20
137
|
.string()
|
|
21
138
|
.optional()
|
|
22
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."),
|
|
23
|
-
|
|
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 _ : . -)."),
|
|
24
182
|
workspace_id: z.string().optional().describe("Workspace to create in (defaults to the key's first workspace)."),
|
|
25
183
|
env: envSchema,
|
|
26
|
-
wait_ready: z
|
|
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."),
|
|
27
188
|
})
|
|
28
189
|
.strict(),
|
|
29
190
|
}, async (a) => {
|
|
30
191
|
const owner = await client.resolveOwner();
|
|
31
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).
|
|
32
213
|
const body = {
|
|
33
214
|
...(owner.ownerType ? { ownerType: owner.ownerType } : {}),
|
|
34
215
|
...(owner.ownerId ? { ownerId: owner.ownerId } : {}),
|
|
35
216
|
...(workspaceId ? { workspaceId } : {}),
|
|
36
217
|
...(a.name ? { name: a.name } : {}),
|
|
37
|
-
...(a.cpu_cores ? { cpuCores: a.cpu_cores } : {}),
|
|
38
|
-
...(a.memory_mb ? { memoryMb: a.memory_mb } : {}),
|
|
39
|
-
...(a.disk_size_gb ? { diskSizeGb: a.disk_size_gb } : {}),
|
|
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 } : {}),
|
|
40
221
|
...(a.max_duration_seconds ? { maxDuration: `${a.max_duration_seconds}s` } : {}),
|
|
41
|
-
...(a.idle_timeout_minutes ? { idleTimeoutMinutes: a.idle_timeout_minutes } : {}),
|
|
222
|
+
...(a.idle_timeout_minutes !== undefined ? { idleTimeoutMinutes: a.idle_timeout_minutes } : {}),
|
|
223
|
+
...(a.sticky !== undefined ? { sticky: a.sticky } : {}),
|
|
42
224
|
...(a.clone_repo_url ? { cloneRepoUrl: a.clone_repo_url } : {}),
|
|
43
|
-
...(a.allow_outbound ? { allowOutbound:
|
|
44
|
-
...(a.allow_inbound ? { allowInbound:
|
|
225
|
+
...(a.allow_outbound !== undefined ? { allowOutbound: a.allow_outbound } : {}),
|
|
226
|
+
...(a.allow_inbound !== undefined ? { allowInbound: a.allow_inbound } : {}),
|
|
227
|
+
...(egress ? { egress } : {}),
|
|
228
|
+
...(egressPolicies ? { egressPolicies } : {}),
|
|
45
229
|
...(a.snapshot_id ? { snapshotId: a.snapshot_id } : {}),
|
|
46
230
|
// The API still uses registryRef internally for template-image launches.
|
|
47
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 } : {}),
|
|
48
280
|
...(a.tags && a.tags.length ? { tags: a.tags } : {}),
|
|
49
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 } : {}),
|
|
50
285
|
};
|
|
51
286
|
const resp = await client.control("CreateSession", body);
|
|
52
287
|
const session = resp.session ?? resp;
|
|
53
288
|
const sessionId = session.id ?? resp.sessionId;
|
|
54
289
|
const dataPlaneEndpoint = resp.dataPlaneEndpoint ?? resp.data_plane_endpoint;
|
|
55
|
-
const
|
|
56
|
-
const finalSession = wait && sessionId ? await client.waitForState(sessionId, "RUNNING") : session;
|
|
57
|
-
return ok({ session: finalSession, dataPlaneEndpoint });
|
|
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 } : {}) });
|
|
58
293
|
});
|
|
59
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 })));
|
|
60
|
-
server.tool("
|
|
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.", {
|
|
61
309
|
include_terminated: z.boolean().optional().describe("Include terminated sandboxes (default false)."),
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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", {
|
|
65
315
|
...(include_terminated ? { includeTerminated: true } : {}),
|
|
316
|
+
...(tags && tags.length ? { tags } : {}),
|
|
317
|
+
...(sticky !== undefined ? { sticky } : {}),
|
|
66
318
|
...(page_size ? { pageSize: page_size } : {}),
|
|
67
319
|
...(page_token ? { pageToken: page_token } : {}),
|
|
68
320
|
})));
|
|
69
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 })));
|
|
70
|
-
server.tool("tenki_pause_sandbox", "Pause a sandbox (snapshot + suspend) so it can be resumed later.
|
|
71
|
-
|
|
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.`);
|
|
72
388
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import { ok, sessionIdSchema } from "./common.js";
|
|
2
|
+
import { listQuery, ok, protoEnum, searchSchema, sessionIdSchema, tagsPatch, tagsSchema } from "./common.js";
|
|
3
|
+
const SESSION_STATES = ["CREATING", "RUNNING", "PAUSING", "PAUSED", "RESUMING", "TERMINATING", "TERMINATED", "USER_SHUTDOWN"];
|
|
3
4
|
/**
|
|
4
5
|
* Extended sandbox (session) admin ops on the control plane: wall-clock lifetime
|
|
5
6
|
* extension, mutable-field updates, bulk termination, activity heartbeats, and
|
|
@@ -18,43 +19,82 @@ export function registerSessionsAdmin(server, client) {
|
|
|
18
19
|
sessionId: session_id,
|
|
19
20
|
additionalDuration: `${additional_duration_seconds}s`,
|
|
20
21
|
})));
|
|
21
|
-
server.tool("tenki_update_sandbox", "Update mutable fields on an existing sandbox
|
|
22
|
+
server.tool("tenki_update_sandbox", "Update mutable fields on an existing sandbox: name, tags, sticky flag, or max duration. The idle timeout is NOT updatable after creation (UpdateSession has no such field). max_duration_seconds requires an explicit sticky value on the wire: when you pass it without `sticky` the tool checks the sandbox and sends sticky=false only if it is not already sticky (a sticky sandbox is refused, to avoid silently revoking stickiness); sticky=true discards max_duration with a warning. The response carries any `warnings` the API returned.", {
|
|
22
23
|
session_id: sessionIdSchema.describe("The sandbox/session ID to update."),
|
|
23
|
-
name: z.string().optional().describe("New human-readable name."),
|
|
24
|
-
tags:
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
.
|
|
28
|
-
.positive()
|
|
24
|
+
name: z.string().max(64).optional().describe("New human-readable name (max 64 chars)."),
|
|
25
|
+
tags: tagsSchema.describe("Replacement tag list. Pass [] (or clear_tags) to remove all tags."),
|
|
26
|
+
clear_tags: z.boolean().optional().describe("Remove all tags from the sandbox."),
|
|
27
|
+
sticky: z
|
|
28
|
+
.boolean()
|
|
29
29
|
.optional()
|
|
30
|
-
.describe("
|
|
30
|
+
.describe("true = keep the sandbox alive with no hard lifetime cap (not allowed for service credentials). false = ordinary lifetime: pass max_duration_seconds with it, otherwise the API resets the cap to the workspace default duration from now."),
|
|
31
31
|
max_duration_seconds: z
|
|
32
32
|
.number()
|
|
33
33
|
.int()
|
|
34
34
|
.positive()
|
|
35
35
|
.optional()
|
|
36
|
-
.describe("New hard lifetime cap in seconds (sent as a Duration string, e.g. 3600s)."),
|
|
37
|
-
}, async ({ session_id, name, tags,
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
36
|
+
.describe("New hard lifetime cap in seconds (sent as a Duration string, e.g. 3600s). Sends sticky=false for a non-sticky sandbox unless sticky is given; refused on a sticky sandbox without an explicit sticky value."),
|
|
37
|
+
}, async ({ session_id, name, tags, clear_tags, sticky, max_duration_seconds }) => {
|
|
38
|
+
const tagPatch = tagsPatch(tags, clear_tags);
|
|
39
|
+
if (name === undefined && sticky === undefined && max_duration_seconds === undefined && !Object.keys(tagPatch).length) {
|
|
40
|
+
throw new Error("tenki_update_sandbox: pass at least one field to change — nothing was sent (the API would return the unchanged sandbox, which reads like a successful update).");
|
|
41
|
+
}
|
|
42
|
+
// The API rejects max_duration without an explicit sticky value, and sticky=false
|
|
43
|
+
// is the only combination that applies the cap. Sending false on a STICKY
|
|
44
|
+
// sandbox would silently revoke stickiness (hard lifetime + idle reaper), so
|
|
45
|
+
// the default is only filled in after checking the sandbox is not sticky.
|
|
46
|
+
let stickyValue = sticky;
|
|
47
|
+
if (stickyValue === undefined && max_duration_seconds !== undefined) {
|
|
48
|
+
const current = await client.control("GetSession", { sessionId: session_id });
|
|
49
|
+
if ((current.session ?? current)?.sticky === true) {
|
|
50
|
+
throw new Error("tenki_update_sandbox: this sandbox is sticky, so max_duration_seconds would revoke that — pass sticky:false explicitly to un-sticky it with this cap, or sticky:true to keep it (the cap is then ignored).");
|
|
51
|
+
}
|
|
52
|
+
stickyValue = false;
|
|
53
|
+
}
|
|
54
|
+
return ok(await client.control("UpdateSession", {
|
|
55
|
+
sessionId: session_id,
|
|
56
|
+
...(name !== undefined ? { name } : {}),
|
|
57
|
+
...tagPatch,
|
|
58
|
+
...(stickyValue !== undefined ? { sticky: stickyValue } : {}),
|
|
59
|
+
...(max_duration_seconds !== undefined ? { maxDuration: `${max_duration_seconds}s` } : {}),
|
|
60
|
+
}));
|
|
61
|
+
});
|
|
62
|
+
server.tool("tenki_terminate_sandboxes", "Terminate MULTIPLE sandboxes in one call (bulk, max 100 per call). IRREVERSIBLE — every listed sandbox and its filesystem is destroyed. The response lists `sessions` that were terminated and `failures` with a reason per id that was not. Use tenki_terminate_sandbox for a single one.", {
|
|
63
|
+
session_ids: z.array(sessionIdSchema).min(1).max(100).describe("The sandbox/session IDs to terminate (1-100)."),
|
|
46
64
|
}, async ({ session_ids }) => ok(await client.control("TerminateSessions", { sessionIds: session_ids })));
|
|
47
65
|
server.tool("tenki_report_sandbox_activity", "Report client-side activity on a sandbox to reset its idle timer and keep it from being reaped as idle (a keep-alive heartbeat).", { session_id: sessionIdSchema.describe("The sandbox/session ID to mark as active.") }, async ({ session_id }) => ok(await client.control("ReportSessionActivity", { sessionId: session_id })));
|
|
48
|
-
server.tool("tenki_list_workspace_sandboxes", "List every sandbox belonging to a specific workspace (defaults to the API key's workspace) — useful for spotting leaked, still-billing
|
|
66
|
+
server.tool("tenki_list_workspace_sandboxes", "List every sandbox belonging to a specific workspace (defaults to the API key's workspace) with server-side search (name, id, snapshot names/ids, image refs), state/tag/sticky filters, sorting and optional state-count facets — useful for finding a sandbox by name or spotting leaked, still-billing ones. NOTE: the underlying RPC (ListWorkspaceSandboxes) is marked deprecated upstream, but it is the only list with search/sort; tenki_list_sandboxes is the plain credential-scoped list.", {
|
|
49
67
|
workspace_id: z.string().optional().describe("Workspace to list (defaults to the key's first workspace)."),
|
|
50
68
|
include_terminated: z.boolean().optional().describe("Include terminated sandboxes (default false)."),
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
69
|
+
search: searchSchema.describe("Free-text search over name, id, snapshot names/ids and published image refs (max 256 chars)."),
|
|
70
|
+
states: z.array(z.enum(SESSION_STATES)).max(16).optional().describe("Only sandboxes in any of these states (empty = no filter)."),
|
|
71
|
+
state: z.enum(SESSION_STATES).optional().describe("Only sandboxes in this one state (combines with states)."),
|
|
72
|
+
tags: z.array(z.string()).optional().describe("Only sandboxes carrying all of these tags."),
|
|
73
|
+
sticky: z.boolean().optional().describe("Only sticky (true) or only non-sticky (false) sandboxes."),
|
|
74
|
+
order: z
|
|
75
|
+
.enum(["STATE_THEN_CREATED"])
|
|
76
|
+
.optional()
|
|
77
|
+
.describe("Default order is newest first; STATE_THEN_CREATED groups running, then paused, then terminated (newest first within each). Ignored when sort_by is set."),
|
|
78
|
+
sort_by: z
|
|
79
|
+
.enum(["NAME", "STATE", "CREATED_AT", "CPU_CORES", "MEMORY_MB", "DISK_SIZE_GB", "EXPIRATION", "SNAPSHOT_STORAGE"])
|
|
80
|
+
.optional()
|
|
81
|
+
.describe("Column to sort by (overrides order)."),
|
|
82
|
+
sort_desc: z.boolean().optional().describe("Sort descending (default ascending)."),
|
|
83
|
+
include_facets: z.boolean().optional().describe("Also return per-state counts for the same filter scope (facets)."),
|
|
84
|
+
page_size: z.number().int().min(1).max(100).optional().describe("Rows per page (max 100)."),
|
|
85
|
+
page_token: z.string().optional().describe("Cursor from a previous response's nextPageToken."),
|
|
86
|
+
}, async ({ workspace_id, include_terminated, search, states, state, tags, sticky, order, sort_by, sort_desc, include_facets, page_size, page_token }) => {
|
|
54
87
|
const workspaceId = workspace_id ?? (await client.resolveOwner()).workspaceId;
|
|
55
88
|
return ok(await client.control("ListWorkspaceSandboxes", {
|
|
56
89
|
...(workspaceId ? { workspaceId } : {}),
|
|
57
90
|
...(include_terminated ? { includeTerminated: true } : {}),
|
|
91
|
+
...(state ? { state: protoEnum("SESSION_STATE", state) } : {}),
|
|
92
|
+
...(states && states.length ? { states: states.map((s) => protoEnum("SESSION_STATE", s)) } : {}),
|
|
93
|
+
...(tags && tags.length ? { tags } : {}),
|
|
94
|
+
...(sticky !== undefined ? { sticky } : {}),
|
|
95
|
+
...(order ? { order: protoEnum("SANDBOX_LIST_ORDER", order) } : {}),
|
|
96
|
+
...(sort_by ? { sortBy: protoEnum("SANDBOX_SORT_FIELD", sort_by) } : {}),
|
|
97
|
+
...listQuery(search, include_facets, sort_desc),
|
|
58
98
|
...(page_size ? { pageSize: page_size } : {}),
|
|
59
99
|
...(page_token ? { pageToken: page_token } : {}),
|
|
60
100
|
}));
|