@adobe/aio-lib-sandbox 0.1.0-alpha.3

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/src/Sandbox.js ADDED
@@ -0,0 +1,467 @@
1
+ /*
2
+ Copyright 2026 Adobe. All rights reserved.
3
+ This file is licensed to you under the Apache License, Version 2.0 (the "License");
4
+ you may not use this file except in compliance with the License. You may obtain a copy
5
+ of the License at http://www.apache.org/licenses/LICENSE-2.0
6
+ Unless required by applicable law or agreed to in writing, software distributed under
7
+ the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
8
+ OF ANY KIND, either express or implied. See the License for the specific language
9
+ governing permissions and limitations under the License.
10
+ */
11
+
12
+ const crypto = require('node:crypto')
13
+ const {
14
+ SandboxClientError,
15
+ SandboxTimeoutError,
16
+ SandboxWebSocketError
17
+ } = require('./errors')
18
+ const {
19
+ buildWebSocketEndpoint,
20
+ resolveCredentials,
21
+ normalizeSize,
22
+ apiRequest
23
+ } = require('./utils')
24
+ const { SANDBOX_SIZES } = require('./constants')
25
+ const { SandboxSocket } = require('./ws')
26
+
27
+ /**
28
+ * Connected compute sandbox session.
29
+ *
30
+ * Use `Sandbox.create()` or `Sandbox.get()`
31
+ */
32
+ class Sandbox {
33
+ /**
34
+ * @param {object} options sandbox options
35
+ * @private
36
+ */
37
+ constructor (options) {
38
+ this.id = options.id
39
+ this.endpoint = options.endpoint
40
+ this.status = options.status
41
+ this.cluster = options.cluster
42
+ this.region = options.region
43
+ this.maxLifetime = options.maxLifetime
44
+
45
+ this.namespace = options.namespace
46
+ this.apiHost = options.apiHost
47
+ this.apiKey = options.apiKey
48
+ this.token = options.token
49
+ this.publicUrlTemplate = options.publicUrlTemplate || null
50
+ this.managementEndpoint = options.managementEndpoint || null
51
+ this.ws = null
52
+ }
53
+
54
+ /**
55
+ * Creates a new compute sandbox and opens its WebSocket session.
56
+ *
57
+ * Credentials are read from the environment automatically when running inside
58
+ * a Runtime action (`__OW_API_HOST`, `__OW_NAMESPACE`, `__OW_API_KEY`).
59
+ * Any value passed explicitly in `options` overrides the environment.
60
+ *
61
+ * Commands run inside the sandbox start in the `/workspace` directory by default.
62
+ *
63
+ * @param {object} [options] creation options
64
+ * @param {string} [options.apiHost] Runtime API host (overrides `__OW_API_HOST`)
65
+ * @param {string} [options.namespace] Runtime namespace (overrides `__OW_NAMESPACE`)
66
+ * @param {string} [options.auth] Runtime API key (overrides `__OW_API_KEY`)
67
+ * @param {string} [options.name] sandbox display name
68
+ * @param {string} [options.type] sandbox type (default: `'cpu:default'`)
69
+ * @param {string|object} [options.size] sandbox size tier (name or spec object)
70
+ * @param {number} [options.maxLifetime] maximum lifetime in seconds
71
+ * @param {object} [options.envs] environment variables to inject into the sandbox
72
+ * @param {object} [options.policy] network policy (e.g. egress allowlist)
73
+ * @returns {Promise<Sandbox>} connected sandbox instance
74
+ */
75
+ static async create (options = {}) {
76
+ console.warn('[aio-lib-sandbox] alpha — APIs may change without notice')
77
+ const creds = resolveCredentials(options)
78
+
79
+ const body = {
80
+ name: options.name,
81
+ size: normalizeSize(options.size),
82
+ type: options.type || 'cpu:default',
83
+ maxLifetime: options.maxLifetime || 3600
84
+ }
85
+
86
+ if (options.cluster !== undefined) body.cluster = options.cluster
87
+ if (options.region !== undefined) body.region = options.region
88
+ if (options.envs !== undefined) body.envs = options.envs
89
+ if (options.policy !== undefined) body.policy = options.policy
90
+
91
+ const url = `${creds.apiHost}/api/v1/namespaces/${creds.namespace}/sandbox`
92
+ const payload = await apiRequest('POST', url, creds.apiKey, body)
93
+
94
+ const sandboxId = payload.sandboxId
95
+ const endpoint = payload.wsEndpoint || buildWebSocketEndpoint(creds.apiHost, creds.namespace, sandboxId)
96
+
97
+ const sandbox = new Sandbox({
98
+ id: sandboxId,
99
+ endpoint,
100
+ status: payload.status,
101
+ cluster: payload.cluster,
102
+ region: payload.region,
103
+ maxLifetime: payload.maxLifetime,
104
+ publicUrlTemplate: payload.publicUrlTemplate || null,
105
+ managementEndpoint: payload.managementEndpoint || null,
106
+ namespace: creds.namespace,
107
+ apiHost: creds.apiHost,
108
+ apiKey: creds.apiKey,
109
+ token: payload.token
110
+ })
111
+
112
+ await sandbox.connect()
113
+ return sandbox
114
+ }
115
+
116
+ /**
117
+ * Fetches an existing sandbox.
118
+ *
119
+ * Credentials are read from the environment automatically.
120
+ * Any value passed explicitly in `options` overrides the environment.
121
+ *
122
+ * @param {string} sandboxId the sandbox ID to look up
123
+ * @param {object} [options] credential overrides
124
+ * @param {string} [options.apiHost] Runtime API host
125
+ * @param {string} [options.namespace] Runtime namespace
126
+ * @param {string} [options.auth] Runtime API key
127
+ * @returns {Promise<Sandbox>} sandbox instance with `status` populated (not WebSocket-connected)
128
+ */
129
+ static async get (sandboxId, options = {}) {
130
+ console.warn('[aio-lib-sandbox] alpha — APIs may change without notice')
131
+ const creds = resolveCredentials(options)
132
+ const url = `${creds.apiHost}/api/v1/namespaces/${creds.namespace}/sandbox/${sandboxId}`
133
+ const payload = await apiRequest('GET', url, creds.apiKey)
134
+
135
+ return new Sandbox({
136
+ id: payload.sandboxId || sandboxId,
137
+ endpoint: null,
138
+ status: payload.status,
139
+ cluster: payload.cluster,
140
+ region: payload.region,
141
+ maxLifetime: payload.maxLifetime,
142
+ namespace: creds.namespace,
143
+ apiHost: creds.apiHost,
144
+ apiKey: creds.apiKey,
145
+ token: null
146
+ })
147
+ }
148
+
149
+ /**
150
+ * Named sandbox size tiers.
151
+ *
152
+ * @type {object}
153
+ */
154
+ static get sizes () {
155
+ return SANDBOX_SIZES
156
+ }
157
+
158
+ /**
159
+ * Exposes `resolveCredentials` as a static helper (useful for testing).
160
+ *
161
+ * @param {object} overrides credential overrides
162
+ * @returns {{ apiHost: string, namespace: string, apiKey: string }}
163
+ */
164
+ static resolveCredentials (overrides = {}) {
165
+ return resolveCredentials(overrides)
166
+ }
167
+
168
+ /**
169
+ * Exposes `normalizeSize` as a static helper (useful for testing).
170
+ *
171
+ * @param {string|object|undefined} size
172
+ * @returns {string}
173
+ */
174
+ static normalizeSize (size) {
175
+ return normalizeSize(size)
176
+ }
177
+
178
+ // ------------------------------------------------------------------
179
+ // WebSocket connection
180
+ // ------------------------------------------------------------------
181
+
182
+ /**
183
+ * Opens the sandbox WebSocket connection (called automatically by `create()`).
184
+ *
185
+ * @returns {Promise<void>}
186
+ */
187
+ connect () {
188
+ if (!this.ws) {
189
+ this.ws = new SandboxSocket({
190
+ id: this.id,
191
+ endpoint: this.endpoint,
192
+ token: this.token
193
+ })
194
+ }
195
+ return this.ws.connect()
196
+ }
197
+
198
+ // ------------------------------------------------------------------
199
+ // Exec
200
+ // ------------------------------------------------------------------
201
+
202
+ /**
203
+ * Executes a command inside the sandbox.
204
+ *
205
+ * Returns a Promise (with an `execId` property) that resolves to
206
+ * `{ execId, stdout, stderr, exitCode }` when the command completes.
207
+ *
208
+ * @param {string} command command to run
209
+ * @param {object} [options] execution options
210
+ * @param {number} [options.timeout] timeout in milliseconds
211
+ * @param {string|Buffer} [options.stdin] data to send to stdin at startup
212
+ * @param {function} [options.onOutput] callback called with `(data, stream)` for each output chunk
213
+ * @returns {Promise<{execId: string, stdout: string, stderr: string, exitCode: number}>}
214
+ */
215
+ exec (command, options = {}) {
216
+ try {
217
+ this.ensureOpen()
218
+ } catch (error) {
219
+ return Promise.reject(error)
220
+ }
221
+
222
+ const execId = `exec-${crypto.randomBytes(12).toString('hex')}`
223
+ let timeoutHandle
224
+
225
+ const execPromise = new Promise((resolve, reject) => {
226
+ this.ws.pendingExecs.set(execId, {
227
+ resolve,
228
+ reject,
229
+ stdout: '',
230
+ stderr: '',
231
+ onOutput: options.onOutput,
232
+ timeout: undefined
233
+ })
234
+
235
+ if (options.timeout) {
236
+ timeoutHandle = setTimeout(() => {
237
+ try { this.kill(execId) } catch (_) {}
238
+ this.ws.rejectExec(execId, new SandboxTimeoutError(
239
+ `Command '${command}' exceeded timeout of ${options.timeout}ms`
240
+ ))
241
+ }, options.timeout)
242
+ this.ws.pendingExecs.get(execId).timeout = timeoutHandle
243
+ }
244
+ })
245
+
246
+ execPromise.execId = execId
247
+
248
+ try {
249
+ this.sendFrame({ type: 'exec.run', execId, command })
250
+ if (options.stdin !== undefined) {
251
+ this.writeStdin(execId, options.stdin)
252
+ this.closeStdin(execId)
253
+ }
254
+ } catch (error) {
255
+ this.ws.rejectExec(execId, new SandboxWebSocketError(
256
+ `Could not send exec frame: ${error.message}`
257
+ ))
258
+ }
259
+
260
+ return execPromise
261
+ }
262
+
263
+ /**
264
+ * Sends a signal to a running command.
265
+ *
266
+ * @param {string} execId execution id
267
+ * @param {string} [signal] signal to deliver (default: `'SIGTERM'`)
268
+ */
269
+ kill (execId, signal = 'SIGTERM') {
270
+ this.ensureOpen()
271
+ this.sendFrame({ type: 'exec.kill', execId, signal })
272
+ }
273
+
274
+ /**
275
+ * Writes data to the stdin of a running command.
276
+ * Fire-and-forget — there is no response on success.
277
+ *
278
+ * @param {string} execId execution id from `exec()`
279
+ * @param {string|Buffer} data data to write
280
+ */
281
+ writeStdin (execId, data) {
282
+ this.ensureOpen()
283
+ const frame = { type: 'exec.input', execId }
284
+ if (Buffer.isBuffer(data)) {
285
+ frame.data = data.toString('base64')
286
+ frame.encoding = 'base64'
287
+ } else {
288
+ frame.data = data
289
+ }
290
+ this.sendFrame(frame)
291
+ }
292
+
293
+ /**
294
+ * Closes stdin for a running command, signalling EOF.
295
+ * Fire-and-forget — there is no response on success.
296
+ *
297
+ * @param {string} execId execution id from `exec()`
298
+ */
299
+ closeStdin (execId) {
300
+ this.ensureOpen()
301
+ this.sendFrame({ type: 'exec.endInput', execId })
302
+ }
303
+
304
+ // ------------------------------------------------------------------
305
+ // File operations
306
+ // ------------------------------------------------------------------
307
+
308
+ /**
309
+ * Reads a file from the sandbox filesystem.
310
+ *
311
+ * @param {string} path path inside the sandbox
312
+ * @returns {Promise<string>} file contents as a UTF-8 string
313
+ */
314
+ readFile (path) {
315
+ try {
316
+ this.ensureOpen()
317
+ } catch (error) {
318
+ return Promise.reject(error)
319
+ }
320
+
321
+ const execId = `file-${crypto.randomBytes(12).toString('hex')}`
322
+ const opPromise = new Promise((resolve, reject) => {
323
+ this.ws.pendingFileOps.set(execId, { resolve, reject })
324
+ })
325
+
326
+ try {
327
+ this.sendFrame({ type: 'file.read', execId, path })
328
+ } catch (error) {
329
+ this.ws.rejectFileOp(execId, new SandboxWebSocketError(
330
+ `Could not send file.read frame: ${error.message}`
331
+ ))
332
+ }
333
+
334
+ return opPromise
335
+ }
336
+
337
+ /**
338
+ * Writes a file to the sandbox filesystem. Parent directories are created automatically.
339
+ *
340
+ * @param {string} path path inside the sandbox
341
+ * @param {string|Buffer} content file contents
342
+ * @returns {Promise<{path: string, size: number, ok: boolean}>} write confirmation
343
+ */
344
+ writeFile (path, content) {
345
+ try {
346
+ this.ensureOpen()
347
+ } catch (error) {
348
+ return Promise.reject(error)
349
+ }
350
+
351
+ const execId = `file-${crypto.randomBytes(12).toString('hex')}`
352
+ const encoded = Buffer.isBuffer(content)
353
+ ? content.toString('base64')
354
+ : Buffer.from(content).toString('base64')
355
+
356
+ const opPromise = new Promise((resolve, reject) => {
357
+ this.ws.pendingFileOps.set(execId, { resolve, reject })
358
+ })
359
+
360
+ try {
361
+ this.sendFrame({ type: 'file.write', execId, path, content: encoded, encoding: 'base64' })
362
+ } catch (error) {
363
+ this.ws.rejectFileOp(execId, new SandboxWebSocketError(
364
+ `Could not send file.write frame: ${error.message}`
365
+ ))
366
+ }
367
+
368
+ return opPromise
369
+ }
370
+
371
+ /**
372
+ * Lists the contents of a directory inside the sandbox.
373
+ *
374
+ * @param {string} path directory path inside the sandbox
375
+ * @returns {Promise<Array<{name: string, type: string, size?: number}>>} directory entries
376
+ */
377
+ listFiles (path) {
378
+ try {
379
+ this.ensureOpen()
380
+ } catch (error) {
381
+ return Promise.reject(error)
382
+ }
383
+
384
+ const execId = `file-${crypto.randomBytes(12).toString('hex')}`
385
+ const opPromise = new Promise((resolve, reject) => {
386
+ this.ws.pendingFileOps.set(execId, { resolve, reject })
387
+ })
388
+
389
+ try {
390
+ this.sendFrame({ type: 'file.list', execId, path })
391
+ } catch (error) {
392
+ this.ws.rejectFileOp(execId, new SandboxWebSocketError(
393
+ `Could not send file.list frame: ${error.message}`
394
+ ))
395
+ }
396
+
397
+ return opPromise
398
+ }
399
+
400
+ // ------------------------------------------------------------------
401
+ // Lifecycle
402
+ // ------------------------------------------------------------------
403
+
404
+ /**
405
+ * Returns the public preview URL for a given port on this sandbox.
406
+ *
407
+ * @param {object} options URL options
408
+ * @param {number} options.port port number (1–65535)
409
+ * @param {string} [options.protocol] override the URL scheme (e.g. `'wss'`)
410
+ * @returns {Promise<string>} public preview URL
411
+ */
412
+ async getUrl ({ port, protocol } = {}) {
413
+ if (!this.publicUrlTemplate) {
414
+ throw new SandboxClientError(
415
+ `Cannot get URL for sandbox '${this.id}': publicUrlTemplate is not available`
416
+ )
417
+ }
418
+
419
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
420
+ throw new SandboxClientError(
421
+ `Invalid port '${port}': must be an integer between 1 and 65535`
422
+ )
423
+ }
424
+
425
+ let url = this.publicUrlTemplate
426
+ .replace('{sandboxId}', this.id)
427
+ .replace('{port}', String(port))
428
+
429
+ if (protocol) {
430
+ url = url.replace(/^https?:\/\//, `${protocol}://`)
431
+ }
432
+
433
+ return url
434
+ }
435
+
436
+ /**
437
+ * Destroys the sandbox and closes its WebSocket connection.
438
+ *
439
+ * @returns {Promise<object>} destroy response payload
440
+ */
441
+ async destroy () {
442
+ const base = this.managementEndpoint || this.apiHost
443
+ const url = `${base}/api/v1/namespaces/${this.namespace}/sandbox/${this.id}`
444
+ const payload = await apiRequest('DELETE', url, this.apiKey)
445
+
446
+ this.status = payload.status || this.status
447
+ this.ws?.close()
448
+ return payload
449
+ }
450
+
451
+ // ------------------------------------------------------------------
452
+ // Private helpers
453
+ // ------------------------------------------------------------------
454
+
455
+ ensureOpen () {
456
+ if (!this.ws) {
457
+ throw new SandboxWebSocketError(`Sandbox '${this.id}' is not connected`)
458
+ }
459
+ this.ws.ensureOpen()
460
+ }
461
+
462
+ sendFrame (frame) {
463
+ this.ws.send(frame)
464
+ }
465
+ }
466
+
467
+ module.exports = Sandbox
@@ -0,0 +1,19 @@
1
+ /*
2
+ Copyright 2026 Adobe. All rights reserved.
3
+ This file is licensed to you under the Apache License, Version 2.0 (the "License");
4
+ you may not use this file except in compliance with the License. You may obtain a copy
5
+ of the License at http://www.apache.org/licenses/LICENSE-2.0
6
+ Unless required by applicable law or agreed to in writing, software distributed under
7
+ the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
8
+ OF ANY KIND, either express or implied. See the License for the specific language
9
+ governing permissions and limitations under the License.
10
+ */
11
+
12
+ const SANDBOX_SIZES = Object.freeze({
13
+ SMALL: { cpu: '500m', memory: '512Mi', gpu: 0 },
14
+ MEDIUM: { cpu: '2000m', memory: '4Gi', gpu: 0 },
15
+ LARGE: { cpu: '4000m', memory: '16Gi', gpu: 0 },
16
+ XLARGE: { cpu: '8000m', memory: '32Gi', gpu: 1 }
17
+ })
18
+
19
+ module.exports = { SANDBOX_SIZES }
package/src/errors.js ADDED
@@ -0,0 +1,37 @@
1
+ /*
2
+ Copyright 2026 Adobe. All rights reserved.
3
+ This file is licensed to you under the Apache License, Version 2.0 (the "License");
4
+ you may not use this file except in compliance with the License. You may obtain a copy
5
+ of the License at http://www.apache.org/licenses/LICENSE-2.0
6
+ Unless required by applicable law or agreed to in writing, software distributed under
7
+ the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
8
+ OF ANY KIND, either express or implied. See the License for the specific language
9
+ governing permissions and limitations under the License.
10
+ */
11
+
12
+ class SandboxSDKError extends Error {
13
+ constructor (message) {
14
+ super(message)
15
+ this.name = this.constructor.name
16
+ if (Error.captureStackTrace) {
17
+ Error.captureStackTrace(this, this.constructor)
18
+ }
19
+ }
20
+ }
21
+
22
+ class SandboxInitializationError extends SandboxSDKError {}
23
+ class SandboxClientError extends SandboxSDKError {}
24
+ class SandboxNotFoundError extends SandboxSDKError {}
25
+ class SandboxUnauthorizedError extends SandboxSDKError {}
26
+ class SandboxTimeoutError extends SandboxSDKError {}
27
+ class SandboxWebSocketError extends SandboxSDKError {}
28
+
29
+ module.exports = {
30
+ SandboxSDKError,
31
+ SandboxInitializationError,
32
+ SandboxClientError,
33
+ SandboxNotFoundError,
34
+ SandboxUnauthorizedError,
35
+ SandboxTimeoutError,
36
+ SandboxWebSocketError
37
+ }
package/src/index.js ADDED
@@ -0,0 +1,32 @@
1
+ /*
2
+ Copyright 2026 Adobe. All rights reserved.
3
+ This file is licensed to you under the Apache License, Version 2.0 (the "License");
4
+ you may not use this file except in compliance with the License. You may obtain a copy
5
+ of the License at http://www.apache.org/licenses/LICENSE-2.0
6
+ Unless required by applicable law or agreed to in writing, software distributed under
7
+ the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
8
+ OF ANY KIND, either express or implied. See the License for the specific language
9
+ governing permissions and limitations under the License.
10
+ */
11
+
12
+ const Sandbox = require('./Sandbox')
13
+ const {
14
+ SandboxSDKError,
15
+ SandboxInitializationError,
16
+ SandboxClientError,
17
+ SandboxNotFoundError,
18
+ SandboxUnauthorizedError,
19
+ SandboxTimeoutError,
20
+ SandboxWebSocketError
21
+ } = require('./errors')
22
+
23
+ module.exports = {
24
+ Sandbox,
25
+ SandboxSDKError,
26
+ SandboxInitializationError,
27
+ SandboxClientError,
28
+ SandboxNotFoundError,
29
+ SandboxUnauthorizedError,
30
+ SandboxTimeoutError,
31
+ SandboxWebSocketError
32
+ }
package/src/utils.js ADDED
@@ -0,0 +1,143 @@
1
+ /*
2
+ Copyright 2026 Adobe. All rights reserved.
3
+ This file is licensed to you under the Apache License, Version 2.0 (the "License");
4
+ you may not use this file except in compliance with the License. You may obtain a copy
5
+ of the License at http://www.apache.org/licenses/LICENSE-2.0
6
+ Unless required by applicable law or agreed to in writing, software distributed under
7
+ the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
8
+ OF ANY KIND, either express or implied. See the License for the specific language
9
+ governing permissions and limitations under the License.
10
+ */
11
+
12
+ const {
13
+ SandboxClientError,
14
+ SandboxInitializationError,
15
+ SandboxNotFoundError,
16
+ SandboxUnauthorizedError,
17
+ SandboxTimeoutError
18
+ } = require('./errors')
19
+ const { SANDBOX_SIZES } = require('./constants')
20
+
21
+ function buildAuthorizationHeader (apiKey) {
22
+ return `Basic ${Buffer.from(apiKey).toString('base64')}`
23
+ }
24
+
25
+ function createSandboxHttpError (status, message) {
26
+ if (status === 401 || status === 403) {
27
+ return new SandboxUnauthorizedError(message)
28
+ }
29
+ if (status === 404) {
30
+ return new SandboxNotFoundError(message)
31
+ }
32
+ if (status === 504) {
33
+ return new SandboxTimeoutError(message)
34
+ }
35
+ return new SandboxClientError(message)
36
+ }
37
+
38
+ function normalizeApiHost (host) {
39
+ if (!host.match(/^https?:\/\//)) {
40
+ return `https://${host}`
41
+ }
42
+ return host
43
+ }
44
+
45
+ function buildWebSocketEndpoint (apiHost, namespace, sandboxId) {
46
+ const url = new URL(apiHost)
47
+ url.protocol = url.protocol === 'http:' ? 'ws:' : 'wss:'
48
+ url.pathname = `/ws/v1/namespaces/${namespace}/sandbox/${sandboxId}/exec`
49
+ url.search = ''
50
+ return url.toString()
51
+ }
52
+
53
+ /**
54
+ * Reads Runtime credentials from environment variables, merged with any
55
+ * explicit overrides. Throws `SandboxInitializationError` for missing values.
56
+ *
57
+ * @param {object} overrides explicit credential overrides
58
+ * @returns {{ apiHost: string, namespace: string, apiKey: string }}
59
+ */
60
+ function resolveCredentials (overrides = {}) {
61
+ const apiHost = overrides.apiHost || process.env.__OW_API_HOST
62
+ const namespace = overrides.namespace || process.env.__OW_NAMESPACE
63
+ const apiKey = overrides.auth || process.env.__OW_API_KEY
64
+
65
+ const missing = []
66
+ if (!apiHost) missing.push('apiHost')
67
+ if (!namespace) missing.push('namespace')
68
+ if (!apiKey) missing.push('auth')
69
+
70
+ if (missing.length > 0) {
71
+ throw new SandboxInitializationError(
72
+ `Missing required credentials: ${missing.join(', ')}. ` +
73
+ 'Pass them explicitly or set __OW_API_HOST, __OW_NAMESPACE, __OW_API_KEY in the environment.'
74
+ )
75
+ }
76
+
77
+ return { apiHost: normalizeApiHost(apiHost), namespace, apiKey }
78
+ }
79
+
80
+ /**
81
+ * @param {string|object|undefined} size size name or spec object
82
+ * @returns {string} normalised size name
83
+ */
84
+ function normalizeSize (size) {
85
+ if (!size) return 'MEDIUM'
86
+
87
+ if (typeof size === 'string' && SANDBOX_SIZES[size]) return size
88
+
89
+ if (typeof size === 'object') {
90
+ const entry = Object.entries(SANDBOX_SIZES).find(
91
+ ([, v]) => v.cpu === size.cpu && v.memory === size.memory && v.gpu === size.gpu
92
+ )
93
+ if (entry) return entry[0]
94
+ }
95
+
96
+ throw new SandboxClientError('Invalid sandbox size provided')
97
+ }
98
+
99
+ /**
100
+ * Thin fetch wrapper around the management REST API.
101
+ *
102
+ * Uses the Node.js global `fetch` (available since Node 18).
103
+ *
104
+ * @param {string} method HTTP method
105
+ * @param {string} url full request URL
106
+ * @param {string} apiKey API key for Basic auth
107
+ * @param {object|undefined} body request body (JSON-encoded when present)
108
+ * @returns {Promise<object>} parsed response JSON
109
+ */
110
+ async function apiRequest (method, url, apiKey, body) {
111
+ const headers = { Authorization: buildAuthorizationHeader(apiKey) }
112
+ const init = { method, headers }
113
+
114
+ if (body !== undefined) {
115
+ headers['Content-Type'] = 'application/json'
116
+ init.body = JSON.stringify(body)
117
+ }
118
+
119
+ let response
120
+ try {
121
+ response = await fetch(url, init)
122
+ } catch (error) {
123
+ throw new SandboxClientError(`Sandbox API request failed: ${error.message}`)
124
+ }
125
+
126
+ if (!response.ok) {
127
+ const text = await response.text()
128
+ const detail = `${response.status}${text ? ` ${text}` : ''}`
129
+ throw createSandboxHttpError(response.status, detail)
130
+ }
131
+
132
+ return response.json()
133
+ }
134
+
135
+ module.exports = {
136
+ buildAuthorizationHeader,
137
+ createSandboxHttpError,
138
+ normalizeApiHost,
139
+ buildWebSocketEndpoint,
140
+ resolveCredentials,
141
+ normalizeSize,
142
+ apiRequest
143
+ }