Migrate test plugins to Rust and async-ify syscall modules
- Replace Python test plugins (echo, ai_test, claudecode) with Rust implementations driven by the plugin SDK - Convert fs/network/process/timer syscall modules to async/await - Change Network.OpenConn to return stream ConnEvent (conn_id handed to client in first "open" frame for Send/Close routing) - Bind timer callbacks to plugin identity; stream_call subscriptions are identity-checked - Update docs and regenerate Python SDK protobuf stubs Verified: cargo build/test/clippy clean; echo + ai_test full syscall suites pass; hermes & claudecode register and heartbeat; cross-plugin bridge.call routing and auth denial verified end-to-end. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
143
Cargo.lock
generated
143
Cargo.lock
generated
@@ -8,6 +8,8 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"agentsd-proto",
|
||||
"anyhow",
|
||||
"chrono",
|
||||
"cron",
|
||||
"libc",
|
||||
"notify",
|
||||
"prost",
|
||||
@@ -123,6 +125,15 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "android_system_properties"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.102"
|
||||
@@ -273,6 +284,36 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
|
||||
dependencies = [
|
||||
"iana-time-zone",
|
||||
"js-sys",
|
||||
"num-traits",
|
||||
"wasm-bindgen",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation-sys"
|
||||
version = "0.8.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||
|
||||
[[package]]
|
||||
name = "cron"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6f8c3e73077b4b4a6ab1ea5047c37c57aee77657bc8ecd6f29b0af082d0b0c07"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"nom",
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "displaydoc"
|
||||
version = "0.2.6"
|
||||
@@ -638,6 +679,30 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iana-time-zone"
|
||||
version = "0.1.65"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
|
||||
dependencies = [
|
||||
"android_system_properties",
|
||||
"core-foundation-sys",
|
||||
"iana-time-zone-haiku",
|
||||
"js-sys",
|
||||
"log",
|
||||
"wasm-bindgen",
|
||||
"windows-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iana-time-zone-haiku"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_collections"
|
||||
version = "2.2.0"
|
||||
@@ -939,6 +1004,12 @@ version = "0.3.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||
|
||||
[[package]]
|
||||
name = "minimal-lexical"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "1.2.1"
|
||||
@@ -957,6 +1028,16 @@ version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084"
|
||||
|
||||
[[package]]
|
||||
name = "nom"
|
||||
version = "7.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"minimal-lexical",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "notify"
|
||||
version = "7.0.0"
|
||||
@@ -994,6 +1075,15 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
@@ -2181,12 +2271,65 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.62.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
|
||||
dependencies = [
|
||||
"windows-implement",
|
||||
"windows-interface",
|
||||
"windows-link",
|
||||
"windows-result",
|
||||
"windows-strings",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-implement"
|
||||
version = "0.60.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-interface"
|
||||
version = "0.59.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-strings"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.52.0"
|
||||
|
||||
@@ -71,14 +71,14 @@ agentsd ← 对标 Linux 内核 + systemd(PID 1)
|
||||
|
||||
## 当前状态
|
||||
|
||||
阶段 1 已完成:内核骨架可运行,9 个 syscall gRPC service + PluginBridge 已注册,Python 测试插件验证全链路通过。默认运行数据写入程序所在目录的 `data/` 文件夹。`plugins/ai_test` 提供手动 AI 验证插件,默认不开启/不自动加载。Release 二进制约 6.1MB。
|
||||
阶段 1 已完成:内核骨架可运行,9 个 syscall gRPC service + PluginBridge 已注册,测试插件验证全链路通过。默认运行数据写入程序所在目录的 `data/` 文件夹。`plugins/ai_test` 提供手动 AI 验证插件,默认不开启/不自动加载。Release 二进制约 6.1MB。
|
||||
|
||||
## 手动验证
|
||||
|
||||
```bash
|
||||
cargo build --release
|
||||
RUST_LOG=agentsd=info ./target/release/agentsd.exe
|
||||
python plugins/ai_test/ai_test_plugin.py
|
||||
cargo run -p agentsd-plugin-ai-test
|
||||
```
|
||||
|
||||
`plugins/ai_test/plugin.yaml` 中设置了 `enabled: false` 和 `autoload: false`,未来实现插件自动加载时应默认跳过它。
|
||||
|
||||
@@ -23,3 +23,5 @@ uuid = { workspace = true }
|
||||
notify = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
libc = { workspace = true }
|
||||
cron = "0.12"
|
||||
chrono = "0.4"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
/// A fired timer event delivered to the plugin that created it.
|
||||
@@ -11,7 +12,8 @@ pub struct TimerEvent {
|
||||
|
||||
/// Manages per-plugin event channels for timer callbacks.
|
||||
pub struct CallbackBus {
|
||||
senders: Mutex<HashMap<String, mpsc::Sender<TimerEvent>>>,
|
||||
senders: Mutex<HashMap<String, (mpsc::Sender<TimerEvent>, u64)>>,
|
||||
generation: AtomicU64,
|
||||
}
|
||||
|
||||
impl Default for CallbackBus {
|
||||
@@ -24,23 +26,27 @@ impl CallbackBus {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
senders: Mutex::new(HashMap::new()),
|
||||
generation: AtomicU64::new(0),
|
||||
}
|
||||
}
|
||||
|
||||
/// Register a plugin and get a receiver for its timer events.
|
||||
pub fn subscribe(&self, plugin_id: &str) -> mpsc::Receiver<TimerEvent> {
|
||||
/// Returns the receiver and a generation counter to be used with
|
||||
/// [`remove_if_generation`] for safe cleanup.
|
||||
pub fn subscribe(&self, plugin_id: &str) -> (mpsc::Receiver<TimerEvent>, u64) {
|
||||
let (tx, rx) = mpsc::channel(64);
|
||||
let gen = self.generation.fetch_add(1, Ordering::Relaxed);
|
||||
self.senders
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(plugin_id.to_string(), tx);
|
||||
rx
|
||||
.insert(plugin_id.to_string(), (tx, gen));
|
||||
(rx, gen)
|
||||
}
|
||||
|
||||
/// Deliver a timer event to the target plugin.
|
||||
pub fn fire(&self, plugin_id: &str, event: TimerEvent) {
|
||||
let senders = self.senders.lock().unwrap();
|
||||
if let Some(tx) = senders.get(plugin_id) {
|
||||
if let Some((tx, _gen)) = senders.get(plugin_id) {
|
||||
match tx.try_send(event) {
|
||||
Ok(()) => {}
|
||||
Err(mpsc::error::TrySendError::Full(_)) => {
|
||||
@@ -57,7 +63,20 @@ impl CallbackBus {
|
||||
}
|
||||
|
||||
/// Remove a plugin's channel (on unregister/disconnect).
|
||||
#[allow(dead_code)]
|
||||
pub fn remove(&self, plugin_id: &str) {
|
||||
self.senders.lock().unwrap().remove(plugin_id);
|
||||
}
|
||||
|
||||
/// Remove a plugin's channel only if the generation counter matches.
|
||||
/// This prevents a stale unsubscribe from accidentally removing a newer
|
||||
/// subscription that replaced it.
|
||||
pub fn remove_if_generation(&self, plugin_id: &str, gen: u64) {
|
||||
let mut senders = self.senders.lock().unwrap();
|
||||
if let Some((_, current_gen)) = senders.get(plugin_id) {
|
||||
if *current_gen == gen {
|
||||
senders.remove(plugin_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,8 +46,12 @@ pub fn check_permission(
|
||||
syscall_name: &str,
|
||||
) -> Result<String, Status> {
|
||||
let Some(plugin_id) = plugin_id_from_metadata(req_metadata) else {
|
||||
// Direct/internal/manual testing call.
|
||||
return Ok(String::new());
|
||||
// The server listens on TCP: an absent plugin id means an unidentified
|
||||
// local process, not a trusted internal call. Reject instead of allow-all.
|
||||
return Err(Status::unauthenticated(format!(
|
||||
"syscall '{}' requires x-plugin-id metadata from a registered plugin",
|
||||
syscall_name
|
||||
)));
|
||||
};
|
||||
|
||||
let record = require_running_plugin(plugins, &plugin_id)?;
|
||||
|
||||
@@ -120,10 +120,18 @@ impl PluginManager {
|
||||
}
|
||||
|
||||
fn save_to_disk(&self) {
|
||||
let plugins = self.plugins.lock().unwrap();
|
||||
let records: Vec<&PluginRecord> = plugins.values().collect();
|
||||
let records: Vec<PluginRecord> = {
|
||||
let plugins = self.plugins.lock().unwrap();
|
||||
plugins.values().cloned().collect()
|
||||
};
|
||||
// Lock released here — serialize and write outside the lock
|
||||
if let Ok(json) = serde_json::to_string_pretty(&records) {
|
||||
let _ = std::fs::write(Self::state_path(), json);
|
||||
// Write to temp file first, then rename for atomicity
|
||||
let path = Self::state_path();
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
if std::fs::write(&tmp, &json).is_ok() {
|
||||
let _ = std::fs::rename(&tmp, &path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,9 +9,11 @@ use agentsd_proto::agentsd::{
|
||||
plugin_bridge_server::PluginBridge as PluginBridgeService,
|
||||
process_server::Process as ProcessService, timer_server::Timer as TimerService,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use std::sync::{Arc, OnceLock};
|
||||
use tonic::{Request, Response, Status};
|
||||
|
||||
static REGISTER_TOKEN: OnceLock<Option<String>> = OnceLock::new();
|
||||
|
||||
pub struct AgentsdServer {
|
||||
kernel: Arc<Kernel>,
|
||||
plugins: Arc<PluginManager>,
|
||||
@@ -231,10 +233,9 @@ impl AudioService for AudioSvc {
|
||||
req: Request<RecordStartRequest>,
|
||||
) -> Result<Response<Self::RecordStreamStream>, Status> {
|
||||
auth(&self.plugins, &req, "audio.record_stream")?;
|
||||
let (_tx, rx) = tokio::sync::mpsc::channel(16);
|
||||
Ok(Response::new(tokio_stream::wrappers::ReceiverStream::new(
|
||||
rx,
|
||||
)))
|
||||
Err(Status::unimplemented(
|
||||
"audio.record_stream not yet implemented (requires audio driver plugin)",
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -366,7 +367,24 @@ impl FsService for FsSvc {
|
||||
req: Request<FsPathRequest>,
|
||||
) -> Result<Response<Self::WatchStream>, Status> {
|
||||
auth(&self.plugins, &req, "fs.watch")?;
|
||||
let (_tx, rx) = tokio::sync::mpsc::channel(16);
|
||||
let r = req.into_inner();
|
||||
let mut events = self
|
||||
.kernel
|
||||
.fs
|
||||
.watch(&r.path)
|
||||
.map_err(Status::invalid_argument)?;
|
||||
let (tx, rx) = tokio::sync::mpsc::channel(64);
|
||||
tokio::spawn(async move {
|
||||
while let Some(ev) = events.recv().await {
|
||||
let event = FsWatchEvent {
|
||||
path: ev.path,
|
||||
kind: ev.kind,
|
||||
};
|
||||
if tx.send(Ok(event)).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
Ok(Response::new(tokio_stream::wrappers::ReceiverStream::new(
|
||||
rx,
|
||||
)))
|
||||
@@ -518,14 +536,49 @@ impl NetworkService for NetworkSvc {
|
||||
}
|
||||
}
|
||||
|
||||
type OpenConnStream = tokio_stream::wrappers::ReceiverStream<Result<DataChunk, Status>>;
|
||||
type OpenConnStream = tokio_stream::wrappers::ReceiverStream<Result<ConnEvent, Status>>;
|
||||
|
||||
async fn open_conn(
|
||||
&self,
|
||||
req: Request<ConnectRequest>,
|
||||
) -> Result<Response<Self::OpenConnStream>, Status> {
|
||||
auth(&self.plugins, &req, "network.open_conn")?;
|
||||
let (_tx, rx) = tokio::sync::mpsc::channel(16);
|
||||
let r = req.into_inner();
|
||||
let (conn_id, mut inbound) = self
|
||||
.kernel
|
||||
.network
|
||||
.open_conn(&r.addr, &r.protocol)
|
||||
.await
|
||||
.map_err(Status::unavailable)?;
|
||||
let (tx, rx) = tokio::sync::mpsc::channel(64);
|
||||
tokio::spawn(async move {
|
||||
// First frame hands the conn_id to the client for Send/Close routing.
|
||||
let open_event = ConnEvent {
|
||||
conn_id: conn_id.clone(),
|
||||
data: vec![],
|
||||
kind: "open".to_string(),
|
||||
};
|
||||
if tx.send(Ok(open_event)).await.is_err() {
|
||||
return;
|
||||
}
|
||||
while let Some(data) = inbound.recv().await {
|
||||
let event = ConnEvent {
|
||||
conn_id: conn_id.clone(),
|
||||
data,
|
||||
kind: "data".to_string(),
|
||||
};
|
||||
if tx.send(Ok(event)).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let _ = tx
|
||||
.send(Ok(ConnEvent {
|
||||
conn_id: conn_id.clone(),
|
||||
data: vec![],
|
||||
kind: "closed".to_string(),
|
||||
}))
|
||||
.await;
|
||||
});
|
||||
Ok(Response::new(tokio_stream::wrappers::ReceiverStream::new(
|
||||
rx,
|
||||
)))
|
||||
@@ -538,7 +591,51 @@ impl NetworkService for NetworkSvc {
|
||||
req: Request<ListenRequest>,
|
||||
) -> Result<Response<Self::ListenStream>, Status> {
|
||||
auth(&self.plugins, &req, "network.listen")?;
|
||||
let (_tx, rx) = tokio::sync::mpsc::channel(16);
|
||||
let r = req.into_inner();
|
||||
let mut accepted = self
|
||||
.kernel
|
||||
.network
|
||||
.listen(r.port, &r.protocol)
|
||||
.await
|
||||
.map_err(Status::unavailable)?;
|
||||
let (tx, rx) = tokio::sync::mpsc::channel(64);
|
||||
let network = self.kernel.network.clone();
|
||||
tokio::spawn(async move {
|
||||
while let Some(conn) = accepted.recv().await {
|
||||
let accept_event = ConnEvent {
|
||||
conn_id: conn.conn_id.clone(),
|
||||
data: conn.peer.clone().into_bytes(),
|
||||
kind: "accept".to_string(),
|
||||
};
|
||||
if tx.send(Ok(accept_event)).await.is_err() {
|
||||
break;
|
||||
}
|
||||
// Forward this connection's inbound data onto the same event stream.
|
||||
if let Some(mut inbound) = network.take_inbound(&conn.conn_id).await {
|
||||
let tx_data = tx.clone();
|
||||
let cid = conn.conn_id.clone();
|
||||
tokio::spawn(async move {
|
||||
while let Some(data) = inbound.recv().await {
|
||||
let event = ConnEvent {
|
||||
conn_id: cid.clone(),
|
||||
data,
|
||||
kind: "data".to_string(),
|
||||
};
|
||||
if tx_data.send(Ok(event)).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let _ = tx_data
|
||||
.send(Ok(ConnEvent {
|
||||
conn_id: cid.clone(),
|
||||
data: vec![],
|
||||
kind: "closed".to_string(),
|
||||
}))
|
||||
.await;
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
Ok(Response::new(tokio_stream::wrappers::ReceiverStream::new(
|
||||
rx,
|
||||
)))
|
||||
@@ -546,18 +643,26 @@ impl NetworkService for NetworkSvc {
|
||||
|
||||
async fn send(&self, req: Request<SendRequest>) -> Result<Response<NetResponse>, Status> {
|
||||
auth(&self.plugins, &req, "network.send")?;
|
||||
Ok(Response::new(NetResponse {
|
||||
ok: false,
|
||||
error: "send: not yet implemented".to_string(),
|
||||
}))
|
||||
let r = req.into_inner();
|
||||
match self.kernel.network.send(&r.conn_id, &r.data).await {
|
||||
Ok(()) => Ok(Response::new(NetResponse {
|
||||
ok: true,
|
||||
error: String::new(),
|
||||
})),
|
||||
Err(e) => Ok(Response::new(NetResponse { ok: false, error: e })),
|
||||
}
|
||||
}
|
||||
|
||||
async fn close(&self, req: Request<CloseRequest>) -> Result<Response<NetResponse>, Status> {
|
||||
auth(&self.plugins, &req, "network.close")?;
|
||||
Ok(Response::new(NetResponse {
|
||||
ok: false,
|
||||
error: "close: not yet implemented".to_string(),
|
||||
}))
|
||||
let r = req.into_inner();
|
||||
match self.kernel.network.close(&r.conn_id).await {
|
||||
Ok(()) => Ok(Response::new(NetResponse {
|
||||
ok: true,
|
||||
error: String::new(),
|
||||
})),
|
||||
Err(e) => Ok(Response::new(NetResponse { ok: false, error: e })),
|
||||
}
|
||||
}
|
||||
|
||||
async fn available(
|
||||
@@ -617,10 +722,7 @@ impl InputService for InputSvc {
|
||||
|
||||
async fn events(&self, req: Request<Empty>) -> Result<Response<Self::EventsStream>, Status> {
|
||||
auth(&self.plugins, &req, "input.events")?;
|
||||
let (_tx, rx) = tokio::sync::mpsc::channel(16);
|
||||
Ok(Response::new(tokio_stream::wrappers::ReceiverStream::new(
|
||||
rx,
|
||||
)))
|
||||
Err(Status::unimplemented("input.events not yet implemented"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -792,12 +894,20 @@ impl TimerService for TimerSvc {
|
||||
&self,
|
||||
req: Request<TimerCronRequest>,
|
||||
) -> Result<Response<TimerResponse>, Status> {
|
||||
auth(&self.plugins, &req, "timer.cron")?;
|
||||
Ok(Response::new(TimerResponse {
|
||||
timer_id: String::new(),
|
||||
ok: false,
|
||||
error: "cron: not yet implemented".to_string(),
|
||||
}))
|
||||
let plugin_id = auth(&self.plugins, &req, "timer.cron")?;
|
||||
let r = req.into_inner();
|
||||
match self.kernel.timer.cron(&r.expr, r.callback_id, plugin_id) {
|
||||
Ok(id) => Ok(Response::new(TimerResponse {
|
||||
timer_id: id,
|
||||
ok: true,
|
||||
error: String::new(),
|
||||
})),
|
||||
Err(e) => Ok(Response::new(TimerResponse {
|
||||
timer_id: String::new(),
|
||||
ok: false,
|
||||
error: e,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
async fn cancel(
|
||||
@@ -921,7 +1031,35 @@ impl PluginBridgeService for BridgeSvc {
|
||||
&self,
|
||||
req: Request<PluginInfo>,
|
||||
) -> Result<Response<RegisterResponse>, Status> {
|
||||
// Require register token when AGENTSD_REGISTER_TOKEN env is set.
|
||||
let env_token =
|
||||
REGISTER_TOKEN.get_or_init(|| std::env::var("AGENTSD_REGISTER_TOKEN").ok());
|
||||
if let Some(expected) = env_token {
|
||||
let provided = req
|
||||
.metadata()
|
||||
.get("x-register-token")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or_default();
|
||||
if provided != expected.as_str() {
|
||||
return Ok(Response::new(RegisterResponse {
|
||||
ok: false,
|
||||
error: "invalid or missing register token".into(),
|
||||
session_id: String::new(),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
let r = req.into_inner();
|
||||
|
||||
// External registrations must never claim plugin_type="system".
|
||||
if r.plugin_type == "system" {
|
||||
return Ok(Response::new(RegisterResponse {
|
||||
ok: false,
|
||||
error: "external registration of system plugins is not allowed".into(),
|
||||
session_id: String::new(),
|
||||
}));
|
||||
}
|
||||
|
||||
let record = PluginRecord {
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
@@ -956,10 +1094,21 @@ impl PluginBridgeService for BridgeSvc {
|
||||
}
|
||||
|
||||
async fn call(&self, req: Request<CallRequest>) -> Result<Response<CallResponse>, Status> {
|
||||
// bridge.call requires a verified, running plugin identity.
|
||||
let plugin_id = permission::plugin_id_from_metadata(req.metadata())
|
||||
.ok_or_else(|| Status::unauthenticated("bridge.call requires x-plugin-id"))?;
|
||||
permission::require_running_plugin(&self.plugins, &plugin_id)?;
|
||||
let r = req.into_inner();
|
||||
let fn_name = &r.r#fn;
|
||||
match self.plugins.find_export_record(&r.target, fn_name) {
|
||||
Some(record) => {
|
||||
if record.status != PluginStatus::Running {
|
||||
return Ok(Response::new(CallResponse {
|
||||
result: vec![],
|
||||
ok: false,
|
||||
error: format!("plugin '{}' is not running", record.id),
|
||||
}));
|
||||
}
|
||||
let endpoint = record.endpoint.as_deref().unwrap_or_default();
|
||||
if endpoint.is_empty() {
|
||||
return Ok(Response::new(CallResponse {
|
||||
@@ -1002,28 +1151,25 @@ impl PluginBridgeService for BridgeSvc {
|
||||
&self,
|
||||
req: Request<CallRequest>,
|
||||
) -> Result<Response<Self::StreamCallStream>, Status> {
|
||||
let metadata_plugin_id = permission::plugin_id_from_metadata(req.metadata());
|
||||
// Subscriptions are identity-bound: the caller may only subscribe to
|
||||
// its own event channel, so a verified x-plugin-id is mandatory.
|
||||
let Some(plugin_id) = permission::plugin_id_from_metadata(req.metadata()) else {
|
||||
return Err(Status::unauthenticated(
|
||||
"stream_call requires x-plugin-id metadata",
|
||||
));
|
||||
};
|
||||
let r = req.into_inner();
|
||||
let target = r.target;
|
||||
|
||||
if let Some(ref metadata_id) = metadata_plugin_id {
|
||||
if !target.is_empty() && metadata_id != &target {
|
||||
return Err(Status::permission_denied(format!(
|
||||
"plugin '{}' cannot subscribe to '{}' events",
|
||||
metadata_id, target
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
let plugin_id = metadata_plugin_id.unwrap_or(target);
|
||||
if plugin_id.is_empty() {
|
||||
return Err(Status::invalid_argument(
|
||||
"stream_call requires target or x-plugin-id",
|
||||
));
|
||||
if !target.is_empty() && plugin_id != target {
|
||||
return Err(Status::permission_denied(format!(
|
||||
"plugin '{}' cannot subscribe to '{}' events",
|
||||
plugin_id, target
|
||||
)));
|
||||
}
|
||||
permission::require_running_plugin(&self.plugins, &plugin_id)?;
|
||||
|
||||
let mut events = self.kernel.callback_bus.subscribe(&plugin_id);
|
||||
let (mut events, generation) = self.kernel.callback_bus.subscribe(&plugin_id);
|
||||
let (tx, rx) = tokio::sync::mpsc::channel(16);
|
||||
let bus = self.kernel.callback_bus.clone();
|
||||
let subscribed_plugin_id = plugin_id.clone();
|
||||
@@ -1043,7 +1189,7 @@ impl PluginBridgeService for BridgeSvc {
|
||||
break;
|
||||
}
|
||||
}
|
||||
bus.remove(&subscribed_plugin_id);
|
||||
bus.remove_if_generation(&subscribed_plugin_id, generation);
|
||||
});
|
||||
Ok(Response::new(tokio_stream::wrappers::ReceiverStream::new(
|
||||
rx,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use std::path::Path;
|
||||
use tokio::fs as async_fs;
|
||||
use tokio::io::{AsyncReadExt, AsyncSeekExt};
|
||||
use notify::{Watcher, RecursiveMode, EventKind};
|
||||
|
||||
const MAX_READ_SIZE: usize = 64 * 1024 * 1024; // 64 MB
|
||||
|
||||
@@ -106,6 +107,57 @@ impl FsSyscall {
|
||||
}
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
pub fn watch(&self, path: &str) -> Result<tokio::sync::mpsc::Receiver<FsWatchEventInfo>, String> {
|
||||
let (std_tx, std_rx) = std::sync::mpsc::channel::<notify::Event>();
|
||||
|
||||
let mut watcher = notify::recommended_watcher(move |res: Result<notify::Event, notify::Error>| {
|
||||
match res {
|
||||
Ok(event) => {
|
||||
let _ = std_tx.send(event);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("fs watch notify error: {}", e);
|
||||
}
|
||||
}
|
||||
}).map_err(|e| format!("failed to create watcher: {}", e))?;
|
||||
|
||||
watcher
|
||||
.watch(std::path::Path::new(path), RecursiveMode::Recursive)
|
||||
.map_err(|e| format!("failed to watch path '{}': {}", path, e))?;
|
||||
|
||||
let (tx, rx) = tokio::sync::mpsc::channel::<FsWatchEventInfo>(64);
|
||||
|
||||
tokio::task::spawn_blocking(move || {
|
||||
// Move watcher into this task to keep it alive
|
||||
let _watcher = watcher;
|
||||
|
||||
while let Ok(event) = std_rx.recv() {
|
||||
let kind = match event.kind {
|
||||
EventKind::Create(_) => "create",
|
||||
EventKind::Modify(_) => "modify",
|
||||
EventKind::Remove(_) => "remove",
|
||||
EventKind::Access(_) => continue,
|
||||
_ => "other",
|
||||
};
|
||||
let path = event
|
||||
.paths
|
||||
.first()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
let info = FsWatchEventInfo {
|
||||
path,
|
||||
kind: kind.to_string(),
|
||||
};
|
||||
if tx.blocking_send(info).is_err() {
|
||||
// Receiver dropped (gRPC stream closed), stop watching
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(rx)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct FsStatInfo {
|
||||
@@ -121,3 +173,8 @@ pub struct FsEntryInfo {
|
||||
pub is_dir: bool,
|
||||
pub size: i64,
|
||||
}
|
||||
|
||||
pub struct FsWatchEventInfo {
|
||||
pub path: String,
|
||||
pub kind: String,
|
||||
}
|
||||
|
||||
@@ -1,12 +1,34 @@
|
||||
use std::collections::HashMap;
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
const MAX_RESPONSE_BODY: u64 = 16 * 1024 * 1024; // 16 MB
|
||||
|
||||
/// Accepted connection info delivered via the listen channel.
|
||||
pub struct ConnAccepted {
|
||||
pub conn_id: String,
|
||||
pub peer: String,
|
||||
}
|
||||
|
||||
/// Internal handle for a managed TCP connection.
|
||||
struct ConnHandle {
|
||||
writer: Arc<tokio::sync::Mutex<tokio::net::tcp::OwnedWriteHalf>>,
|
||||
reader_task: JoinHandle<()>,
|
||||
inbound: Option<mpsc::Receiver<Vec<u8>>>,
|
||||
}
|
||||
|
||||
/// Network syscall: remote communication.
|
||||
pub struct NetworkSyscall {
|
||||
client: reqwest::Client,
|
||||
conns: Arc<tokio::sync::Mutex<HashMap<String, ConnHandle>>>,
|
||||
next_conn_id: Arc<AtomicU64>,
|
||||
}
|
||||
|
||||
impl NetworkSyscall {
|
||||
@@ -18,13 +40,23 @@ impl NetworkSyscall {
|
||||
.redirect(reqwest::redirect::Policy::limited(5))
|
||||
.build()
|
||||
.unwrap_or_else(|_| reqwest::Client::new());
|
||||
Self { client }
|
||||
Self {
|
||||
client,
|
||||
conns: Arc::new(tokio::sync::Mutex::new(HashMap::new())),
|
||||
next_conn_id: Arc::new(AtomicU64::new(1)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate the next unique connection ID.
|
||||
fn alloc_conn_id(&self) -> String {
|
||||
let id = self.next_conn_id.fetch_add(1, Ordering::Relaxed);
|
||||
format!("conn_{}", id)
|
||||
}
|
||||
|
||||
pub async fn available(&self) -> bool {
|
||||
tokio::time::timeout(
|
||||
Duration::from_secs(2),
|
||||
tokio::net::TcpStream::connect(SocketAddr::from(([8, 8, 8, 8], 53))),
|
||||
TcpStream::connect(SocketAddr::from(([8, 8, 8, 8], 53))),
|
||||
)
|
||||
.await
|
||||
.map(|result| result.is_ok())
|
||||
@@ -91,4 +123,162 @@ impl NetworkSyscall {
|
||||
|
||||
Ok((status, resp_headers, resp_body.to_vec()))
|
||||
}
|
||||
|
||||
/// Open an outbound TCP connection to `addr` (e.g. "host:port").
|
||||
/// Returns (conn_id, inbound data receiver).
|
||||
pub async fn open_conn(
|
||||
&self,
|
||||
addr: &str,
|
||||
_protocol: &str,
|
||||
) -> Result<(String, mpsc::Receiver<Vec<u8>>), String> {
|
||||
let stream = TcpStream::connect(addr)
|
||||
.await
|
||||
.map_err(|e| format!("connect failed: {e}"))?;
|
||||
let (read_half, write_half) = stream.into_split();
|
||||
|
||||
let conn_id = self.alloc_conn_id();
|
||||
let (tx, rx) = mpsc::channel::<Vec<u8>>(64);
|
||||
|
||||
let writer = Arc::new(tokio::sync::Mutex::new(write_half));
|
||||
let conns = Arc::clone(&self.conns);
|
||||
let cid = conn_id.clone();
|
||||
|
||||
let reader_task = tokio::spawn(async move {
|
||||
let mut read_half = read_half;
|
||||
let mut buf = vec![0u8; 8192];
|
||||
loop {
|
||||
match read_half.read(&mut buf).await {
|
||||
Ok(0) => break,
|
||||
Ok(n) => {
|
||||
if tx.send(buf[..n].to_vec()).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
conns.lock().await.remove(&cid);
|
||||
});
|
||||
|
||||
let handle = ConnHandle {
|
||||
writer,
|
||||
reader_task,
|
||||
inbound: None,
|
||||
};
|
||||
self.conns.lock().await.insert(conn_id.clone(), handle);
|
||||
|
||||
Ok((conn_id, rx))
|
||||
}
|
||||
|
||||
/// Send data on an existing connection.
|
||||
pub async fn send(&self, conn_id: &str, data: &[u8]) -> Result<(), String> {
|
||||
let writer = {
|
||||
let map = self.conns.lock().await;
|
||||
let handle = map
|
||||
.get(conn_id)
|
||||
.ok_or_else(|| format!("connection not found: {conn_id}"))?;
|
||||
Arc::clone(&handle.writer)
|
||||
};
|
||||
let mut w = writer.lock().await;
|
||||
w.write_all(data)
|
||||
.await
|
||||
.map_err(|e| format!("write failed: {e}"))?;
|
||||
w.flush().await.map_err(|e| format!("flush failed: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Close and remove a connection.
|
||||
pub async fn close(&self, conn_id: &str) -> Result<(), String> {
|
||||
let handle = self
|
||||
.conns
|
||||
.lock()
|
||||
.await
|
||||
.remove(conn_id)
|
||||
.ok_or_else(|| format!("connection not found: {conn_id}"))?;
|
||||
handle.reader_task.abort();
|
||||
let mut w = handle.writer.lock().await;
|
||||
let _ = w.shutdown().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Start listening on a TCP port. Returns a receiver that yields each
|
||||
/// accepted connection's id and peer address.
|
||||
pub async fn listen(
|
||||
&self,
|
||||
port: u32,
|
||||
_protocol: &str,
|
||||
) -> Result<mpsc::Receiver<ConnAccepted>, String> {
|
||||
let listener = TcpListener::bind((std::net::Ipv6Addr::LOCALHOST, port as u16))
|
||||
.await
|
||||
.map_err(|e| format!("bind failed: {e}"))?;
|
||||
|
||||
let (accept_tx, accept_rx) = mpsc::channel::<ConnAccepted>(16);
|
||||
let conns = Arc::clone(&self.conns);
|
||||
let id_gen = Arc::clone(&self.next_conn_id);
|
||||
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
let (stream, peer_addr) = match listener.accept().await {
|
||||
Ok(v) => v,
|
||||
Err(_) => break,
|
||||
};
|
||||
|
||||
let (read_half, write_half) = stream.into_split();
|
||||
let conn_id = {
|
||||
let id = id_gen.fetch_add(1, Ordering::Relaxed);
|
||||
format!("conn_{}", id)
|
||||
};
|
||||
|
||||
let (tx, inbound_rx) = mpsc::channel::<Vec<u8>>(64);
|
||||
let writer = Arc::new(tokio::sync::Mutex::new(write_half));
|
||||
let conns_inner = Arc::clone(&conns);
|
||||
let cid = conn_id.clone();
|
||||
|
||||
let reader_task = tokio::spawn(async move {
|
||||
let mut read_half = read_half;
|
||||
let mut buf = vec![0u8; 8192];
|
||||
loop {
|
||||
match read_half.read(&mut buf).await {
|
||||
Ok(0) => break,
|
||||
Ok(n) => {
|
||||
if tx.send(buf[..n].to_vec()).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
conns_inner.lock().await.remove(&cid);
|
||||
});
|
||||
|
||||
let handle = ConnHandle {
|
||||
writer,
|
||||
reader_task,
|
||||
inbound: Some(inbound_rx),
|
||||
};
|
||||
conns.lock().await.insert(conn_id.clone(), handle);
|
||||
|
||||
let accepted = ConnAccepted {
|
||||
conn_id,
|
||||
peer: peer_addr.to_string(),
|
||||
};
|
||||
if accept_tx.send(accepted).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(accept_rx)
|
||||
}
|
||||
|
||||
/// Take the inbound data receiver for a passively-accepted connection.
|
||||
/// Returns None if the connection doesn't exist or has no stored receiver.
|
||||
pub async fn take_inbound(
|
||||
&self,
|
||||
conn_id: &str,
|
||||
) -> Option<mpsc::Receiver<Vec<u8>>> {
|
||||
let mut map = self.conns.lock().await;
|
||||
let handle = map.get_mut(conn_id)?;
|
||||
handle.inbound.take()
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,8 @@ use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::process::Command;
|
||||
use tokio::sync::{mpsc, Mutex, RwLock};
|
||||
use tokio::sync::{mpsc, watch, Mutex, RwLock};
|
||||
use tokio::time::Duration;
|
||||
|
||||
/// Per-process managed state, decoupled from the Child handle.
|
||||
struct ManagedProcess {
|
||||
@@ -10,8 +11,8 @@ struct ManagedProcess {
|
||||
status: Arc<Mutex<String>>,
|
||||
stdin_tx: Option<mpsc::Sender<Vec<u8>>>,
|
||||
stdout_rx: Arc<Mutex<mpsc::Receiver<Vec<u8>>>>,
|
||||
/// Notified when the process exits. Clone the receiver to wait.
|
||||
exit_notify: Arc<tokio::sync::Notify>,
|
||||
/// Watch receiver: becomes `true` when process exits. Clone to wait.
|
||||
exit_rx: watch::Receiver<bool>,
|
||||
/// Handle to abort background tasks on kill.
|
||||
_tasks: Vec<tokio::task::JoinHandle<()>>,
|
||||
}
|
||||
@@ -19,13 +20,13 @@ struct ManagedProcess {
|
||||
/// Process syscall: spawn and manage subprocesses.
|
||||
/// Uses the real OS PID as the identifier.
|
||||
pub struct ProcessSyscall {
|
||||
procs: RwLock<HashMap<u64, Arc<ManagedProcess>>>,
|
||||
procs: Arc<RwLock<HashMap<u64, Arc<ManagedProcess>>>>,
|
||||
}
|
||||
|
||||
impl ProcessSyscall {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
procs: RwLock::new(HashMap::new()),
|
||||
procs: Arc::new(RwLock::new(HashMap::new())),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,7 +62,7 @@ impl ProcessSyscall {
|
||||
let child_stderr = child.stderr.take();
|
||||
|
||||
let status = Arc::new(Mutex::new("running".to_string()));
|
||||
let exit_notify = Arc::new(tokio::sync::Notify::new());
|
||||
let (exit_tx, exit_rx) = watch::channel(false);
|
||||
|
||||
// Stdin writer task
|
||||
let (stdin_tx, mut stdin_rx) = mpsc::channel::<Vec<u8>>(64);
|
||||
@@ -111,14 +112,17 @@ impl ProcessSyscall {
|
||||
}
|
||||
});
|
||||
|
||||
// Reaper task: waits for exit and updates status
|
||||
// Reaper task: waits for exit, updates status, signals watchers, delayed cleanup
|
||||
let status_clone = status.clone();
|
||||
let exit_notify_clone = exit_notify.clone();
|
||||
let procs_clone = Arc::clone(&self.procs);
|
||||
let reaper_task = tokio::spawn(async move {
|
||||
let exit_status = child.wait().await;
|
||||
let code = exit_status.map(|s| s.code().unwrap_or(-1)).unwrap_or(-1);
|
||||
*status_clone.lock().await = format!("exited({})", code);
|
||||
exit_notify_clone.notify_waiters();
|
||||
let _ = exit_tx.send(true);
|
||||
// Delayed cleanup: give stdout readers time to drain buffered data
|
||||
tokio::time::sleep(Duration::from_secs(5)).await;
|
||||
procs_clone.write().await.remove(&pid);
|
||||
});
|
||||
|
||||
let managed = Arc::new(ManagedProcess {
|
||||
@@ -126,7 +130,7 @@ impl ProcessSyscall {
|
||||
status,
|
||||
stdin_tx: Some(stdin_tx),
|
||||
stdout_rx: Arc::new(Mutex::new(stdout_rx)),
|
||||
exit_notify,
|
||||
exit_rx,
|
||||
_tasks: vec![stdin_task, stdout_task, stderr_task, reaper_task],
|
||||
});
|
||||
|
||||
@@ -136,19 +140,25 @@ impl ProcessSyscall {
|
||||
}
|
||||
|
||||
pub async fn kill(&self, pid: u64) -> Result<(), String> {
|
||||
let procs = self.procs.read().await;
|
||||
let proc = procs
|
||||
.get(&pid)
|
||||
.ok_or_else(|| format!("pid {} not found", pid))?
|
||||
.clone();
|
||||
drop(procs);
|
||||
let proc = {
|
||||
let procs = self.procs.read().await;
|
||||
procs
|
||||
.get(&pid)
|
||||
.ok_or_else(|| format!("pid {} not found", pid))?
|
||||
.clone()
|
||||
};
|
||||
|
||||
// Set status to killed first
|
||||
*proc.status.lock().await = "killed".to_string();
|
||||
|
||||
// Abort all background tasks (stdin writer, stdout reader, stderr drain, reaper)
|
||||
// Aborting reaper drops exit_tx, so any wait() blocked on changed() will wake with Err.
|
||||
for task in proc._tasks.iter() {
|
||||
task.abort();
|
||||
}
|
||||
*proc.status.lock().await = "killed".to_string();
|
||||
proc.exit_notify.notify_waiters();
|
||||
|
||||
// Remove from map
|
||||
self.procs.write().await.remove(&pid);
|
||||
|
||||
// Also send OS kill
|
||||
#[cfg(unix)]
|
||||
@@ -161,46 +171,41 @@ impl ProcessSyscall {
|
||||
// On Windows, aborting the reaper task triggers kill_on_drop on the Child
|
||||
}
|
||||
|
||||
self.procs.write().await.remove(&pid);
|
||||
tracing::info!("process killed: pid={}", pid);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn wait(&self, pid: u64) -> Result<i32, String> {
|
||||
let procs = self.procs.read().await;
|
||||
let proc = procs
|
||||
.get(&pid)
|
||||
.ok_or_else(|| format!("pid {} not found", pid))?
|
||||
.clone();
|
||||
drop(procs);
|
||||
let proc = {
|
||||
let procs = self.procs.read().await;
|
||||
procs
|
||||
.get(&pid)
|
||||
.ok_or_else(|| format!("pid {} not found", pid))?
|
||||
.clone()
|
||||
};
|
||||
|
||||
// Check if already exited before waiting
|
||||
{
|
||||
let status = proc.status.lock().await;
|
||||
if status.starts_with("exited") || *status == "killed" {
|
||||
let s = status.clone();
|
||||
drop(status);
|
||||
self.procs.write().await.remove(&pid);
|
||||
return parse_exit_code(&s);
|
||||
// Use watch channel: no race between checking status and waiting.
|
||||
// watch retains the latest value, so even if send(true) happened before
|
||||
// we subscribe, borrow_and_update() will see `true` immediately.
|
||||
let mut rx = proc.exit_rx.clone();
|
||||
while !*rx.borrow_and_update() {
|
||||
if rx.changed().await.is_err() {
|
||||
break; // sender dropped (e.g. kill aborted reaper)
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for exit notification
|
||||
proc.exit_notify.notified().await;
|
||||
let status = proc.status.lock().await.clone();
|
||||
|
||||
// Clean up from map
|
||||
self.procs.write().await.remove(&pid);
|
||||
parse_exit_code(&status)
|
||||
}
|
||||
|
||||
pub async fn write_stdin(&self, pid: u64, data: &[u8]) -> Result<(), String> {
|
||||
let procs = self.procs.read().await;
|
||||
let proc = procs
|
||||
.get(&pid)
|
||||
.ok_or_else(|| format!("pid {} not found", pid))?
|
||||
.clone();
|
||||
drop(procs);
|
||||
let proc = {
|
||||
let procs = self.procs.read().await;
|
||||
procs
|
||||
.get(&pid)
|
||||
.ok_or_else(|| format!("pid {} not found", pid))?
|
||||
.clone()
|
||||
};
|
||||
|
||||
if let Some(ref tx) = proc.stdin_tx {
|
||||
tx.send(data.to_vec())
|
||||
@@ -212,12 +217,13 @@ impl ProcessSyscall {
|
||||
}
|
||||
|
||||
pub async fn read_stdout(&self, pid: u64, buf: &mut [u8]) -> Result<usize, String> {
|
||||
let procs = self.procs.read().await;
|
||||
let proc = procs
|
||||
.get(&pid)
|
||||
.ok_or_else(|| format!("pid {} not found", pid))?
|
||||
.clone();
|
||||
drop(procs);
|
||||
let proc = {
|
||||
let procs = self.procs.read().await;
|
||||
procs
|
||||
.get(&pid)
|
||||
.ok_or_else(|| format!("pid {} not found", pid))?
|
||||
.clone()
|
||||
};
|
||||
|
||||
let mut rx = proc.stdout_rx.lock().await;
|
||||
match rx.recv().await {
|
||||
@@ -268,9 +274,13 @@ impl ProcessSyscall {
|
||||
}
|
||||
|
||||
pub async fn list(&self) -> Vec<(u64, String, String)> {
|
||||
let procs = self.procs.read().await;
|
||||
let mut result = Vec::with_capacity(procs.len());
|
||||
for (pid, mp) in procs.iter() {
|
||||
let snapshot: Vec<(u64, Arc<ManagedProcess>)> = {
|
||||
let procs = self.procs.read().await;
|
||||
procs.iter().map(|(pid, mp)| (*pid, mp.clone())).collect()
|
||||
};
|
||||
// procs read lock is dropped here
|
||||
let mut result = Vec::with_capacity(snapshot.len());
|
||||
for (pid, mp) in &snapshot {
|
||||
let status = mp.status.lock().await.clone();
|
||||
result.push((*pid, mp.cmd.clone(), status));
|
||||
}
|
||||
@@ -288,3 +298,4 @@ fn parse_exit_code(status: &str) -> Result<i32, String> {
|
||||
Ok(-1)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use std::collections::HashMap;
|
||||
use std::str::FromStr;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tokio::time::{self, Duration};
|
||||
|
||||
@@ -75,4 +76,53 @@ impl TimerSyscall {
|
||||
.unwrap_or_default()
|
||||
.as_millis() as i64
|
||||
}
|
||||
|
||||
/// Cron 表达式为 cron crate 7 段格式: sec min hour day-of-month month day-of-week year
|
||||
pub fn cron(&self, expr: &str, callback_id: String, plugin_id: String) -> Result<String, String> {
|
||||
let schedule = cron::Schedule::from_str(expr)
|
||||
.map_err(|e| format!("invalid cron expression: {e}"))?;
|
||||
|
||||
let timer_id = {
|
||||
let mut next = self.next_id.lock().unwrap();
|
||||
let id = format!("cron_{}", *next);
|
||||
*next += 1;
|
||||
id
|
||||
};
|
||||
|
||||
let tid = timer_id.clone();
|
||||
let cb = callback_id.clone();
|
||||
let pid = plugin_id.clone();
|
||||
let bus = self.bus.clone();
|
||||
let timers = self.timers.clone();
|
||||
|
||||
let handle = tokio::spawn(async move {
|
||||
loop {
|
||||
let next = schedule.upcoming(chrono::Utc).next();
|
||||
match next {
|
||||
Some(t) => {
|
||||
let dur = (t - chrono::Utc::now())
|
||||
.to_std()
|
||||
.unwrap_or(std::time::Duration::ZERO);
|
||||
tokio::time::sleep(dur).await;
|
||||
bus.fire(
|
||||
&pid,
|
||||
TimerEvent {
|
||||
timer_id: tid.clone(),
|
||||
callback_id: cb.clone(),
|
||||
},
|
||||
);
|
||||
}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
timers.lock().unwrap().remove(&tid);
|
||||
});
|
||||
|
||||
self.timers
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(timer_id.clone(), TimerEntry { handle });
|
||||
|
||||
Ok(timer_id)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,15 +78,18 @@ memory.search(query) -> results
|
||||
**管**:对外连接、监听 | **不管**:本地 IPC(内核 gRPC 总线)
|
||||
|
||||
```
|
||||
net.request(url, method, body?) -> response
|
||||
net.connect(addr, protocol) -> conn
|
||||
net.listen(port, protocol) -> listener
|
||||
net.send(conn, data)
|
||||
net.recv(conn) -> data
|
||||
net.close(conn)
|
||||
net.available() -> bool
|
||||
network.request(url, method, headers?, body?) -> response # HTTP 请求
|
||||
network.open_conn(addr, protocol) -> stream ConnEvent # TCP 建连,流式推送入站数据
|
||||
network.send(conn_id, data) # 向已有连接发数据
|
||||
network.close(conn_id) # 关闭连接
|
||||
network.listen(port, protocol) -> stream ConnEvent # 监听端口,每 accept 推送 ConnEvent
|
||||
network.available() -> bool # 网络连通性探测
|
||||
```
|
||||
|
||||
gRPC 流式语义:OpenConn/Listen 返回 `stream ConnEvent{conn_id, data, kind}`。
|
||||
- 首帧 kind="open"/"accept" 携带 conn_id,后续 kind="data" 携带入站字节,kind="closed" 表示对端关闭。
|
||||
- 发送用单独的 `Send` RPC(需传 conn_id),关闭用 `Close` RPC。
|
||||
|
||||
---
|
||||
|
||||
## 6. Process
|
||||
@@ -115,7 +118,7 @@ proc.list() -> [pid]
|
||||
|
||||
```
|
||||
timer.once(delay, callback) -> id # 到期后通过 PluginBridge.StreamCall 投递事件
|
||||
timer.cron(expr, callback) -> id
|
||||
timer.cron(expr, callback) -> id # cron 表达式格式(7段): sec min hour day month weekday year
|
||||
timer.cancel(id)
|
||||
timer.now() -> timestamp
|
||||
```
|
||||
|
||||
@@ -177,3 +177,25 @@ message CallRequest {
|
||||
- 系统插件:内核信任,无限制
|
||||
- 基础插件:按 `syscalls` 白名单精确到方法(如 `fs.list`)校验,也支持 `fs` / `fs.*` / `*` 授权(= seccomp filter)
|
||||
- 标准插件:syscalls + depends 白名单,未声明的一律拒绝
|
||||
|
||||
### 身份认证
|
||||
|
||||
所有 syscall 调用**必须**携带 `x-plugin-id` gRPC metadata,否则返回 `UNAUTHENTICATED`。
|
||||
这是第一道安全边界——即使 server 监听 TCP,未注册的进程也无法调用任何 syscall。
|
||||
|
||||
认证流程:
|
||||
1. 插件通过 `PluginBridge.Register` 注册,获得 `session_id`
|
||||
2. 后续所有 syscall 请求须在 gRPC metadata 中带 `x-plugin-id: <plugin_id>`
|
||||
3. 内核校验:plugin_id 存在 → status=Running → syscall 在该插件的白名单内 → 放行
|
||||
|
||||
### stream_call 身份绑定
|
||||
|
||||
`PluginBridge.StreamCall` 用于订阅 Timer 等回调事件。安全策略:
|
||||
- 必须携带 `x-plugin-id`(否则 UNAUTHENTICATED)
|
||||
- 只能订阅**自己**的事件通道(target 为空或等于自身 plugin_id)
|
||||
- 尝试订阅其他插件的事件返回 PERMISSION_DENIED
|
||||
|
||||
### 死插件路由保护
|
||||
|
||||
`PluginBridge.Call` 在路由到目标插件前,校验其 status 为 Running。
|
||||
Stopped/Failed 的插件会立即返回错误,而不是等 5 秒连接超时。
|
||||
|
||||
@@ -8,24 +8,27 @@
|
||||
- [x] 实现 Memory syscall(SQLite KV + FTS5 trigram search,默认写入 data/memory.db)
|
||||
- [x] 实现 Input syscall(stdin)
|
||||
- [x] 实现 Display syscall(stdout)
|
||||
- [x] 实现 Process syscall(spawn/kill/wait/stdin/stdout)
|
||||
- [x] 实现 Process syscall(spawn/kill/wait/stdin/stdout + watch channel 无竞态 wait)
|
||||
- [x] 实现 Timer syscall(once/cancel/now + 回调事件通道)
|
||||
- [x] 实现 Audio syscall(stub,待系统插件)
|
||||
- [x] 实现 HID syscall(stub,待系统插件)
|
||||
- [x] 实现 Network syscall(HTTP via curl,待替换)
|
||||
- [x] 实现 Network syscall(HTTP via reqwest)
|
||||
- [x] Plugin Bridge(注册/调用路由/心跳,注册状态写入 data/plugins.json)
|
||||
- [x] 插件加载:gRPC 握手 + 注册
|
||||
- [x] echo 测试插件(Python)验证基础链路
|
||||
- [x] ai_test 手动验证插件(Python,默认 disabled/autoload false)覆盖 Display/Memory/FS/Process/Timer/权限拒绝
|
||||
- [x] echo 测试插件(Rust)验证基础链路
|
||||
- [x] ai_test 手动验证插件(Rust,默认 disabled/autoload false)覆盖 Display/Memory/FS/Process/Timer/权限拒绝
|
||||
- [x] proto 定义:完整 9 个 service + PluginBridge
|
||||
- [x] 权限安全:无 x-plugin-id 的请求一律拒绝(UNAUTHENTICATED)
|
||||
- [x] 死插件路由保护:Bridge.call 校验 PluginStatus::Running
|
||||
|
||||
**验证结果**:agentsd 启动 → Python 插件通过 gRPC 注册 → ai_test 调用 Display/Memory/FS/Process/Timer syscall 并验证权限拒绝。
|
||||
**验证结果**:agentsd 启动 → Rust 插件通过 gRPC 注册 → ai_test 调用 Display/Memory/FS/Process/Timer syscall 并验证权限拒绝。
|
||||
|
||||
## 阶段 2:完善核心 syscall
|
||||
## 阶段 2:完善核心 syscall ✅ 部分完成
|
||||
|
||||
- [ ] Network:替换 curl 为 hyper/reqwest 原生 HTTP client
|
||||
- [ ] FS.Watch:接入 notify-rs 文件监听
|
||||
- [ ] Timer.Cron:接入 cron 表达式解析
|
||||
- [x] Network:reqwest 原生 HTTP client(阶段 1 已完成)
|
||||
- [x] Network TCP 流:open_conn/send/close/listen 双向 TCP 通信
|
||||
- [x] FS.Watch:接入 notify-rs v7 文件监听(递归)
|
||||
- [x] Timer.Cron:接入 cron 表达式定时(7 段格式)
|
||||
- [ ] Audio:接入系统音频后端(wasapi/alsa)
|
||||
- [ ] HID:接入屏幕截取(Windows: win32 API)
|
||||
- [ ] Unix socket / Named pipe 传输(替代 TCP)
|
||||
|
||||
@@ -51,22 +51,21 @@ Agentsd/
|
||||
│ ├── agentsd_pb2.py
|
||||
│ └── agentsd_pb2_grpc.py
|
||||
│
|
||||
├── echo/ # 测试插件
|
||||
├── echo/ # 测试插件(Rust)
|
||||
│ ├── plugin.yaml
|
||||
│ └── echo_plugin.py
|
||||
│ └── src/main.rs
|
||||
│
|
||||
├── ai_test/ # AI 手动验证插件(enabled=false,autoload=false)
|
||||
├── ai_test/ # AI 手动验证插件(Rust,enabled=false,autoload=false)
|
||||
│ ├── plugin.yaml
|
||||
│ └── ai_test_plugin.py
|
||||
│ └── src/main.rs
|
||||
│
|
||||
├── hermes/ # Hermes Agent 桥接(63 tool)
|
||||
├── hermes/ # Hermes Agent 桥接(Python,Rust 版仅 stub)
|
||||
│ ├── plugin.yaml
|
||||
│ └── hermes_plugin.py
|
||||
│
|
||||
└── claudecode/ # Claude Code CLI 桥接
|
||||
└── claudecode/ # Claude Code CLI 桥接(Rust)
|
||||
├── plugin.yaml
|
||||
├── claudecode_plugin.py
|
||||
└── test_chat.py
|
||||
└── src/main.rs
|
||||
```
|
||||
|
||||
## 布局原则
|
||||
@@ -94,7 +93,7 @@ cargo build --release # 约 6.1MB 单二进制
|
||||
|
||||
# 启动插件(各开一个终端)
|
||||
python plugins/hermes/hermes_plugin.py
|
||||
python plugins/claudecode/claudecode_plugin.py
|
||||
cargo run -p agentsd-plugin-claudecode
|
||||
```
|
||||
|
||||
## 手动验证插件
|
||||
@@ -103,7 +102,7 @@ python plugins/claudecode/claudecode_plugin.py
|
||||
|
||||
```bash
|
||||
RUST_LOG=agentsd=info ./target/release/agentsd.exe
|
||||
python plugins/ai_test/ai_test_plugin.py
|
||||
cargo run -p agentsd-plugin-ai-test
|
||||
```
|
||||
|
||||
## 重新生成 SDK
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1160,7 +1160,7 @@ class NetworkStub:
|
||||
self.OpenConn = channel.unary_stream(
|
||||
'/agentsd.Network/OpenConn',
|
||||
request_serializer=agentsd__pb2.ConnectRequest.SerializeToString,
|
||||
response_deserializer=agentsd__pb2.DataChunk.FromString,
|
||||
response_deserializer=agentsd__pb2.ConnEvent.FromString,
|
||||
_registered_method=True)
|
||||
self.Listen = channel.unary_stream(
|
||||
'/agentsd.Network/Listen',
|
||||
@@ -1235,7 +1235,7 @@ def add_NetworkServicer_to_server(servicer, server):
|
||||
'OpenConn': grpc.unary_stream_rpc_method_handler(
|
||||
servicer.OpenConn,
|
||||
request_deserializer=agentsd__pb2.ConnectRequest.FromString,
|
||||
response_serializer=agentsd__pb2.DataChunk.SerializeToString,
|
||||
response_serializer=agentsd__pb2.ConnEvent.SerializeToString,
|
||||
),
|
||||
'Listen': grpc.unary_stream_rpc_method_handler(
|
||||
servicer.Listen,
|
||||
@@ -1312,7 +1312,7 @@ class Network:
|
||||
target,
|
||||
'/agentsd.Network/OpenConn',
|
||||
agentsd__pb2.ConnectRequest.SerializeToString,
|
||||
agentsd__pb2.DataChunk.FromString,
|
||||
agentsd__pb2.ConnEvent.FromString,
|
||||
options,
|
||||
channel_credentials,
|
||||
insecure,
|
||||
|
||||
@@ -1,222 +0,0 @@
|
||||
"""
|
||||
AI Test Plugin for Agentsd
|
||||
==========================
|
||||
|
||||
Manual verification plugin for AI agents and humans. It is intentionally
|
||||
not autoloaded by default; run it only when testing Agentsd runtime behavior.
|
||||
|
||||
Usage:
|
||||
# Start agentsd first:
|
||||
# RUST_LOG=agentsd=info ./target/release/agentsd.exe
|
||||
python plugins/ai_test/ai_test_plugin.py
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor, TimeoutError
|
||||
from pathlib import Path
|
||||
|
||||
import grpc
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
SDK_DIR = ROOT / "plugins" / "_sdk"
|
||||
sys.path.insert(0, str(SDK_DIR))
|
||||
|
||||
import agentsd_pb2 as pb # noqa: E402
|
||||
import agentsd_pb2_grpc as rpc # noqa: E402
|
||||
|
||||
PLUGIN_ID = "ai-test"
|
||||
METADATA = (("x-plugin-id", PLUGIN_ID),)
|
||||
ADDR = "localhost:50051"
|
||||
|
||||
|
||||
class CheckRunner:
|
||||
def __init__(self):
|
||||
self.channel = grpc.insecure_channel(ADDR)
|
||||
self.bridge = rpc.PluginBridgeStub(self.channel)
|
||||
self.display = rpc.DisplayStub(self.channel)
|
||||
self.memory = rpc.MemoryStub(self.channel)
|
||||
self.fs = rpc.FSStub(self.channel)
|
||||
self.process = rpc.ProcessStub(self.channel)
|
||||
self.timer = rpc.TimerStub(self.channel)
|
||||
self.network = rpc.NetworkStub(self.channel)
|
||||
self.results = []
|
||||
self.run_id = str(int(time.time() * 1000))
|
||||
self.scratch_dir = ROOT / "target" / "release" / "data" / "ai_test" / self.run_id
|
||||
|
||||
def close(self):
|
||||
self.channel.close()
|
||||
|
||||
def record(self, name, ok, details=""):
|
||||
icon = "PASS" if ok else "FAIL"
|
||||
print(f"[{icon}] {name}: {details}")
|
||||
self.results.append({"name": name, "ok": bool(ok), "details": details})
|
||||
|
||||
def check(self, name, fn):
|
||||
try:
|
||||
details = fn()
|
||||
self.record(name, True, details or "ok")
|
||||
except Exception as exc:
|
||||
self.record(name, False, str(exc))
|
||||
|
||||
def register(self):
|
||||
resp = self.bridge.Register(pb.PluginInfo(
|
||||
id=PLUGIN_ID,
|
||||
name="AI Test Plugin",
|
||||
version="0.1.0",
|
||||
plugin_type="basic",
|
||||
syscalls=[
|
||||
"display.text",
|
||||
"memory.write", "memory.read", "memory.list", "memory.delete",
|
||||
"fs.write", "fs.read", "fs.stat", "fs.list", "fs.rename", "fs.delete",
|
||||
"process.spawn", "process.stdout", "process.wait",
|
||||
"timer.once",
|
||||
],
|
||||
depends=[],
|
||||
exports=[pb.ExportDef(name="run_checks", description="Run Agentsd verification checks")],
|
||||
))
|
||||
if not resp.ok:
|
||||
raise RuntimeError(resp.error)
|
||||
return f"session={resp.session_id}"
|
||||
|
||||
def display_text(self):
|
||||
resp = self.display.Text(
|
||||
pb.DisplayTextRequest(content=f"[ai-test] display check run={self.run_id}"),
|
||||
metadata=METADATA,
|
||||
)
|
||||
if not resp.ok:
|
||||
raise RuntimeError(resp.error)
|
||||
return "display.text ok"
|
||||
|
||||
def memory_roundtrip(self):
|
||||
key = f"ai_test.{self.run_id}.greeting"
|
||||
value = f"hello-{self.run_id}".encode("utf-8")
|
||||
resp = self.memory.Write(pb.MemoryWriteRequest(key=key, value=value), metadata=METADATA)
|
||||
if not resp.ok:
|
||||
raise RuntimeError(f"write: {resp.error}")
|
||||
resp = self.memory.Read(pb.MemoryReadRequest(key=key), metadata=METADATA)
|
||||
if not resp.found or resp.value != value:
|
||||
raise RuntimeError(f"read mismatch found={resp.found} value={resp.value!r}")
|
||||
resp = self.memory.List(pb.MemoryListRequest(prefix=f"ai_test.{self.run_id}", limit=10), metadata=METADATA)
|
||||
if key not in resp.keys:
|
||||
raise RuntimeError(f"list missing key; keys={list(resp.keys)} error={resp.error!r}")
|
||||
resp = self.memory.Delete(pb.MemoryKeyRequest(key=key), metadata=METADATA)
|
||||
if not resp.ok:
|
||||
raise RuntimeError(f"delete: {resp.error}")
|
||||
return f"key={key}"
|
||||
|
||||
def fs_roundtrip(self):
|
||||
base = self.scratch_dir
|
||||
file_a = base / "sample.txt"
|
||||
file_b = base / "renamed.txt"
|
||||
data = f"AI_TEST_FS_OK {self.run_id}".encode("utf-8")
|
||||
|
||||
resp = self.fs.Write(pb.FsWriteRequest(path=str(file_a), data=data, append=False), metadata=METADATA)
|
||||
if not resp.ok:
|
||||
raise RuntimeError(f"write: {resp.error}")
|
||||
|
||||
resp = self.fs.Read(pb.FsReadRequest(path=str(file_a), offset=0, length=0), metadata=METADATA)
|
||||
if not resp.ok or resp.data != data:
|
||||
raise RuntimeError(f"read mismatch ok={resp.ok} data={resp.data!r} error={resp.error!r}")
|
||||
|
||||
resp = self.fs.Stat(pb.FsPathRequest(path=str(file_a)), metadata=METADATA)
|
||||
if not resp.ok or not resp.is_file or resp.size != len(data):
|
||||
raise RuntimeError(f"stat mismatch ok={resp.ok} size={resp.size} error={resp.error!r}")
|
||||
|
||||
resp = self.fs.List(pb.FsPathRequest(path=str(base)), metadata=METADATA)
|
||||
names = [e.name for e in resp.entries]
|
||||
if not resp.ok or "sample.txt" not in names:
|
||||
raise RuntimeError(f"list mismatch ok={resp.ok} names={names} error={resp.error!r}")
|
||||
|
||||
resp = self.fs.Rename(pb.FsRenameRequest(**{"from": str(file_a), "to": str(file_b)}), metadata=METADATA)
|
||||
if not resp.ok:
|
||||
raise RuntimeError(f"rename: {resp.error}")
|
||||
|
||||
resp = self.fs.Delete(pb.FsPathRequest(path=str(base)), metadata=METADATA)
|
||||
if not resp.ok:
|
||||
raise RuntimeError(f"delete: {resp.error}")
|
||||
return str(base)
|
||||
|
||||
def process_roundtrip(self):
|
||||
code = "print('AI_TEST_PROCESS_OK')"
|
||||
resp = self.process.Spawn(pb.SpawnRequest(
|
||||
cmd=sys.executable,
|
||||
args=["-c", code],
|
||||
env={},
|
||||
cwd=str(ROOT),
|
||||
), metadata=METADATA)
|
||||
if not resp.ok:
|
||||
raise RuntimeError(resp.error)
|
||||
pid = resp.pid
|
||||
|
||||
chunks = []
|
||||
for chunk in self.process.Stdout(pb.PidRequest(pid=pid), metadata=METADATA):
|
||||
chunks.append(chunk.data)
|
||||
output = b"".join(chunks).decode("utf-8", errors="replace").strip()
|
||||
|
||||
wait = self.process.Wait(pb.PidRequest(pid=pid), metadata=METADATA)
|
||||
if not wait.ok or wait.exit_code != 0:
|
||||
raise RuntimeError(f"wait failed ok={wait.ok} code={wait.exit_code} error={wait.error!r}")
|
||||
if output != "AI_TEST_PROCESS_OK":
|
||||
raise RuntimeError(f"stdout mismatch: {output!r}")
|
||||
return f"pid={pid} stdout={output}"
|
||||
|
||||
def timer_event(self):
|
||||
stream = self.bridge.StreamCall(
|
||||
pb.CallRequest(target=PLUGIN_ID, fn="events", args=b""),
|
||||
metadata=METADATA,
|
||||
)
|
||||
resp = self.timer.Once(pb.TimerOnceRequest(delay_ms=100, callback_id="ai-test-callback"), metadata=METADATA)
|
||||
if not resp.ok:
|
||||
raise RuntimeError(resp.error)
|
||||
|
||||
with ThreadPoolExecutor(max_workers=1) as executor:
|
||||
future = executor.submit(next, stream)
|
||||
try:
|
||||
event = future.result(timeout=5)
|
||||
except TimeoutError:
|
||||
stream.cancel()
|
||||
raise RuntimeError("timed out waiting for timer event")
|
||||
|
||||
payload = json.loads(event.result.decode("utf-8"))
|
||||
if payload.get("event") != "timer" or payload.get("callback_id") != "ai-test-callback":
|
||||
raise RuntimeError(f"unexpected payload: {payload}")
|
||||
return json.dumps(payload, ensure_ascii=False)
|
||||
|
||||
def permission_denial(self):
|
||||
try:
|
||||
self.network.Available(pb.Empty(), metadata=METADATA)
|
||||
except grpc.RpcError as exc:
|
||||
if exc.code() == grpc.StatusCode.PERMISSION_DENIED:
|
||||
return exc.details()
|
||||
raise RuntimeError(f"wrong error: {exc.code().name} {exc.details()}")
|
||||
raise RuntimeError("network.available unexpectedly succeeded")
|
||||
|
||||
def run(self):
|
||||
print(f"AI Test Plugin connecting to {ADDR}")
|
||||
print(f"Scratch dir: {self.scratch_dir}")
|
||||
self.check("register", self.register)
|
||||
self.check("display.text", self.display_text)
|
||||
self.check("memory roundtrip", self.memory_roundtrip)
|
||||
self.check("fs roundtrip", self.fs_roundtrip)
|
||||
self.check("process roundtrip", self.process_roundtrip)
|
||||
self.check("timer event", self.timer_event)
|
||||
self.check("permission denial", self.permission_denial)
|
||||
|
||||
ok = all(r["ok"] for r in self.results)
|
||||
summary = {"plugin": PLUGIN_ID, "ok": ok, "results": self.results}
|
||||
print("AI_TEST_SUMMARY " + json.dumps(summary, ensure_ascii=False))
|
||||
return 0 if ok else 1
|
||||
|
||||
|
||||
def main():
|
||||
runner = CheckRunner()
|
||||
try:
|
||||
return runner.run()
|
||||
finally:
|
||||
runner.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -28,4 +28,4 @@ exports:
|
||||
- name: run_checks
|
||||
description: Run end-to-end Agentsd syscall checks for AI verification
|
||||
|
||||
entry: python ai_test_plugin.py
|
||||
entry: cargo run -p agentsd-plugin-ai-test
|
||||
|
||||
@@ -1,312 +0,0 @@
|
||||
"""
|
||||
Claude Code Plugin for Agentsd
|
||||
==============================
|
||||
|
||||
Bridges locally installed Claude Code CLI into agentsd as a standard plugin.
|
||||
Enables agentsd to use Claude Code for code generation, editing, and reasoning.
|
||||
|
||||
Architecture:
|
||||
agentsd (Rust) ←gRPC→ claudecode_plugin.py → claude CLI (subprocess)
|
||||
|
||||
Capabilities:
|
||||
- chat: Send a prompt to Claude Code, get response
|
||||
- code: Generate/edit code with full Claude Code capabilities
|
||||
- session: Resume previous Claude Code sessions
|
||||
- tools: Let Claude Code use its built-in tools (Bash, Edit, Read, etc.)
|
||||
|
||||
Usage:
|
||||
# Start agentsd first, then:
|
||||
python claudecode_plugin.py
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from concurrent import futures
|
||||
|
||||
import grpc
|
||||
|
||||
# Add SDK path
|
||||
SDK_DIR = Path(__file__).resolve().parent.parent / "_sdk"
|
||||
sys.path.insert(0, str(SDK_DIR))
|
||||
|
||||
import agentsd_pb2 as pb
|
||||
import agentsd_pb2_grpc as rpc
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="[claudecode] %(levelname)s %(message)s")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Find claude CLI
|
||||
CLAUDE_BIN = os.environ.get("CLAUDE_BIN", None)
|
||||
|
||||
|
||||
def find_claude():
|
||||
"""Verify claude CLI is available and return the path."""
|
||||
global CLAUDE_BIN
|
||||
|
||||
if CLAUDE_BIN is None:
|
||||
# Auto-detect
|
||||
candidates = [
|
||||
"claude",
|
||||
"claude.cmd",
|
||||
os.path.expandvars(r"%APPDATA%\npm\claude.cmd"),
|
||||
os.path.expanduser("~/.local/bin/claude"),
|
||||
"/usr/local/bin/claude",
|
||||
]
|
||||
for c in candidates:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[c, "--version"],
|
||||
capture_output=True, text=True, timeout=10
|
||||
)
|
||||
if result.returncode == 0:
|
||||
CLAUDE_BIN = c
|
||||
break
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
|
||||
continue
|
||||
|
||||
if CLAUDE_BIN is None:
|
||||
logger.error("Claude Code CLI not found")
|
||||
logger.error("Install: npm install -g @anthropic-ai/claude-code")
|
||||
return None
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[CLAUDE_BIN, "--version"],
|
||||
capture_output=True, text=True, timeout=10
|
||||
)
|
||||
version = result.stdout.strip()
|
||||
logger.info("Found Claude Code: %s at %s", version, CLAUDE_BIN)
|
||||
return version
|
||||
except Exception as e:
|
||||
logger.error("Claude Code CLI error: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
class ClaudeCodePlugin:
|
||||
"""Bridges Claude Code CLI into agentsd."""
|
||||
|
||||
def __init__(self, agentsd_addr: str = "localhost:50051"):
|
||||
self.addr = agentsd_addr
|
||||
self.channel = None
|
||||
self.bridge = None
|
||||
self.version = None
|
||||
self.default_cwd = os.getcwd()
|
||||
|
||||
def connect(self):
|
||||
"""Connect to agentsd."""
|
||||
self.channel = grpc.insecure_channel(self.addr)
|
||||
self.bridge = rpc.PluginBridgeStub(self.channel)
|
||||
logger.info("Connected to agentsd at %s", self.addr)
|
||||
|
||||
def register(self):
|
||||
"""Register with agentsd."""
|
||||
resp = self.bridge.Register(pb.PluginInfo(
|
||||
id="claudecode",
|
||||
name="Claude Code",
|
||||
version=self.version or "unknown",
|
||||
plugin_type="standard",
|
||||
syscalls=["process.spawn", "display.text", "fs.read", "fs.write", "network.request"],
|
||||
depends=["hermes"],
|
||||
exports=[
|
||||
pb.ExportDef(name="chat", description="Send prompt to Claude Code, get text response"),
|
||||
pb.ExportDef(name="code", description="Generate or edit code with Claude Code"),
|
||||
pb.ExportDef(name="review", description="Review code with Claude Code"),
|
||||
pb.ExportDef(name="ask", description="Ask Claude Code a question about the codebase"),
|
||||
pb.ExportDef(name="run", description="Run Claude Code with full tool access"),
|
||||
],
|
||||
))
|
||||
if resp.ok:
|
||||
logger.info("Registered with agentsd: session=%s", resp.session_id)
|
||||
else:
|
||||
logger.error("Registration failed: %s", resp.error)
|
||||
return resp.ok
|
||||
|
||||
def call_claude(self, prompt: str, cwd: str = None, max_turns: int = None,
|
||||
allowed_tools: list = None, system_prompt: str = None,
|
||||
output_format: str = "text") -> dict:
|
||||
"""
|
||||
Call Claude Code CLI in print mode.
|
||||
|
||||
Args:
|
||||
prompt: The prompt to send
|
||||
cwd: Working directory for Claude Code
|
||||
max_turns: Max agentic turns (default: unlimited)
|
||||
allowed_tools: List of tools to allow (e.g. ["Bash", "Read", "Edit"])
|
||||
system_prompt: Override system prompt
|
||||
output_format: "text" or "json" or "stream-json"
|
||||
|
||||
Returns:
|
||||
dict with "result" or "error"
|
||||
"""
|
||||
cmd = [CLAUDE_BIN, "-p", "--output-format", output_format]
|
||||
|
||||
if max_turns is not None:
|
||||
cmd.extend(["--max-turns", str(max_turns)])
|
||||
|
||||
if allowed_tools:
|
||||
cmd.extend(["--allowedTools"] + allowed_tools)
|
||||
|
||||
if system_prompt:
|
||||
cmd.extend(["--system-prompt", system_prompt])
|
||||
|
||||
work_dir = cwd or self.default_cwd
|
||||
|
||||
logger.info("Calling claude: cwd=%s prompt=%s...", work_dir, prompt[:60])
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
input=prompt,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=work_dir,
|
||||
timeout=300, # 5 min max
|
||||
env={**os.environ, "CLAUDE_CODE_SIMPLE": "1"},
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
output = result.stdout.strip()
|
||||
if output_format == "json":
|
||||
try:
|
||||
return json.loads(output)
|
||||
except json.JSONDecodeError:
|
||||
return {"result": output}
|
||||
return {"result": output}
|
||||
else:
|
||||
error = result.stderr.strip() or f"exit code {result.returncode}"
|
||||
return {"error": error}
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
return {"error": "timeout (300s)"}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
def handle_call(self, fn_name: str, args: dict) -> dict:
|
||||
"""Route a call to the appropriate handler."""
|
||||
prompt = args.get("prompt", args.get("text", ""))
|
||||
cwd = args.get("cwd", self.default_cwd)
|
||||
|
||||
if fn_name == "chat":
|
||||
return self.call_claude(
|
||||
prompt=prompt,
|
||||
cwd=cwd,
|
||||
max_turns=1, # Single turn, no tools
|
||||
allowed_tools=[],
|
||||
output_format="text",
|
||||
)
|
||||
|
||||
elif fn_name == "code":
|
||||
return self.call_claude(
|
||||
prompt=prompt,
|
||||
cwd=cwd,
|
||||
allowed_tools=["Bash", "Read", "Edit", "Write", "Glob", "Grep"],
|
||||
output_format="text",
|
||||
)
|
||||
|
||||
elif fn_name == "review":
|
||||
review_prompt = f"Review the following code or changes. Be concise and actionable:\n\n{prompt}"
|
||||
return self.call_claude(
|
||||
prompt=review_prompt,
|
||||
cwd=cwd,
|
||||
max_turns=1,
|
||||
allowed_tools=["Read", "Glob", "Grep"],
|
||||
output_format="text",
|
||||
)
|
||||
|
||||
elif fn_name == "ask":
|
||||
return self.call_claude(
|
||||
prompt=prompt,
|
||||
cwd=cwd,
|
||||
max_turns=3,
|
||||
allowed_tools=["Read", "Glob", "Grep"],
|
||||
output_format="text",
|
||||
)
|
||||
|
||||
elif fn_name == "run":
|
||||
# Full access mode
|
||||
return self.call_claude(
|
||||
prompt=prompt,
|
||||
cwd=cwd,
|
||||
output_format="text",
|
||||
)
|
||||
|
||||
else:
|
||||
return {"error": f"unknown function: {fn_name}"}
|
||||
|
||||
def run_server(self, port: int = 50053):
|
||||
"""Run callback gRPC server for agentsd."""
|
||||
server = grpc.server(futures.ThreadPoolExecutor(max_workers=4))
|
||||
rpc.add_PluginBridgeServicer_to_server(ClaudeCodeServicer(self), server)
|
||||
server.add_insecure_port(f"[::]:{port}")
|
||||
server.start()
|
||||
logger.info("Claude Code plugin server listening on port %d", port)
|
||||
return server
|
||||
|
||||
|
||||
class ClaudeCodeServicer(rpc.PluginBridgeServicer):
|
||||
"""gRPC servicer for Claude Code plugin."""
|
||||
|
||||
def __init__(self, plugin: ClaudeCodePlugin):
|
||||
self.plugin = plugin
|
||||
|
||||
def Call(self, request, context):
|
||||
fn_name = request.fn
|
||||
try:
|
||||
args = json.loads(request.args) if request.args else {}
|
||||
except json.JSONDecodeError:
|
||||
args = {}
|
||||
|
||||
logger.info("Call: %s(%s)", fn_name, list(args.keys()))
|
||||
result = self.plugin.handle_call(fn_name, args)
|
||||
result_bytes = json.dumps(result, ensure_ascii=False, default=str).encode("utf-8")
|
||||
|
||||
if "error" in result and not result.get("result"):
|
||||
return pb.CallResponse(result=result_bytes, ok=False, error=result["error"])
|
||||
return pb.CallResponse(result=result_bytes, ok=True, error="")
|
||||
|
||||
def Register(self, request, context):
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
return pb.RegisterResponse(ok=False, error="not a bridge")
|
||||
|
||||
def Heartbeat(self, request, context):
|
||||
return pb.HeartbeatResponse(ok=True)
|
||||
|
||||
def StreamCall(self, request, context):
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
return pb.CallResponse(ok=False, error="not implemented")
|
||||
|
||||
|
||||
def main():
|
||||
# Verify claude CLI
|
||||
version = find_claude()
|
||||
if not version:
|
||||
sys.exit(1)
|
||||
|
||||
plugin = ClaudeCodePlugin()
|
||||
plugin.version = version
|
||||
|
||||
# Connect and register
|
||||
plugin.connect()
|
||||
if not plugin.register():
|
||||
sys.exit(1)
|
||||
|
||||
# Start callback server
|
||||
server = plugin.run_server(port=50053)
|
||||
|
||||
logger.info("Claude Code plugin running (v%s). Exports: chat, code, review, ask, run", version)
|
||||
try:
|
||||
while True:
|
||||
time.sleep(1)
|
||||
except KeyboardInterrupt:
|
||||
logger.info("Shutting down...")
|
||||
server.stop(grace=5)
|
||||
plugin.channel.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -25,5 +25,5 @@ exports:
|
||||
- name: run
|
||||
description: Full Claude Code access (all tools, unlimited turns)
|
||||
|
||||
entry: python claudecode_plugin.py
|
||||
entry: cargo run -p agentsd-plugin-claudecode
|
||||
port: 50053
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
"""Quick test: call Claude Code plugin's chat export."""
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / "_sdk"))
|
||||
|
||||
import grpc
|
||||
import agentsd_pb2 as pb
|
||||
import agentsd_pb2_grpc as rpc
|
||||
|
||||
|
||||
def main():
|
||||
# Connect to claudecode plugin's callback server
|
||||
channel = grpc.insecure_channel("localhost:50053")
|
||||
bridge = rpc.PluginBridgeStub(channel)
|
||||
|
||||
# Call "chat"
|
||||
args = json.dumps({"prompt": "What is 2+2? Reply with just the number."}).encode()
|
||||
resp = bridge.Call(pb.CallRequest(target="claudecode", fn="chat", args=args))
|
||||
print(f"ok={resp.ok}")
|
||||
if resp.result:
|
||||
result = json.loads(resp.result)
|
||||
print(f"result: {result.get('result', result.get('error', ''))}")
|
||||
channel.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,67 +0,0 @@
|
||||
"""
|
||||
Echo plugin for agentsd - verifies the gRPC syscall chain works.
|
||||
Registers as a basic plugin, calls Display.Text and FS.Read.
|
||||
"""
|
||||
import grpc
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Add SDK path
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "_sdk"))
|
||||
|
||||
from agentsd_pb2 import (
|
||||
DisplayTextRequest, FsReadRequest, FsPathRequest,
|
||||
MemoryWriteRequest, MemoryReadRequest,
|
||||
PluginInfo, ExportDef, Empty
|
||||
)
|
||||
from agentsd_pb2_grpc import (
|
||||
DisplayStub, FSStub, MemoryStub, PluginBridgeStub
|
||||
)
|
||||
|
||||
|
||||
PLUGIN_ID = "echo-test"
|
||||
METADATA = (("x-plugin-id", PLUGIN_ID),)
|
||||
|
||||
|
||||
def main():
|
||||
channel = grpc.insecure_channel('localhost:50051')
|
||||
|
||||
# Register plugin
|
||||
bridge = PluginBridgeStub(channel)
|
||||
resp = bridge.Register(PluginInfo(
|
||||
id=PLUGIN_ID,
|
||||
name="Echo Test Plugin",
|
||||
version="0.1.0",
|
||||
plugin_type="basic",
|
||||
syscalls=["display.text", "fs.list", "memory.read", "memory.write"],
|
||||
depends=[],
|
||||
exports=[ExportDef(name="echo", description="Echo test")]
|
||||
))
|
||||
print(f"Register: ok={resp.ok} session={resp.session_id}")
|
||||
|
||||
# Test Display
|
||||
display = DisplayStub(channel)
|
||||
resp = display.Text(DisplayTextRequest(content="Hello from echo plugin!"), metadata=METADATA)
|
||||
print(f"Display.Text: ok={resp.ok}")
|
||||
|
||||
# Test Memory write + read
|
||||
memory = MemoryStub(channel)
|
||||
resp = memory.Write(MemoryWriteRequest(key="test.greeting", value=b"Hello World"), metadata=METADATA)
|
||||
print(f"Memory.Write: ok={resp.ok}")
|
||||
|
||||
resp = memory.Read(MemoryReadRequest(key="test.greeting"), metadata=METADATA)
|
||||
print(f"Memory.Read: found={resp.found} value={resp.value.decode()}")
|
||||
|
||||
# Test FS list
|
||||
fs = FSStub(channel)
|
||||
resp = fs.List(FsPathRequest(path="."), metadata=METADATA)
|
||||
print(f"FS.List: ok={resp.ok} entries={len(resp.entries)}")
|
||||
for entry in resp.entries[:5]:
|
||||
print(f" {entry.name} {'[dir]' if entry.is_dir else ''}")
|
||||
|
||||
print("\nAll syscalls verified OK!")
|
||||
channel.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -13,4 +13,4 @@ exports:
|
||||
- name: echo
|
||||
description: Echo test
|
||||
|
||||
entry: python echo_plugin.py
|
||||
entry: cargo run -p agentsd-plugin-echo
|
||||
|
||||
@@ -45,8 +45,10 @@ logger = logging.getLogger(__name__)
|
||||
class HermesPlugin:
|
||||
"""Bridges Hermes tools into agentsd."""
|
||||
|
||||
def __init__(self, agentsd_addr: str = "localhost:50051"):
|
||||
self.addr = agentsd_addr
|
||||
def __init__(self, agentsd_addr: str = None):
|
||||
# agentsd binds [::1]:50051 (IPv6 loopback); match it so "localhost"
|
||||
# resolving to IPv4 first doesn't break the connection. AGENTSD_ADDR overrides.
|
||||
self.addr = agentsd_addr or os.environ.get("AGENTSD_ADDR", "[::1]:50051")
|
||||
self.channel = None
|
||||
self.bridge = None
|
||||
self.registry = None
|
||||
|
||||
@@ -93,7 +93,7 @@ message MemoryResponse { bool ok = 1; string error = 2; }
|
||||
// ========== Network ==========
|
||||
service Network {
|
||||
rpc Request(HttpRequest) returns (HttpResponse);
|
||||
rpc OpenConn(ConnectRequest) returns (stream DataChunk);
|
||||
rpc OpenConn(ConnectRequest) returns (stream ConnEvent);
|
||||
rpc Listen(ListenRequest) returns (stream ConnEvent);
|
||||
rpc Send(SendRequest) returns (NetResponse);
|
||||
rpc Close(CloseRequest) returns (NetResponse);
|
||||
|
||||
Reference in New Issue
Block a user