1mod server;
2mod transport;
3
4use rpi_plugin_sdk::{
5 register_entrypoint_unified, EventTag, FreeStringFn, PluginApi, RuntimeActionFn, RuntimeActionId,
6 StablePluginEvent, StableToolSchema, StbString, StbStringRef, StepHandle, StepResult,
7 ToolPartialCb,
8};
9use serde_json::{json, Value};
10use std::collections::HashMap;
11use std::sync::atomic::{AtomicBool, AtomicPtr, Ordering};
12use std::sync::{Arc, Mutex, OnceLock};
13use std::sync::mpsc;
14use std::thread;
15use std::time::{Duration, Instant};
16use server::{generate_token, launch_args, run_server, TcpServerHandle};
17use transport::{ManagedConnection, TcpTransport};
18
19const DEFAULT_TIMEOUT: u64 = 30;
20
21static SERVERS: OnceLock<Mutex<HashMap<String, Arc<TcpServerHandle>>>> = OnceLock::new();
24
25fn servers() -> &'static Mutex<HashMap<String, Arc<TcpServerHandle>>> {
26 SERVERS.get_or_init(|| Mutex::new(HashMap::new()))
27}
28
29static RUNTIME_ACTION: OnceLock<RuntimeActionFn> = OnceLock::new();
35static RUNTIME_USER_DATA: AtomicPtr<std::ffi::c_void> = AtomicPtr::new(std::ptr::null_mut());
36static HOST_FREE_STRING: OnceLock<FreeStringFn> = OnceLock::new();
38
39fn get_cli_flag(name: &str) -> Option<Value> {
41 let runtime_action = *RUNTIME_ACTION.get()?;
42 let user_data = RUNTIME_USER_DATA.load(Ordering::Acquire);
43 if user_data.is_null() {
44 return None;
45 }
46
47 let args = json!({"name": name});
48 let args_str = args.to_string();
49 let args_ref = StbStringRef::from_str(&args_str);
50 let mut out = StbString::empty();
51
52 let rc = runtime_action(
53 RuntimeActionId::GetCliFlag as u32,
54 args_ref,
55 &mut out,
56 user_data,
57 );
58
59 let result_str = if rc == 0 {
62 Some(out.to_string_lossy())
63 } else {
64 None
65 };
66 if let Some(free) = HOST_FREE_STRING.get() {
67 free(out);
68 }
69
70 let result: Value = serde_json::from_str(&result_str?).ok()?;
71 result.get("value").cloned()
72}
73
74struct Drive {
75 receiver: mpsc::Receiver<Result<String, String>>,
76 cancelled: Arc<AtomicBool>,
77 done: bool,
78}
79
80fn string_param(params: &Value, key: &str) -> Result<String, String> {
83 params
84 .get(key)
85 .and_then(Value::as_str)
86 .map(str::to_owned)
87 .filter(|s| !s.trim().is_empty())
88 .ok_or_else(|| format!("{key} is required"))
89}
90
91fn optional_string(params: &Value, key: &str) -> Result<Option<String>, String> {
92 match params.get(key) {
93 None | Some(Value::Null) => Ok(None),
94 Some(Value::String(s)) if !s.trim().is_empty() => Ok(Some(s.clone())),
95 Some(Value::String(_)) => Err(format!("{key} must not be empty")),
96 Some(_) => Err(format!("{key} must be a string")),
97 }
98}
99
100fn timeout_param(params: &Value) -> Result<Duration, String> {
101 match params.get("timeoutSeconds") {
102 None | Some(Value::Null) => Ok(Duration::from_secs(DEFAULT_TIMEOUT)),
103 Some(v) => v
104 .as_u64()
105 .filter(|v| (1..=300).contains(v))
106 .map(Duration::from_secs)
107 .ok_or_else(|| "timeoutSeconds must be an integer from 1 to 300".into()),
108 }
109}
110
111fn do_start(params: &Value) -> Result<String, String> {
114 let bind_addr = optional_string(params, "bind")?.unwrap_or_else(|| "127.0.0.1".into());
115 let port: u16 = match params.get("port") {
116 Some(Value::Number(n)) => n.as_u64()
117 .and_then(|n| u16::try_from(n).ok())
118 .ok_or("port must be an integer 0–65535")?,
119 None => 9800,
120 Some(_) => return Err("port must be an integer".into()),
121 };
122
123 let (executable, args) = launch_args(params)?;
124 let no_token = params
125 .get("noToken")
126 .and_then(Value::as_bool)
127 .unwrap_or(false);
128 let token = if no_token {
129 String::new()
131 } else {
132 params
133 .get("token")
134 .and_then(Value::as_str)
135 .map(str::to_owned)
136 .filter(|t| !t.is_empty())
137 .unwrap_or_else(generate_token)
138 };
139
140 let exec_clone = executable.clone();
141 let args_clone = args.clone();
142 let token_clone = token.clone();
143 let bind_clone = bind_addr.clone();
144
145 let (ready_tx, ready_rx) = mpsc::channel();
146 thread::spawn(move || {
147 let runtime = match tokio::runtime::Builder::new_multi_thread()
148 .enable_all()
149 .worker_threads(2)
150 .build()
151 {
152 Ok(r) => r,
153 Err(e) => {
154 let _ = ready_tx.send(Err(format!("failed to create runtime: {e}")));
155 return;
156 }
157 };
158
159 let result = runtime.block_on(async {
160 let transport = Arc::new(TcpTransport::new());
161 run_server(transport, &bind_clone, port, token_clone, exec_clone, args_clone).await
162 });
163
164 match result {
165 Ok(server) => {
166 if let Ok(mut registry) = servers().lock() {
167 registry.insert(server.id.clone(), server.clone());
168 }
169 let _ = ready_tx.send(Ok(server));
170 }
171 Err(e) => {
172 let _ = ready_tx.send(Err(e));
173 }
174 }
175
176 runtime.block_on(async { loop { tokio::time::sleep(Duration::from_secs(3600)).await; } });
177 });
178
179 let server = match ready_rx.recv_timeout(Duration::from_secs(10)) {
180 Ok(Ok(s)) => s,
181 Ok(Err(e)) => return Err(e),
182 Err(_) => return Err("timed out waiting for server startup".into()),
183 };
184
185 Ok(json!({
186 "id": server.id,
187 "address": server.address,
188 "token": token,
189 "state": "running",
190 "protocol": "jsonl",
191 "transport": "tcp",
192 }).to_string())
193}
194
195fn do_stop(id: &str) -> Result<String, String> {
196 let server = servers()
197 .lock()
198 .map_err(|_| "server registry poisoned")?
199 .remove(id)
200 .ok_or("RPC server not found")?;
201
202 let (tx, rx) = mpsc::channel();
203 let server_clone = Arc::clone(&server);
204 thread::spawn(move || {
205 let runtime = tokio::runtime::Builder::new_current_thread()
206 .enable_all()
207 .build()
208 .unwrap();
209 let result = runtime.block_on(server_clone.stop());
210 let _ = tx.send(result);
211 });
212
213 match rx.recv_timeout(Duration::from_secs(5)) {
214 Ok(Ok(())) => Ok(json!({"id": id, "state": "stopped"}).to_string()),
215 Ok(Err(e)) => Err(e),
216 Err(_) => Err("timed out waiting for server stop".into()),
217 }
218}
219
220async fn server_status(server: &Arc<TcpServerHandle>) -> Result<Value, String> {
221 let info = server.info().await;
222 Ok(json!({
223 "id": info.id,
224 "address": info.address,
225 "token": info.token,
226 "state": info.state,
227 "protocol": "jsonl",
228 "transport": "tcp",
229 "sessions": info.sessions,
230 }))
231}
232
233async fn do_status(id: &str) -> Result<String, String> {
234 let server = servers()
235 .lock()
236 .map_err(|_| "server registry poisoned")?
237 .get(id)
238 .cloned()
239 .ok_or("RPC server not found")?;
240 Ok(server_status(&server).await?.to_string())
241}
242
243async fn do_list() -> Result<String, String> {
244 let registry = servers().lock().map_err(|_| "server registry poisoned")?;
245 let mut list: Vec<Value> = Vec::new();
246 for server in registry.values() {
247 list.push(server_status(server).await?);
248 }
249 Ok(json!(list).to_string())
250}
251
252async fn server_tool(params: &Value) -> Result<String, String> {
253 match params.get("action").and_then(Value::as_str).unwrap_or("start") {
254 "start" => do_start(params),
255 "stop" => {
256 let id = string_param(params, "id")?;
257 do_stop(&id)
258 }
259 "status" => {
260 let id = string_param(params, "id")?;
261 do_status(&id).await
262 }
263 "list" => do_list().await,
264 other => Err(format!("action must be one of: start, stop, status, list (got {other})")),
265 }
266}
267
268fn stop_all_servers() {
269 let ids: Vec<String> = match servers().lock() {
270 Ok(registry) => registry.keys().cloned().collect(),
271 Err(_) => return,
272 };
273 for id in ids {
274 let _ = do_stop(&id);
275 }
276}
277
278extern "C" fn on_session_shutdown(event: StablePluginEvent, _: *mut std::ffi::c_void) -> i32 {
279 if event.tag == EventTag::SessionShutdown {
280 stop_all_servers();
281 }
282 0
283}
284
285fn flag_is_on(value: Option<&Value>) -> bool {
288 match value {
289 Some(Value::Bool(b)) => *b,
290 Some(Value::String(s)) => !s.is_empty() && !s.eq_ignore_ascii_case("false"),
291 _ => false,
292 }
293}
294
295extern "C" fn on_session_start(event: StablePluginEvent, _: *mut std::ffi::c_void) -> i32 {
297 if event.tag != EventTag::SessionStart {
298 return 0;
299 }
300
301 if !flag_is_on(get_cli_flag("server").as_ref()) {
305 return 0;
306 }
307
308 let port: u16 = match get_cli_flag("port") {
310 Some(Value::Number(n)) => n.as_u64().and_then(|n| u16::try_from(n).ok()).unwrap_or(9800),
311 Some(Value::String(s)) => s.parse().unwrap_or(9800),
312 _ => 9800,
313 };
314
315 let bind = match get_cli_flag("bind") {
317 Some(Value::String(s)) if !s.is_empty() => s,
318 _ => "127.0.0.1".to_string(),
319 };
320
321 eprintln!("[rpi-server] --server detected, starting TCP server on {bind}:{port}");
322
323 let token = match get_cli_flag("token") {
324 Some(Value::String(s)) if !s.is_empty() => Some(s),
325 _ => None,
326 };
327 let no_token = flag_is_on(get_cli_flag("no-token").as_ref());
328
329 let params = json!({
330 "bind": bind,
331 "port": port,
332 "token": token,
333 "noToken": no_token,
334 });
335
336 match do_start(¶ms) {
337 Ok(info) => eprintln!("[rpi-server] TCP server started: {info}"),
338 Err(e) => eprintln!("[rpi-server] failed to start: {e}"),
339 }
340
341 0
342}
343
344fn client_tool(params: &Value, cancelled: &AtomicBool) -> Result<String, String> {
347 let server_id = string_param(params, "serverId")?;
348 let method = string_param(params, "method")?;
349 let rpc_params = params.get("params").cloned().unwrap_or(json!({}));
350
351 let server = servers()
352 .lock()
353 .map_err(|_| "server registry poisoned")?
354 .get(&server_id)
355 .cloned()
356 .ok_or("RPC server not found")?;
357
358 if !server.is_running() {
359 return Err("RPC server is not running".into());
360 }
361
362 let wait = timeout_param(params)?;
363 let subscribe = params.get("subscribe").and_then(Value::as_bool).unwrap_or(false);
364
365 let (tx, rx) = mpsc::channel();
366 let cancelled_clone = Arc::new(AtomicBool::new(cancelled.load(Ordering::Acquire)));
367 let cancelled_inner = Arc::clone(&cancelled_clone);
368
369 thread::spawn(move || {
370 let runtime = tokio::runtime::Builder::new_current_thread()
371 .enable_all()
372 .build()
373 .unwrap();
374
375 let result = runtime.block_on(async {
376 let transport = Arc::new(TcpTransport::new());
377 let managed = ManagedConnection::new(transport);
378
379 managed.connect_with_retry(&server.address).await
381 .map_err(|e| format!("failed to connect: {e}"))?;
382
383 if !server.token.is_empty() {
385 let auth = json!({
386 "jsonrpc": "2.0",
387 "id": 0,
388 "method": "authenticate",
389 "params": {"token": server.token},
390 });
391 managed
392 .send(auth)
393 .await
394 .map_err(|e| format!("auth send failed: {e}"))?;
395 match tokio::time::timeout(Duration::from_secs(5), managed.recv()).await {
396 Ok(Ok(Some(response))) => {
397 if let Some(error) = response.get("error") {
398 return Err(format!("authentication failed: {error}"));
399 }
400 }
401 Ok(Ok(None)) => {
402 return Err("authentication failed: connection closed".into())
403 }
404 Ok(Err(e)) => return Err(format!("authentication failed: {e}")),
405 Err(_) => return Err("authentication failed: timed out".into()),
406 }
407 }
408
409 let request = json!({
411 "jsonrpc": "2.0",
412 "method": method,
413 "params": rpc_params,
414 "id": 1,
415 });
416 managed.send(request).await
417 .map_err(|e| format!("send failed: {e}"))?;
418
419 if subscribe {
420 let mut events = Vec::new();
422 let deadline = Instant::now() + wait;
423
424 loop {
425 if cancelled_inner.load(Ordering::Acquire) { break; }
426 if Instant::now() >= deadline { break; }
427
428 match tokio::time::timeout(Duration::from_millis(100), managed.recv()).await {
429 Ok(Ok(Some(event))) => {
430 let is_end = event.get("type").and_then(Value::as_str) == Some("session_end");
432 events.push(event);
433 if is_end {
434 break;
435 }
436 }
437 Ok(Ok(None)) => break,
438 Ok(Err(e)) => return Err(format!("recv error: {e}")),
439 Err(_) => continue, }
441 }
442
443 Ok(json!({
444 "serverId": server_id,
445 "method": method,
446 "events": events,
447 "eventCount": events.len(),
448 }).to_string())
449 } else {
450 let deadline = Instant::now() + wait;
452 loop {
453 if cancelled_inner.load(Ordering::Acquire) {
454 return Err("request cancelled".into());
455 }
456 if Instant::now() >= deadline {
457 return Err("request timed out".into());
458 }
459
460 match tokio::time::timeout(Duration::from_millis(100), managed.recv()).await {
461 Ok(Ok(Some(response))) => {
462 return Ok(json!({
463 "serverId": server_id,
464 "method": method,
465 "result": response,
466 }).to_string());
467 }
468 Ok(Ok(None)) => return Err("connection closed".into()),
469 Ok(Err(e)) => return Err(format!("recv error: {e}")),
470 Err(_) => continue, }
472 }
473 }
474 });
475
476 let _ = tx.send(result);
477 });
478
479 let deadline = Instant::now() + wait;
480 loop {
481 if cancelled.load(Ordering::Acquire) {
482 return Err("request cancelled".into());
483 }
484 if Instant::now() >= deadline {
485 return Err("request timed out".into());
486 }
487 match rx.recv_timeout(Duration::from_millis(100)) {
488 Ok(result) => return result,
489 Err(mpsc::RecvTimeoutError::Timeout) => continue,
490 Err(mpsc::RecvTimeoutError::Disconnected) => {
491 return Err("worker stopped without result".into());
492 }
493 }
494 }
495}
496
497extern "C" fn execute_server(
500 tool_call_id: StbStringRef,
501 params: StbString,
502 free: Option<FreeStringFn>,
503) -> StepHandle {
504 let _ = tool_call_id;
505 let text = params.to_string_lossy();
506 params.free_with(free);
507 let cancelled = Arc::new(AtomicBool::new(false));
508 let (tx, rx) = mpsc::channel();
509 thread::spawn(move || {
510 let result = serde_json::from_str::<Value>(&text)
511 .map_err(|e| format!("invalid parameters: {e}"))
512 .and_then(|p| {
513 let runtime = tokio::runtime::Builder::new_current_thread()
514 .enable_all()
515 .build()
516 .unwrap();
517 runtime.block_on(async {
518 server_tool(&p).await
519 })
520 });
521 let _ = tx.send(result);
522 });
523 Box::into_raw(Box::new(Drive { receiver: rx, cancelled, done: false })) as StepHandle
524}
525
526extern "C" fn execute_client(
527 tool_call_id: StbStringRef,
528 params: StbString,
529 free: Option<FreeStringFn>,
530) -> StepHandle {
531 let _ = tool_call_id;
532 let text = params.to_string_lossy();
533 params.free_with(free);
534 let cancelled = Arc::new(AtomicBool::new(false));
535 let cancelled_clone = Arc::clone(&cancelled);
536 let (tx, rx) = mpsc::channel();
537 thread::spawn(move || {
538 let result = serde_json::from_str::<Value>(&text)
539 .map_err(|e| format!("invalid parameters: {e}"))
540 .and_then(|p| client_tool(&p, &cancelled_clone));
541 let _ = tx.send(result);
542 });
543 Box::into_raw(Box::new(Drive { receiver: rx, cancelled, done: false })) as StepHandle
544}
545
546extern "C" fn poll(
547 handle: StepHandle,
548 _: Option<ToolPartialCb>,
549 _: *mut std::ffi::c_void,
550) -> StepResult {
551 if handle.is_null() {
552 return StepResult::err(StbString::from_string("null handle".into()));
553 }
554 let drive = unsafe { &mut *(handle as *mut Drive) };
555 if drive.done {
556 return StepResult::err(StbString::from_string("handle polled after completion".into()));
557 }
558 if drive.cancelled.load(Ordering::Acquire) {
559 drive.done = true;
560 return StepResult::err(StbString::from_string("request cancelled".into()));
561 }
562 match drive.receiver.try_recv() {
563 Ok(Ok(text)) => {
564 drive.done = true;
565 StepResult::done(StbString::from_string(
566 json!({"content": [{"type": "text", "text": text}]}).to_string(),
567 ))
568 }
569 Ok(Err(e)) => {
570 drive.done = true;
571 StepResult::err(StbString::from_string(e))
572 }
573 Err(mpsc::TryRecvError::Empty) => StepResult::pending(StbString::empty()),
574 Err(mpsc::TryRecvError::Disconnected) => {
575 drive.done = true;
576 StepResult::err(StbString::from_string("worker stopped without result".into()))
577 }
578 }
579}
580
581extern "C" fn cancel(handle: StepHandle) {
582 if !handle.is_null() {
583 unsafe { (&*(handle as *mut Drive)).cancelled.store(true, Ordering::Release); }
584 }
585}
586
587extern "C" fn destroy(handle: StepHandle) {
588 if !handle.is_null() {
589 unsafe {
590 let drive = Box::from_raw(handle as *mut Drive);
591 drive.cancelled.store(true, Ordering::Release);
592 }
593 }
594}
595
596extern "C" fn free_string(s: StbString) {
597 if !s.is_empty() && !s.ptr.is_null() {
598 unsafe {
599 let bytes = std::slice::from_raw_parts(s.ptr as *const u8, s.len);
600 let _ = Box::from_raw(bytes as *const [u8] as *mut [u8]);
601 }
602 }
603}
604
605const SERVER_PARAMETERS: &str = r#"{
608 "type":"object",
609 "properties":{
610 "action":{"type":"string","enum":["start","stop","status","list"]},
611 "id":{"type":"string"},
612 "bind":{"type":"string"},
613 "port":{"type":"integer","minimum":0,"maximum":65535},
614 "token":{"type":"string"},
615 "provider":{"type":"string"},
616 "model":{"type":"string"},
617 "baseUrl":{"type":"string"},
618 "systemPrompt":{"type":"string"},
619 "appendSystemPrompt":{"type":"array","items":{"type":"string"}},
620 "thinking":{"type":"string","enum":["off","minimal","low","medium","high","xhigh","max"]},
621 "name":{"type":"string"},
622 "session":{"type":"string"},
623 "sessionId":{"type":"string"},
624 "sessionDir":{"type":"string"},
625 "noSession":{"type":"boolean"},
626 "tools":{"type":"array","items":{"type":"string"}},
627 "excludeTools":{"type":"array","items":{"type":"string"}},
628 "noTools":{"type":"boolean"},
629 "noBuiltinTools":{"type":"boolean"},
630 "noSkills":{"type":"boolean"},
631 "noPromptTemplates":{"type":"boolean"},
632 "noContextFiles":{"type":"boolean"},
633 "noExtensions":{"type":"boolean"},
634 "enablePiPackages":{"type":"boolean"},
635 "extensionsDir":{"type":"array","items":{"type":"string"}},
636 "extension":{"type":"array","items":{"type":"string"}},
637 "skill":{"type":"array","items":{"type":"string"}},
638 "promptTemplate":{"type":"array","items":{"type":"string"}},
639 "offline":{"type":"boolean"},
640 "approve":{"type":"boolean"}
641 }
642}"#;
643
644const CLIENT_PARAMETERS: &str = r#"{
645 "type":"object",
646 "properties":{
647 "serverId":{"type":"string"},
648 "method":{"type":"string"},
649 "params":{"type":"object"},
650 "subscribe":{"type":"boolean","description":"Enable streaming subscription"},
651 "timeoutSeconds":{"type":"integer","minimum":1,"maximum":300}
652 },
653 "required":["serverId","method"]
654}"#;
655
656#[no_mangle]
659pub extern "C" fn rpi_plugin_register(api: *const PluginApi) -> i32 {
660 unsafe {
661 register_entrypoint_unified(api, |api| {
662 let _ = RUNTIME_ACTION.set(api.runtime_action);
665 RUNTIME_USER_DATA.store(api.user_data, Ordering::Release);
666 let _ = HOST_FREE_STRING.set(api.free_string);
667 let Some(register) = api.register_tool else {
668 return 1;
669 };
670
671 let server_schema = Box::new(StableToolSchema {
672 name: StbString::from_string("rpc_server".into()),
673 description: StbString::from_string(
674 "Start and manage a TCP JSONL server with streaming subscriptions.".into(),
675 ),
676 parameters: StbString::from_string(SERVER_PARAMETERS.into()),
677 });
678
679 let client_schema = Box::new(StableToolSchema {
680 name: StbString::from_string("rpc_client".into()),
681 description: StbString::from_string(
682 "Send a JSON-RPC request to a running TCP server. Set subscribe=true for streaming responses.".into(),
683 ),
684 parameters: StbString::from_string(CLIENT_PARAMETERS.into()),
685 });
686
687 let first = register(
688 &*server_schema,
689 execute_server,
690 poll,
691 cancel,
692 destroy,
693 free_string,
694 );
695
696 let second = if first == 0 {
697 register(
698 &*client_schema,
699 execute_client,
700 poll,
701 cancel,
702 destroy,
703 free_string,
704 )
705 } else {
706 first
707 };
708
709 let third = if second == 0 {
710 api.register_event_handler
711 .map(|register| {
712 register(
713 EventTag::SessionShutdown,
714 on_session_shutdown,
715 std::ptr::null_mut(),
716 )
717 })
718 .unwrap_or(0)
719 } else {
720 second
721 };
722
723 let fourth = if third == 0 {
725 api.register_event_handler
726 .map(|register| {
727 register(
728 EventTag::SessionStart,
729 on_session_start,
730 std::ptr::null_mut(),
731 )
732 })
733 .unwrap_or(0)
734 } else {
735 third
736 };
737
738 drop(server_schema);
739 drop(client_schema);
740 fourth
741 })
742 }
743}
744
745#[cfg(test)]
746mod tests {
747 use super::*;
748
749 #[test]
750 fn launch_args_basic() {
751 let (_, args) = launch_args(&json!({
752 "provider": "anthropic",
753 "model": "claude-3",
754 "noSession": true,
755 }))
756 .unwrap();
757 assert_eq!(&args[0..2], ["--mode", "rpc"]);
758 assert!(args.windows(2).any(|w| w == ["--provider", "anthropic"]));
759 assert!(args.windows(2).any(|w| w == ["--model", "claude-3"]));
760 assert!(args.contains(&"--no-session".to_string()));
761 }
762
763 #[test]
764 fn schemas_are_valid_json() {
765 assert!(serde_json::from_str::<Value>(SERVER_PARAMETERS).is_ok());
766 assert!(serde_json::from_str::<Value>(CLIENT_PARAMETERS).is_ok());
767 }
768
769 #[test]
770 fn generate_token_is_32_hex_chars() {
771 let t = generate_token();
772 assert_eq!(t.len(), 32);
773 assert!(t.chars().all(|c| c.is_ascii_hexdigit()));
774 }
775}