mirror of
https://github.com/Priler/jarvis.git
synced 2026-05-26 07:08:11 +00:00
basic Lua 5.4 implementation with few example commands
This commit is contained in:
1182
Cargo.lock
generated
1182
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -40,4 +40,8 @@ futures-util = "0.3"
|
||||
fluent = "0.17.0"
|
||||
fluent-bundle = "0.16.0"
|
||||
unic-langid = "0.9"
|
||||
chrono = "0.4"
|
||||
chrono = "0.4"
|
||||
mlua = { version = "0.11.5", features = ["lua54", "vendored", "async", "serde"] }
|
||||
reqwest = { version = "0.13.1", features = ["blocking", "json"] }
|
||||
tempfile = "^3.24"
|
||||
winrt-notification = "0.5"
|
||||
@@ -306,7 +306,7 @@ fn execute_command(text: &str, rt: &tokio::runtime::Runtime) -> bool {
|
||||
if let Some((cmd_path, cmd_config)) = cmd_result {
|
||||
info!("Command found: {:?}", cmd_path);
|
||||
|
||||
match commands::execute_command(&cmd_path, &cmd_config) {
|
||||
match commands::execute_command(&cmd_path, &cmd_config, Some(&text)) {
|
||||
Ok(chain) => {
|
||||
info!("Command executed successfully");
|
||||
// voices::play_ok();
|
||||
|
||||
@@ -38,7 +38,16 @@ intent-classifier = { version = "0.1.0", optional = true }
|
||||
|
||||
tokio = { version = "1", features = ["sync"], optional = true }
|
||||
|
||||
mlua = { workspace = true, optional = true }
|
||||
reqwest = { workspace = true, optional = true }
|
||||
tempfile.workspace = true
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
winrt-notification = { workspace = true, optional = true }
|
||||
|
||||
[features]
|
||||
default = ["jarvis_app"]
|
||||
jarvis_app = ["vosk", "intent-classifier", "tokio", "nnnoiseless", "tokio-tungstenite", "futures-util"]
|
||||
intent = ["intent-classifier", "tokio"]
|
||||
jarvis_app = ["vosk", "intent-classifier", "tokio", "nnnoiseless", "tokio-tungstenite", "futures-util", "lua"]
|
||||
intent = ["intent-classifier", "tokio"]
|
||||
lua = ["mlua", "reqwest", "winrt-notification"]
|
||||
lua_only = ["lua", "tokio"]
|
||||
@@ -10,6 +10,8 @@ pub use structs::*;
|
||||
|
||||
use crate::{config, i18n, APP_DIR};
|
||||
|
||||
#[cfg(feature = "lua")]
|
||||
use crate::lua::{self, SandboxLevel, CommandContext};
|
||||
|
||||
pub fn parse_commands() -> Result<Vec<JCommandsList>, String> {
|
||||
let mut commands: Vec<JCommandsList> = Vec::new();
|
||||
@@ -189,10 +191,20 @@ pub fn execute_cli(cmd: &str, args: &[String]) -> std::io::Result<Child> {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn execute_command(cmd_path: &Path, cmd_config: &JCommand) -> Result<bool, String> {
|
||||
match cmd_config.action.as_str() {
|
||||
pub fn execute_command(cmd_path: &PathBuf, cmd_config: &JCommand, phrase: Option<&str>) -> Result<bool, String> {
|
||||
match cmd_config.cmd_type.as_str() {
|
||||
|
||||
// BRUH
|
||||
"voice" => Ok(true),
|
||||
|
||||
// LUA command
|
||||
#[cfg(feature = "lua")]
|
||||
"lua" => {
|
||||
execute_lua_command(cmd_path, cmd_config, phrase)
|
||||
}
|
||||
|
||||
// AutoHotkey command
|
||||
// @TODO: Consider adding ahk source files execution?
|
||||
"ahk" => {
|
||||
let exe_path_absolute = Path::new(&cmd_config.exe_path);
|
||||
let exe_path_local = cmd_path.join(&cmd_config.exe_path);
|
||||
@@ -208,23 +220,80 @@ pub fn execute_command(cmd_path: &Path, cmd_config: &JCommand) -> Result<bool, S
|
||||
.map_err(|e| format!("AHK process spawn error: {}", e))
|
||||
}
|
||||
|
||||
// CLI command type
|
||||
// @TODO: Consider security restrictions
|
||||
"cli" => {
|
||||
execute_cli(&cmd_config.cli_cmd, &cmd_config.cli_args)
|
||||
.map(|_| true)
|
||||
.map_err(|e| format!("CLI command error: {}", e))
|
||||
}
|
||||
|
||||
// TERMINATOR command (T1000)
|
||||
"terminate" => {
|
||||
std::thread::sleep(Duration::from_secs(2));
|
||||
std::process::exit(0);
|
||||
}
|
||||
|
||||
// STOP CHANING
|
||||
"stop_chaining" => Ok(false),
|
||||
|
||||
_ => Err(format!("Unknown command type: {}", cmd_config.action)),
|
||||
|
||||
// other
|
||||
_ => {
|
||||
error!("Command type unknown: {}", cmd_config.cmd_type);
|
||||
Err(format!("Command type unknown: {}", cmd_config.cmd_type).into())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn list_paths(commands: &[JCommandsList]) -> Vec<&Path> {
|
||||
commands.iter().map(|x| x.path.as_path()).collect()
|
||||
}
|
||||
|
||||
#[cfg(feature = "lua")]
|
||||
fn execute_lua_command(
|
||||
cmd_path: &PathBuf,
|
||||
cmd_config: &JCommand,
|
||||
phrase: Option<&str>,
|
||||
) -> Result<bool, String> {
|
||||
// get script path
|
||||
let script_name = if cmd_config.script.is_empty() {
|
||||
"script.lua"
|
||||
} else {
|
||||
&cmd_config.script
|
||||
};
|
||||
|
||||
let script_path = cmd_path.join(script_name);
|
||||
|
||||
if !script_path.exists() {
|
||||
return Err(format!("Lua script not found: {}", script_path.display()));
|
||||
}
|
||||
|
||||
// parse sandbox level
|
||||
let sandbox = SandboxLevel::from_str(&cmd_config.sandbox);
|
||||
|
||||
// create context
|
||||
let context = CommandContext {
|
||||
phrase: phrase.unwrap_or("").to_string(),
|
||||
command_id: cmd_config.id.clone(),
|
||||
command_path: cmd_path.clone(),
|
||||
language: i18n::get_language(),
|
||||
};
|
||||
|
||||
// get timeout
|
||||
let timeout = Duration::from_millis(cmd_config.timeout);
|
||||
|
||||
info!("Executing Lua command: {} (sandbox: {:?}, timeout: {:?})",
|
||||
cmd_config.id, sandbox, timeout);
|
||||
|
||||
// execute
|
||||
match lua::execute(&script_path, context, sandbox, timeout) {
|
||||
Ok(result) => {
|
||||
info!("Lua command {} completed (chain: {})", cmd_config.id, result.chain);
|
||||
Ok(result.chain)
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Lua command {} failed: {}", cmd_config.id, e);
|
||||
Err(e.to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,26 +15,42 @@ pub struct JCommandsList {
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct JCommand {
|
||||
pub id: String,
|
||||
pub action: String,
|
||||
|
||||
// Available command types are: "lua", "ahk", "cli", "voice", "terminate", "stop_chaining"
|
||||
#[serde(rename = "type")]
|
||||
pub cmd_type: String,
|
||||
|
||||
#[serde(default)]
|
||||
pub description: String,
|
||||
|
||||
// for "ahk" type
|
||||
#[serde(default)]
|
||||
pub exe_path: String,
|
||||
|
||||
#[serde(default)]
|
||||
pub exe_args: Vec<String>,
|
||||
|
||||
// for "cli" type
|
||||
#[serde(default)]
|
||||
pub cli_cmd: String,
|
||||
|
||||
#[serde(default)]
|
||||
pub cli_args: Vec<String>,
|
||||
|
||||
// #[serde(default)]
|
||||
// pub sounds: Vec<String>,
|
||||
|
||||
// for "lua" type
|
||||
#[serde(default)]
|
||||
pub script: String,
|
||||
|
||||
// Lua sandbox level: "minimal", "standard", "full"
|
||||
// basically this is an access level
|
||||
#[serde(default)]
|
||||
pub sandbox: String,
|
||||
|
||||
// Script timeout in milliseconds (default 10000 = 10s)
|
||||
#[serde(default)]
|
||||
pub timeout: u64,
|
||||
|
||||
// Multi-language sounds
|
||||
#[serde(default)]
|
||||
pub sounds: HashMap<String, Vec<String>>,
|
||||
@@ -57,12 +73,20 @@ impl Clone for JCommand {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
id: self.id.clone(),
|
||||
action: self.action.clone(),
|
||||
|
||||
cmd_type: self.cmd_type.clone(),
|
||||
description: self.description.clone(),
|
||||
|
||||
exe_path: self.exe_path.clone(),
|
||||
exe_args: self.exe_args.clone(),
|
||||
|
||||
cli_cmd: self.cli_cmd.clone(),
|
||||
cli_args: self.cli_args.clone(),
|
||||
|
||||
script: self.script.clone(),
|
||||
sandbox: self.sandbox.clone(),
|
||||
timeout: self.timeout.clone(),
|
||||
|
||||
sounds: self.sounds.clone(),
|
||||
phrases: self.phrases.clone(),
|
||||
|
||||
|
||||
@@ -175,6 +175,9 @@ pub const GAIN_MAX: f32 = 3.0; // maximum gain multiplier
|
||||
// nnnoiseless frame size (fixed by library)
|
||||
pub const NNNOISELESS_FRAME_SIZE: usize = 480;
|
||||
|
||||
// LUA
|
||||
pub const DEFAULT_LUA_SANDBOX: &str = "standard";
|
||||
pub const DEFAULT_LUA_TIMEOUT: u64 = 10000; // ms
|
||||
|
||||
// ETC
|
||||
pub const CMD_RATIO_THRESHOLD: f64 = 65f64;
|
||||
|
||||
@@ -38,6 +38,9 @@ pub mod voices;
|
||||
|
||||
pub mod audio_buffer;
|
||||
|
||||
#[cfg(feature = "lua")]
|
||||
pub mod lua;
|
||||
|
||||
// shared statics
|
||||
// pub static APP_DIR: Lazy<PathBuf> = Lazy::new(|| std::env::current_dir().unwrap());
|
||||
pub static APP_DIR: Lazy<PathBuf> = Lazy::new(|| {
|
||||
|
||||
28
crates/jarvis-core/src/lua.rs
Normal file
28
crates/jarvis-core/src/lua.rs
Normal file
@@ -0,0 +1,28 @@
|
||||
mod engine;
|
||||
mod sandbox;
|
||||
mod error;
|
||||
mod api;
|
||||
|
||||
mod structs;
|
||||
pub use structs::*;
|
||||
|
||||
pub use engine::LuaEngine;
|
||||
pub use sandbox::SandboxLevel;
|
||||
pub use error::LuaError;
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
// Execute a Lua command script
|
||||
pub fn execute(
|
||||
script_path: &PathBuf,
|
||||
context: CommandContext,
|
||||
sandbox: SandboxLevel,
|
||||
timeout: Duration,
|
||||
) -> Result<CommandResult, LuaError> {
|
||||
let engine = LuaEngine::new(sandbox)?;
|
||||
engine.execute(script_path, context, timeout)
|
||||
}
|
||||
7
crates/jarvis-core/src/lua/api.rs
Normal file
7
crates/jarvis-core/src/lua/api.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
pub mod core;
|
||||
pub mod audio;
|
||||
pub mod context;
|
||||
pub mod http;
|
||||
pub mod fs;
|
||||
pub mod state;
|
||||
pub mod system;
|
||||
37
crates/jarvis-core/src/lua/api/_new_api_module_example.rs
Normal file
37
crates/jarvis-core/src/lua/api/_new_api_module_example.rs
Normal file
@@ -0,0 +1,37 @@
|
||||
// QUICK HELP ON HOW TO ADD NEW LUA API MODULE
|
||||
//
|
||||
// # 1. DEFINE NEW API MODULE FILE
|
||||
|
||||
use mlua::{Lua, Table};
|
||||
use crate::lua::error::LuaError;
|
||||
|
||||
pub fn register(lua: &Lua, jarvis: &Table) -> mlua::Result<()> {
|
||||
let mymodule = lua.create_table()?;
|
||||
|
||||
// add functions
|
||||
let my_fn = lua.create_function(|_, arg: String| {
|
||||
// implementation
|
||||
Ok(format!("Result: {}", arg))
|
||||
})?;
|
||||
mymodule.set("my_function", my_fn)?;
|
||||
|
||||
jarvis.set("mymodule", mymodule)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// # 2. ADD NEW API MODULE TO mod.rs
|
||||
|
||||
pub mod mymodule;
|
||||
|
||||
|
||||
// # 3. REGISTER NEW MODULE IN engine.rs
|
||||
// in register_api()
|
||||
api::mymodule::register(&self.lua, &jarvis)?;
|
||||
|
||||
// # 4. YOU CAN ALSO DEFINE ASYNC FUNCTIONS INSTEAD
|
||||
let async_fn = lua.create_async_function(|_, url: String| async move {
|
||||
let response = reqwest::get(&url).await
|
||||
.map_err(|e| mlua::Error::runtime(e.to_string()))?;
|
||||
Ok(response.text().await.unwrap_or_default())
|
||||
})?;
|
||||
77
crates/jarvis-core/src/lua/api/audio.rs
Normal file
77
crates/jarvis-core/src/lua/api/audio.rs
Normal file
@@ -0,0 +1,77 @@
|
||||
// Audio Lua API: something sound related, apparently :3
|
||||
|
||||
use mlua::{Lua, Table};
|
||||
use crate::voices::{self, Reaction};
|
||||
|
||||
pub fn register(lua: &Lua, jarvis: &Table) -> mlua::Result<()> {
|
||||
let audio = lua.create_table()?;
|
||||
|
||||
// jarvis.audio.play(reaction)
|
||||
// reactions: "ok", "reply", "greet", "not_found", "error", "goodbye", "thanks"
|
||||
let play_fn = lua.create_function(|_, reaction: String| {
|
||||
let reaction_enum = match reaction.to_lowercase().as_str() {
|
||||
"ok" => Reaction::Ok,
|
||||
"reply" => Reaction::Reply,
|
||||
"greet" => Reaction::Greet,
|
||||
"not_found" => Reaction::NotFound,
|
||||
"error" => Reaction::Error,
|
||||
"goodbye" => Reaction::Goodbye,
|
||||
"thanks" => Reaction::Thanks,
|
||||
// "joke" => Reaction::Joke, NO PUN INTENDED :3
|
||||
_ => {
|
||||
log::warn!("[Lua] Unknown reaction: {}", reaction);
|
||||
return Ok(false);
|
||||
}
|
||||
};
|
||||
|
||||
voices::play(reaction_enum);
|
||||
Ok(true)
|
||||
})?;
|
||||
audio.set("play", play_fn)?;
|
||||
|
||||
// jarvis.audio.play_ok()
|
||||
let play_ok_fn = lua.create_function(|_, ()| {
|
||||
voices::play_ok();
|
||||
Ok(())
|
||||
})?;
|
||||
audio.set("play_ok", play_ok_fn)?;
|
||||
|
||||
// jarvis.audio.play_reply()
|
||||
let play_reply_fn = lua.create_function(|_, ()| {
|
||||
voices::play_reply();
|
||||
Ok(())
|
||||
})?;
|
||||
audio.set("play_reply", play_reply_fn)?;
|
||||
|
||||
// jarvis.audio.play_error()
|
||||
let play_error_fn = lua.create_function(|_, ()| {
|
||||
voices::play_error();
|
||||
Ok(())
|
||||
})?;
|
||||
audio.set("play_error", play_error_fn)?;
|
||||
|
||||
// jarvis.audio.play_not_found()
|
||||
let play_not_found_fn = lua.create_function(|_, ()| {
|
||||
voices::play_not_found();
|
||||
Ok(())
|
||||
})?;
|
||||
audio.set("play_not_found", play_not_found_fn)?;
|
||||
|
||||
// jarvis.audio.play_greet()
|
||||
let play_greet_fn = lua.create_function(|_, ()| {
|
||||
voices::play_greet();
|
||||
Ok(())
|
||||
})?;
|
||||
audio.set("play_greet", play_greet_fn)?;
|
||||
|
||||
// jarvis.audio.play_goodbye()
|
||||
let play_goodbye_fn = lua.create_function(|_, ()| {
|
||||
voices::play_goodbye();
|
||||
Ok(())
|
||||
})?;
|
||||
audio.set("play_goodbye", play_goodbye_fn)?;
|
||||
|
||||
jarvis.set("audio", audio)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
31
crates/jarvis-core/src/lua/api/context.rs
Normal file
31
crates/jarvis-core/src/lua/api/context.rs
Normal file
@@ -0,0 +1,31 @@
|
||||
// Context Lua API: read-only command context
|
||||
|
||||
use mlua::{Lua, Table};
|
||||
use crate::lua::{CommandContext};
|
||||
|
||||
pub fn register(lua: &Lua, jarvis: &Table, ctx: &CommandContext) -> mlua::Result<()> {
|
||||
let context = lua.create_table()?;
|
||||
|
||||
// read-only context values
|
||||
context.set("phrase", ctx.phrase.clone())?;
|
||||
context.set("command_id", ctx.command_id.clone())?;
|
||||
context.set("command_path", ctx.command_path.to_string_lossy().to_string())?;
|
||||
context.set("language", ctx.language.clone())?;
|
||||
|
||||
// time info
|
||||
let time = lua.create_table()?;
|
||||
let now = chrono::Local::now();
|
||||
time.set("year", now.format("%Y").to_string())?;
|
||||
time.set("month", now.format("%m").to_string())?;
|
||||
time.set("day", now.format("%d").to_string())?;
|
||||
time.set("hour", now.format("%H").to_string())?;
|
||||
time.set("minute", now.format("%M").to_string())?;
|
||||
time.set("second", now.format("%S").to_string())?;
|
||||
time.set("weekday", now.format("%A").to_string())?;
|
||||
time.set("timestamp", now.timestamp())?;
|
||||
context.set("time", time)?;
|
||||
|
||||
jarvis.set("context", context)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
50
crates/jarvis-core/src/lua/api/core.rs
Normal file
50
crates/jarvis-core/src/lua/api/core.rs
Normal file
@@ -0,0 +1,50 @@
|
||||
// Core Lua API: log, sleep, print, etc.
|
||||
|
||||
use mlua::{Lua, Table, MultiValue};
|
||||
|
||||
pub fn register(lua: &Lua, jarvis: &Table) -> mlua::Result<()> {
|
||||
|
||||
// @ jarvis.log(level, message)
|
||||
// log something
|
||||
let log_fn = lua.create_function(|_, (level, message): (String, String)| {
|
||||
match level.to_lowercase().as_str() {
|
||||
"debug" => log::debug!("[Lua] {}", message),
|
||||
"info" => log::info!("[Lua] {}", message),
|
||||
"warn" => log::warn!("[Lua] {}", message),
|
||||
"error" => log::error!("[Lua] {}", message),
|
||||
_ => log::info!("[Lua] {}", message),
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
jarvis.set("log", log_fn)?;
|
||||
|
||||
// @ jarvis.print(...)
|
||||
// simple print
|
||||
let print_fn = lua.create_function(|_, args: MultiValue| {
|
||||
let parts: Vec<String> = args.iter()
|
||||
.map(|v| format!("{:?}", v))
|
||||
.collect();
|
||||
log::info!("[Lua] {}", parts.join(" "));
|
||||
Ok(())
|
||||
})?;
|
||||
jarvis.set("print", print_fn)?;
|
||||
|
||||
// @ jarvis.sleep(ms)
|
||||
// ..zZZ
|
||||
let sleep_fn = lua.create_function(|_, ms: u64| {
|
||||
std::thread::sleep(std::time::Duration::from_millis(ms));
|
||||
Ok(())
|
||||
})?;
|
||||
jarvis.set("sleep", sleep_fn)?;
|
||||
|
||||
// @ jarvis.speak(text)
|
||||
// @TODO: update when TTS will be implemented
|
||||
let speak_fn = lua.create_function(|_, text: String| {
|
||||
log::info!("[Lua] SPEAK: {}", text);
|
||||
// pass
|
||||
Ok(())
|
||||
})?;
|
||||
jarvis.set("speak", speak_fn)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
212
crates/jarvis-core/src/lua/api/fs.rs
Normal file
212
crates/jarvis-core/src/lua/api/fs.rs
Normal file
@@ -0,0 +1,212 @@
|
||||
// File System Lua API: read, write, list (sandboxed)
|
||||
|
||||
use mlua::{Lua, Table};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::fs;
|
||||
|
||||
use crate::lua::sandbox::SandboxLevel;
|
||||
|
||||
pub fn register(
|
||||
lua: &Lua,
|
||||
jarvis: &Table,
|
||||
command_path: &PathBuf,
|
||||
sandbox: SandboxLevel,
|
||||
) -> mlua::Result<()> {
|
||||
let fs_table = lua.create_table()?;
|
||||
|
||||
let cmd_path = command_path.clone();
|
||||
let sandbox_level = sandbox;
|
||||
|
||||
// jarvis.fs.read(path)
|
||||
let cmd_path_read = cmd_path.clone();
|
||||
let read_fn = lua.create_function(move |_, path: String| {
|
||||
let full_path = resolve_path(&cmd_path_read, &path, sandbox_level)?;
|
||||
|
||||
fs::read_to_string(&full_path)
|
||||
.map_err(|e| mlua::Error::runtime(format!("Read error: {}", e)))
|
||||
})?;
|
||||
fs_table.set("read", read_fn)?;
|
||||
|
||||
// jarvis.fs.read_bytes(path)
|
||||
let cmd_path_read_bytes = cmd_path.clone();
|
||||
let read_bytes_fn = lua.create_function(move |lua, path: String| {
|
||||
let full_path = resolve_path(&cmd_path_read_bytes, &path, sandbox_level)?;
|
||||
|
||||
let bytes = fs::read(&full_path)
|
||||
.map_err(|e| mlua::Error::runtime(format!("Read error: {}", e)))?;
|
||||
|
||||
Ok(lua.create_string(&bytes)?)
|
||||
})?;
|
||||
fs_table.set("read_bytes", read_bytes_fn)?;
|
||||
|
||||
// jarvis.fs.write(path, content)
|
||||
let cmd_path_write = cmd_path.clone();
|
||||
let write_fn = lua.create_function(move |_, (path, content): (String, String)| {
|
||||
if !sandbox_level.allows_fs_write() {
|
||||
return Err(mlua::Error::runtime("Write not allowed in this sandbox"));
|
||||
}
|
||||
|
||||
let full_path = resolve_path(&cmd_path_write, &path, sandbox_level)?;
|
||||
|
||||
// ensure parent directory exists
|
||||
if let Some(parent) = full_path.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.map_err(|e| mlua::Error::runtime(format!("Create dir error: {}", e)))?;
|
||||
}
|
||||
|
||||
fs::write(&full_path, content)
|
||||
.map_err(|e| mlua::Error::runtime(format!("Write error: {}", e)))?;
|
||||
|
||||
Ok(true)
|
||||
})?;
|
||||
fs_table.set("write", write_fn)?;
|
||||
|
||||
// jarvis.fs.append(path, content)
|
||||
let cmd_path_append = cmd_path.clone();
|
||||
let append_fn = lua.create_function(move |_, (path, content): (String, String)| {
|
||||
if !sandbox_level.allows_fs_write() {
|
||||
return Err(mlua::Error::runtime("Write not allowed in this sandbox"));
|
||||
}
|
||||
|
||||
let full_path = resolve_path(&cmd_path_append, &path, sandbox_level)?;
|
||||
|
||||
use std::io::Write;
|
||||
let mut file = fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&full_path)
|
||||
.map_err(|e| mlua::Error::runtime(format!("Open error: {}", e)))?;
|
||||
|
||||
file.write_all(content.as_bytes())
|
||||
.map_err(|e| mlua::Error::runtime(format!("Write error: {}", e)))?;
|
||||
|
||||
Ok(true)
|
||||
})?;
|
||||
fs_table.set("append", append_fn)?;
|
||||
|
||||
// jarvis.fs.exists(path)
|
||||
let cmd_path_exists = cmd_path.clone();
|
||||
let exists_fn = lua.create_function(move |_, path: String| {
|
||||
let full_path = resolve_path(&cmd_path_exists, &path, sandbox_level)?;
|
||||
Ok(full_path.exists())
|
||||
})?;
|
||||
fs_table.set("exists", exists_fn)?;
|
||||
|
||||
// jarvis.fs.is_file(path)
|
||||
let cmd_path_is_file = cmd_path.clone();
|
||||
let is_file_fn = lua.create_function(move |_, path: String| {
|
||||
let full_path = resolve_path(&cmd_path_is_file, &path, sandbox_level)?;
|
||||
Ok(full_path.is_file())
|
||||
})?;
|
||||
fs_table.set("is_file", is_file_fn)?;
|
||||
|
||||
// jarvis.fs.is_dir(path)
|
||||
let cmd_path_is_dir = cmd_path.clone();
|
||||
let is_dir_fn = lua.create_function(move |_, path: String| {
|
||||
let full_path = resolve_path(&cmd_path_is_dir, &path, sandbox_level)?;
|
||||
Ok(full_path.is_dir())
|
||||
})?;
|
||||
fs_table.set("is_dir", is_dir_fn)?;
|
||||
|
||||
// jarvis.fs.list(path)
|
||||
let cmd_path_list = cmd_path.clone();
|
||||
let list_fn = lua.create_function(move |lua, path: Option<String>| {
|
||||
let full_path = if let Some(p) = path {
|
||||
resolve_path(&cmd_path_list, &p, sandbox_level)?
|
||||
} else {
|
||||
cmd_path_list.clone()
|
||||
};
|
||||
|
||||
let result = lua.create_table()?;
|
||||
|
||||
let entries = fs::read_dir(&full_path)
|
||||
.map_err(|e| mlua::Error::runtime(format!("List error: {}", e)))?;
|
||||
|
||||
let mut idx = 1;
|
||||
for entry in entries {
|
||||
if let Ok(entry) = entry {
|
||||
let item = lua.create_table()?;
|
||||
item.set("name", entry.file_name().to_string_lossy().to_string())?;
|
||||
item.set("path", entry.path().to_string_lossy().to_string())?;
|
||||
item.set("is_file", entry.path().is_file())?;
|
||||
item.set("is_dir", entry.path().is_dir())?;
|
||||
|
||||
result.set(idx, item)?;
|
||||
idx += 1;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
})?;
|
||||
fs_table.set("list", list_fn)?;
|
||||
|
||||
// jarvis.fs.mkdir(path)
|
||||
let cmd_path_mkdir = cmd_path.clone();
|
||||
let mkdir_fn = lua.create_function(move |_, path: String| {
|
||||
if !sandbox_level.allows_fs_write() {
|
||||
return Err(mlua::Error::runtime("Write not allowed in this sandbox"));
|
||||
}
|
||||
|
||||
let full_path = resolve_path(&cmd_path_mkdir, &path, sandbox_level)?;
|
||||
|
||||
fs::create_dir_all(&full_path)
|
||||
.map_err(|e| mlua::Error::runtime(format!("Mkdir error: {}", e)))?;
|
||||
|
||||
Ok(true)
|
||||
})?;
|
||||
fs_table.set("mkdir", mkdir_fn)?;
|
||||
|
||||
// jarvis.fs.remove(path)
|
||||
let cmd_path_remove = cmd_path.clone();
|
||||
let remove_fn = lua.create_function(move |_, path: String| {
|
||||
if !sandbox_level.allows_fs_write() {
|
||||
return Err(mlua::Error::runtime("Write not allowed in this sandbox"));
|
||||
}
|
||||
|
||||
let full_path = resolve_path(&cmd_path_remove, &path, sandbox_level)?;
|
||||
|
||||
if full_path.is_dir() {
|
||||
fs::remove_dir_all(&full_path)
|
||||
.map_err(|e| mlua::Error::runtime(format!("Remove error: {}", e)))?;
|
||||
} else {
|
||||
fs::remove_file(&full_path)
|
||||
.map_err(|e| mlua::Error::runtime(format!("Remove error: {}", e)))?;
|
||||
}
|
||||
|
||||
Ok(true)
|
||||
})?;
|
||||
fs_table.set("remove", remove_fn)?;
|
||||
|
||||
jarvis.set("fs", fs_table)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Resolve path relative to command folder, with sandbox checks
|
||||
fn resolve_path(command_path: &PathBuf, path: &str, sandbox: SandboxLevel) -> mlua::Result<PathBuf> {
|
||||
let path = Path::new(path);
|
||||
|
||||
// if absolute path, check sandbox allows it
|
||||
if path.is_absolute() {
|
||||
if !sandbox.allows_expanded_paths() {
|
||||
return Err(mlua::Error::runtime("Absolute paths not allowed in this sandbox"));
|
||||
}
|
||||
return Ok(path.to_path_buf());
|
||||
}
|
||||
|
||||
// relative path - resolve against command folder
|
||||
let resolved = command_path.join(path);
|
||||
|
||||
// canonicalize to resolve ../ etc and check it's still within command folder
|
||||
let canonical = resolved.canonicalize()
|
||||
.unwrap_or_else(|_| resolved.clone());
|
||||
|
||||
let cmd_canonical = command_path.canonicalize()
|
||||
.unwrap_or_else(|_| command_path.clone());
|
||||
|
||||
if !sandbox.allows_expanded_paths() && !canonical.starts_with(&cmd_canonical) {
|
||||
return Err(mlua::Error::runtime("Path escapes command folder"));
|
||||
}
|
||||
|
||||
Ok(resolved)
|
||||
}
|
||||
226
crates/jarvis-core/src/lua/api/http.rs
Normal file
226
crates/jarvis-core/src/lua/api/http.rs
Normal file
@@ -0,0 +1,226 @@
|
||||
// HTTP Lua API: GET, POST, JSON requests
|
||||
|
||||
use mlua::{Lua, Table, Value};
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
|
||||
pub fn register(lua: &Lua, jarvis: &Table) -> mlua::Result<()> {
|
||||
let http = lua.create_table()?;
|
||||
|
||||
// jarvis.http.get(url, headers?)
|
||||
let get_fn = lua.create_function(|lua, (url, headers): (String, Option<Table>)| {
|
||||
http_request(lua, "GET", &url, None, headers)
|
||||
})?;
|
||||
http.set("get", get_fn)?;
|
||||
|
||||
// jarvis.http.post(url, body, headers?)
|
||||
let post_fn = lua.create_function(|lua, (url, body, headers): (String, Option<String>, Option<Table>)| {
|
||||
http_request(lua, "POST", &url, body, headers)
|
||||
})?;
|
||||
http.set("post", post_fn)?;
|
||||
|
||||
// jarvis.http.post_json(url, data, headers?)
|
||||
let post_json_fn = lua.create_function(|lua, (url, data, headers): (String, Table, Option<Table>)| {
|
||||
// convert Lua table to JSON string
|
||||
let json_value = table_to_json(lua, data)?;
|
||||
let body = serde_json::to_string(&json_value)
|
||||
.map_err(|e| mlua::Error::runtime(e.to_string()))?;
|
||||
|
||||
// add content-type header
|
||||
let mut header_map: HashMap<String, String> = HashMap::new();
|
||||
header_map.insert("Content-Type".to_string(), "application/json".to_string());
|
||||
|
||||
if let Some(h) = headers {
|
||||
for pair in h.pairs::<String, String>() {
|
||||
if let Ok((k, v)) = pair {
|
||||
header_map.insert(k, v);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
http_request_with_headers(lua, "POST", &url, Some(body), header_map)
|
||||
})?;
|
||||
http.set("post_json", post_json_fn)?;
|
||||
|
||||
// jarvis.http.json(url) - GET + parse JSON
|
||||
let json_fn = lua.create_function(|lua, url: String| {
|
||||
let client = reqwest::blocking::Client::builder()
|
||||
.timeout(Duration::from_secs(30))
|
||||
.build()
|
||||
.map_err(|e| mlua::Error::runtime(e.to_string()))?;
|
||||
|
||||
let response = client.get(&url)
|
||||
.send()
|
||||
.map_err(|e| mlua::Error::runtime(e.to_string()))?;
|
||||
|
||||
if response.status().is_success() {
|
||||
let json: serde_json::Value = response.json()
|
||||
.map_err(|e| mlua::Error::runtime(e.to_string()))?;
|
||||
json_to_lua(lua, json)
|
||||
} else {
|
||||
Ok(Value::Nil)
|
||||
}
|
||||
})?;
|
||||
http.set("json", json_fn)?;
|
||||
|
||||
jarvis.set("http", http)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn http_request(
|
||||
lua: &Lua,
|
||||
method: &str,
|
||||
url: &str,
|
||||
body: Option<String>,
|
||||
headers: Option<Table>,
|
||||
) -> mlua::Result<Table> {
|
||||
let header_map: HashMap<String, String> = if let Some(h) = headers {
|
||||
h.pairs::<String, String>()
|
||||
.filter_map(|r| r.ok())
|
||||
.collect()
|
||||
} else {
|
||||
HashMap::new()
|
||||
};
|
||||
|
||||
http_request_with_headers(lua, method, url, body, header_map)
|
||||
}
|
||||
|
||||
fn http_request_with_headers(
|
||||
lua: &Lua,
|
||||
method: &str,
|
||||
url: &str,
|
||||
body: Option<String>,
|
||||
headers: HashMap<String, String>,
|
||||
) -> mlua::Result<Table> {
|
||||
let client = reqwest::blocking::Client::builder()
|
||||
.timeout(Duration::from_secs(30))
|
||||
.build()
|
||||
.map_err(|e| mlua::Error::runtime(e.to_string()))?;
|
||||
|
||||
let mut request = match method {
|
||||
"POST" => client.post(url),
|
||||
"PUT" => client.put(url),
|
||||
"DELETE" => client.delete(url),
|
||||
_ => client.get(url),
|
||||
};
|
||||
|
||||
for (k, v) in headers {
|
||||
request = request.header(&k, &v);
|
||||
}
|
||||
|
||||
if let Some(b) = body {
|
||||
request = request.body(b);
|
||||
}
|
||||
|
||||
let result = lua.create_table()?;
|
||||
|
||||
match request.send() {
|
||||
Ok(response) => {
|
||||
result.set("ok", response.status().is_success())?;
|
||||
result.set("status", response.status().as_u16())?;
|
||||
|
||||
// get headers
|
||||
let headers_table = lua.create_table()?;
|
||||
for (name, value) in response.headers() {
|
||||
if let Ok(v) = value.to_str() {
|
||||
headers_table.set(name.as_str(), v)?;
|
||||
}
|
||||
}
|
||||
result.set("headers", headers_table)?;
|
||||
|
||||
// get body
|
||||
match response.text() {
|
||||
Ok(text) => result.set("body", text)?,
|
||||
Err(e) => result.set("body", format!("Error reading body: {}", e))?,
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
result.set("ok", false)?;
|
||||
result.set("status", 0)?;
|
||||
result.set("error", e.to_string())?;
|
||||
result.set("body", "")?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
// Convert Lua table to serde_json::Value
|
||||
fn table_to_json(lua: &Lua, table: Table) -> mlua::Result<serde_json::Value> {
|
||||
use serde_json::{Value as JsonValue, Map, Number};
|
||||
|
||||
// check if it's an array (sequential integer keys starting from 1)
|
||||
let is_array = table.clone().pairs::<i64, Value>()
|
||||
.filter_map(|r| r.ok())
|
||||
.enumerate()
|
||||
.all(|(i, (k, _))| k == (i + 1) as i64);
|
||||
|
||||
if is_array && table.len()? > 0 {
|
||||
let arr: Vec<JsonValue> = table.sequence_values::<Value>()
|
||||
.filter_map(|r| r.ok())
|
||||
.map(|v| lua_to_json(lua, v))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
Ok(JsonValue::Array(arr))
|
||||
} else {
|
||||
let mut map = Map::new();
|
||||
for pair in table.pairs::<String, Value>() {
|
||||
let (k, v) = pair?;
|
||||
map.insert(k, lua_to_json(lua, v)?);
|
||||
}
|
||||
Ok(JsonValue::Object(map))
|
||||
}
|
||||
}
|
||||
|
||||
// Convert Lua Value to serde_json::Value
|
||||
fn lua_to_json(lua: &Lua, value: Value) -> mlua::Result<serde_json::Value> {
|
||||
use serde_json::{Value as JsonValue, Number};
|
||||
|
||||
match value {
|
||||
Value::Nil => Ok(JsonValue::Null),
|
||||
Value::Boolean(b) => Ok(JsonValue::Bool(b)),
|
||||
Value::Integer(i) => Ok(JsonValue::Number(Number::from(i))),
|
||||
Value::Number(n) => {
|
||||
Number::from_f64(n)
|
||||
.map(JsonValue::Number)
|
||||
.ok_or_else(|| mlua::Error::runtime("Invalid float"))
|
||||
}
|
||||
Value::String(s) => Ok(JsonValue::String(s.to_str()?.to_string())),
|
||||
Value::Table(t) => table_to_json(lua, t),
|
||||
_ => Err(mlua::Error::runtime("Unsupported type for JSON")),
|
||||
}
|
||||
}
|
||||
|
||||
// Convert serde_json::Value to Lua Value
|
||||
fn json_to_lua(lua: &Lua, json: serde_json::Value) -> mlua::Result<Value> {
|
||||
use serde_json::Value as JsonValue;
|
||||
|
||||
match json {
|
||||
JsonValue::Null => Ok(Value::Nil),
|
||||
JsonValue::Bool(b) => Ok(Value::Boolean(b)),
|
||||
JsonValue::Number(n) => {
|
||||
if let Some(i) = n.as_i64() {
|
||||
Ok(Value::Integer(i))
|
||||
} else if let Some(f) = n.as_f64() {
|
||||
Ok(Value::Number(f))
|
||||
} else {
|
||||
Ok(Value::Nil)
|
||||
}
|
||||
}
|
||||
JsonValue::String(s) => Ok(Value::String(lua.create_string(&s)?)),
|
||||
JsonValue::Array(arr) => {
|
||||
let table = lua.create_table()?;
|
||||
for (i, v) in arr.into_iter().enumerate() {
|
||||
table.set(i + 1, json_to_lua(lua, v)?)?;
|
||||
}
|
||||
Ok(Value::Table(table))
|
||||
}
|
||||
JsonValue::Object(map) => {
|
||||
let table = lua.create_table()?;
|
||||
for (k, v) in map {
|
||||
table.set(k, json_to_lua(lua, v)?)?;
|
||||
}
|
||||
Ok(Value::Table(table))
|
||||
}
|
||||
}
|
||||
}
|
||||
0
crates/jarvis-core/src/lua/api/settings.rs
Normal file
0
crates/jarvis-core/src/lua/api/settings.rs
Normal file
184
crates/jarvis-core/src/lua/api/state.rs
Normal file
184
crates/jarvis-core/src/lua/api/state.rs
Normal file
@@ -0,0 +1,184 @@
|
||||
// State Lua API: persistent key-value storage per command
|
||||
|
||||
use mlua::{Lua, Table, Result, Value};
|
||||
use std::path::PathBuf;
|
||||
use std::fs;
|
||||
use std::collections::HashMap;
|
||||
|
||||
const STATE_FILE: &str = ".state.json";
|
||||
|
||||
pub fn register(lua: &Lua, jarvis: &Table, command_path: &PathBuf) -> mlua::Result<()> {
|
||||
let state = lua.create_table()?;
|
||||
let state_path = command_path.join(STATE_FILE);
|
||||
|
||||
// jarvis.state.get(key)
|
||||
let state_path_get = state_path.clone();
|
||||
let get_fn = lua.create_function(move |lua, key: String| {
|
||||
let data = load_state(&state_path_get);
|
||||
|
||||
if let Some(value) = data.get(&key) {
|
||||
json_to_lua_value(lua, value.clone())
|
||||
} else {
|
||||
Ok(Value::Nil)
|
||||
}
|
||||
})?;
|
||||
state.set("get", get_fn)?;
|
||||
|
||||
// jarvis.state.set(key, value)
|
||||
let state_path_set = state_path.clone();
|
||||
let set_fn = lua.create_function(move |_, (key, value): (String, Value)| {
|
||||
let mut data = load_state(&state_path_set);
|
||||
|
||||
let json_value = lua_to_json_value(value)?;
|
||||
data.insert(key, json_value);
|
||||
|
||||
save_state(&state_path_set, &data)?;
|
||||
Ok(true)
|
||||
})?;
|
||||
state.set("set", set_fn)?;
|
||||
|
||||
// jarvis.state.delete(key)
|
||||
let state_path_delete = state_path.clone();
|
||||
let delete_fn = lua.create_function(move |_, key: String| {
|
||||
let mut data = load_state(&state_path_delete);
|
||||
let existed = data.remove(&key).is_some();
|
||||
save_state(&state_path_delete, &data)?;
|
||||
Ok(existed)
|
||||
})?;
|
||||
state.set("delete", delete_fn)?;
|
||||
|
||||
// jarvis.state.clear()
|
||||
let state_path_clear = state_path.clone();
|
||||
let clear_fn = lua.create_function(move |_, ()| {
|
||||
let data: HashMap<String, serde_json::Value> = HashMap::new();
|
||||
save_state(&state_path_clear, &data)?;
|
||||
Ok(true)
|
||||
})?;
|
||||
state.set("clear", clear_fn)?;
|
||||
|
||||
// jarvis.state.keys()
|
||||
let state_path_keys = state_path.clone();
|
||||
let keys_fn = lua.create_function(move |lua, ()| {
|
||||
let data = load_state(&state_path_keys);
|
||||
let table = lua.create_table()?;
|
||||
|
||||
for (i, key) in data.keys().enumerate() {
|
||||
table.set(i + 1, key.clone())?;
|
||||
}
|
||||
|
||||
Ok(table)
|
||||
})?;
|
||||
state.set("keys", keys_fn)?;
|
||||
|
||||
// jarvis.state.all()
|
||||
let state_path_all = state_path.clone();
|
||||
let all_fn = lua.create_function(move |lua, ()| {
|
||||
let data = load_state(&state_path_all);
|
||||
let table = lua.create_table()?;
|
||||
|
||||
for (key, value) in data {
|
||||
let lua_value = json_to_lua_value(lua, value)?;
|
||||
table.set(key, lua_value)?;
|
||||
}
|
||||
|
||||
Ok(table)
|
||||
})?;
|
||||
state.set("all", all_fn)?;
|
||||
|
||||
jarvis.set("state", state)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn load_state(path: &PathBuf) -> HashMap<String, serde_json::Value> {
|
||||
if !path.exists() {
|
||||
return HashMap::new();
|
||||
}
|
||||
|
||||
fs::read_to_string(path)
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn save_state(path: &PathBuf, data: &HashMap<String, serde_json::Value>) -> mlua::Result<()> {
|
||||
let json = serde_json::to_string_pretty(data)
|
||||
.map_err(|e| mlua::Error::runtime(e.to_string()))?;
|
||||
|
||||
fs::write(path, json)
|
||||
.map_err(|e| mlua::Error::runtime(e.to_string()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn lua_to_json_value(value: Value) -> mlua::Result<serde_json::Value> {
|
||||
use serde_json::Value as JsonValue;
|
||||
|
||||
match value {
|
||||
Value::Nil => Ok(JsonValue::Null),
|
||||
Value::Boolean(b) => Ok(JsonValue::Bool(b)),
|
||||
Value::Integer(i) => Ok(JsonValue::Number(i.into())),
|
||||
Value::Number(n) => {
|
||||
serde_json::Number::from_f64(n)
|
||||
.map(JsonValue::Number)
|
||||
.ok_or_else(|| mlua::Error::runtime("Invalid float"))
|
||||
}
|
||||
Value::String(s) => Ok(JsonValue::String(s.to_str()?.to_string())),
|
||||
Value::Table(t) => {
|
||||
// check if array
|
||||
let is_array = t.clone().pairs::<i64, Value>()
|
||||
.filter_map(|r| r.ok())
|
||||
.enumerate()
|
||||
.all(|(i, (k, _))| k == (i + 1) as i64);
|
||||
|
||||
if is_array && t.len().unwrap_or(0) > 0 {
|
||||
let arr: Vec<JsonValue> = t.sequence_values::<Value>()
|
||||
.filter_map(|r| r.ok())
|
||||
.map(lua_to_json_value)
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
Ok(JsonValue::Array(arr))
|
||||
} else {
|
||||
let mut map = serde_json::Map::new();
|
||||
for pair in t.pairs::<String, Value>() {
|
||||
let (k, v) = pair?;
|
||||
map.insert(k, lua_to_json_value(v)?);
|
||||
}
|
||||
Ok(JsonValue::Object(map))
|
||||
}
|
||||
}
|
||||
_ => Err(mlua::Error::runtime("Unsupported type for state")),
|
||||
}
|
||||
}
|
||||
|
||||
fn json_to_lua_value(lua: &Lua, json: serde_json::Value) -> mlua::Result<Value> {
|
||||
use serde_json::Value as JsonValue;
|
||||
|
||||
match json {
|
||||
JsonValue::Null => Ok(Value::Nil),
|
||||
JsonValue::Bool(b) => Ok(Value::Boolean(b)),
|
||||
JsonValue::Number(n) => {
|
||||
if let Some(i) = n.as_i64() {
|
||||
Ok(Value::Integer(i))
|
||||
} else if let Some(f) = n.as_f64() {
|
||||
Ok(Value::Number(f))
|
||||
} else {
|
||||
Ok(Value::Nil)
|
||||
}
|
||||
}
|
||||
JsonValue::String(s) => Ok(Value::String(lua.create_string(&s)?)),
|
||||
JsonValue::Array(arr) => {
|
||||
let table = lua.create_table()?;
|
||||
for (i, v) in arr.into_iter().enumerate() {
|
||||
table.set(i + 1, json_to_lua_value(lua, v)?)?;
|
||||
}
|
||||
Ok(Value::Table(table))
|
||||
}
|
||||
JsonValue::Object(map) => {
|
||||
let table = lua.create_table()?;
|
||||
for (k, v) in map {
|
||||
table.set(k, json_to_lua_value(lua, v)?)?;
|
||||
}
|
||||
Ok(Value::Table(table))
|
||||
}
|
||||
}
|
||||
}
|
||||
240
crates/jarvis-core/src/lua/api/system.rs
Normal file
240
crates/jarvis-core/src/lua/api/system.rs
Normal file
@@ -0,0 +1,240 @@
|
||||
// System Lua API: exec, open, clipboard, notify
|
||||
|
||||
use mlua::{Lua, Table};
|
||||
use std::process::Command;
|
||||
|
||||
use crate::lua::sandbox::SandboxLevel;
|
||||
|
||||
pub fn register(lua: &Lua, jarvis: &Table, sandbox: SandboxLevel) -> mlua::Result<()> {
|
||||
let system = lua.create_table()?;
|
||||
|
||||
// jarvis.system.open(url_or_path) - always available
|
||||
let open_fn = lua.create_function(|_, target: String| {
|
||||
let result = if cfg!(target_os = "windows") {
|
||||
Command::new("cmd")
|
||||
.args(["/C", "start", "", &target])
|
||||
.spawn()
|
||||
} else if cfg!(target_os = "macos") {
|
||||
Command::new("open")
|
||||
.arg(&target)
|
||||
.spawn()
|
||||
} else {
|
||||
Command::new("xdg-open")
|
||||
.arg(&target)
|
||||
.spawn()
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(_) => Ok(true),
|
||||
Err(e) => {
|
||||
log::warn!("[Lua] Failed to open {}: {}", target, e);
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
})?;
|
||||
system.set("open", open_fn)?;
|
||||
|
||||
// jarvis.system.exec(cmd, args?) - only in full sandbox
|
||||
if sandbox.allows_exec() {
|
||||
let exec_fn = lua.create_function(|lua, (cmd, args): (String, Option<Table>)| {
|
||||
let mut command = if cfg!(target_os = "windows") {
|
||||
let mut c = Command::new("cmd");
|
||||
c.args(["/C", &cmd]);
|
||||
c
|
||||
} else {
|
||||
let mut c = Command::new("sh");
|
||||
c.args(["-c", &cmd]);
|
||||
c
|
||||
};
|
||||
|
||||
// add extra args if provided
|
||||
if let Some(args_table) = args {
|
||||
for pair in args_table.sequence_values::<String>() {
|
||||
if let Ok(arg) = pair {
|
||||
command.arg(arg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let output = command.output()
|
||||
.map_err(|e| mlua::Error::runtime(e.to_string()))?;
|
||||
|
||||
let result = lua.create_table()?;
|
||||
result.set("success", output.status.success())?;
|
||||
result.set("code", output.status.code().unwrap_or(-1))?;
|
||||
result.set("stdout", String::from_utf8_lossy(&output.stdout).to_string())?;
|
||||
result.set("stderr", String::from_utf8_lossy(&output.stderr).to_string())?;
|
||||
|
||||
Ok(result)
|
||||
})?;
|
||||
system.set("exec", exec_fn)?;
|
||||
}
|
||||
|
||||
// jarvis.system.notify(title, message) - always available
|
||||
let notify_fn = lua.create_function(|_, (title, message): (String, String)| {
|
||||
log::info!("[Lua] NOTIFY: {} - {}", title, message);
|
||||
|
||||
// platform-specific notification
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
use winrt_notification::{Toast, Duration as ToastDuration};
|
||||
|
||||
if let Err(e) = Toast::new(Toast::POWERSHELL_APP_ID)
|
||||
.title(&title)
|
||||
.text1(&message)
|
||||
.duration(ToastDuration::Short)
|
||||
.show()
|
||||
{
|
||||
log::warn!("[Lua] Failed to show toast notification: {}", e);
|
||||
// fallback to msg.exe
|
||||
let _ = Command::new("msg")
|
||||
.args(["*", "/time:10", &format!("{}: {}", title, message)])
|
||||
.spawn();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let _ = Command::new("notify-send")
|
||||
.args([&title, &message])
|
||||
.spawn();
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let script = format!(
|
||||
r#"display notification "{}" with title "{}""#,
|
||||
message.replace("\"", "\\\""),
|
||||
title.replace("\"", "\\\"")
|
||||
);
|
||||
let _ = Command::new("osascript")
|
||||
.args(["-e", &script])
|
||||
.spawn();
|
||||
}
|
||||
|
||||
Ok(true)
|
||||
})?;
|
||||
system.set("notify", notify_fn)?;
|
||||
|
||||
// jarvis.system.clipboard - subtable
|
||||
let clipboard = lua.create_table()?;
|
||||
|
||||
// jarvis.system.clipboard.get() - always available
|
||||
let clipboard_get_fn = lua.create_function(|_, ()| {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let output = Command::new("powershell")
|
||||
.args(["-Command", "Get-Clipboard"])
|
||||
.output()
|
||||
.map_err(|e| mlua::Error::runtime(e.to_string()))?;
|
||||
|
||||
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let output = Command::new("xclip")
|
||||
.args(["-selection", "clipboard", "-o"])
|
||||
.output()
|
||||
.or_else(|_| {
|
||||
Command::new("xsel")
|
||||
.args(["--clipboard", "--output"])
|
||||
.output()
|
||||
})
|
||||
.map_err(|e| mlua::Error::runtime(e.to_string()))?;
|
||||
|
||||
Ok(String::from_utf8_lossy(&output.stdout).to_string())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let output = Command::new("pbpaste")
|
||||
.output()
|
||||
.map_err(|e| mlua::Error::runtime(e.to_string()))?;
|
||||
|
||||
Ok(String::from_utf8_lossy(&output.stdout).to_string())
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "windows", target_os = "linux", target_os = "macos")))]
|
||||
{
|
||||
Err(mlua::Error::runtime("Clipboard not supported on this platform"))
|
||||
}
|
||||
})?;
|
||||
clipboard.set("get", clipboard_get_fn)?;
|
||||
|
||||
// jarvis.system.clipboard.set(text) - only in full sandbox
|
||||
if sandbox.allows_clipboard_write() {
|
||||
let clipboard_set_fn = lua.create_function(|_, text: String| {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let script = format!("Set-Clipboard -Value '{}'", text.replace("'", "''"));
|
||||
Command::new("powershell")
|
||||
.args(["-Command", &script])
|
||||
.output()
|
||||
.map_err(|e| mlua::Error::runtime(e.to_string()))?;
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
use std::io::Write;
|
||||
let mut child = Command::new("xclip")
|
||||
.args(["-selection", "clipboard"])
|
||||
.stdin(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
.or_else(|_| {
|
||||
Command::new("xsel")
|
||||
.args(["--clipboard", "--input"])
|
||||
.stdin(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
})
|
||||
.map_err(|e| mlua::Error::runtime(e.to_string()))?;
|
||||
|
||||
if let Some(stdin) = child.stdin.as_mut() {
|
||||
stdin.write_all(text.as_bytes())
|
||||
.map_err(|e| mlua::Error::runtime(e.to_string()))?;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
use std::io::Write;
|
||||
let mut child = Command::new("pbcopy")
|
||||
.stdin(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| mlua::Error::runtime(e.to_string()))?;
|
||||
|
||||
if let Some(stdin) = child.stdin.as_mut() {
|
||||
stdin.write_all(text.as_bytes())
|
||||
.map_err(|e| mlua::Error::runtime(e.to_string()))?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(true)
|
||||
})?;
|
||||
clipboard.set("set", clipboard_set_fn)?;
|
||||
}
|
||||
|
||||
system.set("clipboard", clipboard)?;
|
||||
|
||||
// jarvis.system.env(name) - get environment variable (always available)
|
||||
let env_fn = lua.create_function(|_, name: String| {
|
||||
Ok(std::env::var(&name).ok())
|
||||
})?;
|
||||
system.set("env", env_fn)?;
|
||||
|
||||
// jarvis.system.platform - read-only string
|
||||
let platform = if cfg!(target_os = "windows") {
|
||||
"windows"
|
||||
} else if cfg!(target_os = "macos") {
|
||||
"macos"
|
||||
} else if cfg!(target_os = "linux") {
|
||||
"linux"
|
||||
} else {
|
||||
"unknown"
|
||||
};
|
||||
system.set("platform", platform)?;
|
||||
|
||||
jarvis.set("system", system)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
171
crates/jarvis-core/src/lua/engine.rs
Normal file
171
crates/jarvis-core/src/lua/engine.rs
Normal file
@@ -0,0 +1,171 @@
|
||||
use mlua::{Lua, Result as LuaResult, Value, StdLib};
|
||||
use std::path::PathBuf;
|
||||
use std::time::{Duration, Instant};
|
||||
use std::fs;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, mpsc};
|
||||
|
||||
use super::sandbox::SandboxLevel;
|
||||
use super::error::LuaError;
|
||||
use super::{CommandContext, CommandResult};
|
||||
use super::api;
|
||||
|
||||
pub struct LuaEngine {
|
||||
lua: Lua,
|
||||
sandbox: SandboxLevel,
|
||||
}
|
||||
|
||||
|
||||
|
||||
impl LuaEngine {
|
||||
pub fn new(sandbox: SandboxLevel) -> Result<Self, LuaError> {
|
||||
// select which standard libraries to load based on sandbox access level
|
||||
let std_libs = match sandbox {
|
||||
SandboxLevel::Minimal => {
|
||||
StdLib::TABLE | StdLib::STRING | StdLib::MATH
|
||||
}
|
||||
SandboxLevel::Standard => {
|
||||
StdLib::TABLE | StdLib::STRING | StdLib::MATH | StdLib::UTF8
|
||||
}
|
||||
SandboxLevel::Full => {
|
||||
StdLib::TABLE | StdLib::STRING | StdLib::MATH | StdLib::UTF8 | StdLib::OS
|
||||
}
|
||||
};
|
||||
|
||||
let lua = Lua::new_with(std_libs, mlua::LuaOptions::default())
|
||||
.map_err(|e| LuaError::InitError(e.to_string()))?;
|
||||
|
||||
// remove dangerous globals regardless of sandbox
|
||||
{
|
||||
let globals = lua.globals();
|
||||
|
||||
// always remove these
|
||||
let _ = globals.set("loadfile", Value::Nil);
|
||||
let _ = globals.set("dofile", Value::Nil);
|
||||
let _ = globals.set("load", Value::Nil);
|
||||
let _ = globals.set("loadstring", Value::Nil);
|
||||
|
||||
// remove io unless full sandbox
|
||||
if !matches!(sandbox, SandboxLevel::Full) {
|
||||
let _ = globals.set("io", Value::Nil);
|
||||
}
|
||||
|
||||
// remove os.execute, os.exit, os.setlocale even in full mode
|
||||
// for SECURITY REASONS!!!
|
||||
if matches!(sandbox, SandboxLevel::Full) {
|
||||
if let Ok(os) = globals.get::<mlua::Table>("os") {
|
||||
let _ = os.set("execute", Value::Nil);
|
||||
let _ = os.set("exit", Value::Nil);
|
||||
let _ = os.set("remove", Value::Nil);
|
||||
let _ = os.set("rename", Value::Nil);
|
||||
let _ = os.set("setlocale", Value::Nil);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Self { lua, sandbox })
|
||||
}
|
||||
|
||||
// Register all jarvis APIs
|
||||
fn register_api(&self, context: &CommandContext) -> Result<(), LuaError> {
|
||||
let globals = self.lua.globals();
|
||||
|
||||
// main jarvis table
|
||||
let jarvis = self.lua.create_table()
|
||||
.map_err(|e| LuaError::InitError(e.to_string()))?;
|
||||
|
||||
// always register core APIs
|
||||
api::core::register(&self.lua, &jarvis)?;
|
||||
api::audio::register(&self.lua, &jarvis)?;
|
||||
api::context::register(&self.lua, &jarvis, context)?;
|
||||
|
||||
// sandbox-controlled APIs
|
||||
if self.sandbox.allows_http() {
|
||||
api::http::register(&self.lua, &jarvis)?;
|
||||
}
|
||||
|
||||
if self.sandbox.allows_state() {
|
||||
api::state::register(&self.lua, &jarvis, &context.command_path)?;
|
||||
}
|
||||
|
||||
if self.sandbox.allows_fs() {
|
||||
api::fs::register(&self.lua, &jarvis, &context.command_path, self.sandbox)?;
|
||||
}
|
||||
|
||||
api::system::register(&self.lua, &jarvis, self.sandbox)?;
|
||||
|
||||
globals.set("jarvis", jarvis)
|
||||
.map_err(|e| LuaError::InitError(e.to_string()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Main LUA executor
|
||||
pub fn execute(
|
||||
&self,
|
||||
script_path: &PathBuf,
|
||||
context: CommandContext,
|
||||
timeout: Duration,
|
||||
) -> Result<CommandResult, LuaError> {
|
||||
// register APIs
|
||||
self.register_api(&context)?;
|
||||
|
||||
// load script
|
||||
let script_content = fs::read_to_string(script_path)
|
||||
.map_err(|e| LuaError::LoadError(format!("{}: {}", script_path.display(), e)))?;
|
||||
|
||||
let script_name = script_path.file_name()
|
||||
.unwrap()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
|
||||
|
||||
// set up timeout hook
|
||||
let start = std::time::Instant::now();
|
||||
self.lua.set_hook(mlua::HookTriggers {
|
||||
every_nth_instruction: Some(1000),
|
||||
..Default::default()
|
||||
}, move |_lua, _debug| {
|
||||
if start.elapsed() > timeout {
|
||||
Err(mlua::Error::runtime("Script timeout"))
|
||||
} else {
|
||||
Ok(mlua::VmState::Continue)
|
||||
}
|
||||
}).map_err(|e| LuaError::InitError(e.to_string()))?;
|
||||
|
||||
// execute script
|
||||
let result = self.lua.load(&script_content)
|
||||
.set_name(&script_name)
|
||||
.eval::<Value>();
|
||||
|
||||
// remove hook
|
||||
let _ = self.lua.remove_hook();
|
||||
|
||||
// result
|
||||
match result {
|
||||
Ok(value) => Ok(self.parse_result(value)),
|
||||
Err(e) => {
|
||||
if e.to_string().contains("timeout") {
|
||||
Err(LuaError::Timeout)
|
||||
} else {
|
||||
Err(LuaError::RuntimeError(e.to_string()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parse Lua return value into CommandResult
|
||||
fn parse_result(&self, value: Value) -> CommandResult {
|
||||
match value {
|
||||
// return { chain = false }
|
||||
Value::Table(t) => {
|
||||
let chain = t.get::<bool>("chain").unwrap_or(true);
|
||||
CommandResult { chain }
|
||||
}
|
||||
// return false (shorthand for no chain)
|
||||
Value::Boolean(chain) => CommandResult { chain },
|
||||
// return nil or no return = chain
|
||||
_ => CommandResult::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
49
crates/jarvis-core/src/lua/error.rs
Normal file
49
crates/jarvis-core/src/lua/error.rs
Normal file
@@ -0,0 +1,49 @@
|
||||
use std::fmt;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum LuaError {
|
||||
// Failed to create Lua VM
|
||||
InitError(String),
|
||||
|
||||
// Failed to load script file
|
||||
LoadError(String),
|
||||
|
||||
// Script execution error
|
||||
RuntimeError(String),
|
||||
|
||||
// Script exceeded timeout
|
||||
Timeout,
|
||||
|
||||
// Sandbox violation
|
||||
SandboxViolation(String),
|
||||
|
||||
// IO error
|
||||
IoError(std::io::Error),
|
||||
}
|
||||
|
||||
impl fmt::Display for LuaError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
LuaError::InitError(msg) => write!(f, "Lua init error: {}", msg),
|
||||
LuaError::LoadError(msg) => write!(f, "Script load error: {}", msg),
|
||||
LuaError::RuntimeError(msg) => write!(f, "Runtime error: {}", msg),
|
||||
LuaError::Timeout => write!(f, "Script timeout"),
|
||||
LuaError::SandboxViolation(msg) => write!(f, "Sandbox violation: {}", msg),
|
||||
LuaError::IoError(e) => write!(f, "IO error: {}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for LuaError {}
|
||||
|
||||
impl From<mlua::Error> for LuaError {
|
||||
fn from(e: mlua::Error) -> Self {
|
||||
LuaError::RuntimeError(e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<std::io::Error> for LuaError {
|
||||
fn from(e: std::io::Error) -> Self {
|
||||
LuaError::IoError(e)
|
||||
}
|
||||
}
|
||||
65
crates/jarvis-core/src/lua/sandbox.rs
Normal file
65
crates/jarvis-core/src/lua/sandbox.rs
Normal file
@@ -0,0 +1,65 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// Sandbox level controlling what APIs are available
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum SandboxLevel {
|
||||
// Minimal: only core APIs (log, speak, audio, context)
|
||||
Minimal,
|
||||
|
||||
// Standard: + http, state, fs (command folder only)
|
||||
Standard,
|
||||
|
||||
// Full: + system.exec, expanded fs access
|
||||
Full,
|
||||
}
|
||||
|
||||
impl SandboxLevel {
|
||||
pub fn from_str(s: &str) -> Self {
|
||||
match s.to_lowercase().as_str() {
|
||||
"minimal" => SandboxLevel::Minimal,
|
||||
"full" => SandboxLevel::Full,
|
||||
_ => SandboxLevel::Standard,
|
||||
}
|
||||
}
|
||||
|
||||
// Can use HTTP API
|
||||
pub fn allows_http(&self) -> bool {
|
||||
matches!(self, SandboxLevel::Standard | SandboxLevel::Full)
|
||||
}
|
||||
|
||||
// Can use persistent state API
|
||||
pub fn allows_state(&self) -> bool {
|
||||
matches!(self, SandboxLevel::Standard | SandboxLevel::Full)
|
||||
}
|
||||
|
||||
// Can use file system API
|
||||
pub fn allows_fs(&self) -> bool {
|
||||
matches!(self, SandboxLevel::Standard | SandboxLevel::Full)
|
||||
}
|
||||
|
||||
// Can write files
|
||||
pub fn allows_fs_write(&self) -> bool {
|
||||
matches!(self, SandboxLevel::Standard | SandboxLevel::Full)
|
||||
}
|
||||
|
||||
// Can execute system commands
|
||||
pub fn allows_exec(&self) -> bool {
|
||||
matches!(self, SandboxLevel::Full)
|
||||
}
|
||||
|
||||
// Can access clipboard write
|
||||
pub fn allows_clipboard_write(&self) -> bool {
|
||||
matches!(self, SandboxLevel::Full)
|
||||
}
|
||||
|
||||
// Can access paths outside command folder
|
||||
pub fn allows_expanded_paths(&self) -> bool {
|
||||
matches!(self, SandboxLevel::Full)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for SandboxLevel {
|
||||
fn default() -> Self {
|
||||
SandboxLevel::Standard
|
||||
}
|
||||
}
|
||||
30
crates/jarvis-core/src/lua/structs.rs
Normal file
30
crates/jarvis-core/src/lua/structs.rs
Normal file
@@ -0,0 +1,30 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
// Context passed to Lua scripts
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CommandContext {
|
||||
// The phrase that triggered the command
|
||||
pub phrase: String,
|
||||
|
||||
// Command ID
|
||||
pub command_id: String,
|
||||
|
||||
// Path to command folder
|
||||
pub command_path: PathBuf,
|
||||
|
||||
// Current language
|
||||
pub language: String,
|
||||
}
|
||||
|
||||
// Result returned from Lua script execution
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CommandResult {
|
||||
// Whether to continue chaining commands
|
||||
pub chain: bool,
|
||||
}
|
||||
|
||||
impl Default for CommandResult {
|
||||
fn default() -> Self {
|
||||
Self { chain: true }
|
||||
}
|
||||
}
|
||||
111
crates/jarvis-core/src/lua/tests.rs
Normal file
111
crates/jarvis-core/src/lua/tests.rs
Normal file
@@ -0,0 +1,111 @@
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::lua::{CommandContext, LuaError, SandboxLevel, execute};
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
use tempfile::tempdir;
|
||||
use std::fs;
|
||||
|
||||
fn create_test_context(cmd_path: PathBuf) -> CommandContext {
|
||||
CommandContext {
|
||||
phrase: "test phrase".to_string(),
|
||||
command_id: "test_cmd".to_string(),
|
||||
command_path: cmd_path,
|
||||
language: "en".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_minimal_sandbox() {
|
||||
let dir = tempdir().unwrap();
|
||||
let script_path = dir.path().join("test.lua");
|
||||
|
||||
fs::write(&script_path, r#"
|
||||
jarvis.log("info", "test log")
|
||||
return { chain = false }
|
||||
"#).unwrap();
|
||||
|
||||
let context = create_test_context(dir.path().to_path_buf());
|
||||
let result = execute(
|
||||
&script_path,
|
||||
context,
|
||||
SandboxLevel::Minimal,
|
||||
Duration::from_secs(5),
|
||||
);
|
||||
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(result.unwrap().chain, false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_state_persistence() {
|
||||
let dir = tempdir().unwrap();
|
||||
let script_path = dir.path().join("test.lua");
|
||||
|
||||
// first run - set state
|
||||
fs::write(&script_path, r#"
|
||||
jarvis.state.set("key", "value")
|
||||
return true
|
||||
"#).unwrap();
|
||||
|
||||
let context = create_test_context(dir.path().to_path_buf());
|
||||
execute(&script_path, context, SandboxLevel::Standard, Duration::from_secs(5)).unwrap();
|
||||
|
||||
// second run - read state
|
||||
fs::write(&script_path, r#"
|
||||
local val = jarvis.state.get("key")
|
||||
if val == "value" then
|
||||
return true
|
||||
else
|
||||
error("State not persisted")
|
||||
end
|
||||
"#).unwrap();
|
||||
|
||||
let context = create_test_context(dir.path().to_path_buf());
|
||||
let result = execute(&script_path, context, SandboxLevel::Standard, Duration::from_secs(5));
|
||||
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_timeout() {
|
||||
let dir = tempdir().unwrap();
|
||||
let script_path = dir.path().join("test.lua");
|
||||
|
||||
fs::write(&script_path, r#"
|
||||
while true do end
|
||||
"#).unwrap();
|
||||
|
||||
let context = create_test_context(dir.path().to_path_buf());
|
||||
let result = execute(
|
||||
&script_path,
|
||||
context,
|
||||
SandboxLevel::Minimal,
|
||||
Duration::from_millis(100),
|
||||
);
|
||||
|
||||
assert!(matches!(result, Err(LuaError::Timeout)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sandbox_fs_escape() {
|
||||
let dir = tempdir().unwrap();
|
||||
let script_path = dir.path().join("test.lua");
|
||||
|
||||
fs::write(&script_path, r#"
|
||||
local ok, err = pcall(function()
|
||||
jarvis.fs.read("../../../etc/passwd")
|
||||
end)
|
||||
if ok then
|
||||
error("Should have been blocked")
|
||||
end
|
||||
return true
|
||||
"#).unwrap();
|
||||
|
||||
let context = create_test_context(dir.path().to_path_buf());
|
||||
let result = execute(&script_path, context, SandboxLevel::Standard, Duration::from_secs(5));
|
||||
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,8 @@ use parking_lot::RwLock;
|
||||
|
||||
use crate::{DB, SOUND_DIR, audio, config, time};
|
||||
|
||||
pub mod structs;
|
||||
mod structs;
|
||||
pub use structs::*;
|
||||
|
||||
static VOICES: OnceCell<Vec<structs::VoiceConfig>> = OnceCell::new();
|
||||
static CURRENT_VOICE_ID: OnceCell<RwLock<String>> = OnceCell::new();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use jarvis_core::voices::{self, structs::VoiceConfig};
|
||||
use jarvis_core::voices::{self, VoiceConfig};
|
||||
|
||||
#[tauri::command]
|
||||
pub fn list_voices() -> Vec<VoiceConfig> {
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
# some config vars
|
||||
# format: (source, destination_name)
|
||||
@@ -30,6 +31,9 @@ TARGET_DIRS = (
|
||||
|
||||
ABS_PATH = os.getcwd() + "/"
|
||||
|
||||
# check for force flag
|
||||
force_overwrite = "-force" in sys.argv
|
||||
|
||||
for tdir in TARGET_DIRS:
|
||||
tdir = ABS_PATH + tdir
|
||||
|
||||
@@ -52,7 +56,12 @@ for tdir in TARGET_DIRS:
|
||||
full_target_dir_path = os.path.join(tdir, target_name)
|
||||
|
||||
if os.path.isdir(full_target_dir_path):
|
||||
print("[-] Directory already exists, skipping: ", src, "->", target_name)
|
||||
if force_overwrite:
|
||||
shutil.rmtree(full_target_dir_path)
|
||||
shutil.copytree(src_path, full_target_dir_path)
|
||||
print("[+] Directory overwritten: ", src, "->", target_name)
|
||||
else:
|
||||
print("[-] Directory already exists, skipping: ", src, "->", target_name)
|
||||
else:
|
||||
shutil.copytree(src_path, full_target_dir_path)
|
||||
print("[+] Directory copied: ", src, "->", target_name)
|
||||
@@ -63,11 +72,16 @@ for tdir in TARGET_DIRS:
|
||||
full_target_file_path = os.path.join(tdir, target_name)
|
||||
|
||||
if os.path.isfile(full_target_file_path):
|
||||
print("[-] File already exists, skipping: ", src, "->", target_name)
|
||||
if force_overwrite:
|
||||
os.remove(full_target_file_path)
|
||||
shutil.copy(src_path, full_target_file_path)
|
||||
print("[+] File overwritten: ", src, "->", target_name)
|
||||
else:
|
||||
print("[-] File already exists, skipping: ", src, "->", target_name)
|
||||
else:
|
||||
shutil.copy(src_path, full_target_file_path)
|
||||
print("[+] File copied: ", src, "->", target_name)
|
||||
else:
|
||||
print("[?] Unknown entity to copy: ", src)
|
||||
|
||||
print("Post compile build done.")
|
||||
print("Post compile build done.")
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[[commands]]
|
||||
id = "browser_open"
|
||||
action = "ahk"
|
||||
type = "ahk"
|
||||
exe_path = "ahk/Run browser.exe"
|
||||
sounds.ru = ["ok1", "ok2", "ok3", "ok4"]
|
||||
sounds.en = ["ok1", "ok2", "ok3"]
|
||||
@@ -36,7 +36,7 @@ phrases.ua = [
|
||||
|
||||
[[commands]]
|
||||
id = "browser_close"
|
||||
action = "ahk"
|
||||
type = "ahk"
|
||||
exe_path = "ahk/Close browser.exe"
|
||||
sounds.ru = ["ok1", "ok2", "ok3", "ok4"]
|
||||
sounds.en = ["ok1", "ok2", "ok3"]
|
||||
@@ -64,7 +64,7 @@ phrases.ua = [
|
||||
|
||||
[[commands]]
|
||||
id = "open_google"
|
||||
action = "ahk"
|
||||
type = "ahk"
|
||||
exe_path = "ahk/Run website.exe"
|
||||
exe_args = ["http://google.com"]
|
||||
sounds.ru = ["ok1", "ok2", "ok3", "ok4"]
|
||||
|
||||
13
resources/commands/counter/command.toml
Normal file
13
resources/commands/counter/command.toml
Normal file
@@ -0,0 +1,13 @@
|
||||
[[commands]]
|
||||
id = "counter"
|
||||
type = "lua"
|
||||
script = "script.lua"
|
||||
sandbox = "standard"
|
||||
timeout = 5000
|
||||
phrases.ru = [
|
||||
"счётчик"
|
||||
]
|
||||
phrases.en = [
|
||||
"counter",
|
||||
"count",
|
||||
]
|
||||
16
resources/commands/counter/script.lua
Normal file
16
resources/commands/counter/script.lua
Normal file
@@ -0,0 +1,16 @@
|
||||
-- simple counter demonstrating state persistence
|
||||
|
||||
local count = jarvis.state.get("count") or 0
|
||||
count = count + 1
|
||||
jarvis.state.set("count", count)
|
||||
|
||||
local lang = jarvis.context.language
|
||||
local msg = lang == "ru"
|
||||
and "Счётчик: " .. count
|
||||
or "Counter: " .. count
|
||||
|
||||
jarvis.log("info", msg)
|
||||
jarvis.system.notify("Counter", tostring(count))
|
||||
jarvis.audio.play_ok()
|
||||
|
||||
return { chain = true }
|
||||
14
resources/commands/hello/command.toml
Normal file
14
resources/commands/hello/command.toml
Normal file
@@ -0,0 +1,14 @@
|
||||
[[commands]]
|
||||
id = "hello"
|
||||
type = "lua"
|
||||
script = "script.lua"
|
||||
sandbox = "minimal"
|
||||
timeout = 5000
|
||||
phrases.ru = [
|
||||
"привет",
|
||||
"здравствуй",
|
||||
]
|
||||
phrases.en = [
|
||||
"hello",
|
||||
"hi",
|
||||
]
|
||||
21
resources/commands/hello/script.lua
Normal file
21
resources/commands/hello/script.lua
Normal file
@@ -0,0 +1,21 @@
|
||||
-- simple test hello command
|
||||
|
||||
local lang = jarvis.context.language
|
||||
local hour = tonumber(jarvis.context.time.hour)
|
||||
|
||||
-- determine greeting based on time
|
||||
local greeting
|
||||
if hour >= 5 and hour < 12 then
|
||||
greeting = lang == "ru" and "Доброе утро" or "Good morning"
|
||||
elseif hour >= 12 and hour < 17 then
|
||||
greeting = lang == "ru" and "Добрый день" or "Good afternoon"
|
||||
elseif hour >= 17 and hour < 22 then
|
||||
greeting = lang == "ru" and "Добрый вечер" or "Good evening"
|
||||
else
|
||||
greeting = lang == "ru" and "Доброй ночи" or "Good night"
|
||||
end
|
||||
|
||||
jarvis.log("info", "Greeting user: " .. greeting)
|
||||
jarvis.audio.play_reply()
|
||||
|
||||
return { chain = true }
|
||||
@@ -1,7 +1,9 @@
|
||||
[[commands]]
|
||||
id = "weather"
|
||||
action = "cli"
|
||||
cli_cmd = "weather.py"
|
||||
type = "lua"
|
||||
script = "script.lua"
|
||||
timeout = 5000
|
||||
sandbox = "standard"
|
||||
|
||||
[commands.phrases]
|
||||
ru = [
|
||||
@@ -15,4 +17,18 @@ en = [
|
||||
|
||||
[commands.sounds]
|
||||
ru = ["weather_ru_1", "weather_ru_2"]
|
||||
en = ["weather_en_1", "weather_en_2"]
|
||||
en = ["weather_en_1", "weather_en_2"]
|
||||
|
||||
|
||||
|
||||
[[commands]]
|
||||
id = "set_city"
|
||||
type = "lua"
|
||||
script = "set_city.lua"
|
||||
sandbox = "standard"
|
||||
timeout = 5000
|
||||
phrases = [
|
||||
"установи город",
|
||||
"set city",
|
||||
"change city",
|
||||
]
|
||||
29
resources/commands/weather/script.lua
Normal file
29
resources/commands/weather/script.lua
Normal file
@@ -0,0 +1,29 @@
|
||||
-- weather command using wttr.in API
|
||||
|
||||
local lang = jarvis.context.language
|
||||
|
||||
-- get saved city or use default
|
||||
local city = jarvis.state.get("city") or "Moscow"
|
||||
|
||||
jarvis.log("info", "Fetching weather for: " .. city)
|
||||
|
||||
-- build URL
|
||||
local url = "https://wttr.in/" .. city .. "?format=3&lang=" .. lang
|
||||
|
||||
-- make request
|
||||
local response = jarvis.http.get(url)
|
||||
|
||||
if response.ok then
|
||||
jarvis.log("info", "Weather: " .. response.body)
|
||||
|
||||
-- show notification
|
||||
local title = lang == "ru" and "Погода" or "Weather"
|
||||
jarvis.system.notify(title, response.body)
|
||||
|
||||
jarvis.audio.play_ok()
|
||||
else
|
||||
jarvis.log("error", "Failed to fetch weather: " .. (response.error or "unknown error"))
|
||||
jarvis.audio.play_error()
|
||||
end
|
||||
|
||||
return { chain = false }
|
||||
32
resources/commands/weather/set_city.lua
Normal file
32
resources/commands/weather/set_city.lua
Normal file
@@ -0,0 +1,32 @@
|
||||
-- set city for weather command
|
||||
|
||||
local phrase = jarvis.context.phrase
|
||||
local lang = jarvis.context.language
|
||||
|
||||
-- try to extract city name from phrase
|
||||
-- this is a simple example - you might want better parsing
|
||||
local city = phrase:match("город%s+(.+)") or phrase:match("city%s+(.+)")
|
||||
|
||||
if city then
|
||||
city = city:gsub("^%s*(.-)%s*$", "%1") -- trim
|
||||
|
||||
-- save to state (shared with weather command)
|
||||
jarvis.state.set("city", city)
|
||||
|
||||
local msg = lang == "ru"
|
||||
and "Город установлен: " .. city
|
||||
or "City set to: " .. city
|
||||
|
||||
jarvis.log("info", msg)
|
||||
jarvis.system.notify("Jarvis", msg)
|
||||
jarvis.audio.play_ok()
|
||||
else
|
||||
local msg = lang == "ru"
|
||||
and "Не удалось определить город"
|
||||
or "Could not determine city"
|
||||
|
||||
jarvis.log("warn", msg)
|
||||
jarvis.audio.play_not_found()
|
||||
end
|
||||
|
||||
return { chain = false }
|
||||
Reference in New Issue
Block a user