UNPKG

naisys

Version:

NAISYS - Autonomous AI agent runner with built-in context management and cost tracking

592 lines 26.2 kB
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