@agentuity/server 1.0.15 → 1.0.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/dist/api/api.d.ts +6 -0
  2. package/dist/api/api.d.ts.map +1 -1
  3. package/dist/api/api.js +27 -5
  4. package/dist/api/api.js.map +1 -1
  5. package/dist/api/queue/index.d.ts +2 -1
  6. package/dist/api/queue/index.d.ts.map +1 -1
  7. package/dist/api/queue/index.js +5 -1
  8. package/dist/api/queue/index.js.map +1 -1
  9. package/dist/api/queue/types.d.ts +61 -0
  10. package/dist/api/queue/types.d.ts.map +1 -1
  11. package/dist/api/queue/types.js +41 -0
  12. package/dist/api/queue/types.js.map +1 -1
  13. package/dist/api/queue/websocket.d.ts +144 -0
  14. package/dist/api/queue/websocket.d.ts.map +1 -0
  15. package/dist/api/queue/websocket.js +376 -0
  16. package/dist/api/queue/websocket.js.map +1 -0
  17. package/dist/api/region/create.d.ts.map +1 -1
  18. package/dist/api/region/create.js +7 -0
  19. package/dist/api/region/create.js.map +1 -1
  20. package/dist/api/sandbox/cli-list.d.ts +1 -1
  21. package/dist/api/sandbox/cli-list.d.ts.map +1 -1
  22. package/dist/api/sandbox/create.d.ts +7 -0
  23. package/dist/api/sandbox/create.d.ts.map +1 -1
  24. package/dist/api/sandbox/create.js +11 -1
  25. package/dist/api/sandbox/create.js.map +1 -1
  26. package/dist/api/sandbox/get.d.ts +8 -0
  27. package/dist/api/sandbox/get.d.ts.map +1 -1
  28. package/dist/api/sandbox/get.js +6 -1
  29. package/dist/api/sandbox/get.js.map +1 -1
  30. package/dist/api/sandbox/index.d.ts +1 -1
  31. package/dist/api/sandbox/index.d.ts.map +1 -1
  32. package/dist/api/sandbox/index.js +1 -1
  33. package/dist/api/sandbox/index.js.map +1 -1
  34. package/dist/api/sandbox/list.d.ts +9 -0
  35. package/dist/api/sandbox/list.d.ts.map +1 -1
  36. package/dist/api/sandbox/list.js +1 -1
  37. package/dist/api/sandbox/list.js.map +1 -1
  38. package/dist/api/sandbox/snapshot-build.d.ts +14 -1
  39. package/dist/api/sandbox/snapshot-build.d.ts.map +1 -1
  40. package/dist/api/sandbox/snapshot-build.js +21 -3
  41. package/dist/api/sandbox/snapshot-build.js.map +1 -1
  42. package/dist/api/sandbox/snapshot.d.ts +1 -0
  43. package/dist/api/sandbox/snapshot.d.ts.map +1 -1
  44. package/dist/api/sandbox/snapshot.js +10 -1
  45. package/dist/api/sandbox/snapshot.js.map +1 -1
  46. package/dist/index.d.ts +1 -0
  47. package/dist/index.d.ts.map +1 -1
  48. package/dist/index.js +2 -0
  49. package/dist/index.js.map +1 -1
  50. package/dist/util/mime.d.ts +10 -0
  51. package/dist/util/mime.d.ts.map +1 -0
  52. package/dist/util/mime.js +102 -0
  53. package/dist/util/mime.js.map +1 -0
  54. package/package.json +4 -4
  55. package/src/api/api.ts +43 -7
  56. package/src/api/queue/index.ts +19 -0
  57. package/src/api/queue/types.ts +51 -0
  58. package/src/api/queue/websocket.ts +488 -0
  59. package/src/api/region/create.ts +7 -0
  60. package/src/api/sandbox/cli-list.ts +1 -1
  61. package/src/api/sandbox/create.ts +16 -1
  62. package/src/api/sandbox/get.ts +6 -1
  63. package/src/api/sandbox/index.ts +1 -1
  64. package/src/api/sandbox/list.ts +1 -1
  65. package/src/api/sandbox/snapshot-build.ts +29 -3
  66. package/src/api/sandbox/snapshot.ts +15 -1
  67. package/src/index.ts +3 -0
  68. package/src/util/mime.ts +109 -0
