ryu-webhook-ingress 0.1.5

Webhook ingress: the swappable public-reachability seam that lets a loopback-bound Ryu Core receive third-party webhooks (Composio triggers, per-workflow webhooks). Owns the four ingress backends (managed RyuRelay outbound SSE push, Tailscale Funnel, cloudflared quick tunnel, BYO own-relay), the path-routed inbound dispatcher with fail-closed HMAC re-verification, replay-window + delivery dedup, and the ingress-URL registry. An extracted Core capability crate — in-process default (no IPC on any hot path); the kernel couplings (composio verify/run, workflow-secret lookup, mesh funnel, auth token, data dir) are inverted through the WebhookIngressHost trait so this crate has ZERO dependency on apps/core. Public webhook ROUTES stay kernel-ingress in Core and forward into this engine (program §5).
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
//! Concrete [`WebhookIngress`] sources and their enum-dispatch wrapper.
//!
//! The project has no `async-trait` dep on this hot path, so the
//! [`WebhookIngress`] trait declares native `async fn` methods (not object-safe).
//! Heterogeneous storage is a small closed [`Ingress`] enum, match-dispatched —
//! never `Box<dyn ..>`. (The kernel-coupling host seam is the one `async-trait`
//! boundary — see [`super::WebhookIngressHost`].)
//!
//! Every source points Composio at Core's **existing** public webhook handler
//! (`POST /api/composio/webhook`); the tunnel only provides the publicly-reachable
//! base URL. Core's handler (reached via the host) fires agents unchanged.

use std::process::Stdio;
use std::sync::RwLock;
use std::time::Duration;

use anyhow::{anyhow, bail, Result};
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::Command;

use super::host::host;
use super::{IngressKind, WebhookIngress};
use crate::win_process::NoWindow;

/// The path Composio is pointed at. Every tunnel/relay appends this to its public
/// base so an inbound webhook lands on Core's existing handler.
pub const WEBHOOK_PATH: &str = "/api/composio/webhook";

/// Join a public base URL with [`WEBHOOK_PATH`], collapsing a trailing slash so
/// `https://x.com/` and `https://x.com` both yield `https://x.com/api/composio/webhook`.
fn join_webhook(base: &str) -> String {
    format!("{}{}", base.trim_end_matches('/'), WEBHOOK_PATH)
}

/// **OwnRelay** — the BYO ingress: the user already exposes Core (or a reverse
/// proxy) at a public URL and configures it here. The base comes from the env
/// `RYU_WEBHOOK_INGRESS_URL` (preferred) or the value handed at construction
/// (e.g. a pref). `public_url()` appends the webhook path.
#[derive(Clone, Debug)]
pub struct OwnRelaySource {
    /// The public base URL Core is reachable at (no path). May be empty when
    /// nothing is configured, in which case `public_url()` errors.
    pub base_url: String,
}

/// The env var a BYO operator sets to declare Core's public base URL.
pub const OWN_RELAY_URL_ENV: &str = "RYU_WEBHOOK_INGRESS_URL";

impl OwnRelaySource {
    /// Build from the env override first, falling back to the supplied base
    /// (typically the pref or the resolved local `server_url`).
    pub fn new(fallback_base: impl Into<String>) -> Self {
        let env_base = std::env::var(OWN_RELAY_URL_ENV)
            .ok()
            .map(|v| v.trim().to_owned())
            .filter(|v| !v.is_empty());
        Self {
            base_url: env_base.unwrap_or_else(|| fallback_base.into()),
        }
    }
}

impl WebhookIngress for OwnRelaySource {
    fn kind(&self) -> IngressKind {
        IngressKind::OwnRelay
    }

    async fn start(&self) -> Result<()> {
        if self.base_url.trim().is_empty() {
            bail!(
                "own-relay ingress: no public URL set (env {OWN_RELAY_URL_ENV} \
                 or the webhook.ingress.url pref)"
            );
        }
        Ok(())
    }

    async fn public_url(&self) -> Result<String> {
        let base = self.base_url.trim();
        if base.is_empty() {
            bail!(
                "own-relay ingress: no public URL set (env {OWN_RELAY_URL_ENV} \
                 or the webhook.ingress.url pref)"
            );
        }
        Ok(join_webhook(base))
    }
}

/// **TailscaleFunnel** — exposes Core's bind port to the public internet via the
/// P5 mesh's Tailscale Funnel. Consumes the host's `ensure_funnel` / `funnel_url`
/// (Core forwards to `crate::mesh`). When the mesh is not enabled/available — or
/// no host is installed — it stub-errors with a clear "Phase 5" message so this
/// runs standalone.
#[derive(Clone, Debug)]
pub struct TailscaleFunnelSource {
    /// Core's local bind port, the target the Funnel serves.
    pub port: u16,
}

