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::path_completion::{CompletionHost, CompletionKind, PathCompletion};
40use mj_core::refusal::{Refusal, RefusalKind};
41use mj_core::state::{
42 MoveOperation, MovePhase, MovePreparation, MoveSelection, MoveSessionRequest,
43 ProjectSourceIdentity, SessionResourceAllocation, SessionState, SessionTransitionKind,
44 State as AppState,
45};
46
47use crate::targets::AdditionalMount;
48
49use crate::dictation::{
50 DictationError, DictationOperation, DictationRequest, DictationResponse, MAX_AUDIO_BYTES,
51 validate_wav,
52};
53use crate::image::optimize_image;
54
55pub mod api;
56
57pub use api::{
58 ApiFailure, ApiSession, PromptRequest, PromptResponse, SessionListResponse,
59 StartSessionRequest, StartSessionResponse, SubagentBackend, WaitOutcome, WaitRequest,
60 WaitResponse, api_token_path, load_or_create_api_token, map_stop_reason, resolve_wait,
61};
62
63pub use mj_client::web::{
64 BrowserDiffStat, BrowserTranscript, BrowserTranscriptEntry, WebListenerProcess,
65 WebViewerAccess, WebViewerRecovery,
66};
67
68pub use mj_core::state::ResumeQueueDisposition;
72
73pub fn install_rustls_crypto_provider() {
80 let _ = rustls::crypto::ring::default_provider().install_default();
81}
82
83pub const COOKIE_NAME: &str = "hel_viewer_session";
84const DEFAULT_SESSION_TTL: Duration = Duration::from_secs(30 * 24 * 60 * 60);
85const EPHEMERAL_SESSION_TTL: Duration = Duration::from_secs(24 * 60 * 60);
86const MAX_BODY_BYTES: usize = 128 * 1024;
87const MAX_CODE_FAILURES: u32 = 5;
88const CODE_LOCKOUT_BASE: Duration = Duration::from_secs(30);
89const CODE_LOCKOUT_CAP: Duration = Duration::from_secs(60 * 60);
90const MAX_TITLE_CHARS: usize = 120;
91const MAX_PROMPT_CHARS: usize = 64 * 1024;
92const MAX_DIRTY_ACKNOWLEDGEMENTS: usize = 32;
95const MAX_DRAFT_BYTES: usize = 64 * 1024;
99pub const MAX_HISTORY_MATCHES: usize = 40;
103const MAX_PROMPT_BODY_BYTES: usize = 32 * 1024 * 1024;
108const MAX_ATTACHMENT_UPLOAD_BYTES: usize = 64 * 1024 * 1024;
112const MAX_CONCURRENT_DICTATIONS: usize = 2;
116pub const MAX_PROMPT_IMAGES: usize = MAX_IMAGES;
118const COOKIE_KEY_BYTES: usize = 32;
119const COOKIE_KEY_FILE: &str = "phone-cookie-key";
120
121pub const fn default_session_ttl() -> Duration {
126 DEFAULT_SESSION_TTL
127}
128
129pub fn cookie_key_path() -> PathBuf {
130 mj_core::config::data_dir().join(COOKIE_KEY_FILE)
131}
132
133pub fn load_or_create_cookie_key(path: &std::path::Path) -> AnyResult<Vec<u8>> {
143 match std::fs::read(path) {
144 Ok(key) if key.len() >= COOKIE_KEY_BYTES => return Ok(key),
145 Ok(key) => tracing::warn!(
146 path = %path.display(),
147 bytes = key.len(),
148 "phone cookie key is shorter than {COOKIE_KEY_BYTES} bytes; generating a new key signs every phone out"
149 ),
150 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
151 Err(error) => tracing::warn!(
152 path = %path.display(),
153 "could not read the phone cookie key ({error}); generating a new key signs every phone out"
154 ),
155 }
156 let key = generate_cookie_key()?;
157 mj_core::config::atomic_write(path, &key)
158 .with_context(|| format!("persist Mjolnir phone cookie key {}", path.display()))?;
159 Ok(key.to_vec())
160}
161
162#[derive(Clone)]
170pub struct ServerOptions {
171 pub bind: SocketAddr,
172 pub snapshot_rx: watch::Receiver<ViewerSnapshot>,
173 pub conversation_rx: watch::Receiver<BTreeMap<String, BrowserTranscript>>,
174 pub action_tx: mpsc::Sender<ControllerRequest>,
175 pub bundle_tx: mpsc::Sender<BundleRequest>,
176 pub receipt_tx: mpsc::Sender<ReadReceiptRequest>,
177 pub preflight_tx: mpsc::Sender<PreflightRequest>,
178 pub move_preparation_tx: mpsc::Sender<MovePreparationRequest>,
179 pub client_state_tx: mpsc::Sender<ClientStateRequest>,
180 pub dictation_tx: mpsc::Sender<DictationRequest>,
181 background_task_stop_tx: mpsc::Sender<BackgroundTaskStopRequest>,
185 pub shutdown: CancellationToken,
186 pub session_ttl: Duration,
187 pub secure_cookie: bool,
190 tls_config: Option<axum_server::tls_rustls::RustlsConfig>,
191 viewer_code: String,
192 login_token: String,
193 cookie_key: Vec<u8>,
194 viewer_revocations: Arc<ViewerRevocations>,
195 api_token: String,
196 subagent: Option<Arc<dyn api::SubagentBackend>>,
197 preferences_path: PathBuf,
202 engine_checks: Arc<api::LocalEngineChecks>,
206 worker_source_check: Option<WorkerSourceCheck>,
207 upgrade_gate: Arc<crate::upgrade::Gate>,
211}
212
213pub const UPGRADE_HEADER: &str = "x-mj-upgrade";
217
218pub type WorkerSourceCheck = Arc<dyn Fn(&str) -> Option<String> + Send + Sync>;
222
223pub struct ServerRequests {
225 pub action_tx: mpsc::Sender<ControllerRequest>,
226 pub bundle_tx: mpsc::Sender<BundleRequest>,
227 pub receipt_tx: mpsc::Sender<ReadReceiptRequest>,
228 pub preflight_tx: mpsc::Sender<PreflightRequest>,
229 pub move_preparation_tx: mpsc::Sender<MovePreparationRequest>,
230 pub client_state_tx: mpsc::Sender<ClientStateRequest>,
231 pub dictation_tx: mpsc::Sender<DictationRequest>,
232}
233
234impl ServerOptions {
235 pub fn new(
236 bind: SocketAddr,
237 snapshot_rx: watch::Receiver<ViewerSnapshot>,
238 conversation_rx: watch::Receiver<BTreeMap<String, BrowserTranscript>>,
239 requests: ServerRequests,
240 ) -> AnyResult<Self> {
241 let cookie_key = generate_cookie_key()?.to_vec();
242 Ok(Self {
243 bind,
244 snapshot_rx,
245 conversation_rx,
246 action_tx: requests.action_tx,
247 bundle_tx: requests.bundle_tx,
248 receipt_tx: requests.receipt_tx,
249 preflight_tx: requests.preflight_tx,
250 move_preparation_tx: requests.move_preparation_tx,
251 client_state_tx: requests.client_state_tx,
252 dictation_tx: requests.dictation_tx,
253 background_task_stop_tx: mpsc::channel(1).0,
254 shutdown: CancellationToken::new(),
255 session_ttl: DEFAULT_SESSION_TTL,
256 secure_cookie: true,
257 tls_config: None,
258 viewer_code: generate_viewer_code()?,
259 login_token: derive_login_token(&cookie_key),
260 cookie_key,
261 viewer_revocations: Arc::new(ViewerRevocations::default()),
262 api_token: String::new(),
265 subagent: None,
266 preferences_path: mj_core::go::GoPreferences::path(),
267 engine_checks: Arc::new(api::LocalEngineChecks::on_this_host()),
268 worker_source_check: None,
269 upgrade_gate: crate::upgrade::gate().clone(),
270 })
271 }
272
273 #[cfg(test)]
274 pub(crate) fn set_upgrade_gate(&mut self, gate: Arc<crate::upgrade::Gate>) {
275 self.upgrade_gate = gate;
276 }
277
278 pub fn viewer_code(&self) -> &str {
279 &self.viewer_code
280 }
281
282 pub fn login_token(&self) -> &str {
283 &self.login_token
284 }
285
286 pub fn set_tls_config(&mut self, config: axum_server::tls_rustls::RustlsConfig) {
290 self.tls_config = Some(config);
291 self.secure_cookie = true;
292 }
293
294 pub fn set_cookie_key(&mut self, key: Vec<u8>) -> AnyResult<()> {
298 anyhow::ensure!(
299 key.len() >= COOKIE_KEY_BYTES,
300 "cookie signing key must be at least {COOKIE_KEY_BYTES} bytes"
301 );
302 self.login_token = derive_login_token(&key);
303 self.cookie_key = key;
304 Ok(())
305 }
306
307 pub async fn load_cookie_credentials(&mut self, path: PathBuf) -> AnyResult<()> {
309 let (key, revocations) = tokio::task::spawn_blocking(move || {
310 let key = load_or_create_cookie_key(&path)?;
311 let revocations =
312 ViewerRevocations::load(path.with_file_name("phone-cookie-revocations.json"))?;
313 Ok::<_, anyhow::Error>((key, revocations))
314 })
315 .await
316 .context("load viewer credentials task")??;
317 self.set_cookie_key(key)?;
318 self.viewer_revocations = Arc::new(revocations);
319 Ok(())
320 }
321
322 pub fn set_background_task_stop_tx(&mut self, tx: mpsc::Sender<BackgroundTaskStopRequest>) {
324 self.background_task_stop_tx = tx;
325 }
326
327 pub fn set_api_token(&mut self, token: String) {
330 self.api_token = token;
331 }
332
333 pub fn set_subagent_backend(&mut self, backend: Arc<dyn api::SubagentBackend>) {
336 self.subagent = Some(backend);
337 }
338
339 pub fn set_worker_source_check(&mut self, check: WorkerSourceCheck) {
342 self.worker_source_check = Some(check);
343 }
344
345 pub fn set_preferences_path(&mut self, path: PathBuf) {
348 self.preferences_path = path;
349 }
350
351 #[cfg(test)]
353 fn set_engine_probe(&mut self, probe: api::EngineProbe) {
354 self.engine_checks = Arc::new(api::LocalEngineChecks::new(probe));
355 }
356
357 #[cfg(test)]
358 fn with_test_credentials(mut self, code: &str, key: &[u8]) -> Self {
359 self.viewer_code = code.to_string();
360 self.login_token = "test-login-token".into();
361 self.cookie_key = key.to_vec();
362 self.secure_cookie = false;
363 self.api_token = "test-api-token".into();
364 self
365 }
366}
367
368pub async fn run_server(options: ServerOptions) -> AnyResult<()> {
374 let listener = tokio::net::TcpListener::bind(options.bind)
375 .await
376 .with_context(|| format!("bind web viewer to {}", options.bind))?;
377 run_server_on_listener(options, listener).await
378}
379
380pub async fn run_server_on_listener(
382 options: ServerOptions,
383 listener: tokio::net::TcpListener,
384) -> AnyResult<()> {
385 let mut options = options;
386 let bind = listener.local_addr().context("read web viewer address")?;
387 let shutdown = options.shutdown.clone();
388 let viewer_code = options.viewer_code.clone();
389 let tls_config = options.tls_config.take();
390 let app = router(options);
391 println!("Mjolnir viewer code: {viewer_code}");
392 let listener = listener.into_std().context("prepare web viewer listener")?;
393 let handle = axum_server::Handle::new();
394 let shutdown_handle = handle.clone();
395 let serve = async move {
396 if let Some(tls_config) = tls_config {
397 axum_server::from_tcp_rustls(listener, tls_config)
398 .handle(handle)
399 .serve(app.into_make_service())
400 .await
401 } else {
402 axum_server::from_tcp(listener)
403 .handle(handle)
404 .serve(app.into_make_service())
405 .await
406 }
407 };
408 tokio::pin!(serve);
409 tokio::select! {
410 result = &mut serve => result,
411 _ = shutdown.cancelled() => {
412 shutdown_handle.graceful_shutdown(Some(Duration::from_secs(2)));
413 serve.await
414 }
415 }
416 .with_context(|| format!("serve web viewer on {bind}"))
417}
418
419mod viewer_feed;
420mod viewer_types;
421pub use viewer_types::*;
422mod actions;
423pub use actions::*;
424mod routes;
425use routes::*;
426mod handlers;
427use handlers::*;
428mod validation;
429use validation::*;
430mod errors;
431use errors::*;
432mod auth;
433pub use auth::*;
434mod assets;
435use assets::*;
436mod config_view;
437pub use config_view::*;
438
439#[cfg(test)]
440mod tests;