返回 CodeWhale
remote.rs
根目录 / crates / tui / src / tui / setup / remote.rs
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
611 lines RUST