@llmindset/hf-mcp 0.3.19 → 0.3.21

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.
@@ -0,0 +1,727 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { z } from 'zod';
3
+ import { JobsApiClient } from './jobs/api-client.js';
4
+ import type { JobInfo, JobSpec, JobVolume } from './jobs/types.js';
5
+ import { parseTimeout, parseVolumes } from './jobs/commands/utils.js';
6
+ import type { ToolResult } from './types/tool-result.js';
7
+ import { fetchWithProfile, NETWORK_FETCH_PROFILES } from './network/fetch-profile.js';
8
+
9
+ const SANDBOX_HANDLE_VERSION = 'hfsb1';
10
+ const SANDBOX_PORT = 8000;
11
+ const SANDBOX_ROOT = '/sandbox';
12
+ const DEFAULT_BUCKET_MOUNT_PATH = '/data';
13
+ const DEFAULT_IMAGE = 'python:3.12';
14
+ const DEFAULT_FLAVOR = 'cpu-basic';
15
+ const DEFAULT_TIMEOUT = '1h';
16
+ const VOLUME_FORMAT = 'hf://[models|datasets|spaces|buckets]/OWNER/NAME[/PATH]:/MOUNT_PATH[:ro|:rw]';
17
+ const TOKEN_MIN_LENGTH = 32;
18
+ const TOKEN_PATTERN = /^[A-Za-z0-9_-]+$/;
19
+ const NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9-]{0,62}$/;
20
+ const HOST_SAFE_PATTERN = /^[A-Za-z0-9][A-Za-z0-9-]*$/;
21
+ const NAMESPACE_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
22
+
23
+ const SANDBOX_SERVER_SCRIPT = String.raw`
24
+ import base64
25
+ import json
26
+ import os
27
+ import subprocess
28
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
29
+
30
+ TOKEN = os.environ.get("HF_SANDBOX_TOKEN", "")
31
+ ROOT = os.environ.get("HF_SANDBOX_ROOT", "/sandbox")
32
+ PORT = int(os.environ.get("HF_SANDBOX_PORT", "8000"))
33
+ os.makedirs(ROOT, exist_ok=True)
34
+ os.chdir(ROOT)
35
+
36
+ def send_json(handler, status, payload):
37
+ data = json.dumps(payload).encode("utf-8")
38
+ handler.send_response(status)
39
+ handler.send_header("Content-Type", "application/json")
40
+ handler.send_header("Content-Length", str(len(data)))
41
+ handler.end_headers()
42
+ handler.wfile.write(data)
43
+
44
+ def resolve_path(path):
45
+ if not isinstance(path, str) or not path:
46
+ raise ValueError("path must be a non-empty string")
47
+ if "\x00" in path:
48
+ raise ValueError("path cannot contain null bytes")
49
+ candidate = path if os.path.isabs(path) else os.path.join(ROOT, path)
50
+ return os.path.abspath(candidate)
51
+
52
+ class Handler(BaseHTTPRequestHandler):
53
+ server_version = "hf-sandbox-rpc/1"
54
+
55
+ def log_message(self, fmt, *args):
56
+ return
57
+
58
+ def authorized(self):
59
+ return bool(TOKEN) and self.headers.get("X-Sandbox-Token") == TOKEN
60
+
61
+ def read_payload(self):
62
+ length = int(self.headers.get("Content-Length", "0"))
63
+ if length == 0:
64
+ return {}
65
+ return json.loads(self.rfile.read(length).decode("utf-8"))
66
+
67
+ def do_GET(self):
68
+ if self.path != "/health":
69
+ send_json(self, 404, {"error": "not found"})
70
+ return
71
+ if not self.authorized():
72
+ send_json(self, 401, {"error": "unauthorized"})
73
+ return
74
+ send_json(self, 200, {"ok": True, "name": os.environ.get("HF_SANDBOX_NAME"), "root": ROOT})
75
+
76
+ def do_POST(self):
77
+ if not self.authorized():
78
+ send_json(self, 401, {"error": "unauthorized"})
79
+ return
80
+ try:
81
+ payload = self.read_payload()
82
+ if self.path == "/exec":
83
+ command = payload.get("command")
84
+ if not isinstance(command, list) or not command or not all(isinstance(item, str) for item in command):
85
+ raise ValueError("command must be a non-empty string array")
86
+ workdir = payload.get("workdir") or ROOT
87
+ workdir = resolve_path(workdir)
88
+ timeout = int(payload.get("timeout", 600))
89
+ stdin = payload.get("stdin")
90
+ result = subprocess.run(
91
+ command,
92
+ cwd=workdir,
93
+ input=stdin,
94
+ text=True,
95
+ capture_output=True,
96
+ timeout=timeout,
97
+ check=False,
98
+ )
99
+ send_json(self, 200, {
100
+ "returncode": result.returncode,
101
+ "stdout": result.stdout,
102
+ "stderr": result.stderr,
103
+ })
104
+ return
105
+ if self.path == "/write":
106
+ target = resolve_path(payload.get("path"))
107
+ content = payload.get("content")
108
+ encoding = payload.get("encoding", "utf-8")
109
+ if not isinstance(content, str):
110
+ raise ValueError("content must be a string")
111
+ data = base64.b64decode(content) if encoding == "base64" else content.encode("utf-8")
112
+ os.makedirs(os.path.dirname(target), exist_ok=True)
113
+ with open(target, "wb") as f:
114
+ f.write(data)
115
+ send_json(self, 200, {"path": target, "bytes": len(data)})
116
+ return
117
+ if self.path == "/read":
118
+ target = resolve_path(payload.get("path"))
119
+ encoding = payload.get("encoding", "utf-8")
120
+ with open(target, "rb") as f:
121
+ data = f.read()
122
+ content = base64.b64encode(data).decode("ascii") if encoding == "base64" else data.decode("utf-8")
123
+ send_json(self, 200, {"path": target, "content": content, "encoding": encoding, "bytes": len(data)})
124
+ return
125
+ send_json(self, 404, {"error": "not found"})
126
+ except subprocess.TimeoutExpired as exc:
127
+ send_json(self, 408, {"error": "command timed out", "stdout": exc.stdout or "", "stderr": exc.stderr or ""})
128
+ except Exception as exc:
129
+ send_json(self, 400, {"error": str(exc)})
130
+
131
+ ThreadingHTTPServer(("0.0.0.0", PORT), Handler).serve_forever()
132
+ `;
133
+
134
+ const createArgsSchema = z
135
+ .object({
136
+ image: z.string().optional().default(DEFAULT_IMAGE),
137
+ flavor: z.string().optional().default(DEFAULT_FLAVOR),
138
+ timeout: z.string().optional().default(DEFAULT_TIMEOUT),
139
+ namespace: z.string().optional(),
140
+ name: z.string().optional(),
141
+ sandbox_token: z.string().optional(),
142
+ forward_hf_token: z.boolean().optional().default(false),
143
+ bucket: z
144
+ .string()
145
+ .optional()
146
+ .describe(
147
+ `Convenience bucket mount in OWNER/NAME format. Mounts at bucket_mount_path, default ${DEFAULT_BUCKET_MOUNT_PATH}.`
148
+ ),
149
+ bucket_mode: z.enum(['ro', 'rw']).optional().default('rw').describe('Access mode for bucket convenience mount.'),
150
+ bucket_mount_path: z
151
+ .string()
152
+ .optional()
153
+ .default(DEFAULT_BUCKET_MOUNT_PATH)
154
+ .describe('Absolute mount path for bucket convenience mount.'),
155
+ volumes: z
156
+ .array(z.string())
157
+ .optional()
158
+ .describe(`Volume mounts using hf:// URLs. Format: ${VOLUME_FORMAT}. Type prefixes are plural.`),
159
+ })
160
+ .strict();
161
+
162
+ const execArgsSchema = z
163
+ .object({
164
+ handle: z.string(),
165
+ command: z.array(z.string()).min(1),
166
+ workdir: z.string().optional(),
167
+ stdin: z.string().optional(),
168
+ timeout: z.number().int().positive().optional().default(600),
169
+ })
170
+ .strict();
171
+
172
+ const shellExecArgsSchema = z
173
+ .object({
174
+ handle: z.string().describe('Portable sandbox handle returned by hf_sandbox create.'),
175
+ cmd: z.string().min(1).describe('Shell command to execute inside the sandbox. Runs via /bin/sh -lc.'),
176
+ workdir: z.string().optional().describe(`Working directory inside the sandbox. Defaults to ${SANDBOX_ROOT}.`),
177
+ stdin: z.string().optional().describe('Optional stdin to pass to the command.'),
178
+ timeout: z.number().int().positive().optional().default(600).describe('Command timeout in seconds.'),
179
+ })
180
+ .strict();
181
+
182
+ const fileEncodingSchema = z.enum(['utf-8', 'base64']).optional().default('utf-8');
183
+
184
+ const writeArgsSchema = z
185
+ .object({
186
+ handle: z.string(),
187
+ path: z.string().min(1),
188
+ content: z.string(),
189
+ encoding: fileEncodingSchema,
190
+ })
191
+ .strict();
192
+
193
+ const readArgsSchema = z
194
+ .object({
195
+ handle: z.string(),
196
+ path: z.string().min(1),
197
+ encoding: fileEncodingSchema,
198
+ })
199
+ .strict();
200
+
201
+ const handleArgsSchema = z
202
+ .object({
203
+ handle: z.string(),
204
+ })
205
+ .strict();
206
+
207
+ const operations = ['create', 'write', 'read', 'status', 'terminate'] as const;
208
+ type SandboxOperation = (typeof operations)[number];
209
+
210
+ export interface SandboxHandle {
211
+ namespace: string;
212
+ jobId: string;
213
+ sandboxToken: string;
214
+ }
215
+
216
+ export interface SandboxRpcClient {
217
+ health(handle: SandboxHandle, hfToken: string): Promise<unknown>;
218
+ exec(handle: SandboxHandle, hfToken: string, args: z.infer<typeof execArgsSchema>): Promise<unknown>;
219
+ write(handle: SandboxHandle, hfToken: string, args: z.infer<typeof writeArgsSchema>): Promise<unknown>;
220
+ read(handle: SandboxHandle, hfToken: string, args: z.infer<typeof readArgsSchema>): Promise<unknown>;
221
+ }
222
+
223
+ export interface SandboxJobsClient {
224
+ getNamespace(namespace?: string): Promise<string>;
225
+ runJob(jobSpec: JobSpec, namespace?: string): Promise<JobInfo>;
226
+ getJob(jobId: string, namespace?: string): Promise<JobInfo>;
227
+ cancelJob(jobId: string, namespace?: string): Promise<void>;
228
+ }
229
+
230
+ export const HF_SANDBOX_TOOL_CONFIG = {
231
+ name: 'hf_sandbox',
232
+ description:
233
+ 'Create and manage interactive Hugging Face Jobs sandboxes. Supports create, read, write, status, and terminate with portable stateless handles. Use hf_sandbox_exec to run shell commands in a sandbox. ' +
234
+ `Mount Hub repos with volumes using ${VOLUME_FORMAT}; type prefixes must be plural. Examples: ` +
235
+ '["hf://buckets/user/bucket:/data:rw"], ["hf://datasets/org/dataset:/data:ro"], ["hf://models/org/model:/model"]. ' +
236
+ 'For buckets, create also accepts bucket, bucket_mode, and bucket_mount_path as a convenience. ' +
237
+ `The default working directory is ${SANDBOX_ROOT}, which is fast ephemeral container storage; mounted buckets use FUSE and are better for persisted artifacts than build-heavy work.`,
238
+ schema: z.object({
239
+ operation: z
240
+ .enum(operations)
241
+ .optional()
242
+ .describe(`Operation to execute: ${operations.join(', ')}`),
243
+ args: z.record(z.any()).optional().describe('Operation-specific arguments as a JSON object'),
244
+ }),
245
+ annotations: {
246
+ title: 'Hugging Face Sandbox',
247
+ readOnlyHint: false,
248
+ openWorldHint: true,
249
+ },
250
+ } as const;
251
+
252
+ export const HF_SANDBOX_EXEC_TOOL_CONFIG = {
253
+ name: 'hf_sandbox_exec',
254
+ description:
255
+ 'Execute shell commands inside a Hugging Face Jobs sandbox. Provide a portable sandbox handle and a shell command string; returns stdout, stderr, and returncode.',
256
+ schema: shellExecArgsSchema,
257
+ annotations: {
258
+ title: 'Hugging Face Sandbox Exec',
259
+ readOnlyHint: false,
260
+ openWorldHint: true,
261
+ },
262
+ } as const;
263
+
264
+ function formatJson(value: unknown): string {
265
+ return `\`\`\`json\n${JSON.stringify(value, null, 2)}\n\`\`\``;
266
+ }
267
+
268
+ function normalizeSandboxVolumes(args: z.infer<typeof createArgsSchema>): JobVolume[] | undefined {
269
+ const volumeSpecs = [...(args.volumes ?? [])];
270
+ if (args.bucket) {
271
+ volumeSpecs.push(`hf://buckets/${args.bucket}:${args.bucket_mount_path}:${args.bucket_mode}`);
272
+ }
273
+
274
+ return parseVolumes(volumeSpecs);
275
+ }
276
+
277
+ function parseStoredSandboxVolumes(job: JobInfo): JobVolume[] {
278
+ const storedVolumes = job.environment?.HF_SANDBOX_VOLUMES;
279
+ if (!storedVolumes) {
280
+ return [];
281
+ }
282
+
283
+ try {
284
+ const parsed = JSON.parse(storedVolumes) as unknown;
285
+ if (!Array.isArray(parsed)) {
286
+ return [];
287
+ }
288
+ return parsed.filter((volume): volume is JobVolume => {
289
+ if (!volume || typeof volume !== 'object') {
290
+ return false;
291
+ }
292
+ const candidate = volume as Partial<JobVolume>;
293
+ return (
294
+ typeof candidate.type === 'string' &&
295
+ typeof candidate.source === 'string' &&
296
+ typeof candidate.mountPath === 'string'
297
+ );
298
+ });
299
+ } catch {
300
+ return [];
301
+ }
302
+ }
303
+
304
+ function randomSuffix(): string {
305
+ return randomBytes(18).toString('base64url');
306
+ }
307
+
308
+ function generateName(): string {
309
+ const adjectives = ['calm', 'bright', 'clear', 'quick', 'steady', 'fresh', 'kind', 'prime'];
310
+ const nouns = ['harbor', 'summit', 'orbit', 'signal', 'meadow', 'bridge', 'canvas', 'spark'];
311
+ const adjectiveIndex = (randomBytes(1)[0] ?? 0) % adjectives.length;
312
+ const nounIndex = (randomBytes(1)[0] ?? 0) % nouns.length;
313
+ const adjective = adjectives[adjectiveIndex] ?? adjectives[0];
314
+ const noun = nouns[nounIndex] ?? nouns[0];
315
+ return `${adjective}-${noun}`;
316
+ }
317
+
318
+ function validateName(name: string): void {
319
+ if (!NAME_PATTERN.test(name)) {
320
+ throw new Error('Sandbox name must be 1-63 URL-safe alphanumeric or hyphen characters.');
321
+ }
322
+ }
323
+
324
+ function validateToken(token: string): void {
325
+ if (token.length < TOKEN_MIN_LENGTH || !TOKEN_PATTERN.test(token)) {
326
+ throw new Error('sandbox_token must be at least 32 URL-safe characters.');
327
+ }
328
+ }
329
+
330
+ function validateNamespace(namespace: string): void {
331
+ if (!NAMESPACE_PATTERN.test(namespace)) {
332
+ throw new Error('namespace contains unsupported characters.');
333
+ }
334
+ }
335
+
336
+ function validateJobId(jobId: string): void {
337
+ if (!HOST_SAFE_PATTERN.test(jobId)) {
338
+ throw new Error('job id in handle contains unsupported characters.');
339
+ }
340
+ }
341
+
342
+ function createSandboxToken(name: string, suppliedToken?: string): string {
343
+ if (suppliedToken) {
344
+ validateToken(suppliedToken);
345
+ return suppliedToken;
346
+ }
347
+ return `${name}-${randomSuffix()}`;
348
+ }
349
+
350
+ export function parseSandboxHandle(handle: string): SandboxHandle {
351
+ const parts = handle.split(':');
352
+ if (parts.length !== 4 || parts[0] !== SANDBOX_HANDLE_VERSION) {
353
+ throw new Error(`Invalid sandbox handle. Expected ${SANDBOX_HANDLE_VERSION}:<namespace>:<job_id>:<token>.`);
354
+ }
355
+
356
+ const [, namespace, jobId, sandboxToken] = parts;
357
+ if (!namespace || !jobId || !sandboxToken) {
358
+ throw new Error('Invalid sandbox handle. All handle fields are required.');
359
+ }
360
+
361
+ validateNamespace(namespace);
362
+ validateJobId(jobId);
363
+ validateToken(sandboxToken);
364
+
365
+ return { namespace, jobId, sandboxToken };
366
+ }
367
+
368
+ export function formatSandboxHandle(handle: SandboxHandle): string {
369
+ validateNamespace(handle.namespace);
370
+ validateJobId(handle.jobId);
371
+ validateToken(handle.sandboxToken);
372
+ return `${SANDBOX_HANDLE_VERSION}:${handle.namespace}:${handle.jobId}:${handle.sandboxToken}`;
373
+ }
374
+
375
+ function getSandboxUrl(jobId: string): string {
376
+ return `https://${jobId}--${String(SANDBOX_PORT)}.hf.jobs`;
377
+ }
378
+
379
+ function getJobUrl(namespace: string, jobId: string): string {
380
+ return `https://huggingface.co/jobs/${namespace}/${jobId}`;
381
+ }
382
+
383
+ function getExposeUrl(job: JobInfo, jobId: string, port: number): string {
384
+ const exposed = job.status.expose_urls?.find((url) => typeof url === 'string' && url.startsWith('https://'));
385
+ return exposed ?? `https://${jobId}--${String(port)}.hf.jobs`;
386
+ }
387
+
388
+ class HttpSandboxRpcClient implements SandboxRpcClient {
389
+ private async request(
390
+ handle: SandboxHandle,
391
+ hfToken: string,
392
+ path: string,
393
+ body?: unknown,
394
+ timeoutSeconds = 30
395
+ ): Promise<unknown> {
396
+ const requestInit: RequestInit = {
397
+ method: body ? 'POST' : 'GET',
398
+ headers: {
399
+ Accept: 'application/json',
400
+ Authorization: `Bearer ${hfToken}`,
401
+ 'X-Sandbox-Token': handle.sandboxToken,
402
+ ...(body ? { 'Content-Type': 'application/json' } : {}),
403
+ },
404
+ ...(body ? { body: JSON.stringify(body) } : {}),
405
+ };
406
+ const { response } = await fetchWithProfile(
407
+ `${getSandboxUrl(handle.jobId)}${path}`,
408
+ NETWORK_FETCH_PROFILES.externalHttps(),
409
+ {
410
+ timeoutMs: timeoutSeconds * 1000,
411
+ requestInit,
412
+ }
413
+ );
414
+ const responseText = await response.text();
415
+ const payload = responseText ? (JSON.parse(responseText) as unknown) : {};
416
+
417
+ if (!response.ok) {
418
+ throw new Error(`Sandbox RPC ${path} failed with ${String(response.status)}: ${JSON.stringify(payload)}`);
419
+ }
420
+
421
+ return payload;
422
+ }
423
+
424
+ health(handle: SandboxHandle, hfToken: string): Promise<unknown> {
425
+ return this.request(handle, hfToken, '/health');
426
+ }
427
+
428
+ exec(handle: SandboxHandle, hfToken: string, args: z.infer<typeof execArgsSchema>): Promise<unknown> {
429
+ return this.request(
430
+ handle,
431
+ hfToken,
432
+ '/exec',
433
+ {
434
+ command: args.command,
435
+ workdir: args.workdir,
436
+ stdin: args.stdin,
437
+ timeout: args.timeout,
438
+ },
439
+ args.timeout + 5
440
+ );
441
+ }
442
+
443
+ write(handle: SandboxHandle, hfToken: string, args: z.infer<typeof writeArgsSchema>): Promise<unknown> {
444
+ return this.request(handle, hfToken, '/write', {
445
+ path: args.path,
446
+ content: args.content,
447
+ encoding: args.encoding,
448
+ });
449
+ }
450
+
451
+ read(handle: SandboxHandle, hfToken: string, args: z.infer<typeof readArgsSchema>): Promise<unknown> {
452
+ return this.request(handle, hfToken, '/read', {
453
+ path: args.path,
454
+ encoding: args.encoding,
455
+ });
456
+ }
457
+ }
458
+
459
+ function authRequiredResult(): ToolResult {
460
+ return {
461
+ formatted:
462
+ 'Hugging Face sandboxes require authentication because they create and control HF Jobs. Set HF_TOKEN or authenticate your MCP client, then retry with ?mix=sandbox or ?bouquet=sandbox.',
463
+ totalResults: 0,
464
+ resultsShared: 0,
465
+ isError: true,
466
+ };
467
+ }
468
+
469
+ function validationErrorResult(error: z.ZodError | Error, operation: string): ToolResult {
470
+ const message =
471
+ error instanceof z.ZodError
472
+ ? error.errors.map((entry) => `${entry.path.join('.') || 'args'}: ${entry.message}`).join('\n')
473
+ : error.message;
474
+ return {
475
+ formatted: `Error: Invalid parameters for '${operation}'\n\n${message}`,
476
+ totalResults: 0,
477
+ resultsShared: 0,
478
+ isError: true,
479
+ };
480
+ }
481
+
482
+ function isOperation(value: string): value is SandboxOperation {
483
+ return (operations as readonly string[]).includes(value);
484
+ }
485
+
486
+ export class HfSandboxTool {
487
+ private jobsClient: SandboxJobsClient;
488
+ private rpcClient: SandboxRpcClient;
489
+ private hfToken?: string;
490
+ private isAuthenticated: boolean;
491
+
492
+ constructor(
493
+ hfToken?: string,
494
+ isAuthenticated?: boolean,
495
+ namespace?: string,
496
+ jobsClient?: SandboxJobsClient,
497
+ rpcClient?: SandboxRpcClient
498
+ ) {
499
+ this.hfToken = hfToken;
500
+ this.isAuthenticated = isAuthenticated ?? !!hfToken;
501
+ this.jobsClient = jobsClient ?? new JobsApiClient(hfToken, namespace);
502
+ this.rpcClient = rpcClient ?? new HttpSandboxRpcClient();
503
+ }
504
+
505
+ async execute(params: { operation?: string; args?: Record<string, unknown> }): Promise<ToolResult> {
506
+ if (!this.isAuthenticated || !this.hfToken) {
507
+ return authRequiredResult();
508
+ }
509
+
510
+ if (!params.operation) {
511
+ return {
512
+ formatted:
513
+ '# Hugging Face Sandbox\n\n' +
514
+ 'Available operations: create, write, read, status, terminate. Use hf_sandbox_exec for shell commands.\n\n' +
515
+ `Sandbox commands run from ${SANDBOX_ROOT} by default. This is fast ephemeral container storage and is deleted with the sandbox Job. ` +
516
+ 'Use mounted Hub volumes for persisted inputs or outputs; for build-heavy work, prefer building in /sandbox and copying final artifacts to the mounted bucket.\n\n' +
517
+ `Mount Hub repos with create args volumes: ["${VOLUME_FORMAT}"]. Type prefixes are plural: models, datasets, spaces, buckets. ` +
518
+ 'Examples: ["hf://buckets/user/bucket:/data:rw"], ["hf://datasets/org/dataset:/data:ro"], ["hf://models/org/model:/model"]. ' +
519
+ `For buckets only, you can use {"bucket": "user/bucket", "bucket_mode": "rw", "bucket_mount_path": "${DEFAULT_BUCKET_MOUNT_PATH}"}.\n\n` +
520
+ 'Handles are portable bearer capabilities. Do not share them in logs or URLs.',
521
+ totalResults: 1,
522
+ resultsShared: 1,
523
+ };
524
+ }
525
+
526
+ const operation = params.operation.toLowerCase();
527
+ if (!isOperation(operation)) {
528
+ return {
529
+ formatted: `Unknown sandbox operation: "${params.operation}". Available operations: ${operations.join(', ')}.`,
530
+ totalResults: 0,
531
+ resultsShared: 0,
532
+ isError: true,
533
+ };
534
+ }
535
+
536
+ try {
537
+ const result = await this.executeOperation(operation, params.args ?? {});
538
+ return {
539
+ formatted: formatJson(result),
540
+ totalResults: 1,
541
+ resultsShared: 1,
542
+ };
543
+ } catch (error) {
544
+ if (error instanceof z.ZodError) {
545
+ return validationErrorResult(error, operation);
546
+ }
547
+ if (error instanceof Error) {
548
+ return {
549
+ formatted: `Error executing sandbox ${operation}: ${error.message}`,
550
+ totalResults: 0,
551
+ resultsShared: 0,
552
+ isError: true,
553
+ };
554
+ }
555
+ return {
556
+ formatted: `Error executing sandbox ${operation}: ${String(error)}`,
557
+ totalResults: 0,
558
+ resultsShared: 0,
559
+ isError: true,
560
+ };
561
+ }
562
+ }
563
+
564
+ private async executeOperation(operation: SandboxOperation, args: Record<string, unknown>): Promise<unknown> {
565
+ switch (operation) {
566
+ case 'create':
567
+ return this.create(createArgsSchema.parse(args));
568
+ case 'write': {
569
+ const parsed = writeArgsSchema.parse(args);
570
+ return this.rpcClient.write(parseSandboxHandle(parsed.handle), this.requireToken(), parsed);
571
+ }
572
+ case 'read': {
573
+ const parsed = readArgsSchema.parse(args);
574
+ return this.rpcClient.read(parseSandboxHandle(parsed.handle), this.requireToken(), parsed);
575
+ }
576
+ case 'status':
577
+ return this.status(handleArgsSchema.parse(args));
578
+ case 'terminate':
579
+ return this.terminate(handleArgsSchema.parse(args));
580
+ }
581
+ }
582
+
583
+ private requireToken(): string {
584
+ if (!this.hfToken) {
585
+ throw new Error('HF token is required.');
586
+ }
587
+ return this.hfToken;
588
+ }
589
+
590
+ private async create(args: z.infer<typeof createArgsSchema>): Promise<unknown> {
591
+ const name = args.name ?? generateName();
592
+ validateName(name);
593
+ const namespace = await this.jobsClient.getNamespace(args.namespace);
594
+ validateNamespace(namespace);
595
+ const sandboxToken = createSandboxToken(name, args.sandbox_token);
596
+
597
+ const secrets: Record<string, string> = {
598
+ HF_SANDBOX_TOKEN: sandboxToken,
599
+ };
600
+ if (args.forward_hf_token) {
601
+ secrets.HF_TOKEN = this.requireToken();
602
+ }
603
+ const volumes = normalizeSandboxVolumes(args);
604
+
605
+ const jobSpec: JobSpec = {
606
+ dockerImage: args.image,
607
+ command: ['python', '-u', '-c', SANDBOX_SERVER_SCRIPT],
608
+ flavor: args.flavor,
609
+ timeoutSeconds: parseTimeout(args.timeout),
610
+ environment: {
611
+ HF_SANDBOX_NAME: name,
612
+ HF_SANDBOX_HANDLE_VERSION: '1',
613
+ HF_SANDBOX_PORT: String(SANDBOX_PORT),
614
+ HF_SANDBOX_ROOT: SANDBOX_ROOT,
615
+ ...(volumes ? { HF_SANDBOX_VOLUMES: JSON.stringify(volumes) } : {}),
616
+ },
617
+ secrets,
618
+ labels: {
619
+ 'hf-sandbox': '',
620
+ pet: name,
621
+ },
622
+ expose: { ports: [SANDBOX_PORT] },
623
+ };
624
+ if (volumes) {
625
+ jobSpec.volumes = volumes;
626
+ }
627
+
628
+ const job = await this.jobsClient.runJob(jobSpec, namespace);
629
+ const handle = formatSandboxHandle({
630
+ namespace,
631
+ jobId: job.id,
632
+ sandboxToken,
633
+ });
634
+
635
+ return {
636
+ name,
637
+ namespace,
638
+ job_id: job.id,
639
+ port: SANDBOX_PORT,
640
+ url: getExposeUrl(job, job.id, SANDBOX_PORT),
641
+ handle,
642
+ job_url: getJobUrl(namespace, job.id),
643
+ volumes: volumes ?? [],
644
+ };
645
+ }
646
+
647
+ private async status(args: z.infer<typeof handleArgsSchema>): Promise<unknown> {
648
+ const handle = parseSandboxHandle(args.handle);
649
+ const job = await this.jobsClient.getJob(handle.jobId, handle.namespace);
650
+ let health: unknown;
651
+ try {
652
+ health = await this.rpcClient.health(handle, this.requireToken());
653
+ } catch (error) {
654
+ health = {
655
+ ok: false,
656
+ error: error instanceof Error ? error.message : String(error),
657
+ };
658
+ }
659
+
660
+ return {
661
+ namespace: handle.namespace,
662
+ job_id: handle.jobId,
663
+ port: SANDBOX_PORT,
664
+ url: getExposeUrl(job, handle.jobId, SANDBOX_PORT),
665
+ job_url: getJobUrl(handle.namespace, handle.jobId),
666
+ status: job.status,
667
+ health,
668
+ volumes: parseStoredSandboxVolumes(job),
669
+ };
670
+ }
671
+
672
+ private async terminate(args: z.infer<typeof handleArgsSchema>): Promise<unknown> {
673
+ const handle = parseSandboxHandle(args.handle);
674
+ await this.jobsClient.cancelJob(handle.jobId, handle.namespace);
675
+ return {
676
+ namespace: handle.namespace,
677
+ job_id: handle.jobId,
678
+ terminated: true,
679
+ job_url: getJobUrl(handle.namespace, handle.jobId),
680
+ };
681
+ }
682
+ }
683
+
684
+ export class HfSandboxExecTool {
685
+ private rpcClient: SandboxRpcClient;
686
+ private hfToken?: string;
687
+ private isAuthenticated: boolean;
688
+
689
+ constructor(hfToken?: string, isAuthenticated?: boolean, rpcClient?: SandboxRpcClient) {
690
+ this.hfToken = hfToken;
691
+ this.isAuthenticated = isAuthenticated ?? !!hfToken;
692
+ this.rpcClient = rpcClient ?? new HttpSandboxRpcClient();
693
+ }
694
+
695
+ async execute(params: z.infer<typeof shellExecArgsSchema>): Promise<ToolResult> {
696
+ if (!this.isAuthenticated || !this.hfToken) {
697
+ return authRequiredResult();
698
+ }
699
+
700
+ try {
701
+ const parsed = shellExecArgsSchema.parse(params);
702
+ const result = await this.rpcClient.exec(parseSandboxHandle(parsed.handle), this.hfToken, {
703
+ handle: parsed.handle,
704
+ command: ['/bin/sh', '-lc', parsed.cmd],
705
+ workdir: parsed.workdir,
706
+ stdin: parsed.stdin,
707
+ timeout: parsed.timeout,
708
+ });
709
+
710
+ return {
711
+ formatted: formatJson(result),
712
+ totalResults: 1,
713
+ resultsShared: 1,
714
+ };
715
+ } catch (error) {
716
+ if (error instanceof z.ZodError) {
717
+ return validationErrorResult(error, HF_SANDBOX_EXEC_TOOL_CONFIG.name);
718
+ }
719
+ return {
720
+ formatted: `Error executing sandbox command: ${error instanceof Error ? error.message : String(error)}`,
721
+ totalResults: 0,
722
+ resultsShared: 0,
723
+ isError: true,
724
+ };
725
+ }
726
+ }
727
+ }