rpi_extensions/lib.rs
1//! Rust-native (cdylib) plugin loader + `AgentTool` adapter for rpi.
2//!
3//! This is Part B1 of the extension-alignment plan. `rpi` loads extensions as
4//! **compiled Rust cdylibs** (`.dll`/`.so`/`.dylib`) via `libloading` — **not**
5//! TS/jiti — because we control both sides and the plugin is Rust
6//! (`动态加载可以加载rs 的代码 没必要是 ts`). The ABI contract lives in
7//! [`rpi_plugin_sdk`]; this crate is the **host side** that loads plugins and
8//! bridges their tools/events into rpi's native async types.
9//!
10//! ## Crate DAG position
11//!
12//! Depends on **`rpi-plugin-sdk` + `rpi-ai` + `rpi-agent` only** (NOT
13//! `rpi-harness`). The harness consumes this crate's adapters as trait objects
14//! via `AgentHarnessOptions` injection, so `rpi-harness` never imports
15//! `rpi-extensions` — cycle-free (verified by adversarial review of the first
16//! draft, which wrongly added a `rpi-harness` dep).
17//!
18//! ## The async-across-ABI bridge (load-bearing — corrected vs first draft)
19//!
20//! A plugin tool drives an execution through **four** plugin-exported fns
21//! (`execute`→handle, `poll`, `cancel`, `destroy`) — see
22//! [`rpi_plugin_sdk::ToolExecuteFn`] etc. The host's [`PluginToolAdapter`]
23//! impls `AgentTool::execute` by:
24//!
25//! 1. **NOT owning a runtime.** Acquire the ambient runtime
26//! (`tokio::runtime::Handle::try_current()`) — the adapter only ever runs
27//! inside the agent loop's runtime.
28//! 2. Set up an **unbounded mpsc** for `ToolResultPartial` (async side drains +
29//! forwards to `on_update`) and a **oneshot** for the terminal
30//! `AgentToolResult`.
31//! 3. `handle.spawn_blocking(move || drive(plugin, cancel_flag, partial_tx,
32//! done_tx))` — the blocking driver loops `poll` until `Done`/`Err`,
33//! forwarding `Pending` partials through the mpsc (via a `catch_unwind`
34//! trampoline so a panicking partial callback can't unwind across FFI),
35//! then sends the terminal result + calls `destroy` **exactly once** on exit.
36//! 4. The async future `select!`s between the oneshot (terminal) and
37//! `signal.cancelled()` (the child token). On cancel: **set an `AtomicBool`
38//! cancel flag (SeqCst)** so the blocking driver observes it; **keep
39//! awaiting the oneshot** (never drop the driver — `spawn_blocking` tasks
40//! run to completion regardless of outer-future drop, so dropping is a
41//! thread leak).
42//! 5. `Drop`/drop-guard sets **only the cancel flag** — never calls `destroy`
43//! synchronously (the driver may be mid-`poll`); `destroy` is called exactly
44//! once by the blocking driver.
45//!
46//! `cancel` ≠ `destroy`: the first draft conflated them → UAF/double-free. Here
47//! `cancel` is an idempotent thread-safe flag-set; `destroy` is the single
48//! free, owned by the driver.
49//!
50//! ## Events
51//!
52//! [`ExtensionEmitter`] impls `AgentEmitter` by subscribing to the host's
53//! `broadcast::Sender<AgentEvent>`, translating each `AgentEvent` → a
54//! [`rpi_plugin_sdk::StablePluginEvent`], and dispatching to every registered
55//! handler for the event's tag — all dispatch wrapped in `catch_unwind`. The
56//! 33-category `on()` surface (B3) is driven through this emitter; the
57//! 10 already-emitted `AgentEvent` variants fold into the matching tags now,
58//! and the remaining tags light up as B3/B4/B5 add the emission points.
59
60use std::ffi::c_void;
61use std::sync::{Arc, Mutex};
62
63use rpi_plugin_sdk::{
64 BeforeAgentStartFn, EventHandlerFn, EventTag, FreeStringFn, OAuthRequestFn, ProviderRequestFn,
65 RenderFn, ResourcesDiscoverFn, RuntimeActionFn, StablePluginEvent, StableToolSchema, StbString,
66 StbStringRef, ToolCancelFn, ToolDestroyFn, ToolExecuteFn, ToolPollFn,
67};
68use thiserror::Error;
69
70pub use actions::{
71 reload_callback_from_mailbox, trampoline_runtime_action, ActionBridge, ReloadMailbox,
72 RuntimeActionHost, UiDialogMailbox, UiDialogRequest,
73};
74pub use editor_text::{EditorTextEdit, EditorTextMailbox, EditorTextMode};
75pub use event_log::{event_log_path, log_handler_invocation, EventLogger};
76pub use loader::{
77 load_dir, load_one, load_session, load_session_mixed, merge_registries, ExtensionSession,
78 LoadedPlugin, PluginKeepalive, PluginLoadError,
79};
80pub use oauth::{
81 list as list_oauth_providers, request as request_oauth, OAuthActionResponse,
82 OAuthExtensionError, OAuthProviderInfo, OAuthResponseKind,
83};
84pub use prompt_transform::emit_before_agent_start;
85pub use provider::PluggableProvider;
86pub use provider_hooks::ExtensionProviderHooks;
87pub use registry::{
88 assert_active, current_platform, platform_allows, BeforeAgentStartHandler, ExtensionRegistry,
89 ExtensionTool, RegisteredFlag, RegisteredHandler, RegisteredOAuthProvider, RegisteredProvider,
90 RegisteredRenderer, RegisteredRendererKind, RegisteredShortcut, RegistryEntry,
91 RegistrySnapshot, ResourcesDiscoverHandler, DEFAULT_PRIORITY,
92};
93pub use resources::{emit_resources_discover, DiscoveredResources};
94pub use status::ExtensionStatusMailbox;
95mod panel;
96pub use panel::{ExtensionPanel, PanelAnchor, PanelLayout};
97pub use tool::{PluginToolAdapter, PluginToolHandle, ToolCallContext};
98pub use translate::{
99 dispatch_data_event, dispatch_data_event_claiming, dispatch_empty_event,
100 dispatch_lifecycle_event, dispatch_tool_call_event, ExtensionEmitter, TeeEmitter,
101};
102
103pub use stdio::StdioLifecycle;
104mod actions;
105mod editor_text;
106mod event_log;
107mod loader;
108mod oauth;
109mod prompt_transform;
110mod provider;
111mod provider_hooks;
112mod registry;
113mod resources;
114mod status;
115mod stdio;
116mod tool;
117mod translate;
118
119/// A diagnostics/event sink the host wires so the loader + adapter can report
120/// plugin-load skips, ABI mismatches, panics caught at the FFI boundary, etc.
121/// Mirrors the `diagnostic` channel pi surfaces for extensions.
122pub trait PluginDiagnostics: Send + Sync {
123 /// A non-fatal warning (e.g. "skipped plugin foo: ABI version mismatch").
124 fn warn(&self, message: &str);
125 /// A plugin that registered something but a registration slot is not yet
126 /// wired on the host (e.g. an event tag the host does not emit yet).
127 fn unsupported(&self, message: &str);
128}
129
130/// A no-op diagnostics sink (the default when the host does not supply one).
131#[derive(Default)]
132pub struct NullDiagnostics;
133
134impl PluginDiagnostics for NullDiagnostics {
135 fn warn(&self, _message: &str) {}
136 fn unsupported(&self, _message: &str) {}
137}
138
139/// Error returned by [`PluginToolAdapter::execute`] when the plugin side failed
140/// (terminal `Err` from `poll`, or the drive handle was never produced).
141#[derive(Debug, Error)]
142pub enum PluginToolError {
143 #[error("plugin execute returned a null handle (allocation failure)")]
144 NullHandle,
145 #[error("plugin error: {0}")]
146 Plugin(String),
147 #[error("runtime unavailable: {0}")]
148 NoRuntime(String),
149}
150
151/// The host's `free_string` for [`StbString`]s the host *produces* and hands to
152/// the plugin (event payloads, action outputs, execute params when the host
153/// owns them). The plugin frees what it *receives* via this; the host frees
154/// what it *receives* via the plugin's `free_string` (stored per-tool).
155///
156/// Reconstructs the `Box<[u8]>` from ptr+len and drops it. Idempotent on
157/// empty/null.
158pub extern "C" fn host_free_string(s: StbString) {
159 if s.is_empty() || s.ptr.is_null() {
160 return;
161 }
162 // SAFETY: the host produced this StbString via `StbString::from_owned`/
163 // `from_string` (a `Box<[u8]>`), so ptr+len reconstruct the same allocation.
164 unsafe {
165 let slice = std::slice::from_raw_parts(s.ptr as *const u8, s.len);
166 let _ = Box::from_raw(slice as *const [u8] as *mut [u8]);
167 }
168}
169
170// ===========================================================================
171// HostApi — the PluginApi the host builds and hands to each plugin's register
172// ===========================================================================
173
174/// The host state a [`PluginApi`] closes over. Held behind `Arc` so the fn
175/// pointers (which are `extern "C"`,不好做闭包) can recover the host state via
176/// the `user_data` slot — but since `extern "C" fn` cannot capture, the host
177/// stores per-registration receiver state in the [`HostApi`] itself keyed by
178/// nothing (single registry per host), and the fns are thin trampolines that
179/// read a process-global-attached registry. The host builds one `HostApi` per
180/// load session; the `register_*` trampolines forward into it.
181///
182/// This struct is `Send + Sync` (registry is `Mutex`-guarded).
183pub struct HostApi {
184 registry: Mutex<Option<ExtensionRegistry>>,
185 /// Display name of the plugin currently registering (from the cdylib file
186 /// stem). Stamped onto every registration the plugin makes (e.g. event
187 /// handlers) so host-side diagnostics can name the owning extension. Not
188 /// part of the ABI — the plugin never sees it.
189 name: String,
190 /// B5a: the plugin→host action bridge, carried in
191 /// [`PluginApi::user_data`] so [`trampoline_runtime_action`] can recover
192 /// the harness state from ANY thread a plugin calls from (post-register, no
193 /// thread-local). `None` keeps the no-bridge stub `runtime_action` and the
194 /// register `user_data` (the `HostApi` pointer) — so call sites that
195 /// don't pass a bridge behave exactly as before.
196 action_bridge: Option<Arc<ActionBridge>>,
197}
198
199impl HostApi {
200 /// Build a fresh host API bound to the given registry + diagnostics. With
201 /// no action bridge, `runtime_action` stays the stub returning `-1`. `name`
202 /// is the registering plugin's display name (from the cdylib file stem).
203 pub fn new(
204 name: impl Into<String>,
205 registry: ExtensionRegistry,
206 _diagnostics: Arc<dyn PluginDiagnostics>,
207 ) -> Arc<Self> {
208 Arc::new(Self {
209 registry: Mutex::new(Some(registry)),
210 name: name.into(),
211 action_bridge: None,
212 })
213 }
214
215 /// The registering plugin's display name.
216 pub fn name(&self) -> &str {
217 &self.name
218 }
219
220 /// B5a: build a host API that wires the real [`trampoline_runtime_action`]
221 /// via the bridge. The bridge is cloned into every plugin's vtable
222 /// `user_data` so post-register `runtime_action` calls recover it on any
223 /// thread. `rpi-cli` keeps one master `Arc<ActionBridge>` per session; the
224 /// plugin gets a scoped context sharing the session's facilities. The
225 /// loader retains that context with the library, so its pointer stays valid
226 /// after registration. `name` is the registering plugin's display name.
227 pub fn with_action_bridge(
228 name: impl Into<String>,
229 registry: ExtensionRegistry,
230 _diagnostics: Arc<dyn PluginDiagnostics>,
231 action_bridge: Arc<ActionBridge>,
232 ) -> Arc<Self> {
233 let name = name.into();
234 let action_bridge = action_bridge.for_plugin(&name);
235 Arc::new(Self {
236 registry: Mutex::new(Some(registry)),
237 name,
238 action_bridge: Some(action_bridge),
239 })
240 }
241
242 fn with_registry<R>(&self, f: impl FnOnce(&mut ExtensionRegistry) -> R) -> Option<R> {
243 let mut guard = self.registry.lock().expect("host api registry lock");
244 guard.as_mut().map(f)
245 }
246
247 /// Detach the registry (call after registration completes so the host can
248 /// move it out for use). The `Mutex<Option<...>>` is left `None`.
249 pub fn take_registry(&self) -> Option<ExtensionRegistry> {
250 self.registry.lock().expect("host api registry lock").take()
251 }
252
253 /// Build the [`PluginApi`] struct passed to `rpi_plugin_register`.
254 ///
255 /// Every optional registrar slot resolves to a real `extern "C"` trampoline
256 /// that forwards into `self`'s registry (so a plugin that calls
257 /// `register_tool` / `register_command` / renderer registration sees its
258 /// registration land). `runtime_action` resolves to the real
259 /// [`trampoline_runtime_action`] when a bridge is present (B5a), else the
260 /// stub returning `-1`.
261 ///
262 /// **`user_data`**: the register trampolines recover host state via the
263 /// thread-local `CURRENT_HOST_API` (set for the duration of register in
264 /// `load_one`) — they ignore `user_data`. Post-register `runtime_action`
265 /// cannot use the thread-local (plugin calls from foreign threads), so when
266 /// a bridge is present `user_data` is repurposed to point at the
267 /// `ActionBridge` (the SDK-designated "host's opaque context"). The bridge's
268 /// scoped `Arc` is retained by the loaded plugin and session keepalive, so
269 /// the pointer a plugin stores during register stays valid.
270 pub fn build_vtable(self: &Arc<Self>) -> rpi_plugin_sdk::PluginApi {
271 // When a bridge is present, user_data carries it (post-register action
272 // recovery). Otherwise keep the register-path HostApi pointer (harmless
273 // — register trampolines use the thread-local and ignore user_data).
274 // Cast both arms to the `RuntimeActionFn` pointer type — distinct fn
275 // items have unique types even with identical signatures, so the match
276 // needs a common fn-pointer type to unify on (the struct field is
277 // `RuntimeActionFn`, a bare `extern "C" fn` alias, not an `Option`).
278 let (runtime_action_fn, ud) = match &self.action_bridge {
279 Some(bridge) => (
280 trampoline_runtime_action as RuntimeActionFn,
281 Arc::as_ptr(bridge) as *mut c_void,
282 ),
283 None => (
284 stub_runtime_action as RuntimeActionFn,
285 Arc::as_ptr(self) as *mut c_void,
286 ),
287 };
288 rpi_plugin_sdk::PluginApi {
289 abi_version: rpi_plugin_sdk::RPI_PLUGIN_ABI_VERSION_UNIFIED,
290 struct_size: std::mem::size_of::<rpi_plugin_sdk::PluginApi>() as u32,
291 free_string: host_free_string,
292 register_tool: Some(trampoline_register_tool),
293 register_command: Some(trampoline_register_command),
294 register_shortcut: Some(trampoline_register_shortcut),
295 register_flag: Some(trampoline_register_flag),
296 register_provider: Some(trampoline_register_provider), // B5c
297 register_message_renderer: Some(trampoline_register_message_renderer), // B5c
298 register_markdown_transformer: Some(trampoline_register_markdown_transformer), // B5c
299 register_entry_renderer: Some(trampoline_register_entry_renderer), // B5c
300 register_event_handler: Some(trampoline_register_event_handler),
301 register_resources_discover: Some(trampoline_register_resources_discover),
302 // Appended last so `struct_size` grows without shifting any earlier
303 // offset (a plugin built against the previous layout keeps working).
304 register_before_agent_start: Some(trampoline_register_before_agent_start),
305 register_oauth_provider: Some(trampoline_register_oauth_provider),
306 runtime_action: runtime_action_fn,
307 dispatch_event: Some(trampoline_dispatch_event),
308 user_data: ud,
309 declare: Some(trampoline_declare),
310 }
311 }
312}
313
314// Thread-local "current host api" for the duration of a plugin register call.
315// Set by [`load_one`] before calling register, cleared after. This is the
316// sound way to let `extern "C" fn` trampolines (which cannot capture) reach the
317// host's registry: the register call is synchronous and single-threaded per
318// plugin, so a thread-local is unambiguous.
319thread_local! {
320 static CURRENT_HOST_API: std::cell::Cell<*const HostApi> = std::cell::Cell::new(std::ptr::null());
321}
322
323/// SAFETY: must be called only while the `Arc<HostApi>` pointed to by `api` is
324/// kept alive (i.e. inside `with_current_api`). Sets the thread-local current
325/// api pointer.
326unsafe fn set_current_api(api: &Arc<HostApi>) {
327 CURRENT_HOST_API.with(|c| c.set(Arc::as_ptr(api) as *const HostApi));
328}
329
330fn clear_current_api() {
331 CURRENT_HOST_API.with(|c| c.set(std::ptr::null()));
332}
333
334/// Run `f` with the current thread-local host api borrowed. No-op (returns
335/// `false`) if no api is current (e.g. a trampoline called outside register).
336fn with_current_api<R>(f: impl FnOnce(&HostApi) -> R) -> Option<R> {
337 let ptr = CURRENT_HOST_API.with(|c| c.get());
338 if ptr.is_null() {
339 return None;
340 }
341 // SAFETY: the `Arc<HostApi>` is alive for the duration of register (kept by
342 // `load_one`'s stack), and this thread set it; borrowing here is sound.
343 let api = unsafe { &*ptr };
344 Some(f(api))
345}
346
347/// True if a current host api is set (cheap check used by trampolines that only
348/// need presence, not the api itself).
349fn current_api_present() -> bool {
350 let ptr = CURRENT_HOST_API.with(|c| c.get());
351 !ptr.is_null()
352}
353
354// --- the registrar trampolines (extern "C", forward into the current api) ---
355
356extern "C" fn trampoline_register_tool(
357 schema: *const StableToolSchema,
358 execute_fn: ToolExecuteFn,
359 poll_fn: ToolPollFn,
360 cancel_fn: ToolCancelFn,
361 destroy_fn: ToolDestroyFn,
362 plugin_free_string: FreeStringFn,
363) -> i32 {
364 if !current_api_present() {
365 return -1;
366 }
367 if schema.is_null() {
368 return 1;
369 }
370 // SAFETY: the plugin guarantees `schema` is valid for the call; we copy the
371 // strings out immediately (under the borrow) and free them via the plugin's
372 // `plugin_free_string` right after, so no retention past the call.
373 let (name, description, parameters_value, schema_owned) = unsafe {
374 let s = &*schema;
375 (
376 s.name.to_string_lossy(),
377 s.description.to_string_lossy(),
378 serde_json::from_str::<serde_json::Value>(&s.parameters.to_string_lossy()).ok(),
379 *s,
380 )
381 };
382 // Free the schema strings via the plugin's free fn (plugin produced them).
383 // `FreeStringFn` is `extern "C" fn(StbString)` — calling a bare fn pointer
384 // is safe (no `unsafe` needed); the idempotent free contract is on the plugin.
385 plugin_free_string(schema_owned.name);
386 plugin_free_string(schema_owned.description);
387 plugin_free_string(schema_owned.parameters);
388 let Some(parameters_value) = parameters_value else {
389 return 2;
390 };
391 if name.trim().is_empty() || !parameters_value.is_object() {
392 return 2;
393 }
394 let tool = rpi_ai::types::Tool {
395 name,
396 description,
397 parameters: rpi_ai::types::Schema::new(parameters_value),
398 constrained_sampling: None,
399 };
400 let handle = PluginToolHandle {
401 execute_fn,
402 poll_fn,
403 cancel_fn,
404 destroy_fn,
405 plugin_free_string,
406 };
407 // `register_tool` returns `bool` (true on overwrite of a prior same-named
408 // tool). For the plugin a name collision is not a hard failure — always
409 // return 0 (success) when the registry accepted it; -1 only if the registry
410 // was already detached.
411 let ok =
412 with_current_api(
413 |api| match api.with_registry(|reg| reg.register_tool(tool, handle)) {
414 Some(_) => true,
415 None => false,
416 },
417 );
418 if ok == Some(true) {
419 0
420 } else {
421 -1
422 }
423}
424
425extern "C" fn trampoline_register_command(
426 name: StbStringRef,
427 description: StbStringRef,
428 handler: rpi_plugin_sdk::CommandHandlerFn,
429) -> i32 {
430 if !current_api_present() {
431 return -1;
432 }
433 // SAFETY: the plugin guarantees the refs are valid for this call.
434 let (name, description) =
435 unsafe { (name.as_str().to_string(), description.as_str().to_string()) };
436 let ok = with_current_api(|api| {
437 // The v1 command ABI predates an explicit context parameter. Keep a
438 // null context for compatibility; plugins that need host state can use
439 // `runtime_action` from their command callback.
440 match api.with_registry(|reg| {
441 reg.register_command(name, description, handler, std::ptr::null_mut())
442 }) {
443 Some(_) => true,
444 None => false,
445 }
446 });
447 if ok == Some(true) {
448 0
449 } else {
450 -1
451 }
452}
453
454extern "C" fn trampoline_register_shortcut(key: StbStringRef, description: StbStringRef) -> i32 {
455 if !current_api_present() {
456 return -1;
457 }
458 // SAFETY: the plugin guarantees these borrowed refs are valid for the
459 // duration of the registration call; copy before returning.
460 let (key, description) =
461 unsafe { (key.as_str().to_string(), description.as_str().to_string()) };
462 // Record the claim. The key is NOT bound to a callback here: the owning
463 // plugin receives presses/releases through its `Input` event handler
464 // (dispatched by the TUI) and decides what to do. The host only needs to
465 // know the key is claimed so it can route it away from the editor.
466 let ok = with_current_api(|api| {
467 match api
468 .with_registry(|reg| reg.register_shortcut(api.name().to_string(), key, description))
469 {
470 Some(_) => true,
471 None => false,
472 }
473 });
474 if ok == Some(true) {
475 0
476 } else {
477 -1
478 }
479}
480
481extern "C" fn trampoline_register_flag(name: StbStringRef, description: StbStringRef) -> i32 {
482 if !current_api_present() {
483 return -1;
484 }
485 // SAFETY: the plugin guarantees these borrowed refs are valid for the
486 // duration of the registration call; copy them before returning.
487 let (name, description) =
488 unsafe { (name.as_str().to_string(), description.as_str().to_string()) };
489 if name.trim().is_empty() || name.starts_with('-') || name.contains('=') || name.contains(' ') {
490 return 2;
491 }
492 let ok = with_current_api(|api| {
493 match api.with_registry(|reg| reg.register_flag(name, description)) {
494 Some(_) => true,
495 None => false,
496 }
497 });
498 if ok == Some(true) {
499 0
500 } else {
501 -1
502 }
503}
504
505extern "C" fn trampoline_register_event_handler(
506 tag: EventTag,
507 handler: EventHandlerFn,
508 user_data: *mut c_void,
509) -> i32 {
510 if !current_api_present() {
511 return -1;
512 }
513 let ok = with_current_api(|api| {
514 match api.with_registry(|reg| {
515 reg.register_event_handler(api.name().to_string(), tag, handler, user_data)
516 }) {
517 Some(_) => true,
518 None => false,
519 }
520 });
521 if ok == Some(true) {
522 0
523 } else {
524 -1
525 }
526}
527
528/// Plugin `declare` trampoline. Runs synchronously during a plugin's
529/// `register` (thread-local `CURRENT_HOST_API` is set). The plugin hands a
530/// borrowed JSON payload (`{"priority":60,"platforms":["linux"]}`); we parse
531/// and apply it to this plugin's registry (retro-updating any handlers already
532/// registered, so declare order doesn't matter). Returns `0` on a parsed
533/// payload, `1` on malformed JSON, `-1` outside register.
534extern "C" fn trampoline_declare(json: StbStringRef) -> i32 {
535 if !current_api_present() {
536 return -1;
537 }
538 // SAFETY: the plugin guarantees `json` is valid UTF-8 for the call; we read
539 // it immediately (copy into the registry) and never retain the borrow.
540 let text = unsafe { json.as_str() };
541 match with_current_api(|api| {
542 api.with_registry(|reg| {
543 let Ok(value) = serde_json::from_str::<serde_json::Value>(text) else {
544 return false;
545 };
546 if value.get("ownsStdio").and_then(serde_json::Value::as_bool) == Some(true) {
547 if let Some(bridge) = &api.action_bridge {
548 if let Err(error) = bridge.claim_stdio() {
549 tracing::error!("{error}");
550 return false;
551 }
552 } else {
553 return false; // A stdio owner requires a live lifecycle bridge.
554 }
555 }
556 reg.apply_declaration_json(text)
557 })
558 }) {
559 Some(Some(true)) => 0,
560 Some(Some(false)) => 1,
561 _ => -1,
562 }
563}
564
565/// B5c: `register_provider` trampoline. Runs synchronously inside a plugin's
566/// `register` call (thread-local `CURRENT_HOST_API` is set). The plugin hands its
567/// `provider_id`/`base_url`/`api_style` (borrowed `StbStringRef`s), its
568/// `request_fn`, its own `plugin_free_string` (the `out` StbString `request_fn`
569/// later produces is plugin-owned — the host reclaims it via this fn), and its
570/// opaque `user_data`. We copy the id/base_url/api_style to owned `String`s
571/// (they're the provider's identity, read when building the
572/// [`PluggableProvider`](crate::PluggableProvider) — we can't keep the borrowed
573/// refs past register), then store the full record in the registry.
574extern "C" fn trampoline_register_provider(
575 provider_id: StbStringRef,
576 base_url: StbStringRef,
577 api_style: StbStringRef,
578 request_fn: ProviderRequestFn,
579 plugin_free_string: FreeStringFn,
580 user_data: *mut c_void,
581) -> i32 {
582 if !current_api_present() {
583 return -1;
584 }
585 // SAFETY: the plugin guarantees the refs are valid for this call.
586 let record = crate::registry::RegisteredProvider {
587 provider_id: unsafe { provider_id.as_str().to_string() },
588 base_url: unsafe { base_url.as_str().to_string() },
589 api_style: unsafe { api_style.as_str().to_string() },
590 request_fn,
591 plugin_free_string,
592 user_data,
593 };
594 let ok = with_current_api(
595 |api| match api.with_registry(|reg| reg.register_provider(record)) {
596 Some(_) => true,
597 None => false,
598 },
599 );
600 if ok == Some(true) {
601 0
602 } else {
603 -1
604 }
605}
606
607/// Register an OAuth provider manifest and action callback. The manifest is
608/// copied immediately; token-bearing responses remain plugin-owned and are
609/// reclaimed through the plugin free function when invoked by a future host
610/// OAuth bridge.
611extern "C" fn trampoline_register_oauth_provider(
612 manifest_json: StbStringRef,
613 request_fn: OAuthRequestFn,
614 plugin_free_string: FreeStringFn,
615 user_data: *mut c_void,
616) -> i32 {
617 if !current_api_present() {
618 return -1;
619 }
620 let manifest = unsafe { manifest_json.as_str().to_string() };
621 let record = crate::registry::RegisteredOAuthProvider {
622 plugin: with_current_api(|api| api.name.clone()).unwrap_or_default(),
623 manifest_json: manifest,
624 request_fn,
625 plugin_free_string,
626 user_data,
627 };
628 match with_current_api(|api| api.with_registry(|reg| reg.register_oauth_provider(record))) {
629 Some(Some(true)) => 1,
630 Some(Some(false)) => 0,
631 _ => -1,
632 }
633}
634
635/// B5c: the three renderer registrars share a single recording helper; each
636/// trampoline below fixes its [`RegisteredRendererKind`] and forwards here.
637fn register_renderer_common(
638 name: StbStringRef,
639 kind: crate::registry::RegisteredRendererKind,
640 render_fn: RenderFn,
641 plugin_free_string: FreeStringFn,
642 user_data: *mut c_void,
643) -> i32 {
644 if !current_api_present() {
645 return -1;
646 }
647 // SAFETY: the plugin guarantees the ref is valid for this call.
648 let record = crate::registry::RegisteredRenderer {
649 name: unsafe { name.as_str().to_string() },
650 kind,
651 render_fn,
652 plugin_free_string,
653 user_data,
654 };
655 let ok = with_current_api(
656 |api| match api.with_registry(|reg| reg.register_renderer(record)) {
657 Some(_) => true,
658 None => false,
659 },
660 );
661 if ok == Some(true) {
662 0
663 } else {
664 -1
665 }
666}
667
668extern "C" fn trampoline_register_message_renderer(
669 name: StbStringRef,
670 render_fn: RenderFn,
671 plugin_free_string: FreeStringFn,
672 user_data: *mut c_void,
673) -> i32 {
674 register_renderer_common(
675 name,
676 crate::registry::RegisteredRendererKind::Message,
677 render_fn,
678 plugin_free_string,
679 user_data,
680 )
681}
682
683extern "C" fn trampoline_register_markdown_transformer(
684 name: StbStringRef,
685 render_fn: RenderFn,
686 plugin_free_string: FreeStringFn,
687 user_data: *mut c_void,
688) -> i32 {
689 register_renderer_common(
690 name,
691 crate::registry::RegisteredRendererKind::Markdown,
692 render_fn,
693 plugin_free_string,
694 user_data,
695 )
696}
697
698extern "C" fn trampoline_register_entry_renderer(
699 name: StbStringRef,
700 render_fn: RenderFn,
701 plugin_free_string: FreeStringFn,
702 user_data: *mut c_void,
703) -> i32 {
704 register_renderer_common(
705 name,
706 crate::registry::RegisteredRendererKind::Entry,
707 render_fn,
708 plugin_free_string,
709 user_data,
710 )
711}
712
713extern "C" fn trampoline_dispatch_event(_event: StablePluginEvent, _user_data: *mut c_void) -> i32 {
714 // v1: a plugin emitting an event upstream — we accept it (return 0) but do
715 // not yet forward to host subscribers (no upstream channel wired). B5 wires
716 // the reverse-direction event bus.
717 0
718}
719
720/// B5b: `register_resources_discover` trampoline. Runs synchronously inside a
721/// plugin's `register` call (so the thread-local `CURRENT_HOST_API` is set → the
722/// register-path recovery works, same as the other `trampoline_register_*` fns).
723/// The plugin hands its `handler`, its own `plugin_free_string` (the `out`
724/// StbString the handler later produces is plugin-owned — the host must reclaim
725/// it via this fn), and its opaque `user_data`. The host stores all three in the
726/// registry and later fans the discovery event out via `emit_resources_discover`.
727extern "C" fn trampoline_register_resources_discover(
728 handler: ResourcesDiscoverFn,
729 plugin_free_string: FreeStringFn,
730 user_data: *mut c_void,
731) -> i32 {
732 if !current_api_present() {
733 return -1;
734 }
735 let ok = with_current_api(|api| {
736 match api.with_registry(|reg| {
737 reg.register_resources_discover(handler, plugin_free_string, user_data)
738 }) {
739 Some(_) => true,
740 None => false,
741 }
742 });
743 if ok == Some(true) {
744 0
745 } else {
746 -1
747 }
748}
749
750/// `register_before_agent_start` trampoline. Same shape as
751/// [`trampoline_register_resources_discover`]: runs synchronously inside a
752/// plugin's `register` call (thread-local `CURRENT_HOST_API` is set), stores the
753/// handler plus the plugin's own `plugin_free_string` (the `out` StbString the
754/// handler later produces is plugin-owned) and its opaque `user_data`.
755extern "C" fn trampoline_register_before_agent_start(
756 handler: BeforeAgentStartFn,
757 plugin_free_string: FreeStringFn,
758 user_data: *mut c_void,
759) -> i32 {
760 if !current_api_present() {
761 return -1;
762 }
763 let ok = with_current_api(|api| {
764 match api.with_registry(|reg| {
765 reg.register_before_agent_start(handler, plugin_free_string, user_data)
766 }) {
767 Some(_) => true,
768 None => false,
769 }
770 });
771 if ok == Some(true) {
772 0
773 } else {
774 -1
775 }
776}
777
778/// Fallback for [`HostApi`]s built without an [`ActionBridge`] (the
779/// `HostApi::new` path). Return -1 (`EPERM`-ish) so a plugin can detect
780/// "unsupported" without crashing. When a bridge is present, `build_vtable`
781/// installs [`trampoline_runtime_action`] instead.
782extern "C" fn stub_runtime_action(
783 _action_id: u32,
784 _args: StbStringRef,
785 _out: *mut StbString,
786 _user_data: *mut c_void,
787) -> i32 {
788 -1
789}
790
791// Keep the `RuntimeActionFn` type name referenced for doc clarity / future wiring.
792#[allow(dead_code)]
793type _RuntimeActionFnDoc = RuntimeActionFn;