Skip to main content

mj_controller/
server.rs

1//! Daemon-owned, phone-oriented control surface for Hel.
2//!
3//! The server deliberately owns no controller business logic. It publishes a
4//! redacted projection of controller state and forwards validated, typed
5//! actions through a channel supplied by the controller.
6
7use 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
64// Keep all control surfaces on the same queue vocabulary. The resume flow
65// used to define a private copy here, which made a move request impossible to
66// pass through the web and daemon boundaries without lossy conversion.
67pub use mj_core::state::ResumeQueueDisposition;
68
69/// Select the process-wide rustls provider before any TLS configuration is built.
70///
71/// Dependency feature unification can enable both rustls providers. Rustls
72/// deliberately refuses to guess in that case, so each executable that links
73/// the controller installs the ring provider at process startup. A provider
74/// installed even earlier is already sufficient and remains in place.
75pub 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;
88/// How many repositories one dirty-worktree acknowledgement may name. A bundle
89/// with more repositories than this than has bigger problems than the phone.
90const MAX_DIRTY_ACKNOWLEDGEMENTS: usize = 32;
91/// The largest draft a phone may store. A composer is for a prompt, and a
92/// prompt this size has other problems; the bound exists so one viewer cannot
93/// fill the daemon's database with text it never sent.
94const MAX_DRAFT_BYTES: usize = 64 * 1024;
95/// How many prompt-history matches one search returns. Public because the
96/// controller loop performs the search and must use the same bound the phone
97/// was promised.
98pub const MAX_HISTORY_MATCHES: usize = 40;
99/// Image prompts need far more room than any other phone request. Browser
100/// uploads are base64-encoded, so two ordinary photographs already exceed the
101/// general body limit even when each one fits it. The larger bound therefore
102/// stays scoped to the action route that carries prompts.
103const MAX_PROMPT_BODY_BYTES: usize = 32 * 1024 * 1024;
104/// A browser uploads one source image at a time. The image optimizer has its
105/// own decoded-allocation bound; this is the HTTP envelope bound before that
106/// work starts.
107const MAX_ATTACHMENT_UPLOAD_BYTES: usize = 64 * 1024 * 1024;
108/// Keep this in sync with the prompt admission bound and the browser composer.
109pub const MAX_PROMPT_IMAGES: usize = MAX_IMAGES;
110const COOKIE_KEY_BYTES: usize = 32;
111const COOKIE_KEY_FILE: &str = "phone-cookie-key";
112
113/// How long stored viewer state outlives its last use.
114///
115/// It matches the session cookie's own lifetime: state keyed to an identity
116/// that can no longer authenticate has nothing left to belong to.
117pub 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
125/// Load the phone cookie signing key, creating it on first use.
126///
127/// This signing key keeps a signed-in phone signed in across daemon restarts.
128/// Logged-out identities are tracked separately. Deleting the key is
129/// therefore the explicit sign-everyone-out gesture: the next start writes a
130/// new key and every outstanding cookie stops validating. A missing file is
131/// ordinary first use; an unreadable or too-short one is replaced loudly,
132/// because refusing to start would be a worse answer than asking phones to
133/// enter the viewer code again.
134pub 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/// Options for the daemon's phone service.
155///
156/// `ServerOptions::new` generates both the six-digit viewer code and an
157/// ephemeral cookie key. A caller that wants cookies to survive server
158/// restarts loads its key and logout records with `load_cookie_credentials`.
159/// `set_cookie_key` installs a key for callers managing storage themselves. The
160/// key and viewer code are intentionally omitted from `Debug` output.
161#[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    /// Dedicated bounded path for stopping one live background task. This is
174    /// deliberately separate from [`ControllerAction`]: stopping a provider
175    /// task does not occupy the controller's action admission slot.
176    background_task_stop_tx: mpsc::Sender<BackgroundTaskStopRequest>,
177    pub shutdown: CancellationToken,
178    pub session_ttl: Duration,
179    /// Keep this enabled for direct HTTPS or an HTTPS reverse proxy. It may be
180    /// disabled only for an explicitly trusted HTTP development endpoint.
181    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    /// Where the remembered fast-start preferences live. Injected rather than
191    /// resolved from the process environment inside a handler, so the
192    /// documented API answers from its own state and tests never read the
193    /// developer's real configuration.
194    preferences_path: PathBuf,
195    /// Checks the engine behind each local container target for the launch
196    /// options route. Tests replace it so the answer does not depend on the
197    /// engines the test host has.
198    engine_checks: Arc<api::LocalEngineChecks>,
199    worker_source_check: Option<WorkerSourceCheck>,
200    /// The daemon's upgrade admission. A closed gate refuses new requests, and
201    /// tells the long requests a shutdown ends that the daemon is being
202    /// replaced, so their clients follow it. Tests give the server its own.
203    upgrade_gate: Arc<crate::upgrade::Gate>,
204}
205
206/// The header, valued `pending`, that marks a 503 as "this daemon is being
207/// replaced by an automatic upgrade; ask the next one". The request was not
208/// accepted, or was a read the shutdown ended, so asking again repeats nothing.
209pub const UPGRADE_HEADER: &str = "x-mj-upgrade";
210
211/// Answers, for a target id, why no worker binary could serve a session there.
212/// It blocks (a bare SSH host is asked its platform), so callers run it off
213/// the async runtime.
214pub type WorkerSourceCheck = Arc<dyn Fn(&str) -> Option<String> + Send + Sync>;
215
216/// Typed request channels served by the authenticated HTTP surface.
217pub 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            // An empty token authenticates nothing: the daemon installs the
258            // persisted one, and a server without it serves the viewer only.
259            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    /// Serve HTTPS directly using the supplied Rustls configuration. Hel's
282    /// CLI can load its persisted certificate (including a Tailscale-issued
283    /// certificate) and pass it here without coupling this module to disk.
284    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    /// Install a persisted signing key. Rotating this value signs every phone
290    /// out and revokes QR login URLs. This does not load durable logout records;
291    /// use `load_cookie_credentials` for the daemon's persisted credentials.
292    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    /// Load the signing key and durable logout records off the async runtime.
303    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    /// Install the controller's bounded background-task stop path.
318    pub fn set_background_task_stop_tx(&mut self, tx: mpsc::Sender<BackgroundTaskStopRequest>) {
319        self.background_task_stop_tx = tx;
320    }
321
322    /// Install the persisted bearer token for the `/api/v1` routes. Rotating
323    /// it revokes every client that still holds the old one.
324    pub fn set_api_token(&mut self, token: String) {
325        self.api_token = token;
326    }
327
328    /// Install the daemon-side backend the `/api/v1` routes drive sessions
329    /// through. Without it those routes answer 503.
330    pub fn set_subagent_backend(&mut self, backend: Arc<dyn api::SubagentBackend>) {
331        self.subagent = Some(backend);
332    }
333
334    /// Refuse launches, resumes and moves onto a target the check reports as
335    /// having no worker binary. Without it every target is admitted.
336    pub fn set_worker_source_check(&mut self, check: WorkerSourceCheck) {
337        self.worker_source_check = Some(check);
338    }
339
340    /// Read the remembered default from a specific preferences file instead
341    /// of the user's own. Tests point this at a temporary path.
342    pub fn set_preferences_path(&mut self, path: PathBuf) {
343        self.preferences_path = path;
344    }
345
346    /// Answer local engine checks with `probe` instead of this host's engines.
347    #[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
363/// Run the phone server until its shutdown token is cancelled.
364///
365/// This binds only the requested listener. It does not daemonize, provision a
366/// target, or keep sessions alive: controller availability is required, just
367/// like MJ's explicit remote-viewer model.
368pub 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
375/// Serve a reserved socket so readiness and advertised ports reflect a real listener.
376pub 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;