naisys
Version:
NAISYS - Autonomous AI agent runner with built-in context management and cost tracking
592 lines • 26.2 kB
JavaScript
import xterm from "@xterm/headless";
import { spawn } from "child_process";
import crypto from "crypto";
import * as fs from "fs";
import os from "os";
import path from "path";
import stripAnsi from "strip-ansi";
import treeKill from "tree-kill";
import { createNaisysApiService, } from "../services/hub/naisysApiService.js";
import * as pathService from "../services/runtime/pathService.js";
import { getPlatformConfig } from "../services/runtime/shellPlatform.js";
import { killCmd, waitCmd } from "./commandDefs.js";
import { createOutputBuffer } from "./outputBuffer.js";
// Bound shell output memory at ~4MB (head + tail). Sized well above the
// downstream token-truncation limit in shellCommand.ts so it only kicks in
// for true runaway output (e.g. `cat /dev/urandom`), not normal commands.
const OUTPUT_HEAD_MAX = 2_000_000;
const OUTPUT_TAIL_MAX = 2_000_000;
export function createShellWrapper({ globalConfig }, { agentConfig, getHomeDir }, output, naisysApiService = createNaisysApiService(), apiUrlBase = undefined) {
let _process;
let _currentProcessId;
const _commandOutput = createOutputBuffer(OUTPUT_HEAD_MAX, OUTPUT_TAIL_MAX);
let _currentPath;
let _terminal;
let _bufferChangeEvent;
let _currentBufferType = "normal";
let _resolveCurrentCommand;
let _currentCommandTimeout;
let _currentCommandText;
/** Lines actually written to shell stdin — used to filter echoed input from output.
* Diverges from _currentCommandText when the multi-line wrapper rewrites the command. */
let _shellInputLines = [];
/** How we know the command has completed when running the command inside a shell like bash or wsl.
* Random suffix per session so the LLM cannot spoof it with crafted output. */
const _commandDelimiter = `__COMMAND_END_${crypto.randomBytes(8).toString("hex")}__`;
let _wrapperSuspended = false;
/** Set when a caller (e.g. ns-pty) flags the command as likely to receive
* sensitive input (passwords, keys). While set, callers redact input lines
* in logs/context. Cleared on command completion via resetCommand. */
let _secureContinuation = false;
/** Holds the next NAISYS_API_KEY when the shell is busy — writing the
* export to stdin while an interactive command (sudo, vi, `read`) owns
* it could leak the secret as program input. Drained in _completeCommand
* once the shell idles. */
let _pendingRuntimeApiKey;
const _queuedOutput = [];
// Flow control watermarks to prevent xterm buffer overflow
const HIGH_WATERMARK = 100000; // Pause input when buffer exceeds this (bytes)
const LOW_WATERMARK = 10000; // Resume input when buffer drops below this (bytes)
let _writeWatermark = 0;
const platformConfig = getPlatformConfig();
/**
* Create clean env variables to pass to a spawned process.
* Starts from system env (for PATH, HOME, etc.) and overlays .env vars,
* then removes NAISYS-specific vars that could conflict with the spawned process.
*/
function getCleanEnv() {
const cleanEnv = { ...process.env, ...globalConfig().shellVariableMap };
const currentKey = naisysApiService.getKey();
if (currentKey) {
cleanEnv.NAISYS_API_KEY = currentKey;
}
if (apiUrlBase) {
cleanEnv.NAISYS_API_URL_BASE = apiUrlBase;
}
return cleanEnv;
}
// Queues if a command is in flight — see _pendingRuntimeApiKey for why.
naisysApiService.onChange((newKey) => {
if (!_process || !_process.stdin.writable)
return;
if (_resolveCurrentCommand || _wrapperSuspended) {
_pendingRuntimeApiKey = newKey;
return;
}
const cmd = platformConfig.exportEnvCommand("NAISYS_API_KEY", newKey);
_process.stdin.write(`${cmd}\n`);
_pendingRuntimeApiKey = undefined;
});
async function ensureOpen() {
if (_process) {
return;
}
resetCommand();
_process = spawn(platformConfig.shellCommand, platformConfig.shellArgs, {
stdio: "pipe",
env: getCleanEnv(),
shell: false,
});
const pid = _process.pid;
if (!pid) {
throw "Shell process failed to start";
}
_currentProcessId = pid;
_process.stdout.on("data", (data) => {
processOutput(data, "stdout", pid);
});
_process.stderr.on("data", (data) => {
processOutput(data, "stderr", pid);
});
_process.on("close", (code) => {
processOutput(Buffer.from(`${code}`), "exit", pid);
});
// Install pattern-based handler that catches any `ns-*` command leaking into
// the shell (e.g. `echo foo && ns-mail ...`) and tells the LLM to run NAISYS
// commands on their own line. Pattern-based so new ns-* commands need no sync.
errorIfNotEmpty(await executeCommand(platformConfig.nsCommandNotFoundHandler));
// Init users home dir on first run, on shell crash/rerun go back to the current path
if (!_currentPath) {
output.commentAndLog(`NEW ${platformConfig.shellName.toUpperCase()} SHELL OPENED. PID: ${pid}`);
const homeDir = getHomeDir();
if (homeDir) {
errorIfNotEmpty(await executeCommand(platformConfig.mkdirCommand(homeDir)));
errorIfNotEmpty(await executeCommand(platformConfig.cdCommand(homeDir)));
}
}
else {
output.commentAndLog(`${platformConfig.shellName.toUpperCase()} SHELL RESTORED. PID: ${pid}`);
errorIfNotEmpty(await executeCommand(platformConfig.cdCommand(_currentPath)));
}
// Stop running commands if one fails
// Often the LLM will give us back all kinds of invalid commands, we want to break on the first one
// Unfortunately this also causes the shell to exit on failures, so we need to handle that
//commentIfNotEmpty(await executeCommand("set -e"));
}
/** Basically don't show anything in the console unless there is an error */
function errorIfNotEmpty(response) {
if (response) {
output.errorAndLog(response);
}
}
/** Filter out PowerShell noise from output */
function filterPowerShellNoise(str) {
if (platformConfig.platform !== "windows") {
return str;
}
return str
.split("\n")
.filter((line) => {
const trimmed = line.trim();
// Filter out PS prompts (PS C:\...>)
if (/^PS [A-Za-z]:\\.*>/.test(trimmed))
return false;
// Filter out the delimiter echo command and its output
if (trimmed.includes(_commandDelimiter))
return false;
// Filter out PSReadline warnings
if (trimmed.includes("Cannot load PSReadline module"))
return false;
if (trimmed.includes("Console is running without PSReadline"))
return false;
// Filter out our setup commands being echoed
if (trimmed.startsWith("New-Item -ItemType Directory"))
return false;
if (trimmed.startsWith("Set-Location "))
return false;
if (trimmed === "(Get-Location).Path")
return false;
// Filter out echoed lines we sent to stdin (original command or wrapper invocation)
if (_shellInputLines.includes(trimmed))
return false;
return true;
})
.join("\n");
}
function processOutput(rawDataStr, eventType, pid) {
if (_wrapperSuspended) {
_queuedOutput.push({ rawDataStr, eventType, pid });
return;
}
let dataStr = stripAnsi(rawDataStr.toString());
if (pid != _currentProcessId) {
// This funcion cant be async, but the console write will still be synchronous, just the db write will have a small delay
void output.commentAndLog(`Ignoring '${eventType}' from old shell process ${pid}: ` + dataStr);
return;
}
if (!_resolveCurrentCommand) {
// Don't log if it's just empty noise (like a filtered PowerShell prompt)
const filteredStr = filterPowerShellNoise(dataStr).trim();
if (filteredStr) {
void output.commentAndLog(`Ignoring '${eventType}' from process ${pid} with no resolve handler: ` +
filteredStr);
}
return;
}
if (eventType === "exit") {
void output.errorAndLog(`SHELL EXIT. PID: ${_process?.pid}, CODE: ${rawDataStr}`);
let finalOutput = _currentBufferType == "alternate"
? _getTerminalActiveBuffer()
: filterPowerShellNoise(_commandOutput.get()).trim();
if (finalOutput.endsWith(platformConfig.commandNotFoundSuffix) ||
finalOutput.includes("unexpected EOF") ||
finalOutput.includes("is not recognized")) {
finalOutput += `\nNAISYS: Make sure that you are using valid ${platformConfig.shellName} commands, and that any non-commands are prefixed with the 'ns-comment' command.`;
}
finalOutput += `\nNAISYS: Command killed.`;
resetProcess();
_completeCommand(finalOutput);
return;
}
// Should only happen back in normal mode, so we don't need to modify the rawDataStr
let endDelimiterHit = false;
// Use lastIndexOf to find the actual delimiter output, not an echoed command containing it
const endDelimiterPos = dataStr.lastIndexOf(_commandDelimiter);
if (endDelimiterPos != -1 &&
// Quotes will only precede the delimiter if the echo command got in the output, so don't count it
// For example running nano or vi will cause this
dataStr[endDelimiterPos - 1] != '"') {
endDelimiterHit = true;
dataStr = dataStr.slice(0, endDelimiterPos);
// If it does happen somehow, log it so I can figure out why/how and what to do about it
if (_currentBufferType == "alternate") {
void output.errorAndLog("UNEXPECTED END DELIMITER IN ALTERNATE BUFFER: " + dataStr);
}
}
// If we're in alternate mode, just write the data to the terminal
// When the buffer changes back to normal, the output will be copied back to the command output
if (_currentBufferType == "normal") {
_commandOutput.append(dataStr);
}
// Implement flow control to prevent xterm buffer overflow
_writeWatermark += rawDataStr.length;
_terminal?.write(rawDataStr, () => {
// Callback fires when xterm finishes processing this chunk
_writeWatermark = Math.max(_writeWatermark - rawDataStr.length, 0);
// Resume streams if watermark drops below threshold
if (_writeWatermark < LOW_WATERMARK) {
if (_process?.stdout.isPaused()) {
_process.stdout.resume();
}
if (_process?.stderr.isPaused()) {
_process.stderr.resume();
}
}
});
// Pause streams if watermark exceeds threshold to prevent buffer overflow
if (_writeWatermark > HIGH_WATERMARK) {
_process?.stdout.pause();
_process?.stderr.pause();
}
if (endDelimiterHit) {
// Filter PowerShell noise from complete output (not during streaming due to chunking)
const finalOutput = filterPowerShellNoise(_commandOutput.get()).trim();
resetCommand();
_completeCommand(finalOutput);
}
}
async function executeCommand(command, options) {
// Defensive: every external caller (shellCommand.handleCommand, ns-pty,
// expandShellArgs, etc.) is supposed to check isShellSuspended() and
// route via continueCommand instead. If this fires, the caller has a bug
// — surface a message that points at the caller rather than dressing it
// up as user-facing guidance the agent can act on.
if (_wrapperSuspended) {
throw `executeCommand invoked while shell is suspended (running '${getCurrentCommandName()}') — caller must check isShellSuspended() first and route via continueCommand`;
}
command = command.trim();
_currentCommandText = command;
await ensureOpen();
if (options?.secure) {
_secureContinuation = true;
}
if (_currentPath && command.split("\n").length > 1) {
command = putMultilineCommandInAScript(command);
}
return new Promise((resolve, reject) => {
_resolveCurrentCommand = resolve;
if (!_process) {
reject("Shell process is not open");
return;
}
// Inline delimiter (cmd ; echo "...") for PTY-wrapping commands.
// The default \n form leaves the delimiter line sitting in the shell's
// stdin pipe, where a long-running child that inherited stdin (e.g.
// `script -qfc`) will read it before bash gets to. Same-line form keeps
// the entire compound command in bash's parser buffer instead.
const separator = options?.inlineDelimiter ? " ; " : "\n";
const commandWithDelimiter = `${command}${separator}${platformConfig.echoDelimiter(_commandDelimiter)}\n`;
_shellInputLines = command.split("\n").map((l) => l.trim());
_process.stdin.write(commandWithDelimiter);
// Set timeout to wait for response from command
setCommandTimeout("start");
});
}
/** The LLM made its decision on how it wants to continue with the shell that previously timed out */
function continueCommand(action) {
if (!_wrapperSuspended) {
throw "Shell is not suspended, use execute command";
}
_wrapperSuspended = false;
return new Promise((resolve, reject) => {
_resolveCurrentCommand = resolve;
// If new output from the shell was queued while waiting for the LLM to decide what to do
if (_queuedOutput.length > 0) {
for (const output of _queuedOutput) {
processOutput(output.rawDataStr, output.eventType, output.pid);
}
_queuedOutput.length = 0;
// If processing queue resolved the command, then we're done
if (!_resolveCurrentCommand) {
return;
}
// Used to return here if LLM was sending if output was generated while waiting for the LLM
// In normal mode this would make the log confusing and out of order
// But since we only use the terminal in alternate mode, this is fine and works
// with commands like `mtr` changing the display type
}
switch (action.kind) {
case "wait":
setCommandTimeout("extend", action.seconds);
return;
case "kill":
if (!_currentProcessId) {
reject("No process to kill");
}
else if (resetShell(_currentProcessId)) {
return; // Wait for exit event
}
else {
reject("Unable to kill. Process not found");
}
return;
case "input": {
if (!_process) {
reject("Shell process is not open");
return;
}
const text = action.text.trim();
_shellInputLines = text.split("\n").map((l) => l.trim());
_process.stdin.write(text + "\n");
setCommandTimeout("start");
return;
}
}
});
}
let _startCommandTime;
let _startWaitTime;
function setCommandTimeout(type, waitParam) {
_startWaitTime = new Date();
if (type == "start") {
_startCommandTime = new Date();
}
let waitSeconds = globalConfig().shellCommand.timeoutSeconds;
// Parse wait time parameter if provided
if (waitParam) {
const waitTime = parseInt(waitParam, 10);
if (!isNaN(waitTime) && waitTime > 0) {
waitSeconds = waitTime;
}
}
const maxTimeoutSeconds = globalConfig().shellCommand.maxTimeoutSeconds;
if (waitSeconds > maxTimeoutSeconds) {
waitSeconds = maxTimeoutSeconds;
}
_currentCommandTimeout = setTimeout(() => {
returnControlToNaisys();
}, waitSeconds * 1000);
}
function returnControlToNaisys() {
_wrapperSuspended = true;
_queuedOutput.length = 0;
// Flush the output to the console, and give the LLM instructions of how it might continue
let outputWithInstruction = _currentBufferType == "alternate"
? _getTerminalActiveBuffer()
: filterPowerShellNoise(_commandOutput.get()).trim();
_commandOutput.reset();
// Don't clear the alternate buffer, it's a special terminal full screen mode that the
// LLM might want to see updates too
if (_currentBufferType != "alternate") {
resetTerminal();
}
const actualWaitSeconds = _startWaitTime
? Math.round((new Date().getTime() - _startWaitTime.getTime()) / 1000).toString()
: "?";
outputWithInstruction += `\nNAISYS: Command interrupted after waiting ${actualWaitSeconds} seconds.`;
_completeCommand(outputWithInstruction);
}
function resetShell(pid) {
if (!_process || _process.pid != pid) {
output.commentAndLog("Ignoring timeout for old shell process " + pid);
return false;
}
output.errorAndLog(`KILL-TREE SIGNAL SENT TO PID: ${_process.pid}`);
treeKill(pid, "SIGKILL");
// Should trigger the process close event from here
return true;
}
async function getCurrentPath() {
// If wrapper suspended just give the last known path
if (_wrapperSuspended) {
return _currentPath;
}
await ensureOpen();
_currentPath = await executeCommand(platformConfig.pwdCommand);
return _currentPath;
}
async function terminate() {
/*const exitCode = */ await executeCommand("exit");
// For some reason showing the exit code clears the console
resetProcess();
}
function resetCommand() {
_commandOutput.reset();
_writeWatermark = 0;
_currentCommandText = undefined;
_shellInputLines = [];
_secureContinuation = false;
resetTerminal();
clearTimeout(_currentCommandTimeout);
}
function resetTerminal() {
_bufferChangeEvent?.dispose();
_terminal?.dispose();
_terminal = new xterm.Terminal({
allowProposedApi: true,
rows: process.stdout.rows || 24,
cols: process.stdout.columns || 80,
});
_currentBufferType = "normal";
_bufferChangeEvent = _terminal.buffer.onBufferChange((buffer) => {
// If changing back to normal, copy the alternate buffer back to the output
// so it shows up when the command is resolved
if (_currentBufferType == "alternate" && buffer.type == "normal") {
output.comment("NAISYS: BUFFER CHANGE BACK TO NORMAL");
_commandOutput.append("\n" + _getTerminalActiveBuffer() + "\n");
}
_currentBufferType = buffer.type;
});
}
function resetProcess() {
resetCommand();
_process?.removeAllListeners();
_process = undefined;
_terminal?.dispose();
_terminal = undefined;
}
/** Hash of the process working directory, used to disambiguate script files
* when multiple instances run under the same username */
const _cwdHash = crypto
.createHash("sha256")
.update(process.cwd())
.digest("hex")
.slice(0, 4);
/** Wraps multi line commands in a script to make it easier to diagnose the source of errors based on line number
* May also help with common escaping errors */
function putMultilineCommandInAScript(command) {
const naisysFolder = process.env.NAISYS_FOLDER;
const scriptPath = naisysFolder
? path.join(naisysFolder, "agent-data", agentConfig().username, `multiline-command${platformConfig.scriptExtension}`)
: path.join(os.homedir(), ".naisys", "agent-data", `${agentConfig().username}-${_cwdHash}`, `multiline-command${platformConfig.scriptExtension}`);
pathService.ensureFileDirExists(scriptPath);
// Build platform-specific script content
let scriptContent;
if (platformConfig.platform === "windows") {
// Dot-sourced so cd/variable changes persist; try/finally restores
// $ErrorActionPreference so strict mode doesn't leak to parent shell
scriptContent = `${platformConfig.scriptHeader}
$__naisys_prevEAP = $ErrorActionPreference
${platformConfig.scriptSetError}
try {
Set-Location "${_currentPath}"
${command.trim()}
} finally {
$ErrorActionPreference = $__naisys_prevEAP
Remove-Variable __naisys_prevEAP
}`;
}
else {
// Bash script
scriptContent = `${platformConfig.scriptHeader}
${platformConfig.scriptSetError}
cd "${_currentPath}"
${command.trim()}`;
}
// create/write file
fs.writeFileSync(scriptPath, scriptContent);
return platformConfig.sourceScript(scriptPath);
}
function _completeCommand(output) {
if (!_resolveCurrentCommand) {
throw "No command to resolve";
}
_resolveCurrentCommand(output);
_resolveCurrentCommand = undefined;
// We're sync-ahead of the agent's awaiting continuation (which runs
// as a microtask), so writing now lands cleanly between commands.
// Skip if suspended — the long-running command is still in the shell.
if (_pendingRuntimeApiKey &&
!_wrapperSuspended &&
_process?.stdin.writable) {
const cmd = platformConfig.exportEnvCommand("NAISYS_API_KEY", _pendingRuntimeApiKey);
_process.stdin.write(`${cmd}\n`);
_pendingRuntimeApiKey = undefined;
}
}
function isShellSuspended() {
return _wrapperSuspended;
}
function isSecureContinuation() {
return _secureContinuation;
}
function getCommandElapsedTimeString() {
if (!_startCommandTime) {
return 0;
}
const totalSeconds = Math.round((new Date().getTime() - _startCommandTime.getTime()) / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
if (minutes > 0) {
return `${minutes}m ${seconds}s`;
}
else {
return `${seconds}s`;
}
}
/**
* The alternate/active buffer is a special terminal mode that runs full screen
* independent of the 'normal' buffer that is more like a log
*/
function _getTerminalActiveBuffer() {
let output = "";
const bufferLineCount = _terminal?.buffer.normal?.length || 0;
for (let i = 0; i < bufferLineCount; i++) {
const line = _terminal?.buffer.alternate
?.getLine(i)
?.translateToString()
.trim();
if (line) {
output += line + "\n";
}
}
return output.trim();
}
/**
* Resolve relative file paths against the shell's cwd, verifying each exists.
*/
async function resolvePaths(filePaths) {
const cwd = await getCurrentPath();
const resolved = [];
for (const fp of filePaths) {
let r = fp;
if (!path.isAbsolute(r) && cwd) {
r = path.resolve(cwd, r);
}
if (!fs.existsSync(r)) {
throw `File not found: ${r}`;
}
resolved.push(r);
}
return resolved;
}
function getCurrentCommandName() {
return _currentCommandText?.split(/\s/)[0] ?? "";
}
// ns-wait/ns-kill only do real work while a shell command is suspended; out-of-context
// invocations get a friendly explanation instead of falling through to bash.
const nsWaitCommand = {
command: waitCmd,
handleCommand: async (cmdArgs) => {
if (!_wrapperSuspended) {
return "ns-wait only works while a shell command is running long. There is no active command to wait on.";
}
return await continueCommand({
kind: "wait",
seconds: cmdArgs.trim() || undefined,
});
},
};
const nsKillCommand = {
command: killCmd,
handleCommand: async () => {
if (!_wrapperSuspended) {
return "ns-kill only works while a shell command is running long. There is no active command to terminate.";
}
return await continueCommand({ kind: "kill" });
},
};
return {
executeCommand,
continueCommand,
getCurrentPath,
resolvePaths,
terminate,
isShellSuspended,
isSecureContinuation,
getCommandElapsedTimeString,
getCurrentCommandName,
commands: [nsWaitCommand, nsKillCommand],
};
}
//# sourceMappingURL=shellWrapper.js.map