impl TailscaleFunnelSource {
    pub fn new(port: u16) -> Self {
        Self { port }
    }
}

impl WebhookIngress for TailscaleFunnelSource {
    fn kind(&self) -> IngressKind {
        IngressKind::TailscaleFunnel
    }

    async fn start(&self) -> Result<()> {
        // ensure_funnel itself bails clearly when the mesh is disabled (or no host
        // is installed); that is the graceful "mesh funnel not available — Phase 5"
        // path until P5's daemon is enrolled on this node.
        let url = host()?
            .ensure_funnel(self.port)
            .await
            .map_err(|e| anyhow::anyhow!("mesh funnel not available — Phase 5 ({e})"))?;
        let _ = url;
        Ok(())
    }

    async fn public_url(&self) -> Result<String> {
        match host()?.funnel_url(self.port).await {
            Some(base) => Ok(join_webhook(&base)),
            None => bail!("mesh funnel not available — Phase 5 (no active Funnel for this port)"),
        }
    }
}

/// **Cloudflared** — adopt-or-spawn a `cloudflared` quick tunnel pointed at
/// Core's local port. `start()` spawns `cloudflared tunnel --url
/// http://localhost:<port>`, parses the assigned `https://<sub>.trycloudflare.com`
/// base from its output, and holds the child alive for the process lifetime
/// (dropping the child tears the tunnel down). `public_url()` returns that base
/// joined with [`WEBHOOK_PATH`]. No account/login is needed — quick tunnels are
/// anonymous and ephemeral, which is exactly the BYO-public-URL contract this seam
/// needs. Requires the `cloudflared` binary on PATH; spawn failure errors clearly.
#[derive(Clone, Debug)]
pub struct CloudflaredSource {
    /// Core's local bind port, the target the tunnel forwards to.
    pub port: u16,
}

impl CloudflaredSource {
    pub fn new(port: u16) -> Self {
        Self { port }
    }
}

/// Process-global state for the single managed cloudflared quick tunnel: the
/// resolved public base URL plus the held child. The child is kept here (and never
/// dropped) so the tunnel stays up; `kill_on_drop` ensures it dies with Core.
struct CloudflaredState {
    base_url: String,
    #[allow(dead_code)]
    child: tokio::process::Child,
}

static CLOUDFLARED: RwLock<Option<CloudflaredState>> = RwLock::new(None);

/// The current cloudflared base URL, if a tunnel is active.
fn cloudflared_base_url() -> Option<String> {
    CLOUDFLARED
        .read()
        .ok()
        .and_then(|g| g.as_ref().map(|s| s.base_url.clone()))
}

/// Extract a `https://<sub>.trycloudflare.com` URL from a single output line, if
/// present. cloudflared prints the assigned quick-tunnel URL on its own banner
/// line (to stderr); this finds it regardless of surrounding box-drawing chars.
fn extract_trycloudflare_url(line: &str) -> Option<String> {
    let start = line.find("https://")?;
    let rest = &line[start..];
    let end = rest
        .find(|c: char| c.is_whitespace() || c == '|' || c == '"')
        .unwrap_or(rest.len());
    let url = rest[..end].trim_end_matches('/');
    if url.ends_with(".trycloudflare.com") {
        Some(url.to_owned())
    } else {
        None
    }
}

impl WebhookIngress for CloudflaredSource {
    fn kind(&self) -> IngressKind {
        IngressKind::Cloudflared
    }

