agentmux_srv/server/muxbus_handlers.rs
1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! MuxBus cloud connectivity RPC handlers.
5//!
6//! Five commands:
7//! * `muxbus.login` — PKCE browser flow (blocks until complete or timeout)
8//! * `muxbus.login.cancel` — abort an in-flight `muxbus.login` (e.g. user closed the browser)
9//! * `muxbus.status` — current credential status
10//! * `muxbus.disconnect` — clear stored credentials
11//! * `muxbus.cloudconfig` — the cloud settings srv resolved, and where they came from
12
13use std::sync::Arc;
14
15use serde::{Deserialize, Serialize};
16
17use crate::backend::rpc::engine::WshRpcEngine;
18
19use super::AppState;
20
21pub const COMMAND_MUXBUS_LOGIN: &str = "muxbus.login";
22pub const COMMAND_MUXBUS_LOGIN_CANCEL: &str = "muxbus.login.cancel";
23pub const COMMAND_MUXBUS_STATUS: &str = "muxbus.status";
24pub const COMMAND_MUXBUS_DISCONNECT: &str = "muxbus.disconnect";
25pub const COMMAND_MUXBUS_CLOUDCONFIG: &str = "muxbus.cloudconfig";
26
27#[derive(Debug, Deserialize, Serialize, ts_rs::TS)]
28#[ts(export, export_to = "../../../frontend/types/rpc/")]
29#[serde(rename_all = "camelCase")]
30pub struct MuxBusLoginReq {
31 pub cognito_domain: String,
32 pub client_id: String,
33}
34
35#[derive(Debug, Serialize, Deserialize, ts_rs::TS)]
36#[ts(export, export_to = "../../../frontend/types/rpc/")]
37#[serde(rename_all = "camelCase")]
38pub struct MuxBusLoginResp {
39 pub success: bool,
40 pub email: String,
41 /// skip_serializing_if, so the key is OMITTED on success rather than
42 /// carrying null -- `error?: string`, which is what the hand-written
43 /// inline type already said.
44 #[serde(skip_serializing_if = "Option::is_none")]
45 #[ts(optional)]
46 pub error: Option<String>,
47}
48
49#[derive(Debug, Serialize, Deserialize, ts_rs::TS)]
50#[ts(export, export_to = "../../../frontend/types/rpc/")]
51#[serde(rename_all = "camelCase")]
52pub struct MuxBusLoginCancelResp {
53 /// False when there was no in-flight login to cancel (already resolved,
54 /// or never started) — not an error, just nothing to do.
55 pub cancelled: bool,
56}
57
58#[derive(Debug, Serialize, Deserialize, ts_rs::TS)]
59#[ts(export, export_to = "../../../frontend/types/rpc/")]
60#[serde(rename_all = "camelCase")]
61pub struct MuxBusStatusResp {
62 pub connected: bool,
63 pub email: String,
64 pub cognito_domain: String,
65 #[ts(type = "number")]
66 pub expires_at: i64,
67 pub valid: bool,
68 /// The stored sign-in can't work anymore and only a new sign-in helps:
69 /// the refresh was refused, or it was issued by a client the cloud no
70 /// longer publishes (SPEC_CLOUD_SETTINGS_DISCOVERY_2026_09_27.md §3.3).
71 /// The UI shows "Sign in again" with `email`.
72 pub needs_reauth: bool,
73 /// Whether cloud messages are reaching this channel's agents
74 /// (`muxbus::delivery_status`); live changes arrive as the
75 /// `muxbus:status` event. Always present from this srv.
76 #[serde(default, skip_serializing_if = "Option::is_none")]
77 #[ts(optional)]
78 pub delivery: Option<crate::backend::rpc_types::MuxBusDeliveryStatus>,
79}
80
81/// Empty request shapes for `muxbus.login.cancel`, `muxbus.status` and
82/// `muxbus.disconnect`. The handlers ignore their payload, but these must be
83/// structs rather than `()`: the stub calls all three with `{}`, and serde
84/// deserializes `()` only from JSON `null`, so a unit Req would reject every
85/// real call at runtime while compiling and passing every CI gate -- the
86/// `bookmarks.list` bug.
87#[derive(Debug, Default, Serialize, Deserialize, ts_rs::TS)]
88#[ts(export, export_to = "../../../frontend/types/rpc/")]
89pub struct MuxBusLoginCancelReq {}
90
91#[derive(Debug, Default, Serialize, Deserialize, ts_rs::TS)]
92#[ts(export, export_to = "../../../frontend/types/rpc/")]
93pub struct MuxBusStatusReq {}
94
95#[derive(Debug, Default, Serialize, Deserialize, ts_rs::TS)]
96#[ts(export, export_to = "../../../frontend/types/rpc/")]
97pub struct MuxBusDisconnectReq {}
98
99/// Result of `muxbus.disconnect`. Was an inline `json!({})`; the stub already
100/// typed it `Record<string, never>`, which is exactly what an empty struct
101/// generates.
102#[derive(Debug, Default, Serialize, Deserialize, ts_rs::TS)]
103#[ts(export, export_to = "../../../frontend/types/rpc/")]
104pub struct MuxBusDisconnectResp {}
105
106/// Empty request for `muxbus.cloudconfig`: a struct rather than `()` so the
107/// stub's `{}` deserializes, like the request shapes above.
108#[derive(Debug, Default, Serialize, Deserialize, ts_rs::TS)]
109#[ts(export, export_to = "../../../frontend/types/rpc/")]
110pub struct MuxBusCloudConfigReq {}
111
112/// Where `muxbus.cloudconfig`'s settings came from.
113#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)]
114#[ts(export, export_to = "../../../frontend/types/rpc/")]
115#[serde(rename_all = "lowercase")]
116pub enum MuxBusCloudConfigSource {
117 /// The relay's `/.well-known/agentmux-cloud.json` (the last good copy
118 /// when a later fetch failed).
119 Discovered,
120 /// No document: srv's compiled relay URLs, and no sign-in settings.
121 Default,
122}
123
124/// Result of `muxbus.cloudconfig`: the AgentMux Cloud settings srv resolved
125/// (SPEC_CLOUD_SETTINGS_DISCOVERY_2026_09_27.md §3.2). A build compiled
126/// without a Cognito client id asks this whether sign-in is possible anyway.
127#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)]
128#[ts(export, export_to = "../../../frontend/types/rpc/")]
129#[serde(rename_all = "camelCase")]
130pub struct MuxBusCloudConfigResp {
131 pub source: MuxBusCloudConfigSource,
132 /// The relay REST base srv uses. Always `relay::rest_base_url()`, the
133 /// root of trust the document is fetched from, whatever the document says.
134 pub api: String,
135 /// The relay WebSocket URL the cloud subscriber connects to.
136 pub ws: String,
137 /// The Cloud Console URL; empty when none is published.
138 pub console: String,
139 /// The Cognito hosted-UI domain and desktop client id `muxbus.login`
140 /// prefers over the ones in its request. Empty with `source: "default"`:
141 /// srv has no compiled sign-in settings, only the frontend build does.
142 pub cognito_domain: String,
143 pub client_id: String,
144 pub region: String,
145 pub user_pool_id: String,
146}
147
148/// What `muxbus.cloudconfig` reports for a discovery result.
149fn cloud_config(discovered: Option<crate::muxbus::discovery::CloudSettings>, rest_base: String) -> MuxBusCloudConfigResp {
150 match discovered {
151 Some(s) => MuxBusCloudConfigResp {
152 source: MuxBusCloudConfigSource::Discovered,
153 api: rest_base,
154 ws: s.ws,
155 console: s.console,
156 cognito_domain: s.cognito.domain,
157 client_id: s.cognito.client_id,
158 region: s.cognito.region,
159 user_pool_id: s.cognito.user_pool_id,
160 },
161 None => MuxBusCloudConfigResp {
162 source: MuxBusCloudConfigSource::Default,
163 api: rest_base,
164 ws: crate::muxbus::cloud_subscriber::MUXBUS_WS_URL.to_string(),
165 console: String::new(),
166 cognito_domain: String::new(),
167 client_id: String::new(),
168 region: String::new(),
169 user_pool_id: String::new(),
170 },
171 }
172}
173
174pub fn register_muxbus_handlers(engine: &Arc<WshRpcEngine>, state: &AppState) {
175 // muxbus.login — PKCE browser flow, returns when browser login completes
176 let mstore_login = state.id_store.clone();
177 let http_client_login = state.http_client.clone();
178 engine.register_typed(
179 COMMAND_MUXBUS_LOGIN,
180 move |req: MuxBusLoginReq, _ctx| {
181 let mstore = mstore_login.clone();
182 let http = http_client_login.clone();
183 async move {
184 // The cloud's published sign-in settings win over the build's
185 // compiled ones in the request, so a new user pool reaches
186 // installed builds (SPEC_CLOUD_SETTINGS_DISCOVERY_2026_09_27.md §3.2).
187 let discovered = crate::muxbus::discovery::cloud_settings(&http).await;
188 let (cognito_domain, client_id) = match crate::muxbus::discovery::resolve_login(
189 discovered.as_ref(),
190 &req.cognito_domain,
191 &req.client_id,
192 ) {
193 Ok(pair) => pair,
194 // Same shape as a failed PKCE flow, so the UI shows it the same way.
195 Err(e) => {
196 return Ok(MuxBusLoginResp { success: false, email: String::new(), error: Some(e) });
197 }
198 };
199
200 match crate::muxbus::pkce::run_pkce_login(
201 &cognito_domain,
202 &client_id,
203 &http,
204 )
205 .await
206 {
207 Ok(result) => {
208 // reagent P1: previously this only logged a warning on a
209 // save failure and still reported success — now that
210 // MuxBus tokens live in the OS keychain (which can
211 // genuinely fail: locked, no Secret Service daemon on
212 // headless Linux, permission denied), that meant the UI
213 // could show "logged in" for a credential that was never
214 // actually persisted anywhere. Report the real outcome.
215 // spawn_blocking — reagent P1 on #2260: muxbus_save
216 // does a synchronous OS-keychain write, which can
217 // hang on a slow/unresponsive Secret Service D-Bus
218 // daemon (headless Linux) and must not stall this
219 // tokio worker thread.
220 let email = result.credentials.user_email.clone();
221 let save_store = mstore.clone();
222 let save_result = tokio::task::spawn_blocking(move || {
223 save_store.muxbus_save(&result.credentials)
224 })
225 .await
226 .map_err(|e| format!("muxbus.login: save task: {e}"))?;
227 if let Err(e) = save_result {
228 tracing::warn!(error = %e, "muxbus.login: failed to save credentials");
229 let resp = MuxBusLoginResp {
230 success: false,
231 email: String::new(),
232 error: Some(format!("login succeeded but credentials couldn't be saved: {e}")),
233 };
234 return Ok(resp);
235 }
236 // Kick the cloud subscriber to open a WS with the new
237 // token. On an isolated local-package channel
238 // (reagentx P1 on PR #3248, round 2), boot skipped
239 // `CloudSubscriber::init_global` entirely, so there is
240 // no subscriber to reload yet — an explicit, successful
241 // login is exactly the signal that should lazily bring
242 // one up now, rather than leaving `muxbus.login` report
243 // "success" while no WebSocket ever opens and agents
244 // silently never receive WAN injections. `init_global`
245 // itself is idempotent (`OnceLock::set`, no-ops if
246 // already initialized), so this is safe to call
247 // unconditionally rather than re-deriving "was this
248 // isolated at boot" here.
249 match crate::muxbus::cloud_subscriber::get_global_subscriber() {
250 Some(sub) => sub.reload_token(),
251 None => {
252 crate::muxbus::cloud_subscriber::CloudSubscriber::init_global(
253 mstore.clone(),
254 );
255 }
256 }
257 // W3-S: a login may be an account switch — forget
258 // peer records fetched under the old account (§2.3),
259 // and publish any keys that were waiting on a login.
260 // A login to another relay or account is a directory
261 // that has never seen this install's keys; publications
262 // are recorded per directory, so the pass this nudge
263 // starts finds them all pending there.
264 crate::muxbus::delivery_status::fresh_sign_in(Some(email.clone()));
265 clear_wan_peer_cache();
266 crate::muxbus::wan_publish::nudge();
267 // And publish this install's presence now, whatever
268 // the presence publisher was waiting out.
269 crate::muxbus::wan_presence::sign_in_changed();
270 let resp = MuxBusLoginResp {
271 success: true,
272 email,
273 error: None,
274 };
275 Ok(resp)
276 }
277 Err(e) => {
278 tracing::warn!(error = %e, "muxbus.login: PKCE flow failed");
279 let resp = MuxBusLoginResp {
280 success: false,
281 email: String::new(),
282 error: Some(e),
283 };
284 Ok(resp)
285 }
286 }
287 }
288 },
289 );
290
291 // muxbus.login.cancel — abort an in-flight muxbus.login. The aborted
292 // flow's own task.await (in run_pkce_login) is what actually resolves
293 // the original muxbus.login RPC call with a "sign-in cancelled" error —
294 // this handler just fires the abort and returns immediately, it does
295 // not wait for that resolution.
296 engine.register_typed(
297 COMMAND_MUXBUS_LOGIN_CANCEL,
298 move |_req: MuxBusLoginCancelReq, _ctx| {
299 async move {
300 let cancelled = crate::muxbus::pkce::cancel_active_login();
301 let resp = MuxBusLoginCancelResp { cancelled };
302 Ok(resp)
303 }
304 },
305 );
306
307 // muxbus.status — return current credential state
308 let mstore_status = state.id_store.clone();
309 let http_client_status = state.http_client.clone();
310 engine.register_typed(
311 COMMAND_MUXBUS_STATUS,
312 move |_req: MuxBusStatusReq, _ctx| {
313 let mstore = mstore_status.clone();
314 let http = http_client_status.clone();
315 async move {
316 // reagentx P0 on PR #3248, round 2: `frontend/app/statusbar/
317 // HostPopover.tsx` mounts globally and polls this handler on
318 // mount + every 60s, unconditionally — so an unguarded
319 // `muxbus_load()` here hit Keychain within a minute of launch
320 // on a fresh local build regardless of the boot-time and
321 // agent-spawn fixes elsewhere in this PR, defeating the whole
322 // point of it. Gated on subscriber presence, not the static
323 // channel flag: on an isolated channel the subscriber only
324 // starts at an explicit `muxbus.login`, so presence is what
325 // says a session exists. Before any MuxBus session has ever been
326 // established on this process, report "not connected"
327 // without touching the keychain at all — identical to the
328 // real Ok(None) response below, just without the read.
329 if crate::muxbus::cloud_subscriber::get_global_subscriber().is_none() {
330 let resp = MuxBusStatusResp {
331 connected: false,
332 email: String::new(),
333 cognito_domain: String::new(),
334 expires_at: 0,
335 valid: false,
336 needs_reauth: false,
337 delivery: Some(crate::muxbus::delivery_status::status()),
338 };
339 return Ok(resp);
340 }
341 // spawn_blocking — reagent P1 on #2260: same
342 // synchronous-keychain-read concern as muxbus.login's save.
343 let load_store = mstore.clone();
344 let load_result = tokio::task::spawn_blocking(move || load_store.muxbus_load())
345 .await
346 .map_err(|e| format!("muxbus.status: load task: {e}"))?;
347 match load_result {
348 Ok(Some(creds)) => {
349 let valid = creds.is_valid();
350 let needs_reauth = crate::muxbus::stale_sign_in(&creds, &http).await.is_some();
351 let resp = MuxBusStatusResp {
352 connected: !creds.access_token.is_empty(),
353 email: creds.user_email,
354 cognito_domain: creds.cognito_domain,
355 expires_at: creds.expires_at,
356 valid,
357 needs_reauth,
358 delivery: Some(crate::muxbus::delivery_status::status()),
359 };
360 Ok(resp)
361 }
362 Ok(None) => {
363 let resp = MuxBusStatusResp {
364 connected: false,
365 email: String::new(),
366 cognito_domain: String::new(),
367 expires_at: 0,
368 valid: false,
369 needs_reauth: false,
370 delivery: Some(crate::muxbus::delivery_status::status()),
371 };
372 Ok(resp)
373 }
374 // The UI already reads a failed status as disconnected;
375 // answering that shape keeps it so, and still carries
376 // `delivery`, which is what says the sign-in is unreadable.
377 Err(e) => {
378 tracing::warn!(error = %e, "muxbus.status: stored sign-in unreadable");
379 Ok(MuxBusStatusResp {
380 connected: false,
381 email: String::new(),
382 cognito_domain: String::new(),
383 expires_at: 0,
384 valid: false,
385 needs_reauth: false,
386 delivery: Some(crate::muxbus::delivery_status::status()),
387 })
388 }
389 }
390 }
391 },
392 );
393
394 // muxbus.disconnect — clear credentials
395 let mstore_disconnect = state.id_store.clone();
396 engine.register_typed(
397 COMMAND_MUXBUS_DISCONNECT,
398 move |_req: MuxBusDisconnectReq, _ctx| {
399 let mstore = mstore_disconnect.clone();
400 async move {
401 // The presence goodbye goes first: it needs the sign-in
402 // being cleared. At most 2 s; nothing is published after it
403 // until `signed_out`, which runs whether or not the clear
404 // below works.
405 let said_goodbye = crate::muxbus::wan_presence::goodbye_before_sign_out().await;
406 // spawn_blocking — reagent P1 on #2260: muxbus_clear does a
407 // synchronous OS-keychain delete, same concern as every
408 // other muxbus call site in this module.
409 let cleared = tokio::task::spawn_blocking(move || mstore.muxbus_clear()).await;
410 crate::muxbus::wan_presence::signed_out(said_goodbye);
411 cleared
412 .map_err(|e| format!("muxbus.disconnect: task: {e}"))?
413 .map_err(|e| format!("muxbus.disconnect: {e}"))?;
414 clear_wan_peer_cache();
415 // A deliberate sign-out, not a lost sign-in: no notification,
416 // no agent notes. And close the relay connection, which would
417 // otherwise keep delivering on the old token until it ends.
418 crate::muxbus::delivery_status::signed_out();
419 if let Some(sub) = crate::muxbus::cloud_subscriber::get_global_subscriber() {
420 sub.reload_token();
421 }
422 Ok(MuxBusDisconnectResp {})
423 }
424 },
425 );
426
427 // muxbus.cloudconfig — the resolved cloud settings. No credential or
428 // keychain access; discovery itself is cached (`discovery::cloud_settings`).
429 let http_client_cloudconfig = state.http_client.clone();
430 engine.register_typed(
431 COMMAND_MUXBUS_CLOUDCONFIG,
432 move |_req: MuxBusCloudConfigReq, _ctx| {
433 let http = http_client_cloudconfig.clone();
434 async move {
435 let discovered = crate::muxbus::discovery::cloud_settings(&http).await;
436 Ok(cloud_config(discovered, crate::muxbus::relay::rest_base_url()))
437 }
438 },
439 );
440}
441
442/// W3-S (`SPEC_WAN_JEKT_VERIFICATION_2026_09_24.md` §2.3): cached peer
443/// records belong to the account they were fetched under. Best-effort — the
444/// records are self-certifying, so a stale one can't verify a forgery; this
445/// only keeps one account's directory from answering for another's.
446fn clear_wan_peer_cache() {
447 if let Some(wan) = crate::backend::storage::wan_identity::global() {
448 if let Err(e) = wan.peer_cache_clear() {
449 tracing::warn!(error = %e, "wan identity: could not clear the peer-record cache");
450 }
451 }
452}
453
454// Request-shape tests for the `muxbus.*` commands.
455//
456// These types were private to this file until now, so the frontend's inline
457// copies were hand-maintained against nothing.
458#[cfg(test)]
459mod req_shape_tests {
460 use super::*;
461 use serde_json::json;
462
463 // The stub sends camelCase keys; the Rust fields are snake_case and rely
464 // on `rename_all = "camelCase"`. If that attribute were ever dropped the
465 // struct would still compile and the binding would still generate -- it
466 // would just silently stop parsing real payloads. Pin it.
467 #[test]
468 fn login_accepts_the_camel_case_payload_the_stub_sends() {
469 let r: MuxBusLoginReq =
470 serde_json::from_value(json!({"cognitoDomain": "d", "clientId": "c"}))
471 .expect("muxbus.login must accept camelCase keys");
472 assert_eq!((r.cognito_domain.as_str(), r.client_id.as_str()), ("d", "c"));
473
474 assert!(
475 serde_json::from_value::<MuxBusLoginReq>(json!({"cognito_domain": "d", "client_id": "c"}))
476 .is_err(),
477 "snake_case keys are NOT the wire format here"
478 );
479 }
480
481 // All three payload-ignoring commands are called with `{}`. A unit Req
482 // would reject that (serde deserializes `()` only from null).
483 #[test]
484 fn the_payload_ignoring_commands_accept_the_empty_object() {
485 serde_json::from_value::<MuxBusLoginCancelReq>(json!({})).expect("login.cancel");
486 serde_json::from_value::<MuxBusStatusReq>(json!({})).expect("status");
487 serde_json::from_value::<MuxBusDisconnectReq>(json!({})).expect("disconnect");
488 serde_json::from_value::<MuxBusCloudConfigReq>(json!({})).expect("cloudconfig");
489 assert!(serde_json::from_value::<()>(json!({})).is_err());
490 }
491
492 // `error` is skip_serializing_if, so a successful login OMITS the key
493 // rather than sending null -- which is why the binding says `error?`.
494 #[test]
495 fn login_response_omits_error_on_success() {
496 let ok = serde_json::to_value(MuxBusLoginResp {
497 success: true,
498 email: "a@b.c".to_string(),
499 error: None,
500 })
501 .expect("serializable");
502 assert_eq!(ok, json!({"success": true, "email": "a@b.c"}));
503 }
504
505 // disconnect answered with an inline `json!({})`; the stub already typed it
506 // `Record<string, never>`, which is what an empty struct generates.
507 #[test]
508 fn disconnect_response_is_an_empty_object() {
509 assert_eq!(serde_json::to_value(MuxBusDisconnectResp {}).unwrap(), json!({}));
510 }
511
512 fn published() -> crate::muxbus::discovery::CloudSettings {
513 crate::muxbus::discovery::parse(
514 r#"{"version":1,"api":"https://api.example.test","ws":"wss://ws.example.test","console":"https://console.example.test",
515 "cognito":{"domain":"https://auth.example.test","clientId":"abc123","region":"us-east-1","userPoolId":"us-east-1_X"}}"#,
516 "https://relay.example.test",
517 )
518 .unwrap()
519 }
520
521 // SPEC_CLOUD_SETTINGS_DISCOVERY_2026_09_27.md §3.2: a published document
522 // supplies the sign-in settings and the WebSocket URL; REST stays srv's.
523 #[test]
524 fn cloudconfig_reports_a_published_document_as_discovered() {
525 let r = cloud_config(Some(published()), "https://relay.example.test".to_string());
526 assert_eq!(r.source, MuxBusCloudConfigSource::Discovered);
527 assert_eq!(r.api, "https://relay.example.test", "REST is never taken from the document");
528 assert_eq!(r.ws, "wss://ws.example.test");
529 assert_eq!(r.console, "https://console.example.test");
530 assert_eq!(r.cognito_domain, "https://auth.example.test");
531 assert_eq!(r.client_id, "abc123");
532 assert_eq!(r.region, "us-east-1");
533 assert_eq!(r.user_pool_id, "us-east-1_X");
534 }
535
536 // No document (an older relay, or discovery failed with no earlier copy):
537 // srv's compiled relay URLs, and no client id, so a build without its
538 // own can't offer sign-in.
539 #[test]
540 fn cloudconfig_without_a_document_reports_the_defaults() {
541 let r = cloud_config(None, "https://relay.example.test".to_string());
542 assert_eq!(r.source, MuxBusCloudConfigSource::Default);
543 assert_eq!(r.api, "https://relay.example.test");
544 assert_eq!(r.ws, crate::muxbus::cloud_subscriber::MUXBUS_WS_URL);
545 assert!(r.client_id.is_empty() && r.cognito_domain.is_empty() && r.console.is_empty());
546 }
547
548 // The wire shape the frontend reads: camelCase keys, lowercase source.
549 #[test]
550 fn cloudconfig_serializes_camel_case_with_a_lowercase_source() {
551 let v = serde_json::to_value(cloud_config(Some(published()), "https://relay.example.test".to_string())).unwrap();
552 assert_eq!(v["source"], "discovered");
553 assert_eq!(v["clientId"], "abc123");
554 assert_eq!(v["cognitoDomain"], "https://auth.example.test");
555 assert_eq!(v["userPoolId"], "us-east-1_X");
556 let v = serde_json::to_value(cloud_config(None, String::new())).unwrap();
557 assert_eq!(v["source"], "default");
558 }
559}