deepclause-agentvm
Advanced tools
+229
| #!/usr/bin/env node | ||
| const { AgentVM } = require('../src/index.js'); | ||
| const path = require('node:path'); | ||
| function printUsage() { | ||
| console.log(` | ||
| AgentVM CLI - Interactive terminal into the VM | ||
| Usage: agentvm [options] | ||
| Options: | ||
| --network, -n Enable networking (default: enabled) | ||
| --no-network Disable networking | ||
| --mount, -m <path> Mount a host directory into the VM at /mnt/host | ||
| Can specify VM path with: --mount /host/path:/vm/path | ||
| --debug, -d Enable debug logging | ||
| --help, -h Show this help message | ||
| Examples: | ||
| agentvm # Start VM with networking enabled | ||
| agentvm --no-network # Start VM without networking | ||
| agentvm -m /home/user/project # Mount directory at /mnt/host | ||
| agentvm -m /tmp/data:/data # Mount /tmp/data at /data in VM | ||
| agentvm -n -m ./mydir # Network on, mount ./mydir | ||
| `); | ||
| } | ||
| function parseArgs(args) { | ||
| const options = { | ||
| network: true, | ||
| mounts: {}, | ||
| debug: false, | ||
| }; | ||
| for (let i = 0; i < args.length; i++) { | ||
| const arg = args[i]; | ||
| switch (arg) { | ||
| case '--help': | ||
| case '-h': | ||
| printUsage(); | ||
| process.exit(0); | ||
| break; | ||
| case '--network': | ||
| case '-n': | ||
| options.network = true; | ||
| break; | ||
| case '--no-network': | ||
| options.network = false; | ||
| break; | ||
| case '--mount': | ||
| case '-m': | ||
| const mountArg = args[++i]; | ||
| if (!mountArg) { | ||
| console.error('Error: --mount requires a path argument'); | ||
| process.exit(1); | ||
| } | ||
| // Support both "/host/path" and "/host/path:/vm/path" formats | ||
| if (mountArg.includes(':') && !mountArg.startsWith('/') || mountArg.split(':').length > 2) { | ||
| // Handle Windows-style paths or explicit VM path | ||
| const lastColon = mountArg.lastIndexOf(':'); | ||
| if (lastColon > 0 && mountArg[lastColon - 1] !== '\\') { | ||
| const hostPath = mountArg.substring(0, lastColon); | ||
| const vmPath = mountArg.substring(lastColon + 1); | ||
| options.mounts[vmPath] = path.resolve(hostPath); | ||
| } else { | ||
| options.mounts['/mnt/host'] = path.resolve(mountArg); | ||
| } | ||
| } else if (mountArg.includes(':')) { | ||
| const [hostPath, vmPath] = mountArg.split(':'); | ||
| options.mounts[vmPath] = path.resolve(hostPath); | ||
| } else { | ||
| options.mounts['/mnt/host'] = path.resolve(mountArg); | ||
| } | ||
| break; | ||
| case '--debug': | ||
| case '-d': | ||
| options.debug = true; | ||
| break; | ||
| default: | ||
| if (arg.startsWith('-')) { | ||
| console.error(`Unknown option: ${arg}`); | ||
| printUsage(); | ||
| process.exit(1); | ||
| } | ||
| break; | ||
| } | ||
| } | ||
| return options; | ||
| } | ||
| async function main() { | ||
| const args = process.argv.slice(2); | ||
| const options = parseArgs(args); | ||
| // Print startup info to stderr so it doesn't interfere with VM output | ||
| process.stderr.write('AgentVM Terminal\n'); | ||
| process.stderr.write('================\n'); | ||
| process.stderr.write(`Network: ${options.network ? 'enabled' : 'disabled'}\n`); | ||
| if (Object.keys(options.mounts).length > 0) { | ||
| process.stderr.write('Mounts:\n'); | ||
| for (const [vmPath, hostPath] of Object.entries(options.mounts)) { | ||
| process.stderr.write(` ${hostPath} -> ${vmPath}\n`); | ||
| } | ||
| } | ||
| process.stderr.write('\n'); | ||
| const vm = new AgentVM({ | ||
| network: options.network, | ||
| mounts: options.mounts, | ||
| debug: options.debug, | ||
| interactive: true, // Use interactive/raw mode | ||
| }); | ||
| // Connect VM stdout/stderr directly to process stdout/stderr | ||
| vm.onStdout = (data) => { | ||
| process.stdout.write(data); | ||
| }; | ||
| vm.onStderr = (data) => { | ||
| process.stderr.write(data); | ||
| }; | ||
| // Handle VM exit (e.g., user types 'exit' in shell) | ||
| vm.onExit = async () => { | ||
| if (process.stdin.isTTY) { | ||
| process.stdin.setRawMode(false); | ||
| } | ||
| process.exit(0); | ||
| }; | ||
| process.stderr.write('Booting VM...\n'); | ||
| try { | ||
| await vm.start(); | ||
| } catch (err) { | ||
| process.stderr.write(`Failed to start VM: ${err.message}\n`); | ||
| process.exit(1); | ||
| } | ||
| process.stderr.write('VM Ready. Press Ctrl+D to exit.\n\n'); | ||
| // Put stdin in raw mode for true terminal experience | ||
| if (process.stdin.isTTY) { | ||
| process.stdin.setRawMode(true); | ||
| } | ||
| process.stdin.resume(); | ||
| // Pipe stdin directly to VM | ||
| process.stdin.on('data', async (data) => { | ||
| // Check for Ctrl+D (EOF) - exit cleanly | ||
| if (data.length === 1 && data[0] === 0x04) { | ||
| process.stderr.write('\n\nShutting down VM...\n'); | ||
| if (process.stdin.isTTY) { | ||
| process.stdin.setRawMode(false); | ||
| } | ||
| await vm.stop(); | ||
| process.exit(0); | ||
| } | ||
| try { | ||
| await vm.writeToStdin(data); | ||
| } catch (err) { | ||
| if (options.debug) { | ||
| process.stderr.write(`Error writing to VM: ${err.message}\n`); | ||
| } | ||
| } | ||
| }); | ||
| // Handle stdin close | ||
| process.stdin.on('end', async () => { | ||
| process.stderr.write('\n\nShutting down VM...\n'); | ||
| await vm.stop(); | ||
| process.exit(0); | ||
| }); | ||
| // Handle Ctrl+C - pass it through to VM unless pressed twice | ||
| let ctrlCCount = 0; | ||
| let ctrlCTimer = null; | ||
| process.on('SIGINT', async () => { | ||
| ctrlCCount++; | ||
| if (ctrlCCount >= 2) { | ||
| process.stderr.write('\n\nForce shutting down VM...\n'); | ||
| if (process.stdin.isTTY) { | ||
| process.stdin.setRawMode(false); | ||
| } | ||
| await vm.stop(); | ||
| process.exit(0); | ||
| } | ||
| // Send Ctrl+C to VM | ||
| try { | ||
| await vm.writeToStdin('\x03'); | ||
| } catch (err) { | ||
| // Ignore | ||
| } | ||
| // Reset counter after a delay | ||
| clearTimeout(ctrlCTimer); | ||
| ctrlCTimer = setTimeout(() => { | ||
| ctrlCCount = 0; | ||
| }, 500); | ||
| }); | ||
| // Handle process termination | ||
| process.on('SIGTERM', async () => { | ||
| if (process.stdin.isTTY) { | ||
| process.stdin.setRawMode(false); | ||
| } | ||
| await vm.stop(); | ||
| process.exit(0); | ||
| }); | ||
| } | ||
| main().catch((err) => { | ||
| process.stderr.write(`Fatal error: ${err}\n`); | ||
| process.exit(1); | ||
| }); |
+17
-2
| { | ||
| "name": "deepclause-agentvm", | ||
| "version": "0.0.2", | ||
| "version": "0.0.3", | ||
| "main": "src/index.js", | ||
| "bin": { | ||
| "agentvm": "./bin/cli.js" | ||
| }, | ||
| "scripts": { | ||
| "test": "node test/basic.test.js && node test/mount.test.js && node test/network/http.test.js" | ||
| }, | ||
| "keywords": ["vm", "wasm", "sandbox", "agent", "linux", "alpine", "container", "wasi"], | ||
| "keywords": [ | ||
| "vm", | ||
| "wasm", | ||
| "sandbox", | ||
| "agent", | ||
| "linux", | ||
| "alpine", | ||
| "container", | ||
| "wasi" | ||
| ], | ||
| "author": "DeepClause", | ||
@@ -18,2 +30,3 @@ "license": "MIT", | ||
| "src/", | ||
| "bin/", | ||
| "agentvm-alpine-python.wasm", | ||
@@ -27,5 +40,7 @@ "README.md" | ||
| "@ai-sdk/openai": "^3.0.12", | ||
| "0x": "^6.0.0", | ||
| "ai": "^6.0.39", | ||
| "clinic": "^13.0.0", | ||
| "dotenv": "^17.2.3" | ||
| } | ||
| } |
+127
-12
@@ -19,2 +19,5 @@ const { Worker, MessageChannel, SHARE_ENV } = require('node:worker_threads'); | ||
| * @param {string} [options.mac] - MAC address for the VM (default: 02:00:00:00:00:01). | ||
| * @param {number} [options.networkRateLimit] - Network rate limit in bytes/sec (default: 256KB/s). Set to 0 for unlimited. | ||
| * @param {boolean} [options.debug] - Enable debug logging. | ||
| * @param {boolean} [options.interactive] - Interactive/raw mode - skip shell setup for direct terminal access. | ||
| */ | ||
@@ -26,2 +29,6 @@ constructor(options = {}) { | ||
| this.mac = options.mac || '02:00:00:00:00:01'; | ||
| this.debug = options.debug || false; | ||
| this.interactive = options.interactive || false; | ||
| // Rate limit: 256KB/s default to avoid overwhelming VM filesystem writes | ||
| this.networkRateLimit = options.networkRateLimit !== undefined ? options.networkRateLimit : 1024 * 1024 * 1024; | ||
| this.sharedBuffer = new SharedArrayBuffer(SHARED_BUFFER_SIZE); | ||
@@ -36,5 +43,10 @@ this.inputInt32 = new Int32Array(this.sharedBuffer); | ||
| // Callbacks for raw/interactive mode | ||
| this.onStdout = null; | ||
| this.onStderr = null; | ||
| this.onExit = null; | ||
| // NAT: Main thread handles sockets, worker polls for responses via MessageChannel | ||
| this.udpSessions = new Map(); // key -> { socket, lastActive } | ||
| this.tcpSessions = new Map(); // key -> { socket, state, ... } | ||
| this.tcpSessions = new Map(); // key -> { socket, state, bytesThisSecond, lastReset, paused, pendingData } | ||
| this.netChannel = null; | ||
@@ -83,3 +95,10 @@ } | ||
| this.isReady = true; | ||
| // Run setup commands | ||
| // In interactive mode, skip shell setup and resolve immediately | ||
| if (this.interactive) { | ||
| resolve(); | ||
| return; | ||
| } | ||
| // Run setup commands for exec() mode | ||
| this.exec("stty -echo; export PS1=''").then(async () => { | ||
@@ -104,7 +123,12 @@ // Auto-setup network if enabled | ||
| } else if (msg.type === 'debug') { | ||
| // console.log('[Worker Debug]', msg.msg); | ||
| // Worker debug messages (disabled by default for performance) | ||
| // if (this.debug) console.log('[Worker]', msg.msg); | ||
| } else if (msg.type === 'exit') { | ||
| if (!this.destroyed) { | ||
| if (!this.destroyed && !this.interactive) { | ||
| console.error('VM Exited unexpectedly:', msg.error); | ||
| } | ||
| // In interactive mode, trigger onExit callback | ||
| if (this.interactive && this.onExit) { | ||
| this.onExit(msg.error); | ||
| } | ||
| } | ||
@@ -240,5 +264,17 @@ }); | ||
| const socket = new net.Socket(); | ||
| const session = { socket, srcIP, srcPort, dstIP, dstPort }; | ||
| const session = { | ||
| socket, srcIP, srcPort, dstIP, dstPort, | ||
| // Rate limiting state | ||
| bytesThisSecond: 0, | ||
| lastReset: Date.now(), | ||
| rateLimitPaused: false, | ||
| flowControlPaused: false, // Track flow control state | ||
| pendingResume: null | ||
| }; | ||
| this.tcpSessions.set(key, session); | ||
| if (this.debug) { | ||
| console.log(`[TCP] Connecting to ${connectIP}:${dstPort}, key=${key}`); | ||
| } | ||
| socket.connect(dstPort, connectIP, () => { | ||
@@ -254,9 +290,63 @@ if (!this.netChannel) return; | ||
| if (!this.netChannel) return; | ||
| // Rate limiting: track bytes per second | ||
| if (this.networkRateLimit > 0) { | ||
| const now = Date.now(); | ||
| // Reset counter every second | ||
| if (now - session.lastReset >= 1000) { | ||
| session.bytesThisSecond = 0; | ||
| session.lastReset = now; | ||
| } | ||
| session.bytesThisSecond += data.length; | ||
| // If we've exceeded rate limit, pause the socket | ||
| if (session.bytesThisSecond >= this.networkRateLimit && !session.rateLimitPaused) { | ||
| session.rateLimitPaused = true; | ||
| socket.pause(); | ||
| if (this.debug) { | ||
| console.log(`[RateLimit] Pausing ${key}, sent ${session.bytesThisSecond} bytes this second`); | ||
| } | ||
| // Schedule resume at start of next second | ||
| const timeUntilNextSecond = 1000 - (now - session.lastReset); | ||
| session.pendingResume = setTimeout(() => { | ||
| // Always clear the rate limit pause flag and reset counters | ||
| session.rateLimitPaused = false; | ||
| session.bytesThisSecond = 0; | ||
| session.lastReset = Date.now(); | ||
| session.pendingResume = null; | ||
| // Only actually resume if flow control also allows it | ||
| if (!session.flowControlPaused) { | ||
| socket.resume(); | ||
| if (this.debug) { | ||
| console.log(`[RateLimit] Resuming ${key}`); | ||
| } | ||
| } else if (this.debug) { | ||
| console.log(`[RateLimit] Rate limit cleared for ${key}, but flow control still paused`); | ||
| } | ||
| }, timeUntilNextSecond); | ||
| } | ||
| } | ||
| // Use transferable Uint8Array for efficiency with large data | ||
| const uint8 = new Uint8Array(data.buffer, data.byteOffset, data.length); | ||
| this.netChannel.port2.postMessage({ | ||
| type: 'tcp-data', | ||
| key, | ||
| data: uint8 | ||
| }, [uint8.buffer]); | ||
| // NOTE: We must copy the data because socket buffers may share ArrayBuffer | ||
| try { | ||
| const copy = new Uint8Array(data.length); | ||
| copy.set(data); | ||
| this.netChannel.port2.postMessage({ | ||
| type: 'tcp-data', | ||
| key, | ||
| data: copy | ||
| }, [copy.buffer]); | ||
| } catch (e) { | ||
| console.error('[TCP] Failed to post data:', e.message); | ||
| // Fallback: send without transfer | ||
| this.netChannel.port2.postMessage({ | ||
| type: 'tcp-data', | ||
| key, | ||
| data: new Uint8Array(data) | ||
| }); | ||
| } | ||
| }); | ||
@@ -273,2 +363,7 @@ | ||
| socket.on('close', () => { | ||
| // Clean up rate limit timer | ||
| if (session.pendingResume) { | ||
| clearTimeout(session.pendingResume); | ||
| session.pendingResume = null; | ||
| } | ||
| if (this.netChannel) { | ||
@@ -284,2 +379,7 @@ this.netChannel.port2.postMessage({ | ||
| socket.on('error', (err) => { | ||
| // Clean up rate limit timer | ||
| if (session.pendingResume) { | ||
| clearTimeout(session.pendingResume); | ||
| session.pendingResume = null; | ||
| } | ||
| if (this.netChannel) { | ||
@@ -332,2 +432,3 @@ this.netChannel.port2.postMessage({ | ||
| if (session && session.socket) { | ||
| session.flowControlPaused = true; | ||
| session.socket.pause(); | ||
@@ -345,3 +446,7 @@ } | ||
| if (session && session.socket) { | ||
| session.socket.resume(); | ||
| session.flowControlPaused = false; | ||
| // Only resume if not also rate-limit paused | ||
| if (!session.rateLimitPaused) { | ||
| session.socket.resume(); | ||
| } | ||
| } | ||
@@ -409,2 +514,12 @@ } | ||
| // In interactive mode, call callbacks directly | ||
| if (this.interactive) { | ||
| if (type === 'stdout' && this.onStdout) { | ||
| this.onStdout(dataUint8); | ||
| } else if (type === 'stderr' && this.onStderr) { | ||
| this.onStderr(dataUint8); | ||
| } | ||
| return; | ||
| } | ||
| if (!this.pendingCommand) return; | ||
@@ -411,0 +526,0 @@ |
+0
-2
@@ -147,4 +147,2 @@ const EventEmitter = require('events'); | ||
| // MTU is 1500, IP header is 20, TCP header is 20 | ||
@@ -151,0 +149,0 @@ // Maximum Segment Size (MSS) = 1500 - 20 - 20 = 1460 |
+17
-3
@@ -680,2 +680,5 @@ const { parentPort, workerData, receiveMessageOnPort } = require('node:worker_threads'); | ||
| // Poll for network responses before reading | ||
| netStack.pollNetResponses(); | ||
| const data = netStack.readFromNetwork(4096); | ||
@@ -799,4 +802,9 @@ if (!data || data.length === 0) { | ||
| // IMPORTANT: Always poll for network responses first! | ||
| // This ensures TCP data from main thread is received even when | ||
| // we're polling for both read and write (common during TLS handshake). | ||
| netStack.pollNetResponses(); | ||
| // 2. Check Immediate Status | ||
| const netReadable = netStack.hasPendingData() || netStack.hasReceivedFin(); | ||
| const netReadable = sockRecvBuffer.length > 0 || netStack.hasPendingData() || netStack.hasReceivedFin(); | ||
| const netWritable = true; // Always writable | ||
@@ -861,3 +869,3 @@ const stdinReadable = localBuffer.length > 0 || Atomics.load(inputInt32, INPUT_FLAG_INDEX) !== 0; | ||
| const postStdinReadable = localBuffer.length > 0 || Atomics.load(inputInt32, INPUT_FLAG_INDEX) !== 0; | ||
| const postNetReadable = netStack.hasPendingData() || netStack.hasReceivedFin(); | ||
| const postNetReadable = sockRecvBuffer.length > 0 || netStack.hasPendingData() || netStack.hasReceivedFin(); | ||
@@ -886,3 +894,3 @@ for(let i=0; i<nsubscriptions; i++) { | ||
| evType = 1; | ||
| nbytes = netStack.txBuffer.length; | ||
| nbytes = sockRecvBuffer.length + netStack.txBuffer.length; | ||
| // // parentPort.postMessage({ type: 'debug', msg: `poll: conn fd readable, ${nbytes} bytes` }); | ||
@@ -925,2 +933,5 @@ } | ||
| sock_accept: (fd, flags, result_fd_ptr) => { | ||
| // Poll for network responses first - TCP-connected messages may be pending | ||
| netStack.pollNetResponses(); | ||
| if (fd !== NET_FD) { | ||
@@ -946,2 +957,5 @@ // parentPort.postMessage({ type: 'debug', msg: `sock_accept(${fd}) - wrong fd` }); | ||
| sock_recv: (fd, ri_data_ptr, ri_data_len, ri_flags, ro_datalen_ptr, ro_flags_ptr) => { | ||
| // Poll for network responses first | ||
| netStack.pollNetResponses(); | ||
| parentPort.postMessage({ type: 'debug', msg: `sock_recv(${fd}) called, buffered=${sockRecvBuffer.length}, pending=${netStack.hasPendingData()}, fin=${netStack.hasReceivedFin()}` }); | ||
@@ -948,0 +962,0 @@ |
Sorry, the diff of this file is not supported yet
Network access
Supply chain riskThis module accesses the network.
Found 1 instance in 1 package
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 2 instances in 1 package
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
Found 1 instance in 1 package
Network access
Supply chain riskThis module accesses the network.
Found 1 instance in 1 package
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 2 instances in 1 package
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
Found 1 instance in 1 package
180838766
21.47%8
14.29%2273
15.62%5
66.67%28
7.69%