Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions implants/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ members = [
"lib/eldritch/stdlib/eldritch-libsys",
"lib/eldritch/stdlib/eldritch-libtime",
"lib/eldritch/eldritch",
"lib/eldritch/eldritch-wasm",
"lib/portals/portal-stream",
]
exclude = [
Expand Down
5 changes: 3 additions & 2 deletions implants/lib/eldritch/eldritch-wasm/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -26,17 +26,18 @@ eldritch = { workspace = true, default-features = false, features = [
] }

[target.'cfg(target_arch = "wasm32")'.dependencies]
wasm-bindgen = { workspace = true }
getrandom = { workspace = true, features = ["js"] }
getrandom = { version = "0.2", features = ["js"] }
uuid = { workspace = true, features = ["js"] }
eldritch = { workspace = true, default-features = false, features = [
"fake_bindings",
] }

[dependencies]
eldritch-core = { workspace = true }
eldritch-repl = { workspace = true, default-features = false }
spin = { workspace = true, features = ["rwlock"] }
log = "0.4"
wasm-bindgen = "0.2"

[dev-dependencies]
criterion = "0.5"
28 changes: 28 additions & 0 deletions implants/lib/eldritch/eldritch-wasm/build-headless.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
#!/bin/bash
set -e

echo "Building WASM for Tavern..."

# Check if wasm-pack is available
if ! command -v wasm-pack &> /dev/null; then
echo "wasm-pack not found. Attempting to install..."
cargo install wasm-pack
fi

if ! command -v wasm-pack &> /dev/null; then
echo "Skipping WASM build as wasm-pack is missing."
exit 1
fi

# Determine absolute path for output to avoid confusion
# We are in implants/lib/eldritch/eldritch-wasm/
# Tavern public dir is ../../../../tavern/internal/www/public/wasm
OUTPUT_DIR="../../../../tavern/internal/www/public/wasm"

# Ensure output directory exists
mkdir -p "$OUTPUT_DIR"

# Build with fake_bindings feature. We use --target web to get a standard ES module interface.
wasm-pack build --target web --out-dir "$OUTPUT_DIR" --features fake_bindings

echo "Done. WASM artifacts built and copied to $OUTPUT_DIR"
206 changes: 206 additions & 0 deletions implants/lib/eldritch/eldritch-wasm/src/headless.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
use alloc::format;
use alloc::string::{String, ToString};
use alloc::sync::Arc;
use alloc::vec::Vec;
use eldritch_core::{BufferPrinter, Interpreter, Lexer, TokenKind};
use wasm_bindgen::prelude::*;

#[cfg(feature = "fake_bindings")]
use eldritch::{
agent::fake::AgentLibraryFake, assets::fake::FakeAssetsLibrary,
crypto::fake::CryptoLibraryFake, file::fake::FileLibraryFake, http::fake::HttpLibraryFake,
pivot::fake::PivotLibraryFake, process::fake::ProcessLibraryFake,
random::fake::RandomLibraryFake, regex::fake::RegexLibraryFake,
report::fake::ReportLibraryFake, sys::fake::SysLibraryFake, time::fake::TimeLibraryFake,
};

#[wasm_bindgen]
pub struct HeadlessRepl {
buffer: String,
interpreter: Interpreter,
}

#[wasm_bindgen]
impl HeadlessRepl {
#[wasm_bindgen(constructor)]
pub fn new() -> HeadlessRepl {
let printer = Arc::new(BufferPrinter::new());
let mut interp = Interpreter::new_with_printer(printer);

#[cfg(feature = "fake_bindings")]
{
interp.register_lib(FileLibraryFake::default());
interp.register_lib(ProcessLibraryFake::default());
interp.register_lib(SysLibraryFake::default());
interp.register_lib(HttpLibraryFake::default());
interp.register_lib(CryptoLibraryFake::default());
interp.register_lib(AgentLibraryFake::default());
interp.register_lib(FakeAssetsLibrary::default());
interp.register_lib(PivotLibraryFake::default());
interp.register_lib(RandomLibraryFake::default());
interp.register_lib(RegexLibraryFake::default());
interp.register_lib(ReportLibraryFake::default());
interp.register_lib(TimeLibraryFake::default());
}

HeadlessRepl {
buffer: String::new(),
interpreter: interp,
}
}

pub fn input(&mut self, line: &str) -> String {
if !self.buffer.is_empty() {
self.buffer.push('\n');
}
self.buffer.push_str(line);

let trimmed = self.buffer.trim();
if trimmed == "exit" {
let payload = self.buffer.clone();
self.buffer.clear();
return format!("{{ \"status\": \"complete\", \"payload\": {:?} }}", payload);
}

// Check for completeness
let mut balance = 0;
let mut is_incomplete_string = false;
let mut has_error = false;
let mut error_msg = String::new();

let tokens = Lexer::new(self.buffer.clone()).scan_tokens();
for t in tokens {
match t.kind {
TokenKind::LParen | TokenKind::LBracket | TokenKind::LBrace => balance += 1,
TokenKind::RParen | TokenKind::RBracket | TokenKind::RBrace => {
if balance > 0 {
balance -= 1;
}
}
TokenKind::Error(ref msg) => {
if msg.contains("Unterminated string literal") && !msg.contains("(newline)") {
is_incomplete_string = true;
} else {
// Genuine error
has_error = true;
error_msg = msg.clone();
}
}
_ => {}
}
}

// If we have an open bracket/paren/brace or incomplete string, it's definitely incomplete.
if balance > 0 || is_incomplete_string {
return String::from("{ \"status\": \"incomplete\", \"prompt\": \".. \" }");
}

// If there's a syntax error that isn't just "incomplete string", it might be a real error,
// OR it might be incomplete code that looks like an error (e.g. `def foo`).
// However, `eldritch-repl` logic is: if balance > 0 || incomplete_string -> incomplete.
// Otherwise, check for colon at end of line or if it's a single line.

// If we have a hard error from lexer (like bad char), we might return error.
// But let's follow the REPL logic:
// logic from repl:
// if balance > 0 || is_incomplete_string -> false (incomplete)
// ends_with_colon -> false (incomplete)
// line_count == 1 && !ends_with_colon -> true (complete)
// line_count > 1 && is_empty_last -> true (complete)

if has_error {
// If we have a lexer error that is NOT incomplete string, report error
// Unless it's something that could be fixed by typing more?
// Unexpected char is usually fatal.
self.buffer.clear();
return format!("{{ \"status\": \"error\", \"message\": {:?} }}", error_msg);
}

let ends_with_colon = trimmed.ends_with(':');
let lines: Vec<&str> = self.buffer.lines().collect();
let line_count = lines.len();
let last_line_empty =
self.buffer.ends_with('\n') && lines.last().map_or(true, |l| l.trim().is_empty());

// If single line and doesn't end with colon, it's complete.
if line_count == 1 && !ends_with_colon {
let payload = self.buffer.clone();
self.buffer.clear();
return format!("{{ \"status\": \"complete\", \"payload\": {:?} }}", payload);
}

// If multi-line (or ends with colon), we need an empty line to finish.
// Wait, if line_count == 1 and ends with colon, we need more.
// If line_count > 1, check if last line is empty.
// Note: `lines()` iterator doesn't include the final empty string if string ends with \n.
// We need to check if the input `line` was empty (user pressed enter on empty line).

if (line_count > 1 || ends_with_colon) && line.trim().is_empty() {
let payload = self.buffer.clone();
self.buffer.clear();
return format!("{{ \"status\": \"complete\", \"payload\": {:?} }}", payload);
}

// Otherwise incomplete
String::from("{ \"status\": \"incomplete\", \"prompt\": \".. \" }")
}

pub fn complete(&self, line: &str, cursor: usize) -> String {
// We use the internal interpreter to get completions.
// The interpreter has builtins loaded.
let (start, candidates) = self.interpreter.complete(line, cursor);

// Return JSON list
// Format: { "suggestions": [...], "start": start }
// We'll just return the list as requested: "Return a JSON list of autocomplete suggestions"
// But the prompt implies just a list?
// "Return a JSON list of autocomplete suggestions"
// I'll return a JSON array of strings.

let mut json = String::from("[");
for (i, c) in candidates.iter().enumerate() {
if i > 0 {
json.push(',');
}
json.push_str(&format!("{:?}", c));
}
json.push(']');
json
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn test_headless_repl_simple() {
let mut repl = HeadlessRepl::new();
let res = repl.input("print('hello')");
assert!(res.contains("\"status\": \"complete\""));
assert!(res.contains("\"payload\": \"print('hello')\""));
}

#[test]
fn test_headless_repl_incomplete() {
let mut repl = HeadlessRepl::new();
let res = repl.input("def foo():");
assert!(res.contains("\"status\": \"incomplete\""));

let res = repl.input(" pass");
assert!(res.contains("\"status\": \"incomplete\""));

let res = repl.input("");
assert!(res.contains("\"status\": \"complete\""));
// Payload check: depends on formatting, check substring
assert!(res.contains("def foo():"));
assert!(res.contains("pass"));
}

#[test]
fn test_headless_repl_complete() {
let repl = HeadlessRepl::new();
let res = repl.complete("pri", 3);
assert!(res.contains("print"));
}
}
37 changes: 37 additions & 0 deletions implants/lib/eldritch/eldritch-wasm/src/headless_test.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
#[cfg(test)]
mod tests {
use super::*;

#[test]
fn test_headless_repl_input() {
let mut repl = HeadlessRepl::new();

// Simple complete command
let res = repl.input("print('hello')");
assert!(res.contains("\"status\": \"complete\""));
assert!(res.contains("\"payload\": \"print('hello')\""));

// Incomplete command (def)
let res = repl.input("def foo():");
assert!(res.contains("\"status\": \"incomplete\""));

// Continue incomplete command
let res = repl.input(" pass");
assert!(res.contains("\"status\": \"incomplete\"")); // Needs empty line

// Finish incomplete command
let res = repl.input("");
assert!(res.contains("\"status\": \"complete\""));
// Payload should contain full block
assert!(res.contains("def foo():\\n pass\\n"));
}

#[test]
fn test_headless_repl_complete() {
let repl = HeadlessRepl::new();

// Check completions for global 'print'
let res = repl.complete("pri", 3);
assert!(res.contains("print"));
}
}
6 changes: 6 additions & 0 deletions implants/lib/eldritch/eldritch-wasm/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,2 +1,8 @@
extern crate alloc;

#[cfg(target_arch = "wasm32")]
pub mod wasm;

pub mod headless;

pub use eldritch_repl::{Input, Repl, ReplAction};
3 changes: 2 additions & 1 deletion implants/lib/eldritch/eldritch/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ eldritch-libregex = { workspace = true, default-features = false }
eldritch-libreport = { workspace = true, default-features = false }
eldritch-libsys = { workspace = true, default-features = false }
eldritch-libtime = { workspace = true, default-features = false }
pb = { workspace = true }
pb = { workspace = true, optional = true }
eldritch-repl = { workspace = true, default-features = false }

[features]
Expand Down Expand Up @@ -55,6 +55,7 @@ fake_report = ["eldritch-libreport/fake_bindings"]
fake_sys = ["eldritch-libsys/fake_bindings"]
fake_time = ["eldritch-libtime/fake_bindings"]
stdlib = [
"dep:pb",
"eldritch-libagent/stdlib",
"eldritch-libassets/stdlib",
"eldritch-libcrypto/stdlib",
Expand Down
6 changes: 3 additions & 3 deletions implants/lib/eldritch/stdlib/eldritch-libassets/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,16 @@ edition = "2024"
[dependencies]
eldritch-core = { workspace = true }
eldritch-macros = { workspace = true }
eldritch-agent = { workspace = true }
pb = { workspace = true }
eldritch-agent = { workspace = true, optional = true }
pb = { workspace = true, optional = true }
anyhow = { version = "1.0" }
rust-embed = { version = "8.0" }

[features]
default = ["std", "stdlib"]
std = ["eldritch-core/std"]
fake_bindings = []
stdlib = []
stdlib = ["dep:eldritch-agent", "dep:pb"]

[dev-dependencies]
tempfile = "3.3"
6 changes: 4 additions & 2 deletions implants/lib/eldritch/stdlib/eldritch-libpivot/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ edition = "2024"
[dependencies]
eldritch-core = { workspace = true }
eldritch-macros = { workspace = true }
eldritch-agent = { workspace = true }
eldritch-agent = { workspace = true, optional = true }

anyhow = { workspace = true, optional = true }
async-recursion = { workspace = true, optional = true }
Expand All @@ -22,7 +22,7 @@ tokio = { workspace = true, features = [
"fs",
], optional = true }
pnet = { workspace = true, optional = true }
pb = { workspace = true }
pb = { workspace = true, optional = true }

[target.'cfg(not(target_os = "freebsd"))'.dependencies]
listeners = { workspace = true, optional = true }
Expand All @@ -34,6 +34,8 @@ eldritch-agent = { workspace = true }
[features]
default = ["stdlib"]
stdlib = [
"dep:eldritch-agent",
"dep:pb",
"anyhow",
"async-recursion",
"async-trait",
Expand Down
3 changes: 3 additions & 0 deletions implants/lib/netstat/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,4 +85,7 @@ pub fn netstat() -> Result<Vec<NetstatEntry>> {

#[cfg(target_os = "freebsd")]
return freebsd::netstat();

#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows", target_os = "freebsd")))]
return Ok(Vec::new());
}
5 changes: 5 additions & 0 deletions tavern/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,11 @@ func NewServer(ctx context.Context, options ...func(*Config)) (*Server, error) {
"/shell/ws": tavernhttp.Endpoint{
Handler: stream.NewShellHandler(client, wsShellMux),
},
"/shellv2/ws": tavernhttp.Endpoint{
Handler: tavernhttp.NewShellV2Handler(),
AllowUnauthenticated: true,
AllowUnactivated: true,
},
"/shell/ping": tavernhttp.Endpoint{
Handler: stream.NewPingHandler(client, wsShellMux),
},
Expand Down
Loading