From df5cd07afd6a00cebc5069479f50b20ceed7ecd4 Mon Sep 17 00:00:00 2001 From: Michael Goberling Date: Wed, 18 Mar 2026 23:20:32 -0400 Subject: [PATCH 01/15] feat: add initial support for agent sandboxes on runtime --- package.json | 3 +- src/ComputeAPI.js | 29 +++ src/RuntimeAPI.js | 7 + src/SDKErrors.js | 5 + src/Sandbox.js | 330 +++++++++++++++++++++++++++++ src/SandboxAPI.js | 212 +++++++++++++++++++ src/types.jsdoc.js | 44 +++- test/RuntimeAPI.test.js | 23 ++ test/compute.test.js | 265 +++++++++++++++++++++++ test/sandbox.test.js | 459 ++++++++++++++++++++++++++++++++++++++++ types.d.ts | 170 +++++++++++++++ 11 files changed, 1545 insertions(+), 2 deletions(-) create mode 100644 src/ComputeAPI.js create mode 100644 src/Sandbox.js create mode 100644 src/SandboxAPI.js create mode 100644 test/compute.test.js create mode 100644 test/sandbox.test.js diff --git a/package.json b/package.json index 2247f594..09d3e1bd 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,8 @@ "openwhisk-fqn": "0.0.2", "proxy-from-env": "^1.1.0", "sha1": "^1.1.1", - "webpack": "^5.26.3" + "webpack": "^5.26.3", + "ws": "^8.19.0" }, "deprecated": false, "description": "Adobe I/O Runtime Lib", diff --git a/src/ComputeAPI.js b/src/ComputeAPI.js new file mode 100644 index 00000000..d7541b98 --- /dev/null +++ b/src/ComputeAPI.js @@ -0,0 +1,29 @@ +/* +Copyright 2020 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +const SandboxAPI = require('./SandboxAPI') + +/** + * Compute management API. + */ +class ComputeAPI { + /** + * @param {string} apiHost Runtime API host + * @param {string} namespace Runtime namespace + * @param {string} apiKey Runtime auth key + * @param {object} [options] SDK transport options + */ + constructor (apiHost, namespace, apiKey, options = {}) { + this.sandbox = new SandboxAPI(apiHost, namespace, apiKey, options) + } +} + +module.exports = ComputeAPI diff --git a/src/RuntimeAPI.js b/src/RuntimeAPI.js index 9b40a617..b5aa93f6 100644 --- a/src/RuntimeAPI.js +++ b/src/RuntimeAPI.js @@ -17,6 +17,7 @@ const deepCopy = require('lodash.clonedeep') const aioLogger = require('@adobe/aio-lib-core-logging')('@adobe/aio-lib-runtime:RuntimeAPI', { provider: 'debug', level: process.env.LOG_LEVEL }) const LogForwarding = require('./LogForwarding') const LogForwardingLocalDestinationsProvider = require('./LogForwardingLocalDestinationsProvider') +const ComputeAPI = require('./ComputeAPI') const { patchOWForTunnelingIssue } = require('./openwhisk-patch') const { getProxyAgent } = require('./utils') @@ -106,6 +107,12 @@ class RuntimeAPI { new LogForwardingLocalDestinationsProvider(), clonedOptions.auth_handler ), + compute: new ComputeAPI( + clonedOptions.apihost, + clonedOptions.namespace, + clonedOptions.api_key, + clonedOptions + ), initOptions: clonedOptions } } diff --git a/src/SDKErrors.js b/src/SDKErrors.js index 140fabe9..b4e1bf4a 100644 --- a/src/SDKErrors.js +++ b/src/SDKErrors.js @@ -49,3 +49,8 @@ module.exports = { // Define your error codes with the wrapper E('ERROR_SDK_INITIALIZATION', 'SDK initialization error(s). Missing arguments: %s') +E('ERROR_SANDBOX_CLIENT', 'Sandbox client error: %s') +E('ERROR_SANDBOX_NOT_FOUND', 'Sandbox not found: %s') +E('ERROR_SANDBOX_UNAUTHORIZED', 'Sandbox authorization error: %s') +E('ERROR_SANDBOX_TIMEOUT', 'Sandbox timeout error: %s') +E('ERROR_SANDBOX_WEBSOCKET', 'Sandbox WebSocket error: %s') diff --git a/src/Sandbox.js b/src/Sandbox.js new file mode 100644 index 00000000..36deaf46 --- /dev/null +++ b/src/Sandbox.js @@ -0,0 +1,330 @@ +/* +Copyright 2020 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +const crypto = require('crypto') +const WebSocket = require('ws') +const { codes } = require('./SDKErrors') +const { createFetch } = require('@adobe/aio-lib-core-networking') +require('./types.jsdoc') // for VS Code autocomplete +/* global SandboxExecOptions, SandboxExecResult */ + +/** + * Connected compute sandbox session. + */ +class Sandbox { + /** + * @param {object} options sandbox options + */ + constructor (options) { + this.id = options.id + this.endpoint = options.endpoint + this.status = options.status + this.cluster = options.cluster + this.region = options.region + this.maxLifetime = options.maxLifetime + + this.namespace = options.namespace + this.apiHost = options.apiHost + this.apiKey = options.apiKey + this.agent = options.agent + this.ignoreCerts = options.ignore_certs + + this._token = options.token + this._socket = null + this._connectPromise = null + this._pendingExecs = new Map() + } + + /** + * Opens the sandbox WebSocket connection. + * + * @returns {Promise} + */ + connect () { + if (this._socket && this._socket.readyState === WebSocket.OPEN) { + return Promise.resolve() + } + + if (this._connectPromise) { + return this._connectPromise + } + + const wsOptions = {} + if (this.agent) { + wsOptions.agent = this.agent + } + + if (this.ignoreCerts) { + wsOptions.rejectUnauthorized = false + } + + this._socket = new WebSocket(this.endpoint, wsOptions) + const socket = this._socket + + socket.on('message', message => this._handleMessage(message)) + socket.on('close', (code, reason) => this._handleClose(code, reason)) + socket.on('error', () => {}) + + this._connectPromise = new Promise((resolve, reject) => { + const onOpen = () => { + try { + this._sendFrame({ type: 'auth', token: this._token }) + } catch (error) { + onError(error) + } + } + + const onMessage = (message) => { + const frame = this._parseFrame(message) + if (!frame || !this._isAuthAckFrame(frame)) { + return + } + + cleanup() + this._connectPromise = null + resolve() + } + + const onClose = (code) => { + cleanup() + this._connectPromise = null + reject(this._createCloseError(code)) + } + + const onError = (error) => { + cleanup() + this._connectPromise = null + reject(new codes.ERROR_SANDBOX_WEBSOCKET({ + messageValues: `Could not connect sandbox '${this.id}': ${error.message}` + })) + } + + const cleanup = () => { + socket.off('open', onOpen) + socket.off('message', onMessage) + socket.off('close', onClose) + socket.off('error', onError) + } + + socket.once('open', onOpen) + socket.on('message', onMessage) + socket.once('close', onClose) + socket.once('error', onError) + }) + + return this._connectPromise + } + + /** + * Executes a command inside the sandbox. + * + * @param {string} command command to execute + * @param {SandboxExecOptions} [options] execution options + * @returns {Promise} execution result promise + */ + exec (command, options = {}) { + try { + this._ensureOpen() + } catch (error) { + return Promise.reject(error) + } + + const execId = `exec-${crypto.randomBytes(12).toString('hex')}` + let timeout + + const execPromise = new Promise((resolve, reject) => { + this._pendingExecs.set(execId, { + resolve, + reject, + stdout: '', + stderr: '', + onOutput: options.onOutput, + timeout: undefined + }) + + if (options.timeout) { + timeout = setTimeout(() => { + this.kill(execId).catch(() => {}) + this._rejectPendingExec(execId, new codes.ERROR_SANDBOX_TIMEOUT({ + messageValues: `Command '${execId}' exceeded timeout of ${options.timeout}ms` + })) + }, options.timeout) + + this._pendingExecs.get(execId).timeout = timeout + } + }) + + execPromise.execId = execId + this._sendFrame({ type: 'exec.run', execId, command }) + return execPromise + } + + /** + * Sends a kill signal to a running command. + * + * @param {string} execId execution id + * @param {string} [signal] signal to deliver + * @returns {Promise} + */ + async kill (execId, signal = 'SIGTERM') { + this._ensureOpen() + this._sendFrame({ type: 'exec.kill', execId, signal }) + } + + /** + * Destroys the sandbox and closes its WebSocket connection. + * + * @returns {Promise} destroy response payload + */ + async destroy () { + const fetch = createFetch() + const requestOptions = { + method: 'DELETE', + headers: { + Authorization: this._buildAuthorizationHeader() + } + } + + if (this.agent) { + requestOptions.agent = this.agent + } + + if (this.ignoreCerts) { + requestOptions.rejectUnauthorized = false + } + + let response + try { + response = await fetch(`${this.apiHost}/api/v1/namespaces/${this.namespace}/sandbox/${this.id}`, requestOptions) + } catch (error) { + throw new codes.ERROR_SANDBOX_CLIENT({ + messageValues: `Could not destroy sandbox '${this.id}': ${error.message}` + }) + } + + if (!response.ok) { + const message = await response.text() + throw new codes.ERROR_SANDBOX_CLIENT({ + messageValues: `Could not destroy sandbox '${this.id}': ${response.status}${message ? ` ${message}` : ''}` + }) + } + + const payload = await response.json() + this.status = payload.status || this.status + this._socket?.close() + return payload + } + + _handleMessage (message) { + const frame = this._parseFrame(message) + if (!frame || this._isAuthAckFrame(frame)) { + return + } + + const pendingExec = this._pendingExecs.get(frame.execId) + if (!pendingExec) { + return + } + + if (frame.type === 'exec.output') { + if (frame.stream === 'stderr') { + pendingExec.stderr += frame.data || '' + } else { + pendingExec.stdout += frame.data || '' + } + + if (pendingExec.onOutput) { + pendingExec.onOutput(frame.data || '') + } + return + } + + if (frame.type === 'exec.exit') { + this._pendingExecs.delete(frame.execId) + clearTimeout(pendingExec.timeout) + pendingExec.resolve({ + execId: frame.execId, + stdout: pendingExec.stdout, + stderr: pendingExec.stderr, + exitCode: frame.exitCode + }) + return + } + + if (frame.type === 'error') { + this._rejectPendingExec(frame.execId, new codes.ERROR_SANDBOX_CLIENT({ + messageValues: frame.message || `Command '${frame.execId}' failed` + })) + } + } + + _handleClose (code) { + const error = this._createCloseError(code) + for (const execId of this._pendingExecs.keys()) { + this._rejectPendingExec(execId, error) + } + this._connectPromise = null + this._socket = null + } + + _rejectPendingExec (execId, error) { + const pendingExec = this._pendingExecs.get(execId) + if (!pendingExec) { + return + } + + this._pendingExecs.delete(execId) + clearTimeout(pendingExec.timeout) + pendingExec.reject(error) + } + + _createCloseError (code) { + if (code === 4001) { + return new codes.ERROR_SANDBOX_UNAUTHORIZED({ + messageValues: `Sandbox '${this.id}' rejected the WebSocket authentication token` + }) + } + + return new codes.ERROR_SANDBOX_WEBSOCKET({ + messageValues: `Sandbox '${this.id}' WebSocket closed with code ${code}` + }) + } + + _ensureOpen () { + if (!this._socket || this._socket.readyState !== WebSocket.OPEN) { + throw new codes.ERROR_SANDBOX_WEBSOCKET({ + messageValues: `Sandbox '${this.id}' is not connected` + }) + } + } + + _sendFrame (frame) { + this._socket.send(JSON.stringify(frame)) + } + + _parseFrame (message) { + try { + return JSON.parse(message.toString()) + } catch (error) { + return null + } + } + + _isAuthAckFrame (frame) { + return frame?.type === 'auth.ok' && (!frame.sandboxId || frame.sandboxId === this.id) + } + + _buildAuthorizationHeader () { + return `Basic ${Buffer.from(this.apiKey).toString('base64')}` + } +} + +module.exports = Sandbox diff --git a/src/SandboxAPI.js b/src/SandboxAPI.js new file mode 100644 index 00000000..ed894c0e --- /dev/null +++ b/src/SandboxAPI.js @@ -0,0 +1,212 @@ +/* +Copyright 2020 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +const { codes } = require('./SDKErrors') +const Sandbox = require('./Sandbox') +const { createFetch } = require('@adobe/aio-lib-core-networking') +require('./types.jsdoc') // for VS Code autocomplete +/* global SandboxCreateOptions, SandboxSizes */ + +const SANDBOX_SIZES = Object.freeze({ + SMALL: { cpu: '500m', memory: '512Mi', gpu: 0 }, + MEDIUM: { cpu: '2000m', memory: '4Gi', gpu: 0 }, + LARGE: { cpu: '4000m', memory: '16Gi', gpu: 0 }, + XLARGE: { cpu: '8000m', memory: '32Gi', gpu: 1 } +}) + +/** + * Compute Sandbox management API. + */ +class SandboxAPI { + /** + * @param {string} apiHost Runtime API host + * @param {string} namespace Runtime namespace + * @param {string} apiKey Runtime auth key + * @param {object} [options] SDK transport options + */ + constructor (apiHost, namespace, apiKey, options = {}) { + this.apiHost = apiHost.match(/^http(s)?:\/\//) ? apiHost : `https://${apiHost}` + this.namespace = namespace + this.apiKey = apiKey + this.agent = options.agent + this.ignoreCerts = options.ignore_certs + this.sizes = SANDBOX_SIZES + } + + /** + * Creates a new compute sandbox and connects its WebSocket session. + * + * @param {SandboxCreateOptions} [options] sandbox create options + * @returns {Promise} connected sandbox instance + */ + async create (options = {}) { + const payload = await this._request( + 'create sandbox', + 'POST', + this._getSandboxPath(), + this._buildCreateRequestBody(options) + ) + + return await this._buildSandbox(payload) + } + + async _buildSandbox (payload) { + const sandbox = new Sandbox({ + id: payload.sandboxId, + endpoint: payload.wsEndpoint || this._buildWebSocketEndpoint(payload.sandboxId), + status: payload.status, + cluster: payload.cluster, + region: payload.region, + maxLifetime: payload.maxLifetime, + namespace: this.namespace, + apiHost: this.apiHost, + apiKey: this.apiKey, + token: payload.token, + agent: this.agent, + ignore_certs: this.ignoreCerts + }) + + await sandbox.connect() + return sandbox + } + + _buildCreateRequestBody (options) { + const body = { + name: options.name, + size: this._normalizeSize(options.size), + type: options.type || 'cpu:default', + maxLifetime: options.maxLifetime || 3600 + } + + if (options.cluster) { + body.cluster = options.cluster + } + + if (options.workspace) { + body.workspace = options.workspace + } + + if (options.envs) { + body.envs = options.envs + } + + return body + } + + _normalizeSize (size) { + if (!size) { + return 'MEDIUM' + } + + if (typeof size === 'string' && SANDBOX_SIZES[size]) { + return size + } + + const entry = Object.entries(SANDBOX_SIZES) + .find(([, value]) => JSON.stringify(value) === JSON.stringify(size)) + + if (entry) { + return entry[0] + } + + throw new codes.ERROR_SANDBOX_CLIENT({ + messageValues: 'Invalid sandbox size provided' + }) + } + + async _request (operation, method, path, body) { + const requestOptions = { + method, + headers: { + Authorization: this._buildAuthorizationHeader() + } + } + + if (this.agent) { + requestOptions.agent = this.agent + } + + if (this.ignoreCerts) { + requestOptions.rejectUnauthorized = false + } + + if (body !== undefined) { + requestOptions.body = JSON.stringify(body) + requestOptions.headers['Content-Type'] = 'application/json' + } + + const fetch = createFetch() + + let response + try { + response = await fetch(this.apiHost + path, requestOptions) + } catch (error) { + throw new codes.ERROR_SANDBOX_CLIENT({ + messageValues: `Could not ${operation}: ${error.message}` + }) + } + + if (!response.ok) { + const message = await response.text() + throw this._createHttpError(operation, response.status, message) + } + + return response.json() + } + + _createHttpError (operation, status, message) { + const messageValues = `Could not ${operation}: ${status}${message ? ` ${message}` : ''}` + if (status === 401 || status === 403) { + return new codes.ERROR_SANDBOX_UNAUTHORIZED({ messageValues }) + } + + if (status === 404) { + return new codes.ERROR_SANDBOX_NOT_FOUND({ messageValues }) + } + + if (status === 504) { + return new codes.ERROR_SANDBOX_TIMEOUT({ messageValues }) + } + + return new codes.ERROR_SANDBOX_CLIENT({ messageValues }) + } + + _buildAuthorizationHeader () { + return `Basic ${Buffer.from(this.apiKey).toString('base64')}` + } + + _getSandboxPath () { + if (!this.namespace) { + throw new codes.ERROR_SANDBOX_CLIENT({ + messageValues: 'Sandbox operations require a namespace' + }) + } + + return `/api/v1/namespaces/${this.namespace}/sandbox` + } + + _buildWebSocketEndpoint (sandboxId) { + const url = new URL(this.apiHost) + url.protocol = url.protocol === 'http:' ? 'ws:' : 'wss:' + url.pathname = `/ws/v1/namespaces/${this.namespace}/sandbox/${sandboxId}/exec` + url.search = '' + return url.toString() + } +} + +/** + * Named sandbox sizes. + * + * @type {SandboxSizes} + */ +SandboxAPI.sizes = SANDBOX_SIZES + +module.exports = SandboxAPI diff --git a/src/types.jsdoc.js b/src/types.jsdoc.js index c841f926..deb28795 100644 --- a/src/types.jsdoc.js +++ b/src/types.jsdoc.js @@ -9,7 +9,7 @@ OF ANY KIND, either express or implied. See the License for the specific languag governing permissions and limitations under the License. */ -/* global ow, LogForwarding */ +/* global ow, LogForwarding, ComputeAPI */ /** * @typedef {object} OpenwhiskOptions @@ -39,4 +39,46 @@ governing permissions and limitations under the License. * @property {ow.Triggers} triggers triggers * @property {ow.Routes} routes routes * @property {LogForwarding} logForwarding Log Forwarding management API + * @property {ComputeAPI} compute Compute management API + * @property {OpenwhiskOptions} initOptions init options + */ + +/** + * @typedef {object} SandboxCreateOptions + * @property {string} name sandbox display name + * @property {string} [cluster] target cluster + * @property {string} [workspace] sandbox workspace + * @property {string|object} [size] sandbox size tier + * @property {string} [type] sandbox runtime type + * @property {number} [maxLifetime] maximum lifetime in seconds + * @property {object} [envs] environment variables + */ + +/** + * @typedef {object} SandboxSize + * @property {string} cpu requested CPU + * @property {string} memory requested memory + * @property {number} gpu requested GPU count + */ + +/** + * @typedef {object} SandboxSizes + * @property {SandboxSize} SMALL small sandbox size + * @property {SandboxSize} MEDIUM medium sandbox size + * @property {SandboxSize} LARGE large sandbox size + * @property {SandboxSize} XLARGE extra large sandbox size + */ + +/** + * @typedef {object} SandboxExecOptions + * @property {function(string): void} [onOutput] output callback + * @property {number} [timeout] client-side timeout in milliseconds + */ + +/** + * @typedef {object} SandboxExecResult + * @property {string} execId execution id + * @property {string} stdout stdout output + * @property {string} stderr stderr output + * @property {number} exitCode process exit code */ diff --git a/test/RuntimeAPI.test.js b/test/RuntimeAPI.test.js index b512e154..21a32b10 100644 --- a/test/RuntimeAPI.test.js +++ b/test/RuntimeAPI.test.js @@ -14,6 +14,7 @@ const { codes } = require('../src/SDKErrors') const Triggers = require('../src/triggers') const LogForwarding = require('../src/LogForwarding') const LogForwardingLocalDestinationsProvider = require('../src/LogForwardingLocalDestinationsProvider') +const ComputeAPI = require('../src/ComputeAPI') const { patchOWForTunnelingIssue } = require('../src/openwhisk-patch') const { getProxyAgent } = require('../src/utils') @@ -22,6 +23,7 @@ jest.mock('openwhisk') jest.mock('../src/triggers') jest.mock('../src/LogForwarding') jest.mock('../src/LogForwardingLocalDestinationsProvider') +jest.mock('../src/ComputeAPI') jest.mock('../src/openwhisk-patch') jest.mock('../src/utils') jest.mock('proxy-from-env') @@ -55,6 +57,9 @@ describe('RuntimeAPI', () => { ow.mockReturnValue(mockOWClient) patchOWForTunnelingIssue.mockReturnValue(mockOWClient) + ComputeAPI.mockImplementation(() => ({ + sandbox: { mock: 'sandbox' } + })) // Create a spy that tracks modifications to the cloned object deepCopy.mockImplementation((obj) => { @@ -91,6 +96,8 @@ describe('RuntimeAPI', () => { expect(result).toHaveProperty('triggers') expect(result).toHaveProperty('routes', mockOWClient.routes) expect(result).toHaveProperty('logForwarding') + expect(result).toHaveProperty('compute') + expect(result.compute).toHaveProperty('sandbox') expect(result).toHaveProperty('initOptions') }) @@ -280,6 +287,22 @@ describe('RuntimeAPI', () => { ) }) + test('should create ComputeAPI instance with correct parameters', async () => { + const result = await runtimeAPI.init(validOptions) + + expect(ComputeAPI).toHaveBeenCalledWith( + validOptions.apihost, + validOptions.namespace, + validOptions.api_key, + expect.objectContaining({ + api_key: validOptions.api_key, + apihost: validOptions.apihost, + namespace: validOptions.namespace + }) + ) + expect(result.compute.sandbox).toBeDefined() + }) + test('should return triggers proxy that delegates to Triggers class', async () => { const mockTriggersInstance = { create: jest.fn(), diff --git a/test/compute.test.js b/test/compute.test.js new file mode 100644 index 00000000..0e65bb7f --- /dev/null +++ b/test/compute.test.js @@ -0,0 +1,265 @@ +const SandboxAPI = require('../src/SandboxAPI') +const Sandbox = require('../src/Sandbox') +const { codes } = require('../src/SDKErrors') +const { createFetch } = require('@adobe/aio-lib-core-networking') + +jest.mock('../src/Sandbox') +jest.mock('@adobe/aio-lib-core-networking') + +describe('SandboxAPI', () => { + let sandboxInstance + let mockFetch + + beforeEach(() => { + sandboxInstance = { + connect: jest.fn().mockResolvedValue(undefined) + } + + Sandbox.mockImplementation((options) => ({ + ...sandboxInstance, + options + })) + + mockFetch = jest.fn() + createFetch.mockReturnValue(mockFetch) + }) + + afterEach(() => { + jest.clearAllMocks() + }) + + test('normalizes apihost and exposes sizes', () => { + const compute = new SandboxAPI('runtime.example.net', '1234-demo', 'uuid:key') + + expect(compute.apiHost).toBe('https://runtime.example.net') + expect(compute.sizes).toEqual(SandboxAPI.sizes) + }) + + test('create posts sandbox payload and returns a connected sandbox', async () => { + const compute = new SandboxAPI('https://runtime.example.net', '1234-demo', 'uuid:key') + const responsePayload = { + sandboxId: 'sb-1234', + token: 'sandbox-token', + status: 'ready', + wsEndpoint: 'wss://runtime.example.net/ws/v1/namespaces/1234-demo/sandbox/sb-1234/exec', + cluster: 'cluster-a', + region: 'va6', + maxLifetime: 7200 + } + + mockFetch.mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue(responsePayload) + }) + + const result = await compute.create({ + name: 'app-instance', + cluster: 'cluster-a', + workspace: 'workspace-a', + size: compute.sizes.SMALL, + type: 'gpu:python', + maxLifetime: 7200, + envs: { API_KEY: 'secret' } + }) + + expect(mockFetch).toHaveBeenCalledWith( + 'https://runtime.example.net/api/v1/namespaces/1234-demo/sandbox', + expect.objectContaining({ + method: 'POST', + headers: { + Authorization: `Basic ${Buffer.from('uuid:key').toString('base64')}`, + 'Content-Type': 'application/json' + } + }) + ) + expect(JSON.parse(mockFetch.mock.calls[0][1].body)).toEqual({ + name: 'app-instance', + cluster: 'cluster-a', + workspace: 'workspace-a', + size: 'SMALL', + type: 'gpu:python', + maxLifetime: 7200, + envs: { API_KEY: 'secret' } + }) + expect(Sandbox).toHaveBeenCalledWith(expect.objectContaining({ + id: 'sb-1234', + endpoint: responsePayload.wsEndpoint, + status: 'ready', + cluster: 'cluster-a', + region: 'va6', + maxLifetime: 7200, + token: 'sandbox-token' + })) + expect(result.connect).toHaveBeenCalledTimes(1) + }) + + test('create uses default values and respects full auth keys', async () => { + const compute = new SandboxAPI('runtime.example.net', 'ignored-namespace', 'uuid:key') + + mockFetch.mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue({ + sandboxId: 'sb-5678', + token: 'sandbox-token', + status: 'ready' + }) + }) + + await compute.create({ + name: 'defaulted', + size: 'LARGE' + }) + + expect(mockFetch).toHaveBeenCalledWith( + 'https://runtime.example.net/api/v1/namespaces/ignored-namespace/sandbox', + { + method: 'POST', + body: JSON.stringify({ + name: 'defaulted', + size: 'LARGE', + type: 'cpu:default', + maxLifetime: 3600 + }), + headers: { + Authorization: `Basic ${Buffer.from('uuid:key').toString('base64')}`, + 'Content-Type': 'application/json' + } + } + ) + + expect(Sandbox).toHaveBeenCalledWith(expect.objectContaining({ + endpoint: 'wss://runtime.example.net/ws/v1/namespaces/ignored-namespace/sandbox/sb-5678/exec' + })) + }) + + test('rest sandbox operations forward configured transport options', async () => { + const compute = new SandboxAPI('https://runtime.example.net', '1234-demo', 'uuid:key', { + agent: { name: 'proxy-agent' }, + ignore_certs: true + }) + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValue({ + sandboxId: 'sb-1234', + token: 'sandbox-token', + status: 'ready' + }) + }) + await compute.create({ name: 'proxy-aware' }) + + expect(mockFetch).toHaveBeenNthCalledWith( + 1, + 'https://runtime.example.net/api/v1/namespaces/1234-demo/sandbox', + expect.objectContaining({ + method: 'POST', + agent: { name: 'proxy-agent' }, + rejectUnauthorized: false + }) + ) + }) + + test('create supports omitted option objects and http websocket endpoints', async () => { + const compute = new SandboxAPI('http://runtime.example.net', 'plainnamespace', 'uuid:key') + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValue({ + sandboxId: 'sb-http', + token: 'token', + status: 'ready' + }) + }) + + await compute.create() + + expect(mockFetch).toHaveBeenCalledWith( + 'http://runtime.example.net/api/v1/namespaces/plainnamespace/sandbox', + expect.objectContaining({ + headers: { + Authorization: `Basic ${Buffer.from('uuid:key').toString('base64')}`, + 'Content-Type': 'application/json' + } + }) + ) + expect(Sandbox).toHaveBeenNthCalledWith(1, expect.objectContaining({ + endpoint: 'ws://runtime.example.net/ws/v1/namespaces/plainnamespace/sandbox/sb-http/exec' + })) + }) + + test('create surfaces timeout responses', async () => { + const compute = new SandboxAPI('https://runtime.example.net', '1234-demo', 'uuid:key') + + mockFetch.mockResolvedValue({ + ok: false, + status: 504, + text: jest.fn().mockResolvedValue('sandbox provisioning timed out') + }) + + await expect(compute.create({ name: 'timeout' })).rejects.toThrow(codes.ERROR_SANDBOX_TIMEOUT) + }) + + test('create surfaces authorization and not-found responses', async () => { + const compute = new SandboxAPI('https://runtime.example.net', '1234-demo', 'uuid:key') + + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 401, + text: jest.fn().mockResolvedValue('unauthorized') + }) + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + text: jest.fn().mockResolvedValue('missing') + }) + + await expect(compute.create({ name: 'sb-auth' })).rejects.toThrow(codes.ERROR_SANDBOX_UNAUTHORIZED) + await expect(compute.create({ name: 'sb-missing' })).rejects.toThrow(codes.ERROR_SANDBOX_NOT_FOUND) + }) + + test('create throws on invalid sizes, generic server failures, and network failures', async () => { + const compute = new SandboxAPI('https://runtime.example.net', '1234-demo', 'uuid:key') + + await expect(compute.create({ + name: 'bad-size', + size: { cpu: '1', memory: '1Gi', gpu: 7 } + })).rejects.toThrow(codes.ERROR_SANDBOX_CLIENT) + + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 500, + text: jest.fn().mockResolvedValue('server-error') + }) + mockFetch.mockRejectedValueOnce(new Error('network down')) + + await expect(compute.create({ name: 'server-error' })).rejects.toThrow(codes.ERROR_SANDBOX_CLIENT) + await expect(compute.create({ name: 'network-error' })).rejects.toThrow(codes.ERROR_SANDBOX_CLIENT) + }) + + test('create surfaces generic failures with empty response bodies', async () => { + const compute = new SandboxAPI('https://runtime.example.net', '1234-demo', 'uuid:key') + + mockFetch.mockResolvedValue({ + ok: false, + status: 500, + text: jest.fn().mockResolvedValue('') + }) + + await expect(compute.create()).rejects.toThrow('Could not create sandbox: 500') + }) + + test('sandbox operations require a namespace', async () => { + const compute = new SandboxAPI('https://runtime.example.net', undefined, 'uuid:key') + + await expect(compute.create()).rejects.toThrow(codes.ERROR_SANDBOX_CLIENT) + expect(mockFetch).not.toHaveBeenCalled() + }) + + test('builds an auth header from the provided api key', () => { + const compute = new SandboxAPI('https://runtime.example.net', undefined, 'uuid:key') + + expect(compute._buildAuthorizationHeader()).toBe( + `Basic ${Buffer.from('uuid:key').toString('base64')}` + ) + }) +}) diff --git a/test/sandbox.test.js b/test/sandbox.test.js new file mode 100644 index 00000000..fa597df6 --- /dev/null +++ b/test/sandbox.test.js @@ -0,0 +1,459 @@ +const EventEmitter = require('events') +const Sandbox = require('../src/Sandbox') +const WebSocket = require('ws') +const { codes } = require('../src/SDKErrors') +const { createFetch } = require('@adobe/aio-lib-core-networking') + +jest.mock('ws') +jest.mock('@adobe/aio-lib-core-networking') + +class FakeWebSocket extends EventEmitter { + constructor (url, options) { + super() + this.url = url + this.options = options + this.readyState = 0 + this.sent = [] + } + + send (data) { + this.sent.push(data) + } + + close () { + this.readyState = 3 + this.emit('close', 1000, 'closed') + } + + open () { + this.readyState = WebSocket.OPEN + this.emit('open') + } + + closeWith (code, reason = 'closed') { + this.readyState = 3 + this.emit('close', code, reason) + } + + message (payload) { + const data = typeof payload === 'string' ? payload : JSON.stringify(payload) + this.emit('message', Buffer.from(data)) + } +} + +describe('Sandbox', () => { + let sockets + let sandboxOptions + let mockFetch + + beforeEach(() => { + sockets = [] + WebSocket.OPEN = 1 + WebSocket.mockImplementation((url, options) => { + const socket = new FakeWebSocket(url, options) + sockets.push(socket) + return socket + }) + + sandboxOptions = { + id: 'sb-1234', + endpoint: 'wss://runtime.example.net/ws/v1/namespaces/1234-demo/sandbox/sb-1234/exec', + status: 'ready', + cluster: 'cluster-a', + region: 'va6', + maxLifetime: 3600, + namespace: '1234-demo', + apiHost: 'https://runtime.example.net', + apiKey: 'uuid:key', + token: 'sandbox-token' + } + + mockFetch = jest.fn() + createFetch.mockReturnValue(mockFetch) + jest.useRealTimers() + }) + + afterEach(() => { + jest.clearAllMocks() + jest.useRealTimers() + }) + + test('connect reuses an open websocket and resolves on auth acknowledgement', async () => { + const sandbox = new Sandbox({ + ...sandboxOptions, + agent: { name: 'proxy-agent' }, + ignore_certs: true + }) + + const connectPromise = sandbox.connect() + + expect(WebSocket).toHaveBeenCalledWith(sandboxOptions.endpoint, { + agent: { name: 'proxy-agent' }, + rejectUnauthorized: false + }) + + sockets[0].open() + expect(JSON.parse(sockets[0].sent[0])).toEqual({ + type: 'auth', + token: 'sandbox-token' + }) + sockets[0].message({ type: 'auth.ok', sandboxId: sandboxOptions.id }) + await connectPromise + + await sandbox.connect() + expect(WebSocket).toHaveBeenCalledTimes(1) + }) + + test('connect reuses an in-flight connection promise', async () => { + const sandbox = new Sandbox(sandboxOptions) + + const firstConnect = sandbox.connect() + const secondConnect = sandbox.connect() + + expect(secondConnect).toBe(firstConnect) + + sockets[0].open() + sockets[0].message({ type: 'auth.ok', sandboxId: sandboxOptions.id }) + await Promise.all([firstConnect, secondConnect]) + expect(WebSocket).toHaveBeenCalledTimes(1) + }) + + test('connect ignores unrelated frames until auth acknowledgement arrives', async () => { + const sandbox = new Sandbox(sandboxOptions) + const connectPromise = sandbox.connect() + const resolved = jest.fn() + connectPromise.then(resolved) + + sockets[0].open() + sockets[0].message({ type: 'exec.output', execId: 'ignored', data: 'ignored' }) + await Promise.resolve() + expect(resolved).not.toHaveBeenCalled() + + sockets[0].message({ type: 'auth.ok', sandboxId: sandboxOptions.id }) + await connectPromise + expect(resolved).toHaveBeenCalledTimes(1) + }) + + test('connect rejects unauthorized closes before opening', async () => { + const sandbox = new Sandbox(sandboxOptions) + const connectPromise = sandbox.connect() + + sockets[0].closeWith(4001, 'unauthorized') + + await expect(connectPromise).rejects.toThrow(codes.ERROR_SANDBOX_UNAUTHORIZED) + }) + + test('connect rejects unauthorized closes after open but before auth acknowledgement', async () => { + const sandbox = new Sandbox(sandboxOptions) + const connectPromise = sandbox.connect() + + sockets[0].open() + sockets[0].closeWith(4001, 'unauthorized') + + await expect(connectPromise).rejects.toThrow(codes.ERROR_SANDBOX_UNAUTHORIZED) + }) + + test('connect rejects websocket errors before opening', async () => { + const sandbox = new Sandbox(sandboxOptions) + const connectPromise = sandbox.connect() + + sockets[0].emit('error', new Error('socket failed')) + + await expect(connectPromise).rejects.toThrow(codes.ERROR_SANDBOX_WEBSOCKET) + }) + + test('connect rejects when sending the auth frame fails', async () => { + const sandbox = new Sandbox(sandboxOptions) + const connectPromise = sandbox.connect() + + sockets[0].send = jest.fn(() => { + throw new Error('send failed') + }) + sockets[0].open() + + await expect(connectPromise).rejects.toThrow(codes.ERROR_SANDBOX_WEBSOCKET) + }) + + test('exec streams output, ignores malformed frames, and resolves on exit', async () => { + const sandbox = new Sandbox(sandboxOptions) + const onOutput = jest.fn() + + const connectPromise = sandbox.connect() + sockets[0].open() + sockets[0].message({ type: 'auth.ok', sandboxId: sandboxOptions.id }) + await connectPromise + + const execPromise = sandbox.exec('ls -la', { onOutput }) + const { execId } = execPromise + expect(execId).toMatch(/^exec-[0-9a-f]{24}$/) + + expect(JSON.parse(sockets[0].sent[1])).toEqual({ + type: 'exec.run', + execId, + command: 'ls -la' + }) + + sockets[0].message('not-json') + sockets[0].message({ type: 'exec.output', execId: 'other', stream: 'stdout', data: 'ignored' }) + sockets[0].message({ type: 'exec.output', execId, stream: 'stdout', data: 'hello\n' }) + sockets[0].message({ type: 'exec.output', execId, stream: 'stderr', data: 'warning\n' }) + sockets[0].message({ type: 'exec.exit', execId, exitCode: 0 }) + + await expect(execPromise).resolves.toEqual({ + execId, + stdout: 'hello\n', + stderr: 'warning\n', + exitCode: 0 + }) + expect(onOutput).toHaveBeenCalledTimes(2) + expect(onOutput).toHaveBeenNthCalledWith(1, 'hello\n') + expect(onOutput).toHaveBeenNthCalledWith(2, 'warning\n') + }) + + test('concurrent exec calls are demultiplexed by execId', async () => { + const sandbox = new Sandbox(sandboxOptions) + + const connectPromise = sandbox.connect() + sockets[0].open() + sockets[0].message({ type: 'auth.ok', sandboxId: sandboxOptions.id }) + await connectPromise + + const first = sandbox.exec('first') + const second = sandbox.exec('second') + + sockets[0].message({ type: 'exec.output', execId: second.execId, stream: 'stdout', data: 'b' }) + sockets[0].message({ type: 'exec.output', execId: first.execId, stream: 'stdout', data: 'a' }) + sockets[0].message({ type: 'exec.exit', execId: first.execId, exitCode: 0 }) + sockets[0].message({ type: 'exec.exit', execId: second.execId, exitCode: 1 }) + + await expect(first).resolves.toEqual({ + execId: first.execId, + stdout: 'a', + stderr: '', + exitCode: 0 + }) + await expect(second).resolves.toEqual({ + execId: second.execId, + stdout: 'b', + stderr: '', + exitCode: 1 + }) + }) + + test('exec rejects on sandbox error frames', async () => { + const sandbox = new Sandbox(sandboxOptions) + + const connectPromise = sandbox.connect() + sockets[0].open() + sockets[0].message({ type: 'auth.ok', sandboxId: sandboxOptions.id }) + await connectPromise + + const execPromise = sandbox.exec('bad-command') + sockets[0].message({ + type: 'error', + execId: execPromise.execId, + message: 'command failed' + }) + + await expect(execPromise).rejects.toThrow(codes.ERROR_SANDBOX_CLIENT) + }) + + test('exec handles empty output payloads and fallback error messages', async () => { + const sandbox = new Sandbox(sandboxOptions) + + const connectPromise = sandbox.connect() + sockets[0].open() + sockets[0].message({ type: 'auth.ok', sandboxId: sandboxOptions.id }) + await connectPromise + + const emptyOutputExec = sandbox.exec('empty-output', { onOutput: jest.fn() }) + sockets[0].message({ type: 'noop', execId: emptyOutputExec.execId }) + sockets[0].message({ type: 'exec.output', execId: emptyOutputExec.execId, stream: 'stdout' }) + sockets[0].message({ type: 'exec.output', execId: emptyOutputExec.execId, stream: 'stderr' }) + sockets[0].message({ type: 'exec.exit', execId: emptyOutputExec.execId, exitCode: 0 }) + + await expect(emptyOutputExec).resolves.toEqual({ + execId: emptyOutputExec.execId, + stdout: '', + stderr: '', + exitCode: 0 + }) + + const fallbackErrorExec = sandbox.exec('fallback-error') + sockets[0].message({ type: 'error', execId: fallbackErrorExec.execId }) + + await expect(fallbackErrorExec).rejects.toThrow(`Command '${fallbackErrorExec.execId}' failed`) + }) + + test('exec timeouts send kill frames and reject', async () => { + jest.useFakeTimers() + const sandbox = new Sandbox(sandboxOptions) + + const connectPromise = sandbox.connect() + sockets[0].open() + sockets[0].message({ type: 'auth.ok', sandboxId: sandboxOptions.id }) + await connectPromise + + const execPromise = sandbox.exec('sleep 10', { timeout: 1000 }) + jest.advanceTimersByTime(1000) + await Promise.resolve() + + await expect(execPromise).rejects.toThrow(codes.ERROR_SANDBOX_TIMEOUT) + expect(JSON.parse(sockets[0].sent[2])).toEqual({ + type: 'exec.kill', + execId: execPromise.execId, + signal: 'SIGTERM' + }) + }) + + test('kill sends exec.kill frames and disconnected sandboxes reject new commands', async () => { + const sandbox = new Sandbox(sandboxOptions) + + const connectPromise = sandbox.connect() + sockets[0].open() + sockets[0].message({ type: 'auth.ok', sandboxId: sandboxOptions.id }) + await connectPromise + + await sandbox.kill('exec-1', 'SIGKILL') + + expect(JSON.parse(sockets[0].sent[1])).toEqual({ + type: 'exec.kill', + execId: 'exec-1', + signal: 'SIGKILL' + }) + + const execPromise = sandbox.exec('long-running') + sockets[0].closeWith(1011, 'server error') + + await expect(execPromise).rejects.toThrow(codes.ERROR_SANDBOX_WEBSOCKET) + await expect(sandbox.kill('exec-2')).rejects.toThrow(codes.ERROR_SANDBOX_WEBSOCKET) + await expect(sandbox.exec('after-close')).rejects.toThrow(codes.ERROR_SANDBOX_WEBSOCKET) + }) + + test('destroy deletes the sandbox and closes the websocket', async () => { + const sandbox = new Sandbox(sandboxOptions) + + const connectPromise = sandbox.connect() + sockets[0].open() + sockets[0].message({ type: 'auth.ok', sandboxId: sandboxOptions.id }) + await connectPromise + + mockFetch.mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue({ + sandboxId: 'sb-1234', + status: 'terminating' + }) + }) + + const result = await sandbox.destroy() + + expect(createFetch).toHaveBeenCalledTimes(1) + expect(mockFetch).toHaveBeenCalledWith( + 'https://runtime.example.net/api/v1/namespaces/1234-demo/sandbox/sb-1234', + { + method: 'DELETE', + headers: { + Authorization: `Basic ${Buffer.from('uuid:key').toString('base64')}` + } + } + ) + expect(result).toEqual({ + sandboxId: 'sb-1234', + status: 'terminating' + }) + expect(sandbox.status).toBe('terminating') + expect(sockets[0].readyState).toBe(3) + }) + + test('destroy forwards configured transport options to fetch', async () => { + const sandbox = new Sandbox({ + ...sandboxOptions, + agent: { name: 'proxy-agent' }, + ignore_certs: true + }) + + mockFetch.mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue({ + sandboxId: 'sb-1234', + status: 'terminating' + }) + }) + + await sandbox.destroy() + + expect(mockFetch).toHaveBeenCalledWith( + 'https://runtime.example.net/api/v1/namespaces/1234-demo/sandbox/sb-1234', + { + method: 'DELETE', + agent: { name: 'proxy-agent' }, + rejectUnauthorized: false, + headers: { + Authorization: `Basic ${Buffer.from('uuid:key').toString('base64')}` + } + } + ) + }) + + test('destroy surfaces http and network failures', async () => { + const sandbox = new Sandbox({ + ...sandboxOptions, + apiKey: 'uuid:key' + }) + + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 500, + text: jest.fn().mockResolvedValue('server-error') + }) + mockFetch.mockRejectedValueOnce(new Error('network down')) + + await expect(sandbox.destroy()).rejects.toThrow(codes.ERROR_SANDBOX_CLIENT) + await expect(sandbox.destroy()).rejects.toThrow(codes.ERROR_SANDBOX_CLIENT) + }) + + test('destroy omits a trailing space when the server returns an empty error body', async () => { + const sandbox = new Sandbox(sandboxOptions) + + mockFetch.mockResolvedValue({ + ok: false, + status: 500, + text: jest.fn().mockResolvedValue('') + }) + + await expect(sandbox.destroy()).rejects.toThrow("Could not destroy sandbox 'sb-1234': 500") + }) + + test('destroy preserves the current status when the delete response omits one', async () => { + const sandbox = new Sandbox(sandboxOptions) + + mockFetch.mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue({ + sandboxId: 'sb-1234' + }) + }) + + await sandbox.destroy() + + expect(sandbox.status).toBe('ready') + }) + + test('rejecting an unknown exec id is a no-op', () => { + const sandbox = new Sandbox(sandboxOptions) + + expect(() => sandbox._rejectPendingExec('missing', new Error('missing'))).not.toThrow() + }) + + test('builds an auth header from the provided api key', () => { + const sandbox = new Sandbox({ + ...sandboxOptions, + namespace: undefined + }) + + expect(sandbox._buildAuthorizationHeader()).toBe( + `Basic ${Buffer.from('uuid:key').toString('base64')}` + ) + }) +}) diff --git a/types.d.ts b/types.d.ts index 7972ef06..e716c54d 100644 --- a/types.d.ts +++ b/types.d.ts @@ -142,6 +142,33 @@ declare function prepareToBuildAction(action: any, root: string, dist: string): */ declare function zipActions(buildsList: ActionBuild[], lastBuildsPath: string, distFolder: string): string[]; +/** + * Compute Sandbox management API. + * @param apiHost - Runtime API host + * @param namespace - Runtime namespace + * @param apiKey - Runtime auth key + * @param [options] - SDK transport options + */ +declare class ComputeSandbox { + constructor(apiHost: string, namespace: string, apiKey: string, options?: any); + /** + * Creates a new compute sandbox and connects its WebSocket session. + * @param [options] - sandbox create options + * @returns connected sandbox instance + */ + create(options?: SandboxCreateOptions): Promise; + /** + * Connects to an existing compute sandbox. + * @param options - sandbox reconnect options + * @returns connected sandbox instance + */ + connect(options: SandboxConnectOptions): Promise; + /** + * Named sandbox sizes. + */ + static sizes: SandboxSizes; +} + /** * @property [actions] - filter list of actions to deploy by provided array, e.g. ['name1', ..] * @property [byBuiltActions] - if true, trim actions from the manifest based on the already built actions @@ -196,6 +223,17 @@ declare function deployWsk(scriptConfig: any, manifestContent: any, logFunc: any */ declare function init(options: OpenwhiskOptions): Promise; +/** + * This patches the Openwhisk client to handle a tunneling issue with openwhisk > v3.0.0 + * See https://github.com/tomas/needle/issues/406 + * + * Once openwhisk.js supports the use_proxy_from_env_var option (for needle), we can remove this patch. + * @param ow - the Openwhisk object to patch + * @param use_proxy_from_env_var - the needle option to add + * @returns the patched openwhisk object + */ +declare function patchOWForTunnelingIssue(ow: any, use_proxy_from_env_var: boolean): any; + /** * Prints action logs. * @param config - openwhisk config @@ -214,6 +252,36 @@ declare function init(options: OpenwhiskOptions): Promise; */ declare function printActionLogs(config: any, logger: any, limit: number, filterActions: any[], strip: boolean, tail: boolean, fetchLogsInterval?: number, startTime: number): any; +/** + * Connected compute sandbox session. + * @param options - sandbox options + */ +declare class Sandbox { + constructor(options: any); + /** + * Opens the sandbox WebSocket connection. + */ + connect(): Promise; + /** + * Executes a command inside the sandbox. + * @param command - command to execute + * @param [options] - execution options + * @returns execution result promise + */ + exec(command: string, options?: SandboxExecOptions): Promise; + /** + * Sends a kill signal to a running command. + * @param execId - execution id + * @param [signal = SIGTERM] - signal to deliver + */ + kill(execId: string, signal?: string): Promise; + /** + * Destroys the sandbox and closes its WebSocket connection. + * @returns destroy response payload + */ + destroy(): Promise; +} + /** * A class to manage triggers */ @@ -271,6 +339,8 @@ declare type OpenwhiskRetryOptions = { * @property triggers - triggers * @property routes - routes * @property logForwarding - Log Forwarding management API + * @property compute - Compute sandbox management API + * @property initOptions - init options */ declare type OpenwhiskClient = { actions: ow.Actions; @@ -281,6 +351,82 @@ declare type OpenwhiskClient = { triggers: ow.Triggers; routes: ow.Routes; logForwarding: LogForwarding; + compute: any; + initOptions: OpenwhiskOptions; +}; + +/** + * @property name - sandbox display name + * @property [cluster] - target cluster + * @property [workspace] - sandbox workspace + * @property [size] - sandbox size tier + * @property [type] - sandbox runtime type + * @property [maxLifetime] - maximum lifetime in seconds + * @property [envs] - environment variables + */ +declare type SandboxCreateOptions = { + name: string; + cluster?: string; + workspace?: string; + size?: string | any; + type?: string; + maxLifetime?: number; + envs?: any; +}; + +/** + * @property sandboxId - sandbox identifier + * @property token - sandbox token + */ +declare type SandboxConnectOptions = { + sandboxId: string; + token: string; +}; + +/** + * @property cpu - requested CPU + * @property memory - requested memory + * @property gpu - requested GPU count + */ +declare type SandboxSize = { + cpu: string; + memory: string; + gpu: number; +}; + +/** + * @property SMALL - small sandbox size + * @property MEDIUM - medium sandbox size + * @property LARGE - large sandbox size + * @property XLARGE - extra large sandbox size + */ +declare type SandboxSizes = { + SMALL: SandboxSize; + MEDIUM: SandboxSize; + LARGE: SandboxSize; + XLARGE: SandboxSize; +}; + +/** + * @property [onOutput] - output callback + * @property [timeoutMs] - client-side timeout in milliseconds + */ +declare type SandboxExecOptions = { + onOutput?: (...params: any[]) => any; + timeoutMs?: number; +}; + +/** + * @property execId - execution id + * @property stdout - stdout output + * @property stderr - stderr output + * @property exitCode - process exit code + */ +declare type SandboxExecResult = { + execId: string; + stdout: string; + stderr: string; + exitCode: number; }; /** @@ -734,6 +880,21 @@ declare function createSequenceObject(fullName: string, manifestSequence: Manife */ declare function createActionObject(fullName: string, manifestAction: ManifestAction): OpenWhiskEntitiesAction; +/** + * Load the IMS credentials from the environment variables. + * @returns the IMS auth object + */ +declare function loadIMSCredentialsFromEnv(): any; + +/** + * Get the inputs for the include-ims-credentials annotation. + * Throws an error if the imsAuthObject is incomplete. + * @param thisAction - the action to process + * @param imsAuthObject - the IMS auth object + * @returns the inputs or undefined with a warning + */ +declare function getIncludeIMSCredentialsAnnotationInputs(thisAction: any, imsAuthObject: any): any | undefined; + /** * Process the manifest and deployment content and returns deployment entities. * @param packages - the manifest packages @@ -942,3 +1103,12 @@ declare function dumpActionsBuiltInfo(lastBuiltActionsPath: string, actionBuildD */ declare function getSupportedServerRuntimes(apihost: string): string[]; +/** + * Get the proxy agent for the given endpoint + * @param endpoint - The endpoint to get the proxy agent for + * @param proxyUrl - The proxy URL to use + * @param proxyOptions - The proxy options to use + * @returns - The proxy agent + */ +declare function getProxyAgent(endpoint: string, proxyUrl: string, proxyOptions: any): PatchedHttpsProxyAgent | HttpProxyAgent; + From 1ca7953d336412d2ba39042430c14cb77d2534ff Mon Sep 17 00:00:00 2001 From: Michael Goberling Date: Thu, 19 Mar 2026 12:35:42 -0400 Subject: [PATCH 02/15] feat: get status and clean some things up --- src/Sandbox.js | 29 ++++++++++++++++++----------- src/SandboxAPI.js | 26 ++++++++++++++++++++------ src/utils.js | 11 +++++++++++ test/compute.test.js | 41 +++++++++++++++++++++++++++++++++++++++++ test/sandbox.test.js | 2 +- 5 files changed, 91 insertions(+), 18 deletions(-) diff --git a/src/Sandbox.js b/src/Sandbox.js index 36deaf46..1fa444f5 100644 --- a/src/Sandbox.js +++ b/src/Sandbox.js @@ -11,8 +11,10 @@ governing permissions and limitations under the License. const crypto = require('crypto') const WebSocket = require('ws') +const aioLogger = require('@adobe/aio-lib-core-logging')('@adobe/aio-lib-runtime:Sandbox', { provider: 'debug', level: process.env.LOG_LEVEL }) const { codes } = require('./SDKErrors') const { createFetch } = require('@adobe/aio-lib-core-networking') +const { buildAuthorizationHeader } = require('./utils') require('./types.jsdoc') // for VS Code autocomplete /* global SandboxExecOptions, SandboxExecResult */ @@ -71,7 +73,7 @@ class Sandbox { socket.on('message', message => this._handleMessage(message)) socket.on('close', (code, reason) => this._handleClose(code, reason)) - socket.on('error', () => {}) + socket.on('error', (err) => aioLogger.warn(`[${this.id}] WebSocket error: ${err.message}`)) this._connectPromise = new Promise((resolve, reject) => { const onOpen = () => { @@ -152,7 +154,7 @@ class Sandbox { if (options.timeout) { timeout = setTimeout(() => { - this.kill(execId).catch(() => {}) + try { this.kill(execId) } catch (_) {} this._rejectPendingExec(execId, new codes.ERROR_SANDBOX_TIMEOUT({ messageValues: `Command '${execId}' exceeded timeout of ${options.timeout}ms` })) @@ -163,7 +165,13 @@ class Sandbox { }) execPromise.execId = execId - this._sendFrame({ type: 'exec.run', execId, command }) + try { + this._sendFrame({ type: 'exec.run', execId, command }) + } catch (error) { + this._rejectPendingExec(execId, new codes.ERROR_SANDBOX_WEBSOCKET({ + messageValues: `Could not send exec frame: ${error.message}` + })) + } return execPromise } @@ -172,9 +180,8 @@ class Sandbox { * * @param {string} execId execution id * @param {string} [signal] signal to deliver - * @returns {Promise} */ - async kill (execId, signal = 'SIGTERM') { + kill (execId, signal = 'SIGTERM') { this._ensureOpen() this._sendFrame({ type: 'exec.kill', execId, signal }) } @@ -195,10 +202,9 @@ class Sandbox { if (this.agent) { requestOptions.agent = this.agent - } - - if (this.ignoreCerts) { - requestOptions.rejectUnauthorized = false + } else if (this.ignoreCerts) { + const https = require('https') + requestOptions.agent = new https.Agent({ rejectUnauthorized: false }) } let response @@ -225,6 +231,7 @@ class Sandbox { _handleMessage (message) { const frame = this._parseFrame(message) + aioLogger.debug(`[${this.id}] received frame: ${JSON.stringify(frame)}`) if (!frame || this._isAuthAckFrame(frame)) { return } @@ -242,7 +249,7 @@ class Sandbox { } if (pendingExec.onOutput) { - pendingExec.onOutput(frame.data || '') + pendingExec.onOutput(frame.data || '', frame.stream || 'stdout') } return } @@ -323,7 +330,7 @@ class Sandbox { } _buildAuthorizationHeader () { - return `Basic ${Buffer.from(this.apiKey).toString('base64')}` + return buildAuthorizationHeader(this.apiKey) } } diff --git a/src/SandboxAPI.js b/src/SandboxAPI.js index ed894c0e..57da83b9 100644 --- a/src/SandboxAPI.js +++ b/src/SandboxAPI.js @@ -12,6 +12,7 @@ governing permissions and limitations under the License. const { codes } = require('./SDKErrors') const Sandbox = require('./Sandbox') const { createFetch } = require('@adobe/aio-lib-core-networking') +const { buildAuthorizationHeader } = require('./utils') require('./types.jsdoc') // for VS Code autocomplete /* global SandboxCreateOptions, SandboxSizes */ @@ -58,6 +59,20 @@ class SandboxAPI { return await this._buildSandbox(payload) } + /** + * Gets the status of an existing sandbox. + * + * @param {string} sandboxId sandbox ID + * @returns {Promise} sandbox status response + */ + async getStatus (sandboxId) { + return this._request( + 'get sandbox status', + 'GET', + `${this._getSandboxPath()}/${sandboxId}` + ) + } + async _buildSandbox (payload) { const sandbox = new Sandbox({ id: payload.sandboxId, @@ -111,7 +126,7 @@ class SandboxAPI { } const entry = Object.entries(SANDBOX_SIZES) - .find(([, value]) => JSON.stringify(value) === JSON.stringify(size)) + .find(([, value]) => value.cpu === size.cpu && value.memory === size.memory && value.gpu === size.gpu) if (entry) { return entry[0] @@ -132,10 +147,9 @@ class SandboxAPI { if (this.agent) { requestOptions.agent = this.agent - } - - if (this.ignoreCerts) { - requestOptions.rejectUnauthorized = false + } else if (this.ignoreCerts) { + const https = require('https') + requestOptions.agent = new https.Agent({ rejectUnauthorized: false }) } if (body !== undefined) { @@ -180,7 +194,7 @@ class SandboxAPI { } _buildAuthorizationHeader () { - return `Basic ${Buffer.from(this.apiKey).toString('base64')}` + return buildAuthorizationHeader(this.apiKey) } _getSandboxPath () { diff --git a/src/utils.js b/src/utils.js index 8a8d5386..e8bfc571 100644 --- a/src/utils.js +++ b/src/utils.js @@ -2220,6 +2220,16 @@ async function getSupportedServerRuntimes (apihost) { return json.runtimes.nodejs.map(item => item.kind) } +/** + * Builds a Basic Authorization header value from an API key. + * + * @param {string} apiKey the API key (uuid:key format) + * @returns {string} the Authorization header value + */ +function buildAuthorizationHeader (apiKey) { + return `Basic ${Buffer.from(apiKey).toString('base64')}` +} + /** * Get the proxy agent for the given endpoint * @@ -2237,6 +2247,7 @@ function getProxyAgent (endpoint, proxyUrl, proxyOptions = {}) { } module.exports = { + buildAuthorizationHeader, getProxyAgent, getSupportedServerRuntimes, checkOpenWhiskCredentials, diff --git a/test/compute.test.js b/test/compute.test.js index 0e65bb7f..dbe1d0af 100644 --- a/test/compute.test.js +++ b/test/compute.test.js @@ -262,4 +262,45 @@ describe('SandboxAPI', () => { `Basic ${Buffer.from('uuid:key').toString('base64')}` ) }) + + test('getStatus returns sandbox status for a given sandbox id', async () => { + const compute = new SandboxAPI('https://runtime.example.net', '1234-demo', 'uuid:key') + const statusPayload = { sandboxId: 'sb-1234', status: 'ready', cluster: 'cluster-a' } + + mockFetch.mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue(statusPayload) + }) + + const result = await compute.getStatus('sb-1234') + + expect(mockFetch).toHaveBeenCalledWith( + 'https://runtime.example.net/api/v1/namespaces/1234-demo/sandbox/sb-1234', + expect.objectContaining({ + method: 'GET', + headers: expect.objectContaining({ + Authorization: `Basic ${Buffer.from('uuid:key').toString('base64')}` + }) + }) + ) + expect(result).toEqual(statusPayload) + }) + + test('getStatus surfaces server errors', async () => { + const compute = new SandboxAPI('https://runtime.example.net', '1234-demo', 'uuid:key') + + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + text: jest.fn().mockResolvedValue('not found') + }) + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 500, + text: jest.fn().mockResolvedValue('internal error') + }) + + await expect(compute.getStatus('sb-missing')).rejects.toThrow(codes.ERROR_SANDBOX_NOT_FOUND) + await expect(compute.getStatus('sb-broken')).rejects.toThrow(codes.ERROR_SANDBOX_CLIENT) + }) }) diff --git a/test/sandbox.test.js b/test/sandbox.test.js index fa597df6..450bbf71 100644 --- a/test/sandbox.test.js +++ b/test/sandbox.test.js @@ -326,7 +326,7 @@ describe('Sandbox', () => { sockets[0].closeWith(1011, 'server error') await expect(execPromise).rejects.toThrow(codes.ERROR_SANDBOX_WEBSOCKET) - await expect(sandbox.kill('exec-2')).rejects.toThrow(codes.ERROR_SANDBOX_WEBSOCKET) + expect(() => sandbox.kill('exec-2')).toThrow(codes.ERROR_SANDBOX_WEBSOCKET) await expect(sandbox.exec('after-close')).rejects.toThrow(codes.ERROR_SANDBOX_WEBSOCKET) }) From 1129e189abb32ba9f065429a14161687b3de10c5 Mon Sep 17 00:00:00 2001 From: Michael Goberling Date: Thu, 19 Mar 2026 12:36:55 -0400 Subject: [PATCH 03/15] nit: copyright dates + rename test files --- src/ComputeAPI.js | 2 +- src/Sandbox.js | 2 +- src/SandboxAPI.js | 2 +- test/{compute.test.js => ComputeAPI.test.js} | 0 4 files changed, 3 insertions(+), 3 deletions(-) rename test/{compute.test.js => ComputeAPI.test.js} (100%) diff --git a/src/ComputeAPI.js b/src/ComputeAPI.js index d7541b98..c82553e9 100644 --- a/src/ComputeAPI.js +++ b/src/ComputeAPI.js @@ -1,5 +1,5 @@ /* -Copyright 2020 Adobe. All rights reserved. +Copyright 2026 Adobe. All rights reserved. This file is licensed to you under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 diff --git a/src/Sandbox.js b/src/Sandbox.js index 1fa444f5..5a6d015f 100644 --- a/src/Sandbox.js +++ b/src/Sandbox.js @@ -1,5 +1,5 @@ /* -Copyright 2020 Adobe. All rights reserved. +Copyright 2026 Adobe. All rights reserved. This file is licensed to you under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 diff --git a/src/SandboxAPI.js b/src/SandboxAPI.js index 57da83b9..65055977 100644 --- a/src/SandboxAPI.js +++ b/src/SandboxAPI.js @@ -1,5 +1,5 @@ /* -Copyright 2020 Adobe. All rights reserved. +Copyright 2026 Adobe. All rights reserved. This file is licensed to you under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 diff --git a/test/compute.test.js b/test/ComputeAPI.test.js similarity index 100% rename from test/compute.test.js rename to test/ComputeAPI.test.js From 650571f1bea87f411b74e342de86759c052300a4 Mon Sep 17 00:00:00 2001 From: Michael Goberling Date: Thu, 19 Mar 2026 14:55:38 -0400 Subject: [PATCH 04/15] feat: add region option and extract createSandboxHttpError helper --- src/Sandbox.js | 10 +++++----- src/SandboxAPI.js | 20 ++++++-------------- src/types.jsdoc.js | 1 + src/utils.js | 22 ++++++++++++++++++++++ test/ComputeAPI.test.js | 21 ++++++++------------- test/sandbox.test.js | 5 ++--- 6 files changed, 44 insertions(+), 35 deletions(-) diff --git a/src/Sandbox.js b/src/Sandbox.js index 5a6d015f..881024bc 100644 --- a/src/Sandbox.js +++ b/src/Sandbox.js @@ -14,7 +14,7 @@ const WebSocket = require('ws') const aioLogger = require('@adobe/aio-lib-core-logging')('@adobe/aio-lib-runtime:Sandbox', { provider: 'debug', level: process.env.LOG_LEVEL }) const { codes } = require('./SDKErrors') const { createFetch } = require('@adobe/aio-lib-core-networking') -const { buildAuthorizationHeader } = require('./utils') +const { buildAuthorizationHeader, createSandboxHttpError } = require('./utils') require('./types.jsdoc') // for VS Code autocomplete /* global SandboxExecOptions, SandboxExecResult */ @@ -156,7 +156,7 @@ class Sandbox { timeout = setTimeout(() => { try { this.kill(execId) } catch (_) {} this._rejectPendingExec(execId, new codes.ERROR_SANDBOX_TIMEOUT({ - messageValues: `Command '${execId}' exceeded timeout of ${options.timeout}ms` + messageValues: `Command '${command}' exceeded timeout of ${options.timeout}ms` })) }, options.timeout) @@ -218,9 +218,9 @@ class Sandbox { if (!response.ok) { const message = await response.text() - throw new codes.ERROR_SANDBOX_CLIENT({ - messageValues: `Could not destroy sandbox '${this.id}': ${response.status}${message ? ` ${message}` : ''}` - }) + const status = response.status + const detail = `Could not destroy sandbox '${this.id}': ${status}${message ? ` ${message}` : ''}` + throw createSandboxHttpError(codes, status, detail) } const payload = await response.json() diff --git a/src/SandboxAPI.js b/src/SandboxAPI.js index 65055977..44e92c8e 100644 --- a/src/SandboxAPI.js +++ b/src/SandboxAPI.js @@ -12,7 +12,7 @@ governing permissions and limitations under the License. const { codes } = require('./SDKErrors') const Sandbox = require('./Sandbox') const { createFetch } = require('@adobe/aio-lib-core-networking') -const { buildAuthorizationHeader } = require('./utils') +const { buildAuthorizationHeader, createSandboxHttpError } = require('./utils') require('./types.jsdoc') // for VS Code autocomplete /* global SandboxCreateOptions, SandboxSizes */ @@ -105,6 +105,10 @@ class SandboxAPI { body.cluster = options.cluster } + if (options.region) { + body.region = options.region + } + if (options.workspace) { body.workspace = options.workspace } @@ -178,19 +182,7 @@ class SandboxAPI { _createHttpError (operation, status, message) { const messageValues = `Could not ${operation}: ${status}${message ? ` ${message}` : ''}` - if (status === 401 || status === 403) { - return new codes.ERROR_SANDBOX_UNAUTHORIZED({ messageValues }) - } - - if (status === 404) { - return new codes.ERROR_SANDBOX_NOT_FOUND({ messageValues }) - } - - if (status === 504) { - return new codes.ERROR_SANDBOX_TIMEOUT({ messageValues }) - } - - return new codes.ERROR_SANDBOX_CLIENT({ messageValues }) + return createSandboxHttpError(codes, status, messageValues) } _buildAuthorizationHeader () { diff --git a/src/types.jsdoc.js b/src/types.jsdoc.js index deb28795..7b0231fa 100644 --- a/src/types.jsdoc.js +++ b/src/types.jsdoc.js @@ -47,6 +47,7 @@ governing permissions and limitations under the License. * @typedef {object} SandboxCreateOptions * @property {string} name sandbox display name * @property {string} [cluster] target cluster + * @property {string} [region] target region (e.g. "va6", "aus3") * @property {string} [workspace] sandbox workspace * @property {string|object} [size] sandbox size tier * @property {string} [type] sandbox runtime type diff --git a/src/utils.js b/src/utils.js index e8bfc571..3d78555a 100644 --- a/src/utils.js +++ b/src/utils.js @@ -2230,6 +2230,27 @@ function buildAuthorizationHeader (apiKey) { return `Basic ${Buffer.from(apiKey).toString('base64')}` } +/** + * Maps an HTTP status code to a typed sandbox SDK error. + * + * @param {object} codes SDK error codes map + * @param {number} status HTTP status code + * @param {string} messageValues error message + * @returns {Error} the appropriate SDK error instance + */ +function createSandboxHttpError (codes, status, messageValues) { + if (status === 401 || status === 403) { + return new codes.ERROR_SANDBOX_UNAUTHORIZED({ messageValues }) + } + if (status === 404) { + return new codes.ERROR_SANDBOX_NOT_FOUND({ messageValues }) + } + if (status === 504) { + return new codes.ERROR_SANDBOX_TIMEOUT({ messageValues }) + } + return new codes.ERROR_SANDBOX_CLIENT({ messageValues }) +} + /** * Get the proxy agent for the given endpoint * @@ -2248,6 +2269,7 @@ function getProxyAgent (endpoint, proxyUrl, proxyOptions = {}) { module.exports = { buildAuthorizationHeader, + createSandboxHttpError, getProxyAgent, getSupportedServerRuntimes, checkOpenWhiskCredentials, diff --git a/test/ComputeAPI.test.js b/test/ComputeAPI.test.js index dbe1d0af..9717baed 100644 --- a/test/ComputeAPI.test.js +++ b/test/ComputeAPI.test.js @@ -55,6 +55,7 @@ describe('SandboxAPI', () => { const result = await compute.create({ name: 'app-instance', cluster: 'cluster-a', + region: 'va6', workspace: 'workspace-a', size: compute.sizes.SMALL, type: 'gpu:python', @@ -75,6 +76,7 @@ describe('SandboxAPI', () => { expect(JSON.parse(mockFetch.mock.calls[0][1].body)).toEqual({ name: 'app-instance', cluster: 'cluster-a', + region: 'va6', workspace: 'workspace-a', size: 'SMALL', type: 'gpu:python', @@ -153,8 +155,7 @@ describe('SandboxAPI', () => { 'https://runtime.example.net/api/v1/namespaces/1234-demo/sandbox', expect.objectContaining({ method: 'POST', - agent: { name: 'proxy-agent' }, - rejectUnauthorized: false + agent: { name: 'proxy-agent' } }) ) }) @@ -202,18 +203,12 @@ describe('SandboxAPI', () => { test('create surfaces authorization and not-found responses', async () => { const compute = new SandboxAPI('https://runtime.example.net', '1234-demo', 'uuid:key') - mockFetch.mockResolvedValueOnce({ - ok: false, - status: 401, - text: jest.fn().mockResolvedValue('unauthorized') - }) - mockFetch.mockResolvedValueOnce({ - ok: false, - status: 404, - text: jest.fn().mockResolvedValue('missing') - }) + mockFetch.mockResolvedValueOnce({ ok: false, status: 401, text: jest.fn().mockResolvedValue('unauthorized') }) + mockFetch.mockResolvedValueOnce({ ok: false, status: 403, text: jest.fn().mockResolvedValue('forbidden') }) + mockFetch.mockResolvedValueOnce({ ok: false, status: 404, text: jest.fn().mockResolvedValue('missing') }) - await expect(compute.create({ name: 'sb-auth' })).rejects.toThrow(codes.ERROR_SANDBOX_UNAUTHORIZED) + await expect(compute.create({ name: 'sb-401' })).rejects.toThrow(codes.ERROR_SANDBOX_UNAUTHORIZED) + await expect(compute.create({ name: 'sb-403' })).rejects.toThrow(codes.ERROR_SANDBOX_UNAUTHORIZED) await expect(compute.create({ name: 'sb-missing' })).rejects.toThrow(codes.ERROR_SANDBOX_NOT_FOUND) }) diff --git a/test/sandbox.test.js b/test/sandbox.test.js index 450bbf71..3b7dce50 100644 --- a/test/sandbox.test.js +++ b/test/sandbox.test.js @@ -206,8 +206,8 @@ describe('Sandbox', () => { exitCode: 0 }) expect(onOutput).toHaveBeenCalledTimes(2) - expect(onOutput).toHaveBeenNthCalledWith(1, 'hello\n') - expect(onOutput).toHaveBeenNthCalledWith(2, 'warning\n') + expect(onOutput).toHaveBeenNthCalledWith(1, 'hello\n', 'stdout') + expect(onOutput).toHaveBeenNthCalledWith(2, 'warning\n', 'stderr') }) test('concurrent exec calls are demultiplexed by execId', async () => { @@ -388,7 +388,6 @@ describe('Sandbox', () => { { method: 'DELETE', agent: { name: 'proxy-agent' }, - rejectUnauthorized: false, headers: { Authorization: `Basic ${Buffer.from('uuid:key').toString('base64')}` } From 1672926d254b5a75e514cebff50963c2faa76291 Mon Sep 17 00:00:00 2001 From: Michael Goberling Date: Tue, 24 Mar 2026 10:53:43 -0400 Subject: [PATCH 05/15] feat: file ops --- src/Sandbox.js | 155 ++++++++++++++++++++++++++++++++++++++++++++- src/types.jsdoc.js | 7 ++ 2 files changed, 161 insertions(+), 1 deletion(-) diff --git a/src/Sandbox.js b/src/Sandbox.js index 881024bc..54b944e1 100644 --- a/src/Sandbox.js +++ b/src/Sandbox.js @@ -16,7 +16,7 @@ const { codes } = require('./SDKErrors') const { createFetch } = require('@adobe/aio-lib-core-networking') const { buildAuthorizationHeader, createSandboxHttpError } = require('./utils') require('./types.jsdoc') // for VS Code autocomplete -/* global SandboxExecOptions, SandboxExecResult */ +/* global SandboxExecOptions, SandboxExecResult, SandboxFileEntry */ /** * Connected compute sandbox session. @@ -43,6 +43,7 @@ class Sandbox { this._socket = null this._connectPromise = null this._pendingExecs = new Map() + this._pendingFileOps = new Map() } /** @@ -186,6 +187,100 @@ class Sandbox { this._sendFrame({ type: 'exec.kill', execId, signal }) } + /** + * Reads a file from the sandbox filesystem. + * + * @param {string} path absolute path inside the sandbox + * @returns {Promise} file contents as a UTF-8 string + */ + readFile (path) { + try { + this._ensureOpen() + } catch (error) { + return Promise.reject(error) + } + + const execId = `file-${crypto.randomBytes(12).toString('hex')}` + + const opPromise = new Promise((resolve, reject) => { + this._pendingFileOps.set(execId, { resolve, reject }) + }) + + try { + this._sendFrame({ type: 'file.read', execId, path }) + } catch (error) { + this._rejectPendingFileOp(execId, new codes.ERROR_SANDBOX_WEBSOCKET({ + messageValues: `Could not send file.read frame: ${error.message}` + })) + } + + return opPromise + } + + /** + * Writes a file to the sandbox filesystem. Parent directories are created automatically. + * + * @param {string} path absolute path inside the sandbox + * @param {string|Buffer} content file contents + * @returns {Promise<{path: string, size: number, ok: boolean}>} write confirmation + */ + writeFile (path, content) { + try { + this._ensureOpen() + } catch (error) { + return Promise.reject(error) + } + + const execId = `file-${crypto.randomBytes(12).toString('hex')}` + const encoded = Buffer.isBuffer(content) + ? content.toString('base64') + : Buffer.from(content).toString('base64') + + const opPromise = new Promise((resolve, reject) => { + this._pendingFileOps.set(execId, { resolve, reject }) + }) + + try { + this._sendFrame({ type: 'file.write', execId, path, content: encoded, encoding: 'base64' }) + } catch (error) { + this._rejectPendingFileOp(execId, new codes.ERROR_SANDBOX_WEBSOCKET({ + messageValues: `Could not send file.write frame: ${error.message}` + })) + } + + return opPromise + } + + /** + * Lists the contents of a directory inside the sandbox. + * + * @param {string} path absolute directory path inside the sandbox + * @returns {Promise} directory entries + */ + listFiles (path) { + try { + this._ensureOpen() + } catch (error) { + return Promise.reject(error) + } + + const execId = `file-${crypto.randomBytes(12).toString('hex')}` + + const opPromise = new Promise((resolve, reject) => { + this._pendingFileOps.set(execId, { resolve, reject }) + }) + + try { + this._sendFrame({ type: 'file.list', execId, path }) + } catch (error) { + this._rejectPendingFileOp(execId, new codes.ERROR_SANDBOX_WEBSOCKET({ + messageValues: `Could not send file.list frame: ${error.message}` + })) + } + + return opPromise + } + /** * Destroys the sandbox and closes its WebSocket connection. * @@ -236,6 +331,11 @@ class Sandbox { return } + if (this._pendingFileOps.has(frame.execId)) { + this._handleFileFrame(frame) + return + } + const pendingExec = this._pendingExecs.get(frame.execId) if (!pendingExec) { return @@ -273,11 +373,54 @@ class Sandbox { } } + _handleFileFrame (frame) { + const pendingOp = this._pendingFileOps.get(frame.execId) + if (!pendingOp) { + return + } + + if (frame.type === 'file.content') { + this._pendingFileOps.delete(frame.execId) + const content = frame.encoding === 'base64' + ? Buffer.from(frame.content, 'base64').toString('utf8') + : (frame.content || '') + pendingOp.resolve(content) + return + } + + if (frame.type === 'file.writeResult') { + this._pendingFileOps.delete(frame.execId) + if (!frame.ok) { + pendingOp.reject(new codes.ERROR_SANDBOX_CLIENT({ + messageValues: `file.write failed for path '${frame.path}'` + })) + } else { + pendingOp.resolve({ path: frame.path, size: frame.size, ok: frame.ok }) + } + return + } + + if (frame.type === 'file.entries') { + this._pendingFileOps.delete(frame.execId) + pendingOp.resolve(frame.entries || []) + return + } + + if (frame.type === 'error') { + this._rejectPendingFileOp(frame.execId, new codes.ERROR_SANDBOX_CLIENT({ + messageValues: frame.message || `File operation '${frame.execId}' failed` + })) + } + } + _handleClose (code) { const error = this._createCloseError(code) for (const execId of this._pendingExecs.keys()) { this._rejectPendingExec(execId, error) } + for (const execId of this._pendingFileOps.keys()) { + this._rejectPendingFileOp(execId, error) + } this._connectPromise = null this._socket = null } @@ -293,6 +436,16 @@ class Sandbox { pendingExec.reject(error) } + _rejectPendingFileOp (execId, error) { + const pendingOp = this._pendingFileOps.get(execId) + if (!pendingOp) { + return + } + + this._pendingFileOps.delete(execId) + pendingOp.reject(error) + } + _createCloseError (code) { if (code === 4001) { return new codes.ERROR_SANDBOX_UNAUTHORIZED({ diff --git a/src/types.jsdoc.js b/src/types.jsdoc.js index 7b0231fa..1b55fee5 100644 --- a/src/types.jsdoc.js +++ b/src/types.jsdoc.js @@ -83,3 +83,10 @@ governing permissions and limitations under the License. * @property {string} stderr stderr output * @property {number} exitCode process exit code */ + +/** + * @typedef {object} SandboxFileEntry + * @property {string} name file or directory name + * @property {'file'|'dir'} type entry type + * @property {number} [size] file size in bytes (present for files) + */ From bd5952d28ebbf247212d6241b83d8e7315035481 Mon Sep 17 00:00:00 2001 From: Michael Goberling Date: Wed, 25 Mar 2026 14:30:08 -0400 Subject: [PATCH 06/15] fix: use node: prefix for node libs --- src/Sandbox.js | 4 ++-- src/SandboxAPI.js | 2 +- test/sandbox.test.js | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Sandbox.js b/src/Sandbox.js index 54b944e1..cc53976d 100644 --- a/src/Sandbox.js +++ b/src/Sandbox.js @@ -9,7 +9,7 @@ OF ANY KIND, either express or implied. See the License for the specific languag governing permissions and limitations under the License. */ -const crypto = require('crypto') +const crypto = require('node:crypto') const WebSocket = require('ws') const aioLogger = require('@adobe/aio-lib-core-logging')('@adobe/aio-lib-runtime:Sandbox', { provider: 'debug', level: process.env.LOG_LEVEL }) const { codes } = require('./SDKErrors') @@ -298,7 +298,7 @@ class Sandbox { if (this.agent) { requestOptions.agent = this.agent } else if (this.ignoreCerts) { - const https = require('https') + const https = require('node:https') requestOptions.agent = new https.Agent({ rejectUnauthorized: false }) } diff --git a/src/SandboxAPI.js b/src/SandboxAPI.js index 44e92c8e..c48668d2 100644 --- a/src/SandboxAPI.js +++ b/src/SandboxAPI.js @@ -152,7 +152,7 @@ class SandboxAPI { if (this.agent) { requestOptions.agent = this.agent } else if (this.ignoreCerts) { - const https = require('https') + const https = require('node:https') requestOptions.agent = new https.Agent({ rejectUnauthorized: false }) } diff --git a/test/sandbox.test.js b/test/sandbox.test.js index 3b7dce50..bb738258 100644 --- a/test/sandbox.test.js +++ b/test/sandbox.test.js @@ -1,4 +1,4 @@ -const EventEmitter = require('events') +const EventEmitter = require('node:events') const Sandbox = require('../src/Sandbox') const WebSocket = require('ws') const { codes } = require('../src/SDKErrors') From 01c944b862ab835c9bba88a2759f346383923797 Mon Sep 17 00:00:00 2001 From: Michael Goberling Date: Wed, 25 Mar 2026 17:10:39 -0400 Subject: [PATCH 07/15] feat: network policies --- src/SandboxAPI.js | 4 ++ src/types.jsdoc.js | 18 ++++++ test/ComputeAPI.test.js | 126 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 148 insertions(+) diff --git a/src/SandboxAPI.js b/src/SandboxAPI.js index c48668d2..3adb95b9 100644 --- a/src/SandboxAPI.js +++ b/src/SandboxAPI.js @@ -117,6 +117,10 @@ class SandboxAPI { body.envs = options.envs } + if (options.policy) { + body.policy = options.policy + } + return body } diff --git a/src/types.jsdoc.js b/src/types.jsdoc.js index 1b55fee5..9dca4240 100644 --- a/src/types.jsdoc.js +++ b/src/types.jsdoc.js @@ -43,6 +43,23 @@ governing permissions and limitations under the License. * @property {OpenwhiskOptions} initOptions init options */ +/** + * @typedef {object} EgressRule + * @property {string} host - FQDN, wildcard FQDN (*.domain), IP address, or CIDR range + * @property {number} port - Destination port (1-65535) + * @property {string} [protocol='TCP'] - 'TCP' or 'UDP' + */ + +/** + * @typedef {object} NetworkPolicyOptions + * @property {EgressRule[]|'allow-all'} [egress] - Allowed outbound endpoints, or 'allow-all' to permit all egress + */ + +/** + * @typedef {object} PolicyOptions + * @property {NetworkPolicyOptions} [network] - Network policy configuration + */ + /** * @typedef {object} SandboxCreateOptions * @property {string} name sandbox display name @@ -53,6 +70,7 @@ governing permissions and limitations under the License. * @property {string} [type] sandbox runtime type * @property {number} [maxLifetime] maximum lifetime in seconds * @property {object} [envs] environment variables + * @property {PolicyOptions} [policy] - Network policy for the sandbox. When omitted, default-deny applies (DNS + NATS only). */ /** diff --git a/test/ComputeAPI.test.js b/test/ComputeAPI.test.js index 9717baed..38a1f82c 100644 --- a/test/ComputeAPI.test.js +++ b/test/ComputeAPI.test.js @@ -250,6 +250,132 @@ describe('SandboxAPI', () => { expect(mockFetch).not.toHaveBeenCalled() }) + test('create includes policy with egress rules in the POST body', async () => { + const compute = new SandboxAPI('https://runtime.example.net', '1234-demo', 'uuid:key') + + mockFetch.mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue({ + sandboxId: 'sb-policy', + token: 'tok', + status: 'ready' + }) + }) + + await compute.create({ + name: 'policy-sandbox', + policy: { + network: { + egress: [ + { host: 'api.github.com', port: 443 }, + { host: '*.adobe.io', port: 443 } + ] + } + } + }) + + const body = JSON.parse(mockFetch.mock.calls[0][1].body) + expect(body.policy).toEqual({ + network: { + egress: [ + { host: 'api.github.com', port: 443 }, + { host: '*.adobe.io', port: 443 } + ] + } + }) + }) + + test('create includes policy with allow-all egress in the POST body', async () => { + const compute = new SandboxAPI('https://runtime.example.net', '1234-demo', 'uuid:key') + + mockFetch.mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue({ + sandboxId: 'sb-allow', + token: 'tok', + status: 'ready' + }) + }) + + await compute.create({ + name: 'allow-all-sandbox', + policy: { network: { egress: 'allow-all' } } + }) + + const body = JSON.parse(mockFetch.mock.calls[0][1].body) + expect(body.policy).toEqual({ network: { egress: 'allow-all' } }) + }) + + test('create includes policy with empty egress array in the POST body', async () => { + const compute = new SandboxAPI('https://runtime.example.net', '1234-demo', 'uuid:key') + + mockFetch.mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue({ + sandboxId: 'sb-deny', + token: 'tok', + status: 'ready' + }) + }) + + await compute.create({ + name: 'deny-all-sandbox', + policy: { network: { egress: [] } } + }) + + const body = JSON.parse(mockFetch.mock.calls[0][1].body) + expect(body.policy).toEqual({ network: { egress: [] } }) + }) + + test('create omits policy from the POST body when not provided', async () => { + const compute = new SandboxAPI('https://runtime.example.net', '1234-demo', 'uuid:key') + + mockFetch.mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue({ + sandboxId: 'sb-nopolicy', + token: 'tok', + status: 'ready' + }) + }) + + await compute.create({ name: 'no-policy-sandbox' }) + + const body = JSON.parse(mockFetch.mock.calls[0][1].body) + expect(body).not.toHaveProperty('policy') + }) + + test('create passes through egress rules with protocol field', async () => { + const compute = new SandboxAPI('https://runtime.example.net', '1234-demo', 'uuid:key') + + mockFetch.mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue({ + sandboxId: 'sb-proto', + token: 'tok', + status: 'ready' + }) + }) + + await compute.create({ + name: 'protocol-sandbox', + policy: { + network: { + egress: [ + { host: 'api.github.com', port: 443 }, + { host: 'ntp.ubuntu.com', port: 123, protocol: 'UDP' } + ] + } + } + }) + + const body = JSON.parse(mockFetch.mock.calls[0][1].body) + expect(body.policy.network.egress).toEqual([ + { host: 'api.github.com', port: 443 }, + { host: 'ntp.ubuntu.com', port: 123, protocol: 'UDP' } + ]) + }) + test('builds an auth header from the provided api key', () => { const compute = new SandboxAPI('https://runtime.example.net', undefined, 'uuid:key') From 6a85bc9ffa88a55be031f16ab5fe20ca3fa3c6aa Mon Sep 17 00:00:00 2001 From: Michael Goberling Date: Thu, 26 Mar 2026 09:18:23 -0400 Subject: [PATCH 08/15] feat: add base sandbox network policies --- src/SandboxNetworkPolicy.js | 129 ++++++++++++++++++++++++++++++++++++ src/index.js | 4 +- 2 files changed, 132 insertions(+), 1 deletion(-) create mode 100644 src/SandboxNetworkPolicy.js diff --git a/src/SandboxNetworkPolicy.js b/src/SandboxNetworkPolicy.js new file mode 100644 index 00000000..d2656bdf --- /dev/null +++ b/src/SandboxNetworkPolicy.js @@ -0,0 +1,129 @@ +/* +Copyright 2026 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +// --------------------------------------------------------------------------- +// Pre-built network policies for common services. +// +// Each entry is a frozen { egress: [...] } object that can be passed directly +// as the `network` field when creating a sandbox: +// +// policy: { network: NetworkPolicy.base } +// +// Modelled after https://github.com/NVIDIA/OpenShell-Community/blob/main/sandboxes/base/policy.yaml +// --------------------------------------------------------------------------- + +const freeze = obj => Object.freeze({ egress: Object.freeze(obj.egress) }) + +// -- AI / LLM providers ---------------------------------------------------- + +const anthropic = freeze({ + egress: [ + { host: 'api.anthropic.com', port: 443 }, + { host: 'statsig.anthropic.com', port: 443 }, + { host: 'sentry.io', port: 443 } + ] +}) + +// -- GitHub ----------------------------------------------------------------- + +const github = freeze({ + egress: [ + { host: 'github.com', port: 443 }, + { host: 'api.github.com', port: 443 }, + { host: 'objects.githubusercontent.com', port: 443 }, + { host: 'raw.githubusercontent.com', port: 443 }, + { host: 'release-assets.githubusercontent.com', port: 443 } + ] +}) + +const githubCopilot = freeze({ + egress: [ + { host: 'github.com', port: 443 }, + { host: 'api.github.com', port: 443 }, + { host: 'api.githubcopilot.com', port: 443 }, + { host: 'api.enterprise.githubcopilot.com', port: 443 }, + { host: 'release-assets.githubusercontent.com', port: 443 }, + { host: 'copilot-proxy.githubusercontent.com', port: 443 }, + { host: 'default.exp-tas.com', port: 443 } + ] +}) + +// -- Package registries ----------------------------------------------------- + +const pypi = freeze({ + egress: [ + { host: 'pypi.org', port: 443 }, + { host: 'files.pythonhosted.org', port: 443 }, + { host: 'downloads.python.org', port: 443 } + ] +}) + +const npm = freeze({ + egress: [ + { host: 'registry.npmjs.org', port: 443 } + ] +}) + +// -- AI coding tools -------------------------------------------------------- + +const opencode = freeze({ + egress: [ + { host: 'opencode.ai', port: 443 }, + { host: 'integrate.api.nvidia.com', port: 443 } + ] +}) + +// -- IDEs / editors --------------------------------------------------------- + +const vscode = freeze({ + egress: [ + { host: 'update.code.visualstudio.com', port: 443 }, + { host: 'az764295.vo.msecnd.net', port: 443 }, + { host: 'vscode.download.prss.microsoft.com', port: 443 }, + { host: 'marketplace.visualstudio.com', port: 443 }, + { host: 'gallerycdn.vsassets.io', port: 443 } + ] +}) + +const cursor = freeze({ + egress: [ + { host: 'cursor.blob.core.windows.net', port: 443 }, + { host: 'api2.cursor.sh', port: 443 }, + { host: 'repo.cursor.sh', port: 443 }, + { host: 'download.cursor.sh', port: 443 }, + { host: 'cursor.download.prss.microsoft.com', port: 443 } + ] +}) + +// --------------------------------------------------------------------------- +// Base — GitHub + PyPI + npm + Anthropic +// --------------------------------------------------------------------------- + +const base = freeze({ + egress: [ + ...github.egress, + ...pypi.egress, + ...npm.egress, + ...anthropic.egress + ] +}) + +module.exports = Object.freeze({ + anthropic, + github, + githubCopilot, + opencode, + pypi, + npm, + vscode, + cursor, + base +}) diff --git a/src/index.js b/src/index.js index 3b5143f3..34be6c27 100644 --- a/src/index.js +++ b/src/index.js @@ -16,6 +16,7 @@ const deployActions = require('./deploy-actions') const undeployActions = require('./undeploy-actions') const printActionLogs = require('./print-action-logs') const RuntimeAPI = require('./RuntimeAPI') +const NetworkPolicy = require('./SandboxNetworkPolicy') require('./types.jsdoc') // for VS Code autocomplete /* global OpenwhiskOptions, OpenwhiskClient */ // for linter @@ -48,5 +49,6 @@ module.exports = { deployActions, undeployActions, printActionLogs, - utils + utils, + NetworkPolicy } From 5b2413e7147cc3841b8cfa48c8773a946b770be5 Mon Sep 17 00:00:00 2001 From: Michael Goberling Date: Thu, 26 Mar 2026 09:53:06 -0400 Subject: [PATCH 09/15] fix: make netpols more sandbox specific --- src/index.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/index.js b/src/index.js index 34be6c27..cd4ce051 100644 --- a/src/index.js +++ b/src/index.js @@ -16,7 +16,7 @@ const deployActions = require('./deploy-actions') const undeployActions = require('./undeploy-actions') const printActionLogs = require('./print-action-logs') const RuntimeAPI = require('./RuntimeAPI') -const NetworkPolicy = require('./SandboxNetworkPolicy') +const SandboxNetworkPolicy = require('./SandboxNetworkPolicy') require('./types.jsdoc') // for VS Code autocomplete /* global OpenwhiskOptions, OpenwhiskClient */ // for linter @@ -50,5 +50,5 @@ module.exports = { undeployActions, printActionLogs, utils, - NetworkPolicy + SandboxNetworkPolicy } From 298cf0e85c42f8319f206c8e56f611fdc1ef0724 Mon Sep 17 00:00:00 2001 From: Michael Goberling Date: Thu, 26 Mar 2026 10:35:08 -0400 Subject: [PATCH 10/15] docs: sandbox --- docs/sandboxes.md | 88 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 docs/sandboxes.md diff --git a/docs/sandboxes.md b/docs/sandboxes.md new file mode 100644 index 00000000..7216e8ac --- /dev/null +++ b/docs/sandboxes.md @@ -0,0 +1,88 @@ +# Sandbox Quickstart + +## Install + +```bash +npm install github:adobe/aio-lib-runtime#agent-sandboxes +``` + +## Init + +```js +const { init } = require('@adobe/aio-lib-runtime') + +const runtime = await init({ + apihost: process.env.AIO_RUNTIME_APIHOST, + namespace: process.env.AIO_RUNTIME_NAMESPACE, + api_key: process.env.AIO_RUNTIME_AUTH +}) +``` + +## Create Sandbox + +```js +const { SandboxNetworkPolicy } = require('@adobe/aio-lib-runtime') + +const sandbox = await runtime.compute.sandbox.create({ + name: 'my-sandbox', + type: 'cpu:nodejs', + workspace: 'workspace', + maxLifetime: 3600, + envs: { + API_KEY: 'your-api-key' + }, + policy: { + network: SandboxNetworkPolicy.base + } +}) +``` + +## Get Status + +```js +const status = await runtime.compute.sandbox.getStatus(sandbox.id) +console.log('status:', status) +``` + +## Exec + +```js +const result = await sandbox.exec('ls -al', { timeout: 10000 }) +console.log('stdout:', result.stdout.trim()) +console.log('exit code:', result.exitCode) +``` + +## File Management + +```js +const script = `console.log('hello from sandbox script', process.version)\n` +await sandbox.writeFile('hello.js', script) + +const content = await sandbox.readFile('hello.js') +console.log('readFile content:', content.trim()) + +const entries = await sandbox.listFiles('.') +console.log('listFiles entries:', entries) +``` + +## Exec a File + +```js +const result = await sandbox.exec('node hello.js', { timeout: 10000 }) +console.log('stdout:', result.stdout.trim()) +console.log('stderr:', result.stderr.trim()) +console.log('exit code:', result.exitCode) +``` + +## Curl a Site + +```js +const result = await sandbox.exec('curl -s --connect-timeout 5 -o /dev/null -w "%{http_code}" https://github.com', { timeout: 10000 }) +console.log(` github.com (allowed) → HTTP ${result.stdout.trim()}`) +``` + +## Destroy + +```js +await sandbox.destroy() +``` From e07b6e307ac30d4a556d2953c3751f39559b8dea Mon Sep 17 00:00:00 2001 From: Michael Goberling Date: Thu, 26 Mar 2026 10:42:16 -0400 Subject: [PATCH 11/15] docs: more sandbox netpol stuff --- docs/sandboxes.md | 78 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/docs/sandboxes.md b/docs/sandboxes.md index 7216e8ac..72579836 100644 --- a/docs/sandboxes.md +++ b/docs/sandboxes.md @@ -86,3 +86,81 @@ console.log(` github.com (allowed) → HTTP ${result.stdout.trim()}`) ```js await sandbox.destroy() ``` + +--- + +## Network Policies + +Sandboxes are default-deny. All outbound traffic is blocked unless explicitly allowed. + +At creation time, a `policy.network` field is passed with an egress allowlist of `{ host, port }` pairs. Only matching traffic is permitted. + +This library provides composable presets (`SandboxNetworkPolicy.github`, `.pypi`, etc.) as starting points for common services. + +### Base Policy + +Includes GitHub, Anthropic, npm, pypi, and others. + +See [SandboxNetworkPolicy.js](../src/SandboxNetworkPolicy.js) for the full list. + +```js +const { SandboxNetworkPolicy } = require('@adobe/aio-lib-runtime') + +const sandbox = await runtime.compute.sandbox.create({ + name: 'my-sandbox', + type: 'cpu:nodejs', + workspace: 'workspace', + maxLifetime: 3600, + envs: { API_KEY: 'your-api-key' }, + policy: { network: SandboxNetworkPolicy.base } +}) +``` + +### Specific Services + +```js +const { SandboxNetworkPolicy } = require('@adobe/aio-lib-runtime') + +const sandbox = await runtime.compute.sandbox.create({ + name: 'policy-composed', + type: 'cpu:nodejs', + workspace: 'policy-test', + maxLifetime: 300, + policy: { + network: { + egress: [ + ...SandboxNetworkPolicy.github.egress, + ...SandboxNetworkPolicy.pypi.egress + ] + } + } +}) +``` + +### Specific Hosts/Ports + +```js +const sandbox = await runtime.compute.sandbox.create({ + name: 'policy-composed', + workspace: 'policy-test', + maxLifetime: 300, + policy: { + network: { + egress: [ + { host: 'httpbin.org', port: 443 } + ] + } + } +}) +``` + +### Allow All (Debug only) + +```js +const sandbox = await runtime.compute.sandbox.create({ + name: 'policy-allow-all', + workspace: 'policy-test', + maxLifetime: 300, + policy: { network: { egress: 'allow-all' } } +}) +``` From 181ec4fc0f74361fa57df30f10e7961ee1243b54 Mon Sep 17 00:00:00 2001 From: Michael Goberling Date: Thu, 26 Mar 2026 11:05:05 -0400 Subject: [PATCH 12/15] docs: policies differ --- src/SandboxNetworkPolicy.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/SandboxNetworkPolicy.js b/src/SandboxNetworkPolicy.js index d2656bdf..50e15f99 100644 --- a/src/SandboxNetworkPolicy.js +++ b/src/SandboxNetworkPolicy.js @@ -17,7 +17,6 @@ governing permissions and limitations under the License. // // policy: { network: NetworkPolicy.base } // -// Modelled after https://github.com/NVIDIA/OpenShell-Community/blob/main/sandboxes/base/policy.yaml // --------------------------------------------------------------------------- const freeze = obj => Object.freeze({ egress: Object.freeze(obj.egress) }) From 7773808575d7f5f09857375ef24abb12a463cf09 Mon Sep 17 00:00:00 2001 From: Michael Goberling Date: Fri, 27 Mar 2026 12:39:45 -0400 Subject: [PATCH 13/15] feat: allow sending stdin to processes --- docs/sandboxes.md | 22 +++++++++ src/Sandbox.js | 34 ++++++++++++++ src/types.jsdoc.js | 1 + test/sandbox.test.js | 106 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 163 insertions(+) diff --git a/docs/sandboxes.md b/docs/sandboxes.md index 72579836..17ba8727 100644 --- a/docs/sandboxes.md +++ b/docs/sandboxes.md @@ -81,6 +81,28 @@ const result = await sandbox.exec('curl -s --connect-timeout 5 -o /dev/null -w " console.log(` github.com (allowed) → HTTP ${result.stdout.trim()}`) ``` +## Write to Stdin + +### Command start +```js +const result = await sandbox.exec('python process_csv.py', { + stdin: 'col1,col2\nval1,val2\n', + timeout: 10000 +}) +console.log('stdout:', result.stdout.trim()) +``` + +### Running command +```js +const execPromise = sandbox.exec('cat') +sandbox.writeStdin(execPromise.execId, 'line 1\n') +sandbox.writeStdin(execPromise.execId, 'line 2\n') +sandbox.closeStdin(execPromise.execId) + +const result = await execPromise +console.log('stdout:', result.stdout.trim()) +``` + ## Destroy ```js diff --git a/src/Sandbox.js b/src/Sandbox.js index cc53976d..4b681481 100644 --- a/src/Sandbox.js +++ b/src/Sandbox.js @@ -168,6 +168,10 @@ class Sandbox { execPromise.execId = execId try { this._sendFrame({ type: 'exec.run', execId, command }) + if (options.stdin !== undefined) { + this.writeStdin(execId, options.stdin) + this.closeStdin(execId) + } } catch (error) { this._rejectPendingExec(execId, new codes.ERROR_SANDBOX_WEBSOCKET({ messageValues: `Could not send exec frame: ${error.message}` @@ -187,6 +191,36 @@ class Sandbox { this._sendFrame({ type: 'exec.kill', execId, signal }) } + /** + * Writes data to the stdin of a running command. + * Fire-and-forget — there is no response on success. + * + * @param {string} execId execution id from exec() + * @param {string|Buffer} data data to write (Buffer is base64-encoded on the wire) + */ + writeStdin (execId, data) { + this._ensureOpen() + const frame = { type: 'exec.input', execId } + if (Buffer.isBuffer(data)) { + frame.data = data.toString('base64') + frame.encoding = 'base64' + } else { + frame.data = data + } + this._sendFrame(frame) + } + + /** + * Closes stdin for a running command, delivering EOF. + * Fire-and-forget — there is no response on success. + * + * @param {string} execId execution id from exec() + */ + closeStdin (execId) { + this._ensureOpen() + this._sendFrame({ type: 'exec.endInput', execId }) + } + /** * Reads a file from the sandbox filesystem. * diff --git a/src/types.jsdoc.js b/src/types.jsdoc.js index 9dca4240..f278b91c 100644 --- a/src/types.jsdoc.js +++ b/src/types.jsdoc.js @@ -92,6 +92,7 @@ governing permissions and limitations under the License. * @typedef {object} SandboxExecOptions * @property {function(string): void} [onOutput] output callback * @property {number} [timeout] client-side timeout in milliseconds + * @property {string|Buffer} [stdin] data to send to stdin and close automatically */ /** diff --git a/test/sandbox.test.js b/test/sandbox.test.js index bb738258..b6bde971 100644 --- a/test/sandbox.test.js +++ b/test/sandbox.test.js @@ -455,4 +455,110 @@ describe('Sandbox', () => { `Basic ${Buffer.from('uuid:key').toString('base64')}` ) }) + + describe('writeStdin / closeStdin', () => { + let sandbox + let fakeWS + + async function connectSandbox (sb) { + const p = sb.connect() + sockets[sockets.length - 1].open() + sockets[sockets.length - 1].message({ type: 'auth.ok', sandboxId: sandboxOptions.id }) + await p + fakeWS = sockets[sockets.length - 1] + } + + beforeEach(async () => { + sandbox = new Sandbox(sandboxOptions) + await connectSandbox(sandbox) + }) + + test('writeStdin sends exec.input frame with text data', () => { + sandbox.writeStdin('exec-abc', 'print("hello")\n') + + const frame = JSON.parse(fakeWS.sent[fakeWS.sent.length - 1]) + expect(frame).toEqual({ + type: 'exec.input', + execId: 'exec-abc', + data: 'print("hello")\n' + }) + }) + + test('writeStdin sends base64-encoded frame for Buffer', () => { + const buf = Buffer.from('binary-data') + sandbox.writeStdin('exec-abc', buf) + + const frame = JSON.parse(fakeWS.sent[fakeWS.sent.length - 1]) + expect(frame).toEqual({ + type: 'exec.input', + execId: 'exec-abc', + data: buf.toString('base64'), + encoding: 'base64' + }) + }) + + test('writeStdin throws when socket not connected', () => { + fakeWS.readyState = 3 + expect(() => sandbox.writeStdin('exec-abc', 'data')).toThrow(codes.ERROR_SANDBOX_WEBSOCKET) + }) + + test('closeStdin sends exec.endInput frame', () => { + sandbox.closeStdin('exec-abc') + + const frame = JSON.parse(fakeWS.sent[fakeWS.sent.length - 1]) + expect(frame).toEqual({ + type: 'exec.endInput', + execId: 'exec-abc' + }) + }) + + test('closeStdin throws when socket not connected', () => { + fakeWS.readyState = 3 + expect(() => sandbox.closeStdin('exec-abc')).toThrow(codes.ERROR_SANDBOX_WEBSOCKET) + }) + + test('exec with stdin option sends run + input + endInput', async () => { + const execPromise = sandbox.exec('cat', { stdin: 'hello world\n' }) + const { execId } = execPromise + + const sentAfterAuth = fakeWS.sent.slice(1).map(s => JSON.parse(s)) + expect(sentAfterAuth).toEqual([ + { type: 'exec.run', execId, command: 'cat' }, + { type: 'exec.input', execId, data: 'hello world\n' }, + { type: 'exec.endInput', execId } + ]) + + fakeWS.message({ type: 'exec.exit', execId, exitCode: 0 }) + await execPromise + }) + + test('exec with Buffer stdin sends base64 input', async () => { + const buf = Buffer.from('binary-stdin') + const execPromise = sandbox.exec('process', { stdin: buf }) + const { execId } = execPromise + + const sentAfterAuth = fakeWS.sent.slice(1).map(s => JSON.parse(s)) + expect(sentAfterAuth).toEqual([ + { type: 'exec.run', execId, command: 'process' }, + { type: 'exec.input', execId, data: buf.toString('base64'), encoding: 'base64' }, + { type: 'exec.endInput', execId } + ]) + + fakeWS.message({ type: 'exec.exit', execId, exitCode: 0 }) + await execPromise + }) + + test('exec without stdin option does not send input frames', async () => { + const execPromise = sandbox.exec('ls') + const { execId } = execPromise + + const sentAfterAuth = fakeWS.sent.slice(1).map(s => JSON.parse(s)) + expect(sentAfterAuth).toEqual([ + { type: 'exec.run', execId, command: 'ls' } + ]) + + fakeWS.message({ type: 'exec.exit', execId, exitCode: 0 }) + await execPromise + }) + }) }) From 30a0da36b26edac8c6179dcaa2601e613e2d81b1 Mon Sep 17 00:00:00 2001 From: Michael Goberling Date: Tue, 31 Mar 2026 16:39:30 -0400 Subject: [PATCH 14/15] fix: no premade policies, we can't manage these client side --- docs/sandboxes.md | 47 ------------- src/SandboxNetworkPolicy.js | 128 ------------------------------------ src/index.js | 4 +- 3 files changed, 1 insertion(+), 178 deletions(-) delete mode 100644 src/SandboxNetworkPolicy.js diff --git a/docs/sandboxes.md b/docs/sandboxes.md index 17ba8727..91aecdc0 100644 --- a/docs/sandboxes.md +++ b/docs/sandboxes.md @@ -21,8 +21,6 @@ const runtime = await init({ ## Create Sandbox ```js -const { SandboxNetworkPolicy } = require('@adobe/aio-lib-runtime') - const sandbox = await runtime.compute.sandbox.create({ name: 'my-sandbox', type: 'cpu:nodejs', @@ -30,9 +28,6 @@ const sandbox = await runtime.compute.sandbox.create({ maxLifetime: 3600, envs: { API_KEY: 'your-api-key' - }, - policy: { - network: SandboxNetworkPolicy.base } }) ``` @@ -117,48 +112,6 @@ Sandboxes are default-deny. All outbound traffic is blocked unless explicitly al At creation time, a `policy.network` field is passed with an egress allowlist of `{ host, port }` pairs. Only matching traffic is permitted. -This library provides composable presets (`SandboxNetworkPolicy.github`, `.pypi`, etc.) as starting points for common services. - -### Base Policy - -Includes GitHub, Anthropic, npm, pypi, and others. - -See [SandboxNetworkPolicy.js](../src/SandboxNetworkPolicy.js) for the full list. - -```js -const { SandboxNetworkPolicy } = require('@adobe/aio-lib-runtime') - -const sandbox = await runtime.compute.sandbox.create({ - name: 'my-sandbox', - type: 'cpu:nodejs', - workspace: 'workspace', - maxLifetime: 3600, - envs: { API_KEY: 'your-api-key' }, - policy: { network: SandboxNetworkPolicy.base } -}) -``` - -### Specific Services - -```js -const { SandboxNetworkPolicy } = require('@adobe/aio-lib-runtime') - -const sandbox = await runtime.compute.sandbox.create({ - name: 'policy-composed', - type: 'cpu:nodejs', - workspace: 'policy-test', - maxLifetime: 300, - policy: { - network: { - egress: [ - ...SandboxNetworkPolicy.github.egress, - ...SandboxNetworkPolicy.pypi.egress - ] - } - } -}) -``` - ### Specific Hosts/Ports ```js diff --git a/src/SandboxNetworkPolicy.js b/src/SandboxNetworkPolicy.js deleted file mode 100644 index 50e15f99..00000000 --- a/src/SandboxNetworkPolicy.js +++ /dev/null @@ -1,128 +0,0 @@ -/* -Copyright 2026 Adobe. All rights reserved. -This file is licensed to you under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. You may obtain a copy -of the License at http://www.apache.org/licenses/LICENSE-2.0 -Unless required by applicable law or agreed to in writing, software distributed under -the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS -OF ANY KIND, either express or implied. See the License for the specific language -governing permissions and limitations under the License. -*/ - -// --------------------------------------------------------------------------- -// Pre-built network policies for common services. -// -// Each entry is a frozen { egress: [...] } object that can be passed directly -// as the `network` field when creating a sandbox: -// -// policy: { network: NetworkPolicy.base } -// -// --------------------------------------------------------------------------- - -const freeze = obj => Object.freeze({ egress: Object.freeze(obj.egress) }) - -// -- AI / LLM providers ---------------------------------------------------- - -const anthropic = freeze({ - egress: [ - { host: 'api.anthropic.com', port: 443 }, - { host: 'statsig.anthropic.com', port: 443 }, - { host: 'sentry.io', port: 443 } - ] -}) - -// -- GitHub ----------------------------------------------------------------- - -const github = freeze({ - egress: [ - { host: 'github.com', port: 443 }, - { host: 'api.github.com', port: 443 }, - { host: 'objects.githubusercontent.com', port: 443 }, - { host: 'raw.githubusercontent.com', port: 443 }, - { host: 'release-assets.githubusercontent.com', port: 443 } - ] -}) - -const githubCopilot = freeze({ - egress: [ - { host: 'github.com', port: 443 }, - { host: 'api.github.com', port: 443 }, - { host: 'api.githubcopilot.com', port: 443 }, - { host: 'api.enterprise.githubcopilot.com', port: 443 }, - { host: 'release-assets.githubusercontent.com', port: 443 }, - { host: 'copilot-proxy.githubusercontent.com', port: 443 }, - { host: 'default.exp-tas.com', port: 443 } - ] -}) - -// -- Package registries ----------------------------------------------------- - -const pypi = freeze({ - egress: [ - { host: 'pypi.org', port: 443 }, - { host: 'files.pythonhosted.org', port: 443 }, - { host: 'downloads.python.org', port: 443 } - ] -}) - -const npm = freeze({ - egress: [ - { host: 'registry.npmjs.org', port: 443 } - ] -}) - -// -- AI coding tools -------------------------------------------------------- - -const opencode = freeze({ - egress: [ - { host: 'opencode.ai', port: 443 }, - { host: 'integrate.api.nvidia.com', port: 443 } - ] -}) - -// -- IDEs / editors --------------------------------------------------------- - -const vscode = freeze({ - egress: [ - { host: 'update.code.visualstudio.com', port: 443 }, - { host: 'az764295.vo.msecnd.net', port: 443 }, - { host: 'vscode.download.prss.microsoft.com', port: 443 }, - { host: 'marketplace.visualstudio.com', port: 443 }, - { host: 'gallerycdn.vsassets.io', port: 443 } - ] -}) - -const cursor = freeze({ - egress: [ - { host: 'cursor.blob.core.windows.net', port: 443 }, - { host: 'api2.cursor.sh', port: 443 }, - { host: 'repo.cursor.sh', port: 443 }, - { host: 'download.cursor.sh', port: 443 }, - { host: 'cursor.download.prss.microsoft.com', port: 443 } - ] -}) - -// --------------------------------------------------------------------------- -// Base — GitHub + PyPI + npm + Anthropic -// --------------------------------------------------------------------------- - -const base = freeze({ - egress: [ - ...github.egress, - ...pypi.egress, - ...npm.egress, - ...anthropic.egress - ] -}) - -module.exports = Object.freeze({ - anthropic, - github, - githubCopilot, - opencode, - pypi, - npm, - vscode, - cursor, - base -}) diff --git a/src/index.js b/src/index.js index cd4ce051..3b5143f3 100644 --- a/src/index.js +++ b/src/index.js @@ -16,7 +16,6 @@ const deployActions = require('./deploy-actions') const undeployActions = require('./undeploy-actions') const printActionLogs = require('./print-action-logs') const RuntimeAPI = require('./RuntimeAPI') -const SandboxNetworkPolicy = require('./SandboxNetworkPolicy') require('./types.jsdoc') // for VS Code autocomplete /* global OpenwhiskOptions, OpenwhiskClient */ // for linter @@ -49,6 +48,5 @@ module.exports = { deployActions, undeployActions, printActionLogs, - utils, - SandboxNetworkPolicy + utils } From 9a9df1e2acb5f0e010687775959f04ab5c10e094 Mon Sep 17 00:00:00 2001 From: Michael Goberling Date: Wed, 1 Apr 2026 19:19:29 -0400 Subject: [PATCH 15/15] feat: L7 egress rules --- docs/sandboxes.md | 15 ++++++++------ src/types.jsdoc.js | 7 +++++++ test/ComputeAPI.test.js | 43 +++++++++++++++++++++++++++++++++++++++++ types.d.ts | 28 +++++++++++++++++++++++++++ 4 files changed, 87 insertions(+), 6 deletions(-) diff --git a/docs/sandboxes.md b/docs/sandboxes.md index 91aecdc0..6d474cd6 100644 --- a/docs/sandboxes.md +++ b/docs/sandboxes.md @@ -110,26 +110,29 @@ await sandbox.destroy() Sandboxes are default-deny. All outbound traffic is blocked unless explicitly allowed. -At creation time, a `policy.network` field is passed with an egress allowlist of `{ host, port }` pairs. Only matching traffic is permitted. - -### Specific Hosts/Ports +Pass a `policy.network.egress` array at creation time to allowlist outbound endpoints, paths, or HTTP verbs. ```js const sandbox = await runtime.compute.sandbox.create({ - name: 'policy-composed', + name: 'policy-sandbox', workspace: 'policy-test', maxLifetime: 300, policy: { network: { egress: [ - { host: 'httpbin.org', port: 443 } + { host: 'httpbin.org', port: 443 }, + { + host: 'api.github.com', + port: 443, + rules: [{ methods: ['GET'], pathPattern: '/repos/**' }] + } ] } } }) ``` -### Allow All (Debug only) +### Allow All (Not recommended for production) ```js const sandbox = await runtime.compute.sandbox.create({ diff --git a/src/types.jsdoc.js b/src/types.jsdoc.js index f278b91c..83cd81db 100644 --- a/src/types.jsdoc.js +++ b/src/types.jsdoc.js @@ -43,11 +43,18 @@ governing permissions and limitations under the License. * @property {OpenwhiskOptions} initOptions init options */ +/** + * @typedef {object} L7Rule + * @property {string[]} methods - HTTP methods to allow (e.g. ['GET', 'POST']) + * @property {string} pathPattern - URL path pattern to match (e.g. '/repos/**') + */ + /** * @typedef {object} EgressRule * @property {string} host - FQDN, wildcard FQDN (*.domain), IP address, or CIDR range * @property {number} port - Destination port (1-65535) * @property {string} [protocol='TCP'] - 'TCP' or 'UDP' + * @property {L7Rule[]} [rules] - Optional L7 HTTP rules; when present, only matching method+path combinations are allowed */ /** diff --git a/test/ComputeAPI.test.js b/test/ComputeAPI.test.js index 38a1f82c..d12901cd 100644 --- a/test/ComputeAPI.test.js +++ b/test/ComputeAPI.test.js @@ -345,6 +345,49 @@ describe('SandboxAPI', () => { expect(body).not.toHaveProperty('policy') }) + test('create includes egress rules with L7 rules in the POST body', async () => { + const compute = new SandboxAPI('https://runtime.example.net', '1234-demo', 'uuid:key') + + mockFetch.mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue({ + sandboxId: 'sb-l7', + token: 'tok', + status: 'ready' + }) + }) + + await compute.create({ + name: 'l7-sandbox', + policy: { + network: { + egress: [ + { + host: 'api.github.com', + port: 443, + rules: [ + { methods: ['GET'], pathPattern: '/repos/**' }, + { methods: ['GET', 'POST'], pathPattern: '/gists' } + ] + } + ] + } + } + }) + + const body = JSON.parse(mockFetch.mock.calls[0][1].body) + expect(body.policy.network.egress).toEqual([ + { + host: 'api.github.com', + port: 443, + rules: [ + { methods: ['GET'], pathPattern: '/repos/**' }, + { methods: ['GET', 'POST'], pathPattern: '/gists' } + ] + } + ]) + }) + test('create passes through egress rules with protocol field', async () => { const compute = new SandboxAPI('https://runtime.example.net', '1234-demo', 'uuid:key') diff --git a/types.d.ts b/types.d.ts index e716c54d..bd79f4c9 100644 --- a/types.d.ts +++ b/types.d.ts @@ -355,6 +355,28 @@ declare type OpenwhiskClient = { initOptions: OpenwhiskOptions; }; +/** + * @property methods - HTTP methods to allow (e.g. ['GET', 'POST']) + * @property pathPattern - URL path pattern to match (e.g. '/repos/**') + */ +declare type L7Rule = { + methods: string[]; + pathPattern: string; +}; + +/** + * @property host - FQDN, wildcard FQDN (*.domain), IP address, or CIDR range + * @property port - Destination port (1-65535) + * @property [protocol] - 'TCP' or 'UDP' (default: 'TCP') + * @property [rules] - Optional L7 HTTP rules; when present, only matching method+path combinations are allowed + */ +declare type EgressRule = { + host: string; + port: number; + protocol?: string; + rules?: L7Rule[]; +}; + /** * @property name - sandbox display name * @property [cluster] - target cluster @@ -363,6 +385,7 @@ declare type OpenwhiskClient = { * @property [type] - sandbox runtime type * @property [maxLifetime] - maximum lifetime in seconds * @property [envs] - environment variables + * @property [policy] - network policy; when omitted, default-deny applies (DNS + NATS only) */ declare type SandboxCreateOptions = { name: string; @@ -372,6 +395,11 @@ declare type SandboxCreateOptions = { type?: string; maxLifetime?: number; envs?: any; + policy?: { + network?: { + egress?: EgressRule[] | 'allow-all'; + }; + }; }; /**