diff --git a/README.md b/README.md index 2993a1f2..22ed9708 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,7 @@ $ aio runtime --help * [`aio runtime rule status NAME`](#aio-runtime-rule-status-name) * [`aio runtime rule update NAME TRIGGER ACTION`](#aio-runtime-rule-update-name-trigger-action) * [`aio runtime sandbox`](#aio-runtime-sandbox) +* [`aio runtime sandbox exec`](#aio-runtime-sandbox-exec) * [`aio runtime sandbox run`](#aio-runtime-sandbox-run) * [`aio runtime trigger`](#aio-runtime-trigger) * [`aio runtime trigger create TRIGGERNAME`](#aio-runtime-trigger-create-triggername) @@ -2184,6 +2185,73 @@ ALIASES _See code: [src/commands/runtime/sandbox/index.js](https://github.com/adobe/aio-cli-plugin-runtime/blob/8.4.0/src/commands/runtime/sandbox/index.js)_ +## `aio runtime sandbox exec` + +[Alpha] Sandboxes are in a closed alpha. Your namespace must have + +``` +USAGE + $ aio runtime sandbox exec [--cert] [--key] [--apiversion] [--apihost] [-u] [-i] [--debug ] [-v] [--version] + [--help] [-n ] [-e ...] [-p ...] [--max-lifetime ] [--command-timeout ] + [--fail-fast] + +FLAGS + -e, --egress=... egress rule in host:port[:protocol][|METHOD:path] format, or "allow-all" (repeatable) + -i, --insecure bypass certificate check + -n, --name= [default: aio-sandbox] sandbox name + -p, --port=... Port to expose via a preview URL (repeatable) + -u, --auth [env: WHISK_AUTH] whisk auth + -v, --verbose Verbose output + --apihost [env: WHISK_APIHOST] whisk API host + --apiversion [env: WHISK_APIVERSION] whisk API version + --cert client cert + --command-timeout= [default: 30000] per-command timeout in milliseconds + --debug= Debug level output + --fail-fast stop execution when a command returns a non-zero exit code + --help Show help + --key client key + --max-lifetime= [default: 3600] maximum sandbox lifetime in seconds + --version Show version + +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 one or more commands non-interactively, then destroy it. + + Provide a one-shot command after "--" and/or pipe a newline-separated list of + commands on stdin. When both are given, the one-shot command runs first, + followed by the piped commands in order. + + Each command runs in a fresh process. Shell state (working directory, env + exports) does not persist between commands. Chain commands to work around + this: cd mydir && npm install + + By default every command runs and the process exits with the last non-zero + exit code. Use --fail-fast to stop at the first failure. Each command is + capped at --command-timeout milliseconds (default 30000). + + For an interactive session, use "aio runtime sandbox run" instead. + +ALIASES + $ aio rt sandbox exec + +EXAMPLES + $ aio runtime sandbox exec -- node --version + + $ aio runtime sandbox exec < commands.txt + + $ aio runtime sandbox exec -- node --version < commands.txt + + $ aio runtime sandbox exec -e allow-all -p 5173 < commands.txt + + $ aio runtime sandbox exec --fail-fast --command-timeout 120000 < commands.txt +``` + +_See code: [src/commands/runtime/sandbox/exec.js](https://github.com/adobe/aio-cli-plugin-runtime/blob/8.4.0/src/commands/runtime/sandbox/exec.js)_ + ## `aio runtime sandbox run` [Alpha] Sandboxes are in a closed alpha. Your namespace must have diff --git a/src/commands/runtime/sandbox/exec.js b/src/commands/runtime/sandbox/exec.js new file mode 100644 index 00000000..a80c6aff --- /dev/null +++ b/src/commands/runtime/sandbox/exec.js @@ -0,0 +1,191 @@ +/* +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 { Sandbox } = require('@adobe/aio-lib-sandbox') +const { Flags } = require('@oclif/core') +const RuntimeBaseCommand = require('../../../RuntimeBaseCommand') +const { + buildNetworkPolicy, + parsePortFlags, + parseEgressFlags, + splitArgvAtDoubleDash, + buildCommandList, + shellQuote, + logPolicy, + logPreviewUrls +} = require('../../../sandbox-helpers') + +const DEFAULT_COMMAND_TIMEOUT_MS = 30000 + +class SandboxExec extends RuntimeBaseCommand { + async init () { + const rawArgv = [...this.argv] + const { cliArgs } = splitArgvAtDoubleDash(rawArgv) + + await this.parse(SandboxExec, cliArgs) + this.argv = rawArgv + } + + async run () { + const { cliArgs, commandArgs } = splitArgvAtDoubleDash(this.argv) + const { flags } = await this.parse(SandboxExec, cliArgs) + + const stdinText = process.stdin.isTTY === true ? '' : await this._readStdin() + const commands = buildCommandList(commandArgs, stdinText) + + if (commands.length === 0) { + this._failUsage('No commands to run. Pass a command after "--" and/or pipe a newline-separated list on stdin. For an interactive session use "aio runtime sandbox run".') + return + } + + let sandbox + try { + const policy = buildNetworkPolicy(flags.egress) + const ports = parsePortFlags(flags.port) + const options = await this.getOptions() + + this.log('\nCreating sandbox...') + sandbox = await Sandbox.create({ + apiHost: options.apihost, + namespace: options.namespace, + auth: options.api_key, + name: flags.name, + maxLifetime: flags['max-lifetime'], + envs: {}, + ...(ports && { ports }), + ...(policy && { policy }) + }) + this.log(`Created: ${sandbox.id}`) + + logPolicy(policy, msg => this.log(msg)) + await logPreviewUrls(sandbox, ports, msg => this.log(msg)) + + await this._runCommands(sandbox, commands, flags) + } catch (err) { + await this.handleError('failed to exec in sandbox', err) + } finally { + if (sandbox) { + try { + await sandbox.destroy() + this.log('Sandbox destroyed.') + } catch (destroyErr) { + this.log(`failed to destroy sandbox: ${destroyErr.message || destroyErr}`) + } + } + } + } + + _readStdin () { + return new Promise((resolve, reject) => { + const chunks = [] + process.stdin.on('data', chunk => chunks.push(chunk)) + process.stdin.on('end', () => resolve(Buffer.concat(chunks).toString())) + process.stdin.on('error', reject) + }) + } + + _failUsage (message) { + process.stderr.write(`${message}\n`) + process.exitCode = 2 + } + + async _runCommands (sandbox, commands, flags) { + const timeout = flags['command-timeout'] + for (const cmd of commands) { + this.log(`\n$ ${cmd}`) + const result = await sandbox.exec(cmd, { timeout }) + if (result.stdout) process.stdout.write(result.stdout) + if (result.stderr) process.stderr.write(result.stderr) + this.log(`[exit: ${result.exitCode}]`) + + if (result.exitCode !== 0) { + process.exitCode = result.exitCode + if (flags['fail-fast']) { + this.log('Stopping: command exited non-zero (--fail-fast).') + return + } + } + } + } +} + +SandboxExec.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 one or more commands non-interactively, then destroy it. + +Provide a one-shot command after "--" and/or pipe a newline-separated list of +commands on stdin. When both are given, the one-shot command runs first, +followed by the piped commands in order. + +Each command runs in a fresh process. Shell state (working directory, env +exports) does not persist between commands. Chain commands to work around +this: cd mydir && npm install + +By default every command runs and the process exits with the last non-zero +exit code. Use --fail-fast to stop at the first failure. Each command is +capped at --command-timeout milliseconds (default 30000). + +For an interactive session, use "aio runtime sandbox run" instead.` + +SandboxExec.flags = { + ...RuntimeBaseCommand.flags, + name: Flags.string({ + char: 'n', + description: 'sandbox name', + default: 'aio-sandbox' + }), + egress: Flags.string({ + char: 'e', + 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 + }), + 'command-timeout': Flags.integer({ + description: 'per-command timeout in milliseconds', + default: DEFAULT_COMMAND_TIMEOUT_MS + }), + 'fail-fast': Flags.boolean({ + description: 'stop execution when a command returns a non-zero exit code', + default: false + }) +} + +SandboxExec.examples = [ + '<%= config.bin %> <%= command.id %> -- node --version', + '<%= config.bin %> <%= command.id %> < commands.txt', + '<%= config.bin %> <%= command.id %> -- node --version < commands.txt', + '<%= config.bin %> <%= command.id %> -e allow-all -p 5173 < commands.txt', + '<%= config.bin %> <%= command.id %> --fail-fast --command-timeout 120000 < commands.txt' +] + +SandboxExec.aliases = ['rt:sandbox:exec'] + +// exposed for testing +SandboxExec.parseEgressFlags = parseEgressFlags +SandboxExec.parsePortFlags = parsePortFlags +SandboxExec.buildNetworkPolicy = buildNetworkPolicy +SandboxExec.splitArgvAtDoubleDash = splitArgvAtDoubleDash +SandboxExec.buildCommandList = buildCommandList +SandboxExec.shellQuote = shellQuote + +module.exports = SandboxExec diff --git a/src/commands/runtime/sandbox/run.js b/src/commands/runtime/sandbox/run.js index 445f7110..2f0ccd82 100644 --- a/src/commands/runtime/sandbox/run.js +++ b/src/commands/runtime/sandbox/run.js @@ -18,7 +18,9 @@ const { buildNetworkPolicy, parsePortFlags, parseEgressFlags, - splitArgvAtDoubleDash + splitArgvAtDoubleDash, + logPolicy, + logPreviewUrls } = require('../../../sandbox-helpers') const EXEC_TIMEOUT_MS = 30000 @@ -72,12 +74,12 @@ class SandboxRun extends RuntimeBaseCommand { const { flags } = await this.parse(SandboxRun, cliArgs) if (commandArgs.length > 0) { - this._failUsage('This command only supports interactive use. Omit "-- " and type commands when prompted.') + this._failUsage('This command only supports interactive use. Type commands when prompted, or use "aio runtime sandbox exec" for one-shot or scripted commands.') return } if (process.stdin.isTTY !== true) { - this._failUsage('This command requires an interactive terminal. Piped stdin is not supported.') + this._failUsage('This command requires an interactive terminal. Piped stdin is not supported; use "aio runtime sandbox exec" to run a piped list of commands.') return } @@ -101,8 +103,8 @@ class SandboxRun extends RuntimeBaseCommand { }) this.log(`Created: ${sandbox.id}`) - this._logPolicy(policy) - await this._logPreviewUrls(sandbox, ports) + logPolicy(policy, msg => this.log(msg)) + await logPreviewUrls(sandbox, ports, msg => this.log(msg)) this.log('\nSandbox ready. Type "exit" to destroy and quit.\n') @@ -131,34 +133,6 @@ class SandboxRun extends RuntimeBaseCommand { process.exitCode = 2 } - _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 _logPreviewUrls (sandbox, ports) { - if (!ports) { - return - } - - this.log('Preview URLs:') - for (const port of ports) { - this.log(` - ${port}: ${await sandbox.getUrl(port)}`) - } - } - async _repl (rl, sandbox) { while (true) { const cmd = await this._ask(rl) diff --git a/src/sandbox-helpers.js b/src/sandbox-helpers.js index 2ddeeef9..07c17bbf 100644 --- a/src/sandbox-helpers.js +++ b/src/sandbox-helpers.js @@ -134,9 +134,99 @@ function buildNetworkPolicy (egressArgs) { return { network: { egress: parseEgressFlags(egressArgs) } } } +/** + * Quote a single argv token so it survives re-parsing by the sandbox's shell. + * Safe tokens are returned untouched; anything else is wrapped in single quotes + * with embedded single quotes escaped, keeping spaces and shell metacharacters + * literal. + * + * @param {string} arg argv token + * @returns {string} shell-safe token + */ +function shellQuote (arg) { + if (/^[A-Za-z0-9_./:=@%+,-]+$/.test(arg)) { + return arg + } + return `'${arg.replace(/'/g, "'\\''")}'` +} + +/** + * Build the ordered list of commands for a non-interactive `exec` run. The + * one-shot command (everything after `--`) runs first, followed by each + * newline-separated command read from piped stdin; blank lines are dropped. + * One-shot tokens are shell-quoted before joining so arguments with spaces or + * metacharacters survive the round-trip to the sandbox's shell, while piped + * lines are already complete command strings and are passed through verbatim. + * + * @param {string[]} commandArgs argv tokens after `--` + * @param {string} stdinText raw piped stdin contents + * @returns {string[]} ordered commands to execute + */ +function buildCommandList (commandArgs, stdinText) { + const commands = [] + if (commandArgs && commandArgs.length > 0) { + commands.push(commandArgs.map(shellQuote).join(' ')) + } + if (stdinText) { + for (const line of stdinText.split('\n')) { + const trimmed = line.trim() + if (trimmed) { + commands.push(trimmed) + } + } + } + return commands +} + +/** + * Log a human-readable summary of the sandbox network policy. + * + * @param {object|undefined} policy sandbox policy, or undefined for default-deny + * @param {Function} log logger called with each line + */ +function logPolicy (policy, log) { + if (!policy) { + log('Network policy: default-deny (DNS + NATS only)') + return + } + if (policy.network.egress === 'allow-all') { + log('Network policy: allow-all egress') + return + } + 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(' ') : '' + log(` - ${rule.host}:${rule.port} (${proto})${l7}`) + }) +} + +/** + * Log the preview URL for each exposed port, or nothing when no ports. + * + * @param {object} sandbox sandbox instance exposing getUrl(port) + * @param {number[]|undefined} ports exposed ports + * @param {Function} log logger called with each line + * @returns {Promise} resolves once all URLs are logged + */ +async function logPreviewUrls (sandbox, ports, log) { + if (!ports) { + return + } + + log('Preview URLs:') + for (const port of ports) { + log(` - ${port}: ${await sandbox.getUrl(port)}`) + } +} + module.exports = { buildNetworkPolicy, parsePortFlags, parseEgressFlags, - splitArgvAtDoubleDash + splitArgvAtDoubleDash, + buildCommandList, + shellQuote, + logPolicy, + logPreviewUrls } diff --git a/test/commands/runtime/sandbox/exec.test.js b/test/commands/runtime/sandbox/exec.test.js new file mode 100644 index 00000000..fa4e7606 --- /dev/null +++ b/test/commands/runtime/sandbox/exec.test.js @@ -0,0 +1,404 @@ +/* +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 { stdout, stderr } = require('stdout-stderr') +beforeEach(() => { stdout.start(); stderr.start() }) +afterEach(() => { stdout.stop(); stderr.stop() }) +const TheCommand = require('../../../../src/commands/runtime/sandbox/exec.js') +const RuntimeBaseCommand = require('../../../../src/RuntimeBaseCommand.js') +const { Sandbox } = require('@adobe/aio-lib-sandbox') +const { logPolicy, logPreviewUrls } = require('../../../../src/sandbox-helpers') + +/** + * Build a fake `Sandbox` object suitable for stubbing Sandbox.create resolutions. + * + * @param {object} [overrides] override individual fields + * @returns {object} fake sandbox + */ +function fakeSandbox (overrides = {}) { + return { + id: 'sandbox-123', + exec: jest.fn().mockResolvedValue({ stdout: 'ok\n', stderr: '', exitCode: 0 }), + getUrl: jest.fn(port => Promise.resolve(`https://sandbox-${port}.example.net`)), + destroy: jest.fn().mockResolvedValue({ status: 'destroyed' }), + ...overrides + } +} + +test('exports', async () => { + expect(typeof TheCommand).toEqual('function') + expect(TheCommand.prototype instanceof RuntimeBaseCommand).toBeTruthy() +}) + +test('description', async () => { + expect(TheCommand.description).toBeDefined() + expect(TheCommand.description).toMatch(/non-interactively/) +}) + +test('aliases', async () => { + expect(TheCommand.aliases).toContain('rt:sandbox:exec') +}) + +test('examples', async () => { + expect(TheCommand.examples).toBeInstanceOf(Array) + expect(TheCommand.examples.length).toBeGreaterThan(0) +}) + +test('flags', async () => { + // shared with run + expect(TheCommand.flags.name.char).toBe('n') + expect(TheCommand.flags.name.default).toBe('aio-sandbox') + 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) + // exec-specific: --command-timeout is the per-command cap, default 30s + expect(TheCommand.flags['command-timeout'].default).toBe(30000) + expect(TheCommand.flags['fail-fast']).toBeDefined() + // inherits base flags + expect(TheCommand.flags.apihost).toBeDefined() +}) + +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']) + }) +}) + +describe('_readStdin', () => { + test('collects piped chunks until end', async () => { + const command = new TheCommand([]) + const promise = command._readStdin() + process.stdin.emit('data', Buffer.from('echo one\n')) + process.stdin.emit('data', Buffer.from('echo two\n')) + process.stdin.emit('end') + await expect(promise).resolves.toBe('echo one\necho two\n') + process.stdin.removeAllListeners('data') + process.stdin.removeAllListeners('end') + process.stdin.removeAllListeners('error') + }) + + test('rejects on stdin error', async () => { + const command = new TheCommand([]) + const promise = command._readStdin() + process.stdin.emit('error', new Error('stdin boom')) + await expect(promise).rejects.toThrow('stdin boom') + process.stdin.removeAllListeners('data') + process.stdin.removeAllListeners('end') + process.stdin.removeAllListeners('error') + }) +}) + +describe('shellQuote', () => { + test('leaves safe tokens untouched', () => { + expect(TheCommand.shellQuote('node')).toBe('node') + expect(TheCommand.shellQuote('--version')).toBe('--version') + expect(TheCommand.shellQuote('vite@latest')).toBe('vite@latest') + expect(TheCommand.shellQuote('a/b.c_d:e=f')).toBe('a/b.c_d:e=f') + }) + + test('single-quotes tokens with spaces', () => { + expect(TheCommand.shellQuote('a b')).toBe("'a b'") + }) + + test('single-quotes shell metacharacters so they stay literal', () => { + expect(TheCommand.shellQuote('*')).toBe("'*'") + expect(TheCommand.shellQuote('a && b')).toBe("'a && b'") + }) + + test('escapes embedded single quotes', () => { + expect(TheCommand.shellQuote("can't")).toBe("'can'\\''t'") + }) +}) + +describe('buildCommandList', () => { + test('one-shot only', () => { + expect(TheCommand.buildCommandList(['node', '--version'], '')).toEqual(['node --version']) + }) + + test('shell-quotes one-shot tokens that need it', () => { + expect(TheCommand.buildCommandList(['echo', 'a b'], '')).toEqual(["echo 'a b'"]) + expect(TheCommand.buildCommandList(['echo', '*'], '')).toEqual(["echo '*'"]) + }) + + test('piped only, newline-split, blanks dropped', () => { + expect(TheCommand.buildCommandList([], 'a\n\nb\n')).toEqual(['a', 'b']) + }) + + test('piped lines are passed verbatim, not quoted', () => { + expect(TheCommand.buildCommandList([], 'cd app && npm install\n')).toEqual(['cd app && npm install']) + }) + + test('one-shot runs first, then piped', () => { + expect(TheCommand.buildCommandList(['node', '--version'], 'a\nb')).toEqual(['node --version', 'a', 'b']) + }) + + test('empty when nothing provided', () => { + expect(TheCommand.buildCommandList([], '')).toEqual([]) + }) +}) + +describe('logPolicy', () => { + test('logs default-deny when no policy', () => { + const log = jest.fn() + logPolicy(undefined, log) + expect(log).toHaveBeenCalledWith('Network policy: default-deny (DNS + NATS only)') + }) + + test('logs allow-all egress', () => { + const log = jest.fn() + logPolicy({ network: { egress: 'allow-all' } }, log) + expect(log).toHaveBeenCalledWith('Network policy: allow-all egress') + }) + + test('logs custom egress rules', () => { + const log = jest.fn() + logPolicy({ + network: { + egress: [ + { host: 'pypi.org', port: 443 }, + { host: 'api.github.com', port: 443, rules: [{ methods: ['GET'], pathPattern: '/repos/**' }] } + ] + } + }, log) + expect(log).toHaveBeenCalledWith('Network policy: custom egress') + expect(log).toHaveBeenCalledWith(' - pypi.org:443 (TCP)') + expect(log).toHaveBeenCalledWith(' - api.github.com:443 (TCP) GET:/repos/**') + }) +}) + +describe('logPreviewUrls', () => { + test('does nothing when no ports', async () => { + const log = jest.fn() + const sandbox = { getUrl: jest.fn() } + await logPreviewUrls(sandbox, undefined, log) + expect(log).not.toHaveBeenCalled() + expect(sandbox.getUrl).not.toHaveBeenCalled() + }) + + test('logs a preview URL per port', async () => { + const log = jest.fn() + const sandbox = { getUrl: jest.fn(port => Promise.resolve(`https://sandbox-${port}.example.net`)) } + await logPreviewUrls(sandbox, [3000, 8080], log) + expect(log).toHaveBeenCalledWith('Preview URLs:') + expect(log).toHaveBeenCalledWith(' - 3000: https://sandbox-3000.example.net') + expect(log).toHaveBeenCalledWith(' - 8080: https://sandbox-8080.example.net') + }) +}) + +describe('run', () => { + let command + let handleError + let sandbox + const originalStdinIsTTY = process.stdin.isTTY + const originalExitCode = process.exitCode + + beforeEach(async () => { + command = new TheCommand([]) + handleError = jest.spyOn(command, 'handleError').mockResolvedValue(undefined) + sandbox = fakeSandbox() + Sandbox.create.mockReset() + Sandbox.create.mockResolvedValue(sandbox) + jest.spyOn(command, '_readStdin').mockResolvedValue('') + Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true }) + }) + + afterEach(() => { + process.exitCode = originalExitCode + Object.defineProperty(process.stdin, 'isTTY', { value: originalStdinIsTTY, configurable: true }) + }) + + test('runs a single one-shot command and destroys', async () => { + command.argv = ['--', 'node', '--version'] + command._readStdin.mockResolvedValue('') + sandbox.exec.mockResolvedValueOnce({ stdout: 'v25.9.0\n', stderr: '', exitCode: 0 }) + + await command.run() + + expect(sandbox.exec).toHaveBeenCalledWith('node --version', { timeout: 30000 }) + expect(stdout.output).toMatch('v25.9.0') + expect(stdout.output).toMatch('[exit: 0]') + expect(sandbox.destroy).toHaveBeenCalled() + }) + + test('runs piped commands in order', async () => { + command.argv = [] + command._readStdin.mockResolvedValue('echo one\necho two\n') + + await command.run() + + expect(sandbox.exec).toHaveBeenNthCalledWith(1, 'echo one', { timeout: 30000 }) + expect(sandbox.exec).toHaveBeenNthCalledWith(2, 'echo two', { timeout: 30000 }) + }) + + test('one-shot runs before piped commands', async () => { + command.argv = ['--', 'node', '--version'] + command._readStdin.mockResolvedValue('echo after\n') + + await command.run() + + expect(sandbox.exec).toHaveBeenNthCalledWith(1, 'node --version', { timeout: 30000 }) + expect(sandbox.exec).toHaveBeenNthCalledWith(2, 'echo after', { timeout: 30000 }) + }) + + test('does not read stdin on a TTY', async () => { + Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true }) + command.argv = ['--', 'true'] + + await command.run() + + expect(command._readStdin).not.toHaveBeenCalled() + expect(sandbox.exec).toHaveBeenCalledWith('true', { timeout: 30000 }) + }) + + test('defaults the per-command timeout to 30s when --command-timeout is omitted', async () => { + command.argv = ['--', 'true'] + + await command.run() + + expect(sandbox.exec).toHaveBeenCalledWith('true', { timeout: 30000 }) + }) + + test('--command-timeout overrides the per-command default', async () => { + command.argv = ['--command-timeout', '5000', '--', 'true'] + + await command.run() + + expect(sandbox.exec).toHaveBeenCalledWith('true', { timeout: 5000 }) + }) + + test('--fail-fast stops on first non-zero exit and sets exit code', async () => { + command.argv = ['--fail-fast'] + command._readStdin.mockResolvedValue('bad\ngood\n') + sandbox.exec.mockResolvedValueOnce({ stdout: '', stderr: 'nope\n', exitCode: 1 }) + + await command.run() + + expect(sandbox.exec).toHaveBeenCalledTimes(1) + expect(sandbox.exec).toHaveBeenCalledWith('bad', { timeout: 30000 }) + expect(process.exitCode).toBe(1) + expect(sandbox.destroy).toHaveBeenCalled() + }) + + test('without --fail-fast runs all and exits with last non-zero code', async () => { + command.argv = [] + command._readStdin.mockResolvedValue('first\nsecond\nthird\n') + sandbox.exec + .mockResolvedValueOnce({ stdout: '', stderr: 'e1\n', exitCode: 3 }) + .mockResolvedValueOnce({ stdout: 'ok\n', stderr: '', exitCode: 0 }) + .mockResolvedValueOnce({ stdout: '', stderr: 'e2\n', exitCode: 5 }) + + await command.run() + + expect(sandbox.exec).toHaveBeenCalledTimes(3) + expect(process.exitCode).toBe(5) + }) + + test('exits 0 when all commands succeed', async () => { + command.argv = [] + command._readStdin.mockResolvedValue('a\nb\n') + process.exitCode = 0 + + await command.run() + + expect(sandbox.exec).toHaveBeenCalledTimes(2) + expect(process.exitCode).toBe(0) + }) + + test('errors when no command is provided on a TTY', async () => { + Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true }) + command.argv = [] + command._readStdin.mockResolvedValue('') + + await command.run() + + expect(stderr.output).toMatch(/no commands/i) + expect(stderr.output).toMatch(/sandbox run/) + expect(process.exitCode).toBe(2) + expect(Sandbox.create).not.toHaveBeenCalled() + expect(sandbox.destroy).not.toHaveBeenCalled() + }) + + test('forwards name, egress, port, max-lifetime to Sandbox.create', async () => { + command.argv = ['-n', 'mybox', '--max-lifetime', '600', '-e', 'allow-all', '-p', '5173', '--', 'true'] + + await command.run() + + expect(Sandbox.create).toHaveBeenCalledWith(expect.objectContaining({ + name: 'mybox', + maxLifetime: 600, + ports: [5173], + policy: { network: { egress: 'allow-all' } } + })) + expect(sandbox.getUrl).toHaveBeenCalledWith(5173) + }) + + test('logs custom egress rules', async () => { + command.argv = ['-e', 'pypi.org:443', '-e', 'api.github.com:443|GET:/repos/**', '--', 'true'] + + await command.run() + + expect(stdout.output).toMatch('Network policy: custom egress') + expect(stdout.output).toMatch('pypi.org:443 (TCP)') + expect(stdout.output).toMatch('api.github.com:443 (TCP) GET:/repos/**') + }) + + test('logs default-deny policy when no egress', async () => { + command.argv = ['--', 'true'] + + await command.run() + + expect(stdout.output).toMatch('Network policy: default-deny') + }) + + test('reports command stderr', async () => { + command.argv = ['--', 'cat', 'missing'] + sandbox.exec.mockResolvedValueOnce({ stdout: '', stderr: 'no such file\n', exitCode: 1 }) + + await command.run() + + expect(stderr.output).toMatch('no such file') + expect(stdout.output).toMatch('[exit: 1]') + }) + + test('routes create errors through handleError and skips destroy', async () => { + Sandbox.create.mockRejectedValue(new Error('boom')) + command.argv = ['--', 'true'] + + await command.run() + + expect(handleError).toHaveBeenCalledWith('failed to exec in 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 = ['--', 'true'] + + 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 = ['--', 'true'] + + await command.run() + + expect(stdout.output).toMatch('failed to destroy sandbox: plain reason') + }) +}) diff --git a/test/commands/runtime/sandbox/run.test.js b/test/commands/runtime/sandbox/run.test.js index 57f5b6cb..dc957310 100644 --- a/test/commands/runtime/sandbox/run.test.js +++ b/test/commands/runtime/sandbox/run.test.js @@ -304,6 +304,7 @@ describe('run', () => { await command.run() expect(stderr.output).toMatch('only supports interactive use') + expect(stderr.output).toMatch('aio runtime sandbox exec') expect(stderr.output).not.toMatch('CLIError') expect(process.exitCode).toBe(2) expect(handleError).not.toHaveBeenCalled() @@ -322,6 +323,7 @@ describe('run', () => { await command.run() expect(stderr.output).toMatch('Piped stdin is not supported') + expect(stderr.output).toMatch('aio runtime sandbox exec') expect(stderr.output).not.toMatch('CLIError') expect(process.exitCode).toBe(2) expect(handleError).not.toHaveBeenCalled() @@ -338,6 +340,7 @@ describe('run', () => { await command.run() expect(stderr.output).toMatch('Piped stdin is not supported') + expect(stderr.output).toMatch('aio runtime sandbox exec') expect(stderr.output).not.toMatch('CLIError') expect(process.exitCode).toBe(2) expect(handleError).not.toHaveBeenCalled()