    async fn start(&self) -> Result<()> {
        // Idempotent: a tunnel is already up.
        if cloudflared_base_url().is_some() {
            return Ok(());
        }

        let mut child = Command::new("cloudflared")
            .arg("tunnel")
            .arg("--no-autoupdate")
            .arg("--url")
            .arg(format!("http://localhost:{}", self.port))
            .stdout(Stdio::piped())
            .stderr(Stdio::piped())
            .kill_on_drop(true)
            .no_window()
            .spawn()
            .map_err(|e| {
                anyhow!(
                    "cloudflared ingress: failed to spawn `cloudflared` ({e}) — install \
                     cloudflared and ensure it is on PATH, or use own-relay / tailscale-funnel"
                )
            })?;

        // Drain stdout so its pipe never fills (cloudflared logs there too).
        if let Some(out) = child.stdout.take() {
            tokio::spawn(async move {
                let mut lines = BufReader::new(out).lines();
                while let Ok(Some(_)) = lines.next_line().await {}
            });
        }

        // cloudflared prints the assigned URL to stderr. Read until we find it,
        // hand it back via a oneshot, then keep draining so the pipe never blocks.
        let stderr = child
            .stderr
            .take()
            .ok_or_else(|| anyhow!("cloudflared ingress: no stderr handle on child"))?;
        let (tx, rx) = tokio::sync::oneshot::channel::<String>();
        tokio::spawn(async move {
            let mut lines = BufReader::new(stderr).lines();
            let mut tx = Some(tx);
            while let Ok(Some(line)) = lines.next_line().await {
                if let Some(url) = extract_trycloudflare_url(&line) {
                    if let Some(tx) = tx.take() {
                        let _ = tx.send(url);
                    }
                }
            }
        });

        let url = tokio::time::timeout(Duration::from_secs(30), rx)
            .await
            .map_err(|_| {
                anyhow!("cloudflared ingress: timed out waiting for the tunnel URL (is cloudflared healthy?)")
            })?
            .map_err(|_| {
                anyhow!("cloudflared ingress: process exited before reporting a tunnel URL")
            })?;

        if let Ok(mut guard) = CLOUDFLARED.write() {
            *guard = Some(CloudflaredState {
                base_url: url,
                child,
            });
        }
        Ok(())
    }

    async fn public_url(&self) -> Result<String> {
        match cloudflared_base_url() {
            Some(base) => Ok(join_webhook(&base)),
            None => bail!("cloudflared ingress: no active tunnel (call start first)"),
        }
    }
}

/// **RyuRelay** — the managed push relay (the default). Core opens an outbound
/// SSE subscription to `apps/server`; Composio POSTs to a public ingress URL and
/// the server fans the payload out over that stream, which Core dispatches
/// in-process. The register + SSE-client loop live in [`super::ryu_relay`]; this
/// source delegates to them.
#[derive(Clone, Debug, Default)]
pub struct RyuRelaySource;

impl RyuRelaySource {
    pub fn new() -> Self {
        Self
    }
}

impl WebhookIngress for RyuRelaySource {
    fn kind(&self) -> IngressKind {
        IngressKind::RyuRelay
    }

    async fn start(&self) -> Result<()> {
        // Registers with the relay server (publishing the public URL via the
        // process-global) and spawns the background SSE-client loop. Errors when
        // not logged in so `main.rs` logs a clear "not active".
        super::ryu_relay::start().await
    }

    async fn public_url(&self) -> Result<String> {
        // The loop publishes the ingress URL to the process-global once register
        // succeeds; until then there is no URL to report.
        super::public_url().ok_or_else(|| {
            anyhow::anyhow!("ryu-relay ingress: not registered yet (login required)")
        })
    }
}

/// The closed set of ingress backends, match-dispatched (no `async-trait`/`dyn`).
#[derive(Clone, Debug)]
pub enum Ingress {
    RyuRelay(RyuRelaySource),
    TailscaleFunnel(TailscaleFunnelSource),
    Cloudflared(CloudflaredSource),
    OwnRelay(OwnRelaySource),
}

impl Ingress {
    /// The backend kind this ingress represents.
    pub fn kind(&self) -> IngressKind {
        match self {
            Ingress::RyuRelay(s) => s.kind(),
            Ingress::TailscaleFunnel(s) => s.kind(),
            Ingress::Cloudflared(s) => s.kind(),
            Ingress::OwnRelay(s) => s.kind(),
        }
    }

    /// Start (or adopt) the backend so it is ready to receive webhooks.
    pub async fn start(&self) -> Result<()> {
        match self {
            Ingress::RyuRelay(s) => s.start().await,
            Ingress::TailscaleFunnel(s) => s.start().await,
            Ingress::Cloudflared(s) => s.start().await,
            Ingress::OwnRelay(s) => s.start().await,
        }
    }

