Skip to main content

rpi_server/
lib.rs

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
23// ── Data ──────────────────────────────────────────────────────────────────────
24
25static 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
31// The host's `runtime_action` entry point + its opaque `user_data` (the host's
32// `ActionBridge`), copied OUT of the vtable during `register`. The vtable is the
33// host's to own — we must not retain the `api` pointer (that is a
34// use-after-free: the host builds it on the register stack). `GetCliFlag` needs
35// both the entry point and the `user_data`.
36static RUNTIME_ACTION: OnceLock<RuntimeActionFn> = OnceLock::new();
37static RUNTIME_USER_DATA: AtomicPtr<std::ffi::c_void> = AtomicPtr::new(std::ptr::null_mut());
38// The host's `free_string`, for reclaiming host-produced `out` strings.
39static HOST_FREE_STRING: OnceLock<FreeStringFn> = OnceLock::new();
40
41/// Call the host's `GetCliFlag` runtime action to read a parsed CLI flag value.
42fn 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    // The host may have produced `out` even on an error path; reclaim it via the
62    // host's free so we don't leak the host allocation.
63    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
83// ── Param parsing ─────────────────────────────────────────────────────────────
84
85fn 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
114// ── Server lifecycle ──────────────────────────────────────────────────────────
115
116fn 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        // Authentication disabled: any client may connect (trusted local use).
134        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
308/// Whether a `GetCliFlag` result means the switch is ON. `GetCliFlag` yields
309/// `true` for a bare `--flag`, a string when `--flag=v`, and `null` when unset.
310fn 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
318/// On SessionStart, check if --server CLI flag was passed and auto-start the TCP server.
319extern "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    // Check if --server flag was passed. `GetCliFlag` returns `{"value": null}`
325    // for an *unset* flag, so a bare `is_none()` would wrongly treat that as
326    // present — require an explicit truthy value.
327    if !flag_is_on(get_cli_flag("server").as_ref()) {
328        return 0;
329    }
330
331    // Read optional --port flag (default 9800)
332    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    // Read optional --bind flag (default 127.0.0.1)
342    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(&params) {
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
370// ── Client ────────────────────────────────────────────────────────────────────
371
372fn 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            // Connect with retry
409            managed
410                .connect_with_retry(&server.address)
411                .await
412                .map_err(|e| format!("failed to connect: {e}"))?;
413
414            // Authenticate when the server requires a token.
415            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            // Send request
439            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                // Collect streaming events
452                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                            // Check for session_end before pushing
466                            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, // timeout, loop again
476                    }
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                // Wait for response
488                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, // timeout, loop again
509                    }
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
535// ── SDK integration ───────────────────────────────────────────────────────────
536
537extern "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
648// ── Schemas ───────────────────────────────────────────────────────────────────
649
650const 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
705// Shared by registration and tests: tests inspect exactly the schemas we register.
706fn 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
721// ── Plugin entry ──────────────────────────────────────────────────────────────
722rpi_plugin_sdk::export_plugin!(|api| {
723    // Copy the host function pointers/handles OUT of the vtable during
724    // register — the `api` pointer is only valid for this call.
725    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    // Register SessionStart handler to auto-start server when --server flag is passed
771    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            // Do not close the object: host-injected optional __rpi must remain accepted.
862            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(&params).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}