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 = [
|
dependencies = [
|
||||||
"agentsd-proto",
|
"agentsd-proto",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
"chrono",
|
||||||
|
"cron",
|
||||||
"libc",
|
"libc",
|
||||||
"notify",
|
"notify",
|
||||||
"prost",
|
"prost",
|
||||||
@@ -123,6 +125,15 @@ dependencies = [
|
|||||||
"memchr",
|
"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]]
|
[[package]]
|
||||||
name = "anyhow"
|
name = "anyhow"
|
||||||
version = "1.0.102"
|
version = "1.0.102"
|
||||||
@@ -273,6 +284,36 @@ version = "0.2.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
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]]
|
[[package]]
|
||||||
name = "displaydoc"
|
name = "displaydoc"
|
||||||
version = "0.2.6"
|
version = "0.2.6"
|
||||||
@@ -638,6 +679,30 @@ dependencies = [
|
|||||||
"tracing",
|
"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]]
|
[[package]]
|
||||||
name = "icu_collections"
|
name = "icu_collections"
|
||||||
version = "2.2.0"
|
version = "2.2.0"
|
||||||
@@ -939,6 +1004,12 @@ version = "0.3.17"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "minimal-lexical"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mio"
|
name = "mio"
|
||||||
version = "1.2.1"
|
version = "1.2.1"
|
||||||
@@ -957,6 +1028,16 @@ version = "0.10.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084"
|
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]]
|
[[package]]
|
||||||
name = "notify"
|
name = "notify"
|
||||||
version = "7.0.0"
|
version = "7.0.0"
|
||||||
@@ -994,6 +1075,15 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"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]]
|
[[package]]
|
||||||
name = "once_cell"
|
name = "once_cell"
|
||||||
version = "1.21.4"
|
version = "1.21.4"
|
||||||
@@ -2181,12 +2271,65 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"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]]
|
[[package]]
|
||||||
name = "windows-link"
|
name = "windows-link"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
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]]
|
[[package]]
|
||||||
name = "windows-sys"
|
name = "windows-sys"
|
||||||
version = "0.52.0"
|
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
|
```bash
|
||||||
cargo build --release
|
cargo build --release
|
||||||
RUST_LOG=agentsd=info ./target/release/agentsd.exe
|
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`,未来实现插件自动加载时应默认跳过它。
|
`plugins/ai_test/plugin.yaml` 中设置了 `enabled: false` 和 `autoload: false`,未来实现插件自动加载时应默认跳过它。
|
||||||
|
|||||||
@@ -23,3 +23,5 @@ uuid = { workspace = true }
|
|||||||
notify = { workspace = true }
|
notify = { workspace = true }
|
||||||
reqwest = { workspace = true }
|
reqwest = { workspace = true }
|
||||||
libc = { workspace = true }
|
libc = { workspace = true }
|
||||||
|
cron = "0.12"
|
||||||
|
chrono = "0.4"
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
/// A fired timer event delivered to the plugin that created it.
|
/// 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.
|
/// Manages per-plugin event channels for timer callbacks.
|
||||||
pub struct CallbackBus {
|
pub struct CallbackBus {
|
||||||
senders: Mutex<HashMap<String, mpsc::Sender<TimerEvent>>>,
|
senders: Mutex<HashMap<String, (mpsc::Sender<TimerEvent>, u64)>>,
|
||||||
|
generation: AtomicU64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for CallbackBus {
|
impl Default for CallbackBus {
|
||||||
@@ -24,23 +26,27 @@ impl CallbackBus {
|
|||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
senders: Mutex::new(HashMap::new()),
|
senders: Mutex::new(HashMap::new()),
|
||||||
|
generation: AtomicU64::new(0),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Register a plugin and get a receiver for its timer events.
|
/// 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 (tx, rx) = mpsc::channel(64);
|
||||||
|
let gen = self.generation.fetch_add(1, Ordering::Relaxed);
|
||||||
self.senders
|
self.senders
|
||||||
.lock()
|
.lock()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.insert(plugin_id.to_string(), tx);
|
.insert(plugin_id.to_string(), (tx, gen));
|
||||||
rx
|
(rx, gen)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Deliver a timer event to the target plugin.
|
/// Deliver a timer event to the target plugin.
|
||||||
pub fn fire(&self, plugin_id: &str, event: TimerEvent) {
|
pub fn fire(&self, plugin_id: &str, event: TimerEvent) {
|
||||||
let senders = self.senders.lock().unwrap();
|
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) {
|
match tx.try_send(event) {
|
||||||
Ok(()) => {}
|
Ok(()) => {}
|
||||||
Err(mpsc::error::TrySendError::Full(_)) => {
|
Err(mpsc::error::TrySendError::Full(_)) => {
|
||||||
@@ -57,7 +63,20 @@ impl CallbackBus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Remove a plugin's channel (on unregister/disconnect).
|
/// Remove a plugin's channel (on unregister/disconnect).
|
||||||
|
#[allow(dead_code)]
|
||||||
pub fn remove(&self, plugin_id: &str) {
|
pub fn remove(&self, plugin_id: &str) {
|
||||||
self.senders.lock().unwrap().remove(plugin_id);
|
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,
|
syscall_name: &str,
|
||||||
) -> Result<String, Status> {
|
) -> Result<String, Status> {
|
||||||
let Some(plugin_id) = plugin_id_from_metadata(req_metadata) else {
|
let Some(plugin_id) = plugin_id_from_metadata(req_metadata) else {
|
||||||
// Direct/internal/manual testing call.
|
// The server listens on TCP: an absent plugin id means an unidentified
|
||||||
return Ok(String::new());
|
// 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)?;
|
let record = require_running_plugin(plugins, &plugin_id)?;
|
||||||
|
|||||||
@@ -120,10 +120,18 @@ impl PluginManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn save_to_disk(&self) {
|
fn save_to_disk(&self) {
|
||||||
let plugins = self.plugins.lock().unwrap();
|
let records: Vec<PluginRecord> = {
|
||||||
let records: Vec<&PluginRecord> = plugins.values().collect();
|
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) {
|
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,
|
plugin_bridge_server::PluginBridge as PluginBridgeService,
|
||||||
process_server::Process as ProcessService, timer_server::Timer as TimerService,
|
process_server::Process as ProcessService, timer_server::Timer as TimerService,
|
||||||
};
|
};
|
||||||
use std::sync::Arc;
|
use std::sync::{Arc, OnceLock};
|
||||||
use tonic::{Request, Response, Status};
|
use tonic::{Request, Response, Status};
|
||||||
|
|
||||||
|
static REGISTER_TOKEN: OnceLock<Option<String>> = OnceLock::new();
|
||||||
|
|
||||||
pub struct AgentsdServer {
|
pub struct AgentsdServer {
|
||||||
kernel: Arc<Kernel>,
|
kernel: Arc<Kernel>,
|
||||||
plugins: Arc<PluginManager>,
|
plugins: Arc<PluginManager>,
|
||||||
@@ -231,10 +233,9 @@ impl AudioService for AudioSvc {
|
|||||||
req: Request<RecordStartRequest>,
|
req: Request<RecordStartRequest>,
|
||||||
) -> Result<Response<Self::RecordStreamStream>, Status> {
|
) -> Result<Response<Self::RecordStreamStream>, Status> {
|
||||||
auth(&self.plugins, &req, "audio.record_stream")?;
|
auth(&self.plugins, &req, "audio.record_stream")?;
|
||||||
let (_tx, rx) = tokio::sync::mpsc::channel(16);
|
Err(Status::unimplemented(
|
||||||
Ok(Response::new(tokio_stream::wrappers::ReceiverStream::new(
|
"audio.record_stream not yet implemented (requires audio driver plugin)",
|
||||||
rx,
|
))
|
||||||
)))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -366,7 +367,24 @@ impl FsService for FsSvc {
|
|||||||
req: Request<FsPathRequest>,
|
req: Request<FsPathRequest>,
|
||||||
) -> Result<Response<Self::WatchStream>, Status> {
|
) -> Result<Response<Self::WatchStream>, Status> {
|
||||||
auth(&self.plugins, &req, "fs.watch")?;
|
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(
|
Ok(Response::new(tokio_stream::wrappers::ReceiverStream::new(
|
||||||
rx,
|
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(
|
async fn open_conn(
|
||||||
&self,
|
&self,
|
||||||
req: Request<ConnectRequest>,
|
req: Request<ConnectRequest>,
|
||||||
) -> Result<Response<Self::OpenConnStream>, Status> {
|
) -> Result<Response<Self::OpenConnStream>, Status> {
|
||||||
auth(&self.plugins, &req, "network.open_conn")?;
|
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(
|
Ok(Response::new(tokio_stream::wrappers::ReceiverStream::new(
|
||||||
rx,
|
rx,
|
||||||
)))
|
)))
|
||||||
@@ -538,7 +591,51 @@ impl NetworkService for NetworkSvc {
|
|||||||
req: Request<ListenRequest>,
|
req: Request<ListenRequest>,
|
||||||
) -> Result<Response<Self::ListenStream>, Status> {
|
) -> Result<Response<Self::ListenStream>, Status> {
|
||||||
auth(&self.plugins, &req, "network.listen")?;
|
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(
|
Ok(Response::new(tokio_stream::wrappers::ReceiverStream::new(
|
||||||
rx,
|
rx,
|
||||||
)))
|
)))
|
||||||
@@ -546,18 +643,26 @@ impl NetworkService for NetworkSvc {
|
|||||||
|
|
||||||
async fn send(&self, req: Request<SendRequest>) -> Result<Response<NetResponse>, Status> {
|
async fn send(&self, req: Request<SendRequest>) -> Result<Response<NetResponse>, Status> {
|
||||||
auth(&self.plugins, &req, "network.send")?;
|
auth(&self.plugins, &req, "network.send")?;
|
||||||
Ok(Response::new(NetResponse {
|
let r = req.into_inner();
|
||||||
ok: false,
|
match self.kernel.network.send(&r.conn_id, &r.data).await {
|
||||||
error: "send: not yet implemented".to_string(),
|
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> {
|
async fn close(&self, req: Request<CloseRequest>) -> Result<Response<NetResponse>, Status> {
|
||||||
auth(&self.plugins, &req, "network.close")?;
|
auth(&self.plugins, &req, "network.close")?;
|
||||||
Ok(Response::new(NetResponse {
|
let r = req.into_inner();
|
||||||
ok: false,
|
match self.kernel.network.close(&r.conn_id).await {
|
||||||
error: "close: not yet implemented".to_string(),
|
Ok(()) => Ok(Response::new(NetResponse {
|
||||||
}))
|
ok: true,
|
||||||
|
error: String::new(),
|
||||||
|
})),
|
||||||
|
Err(e) => Ok(Response::new(NetResponse { ok: false, error: e })),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn available(
|
async fn available(
|
||||||
@@ -617,10 +722,7 @@ impl InputService for InputSvc {
|
|||||||
|
|
||||||
async fn events(&self, req: Request<Empty>) -> Result<Response<Self::EventsStream>, Status> {
|
async fn events(&self, req: Request<Empty>) -> Result<Response<Self::EventsStream>, Status> {
|
||||||
auth(&self.plugins, &req, "input.events")?;
|
auth(&self.plugins, &req, "input.events")?;
|
||||||
let (_tx, rx) = tokio::sync::mpsc::channel(16);
|
Err(Status::unimplemented("input.events not yet implemented"))
|
||||||
Ok(Response::new(tokio_stream::wrappers::ReceiverStream::new(
|
|
||||||
rx,
|
|
||||||
)))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -792,12 +894,20 @@ impl TimerService for TimerSvc {
|
|||||||
&self,
|
&self,
|
||||||
req: Request<TimerCronRequest>,
|
req: Request<TimerCronRequest>,
|
||||||
) -> Result<Response<TimerResponse>, Status> {
|
) -> Result<Response<TimerResponse>, Status> {
|
||||||
auth(&self.plugins, &req, "timer.cron")?;
|
let plugin_id = auth(&self.plugins, &req, "timer.cron")?;
|
||||||
Ok(Response::new(TimerResponse {
|
let r = req.into_inner();
|
||||||
timer_id: String::new(),
|
match self.kernel.timer.cron(&r.expr, r.callback_id, plugin_id) {
|
||||||
ok: false,
|
Ok(id) => Ok(Response::new(TimerResponse {
|
||||||
error: "cron: not yet implemented".to_string(),
|
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(
|
async fn cancel(
|
||||||
@@ -921,7 +1031,35 @@ impl PluginBridgeService for BridgeSvc {
|
|||||||
&self,
|
&self,
|
||||||
req: Request<PluginInfo>,
|
req: Request<PluginInfo>,
|
||||||
) -> Result<Response<RegisterResponse>, Status> {
|
) -> 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();
|
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 {
|
let record = PluginRecord {
|
||||||
id: r.id,
|
id: r.id,
|
||||||
name: r.name,
|
name: r.name,
|
||||||
@@ -956,10 +1094,21 @@ impl PluginBridgeService for BridgeSvc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn call(&self, req: Request<CallRequest>) -> Result<Response<CallResponse>, Status> {
|
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 r = req.into_inner();
|
||||||
let fn_name = &r.r#fn;
|
let fn_name = &r.r#fn;
|
||||||
match self.plugins.find_export_record(&r.target, fn_name) {
|
match self.plugins.find_export_record(&r.target, fn_name) {
|
||||||
Some(record) => {
|
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();
|
let endpoint = record.endpoint.as_deref().unwrap_or_default();
|
||||||
if endpoint.is_empty() {
|
if endpoint.is_empty() {
|
||||||
return Ok(Response::new(CallResponse {
|
return Ok(Response::new(CallResponse {
|
||||||
@@ -1002,28 +1151,25 @@ impl PluginBridgeService for BridgeSvc {
|
|||||||
&self,
|
&self,
|
||||||
req: Request<CallRequest>,
|
req: Request<CallRequest>,
|
||||||
) -> Result<Response<Self::StreamCallStream>, Status> {
|
) -> 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 r = req.into_inner();
|
||||||
let target = r.target;
|
let target = r.target;
|
||||||
|
|
||||||
if let Some(ref metadata_id) = metadata_plugin_id {
|
if !target.is_empty() && plugin_id != target {
|
||||||
if !target.is_empty() && metadata_id != &target {
|
return Err(Status::permission_denied(format!(
|
||||||
return Err(Status::permission_denied(format!(
|
"plugin '{}' cannot subscribe to '{}' events",
|
||||||
"plugin '{}' cannot subscribe to '{}' events",
|
plugin_id, target
|
||||||
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",
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
permission::require_running_plugin(&self.plugins, &plugin_id)?;
|
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 (tx, rx) = tokio::sync::mpsc::channel(16);
|
||||||
let bus = self.kernel.callback_bus.clone();
|
let bus = self.kernel.callback_bus.clone();
|
||||||
let subscribed_plugin_id = plugin_id.clone();
|
let subscribed_plugin_id = plugin_id.clone();
|
||||||
@@ -1043,7 +1189,7 @@ impl PluginBridgeService for BridgeSvc {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
bus.remove(&subscribed_plugin_id);
|
bus.remove_if_generation(&subscribed_plugin_id, generation);
|
||||||
});
|
});
|
||||||
Ok(Response::new(tokio_stream::wrappers::ReceiverStream::new(
|
Ok(Response::new(tokio_stream::wrappers::ReceiverStream::new(
|
||||||
rx,
|
rx,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use tokio::fs as async_fs;
|
use tokio::fs as async_fs;
|
||||||
use tokio::io::{AsyncReadExt, AsyncSeekExt};
|
use tokio::io::{AsyncReadExt, AsyncSeekExt};
|
||||||
|
use notify::{Watcher, RecursiveMode, EventKind};
|
||||||
|
|
||||||
const MAX_READ_SIZE: usize = 64 * 1024 * 1024; // 64 MB
|
const MAX_READ_SIZE: usize = 64 * 1024 * 1024; // 64 MB
|
||||||
|
|
||||||
@@ -106,6 +107,57 @@ impl FsSyscall {
|
|||||||
}
|
}
|
||||||
Ok(entries)
|
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 {
|
pub struct FsStatInfo {
|
||||||
@@ -121,3 +173,8 @@ pub struct FsEntryInfo {
|
|||||||
pub is_dir: bool,
|
pub is_dir: bool,
|
||||||
pub size: i64,
|
pub size: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub struct FsWatchEventInfo {
|
||||||
|
pub path: String,
|
||||||
|
pub kind: String,
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,12 +1,34 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
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
|
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.
|
/// Network syscall: remote communication.
|
||||||
pub struct NetworkSyscall {
|
pub struct NetworkSyscall {
|
||||||
client: reqwest::Client,
|
client: reqwest::Client,
|
||||||
|
conns: Arc<tokio::sync::Mutex<HashMap<String, ConnHandle>>>,
|
||||||
|
next_conn_id: Arc<AtomicU64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl NetworkSyscall {
|
impl NetworkSyscall {
|
||||||
@@ -18,13 +40,23 @@ impl NetworkSyscall {
|
|||||||
.redirect(reqwest::redirect::Policy::limited(5))
|
.redirect(reqwest::redirect::Policy::limited(5))
|
||||||
.build()
|
.build()
|
||||||
.unwrap_or_else(|_| reqwest::Client::new());
|
.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 {
|
pub async fn available(&self) -> bool {
|
||||||
tokio::time::timeout(
|
tokio::time::timeout(
|
||||||
Duration::from_secs(2),
|
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
|
.await
|
||||||
.map(|result| result.is_ok())
|
.map(|result| result.is_ok())
|
||||||
@@ -91,4 +123,162 @@ impl NetworkSyscall {
|
|||||||
|
|
||||||
Ok((status, resp_headers, resp_body.to_vec()))
|
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 std::sync::Arc;
|
||||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
use tokio::process::Command;
|
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.
|
/// Per-process managed state, decoupled from the Child handle.
|
||||||
struct ManagedProcess {
|
struct ManagedProcess {
|
||||||
@@ -10,8 +11,8 @@ struct ManagedProcess {
|
|||||||
status: Arc<Mutex<String>>,
|
status: Arc<Mutex<String>>,
|
||||||
stdin_tx: Option<mpsc::Sender<Vec<u8>>>,
|
stdin_tx: Option<mpsc::Sender<Vec<u8>>>,
|
||||||
stdout_rx: Arc<Mutex<mpsc::Receiver<Vec<u8>>>>,
|
stdout_rx: Arc<Mutex<mpsc::Receiver<Vec<u8>>>>,
|
||||||
/// Notified when the process exits. Clone the receiver to wait.
|
/// Watch receiver: becomes `true` when process exits. Clone to wait.
|
||||||
exit_notify: Arc<tokio::sync::Notify>,
|
exit_rx: watch::Receiver<bool>,
|
||||||
/// Handle to abort background tasks on kill.
|
/// Handle to abort background tasks on kill.
|
||||||
_tasks: Vec<tokio::task::JoinHandle<()>>,
|
_tasks: Vec<tokio::task::JoinHandle<()>>,
|
||||||
}
|
}
|
||||||
@@ -19,13 +20,13 @@ struct ManagedProcess {
|
|||||||
/// Process syscall: spawn and manage subprocesses.
|
/// Process syscall: spawn and manage subprocesses.
|
||||||
/// Uses the real OS PID as the identifier.
|
/// Uses the real OS PID as the identifier.
|
||||||
pub struct ProcessSyscall {
|
pub struct ProcessSyscall {
|
||||||
procs: RwLock<HashMap<u64, Arc<ManagedProcess>>>,
|
procs: Arc<RwLock<HashMap<u64, Arc<ManagedProcess>>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ProcessSyscall {
|
impl ProcessSyscall {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
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 child_stderr = child.stderr.take();
|
||||||
|
|
||||||
let status = Arc::new(Mutex::new("running".to_string()));
|
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
|
// Stdin writer task
|
||||||
let (stdin_tx, mut stdin_rx) = mpsc::channel::<Vec<u8>>(64);
|
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 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 reaper_task = tokio::spawn(async move {
|
||||||
let exit_status = child.wait().await;
|
let exit_status = child.wait().await;
|
||||||
let code = exit_status.map(|s| s.code().unwrap_or(-1)).unwrap_or(-1);
|
let code = exit_status.map(|s| s.code().unwrap_or(-1)).unwrap_or(-1);
|
||||||
*status_clone.lock().await = format!("exited({})", code);
|
*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 {
|
let managed = Arc::new(ManagedProcess {
|
||||||
@@ -126,7 +130,7 @@ impl ProcessSyscall {
|
|||||||
status,
|
status,
|
||||||
stdin_tx: Some(stdin_tx),
|
stdin_tx: Some(stdin_tx),
|
||||||
stdout_rx: Arc::new(Mutex::new(stdout_rx)),
|
stdout_rx: Arc::new(Mutex::new(stdout_rx)),
|
||||||
exit_notify,
|
exit_rx,
|
||||||
_tasks: vec![stdin_task, stdout_task, stderr_task, reaper_task],
|
_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> {
|
pub async fn kill(&self, pid: u64) -> Result<(), String> {
|
||||||
let procs = self.procs.read().await;
|
let proc = {
|
||||||
let proc = procs
|
let procs = self.procs.read().await;
|
||||||
.get(&pid)
|
procs
|
||||||
.ok_or_else(|| format!("pid {} not found", pid))?
|
.get(&pid)
|
||||||
.clone();
|
.ok_or_else(|| format!("pid {} not found", pid))?
|
||||||
drop(procs);
|
.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Set status to killed first
|
||||||
|
*proc.status.lock().await = "killed".to_string();
|
||||||
|
|
||||||
// Abort all background tasks (stdin writer, stdout reader, stderr drain, reaper)
|
// 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() {
|
for task in proc._tasks.iter() {
|
||||||
task.abort();
|
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
|
// Also send OS kill
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
@@ -161,46 +171,41 @@ impl ProcessSyscall {
|
|||||||
// On Windows, aborting the reaper task triggers kill_on_drop on the Child
|
// 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);
|
tracing::info!("process killed: pid={}", pid);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn wait(&self, pid: u64) -> Result<i32, String> {
|
pub async fn wait(&self, pid: u64) -> Result<i32, String> {
|
||||||
let procs = self.procs.read().await;
|
let proc = {
|
||||||
let proc = procs
|
let procs = self.procs.read().await;
|
||||||
.get(&pid)
|
procs
|
||||||
.ok_or_else(|| format!("pid {} not found", pid))?
|
.get(&pid)
|
||||||
.clone();
|
.ok_or_else(|| format!("pid {} not found", pid))?
|
||||||
drop(procs);
|
.clone()
|
||||||
|
};
|
||||||
|
|
||||||
// Check if already exited before waiting
|
// Use watch channel: no race between checking status and waiting.
|
||||||
{
|
// watch retains the latest value, so even if send(true) happened before
|
||||||
let status = proc.status.lock().await;
|
// we subscribe, borrow_and_update() will see `true` immediately.
|
||||||
if status.starts_with("exited") || *status == "killed" {
|
let mut rx = proc.exit_rx.clone();
|
||||||
let s = status.clone();
|
while !*rx.borrow_and_update() {
|
||||||
drop(status);
|
if rx.changed().await.is_err() {
|
||||||
self.procs.write().await.remove(&pid);
|
break; // sender dropped (e.g. kill aborted reaper)
|
||||||
return parse_exit_code(&s);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for exit notification
|
|
||||||
proc.exit_notify.notified().await;
|
|
||||||
let status = proc.status.lock().await.clone();
|
let status = proc.status.lock().await.clone();
|
||||||
|
|
||||||
// Clean up from map
|
|
||||||
self.procs.write().await.remove(&pid);
|
|
||||||
parse_exit_code(&status)
|
parse_exit_code(&status)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn write_stdin(&self, pid: u64, data: &[u8]) -> Result<(), String> {
|
pub async fn write_stdin(&self, pid: u64, data: &[u8]) -> Result<(), String> {
|
||||||
let procs = self.procs.read().await;
|
let proc = {
|
||||||
let proc = procs
|
let procs = self.procs.read().await;
|
||||||
.get(&pid)
|
procs
|
||||||
.ok_or_else(|| format!("pid {} not found", pid))?
|
.get(&pid)
|
||||||
.clone();
|
.ok_or_else(|| format!("pid {} not found", pid))?
|
||||||
drop(procs);
|
.clone()
|
||||||
|
};
|
||||||
|
|
||||||
if let Some(ref tx) = proc.stdin_tx {
|
if let Some(ref tx) = proc.stdin_tx {
|
||||||
tx.send(data.to_vec())
|
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> {
|
pub async fn read_stdout(&self, pid: u64, buf: &mut [u8]) -> Result<usize, String> {
|
||||||
let procs = self.procs.read().await;
|
let proc = {
|
||||||
let proc = procs
|
let procs = self.procs.read().await;
|
||||||
.get(&pid)
|
procs
|
||||||
.ok_or_else(|| format!("pid {} not found", pid))?
|
.get(&pid)
|
||||||
.clone();
|
.ok_or_else(|| format!("pid {} not found", pid))?
|
||||||
drop(procs);
|
.clone()
|
||||||
|
};
|
||||||
|
|
||||||
let mut rx = proc.stdout_rx.lock().await;
|
let mut rx = proc.stdout_rx.lock().await;
|
||||||
match rx.recv().await {
|
match rx.recv().await {
|
||||||
@@ -268,9 +274,13 @@ impl ProcessSyscall {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn list(&self) -> Vec<(u64, String, String)> {
|
pub async fn list(&self) -> Vec<(u64, String, String)> {
|
||||||
let procs = self.procs.read().await;
|
let snapshot: Vec<(u64, Arc<ManagedProcess>)> = {
|
||||||
let mut result = Vec::with_capacity(procs.len());
|
let procs = self.procs.read().await;
|
||||||
for (pid, mp) in procs.iter() {
|
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();
|
let status = mp.status.lock().await.clone();
|
||||||
result.push((*pid, mp.cmd.clone(), status));
|
result.push((*pid, mp.cmd.clone(), status));
|
||||||
}
|
}
|
||||||
@@ -288,3 +298,4 @@ fn parse_exit_code(status: &str) -> Result<i32, String> {
|
|||||||
Ok(-1)
|
Ok(-1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
use std::str::FromStr;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use tokio::time::{self, Duration};
|
use tokio::time::{self, Duration};
|
||||||
|
|
||||||
@@ -75,4 +76,53 @@ impl TimerSyscall {
|
|||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.as_millis() as i64
|
.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 总线)
|
**管**:对外连接、监听 | **不管**:本地 IPC(内核 gRPC 总线)
|
||||||
|
|
||||||
```
|
```
|
||||||
net.request(url, method, body?) -> response
|
network.request(url, method, headers?, body?) -> response # HTTP 请求
|
||||||
net.connect(addr, protocol) -> conn
|
network.open_conn(addr, protocol) -> stream ConnEvent # TCP 建连,流式推送入站数据
|
||||||
net.listen(port, protocol) -> listener
|
network.send(conn_id, data) # 向已有连接发数据
|
||||||
net.send(conn, data)
|
network.close(conn_id) # 关闭连接
|
||||||
net.recv(conn) -> data
|
network.listen(port, protocol) -> stream ConnEvent # 监听端口,每 accept 推送 ConnEvent
|
||||||
net.close(conn)
|
network.available() -> bool # 网络连通性探测
|
||||||
net.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
|
## 6. Process
|
||||||
@@ -115,7 +118,7 @@ proc.list() -> [pid]
|
|||||||
|
|
||||||
```
|
```
|
||||||
timer.once(delay, callback) -> id # 到期后通过 PluginBridge.StreamCall 投递事件
|
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.cancel(id)
|
||||||
timer.now() -> timestamp
|
timer.now() -> timestamp
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -177,3 +177,25 @@ message CallRequest {
|
|||||||
- 系统插件:内核信任,无限制
|
- 系统插件:内核信任,无限制
|
||||||
- 基础插件:按 `syscalls` 白名单精确到方法(如 `fs.list`)校验,也支持 `fs` / `fs.*` / `*` 授权(= seccomp filter)
|
- 基础插件:按 `syscalls` 白名单精确到方法(如 `fs.list`)校验,也支持 `fs` / `fs.*` / `*` 授权(= seccomp filter)
|
||||||
- 标准插件:syscalls + depends 白名单,未声明的一律拒绝
|
- 标准插件: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] 实现 Memory syscall(SQLite KV + FTS5 trigram search,默认写入 data/memory.db)
|
||||||
- [x] 实现 Input syscall(stdin)
|
- [x] 实现 Input syscall(stdin)
|
||||||
- [x] 实现 Display syscall(stdout)
|
- [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] 实现 Timer syscall(once/cancel/now + 回调事件通道)
|
||||||
- [x] 实现 Audio syscall(stub,待系统插件)
|
- [x] 实现 Audio syscall(stub,待系统插件)
|
||||||
- [x] 实现 HID 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] Plugin Bridge(注册/调用路由/心跳,注册状态写入 data/plugins.json)
|
||||||
- [x] 插件加载:gRPC 握手 + 注册
|
- [x] 插件加载:gRPC 握手 + 注册
|
||||||
- [x] echo 测试插件(Python)验证基础链路
|
- [x] echo 测试插件(Rust)验证基础链路
|
||||||
- [x] ai_test 手动验证插件(Python,默认 disabled/autoload false)覆盖 Display/Memory/FS/Process/Timer/权限拒绝
|
- [x] ai_test 手动验证插件(Rust,默认 disabled/autoload false)覆盖 Display/Memory/FS/Process/Timer/权限拒绝
|
||||||
- [x] proto 定义:完整 9 个 service + PluginBridge
|
- [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
|
- [x] Network:reqwest 原生 HTTP client(阶段 1 已完成)
|
||||||
- [ ] FS.Watch:接入 notify-rs 文件监听
|
- [x] Network TCP 流:open_conn/send/close/listen 双向 TCP 通信
|
||||||
- [ ] Timer.Cron:接入 cron 表达式解析
|
- [x] FS.Watch:接入 notify-rs v7 文件监听(递归)
|
||||||
|
- [x] Timer.Cron:接入 cron 表达式定时(7 段格式)
|
||||||
- [ ] Audio:接入系统音频后端(wasapi/alsa)
|
- [ ] Audio:接入系统音频后端(wasapi/alsa)
|
||||||
- [ ] HID:接入屏幕截取(Windows: win32 API)
|
- [ ] HID:接入屏幕截取(Windows: win32 API)
|
||||||
- [ ] Unix socket / Named pipe 传输(替代 TCP)
|
- [ ] Unix socket / Named pipe 传输(替代 TCP)
|
||||||
|
|||||||
@@ -51,22 +51,21 @@ Agentsd/
|
|||||||
│ ├── agentsd_pb2.py
|
│ ├── agentsd_pb2.py
|
||||||
│ └── agentsd_pb2_grpc.py
|
│ └── agentsd_pb2_grpc.py
|
||||||
│
|
│
|
||||||
├── echo/ # 测试插件
|
├── echo/ # 测试插件(Rust)
|
||||||
│ ├── plugin.yaml
|
│ ├── 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
|
│ ├── plugin.yaml
|
||||||
│ └── ai_test_plugin.py
|
│ └── src/main.rs
|
||||||
│
|
│
|
||||||
├── hermes/ # Hermes Agent 桥接(63 tool)
|
├── hermes/ # Hermes Agent 桥接(Python,Rust 版仅 stub)
|
||||||
│ ├── plugin.yaml
|
│ ├── plugin.yaml
|
||||||
│ └── hermes_plugin.py
|
│ └── hermes_plugin.py
|
||||||
│
|
│
|
||||||
└── claudecode/ # Claude Code CLI 桥接
|
└── claudecode/ # Claude Code CLI 桥接(Rust)
|
||||||
├── plugin.yaml
|
├── plugin.yaml
|
||||||
├── claudecode_plugin.py
|
└── src/main.rs
|
||||||
└── test_chat.py
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 布局原则
|
## 布局原则
|
||||||
@@ -94,7 +93,7 @@ cargo build --release # 约 6.1MB 单二进制
|
|||||||
|
|
||||||
# 启动插件(各开一个终端)
|
# 启动插件(各开一个终端)
|
||||||
python plugins/hermes/hermes_plugin.py
|
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
|
```bash
|
||||||
RUST_LOG=agentsd=info ./target/release/agentsd.exe
|
RUST_LOG=agentsd=info ./target/release/agentsd.exe
|
||||||
python plugins/ai_test/ai_test_plugin.py
|
cargo run -p agentsd-plugin-ai-test
|
||||||
```
|
```
|
||||||
|
|
||||||
## 重新生成 SDK
|
## 重新生成 SDK
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -1160,7 +1160,7 @@ class NetworkStub:
|
|||||||
self.OpenConn = channel.unary_stream(
|
self.OpenConn = channel.unary_stream(
|
||||||
'/agentsd.Network/OpenConn',
|
'/agentsd.Network/OpenConn',
|
||||||
request_serializer=agentsd__pb2.ConnectRequest.SerializeToString,
|
request_serializer=agentsd__pb2.ConnectRequest.SerializeToString,
|
||||||
response_deserializer=agentsd__pb2.DataChunk.FromString,
|
response_deserializer=agentsd__pb2.ConnEvent.FromString,
|
||||||
_registered_method=True)
|
_registered_method=True)
|
||||||
self.Listen = channel.unary_stream(
|
self.Listen = channel.unary_stream(
|
||||||
'/agentsd.Network/Listen',
|
'/agentsd.Network/Listen',
|
||||||
@@ -1235,7 +1235,7 @@ def add_NetworkServicer_to_server(servicer, server):
|
|||||||
'OpenConn': grpc.unary_stream_rpc_method_handler(
|
'OpenConn': grpc.unary_stream_rpc_method_handler(
|
||||||
servicer.OpenConn,
|
servicer.OpenConn,
|
||||||
request_deserializer=agentsd__pb2.ConnectRequest.FromString,
|
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(
|
'Listen': grpc.unary_stream_rpc_method_handler(
|
||||||
servicer.Listen,
|
servicer.Listen,
|
||||||
@@ -1312,7 +1312,7 @@ class Network:
|
|||||||
target,
|
target,
|
||||||
'/agentsd.Network/OpenConn',
|
'/agentsd.Network/OpenConn',
|
||||||
agentsd__pb2.ConnectRequest.SerializeToString,
|
agentsd__pb2.ConnectRequest.SerializeToString,
|
||||||
agentsd__pb2.DataChunk.FromString,
|
agentsd__pb2.ConnEvent.FromString,
|
||||||
options,
|
options,
|
||||||
channel_credentials,
|
channel_credentials,
|
||||||
insecure,
|
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
|
- name: run_checks
|
||||||
description: Run end-to-end Agentsd syscall checks for AI verification
|
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
|
- name: run
|
||||||
description: Full Claude Code access (all tools, unlimited turns)
|
description: Full Claude Code access (all tools, unlimited turns)
|
||||||
|
|
||||||
entry: python claudecode_plugin.py
|
entry: cargo run -p agentsd-plugin-claudecode
|
||||||
port: 50053
|
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
|
- name: echo
|
||||||
description: Echo test
|
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:
|
class HermesPlugin:
|
||||||
"""Bridges Hermes tools into agentsd."""
|
"""Bridges Hermes tools into agentsd."""
|
||||||
|
|
||||||
def __init__(self, agentsd_addr: str = "localhost:50051"):
|
def __init__(self, agentsd_addr: str = None):
|
||||||
self.addr = agentsd_addr
|
# 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.channel = None
|
||||||
self.bridge = None
|
self.bridge = None
|
||||||
self.registry = None
|
self.registry = None
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ message MemoryResponse { bool ok = 1; string error = 2; }
|
|||||||
// ========== Network ==========
|
// ========== Network ==========
|
||||||
service Network {
|
service Network {
|
||||||
rpc Request(HttpRequest) returns (HttpResponse);
|
rpc Request(HttpRequest) returns (HttpResponse);
|
||||||
rpc OpenConn(ConnectRequest) returns (stream DataChunk);
|
rpc OpenConn(ConnectRequest) returns (stream ConnEvent);
|
||||||
rpc Listen(ListenRequest) returns (stream ConnEvent);
|
rpc Listen(ListenRequest) returns (stream ConnEvent);
|
||||||
rpc Send(SendRequest) returns (NetResponse);
|
rpc Send(SendRequest) returns (NetResponse);
|
||||||
rpc Close(CloseRequest) returns (NetResponse);
|
rpc Close(CloseRequest) returns (NetResponse);
|
||||||
|
|||||||
Reference in New Issue
Block a user