@@ -0,0 +1,488 @@
1
+ /**
2
+ * @module websocket
3
+ *
4
+ * WebSocket client for real-time queue message subscriptions.
5
+ *
6
+ * Provides both a callback-based API ({@link createQueueWebSocket}) and an
7
+ * async iterator API ({@link subscribeToQueue}) for receiving messages from
8
+ * a queue in real time via WebSocket.
9
+ *
10
+ * @example Callback-based API
11
+ * ```typescript
12
+ * import { createQueueWebSocket } from '@agentuity/server';
13
+ *
14
+ * const connection = createQueueWebSocket({
15
+ * queueName: 'order-processing',
16
+ * baseUrl: 'https://catalyst.agentuity.cloud',
17
+ * onMessage: (message) => {
18
+ * console.log('Received:', message.id, message.payload);
19
+ * },
20
+ * onOpen: () => console.log('Connected'),
21
+ * onClose: (code, reason) => console.log('Closed:', code, reason),
22
+ * onError: (error) => console.error('Error:', error),
23
+ * });
24
+ *
25
+ * // Later: close the connection
26
+ * connection.close();
27
+ * ```
28
+ *
29
+ * @example Async iterator API
30
+ * ```typescript
31
+ * import { subscribeToQueue } from '@agentuity/server';
32
+ *
33
+ * const controller = new AbortController();
34
+ * for await (const message of subscribeToQueue({
35
+ * queueName: 'order-processing',
36
+ * baseUrl: 'https://catalyst.agentuity.cloud',
37
+ * signal: controller.signal,
38
+ * })) {
39
+ * console.log('Received:', message.id, message.payload);
40
+ * }
41
+ * ```
42
+ */
43
+
44
+ import type { Message } from './types';
45
+ import { WebSocketAuthResponseSchema, WebSocketMessageSchema } from './types';
46
+ import { QueueError } from './util';
47
+ import { validateQueueName } from './validation';
48
+
49
+ // ============================================================================
50
+ // Types
51
+ // ============================================================================
52
+
53
+ /** Connection state for a queue WebSocket connection. */
54
+ export type QueueWebSocketState =
55
+ | 'connecting'
56
+ | 'authenticating'
57
+ | 'connected'
58
+ | 'reconnecting'
59
+ | 'closed';
60
+
61
+ /** Options for creating a queue WebSocket subscription. */
62
+ export interface QueueWebSocketOptions {
63
+ /** Queue name to subscribe to. */
64
+ queueName: string;
65
+ /** API key for authentication (if not provided, uses AGENTUITY_SDK_KEY env var). */
66
+ apiKey?: string;
67
+ /** Base URL of the catalyst service (e.g., https://catalyst.agentuity.cloud). */
68
+ baseUrl: string;
69
+ /** Called when a message is received. */
70
+ onMessage: (message: Message) => void;
71
+ /** Called when the connection is established and authenticated. */
72
+ onOpen?: () => void;
73
+ /** Called when the connection is closed. */
74
+ onClose?: (code: number, reason: string) => void;
75
+ /** Called when an error occurs. */
76
+ onError?: (error: Error) => void;
77
+ /** Whether to automatically reconnect on disconnection (default: true). */
78
+ autoReconnect?: boolean;
79
+ /** Maximum number of reconnection attempts (default: Infinity). */
80
+ maxReconnectAttempts?: number;
81
+ /** Initial reconnection delay in ms (default: 1000). Uses exponential backoff. */
82
+ reconnectDelayMs?: number;
83
+ /** Maximum reconnection delay in ms (default: 30000). */
84
+ maxReconnectDelayMs?: number;
85
+ }
86
+
87
+ /** Return type from {@link createQueueWebSocket}. */
88
+ export interface QueueWebSocketConnection {
89
+ /** Close the WebSocket connection. Disables auto-reconnect. */
90
+ close(): void;
91
+ /** The current connection state. */
92
+ readonly state: QueueWebSocketState;
93
+ /** The client/subscription ID assigned by the server. Stable across reconnections. */
94
+ readonly clientId: string | undefined;
95
+ /** The offset of the last message processed. */
96
+ readonly lastOffset: number | undefined;
97
+ }
98
+
99
+ /** Options for the async iterator queue subscription. */
100
+ export interface SubscribeToQueueOptions {
101
+ /** Queue name to subscribe to. */
102
+ queueName: string;
103
+ /** API key for authentication (if not provided, uses AGENTUITY_SDK_KEY env var). */
104
+ apiKey?: string;
105
+ /** Base URL of the catalyst service. */
106
+ baseUrl: string;
107
+ /** AbortSignal to stop the subscription. */
108
+ signal?: AbortSignal;
109
+ }
110
+
111
+ // ============================================================================
112
+ // Internal Helpers
113
+ // ============================================================================
114
+
115
+ /**
116
+ * Resolve the API key from the options or the AGENTUITY_SDK_KEY environment variable.
117
+ * Throws a {@link QueueError} if no API key is available.
118
+ */
119
+ function resolveApiKey(apiKey?: string): string {
120
+ const key = apiKey ?? process.env.AGENTUITY_SDK_KEY;
121
+ if (!key) {
122
+ throw new QueueError({
123
+ message:
124
+ 'No API key provided. Pass apiKey in options or set the AGENTUITY_SDK_KEY environment variable.',
125
+ });
126
+ }
127
+ return key;
128
+ }
129
+
130
+ /**
131
+ * Convert an HTTP(S) base URL to a WebSocket URL and append the queue path.
132
+ *
133
+ * The WebSocket route is registered at `/queue/ws/{name}` (not versioned).
134
+ */
135
+ function buildWebSocketUrl(baseUrl: string, queueName: string): string {
136
+ const wsUrl = baseUrl.replace(/^https:\/\//, 'wss://').replace(/^http:\/\//, 'ws://');
137
+ // Remove trailing slash if present
138
+ const base = wsUrl.replace(/\/$/, '');
139
+ return `${base}/queue/ws/${encodeURIComponent(queueName)}`;
140
+ }
141
+
142
+ // ============================================================================
143
+ // Callback-based API
144
+ // ============================================================================
145
+
146
+ /**
147
+ * Create a WebSocket connection to receive real-time messages from a queue.
148
+ *
149
+ * The connection handles authentication, automatic reconnection with exponential
150
+ * backoff, and ping/pong keep-alive (handled automatically by the WebSocket
151
+ * implementation).
152
+ *
153
+ * @param options - Configuration for the WebSocket connection
154
+ * @returns A {@link QueueWebSocketConnection} handle for managing the connection
155
+ * @throws {QueueError} If no API key is available
156
+ * @throws {QueueValidationError} If the queue name is invalid
157
+ *
158
+ * @example
159
+ * ```typescript
160
+ * const connection = createQueueWebSocket({
161
+ * queueName: 'order-processing',
162
+ * baseUrl: 'https://catalyst.agentuity.cloud',
163
+ * onMessage: (message) => {
164
+ * console.log('Received:', message.id, message.payload);
165
+ * },
166
+ * });
167
+ *
168
+ * // Later: close the connection
169
+ * connection.close();
170
+ * ```
171
+ */
172
+ export function createQueueWebSocket(options: QueueWebSocketOptions): QueueWebSocketConnection {
173
+ // Validate inputs eagerly so callers get immediate feedback.
174
+ validateQueueName(options.queueName);
175
+ const apiKey = resolveApiKey(options.apiKey);
176
+
177
+ const {
178
+ queueName,
179
+ baseUrl,
180
+ onMessage,
181
+ onOpen,
182
+ onClose,
183
+ onError,
184
+ autoReconnect = true,
185
+ maxReconnectAttempts = Infinity,
186
+ reconnectDelayMs = 1000,
187
+ maxReconnectDelayMs = 30000,
188
+ } = options;
189
+
190
+ let state: QueueWebSocketState = 'connecting';
191
+ let ws: WebSocket | null = null;
192
+ let intentionallyClosed = false;
193
+ let reconnectAttempts = 0;
194
+ let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
195
+ let clientId: string | undefined;
196
+ let lastProcessedOffset: number | undefined;
197
+
198
+ function connect() {
199
+ if (intentionallyClosed) return;
200
+
201
+ const url = buildWebSocketUrl(baseUrl, queueName);
202
+ state = reconnectAttempts > 0 ? 'reconnecting' : 'connecting';
203
+
204
+ try {
205
+ ws = new WebSocket(url);
206
+ } catch (err) {
207
+ state = 'closed';
208
+ onError?.(
209
+ new QueueError({
210
+ message: `Failed to create WebSocket connection: ${err instanceof Error ? err.message : String(err)}`,
211
+ queueName,
212
+ cause: err instanceof Error ? err : undefined,
213
+ }),
214
+ );
215
+ scheduleReconnect();
216
+ return;
217
+ }
218
+
219
+ ws.onopen = () => {
220
+ state = 'authenticating';
221
+ // Send auth message — raw API key, no "Bearer " prefix.
222
+ // Include client_id and last_offset on reconnect for resumption.
223
+ const authPayload: Record<string, unknown> = { authorization: apiKey };
224
+ if (clientId) {
225
+ authPayload.client_id = clientId;
226
+ }
227
+ if (lastProcessedOffset !== undefined) {
228
+ authPayload.last_offset = lastProcessedOffset;
229
+ }
230
+ ws!.send(JSON.stringify(authPayload));
231
+ };
232
+
233
+ /** Whether the auth handshake has completed successfully. */
234
+ let authenticated = false;
235
+
236
+ ws.onmessage = (event: MessageEvent) => {
237
+ const raw = typeof event.data === 'string' ? event.data : String(event.data);
238
+
239
+ if (!authenticated) {
240
+ // First message after open must be the auth response.
241
+ try {
242
+ const parsed = JSON.parse(raw);
243
+ const authResult = WebSocketAuthResponseSchema.safeParse(parsed);
244
+ if (!authResult.success) {
245
+ const err = new QueueError({
246
+ message: `Unexpected auth response from server: ${raw}`,
247
+ queueName,
248
+ });
249
+ onError?.(err);
250
+ ws?.close(4000, 'Invalid auth response');
251
+ return;
252
+ }
253
+
254
+ if (!authResult.data.success) {
255
+ const err = new QueueError({
256
+ message: `Authentication failed: ${authResult.data.error ?? 'Unknown error'}`,
257
+ queueName,
258
+ });
259
+ onError?.(err);
260
+ // Auth rejection is terminal — do not reconnect with the same bad credentials.
261
+ intentionallyClosed = true;
262
+ ws?.close(4001, 'Auth failed');
263
+ return;
264
+ }
265
+
266
+ authenticated = true;
267
+ reconnectAttempts = 0; // Reset on successful auth.
268
+ state = 'connected';
269
+ if (authResult.data.client_id) {
270
+ clientId = authResult.data.client_id;
271
+ }
272
+ onOpen?.();
273
+ } catch {
274
+ const err = new QueueError({
275
+ message: `Failed to parse auth response: ${raw}`,
276
+ queueName,
277
+ });
278
+ onError?.(err);
279
+ ws?.close(4000, 'Invalid auth response');
280
+ }
281
+ return;
282
+ }
283
+
284
+ // Normal message after authentication.
285
+ try {
286
+ const parsed = JSON.parse(raw);
287
+ const msgResult = WebSocketMessageSchema.safeParse(parsed);
288
+ if (msgResult.success && msgResult.data.messages.length > 0) {
289
+ for (const msg of msgResult.data.messages) {
290
+ onMessage(msg);
291
+ if (msg.offset !== undefined) {
292
+ lastProcessedOffset = msg.offset;
293
+ }
294
+ }
295
+ }
296
+ } catch {
297
+ // Non-JSON frames are silently ignored; the server may send
298
+ // ping text frames that are not JSON.
299
+ }
300
+ };
301
+
302
+ ws.onclose = (event: CloseEvent) => {
303
+ state = 'closed';
304
+ ws = null;
305
+
306
+ onClose?.(event.code, event.reason);
307
+
308
+ // Reconnect on any unintentional close — whether we were fully
309
+ // connected, mid-auth, or never authenticated (transient network issue).
310
+ if (!intentionallyClosed) {
311
+ scheduleReconnect();
312
+ }
313
+ };
314
+
315
+ ws.onerror = () => {
316
+ // The browser/Node WebSocket fires `error` then `close`.
317
+ // We report the error but let `onclose` handle reconnection.
318
+ onError?.(
319
+ new QueueError({
320
+ message: 'WebSocket connection error',
321
+ queueName,
322
+ }),
323
+ );
324
+ };
325
+ }
326
+
327
+ function scheduleReconnect() {
328
+ if (intentionallyClosed || !autoReconnect) return;
329
+ if (reconnectAttempts >= maxReconnectAttempts) {
330
+ onError?.(
331
+ new QueueError({
332
+ message: `Exceeded maximum reconnection attempts (${maxReconnectAttempts})`,
333
+ queueName,
334
+ }),
335
+ );
336
+ return;
337
+ }
338
+
339
+ // Exponential backoff with jitter, capped at maxReconnectDelayMs.
340
+ const baseDelay = reconnectDelayMs * Math.pow(2, reconnectAttempts);
341
+ const jitter = 0.5 + Math.random() * 0.5;
342
+ const delay = Math.min(Math.floor(baseDelay * jitter), maxReconnectDelayMs);
343
+
344
+ reconnectAttempts++;
345
+ state = 'reconnecting';
346
+ reconnectTimer = setTimeout(() => {
347
+ reconnectTimer = null;
348
+ connect();
349
+ }, delay);
350
+ }
351
+
352
+ // Kick off the initial connection.
353
+ connect();
354
+
355
+ return {
356
+ close() {
357
+ intentionallyClosed = true;
358
+ if (reconnectTimer !== null) {
359
+ clearTimeout(reconnectTimer);
360
+ reconnectTimer = null;
361
+ }
362
+ if (ws) {
363
+ ws.close(1000, 'Client closed');
364
+ ws = null;
365
+ }
366
+ state = 'closed';
367
+ },
368
+ get state() {
369
+ return state;
370
+ },
371
+ get clientId() {
372
+ return clientId;
373
+ },
374
+ get lastOffset() {
375
+ return lastProcessedOffset;
376
+ },
377
+ };
378
+ }
379
+
380
+ // ============================================================================
381
+ // Async Iterator API
382
+ // ============================================================================
383
+
384
+ /**
385
+ * Subscribe to real-time messages from a queue via WebSocket.
386
+ *
387
+ * Returns an async iterator that yields messages as they arrive.
388
+ * The connection is automatically managed (auth, reconnection, cleanup).
389
+ *
390
+ * @param options - Configuration for the subscription
391
+ * @returns An async generator that yields {@link Message} objects
392
+ * @throws {QueueError} If no API key is available
393
+ * @throws {QueueValidationError} If the queue name is invalid
394
+ *
395
+ * @example
396
+ * ```typescript
397
+ * const controller = new AbortController();
398
+ * for await (const message of subscribeToQueue({
399
+ * queueName: 'order-processing',
400
+ * baseUrl: 'https://catalyst.agentuity.cloud',
401
+ * signal: controller.signal,
402
+ * })) {
403
+ * console.log('Received:', message.id, message.payload);
404
+ * }
405
+ * ```
406
+ */
407
+ export async function* subscribeToQueue(
408
+ options: SubscribeToQueueOptions,
409
+ ): AsyncGenerator<Message, void, unknown> {
410
+ const { signal } = options;
411
+
412
+ // Check if already aborted.
413
+ if (signal?.aborted) return;
414
+
415
+ // A queue for buffering messages between the WebSocket callbacks and the
416
+ // async iterator consumer.
417
+ const buffer: Message[] = [];
418
+ let resolve: (() => void) | null = null;
419
+ let done = false;
420
+ let lastError: Error | null = null;
421
+
422
+ function push(message: Message) {
423
+ buffer.push(message);
424
+ if (resolve) {
425
+ resolve();
426
+ resolve = null;
427
+ }
428
+ }
429
+
430
+ function finish(error?: Error) {
431
+ done = true;
432
+ if (error) lastError = error;
433
+ if (resolve) {
434
+ resolve();
435
+ resolve = null;
436
+ }
437
+ }
438
+
439
+ const connection = createQueueWebSocket({
440
+ queueName: options.queueName,
441
+ apiKey: options.apiKey,
442
+ baseUrl: options.baseUrl,
443
+ onMessage: push,
444
+ onError: (err) => finish(err),
445
+ onClose: () => {
446
+ // Only finish if the connection is intentionally closed (signal aborted).
447
+ // Otherwise, the callback-based API handles reconnection.
448
+ },
449
+ autoReconnect: true,
450
+ });
451
+
452
+ // Wire up the abort signal to close the connection.
453
+ const onAbort = () => {
454
+ connection.close();
455
+ finish();
456
+ };
457
+ signal?.addEventListener('abort', onAbort, { once: true });
458
+
459
+ try {
460
+ while (!done) {
461
+ // Drain buffered messages.
462
+ while (buffer.length > 0) {
463
+ yield buffer.shift()!;
464
+ // Re-check after yield in case signal was aborted.
465
+ if (done || signal?.aborted) return;
466
+ }
467
+
468
+ if (done || signal?.aborted) return;
469
+
470
+ // Wait for the next message or completion.
471
+ await new Promise<void>((r) => {
472
+ resolve = r;
473
+ });
474
+ }
475
+
476
+ // Drain any remaining messages.
477
+ while (buffer.length > 0) {
478
+ yield buffer.shift()!;
479
+ }
480
+
481
+ if (lastError) {
482
+ throw lastError;
483
+ }
484
+ } finally {
485
+ signal?.removeEventListener('abort', onAbort);
486
+ connection.close();
487
+ }
488
+ }
@@ -103,6 +103,13 @@ export function validateBucketName(name: string): { valid: boolean; error?: stri
103
103
  if (isIPv4Address(name)) {
104
104
  return { valid: false, error: 'bucket name cannot be an IP address' };
105
105
  }
106
+ // Reserved prefixes (system-generated names)
107
+ if (name.startsWith('ag-') || name.startsWith('ago-')) {
108
+ return {
109
+ valid: false,
110
+ error: 'bucket names starting with "ag-" or "ago-" are reserved for system use',
111
+ };
112
+ }
106
113
  return { valid: true };
107
114
  }
108
115
 
@@ -45,7 +45,7 @@ export interface CLISandboxListOptions {
45
45
  /**
46
46
  * Filter by sandbox status
47
47
  */
48
- status?: 'creating' | 'idle' | 'running' | 'terminated' | 'failed';
48
+ status?: 'creating' | 'idle' | 'running' | 'paused' | 'stopping' | 'suspended' | 'terminated' | 'failed';
49
49
  /**
50
50
  * Maximum number of sandboxes to return (default: 50, max: 100)
51
51
  */
@@ -1,6 +1,7 @@
1
1
  import type { SandboxCreateOptions, SandboxStatus } from '@agentuity/core';
2
2
  import { z } from 'zod';
3
3
  import { type APIClient, APIResponseSchema } from '../api';
4
+ import { NPM_PACKAGE_NAME_PATTERN } from './snapshot-build';
4
5
  import { API_VERSION, throwSandboxError } from './util';
5
6
 
6
7
  export const SandboxCreateRequestSchema = z
@@ -86,6 +87,17 @@ export const SandboxCreateRequestSchema = z
86
87
  .array(z.string())
87
88
  .optional()
88
89
  .describe('Apt packages to install when creating the sandbox'),
90
+ packages: z
91
+ .array(
92
+ z
93
+ .string()
94
+ .regex(
95
+ NPM_PACKAGE_NAME_PATTERN,
96
+ 'Invalid npm/bun package specifier: must not contain whitespace, semicolons, backticks, pipes, or dollar signs'
97
+ )
98
+ )
99
+ .optional()
100
+ .describe('npm/bun packages to install globally when creating the sandbox'),
89
101
  metadata: z
90
102
  .record(z.string(), z.unknown())
91
103
  .optional()
@@ -110,7 +122,7 @@ export const SandboxCreateDataSchema = z
110
122
  .object({
111
123
  sandboxId: z.string().describe('Unique identifier for the created sandbox'),
112
124
  status: z
113
- .enum(['creating', 'idle', 'running', 'terminated', 'failed'])
125
+ .enum(['creating', 'idle', 'running', 'paused', 'stopping', 'suspended', 'terminated', 'failed'])
114
126
  .describe('Current status of the sandbox'),
115
127
  stdoutStreamId: z.string().optional().describe('Stream ID for reading stdout'),
116
128
  stdoutStreamUrl: z.string().optional().describe('URL for streaming stdout output'),
@@ -202,6 +214,9 @@ export async function sandboxCreate(
202
214
  if (options.dependencies && options.dependencies.length > 0) {
203
215
  body.dependencies = options.dependencies;
204
216
  }
217
+ if (options.packages && options.packages.length > 0) {
218
+ body.packages = options.packages;
219
+ }
205
220
  if (options.metadata) {
206
221
  body.metadata = options.metadata;
207
222
  }
@@ -105,7 +105,7 @@ export const SandboxInfoDataSchema = z
105
105
  name: z.string().optional().describe('Sandbox name'),
106
106
  description: z.string().optional().describe('Sandbox description'),
107
107
  status: z
108
- .enum(['creating', 'idle', 'running', 'terminated', 'failed', 'deleted'])
108
+ .enum(['creating', 'idle', 'running', 'paused', 'stopping', 'suspended', 'terminated', 'failed', 'deleted'])
109
109
  .describe('Current status of the sandbox'),
110
110
  mode: z.string().optional().describe('Sandbox mode (interactive or oneshot)'),
111
111
  createdAt: z.string().describe('ISO timestamp when the sandbox was created'),
@@ -123,6 +123,10 @@ export const SandboxInfoDataSchema = z
123
123
  .array(z.string())
124
124
  .optional()
125
125
  .describe('Apt packages installed in the sandbox'),
126
+ packages: z
127
+ .array(z.string())
128
+ .optional()
129
+ .describe('npm/bun packages installed globally in the sandbox'),
126
130
  metadata: z
127
131
  .record(z.string(), z.unknown())
128
132
  .optional()
@@ -209,6 +213,7 @@ export async function sandboxGet(
209
213
  stdoutStreamUrl: resp.data.stdoutStreamUrl,
210
214
  stderrStreamUrl: resp.data.stderrStreamUrl,
211
215
  dependencies: resp.data.dependencies,
216
+ packages: resp.data.packages,
212
217
  metadata: resp.data.metadata as Record<string, unknown> | undefined,
213
218
  resources: resp.data.resources,
214
219
  cpuTimeMs: resp.data.cpuTimeMs,
@@ -171,7 +171,7 @@ export {
171
171
  snapshotUpload,
172
172
  } from './snapshot';
173
173
  export type { SnapshotBuildFile } from './snapshot-build';
174
- export { SnapshotBuildFileSchema } from './snapshot-build';
174
+ export { SnapshotBuildFileSchema, NPM_PACKAGE_NAME_PATTERN } from './snapshot-build';
175
175
  export type { SandboxErrorCode, SandboxErrorContext } from './util';
176
176
  export {
177
177
  ExecutionCancelledError,
@@ -76,7 +76,7 @@ export const SandboxInfoSchema = z
76
76
  name: z.string().optional().describe('Sandbox name'),
77
77
  description: z.string().optional().describe('Sandbox description'),
78
78
  status: z
79
- .enum(['creating', 'idle', 'running', 'terminated', 'failed', 'deleted'])
79
+ .enum(['creating', 'idle', 'running', 'paused', 'stopping', 'suspended', 'terminated', 'failed', 'deleted'])
80
80
  .describe('Current status of the sandbox'),
81
81
  mode: z.string().optional().describe('Sandbox mode (interactive or oneshot)'),
82
82
  createdAt: z.string().describe('ISO timestamp when the sandbox was created'),
@@ -1,5 +1,17 @@
1
1
  import { z } from 'zod';
2
2
 
3
+ /**
4
+ * Regex pattern for validating npm/bun package specifiers.
5
+ * Uses a blocklist approach: rejects shell injection characters while allowing
6
+ * all legitimate specifier formats (names, scoped packages, URLs, git refs, etc.).
7
+ *
8
+ * Valid examples: "typescript", "@types/node", "opencode-ai@1.2.3",
9
+ * "https://github.com/user/repo", "git+https://github.com/user/repo.git",
10
+ * "github:user/repo", "file:../local-pkg"
11
+ * Invalid examples: "foo bar", "pkg;rm -rf", "pkg|cat /etc/passwd", "$(evil)"
12
+ */
13
+ export const NPM_PACKAGE_NAME_PATTERN = /^[^\s;`|$]+$/;
14
+
3
15
  /**
4
16
  * Base schema for snapshot build configuration file (agentuity-snapshot.yaml)
5
17
  * This is the canonical schema - used for JSON Schema generation.
@@ -22,6 +34,19 @@ export const SnapshotBuildFileBaseSchema = z
22
34
  .describe(
23
35
  'List of apt packages to install. Supports version pinning: package=version or package=version* for prefix matching'
24
36
  ),
37
+ packages: z
38
+ .array(
39
+ z
40
+ .string()
41
+ .regex(
42
+ NPM_PACKAGE_NAME_PATTERN,
43
+ 'Invalid npm/bun package specifier: must not contain whitespace, semicolons, backticks, pipes, or dollar signs'
44
+ )
45
+ )
46
+ .optional()
47
+ .describe(
48
+ 'List of npm/bun packages to install globally via bun install -g. Example: opencode-ai, typescript'
49
+ ),
25
50
  files: z
26
51
  .array(z.string())
27
52
  .optional()
@@ -49,17 +74,18 @@ export const SnapshotBuildFileBaseSchema = z
49
74
 
50
75
  /**
51
76
  * Schema with validation refinement - use this for parsing/validation.
52
- * Ensures at least one of dependencies, files, or env is specified.
77
+ * Ensures at least one of dependencies, files, env, or packages is specified.
53
78
  */
54
79
  export const SnapshotBuildFileSchema = SnapshotBuildFileBaseSchema.refine(
55
80
  (data) => {
56
81
  const hasDependencies = data.dependencies && data.dependencies.length > 0;
57
82
  const hasFiles = data.files && data.files.length > 0;
58
83
  const hasEnv = data.env && Object.keys(data.env).length > 0;
59
- return hasDependencies || hasFiles || hasEnv;
84
+ const hasPackages = data.packages && data.packages.length > 0;
85
+ return hasDependencies || hasFiles || hasEnv || hasPackages;
60
86
  },
61
87
  {
62
- message: 'At least one of dependencies, files, or env must be specified',
88
+ message: 'At least one of dependencies, files, env, or packages must be specified',
63
89
  }
64
90
  );
65
91