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