Skip to main content

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;