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, to_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::Semaphore;
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::state::{
40 MoveOperation, MovePhase, MovePreparation, MoveSelection, MoveSessionRequest,
41 ProjectSourceIdentity, SessionResourceAllocation, SessionState, SessionTransitionKind,
42 State as AppState,
43};
44
45use crate::targets::AdditionalMount;
46
47use crate::dictation::{
48 DictationError, DictationOperation, DictationRequest, DictationResponse, MAX_AUDIO_BYTES,
49 validate_wav,
50};
51use crate::image::optimize_image;
52
53pub mod api;
54
55pub use api::{
56 ApiFailure, ApiSession, PromptRequest, PromptResponse, SessionListResponse,
57 StartSessionRequest, StartSessionResponse, SubagentBackend, WaitOutcome, WaitRequest,
58 WaitResponse, api_token_path, load_or_create_api_token, map_stop_reason, resolve_wait,
59};
60
61pub use mj_client::web::{
62 BrowserDiffStat, BrowserTranscript, BrowserTranscriptEntry, WebListenerProcess,
63 WebViewerAccess, WebViewerRecovery,
64};
65
66pub use mj_core::state::ResumeQueueDisposition;
70
71pub fn install_rustls_crypto_provider() {
78 let _ = rustls::crypto::ring::default_provider().install_default();
79}
80
81pub const COOKIE_NAME: &str = "hel_viewer_session";
82const DEFAULT_SESSION_TTL: Duration = Duration::from_secs(30 * 24 * 60 * 60);
83const EPHEMERAL_SESSION_TTL: Duration = Duration::from_secs(24 * 60 * 60);
84const MAX_BODY_BYTES: usize = 128 * 1024;
85const MAX_CODE_FAILURES: u32 = 5;
86const CODE_LOCKOUT_BASE: Duration = Duration::from_secs(30);
87const CODE_LOCKOUT_CAP: Duration = Duration::from_secs(60 * 60);
88const MAX_TITLE_CHARS: usize = 120;
89const MAX_PROMPT_CHARS: usize = 64 * 1024;
90const MAX_DIRTY_ACKNOWLEDGEMENTS: usize = 32;
93const MAX_DRAFT_BYTES: usize = 64 * 1024;
97pub const MAX_HISTORY_MATCHES: usize = 40;
101const MAX_PROMPT_BODY_BYTES: usize = 32 * 1024 * 1024;
106const MAX_ATTACHMENT_UPLOAD_BYTES: usize = 64 * 1024 * 1024;
110const MAX_CONCURRENT_DICTATIONS: usize = 2;
114pub const MAX_PROMPT_IMAGES: usize = MAX_IMAGES;
116const COOKIE_KEY_BYTES: usize = 32;
117const COOKIE_KEY_FILE: &str = "phone-cookie-key";
118
119pub const fn default_session_ttl() -> Duration {
124 DEFAULT_SESSION_TTL
125}
126
127pub fn cookie_key_path() -> PathBuf {
128 mj_core::config::data_dir().join(COOKIE_KEY_FILE)
129}
130
131pub fn load_or_create_cookie_key(path: &std::path::Path) -> AnyResult<Vec<u8>> {
141 match std::fs::read(path) {
142 Ok(key) if key.len() >= COOKIE_KEY_BYTES => return Ok(key),
143 Ok(key) => tracing::warn!(
144 path = %path.display(),
145 bytes = key.len(),
146 "phone cookie key is shorter than {COOKIE_KEY_BYTES} bytes; generating a new key signs every phone out"
147 ),
148 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
149 Err(error) => tracing::warn!(
150 path = %path.display(),
151 "could not read the phone cookie key ({error}); generating a new key signs every phone out"
152 ),
153 }
154 let key = generate_cookie_key()?;
155 mj_core::config::atomic_write(path, &key)
156 .with_context(|| format!("persist Mjolnir phone cookie key {}", path.display()))?;
157 Ok(key.to_vec())
158}
159
160#[derive(Clone)]
168pub struct ServerOptions {
169 pub bind: SocketAddr,
170 pub snapshot_rx: watch::Receiver<ViewerSnapshot>,
171 pub conversation_rx: watch::Receiver<BTreeMap<String, BrowserTranscript>>,
172 pub action_tx: mpsc::Sender<ControllerRequest>,
173 pub bundle_tx: mpsc::Sender<BundleRequest>,
174 pub receipt_tx: mpsc::Sender<ReadReceiptRequest>,
175 pub preflight_tx: mpsc::Sender<PreflightRequest>,
176 pub move_preparation_tx: mpsc::Sender<MovePreparationRequest>,
177 pub client_state_tx: mpsc::Sender<ClientStateRequest>,
178 pub dictation_tx: mpsc::Sender<DictationRequest>,
179 background_task_stop_tx: mpsc::Sender<BackgroundTaskStopRequest>,
183 pub shutdown: CancellationToken,
184 pub session_ttl: Duration,
185 pub secure_cookie: bool,
188 tls_config: Option<axum_server::tls_rustls::RustlsConfig>,
189 viewer_code: String,
190 login_token: String,
191 cookie_key: Vec<u8>,
192 api_token: String,
193 subagent: Option<Arc<dyn api::SubagentBackend>>,
194}
195
196pub struct ServerRequests {
198 pub action_tx: mpsc::Sender<ControllerRequest>,
199 pub bundle_tx: mpsc::Sender<BundleRequest>,
200 pub receipt_tx: mpsc::Sender<ReadReceiptRequest>,
201 pub preflight_tx: mpsc::Sender<PreflightRequest>,
202 pub move_preparation_tx: mpsc::Sender<MovePreparationRequest>,
203 pub client_state_tx: mpsc::Sender<ClientStateRequest>,
204 pub dictation_tx: mpsc::Sender<DictationRequest>,
205}
206
207impl ServerOptions {
208 pub fn new(
209 bind: SocketAddr,
210 snapshot_rx: watch::Receiver<ViewerSnapshot>,
211 conversation_rx: watch::Receiver<BTreeMap<String, BrowserTranscript>>,
212 requests: ServerRequests,
213 ) -> AnyResult<Self> {
214 Ok(Self {
215 bind,
216 snapshot_rx,
217 conversation_rx,
218 action_tx: requests.action_tx,
219 bundle_tx: requests.bundle_tx,
220 receipt_tx: requests.receipt_tx,
221 preflight_tx: requests.preflight_tx,
222 move_preparation_tx: requests.move_preparation_tx,
223 client_state_tx: requests.client_state_tx,
224 dictation_tx: requests.dictation_tx,
225 background_task_stop_tx: mpsc::channel(1).0,
226 shutdown: CancellationToken::new(),
227 session_ttl: DEFAULT_SESSION_TTL,
228 secure_cookie: true,
229 tls_config: None,
230 viewer_code: generate_viewer_code()?,
231 login_token: generate_login_token()?,
232 cookie_key: generate_cookie_key()?.to_vec(),
233 api_token: String::new(),
236 subagent: None,
237 })
238 }
239
240 pub fn viewer_code(&self) -> &str {
241 &self.viewer_code
242 }
243
244 pub fn login_token(&self) -> &str {
245 &self.login_token
246 }
247
248 pub fn set_tls_config(&mut self, config: axum_server::tls_rustls::RustlsConfig) {
252 self.tls_config = Some(config);
253 self.secure_cookie = true;
254 }
255
256 pub fn set_cookie_key(&mut self, key: Vec<u8>) -> AnyResult<()> {
259 anyhow::ensure!(
260 key.len() >= COOKIE_KEY_BYTES,
261 "cookie signing key must be at least {COOKIE_KEY_BYTES} bytes"
262 );
263 self.cookie_key = key;
264 Ok(())
265 }
266
267 pub fn set_background_task_stop_tx(&mut self, tx: mpsc::Sender<BackgroundTaskStopRequest>) {
269 self.background_task_stop_tx = tx;
270 }
271
272 pub fn set_api_token(&mut self, token: String) {
275 self.api_token = token;
276 }
277
278 pub fn set_subagent_backend(&mut self, backend: Arc<dyn api::SubagentBackend>) {
281 self.subagent = Some(backend);
282 }
283
284 #[cfg(test)]
285 fn with_test_credentials(mut self, code: &str, key: &[u8]) -> Self {
286 self.viewer_code = code.to_string();
287 self.login_token = "test-login-token".into();
288 self.cookie_key = key.to_vec();
289 self.secure_cookie = false;
290 self.api_token = "test-api-token".into();
291 self
292 }
293}
294
295pub async fn run_server(options: ServerOptions) -> AnyResult<()> {
301 let listener = tokio::net::TcpListener::bind(options.bind)
302 .await
303 .with_context(|| format!("bind web viewer to {}", options.bind))?;
304 run_server_on_listener(options, listener).await
305}
306
307pub async fn run_server_on_listener(
309 options: ServerOptions,
310 listener: tokio::net::TcpListener,
311) -> AnyResult<()> {
312 let mut options = options;
313 let bind = listener.local_addr().context("read web viewer address")?;
314 let shutdown = options.shutdown.clone();
315 let viewer_code = options.viewer_code.clone();
316 let tls_config = options.tls_config.take();
317 let app = router(options);
318 println!("Mjolnir viewer code: {viewer_code}");
319 let listener = listener.into_std().context("prepare web viewer listener")?;
320 let handle = axum_server::Handle::new();
321 let shutdown_handle = handle.clone();
322 let serve = async move {
323 if let Some(tls_config) = tls_config {
324 axum_server::from_tcp_rustls(listener, tls_config)
325 .handle(handle)
326 .serve(app.into_make_service())
327 .await
328 } else {
329 axum_server::from_tcp(listener)
330 .handle(handle)
331 .serve(app.into_make_service())
332 .await
333 }
334 };
335 tokio::pin!(serve);
336 tokio::select! {
337 result = &mut serve => result,
338 _ = shutdown.cancelled() => {
339 shutdown_handle.graceful_shutdown(Some(Duration::from_secs(2)));
340 serve.await
341 }
342 }
343 .with_context(|| format!("serve web viewer on {bind}"))
344}
345
346mod viewer_types;
347pub use viewer_types::*;
348mod actions;
349pub use actions::*;
350mod routes;
351use routes::*;
352mod handlers;
353use handlers::*;
354mod validation;
355use validation::*;
356mod errors;
357use errors::*;
358mod auth;
359pub use auth::*;
360mod assets;
361use assets::*;
362mod config_view;
363pub use config_view::*;
364
365#[cfg(test)]
366mod tests;