From cfbf1f009b27cc6fc1fd6b073a1276128b2e77d5 Mon Sep 17 00:00:00 2001 From: Michael Goberling Date: Mon, 4 May 2026 11:59:35 -0400 Subject: [PATCH 01/11] feat: add runtime sandbox run command Adds a new `aio runtime sandbox run` command (under a new `runtime sandbox` topic) that creates a compute sandbox via aio-lib-runtime and drops the user into an interactive REPL against it, mirroring the standalone aio-lib-runtime-sandbox tool but wired into the standard runtime auth/config plumbing. Temporarily pins @adobe/aio-lib-runtime to the agent-sandboxes branch so ow.compute.sandbox.create resolves; this needs to move to a published version before merge. Co-authored-by: Cursor --- package.json | 2 +- src/commands/runtime/sandbox/index.js | 27 ++ src/commands/runtime/sandbox/run.js | 268 ++++++++++++ test/__mocks__/@adobe/aio-lib-runtime.js | 5 + test/commands/runtime/sandbox/index.test.js | 52 +++ test/commands/runtime/sandbox/run.test.js | 427 ++++++++++++++++++++ 6 files changed, 780 insertions(+), 1 deletion(-) create mode 100644 src/commands/runtime/sandbox/index.js create mode 100644 src/commands/runtime/sandbox/run.js create mode 100644 test/commands/runtime/sandbox/index.test.js create mode 100644 test/commands/runtime/sandbox/run.test.js diff --git a/package.json b/package.json index e092a261..a4606536 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "@adobe/aio-lib-core-networking": "^5", "@adobe/aio-lib-env": "^3.0.1", "@adobe/aio-lib-ims": "^8.0.1", - "@adobe/aio-lib-runtime": "^7.1.0", + "@adobe/aio-lib-runtime": "adobe/aio-lib-runtime#agent-sandboxes", "@oclif/core": "^4.0.0", "@types/jest": "^29.5.3", "chalk": "^4.1.2", diff --git a/src/commands/runtime/sandbox/index.js b/src/commands/runtime/sandbox/index.js new file mode 100644 index 00000000..366f0750 --- /dev/null +++ b/src/commands/runtime/sandbox/index.js @@ -0,0 +1,27 @@ +/* +Copyright 2026 Adobe Inc. 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 { Help } = require('@oclif/core') +const RuntimeBaseCommand = require('../../../RuntimeBaseCommand') + +class IndexCommand extends RuntimeBaseCommand { + async run () { + const help = new Help(this.config) + await help.showHelp(['runtime:sandbox', '--help']) + } +} + +IndexCommand.description = 'Manage runtime sandboxes' + +IndexCommand.aliases = ['rt:sandbox'] + +module.exports = IndexCommand diff --git a/src/commands/runtime/sandbox/run.js b/src/commands/runtime/sandbox/run.js new file mode 100644 index 00000000..cd54b2a1 --- /dev/null +++ b/src/commands/runtime/sandbox/run.js @@ -0,0 +1,268 @@ +/* +Copyright 2026 Adobe Inc. 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 readline = require('node:readline') +const { Flags } = require('@oclif/core') +const RuntimeBaseCommand = require('../../../RuntimeBaseCommand') + +const VALID_HTTP_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'] +const EXEC_TIMEOUT_MS = 30000 +const PROBE_TIMEOUT_MS = 10000 + +/** + * Parse a list of `--egress` flag values into the `network.egress` rule array. + * Throws on malformed input. The caller is responsible for handling the + * `allow-all` shorthand separately. + * + * @param {string[]} egressArgs raw flag values + * @returns {Array} parsed egress rules + */ +function parseEgressFlags (egressArgs) { + return egressArgs.map(arg => { + // Split on | to separate L4 (host:port[:protocol]) from optional L7 (METHOD[,METHOD]:path) + const pipeIdx = arg.indexOf('|') + const l4Part = pipeIdx === -1 ? arg : arg.slice(0, pipeIdx) + const l7Part = pipeIdx === -1 ? null : arg.slice(pipeIdx + 1) + + const parts = l4Part.split(':') + if (parts.length < 2 || parts.length > 3) { + throw new Error(`Invalid egress format: "${arg}". Expected host:port[:protocol][|METHOD:path]`) + } + const port = parseInt(parts[1], 10) + if (Number.isNaN(port) || port < 1 || port > 65535) { + throw new Error(`Invalid port in egress rule: "${arg}". Port must be 1-65535`) + } + const rule = { host: parts[0], port } + if (parts[2]) { + const proto = parts[2].toUpperCase() + if (proto !== 'TCP' && proto !== 'UDP') { + throw new Error(`Invalid protocol in egress rule: "${arg}". Must be TCP or UDP`) + } + rule.protocol = proto + } + + if (l7Part) { + const colonIdx = l7Part.indexOf(':') + if (colonIdx === -1 || !l7Part.slice(colonIdx + 1).startsWith('/')) { + throw new Error(`Invalid L7 rule: "${arg}". Expected METHOD[,METHOD]:/ after |`) + } + const methods = l7Part.slice(0, colonIdx).split(',').map(m => m.trim().toUpperCase()) + const pathPattern = l7Part.slice(colonIdx + 1) + for (const method of methods) { + if (!VALID_HTTP_METHODS.includes(method)) { + throw new Error(`Invalid HTTP method "${method}" in "${arg}". Must be one of: ${VALID_HTTP_METHODS.join(', ')}`) + } + } + rule.rules = [{ methods, pathPattern }] + } + + return rule + }) +} + +/** + * Build the sandbox `policy` object from `--egress` flag values, or return + * `undefined` if no egress flags were provided. + * + * @param {string[]} [egressArgs] raw `--egress` flag values + * @returns {object|undefined} sandbox policy + */ +function buildPolicy (egressArgs) { + if (!egressArgs || egressArgs.length === 0) { + return undefined + } + if (egressArgs.length === 1 && egressArgs[0] === 'allow-all') { + return { network: { egress: 'allow-all' } } + } + if (egressArgs.includes('allow-all')) { + throw new Error('allow-all cannot be combined with other egress rules.') + } + return { network: { egress: parseEgressFlags(egressArgs) } } +} + +class SandboxRun extends RuntimeBaseCommand { + async run () { + const { flags } = await this.parse(SandboxRun) + + let sandbox + let rl + try { + const policy = buildPolicy(flags.egress) + const ow = await this.wsk() + + this.log('\nCreating sandbox...') + sandbox = await ow.compute.sandbox.create({ + name: flags.name, + ...(flags.type && { type: flags.type }), + ...(flags.size && { size: flags.size }), + workspace: 'workspace', + maxLifetime: flags['max-lifetime'], + envs: {}, + ...(policy && { policy }) + }) + this.log(`Created: ${sandbox.id}`) + + this._logPolicy(policy) + + const probe = await sandbox.exec('node --version', { timeout: PROBE_TIMEOUT_MS }) + this.log(`Node version: ${(probe.stdout || '').trim()} | exit: ${probe.exitCode}`) + + this.log('\nSandbox ready. Type ".help" for commands, or "exit" to destroy and quit.\n') + + rl = readline.createInterface({ input: process.stdin, output: process.stdout }) + await this._repl(rl, sandbox) + } catch (err) { + await this.handleError('failed to run sandbox', err) + } finally { + if (rl) { + rl.close() + } + if (sandbox) { + try { + await sandbox.destroy() + this.log('Sandbox destroyed.') + } catch (destroyErr) { + this.log(`failed to destroy sandbox: ${destroyErr.message || destroyErr}`) + } + } + } + } + + _logPolicy (policy) { + if (!policy) { + this.log('Network policy: default-deny (DNS + NATS only)') + return + } + if (policy.network.egress === 'allow-all') { + this.log('Network policy: allow-all egress') + return + } + this.log('Network policy: custom egress') + policy.network.egress.forEach(rule => { + const proto = rule.protocol || 'TCP' + const l7 = rule.rules ? ' ' + rule.rules.map(r => `${r.methods.join(',')}:${r.pathPattern}`).join(' ') : '' + this.log(` - ${rule.host}:${rule.port} (${proto})${l7}`) + }) + } + + async _repl (rl, sandbox) { + while (true) { + const cmd = await this._ask(rl, 'Enter command to run on sandbox: ') + const trimmed = (cmd || '').trim() + if (trimmed === 'exit' || trimmed === 'quit') { + break + } + if (!trimmed) { + continue + } + if (trimmed === '.help') { + this._printHelp() + continue + } + + try { + if (trimmed.includes(' <<< ')) { + await this._handleHereString(sandbox, trimmed) + } else { + await this._handleExec(sandbox, trimmed) + } + } catch (err) { + this.log(`exec error: ${err.message || err}`) + } + } + } + + _ask (rl, question) { + return new Promise(resolve => rl.question(question, resolve)) + } + + async _handleExec (sandbox, cmd) { + const result = await sandbox.exec(cmd, { timeout: EXEC_TIMEOUT_MS }) + if (result.stdout) process.stdout.write(result.stdout) + if (result.stderr) process.stderr.write(result.stderr) + this.log(`[exit: ${result.exitCode}]`) + } + + async _handleHereString (sandbox, input) { + const idx = input.indexOf(' <<< ') + const command = input.slice(0, idx).trim() + let text = input.slice(idx + 5).trim() + if ((text.startsWith('"') && text.endsWith('"')) || (text.startsWith("'") && text.endsWith("'"))) { + text = text.slice(1, -1) + } + text += '\n' + + this.log(`(sending ${text.length} bytes to stdin)`) + const result = await sandbox.exec(command, { timeout: EXEC_TIMEOUT_MS, stdin: text }) + const hasOutput = result.stdout || result.stderr + if (hasOutput) { + this.log('') + if (result.stdout) process.stdout.write(result.stdout) + if (result.stderr) process.stderr.write(result.stderr) + this.log('') + } + this.log(`[exit: ${result.exitCode}]\n`) + } + + _printHelp () { + this.log('') + this.log('How it works:') + this.log(' Each command runs in a fresh process on the sandbox.') + this.log(' Shell state (working directory, exports) does not persist between commands.') + this.log(' To run multi-step workflows, chain commands: cd mydir && npm install') + this.log('') + this.log('Stdin:') + this.log(' command <<< "text" Send inline text as stdin') + this.log(' cat -n <<< "hello world"') + this.log('') + this.log('Other:') + this.log(' exit / quit Destroy sandbox and exit') + this.log(' .help Show this help') + this.log('') + } +} + +SandboxRun.description = 'Create a sandbox and run an interactive REPL against it' + +SandboxRun.flags = { + ...RuntimeBaseCommand.flags, + name: Flags.string({ + description: 'sandbox name', + default: 'aio-sandbox' + }), + type: Flags.string({ + char: 't', + description: 'sandbox type (e.g. cpu:default, cpu:nodejs)' + }), + size: Flags.string({ + char: 's', + description: 'sandbox size', + options: ['SMALL', 'MEDIUM', 'LARGE', 'XLARGE'] + }), + egress: Flags.string({ + char: 'e', + description: 'egress rule in host:port[:protocol][|METHOD:path] format, or "allow-all" (repeatable)', + multiple: true + }), + 'max-lifetime': Flags.integer({ + description: 'maximum sandbox lifetime in seconds', + default: 3600 + }) +} + +SandboxRun.aliases = ['rt:sandbox:run'] + +// exposed for testing +SandboxRun.parseEgressFlags = parseEgressFlags +SandboxRun.buildPolicy = buildPolicy + +module.exports = SandboxRun diff --git a/test/__mocks__/@adobe/aio-lib-runtime.js b/test/__mocks__/@adobe/aio-lib-runtime.js index 8a5fc480..6d965573 100644 --- a/test/__mocks__/@adobe/aio-lib-runtime.js +++ b/test/__mocks__/@adobe/aio-lib-runtime.js @@ -20,6 +20,11 @@ const mockRtLibInstance = { }, feeds: {}, routes: {}, + compute: { + sandbox: { + create: jest.fn() + } + }, mockFn: function (methodName) { const cmd = methodName.split('.') let method = this diff --git a/test/commands/runtime/sandbox/index.test.js b/test/commands/runtime/sandbox/index.test.js new file mode 100644 index 00000000..5384587b --- /dev/null +++ b/test/commands/runtime/sandbox/index.test.js @@ -0,0 +1,52 @@ +/* +Copyright 2026 Adobe Inc. 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 TheCommand = require('../../../../src/commands/runtime/sandbox/index.js') +const RuntimeBaseCommand = require('../../../../src/RuntimeBaseCommand.js') +const { Help } = require('@oclif/core') + +test('exports', async () => { + expect(typeof TheCommand).toEqual('function') + expect(TheCommand.prototype instanceof RuntimeBaseCommand).toBeTruthy() +}) + +test('description', async () => { + expect(TheCommand.description).toBeDefined() +}) + +test('aliases', async () => { + expect(TheCommand.aliases).toBeDefined() + expect(TheCommand.aliases).toBeInstanceOf(Array) + expect(TheCommand.aliases.length).toBeGreaterThan(0) +}) + +describe('instance methods', () => { + let command + + beforeEach(() => { + command = new TheCommand([]) + }) + + describe('run', () => { + test('exists', async () => { + expect(command.run).toBeInstanceOf(Function) + }) + + test('returns help file for runtime sandbox command', async () => { + const spy = jest.spyOn(Help.prototype, 'showHelp').mockReturnValue(true) + command.config = {} + command.id = 'runtime:sandbox' + await command.run() + expect(spy).toHaveBeenCalledWith(['runtime:sandbox', '--help']) + }) + }) +}) diff --git a/test/commands/runtime/sandbox/run.test.js b/test/commands/runtime/sandbox/run.test.js new file mode 100644 index 00000000..cc6c3766 --- /dev/null +++ b/test/commands/runtime/sandbox/run.test.js @@ -0,0 +1,427 @@ +/* +Copyright 2026 Adobe Inc. 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. +*/ + +jest.mock('node:readline') + +const readline = require('node:readline') +const { stdout, stderr } = require('stdout-stderr') +beforeEach(() => stderr.start()) +afterEach(() => stderr.stop()) +const TheCommand = require('../../../../src/commands/runtime/sandbox/run.js') +const RuntimeBaseCommand = require('../../../../src/RuntimeBaseCommand.js') +const RuntimeLib = require('@adobe/aio-lib-runtime') + +const rtCreate = 'compute.sandbox.create' + +/** + * Build a fake `Sandbox` object suitable for stubbing `compute.sandbox.create` + * resolutions from the mocked aio-lib-runtime. + * + * @param {object} [overrides] override individual fields + * @returns {object} fake sandbox + */ +function fakeSandbox (overrides = {}) { + return { + id: 'sandbox-123', + exec: jest.fn(), + destroy: jest.fn().mockResolvedValue({ status: 'destroyed' }), + ...overrides + } +} + +/** + * Build a fake readline interface that scripts the supplied user inputs. + * Tests should always include a terminator (`exit`/`quit`) as the last entry, + * otherwise the REPL loop will hang. + * + * @param {string[]} answers ordered REPL inputs + * @returns {object} fake readline interface + */ +function makeRl (answers) { + const queue = [...answers] + return { + question: jest.fn((q, cb) => { + const next = queue.shift() + // resolve asynchronously so any pending microtasks (e.g. exec promises) + // can settle before the next prompt arrives + setImmediate(() => cb(next)) + }), + close: jest.fn() + } +} + +test('exports', async () => { + expect(typeof TheCommand).toEqual('function') + expect(TheCommand.prototype instanceof RuntimeBaseCommand).toBeTruthy() +}) + +test('description', async () => { + expect(TheCommand.description).toBeDefined() +}) + +test('aliases', async () => { + expect(TheCommand.aliases).toBeDefined() + expect(TheCommand.aliases).toBeInstanceOf(Array) + expect(TheCommand.aliases.length).toBeGreaterThan(0) +}) + +test('flags', async () => { + expect(typeof TheCommand.flags.name).toBe('object') + expect(TheCommand.flags.name.default).toBe('aio-sandbox') + expect(TheCommand.flags.type.char).toBe('t') + expect(TheCommand.flags.size.char).toBe('s') + expect(TheCommand.flags.size.options).toEqual(['SMALL', 'MEDIUM', 'LARGE', 'XLARGE']) + expect(TheCommand.flags.egress.char).toBe('e') + expect(TheCommand.flags.egress.multiple).toBe(true) + expect(TheCommand.flags['max-lifetime'].default).toBe(3600) + // inherits base flags + expect(TheCommand.flags.apihost).toBeDefined() + expect(TheCommand.flags.auth).toBeDefined() +}) + +describe('parseEgressFlags', () => { + test('parses single L4 rule', () => { + expect(TheCommand.parseEgressFlags(['pypi.org:443'])).toEqual([ + { host: 'pypi.org', port: 443 } + ]) + }) + + test('parses L4 rule with TCP protocol', () => { + expect(TheCommand.parseEgressFlags(['pypi.org:443:tcp'])).toEqual([ + { host: 'pypi.org', port: 443, protocol: 'TCP' } + ]) + }) + + test('parses L4 rule with UDP protocol', () => { + expect(TheCommand.parseEgressFlags(['dns.google:53:udp'])).toEqual([ + { host: 'dns.google', port: 53, protocol: 'UDP' } + ]) + }) + + test('parses L4+L7 rule', () => { + expect(TheCommand.parseEgressFlags(['api.github.com:443|GET,POST:/repos/**'])).toEqual([ + { + host: 'api.github.com', + port: 443, + rules: [{ methods: ['GET', 'POST'], pathPattern: '/repos/**' }] + } + ]) + }) + + test('parses multiple rules', () => { + const result = TheCommand.parseEgressFlags(['a.com:80', 'b.com:443:TCP']) + expect(result).toHaveLength(2) + }) + + test('rejects invalid format (too few parts)', () => { + expect(() => TheCommand.parseEgressFlags(['bad'])).toThrow(/Invalid egress format/) + }) + + test('rejects invalid format (too many parts)', () => { + expect(() => TheCommand.parseEgressFlags(['a:1:tcp:extra'])).toThrow(/Invalid egress format/) + }) + + test('rejects non-numeric port', () => { + expect(() => TheCommand.parseEgressFlags(['a:nope'])).toThrow(/Invalid port/) + }) + + test('rejects out-of-range port', () => { + expect(() => TheCommand.parseEgressFlags(['a:99999'])).toThrow(/Invalid port/) + }) + + test('rejects port below 1', () => { + expect(() => TheCommand.parseEgressFlags(['a:0'])).toThrow(/Invalid port/) + }) + + test('rejects unknown protocol', () => { + expect(() => TheCommand.parseEgressFlags(['a:80:sctp'])).toThrow(/Invalid protocol/) + }) + + test('rejects L7 rule without colon', () => { + expect(() => TheCommand.parseEgressFlags(['a:80|GET'])).toThrow(/Invalid L7 rule/) + }) + + test('rejects L7 rule with non-slash path', () => { + expect(() => TheCommand.parseEgressFlags(['a:80|GET:nope'])).toThrow(/Invalid L7 rule/) + }) + + test('rejects unknown HTTP method in L7 rule', () => { + expect(() => TheCommand.parseEgressFlags(['a:80|FOO:/path'])).toThrow(/Invalid HTTP method/) + }) +}) + +describe('buildPolicy', () => { + test('returns undefined when no egress flags', () => { + expect(TheCommand.buildPolicy(undefined)).toBeUndefined() + expect(TheCommand.buildPolicy([])).toBeUndefined() + }) + + test('returns allow-all policy when sole value is allow-all', () => { + expect(TheCommand.buildPolicy(['allow-all'])).toEqual({ network: { egress: 'allow-all' } }) + }) + + test('throws when allow-all is mixed with other rules', () => { + expect(() => TheCommand.buildPolicy(['allow-all', 'a.com:80'])).toThrow(/cannot be combined/) + }) + + test('returns parsed egress rules', () => { + expect(TheCommand.buildPolicy(['a.com:80'])).toEqual({ + network: { egress: [{ host: 'a.com', port: 80 }] } + }) + }) +}) + +describe('run', () => { + let command + let handleError + let rtLib + let sandbox + + beforeEach(async () => { + command = new TheCommand([]) + handleError = jest.spyOn(command, 'handleError').mockResolvedValue(undefined) + rtLib = await RuntimeLib.init({ apihost: 'fakehost', api_key: 'fakekey' }) + RuntimeLib.mockReset() + sandbox = fakeSandbox() + rtLib.mockResolved(rtCreate, sandbox) + sandbox.exec.mockResolvedValue({ stdout: 'v20.0.0\n', stderr: '', exitCode: 0 }) + readline.createInterface.mockReturnValue(makeRl(['exit'])) + }) + + test('creates a sandbox with default flags and destroys on exit', async () => { + command.argv = [] + await command.run() + expect(rtLib.compute.sandbox.create).toHaveBeenCalledWith(expect.objectContaining({ + name: 'aio-sandbox', + workspace: 'workspace', + maxLifetime: 3600, + envs: {} + })) + // default-deny policy log + expect(stdout.output).toMatch('Network policy: default-deny') + expect(stdout.output).toMatch('Created: sandbox-123') + expect(stdout.output).toMatch('Sandbox destroyed.') + expect(sandbox.destroy).toHaveBeenCalled() + }) + + test('forwards --type, --size, --name, --max-lifetime', async () => { + command.argv = ['--type', 'cpu:nodejs', '--size', 'LARGE', '--name', 'mybox', '--max-lifetime', '600'] + await command.run() + expect(rtLib.compute.sandbox.create).toHaveBeenCalledWith(expect.objectContaining({ + name: 'mybox', + type: 'cpu:nodejs', + size: 'LARGE', + maxLifetime: 600 + })) + }) + + test('quit also destroys the sandbox', async () => { + readline.createInterface.mockReturnValue(makeRl(['quit'])) + command.argv = [] + await command.run() + expect(sandbox.destroy).toHaveBeenCalled() + }) + + test('passes --egress allow-all through to policy', async () => { + command.argv = ['--egress', 'allow-all'] + await command.run() + expect(rtLib.compute.sandbox.create).toHaveBeenCalledWith(expect.objectContaining({ + policy: { network: { egress: 'allow-all' } } + })) + expect(stdout.output).toMatch('Network policy: allow-all egress') + }) + + test('passes custom --egress rules through to policy and logs them', async () => { + command.argv = ['--egress', 'pypi.org:443', '--egress', 'dns.google:53:UDP', '--egress', 'api.github.com:443|GET:/repos/**'] + await command.run() + expect(rtLib.compute.sandbox.create).toHaveBeenCalledWith(expect.objectContaining({ + policy: { + network: { + egress: [ + { host: 'pypi.org', port: 443 }, + { host: 'dns.google', port: 53, protocol: 'UDP' }, + { host: 'api.github.com', port: 443, rules: [{ methods: ['GET'], pathPattern: '/repos/**' }] } + ] + } + } + })) + expect(stdout.output).toMatch('Network policy: custom egress') + expect(stdout.output).toMatch('pypi.org:443 (TCP)') + expect(stdout.output).toMatch('dns.google:53 (UDP)') + expect(stdout.output).toMatch('api.github.com:443 (TCP) GET:/repos/**') + }) + + test('rejects --egress allow-all combined with other rules', async () => { + command.argv = ['--egress', 'allow-all', '--egress', 'pypi.org:443'] + await command.run() + expect(handleError).toHaveBeenCalledWith('failed to run sandbox', expect.objectContaining({ + message: expect.stringMatching(/cannot be combined/) + })) + // create was never called + expect(rtLib.compute.sandbox.create).not.toHaveBeenCalled() + }) + + test('REPL: blank input is ignored, .help prints help, command runs exec', async () => { + readline.createInterface.mockReturnValue(makeRl(['', '.help', 'ls -la', 'exit'])) + sandbox.exec + .mockResolvedValueOnce({ stdout: 'v20.0.0\n', stderr: '', exitCode: 0 }) // probe + .mockResolvedValueOnce({ stdout: 'total 0\n', stderr: '', exitCode: 0 }) // ls + + command.argv = [] + await command.run() + + expect(sandbox.exec).toHaveBeenCalledWith('ls -la', { timeout: 30000 }) + expect(stdout.output).toMatch('How it works:') + expect(stdout.output).toMatch('total 0') + expect(stdout.output).toMatch('[exit: 0]') + }) + + test('REPL: command produces stderr', async () => { + readline.createInterface.mockReturnValue(makeRl(['cat missing', 'exit'])) + sandbox.exec + .mockResolvedValueOnce({ stdout: 'v20.0.0\n', stderr: '', exitCode: 0 }) + .mockResolvedValueOnce({ stdout: '', stderr: 'cat: missing: No such file\n', exitCode: 1 }) + + command.argv = [] + await command.run() + + expect(stderr.output).toMatch('cat: missing: No such file') + expect(stdout.output).toMatch('[exit: 1]') + }) + + test('REPL: exec errors are reported and do not break the loop', async () => { + readline.createInterface.mockReturnValue(makeRl(['boom', 'exit'])) + sandbox.exec + .mockResolvedValueOnce({ stdout: 'v20.0.0\n', stderr: '', exitCode: 0 }) + .mockRejectedValueOnce(new Error('exec failed')) + + command.argv = [] + await command.run() + + expect(stdout.output).toMatch('exec error: exec failed') + expect(sandbox.destroy).toHaveBeenCalled() + }) + + test('REPL: exec errors without .message stringify the thrown value', async () => { + readline.createInterface.mockReturnValue(makeRl(['boom', 'exit'])) + sandbox.exec + .mockResolvedValueOnce({ stdout: 'v20.0.0\n', stderr: '', exitCode: 0 }) + .mockRejectedValueOnce('plain string error') + + command.argv = [] + await command.run() + + expect(stdout.output).toMatch('exec error: plain string error') + }) + + test('REPL: here-string with double-quoted text strips quotes and sends stdin', async () => { + readline.createInterface.mockReturnValue(makeRl(['cat -n <<< "hello"', 'exit'])) + sandbox.exec + .mockResolvedValueOnce({ stdout: 'v20.0.0\n', stderr: '', exitCode: 0 }) + .mockResolvedValueOnce({ stdout: ' 1\thello\n', stderr: '', exitCode: 0 }) + + command.argv = [] + await command.run() + + expect(sandbox.exec).toHaveBeenLastCalledWith('cat -n', expect.objectContaining({ + stdin: 'hello\n', + timeout: 30000 + })) + expect(stdout.output).toMatch('') + expect(stdout.output).toMatch('') + }) + + test('REPL: here-string with single-quoted text strips quotes', async () => { + readline.createInterface.mockReturnValue(makeRl(["cat <<< 'world'", 'exit'])) + sandbox.exec + .mockResolvedValueOnce({ stdout: 'v20.0.0\n', stderr: '', exitCode: 0 }) + .mockResolvedValueOnce({ stdout: 'world\n', stderr: '', exitCode: 0 }) + + command.argv = [] + await command.run() + + expect(sandbox.exec).toHaveBeenLastCalledWith('cat', expect.objectContaining({ + stdin: 'world\n' + })) + }) + + test('REPL: here-string with unquoted text passes through verbatim', async () => { + readline.createInterface.mockReturnValue(makeRl(['wc -c <<< abc', 'exit'])) + sandbox.exec + .mockResolvedValueOnce({ stdout: 'v20.0.0\n', stderr: '', exitCode: 0 }) + .mockResolvedValueOnce({ stdout: '4\n', stderr: '', exitCode: 0 }) + + command.argv = [] + await command.run() + + expect(sandbox.exec).toHaveBeenLastCalledWith('wc -c', expect.objectContaining({ + stdin: 'abc\n' + })) + }) + + test('REPL: here-string with stderr output is included in block', async () => { + readline.createInterface.mockReturnValue(makeRl(['cat - <<< "x"', 'exit'])) + sandbox.exec + .mockResolvedValueOnce({ stdout: 'v20.0.0\n', stderr: '', exitCode: 0 }) + .mockResolvedValueOnce({ stdout: '', stderr: 'oops\n', exitCode: 1 }) + + command.argv = [] + await command.run() + + expect(stdout.output).toMatch('') + expect(stderr.output).toMatch('oops') + }) + + test('REPL: here-string with no output skips block', async () => { + readline.createInterface.mockReturnValue(makeRl(['true <<< "x"', 'exit'])) + sandbox.exec + .mockResolvedValueOnce({ stdout: 'v20.0.0\n', stderr: '', exitCode: 0 }) + .mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 }) + + command.argv = [] + await command.run() + + // the second exec produced no output, so no block this turn + const after = stdout.output.split('Sandbox ready.').slice(1).join('Sandbox ready.') + expect(after).not.toMatch('') + expect(after).toMatch('[exit: 0]') + }) + + test('handles probe with no stdout (exec result missing stdout)', async () => { + sandbox.exec.mockResolvedValueOnce({ stderr: '', exitCode: 0 }) + command.argv = [] + await command.run() + expect(stdout.output).toMatch('Node version:') + }) + + test('routes create errors through handleError and skips destroy', async () => { + rtLib.mockRejected(rtCreate, new Error('boom')) + command.argv = [] + await command.run() + expect(handleError).toHaveBeenCalledWith('failed to run sandbox', expect.objectContaining({ message: 'boom' })) + expect(sandbox.destroy).not.toHaveBeenCalled() + }) + + test('logs a message when destroy fails', async () => { + sandbox.destroy.mockRejectedValue(new Error('destroy failed')) + command.argv = [] + await command.run() + expect(stdout.output).toMatch('failed to destroy sandbox: destroy failed') + }) + + test('logs a stringified value when destroy rejects without .message', async () => { + sandbox.destroy.mockRejectedValue('plain reason') + command.argv = [] + await command.run() + expect(stdout.output).toMatch('failed to destroy sandbox: plain reason') + }) +}) From 0958c7259df8c3de5c674e91acbd2f501e5bf8e6 Mon Sep 17 00:00:00 2001 From: Michael Goberling Date: Mon, 4 May 2026 14:36:54 -0400 Subject: [PATCH 02/11] feat(sandbox): use oclifs built in help --- src/commands/runtime/sandbox/run.js | 38 ++++++++++------------- test/commands/runtime/sandbox/run.test.js | 17 ++++++++-- 2 files changed, 31 insertions(+), 24 deletions(-) diff --git a/src/commands/runtime/sandbox/run.js b/src/commands/runtime/sandbox/run.js index cd54b2a1..b2a44e41 100644 --- a/src/commands/runtime/sandbox/run.js +++ b/src/commands/runtime/sandbox/run.js @@ -164,10 +164,6 @@ class SandboxRun extends RuntimeBaseCommand { if (!trimmed) { continue } - if (trimmed === '.help') { - this._printHelp() - continue - } try { if (trimmed.includes(' <<< ')) { @@ -213,25 +209,18 @@ class SandboxRun extends RuntimeBaseCommand { this.log(`[exit: ${result.exitCode}]\n`) } - _printHelp () { - this.log('') - this.log('How it works:') - this.log(' Each command runs in a fresh process on the sandbox.') - this.log(' Shell state (working directory, exports) does not persist between commands.') - this.log(' To run multi-step workflows, chain commands: cd mydir && npm install') - this.log('') - this.log('Stdin:') - this.log(' command <<< "text" Send inline text as stdin') - this.log(' cat -n <<< "hello world"') - this.log('') - this.log('Other:') - this.log(' exit / quit Destroy sandbox and exit') - this.log(' .help Show this help') - this.log('') - } } -SandboxRun.description = 'Create a sandbox and run an interactive REPL against it' +SandboxRun.description = `Create a sandbox and run an interactive REPL against it. + +Each command you enter runs in a fresh process; shell state (working directory, +environment exports) does not persist between prompts. Chain commands to work +around this: cd mydir && npm install + +Send text to stdin with the here-string operator: + command <<< "text" + +Type exit or quit to destroy the sandbox and leave.` SandboxRun.flags = { ...RuntimeBaseCommand.flags, @@ -259,6 +248,13 @@ SandboxRun.flags = { }) } +SandboxRun.examples = [ + '<%= config.bin %> <%= command.id %>', + '<%= config.bin %> <%= command.id %> -e allow-all', + '<%= config.bin %> <%= command.id %> -e "pypi.org:443" -e "api.github.com:443|GET:/repos/**"', + '<%= config.bin %> <%= command.id %> --size LARGE --type cpu:nodejs' +] + SandboxRun.aliases = ['rt:sandbox:run'] // exposed for testing diff --git a/test/commands/runtime/sandbox/run.test.js b/test/commands/runtime/sandbox/run.test.js index cc6c3766..70154e22 100644 --- a/test/commands/runtime/sandbox/run.test.js +++ b/test/commands/runtime/sandbox/run.test.js @@ -74,6 +74,18 @@ test('aliases', async () => { expect(TheCommand.aliases.length).toBeGreaterThan(0) }) +test('examples', async () => { + expect(TheCommand.examples).toBeDefined() + expect(TheCommand.examples).toBeInstanceOf(Array) + expect(TheCommand.examples.length).toBeGreaterThan(0) +}) + +test('description includes REPL usage notes', async () => { + expect(TheCommand.description).toMatch(/fresh process/) + expect(TheCommand.description).toMatch(/<< { expect(typeof TheCommand.flags.name).toBe('object') expect(TheCommand.flags.name.default).toBe('aio-sandbox') @@ -270,8 +282,8 @@ describe('run', () => { expect(rtLib.compute.sandbox.create).not.toHaveBeenCalled() }) - test('REPL: blank input is ignored, .help prints help, command runs exec', async () => { - readline.createInterface.mockReturnValue(makeRl(['', '.help', 'ls -la', 'exit'])) + test('REPL: blank input is ignored and command runs exec', async () => { + readline.createInterface.mockReturnValue(makeRl(['', 'ls -la', 'exit'])) sandbox.exec .mockResolvedValueOnce({ stdout: 'v20.0.0\n', stderr: '', exitCode: 0 }) // probe .mockResolvedValueOnce({ stdout: 'total 0\n', stderr: '', exitCode: 0 }) // ls @@ -280,7 +292,6 @@ describe('run', () => { await command.run() expect(sandbox.exec).toHaveBeenCalledWith('ls -la', { timeout: 30000 }) - expect(stdout.output).toMatch('How it works:') expect(stdout.output).toMatch('total 0') expect(stdout.output).toMatch('[exit: 0]') }) From 2a86e77ac941b279ea47e853d6129c4ed4bf9ddf Mon Sep 17 00:00:00 2001 From: Michael Goberling Date: Tue, 2 Jun 2026 13:07:24 -0400 Subject: [PATCH 03/11] feat: remove type and size flags, not exposed currently --- src/commands/runtime/sandbox/run.js | 17 +---------------- 1 file changed, 1 insertion(+), 16 deletions(-) diff --git a/src/commands/runtime/sandbox/run.js b/src/commands/runtime/sandbox/run.js index b2a44e41..3ef3eb60 100644 --- a/src/commands/runtime/sandbox/run.js +++ b/src/commands/runtime/sandbox/run.js @@ -102,8 +102,6 @@ class SandboxRun extends RuntimeBaseCommand { this.log('\nCreating sandbox...') sandbox = await ow.compute.sandbox.create({ name: flags.name, - ...(flags.type && { type: flags.type }), - ...(flags.size && { size: flags.size }), workspace: 'workspace', maxLifetime: flags['max-lifetime'], envs: {}, @@ -113,9 +111,6 @@ class SandboxRun extends RuntimeBaseCommand { this._logPolicy(policy) - const probe = await sandbox.exec('node --version', { timeout: PROBE_TIMEOUT_MS }) - this.log(`Node version: ${(probe.stdout || '').trim()} | exit: ${probe.exitCode}`) - this.log('\nSandbox ready. Type ".help" for commands, or "exit" to destroy and quit.\n') rl = readline.createInterface({ input: process.stdin, output: process.stdout }) @@ -228,15 +223,6 @@ SandboxRun.flags = { description: 'sandbox name', default: 'aio-sandbox' }), - type: Flags.string({ - char: 't', - description: 'sandbox type (e.g. cpu:default, cpu:nodejs)' - }), - size: Flags.string({ - char: 's', - description: 'sandbox size', - options: ['SMALL', 'MEDIUM', 'LARGE', 'XLARGE'] - }), egress: Flags.string({ char: 'e', description: 'egress rule in host:port[:protocol][|METHOD:path] format, or "allow-all" (repeatable)', @@ -251,8 +237,7 @@ SandboxRun.flags = { SandboxRun.examples = [ '<%= config.bin %> <%= command.id %>', '<%= config.bin %> <%= command.id %> -e allow-all', - '<%= config.bin %> <%= command.id %> -e "pypi.org:443" -e "api.github.com:443|GET:/repos/**"', - '<%= config.bin %> <%= command.id %> --size LARGE --type cpu:nodejs' + '<%= config.bin %> <%= command.id %> -e "pypi.org:443" -e "api.github.com:443|GET:/repos/**"' ] SandboxRun.aliases = ['rt:sandbox:run'] From b34685109c17a94938d5df24536a2cae2a44fd24 Mon Sep 17 00:00:00 2001 From: Michael Goberling Date: Tue, 2 Jun 2026 13:56:17 -0400 Subject: [PATCH 04/11] feat: plumb in new sandbox lib, add interactive flag, and handle detached --- package.json | 3 +- src/commands/runtime/sandbox/run.js | 161 +++++++-------- src/sandbox-helpers.js | 137 +++++++++++++ test/__mocks__/@adobe/aio-lib-sandbox.js | 5 + test/commands/runtime/sandbox/run.test.js | 235 ++++++++++++++++------ 5 files changed, 399 insertions(+), 142 deletions(-) create mode 100644 src/sandbox-helpers.js create mode 100644 test/__mocks__/@adobe/aio-lib-sandbox.js diff --git a/package.json b/package.json index a4606536..4c1cd83c 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,8 @@ "@adobe/aio-lib-core-networking": "^5", "@adobe/aio-lib-env": "^3.0.1", "@adobe/aio-lib-ims": "^8.0.1", - "@adobe/aio-lib-runtime": "adobe/aio-lib-runtime#agent-sandboxes", + "@adobe/aio-lib-runtime": "^7.4.0", + "@adobe/aio-lib-sandbox": "0.1.0-alpha.4", "@oclif/core": "^4.0.0", "@types/jest": "^29.5.3", "chalk": "^4.1.2", diff --git a/src/commands/runtime/sandbox/run.js b/src/commands/runtime/sandbox/run.js index 3ef3eb60..b8f2dd4b 100644 --- a/src/commands/runtime/sandbox/run.js +++ b/src/commands/runtime/sandbox/run.js @@ -11,98 +11,51 @@ governing permissions and limitations under the License. */ const readline = require('node:readline') +const { Sandbox } = require('@adobe/aio-lib-sandbox') const { Flags } = require('@oclif/core') const RuntimeBaseCommand = require('../../../RuntimeBaseCommand') +const { + buildNetworkPolicy, + buildSandboxCommand, + parseEgressFlags, + splitArgvAtDoubleDash +} = require('../../../sandbox-helpers') -const VALID_HTTP_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'] const EXEC_TIMEOUT_MS = 30000 -const PROBE_TIMEOUT_MS = 10000 /** - * Parse a list of `--egress` flag values into the `network.egress` rule array. - * Throws on malformed input. The caller is responsible for handling the - * `allow-all` shorthand separately. + * Write live command output to the matching local stream. * - * @param {string[]} egressArgs raw flag values - * @returns {Array} parsed egress rules + * @param {string|Buffer} data output chunk + * @param {string} stream stream name from the sandbox SDK */ -function parseEgressFlags (egressArgs) { - return egressArgs.map(arg => { - // Split on | to separate L4 (host:port[:protocol]) from optional L7 (METHOD[,METHOD]:path) - const pipeIdx = arg.indexOf('|') - const l4Part = pipeIdx === -1 ? arg : arg.slice(0, pipeIdx) - const l7Part = pipeIdx === -1 ? null : arg.slice(pipeIdx + 1) - - const parts = l4Part.split(':') - if (parts.length < 2 || parts.length > 3) { - throw new Error(`Invalid egress format: "${arg}". Expected host:port[:protocol][|METHOD:path]`) - } - const port = parseInt(parts[1], 10) - if (Number.isNaN(port) || port < 1 || port > 65535) { - throw new Error(`Invalid port in egress rule: "${arg}". Port must be 1-65535`) - } - const rule = { host: parts[0], port } - if (parts[2]) { - const proto = parts[2].toUpperCase() - if (proto !== 'TCP' && proto !== 'UDP') { - throw new Error(`Invalid protocol in egress rule: "${arg}". Must be TCP or UDP`) - } - rule.protocol = proto - } - - if (l7Part) { - const colonIdx = l7Part.indexOf(':') - if (colonIdx === -1 || !l7Part.slice(colonIdx + 1).startsWith('/')) { - throw new Error(`Invalid L7 rule: "${arg}". Expected METHOD[,METHOD]:/ after |`) - } - const methods = l7Part.slice(0, colonIdx).split(',').map(m => m.trim().toUpperCase()) - const pathPattern = l7Part.slice(colonIdx + 1) - for (const method of methods) { - if (!VALID_HTTP_METHODS.includes(method)) { - throw new Error(`Invalid HTTP method "${method}" in "${arg}". Must be one of: ${VALID_HTTP_METHODS.join(', ')}`) - } - } - rule.rules = [{ methods, pathPattern }] - } - - return rule - }) -} - -/** - * Build the sandbox `policy` object from `--egress` flag values, or return - * `undefined` if no egress flags were provided. - * - * @param {string[]} [egressArgs] raw `--egress` flag values - * @returns {object|undefined} sandbox policy - */ -function buildPolicy (egressArgs) { - if (!egressArgs || egressArgs.length === 0) { - return undefined - } - if (egressArgs.length === 1 && egressArgs[0] === 'allow-all') { - return { network: { egress: 'allow-all' } } - } - if (egressArgs.includes('allow-all')) { - throw new Error('allow-all cannot be combined with other egress rules.') - } - return { network: { egress: parseEgressFlags(egressArgs) } } +function streamOutput (data, stream) { + const sink = stream === 'stderr' ? process.stderr : process.stdout + sink.write(data) } class SandboxRun extends RuntimeBaseCommand { async run () { - const { flags } = await this.parse(SandboxRun) + const { cliArgs, commandArgs, hasSeparator } = splitArgvAtDoubleDash(this.argv) + const { flags } = await this.parse(SandboxRun, cliArgs) let sandbox let rl try { - const policy = buildPolicy(flags.egress) - const ow = await this.wsk() + if (commandArgs.length === 0 && !flags.interactive && hasSeparator) { + throw new Error('Missing command after --. Use --interactive for an interactive session.') + } + + const policy = buildNetworkPolicy(flags.egress) + const options = await this.getOptions() + const command = buildSandboxCommand(commandArgs) this.log('\nCreating sandbox...') - sandbox = await ow.compute.sandbox.create({ + sandbox = await Sandbox.create({ + apiHost: options.apihost, + namespace: options.namespace, + auth: options.api_key, name: flags.name, - workspace: 'workspace', maxLifetime: flags['max-lifetime'], envs: {}, ...(policy && { policy }) @@ -111,10 +64,16 @@ class SandboxRun extends RuntimeBaseCommand { this._logPolicy(policy) - this.log('\nSandbox ready. Type ".help" for commands, or "exit" to destroy and quit.\n') + if (command) { + await this._runOnce(sandbox, command) + } + + if (flags.interactive || !command) { + this.log('\nSandbox ready. Type "exit" to destroy and quit.\n') - rl = readline.createInterface({ input: process.stdin, output: process.stdout }) - await this._repl(rl, sandbox) + rl = readline.createInterface({ input: process.stdin, output: process.stdout }) + await this._repl(rl, sandbox) + } } catch (err) { await this.handleError('failed to run sandbox', err) } finally { @@ -161,7 +120,9 @@ class SandboxRun extends RuntimeBaseCommand { } try { - if (trimmed.includes(' <<< ')) { + if (trimmed.startsWith('.detached')) { + await this._handleDetached(sandbox, trimmed) + } else if (trimmed.includes(' <<< ')) { await this._handleHereString(sandbox, trimmed) } else { await this._handleExec(sandbox, trimmed) @@ -183,6 +144,26 @@ class SandboxRun extends RuntimeBaseCommand { this.log(`[exit: ${result.exitCode}]`) } + async _handleDetached (sandbox, input) { + const commandText = input.slice('.detached'.length).trim() + if (!commandText) { + this.log('Usage: .detached ') + return + } + + const command = await sandbox.exec(commandText, { detached: true, onOutput: streamOutput }) + this.log(`[detached: ${command.execId} pid: ${command.pid || 'unknown'}]`) + } + + async _runOnce (sandbox, cmd) { + const result = await sandbox.exec(cmd, { timeout: EXEC_TIMEOUT_MS }) + if (result.stdout) process.stdout.write(result.stdout) + if (result.stderr) process.stderr.write(result.stderr) + if (result.exitCode) { + process.exitCode = result.exitCode + } + } + async _handleHereString (sandbox, input) { const idx = input.indexOf(' <<< ') const command = input.slice(0, idx).trim() @@ -203,19 +184,26 @@ class SandboxRun extends RuntimeBaseCommand { } this.log(`[exit: ${result.exitCode}]\n`) } - } -SandboxRun.description = `Create a sandbox and run an interactive REPL against it. +SandboxRun.description = `Create a sandbox and run a command against it. + +Pass -- to run one command, print its output, destroy the sandbox, +and exit with the command's status. + +Use --interactive, or omit -- , to enter a REPL. When --interactive +is combined with -- , the command runs before the REPL starts. Each command you enter runs in a fresh process; shell state (working directory, environment exports) does not persist between prompts. Chain commands to work around this: cd mydir && npm install -Send text to stdin with the here-string operator: +During interactive sessions: +- Send text to stdin with the here-string operator: command <<< "text" - -Type exit or quit to destroy the sandbox and leave.` +- Start a background command and stream its output with: + .detached +- Type exit or quit to destroy the sandbox and leave.` SandboxRun.flags = { ...RuntimeBaseCommand.flags, @@ -231,11 +219,16 @@ SandboxRun.flags = { 'max-lifetime': Flags.integer({ description: 'maximum sandbox lifetime in seconds', default: 3600 + }), + interactive: Flags.boolean({ + description: 'enter an interactive command loop instead of running a one-shot command' }) } SandboxRun.examples = [ '<%= config.bin %> <%= command.id %>', + '<%= config.bin %> <%= command.id %> --interactive', + '<%= config.bin %> <%= command.id %> -- node --version', '<%= config.bin %> <%= command.id %> -e allow-all', '<%= config.bin %> <%= command.id %> -e "pypi.org:443" -e "api.github.com:443|GET:/repos/**"' ] @@ -244,6 +237,8 @@ SandboxRun.aliases = ['rt:sandbox:run'] // exposed for testing SandboxRun.parseEgressFlags = parseEgressFlags -SandboxRun.buildPolicy = buildPolicy +SandboxRun.buildNetworkPolicy = buildNetworkPolicy +SandboxRun.splitArgvAtDoubleDash = splitArgvAtDoubleDash +SandboxRun.buildSandboxCommand = buildSandboxCommand module.exports = SandboxRun diff --git a/src/sandbox-helpers.js b/src/sandbox-helpers.js new file mode 100644 index 00000000..2a7fef4e --- /dev/null +++ b/src/sandbox-helpers.js @@ -0,0 +1,137 @@ +/* +Copyright 2026 Adobe Inc. 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 VALID_HTTP_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'] + +/** + * Split oclif arguments at `--`. Everything after it belongs to the sandbox + * command and must not be parsed as aio CLI flags. + * + * @param {string[]} argv raw command argv + * @returns {{cliArgs: string[], commandArgs: string[], hasSeparator: boolean}} split argv + */ +function splitArgvAtDoubleDash (argv) { + const separatorIndex = argv.indexOf('--') + if (separatorIndex === -1) { + return { cliArgs: argv, commandArgs: [], hasSeparator: false } + } + return { + cliArgs: argv.slice(0, separatorIndex), + commandArgs: argv.slice(separatorIndex + 1), + hasSeparator: true + } +} + +/** + * Quote argv tokens so the remote shell receives the same argument boundaries + * the local shell passed after `--`. + * + * @param {string} arg command argument + * @returns {string} shell-safe argument + */ +function shellQuote (arg) { + if (/^[A-Za-z0-9_./:=@%+,-]+$/.test(arg)) { + return arg + } + return `'${arg.replace(/'/g, "'\\''")}'` +} + +/** + * Convert args after `--` to a command string for the sandbox executor. + * + * @param {string[]} commandArgs raw command args + * @returns {string} command string + */ +function buildSandboxCommand (commandArgs) { + if (commandArgs.length === 1) { + return commandArgs[0] + } + return commandArgs.map(shellQuote).join(' ') +} + +/** + * Parse a list of `--egress` flag values into the `network.egress` rule array. + * Throws on malformed input. The caller is responsible for handling the + * `allow-all` shorthand separately. + * + * @param {string[]} egressArgs raw flag values + * @returns {Array} parsed egress rules + */ +function parseEgressFlags (egressArgs) { + return egressArgs.map(arg => { + // Split on | to separate L4 (host:port[:protocol]) from optional L7 (METHOD[,METHOD]:path) + const pipeIdx = arg.indexOf('|') + const l4Part = pipeIdx === -1 ? arg : arg.slice(0, pipeIdx) + const l7Part = pipeIdx === -1 ? null : arg.slice(pipeIdx + 1) + + const parts = l4Part.split(':') + if (parts.length < 2 || parts.length > 3) { + throw new Error(`Invalid egress format: "${arg}". Expected host:port[:protocol][|METHOD:path]`) + } + const port = parseInt(parts[1], 10) + if (Number.isNaN(port) || port < 1 || port > 65535) { + throw new Error(`Invalid port in egress rule: "${arg}". Port must be 1-65535`) + } + const rule = { host: parts[0], port } + if (parts[2]) { + const proto = parts[2].toUpperCase() + if (proto !== 'TCP' && proto !== 'UDP') { + throw new Error(`Invalid protocol in egress rule: "${arg}". Must be TCP or UDP`) + } + rule.protocol = proto + } + + if (l7Part) { + const colonIdx = l7Part.indexOf(':') + if (colonIdx === -1 || !l7Part.slice(colonIdx + 1).startsWith('/')) { + throw new Error(`Invalid L7 rule: "${arg}". Expected METHOD[,METHOD]:/ after |`) + } + const methods = l7Part.slice(0, colonIdx).split(',').map(m => m.trim().toUpperCase()) + const pathPattern = l7Part.slice(colonIdx + 1) + for (const method of methods) { + if (!VALID_HTTP_METHODS.includes(method)) { + throw new Error(`Invalid HTTP method "${method}" in "${arg}". Must be one of: ${VALID_HTTP_METHODS.join(', ')}`) + } + } + rule.rules = [{ methods, pathPattern }] + } + + return rule + }) +} + +/** + * Build the sandbox `policy` object from `--egress` flag values, or return + * `undefined` if no egress flags were provided. + * + * @param {string[]} [egressArgs] raw `--egress` flag values + * @returns {object|undefined} sandbox policy + */ +function buildNetworkPolicy (egressArgs) { + if (!egressArgs || egressArgs.length === 0) { + return undefined + } + if (egressArgs.length === 1 && egressArgs[0] === 'allow-all') { + return { network: { egress: 'allow-all' } } + } + if (egressArgs.includes('allow-all')) { + throw new Error('allow-all cannot be combined with other egress rules.') + } + return { network: { egress: parseEgressFlags(egressArgs) } } +} + +module.exports = { + buildNetworkPolicy, + buildSandboxCommand, + parseEgressFlags, + splitArgvAtDoubleDash +} diff --git a/test/__mocks__/@adobe/aio-lib-sandbox.js b/test/__mocks__/@adobe/aio-lib-sandbox.js new file mode 100644 index 00000000..ac2178d3 --- /dev/null +++ b/test/__mocks__/@adobe/aio-lib-sandbox.js @@ -0,0 +1,5 @@ +module.exports = { + Sandbox: { + create: jest.fn() + } +} diff --git a/test/commands/runtime/sandbox/run.test.js b/test/commands/runtime/sandbox/run.test.js index 70154e22..6b3e044d 100644 --- a/test/commands/runtime/sandbox/run.test.js +++ b/test/commands/runtime/sandbox/run.test.js @@ -18,13 +18,11 @@ beforeEach(() => stderr.start()) afterEach(() => stderr.stop()) const TheCommand = require('../../../../src/commands/runtime/sandbox/run.js') const RuntimeBaseCommand = require('../../../../src/RuntimeBaseCommand.js') -const RuntimeLib = require('@adobe/aio-lib-runtime') - -const rtCreate = 'compute.sandbox.create' +const { Sandbox } = require('@adobe/aio-lib-sandbox') /** - * Build a fake `Sandbox` object suitable for stubbing `compute.sandbox.create` - * resolutions from the mocked aio-lib-runtime. + * Build a fake `Sandbox` object suitable for stubbing Sandbox.create + * resolutions from the mocked aio-lib-sandbox. * * @param {object} [overrides] override individual fields * @returns {object} fake sandbox @@ -84,22 +82,56 @@ test('description includes REPL usage notes', async () => { expect(TheCommand.description).toMatch(/fresh process/) expect(TheCommand.description).toMatch(/<</) + expect(TheCommand.description).toMatch(/\.detached /) }) test('flags', async () => { expect(typeof TheCommand.flags.name).toBe('object') expect(TheCommand.flags.name.default).toBe('aio-sandbox') - expect(TheCommand.flags.type.char).toBe('t') - expect(TheCommand.flags.size.char).toBe('s') - expect(TheCommand.flags.size.options).toEqual(['SMALL', 'MEDIUM', 'LARGE', 'XLARGE']) + expect(TheCommand.flags.type).toBeUndefined() + expect(TheCommand.flags.size).toBeUndefined() expect(TheCommand.flags.egress.char).toBe('e') expect(TheCommand.flags.egress.multiple).toBe(true) expect(TheCommand.flags['max-lifetime'].default).toBe(3600) + expect(TheCommand.flags.interactive).toBeDefined() // inherits base flags expect(TheCommand.flags.apihost).toBeDefined() expect(TheCommand.flags.auth).toBeDefined() }) +describe('splitArgvAtDoubleDash', () => { + test('returns all argv as cli args when there is no separator', () => { + expect(TheCommand.splitArgvAtDoubleDash(['--name', 'box'])).toEqual({ + cliArgs: ['--name', 'box'], + commandArgs: [], + hasSeparator: false + }) + }) + + test('splits CLI args from one-shot command args', () => { + expect(TheCommand.splitArgvAtDoubleDash(['--name', 'box', '--', 'node', '--version'])).toEqual({ + cliArgs: ['--name', 'box'], + commandArgs: ['node', '--version'], + hasSeparator: true + }) + }) +}) + +describe('buildSandboxCommand', () => { + test('joins safe command args', () => { + expect(TheCommand.buildSandboxCommand(['node', '--version'])).toBe('node --version') + }) + + test('preserves a single command string as-is', () => { + expect(TheCommand.buildSandboxCommand(['npm test -- --watch'])).toBe('npm test -- --watch') + }) + + test('quotes command args that contain shell-sensitive characters', () => { + expect(TheCommand.buildSandboxCommand(['node', '-e', 'console.log("hello world")'])).toBe('node -e \'console.log("hello world")\'') + }) +}) + describe('parseEgressFlags', () => { test('parses single L4 rule', () => { expect(TheCommand.parseEgressFlags(['pypi.org:443'])).toEqual([ @@ -171,22 +203,22 @@ describe('parseEgressFlags', () => { }) }) -describe('buildPolicy', () => { +describe('buildNetworkPolicy', () => { test('returns undefined when no egress flags', () => { - expect(TheCommand.buildPolicy(undefined)).toBeUndefined() - expect(TheCommand.buildPolicy([])).toBeUndefined() + expect(TheCommand.buildNetworkPolicy(undefined)).toBeUndefined() + expect(TheCommand.buildNetworkPolicy([])).toBeUndefined() }) test('returns allow-all policy when sole value is allow-all', () => { - expect(TheCommand.buildPolicy(['allow-all'])).toEqual({ network: { egress: 'allow-all' } }) + expect(TheCommand.buildNetworkPolicy(['allow-all'])).toEqual({ network: { egress: 'allow-all' } }) }) test('throws when allow-all is mixed with other rules', () => { - expect(() => TheCommand.buildPolicy(['allow-all', 'a.com:80'])).toThrow(/cannot be combined/) + expect(() => TheCommand.buildNetworkPolicy(['allow-all', 'a.com:80'])).toThrow(/cannot be combined/) }) test('returns parsed egress rules', () => { - expect(TheCommand.buildPolicy(['a.com:80'])).toEqual({ + expect(TheCommand.buildNetworkPolicy(['a.com:80'])).toEqual({ network: { egress: [{ host: 'a.com', port: 80 }] } }) }) @@ -195,26 +227,27 @@ describe('buildPolicy', () => { describe('run', () => { let command let handleError - let rtLib let sandbox beforeEach(async () => { command = new TheCommand([]) handleError = jest.spyOn(command, 'handleError').mockResolvedValue(undefined) - rtLib = await RuntimeLib.init({ apihost: 'fakehost', api_key: 'fakekey' }) - RuntimeLib.mockReset() sandbox = fakeSandbox() - rtLib.mockResolved(rtCreate, sandbox) + Sandbox.create.mockReset() + Sandbox.create.mockResolvedValue(sandbox) sandbox.exec.mockResolvedValue({ stdout: 'v20.0.0\n', stderr: '', exitCode: 0 }) + readline.createInterface.mockClear() readline.createInterface.mockReturnValue(makeRl(['exit'])) }) test('creates a sandbox with default flags and destroys on exit', async () => { command.argv = [] await command.run() - expect(rtLib.compute.sandbox.create).toHaveBeenCalledWith(expect.objectContaining({ + expect(Sandbox.create).toHaveBeenCalledWith(expect.objectContaining({ + apiHost: 'some.host', + namespace: 'some_namespace', + auth: 'some-gibberish-not-a-real-key', name: 'aio-sandbox', - workspace: 'workspace', maxLifetime: 3600, envs: {} })) @@ -225,15 +258,77 @@ describe('run', () => { expect(sandbox.destroy).toHaveBeenCalled() }) - test('forwards --type, --size, --name, --max-lifetime', async () => { - command.argv = ['--type', 'cpu:nodejs', '--size', 'LARGE', '--name', 'mybox', '--max-lifetime', '600'] + test('runs command after -- once, prints output, and destroys sandbox', async () => { + command.argv = ['--', 'node', '--version'] + await command.run() + + expect(readline.createInterface).not.toHaveBeenCalled() + expect(sandbox.exec).toHaveBeenCalledWith('node --version', { timeout: 30000 }) + expect(stdout.output).toMatch('v20.0.0') + expect(sandbox.destroy).toHaveBeenCalled() + }) + + test('one-shot command preserves argument boundaries', async () => { + command.argv = ['--', 'node', '-e', 'console.log("hello world")'] + await command.run() + + expect(sandbox.exec).toHaveBeenCalledWith('node -e \'console.log("hello world")\'', { timeout: 30000 }) + }) + + test('one-shot command writes stderr and sets process exitCode', async () => { + const previousExitCode = process.exitCode + sandbox.exec.mockResolvedValue({ stdout: '', stderr: 'boom\n', exitCode: 7 }) + + command.argv = ['--', 'false'] + await command.run() + + expect(stderr.output).toMatch('boom') + expect(process.exitCode).toBe(7) + process.exitCode = previousExitCode + }) + + test('--interactive keeps the REPL behavior', async () => { + readline.createInterface.mockReturnValue(makeRl(['exit'])) + command.argv = ['--interactive'] + await command.run() + + expect(readline.createInterface).toHaveBeenCalled() + expect(sandbox.exec).not.toHaveBeenCalled() + expect(stdout.output).toMatch('Sandbox ready.') + }) + + test('--interactive with a command runs the command before entering the REPL', async () => { + readline.createInterface.mockReturnValue(makeRl(['exit'])) + command.argv = ['--interactive', '--', 'node', '--version'] + await command.run() + + expect(sandbox.exec).toHaveBeenCalledWith('node --version', { timeout: 30000 }) + expect(readline.createInterface).toHaveBeenCalled() + expect(stdout.output.indexOf('v20.0.0')).toBeLessThan(stdout.output.indexOf('Sandbox ready.')) + expect(sandbox.destroy).toHaveBeenCalled() + }) + + test('rejects bare -- without a command unless interactive is requested', async () => { + command.argv = ['--'] await command.run() - expect(rtLib.compute.sandbox.create).toHaveBeenCalledWith(expect.objectContaining({ + + expect(handleError).toHaveBeenCalledWith('failed to run sandbox', expect.objectContaining({ + message: expect.stringMatching(/Missing command/) + })) + expect(Sandbox.create).not.toHaveBeenCalled() + }) + + test('forwards --name and --max-lifetime', async () => { + command.argv = ['--name', 'mybox', '--max-lifetime', '600'] + await command.run() + expect(Sandbox.create).toHaveBeenCalledWith(expect.objectContaining({ name: 'mybox', - type: 'cpu:nodejs', - size: 'LARGE', maxLifetime: 600 })) + expect(Sandbox.create).toHaveBeenCalledWith(expect.not.objectContaining({ + type: expect.anything(), + size: expect.anything() + })) }) test('quit also destroys the sandbox', async () => { @@ -246,7 +341,7 @@ describe('run', () => { test('passes --egress allow-all through to policy', async () => { command.argv = ['--egress', 'allow-all'] await command.run() - expect(rtLib.compute.sandbox.create).toHaveBeenCalledWith(expect.objectContaining({ + expect(Sandbox.create).toHaveBeenCalledWith(expect.objectContaining({ policy: { network: { egress: 'allow-all' } } })) expect(stdout.output).toMatch('Network policy: allow-all egress') @@ -255,7 +350,7 @@ describe('run', () => { test('passes custom --egress rules through to policy and logs them', async () => { command.argv = ['--egress', 'pypi.org:443', '--egress', 'dns.google:53:UDP', '--egress', 'api.github.com:443|GET:/repos/**'] await command.run() - expect(rtLib.compute.sandbox.create).toHaveBeenCalledWith(expect.objectContaining({ + expect(Sandbox.create).toHaveBeenCalledWith(expect.objectContaining({ policy: { network: { egress: [ @@ -279,14 +374,12 @@ describe('run', () => { message: expect.stringMatching(/cannot be combined/) })) // create was never called - expect(rtLib.compute.sandbox.create).not.toHaveBeenCalled() + expect(Sandbox.create).not.toHaveBeenCalled() }) test('REPL: blank input is ignored and command runs exec', async () => { readline.createInterface.mockReturnValue(makeRl(['', 'ls -la', 'exit'])) - sandbox.exec - .mockResolvedValueOnce({ stdout: 'v20.0.0\n', stderr: '', exitCode: 0 }) // probe - .mockResolvedValueOnce({ stdout: 'total 0\n', stderr: '', exitCode: 0 }) // ls + sandbox.exec.mockResolvedValueOnce({ stdout: 'total 0\n', stderr: '', exitCode: 0 }) command.argv = [] await command.run() @@ -298,9 +391,7 @@ describe('run', () => { test('REPL: command produces stderr', async () => { readline.createInterface.mockReturnValue(makeRl(['cat missing', 'exit'])) - sandbox.exec - .mockResolvedValueOnce({ stdout: 'v20.0.0\n', stderr: '', exitCode: 0 }) - .mockResolvedValueOnce({ stdout: '', stderr: 'cat: missing: No such file\n', exitCode: 1 }) + sandbox.exec.mockResolvedValueOnce({ stdout: '', stderr: 'cat: missing: No such file\n', exitCode: 1 }) command.argv = [] await command.run() @@ -311,9 +402,7 @@ describe('run', () => { test('REPL: exec errors are reported and do not break the loop', async () => { readline.createInterface.mockReturnValue(makeRl(['boom', 'exit'])) - sandbox.exec - .mockResolvedValueOnce({ stdout: 'v20.0.0\n', stderr: '', exitCode: 0 }) - .mockRejectedValueOnce(new Error('exec failed')) + sandbox.exec.mockRejectedValueOnce(new Error('exec failed')) command.argv = [] await command.run() @@ -324,9 +413,7 @@ describe('run', () => { test('REPL: exec errors without .message stringify the thrown value', async () => { readline.createInterface.mockReturnValue(makeRl(['boom', 'exit'])) - sandbox.exec - .mockResolvedValueOnce({ stdout: 'v20.0.0\n', stderr: '', exitCode: 0 }) - .mockRejectedValueOnce('plain string error') + sandbox.exec.mockRejectedValueOnce('plain string error') command.argv = [] await command.run() @@ -334,11 +421,50 @@ describe('run', () => { expect(stdout.output).toMatch('exec error: plain string error') }) + test('REPL: detached command starts in background and streams output', async () => { + readline.createInterface.mockReturnValue(makeRl(['.detached npm run dev', 'exit'])) + sandbox.exec.mockImplementationOnce(async (cmd, options) => { + options.onOutput('server ready\n', 'stdout') + options.onOutput('debug line\n', 'stderr') + return { execId: 'exec-abc', pid: 1234, detached: true } + }) + + command.argv = [] + await command.run() + + expect(sandbox.exec).toHaveBeenCalledWith('npm run dev', expect.objectContaining({ + detached: true, + onOutput: expect.any(Function) + })) + expect(stdout.output).toMatch('server ready') + expect(stderr.output).toMatch('debug line') + expect(stdout.output).toMatch('[detached: exec-abc pid: 1234]') + expect(stdout.output).not.toMatch('[exit:') + }) + + test('REPL: detached command without a command prints usage', async () => { + readline.createInterface.mockReturnValue(makeRl(['.detached', 'exit'])) + + command.argv = [] + await command.run() + + expect(sandbox.exec).not.toHaveBeenCalled() + expect(stdout.output).toMatch('Usage: .detached ') + }) + + test('REPL: detached command handles missing pid', async () => { + readline.createInterface.mockReturnValue(makeRl(['.detached npm run dev', 'exit'])) + sandbox.exec.mockResolvedValueOnce({ execId: 'exec-no-pid', detached: true }) + + command.argv = [] + await command.run() + + expect(stdout.output).toMatch('[detached: exec-no-pid pid: unknown]') + }) + test('REPL: here-string with double-quoted text strips quotes and sends stdin', async () => { readline.createInterface.mockReturnValue(makeRl(['cat -n <<< "hello"', 'exit'])) - sandbox.exec - .mockResolvedValueOnce({ stdout: 'v20.0.0\n', stderr: '', exitCode: 0 }) - .mockResolvedValueOnce({ stdout: ' 1\thello\n', stderr: '', exitCode: 0 }) + sandbox.exec.mockResolvedValueOnce({ stdout: ' 1\thello\n', stderr: '', exitCode: 0 }) command.argv = [] await command.run() @@ -353,9 +479,7 @@ describe('run', () => { test('REPL: here-string with single-quoted text strips quotes', async () => { readline.createInterface.mockReturnValue(makeRl(["cat <<< 'world'", 'exit'])) - sandbox.exec - .mockResolvedValueOnce({ stdout: 'v20.0.0\n', stderr: '', exitCode: 0 }) - .mockResolvedValueOnce({ stdout: 'world\n', stderr: '', exitCode: 0 }) + sandbox.exec.mockResolvedValueOnce({ stdout: 'world\n', stderr: '', exitCode: 0 }) command.argv = [] await command.run() @@ -367,9 +491,7 @@ describe('run', () => { test('REPL: here-string with unquoted text passes through verbatim', async () => { readline.createInterface.mockReturnValue(makeRl(['wc -c <<< abc', 'exit'])) - sandbox.exec - .mockResolvedValueOnce({ stdout: 'v20.0.0\n', stderr: '', exitCode: 0 }) - .mockResolvedValueOnce({ stdout: '4\n', stderr: '', exitCode: 0 }) + sandbox.exec.mockResolvedValueOnce({ stdout: '4\n', stderr: '', exitCode: 0 }) command.argv = [] await command.run() @@ -381,9 +503,7 @@ describe('run', () => { test('REPL: here-string with stderr output is included in block', async () => { readline.createInterface.mockReturnValue(makeRl(['cat - <<< "x"', 'exit'])) - sandbox.exec - .mockResolvedValueOnce({ stdout: 'v20.0.0\n', stderr: '', exitCode: 0 }) - .mockResolvedValueOnce({ stdout: '', stderr: 'oops\n', exitCode: 1 }) + sandbox.exec.mockResolvedValueOnce({ stdout: '', stderr: 'oops\n', exitCode: 1 }) command.argv = [] await command.run() @@ -394,9 +514,7 @@ describe('run', () => { test('REPL: here-string with no output skips block', async () => { readline.createInterface.mockReturnValue(makeRl(['true <<< "x"', 'exit'])) - sandbox.exec - .mockResolvedValueOnce({ stdout: 'v20.0.0\n', stderr: '', exitCode: 0 }) - .mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 }) + sandbox.exec.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 }) command.argv = [] await command.run() @@ -407,15 +525,16 @@ describe('run', () => { expect(after).toMatch('[exit: 0]') }) - test('handles probe with no stdout (exec result missing stdout)', async () => { + test('REPL: command with no stdout still logs exit status', async () => { + readline.createInterface.mockReturnValue(makeRl(['true', 'exit'])) sandbox.exec.mockResolvedValueOnce({ stderr: '', exitCode: 0 }) command.argv = [] await command.run() - expect(stdout.output).toMatch('Node version:') + expect(stdout.output).toMatch('[exit: 0]') }) test('routes create errors through handleError and skips destroy', async () => { - rtLib.mockRejected(rtCreate, new Error('boom')) + Sandbox.create.mockRejectedValue(new Error('boom')) command.argv = [] await command.run() expect(handleError).toHaveBeenCalledWith('failed to run sandbox', expect.objectContaining({ message: 'boom' })) From 2af7a0d2ad66aed18f01ee7c1e1eaf24cba054f0 Mon Sep 17 00:00:00 2001 From: Michael Goberling Date: Tue, 2 Jun 2026 14:08:03 -0400 Subject: [PATCH 05/11] feat: add support for preview ports --- src/commands/runtime/sandbox/run.js | 22 ++++++++++ src/sandbox-helpers.js | 33 +++++++++++++++ test/commands/runtime/sandbox/run.test.js | 50 +++++++++++++++++++++++ 3 files changed, 105 insertions(+) diff --git a/src/commands/runtime/sandbox/run.js b/src/commands/runtime/sandbox/run.js index b8f2dd4b..7a40bb0e 100644 --- a/src/commands/runtime/sandbox/run.js +++ b/src/commands/runtime/sandbox/run.js @@ -17,6 +17,7 @@ const RuntimeBaseCommand = require('../../../RuntimeBaseCommand') const { buildNetworkPolicy, buildSandboxCommand, + parsePortFlags, parseEgressFlags, splitArgvAtDoubleDash } = require('../../../sandbox-helpers') @@ -47,6 +48,7 @@ class SandboxRun extends RuntimeBaseCommand { } const policy = buildNetworkPolicy(flags.egress) + const ports = parsePortFlags(flags.port) const options = await this.getOptions() const command = buildSandboxCommand(commandArgs) @@ -58,11 +60,13 @@ class SandboxRun extends RuntimeBaseCommand { name: flags.name, maxLifetime: flags['max-lifetime'], envs: {}, + ...(ports && { ports }), ...(policy && { policy }) }) this.log(`Created: ${sandbox.id}`) this._logPolicy(policy) + this._logPreviewUrls(sandbox, ports) if (command) { await this._runOnce(sandbox, command) @@ -108,6 +112,17 @@ class SandboxRun extends RuntimeBaseCommand { }) } + _logPreviewUrls (sandbox, ports) { + if (!ports) { + return + } + + this.log('Preview URLs:') + ports.forEach(port => { + this.log(` - ${port}: ${sandbox.getUrl(port)}`) + }) + } + async _repl (rl, sandbox) { while (true) { const cmd = await this._ask(rl, 'Enter command to run on sandbox: ') @@ -216,6 +231,11 @@ SandboxRun.flags = { description: 'egress rule in host:port[:protocol][|METHOD:path] format, or "allow-all" (repeatable)', multiple: true }), + port: Flags.string({ + char: 'p', + description: 'Port to expose via a preview URL (repeatable)', + multiple: true + }), 'max-lifetime': Flags.integer({ description: 'maximum sandbox lifetime in seconds', default: 3600 @@ -229,6 +249,7 @@ SandboxRun.examples = [ '<%= config.bin %> <%= command.id %>', '<%= config.bin %> <%= command.id %> --interactive', '<%= config.bin %> <%= command.id %> -- node --version', + '<%= config.bin %> <%= command.id %> -p 3000 -p 8080 --interactive', '<%= config.bin %> <%= command.id %> -e allow-all', '<%= config.bin %> <%= command.id %> -e "pypi.org:443" -e "api.github.com:443|GET:/repos/**"' ] @@ -237,6 +258,7 @@ SandboxRun.aliases = ['rt:sandbox:run'] // exposed for testing SandboxRun.parseEgressFlags = parseEgressFlags +SandboxRun.parsePortFlags = parsePortFlags SandboxRun.buildNetworkPolicy = buildNetworkPolicy SandboxRun.splitArgvAtDoubleDash = splitArgvAtDoubleDash SandboxRun.buildSandboxCommand = buildSandboxCommand diff --git a/src/sandbox-helpers.js b/src/sandbox-helpers.js index 2a7fef4e..8323db2b 100644 --- a/src/sandbox-helpers.js +++ b/src/sandbox-helpers.js @@ -12,6 +12,25 @@ governing permissions and limitations under the License. const VALID_HTTP_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'] +/** + * Parse a sandbox preview URL port value. + * + * @param {string|number} value raw port value + * @returns {number} parsed port + */ +function parsePort (value) { + const raw = String(value) + if (!/^\d+$/.test(raw)) { + throw new Error(`Invalid port "${value}". Port must be an integer between 1 and 65535`) + } + + const port = Number(raw) + if (!Number.isInteger(port) || port < 1 || port > 65535) { + throw new Error(`Invalid port "${value}". Port must be an integer between 1 and 65535`) + } + return port +} + /** * Split oclif arguments at `--`. Everything after it belongs to the sandbox * command and must not be parsed as aio CLI flags. @@ -58,6 +77,19 @@ function buildSandboxCommand (commandArgs) { return commandArgs.map(shellQuote).join(' ') } +/** + * Parse repeatable `--port` flag values for sandbox preview URLs. + * + * @param {Array} [portArgs] raw port flag values + * @returns {number[]|undefined} parsed ports, or undefined when omitted + */ +function parsePortFlags (portArgs) { + if (!portArgs || portArgs.length === 0) { + return undefined + } + return portArgs.map(parsePort) +} + /** * Parse a list of `--egress` flag values into the `network.egress` rule array. * Throws on malformed input. The caller is responsible for handling the @@ -132,6 +164,7 @@ function buildNetworkPolicy (egressArgs) { module.exports = { buildNetworkPolicy, buildSandboxCommand, + parsePortFlags, parseEgressFlags, splitArgvAtDoubleDash } diff --git a/test/commands/runtime/sandbox/run.test.js b/test/commands/runtime/sandbox/run.test.js index 6b3e044d..5ea7f2cf 100644 --- a/test/commands/runtime/sandbox/run.test.js +++ b/test/commands/runtime/sandbox/run.test.js @@ -31,6 +31,7 @@ function fakeSandbox (overrides = {}) { return { id: 'sandbox-123', exec: jest.fn(), + getUrl: jest.fn(port => `https://sandbox-${port}.example.net`), destroy: jest.fn().mockResolvedValue({ status: 'destroyed' }), ...overrides } @@ -93,6 +94,8 @@ test('flags', async () => { expect(TheCommand.flags.size).toBeUndefined() expect(TheCommand.flags.egress.char).toBe('e') expect(TheCommand.flags.egress.multiple).toBe(true) + expect(TheCommand.flags.port.char).toBe('p') + expect(TheCommand.flags.port.multiple).toBe(true) expect(TheCommand.flags['max-lifetime'].default).toBe(3600) expect(TheCommand.flags.interactive).toBeDefined() // inherits base flags @@ -203,6 +206,29 @@ describe('parseEgressFlags', () => { }) }) +describe('parsePortFlags', () => { + test('returns undefined when no port flags are provided', () => { + expect(TheCommand.parsePortFlags(undefined)).toBeUndefined() + expect(TheCommand.parsePortFlags([])).toBeUndefined() + }) + + test('parses repeatable port flags', () => { + expect(TheCommand.parsePortFlags(['3000', '8080'])).toEqual([3000, 8080]) + }) + + test('rejects non-numeric ports', () => { + expect(() => TheCommand.parsePortFlags(['abc'])).toThrow(/Invalid port/) + }) + + test('rejects decimal ports', () => { + expect(() => TheCommand.parsePortFlags(['3000.5'])).toThrow(/Invalid port/) + }) + + test('rejects out-of-range ports', () => { + expect(() => TheCommand.parsePortFlags(['65536'])).toThrow(/Invalid port/) + }) +}) + describe('buildNetworkPolicy', () => { test('returns undefined when no egress flags', () => { expect(TheCommand.buildNetworkPolicy(undefined)).toBeUndefined() @@ -347,6 +373,30 @@ describe('run', () => { expect(stdout.output).toMatch('Network policy: allow-all egress') }) + test('passes repeatable --port values through and logs preview URLs', async () => { + command.argv = ['--port', '3000', '-p', '8080'] + await command.run() + + expect(Sandbox.create).toHaveBeenCalledWith(expect.objectContaining({ + ports: [3000, 8080] + })) + expect(sandbox.getUrl).toHaveBeenCalledWith(3000) + expect(sandbox.getUrl).toHaveBeenCalledWith(8080) + expect(stdout.output).toMatch('Preview URLs:') + expect(stdout.output).toMatch('3000: https://sandbox-3000.example.net') + expect(stdout.output).toMatch('8080: https://sandbox-8080.example.net') + }) + + test('rejects invalid --port before creating a sandbox', async () => { + command.argv = ['--port', '0'] + await command.run() + + expect(handleError).toHaveBeenCalledWith('failed to run sandbox', expect.objectContaining({ + message: expect.stringMatching(/Invalid port/) + })) + expect(Sandbox.create).not.toHaveBeenCalled() + }) + test('passes custom --egress rules through to policy and logs them', async () => { command.argv = ['--egress', 'pypi.org:443', '--egress', 'dns.google:53:UDP', '--egress', 'api.github.com:443|GET:/repos/**'] await command.run() From 2e3e0d927cc7984aab5020c2160c33540e655279 Mon Sep 17 00:00:00 2001 From: Michael Goberling Date: Tue, 2 Jun 2026 14:14:39 -0400 Subject: [PATCH 06/11] fix: use alpha dist tag --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 3d34fcd8..2936c7bd 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "@adobe/aio-lib-env": "^3.0.1", "@adobe/aio-lib-ims": "^8.0.1", "@adobe/aio-lib-runtime": "^7.4.0", - "@adobe/aio-lib-sandbox": "0.1.0-alpha.4", + "@adobe/aio-lib-sandbox": "alpha", "@oclif/core": "^4.0.0", "@types/jest": "^29.5.3", "chalk": "^4.1.2", From f80d9958389ebbfc952e30cda9b3447250153e9e Mon Sep 17 00:00:00 2001 From: Michael Goberling Date: Tue, 2 Jun 2026 14:22:37 -0400 Subject: [PATCH 07/11] feat: remove interactive flag, enter REPL if a command isn't specified --- src/commands/runtime/sandbox/run.js | 28 +++++++---------------- test/commands/runtime/sandbox/run.test.js | 24 ++++++------------- 2 files changed, 15 insertions(+), 37 deletions(-) diff --git a/src/commands/runtime/sandbox/run.js b/src/commands/runtime/sandbox/run.js index 7a40bb0e..8de1a875 100644 --- a/src/commands/runtime/sandbox/run.js +++ b/src/commands/runtime/sandbox/run.js @@ -37,16 +37,12 @@ function streamOutput (data, stream) { class SandboxRun extends RuntimeBaseCommand { async run () { - const { cliArgs, commandArgs, hasSeparator } = splitArgvAtDoubleDash(this.argv) + const { cliArgs, commandArgs } = splitArgvAtDoubleDash(this.argv) const { flags } = await this.parse(SandboxRun, cliArgs) let sandbox let rl try { - if (commandArgs.length === 0 && !flags.interactive && hasSeparator) { - throw new Error('Missing command after --. Use --interactive for an interactive session.') - } - const policy = buildNetworkPolicy(flags.egress) const ports = parsePortFlags(flags.port) const options = await this.getOptions() @@ -72,7 +68,7 @@ class SandboxRun extends RuntimeBaseCommand { await this._runOnce(sandbox, command) } - if (flags.interactive || !command) { + if (!command) { this.log('\nSandbox ready. Type "exit" to destroy and quit.\n') rl = readline.createInterface({ input: process.stdin, output: process.stdout }) @@ -201,16 +197,12 @@ class SandboxRun extends RuntimeBaseCommand { } } -SandboxRun.description = `Create a sandbox and run a command against it. +SandboxRun.description = `Create a sandbox and run commands against it. -Pass -- to run one command, print its output, destroy the sandbox, -and exit with the command's status. +Pass -- to run one command and destroy the sandbox. -Use --interactive, or omit -- , to enter a REPL. When --interactive -is combined with -- , the command runs before the REPL starts. - -Each command you enter runs in a fresh process; shell state (working directory, -environment exports) does not persist between prompts. Chain commands to work +Each command you enter runs in a fresh process. Shell state (working directory, +env exports) does not persist between prompts. Chain commands to work around this: cd mydir && npm install During interactive sessions: @@ -218,7 +210,7 @@ During interactive sessions: command <<< "text" - Start a background command and stream its output with: .detached -- Type exit or quit to destroy the sandbox and leave.` +- Type exit or quit to destroy the sandbox.` SandboxRun.flags = { ...RuntimeBaseCommand.flags, @@ -239,17 +231,13 @@ SandboxRun.flags = { 'max-lifetime': Flags.integer({ description: 'maximum sandbox lifetime in seconds', default: 3600 - }), - interactive: Flags.boolean({ - description: 'enter an interactive command loop instead of running a one-shot command' }) } SandboxRun.examples = [ '<%= config.bin %> <%= command.id %>', - '<%= config.bin %> <%= command.id %> --interactive', '<%= config.bin %> <%= command.id %> -- node --version', - '<%= config.bin %> <%= command.id %> -p 3000 -p 8080 --interactive', + '<%= config.bin %> <%= command.id %> -p 3000 -p 8080', '<%= config.bin %> <%= command.id %> -e allow-all', '<%= config.bin %> <%= command.id %> -e "pypi.org:443" -e "api.github.com:443|GET:/repos/**"' ] diff --git a/test/commands/runtime/sandbox/run.test.js b/test/commands/runtime/sandbox/run.test.js index 5ea7f2cf..054394e6 100644 --- a/test/commands/runtime/sandbox/run.test.js +++ b/test/commands/runtime/sandbox/run.test.js @@ -97,7 +97,7 @@ test('flags', async () => { expect(TheCommand.flags.port.char).toBe('p') expect(TheCommand.flags.port.multiple).toBe(true) expect(TheCommand.flags['max-lifetime'].default).toBe(3600) - expect(TheCommand.flags.interactive).toBeDefined() + expect(TheCommand.flags.interactive).toBeUndefined() // inherits base flags expect(TheCommand.flags.apihost).toBeDefined() expect(TheCommand.flags.auth).toBeDefined() @@ -313,9 +313,9 @@ describe('run', () => { process.exitCode = previousExitCode }) - test('--interactive keeps the REPL behavior', async () => { + test('omitting a command enters the REPL', async () => { readline.createInterface.mockReturnValue(makeRl(['exit'])) - command.argv = ['--interactive'] + command.argv = [] await command.run() expect(readline.createInterface).toHaveBeenCalled() @@ -323,27 +323,17 @@ describe('run', () => { expect(stdout.output).toMatch('Sandbox ready.') }) - test('--interactive with a command runs the command before entering the REPL', async () => { + test('bare -- without a command enters the REPL', async () => { readline.createInterface.mockReturnValue(makeRl(['exit'])) - command.argv = ['--interactive', '--', 'node', '--version'] + command.argv = ['--'] await command.run() - expect(sandbox.exec).toHaveBeenCalledWith('node --version', { timeout: 30000 }) expect(readline.createInterface).toHaveBeenCalled() - expect(stdout.output.indexOf('v20.0.0')).toBeLessThan(stdout.output.indexOf('Sandbox ready.')) + expect(sandbox.exec).not.toHaveBeenCalled() + expect(stdout.output).toMatch('Sandbox ready.') expect(sandbox.destroy).toHaveBeenCalled() }) - test('rejects bare -- without a command unless interactive is requested', async () => { - command.argv = ['--'] - await command.run() - - expect(handleError).toHaveBeenCalledWith('failed to run sandbox', expect.objectContaining({ - message: expect.stringMatching(/Missing command/) - })) - expect(Sandbox.create).not.toHaveBeenCalled() - }) - test('forwards --name and --max-lifetime', async () => { command.argv = ['--name', 'mybox', '--max-lifetime', '600'] await command.run() From 2b8b9ac684bfb3beb05669227e737eafeb64555c Mon Sep 17 00:00:00 2001 From: Michael Goberling Date: Tue, 2 Jun 2026 14:27:27 -0400 Subject: [PATCH 08/11] fix: name example --- src/commands/runtime/sandbox/run.js | 2 ++ test/commands/runtime/sandbox/run.test.js | 13 +++++++++++++ 2 files changed, 15 insertions(+) diff --git a/src/commands/runtime/sandbox/run.js b/src/commands/runtime/sandbox/run.js index 8de1a875..3838416c 100644 --- a/src/commands/runtime/sandbox/run.js +++ b/src/commands/runtime/sandbox/run.js @@ -215,6 +215,7 @@ During interactive sessions: SandboxRun.flags = { ...RuntimeBaseCommand.flags, name: Flags.string({ + char: 'n', description: 'sandbox name', default: 'aio-sandbox' }), @@ -237,6 +238,7 @@ SandboxRun.flags = { SandboxRun.examples = [ '<%= config.bin %> <%= command.id %>', '<%= config.bin %> <%= command.id %> -- node --version', + '<%= config.bin %> <%= command.id %> -n my-sandbox -- node --version', '<%= config.bin %> <%= command.id %> -p 3000 -p 8080', '<%= config.bin %> <%= command.id %> -e allow-all', '<%= config.bin %> <%= command.id %> -e "pypi.org:443" -e "api.github.com:443|GET:/repos/**"' diff --git a/test/commands/runtime/sandbox/run.test.js b/test/commands/runtime/sandbox/run.test.js index 054394e6..32b66829 100644 --- a/test/commands/runtime/sandbox/run.test.js +++ b/test/commands/runtime/sandbox/run.test.js @@ -77,6 +77,7 @@ test('examples', async () => { expect(TheCommand.examples).toBeDefined() expect(TheCommand.examples).toBeInstanceOf(Array) expect(TheCommand.examples.length).toBeGreaterThan(0) + expect(TheCommand.examples).toContain('<%= config.bin %> <%= command.id %> -n my-sandbox -- node --version') }) test('description includes REPL usage notes', async () => { @@ -89,6 +90,7 @@ test('description includes REPL usage notes', async () => { test('flags', async () => { expect(typeof TheCommand.flags.name).toBe('object') + expect(TheCommand.flags.name.char).toBe('n') expect(TheCommand.flags.name.default).toBe('aio-sandbox') expect(TheCommand.flags.type).toBeUndefined() expect(TheCommand.flags.size).toBeUndefined() @@ -347,6 +349,17 @@ describe('run', () => { })) }) + test('forwards -n when running a one-shot command', async () => { + command.argv = ['-n', 'my-sandbox', '--', 'node', '--version'] + await command.run() + + expect(Sandbox.create).toHaveBeenCalledWith(expect.objectContaining({ + name: 'my-sandbox' + })) + expect(sandbox.exec).toHaveBeenCalledWith('node --version', { timeout: 30000 }) + expect(readline.createInterface).not.toHaveBeenCalled() + }) + test('quit also destroys the sandbox', async () => { readline.createInterface.mockReturnValue(makeRl(['quit'])) command.argv = [] From 6cc73fbe7b08b6f51909cddb5f57bb573a72b378 Mon Sep 17 00:00:00 2001 From: Michael Goberling Date: Tue, 2 Jun 2026 15:00:18 -0400 Subject: [PATCH 09/11] fix: workflow should run on dynamic version changes to init.py --- src/commands/runtime/sandbox/run.js | 18 +++++++++++++----- test/commands/runtime/sandbox/run.test.js | 15 ++++++++++++--- 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/src/commands/runtime/sandbox/run.js b/src/commands/runtime/sandbox/run.js index 3838416c..43c5d67b 100644 --- a/src/commands/runtime/sandbox/run.js +++ b/src/commands/runtime/sandbox/run.js @@ -36,6 +36,14 @@ function streamOutput (data, stream) { } class SandboxRun extends RuntimeBaseCommand { + async init () { + const rawArgv = [...this.argv] + const { cliArgs } = splitArgvAtDoubleDash(rawArgv) + + await this.parse(SandboxRun, cliArgs) + this.argv = rawArgv + } + async run () { const { cliArgs, commandArgs } = splitArgvAtDoubleDash(this.argv) const { flags } = await this.parse(SandboxRun, cliArgs) @@ -62,7 +70,7 @@ class SandboxRun extends RuntimeBaseCommand { this.log(`Created: ${sandbox.id}`) this._logPolicy(policy) - this._logPreviewUrls(sandbox, ports) + await this._logPreviewUrls(sandbox, ports) if (command) { await this._runOnce(sandbox, command) @@ -108,15 +116,15 @@ class SandboxRun extends RuntimeBaseCommand { }) } - _logPreviewUrls (sandbox, ports) { + async _logPreviewUrls (sandbox, ports) { if (!ports) { return } this.log('Preview URLs:') - ports.forEach(port => { - this.log(` - ${port}: ${sandbox.getUrl(port)}`) - }) + for (const port of ports) { + this.log(` - ${port}: ${await sandbox.getUrl({ port })}`) + } } async _repl (rl, sandbox) { diff --git a/test/commands/runtime/sandbox/run.test.js b/test/commands/runtime/sandbox/run.test.js index 32b66829..99ca45d7 100644 --- a/test/commands/runtime/sandbox/run.test.js +++ b/test/commands/runtime/sandbox/run.test.js @@ -31,7 +31,7 @@ function fakeSandbox (overrides = {}) { return { id: 'sandbox-123', exec: jest.fn(), - getUrl: jest.fn(port => `https://sandbox-${port}.example.net`), + getUrl: jest.fn(({ port }) => Promise.resolve(`https://sandbox-${port}.example.net`)), destroy: jest.fn().mockResolvedValue({ status: 'destroyed' }), ...overrides } @@ -88,6 +88,15 @@ test('description includes REPL usage notes', async () => { expect(TheCommand.description).toMatch(/\.detached /) }) +describe('init', () => { + test('ignores sandbox command args after -- during oclif parsing', async () => { + const command = new TheCommand(['--', 'node', '--version']) + + await expect(command.init()).resolves.toBeUndefined() + expect(command.argv).toEqual(['--', 'node', '--version']) + }) +}) + test('flags', async () => { expect(typeof TheCommand.flags.name).toBe('object') expect(TheCommand.flags.name.char).toBe('n') @@ -383,8 +392,8 @@ describe('run', () => { expect(Sandbox.create).toHaveBeenCalledWith(expect.objectContaining({ ports: [3000, 8080] })) - expect(sandbox.getUrl).toHaveBeenCalledWith(3000) - expect(sandbox.getUrl).toHaveBeenCalledWith(8080) + expect(sandbox.getUrl).toHaveBeenCalledWith({ port: 3000 }) + expect(sandbox.getUrl).toHaveBeenCalledWith({ port: 8080 }) expect(stdout.output).toMatch('Preview URLs:') expect(stdout.output).toMatch('3000: https://sandbox-3000.example.net') expect(stdout.output).toMatch('8080: https://sandbox-8080.example.net') From 89fc5fc5584e90418a81245c035a7f495a09fdf9 Mon Sep 17 00:00:00 2001 From: Michael Goberling Date: Tue, 2 Jun 2026 15:21:05 -0400 Subject: [PATCH 10/11] feat: improve detached repl ux, refresh prompt line on new logs, dont clobber --- src/commands/runtime/sandbox/run.js | 39 +++++++++++++++++++---- test/commands/runtime/sandbox/run.test.js | 34 +++++++++++++++++--- 2 files changed, 62 insertions(+), 11 deletions(-) diff --git a/src/commands/runtime/sandbox/run.js b/src/commands/runtime/sandbox/run.js index 43c5d67b..62453df2 100644 --- a/src/commands/runtime/sandbox/run.js +++ b/src/commands/runtime/sandbox/run.js @@ -23,6 +23,7 @@ const { } = require('../../../sandbox-helpers') const EXEC_TIMEOUT_MS = 30000 +const REPL_PROMPT = 'Enter command to run on sandbox: ' /** * Write live command output to the matching local stream. @@ -35,6 +36,29 @@ function streamOutput (data, stream) { sink.write(data) } +/** + * Write detached command output without permanently displacing the REPL prompt. + * + * @param {object} rl readline interface + * @returns {Function} output handler + */ +function streamOutputWithPromptRedraw (rl) { + return (data, stream) => { + readline.clearLine(process.stdout, 0) + readline.cursorTo(process.stdout, 0) + + streamOutput(data, stream) + + const text = Buffer.isBuffer(data) ? data.toString() : String(data) + if (text && !text.endsWith('\n')) { + const sink = stream === 'stderr' ? process.stderr : process.stdout + sink.write('\n') + } + + rl.prompt(true) + } +} + class SandboxRun extends RuntimeBaseCommand { async init () { const rawArgv = [...this.argv] @@ -80,6 +104,7 @@ class SandboxRun extends RuntimeBaseCommand { this.log('\nSandbox ready. Type "exit" to destroy and quit.\n') rl = readline.createInterface({ input: process.stdin, output: process.stdout }) + rl.setPrompt(REPL_PROMPT) await this._repl(rl, sandbox) } } catch (err) { @@ -123,13 +148,13 @@ class SandboxRun extends RuntimeBaseCommand { this.log('Preview URLs:') for (const port of ports) { - this.log(` - ${port}: ${await sandbox.getUrl({ port })}`) + this.log(` - ${port}: ${await sandbox.getUrl(port)}`) } } async _repl (rl, sandbox) { while (true) { - const cmd = await this._ask(rl, 'Enter command to run on sandbox: ') + const cmd = await this._ask(rl) const trimmed = (cmd || '').trim() if (trimmed === 'exit' || trimmed === 'quit') { break @@ -140,7 +165,7 @@ class SandboxRun extends RuntimeBaseCommand { try { if (trimmed.startsWith('.detached')) { - await this._handleDetached(sandbox, trimmed) + await this._handleDetached(sandbox, trimmed, rl) } else if (trimmed.includes(' <<< ')) { await this._handleHereString(sandbox, trimmed) } else { @@ -152,8 +177,8 @@ class SandboxRun extends RuntimeBaseCommand { } } - _ask (rl, question) { - return new Promise(resolve => rl.question(question, resolve)) + _ask (rl) { + return new Promise(resolve => rl.question(REPL_PROMPT, resolve)) } async _handleExec (sandbox, cmd) { @@ -163,14 +188,14 @@ class SandboxRun extends RuntimeBaseCommand { this.log(`[exit: ${result.exitCode}]`) } - async _handleDetached (sandbox, input) { + async _handleDetached (sandbox, input, rl) { const commandText = input.slice('.detached'.length).trim() if (!commandText) { this.log('Usage: .detached ') return } - const command = await sandbox.exec(commandText, { detached: true, onOutput: streamOutput }) + const command = await sandbox.exec(commandText, { detached: true, onOutput: streamOutputWithPromptRedraw(rl) }) this.log(`[detached: ${command.execId} pid: ${command.pid || 'unknown'}]`) } diff --git a/test/commands/runtime/sandbox/run.test.js b/test/commands/runtime/sandbox/run.test.js index 99ca45d7..ddc33323 100644 --- a/test/commands/runtime/sandbox/run.test.js +++ b/test/commands/runtime/sandbox/run.test.js @@ -31,7 +31,7 @@ function fakeSandbox (overrides = {}) { return { id: 'sandbox-123', exec: jest.fn(), - getUrl: jest.fn(({ port }) => Promise.resolve(`https://sandbox-${port}.example.net`)), + getUrl: jest.fn(port => Promise.resolve(`https://sandbox-${port}.example.net`)), destroy: jest.fn().mockResolvedValue({ status: 'destroyed' }), ...overrides } @@ -54,6 +54,8 @@ function makeRl (answers) { // can settle before the next prompt arrives setImmediate(() => cb(next)) }), + setPrompt: jest.fn(), + prompt: jest.fn(), close: jest.fn() } } @@ -274,6 +276,8 @@ describe('run', () => { Sandbox.create.mockResolvedValue(sandbox) sandbox.exec.mockResolvedValue({ stdout: 'v20.0.0\n', stderr: '', exitCode: 0 }) readline.createInterface.mockClear() + readline.clearLine = jest.fn() + readline.cursorTo = jest.fn() readline.createInterface.mockReturnValue(makeRl(['exit'])) }) @@ -392,8 +396,8 @@ describe('run', () => { expect(Sandbox.create).toHaveBeenCalledWith(expect.objectContaining({ ports: [3000, 8080] })) - expect(sandbox.getUrl).toHaveBeenCalledWith({ port: 3000 }) - expect(sandbox.getUrl).toHaveBeenCalledWith({ port: 8080 }) + expect(sandbox.getUrl).toHaveBeenCalledWith(3000) + expect(sandbox.getUrl).toHaveBeenCalledWith(8080) expect(stdout.output).toMatch('Preview URLs:') expect(stdout.output).toMatch('3000: https://sandbox-3000.example.net') expect(stdout.output).toMatch('8080: https://sandbox-8080.example.net') @@ -484,7 +488,8 @@ describe('run', () => { }) test('REPL: detached command starts in background and streams output', async () => { - readline.createInterface.mockReturnValue(makeRl(['.detached npm run dev', 'exit'])) + const rl = makeRl(['.detached npm run dev', 'exit']) + readline.createInterface.mockReturnValue(rl) sandbox.exec.mockImplementationOnce(async (cmd, options) => { options.onOutput('server ready\n', 'stdout') options.onOutput('debug line\n', 'stderr') @@ -502,6 +507,27 @@ describe('run', () => { expect(stderr.output).toMatch('debug line') expect(stdout.output).toMatch('[detached: exec-abc pid: 1234]') expect(stdout.output).not.toMatch('[exit:') + expect(readline.clearLine).toHaveBeenCalledWith(process.stdout, 0) + expect(readline.cursorTo).toHaveBeenCalledWith(process.stdout, 0) + expect(rl.prompt).toHaveBeenCalledWith(true) + }) + + test('REPL: detached output normalizes partial lines', async () => { + const rl = makeRl(['.detached npm run dev', 'exit']) + readline.createInterface.mockReturnValue(rl) + sandbox.exec.mockImplementationOnce(async (cmd, options) => { + options.onOutput('partial stdout', 'stdout') + options.onOutput(Buffer.from('partial stderr'), 'stderr') + return { execId: 'exec-partial', pid: 1234, detached: true } + }) + + command.argv = [] + await command.run() + + expect(stdout.output).toMatch('partial stdout\n') + expect(stderr.output).toMatch('partial stderr\n') + expect(rl.prompt).toHaveBeenCalledWith(true) + expect(stdout.output).toMatch('[detached: exec-partial pid: 1234]') }) test('REPL: detached command without a command prints usage', async () => { From 67307445fa60018d673357334251cd279fc35ced Mon Sep 17 00:00:00 2001 From: Michael Goberling Date: Tue, 2 Jun 2026 15:30:11 -0400 Subject: [PATCH 11/11] docs: alpha notice to help --- src/commands/runtime/sandbox/run.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/commands/runtime/sandbox/run.js b/src/commands/runtime/sandbox/run.js index 62453df2..b9815212 100644 --- a/src/commands/runtime/sandbox/run.js +++ b/src/commands/runtime/sandbox/run.js @@ -230,7 +230,12 @@ class SandboxRun extends RuntimeBaseCommand { } } -SandboxRun.description = `Create a sandbox and run commands against it. +SandboxRun.description = ` +[Alpha] Sandboxes are in a closed alpha. Your namespace must have +sandboxes enabled before you can use this command; contact Adobe to request +access. + +Create a sandbox and run commands against it. Pass -- to run one command and destroy the sandbox.