    /// The public URL Composio should be pointed at (ends in [`WEBHOOK_PATH`]).
    pub async fn public_url(&self) -> Result<String> {
        match self {
            Ingress::RyuRelay(s) => s.public_url().await,
            Ingress::TailscaleFunnel(s) => s.public_url().await,
            Ingress::Cloudflared(s) => s.public_url().await,
            Ingress::OwnRelay(s) => s.public_url().await,
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn join_webhook_strips_trailing_slash() {
        assert_eq!(
            join_webhook("https://x.com"),
            "https://x.com/api/composio/webhook"
        );
        assert_eq!(
            join_webhook("https://x.com/"),
            "https://x.com/api/composio/webhook"
        );
    }

    #[tokio::test]
    async fn own_relay_public_url_appends_path() {
        let src = OwnRelaySource {
            base_url: "https://relay.example.com/".to_owned(),
        };
        assert_eq!(
            src.public_url().await.unwrap(),
            "https://relay.example.com/api/composio/webhook"
        );
        assert_eq!(src.kind(), IngressKind::OwnRelay);
    }

    #[tokio::test]
    async fn own_relay_empty_base_errors() {
        let src = OwnRelaySource {
            base_url: "   ".to_owned(),
        };
        assert!(src.public_url().await.is_err());
        assert!(src.start().await.is_err());
    }

    #[tokio::test]
    async fn ryu_relay_kind_is_ryu_relay() {
        // Network-free: do NOT call start() (it would register + spawn the SSE
        // loop against the live relay server when a ~/.ryu/auth.json token exists,
        // which is the case on a developer machine). public_url() reads the
        // process-global PUBLIC_URL, which other tests mutate in parallel, so it
        // is not asserted here. The frame-parser + register logic is unit-tested
        // in super::ryu_relay.
        let src = RyuRelaySource::new();
        assert_eq!(src.kind(), IngressKind::RyuRelay);
    }

    #[test]
    fn extract_trycloudflare_url_parses_banner() {
        // The real banner wraps the URL in box-drawing chars; parsing must ignore them.
        assert_eq!(
            extract_trycloudflare_url(
                "2024-01-01 INF |  https://random-words-1234.trycloudflare.com  |"
            ),
            Some("https://random-words-1234.trycloudflare.com".to_owned())
        );
        // Trailing slash is collapsed.
        assert_eq!(
            extract_trycloudflare_url("https://abc.trycloudflare.com/"),
            Some("https://abc.trycloudflare.com".to_owned())
        );
        // A non-trycloudflare https URL (e.g. the docs link cloudflared prints) is ignored.
        assert_eq!(
            extract_trycloudflare_url("Visit https://developers.cloudflare.com for docs"),
            None
        );
        // Lines without a URL yield nothing.
        assert_eq!(extract_trycloudflare_url("starting tunnel"), None);
    }

    #[tokio::test]
    async fn cloudflared_public_url_errors_without_tunnel() {
        // public_url() is deterministic + network-free: with no active tunnel it
        // errors. start() is NOT called here — on a dev machine that has
        // cloudflared on PATH it would spawn a real anonymous tunnel, which a unit
        // test must never do. The spawn-failure path is covered below only when the
        // binary is absent.
        let src = CloudflaredSource::new(7980);
        assert_eq!(src.kind(), IngressKind::Cloudflared);
        if cloudflared_base_url().is_none() {
            assert!(src.public_url().await.is_err());
        }
    }

    #[tokio::test]
    async fn cloudflared_start_errors_when_binary_absent() {
        // Only exercise start() when `cloudflared` is NOT installed, so the test
        // asserts the graceful spawn-failure error without ever opening a live
        // tunnel on a machine that happens to have the binary.
        let has_binary = std::process::Command::new("cloudflared")
            .arg("--version")
            .stdout(std::process::Stdio::null())
            .stderr(std::process::Stdio::null())
            .no_window()
            .status()
            .is_ok();
        if !has_binary {
            let src = CloudflaredSource::new(7980);
            assert!(src.start().await.is_err());
        }
    }

    #[tokio::test]
    async fn tailscale_funnel_stub_errors_when_mesh_off() {
        // In the test process RYU_MESH_ENABLED is unset → mesh off → both paths
        // surface the clear Phase-5 stub error rather than panicking.
        if std::env::var("RYU_MESH_ENABLED").is_err() {
            let src = TailscaleFunnelSource::new(7980);
            assert_eq!(src.kind(), IngressKind::TailscaleFunnel);
            assert!(src.start().await.is_err());
            assert!(src.public_url().await.is_err());
        }
    }

    #[tokio::test]
    async fn enum_dispatch_routes_to_variant() {
        let ing = Ingress::OwnRelay(OwnRelaySource {
            base_url: "https://x.com".to_owned(),
        });
        assert_eq!(ing.kind(), IngressKind::OwnRelay);
        assert_eq!(
            ing.public_url().await.unwrap(),
            "https://x.com/api/composio/webhook"
        );
        assert!(ing.start().await.is_ok());

        let relay = Ingress::RyuRelay(RyuRelaySource::new());
        assert_eq!(relay.kind(), IngressKind::RyuRelay);
    }
}