| 1 | //! Remote runtime setup step (#3409). |
| 2 | //! |
| 3 | //! The step answers one question — *where can this agent be reached from?* — |
| 4 | //! with four modes and no aspiration: |
| 5 | //! |
| 6 | //! | mode | what it actually is | |
| 7 | //! |------|---------------------| |
| 8 | //! | [`RemoteMode::LocalOnly`] | the default: this machine, nothing exposed | |
| 9 | //! | [`RemoteMode::RuntimeApi`] | `codewhale serve --http` on loopback, token-authenticated | |
| 10 | //! | [`RemoteMode::MobileLan`] | the same runtime reachable from a phone on the LAN | |
| 11 | //! | [`RemoteMode::ChatBridge`] | a chat bridge (Telegram, Feishu/Lark) in front of the runtime | |
| 12 | //! |
| 13 | //! Every status is derived from a fact this process can actually observe — |
| 14 | //! whether a credential *name* is set in the environment, what the shipped |
| 15 | //! runtime unit binds to, and what the bridge registry declares. Two rules are |
| 16 | //! absolute and are pinned by tests: |
| 17 | //! |
| 18 | //! 1. **Secret values are never read and never rendered.** Only the *name* of |
| 19 | //! an environment variable and whether it is set are used. |
| 20 | //! 2. **Nothing is written, applied, or provisioned.** The plan preview is |
| 21 | //! rendered in memory through the existing `remote_setup::bundle` contract |
| 22 | //! with redacted placeholders. |
| 23 | |
| 24 | use crate::localization::{Locale, MessageId, tr}; |
| 25 | use crate::remote_setup::{bundle, registry}; |
| 26 | use crate::tui::app::App; |
| 27 | |
| 28 | /// Env var carrying the runtime API bearer token, plus the legacy alias the |
| 29 | /// runtime still honors. Only presence is ever inspected. |
| 30 | const RUNTIME_TOKEN_VARS: [&str; 2] = ["CODEWHALE_RUNTIME_TOKEN", "DEEPSEEK_RUNTIME_TOKEN"]; |
| 31 | |
| 32 | /// Env var a user sets to bind the runtime somewhere other than loopback. The |
| 33 | /// shipped systemd unit hardcodes `127.0.0.1`, so without this the LAN/mobile |
| 34 | /// mode is genuinely unavailable rather than merely unconfigured. |
| 35 | const RUNTIME_HOST_VARS: [&str; 2] = ["CODEWHALE_RUNTIME_HOST", "DEEPSEEK_RUNTIME_HOST"]; |
| 36 | |
| 37 | /// Placeholder substituted for every secret in the generated plan preview. |
| 38 | const REDACTED: &str = "<redacted>"; |
| 39 | |
| 40 | /// The four ways a Codewhale runtime can be reached. |
| 41 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 42 | pub(super) enum RemoteMode { |
| 43 | LocalOnly, |
| 44 | RuntimeApi, |
| 45 | MobileLan, |
| 46 | ChatBridge, |
| 47 | } |
| 48 | |
| 49 | impl RemoteMode { |
| 50 | /// Only the locale-parity test needs to walk every mode; the wizard renders |
| 51 | /// whatever [`observe_modes`] actually produced. |
| 52 | #[cfg(test)] |
| 53 | pub(super) const ALL: [RemoteMode; 4] = [ |
| 54 | RemoteMode::LocalOnly, |
| 55 | RemoteMode::RuntimeApi, |
| 56 | RemoteMode::MobileLan, |
| 57 | RemoteMode::ChatBridge, |
| 58 | ]; |
| 59 | |
| 60 | /// Stable id used in the persisted step result and in tests. |
| 61 | pub(super) const fn id(self) -> &'static str { |
| 62 | match self { |
| 63 | RemoteMode::LocalOnly => "local_only", |
| 64 | RemoteMode::RuntimeApi => "runtime_api", |
| 65 | RemoteMode::MobileLan => "mobile_lan", |
| 66 | RemoteMode::ChatBridge => "chat_bridge", |
| 67 | } |
| 68 | } |
| 69 | |
| 70 | pub(super) const fn label_id(self) -> MessageId { |
| 71 | match self { |
| 72 | RemoteMode::LocalOnly => MessageId::SetupRemoteModeLocalOnly, |
| 73 | RemoteMode::RuntimeApi => MessageId::SetupRemoteModeRuntimeApi, |
| 74 | RemoteMode::MobileLan => MessageId::SetupRemoteModeMobileLan, |
| 75 | RemoteMode::ChatBridge => MessageId::SetupRemoteModeChatBridge, |
| 76 | } |
| 77 | } |
| 78 | } |
| 79 | |
| 80 | /// What the user can do with a mode *right now*. |
| 81 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 82 | pub(super) enum RemoteModeStatus { |
| 83 | /// Not available on this install; no user action would change it here. |
| 84 | Disabled, |
| 85 | /// Usable as configured. |
| 86 | Ready, |
| 87 | /// Available, but something is missing. Never blocks `ready`. |
| 88 | NeedsAction, |
| 89 | } |
| 90 | |
| 91 | impl RemoteModeStatus { |
| 92 | pub(super) const fn id(self) -> &'static str { |
| 93 | match self { |
| 94 | RemoteModeStatus::Disabled => "disabled", |
| 95 | RemoteModeStatus::Ready => "ready", |
| 96 | RemoteModeStatus::NeedsAction => "needs_action", |
| 97 | } |
| 98 | } |
| 99 | |
| 100 | pub(super) const fn label_id(self) -> MessageId { |
| 101 | match self { |
| 102 | RemoteModeStatus::Disabled => MessageId::SetupRemoteStatusDisabled, |
| 103 | RemoteModeStatus::Ready => MessageId::SetupRemoteStatusReady, |
| 104 | RemoteModeStatus::NeedsAction => MessageId::SetupRemoteStatusNeedsAction, |
| 105 | } |
| 106 | } |
| 107 | } |
| 108 | |
| 109 | /// One observed mode. `detail` is a short, secret-free English fact used in the |
| 110 | /// persisted step result and the preview; the localized label is composed at |
| 111 | /// render time from `mode` and `status`. |
| 112 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 113 | pub(super) struct RemoteModeFact { |
| 114 | pub(super) mode: RemoteMode, |
| 115 | pub(super) status: RemoteModeStatus, |
| 116 | pub(super) detail: String, |
| 117 | } |
| 118 | |
| 119 | /// Whether any of `vars` is set to a non-empty value. **Only presence is |
| 120 | /// observed** — the value is never bound to a name, logged, or returned. |
| 121 | fn any_var_present(vars: &[&str], lookup: &dyn Fn(&str) -> Option<String>) -> bool { |
| 122 | vars.iter() |
| 123 | .any(|name| lookup(name).is_some_and(|value| !value.trim().is_empty())) |
| 124 | } |
| 125 | |
| 126 | /// The first var in `vars` that is set, by name only. Used so the UI can say |
| 127 | /// *which* variable satisfied the check without revealing what it holds. |
| 128 | fn present_var_name( |
| 129 | vars: &[&'static str], |
| 130 | lookup: &dyn Fn(&str) -> Option<String>, |
| 131 | ) -> Option<&'static str> { |
| 132 | vars.iter() |
| 133 | .copied() |
| 134 | .find(|name| lookup(name).is_some_and(|value| !value.trim().is_empty())) |
| 135 | } |
| 136 | |
| 137 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 138 | pub(super) struct SetupRemoteFacts { |
| 139 | pub(super) modes: Vec<RemoteModeFact>, |
| 140 | pub(super) clouds_result: String, |
| 141 | pub(super) bridges_result: String, |
| 142 | pub(super) providers_result: String, |
| 143 | pub(super) mode_result: String, |
| 144 | pub(super) command_provider: String, |
| 145 | pub(super) result: String, |
| 146 | } |
| 147 | |
| 148 | impl SetupRemoteFacts { |
| 149 | pub(super) fn from_app(app: &App) -> Self { |
| 150 | Self::from_app_with_env(app, &|name| std::env::var(name).ok()) |
| 151 | } |
| 152 | |
| 153 | /// Test seam: the env lookup is injected so the hostile-token and |
| 154 | /// no-leak cases can be exercised without mutating the process |
| 155 | /// environment. |
| 156 | pub(super) fn from_app_with_env(app: &App, lookup: &dyn Fn(&str) -> Option<String>) -> Self { |
| 157 | let cloud_slugs = registry::CLOUD_TARGETS |
| 158 | .iter() |
| 159 | .map(|cloud| cloud.slug) |
| 160 | .collect::<Vec<_>>(); |
| 161 | let bridge_slugs = registry::BRIDGES |
| 162 | .iter() |
| 163 | .map(|bridge| bridge.slug) |
| 164 | .collect::<Vec<_>>(); |
| 165 | let provider_count = codewhale_config::ProviderKind::all().len(); |
| 166 | // Keep the exact route identity. Named custom routes are not yet |
| 167 | // representable by the remote bundle registry, so the generated CLI |
| 168 | // command must fail explicitly for that name instead of silently |
| 169 | // substituting DeepSeek's endpoint and credential contract. |
| 170 | let command_provider = app.provider_identity_for_persistence().to_string(); |
| 171 | |
| 172 | let modes = observe_modes(lookup); |
| 173 | let mode_result = modes |
| 174 | .iter() |
| 175 | .map(|fact| format!("{}={}", fact.mode.id(), fact.status.id())) |
| 176 | .collect::<Vec<_>>() |
| 177 | .join(", "); |
| 178 | |
| 179 | Self { |
| 180 | clouds_result: format!( |
| 181 | "{} cloud targets: {}", |
| 182 | cloud_slugs.len(), |
| 183 | cloud_slugs.join(", ") |
| 184 | ), |
| 185 | bridges_result: format!( |
| 186 | "{} chat bridges: {}", |
| 187 | bridge_slugs.len(), |
| 188 | bridge_slugs.join(", ") |
| 189 | ), |
| 190 | providers_result: format!( |
| 191 | "{provider_count} providers from the provider registry; active route {} / {}", |
| 192 | app.provider_identity_for_persistence(), |
| 193 | app.model |
| 194 | ), |
| 195 | result: format!("{mode_result}; plan=generate_only, apply=not_implemented"), |
| 196 | mode_result, |
| 197 | command_provider, |
| 198 | modes, |
| 199 | } |
| 200 | } |
| 201 | |
| 202 | /// The mode the step defaults to. Local-only is always the default and is |
| 203 | /// always skippable in one key — a user who never wants remote access is |
| 204 | /// done here immediately. Asserted by test rather than branched on: the |
| 205 | /// wizard's Enter path is unconditional precisely because of this. |
| 206 | #[cfg(test)] |
| 207 | pub(super) fn default_mode(&self) -> RemoteMode { |
| 208 | RemoteMode::LocalOnly |
| 209 | } |
| 210 | |
| 211 | #[cfg(test)] |
| 212 | pub(super) fn status_for(&self, mode: RemoteMode) -> RemoteModeStatus { |
| 213 | self.modes |
| 214 | .iter() |
| 215 | .find(|fact| fact.mode == mode) |
| 216 | .map_or(RemoteModeStatus::NeedsAction, |fact| fact.status) |
| 217 | } |
| 218 | |
| 219 | /// True when something is missing but nothing is broken. Callers record |
| 220 | /// `NeedsAction` — which by contract never blocks the ready screen. |
| 221 | pub(super) fn needs_action(&self) -> bool { |
| 222 | self.modes |
| 223 | .iter() |
| 224 | .any(|fact| fact.status == RemoteModeStatus::NeedsAction) |
| 225 | } |
| 226 | } |
| 227 | |
| 228 | /// Derive all four mode facts from observable state. |
| 229 | fn observe_modes(lookup: &dyn Fn(&str) -> Option<String>) -> Vec<RemoteModeFact> { |
| 230 | let runtime_token = present_var_name(&RUNTIME_TOKEN_VARS, lookup); |
| 231 | let lan_host = present_var_name(&RUNTIME_HOST_VARS, lookup); |
| 232 | |
| 233 | let runtime_api = match runtime_token { |
| 234 | Some(name) => RemoteModeFact { |
| 235 | mode: RemoteMode::RuntimeApi, |
| 236 | status: RemoteModeStatus::Ready, |
| 237 | detail: format!( |
| 238 | "{name} is set; runtime serves 127.0.0.1:{} with token auth", |
| 239 | bundle::DEFAULT_PORT |
| 240 | ), |
| 241 | }, |
| 242 | None => RemoteModeFact { |
| 243 | mode: RemoteMode::RuntimeApi, |
| 244 | status: RemoteModeStatus::NeedsAction, |
| 245 | detail: format!( |
| 246 | "no runtime token set ({}); the runtime would refuse authenticated calls", |
| 247 | RUNTIME_TOKEN_VARS[0] |
| 248 | ), |
| 249 | }, |
| 250 | }; |
| 251 | |
| 252 | // The shipped systemd unit binds loopback. Without an explicit host |
| 253 | // override there is nothing for a phone to reach, and saying "not |
| 254 | // configured" would imply a switch the user could flip here. |
| 255 | let mobile_lan = match lan_host { |
| 256 | None => RemoteModeFact { |
| 257 | mode: RemoteMode::MobileLan, |
| 258 | status: RemoteModeStatus::Disabled, |
| 259 | detail: format!( |
| 260 | "runtime binds 127.0.0.1 only; set {} to expose it on a LAN", |
| 261 | RUNTIME_HOST_VARS[0] |
| 262 | ), |
| 263 | }, |
| 264 | Some(name) => { |
| 265 | // A bound LAN address with no token is reachable *and* |
| 266 | // unauthenticated, so it is the one case that must ask for action. |
| 267 | if runtime_token.is_some() { |
| 268 | RemoteModeFact { |
| 269 | mode: RemoteMode::MobileLan, |
| 270 | status: RemoteModeStatus::Ready, |
| 271 | detail: format!("{name} is set and the runtime token is present"), |
| 272 | } |
| 273 | } else { |
| 274 | RemoteModeFact { |
| 275 | mode: RemoteMode::MobileLan, |
| 276 | status: RemoteModeStatus::NeedsAction, |
| 277 | detail: format!( |
| 278 | "{name} is set but no runtime token is; add {} before exposing a LAN port", |
| 279 | RUNTIME_TOKEN_VARS[0] |
| 280 | ), |
| 281 | } |
| 282 | } |
| 283 | } |
| 284 | }; |
| 285 | |
| 286 | let ready_bridges = registry::BRIDGES |
| 287 | .iter() |
| 288 | .filter(|bridge| { |
| 289 | bridge |
| 290 | .secret_keys |
| 291 | .iter() |
| 292 | .all(|key| any_var_present(&[key], lookup)) |
| 293 | }) |
| 294 | .map(|bridge| bridge.slug) |
| 295 | .collect::<Vec<_>>(); |
| 296 | let chat_bridge = if ready_bridges.is_empty() { |
| 297 | RemoteModeFact { |
| 298 | mode: RemoteMode::ChatBridge, |
| 299 | status: RemoteModeStatus::NeedsAction, |
| 300 | detail: format!( |
| 301 | "{} bridges available ({}); none has its credentials set yet", |
| 302 | registry::BRIDGES.len(), |
| 303 | registry::BRIDGES |
| 304 | .iter() |
| 305 | .map(|bridge| bridge.slug) |
| 306 | .collect::<Vec<_>>() |
| 307 | .join(", ") |
| 308 | ), |
| 309 | } |
| 310 | } else { |
| 311 | RemoteModeFact { |
| 312 | mode: RemoteMode::ChatBridge, |
| 313 | status: RemoteModeStatus::Ready, |
| 314 | detail: format!("credentials present for: {}", ready_bridges.join(", ")), |
| 315 | } |
| 316 | }; |
| 317 | |
| 318 | vec![ |
| 319 | RemoteModeFact { |
| 320 | mode: RemoteMode::LocalOnly, |
| 321 | status: RemoteModeStatus::Ready, |
| 322 | detail: "this machine only; nothing is exposed and nothing to configure".to_string(), |
| 323 | }, |
| 324 | runtime_api, |
| 325 | mobile_lan, |
| 326 | chat_bridge, |
| 327 | ] |
| 328 | } |
| 329 | |
| 330 | /// Render the plan preview **in memory**, with every secret replaced by |
| 331 | /// [`REDACTED`]. |
| 332 | /// |
| 333 | /// This deliberately goes through the same `remote_setup::bundle` planning |
| 334 | /// contract the CLI uses, so the preview cannot drift from what |
| 335 | /// `codewhale remote-setup --generate-only` would produce. It calls |
| 336 | /// [`bundle::render_bundle`] (a pure function) and never |
| 337 | /// [`bundle::write_bundle`]: no file is created, no command is run, and no |
| 338 | /// cloud resource is provisioned. |
| 339 | pub(super) fn redacted_plan_preview(command_provider: &str) -> String { |
| 340 | let Some(cloud) = registry::cloud_by_slug("lighthouse") else { |
| 341 | return "No cloud target is registered; nothing to preview.".to_string(); |
| 342 | }; |
| 343 | let Some(bridge) = registry::bridge_by_slug("telegram") else { |
| 344 | return "No chat bridge is registered; nothing to preview.".to_string(); |
| 345 | }; |
| 346 | let Some(provider) = bundle::ProviderInfo::from_slug(command_provider) else { |
| 347 | return format!( |
| 348 | "Route \"{command_provider}\" is not representable as a remote bundle provider yet, \ |
| 349 | so no plan can be generated for it." |
| 350 | ); |
| 351 | }; |
| 352 | |
| 353 | let inputs = bundle::BundleInputs { |
| 354 | cloud, |
| 355 | bridge, |
| 356 | provider, |
| 357 | model: "auto".to_string(), |
| 358 | // Every secret slot is a constant placeholder. No token is generated, |
| 359 | // no environment value is read, and nothing here is usable. |
| 360 | runtime_token: REDACTED.to_string(), |
| 361 | provider_key_value: REDACTED.to_string(), |
| 362 | bridge_secret_values: bridge |
| 363 | .secret_keys |
| 364 | .iter() |
| 365 | .map(|key| ((*key).to_string(), REDACTED.to_string())) |
| 366 | .collect(), |
| 367 | allowlist: String::new(), |
| 368 | port: bundle::DEFAULT_PORT, |
| 369 | workers: bundle::DEFAULT_WORKERS, |
| 370 | workspace: "<your workspace>".to_string(), |
| 371 | }; |
| 372 | |
| 373 | let mut out = String::from( |
| 374 | "Preview only. Nothing below has been written, applied, or provisioned.\n\ |
| 375 | Every secret is shown as <redacted>; Codewhale never reads their values here.\n\n", |
| 376 | ); |
| 377 | for file in bundle::render_bundle(&inputs) { |
| 378 | out.push_str(&format!("── {} ──\n", file.relative_path)); |
| 379 | out.push_str(&file.contents); |
| 380 | if !file.contents.ends_with('\n') { |
| 381 | out.push('\n'); |
| 382 | } |
| 383 | out.push('\n'); |
| 384 | } |
| 385 | out |
| 386 | } |
| 387 | |
| 388 | pub(super) fn on_ramp_text( |
| 389 | locale: Locale, |
| 390 | clouds_result: &str, |
| 391 | bridges_result: &str, |
| 392 | providers_result: &str, |
| 393 | mode_result: &str, |
| 394 | command_provider: &str, |
| 395 | ) -> String { |
| 396 | let command = format!( |
| 397 | "codewhale remote-setup --generate-only --cloud lighthouse --bridge telegram --provider {command_provider} --out ./codewhale-deploy/lighthouse-telegram" |
| 398 | ); |
| 399 | let base = tr(locale, MessageId::SetupRemoteOnRampText); |
| 400 | let mut out = base |
| 401 | .replace("{clouds_result}", clouds_result) |
| 402 | .replace("{bridges_result}", bridges_result) |
| 403 | .replace("{providers_result}", providers_result) |
| 404 | .replace("{mode_result}", mode_result) |
| 405 | .replace("{command}", &command); |
| 406 | out.push_str("\n\n"); |
| 407 | out.push_str(&redacted_plan_preview(command_provider)); |
| 408 | out |
| 409 | } |
| 410 | |
| 411 | #[cfg(test)] |
| 412 | mod tests { |
| 413 | use super::*; |
| 414 | use std::collections::HashMap; |
| 415 | |
| 416 | fn env(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> { |
| 417 | let map = pairs |
| 418 | .iter() |
| 419 | .map(|(k, v)| ((*k).to_string(), (*v).to_string())) |
| 420 | .collect::<HashMap<_, _>>(); |
| 421 | move |name: &str| map.get(name).cloned() |
| 422 | } |
| 423 | |
| 424 | #[test] |
| 425 | fn local_only_is_always_ready_and_needs_no_configuration() { |
| 426 | let modes = observe_modes(&env(&[])); |
| 427 | let local = modes |
| 428 | .iter() |
| 429 | .find(|fact| fact.mode == RemoteMode::LocalOnly) |
| 430 | .expect("local-only mode"); |
| 431 | assert_eq!(local.status, RemoteModeStatus::Ready); |
| 432 | assert!(local.detail.contains("nothing to configure")); |
| 433 | } |
| 434 | |
| 435 | #[test] |
| 436 | fn a_bare_install_reports_the_honest_mode_matrix() { |
| 437 | let modes = observe_modes(&env(&[])); |
| 438 | let by_mode = |mode| { |
| 439 | modes |
| 440 | .iter() |
| 441 | .find(|fact| fact.mode == mode) |
| 442 | .expect("mode") |
| 443 | .status |
| 444 | }; |
| 445 | |
| 446 | assert_eq!(by_mode(RemoteMode::LocalOnly), RemoteModeStatus::Ready); |
| 447 | // Missing token/config is NeedsAction, never Failed and never blocking. |
| 448 | assert_eq!( |
| 449 | by_mode(RemoteMode::RuntimeApi), |
| 450 | RemoteModeStatus::NeedsAction |
| 451 | ); |
| 452 | // Nothing the user does on this screen can expose a loopback bind. |
| 453 | assert_eq!(by_mode(RemoteMode::MobileLan), RemoteModeStatus::Disabled); |
| 454 | assert_eq!( |
| 455 | by_mode(RemoteMode::ChatBridge), |
| 456 | RemoteModeStatus::NeedsAction |
| 457 | ); |
| 458 | } |
| 459 | |
| 460 | #[test] |
| 461 | fn a_lan_bind_without_a_token_asks_for_action_rather_than_claiming_ready() { |
| 462 | let modes = observe_modes(&env(&[("CODEWHALE_RUNTIME_HOST", "0.0.0.0")])); |
| 463 | let lan = modes |
| 464 | .iter() |
| 465 | .find(|fact| fact.mode == RemoteMode::MobileLan) |
| 466 | .expect("mobile mode"); |
| 467 | assert_eq!(lan.status, RemoteModeStatus::NeedsAction); |
| 468 | |
| 469 | let both = observe_modes(&env(&[ |
| 470 | ("CODEWHALE_RUNTIME_HOST", "0.0.0.0"), |
| 471 | ("CODEWHALE_RUNTIME_TOKEN", "t-secret-value"), |
| 472 | ])); |
| 473 | assert_eq!( |
| 474 | both.iter() |
| 475 | .find(|fact| fact.mode == RemoteMode::MobileLan) |
| 476 | .expect("mobile mode") |
| 477 | .status, |
| 478 | RemoteModeStatus::Ready |
| 479 | ); |
| 480 | } |
| 481 | |
| 482 | #[test] |
| 483 | fn bridge_credentials_flip_the_bridge_mode_to_ready() { |
| 484 | let modes = observe_modes(&env(&[("TELEGRAM_BOT_TOKEN", "12345:AAhostile")])); |
| 485 | let bridge = modes |
| 486 | .iter() |
| 487 | .find(|fact| fact.mode == RemoteMode::ChatBridge) |
| 488 | .expect("bridge mode"); |
| 489 | assert_eq!(bridge.status, RemoteModeStatus::Ready); |
| 490 | assert!(bridge.detail.contains("telegram")); |
| 491 | } |
| 492 | |
| 493 | /// The single most important property of this step: it inspects |
| 494 | /// credentials without ever surfacing one. |
| 495 | #[test] |
| 496 | fn hostile_secret_values_never_reach_any_rendered_fact() { |
| 497 | let hostile_token = "t-\u{202e}AKIAIOSFODNN7EXAMPLE/../../etc/passwd"; |
| 498 | let hostile_bot = "999:AAsuper-secret-bot-token"; |
| 499 | let modes = observe_modes(&env(&[ |
| 500 | ("CODEWHALE_RUNTIME_TOKEN", hostile_token), |
| 501 | ("CODEWHALE_RUNTIME_HOST", "10.0.0.5"), |
| 502 | ("TELEGRAM_BOT_TOKEN", hostile_bot), |
| 503 | ("FEISHU_APP_ID", "cli_hostile"), |
| 504 | ("FEISHU_APP_SECRET", "shh"), |
| 505 | ])); |
| 506 | |
| 507 | let rendered = modes |
| 508 | .iter() |
| 509 | .map(|fact| fact.detail.clone()) |
| 510 | .collect::<Vec<_>>() |
| 511 | .join("\n"); |
| 512 | for secret in [hostile_token, hostile_bot, "cli_hostile", "shh", "AKIA"] { |
| 513 | assert!( |
| 514 | !rendered.contains(secret), |
| 515 | "secret value leaked into the step facts: {rendered}" |
| 516 | ); |
| 517 | } |
| 518 | // Names are fine and are what makes the status explainable. |
| 519 | assert!(rendered.contains("CODEWHALE_RUNTIME_TOKEN")); |
| 520 | // A LAN address is host configuration the user typed, not a secret, |
| 521 | // but it is still not echoed back. |
| 522 | assert!(!rendered.contains("10.0.0.5")); |
| 523 | } |
| 524 | |
| 525 | #[test] |
| 526 | fn plan_preview_is_generated_in_memory_and_fully_redacted() { |
| 527 | let preview = redacted_plan_preview("deepseek"); |
| 528 | |
| 529 | assert!(preview.contains("Nothing below has been written")); |
| 530 | assert!(preview.contains("RUNBOOK.md")); |
| 531 | assert!(preview.contains("runtime.env")); |
| 532 | assert!( |
| 533 | preview.contains(REDACTED), |
| 534 | "secrets must render as the redaction placeholder" |
| 535 | ); |
| 536 | // The generated token slot must be the placeholder, not a real token. |
| 537 | assert!(preview.contains(&format!("CODEWHALE_RUNTIME_TOKEN={REDACTED}"))); |
| 538 | } |
| 539 | |
| 540 | #[test] |
| 541 | fn plan_preview_writes_nothing_to_disk() { |
| 542 | let tmp = tempfile::tempdir().expect("tempdir"); |
| 543 | let before = std::fs::read_dir(tmp.path()).expect("read tempdir").count(); |
| 544 | |
| 545 | let _ = redacted_plan_preview("deepseek"); |
| 546 | |
| 547 | let after = std::fs::read_dir(tmp.path()).expect("read tempdir").count(); |
| 548 | assert_eq!(before, after, "the preview must not create any file"); |
| 549 | assert!(!std::path::Path::new("./codewhale-deploy").exists()); |
| 550 | } |
| 551 | |
| 552 | #[test] |
| 553 | fn an_unrepresentable_route_says_so_instead_of_substituting_a_provider() { |
| 554 | let preview = redacted_plan_preview("my-private-gateway"); |
| 555 | assert!(preview.contains("not representable")); |
| 556 | assert!( |
| 557 | !preview.contains("DEEPSEEK_API_KEY"), |
| 558 | "must not silently substitute another provider's credential contract" |
| 559 | ); |
| 560 | } |
| 561 | |
| 562 | /// A user who wants none of this must be able to leave in one key: the |
| 563 | /// default mode is local-only and local-only is always usable. |
| 564 | #[test] |
| 565 | fn the_default_mode_is_local_only_and_is_never_blocking() { |
| 566 | let facts = SetupRemoteFacts { |
| 567 | modes: observe_modes(&env(&[])), |
| 568 | clouds_result: String::new(), |
| 569 | bridges_result: String::new(), |
| 570 | providers_result: String::new(), |
| 571 | mode_result: String::new(), |
| 572 | command_provider: "deepseek".to_string(), |
| 573 | result: String::new(), |
| 574 | }; |
| 575 | |
| 576 | assert_eq!(facts.default_mode(), RemoteMode::LocalOnly); |
| 577 | assert_eq!( |
| 578 | facts.status_for(RemoteMode::LocalOnly), |
| 579 | RemoteModeStatus::Ready |
| 580 | ); |
| 581 | // Missing tokens/config are surfaced, but only ever as NeedsAction. |
| 582 | assert!(facts.needs_action()); |
| 583 | assert!( |
| 584 | facts |
| 585 | .modes |
| 586 | .iter() |
| 587 | .all(|fact| fact.status != RemoteModeStatus::Disabled |
| 588 | || fact.mode == RemoteMode::MobileLan) |
| 589 | ); |
| 590 | } |
| 591 | |
| 592 | #[test] |
| 593 | fn mode_rows_render_in_every_complete_pack_without_placeholders() { |
| 594 | for locale in Locale::shipped_complete() { |
| 595 | for mode in RemoteMode::ALL { |
| 596 | let label = tr(*locale, mode.label_id()); |
| 597 | assert!(!label.is_empty(), "{locale:?} {mode:?}"); |
| 598 | assert!(!label.contains('{'), "{locale:?} {mode:?}: {label}"); |
| 599 | } |
| 600 | for status in [ |
| 601 | RemoteModeStatus::Disabled, |
| 602 | RemoteModeStatus::Ready, |
| 603 | RemoteModeStatus::NeedsAction, |
| 604 | ] { |
| 605 | let label = tr(*locale, status.label_id()); |
| 606 | assert!(!label.is_empty(), "{locale:?} {status:?}"); |
| 607 | } |
| 608 | } |
| 609 | } |
| 610 | } |
| 611 |