mostly because code is being autogenerated by all the AI stuff using this prefix. it's also used in the stack trace.
158 lines
6.0 KiB
JavaScript
158 lines
6.0 KiB
JavaScript
'use strict';
|
|
|
|
const assert = require('node:assert'),
|
|
BoxError = require('./boxerror.js'),
|
|
child_process = require('node:child_process'),
|
|
debug = require('debug')('box:shell'),
|
|
path = require('node:path'),
|
|
safe = require('safetydance'),
|
|
_ = require('./underscore.js');
|
|
|
|
exports = module.exports = shell;
|
|
|
|
function shell(tag) {
|
|
assert.strictEqual(typeof tag, 'string');
|
|
|
|
return {
|
|
bash: bash.bind(null, tag),
|
|
spawn: spawn.bind(null, tag),
|
|
sudo: sudo.bind(null, tag),
|
|
};
|
|
}
|
|
|
|
const SUDO = '/usr/bin/sudo';
|
|
const KILL_CHILD_CMD = path.join(__dirname, 'scripts/kill-child.sh');
|
|
|
|
function lineCount(buffer) {
|
|
assert(Buffer.isBuffer(buffer));
|
|
|
|
const NEW_LINE = Buffer.from('\n');
|
|
let index = buffer.indexOf(NEW_LINE);
|
|
let count = 0;
|
|
while (index >= 0) {
|
|
index = buffer.indexOf(NEW_LINE, index+1);
|
|
++count;
|
|
}
|
|
return count;
|
|
}
|
|
|
|
function spawn(tag, file, args, options) {
|
|
assert.strictEqual(typeof tag, 'string');
|
|
assert.strictEqual(typeof file, 'string');
|
|
assert(Array.isArray(args));
|
|
assert.strictEqual(typeof options, 'object'); // note: spawn() has no encoding option of it's own
|
|
|
|
debug(`${tag}: ${file} ${args.join(' ').replace(/\n/g, '\\n')}`);
|
|
|
|
const maxLines = options.maxLines || Number.MAX_SAFE_INTEGER;
|
|
const logger = options.logger || null;
|
|
const abortSignal = options.abortSignal || null; // note: we use our own handler and not the child_process one
|
|
|
|
return new Promise((resolve, reject) => {
|
|
const spawnOptions = _.omit(options, [ 'maxLines', 'logger', 'abortSignal', 'onMessage', 'input', 'encoding', 'timeout', 'onTimeout' ]);
|
|
const cp = child_process.spawn(file, args, spawnOptions);
|
|
const stdoutBuffers = [], stderrBuffers = [];
|
|
let stdoutLineCount = 0, stderrLineCount = 0, killTimerId = null, timedOut = false, terminated = false;
|
|
|
|
cp.stdout.on('data', (data) => {
|
|
if (logger) return logger(data);
|
|
stdoutBuffers.push(data);
|
|
stdoutLineCount += lineCount(data);
|
|
if (stdoutLineCount >= maxLines) return cp.kill('SIGKILL');
|
|
});
|
|
cp.stderr.on('data', (data) => {
|
|
if (logger) return logger(data);
|
|
stderrBuffers.push(data);
|
|
stderrLineCount += lineCount(data);
|
|
if (stderrLineCount >= maxLines) return cp.kill('SIGKILL');
|
|
});
|
|
|
|
cp.on('close', function (code, signal) { // always called. after 'exit' or 'error'
|
|
const stdoutBuffer = Buffer.concat(stdoutBuffers);
|
|
const stdout = options.encoding ? stdoutBuffer.toString(options.encoding) : stdoutBuffer;
|
|
|
|
if (killTimerId) clearTimeout(killTimerId);
|
|
|
|
// if terminated or timedout, the code is ignored
|
|
if (!terminated && !timedOut && code === 0) return resolve(stdout);
|
|
|
|
const stderrBuffer = Buffer.concat(stderrBuffers);
|
|
const stderr = options.encoding ? stderrBuffer.toString(options.encoding) : stderrBuffer;
|
|
|
|
const e = new BoxError(BoxError.SHELL_ERROR, `${file} exited with code ${code} signal ${signal}`);
|
|
e.stdout = stdout; // when promisified, this is the way to get stdout
|
|
e.stdoutLineCount = stdoutLineCount;
|
|
e.stderr = stderr; // when promisified, this is the way to get stderr
|
|
e.stderrLineCount = stderrLineCount;
|
|
e.code = code;
|
|
e.signal = signal;
|
|
e.timedOut = timedOut;
|
|
e.terminated = terminated;
|
|
|
|
debug(`${tag}: ${file} ${args.join(' ').replace(/\n/g, '\\n')} errored`, e);
|
|
|
|
reject(e);
|
|
});
|
|
|
|
cp.on('error', function (error) { // when the command itself could not be started
|
|
debug(`${tag}: ${file} ${args.join(' ').replace(/\n/g, '\\n')} errored`, error);
|
|
});
|
|
|
|
cp.terminate = function () {
|
|
terminated = true;
|
|
// many approaches to kill sudo launched process failed. we now have a sudo wrapper to kill the full tree
|
|
child_process.execFile('/usr/bin/sudo', [ KILL_CHILD_CMD, cp.pid, process.pid ], { encoding: 'utf8' }, (error, stdout, stderr) => {
|
|
if (error) debug(`${tag}: failed to kill children`, stdout, stderr);
|
|
else debug(`${tag}: terminated ${cp.pid}`, stdout, stderr);
|
|
});
|
|
};
|
|
|
|
abortSignal?.addEventListener('abort', () => {
|
|
debug(`${tag}: aborting ${cp.pid}`);
|
|
cp.terminate();
|
|
}, { once: true });
|
|
|
|
if (options.onMessage) cp.on('message', options.onMessage); // ipc mode messages
|
|
|
|
if (options.timeout) {
|
|
killTimerId = setTimeout(async () => {
|
|
debug(`${tag}: timedout`);
|
|
timedOut = true;
|
|
if (typeof options.onTimeout !== 'function') return cp.terminate();
|
|
await safe(options.onTimeout(), { debug });
|
|
}, options.timeout);
|
|
}
|
|
|
|
// https://github.com/nodejs/node/issues/25231
|
|
if ('input' in options) { // when empty, just closes
|
|
cp.stdin.write(options.input);
|
|
cp.stdin.end();
|
|
}
|
|
});
|
|
}
|
|
|
|
async function bash(tag, script, options) {
|
|
assert.strictEqual(typeof tag, 'string');
|
|
assert.strictEqual(typeof script, 'string');
|
|
assert.strictEqual(typeof options, 'object');
|
|
|
|
return await spawn(tag, '/bin/bash', [ '-c', script ], options);
|
|
}
|
|
|
|
async function sudo(tag, args, options) {
|
|
assert.strictEqual(typeof tag, 'string');
|
|
assert(Array.isArray(args));
|
|
assert.strictEqual(typeof options, 'object');
|
|
|
|
const sudoArgs = [ '--non-interactive' ]; // avoid prompting the user for input of any kind
|
|
if (options.preserveEnv) sudoArgs.push('-E'); // -E preserves environment
|
|
|
|
if (options.onMessage) { // enable ipc
|
|
sudoArgs.push('--close-from=4'); // keep the ipc open. requires closefrom_override in sudoers file
|
|
options.stdio = ['pipe', 'pipe', 'pipe', 'ipc'];
|
|
}
|
|
|
|
const spawnArgs = [ ...sudoArgs, ...args ];
|
|
return await spawn(tag, SUDO, spawnArgs, options);
|
|
}
|