1use std::collections::BTreeMap;
8use std::convert::Infallible;
9use std::net::SocketAddr;
10use std::path::{Component, PathBuf};
11use std::sync::{Arc, LazyLock, Mutex};
12use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
13
14use anyhow::{Context, Result as AnyResult};
15use axum::body::{Body, Bytes};
16use axum::extract::{DefaultBodyLimit, Path, Query, Request, State};
17use axum::http::header::{
18 CACHE_CONTROL, CONTENT_ENCODING, CONTENT_LENGTH,
19 CONTENT_SECURITY_POLICY as CONTENT_SECURITY_POLICY_HEADER, CONTENT_TYPE, COOKIE, ETAG,
20 HeaderValue, IF_NONE_MATCH, LOCATION, REFERRER_POLICY, SET_COOKIE, X_CONTENT_TYPE_OPTIONS,
21};
22use axum::http::{HeaderMap, Response, StatusCode};
23use axum::middleware::Next;
24use axum::response::IntoResponse;
25use axum::response::sse::{Event, KeepAlive, Sse};
26use axum::routing::{get, post, put};
27use axum::{Json, Router};
28use base64::Engine as _;
29use hmac::{Hmac, KeyInit, Mac};
30use serde::{Deserialize, Serialize};
31use sha2::{Digest, Sha256};
32use tokio::sync::{mpsc, watch};
33use tokio_stream::wrappers::ReceiverStream;
34use tokio_util::sync::CancellationToken;
35
36use mj_core::attachment::{AttachmentRef, AttachmentStore, MAX_IMAGE_BYTES, MAX_IMAGES};
37use mj_core::config::{Config, TargetTemplate, project_history_host, validate_id};
38use mj_core::elicitation::{ElicitationRequest, ElicitationResponse, MAX_ELICITATION_BYTES};
39use mj_core::path_completion::{CompletionHost, CompletionKind, PathCompletion};
40use mj_core::refusal::{Refusal, RefusalKind};
41use mj_core::state::{
42 HostContainerSize, MoveOperation, MovePhase, MovePreparation, MoveSelection,
43 MoveSessionRequest, ProjectSourceIdentity, ResourceAllocationKind, SessionResourceAllocation,
44 SessionState, SessionTransitionKind, State as AppState,
45};
46
47use crate::targets::AdditionalMount;
48
49use crate::image::optimize_image;
50
51pub mod api;
52
53pub use api::{
54 ApiFailure, ApiSession, PromptRequest, PromptResponse, SessionListResponse,
55 StartSessionRequest, StartSessionResponse, SubagentBackend, WaitOutcome, WaitRequest,
56 WaitResponse, api_token_path, load_or_create_api_token, map_stop_reason, resolve_wait,
57};
58
59pub use mj_client::web::{
60 BrowserDiffStat, BrowserTranscript, BrowserTranscriptEntry, WebListenerProcess,
61 WebViewerAccess, WebViewerRecovery,
62};
63
64pub use mj_core::state::ResumeQueueDisposition;
68
69pub fn install_rustls_crypto_provider() {
76 let _ = rustls::crypto::ring::default_provider().install_default();
77}
78
79pub const COOKIE_NAME: &str = "hel_viewer_session";
80const DEFAULT_SESSION_TTL: Duration = Duration::from_secs(30 * 24 * 60 * 60);
81const EPHEMERAL_SESSION_TTL: Duration = Duration::from_secs(24 * 60 * 60);
82const MAX_BODY_BYTES: usize = 128 * 1024;
83const MAX_CODE_FAILURES: u32 = 5;
84const CODE_LOCKOUT_BASE: Duration = Duration::from_secs(30);
85const CODE_LOCKOUT_CAP: Duration = Duration::from_secs(60 * 60);
86const MAX_TITLE_CHARS: usize = 120;
87const MAX_PROMPT_CHARS: usize = 64 * 1024;
88const MAX_DIRTY_ACKNOWLEDGEMENTS: usize = 32;
91const MAX_DRAFT_BYTES: usize = 64 * 1024;
95pub const MAX_HISTORY_MATCHES: usize = 40;
99const MAX_PROMPT_BODY_BYTES: usize = 32 * 1024 * 1024;
104const MAX_ATTACHMENT_UPLOAD_BYTES: usize = 64 * 1024 * 1024;
108pub const MAX_PROMPT_IMAGES: usize = MAX_IMAGES;
110const COOKIE_KEY_BYTES: usize = 32;
111const COOKIE_KEY_FILE: &str = "phone-cookie-key";
112
113pub const fn default_session_ttl() -> Duration {
118 DEFAULT_SESSION_TTL
119}
120
121pub fn cookie_key_path() -> PathBuf {
122 mj_core::config::data_dir().join(COOKIE_KEY_FILE)
123}
124
125pub fn load_or_create_cookie_key(path: &std::path::Path) -> AnyResult<Vec<u8>> {
135 match std::fs::read(path) {
136 Ok(key) if key.len() >= COOKIE_KEY_BYTES => return Ok(key),
137 Ok(key) => tracing::warn!(
138 path = %path.display(),
139 bytes = key.len(),
140 "phone cookie key is shorter than {COOKIE_KEY_BYTES} bytes; generating a new key signs every phone out"
141 ),
142 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
143 Err(error) => tracing::warn!(
144 path = %path.display(),
145 "could not read the phone cookie key ({error}); generating a new key signs every phone out"
146 ),
147 }
148 let key = generate_cookie_key()?;
149 mj_core::config::atomic_write(path, &key)
150 .with_context(|| format!("persist Mjolnir phone cookie key {}", path.display()))?;
151 Ok(key.to_vec())
152}
153
154#[derive(Clone)]
162pub struct ServerOptions {
163 pub bind: SocketAddr,
164 pub snapshot_rx: watch::Receiver<ViewerSnapshot>,
165 pub conversation_rx:
166 watch::Receiver<mj_core::snapshot_map::SnapshotMap<String, BrowserTranscript>>,
167 pub action_tx: mpsc::Sender<ControllerRequest>,
168 pub bundle_tx: mpsc::Sender<BundleRequest>,
169 pub receipt_tx: mpsc::Sender<ReadReceiptRequest>,
170 pub preflight_tx: mpsc::Sender<PreflightRequest>,
171 pub move_preparation_tx: mpsc::Sender<MovePreparationRequest>,
172 pub client_state_tx: mpsc::Sender<ClientStateRequest>,
173 background_task_stop_tx: mpsc::Sender<BackgroundTaskStopRequest>,
177 pub shutdown: CancellationToken,
178 pub session_ttl: Duration,
179 pub secure_cookie: bool,
182 tls_config: Option<axum_server::tls_rustls::RustlsConfig>,
183 viewer_code: String,
184 login_token: String,
185 cookie_key: Vec<u8>,
186 viewer_revocations: Arc<ViewerRevocations>,
187 viewer_history: viewer_feed::ViewerHistoryHandle,
188 api_token: String,
189 subagent: Option<Arc<dyn api::SubagentBackend>>,
190 preferences_path: PathBuf,
195 engine_checks: Arc<api::LocalEngineChecks>,
199 worker_source_check: Option<WorkerSourceCheck>,
200 upgrade_gate: Arc<crate::upgrade::Gate>,
204}
205
206pub const UPGRADE_HEADER: &str = "x-mj-upgrade";
210
211pub type WorkerSourceCheck = Arc<dyn Fn(&str) -> Option<String> + Send + Sync>;
215
216pub struct ServerRequests {
218 pub action_tx: mpsc::Sender<ControllerRequest>,
219 pub bundle_tx: mpsc::Sender<BundleRequest>,
220 pub receipt_tx: mpsc::Sender<ReadReceiptRequest>,
221 pub preflight_tx: mpsc::Sender<PreflightRequest>,
222 pub move_preparation_tx: mpsc::Sender<MovePreparationRequest>,
223 pub client_state_tx: mpsc::Sender<ClientStateRequest>,
224}
225
226impl ServerOptions {
227 pub fn new(
228 bind: SocketAddr,
229 snapshot_rx: watch::Receiver<ViewerSnapshot>,
230 conversation_rx: watch::Receiver<
231 mj_core::snapshot_map::SnapshotMap<String, BrowserTranscript>,
232 >,
233 requests: ServerRequests,
234 ) -> AnyResult<Self> {
235 let cookie_key = generate_cookie_key()?.to_vec();
236 let viewer_history = viewer_feed::ViewerHistoryHandle::new()?;
237 Ok(Self {
238 bind,
239 snapshot_rx,
240 conversation_rx,
241 action_tx: requests.action_tx,
242 bundle_tx: requests.bundle_tx,
243 receipt_tx: requests.receipt_tx,
244 preflight_tx: requests.preflight_tx,
245 move_preparation_tx: requests.move_preparation_tx,
246 client_state_tx: requests.client_state_tx,
247 background_task_stop_tx: mpsc::channel(1).0,
248 shutdown: CancellationToken::new(),
249 session_ttl: DEFAULT_SESSION_TTL,
250 secure_cookie: true,
251 tls_config: None,
252 viewer_code: generate_viewer_code()?,
253 login_token: derive_login_token(&cookie_key),
254 cookie_key,
255 viewer_revocations: Arc::new(ViewerRevocations::default()),
256 viewer_history,
257 api_token: String::new(),
260 subagent: None,
261 preferences_path: mj_core::go::GoPreferences::path(),
262 engine_checks: Arc::new(api::LocalEngineChecks::on_this_host()),
263 worker_source_check: None,
264 upgrade_gate: crate::upgrade::gate().clone(),
265 })
266 }
267
268 #[cfg(test)]
269 pub(crate) fn set_upgrade_gate(&mut self, gate: Arc<crate::upgrade::Gate>) {
270 self.upgrade_gate = gate;
271 }
272
273 pub fn viewer_code(&self) -> &str {
274 &self.viewer_code
275 }
276
277 pub fn login_token(&self) -> &str {
278 &self.login_token
279 }
280
281 pub fn set_tls_config(&mut self, config: axum_server::tls_rustls::RustlsConfig) {
285 self.tls_config = Some(config);
286 self.secure_cookie = true;
287 }
288
289 pub fn set_cookie_key(&mut self, key: Vec<u8>) -> AnyResult<()> {
293 anyhow::ensure!(
294 key.len() >= COOKIE_KEY_BYTES,
295 "cookie signing key must be at least {COOKIE_KEY_BYTES} bytes"
296 );
297 self.login_token = derive_login_token(&key);
298 self.cookie_key = key;
299 Ok(())
300 }
301
302 pub async fn load_cookie_credentials(&mut self, path: PathBuf) -> AnyResult<()> {
304 let (key, revocations) = tokio::task::spawn_blocking(move || {
305 let key = load_or_create_cookie_key(&path)?;
306 let revocations =
307 ViewerRevocations::load(path.with_file_name("phone-cookie-revocations.json"))?;
308 Ok::<_, anyhow::Error>((key, revocations))
309 })
310 .await
311 .context("load viewer credentials task")??;
312 self.set_cookie_key(key)?;
313 self.viewer_revocations = Arc::new(revocations);
314 Ok(())
315 }
316
317 pub fn set_background_task_stop_tx(&mut self, tx: mpsc::Sender<BackgroundTaskStopRequest>) {
319 self.background_task_stop_tx = tx;
320 }
321
322 pub fn set_api_token(&mut self, token: String) {
325 self.api_token = token;
326 }
327
328 pub fn set_subagent_backend(&mut self, backend: Arc<dyn api::SubagentBackend>) {
331 self.subagent = Some(backend);
332 }
333
334 pub fn set_worker_source_check(&mut self, check: WorkerSourceCheck) {
337 self.worker_source_check = Some(check);
338 }
339
340 pub fn set_preferences_path(&mut self, path: PathBuf) {
343 self.preferences_path = path;
344 }
345
346 #[cfg(test)]
348 fn set_engine_probe(&mut self, probe: api::EngineProbe) {
349 self.engine_checks = Arc::new(api::LocalEngineChecks::new(probe));
350 }
351
352 #[cfg(test)]
353 fn with_test_credentials(mut self, code: &str, key: &[u8]) -> Self {
354 self.viewer_code = code.to_string();
355 self.login_token = "test-login-token".into();
356 self.cookie_key = key.to_vec();
357 self.secure_cookie = false;
358 self.api_token = "test-api-token".into();
359 self
360 }
361}
362
363pub async fn run_server(options: ServerOptions) -> AnyResult<()> {
369 let listener = tokio::net::TcpListener::bind(options.bind)
370 .await
371 .with_context(|| format!("bind web viewer to {}", options.bind))?;
372 run_server_on_listener(options, listener).await
373}
374
375pub async fn run_server_on_listener(
377 options: ServerOptions,
378 listener: tokio::net::TcpListener,
379) -> AnyResult<()> {
380 let mut options = options;
381 let bind = listener.local_addr().context("read web viewer address")?;
382 let shutdown = options.shutdown.clone();
383 let viewer_code = options.viewer_code.clone();
384 let tls_config = options.tls_config.take();
385 let app = router(options);
386 println!("Mjolnir viewer code: {viewer_code}");
387 let listener = listener.into_std().context("prepare web viewer listener")?;
388 let handle = axum_server::Handle::new();
389 let shutdown_handle = handle.clone();
390 let serve = async move {
391 if let Some(tls_config) = tls_config {
392 axum_server::from_tcp_rustls(listener, tls_config)
393 .handle(handle)
394 .serve(app.into_make_service())
395 .await
396 } else {
397 axum_server::from_tcp(listener)
398 .handle(handle)
399 .serve(app.into_make_service())
400 .await
401 }
402 };
403 tokio::pin!(serve);
404 tokio::select! {
405 result = &mut serve => result,
406 _ = shutdown.cancelled() => {
407 shutdown_handle.graceful_shutdown(Some(Duration::from_secs(2)));
408 serve.await
409 }
410 }
411 .with_context(|| format!("serve web viewer on {bind}"))
412}
413
414mod viewer_feed;
415mod viewer_types;
416pub(crate) mod viewer_wire;
417pub use viewer_types::*;
418mod actions;
419pub use actions::*;
420mod routes;
421use routes::*;
422mod handlers;
423use handlers::*;
424mod validation;
425use validation::*;
426mod errors;
427use errors::*;
428mod auth;
429pub use auth::*;
430mod assets;
431use assets::*;
432mod config_view;
433pub use config_view::*;
434
435#[cfg(test)]
436mod tests;