Skip to main content

agentmux_srv/muxbus/
wan_publish.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Publish each agent's WAN key to the account's cloud key directory,
5//! certified by this install's instance key
6//! (`SPEC_WAN_JEKT_VERIFICATION_2026_09_24.md` §2.2, step D1b).
7//!
8//! A receiver can verify a WAN jekt only against a key it can fetch, and it
9//! trusts that key only because this install's instance key signed it — so
10//! the directory never has to be trusted, only reachable. The cloud checks
11//! the same chain on `PUT` (hygiene); the receiver checks it again.
12//!
13//! **When.** The spawn path nudges the publisher after it provisions a key
14//! ([`nudge`]); a background loop also re-runs every [`RETRY_SECS`] so a key
15//! that failed to publish (logged out, cloud down) is retried, and every
16//! [`IDLE_RECHECK_SECS`] when idle, because a sign-in in another channel can
17//! change the directory without nudging this process. A cloud that
18//! answers 404 predates the directory (C1 not deployed): retried at most
19//! hourly, never hot.
20//!
21//! **Which token.** The shared account token (`load_valid_token`), not a
22//! per-agent M2M token — a per-agent client may be unowned, and the
23//! directory is keyed by account.
24//!
25//! Nothing here ever makes a jekt fail: until a key is confirmed published,
26//! the relay simply sends that agent's jekts unsigned (`reactive.rs`'s carry
27//! gate, condition 4).
28
29use std::sync::{Arc, OnceLock};
30use std::time::Duration;
31
32use agentmux_common::jekt_sign::WanKeyRecord;
33
34use crate::backend::storage::store::Store;
35use crate::backend::storage::wan_identity::{WanAgentKey, WanIdentityStore, WanInstance};
36
37/// Background retry interval while any key is unpublished.
38const RETRY_SECS: u64 = 5 * 60;
39
40/// Retry interval after the cloud said it has no directory (HTTP 404).
41const NOT_SUPPORTED_RETRY_SECS: u64 = 60 * 60;
42
43/// How often an idle publisher looks again. A sign-in in another channel
44/// (the MuxBus login is shared) changes the directory this one should
45/// publish to, but only nudges its own process (Codex P2 on #3994), so an
46/// idle publisher can't wait on nudges alone.
47const IDLE_RECHECK_SECS: u64 = 5 * 60;
48
49const PUBLISH_TIMEOUT_SECS: u64 = 10;
50
51/// Which directory a publication is for: the relay's REST base plus the
52/// account (the token's `sub`). The directory is keyed by account, and a
53/// relay move or a sign-in to another account is a directory that has never
54/// seen this install's keys — so `wan.db` records publications per this id,
55/// and a key counts as published only to the directory it went to.
56pub(crate) fn directory_id(base_url: &str, account_sub: &str) -> String {
57    format!("{}#{}", base_url.trim_end_matches('/'), account_sub)
58}
59
60static NUDGE: OnceLock<Arc<tokio::sync::Notify>> = OnceLock::new();
61
62fn nudge_handle() -> Arc<tokio::sync::Notify> {
63    NUDGE.get_or_init(|| Arc::new(tokio::sync::Notify::new())).clone()
64}
65
66/// Ask the publisher to run a pass soon — called by the spawn path after it
67/// provisions an agent's WAN key. Cheap and safe to call with no publisher
68/// running.
69pub(crate) fn nudge() {
70    nudge_handle().notify_one();
71}
72
73/// This agent key's directory record, certified by the instance. Channel is
74/// the one the agent signs as (`AGENTMUX_CHANNEL`, `local_channel_id`), and
75/// `issued_at` is the key's creation time, so the same key always yields the
76/// same record.
77pub(crate) fn certify(
78    instance: &WanInstance,
79    agent_id: &str,
80    channel: &str,
81    key: &WanAgentKey,
82) -> Option<WanKeyRecord> {
83    WanKeyRecord::certify(
84        &instance.private_key,
85        agent_id,
86        channel,
87        &key.public_key_bytes()?,
88        &instance.host_hint,
89        key.created_at,
90    )
91}
92
93#[derive(Debug, PartialEq, Eq)]
94pub(crate) enum PublishOutcome {
95    Published,
96    /// HTTP 404: this cloud has no key directory yet.
97    NotSupported,
98    Failed(String),
99}
100
101/// `PUT /agents/:agent_id/wan-key`. A pure HTTP operation, like
102/// `relay::relay_inject`, so tests can point it at a stub.
103pub(crate) async fn publish_record(
104    base_url: &str,
105    http: &reqwest::Client,
106    token: &str,
107    record: &WanKeyRecord,
108) -> PublishOutcome {
109    let url = format!("{}/agents/{}/wan-key", base_url.trim_end_matches('/'), record.agent_id);
110    let resp = http
111        .put(&url)
112        .header("Authorization", format!("Bearer {token}"))
113        .timeout(Duration::from_secs(PUBLISH_TIMEOUT_SECS))
114        .json(record)
115        .send()
116        .await;
117    match resp {
118        Ok(r) if r.status().is_success() => PublishOutcome::Published,
119        Ok(r) if r.status() == reqwest::StatusCode::NOT_FOUND => PublishOutcome::NotSupported,
120        Ok(r) => {
121            let status = r.status();
122            PublishOutcome::Failed(format!("HTTP {status}: {}", r.text().await.unwrap_or_default()))
123        }
124        Err(e) => PublishOutcome::Failed(format!("unreachable: {e}")),
125    }
126}
127
128/// Result of one pass over the unpublished keys.
129#[derive(Debug, Default, PartialEq, Eq)]
130pub(crate) struct PassResult {
131    pub published: usize,
132    pub not_supported: bool,
133    pub failed: usize,
134    /// Keys still unpublished after the pass.
135    pub remaining: usize,
136}
137
138/// Publish every key `wan` doesn't yet have confirmed. Stops at the first
139/// 404 — the whole directory is missing, not one record.
140pub(crate) async fn publish_pending(
141    wan: &WanIdentityStore,
142    instance: &WanInstance,
143    channel: &str,
144    base_url: &str,
145    http: &reqwest::Client,
146    token: &str,
147    directory: &str,
148) -> PassResult {
149    let pending = match wan.agent_keys_unpublished(directory) {
150        Ok(p) => p,
151        Err(e) => {
152            tracing::warn!(error = %e, "wan publish: could not read wan.db");
153            return PassResult { failed: 1, ..Default::default() };
154        }
155    };
156    let mut result = PassResult::default();
157    for (agent_id, key) in &pending {
158        let Some(record) = certify(instance, agent_id, channel, key) else {
159            tracing::warn!(agent = %agent_id, "wan publish: malformed key in wan.db — skipped");
160            result.failed += 1;
161            continue;
162        };
163        match publish_record(base_url, http, token, &record).await {
164            // Marked for `directory`, the one this PUT went to, even if a
165            // sign-in to another account has landed since: the new directory
166            // still sees the key as pending.
167            PublishOutcome::Published => match wan.agent_key_mark_published(agent_id, &key.public_key, directory) {
168                Ok(true) => result.published += 1,
169                // The key changed under us (agent deleted and recreated
170                // mid-PUT); the new key is picked up next pass.
171                Ok(false) => {}
172                Err(e) => {
173                    tracing::warn!(agent = %agent_id, error = %e, "wan publish: could not record publication");
174                    result.failed += 1;
175                }
176            },
177            PublishOutcome::NotSupported => {
178                result.not_supported = true;
179                break;
180            }
181            PublishOutcome::Failed(e) => {
182                tracing::warn!(agent = %agent_id, error = %e, "wan publish: directory rejected or unreachable");
183                result.failed += 1;
184            }
185        }
186    }
187    result.remaining = wan.agent_keys_unpublished(directory).map(|p| p.len()).unwrap_or(pending.len());
188    result
189}
190
191/// Start the background publisher. No-op without a `wan.db`. Runs a pass on
192/// start, on every [`nudge`], every [`RETRY_SECS`] while keys remain, and every
193/// [`IDLE_RECHECK_SECS`] when none do;
194/// after a 404 it waits [`NOT_SUPPORTED_RETRY_SECS`] (nudges included, so a
195/// burst of spawns can't turn an old cloud into a hot loop).
196pub(crate) fn spawn(mstore: Arc<Store>, id_store: Arc<Store>) {
197    let Some(wan) = mstore.wan_identity() else { return };
198    let notify = nudge_handle();
199    tokio::spawn(async move {
200        let http = reqwest::Client::new();
201        loop {
202            let wait = run_pass(&wan, &id_store, &http).await;
203            match wait {
204                Wait::NotSupported => tokio::time::sleep(Duration::from_secs(NOT_SUPPORTED_RETRY_SECS)).await,
205                Wait::Retry => {
206                    tokio::select! {
207                        _ = notify.notified() => {}
208                        _ = tokio::time::sleep(Duration::from_secs(RETRY_SECS)) => {}
209                    }
210                }
211                Wait::Idle => {
212                    tokio::select! {
213                        _ = notify.notified() => {}
214                        _ = tokio::time::sleep(Duration::from_secs(IDLE_RECHECK_SECS)) => {}
215                    }
216                }
217            }
218        }
219    });
220}
221
222enum Wait {
223    /// Everything is published to the current directory: sleep until a
224    /// nudge, or [`IDLE_RECHECK_SECS`] in case the account changed elsewhere.
225    Idle,
226    Retry,
227    NotSupported,
228}
229
230async fn run_pass(wan: &Arc<WanIdentityStore>, id_store: &Arc<Store>, http: &reqwest::Client) -> Wait {
231    let instance = match wan.instance_ensure(&crate::backend::reactive::registry::local_host_label()) {
232        Ok(i) => i,
233        Err(e) => {
234            tracing::warn!(error = %e, "wan publish: no instance");
235            return Wait::Retry;
236        }
237    };
238    let Some(scheduler) = crate::broker::get_global() else { return Wait::Retry };
239    let Some(token) = crate::muxbus::cloud_subscriber::load_valid_token(id_store, &scheduler).await else {
240        tracing::debug!("wan publish: not logged in to muxbus — keys stay unpublished");
241        return Wait::Retry;
242    };
243    let account = crate::muxbus::pkce::token_sub(&token);
244    if account.is_empty() {
245        tracing::warn!("wan publish: the account token names no account — keys stay unpublished");
246        return Wait::Retry;
247    }
248    let base_url = crate::muxbus::relay::rest_base_url();
249    let directory = directory_id(&base_url, &account);
250    if wan.agent_keys_unpublished(&directory).map(|p| p.is_empty()).unwrap_or(false) {
251        return Wait::Idle;
252    }
253    let result = publish_pending(
254        wan,
255        &instance,
256        &crate::backend::reactive::registry::local_channel_id(),
257        &base_url,
258        http,
259        &token,
260        &directory,
261    )
262    .await;
263    if result.published > 0 {
264        tracing::info!(published = result.published, instance = %instance.instance_id, "wan publish: agent keys published");
265    }
266    if result.not_supported {
267        tracing::info!("wan publish: cloud has no WAN key directory yet — retrying hourly");
268        Wait::NotSupported
269    } else if result.remaining > 0 {
270        Wait::Retry
271    } else {
272        Wait::Idle
273    }
274}
275
276#[cfg(test)]
277mod tests {
278    use super::*;
279
280    /// (agent id from the path, Authorization header, body) per PUT.
281    type Seen = Vec<(String, Option<String>, serde_json::Value)>;
282
283    struct Stub {
284        seen: Arc<std::sync::Mutex<Seen>>,
285        _guard: tokio_util::sync::DropGuard,
286        url: String,
287    }
288
289    /// A stub directory that answers `status` to every PUT and records it.
290    async fn stub_directory(status: axum::http::StatusCode) -> Stub {
291        let seen = Arc::new(std::sync::Mutex::new(Vec::new()));
292        let sink = seen.clone();
293        let app = axum::Router::new().route(
294            "/agents/:agent_id/wan-key",
295            axum::routing::put(
296                move |axum::extract::Path(agent_id): axum::extract::Path<String>,
297                      headers: axum::http::HeaderMap,
298                      body: axum::Json<serde_json::Value>| {
299                    let sink = sink.clone();
300                    async move {
301                        let auth = headers.get("authorization").and_then(|v| v.to_str().ok()).map(str::to_string);
302                        sink.lock().unwrap().push((agent_id, auth, body.0));
303                        (status, "{}")
304                    }
305                },
306            ),
307        );
308        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
309        let addr = listener.local_addr().unwrap();
310        let token = tokio_util::sync::CancellationToken::new();
311        let child = token.clone();
312        tokio::spawn(async move {
313            let _ = axum::serve(listener, app).with_graceful_shutdown(async move { child.cancelled().await }).await;
314        });
315        Stub { seen, _guard: token.drop_guard(), url: format!("http://{addr}") }
316    }
317
318    const DIR: &str = "https://relay.test#acct";
319
320    fn published(wan: &WanIdentityStore, agent_id: &str, directory: &str) -> bool {
321        let key = wan.agent_key_load(agent_id).unwrap().unwrap();
322        wan.agent_key_is_published_to(agent_id, &key, directory).unwrap()
323    }
324
325    fn temp_wan() -> (tempfile::TempDir, WanIdentityStore, WanInstance) {
326        let dir = tempfile::tempdir().unwrap();
327        let wan = WanIdentityStore::open(&dir.path().join("wan.db")).unwrap();
328        let instance = wan.instance_ensure("narko").unwrap();
329        (dir, wan, instance)
330    }
331
332    #[test]
333    fn a_certified_record_passes_the_chain_and_is_stable_across_calls() {
334        let (_dir, wan, instance) = temp_wan();
335        let key = wan.agent_key_ensure("Camper", None).unwrap();
336        let record = certify(&instance, "camper", "stable", &key).unwrap();
337        assert!(record.check_chain().is_some());
338        assert_eq!(record.instance_id, instance.instance_id);
339        assert_eq!(record.issued_at, key.created_at);
340        assert_eq!(record.host_hint, "narko");
341        assert_eq!(certify(&instance, "camper", "stable", &key), Some(record), "same key → same record");
342    }
343
344    #[tokio::test]
345    async fn a_pass_publishes_every_pending_key_and_marks_it() {
346        let stub = stub_directory(axum::http::StatusCode::OK).await;
347        let (_dir, wan, instance) = temp_wan();
348        wan.agent_key_ensure("camper", None).unwrap();
349        wan.agent_key_ensure("lark", None).unwrap();
350
351        let result = publish_pending(&wan, &instance, "stable", &stub.url, &reqwest::Client::new(), "tok", DIR).await;
352        assert_eq!(result, PassResult { published: 2, remaining: 0, ..Default::default() });
353        assert!(published(&wan, "camper", DIR));
354
355        let seen = stub.seen.lock().unwrap().clone();
356        assert_eq!(seen.len(), 2);
357        for (agent_id, auth, body) in seen {
358            assert_eq!(auth.as_deref(), Some("Bearer tok"));
359            let record: WanKeyRecord = serde_json::from_value(body).unwrap();
360            assert_eq!(record.agent_id, agent_id, "the path names the record's agent");
361            assert!(record.check_chain().is_some(), "what goes on the wire passes the cloud's chain check");
362            assert_eq!(record.channel, "stable");
363        }
364
365        // Nothing left: a second pass sends nothing.
366        let again = publish_pending(&wan, &instance, "stable", &stub.url, &reqwest::Client::new(), "tok", DIR).await;
367        assert_eq!(again, PassResult::default());
368        assert_eq!(stub.seen.lock().unwrap().len(), 2);
369    }
370
371    #[tokio::test]
372    async fn a_404_means_no_directory_and_stops_the_pass() {
373        let stub = stub_directory(axum::http::StatusCode::NOT_FOUND).await;
374        let (_dir, wan, instance) = temp_wan();
375        wan.agent_key_ensure("camper", None).unwrap();
376        wan.agent_key_ensure("lark", None).unwrap();
377        let result = publish_pending(&wan, &instance, "stable", &stub.url, &reqwest::Client::new(), "tok", DIR).await;
378        assert!(result.not_supported);
379        assert_eq!(result.remaining, 2);
380        assert_eq!(stub.seen.lock().unwrap().len(), 1, "one 404 is enough to know");
381    }
382
383    #[tokio::test]
384    async fn a_rejection_leaves_the_key_unpublished_for_the_next_pass() {
385        let stub = stub_directory(axum::http::StatusCode::BAD_REQUEST).await;
386        let (_dir, wan, instance) = temp_wan();
387        wan.agent_key_ensure("camper", None).unwrap();
388        let result = publish_pending(&wan, &instance, "stable", &stub.url, &reqwest::Client::new(), "tok", DIR).await;
389        assert_eq!(result, PassResult { failed: 1, remaining: 1, ..Default::default() });
390        assert!(!published(&wan, "camper", DIR));
391    }
392
393    #[tokio::test]
394    async fn an_unreachable_directory_is_a_failure_not_a_hang() {
395        let url = {
396            let l = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
397            format!("http://{}", l.local_addr().unwrap())
398        };
399        let (_dir, wan, instance) = temp_wan();
400        wan.agent_key_ensure("camper", None).unwrap();
401        let result = publish_pending(&wan, &instance, "stable", &url, &reqwest::Client::new(), "tok", DIR).await;
402        assert_eq!(result.failed, 1);
403        assert_eq!(result.remaining, 1);
404    }
405
406    #[test]
407    fn a_publication_marks_only_the_key_it_was_for() {
408        let (_dir, wan, _instance) = temp_wan();
409        let old = wan.agent_key_ensure("camper", None).unwrap();
410        // Agent deleted and recreated while the PUT was in flight.
411        wan.agent_keys_delete(&["camper".to_string()]).unwrap();
412        let new = wan.agent_key_ensure("camper", None).unwrap();
413        assert!(!wan.agent_key_mark_published("camper", &old.public_key, DIR).unwrap());
414        assert!(!published(&wan, "camper", DIR));
415        assert!(wan.agent_key_mark_published("camper", &new.public_key, DIR).unwrap());
416        assert!(published(&wan, "camper", DIR));
417    }
418
419    #[tokio::test]
420    async fn a_sign_in_to_another_account_publishes_every_key_again() {
421        // The relay moved (or the user signed in to another account): the
422        // new directory has never seen these keys. Before directory-bound
423        // marks, the old marks made this pass a no-op and every jekt from
424        // here arrived unverified.
425        let stub = stub_directory(axum::http::StatusCode::OK).await;
426        let (_dir, wan, instance) = temp_wan();
427        wan.agent_key_ensure("camper", None).unwrap();
428        let old = directory_id(&stub.url, "old-account");
429        let new = directory_id(&stub.url, "new-account");
430        let first = publish_pending(&wan, &instance, "stable", &stub.url, &reqwest::Client::new(), "tok", &old).await;
431        assert_eq!(first.published, 1);
432
433        let second = publish_pending(&wan, &instance, "stable", &stub.url, &reqwest::Client::new(), "tok2", &new).await;
434        assert_eq!(second, PassResult { published: 1, remaining: 0, ..Default::default() });
435        assert!(published(&wan, "camper", &new));
436        assert!(published(&wan, "camper", &old), "the old directory's record stays true");
437        assert_eq!(stub.seen.lock().unwrap().len(), 2);
438    }
439
440    #[test]
441    fn a_directory_is_the_relay_plus_the_account() {
442        assert_eq!(directory_id("https://relay.example/", "sub-1"), "https://relay.example#sub-1");
443        assert_ne!(directory_id("https://relay.example", "sub-1"), directory_id("https://relay.example", "sub-2"));
444        assert_ne!(directory_id("https://a.example", "sub-1"), directory_id("https://b.example", "sub-1"));
445    }
446}