Add Workstream G: out-of-process, Landlock-sandboxed module runtime
Closes the gap Workstream D's in-process capability scoping left open: build_scoped_env only gated presence of bread.* bindings, but os.execute/ io.open/debug.* remained fully reachable since a module's Lua still ran inside breadd's own process. A module that declares [[permissions]] in bread.module.toml (including an explicit empty list) is now spawned as a separate bread-module-host process instead, restricted by a Landlock ruleset breadd builds from that module's granted permissions and applies via Command::pre_exec before the child executes any Lua at all. A module with no manifest at all keeps today's in-process, ungated behavior for backward compatibility. - bread-module-host: new minimal binary (mlua + tokio + serde_json) that connects to breadd's existing IPC socket, presents a one-time spawn token, and proxies bread.* calls as RPC instead of direct bindings. - breadd/src/module_host.rs: spawn + token registry + apply_sandbox (Landlock ruleset construction), with unit tests that spawn a real child and verify denial at the OS level, not a Lua-level check. - breadd/src/ipc/module_host_bridge.rs: the module_host.* RPC bridge (on/once/off/emit/after/every/cancel, fs.read/write, exec/exec_capture, state.get, log/warn/error, status) plus the hello handshake. Bumped API_VERSION to 1.6.0. - breadd/tests/module_host_sandbox.rs: end-to-end acceptance tests going through a real spawned breadd + bread-module-host + IPC handshake — os.execute/io.open denied outside a module's granted fs.read scope, and kill -9 on a module-host child leaving breadd and other modules intact while breadd reports bread.module.crashed. - bread-shared/src/module_host_ipc.rs: shared wire types (hello result, tagged event/timer push envelope) so breadd and bread-module-host can't drift on the handshake/push shape. Deferred (documented in Documentation.md's Workstream G section): the trust="in-process" opt-out, remaining bread.* namespaces over RPC (hyprland/widget/machine/bluetooth/notify/state.watch), network sandboxing, and a fully static build that would remove the Execute grant Landlock's dynamic-linker requirement forces on system library dirs.
This commit is contained in:
parent
450454d164
commit
1e2817537b
17 changed files with 3585 additions and 70 deletions
29
bread-module-host/Cargo.toml
Normal file
29
bread-module-host/Cargo.toml
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
[package]
|
||||
name = "bread-module-host"
|
||||
version = "0.7.0"
|
||||
edition = "2021"
|
||||
|
||||
[[bin]]
|
||||
name = "bread-module-host"
|
||||
path = "src/main.rs"
|
||||
|
||||
# Deliberately minimal dependency footprint (Workstream G): this binary is
|
||||
# itself reviewable attack surface running third-party Lua under an
|
||||
# OS-level sandbox constructed by breadd's parent process (see
|
||||
# breadd/src/module_host.rs) — it does not depend on landlock itself, since
|
||||
# the Landlock ruleset is applied by breadd via Command::pre_exec() *before*
|
||||
# this binary's own main() ever runs (landlock_restrict_self() applies to
|
||||
# the calling process across the subsequent execve()).
|
||||
[dependencies]
|
||||
bread-shared = { path = "../bread-shared" }
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
tokio = { version = "1.40", features = ["net", "io-util", "rt", "rt-multi-thread", "time", "macros", "sync"] }
|
||||
anyhow.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
mlua = { version = "0.9", features = ["lua54", "vendored", "serialize"] }
|
||||
uuid.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
266
bread-module-host/src/io.rs
Normal file
266
bread-module-host/src/io.rs
Normal file
|
|
@ -0,0 +1,266 @@
|
|||
//! The async half of `bread-module-host`: owns the Unix socket connection
|
||||
//! back to `breadd` and speaks the newline-delimited-JSON IPC protocol
|
||||
//! (`breadd/src/ipc/mod.rs`), extended with `module_host.*` methods (see
|
||||
//! `breadd/src/module_host.rs` for the server side of this bridge).
|
||||
//!
|
||||
//! Runs on its own dedicated OS thread with its own single-threaded Tokio
|
||||
//! runtime — mirroring `breadd`'s own `spawn_runtime` split between an async
|
||||
//! IPC/adapters world and a synchronous, single-threaded Lua world (see
|
||||
//! `breadd/src/lua/mod.rs`'s `spawn_runtime`). The Lua-driving thread in
|
||||
//! `main.rs` talks to this thread over two plain `std::sync::mpsc` channels
|
||||
//! (`IoCommand` out, `HostMessage` in) rather than sharing an async runtime,
|
||||
//! since `mlua::Lua` values are not `Send` and Lua callbacks need to make
|
||||
//! synchronous (blocking, from Lua's point of view) RPC calls.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{mpsc, Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use bread_shared::{ModuleHostHello, ModuleHostPush};
|
||||
use serde_json::{json, Value};
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::net::UnixStream;
|
||||
use tracing::warn;
|
||||
|
||||
/// A request the Lua-driving thread wants sent to `breadd`, with a reply
|
||||
/// channel for the (blocking, from Lua's perspective) response.
|
||||
pub enum IoCommand {
|
||||
Request {
|
||||
method: String,
|
||||
params: Value,
|
||||
reply: mpsc::Sender<Result<Value, String>>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Something the IO thread has for the Lua-driving thread: either an
|
||||
/// unsolicited push (a subscribed event fired, a timer fired) or "the
|
||||
/// connection is gone" (breadd exited, socket closed, etc).
|
||||
pub enum HostMessage {
|
||||
Push(ModuleHostPush),
|
||||
Closed,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct RpcResponse {
|
||||
#[allow(dead_code)]
|
||||
id: String,
|
||||
#[serde(default)]
|
||||
result: Option<Value>,
|
||||
#[serde(default)]
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
/// Connect, perform the `module_host.hello` handshake, and — on success —
|
||||
/// run the steady-state request/response + push-forwarding loop until the
|
||||
/// connection closes. `hello_tx` is always sent to exactly once, before
|
||||
/// anything else; the caller blocks on it to learn the module's granted
|
||||
/// identity (or why the handshake failed) before doing anything else.
|
||||
pub fn run(
|
||||
socket_path: PathBuf,
|
||||
token: String,
|
||||
cmd_rx: mpsc::Receiver<IoCommand>,
|
||||
host_tx: mpsc::Sender<HostMessage>,
|
||||
hello_tx: mpsc::Sender<Result<ModuleHostHello, String>>,
|
||||
) {
|
||||
let rt = match tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
{
|
||||
Ok(rt) => rt,
|
||||
Err(e) => {
|
||||
let _ = hello_tx.send(Err(format!("failed to start io runtime: {e}")));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
rt.block_on(async move {
|
||||
let stream = match UnixStream::connect(&socket_path).await {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
let _ = hello_tx.send(Err(format!(
|
||||
"failed to connect to {}: {e}",
|
||||
socket_path.display()
|
||||
)));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let (read_half, mut write_half) = stream.into_split();
|
||||
let mut lines = BufReader::new(read_half).lines();
|
||||
|
||||
let hello_req = json!({
|
||||
"id": "hello",
|
||||
"method": "module_host.hello",
|
||||
"params": { "token": token },
|
||||
});
|
||||
let Ok(hello_line) = serde_json::to_string(&hello_req) else {
|
||||
let _ = hello_tx.send(Err("failed to encode hello request".to_string()));
|
||||
return;
|
||||
};
|
||||
if write_half
|
||||
.write_all(format!("{hello_line}\n").as_bytes())
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
let _ = hello_tx.send(Err("failed to write hello request".to_string()));
|
||||
return;
|
||||
}
|
||||
|
||||
let response_line = match lines.next_line().await {
|
||||
Ok(Some(line)) => line,
|
||||
Ok(None) => {
|
||||
let _ = hello_tx.send(Err(
|
||||
"connection closed before hello response".to_string(),
|
||||
));
|
||||
return;
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = hello_tx.send(Err(format!("read error awaiting hello: {e}")));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let resp: RpcResponse = match serde_json::from_str(&response_line) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
let _ = hello_tx.send(Err(format!("malformed hello response: {e}")));
|
||||
return;
|
||||
}
|
||||
};
|
||||
if let Some(err) = resp.error {
|
||||
let _ = hello_tx.send(Err(err));
|
||||
return;
|
||||
}
|
||||
let hello: ModuleHostHello = match resp
|
||||
.result
|
||||
.and_then(|v| serde_json::from_value(v).ok())
|
||||
{
|
||||
Some(h) => h,
|
||||
None => {
|
||||
let _ = hello_tx.send(Err("hello response missing result".to_string()));
|
||||
return;
|
||||
}
|
||||
};
|
||||
if hello_tx.send(Ok(hello)).is_err() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Steady state. `pending` routes response lines back to whichever
|
||||
// Lua-side call is blocked waiting for them; a dedicated thread
|
||||
// bridges the synchronous `cmd_rx` (fed from the Lua thread) onto an
|
||||
// async channel this task can select on.
|
||||
let pending: Arc<Mutex<HashMap<String, mpsc::Sender<Result<Value, String>>>>> =
|
||||
Arc::new(Mutex::new(HashMap::new()));
|
||||
|
||||
let (async_cmd_tx, mut async_cmd_rx) = tokio::sync::mpsc::unbounded_channel::<IoCommand>();
|
||||
std::thread::spawn(move || {
|
||||
while let Ok(cmd) = cmd_rx.recv() {
|
||||
if async_cmd_tx.send(cmd).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let pending_for_writer = pending.clone();
|
||||
let write_task = tokio::spawn(async move {
|
||||
let mut next_id: u64 = 1;
|
||||
while let Some(IoCommand::Request {
|
||||
method,
|
||||
params,
|
||||
reply,
|
||||
}) = async_cmd_rx.recv().await
|
||||
{
|
||||
let id = format!("m{next_id}");
|
||||
next_id += 1;
|
||||
let req = json!({ "id": id, "method": method, "params": params });
|
||||
let line = match serde_json::to_string(&req) {
|
||||
Ok(l) => l,
|
||||
Err(e) => {
|
||||
let _ = reply.send(Err(e.to_string()));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
pending_for_writer.lock().unwrap().insert(id.clone(), reply);
|
||||
if write_half
|
||||
.write_all(format!("{line}\n").as_bytes())
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
if let Some(tx) = pending_for_writer.lock().unwrap().remove(&id) {
|
||||
let _ = tx.send(Err("write failed; connection lost".to_string()));
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
loop {
|
||||
let line = match lines.next_line().await {
|
||||
Ok(Some(l)) => l,
|
||||
Ok(None) => break,
|
||||
Err(e) => {
|
||||
warn!(error = %e, "module-host: connection read error");
|
||||
break;
|
||||
}
|
||||
};
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let value: Value = match serde_json::from_str(&line) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
warn!(error = %e, "module-host: malformed line from breadd");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if value.get("push").is_some() {
|
||||
match serde_json::from_value::<ModuleHostPush>(value) {
|
||||
Ok(push) => {
|
||||
if host_tx.send(HostMessage::Push(push)).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(e) => warn!(error = %e, "module-host: malformed push message"),
|
||||
}
|
||||
} else if let Ok(resp) = serde_json::from_value::<RpcResponse>(value) {
|
||||
if let Some(tx) = pending.lock().unwrap().remove(&resp.id) {
|
||||
let result = match resp.error {
|
||||
Some(e) => Err(e),
|
||||
None => Ok(resp.result.unwrap_or(Value::Null)),
|
||||
};
|
||||
let _ = tx.send(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
write_task.abort();
|
||||
// Any calls still blocked waiting for a reply need to be unblocked
|
||||
// rather than hanging forever now that the connection is gone.
|
||||
for (_, tx) in pending.lock().unwrap().drain() {
|
||||
let _ = tx.send(Err("connection closed".to_string()));
|
||||
}
|
||||
let _ = host_tx.send(HostMessage::Closed);
|
||||
});
|
||||
}
|
||||
|
||||
/// Blocking helper used from Lua callback closures (which run on the
|
||||
/// Lua-driving thread, not the async IO thread): send a request and wait —
|
||||
/// with a timeout, so a wedged connection can't hang a Lua callback forever
|
||||
/// — for its response.
|
||||
pub fn call(
|
||||
cmd_tx: &mpsc::Sender<IoCommand>,
|
||||
method: &str,
|
||||
params: Value,
|
||||
timeout: Duration,
|
||||
) -> Result<Value, String> {
|
||||
let (reply_tx, reply_rx) = mpsc::channel();
|
||||
cmd_tx
|
||||
.send(IoCommand::Request {
|
||||
method: method.to_string(),
|
||||
params,
|
||||
reply: reply_tx,
|
||||
})
|
||||
.map_err(|_| "io thread gone".to_string())?;
|
||||
reply_rx
|
||||
.recv_timeout(timeout)
|
||||
.map_err(|_| format!("{method} timed out"))?
|
||||
}
|
||||
531
bread-module-host/src/lua_env.rs
Normal file
531
bread-module-host/src/lua_env.rs
Normal file
|
|
@ -0,0 +1,531 @@
|
|||
//! The Lua half of `bread-module-host`: a `bread` table whose functions are
|
||||
//! RPC-backed proxies to `breadd` instead of directly touching daemon state,
|
||||
//! plus a dispatch loop that turns `ModuleHostPush` messages (from
|
||||
//! `crate::io`) into Lua callback invocations.
|
||||
//!
|
||||
//! Structurally a slimmed-down sibling of `breadd/src/lua/mod.rs`'s
|
||||
//! `LuaEngine`/`spawn_runtime`: one dedicated thread runs Lua synchronously
|
||||
//! and reacts to messages from a channel (`HostMessage` here, `LuaMessage`
|
||||
//! there); a separate thread/task owns the actual async I/O. Only ONE
|
||||
//! module is ever loaded per `bread-module-host` process, so there's no
|
||||
//! module registry, load ordering, or `after` dependency resolution here —
|
||||
//! `breadd` already resolved all of that before deciding this module needed
|
||||
//! its own process.
|
||||
//!
|
||||
//! `bread.module()`'s `store` is process-local (an in-memory table, not
|
||||
//! synced back to `breadd`) — a documented gap vs. the in-process
|
||||
//! implementation's `bread.module().store`, which persists in
|
||||
//! `RuntimeState` and is visible to `bread modules info`/other modules.
|
||||
//! Fine for a single module's own private scratch state; not fine yet for
|
||||
//! anything that expects cross-module visibility. See `Documentation.md`.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::rc::Rc;
|
||||
use std::sync::mpsc;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use bread_shared::{BreadEvent, ModulePermission, PermissionKind};
|
||||
use mlua::{Error as LuaError, Function, Lua, LuaSerdeExt, RegistryKey, Table, Value as LuaValue};
|
||||
use serde_json::{json, Value as JsonValue};
|
||||
use tracing::error;
|
||||
|
||||
use crate::io::{call, IoCommand};
|
||||
|
||||
/// Timeout for a single RPC round trip to `breadd`. Generous relative to a
|
||||
/// same-host Unix socket hop — this exists to fail loudly if the connection
|
||||
/// wedges rather than to accommodate genuinely slow calls.
|
||||
const RPC_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
/// Pure-Lua `bread.spawn`/`bread.wait` sugar, copied verbatim from
|
||||
/// `breadd/src/lua/mod.rs`'s `install_wait_helper`. It only depends on
|
||||
/// `coroutine` plus `bread.once`/`bread.on`/`bread.after`/`bread.cancel`,
|
||||
/// all of which this module provides as RPC-backed bindings above, so the
|
||||
/// suspension mechanism works unmodified against a remote event source.
|
||||
///
|
||||
/// Deliberately duplicated rather than shared: extracting this into
|
||||
/// `bread-shared` (so `breadd` and `bread-module-host` load the same
|
||||
/// constant instead of two hand-kept-in-sync copies) is flagged as
|
||||
/// follow-up work in `Documentation.md` — doing it here would also require
|
||||
/// making `breadd`'s currently-private `const BUILTIN_*`/wait-helper
|
||||
/// strings public, which is a larger refactor than this workstream's time
|
||||
/// budget covers.
|
||||
const WAIT_HELPER: &str = r#"
|
||||
bread.spawn = function(fn)
|
||||
local co = coroutine.create(fn)
|
||||
local ok, err = coroutine.resume(co)
|
||||
if not ok then
|
||||
error(err)
|
||||
end
|
||||
end
|
||||
|
||||
bread.wait = function(pattern, opts)
|
||||
if type(pattern) ~= "string" then
|
||||
error("bread.wait requires a pattern string")
|
||||
end
|
||||
opts = opts or {}
|
||||
local co = coroutine.running()
|
||||
if not co then
|
||||
error("bread.wait must be called inside a coroutine")
|
||||
end
|
||||
local id
|
||||
local timer
|
||||
id = bread.once(pattern, function(event)
|
||||
if timer then
|
||||
bread.cancel(timer)
|
||||
end
|
||||
coroutine.resume(co, event)
|
||||
end)
|
||||
if opts.timeout then
|
||||
timer = bread.after(opts.timeout, function()
|
||||
bread.off(id)
|
||||
coroutine.resume(co, nil)
|
||||
end)
|
||||
end
|
||||
return coroutine.yield()
|
||||
end
|
||||
"#;
|
||||
|
||||
fn json_to_lua<'lua>(lua: &'lua Lua, value: &JsonValue) -> mlua::Result<LuaValue<'lua>> {
|
||||
Ok(match value {
|
||||
JsonValue::Null => LuaValue::Nil,
|
||||
JsonValue::Bool(b) => LuaValue::Boolean(*b),
|
||||
JsonValue::Number(n) => {
|
||||
if let Some(i) = n.as_i64() {
|
||||
LuaValue::Integer(i as i64)
|
||||
} else {
|
||||
LuaValue::Number(n.as_f64().unwrap_or(0.0))
|
||||
}
|
||||
}
|
||||
JsonValue::String(s) => LuaValue::String(lua.create_string(s)?),
|
||||
JsonValue::Array(arr) => {
|
||||
let tbl = lua.create_table()?;
|
||||
for (i, v) in arr.iter().enumerate() {
|
||||
tbl.set(i + 1, json_to_lua(lua, v)?)?;
|
||||
}
|
||||
LuaValue::Table(tbl)
|
||||
}
|
||||
JsonValue::Object(obj) => {
|
||||
let tbl = lua.create_table()?;
|
||||
for (k, v) in obj.iter() {
|
||||
tbl.set(k.clone(), json_to_lua(lua, v)?)?;
|
||||
}
|
||||
LuaValue::Table(tbl)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// The Lua VM plus the bookkeeping needed to route `ModuleHostPush`
|
||||
/// messages to the right registered callback. Lives entirely on one thread
|
||||
/// (`mlua::Lua` is `!Send`) — see `main.rs`.
|
||||
pub struct ModuleHostLua {
|
||||
lua: Lua,
|
||||
/// subscription_id or timer_id -> the Lua callback registered for it.
|
||||
handlers: Rc<RefCell<HashMap<String, RegistryKey>>>,
|
||||
registered: Rc<RefCell<bool>>,
|
||||
module_table_key: Rc<RefCell<Option<RegistryKey>>>,
|
||||
module_name: String,
|
||||
}
|
||||
|
||||
impl ModuleHostLua {
|
||||
pub fn new(
|
||||
cmd_tx: mpsc::Sender<IoCommand>,
|
||||
module_name: String,
|
||||
permissions: Vec<ModulePermission>,
|
||||
) -> Result<Self> {
|
||||
let lua = Lua::new();
|
||||
let bread = lua.create_table()?;
|
||||
let handlers: Rc<RefCell<HashMap<String, RegistryKey>>> = Rc::new(RefCell::new(HashMap::new()));
|
||||
let registered = Rc::new(RefCell::new(false));
|
||||
let module_table_key: Rc<RefCell<Option<RegistryKey>>> = Rc::new(RefCell::new(None));
|
||||
|
||||
Self::install_module_fn(
|
||||
&lua,
|
||||
&bread,
|
||||
module_name.clone(),
|
||||
registered.clone(),
|
||||
module_table_key.clone(),
|
||||
)?;
|
||||
Self::install_logging(&lua, &bread, cmd_tx.clone())?;
|
||||
Self::install_json(&lua, &bread)?;
|
||||
Self::install_events(&lua, &bread, cmd_tx.clone(), handlers.clone())?;
|
||||
Self::install_timers(&lua, &bread, cmd_tx.clone(), handlers.clone())?;
|
||||
Self::install_emit(&lua, &bread, cmd_tx.clone())?;
|
||||
|
||||
let granted: HashSet<PermissionKind> = permissions.iter().map(|p| p.kind).collect();
|
||||
Self::install_fs(&lua, &bread, cmd_tx.clone(), &granted)?;
|
||||
Self::install_exec(&lua, &bread, cmd_tx.clone(), &granted)?;
|
||||
Self::install_state(&lua, &bread, cmd_tx, &granted)?;
|
||||
|
||||
lua.globals().set("bread", bread)?;
|
||||
lua.load(WAIT_HELPER).set_name("<bread-module-host wait helper>").exec()?;
|
||||
|
||||
Ok(Self {
|
||||
lua,
|
||||
handlers,
|
||||
registered,
|
||||
module_table_key,
|
||||
module_name,
|
||||
})
|
||||
}
|
||||
|
||||
fn install_module_fn(
|
||||
lua: &Lua,
|
||||
bread: &Table,
|
||||
expected_name: String,
|
||||
registered: Rc<RefCell<bool>>,
|
||||
module_table_key: Rc<RefCell<Option<RegistryKey>>>,
|
||||
) -> Result<()> {
|
||||
let store: Rc<RefCell<HashMap<String, JsonValue>>> = Rc::new(RefCell::new(HashMap::new()));
|
||||
let module_fn = lua.create_function(move |lua, table: Table| -> mlua::Result<Table> {
|
||||
let name: String = table.get("name")?;
|
||||
if name != expected_name {
|
||||
return Err(LuaError::RuntimeError(format!(
|
||||
"bread.module({{name = \"{name}\"}}) does not match the module breadd spawned this process for (\"{expected_name}\")"
|
||||
)));
|
||||
}
|
||||
let version: Option<String> = table.get("version").ok();
|
||||
|
||||
let module_tbl = lua.create_table()?;
|
||||
module_tbl.set("name", name.clone())?;
|
||||
if let Some(v) = version {
|
||||
module_tbl.set("version", v)?;
|
||||
}
|
||||
|
||||
let store_tbl = lua.create_table()?;
|
||||
let store_get = store.clone();
|
||||
let get_fn = lua.create_function(move |lua, key: String| {
|
||||
match store_get.borrow().get(&key) {
|
||||
Some(v) => json_to_lua(lua, v),
|
||||
None => Ok(LuaValue::Nil),
|
||||
}
|
||||
})?;
|
||||
store_tbl.set("get", get_fn)?;
|
||||
|
||||
let store_set = store.clone();
|
||||
let set_fn = lua.create_function(move |lua, (key, value): (String, LuaValue)| {
|
||||
let json: JsonValue = lua.from_value(value).unwrap_or(JsonValue::Null);
|
||||
store_set.borrow_mut().insert(key, json);
|
||||
Ok(())
|
||||
})?;
|
||||
store_tbl.set("set", set_fn)?;
|
||||
module_tbl.set("store", store_tbl)?;
|
||||
|
||||
*registered.borrow_mut() = true;
|
||||
let key = lua.create_registry_value(module_tbl.clone())?;
|
||||
*module_table_key.borrow_mut() = Some(key);
|
||||
|
||||
Ok(module_tbl)
|
||||
})?;
|
||||
bread.set("module", module_fn)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn install_logging(lua: &Lua, bread: &Table, cmd_tx: mpsc::Sender<IoCommand>) -> Result<()> {
|
||||
for (name, method) in [
|
||||
("log", "module_host.log"),
|
||||
("warn", "module_host.warn"),
|
||||
("error", "module_host.error"),
|
||||
] {
|
||||
let cmd_tx = cmd_tx.clone();
|
||||
let f = lua.create_function(move |_, message: String| {
|
||||
let _ = call(&cmd_tx, method, json!({ "message": message }), RPC_TIMEOUT);
|
||||
Ok(())
|
||||
})?;
|
||||
bread.set(name, f)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn install_json(lua: &Lua, bread: &Table) -> Result<()> {
|
||||
let json_tbl = lua.create_table()?;
|
||||
let decode_fn = lua.create_function(|lua, s: String| {
|
||||
match serde_json::from_str::<JsonValue>(&s) {
|
||||
Ok(v) => Ok((json_to_lua(lua, &v)?, LuaValue::Nil)),
|
||||
Err(e) => Ok((LuaValue::Nil, LuaValue::String(lua.create_string(&e.to_string())?))),
|
||||
}
|
||||
})?;
|
||||
json_tbl.set("decode", decode_fn)?;
|
||||
bread.set("json", json_tbl)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn install_events(
|
||||
lua: &Lua,
|
||||
bread: &Table,
|
||||
cmd_tx: mpsc::Sender<IoCommand>,
|
||||
handlers: Rc<RefCell<HashMap<String, RegistryKey>>>,
|
||||
) -> Result<()> {
|
||||
for (name, once) in [("on", false), ("once", true)] {
|
||||
let cmd_tx = cmd_tx.clone();
|
||||
let handlers = handlers.clone();
|
||||
let method = if once { "module_host.once" } else { "module_host.on" };
|
||||
let f = lua.create_function(move |lua, (pattern, callback): (String, Function)| {
|
||||
let result = call(&cmd_tx, method, json!({ "pattern": pattern }), RPC_TIMEOUT)
|
||||
.map_err(LuaError::external)?;
|
||||
let id = result
|
||||
.get("subscription_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| LuaError::external("module_host.on: missing subscription_id"))?
|
||||
.to_string();
|
||||
let key = lua.create_registry_value(callback)?;
|
||||
handlers.borrow_mut().insert(id.clone(), key);
|
||||
Ok(id)
|
||||
})?;
|
||||
bread.set(name, f)?;
|
||||
}
|
||||
|
||||
let cmd_tx_off = cmd_tx.clone();
|
||||
let handlers_off = handlers.clone();
|
||||
let off_fn = lua.create_function(move |_, id: String| {
|
||||
let _ = call(&cmd_tx_off, "module_host.off", json!({ "id": id }), RPC_TIMEOUT);
|
||||
handlers_off.borrow_mut().remove(&id);
|
||||
Ok(())
|
||||
})?;
|
||||
bread.set("off", off_fn)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn install_timers(
|
||||
lua: &Lua,
|
||||
bread: &Table,
|
||||
cmd_tx: mpsc::Sender<IoCommand>,
|
||||
handlers: Rc<RefCell<HashMap<String, RegistryKey>>>,
|
||||
) -> Result<()> {
|
||||
for (name, method, param_key) in [
|
||||
("after", "module_host.after", "delay_ms"),
|
||||
("every", "module_host.every", "interval_ms"),
|
||||
] {
|
||||
let cmd_tx = cmd_tx.clone();
|
||||
let handlers = handlers.clone();
|
||||
let f = lua.create_function(move |lua, (delay_ms, callback): (u64, Function)| {
|
||||
let result = call(&cmd_tx, method, json!({ param_key: delay_ms }), RPC_TIMEOUT)
|
||||
.map_err(LuaError::external)?;
|
||||
let id = result
|
||||
.get("timer_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| LuaError::external(format!("{method}: missing timer_id")))?
|
||||
.to_string();
|
||||
let key = lua.create_registry_value(callback)?;
|
||||
handlers.borrow_mut().insert(id.clone(), key);
|
||||
Ok(id)
|
||||
})?;
|
||||
bread.set(name, f)?;
|
||||
}
|
||||
|
||||
let cmd_tx_cancel = cmd_tx.clone();
|
||||
let handlers_cancel = handlers.clone();
|
||||
let cancel_fn = lua.create_function(move |_, id: String| {
|
||||
let _ = call(&cmd_tx_cancel, "module_host.cancel", json!({ "id": id }), RPC_TIMEOUT);
|
||||
handlers_cancel.borrow_mut().remove(&id);
|
||||
Ok(())
|
||||
})?;
|
||||
bread.set("cancel", cancel_fn)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn install_emit(lua: &Lua, bread: &Table, cmd_tx: mpsc::Sender<IoCommand>) -> Result<()> {
|
||||
let emit_fn = lua.create_function(move |lua, (event, data): (String, Option<LuaValue>)| {
|
||||
let data_json: JsonValue = match data {
|
||||
Some(v) => lua.from_value(v).unwrap_or(JsonValue::Null),
|
||||
None => json!({}),
|
||||
};
|
||||
call(
|
||||
&cmd_tx,
|
||||
"module_host.emit",
|
||||
json!({ "event": event, "data": data_json }),
|
||||
RPC_TIMEOUT,
|
||||
)
|
||||
.map(|_| ())
|
||||
.map_err(LuaError::external)
|
||||
})?;
|
||||
bread.set("emit", emit_fn)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `bread.state.get(path)`, gated on `state.read`. Only the `get`
|
||||
/// shorthand is bridged here — `.monitors()`/`.active_workspace()`/etc.
|
||||
/// convenience wrappers and `state.watch` (a standing subscription, a
|
||||
/// materially different capability — see `PermissionKind::StateWatch`'s
|
||||
/// doc comment in `bread-shared`) are deferred; see `Documentation.md`'s
|
||||
/// Workstream G section for the full list of what's bridged vs. not.
|
||||
fn install_state(
|
||||
lua: &Lua,
|
||||
bread: &Table,
|
||||
cmd_tx: mpsc::Sender<IoCommand>,
|
||||
granted: &HashSet<PermissionKind>,
|
||||
) -> Result<()> {
|
||||
if !granted.contains(&PermissionKind::StateRead) {
|
||||
return Ok(());
|
||||
}
|
||||
let state_tbl = lua.create_table()?;
|
||||
let get_fn = lua.create_function(move |lua, key: String| {
|
||||
let result = call(&cmd_tx, "module_host.state_get", json!({ "key": key }), RPC_TIMEOUT)
|
||||
.map_err(LuaError::external)?;
|
||||
match result.get("value") {
|
||||
Some(v) => json_to_lua(lua, v),
|
||||
None => Ok(LuaValue::Nil),
|
||||
}
|
||||
})?;
|
||||
state_tbl.set("get", get_fn)?;
|
||||
bread.set("state", state_tbl)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn install_fs(
|
||||
lua: &Lua,
|
||||
bread: &Table,
|
||||
cmd_tx: mpsc::Sender<IoCommand>,
|
||||
granted: &HashSet<PermissionKind>,
|
||||
) -> Result<()> {
|
||||
if !granted.contains(&PermissionKind::FsRead) && !granted.contains(&PermissionKind::FsWrite) {
|
||||
return Ok(());
|
||||
}
|
||||
let fs_tbl = lua.create_table()?;
|
||||
if granted.contains(&PermissionKind::FsRead) {
|
||||
let cmd_tx = cmd_tx.clone();
|
||||
let read_fn = lua.create_function(move |_, path: String| {
|
||||
let result = call(&cmd_tx, "module_host.fs_read", json!({ "path": path }), RPC_TIMEOUT)
|
||||
.map_err(LuaError::external)?;
|
||||
Ok(result
|
||||
.get("content")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string()))
|
||||
})?;
|
||||
fs_tbl.set("read", read_fn)?;
|
||||
}
|
||||
if granted.contains(&PermissionKind::FsWrite) {
|
||||
let cmd_tx = cmd_tx.clone();
|
||||
let write_fn = lua.create_function(move |_, (path, content): (String, String)| {
|
||||
call(
|
||||
&cmd_tx,
|
||||
"module_host.fs_write",
|
||||
json!({ "path": path, "content": content }),
|
||||
RPC_TIMEOUT,
|
||||
)
|
||||
.map(|_| ())
|
||||
.map_err(LuaError::external)
|
||||
})?;
|
||||
fs_tbl.set("write", write_fn)?;
|
||||
}
|
||||
bread.set("fs", fs_tbl)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn install_exec(
|
||||
lua: &Lua,
|
||||
bread: &Table,
|
||||
cmd_tx: mpsc::Sender<IoCommand>,
|
||||
granted: &HashSet<PermissionKind>,
|
||||
) -> Result<()> {
|
||||
if !granted.contains(&PermissionKind::Exec) {
|
||||
return Ok(());
|
||||
}
|
||||
let cmd_tx_exec = cmd_tx.clone();
|
||||
let exec_fn = lua.create_function(move |_, cmd: String| {
|
||||
call(&cmd_tx_exec, "module_host.exec", json!({ "cmd": cmd }), RPC_TIMEOUT)
|
||||
.map(|_| ())
|
||||
.map_err(LuaError::external)
|
||||
})?;
|
||||
bread.set("exec", exec_fn)?;
|
||||
|
||||
let exec_capture_fn = lua.create_function(move |_, (cmd, opts): (String, Option<Table>)| {
|
||||
let timeout_ms: u64 = opts
|
||||
.as_ref()
|
||||
.and_then(|o| o.get("timeout_ms").ok())
|
||||
.unwrap_or(2000);
|
||||
let call_timeout = RPC_TIMEOUT + Duration::from_millis(timeout_ms);
|
||||
let result = call(
|
||||
&cmd_tx,
|
||||
"module_host.exec_capture",
|
||||
json!({ "cmd": cmd, "timeout_ms": timeout_ms }),
|
||||
call_timeout,
|
||||
)
|
||||
.map_err(LuaError::external)?;
|
||||
let ok = result.get("ok").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
let stdout = result
|
||||
.get("stdout")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
Ok((ok, stdout))
|
||||
})?;
|
||||
bread.set("exec_capture", exec_capture_fn)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Load and execute the module's `init.lua`, then verify it actually
|
||||
/// called `bread.module(...)` — mirrors `breadd`'s own
|
||||
/// `load_module`/`load_scoped_lua_file` contract exactly (see
|
||||
/// `breadd/src/lua/mod.rs`).
|
||||
pub fn load_entry(&self, entry_path: &std::path::Path) -> Result<()> {
|
||||
let src = std::fs::read_to_string(entry_path)
|
||||
.map_err(|e| anyhow!("failed to read {}: {e}", entry_path.display()))?;
|
||||
self.lua
|
||||
.load(&src)
|
||||
.set_name(entry_path.to_string_lossy().as_ref())
|
||||
.exec()
|
||||
.map_err(|e| anyhow!(e.to_string()))?;
|
||||
|
||||
if !*self.registered.borrow() {
|
||||
return Err(anyhow!("module did not call bread.module(...)"));
|
||||
}
|
||||
self.run_on_load()
|
||||
}
|
||||
|
||||
fn run_on_load(&self) -> Result<()> {
|
||||
let key_ref = self.module_table_key.borrow();
|
||||
let Some(key) = key_ref.as_ref() else {
|
||||
return Ok(());
|
||||
};
|
||||
let module_tbl: Table = self
|
||||
.lua
|
||||
.registry_value(key)
|
||||
.map_err(|e| anyhow!(e.to_string()))?;
|
||||
let hook: Option<Function> = module_tbl.get("on_load").ok();
|
||||
drop(key_ref);
|
||||
if let Some(hook) = hook {
|
||||
hook.call::<_, ()>(())
|
||||
.map_err(|e| anyhow!("{} on_load failed: {e}", self.module_name))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn dispatch_event(&self, subscription_id: &str, event: &BreadEvent) {
|
||||
let func = self.lookup(subscription_id);
|
||||
if let Some(func) = func {
|
||||
if let Err(e) = self.call_event_handler(&func, event) {
|
||||
error!(subscription_id, error = %e, "module-host: event handler error");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn dispatch_timer(&self, timer_id: &str) {
|
||||
let func = self.lookup(timer_id);
|
||||
if let Some(func) = func {
|
||||
if let Err(e) = func.call::<_, ()>(()) {
|
||||
error!(timer_id, error = %e, "module-host: timer handler error");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn lookup(&self, id: &str) -> Option<Function<'_>> {
|
||||
let handlers = self.handlers.borrow();
|
||||
let key = handlers.get(id)?;
|
||||
self.lua.registry_value::<Function>(key).ok()
|
||||
}
|
||||
|
||||
fn call_event_handler(&self, func: &Function, event: &BreadEvent) -> mlua::Result<()> {
|
||||
let data = json_to_lua(&self.lua, &event.data)?;
|
||||
let evt_tbl = self.lua.create_table()?;
|
||||
evt_tbl.set("event", event.event.clone())?;
|
||||
evt_tbl.set("data", data)?;
|
||||
evt_tbl.set("timestamp", event.timestamp)?;
|
||||
evt_tbl.set("id", event.id.clone())?;
|
||||
if let Some(caused_by) = &event.caused_by {
|
||||
evt_tbl.set("caused_by", caused_by.clone())?;
|
||||
}
|
||||
func.call::<_, ()>(evt_tbl)
|
||||
}
|
||||
}
|
||||
164
bread-module-host/src/main.rs
Normal file
164
bread-module-host/src/main.rs
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
//! `bread-module-host` — the out-of-process runtime for a single third-party
|
||||
//! Bread module (Workstream G).
|
||||
//!
|
||||
//! `breadd` spawns one of these per out-of-process module (see
|
||||
//! `breadd/src/module_host.rs`), sandboxed at the OS level via a Landlock
|
||||
//! ruleset applied by the parent *before* this binary's own `main()` ever
|
||||
//! runs (through `Command::pre_exec` — see that module's doc comment for
|
||||
//! why this binary itself has no Landlock dependency at all). This process
|
||||
//! then:
|
||||
//!
|
||||
//! 1. Connects to `breadd`'s existing IPC socket
|
||||
//! (`$XDG_RUNTIME_DIR/bread/breadd.sock` by default).
|
||||
//! 2. Presents the one-time token `breadd` gave it (via `$BREAD_MODULE_TOKEN`,
|
||||
//! an env var rather than argv, which is visible to any process via
|
||||
//! `/proc/*/cmdline`) via `module_host.hello` and learns its own identity
|
||||
//! (module name + granted permissions) from `breadd`'s answer — it never
|
||||
//! asserts its own name and have that trusted.
|
||||
//! 3. Loads exactly one module's `init.lua` (`$BREAD_MODULE_ENTRY`) into a
|
||||
//! fresh Lua VM whose `bread` table is built entirely from RPC-backed
|
||||
//! proxies (see `lua_env`) instead of direct in-process bindings.
|
||||
//! 4. Reports load success/failure back to `breadd` (`module_host.status`),
|
||||
//! then dispatches subscribed events/timers pushed down the same
|
||||
//! connection until it closes.
|
||||
//!
|
||||
//! Env vars, all required except `BREAD_MODULE_SOCKET` and
|
||||
//! `BREAD_MODULE_NAME`:
|
||||
//! - `BREAD_MODULE_TOKEN` — one-time handshake token.
|
||||
//! - `BREAD_MODULE_ENTRY` — absolute path to the module's entry `.lua` file.
|
||||
//! - `BREAD_MODULE_SOCKET` — override for breadd's socket path (defaults to
|
||||
//! `bread_shared::resolve_socket_path()`, the same resolution breadd's own
|
||||
//! `Config::socket_path` uses).
|
||||
//! - `BREAD_MODULE_NAME` — informational only (early log lines before the
|
||||
//! hello response arrives); never trusted for permission lookup.
|
||||
|
||||
mod io;
|
||||
mod lua_env;
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::mpsc;
|
||||
use std::time::Duration;
|
||||
|
||||
use bread_shared::{ModuleHostHello, ModuleHostPush};
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
use io::{HostMessage, IoCommand};
|
||||
use lua_env::ModuleHostLua;
|
||||
|
||||
fn main() {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
|
||||
.init();
|
||||
|
||||
let module_name_hint = std::env::var("BREAD_MODULE_NAME").unwrap_or_else(|_| "?".to_string());
|
||||
|
||||
let token = match std::env::var("BREAD_MODULE_TOKEN") {
|
||||
Ok(t) => t,
|
||||
Err(_) => {
|
||||
eprintln!("bread-module-host: missing BREAD_MODULE_TOKEN env var");
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
let entry = match std::env::var("BREAD_MODULE_ENTRY") {
|
||||
Ok(e) => PathBuf::from(e),
|
||||
Err(_) => {
|
||||
eprintln!("bread-module-host: missing BREAD_MODULE_ENTRY env var");
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
let socket_path = match std::env::var("BREAD_MODULE_SOCKET") {
|
||||
Ok(s) => PathBuf::from(s),
|
||||
Err(_) => bread_shared::resolve_socket_path(),
|
||||
};
|
||||
|
||||
info!(
|
||||
module_hint = %module_name_hint,
|
||||
entry = %entry.display(),
|
||||
socket = %socket_path.display(),
|
||||
"bread-module-host starting"
|
||||
);
|
||||
|
||||
let (cmd_tx, cmd_rx) = mpsc::channel::<IoCommand>();
|
||||
let (host_tx, host_rx) = mpsc::channel::<HostMessage>();
|
||||
let (hello_tx, hello_rx) = mpsc::channel::<Result<ModuleHostHello, String>>();
|
||||
|
||||
if std::thread::Builder::new()
|
||||
.name("module-host-io".to_string())
|
||||
.spawn(move || io::run(socket_path, token, cmd_rx, host_tx, hello_tx))
|
||||
.is_err()
|
||||
{
|
||||
eprintln!("bread-module-host: failed to spawn io thread");
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
let hello = match hello_rx.recv_timeout(Duration::from_secs(15)) {
|
||||
Ok(Ok(h)) => h,
|
||||
Ok(Err(e)) => {
|
||||
error!(error = %e, "bread-module-host: hello handshake failed");
|
||||
std::process::exit(1);
|
||||
}
|
||||
Err(_) => {
|
||||
error!("bread-module-host: timed out waiting for hello handshake");
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
info!(
|
||||
module = %hello.module,
|
||||
permissions = ?hello.permissions,
|
||||
api_version = %hello.api_version,
|
||||
"bread-module-host: identity established by breadd"
|
||||
);
|
||||
|
||||
let engine = match ModuleHostLua::new(cmd_tx.clone(), hello.module.clone(), hello.permissions.clone()) {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
error!(error = %e, "bread-module-host: failed to build lua environment");
|
||||
report_status(&cmd_tx, false, Some(e.to_string()));
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
match engine.load_entry(&entry) {
|
||||
Ok(()) => {
|
||||
info!(module = %hello.module, "bread-module-host: module loaded successfully");
|
||||
report_status(&cmd_tx, true, None);
|
||||
}
|
||||
Err(e) => {
|
||||
error!(module = %hello.module, error = %e, "bread-module-host: module load failed");
|
||||
report_status(&cmd_tx, false, Some(e.to_string()));
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Steady state: dispatch pushed events/timers until the connection to
|
||||
// breadd drops (breadd exited, socket closed, or we were killed and
|
||||
// this line never runs at all — see breadd/src/module_host.rs's
|
||||
// child-reap thread for the other half of that crash-isolation story).
|
||||
loop {
|
||||
match host_rx.recv() {
|
||||
Ok(HostMessage::Push(ModuleHostPush::Event {
|
||||
subscription_id,
|
||||
event,
|
||||
})) => {
|
||||
engine.dispatch_event(&subscription_id, &event);
|
||||
}
|
||||
Ok(HostMessage::Push(ModuleHostPush::Timer { timer_id })) => {
|
||||
engine.dispatch_timer(&timer_id);
|
||||
}
|
||||
Ok(HostMessage::Closed) | Err(_) => {
|
||||
warn!("bread-module-host: connection to breadd closed, exiting");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn report_status(cmd_tx: &mpsc::Sender<IoCommand>, ok: bool, error: Option<String>) {
|
||||
let params = if ok {
|
||||
serde_json::json!({ "state": "loaded" })
|
||||
} else {
|
||||
serde_json::json!({ "state": "load_error", "error": error })
|
||||
};
|
||||
let _ = io::call(cmd_tx, "module_host.status", params, Duration::from_secs(5));
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue