1#![forbid(unsafe_code)]
2
3mod server;
4mod transport;
5
6use rpi_plugin_sdk::handles::ToolHandles;
7use rpi_plugin_sdk::{
8 EventTag, FreeStringFn, RuntimeActionFn, RuntimeActionId, StablePluginEvent, StableToolSchema,
9 StbString, StbStringRef, StepHandle, StepResult, ToolPartialCb,
10};
11use serde_json::{json, Value};
12use server::{generate_token, launch_args, run_server, TcpServerHandle};
13use std::collections::HashMap;
14use std::sync::atomic::{AtomicBool, AtomicPtr, Ordering};
15use std::sync::mpsc;
16use std::sync::{Arc, Mutex, OnceLock};
17use std::thread;
18use std::time::{Duration, Instant};
19use transport::{ManagedConnection, TcpTransport};
20
21const DEFAULT_TIMEOUT: u64 = 30;
22
23static SERVERS: OnceLock<Mutex<HashMap<String, Arc<TcpServerHandle>>>> = OnceLock::new();
26
27fn servers() -> &'static Mutex<HashMap<String, Arc<TcpServerHandle>>> {
28 SERVERS.get_or_init(|| Mutex::new(HashMap::new()))
29}
30
31static RUNTIME_ACTION: OnceLock<RuntimeActionFn> = OnceLock::new();
37static RUNTIME_USER_DATA: AtomicPtr<std::ffi::c_void> = AtomicPtr::new(std::ptr::null_mut());
38static HOST_FREE_STRING: OnceLock<FreeStringFn> = OnceLock::new();
40
41fn get_cli_flag(name: &str) -> Option<Value> {
43 let runtime_action = *RUNTIME_ACTION.get()?;
44 let user_data = RUNTIME_USER_DATA.load(Ordering::Acquire);
45 if user_data.is_null() {
46 return None;
47 }
48
49 let args = json!({"name": name});
50 let args_str = args.to_string();
51 let args_ref = StbStringRef::from_str(&args_str);
52 let mut out = StbString::empty();
53
54 let rc = runtime_action(
55 RuntimeActionId::GetCliFlag as u32,
56 args_ref,
57 &mut out,
58 user_data,
59 );
60
61 let result_str = if rc == 0 {
64 Some(out.to_string_lossy())
65 } else {
66 None
67 };
68 if let Some(free) = HOST_FREE_STRING.get() {
69 free(out);
70 }
71
72 let result: Value = serde_json::from_str(&result_str?).ok()?;
73 result.get("value").cloned()
74}
75
76static HANDLES: ToolHandles<Drive> = ToolHandles::new();
77
78struct Drive {
79 receiver: mpsc::Receiver<Result<String, String>>,
80 done: bool,
81}
82
83fn string_param(params: &Value, key: &str) -> Result<String, String> {
86 params
87 .get(key)
88 .and_then(Value::as_str)
89 .map(str::to_owned)
90 .filter(|s| !s.trim().is_empty())
91 .ok_or_else(|| format!("{key} is required"))
92}
93
94fn optional_string(params: &Value, key: &str) -> Result<Option<String>, String> {
95 match params.get(key) {
96 None | Some(Value::Null) => Ok(None),
97 Some(Value::String(s)) if !s.trim().is_empty() => Ok(Some(s.clone())),
98 Some(Value::String(_)) => Err(format!("{key} must not be empty")),
99 Some(_) => Err(format!("{key} must be a string")),
100 }
101}
102
103fn timeout_param(params: &Value) -> Result<Duration, String> {
104 match params.get("timeoutSeconds") {
105 None | Some(Value::Null) => Ok(Duration::from_secs(DEFAULT_TIMEOUT)),
106 Some(v) => v
107 .as_u64()
108 .filter(|v| (1..=300).contains(v))
109 .map(Duration::from_secs)
110 .ok_or_else(|| "timeoutSeconds must be an integer from 1 to 300".into()),
111 }
112}
113
114fn do_start(params: &Value) -> Result<String, String> {
117 let bind_addr = optional_string(params, "bind")?.unwrap_or_else(|| "127.0.0.1".into());
118 let port: u16 = match params.get("port") {
119 Some(Value::Number(n)) => n
120 .as_u64()
121 .and_then(|n| u16::try_from(n).ok())
122 .ok_or("port must be an integer 0–65535")?,
123 None => 9800,
124 Some(_) => return Err("port must be an integer".into()),
125 };
126
127 let (executable, args) = launch_args(params)?;
128 let no_token = params
129 .get("noToken")
130 .and_then(Value::as_bool)
131 .unwrap_or(false);
132 let token = if no_token {
133 String::new()
135 } else {
136 params
137 .get("token")
138 .and_then(Value::as_str)
139 .map(str::to_owned)
140 .filter(|t| !t.is_empty())
141 .unwrap_or_else(generate_token)
142 };
143
144 let exec_clone = executable.clone();
145 let args_clone = args.clone();
146 let token_clone = token.clone();
147 let bind_clone = bind_addr.clone();
148
149 let (ready_tx, ready_rx) = mpsc::channel();
150 thread::spawn(move || {
151 let runtime = match tokio::runtime::Builder::new_multi_thread()
152 .enable_all()
153 .worker_threads(2)
154 .build()
155 {
156 Ok(r) => r,
157 Err(e) => {
158 let _ = ready_tx.send(Err(format!("failed to create runtime: {e}")));
159 return;
160 }
161 };
162
163 let result = runtime.block_on(async {
164 let transport = Arc::new(TcpTransport::new());
165 run_server(
166 transport,
167 &bind_clone,
168 port,
169 token_clone,
170 exec_clone,
171 args_clone,
172 )
173 .await
174 });
175
176 match result {
177 Ok(server) => {
178 if let Ok(mut registry) = servers().lock() {
179 registry.insert(server.id.clone(), server.clone());
180 }
181 let _ = ready_tx.send(Ok(server));
182 }
183 Err(e) => {
184 let _ = ready_tx.send(Err(e));
185 }
186 }
187
188 runtime.block_on(async {
189 loop {
190 tokio::time::sleep(Duration::from_secs(3600)).await;
191 }
192 });
193 });
194
195 let server = match ready_rx.recv_timeout(Duration::from_secs(10)) {
196 Ok(Ok(s)) => s,
197 Ok(Err(e)) => return Err(e),
198 Err(_) => return Err("timed out waiting for server startup".into()),
199 };
200
201 Ok(json!({
202 "id": server.id,
203 "address": server.address,
204 "token": token,
205 "state": "running",
206 "protocol": "jsonl",
207 "transport": "tcp",
208 })
209 .to_string())
210}
211
212fn do_stop(id: &str) -> Result<String, String> {
213 let server = servers()
214 .lock()
215 .map_err(|_| "server registry poisoned")?
216 .remove(id)
217 .ok_or("RPC server not found")?;
218
219 let (tx, rx) = mpsc::channel();
220 let server_clone = Arc::clone(&server);
221 thread::spawn(move || {
222 let runtime = tokio::runtime::Builder::new_current_thread()
223 .enable_all()
224 .build()
225 .unwrap();
226 let result = runtime.block_on(server_clone.stop());
227 let _ = tx.send(result);
228 });
229
230 match rx.recv_timeout(Duration::from_secs(5)) {
231 Ok(Ok(())) => Ok(json!({"id": id, "state": "stopped"}).to_string()),
232 Ok(Err(e)) => Err(e),
233 Err(_) => Err("timed out waiting for server stop".into()),
234 }
235}
236
237async fn server_status(server: &Arc<TcpServerHandle>) -> Result<Value, String> {
238 let info = server.info().await;
239 Ok(json!({
240 "id": info.id,
241 "address": info.address,
242 "token": info.token,
243 "state": info.state,
244 "protocol": "jsonl",
245 "transport": "tcp",
246 "sessions": info.sessions,
247 }))
248}
249
250async fn do_status(id: &str) -> Result<String, String> {
251 let server = servers()
252 .lock()
253 .map_err(|_| "server registry poisoned")?
254 .get(id)
255 .cloned()
256 .ok_or("RPC server not found")?;
257 Ok(server_status(&server).await?.to_string())
258}
259
260async fn do_list() -> Result<String, String> {
261 let registry = servers().lock().map_err(|_| "server registry poisoned")?;
262 let mut list: Vec<Value> = Vec::new();
263 for server in registry.values() {
264 list.push(server_status(server).await?);
265 }
266 Ok(json!(list).to_string())
267}
268
269async fn server_tool(params: &Value) -> Result<String, String> {
270 match params
271 .get("action")
272 .and_then(Value::as_str)
273 .unwrap_or("start")
274 {
275 "start" => do_start(params),
276 "stop" => {
277 let id = string_param(params, "id")?;
278 do_stop(&id)
279 }
280 "status" => {
281 let id = string_param(params, "id")?;
282 do_status(&id).await
283 }
284 "list" => do_list().await,
285 other => Err(format!(
286 "action must be one of: start, stop, status, list (got {other})"
287 )),
288 }
289}
290
291fn stop_all_servers() {
292 let ids: Vec<String> = match servers().lock() {
293 Ok(registry) => registry.keys().cloned().collect(),
294 Err(_) => return,
295 };
296 for id in ids {
297 let _ = do_stop(&id);
298 }
299}
300
301extern "C" fn on_session_shutdown(event: StablePluginEvent, _: *mut std::ffi::c_void) -> i32 {
302 if event.tag == EventTag::SessionShutdown {
303 stop_all_servers();
304 }
305 0
306}
307
308fn flag_is_on(value: Option<&Value>) -> bool {
311 match value {
312 Some(Value::Bool(b)) => *b,
313 Some(Value::String(s)) => !s.is_empty() && !s.eq_ignore_ascii_case("false"),
314 _ => false,
315 }
316}
317
318extern "C" fn on_session_start(event: StablePluginEvent, _: *mut std::ffi::c_void) -> i32 {
320 if event.tag != EventTag::SessionStart {
321 return 0;
322 }
323
324 if !flag_is_on(get_cli_flag("server").as_ref()) {
328 return 0;
329 }
330
331 let port: u16 = match get_cli_flag("port") {
333 Some(Value::Number(n)) => n
334 .as_u64()
335 .and_then(|n| u16::try_from(n).ok())
336 .unwrap_or(9800),
337 Some(Value::String(s)) => s.parse().unwrap_or(9800),
338 _ => 9800,
339 };
340
341 let bind = match get_cli_flag("bind") {
343 Some(Value::String(s)) if !s.is_empty() => s,
344 _ => "127.0.0.1".to_string(),
345 };
346
347 eprintln!("[rpi-server] --server detected, starting TCP server on {bind}:{port}");
348
349 let token = match get_cli_flag("token") {
350 Some(Value::String(s)) if !s.is_empty() => Some(s),
351 _ => None,
352 };
353 let no_token = flag_is_on(get_cli_flag("no-token").as_ref());
354
355 let params = json!({
356 "bind": bind,
357 "port": port,
358 "token": token,
359 "noToken": no_token,
360 });
361
362 match do_start(¶ms) {
363 Ok(info) => eprintln!("[rpi-server] TCP server started: {info}"),
364 Err(e) => eprintln!("[rpi-server] failed to start: {e}"),
365 }
366
367 0
368}
369
370fn client_tool(params: &Value, cancelled: &AtomicBool) -> Result<String, String> {
373 let server_id = string_param(params, "serverId")?;
374 let method = string_param(params, "method")?;
375 let rpc_params = params.get("params").cloned().unwrap_or(json!({}));
376
377 let server = servers()
378 .lock()
379 .map_err(|_| "server registry poisoned")?
380 .get(&server_id)
381 .cloned()
382 .ok_or("RPC server not found")?;
383
384 if !server.is_running() {
385 return Err("RPC server is not running".into());
386 }
387
388 let wait = timeout_param(params)?;
389 let subscribe = params
390 .get("subscribe")
391 .and_then(Value::as_bool)
392 .unwrap_or(false);
393
394 let (tx, rx) = mpsc::channel();
395 let cancelled_clone = Arc::new(AtomicBool::new(cancelled.load(Ordering::Acquire)));
396 let cancelled_inner = Arc::clone(&cancelled_clone);
397
398 thread::spawn(move || {
399 let runtime = tokio::runtime::Builder::new_current_thread()
400 .enable_all()
401 .build()
402 .unwrap();
403
404 let result = runtime.block_on(async {
405 let transport = Arc::new(TcpTransport::new());
406 let managed = ManagedConnection::new(transport);
407
408 managed
410 .connect_with_retry(&server.address)
411 .await
412 .map_err(|e| format!("failed to connect: {e}"))?;
413
414 if !server.token.is_empty() {
416 let auth = json!({
417 "jsonrpc": "2.0",
418 "id": 0,
419 "method": "authenticate",
420 "params": {"token": server.token},
421 });
422 managed
423 .send(auth)
424 .await
425 .map_err(|e| format!("auth send failed: {e}"))?;
426 match tokio::time::timeout(Duration::from_secs(5), managed.recv()).await {
427 Ok(Ok(Some(response))) => {
428 if let Some(error) = response.get("error") {
429 return Err(format!("authentication failed: {error}"));
430 }
431 }
432 Ok(Ok(None)) => return Err("authentication failed: connection closed".into()),
433 Ok(Err(e)) => return Err(format!("authentication failed: {e}")),
434 Err(_) => return Err("authentication failed: timed out".into()),
435 }
436 }
437
438 let request = json!({
440 "jsonrpc": "2.0",
441 "method": method,
442 "params": rpc_params,
443 "id": 1,
444 });
445 managed
446 .send(request)
447 .await
448 .map_err(|e| format!("send failed: {e}"))?;
449
450 if subscribe {
451 let mut events = Vec::new();
453 let deadline = Instant::now() + wait;
454
455 loop {
456 if cancelled_inner.load(Ordering::Acquire) {
457 break;
458 }
459 if Instant::now() >= deadline {
460 break;
461 }
462
463 match tokio::time::timeout(Duration::from_millis(100), managed.recv()).await {
464 Ok(Ok(Some(event))) => {
465 let is_end =
467 event.get("type").and_then(Value::as_str) == Some("session_end");
468 events.push(event);
469 if is_end {
470 break;
471 }
472 }
473 Ok(Ok(None)) => break,
474 Ok(Err(e)) => return Err(format!("recv error: {e}")),
475 Err(_) => continue, }
477 }
478
479 Ok(json!({
480 "serverId": server_id,
481 "method": method,
482 "events": events,
483 "eventCount": events.len(),
484 })
485 .to_string())
486 } else {
487 let deadline = Instant::now() + wait;
489 loop {
490 if cancelled_inner.load(Ordering::Acquire) {
491 return Err("request cancelled".into());
492 }
493 if Instant::now() >= deadline {
494 return Err("request timed out".into());
495 }
496
497 match tokio::time::timeout(Duration::from_millis(100), managed.recv()).await {
498 Ok(Ok(Some(response))) => {
499 return Ok(json!({
500 "serverId": server_id,
501 "method": method,
502 "result": response,
503 })
504 .to_string());
505 }
506 Ok(Ok(None)) => return Err("connection closed".into()),
507 Ok(Err(e)) => return Err(format!("recv error: {e}")),
508 Err(_) => continue, }
510 }
511 }
512 });
513
514 let _ = tx.send(result);
515 });
516
517 let deadline = Instant::now() + wait;
518 loop {
519 if cancelled.load(Ordering::Acquire) {
520 return Err("request cancelled".into());
521 }
522 if Instant::now() >= deadline {
523 return Err("request timed out".into());
524 }
525 match rx.recv_timeout(Duration::from_millis(100)) {
526 Ok(result) => return result,
527 Err(mpsc::RecvTimeoutError::Timeout) => continue,
528 Err(mpsc::RecvTimeoutError::Disconnected) => {
529 return Err("worker stopped without result".into());
530 }
531 }
532 }
533}
534
535extern "C" fn execute_server(
538 tool_call_id: StbStringRef,
539 params: StbString,
540 free: Option<FreeStringFn>,
541) -> StepHandle {
542 let _ = tool_call_id;
543 let text = params.to_string_lossy();
544 params.free_with(free);
545 let cancelled = Arc::new(AtomicBool::new(false));
546 let (tx, rx) = mpsc::channel();
547 thread::spawn(move || {
548 let result = serde_json::from_str::<Value>(&text)
549 .map_err(|e| format!("invalid parameters: {e}"))
550 .and_then(|p| {
551 let runtime = tokio::runtime::Builder::new_current_thread()
552 .enable_all()
553 .build()
554 .unwrap();
555 runtime.block_on(async { server_tool(&p).await })
556 });
557 let _ = tx.send(result);
558 });
559 HANDLES.insert_with_cancel(
560 Drive {
561 receiver: rx,
562 done: false,
563 },
564 cancelled,
565 )
566}
567
568extern "C" fn execute_client(
569 tool_call_id: StbStringRef,
570 params: StbString,
571 free: Option<FreeStringFn>,
572) -> StepHandle {
573 let _ = tool_call_id;
574 let text = params.to_string_lossy();
575 params.free_with(free);
576 let cancelled = Arc::new(AtomicBool::new(false));
577 let cancelled_clone = Arc::clone(&cancelled);
578 let (tx, rx) = mpsc::channel();
579 thread::spawn(move || {
580 let result = serde_json::from_str::<Value>(&text)
581 .map_err(|e| format!("invalid parameters: {e}"))
582 .and_then(|p| client_tool(&p, &cancelled_clone));
583 let _ = tx.send(result);
584 });
585 HANDLES.insert_with_cancel(
586 Drive {
587 receiver: rx,
588 done: false,
589 },
590 cancelled,
591 )
592}
593
594extern "C" fn poll(
595 handle: StepHandle,
596 _: Option<ToolPartialCb>,
597 _: *mut std::ffi::c_void,
598) -> StepResult {
599 if handle.is_null() {
600 return StepResult::err(StbString::from_string("null handle".into()));
601 }
602 let Some(entry) = HANDLES.get(handle) else {
603 return StepResult::err(StbString::from_string(
604 "invalid or destroyed tool handle".into(),
605 ));
606 };
607 let mut drive = entry.lock();
608 if drive.done {
609 return StepResult::err(StbString::from_string(
610 "handle polled after completion".into(),
611 ));
612 }
613 if entry.cancelled.load(Ordering::Acquire) {
614 drive.done = true;
615 return StepResult::err(StbString::from_string("request cancelled".into()));
616 }
617 match drive.receiver.try_recv() {
618 Ok(Ok(text)) => {
619 drive.done = true;
620 StepResult::done(StbString::from_string(
621 json!({"content": [{"type": "text", "text": text}]}).to_string(),
622 ))
623 }
624 Ok(Err(e)) => {
625 drive.done = true;
626 StepResult::err(StbString::from_string(e))
627 }
628 Err(mpsc::TryRecvError::Empty) => StepResult::pending(StbString::empty()),
629 Err(mpsc::TryRecvError::Disconnected) => {
630 drive.done = true;
631 StepResult::err(StbString::from_string(
632 "worker stopped without result".into(),
633 ))
634 }
635 }
636}
637
638extern "C" fn cancel(handle: StepHandle) {
639 HANDLES.cancel(handle);
640}
641
642extern "C" fn destroy(handle: StepHandle) {
643 drop(HANDLES.remove(handle));
644}
645
646use rpi_plugin_sdk::callbacks::free_string;
647
648const SERVER_DESCRIPTION: &str = "Manage extension-local TCP JSONL listeners, not HTTP. Default start opens a listener and stores child launch defaults; status/stop require id. Child sessions start separately through rpc_client. Default is loopback with token authentication; non-loopback binds expose session control over unencrypted TCP. Results/logs contain secret tokens: do not publish them. Stop requests listener shutdown and terminates tracked children; session shutdown attempts cleanup. Timeout/cancellation does not undo side effects; inspect state before retrying. Explicitly stop sessions/listeners you create.";
651const CLIENT_DESCRIPTION: &str = "Send JSON-RPC to a running server using its extension-local serverId; authentication is automatic. start_session creates a child, send writes a native command, stop_session terminates a child. method=subscribe with subscribe=true collects live events, not history. Results contain raw JSON-RPC responses: inspect nested errors even when the tool succeeds. Timeout/cancellation does not cancel remote work or imply rollback: do not blindly retry side-effecting methods. Clean up sessions with stop_session and listeners with rpc_server stop. Responses/events may contain secrets; redact before sharing.";
652
653const SERVER_PARAMETERS: &str = r#"{
654 "type":"object",
655 "properties":{
656 "action":{"type":"string","enum":["start","stop","status","list"],"default":"start","description":"Optional; defaults to start. start opens a listener; status reads one server; stop removes/stops one server and kills tracked children; list returns all registered server snapshots. Only start reads launch/network fields; stop/status require id."},
657 "id":{"type":"string","description":"Non-blank extension-local server id returned by start/list; required only for stop/status. Not a child session id. No default; ignored for start/list."},
658 "bind":{"type":"string","default":"127.0.0.1","description":"Start only. Non-blank TCP bind host; omitted/null defaults to 127.0.0.1. 0.0.0.0 exposes all IPv4 interfaces; TCP has no TLS. Prefer loopback; not an HTTP URL."},
659 "port":{"type":"integer","minimum":0,"maximum":65535,"default":9800,"description":"Start only. TCP port, default 9800; 0 requests an OS-assigned port, but returned/stored address currently retains :0 and cannot be used by rpc_client to discover that port. Use a known available port for client calls. null is invalid."},
660 "token":{"type":"string","description":"Start only. Secret authentication token; omitted/empty generates one. noToken=true overrides it. Included in status/results and server logs: do not echo, commit or share. Authentication does not encrypt TCP traffic."},
661 "noToken":{"type":"boolean","default":false,"description":"Start only. Default false requires connection authentication; true disables it and ignores token, allowing any reachable client to control sessions/children. Use only on trusted local networks, preferably loopback."},
662 "provider":{"type":"string","description":"Start only. Non-blank value forwarded as --provider to future children. Omitted/null: no flag, child CLI/config defaults apply (not necessarily the parent session's selection)."},
663 "model":{"type":"string","description":"Start only. Non-blank value forwarded as --model; omitted/null: child CLI/config default. Does not change existing children."},
664 "baseUrl":{"type":"string","description":"Start only. Non-blank provider endpoint forwarded as --base-url; omitted/null: child default. Not the TCP listener address. Only use trusted endpoints; child provider traffic may carry credentials/content."},
665 "systemPrompt":{"type":"string","description":"Start only. Non-blank value forwarded verbatim as --system-prompt; omitted/null: no flag. Native CLI interprets it; no prompt expansion API is provided here. Values appear in child process arguments; avoid secrets."},
666 "appendSystemPrompt":{"type":"array","maxItems":128,"items":{"type":"string","minLength":1,"description":"Non-empty native CLI value; forwarded verbatim."},"description":"Start only. Default [] (omit); up to 128 non-empty strings, each forwarded as --append-system-prompt. null is invalid. Stored for future children, not sent to existing sessions."},
667 "thinking":{"type":"string","enum":["off","minimal","low","medium","high","xhigh","max"],"description":"Start only. Child thinking level forwarded as --thinking; omitted/null uses the child default."},
668 "rpcMessageUpdates":{"type":"string","enum":["snapshot","compact-deltas"],"description":"Start only. Native RPC delta payload mode, forwarded as --rpc-message-updates=<mode> to future children. Omit to use the executable default and remain compatible with older hosts; null and any other value are invalid. Use snapshot only for legacy consumers; compact-deltas consumers must handle deltas and complete boundary messages."},
669 "name":{"type":"string","description":"Start only. Non-blank child name forwarded as --name; omitted/null: no flag. Does not name the listener or select its registry id."},
670 "session":{"type":"string","description":"Start only. Non-blank native session selector forwarded as --session; omitted/null emits no flag. Key presence conflicts with sessionId and with noSession=true, even if null. Not the server-generated session-N handle."},
671 "sessionId":{"type":"string","description":"Start only. Non-blank native session id forwarded as --session-id; omitted/null emits no flag. Key presence conflicts with session and with noSession=true, even if null. Not rpc_client params.sessionId, which addresses a live child."},
672 "sessionDir":{"type":"string","description":"Start only. Non-blank native session storage path forwarded as --session-dir; omitted/null: child default. Child may read/write files with host privileges; no sandbox or path validation is added."},
673 "noSession":{"type":"boolean","default":false,"description":"Start only. true emits --no-session (disable native session persistence); omitted/null/false emits nothing. true conflicts with presence of session/sessionId. Does not disable creation of live RPC child sessions."},
674 "tools":{"type":"array","maxItems":128,"items":{"type":"string","minLength":1,"description":"Non-empty native tool selector; avoid commas unless intended as CLI separators."},"description":"Start only. Default []; up to 128 non-empty strings joined by commas as one --tools value when nonempty. Native CLI resolves tool availability and conflicts; null invalid."},
675 "excludeTools":{"type":"array","maxItems":128,"items":{"type":"string","minLength":1,"description":"Non-empty native tool selector; comma-separated CLI semantics apply."},"description":"Start only. Default []; up to 128 non-empty strings joined by commas as --exclude-tools when nonempty. Excludes child tools; null invalid; no server-side precedence resolution."},
676 "noTools":{"type":"boolean","default":false,"description":"Start only. true forwards --no-tools; omitted/null/false emits nothing. Native CLI controls resulting tool set; does not restrict TCP management methods."},
677 "noBuiltinTools":{"type":"boolean","default":false,"description":"Start only. true forwards --no-builtin-tools; omitted/null/false emits nothing. Child CLI determines interaction with tools/noTools."},
678 "noSkills":{"type":"boolean","default":false,"description":"Start only. true forwards --no-skills; omitted/null/false emits nothing. Child CLI determines resource discovery and explicit skill handling."},
679 "noPromptTemplates":{"type":"boolean","default":false,"description":"Start only. true forwards --no-prompt-templates; omitted/null/false emits nothing. Child CLI determines template loading."},
680 "noContextFiles":{"type":"boolean","default":false,"description":"Start only. true forwards --no-context-files; omitted/null/false emits nothing. Child CLI determines context file loading; not filesystem isolation."},
681 "noExtensions":{"type":"boolean","default":false,"description":"Start only. true forwards --no-extensions; omitted/null/false emits nothing. Child CLI resolves discovery and explicit extension options; not a sandbox."},
682 "enablePiPackages":{"type":"boolean","default":false,"description":"Start only. true forwards --enable-pi-packages; omitted/null/false emits nothing (child defaults apply). Enable only trusted package sources; packages may execute code."},
683 "extensionsDir":{"type":"array","maxItems":128,"items":{"type":"string","minLength":1,"description":"Non-empty trusted directory path, passed verbatim without validation."},"description":"Start only. Default []; up to 128 non-empty values, repeated --extensions-dir. null invalid. Child CLI loads resources with host privileges."},
684 "extension":{"type":"array","maxItems":128,"items":{"type":"string","minLength":1,"description":"Non-empty trusted native extension specifier, passed verbatim."},"description":"Start only. Default []; up to 128 values, repeated --extension; null invalid. Extensions can execute code; no server-side validation or isolation."},
685 "skill":{"type":"array","maxItems":128,"items":{"type":"string","minLength":1,"description":"Non-empty native skill resource specifier, passed verbatim."},"description":"Start only. Default []; up to 128 values, repeated --skill; null invalid. Child CLI resolves resources; only load trusted content."},
686 "promptTemplate":{"type":"array","maxItems":128,"items":{"type":"string","minLength":1,"description":"Non-empty native prompt-template resource specifier, passed verbatim."},"description":"Start only. Default []; up to 128 values, repeated --prompt-template; null invalid. No server prompt API or hook; native child resolves templates."},
687 "offline":{"type":"boolean","default":false,"description":"Start only. true forwards --offline; omitted/null/false emits nothing. Native CLI defines offline behavior; this is not a network firewall and does not disable the TCP listener."},
688 "headless":{"type":"boolean","default":false,"description":"Start only. true forwards --headless; omitted/null/false emits nothing. Native CLI determines headless behavior; server itself always uses TCP JSONL."},
689 "approve":{"type":"boolean","description":"Start only. Omitted: no approval flag, child CLI/config default applies. true emits --approve (automatic approval, potentially dangerous); false emits --no-approve. null invalid. Decide explicitly for unattended children: enabled tools/extensions may modify files or run commands with host privileges."}
690 }
691}"#;
692
693const CLIENT_PARAMETERS: &str = r#"{
694 "type":"object",
695 "properties":{
696 "serverId":{"type":"string","description":"Required non-blank id from rpc_server start/list in this extension process. Must still be running. Not a TCP address or child session id; no default. Connection and token authentication use its stored metadata."},
697 "method":{"type":"string","description":"Required non-blank JSON-RPC method, no default. start_session spawns current executable using server launch defaults (params ignored); send writes params.command to child stdin (acknowledgement is not completion); stop_session kills/waits for a child. list_sessions, server_status, ping read state (server_status includes secret token). subscribe requires params.sessionId; use subscribe=true to collect events. Unknown methods return nested JSON-RPC errors, not necessarily tool errors."},
698 "params":{"type":"object","default":{},"description":"Method-specific payload, default {}. send requires string sessionId (live child handle returned by start_session) and command (native JSON command); stop_session/subscribe require sessionId. start_session/list_sessions/server_status/ping ignore payload. Forwarded without command validation; send may cause file/process/network side effects under child privileges. Do not put the server token here; authentication is automatic."},
699 "subscribe":{"type":"boolean","default":false,"description":"Default false returns the first full JSON-RPC response under result; inspect its error field. true collects responses/live notifications under events/eventCount. Use with method=subscribe and params.sessionId; this flag alone does not subscribe. No history/replay or completeness guarantee. Collection is time-limited, not count/byte-limited; timeout may return an error rather than partial events."},
700 "timeoutSeconds":{"type":"integer","minimum":1,"maximum":300,"default":30,"description":"Wait budget in seconds, default 30 (omitted/null), range 1..300. Timeout/cancellation does not cancel or roll back remote commands or terminate children. Check state before retrying side-effecting methods."}
701 },
702 "required":["serverId","method"]
703}"#;
704
705fn tool_schemas() -> [StableToolSchema; 2] {
707 [
708 StableToolSchema {
709 name: StbString::from_string("rpc_server".into()),
710 description: StbString::from_string(SERVER_DESCRIPTION.into()),
711 parameters: StbString::from_string(SERVER_PARAMETERS.into()),
712 },
713 StableToolSchema {
714 name: StbString::from_string("rpc_client".into()),
715 description: StbString::from_string(CLIENT_DESCRIPTION.into()),
716 parameters: StbString::from_string(CLIENT_PARAMETERS.into()),
717 },
718 ]
719}
720
721rpi_plugin_sdk::export_plugin!(|api| {
723 let _ = RUNTIME_ACTION.set(api.runtime_action);
726 RUNTIME_USER_DATA.store(api.user_data, Ordering::Release);
727 let _ = HOST_FREE_STRING.set(api.free_string);
728 let Some(register) = api.register_tool else {
729 return 1;
730 };
731
732 let [server_schema, client_schema] = tool_schemas().map(Box::new);
733
734 let first = register(
735 &*server_schema,
736 execute_server,
737 poll,
738 cancel,
739 destroy,
740 free_string,
741 );
742
743 let second = if first == 0 {
744 register(
745 &*client_schema,
746 execute_client,
747 poll,
748 cancel,
749 destroy,
750 free_string,
751 )
752 } else {
753 first
754 };
755
756 let third = if second == 0 {
757 api.register_event_handler
758 .map(|register| {
759 register(
760 EventTag::SessionShutdown,
761 on_session_shutdown,
762 std::ptr::null_mut(),
763 )
764 })
765 .unwrap_or(0)
766 } else {
767 second
768 };
769
770 let fourth = if third == 0 {
772 api.register_event_handler
773 .map(|register| {
774 register(
775 EventTag::SessionStart,
776 on_session_start,
777 std::ptr::null_mut(),
778 )
779 })
780 .unwrap_or(0)
781 } else {
782 third
783 };
784
785 drop(server_schema);
786 drop(client_schema);
787 fourth
788});
789
790#[cfg(test)]
791mod tests {
792 use super::*;
793
794 #[test]
795 fn launch_args_basic() {
796 let (_, args) = launch_args(&json!({
797 "provider": "anthropic",
798 "model": "claude-3",
799 "noSession": true,
800 }))
801 .unwrap();
802 assert_eq!(&args[0..2], ["--mode", "rpc"]);
803 assert!(args.windows(2).any(|w| w == ["--provider", "anthropic"]));
804 assert!(args.windows(2).any(|w| w == ["--model", "claude-3"]));
805 assert!(args.contains(&"--no-session".to_string()));
806 }
807
808 #[test]
809 fn rpc_message_updates_is_explicit_and_strict() {
810 let (_, args) = launch_args(&json!({})).unwrap();
811 assert!(!args
812 .iter()
813 .any(|arg| arg.starts_with("--rpc-message-updates")));
814 for mode in ["snapshot", "compact-deltas"] {
815 let (_, args) = launch_args(&json!({"rpcMessageUpdates":mode})).unwrap();
816 assert_eq!(
817 args,
818 vec!["--mode", "rpc", &format!("--rpc-message-updates={mode}")]
819 );
820 }
821 for invalid in [
822 json!(""),
823 json!("compact"),
824 json!("SNAPSHOT"),
825 json!(null),
826 json!(true),
827 json!(1),
828 json!([]),
829 json!({}),
830 ] {
831 assert_eq!(
832 launch_args(&json!({"rpcMessageUpdates":invalid})).unwrap_err(),
833 "rpcMessageUpdates must be snapshot or compact-deltas"
834 );
835 }
836 let schema: Value = serde_json::from_str(SERVER_PARAMETERS).unwrap();
837 assert_eq!(
838 schema["properties"]["rpcMessageUpdates"]["enum"],
839 json!(["snapshot", "compact-deltas"])
840 );
841 assert!(schema["properties"]["rpcMessageUpdates"]
842 .get("default")
843 .is_none());
844 }
845
846 #[test]
847 fn schemas_are_valid_json() {
848 assert!(serde_json::from_str::<Value>(SERVER_PARAMETERS).is_ok());
849 assert!(serde_json::from_str::<Value>(CLIENT_PARAMETERS).is_ok());
850 }
851
852 #[test]
853 fn registered_schemas_are_documented_and_keep_optional_fields() {
854 let schemas = tool_schemas();
855 for (schema, expected_name) in schemas.iter().zip(["rpc_server", "rpc_client"]) {
856 assert_eq!(schema.name.to_string_lossy(), expected_name);
857 assert!(!schema.description.to_string_lossy().trim().is_empty());
858 let parameters: Value =
859 serde_json::from_str(&schema.parameters.to_string_lossy()).unwrap();
860 assert_eq!(parameters["type"], "object");
861 assert_ne!(parameters["additionalProperties"], false);
863 for (name, field) in parameters["properties"].as_object().unwrap() {
864 assert!(
865 field["description"]
866 .as_str()
867 .is_some_and(|s| !s.trim().is_empty()),
868 "{expected_name}.{name} needs guidance"
869 );
870 if field["type"] == "array" {
871 assert_eq!(field["maxItems"], 128);
872 assert_eq!(field["items"]["minLength"], 1);
873 assert!(field["items"]["description"].is_string());
874 }
875 }
876 if expected_name == "rpc_server" {
877 assert!(parameters.get("required").is_none());
878 assert_eq!(parameters["properties"]["action"]["default"], "start");
879 assert_eq!(
880 parameters["properties"]["action"]["enum"],
881 json!(["start", "stop", "status", "list"])
882 );
883 } else {
884 assert_eq!(parameters["required"], json!(["serverId", "method"]));
885 }
886 }
887 for schema in schemas {
888 free_string(schema.name);
889 free_string(schema.description);
890 free_string(schema.parameters);
891 }
892 }
893
894 #[test]
895 fn registered_schemas_keep_defaults_and_limits() {
896 let [server, client] = tool_schemas();
897 let s: Value = serde_json::from_str(&server.parameters.to_string_lossy()).unwrap();
898 let c: Value = serde_json::from_str(&client.parameters.to_string_lossy()).unwrap();
899 let sp = &s["properties"];
900 let cp = &c["properties"];
901 assert_eq!(sp["bind"]["default"], "127.0.0.1");
902 assert_eq!(sp["port"]["default"], 9800);
903 assert_eq!(sp["port"]["minimum"], 0);
904 assert_eq!(sp["port"]["maximum"], 65535);
905 assert_eq!(sp["noToken"]["default"], false);
906 assert!(sp["approve"].get("default").is_none());
907 assert_eq!(cp["params"]["default"], json!({}));
908 assert_eq!(cp["subscribe"]["default"], false);
909 assert_eq!(cp["timeoutSeconds"]["default"], 30);
910 assert_eq!(cp["timeoutSeconds"]["minimum"], 1);
911 assert_eq!(cp["timeoutSeconds"]["maximum"], 300);
912 assert_eq!(sp["headless"]["default"], false);
913 assert_eq!(
914 sp["thinking"]["enum"],
915 json!(["off", "minimal", "low", "medium", "high", "xhigh", "max"])
916 );
917 for schema in [server, client] {
918 free_string(schema.name);
919 free_string(schema.description);
920 free_string(schema.parameters);
921 }
922 }
923
924 #[test]
925 fn launch_options_keep_approval_and_session_conditions() {
926 let (_, defaults) = launch_args(&json!({})).unwrap();
927 assert!(!defaults
928 .iter()
929 .any(|arg| arg == "--approve" || arg == "--no-approve"));
930 for (value, flag) in [(true, "--approve"), (false, "--no-approve")] {
931 let (_, args) = launch_args(&json!({"approve": value})).unwrap();
932 assert!(args.iter().any(|arg| arg == flag));
933 }
934 assert!(launch_args(&json!({"approve": null})).is_err());
935 for params in [
936 json!({"session": "s", "sessionId": "id"}),
937 json!({"session": null, "sessionId": null}),
938 json!({"noSession": true, "session": null}),
939 json!({"noSession": true, "sessionId": null}),
940 ] {
941 assert!(launch_args(¶ms).is_err(), "{params}");
942 }
943 }
944
945 #[test]
946 fn generate_token_is_32_hex_chars() {
947 let t = generate_token();
948 assert_eq!(t.len(), 32);
949 assert!(t.chars().all(|c| c.is_ascii_hexdigit()));
950 }
951}