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, 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, 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
66// Keep all control surfaces on the same queue vocabulary. The resume flow
67// used to define a private copy here, which made a move request impossible to
68// pass through the web and daemon boundaries without lossy conversion.
69pub use mj_core::state::ResumeQueueDisposition;
70
71/// Select the process-wide rustls provider before any TLS configuration is built.
72///
73/// Dependency feature unification can enable both rustls providers. Rustls
74/// deliberately refuses to guess in that case, so each executable that links
75/// the controller installs the ring provider at process startup. A provider
76/// installed even earlier is already sufficient and remains in place.
77pub 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;
90/// How many repositories one dirty-worktree acknowledgement may name. A bundle
91/// with more repositories than this than has bigger problems than the phone.
92const MAX_DIRTY_ACKNOWLEDGEMENTS: usize = 32;
93/// The largest draft a phone may store. A composer is for a prompt, and a
94/// prompt this size has other problems; the bound exists so one viewer cannot
95/// fill the daemon's database with text it never sent.
96const MAX_DRAFT_BYTES: usize = 64 * 1024;
97/// How many prompt-history matches one search returns. Public because the
98/// controller loop performs the search and must use the same bound the phone
99/// was promised.
100pub const MAX_HISTORY_MATCHES: usize = 40;
101/// Image prompts need far more room than any other phone request. Browser
102/// uploads are base64-encoded, so two ordinary photographs already exceed the
103/// general body limit even when each one fits it. The larger bound therefore
104/// stays scoped to the action route that carries prompts.
105const MAX_PROMPT_BODY_BYTES: usize = 32 * 1024 * 1024;
106/// A browser uploads one source image at a time. The image optimizer has its
107/// own decoded-allocation bound; this is the HTTP envelope bound before that
108/// work starts.
109const MAX_ATTACHMENT_UPLOAD_BYTES: usize = 64 * 1024 * 1024;
110/// Keep two browser uploads/transcriptions in flight. The permit is acquired
111/// before reading the request body, so an overloaded client is rejected
112/// without accepting megabytes that cannot be processed yet.
113const MAX_CONCURRENT_DICTATIONS: usize = 2;
114/// Keep this in sync with the prompt admission bound and the browser composer.
115pub const MAX_PROMPT_IMAGES: usize = MAX_IMAGES;
116const COOKIE_KEY_BYTES: usize = 32;
117const COOKIE_KEY_FILE: &str = "phone-cookie-key";
118
119/// How long stored viewer state outlives its last use.
120///
121/// It matches the session cookie's own lifetime: state keyed to an identity
122/// that can no longer authenticate has nothing left to belong to.
123pub 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
131/// Load the phone cookie signing key, creating it on first use.
132///
133/// Session cookies are stateless, so this file is the only thing that keeps a
134/// signed-in phone signed in across daemon restarts. Deleting it is
135/// therefore the explicit sign-everyone-out gesture: the next start writes a
136/// new key and every outstanding cookie stops validating. A missing file is
137/// ordinary first use; an unreadable or too-short one is replaced loudly,
138/// because refusing to start would be a worse answer than asking phones to
139/// enter the viewer code again.
140pub 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/// Options for the daemon's phone service.
161///
162/// `ServerOptions::new` generates both the six-digit viewer code and an
163/// ephemeral cookie key. A caller that wants cookies to survive server
164/// restarts installs a persisted key with `set_cookie_key`, which
165/// `load_or_create_cookie_key` reads from its private Hel data directory. The
166/// key and viewer code are intentionally omitted from `Debug` output.
167#[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    /// Dedicated bounded path for stopping one live background task. This is
180    /// deliberately separate from [`ControllerAction`]: stopping a provider
181    /// task does not occupy the controller's action admission slot.
182    background_task_stop_tx: mpsc::Sender<BackgroundTaskStopRequest>,
183    pub shutdown: CancellationToken,
184    pub session_ttl: Duration,
185    /// Keep this enabled for direct HTTPS or an HTTPS reverse proxy. It may be
186    /// disabled only for an explicitly trusted HTTP development endpoint.
187    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
196/// Typed request channels served by the authenticated HTTP surface.
197pub 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            // An empty token authenticates nothing: the daemon installs the
234            // persisted one, and a server without it serves the viewer only.
235            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    /// Serve HTTPS directly using the supplied Rustls configuration. Hel's
249    /// CLI can load its persisted certificate (including a Tailscale-issued
250    /// certificate) and pass it here without coupling this module to disk.
251    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    /// Install a persisted signing key. Rotating this value signs every phone
257    /// out without maintaining a server-side session database.
258    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    /// Install the controller's bounded background-task stop path.
268    pub fn set_background_task_stop_tx(&mut self, tx: mpsc::Sender<BackgroundTaskStopRequest>) {
269        self.background_task_stop_tx = tx;
270    }
271
272    /// Install the persisted bearer token for the `/api/v1` routes. Rotating
273    /// it revokes every client that still holds the old one.
274    pub fn set_api_token(&mut self, token: String) {
275        self.api_token = token;
276    }
277
278    /// Install the daemon-side backend the `/api/v1` routes drive sessions
279    /// through. Without it those routes answer 503.
280    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
295/// Run the phone server until its shutdown token is cancelled.
296///
297/// This binds only the requested listener. It does not daemonize, provision a
298/// target, or keep sessions alive: controller availability is required, just
299/// like MJ's explicit remote-viewer model.
300pub 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
307/// Serve a reserved socket so readiness and advertised ports reflect a real listener.
308pub 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
346#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
347#[serde(deny_unknown_fields)]
348pub struct ViewerSnapshot {
349    pub revision: u64,
350    pub generated_at: String,
351    /// Unix time in milliseconds, refreshed when serving the projection.
352    /// Clients use this as the clock for live activity cards.
353    #[serde(default)]
354    pub server_time_ms: i64,
355    #[serde(default, skip_serializing_if = "Vec::is_empty")]
356    pub workspaces: Vec<ViewerWorkspace>,
357    pub sessions: Vec<ViewerSession>,
358    pub profiles: Vec<ViewerProfile>,
359    pub targets: Vec<ViewerTarget>,
360    pub bundles: Vec<ViewerBundle>,
361    /// The bounded part of `[review]` needed to report whether review is
362    /// armed. Reviewer model and effort remain controller-private.
363    #[serde(default)]
364    pub review_config: ViewerReviewConfig,
365    /// The global `[subagents] enabled` setting. The new-session form uses it
366    /// as the default for its per-session sub-agent checkbox.
367    #[serde(default)]
368    pub subagents_enabled: bool,
369    /// One entry per host or fleet that can be probed. Empty until the phone
370    /// server's capacity poller has published a reading.
371    #[serde(default, skip_serializing_if = "Vec::is_empty")]
372    pub capacity: Vec<ViewerTargetCapacity>,
373    /// Recent failed launches, independent of provisional session rollback.
374    #[serde(default, skip_serializing_if = "Vec::is_empty")]
375    pub launch_failures: Vec<ViewerLaunchFailure>,
376}
377
378/// Deliberately excludes raw diagnostics, which can contain credentials.
379#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
380pub struct ViewerLaunchFailure {
381    /// Identifies the notice itself, so the browser can dismiss one. It is not
382    /// a session id.
383    pub id: String,
384    pub workspace_id: String,
385    /// The session the failed launch was for, when one had been published.
386    /// Absent when the launch failed before any session record existed.
387    #[serde(default, skip_serializing_if = "Option::is_none")]
388    pub session_id: Option<String>,
389}
390
391impl ViewerSnapshot {
392    /// Build the public projection. In particular, this never copies profile
393    /// homes/environment, SSH hosts/keys, container environment, AWS details,
394    /// concrete resource locators, native session IDs, or raw error strings.
395    pub fn from_config_state(config: &Config, state: &AppState, revision: u64) -> Self {
396        let sessions = state
397            .sessions
398            .values()
399            .map(|session| {
400                let incompatible = config
401                    .targets
402                    .keys()
403                    .filter(|target_id| {
404                        crate::controller::resume_compatibility(session, config, target_id).is_err()
405                    })
406                    .cloned()
407                    .collect::<Vec<_>>();
408                let lifecycle = ViewerLifecycleCategory::of(session.state);
409                let source = session.project_source(config);
410                let subagent = state.subagents.get(&session.id);
411                let subagent_session_ids = state
412                    .subagents
413                    .values()
414                    .filter(|child| child.parent_session_id == session.id)
415                    .map(|child| child.child_session_id.clone())
416                    .collect();
417                ViewerSession {
418                    capacity_retry: None,
419                    id: session.id.clone(),
420                    workspace_id: session.workspace_id.clone(),
421                    title: session.display_title().to_owned(),
422                    subagent_parent_id: subagent.map(|child| child.parent_session_id.clone()),
423                    subagent_task_name: subagent.map(|child| child.task_name.clone()),
424                    subagent_session_ids,
425                    harness_kind: session.harness_kind.id().into(),
426                    profile_id: session.last_profile.clone(),
427                    bundle_id: session.bundle_id.clone(),
428                    target_id: session.target_template_id.clone(),
429                    state: session_state_name(session.state).into(),
430                    created_at: session.created_at.clone(),
431                    updated_at: session.updated_at.clone(),
432                    has_error: session.last_error.is_some()
433                        || session.configuration_issue(config).is_some(),
434                    configuration_issue: session.configuration_issue(config),
435                    preview: Vec::new(),
436                    queued_prompts: Vec::new(),
437                    active_user_shells: Vec::new(),
438                    background_tasks: Vec::new(),
439                    pending_elicitations: Vec::new(),
440                    conversation_available: false,
441                    prompt_images_supported: false,
442                    incompatible_resume_targets: incompatible.clone(),
443                    compatible_resume_targets: config
444                        .targets
445                        .keys()
446                        .filter(|target_id| !incompatible.contains(*target_id))
447                        .cloned()
448                        .collect(),
449                    project_label: source.short,
450                    project_key: project_key(&source.key),
451                    display_location: session.project_target(config, &session.target_template_id),
452                    lifecycle,
453                    transitioning: session.state.transition_kind().is_some(),
454                    latest_event_ordinal: 0,
455                    last_activity_at_ms: None,
456                    activity_details: None,
457                    activity: String::new(),
458                    operation: None,
459                    move_recovery: None,
460                    chat_phase: ViewerChatPhase::default(),
461                    is_idle: false,
462                    config_options: Vec::new(),
463                    plan_mode_active: None,
464                    turn_review: None,
465                    available_commands: Vec::new(),
466                    // What the durable record alone can justify. The phone server
467                    // widens these once it knows whether the session manager holds
468                    // the session and what the agent has advertised.
469                    capabilities: ViewerSessionCapabilities {
470                        open: false,
471                        prompt: false,
472                        run_shell: false,
473                        cancel_turn: false,
474                        cancel_operation: false,
475                        stop: lifecycle.is_dashboard_visible(),
476                        rename: true,
477                        resume: !lifecycle.is_dashboard_visible(),
478                        move_session: false,
479                        set_config: false,
480                        set_plan_mode: false,
481                    },
482                }
483            })
484            .collect();
485        let profiles = config
486            .enabled_profiles()
487            .map(|(id, profile)| ViewerProfile {
488                id: id.to_owned(),
489                harness_kind: profile.kind.id().into(),
490                quota: None,
491            })
492            .collect();
493        let targets = config
494            .targets
495            .iter()
496            .map(|(id, target)| ViewerTarget {
497                id: id.clone(),
498                kind: target_kind_name(target).into(),
499                requires_project_directory: matches!(
500                    target,
501                    TargetTemplate::LocalBare | TargetTemplate::SshBare { .. }
502                ),
503                recent_project_directories: project_history_host(target)
504                    .map(|host| {
505                        state
506                            .project_directories(host)
507                            .iter()
508                            .map(|directory| directory.to_string_lossy().into_owned())
509                            .collect()
510                    })
511                    .unwrap_or_default(),
512            })
513            .collect();
514        let bundles = config
515            .bundles
516            .iter()
517            .map(|(id, bundle)| ViewerBundle {
518                id: id.clone(),
519                primary_repository: bundle.primary_repo.clone(),
520                repositories: bundle
521                    .repositories
522                    .iter()
523                    .map(|repository| ViewerRepository {
524                        id: repository.id.clone(),
525                        github: repository.github.clone(),
526                        destination: repository.destination.to_string_lossy().into_owned(),
527                    })
528                    .collect(),
529            })
530            .collect();
531        Self {
532            revision,
533            generated_at: now_unix().to_string(),
534            server_time_ms: mj_core::clock::epoch_millis(),
535            workspaces: Vec::new(),
536            sessions,
537            profiles,
538            targets,
539            bundles,
540            review_config: ViewerReviewConfig {
541                enabled: config.review.enabled,
542                tier: config.review.tier.label().to_owned(),
543                profile: config.review.profile.clone(),
544            },
545            subagents_enabled: config.subagents.enabled,
546            capacity: Vec::new(),
547            launch_failures: Vec::new(),
548        }
549    }
550}
551
552/// A stable, opaque grouping key for a project.
553///
554/// The controller's own project identity is a bundle, filesystem path, or Git
555/// remote, and this projection publishes neither. A digest groups exactly as
556/// well and says nothing: two sessions in the same project share a key, and a
557/// key on its own reveals no source.
558fn project_key(identity: &str) -> String {
559    use sha2::Digest as _;
560    let digest = Sha256::digest(identity.as_bytes());
561    digest[..8]
562        .iter()
563        .map(|byte| format!("{byte:02x}"))
564        .collect()
565}
566
567#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
568#[serde(deny_unknown_fields)]
569pub struct ViewerSession {
570    #[serde(default, skip_serializing_if = "Option::is_none")]
571    pub capacity_retry: Option<mj_core::relay::CapacityRetry>,
572    pub id: String,
573    #[serde(default, skip_serializing_if = "String::is_empty")]
574    pub workspace_id: String,
575    pub title: String,
576    /// Parent ownership for a borrowed-target child session.
577    #[serde(default, skip_serializing_if = "Option::is_none")]
578    pub subagent_parent_id: Option<String>,
579    /// The stable task label chosen by the parent when it spawned this child.
580    #[serde(default, skip_serializing_if = "Option::is_none")]
581    pub subagent_task_name: Option<String>,
582    /// Direct children of this parent. Children are deliberately never nested.
583    #[serde(default, skip_serializing_if = "Vec::is_empty")]
584    pub subagent_session_ids: Vec<String>,
585    pub harness_kind: String,
586    pub profile_id: String,
587    pub bundle_id: String,
588    pub target_id: String,
589    pub state: String,
590    pub created_at: String,
591    pub updated_at: String,
592    pub has_error: bool,
593    /// Public identifiers and repair guidance only; never raw runtime errors.
594    #[serde(default, skip_serializing_if = "Option::is_none")]
595    pub configuration_issue: Option<String>,
596
597    #[serde(default, skip_serializing_if = "Vec::is_empty")]
598    pub preview: Vec<String>,
599    #[serde(default, skip_serializing_if = "Vec::is_empty")]
600    pub queued_prompts: Vec<ViewerQueuedPrompt>,
601    #[serde(default, skip_serializing_if = "Vec::is_empty")]
602    pub active_user_shells: Vec<ViewerUserShell>,
603    #[serde(default, skip_serializing_if = "Vec::is_empty")]
604    pub background_tasks: Vec<ViewerBackgroundTask>,
605    /// Form questions the session is blocked on, published so a phone can
606    /// answer them. These are the agent's own questions, already visible in
607    /// the transcript, so they travel whole rather than redacted.
608    #[serde(default, skip_serializing_if = "Vec::is_empty")]
609    pub pending_elicitations: Vec<ElicitationRequest>,
610    pub conversation_available: bool,
611    /// Whether this session's agent advertised support for image content in
612    /// prompts. The viewer offers the image controls only when it did, and the
613    /// server refuses images for a session that did not.
614    #[serde(default)]
615    pub prompt_images_supported: bool,
616    /// Target ids this session cannot resume on. Only the ids travel: the
617    /// controller's reasons name project paths and SSH hosts, which this
618    /// projection deliberately keeps on the controller.
619    ///
620    /// Retained beside `compatible_resume_targets` so a viewer cached from
621    /// before that field existed keeps working through a deployment.
622    #[serde(default, skip_serializing_if = "Vec::is_empty")]
623    pub incompatible_resume_targets: Vec<String>,
624    /// Target ids this session can resume on, so the browser never has to
625    /// subtract one set from another to find out.
626    #[serde(default, skip_serializing_if = "Vec::is_empty")]
627    pub compatible_resume_targets: Vec<String>,
628    /// The canonical short source label for this session: a bundle name, path
629    /// leaf, or repository name, never a source path itself.
630    #[serde(default, skip_serializing_if = "String::is_empty")]
631    pub project_label: String,
632    /// A stable key for grouping sessions by project. The controller's own
633    /// source identity stays private, so what travels is a digest of it:
634    /// enough to group by, and nothing to read.
635    #[serde(default, skip_serializing_if = "String::is_empty")]
636    pub project_key: String,
637    /// The configured target's human-facing project location. This is the
638    /// same target projection the terminal uses while a session is running.
639    #[serde(default)]
640    pub display_location: String,
641    pub lifecycle: ViewerLifecycleCategory,
642    /// A lifecycle transition temporarily owns this session's conversation.
643    /// This remains separate from the coarse lifecycle category so Move can
644    /// hide the old transcript while its durable record is still `Running`.
645    #[serde(default)]
646    pub transitioning: bool,
647    /// How far the controller's projection of this session has advanced. A
648    /// phone compares it against its own read frontier to know what is unread,
649    /// without fetching a transcript to find out.
650    #[serde(default)]
651    pub latest_event_ordinal: u64,
652    /// Durable relay receipt watermark from the materialized projection.
653    /// It remains absent when the background snapshot pipeline has not yet
654    /// delivered a projection for this session.
655    #[serde(default, skip_serializing_if = "Option::is_none")]
656    pub last_activity_at_ms: Option<i64>,
657    /// Structured live activity, absent when no operational relay snapshot is
658    /// available for this session.
659    #[serde(default, skip_serializing_if = "Option::is_none")]
660    pub activity_details: Option<ViewerActivityDetails>,
661    #[serde(default, skip_serializing_if = "Option::is_none")]
662    pub operation: Option<ViewerOperation>,
663    /// Safe recovery choices for a failed or cancelled Move. Diagnostics and
664    /// checkpoint paths remain on the controller; this contains only the
665    /// settings a person may choose again.
666    #[serde(default, skip_serializing_if = "Option::is_none")]
667    pub move_recovery: Option<ViewerMoveRecovery>,
668    #[serde(default)]
669    pub chat_phase: ViewerChatPhase,
670    /// Known live activity is idle: no foreground turn, tool, or background work.
671    /// Missing operational state must not be presented as confirmed idle.
672    #[serde(default)]
673    pub is_idle: bool,
674    /// What this session is doing, in the words the dashboard row uses:
675    /// `Turn 43m36s  Step 12s`, `BG 43m36s`, or `[idle]`.
676    #[serde(default, skip_serializing_if = "String::is_empty")]
677    pub activity: String,
678    /// The settings the harness advertised, with the values it accepts.
679    #[serde(default, skip_serializing_if = "Vec::is_empty")]
680    pub config_options: Vec<ViewerConfigOption>,
681    /// Whether plan mode is on, or `None` when this harness has no plan mode.
682    #[serde(default, skip_serializing_if = "Option::is_none")]
683    pub plan_mode_active: Option<bool>,
684    /// The review the daemon is running for this session, if any. A phone
685    /// renders the same review the terminal does and resolves it the same way.
686    #[serde(default, skip_serializing_if = "Option::is_none")]
687    pub turn_review: Option<ViewerTurnReview>,
688    /// The Mjolnir commands this session accepts, published rather than hardcoded
689    /// in the browser: a command list kept in two places is a command list that
690    /// drifts, which is how `/review` went missing from the phone.
691    #[serde(default, skip_serializing_if = "Vec::is_empty")]
692    pub available_commands: Vec<ViewerMjCommand>,
693    pub capabilities: ViewerSessionCapabilities,
694}
695
696#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
697#[serde(deny_unknown_fields)]
698pub struct ViewerMoveRecovery {
699    pub operation_id: String,
700    pub source_profile_id: String,
701    pub source_target_template_id: String,
702    pub destination_profile_id: String,
703    pub destination_target_template_id: String,
704    pub phase: String,
705    pub queue: String,
706    pub clear_resource_allocation: bool,
707    /// The source settings are retained so Resume cannot silently inherit a
708    /// partially converted destination record after a failed Move.
709    #[serde(default)]
710    pub source_additional_mounts: Vec<AdditionalMount>,
711    #[serde(default)]
712    pub source_resource_allocation: Option<SessionResourceAllocation>,
713    /// The exact destination settings are needed when a queue admission
714    /// checkpoint pins retry to the already-provisioned destination.
715    #[serde(default)]
716    pub destination_additional_mounts: Vec<AdditionalMount>,
717    #[serde(default)]
718    pub destination_resource_allocation: Option<SessionResourceAllocation>,
719    pub checkpoint_retained: bool,
720    pub destination_ready: bool,
721    pub queue_admission_started: bool,
722    pub queue_admission_finished: bool,
723}
724
725impl ViewerMoveRecovery {
726    #[must_use]
727    pub fn from_operation(operation: &MoveOperation) -> Option<Self> {
728        if matches!(operation.phase, MovePhase::Completed) {
729            return None;
730        }
731        Some(Self {
732            operation_id: operation.operation_id.clone(),
733            source_profile_id: operation.source_profile_id.clone(),
734            source_target_template_id: operation.source_target_template_id.clone(),
735            destination_profile_id: operation.selection.profile_id.clone().unwrap_or_default(),
736            destination_target_template_id: operation
737                .selection
738                .target_template_id
739                .clone()
740                .unwrap_or_default(),
741            phase: match operation.phase {
742                MovePhase::Preparing => "preparing",
743                MovePhase::ClosingSource => "closing_source",
744                MovePhase::ResumingDestination => "resuming_destination",
745                MovePhase::StartingQueue => "starting_queue",
746                MovePhase::Completed => "completed",
747                MovePhase::Failed => "failed",
748                MovePhase::Cancelled => "cancelled",
749            }
750            .into(),
751            queue: match operation.queue {
752                ResumeQueueDisposition::Start => "start",
753                ResumeQueueDisposition::Discard => "discard",
754            }
755            .into(),
756            clear_resource_allocation: operation.selection.clear_resource_allocation,
757            source_additional_mounts: operation.source_additional_mounts.clone(),
758            source_resource_allocation: operation.source_resource_allocation.clone(),
759            destination_additional_mounts: operation
760                .selection
761                .additional_mounts
762                .clone()
763                .unwrap_or_default(),
764            destination_resource_allocation: operation.selection.resource_allocation.clone(),
765            checkpoint_retained: operation.checkpoint.is_some(),
766            destination_ready: operation.destination_target.is_some()
767                && operation.destination_native_session_id.is_some(),
768            queue_admission_started: operation.queue_admission_started,
769            queue_admission_finished: operation.queue_admission_finished,
770        })
771    }
772}
773
774impl ViewerSession {
775    /// Apply a resolved controller source while keeping paths and remotes out
776    /// of the public projection.
777    pub fn set_project_source(&mut self, source: &ProjectSourceIdentity) {
778        self.project_label = source.short.clone();
779        self.project_key = project_key(&source.key);
780    }
781}
782
783// One wire representation for the UI and native API activity facts.
784pub use crate::database::{
785    ApiActivityDetails as ViewerActivityDetails, ApiActivityKind as ViewerActivityKind,
786};
787
788/// One Mjolnir command a phone may offer for this session.
789#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
790#[serde(deny_unknown_fields)]
791pub struct ViewerMjCommand {
792    pub name: String,
793    pub description: String,
794    /// Whether Mjolnir handles this command locally or forwards it to the
795    /// active agent.
796    pub source: ViewerCommandSource,
797    /// What the argument is called, when the command takes one.
798    #[serde(default, skip_serializing_if = "Option::is_none")]
799    pub argument: Option<String>,
800}
801
802#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
803#[serde(rename_all = "snake_case")]
804pub enum ViewerCommandSource {
805    Mj,
806    Agent,
807}
808
809/// Public review configuration: exactly what `/review status` needs.
810#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
811#[serde(deny_unknown_fields)]
812pub struct ViewerReviewConfig {
813    pub enabled: bool,
814    pub tier: String,
815    #[serde(default, skip_serializing_if = "Option::is_none")]
816    pub profile: Option<String>,
817}
818
819/// A turn review as a phone renders it.
820#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
821#[serde(deny_unknown_fields)]
822pub struct ViewerTurnReview {
823    /// `quick` or `extended`.
824    pub tier: String,
825    /// What the review is doing, in one line.
826    pub status: String,
827    /// One row per reviewing agent: its label and where it has got to.
828    #[serde(default, skip_serializing_if = "Vec::is_empty")]
829    pub roles: Vec<ViewerReviewRole>,
830    /// Present once the review has reached a verdict the user must answer.
831    #[serde(default, skip_serializing_if = "Option::is_none")]
832    pub verdict: Option<ViewerReviewVerdict>,
833}
834
835#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
836#[serde(deny_unknown_fields)]
837pub struct ViewerReviewRole {
838    pub label: String,
839    /// `pending`, `running`, `done`, `findings`, or `failed`.
840    pub state: String,
841}
842
843#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
844#[serde(deny_unknown_fields)]
845pub struct ViewerReviewVerdict {
846    /// `clean`, `findings`, or `failed`.
847    pub kind: String,
848    /// The findings, or the failure's reason.
849    pub text: String,
850    /// The resolutions this verdict accepts: `forward`, `dismiss`, `cancel`.
851    /// A phone shows the rest disabled rather than hiding them, so the buttons
852    /// do not move under a thumb.
853    #[serde(default, skip_serializing_if = "Vec::is_empty")]
854    pub allowed: Vec<String>,
855}
856
857impl ViewerTurnReview {
858    /// The phone's view of one review the daemon is running.
859    #[must_use]
860    pub fn from_runtime(review: &crate::review_host::RuntimeReviewView) -> Self {
861        Self {
862            tier: review.tier.label().to_owned(),
863            status: review.status.clone(),
864            roles: review
865                .roles
866                .iter()
867                .map(|role| ViewerReviewRole {
868                    label: role.label.clone(),
869                    state: role.state.label().to_owned(),
870                })
871                .collect(),
872            verdict: review.verdict.as_ref().map(|verdict| ViewerReviewVerdict {
873                kind: match verdict.kind {
874                    crate::review_host::VerdictKind::Clean => "clean",
875                    crate::review_host::VerdictKind::Findings => "findings",
876                    crate::review_host::VerdictKind::Failed => "failed",
877                }
878                .to_owned(),
879                text: verdict.text.clone(),
880                allowed: verdict
881                    .allowed
882                    .iter()
883                    .filter_map(resolution_name)
884                    .map(str::to_owned)
885                    .collect(),
886            }),
887        }
888    }
889}
890
891/// The wire name of one resolution, shared by the projection and the action
892/// that performs it, so a button's name is the name the server accepts.
893#[must_use]
894pub fn resolution_name(resolution: &mj_core::review::driver::Resolution) -> Option<&'static str> {
895    match resolution {
896        mj_core::review::driver::Resolution::Forwarded => Some("forward"),
897        mj_core::review::driver::Resolution::Dismissed => Some("dismiss"),
898        mj_core::review::driver::Resolution::Cancelled => Some("cancel"),
899        // Not resolutions a surface asks for: the review reaches these itself.
900        mj_core::review::driver::Resolution::NothingToReview
901        | mj_core::review::driver::Resolution::CoverageStarted => None,
902    }
903}
904
905/// The resolution a phone's button asked for.
906#[must_use]
907pub fn resolution_from_name(name: &str) -> Option<mj_core::review::driver::Resolution> {
908    match name {
909        "forward" => Some(mj_core::review::driver::Resolution::Forwarded),
910        "dismiss" => Some(mj_core::review::driver::Resolution::Dismissed),
911        "cancel" => Some(mj_core::review::driver::Resolution::Cancelled),
912        _ => None,
913    }
914}
915
916#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
917#[serde(deny_unknown_fields)]
918pub struct ViewerWorkspace {
919    pub id: String,
920    pub name: String,
921}
922
923#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
924#[serde(deny_unknown_fields)]
925pub struct ViewerQueuedPrompt {
926    pub id: String,
927    pub text: String,
928    pub created_at: String,
929}
930
931#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
932#[serde(deny_unknown_fields)]
933pub struct ViewerUserShell {
934    pub id: String,
935    pub command: String,
936    pub started_at_ms: Option<i64>,
937}
938
939/// One command the active agent left running in the background.
940#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
941#[serde(deny_unknown_fields)]
942pub struct ViewerBackgroundTask {
943    pub id: String,
944    pub command: String,
945    pub started_at_ms: i64,
946    pub can_stop: bool,
947}
948
949#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
950#[serde(deny_unknown_fields)]
951pub struct ViewerProfile {
952    pub id: String,
953    pub harness_kind: String,
954    #[serde(default, skip_serializing_if = "Option::is_none")]
955    pub quota: Option<ViewerQuota>,
956}
957
958/// One usage window a harness reports, such as a weekly or five-hour limit.
959///
960/// `percent_used` is the figure a person acts on, so it travels as a number
961/// rather than inside a sentence. The controller computes headroom; this is
962/// its complement, because a bar fills as a limit is consumed.
963#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
964#[serde(deny_unknown_fields)]
965pub struct ViewerQuotaWindow {
966    pub label: String,
967    #[serde(default, skip_serializing_if = "Option::is_none")]
968    pub percent_used: Option<u8>,
969    #[serde(default, skip_serializing_if = "Option::is_none")]
970    pub resets_at: Option<String>,
971    /// Whether this window is on course to run out before it resets. The
972    /// controller already computes this; a phone should not have to.
973    pub projects_exhaustion_before_reset: bool,
974}
975
976#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
977#[serde(deny_unknown_fields)]
978pub struct ViewerQuota {
979    /// One-line rendering, kept so a viewer cached from before the structured
980    /// windows existed keeps working. The Quota page renders `windows`.
981    pub summary: String,
982    #[serde(default, skip_serializing_if = "Vec::is_empty")]
983    pub windows: Vec<ViewerQuotaWindow>,
984    #[serde(default, skip_serializing_if = "Option::is_none")]
985    pub resets_at: Option<String>,
986    pub stale: bool,
987    /// When the reading was taken. A pulled view delivered by push cannot be
988    /// told from a current one without its age, so this is not optional.
989    #[serde(default)]
990    pub refreshed_at_epoch_seconds: u64,
991    /// Error state only. Raw vendor errors may contain paths or account data
992    /// and remain on the controller.
993    pub has_error: bool,
994}
995
996/// What one host or fleet has, and how fresh the reading is.
997///
998/// Every field that carries a reading is optional, and `sampled_at_epoch_seconds`
999/// is present whenever any of them is: a reading without its age cannot be
1000/// told from a stale one, which is exactly the case where it matters.
1001#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1002#[serde(deny_unknown_fields)]
1003pub struct ViewerTargetCapacity {
1004    pub id: String,
1005    /// The host or fleet as a person names it. Never a locator, an address or
1006    /// a full path.
1007    pub label: String,
1008    pub target_ids: Vec<String>,
1009    #[serde(default, skip_serializing_if = "Option::is_none")]
1010    pub cpu_percent: Option<u8>,
1011    #[serde(default, skip_serializing_if = "Option::is_none")]
1012    pub memory_used_bytes: Option<u64>,
1013    #[serde(default, skip_serializing_if = "Option::is_none")]
1014    pub memory_total_bytes: Option<u64>,
1015    #[serde(default, skip_serializing_if = "Option::is_none")]
1016    pub logical_cores: Option<u64>,
1017    #[serde(default, skip_serializing_if = "Option::is_none")]
1018    pub disk_total_bytes: Option<u64>,
1019    /// How many machines a fleet is running. Absent for a plain host.
1020    #[serde(default, skip_serializing_if = "Option::is_none")]
1021    pub virtual_machines: Option<u64>,
1022    #[serde(default, skip_serializing_if = "Option::is_none")]
1023    pub sampled_at_epoch_seconds: Option<u64>,
1024    pub refreshing: bool,
1025    pub stale: bool,
1026    /// Whether the last probe failed. The probe's own message names hosts and
1027    /// commands, so it stays on the controller.
1028    pub has_error: bool,
1029}
1030
1031#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1032#[serde(deny_unknown_fields)]
1033pub struct ViewerTarget {
1034    pub id: String,
1035    pub kind: String,
1036    pub requires_project_directory: bool,
1037    /// Recent raw project directories for this target's physical host. Managed
1038    /// targets intentionally publish an empty list because they select a
1039    /// configured bundle rather than a host checkout.
1040    #[serde(default)]
1041    pub recent_project_directories: Vec<String>,
1042}
1043
1044#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1045#[serde(deny_unknown_fields)]
1046pub struct ViewerBundle {
1047    pub id: String,
1048    pub primary_repository: String,
1049    pub repositories: Vec<ViewerRepository>,
1050}
1051
1052#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1053#[serde(deny_unknown_fields)]
1054pub struct ViewerRepository {
1055    pub id: String,
1056    pub github: Option<String>,
1057    pub destination: String,
1058}
1059
1060/// What a phone may do with one session, as the controller sees it.
1061///
1062/// The viewer renders a control because a flag here is true, and for no other
1063/// reason. Deciding legality in the browser means copying controller policy
1064/// into JavaScript, where it drifts silently: the browser cannot know that a
1065/// session is unmanaged, that a lifecycle operation holds it, or that the
1066/// harness never advertised the option a control would change.
1067#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
1068#[serde(deny_unknown_fields)]
1069pub struct ViewerSessionCapabilities {
1070    pub open: bool,
1071    pub prompt: bool,
1072    pub run_shell: bool,
1073    /// Cancel the turn the agent is working on now, leaving the session alive.
1074    pub cancel_turn: bool,
1075    /// Cancel the provision, resume or stop currently running.
1076    pub cancel_operation: bool,
1077    pub stop: bool,
1078    pub rename: bool,
1079    pub resume: bool,
1080    /// Prepare and confirm a daemon-owned move to a compatible profile or
1081    /// target. The browser must never compose Stop and Resume itself.
1082    #[serde(default)]
1083    pub move_session: bool,
1084    pub set_config: bool,
1085    pub set_plan_mode: bool,
1086}
1087
1088/// The small set of states a phone reasons about, alongside the precise state.
1089///
1090/// A phone groups and filters by this; it shows the precise `state` string as
1091/// the word it prints. Collapsing here rather than in the browser keeps one
1092/// definition of "live" in the controller.
1093#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1094#[serde(rename_all = "kebab-case")]
1095pub enum ViewerLifecycleCategory {
1096    Live,
1097    Starting,
1098    Stopping,
1099    Stopped,
1100    Failed,
1101}
1102
1103impl ViewerLifecycleCategory {
1104    const fn of(state: SessionState) -> Self {
1105        match state {
1106            SessionState::Provisioning => Self::Starting,
1107            SessionState::Running | SessionState::Disconnected | SessionState::Checkpointing => {
1108                Self::Live
1109            }
1110            SessionState::Closing | SessionState::Destroying => Self::Stopping,
1111            SessionState::Stopped => Self::Stopped,
1112            SessionState::Lost | SessionState::Error | SessionState::DestroyedWithDataLoss => {
1113                Self::Failed
1114            }
1115        }
1116    }
1117
1118    /// Whether this session belongs on the dashboard. Stopped and failed
1119    /// sessions belong to the resume flow instead, which is where a person can
1120    /// do something about them.
1121    pub const fn is_dashboard_visible(self) -> bool {
1122        matches!(self, Self::Live | Self::Starting | Self::Stopping)
1123    }
1124}
1125
1126#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1127#[serde(rename_all = "kebab-case")]
1128pub enum ViewerOperationKind {
1129    Create,
1130    Resume,
1131    Move,
1132    Stop,
1133    Destroy,
1134    Cleanup,
1135    Checkpoint,
1136}
1137
1138impl ViewerOperationKind {
1139    pub const fn transition_kind(self) -> Option<SessionTransitionKind> {
1140        match self {
1141            Self::Create => Some(SessionTransitionKind::Starting),
1142            Self::Resume => Some(SessionTransitionKind::Resuming),
1143            Self::Move => Some(SessionTransitionKind::Moving),
1144            Self::Stop => Some(SessionTransitionKind::Stopping),
1145            Self::Destroy | Self::Cleanup => Some(SessionTransitionKind::Destroying),
1146            // Checkpointing is an ordinary live-session operation. It must
1147            // not replace a readable conversation with a placeholder.
1148            Self::Checkpoint => None,
1149        }
1150    }
1151
1152    pub const fn label(self) -> &'static str {
1153        match self {
1154            Self::Create => "Starting",
1155            Self::Resume => "Resuming",
1156            Self::Move => "Moving",
1157            Self::Stop => "Stopping",
1158            Self::Destroy => "Destroying",
1159            Self::Cleanup => "Cleaning up",
1160            Self::Checkpoint => "Checkpointing",
1161        }
1162    }
1163}
1164
1165/// One stage of a running operation, with the clock it started on.
1166#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1167#[serde(deny_unknown_fields)]
1168pub struct ViewerOperationStage {
1169    pub label: String,
1170    pub started_at_epoch_seconds: u64,
1171}
1172
1173/// A provision, resume, stop or checkpoint the controller is running now.
1174///
1175/// A phone that asked for one of these got `202 Accepted` and an identifier
1176/// rather than a result, because the work outlives the request. This is how it
1177/// finds out what happened.
1178#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1179#[serde(deny_unknown_fields)]
1180pub struct ViewerOperation {
1181    pub id: String,
1182    pub session_id: String,
1183    pub kind: ViewerOperationKind,
1184    pub started_at_epoch_seconds: u64,
1185    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1186    pub stages: Vec<ViewerOperationStage>,
1187    /// Controller-authored and already meant for a person to read, unlike the
1188    /// error text this projection keeps on the controller.
1189    #[serde(default, skip_serializing_if = "Option::is_none")]
1190    pub notice: Option<String>,
1191    pub cancellable: bool,
1192}
1193
1194/// What the agent is doing, mirroring `RelayExecutionState`.
1195#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
1196#[serde(rename_all = "kebab-case")]
1197pub enum ViewerChatPhase {
1198    #[default]
1199    Idle,
1200    Running,
1201    Closing,
1202    Closed,
1203}
1204
1205#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1206#[serde(deny_unknown_fields)]
1207pub struct ViewerConfigChoice {
1208    pub value: String,
1209    pub name: String,
1210    #[serde(default, skip_serializing_if = "Option::is_none")]
1211    pub description: Option<String>,
1212}
1213
1214/// One setting the harness advertised, with the values it will accept.
1215///
1216/// The browser completes `/model` and `/effort` from this rather than from a
1217/// list of its own, so a harness that offers something new needs no viewer
1218/// change, and a viewer can never offer a value the harness would refuse.
1219#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1220#[serde(deny_unknown_fields)]
1221pub struct ViewerConfigOption {
1222    pub key: String,
1223    pub label: String,
1224    #[serde(default, skip_serializing_if = "Option::is_none")]
1225    pub current: Option<String>,
1226    pub choices: Vec<ViewerConfigChoice>,
1227}
1228
1229/// The complete set of operations a phone may ask the controller to perform.
1230/// Secret/config editing is intentionally not representable here, and the one
1231/// destructive variant, `ForceClose`, is not representable on the wire: it is
1232/// `#[serde(skip)]` so only in-process callers such as the HTTP API can build
1233/// it.
1234#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1235#[serde(tag = "action", rename_all = "kebab-case", deny_unknown_fields)]
1236pub enum ControllerAction {
1237    New {
1238        #[serde(default)]
1239        create_managed_worktree: Option<bool>,
1240        /// None follows the global `[subagents] enabled` setting.
1241        #[serde(default)]
1242        mjolnir_subagents: Option<bool>,
1243        /// Which workspace the session belongs to. Optional on the wire so a
1244        /// viewer cached from before workspaces reached the phone still parses,
1245        /// but a controller holding more than one workspace refuses an empty
1246        /// one rather than guessing.
1247        #[serde(default)]
1248        workspace_id: String,
1249        profile_id: String,
1250        bundle_id: String,
1251        target_id: String,
1252        /// Absent means "derive it", which is what the terminal does.
1253        #[serde(default)]
1254        title: Option<String>,
1255        #[serde(default)]
1256        project_directory: Option<PathBuf>,
1257        /// The repositories the person was shown as having uncommitted changes
1258        /// and chose to launch over anyway.
1259        ///
1260        /// This names them rather than being a bare yes, so an acknowledgement
1261        /// cannot be replayed against a set the person never saw: if a
1262        /// different repository has gone dirty since the preflight, the launch
1263        /// stops and asks again.
1264        #[serde(default, skip_serializing_if = "Vec::is_empty")]
1265        dirty_ack: Vec<String>,
1266    },
1267    /// Give a session a new title. The terminal calls this a rename.
1268    Rename {
1269        session_id: String,
1270        title: String,
1271    },
1272    /// Stop the turn the agent is working on, leaving the session alive. This
1273    /// is not `Cancel`, which stops a provision, resume or stop.
1274    CancelTurn {
1275        session_id: String,
1276    },
1277    /// Change one setting the harness advertised, such as `model` or `effort`.
1278    SetConfig {
1279        session_id: String,
1280        key: String,
1281        value: String,
1282    },
1283    /// Turn plan mode on or off. The harness decides how, which is why this
1284    /// carries an intent rather than a mode id.
1285    SetPlanMode {
1286        session_id: String,
1287        active: bool,
1288    },
1289    RefreshQuota {
1290        profile_id: String,
1291    },
1292    RefreshCapacity {
1293        target_id: String,
1294    },
1295    Resume {
1296        session_id: String,
1297        workspace_id: String,
1298        profile_id: String,
1299        target_id: String,
1300        queue: ResumeQueueDisposition,
1301        /// A failed Move supplies the settings recorded before source
1302        /// teardown. Ordinary Resume requests leave these absent and retain
1303        /// the historical inheritance behavior.
1304        #[serde(default)]
1305        additional_mounts: Option<Vec<AdditionalMount>>,
1306        #[serde(default)]
1307        resource_allocation: Option<SessionResourceAllocation>,
1308    },
1309    /// Confirm a previously prepared move. Preparation is a separate
1310    /// authenticated request so changing the destination cannot be smuggled
1311    /// into a confirmation from an older browser form.
1312    Move {
1313        request: MoveSessionRequest,
1314    },
1315    Open {
1316        session_id: String,
1317    },
1318    Prompt {
1319        session_id: String,
1320        text: String,
1321        /// Images to send with the prompt. The controller turns each one into
1322        /// the ACP image content block its prompt path already speaks.
1323        #[serde(default, skip_serializing_if = "Vec::is_empty")]
1324        images: Vec<ViewerPromptImage>,
1325    },
1326    RunShell {
1327        session_id: String,
1328        command: String,
1329    },
1330    CancelShell {
1331        session_id: String,
1332        shell_command_id: String,
1333    },
1334    Close {
1335        session_id: String,
1336    },
1337    /// Destroy a session without checkpointing it: the live target is torn
1338    /// down, the recovery archive is removed, and sub-agent children are
1339    /// destroyed first. This is irreversible.
1340    ///
1341    /// Skipped by serde on purpose. The browser viewer posts this enum to
1342    /// `/actions`, so a wire request must never be able to name this variant;
1343    /// it is reachable only from the HTTP API, which builds it in process.
1344    #[serde(skip)]
1345    ForceClose {
1346        session_id: String,
1347    },
1348    Cancel {
1349        session_id: String,
1350    },
1351    /// Review the turn this session just finished.
1352    StartReview {
1353        session_id: String,
1354    },
1355    /// Forward the findings, dismiss them, or cancel the open review.
1356    ResolveReview {
1357        session_id: String,
1358        /// `forward`, `dismiss`, or `cancel`.
1359        resolution: String,
1360    },
1361    RemoveQueuedPrompt {
1362        session_id: String,
1363        queue_id: String,
1364    },
1365    /// Answer one of the session's pending form questions.
1366    RespondElicitation {
1367        session_id: String,
1368        elicitation_id: String,
1369        response: ElicitationResponse,
1370    },
1371}
1372
1373/// One image a phone attached to a prompt. Legacy callers may send inline
1374/// base64 data; the server normalizes it into an attachment before dispatch.
1375#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1376#[serde(deny_unknown_fields)]
1377pub struct ViewerPromptImage {
1378    /// Legacy inline image bytes. New browser uploads and normalized inline
1379    /// prompts carry an attachment reference and leave this empty.
1380    #[serde(default)]
1381    pub data_base64: String,
1382    pub mime_type: String,
1383    pub width: u32,
1384    pub height: u32,
1385    /// Session-scoped, immutable image bytes. The worker resolves this just
1386    /// before dispatch, keeping browser actions and durable commands small.
1387    #[serde(default, skip_serializing_if = "Option::is_none")]
1388    pub attachment: Option<AttachmentRef>,
1389}
1390
1391/// The controller's answer to one phone action.
1392///
1393/// The answer means "accepted", not "finished": provisioning, resume and close
1394/// run for minutes, and a phone on a mobile network drops a request held open
1395/// that long. How the action then goes travels in snapshots — session state,
1396/// queued prompts, transcripts, and `has_error`.
1397///
1398/// Only the outcome crosses this boundary. The controller's own failure text
1399/// names profile homes, project paths and SSH hosts, so it stays on the
1400/// controller and the phone gets a fixed message it can act on.
1401#[derive(Debug, Clone, PartialEq, Eq)]
1402pub enum ActionOutcome {
1403    /// Admitted and now running; watch the snapshot for what happens next.
1404    ///
1405    /// A `new` action carries the published session id, which is the only way
1406    /// its caller learns what it just created.
1407    Accepted { session_id: Option<String> },
1408    /// The controller already runs as many phone actions as it allows.
1409    Busy,
1410    /// This session already has an operation running.
1411    SessionBusy,
1412    /// A cancel found no operation to cancel.
1413    NotCancellable,
1414    /// The controller could not start the action at all.
1415    Failed,
1416}
1417
1418impl ActionOutcome {
1419    /// Admitted, with no session id to report.
1420    pub const fn accepted() -> Self {
1421        Self::Accepted { session_id: None }
1422    }
1423
1424    /// The published session id, when this outcome carries one.
1425    pub fn session_id(&self) -> Option<&str> {
1426        match self {
1427            Self::Accepted { session_id } => session_id.as_deref(),
1428            _ => None,
1429        }
1430    }
1431
1432    /// The reply an outcome owes the phone, or `None` when it was accepted.
1433    fn rejection(&self) -> Option<ApiError> {
1434        match self {
1435            Self::Accepted { .. } => None,
1436            Self::Busy => Some(ApiError::new(
1437                StatusCode::TOO_MANY_REQUESTS,
1438                "the controller is at its concurrent action limit; retry shortly",
1439            )),
1440            Self::SessionBusy => Some(ApiError::new(
1441                StatusCode::CONFLICT,
1442                "another operation is already running for this session",
1443            )),
1444            Self::NotCancellable => Some(ApiError::new(
1445                StatusCode::CONFLICT,
1446                "the session has no cancellable operation",
1447            )),
1448            Self::Failed => Some(ApiError::new(
1449                StatusCode::INTERNAL_SERVER_ERROR,
1450                "the controller could not start this action",
1451            )),
1452        }
1453    }
1454}
1455
1456#[derive(Debug)]
1457pub struct ControllerRequest {
1458    pub action: ControllerAction,
1459    pub reply: tokio::sync::oneshot::Sender<ActionOutcome>,
1460}
1461
1462/// A phone request to create or reuse a quick project bundle. This has its
1463/// own channel because bundle creation returns a durable id and must publish a
1464/// config snapshot before the HTTP request can succeed; [`ControllerAction`]
1465/// intentionally carries only action admission outcomes.
1466#[derive(Debug)]
1467pub struct BundleRequest {
1468    pub source: String,
1469    pub reply: tokio::sync::oneshot::Sender<Result<String, BundleFailure>>,
1470}
1471
1472/// Safe failure classes for bundle creation. Detailed controller errors stay
1473/// in daemon logs; a browser only needs to know whether to fix its source or
1474/// report a server-side failure.
1475#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1476pub enum BundleFailure {
1477    InvalidSource,
1478    Controller,
1479}
1480
1481/// A phone acknowledging how far it has read a conversation.
1482///
1483/// This deliberately is not a `ControllerAction`: the viewer posts it after
1484/// every conversation fetch, and a fetch follows every revision. Routing it
1485/// through the action pipeline made each receipt reload the controller, bump
1486/// the revision and broadcast a snapshot, which triggered the next fetch, so
1487/// viewer and controller never went quiet; it also consumed the session's
1488/// single action slot, intermittently rejecting real actions. A receipt
1489/// therefore travels on its own channel and only persists one cursor field.
1490/// A phone asking whether a session it is about to create would launch
1491/// cleanly, and which network sources it will use first.
1492///
1493/// This is not a `ControllerAction`: it starts nothing, it takes no session
1494/// slot, and it must answer before the person has decided anything. It also
1495/// needs the controller, because resolving a local repository's configured
1496/// remotes is a fact about the disk rather than about the projection.
1497#[derive(Debug)]
1498pub struct PreflightRequest {
1499    pub bundle_id: String,
1500    pub target_id: String,
1501    pub project_directory: Option<PathBuf>,
1502    pub remote_repairs: Vec<mj_core::local_git::LocalRemoteRepair>,
1503    pub reply: tokio::sync::oneshot::Sender<Result<PreflightNew, PreflightFailure>>,
1504}
1505
1506/// A move preparation is intentionally separate from action admission. It
1507/// performs read-only compatibility checks and returns the exact fingerprint
1508/// the later confirmation must echo; it never interrupts the source session.
1509#[derive(Debug)]
1510pub struct MovePreparationRequest {
1511    pub selection: MoveSelection,
1512    pub reply: tokio::sync::oneshot::Sender<Result<MovePreparation, String>>,
1513}
1514
1515/// A preflight can fail because the requested bare directory is unusable, an
1516/// isolated repository lacks a usable network source, or the controller-side
1517/// check itself could not complete. The HTTP surface keeps those outcomes
1518/// distinct without carrying filesystem, Git, or SSH details to the phone.
1519#[derive(Debug)]
1520pub enum PreflightFailure {
1521    Validation,
1522    /// A configured isolated-session repository cannot be used as a network
1523    /// source. The detail is safe for the phone and tells the person how to
1524    /// choose the supported raw-local path instead.
1525    InvalidRepository(String),
1526    Controller(String),
1527}
1528
1529/// One configured repository's network clone and publication destinations.
1530/// URLs have already been passed through the shared display sanitizer before
1531/// they reach a phone.
1532#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1533#[serde(deny_unknown_fields)]
1534pub struct PreflightRepository {
1535    pub id: String,
1536    pub fetch_url: String,
1537    pub default_branch: String,
1538    pub push_urls: Vec<String>,
1539}
1540
1541/// What a preflight found. Isolated sessions expose their complete network
1542/// source plan so the person can review it before creation. Raw-local targets
1543/// leave the plan empty because they use the selected checkout directly;
1544/// isolated targets set `local_changes_excluded` to make the copy boundary
1545/// explicit.
1546#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1547#[serde(deny_unknown_fields)]
1548pub struct PreflightNew {
1549    #[serde(default, skip_serializing_if = "Option::is_none")]
1550    pub project_directory: Option<PathBuf>,
1551    #[serde(default)]
1552    pub managed_worktree: mj_core::state::ManagedWorktreeOptions,
1553    #[serde(default)]
1554    pub remote_repairs: Vec<mj_core::local_git::LocalRemoteRepair>,
1555    #[serde(default)]
1556    pub dirty_repositories: Vec<String>,
1557    #[serde(default)]
1558    pub remote_repositories: Vec<PreflightRepository>,
1559    pub local_changes_excluded: bool,
1560}
1561
1562/// What a phone asks about, or stores against, its own identity.
1563///
1564/// These travel on their own channel rather than as actions, for the reason a
1565/// read receipt does: they are frequent, they start nothing, and routing them
1566/// through the action pipeline would consume the session's single action slot
1567/// and reload the controller on every keystroke.
1568#[derive(Debug)]
1569pub enum ClientStateRequest {
1570    Read {
1571        client_id: String,
1572        session_id: String,
1573        reply: tokio::sync::oneshot::Sender<Result<ViewerClientState, String>>,
1574    },
1575    SaveDraft {
1576        client_id: String,
1577        session_id: String,
1578        draft: String,
1579        reply: tokio::sync::oneshot::Sender<Result<(), String>>,
1580    },
1581    MarkWorkspaceRead {
1582        client_id: String,
1583        workspace_id: String,
1584        reply: tokio::sync::oneshot::Sender<Result<(), String>>,
1585    },
1586    History {
1587        session_id: String,
1588        query: String,
1589        scope: String,
1590        reply: tokio::sync::oneshot::Sender<Result<ViewerPromptHistory, String>>,
1591    },
1592}
1593
1594#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1595#[serde(deny_unknown_fields)]
1596pub struct ViewerClientState {
1597    pub draft: String,
1598    pub through_event_ordinal: u64,
1599}
1600
1601#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1602#[serde(deny_unknown_fields)]
1603pub struct ViewerPromptHistory {
1604    pub entries: Vec<String>,
1605    /// Whether the search stopped before it ran out of history, so a phone can
1606    /// say the answer is partial rather than presenting it as complete.
1607    pub truncated: bool,
1608}
1609
1610#[derive(Debug)]
1611pub struct ReadReceiptRequest {
1612    pub client_id: String,
1613    pub session_id: String,
1614    pub through: u64,
1615    pub reply: tokio::sync::oneshot::Sender<Result<(), String>>,
1616}
1617
1618/// A phone request to stop one currently projected background task.
1619///
1620/// This is intentionally not a [`ControllerAction`]. The request is already
1621/// validated against the current operational snapshot by the HTTP handler,
1622/// then the controller resolves the live session handle and waits for the
1623/// provider acknowledgement in a supervised task.
1624#[derive(Debug)]
1625pub struct BackgroundTaskStopRequest {
1626    pub session_id: String,
1627    pub background_task_id: String,
1628    pub reply: tokio::sync::oneshot::Sender<Result<(), BackgroundTaskStopFailure>>,
1629}
1630
1631#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1632pub enum BackgroundTaskStopFailure {
1633    /// The session manager could not resolve the live session handle.
1634    SessionUnavailable,
1635    /// The provider or relay rejected the stop request.
1636    Provider,
1637    /// The stop task itself failed before reaching the provider.
1638    Internal,
1639}
1640
1641#[derive(Clone)]
1642struct ServerState {
1643    snapshot_rx: watch::Receiver<ViewerSnapshot>,
1644    conversation_rx: watch::Receiver<BTreeMap<String, BrowserTranscript>>,
1645    action_tx: mpsc::Sender<ControllerRequest>,
1646    bundle_tx: mpsc::Sender<BundleRequest>,
1647    receipt_tx: mpsc::Sender<ReadReceiptRequest>,
1648    preflight_tx: mpsc::Sender<PreflightRequest>,
1649    move_preparation_tx: mpsc::Sender<MovePreparationRequest>,
1650    client_state_tx: mpsc::Sender<ClientStateRequest>,
1651    dictation_tx: mpsc::Sender<DictationRequest>,
1652    background_task_stop_tx: mpsc::Sender<BackgroundTaskStopRequest>,
1653    dictation_permits: Arc<Semaphore>,
1654    dictation_probe_permits: Arc<Semaphore>,
1655    shutdown: CancellationToken,
1656    viewer_code: Arc<str>,
1657    login_token: Arc<str>,
1658    cookie_key: Arc<[u8]>,
1659    session_ttl: Duration,
1660    secure_cookie: bool,
1661    code_guard: Arc<Mutex<CodeGuard>>,
1662    api_token: Arc<str>,
1663    subagent: Option<Arc<dyn api::SubagentBackend>>,
1664}
1665
1666/// Online-guessing defence for the deliberately small viewer code.
1667///
1668/// Five wrong codes lock the endpoint, and each further lockout lasts twice as
1669/// long as the one before it, up to an hour. The escalation count survives an
1670/// expired lockout, so a script cannot recover its full allowance by waiting;
1671/// a correct code clears the whole history, so one mistyped digit still costs
1672/// at most a single short wait.
1673#[derive(Debug, Default)]
1674struct CodeGuard {
1675    failures: u32,
1676    lockouts: u32,
1677    locked_until: Option<Instant>,
1678}
1679
1680impl CodeGuard {
1681    fn locked_at(&mut self, now: Instant) -> bool {
1682        match self.locked_until {
1683            Some(until) if now < until => true,
1684            Some(_) => {
1685                // The wait is served: allow a fresh run of attempts, but keep
1686                // the escalation history that makes the next wait longer.
1687                self.locked_until = None;
1688                self.failures = 0;
1689                false
1690            }
1691            None => false,
1692        }
1693    }
1694
1695    fn record_failure_at(&mut self, now: Instant) {
1696        self.failures = self.failures.saturating_add(1);
1697        if self.failures < MAX_CODE_FAILURES {
1698            return;
1699        }
1700        self.failures = 0;
1701        self.lockouts = self.lockouts.saturating_add(1);
1702        self.locked_until = Some(now + code_lockout(self.lockouts));
1703    }
1704}
1705
1706/// Doubling backoff, capped so the owner of a locked-out server is never shut
1707/// out for longer than it takes to notice.
1708fn code_lockout(lockouts: u32) -> Duration {
1709    let multiplier = 1_u32
1710        .checked_shl(lockouts.saturating_sub(1))
1711        .unwrap_or(u32::MAX);
1712    CODE_LOCKOUT_BASE
1713        .saturating_mul(multiplier)
1714        .min(CODE_LOCKOUT_CAP)
1715}
1716
1717fn router(options: ServerOptions) -> Router {
1718    let state = ServerState {
1719        snapshot_rx: options.snapshot_rx,
1720        conversation_rx: options.conversation_rx,
1721        action_tx: options.action_tx,
1722        bundle_tx: options.bundle_tx,
1723        receipt_tx: options.receipt_tx,
1724        preflight_tx: options.preflight_tx,
1725        move_preparation_tx: options.move_preparation_tx,
1726        client_state_tx: options.client_state_tx,
1727        dictation_tx: options.dictation_tx,
1728        background_task_stop_tx: options.background_task_stop_tx,
1729        dictation_permits: Arc::new(Semaphore::new(MAX_CONCURRENT_DICTATIONS)),
1730        dictation_probe_permits: Arc::new(Semaphore::new(MAX_CONCURRENT_DICTATIONS)),
1731        shutdown: options.shutdown,
1732        viewer_code: options.viewer_code.into(),
1733        login_token: options.login_token.into(),
1734        cookie_key: options.cookie_key.into(),
1735        session_ttl: options.session_ttl,
1736        secure_cookie: options.secure_cookie,
1737        code_guard: Arc::new(Mutex::new(CodeGuard::default())),
1738        api_token: options.api_token.into(),
1739        subagent: options.subagent,
1740    };
1741    let protected = Router::new()
1742        .route("/api/snapshot", get(snapshot))
1743        .route("/api/conversations/{session_id}", get(conversation))
1744        .route(
1745            "/api/conversations/{session_id}/read",
1746            post(mark_conversation_read),
1747        )
1748        .route("/api/events", get(events))
1749        .route("/api/bundles", post(create_bundle))
1750        .route("/api/preflight/new", post(preflight_new))
1751        .route("/api/moves/prepare", post(prepare_move))
1752        .route("/api/sessions/{session_id}/client-state", get(client_state))
1753        .route(
1754            "/api/sessions/{session_id}/dictation",
1755            get(dictation_availability).post(upload_dictation),
1756        )
1757        .route(
1758            "/api/sessions/{session_id}/background-tasks/stop",
1759            post(stop_background_task),
1760        )
1761        .route(
1762            "/api/sessions/{session_id}/attachments",
1763            post(upload_attachment).layer(DefaultBodyLimit::max(MAX_ATTACHMENT_UPLOAD_BYTES)),
1764        )
1765        .route(
1766            "/api/sessions/{session_id}/draft",
1767            put(save_draft).layer(DefaultBodyLimit::max(MAX_DRAFT_BYTES)),
1768        )
1769        .route("/api/sessions/{session_id}/history", get(prompt_history))
1770        .route(
1771            "/api/workspaces/{workspace_id}/read",
1772            post(mark_workspace_read),
1773        )
1774        .route(
1775            "/api/actions",
1776            post(action).layer(DefaultBodyLimit::max(MAX_PROMPT_BODY_BYTES)),
1777        )
1778        .route_layer(axum::middleware::from_fn_with_state(
1779            state.clone(),
1780            require_session,
1781        ));
1782    Router::new()
1783        .route("/", get(viewer))
1784        .route("/login", get(viewer))
1785        .route("/viewer.css", get(viewer_css))
1786        .route("/viewer.js", get(viewer_js))
1787        .route("/voice-worklet.js", get(voice_worklet_js))
1788        .route("/voice-worker.js", get(voice_worker_js))
1789        .route("/markdown.js", get(markdown_js))
1790        .route("/tool-output.js", get(tool_output_js))
1791        .route("/manifest.webmanifest", get(manifest))
1792        .route("/service-worker.js", get(service_worker))
1793        .route("/icon.svg", get(icon))
1794        .route("/icon-192.png", get(icon_192))
1795        .route("/icon-512.png", get(icon_512))
1796        .route("/maskable-512.png", get(maskable_512))
1797        .route("/apple-touch-icon.png", get(apple_touch_icon))
1798        .route("/fonts/jetbrains-mono.woff2", get(mono_font))
1799        .route("/auth/session", post(create_session).delete(clear_session))
1800        .route("/auth/login", get(create_session_from_query))
1801        .merge(protected)
1802        .nest("/api/v1", api::router(state.clone()))
1803        .layer(DefaultBodyLimit::max(MAX_BODY_BYTES))
1804        .layer(axum::middleware::from_fn(security_headers))
1805        .with_state(state)
1806}
1807
1808async fn require_session(
1809    State(state): State<ServerState>,
1810    request: Request,
1811    next: Next,
1812) -> Result<Response<Body>, ApiError> {
1813    let cookie = request
1814        .headers()
1815        .get(COOKIE)
1816        .and_then(|value| value.to_str().ok())
1817        .and_then(|header| cookie_value(header, COOKIE_NAME));
1818    if cookie.is_some_and(|value| session_cookie_valid(&state.cookie_key, value, now_unix())) {
1819        Ok(next.run(request).await)
1820    } else {
1821        Err(ApiError::unauthorized())
1822    }
1823}
1824
1825#[derive(Debug, Deserialize)]
1826#[serde(deny_unknown_fields)]
1827struct LoginRequest {
1828    code: String,
1829}
1830
1831#[derive(Debug, Deserialize)]
1832#[serde(deny_unknown_fields)]
1833struct LoginQuery {
1834    token: String,
1835}
1836
1837async fn create_session_from_query(
1838    State(state): State<ServerState>,
1839    Query(query): Query<LoginQuery>,
1840) -> Result<Response<Body>, ApiError> {
1841    if !constant_time_eq(state.login_token.as_bytes(), query.token.trim().as_bytes()) {
1842        return Err(ApiError::unauthorized());
1843    }
1844    let mut response = issue_session_cookie(&state, StatusCode::SEE_OTHER)?;
1845    response
1846        .headers_mut()
1847        .insert(LOCATION, HeaderValue::from_static("/"));
1848    response
1849        .headers_mut()
1850        .insert(CACHE_CONTROL, HeaderValue::from_static("no-store"));
1851    Ok(response)
1852}
1853
1854async fn create_session(
1855    State(state): State<ServerState>,
1856    Json(request): Json<LoginRequest>,
1857) -> Result<Response<Body>, ApiError> {
1858    if code_locked(&state) {
1859        return Err(ApiError::new(
1860            StatusCode::TOO_MANY_REQUESTS,
1861            "too many incorrect codes; wait and try again",
1862        ));
1863    }
1864    if !constant_time_eq(state.viewer_code.as_bytes(), request.code.trim().as_bytes()) {
1865        record_code_failure(&state);
1866        return Err(ApiError::unauthorized());
1867    }
1868    reset_code_failures(&state);
1869    issue_session_cookie(&state, StatusCode::NO_CONTENT)
1870}
1871
1872fn issue_session_cookie(
1873    state: &ServerState,
1874    status: StatusCode,
1875) -> Result<Response<Body>, ApiError> {
1876    let ephemeral = state.session_ttl.is_zero();
1877    let validity = if ephemeral {
1878        EPHEMERAL_SESSION_TTL
1879    } else {
1880        state.session_ttl
1881    };
1882    let value = signed_cookie_value(
1883        &state.cookie_key,
1884        &generate_viewer_id().map_err(|_| {
1885            ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "cookie creation failed")
1886        })?,
1887        now_unix().saturating_add(validity.as_secs()),
1888    );
1889    let cookie = session_cookie_header(
1890        &value,
1891        (!ephemeral).then_some(validity.as_secs()),
1892        state.secure_cookie,
1893    )?;
1894    let mut response = status.into_response();
1895    response.headers_mut().insert(SET_COOKIE, cookie);
1896    Ok(response)
1897}
1898
1899async fn clear_session(State(state): State<ServerState>) -> Response<Body> {
1900    let mut response = StatusCode::NO_CONTENT.into_response();
1901    response
1902        .headers_mut()
1903        .insert(SET_COOKIE, clear_cookie_header(state.secure_cookie));
1904    response
1905}
1906
1907async fn snapshot(State(state): State<ServerState>) -> Response<Body> {
1908    let mut projection = state.snapshot_rx.borrow().clone();
1909    // A quiet session can keep the same projection for hours. Clock anchors
1910    // describe response time, not the last time that projection changed.
1911    projection.server_time_ms = mj_core::clock::epoch_millis();
1912    let mut response = Json(projection).into_response();
1913    response
1914        .headers_mut()
1915        .insert(CACHE_CONTROL, HeaderValue::from_static("no-store"));
1916    response
1917}
1918
1919/// Optimize and install one browser image off the async request task. The
1920/// request body is deliberately raw bytes: base64 would inflate the upload,
1921/// and the response contains only the small immutable reference the prompt
1922/// needs.
1923async fn upload_attachment(
1924    State(state): State<ServerState>,
1925    Path(session_id): Path<String>,
1926    body: Bytes,
1927) -> Result<Json<ViewerPromptImage>, ApiError> {
1928    validate_public_id(&session_id)?;
1929    let prompt_images_supported = {
1930        let snapshot = state.snapshot_rx.borrow();
1931        require_session_record(&snapshot, &session_id)?.prompt_images_supported
1932    };
1933    if !prompt_images_supported {
1934        return Err(ApiError::bad_request(
1935            "this session does not support image prompts",
1936        ));
1937    }
1938    if body.is_empty() {
1939        return Err(ApiError::bad_request("image upload must not be empty"));
1940    }
1941
1942    let result = tokio::task::spawn_blocking(move || {
1943        let optimized = optimize_image(&body).map_err(|_| {
1944            ApiError::bad_request("unsupported image format or image could not be decoded")
1945        })?;
1946        if optimized.bytes.is_empty() || optimized.bytes.len() > MAX_IMAGE_BYTES {
1947            return Err(ApiError::new(
1948                StatusCode::INTERNAL_SERVER_ERROR,
1949                "the image optimizer returned an invalid image size",
1950            ));
1951        }
1952        let reference = AttachmentRef::new(
1953            &optimized.bytes,
1954            optimized.mime_type.clone(),
1955            optimized.width,
1956            optimized.height,
1957        )
1958        .map_err(|_| {
1959            ApiError::new(
1960                StatusCode::INTERNAL_SERVER_ERROR,
1961                "could not create image attachment",
1962            )
1963        })?;
1964        let store = AttachmentStore::controller(&session_id).map_err(|_| {
1965            ApiError::new(
1966                StatusCode::INTERNAL_SERVER_ERROR,
1967                "could not open the image attachment store",
1968            )
1969        })?;
1970        store.install(&reference, &optimized.bytes).map_err(|_| {
1971            ApiError::new(
1972                StatusCode::INTERNAL_SERVER_ERROR,
1973                "could not store the image attachment",
1974            )
1975        })?;
1976        Ok(ViewerPromptImage {
1977            data_base64: String::new(),
1978            mime_type: reference.mime_type.clone(),
1979            width: reference.width,
1980            height: reference.height,
1981            attachment: Some(reference),
1982        })
1983    })
1984    .await
1985    .map_err(|_| {
1986        ApiError::new(
1987            StatusCode::INTERNAL_SERVER_ERROR,
1988            "the server could not process the image upload",
1989        )
1990    })??;
1991
1992    Ok(Json(result))
1993}
1994
1995/// Hand one validated action to the controller and answer as soon as the
1996/// controller accepts it. Waiting for completion would hold the request open
1997/// for the whole of a provision, resume or close, which mobile networks end
1998/// long before the work does — reporting failure for an action that is in fact
1999/// still running.
2000async fn action(
2001    State(state): State<ServerState>,
2002    Json(action): Json<ControllerAction>,
2003) -> Result<StatusCode, ApiError> {
2004    validate_action(&action, &state.snapshot_rx.borrow())?;
2005    let action = decode_prompt_images_off_task(action).await?;
2006    let (reply, outcome) = tokio::sync::oneshot::channel();
2007    state
2008        .action_tx
2009        .send(ControllerRequest { action, reply })
2010        .await
2011        .map_err(|_| ApiError::controller_unavailable())?;
2012    let outcome = outcome
2013        .await
2014        .map_err(|_| ApiError::controller_unavailable())?;
2015    match outcome.rejection() {
2016        Some(rejection) => Err(rejection),
2017        None => Ok(StatusCode::ACCEPTED),
2018    }
2019}
2020
2021const MAX_BUNDLE_SOURCE_CHARS: usize = 1024;
2022
2023#[derive(Debug, Deserialize)]
2024#[serde(deny_unknown_fields)]
2025struct CreateBundleRequest {
2026    source: String,
2027}
2028
2029#[derive(Debug, Serialize)]
2030struct CreateBundleResponse {
2031    bundle_id: String,
2032}
2033
2034/// Create a quick bundle through the controller's dedicated persistence path.
2035/// The control loop publishes the resulting config before resolving `reply`,
2036/// so a successful response can immediately use the returned bundle id in the
2037/// next new-session request.
2038async fn create_bundle(
2039    State(state): State<ServerState>,
2040    Json(request): Json<CreateBundleRequest>,
2041) -> Result<Json<CreateBundleResponse>, ApiError> {
2042    Ok(Json(CreateBundleResponse {
2043        bundle_id: create_quick_bundle(&state, request.source).await?,
2044    }))
2045}
2046
2047/// Create or reuse the quick bundle for one repository source.
2048///
2049/// Both the viewer's `/api/bundles` route and the documented API's session
2050/// creation need this, and a caller that supplies a project directory instead
2051/// of a bundle id must get exactly the bundle the viewer would have made.
2052async fn create_quick_bundle(state: &ServerState, source: String) -> Result<String, ApiError> {
2053    if source.trim().is_empty() {
2054        return Err(ApiError::bad_request("repository source cannot be empty"));
2055    }
2056    if source.chars().count() > MAX_BUNDLE_SOURCE_CHARS {
2057        return Err(ApiError::bad_request(
2058            "repository source must contain 1024 characters or fewer",
2059        ));
2060    }
2061    let (reply, result) = tokio::sync::oneshot::channel();
2062    state
2063        .bundle_tx
2064        .send(BundleRequest { source, reply })
2065        .await
2066        .map_err(|_| ApiError::controller_unavailable())?;
2067    result
2068        .await
2069        .map_err(|_| ApiError::controller_unavailable())?
2070        .map_err(|failure| match failure {
2071            BundleFailure::InvalidSource => ApiError::bad_request(
2072                "use a GitHub owner/repository or an existing Git checkout on the controller host",
2073            ),
2074            BundleFailure::Controller => ApiError::new(
2075                StatusCode::INTERNAL_SERVER_ERROR,
2076                "the controller could not create the bundle",
2077            ),
2078        })
2079}
2080
2081#[derive(Debug, Deserialize)]
2082struct ConversationQuery {
2083    after_seq: Option<u64>,
2084    presentation_key: Option<String>,
2085}
2086
2087async fn conversation(
2088    State(state): State<ServerState>,
2089    Path(session_id): Path<String>,
2090    Query(query): Query<ConversationQuery>,
2091) -> Result<Json<BrowserTranscript>, ApiError> {
2092    validate_public_id(&session_id)?;
2093    let transitioning = {
2094        let snapshot = state.snapshot_rx.borrow();
2095        require_session_record(&snapshot, &session_id)?.transitioning
2096    };
2097    if transitioning {
2098        return Err(ApiError::new(
2099            StatusCode::CONFLICT,
2100            "conversation unavailable while the session is transitioning",
2101        ));
2102    }
2103    let conversations = state.conversation_rx.borrow();
2104    let transcript = conversations
2105        .get(&session_id)
2106        .ok_or_else(|| ApiError::not_found("conversation unavailable"))?;
2107    let mut response = transcript.clone();
2108    // Presentation grouping can remove or reorder rows without moving the
2109    // relay cursor. A client carrying a key from the previous Rich topology
2110    // must replace its append-only DOM when that topology changed.
2111    let presentation_mismatch = query
2112        .presentation_key
2113        .as_deref()
2114        .is_some_and(|key| key != transcript.presentation_key);
2115    if let Some(after) = query.after_seq {
2116        response.reset = presentation_mismatch || after < response.window_start_seq;
2117        if !response.reset {
2118            response.entries.retain(|entry| entry.updated_seq > after);
2119        }
2120    } else if presentation_mismatch {
2121        response.reset = true;
2122    }
2123    Ok(Json(response))
2124}
2125
2126#[derive(Debug, Deserialize)]
2127#[serde(deny_unknown_fields)]
2128struct ReadRequest {
2129    through: u64,
2130}
2131
2132async fn mark_conversation_read(
2133    State(state): State<ServerState>,
2134    Path(session_id): Path<String>,
2135    headers: HeaderMap,
2136    Json(request): Json<ReadRequest>,
2137) -> Result<StatusCode, ApiError> {
2138    validate_public_id(&session_id)?;
2139    let transitioning = {
2140        let snapshot = state.snapshot_rx.borrow();
2141        require_session_record(&snapshot, &session_id)?.transitioning
2142    };
2143    if transitioning {
2144        return Err(ApiError::new(
2145            StatusCode::CONFLICT,
2146            "conversation unavailable while the session is transitioning",
2147        ));
2148    }
2149    let (reply, result) = tokio::sync::oneshot::channel();
2150    let client_id = viewer_client_id(&state, &headers).ok_or_else(ApiError::unauthorized)?;
2151    state
2152        .receipt_tx
2153        .send(ReadReceiptRequest {
2154            client_id,
2155            session_id,
2156            through: request.through,
2157            reply,
2158        })
2159        .await
2160        .map_err(|_| ApiError::controller_unavailable())?;
2161    result
2162        .await
2163        .map_err(|_| ApiError::controller_unavailable())?
2164        .map_err(|_| ApiError::new(StatusCode::CONFLICT, "read receipt failed"))?;
2165    Ok(StatusCode::NO_CONTENT)
2166}
2167
2168#[derive(Debug, Deserialize)]
2169#[serde(deny_unknown_fields)]
2170struct StopBackgroundTaskRequest {
2171    background_task_id: String,
2172}
2173
2174/// Ask the live session actor to stop one task the current projection still
2175/// shows. The snapshot check is intentionally repeated at admission time:
2176/// a task may have completed, or lost its provider stop capability, between
2177/// the browser rendering its button and the POST arriving.
2178async fn stop_background_task(
2179    State(state): State<ServerState>,
2180    Path(session_id): Path<String>,
2181    Json(request): Json<StopBackgroundTaskRequest>,
2182) -> Result<StatusCode, ApiError> {
2183    validate_public_id(&session_id)?;
2184    // Task ids are opaque provider ids (the worker currently uses values such
2185    // as `terminal:<id>`), so they do not use the config id alphabet. The
2186    // request is still bounded and must name a task in the current snapshot.
2187    if request.background_task_id.is_empty() || request.background_task_id.len() > 256 {
2188        return Err(ApiError::bad_request("invalid background task id"));
2189    }
2190    {
2191        let snapshot = state.snapshot_rx.borrow();
2192        let session = require_session_record(&snapshot, &session_id)?;
2193        let task = session
2194            .background_tasks
2195            .iter()
2196            .find(|task| task.id == request.background_task_id)
2197            .ok_or_else(|| {
2198                ApiError::new(StatusCode::CONFLICT, "background task is no longer running")
2199            })?;
2200        if !task.can_stop {
2201            return Err(ApiError::new(
2202                StatusCode::CONFLICT,
2203                "background task cannot be stopped",
2204            ));
2205        }
2206    }
2207
2208    let (reply, result) = tokio::sync::oneshot::channel();
2209    state
2210        .background_task_stop_tx
2211        .send(BackgroundTaskStopRequest {
2212            session_id,
2213            background_task_id: request.background_task_id,
2214            reply,
2215        })
2216        .await
2217        .map_err(|_| ApiError::controller_unavailable())?;
2218    match result
2219        .await
2220        .map_err(|_| ApiError::controller_unavailable())?
2221    {
2222        Ok(()) => Ok(StatusCode::ACCEPTED),
2223        Err(BackgroundTaskStopFailure::SessionUnavailable) => Err(ApiError::new(
2224            StatusCode::SERVICE_UNAVAILABLE,
2225            "the live session is unavailable",
2226        )),
2227        Err(BackgroundTaskStopFailure::Provider | BackgroundTaskStopFailure::Internal) => {
2228            Err(ApiError::new(
2229                StatusCode::INTERNAL_SERVER_ERROR,
2230                "the provider could not stop this background task",
2231            ))
2232        }
2233    }
2234}
2235
2236#[derive(Debug, Deserialize)]
2237#[serde(deny_unknown_fields)]
2238struct PreflightNewRequest {
2239    #[serde(default)]
2240    remote_repairs: Vec<mj_core::local_git::LocalRemoteRepair>,
2241    #[serde(default)]
2242    workspace_id: String,
2243    profile_id: String,
2244    bundle_id: String,
2245    target_id: String,
2246    #[serde(default)]
2247    project_directory: Option<PathBuf>,
2248}
2249
2250/// Answer whether a new session would launch cleanly, and what to warn about.
2251///
2252/// The same validation the action itself runs happens here, so a phone learns
2253/// about an impossible combination while it can still change it rather than
2254/// after it has committed.
2255async fn preflight_new(
2256    State(state): State<ServerState>,
2257    Json(request): Json<PreflightNewRequest>,
2258) -> Result<Json<PreflightNew>, ApiError> {
2259    let project_validation = request.project_directory.is_some();
2260    let action = ControllerAction::New {
2261        mjolnir_subagents: None,
2262        create_managed_worktree: None,
2263        workspace_id: request.workspace_id,
2264        profile_id: request.profile_id,
2265        bundle_id: request.bundle_id.clone(),
2266        target_id: request.target_id.clone(),
2267        title: None,
2268        project_directory: request.project_directory.clone(),
2269        dirty_ack: Vec::new(),
2270    };
2271    validate_action(&action, &state.snapshot_rx.borrow())?;
2272    let (reply, result) = tokio::sync::oneshot::channel();
2273    state
2274        .preflight_tx
2275        .send(PreflightRequest {
2276            bundle_id: request.bundle_id,
2277            target_id: request.target_id,
2278            project_directory: request.project_directory,
2279            remote_repairs: request.remote_repairs,
2280            reply,
2281        })
2282        .await
2283        .map_err(|_| ApiError::controller_unavailable())?;
2284    result
2285        .await
2286        .map_err(|_| ApiError::controller_unavailable())?
2287        .map(Json)
2288        .map_err(|failure| match failure {
2289            PreflightFailure::Validation if project_validation => ApiError::bad_request(
2290                "project validation failed; check that the directory exists, is accessible, and contains a Git repository with a valid HEAD",
2291            ),
2292            PreflightFailure::InvalidRepository(_) => ApiError::bad_request(
2293                "could not resolve the network repository; check its remote URL, authentication, connectivity, and default branch. Repositories without network remotes require a raw local session",
2294            ),
2295            PreflightFailure::Validation => ApiError::bad_request(
2296                "isolated session repositories need a network Git remote; use a raw local target for a local-only checkout",
2297            ),
2298            PreflightFailure::Controller(_) => ApiError::new(
2299                StatusCode::SERVICE_UNAVAILABLE,
2300                "the controller could not check this project",
2301            ),
2302        })
2303}
2304
2305/// Prepare a move without changing the source session. The returned
2306/// preparation is an expiring, fingerprinted capability: the confirmation
2307/// action must send it back verbatim, and the daemon rechecks it immediately
2308/// before interrupting work.
2309async fn prepare_move(
2310    State(state): State<ServerState>,
2311    Json(selection): Json<MoveSelection>,
2312) -> Result<Json<MovePreparation>, ApiError> {
2313    validate_move_selection(&selection, &state.snapshot_rx.borrow())?;
2314    let (reply, result) = tokio::sync::oneshot::channel();
2315    state
2316        .move_preparation_tx
2317        .send(MovePreparationRequest { selection, reply })
2318        .await
2319        .map_err(|_| ApiError::controller_unavailable())?;
2320    let preparation = result
2321        .await
2322        .map_err(|_| ApiError::controller_unavailable())?
2323        .map_err(|error| {
2324            tracing::debug!(error = %error, "move preparation was rejected");
2325            ApiError::new(
2326                StatusCode::CONFLICT,
2327                "move preparation was rejected; refresh and try again",
2328            )
2329        })?;
2330    Ok(Json(inspector_move_preparation(preparation)))
2331}
2332
2333/// Queued image bytes are replayed from the verified archive after readiness;
2334/// they are not needed by a browser confirmation. Replace them at this
2335/// boundary even if an older daemon did not already make the preparation an
2336/// inspector-only value.
2337fn inspector_move_preparation(mut preparation: MovePreparation) -> MovePreparation {
2338    for command in &mut preparation.queued_commands {
2339        for block in &mut command.content {
2340            if block.get("type").and_then(serde_json::Value::as_str) != Some("image") {
2341                continue;
2342            }
2343            let mime = block
2344                .get("mimeType")
2345                .or_else(|| block.get("mime_type"))
2346                .and_then(serde_json::Value::as_str)
2347                .unwrap_or("image");
2348            *block = serde_json::json!({
2349                "type": "text",
2350                "text": format!("[Image attachment: {mime}]")
2351            });
2352        }
2353    }
2354    preparation
2355}
2356
2357/// Ask the state channel one thing and wait for its answer.
2358async fn ask_client_state<T>(
2359    state: &ServerState,
2360    build: impl FnOnce(tokio::sync::oneshot::Sender<Result<T, String>>) -> ClientStateRequest,
2361) -> Result<T, ApiError> {
2362    let (reply, answer) = tokio::sync::oneshot::channel();
2363    state
2364        .client_state_tx
2365        .send(build(reply))
2366        .await
2367        .map_err(|_| ApiError::controller_unavailable())?;
2368    answer
2369        .await
2370        .map_err(|_| ApiError::controller_unavailable())?
2371        .map_err(|_| {
2372            ApiError::new(
2373                StatusCode::SERVICE_UNAVAILABLE,
2374                "the controller could not reach stored viewer state",
2375            )
2376        })
2377}
2378
2379/// This viewer's draft and read frontier for one session.
2380async fn client_state(
2381    State(state): State<ServerState>,
2382    Path(session_id): Path<String>,
2383    headers: HeaderMap,
2384) -> Result<Json<ViewerClientState>, ApiError> {
2385    validate_public_id(&session_id)?;
2386    require_session_record(&state.snapshot_rx.borrow(), &session_id)?;
2387    // A viewer with a legacy cookie has no identity and so has nothing stored.
2388    // Answering with an empty state is the truth, and is what lets an older
2389    // phone keep working through a deployment.
2390    let Some(client_id) = viewer_client_id(&state, &headers) else {
2391        return Ok(Json(ViewerClientState::default()));
2392    };
2393    ask_client_state(&state, |reply| ClientStateRequest::Read {
2394        client_id,
2395        session_id,
2396        reply,
2397    })
2398    .await
2399    .map(Json)
2400}
2401
2402#[derive(Debug, Serialize)]
2403struct DictationAvailability {
2404    available: bool,
2405    #[serde(skip_serializing_if = "Option::is_none")]
2406    reason: Option<String>,
2407}
2408
2409#[derive(Debug, Serialize)]
2410struct DictationTranscript {
2411    text: String,
2412}
2413
2414/// Report whether one of the session's Codex profiles has usable subscription
2415/// credentials. The controller selects profile paths from its current session
2416/// state, so this endpoint never accepts a browser-supplied credential path.
2417async fn dictation_availability(
2418    State(state): State<ServerState>,
2419    Path(session_id): Path<String>,
2420) -> Result<Json<DictationAvailability>, ApiError> {
2421    validate_public_id(&session_id)?;
2422    require_session_record(&state.snapshot_rx.borrow(), &session_id)?;
2423    let _permit = state
2424        .dictation_probe_permits
2425        .clone()
2426        .try_acquire_owned()
2427        .map_err(|_| ApiError::new(StatusCode::TOO_MANY_REQUESTS, "too many dictation requests"))?;
2428    let result = dispatch_dictation(&state, session_id, DictationOperation::Availability).await?;
2429    match result {
2430        DictationResponse::Availability { available, reason } => {
2431            Ok(Json(DictationAvailability { available, reason }))
2432        }
2433        DictationResponse::Transcript { .. } => Err(ApiError::new(
2434            StatusCode::INTERNAL_SERVER_ERROR,
2435            "the controller returned an invalid dictation response",
2436        )),
2437    }
2438}
2439
2440/// Receive one bounded WAV upload and send it to the supervised controller
2441/// request loop. The semaphore is acquired before `Request::into_body`, so a
2442/// third concurrent upload is rejected without polling its body at all.
2443async fn upload_dictation(
2444    State(state): State<ServerState>,
2445    Path(session_id): Path<String>,
2446    request: Request,
2447) -> Result<Json<DictationTranscript>, ApiError> {
2448    validate_public_id(&session_id)?;
2449    require_session_record(&state.snapshot_rx.borrow(), &session_id)?;
2450    let _permit = state
2451        .dictation_permits
2452        .clone()
2453        .try_acquire_owned()
2454        .map_err(|_| ApiError::new(StatusCode::TOO_MANY_REQUESTS, "too many dictation requests"))?;
2455
2456    if request
2457        .headers()
2458        .get(axum::http::header::CONTENT_LENGTH)
2459        .and_then(|value| value.to_str().ok())
2460        .and_then(|value| value.parse::<u64>().ok())
2461        .is_some_and(|length| length > MAX_AUDIO_BYTES as u64)
2462    {
2463        return Err(ApiError::new(
2464            StatusCode::PAYLOAD_TOO_LARGE,
2465            "audio upload is too large",
2466        ));
2467    }
2468    let body = tokio::select! {
2469        biased;
2470        _ = state.shutdown.cancelled() => return Err(ApiError::controller_unavailable()),
2471        result = tokio::time::timeout(
2472            crate::dictation::DICTATION_TIMEOUT,
2473            to_bytes(request.into_body(), MAX_AUDIO_BYTES),
2474        ) => match result {
2475            Ok(result) => result.map_err(|_| {
2476                ApiError::new(StatusCode::PAYLOAD_TOO_LARGE, "audio upload is too large")
2477            })?,
2478            Err(_) => return Err(ApiError::new(
2479                StatusCode::GATEWAY_TIMEOUT,
2480                "dictation upload timed out",
2481            )),
2482        },
2483    };
2484    // A bounded WAV may still contain millions of small metadata chunks.
2485    // Keep that scan off the HTTP event loop as well as the provider work.
2486    let audio = body.clone();
2487    tokio::task::spawn_blocking(move || validate_wav(&audio))
2488        .await
2489        .map_err(|error| {
2490            tracing::warn!(%error, "dictation audio validation task failed");
2491            ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "audio validation failed")
2492        })?
2493        .map_err(dictation_api_error)?;
2494    let result =
2495        dispatch_dictation(&state, session_id, DictationOperation::Transcribe(body)).await?;
2496    match result {
2497        DictationResponse::Transcript { text } => Ok(Json(DictationTranscript { text })),
2498        DictationResponse::Availability { .. } => Err(ApiError::new(
2499            StatusCode::INTERNAL_SERVER_ERROR,
2500            "the controller returned an invalid dictation response",
2501        )),
2502    }
2503}
2504
2505/// Cancels a request as soon as Axum drops its handler future, which happens
2506/// when a browser disconnects while a provider request is still running.
2507struct DictationCancellationGuard(CancellationToken);
2508
2509impl Drop for DictationCancellationGuard {
2510    fn drop(&mut self) {
2511        self.0.cancel();
2512    }
2513}
2514
2515async fn dispatch_dictation(
2516    state: &ServerState,
2517    session_id: String,
2518    operation: DictationOperation,
2519) -> Result<DictationResponse, ApiError> {
2520    let cancel = CancellationToken::new();
2521    let _guard = DictationCancellationGuard(cancel.clone());
2522    let (reply, answer) = tokio::sync::oneshot::channel();
2523    let request = DictationRequest {
2524        session_id,
2525        operation,
2526        cancel: cancel.clone(),
2527        reply,
2528    };
2529    tokio::select! {
2530        biased;
2531        _ = state.shutdown.cancelled() => return Err(ApiError::controller_unavailable()),
2532        result = state.dictation_tx.send(request) => {
2533            result.map_err(|_| ApiError::controller_unavailable())?;
2534        }
2535    }
2536    let answer = tokio::select! {
2537        biased;
2538        _ = state.shutdown.cancelled() => return Err(ApiError::controller_unavailable()),
2539        result = answer => result.map_err(|_| ApiError::controller_unavailable())?,
2540    };
2541    answer.map_err(dictation_api_error)
2542}
2543
2544fn dictation_api_error(error: DictationError) -> ApiError {
2545    match error {
2546        DictationError::SessionNotFound => ApiError::not_found("unknown session"),
2547        DictationError::CredentialsUnavailable => ApiError::new(
2548            StatusCode::SERVICE_UNAVAILABLE,
2549            "dictation is unavailable because no Codex subscription is signed in",
2550        ),
2551        DictationError::InvalidAudio(message) => ApiError::bad_request(message),
2552        DictationError::Cancelled => {
2553            ApiError::new(StatusCode::REQUEST_TIMEOUT, "dictation cancelled")
2554        }
2555        DictationError::TimedOut => ApiError::new(
2556            StatusCode::GATEWAY_TIMEOUT,
2557            "dictation transcription timed out",
2558        ),
2559        DictationError::CredentialProbe => ApiError::new(
2560            StatusCode::SERVICE_UNAVAILABLE,
2561            "dictation credentials could not be checked",
2562        ),
2563        DictationError::Provider(error) => {
2564            tracing::warn!(%error, "Codex dictation transcription failed");
2565            ApiError::new(StatusCode::BAD_GATEWAY, "dictation transcription failed")
2566        }
2567    }
2568}
2569
2570#[derive(Debug, Deserialize)]
2571#[serde(deny_unknown_fields)]
2572struct DraftRequest {
2573    draft: String,
2574}
2575
2576async fn save_draft(
2577    State(state): State<ServerState>,
2578    Path(session_id): Path<String>,
2579    headers: HeaderMap,
2580    Json(request): Json<DraftRequest>,
2581) -> Result<StatusCode, ApiError> {
2582    validate_public_id(&session_id)?;
2583    require_session_record(&state.snapshot_rx.borrow(), &session_id)?;
2584    if request.draft.len() > MAX_DRAFT_BYTES {
2585        return Err(ApiError::new(
2586            StatusCode::PAYLOAD_TOO_LARGE,
2587            "draft must be 65536 bytes or fewer",
2588        ));
2589    }
2590    let Some(client_id) = viewer_client_id(&state, &headers) else {
2591        // Nothing to key it to. The phone keeps its draft in the composer, and
2592        // silently accepting would promise a persistence that is not there.
2593        return Err(ApiError::new(
2594            StatusCode::CONFLICT,
2595            "this viewer has no stored identity; unlock again to keep drafts",
2596        ));
2597    };
2598    ask_client_state(&state, |reply| ClientStateRequest::SaveDraft {
2599        client_id,
2600        session_id,
2601        draft: request.draft,
2602        reply,
2603    })
2604    .await?;
2605    Ok(StatusCode::NO_CONTENT)
2606}
2607
2608/// Mark every session in a workspace read, in one request.
2609///
2610/// Opening a workspace should not cost one request per session.
2611async fn mark_workspace_read(
2612    State(state): State<ServerState>,
2613    Path(workspace_id): Path<String>,
2614    headers: HeaderMap,
2615) -> Result<StatusCode, ApiError> {
2616    validate_public_id(&workspace_id)?;
2617    let Some(client_id) = viewer_client_id(&state, &headers) else {
2618        return Ok(StatusCode::NO_CONTENT);
2619    };
2620    ask_client_state(&state, |reply| ClientStateRequest::MarkWorkspaceRead {
2621        client_id,
2622        workspace_id,
2623        reply,
2624    })
2625    .await?;
2626    Ok(StatusCode::NO_CONTENT)
2627}
2628
2629#[derive(Debug, Deserialize)]
2630struct HistoryQuery {
2631    #[serde(default)]
2632    q: String,
2633    #[serde(default)]
2634    scope: Option<String>,
2635}
2636
2637/// Search this session's or this project's earlier prompts.
2638async fn prompt_history(
2639    State(state): State<ServerState>,
2640    Path(session_id): Path<String>,
2641    Query(query): Query<HistoryQuery>,
2642) -> Result<Json<ViewerPromptHistory>, ApiError> {
2643    validate_public_id(&session_id)?;
2644    require_session_record(&state.snapshot_rx.borrow(), &session_id)?;
2645    if query.q.chars().count() > MAX_TITLE_CHARS {
2646        return Err(ApiError::bad_request("search text is too long"));
2647    }
2648    let scope = query.scope.unwrap_or_else(|| "project".to_owned());
2649    if !matches!(scope.as_str(), "session" | "project" | "all") {
2650        return Err(ApiError::bad_request(
2651            "scope must be session, project or all",
2652        ));
2653    }
2654    ask_client_state(&state, |reply| ClientStateRequest::History {
2655        session_id,
2656        query: query.q,
2657        scope,
2658        reply,
2659    })
2660    .await
2661    .map(Json)
2662}
2663
2664async fn events(State(state): State<ServerState>) -> impl IntoResponse {
2665    let mut snapshots = state.snapshot_rx.clone();
2666    let (tx, rx) = mpsc::channel::<Result<Event, Infallible>>(8);
2667    tokio::spawn(async move {
2668        let initial = snapshots.borrow().revision;
2669        if tx
2670            .send(Ok(Event::default()
2671                .event("revision")
2672                .data(initial.to_string())))
2673            .await
2674            .is_err()
2675        {
2676            return;
2677        }
2678        while snapshots.changed().await.is_ok() {
2679            let revision = snapshots.borrow_and_update().revision;
2680            if tx
2681                .send(Ok(Event::default()
2682                    .event("revision")
2683                    .data(revision.to_string())))
2684                .await
2685                .is_err()
2686            {
2687                break;
2688            }
2689        }
2690    });
2691    Sse::new(ReceiverStream::new(rx)).keep_alive(KeepAlive::default())
2692}
2693
2694/// Check attached images without decoding megabytes of base64 on the task that
2695/// serves the request. Everything else about an action is cheap enough to
2696/// check inline; a full multi-image prompt is not.
2697async fn decode_prompt_images_off_task(
2698    action: ControllerAction,
2699) -> Result<ControllerAction, ApiError> {
2700    let ControllerAction::Prompt { images, .. } = &action else {
2701        return Ok(action);
2702    };
2703    if images.is_empty() {
2704        return Ok(action);
2705    }
2706    tokio::task::spawn_blocking(move || {
2707        let mut action = action;
2708        let ControllerAction::Prompt {
2709            session_id, images, ..
2710        } = &action
2711        else {
2712            unreachable!("only prompt actions carry images")
2713        };
2714        validate_prompt_images(images)?;
2715        let store = AttachmentStore::controller(session_id).map_err(|_| {
2716            ApiError::new(
2717                StatusCode::INTERNAL_SERVER_ERROR,
2718                "could not open the image attachment store",
2719            )
2720        })?;
2721        let ControllerAction::Prompt { images, .. } = &mut action else {
2722            unreachable!("only prompt actions carry images")
2723        };
2724        for image in images {
2725            if let Some(reference) = image.attachment.clone() {
2726                // Reading through this session's store both verifies the
2727                // digest and prevents a reference from another session being
2728                // smuggled into a prompt.
2729                store
2730                    .read(&reference)
2731                    .map_err(|_| ApiError::bad_request("the image attachment is unavailable"))?;
2732                image.data_base64.clear();
2733                image.mime_type = reference.mime_type;
2734                image.width = reference.width;
2735                image.height = reference.height;
2736            } else {
2737                let bytes = base64::engine::general_purpose::STANDARD
2738                    .decode(&image.data_base64)
2739                    .map_err(|_| ApiError::bad_request("image data must be valid base64"))?;
2740                let optimized = optimize_image(&bytes).map_err(|_| {
2741                    ApiError::bad_request("unsupported image format or image could not be decoded")
2742                })?;
2743                let reference = AttachmentRef::new(
2744                    &optimized.bytes,
2745                    optimized.mime_type.clone(),
2746                    optimized.width,
2747                    optimized.height,
2748                )
2749                .map_err(|_| {
2750                    ApiError::bad_request("the inline image could not become an attachment")
2751                })?;
2752                store.install(&reference, &optimized.bytes).map_err(|_| {
2753                    ApiError::new(
2754                        StatusCode::INTERNAL_SERVER_ERROR,
2755                        "could not store the image attachment",
2756                    )
2757                })?;
2758                image.data_base64.clear();
2759                image.attachment = Some(reference);
2760                image.mime_type = optimized.mime_type;
2761                image.width = optimized.width;
2762                image.height = optimized.height;
2763            }
2764        }
2765        Ok(action)
2766    })
2767    .await
2768    .map_err(|_| {
2769        ApiError::new(
2770            StatusCode::INTERNAL_SERVER_ERROR,
2771            "the server could not check the attached images",
2772        )
2773    })?
2774}
2775
2776fn validate_prompt_images(images: &[ViewerPromptImage]) -> Result<(), ApiError> {
2777    if images.len() > MAX_PROMPT_IMAGES {
2778        return Err(ApiError::bad_request(
2779            "a prompt may contain at most 10 images",
2780        ));
2781    }
2782    for image in images {
2783        if !image.mime_type.starts_with("image/") {
2784            return Err(ApiError::bad_request(
2785                "image mime type must start with image/",
2786            ));
2787        }
2788        if image.width == 0 || image.height == 0 {
2789            return Err(ApiError::bad_request(
2790                "image dimensions must be greater than zero",
2791            ));
2792        }
2793        if let Some(reference) = &image.attachment {
2794            if !image.data_base64.is_empty() {
2795                return Err(ApiError::bad_request(
2796                    "an image cannot contain both inline data and an attachment",
2797                ));
2798            }
2799            if reference.mime_type != image.mime_type
2800                || reference.width != image.width
2801                || reference.height != image.height
2802            {
2803                return Err(ApiError::bad_request(
2804                    "image attachment metadata does not match the prompt",
2805                ));
2806            }
2807            continue;
2808        }
2809        let bytes = base64::engine::general_purpose::STANDARD
2810            .decode(&image.data_base64)
2811            .map_err(|_| ApiError::bad_request("image data must be valid base64"))?;
2812        if bytes.is_empty() {
2813            return Err(ApiError::bad_request("image data must not be empty"));
2814        }
2815    }
2816    Ok(())
2817}
2818
2819const MAX_MOVE_QUEUE_ITEMS: usize = 256;
2820const MAX_MOVE_MOUNTS: usize = 32;
2821
2822fn validate_move_selection(
2823    selection: &MoveSelection,
2824    snapshot: &ViewerSnapshot,
2825) -> Result<(), ApiError> {
2826    validate_public_id(&selection.session_id)?;
2827    if selection.profile_id.is_none() && selection.target_template_id.is_none() {
2828        return Err(ApiError::bad_request(
2829            "move must select a profile, a target, or both",
2830        ));
2831    }
2832    if selection.clear_resource_allocation && selection.resource_allocation.is_some() {
2833        return Err(ApiError::bad_request(
2834            "clear resource sizing cannot be combined with an explicit allocation",
2835        ));
2836    }
2837    if let Some(profile_id) = selection.profile_id.as_deref() {
2838        validate_public_id(profile_id)?;
2839        require_profile(snapshot, profile_id)?;
2840    }
2841    if let Some(target_id) = selection.target_template_id.as_deref() {
2842        validate_public_id(target_id)?;
2843        require_target(snapshot, target_id)?;
2844        let session = require_session_record(snapshot, &selection.session_id)?;
2845        if session
2846            .incompatible_resume_targets
2847            .iter()
2848            .any(|id| id == target_id)
2849        {
2850            return Err(ApiError::bad_request(
2851                "this session cannot resume on that target",
2852            ));
2853        }
2854    } else {
2855        require_session_record(snapshot, &selection.session_id)?;
2856    }
2857    if let Some(mounts) = &selection.additional_mounts {
2858        validate_move_mounts(mounts)?;
2859    }
2860    let session = require_session_record(snapshot, &selection.session_id)?;
2861    let retryable_move = session
2862        .move_recovery
2863        .as_ref()
2864        .is_some_and(|recovery| recovery.checkpoint_retained);
2865    if !session.capabilities.move_session && !retryable_move {
2866        return Err(ApiError::new(
2867            StatusCode::CONFLICT,
2868            "this session cannot be moved now",
2869        ));
2870    }
2871    Ok(())
2872}
2873
2874fn validate_move_mounts(mounts: &[AdditionalMount]) -> Result<(), ApiError> {
2875    if mounts.len() > MAX_MOVE_MOUNTS {
2876        return Err(ApiError::bad_request("a move may carry at most 32 mounts"));
2877    }
2878    for mount in mounts {
2879        for path in [&mount.source, &mount.destination] {
2880            if !path.is_absolute()
2881                || path
2882                    .components()
2883                    .any(|component| component == Component::ParentDir)
2884            {
2885                return Err(ApiError::bad_request(
2886                    "move mount paths must be absolute and must not contain '..'",
2887                ));
2888            }
2889        }
2890    }
2891    Ok(())
2892}
2893
2894fn validate_resume_settings(
2895    additional_mounts: Option<&Vec<AdditionalMount>>,
2896    resource_allocation: Option<&SessionResourceAllocation>,
2897) -> Result<(), ApiError> {
2898    if let Some(mounts) = additional_mounts {
2899        validate_move_mounts(mounts)?;
2900    }
2901    if let Some(allocation) = resource_allocation {
2902        allocation
2903            .validate()
2904            .map_err(|_| ApiError::bad_request("resource allocation is invalid"))?;
2905    }
2906    Ok(())
2907}
2908
2909fn validate_move_request(
2910    request: &MoveSessionRequest,
2911    snapshot: &ViewerSnapshot,
2912) -> Result<(), ApiError> {
2913    let preparation = &request.preparation;
2914    validate_move_selection(&preparation.selection, snapshot)?;
2915    let session = require_session_record(snapshot, &preparation.selection.session_id)?;
2916    if preparation.operation_id.trim().is_empty() || preparation.fingerprint.trim().is_empty() {
2917        return Err(ApiError::bad_request(
2918            "move confirmation is missing its preparation identity",
2919        ));
2920    }
2921    if preparation.queued_commands.len() > MAX_MOVE_QUEUE_ITEMS {
2922        return Err(ApiError::bad_request(
2923            "move queue is too large; prepare again",
2924        ));
2925    }
2926    let active_now = preparation.active
2927        || session.chat_phase == ViewerChatPhase::Running
2928        || !session.active_user_shells.is_empty();
2929    if active_now && !request.acknowledge_interruption {
2930        return Err(ApiError::new(
2931            StatusCode::CONFLICT,
2932            "confirm that the active turn may be interrupted",
2933        ));
2934    }
2935    if !preparation.queued_commands.is_empty() && request.queue.is_none() {
2936        return Err(ApiError::bad_request(
2937            "choose whether queued work is discarded or started after the move",
2938        ));
2939    }
2940    Ok(())
2941}
2942
2943/// What every surface accepts as prompt text.
2944///
2945/// Session creation carries a first prompt before any session record exists,
2946/// so this is separate from [`validate_action`]'s prompt arm rather than being
2947/// restated there: both must refuse the same text.
2948fn validate_prompt_text(text: &str, has_images: bool) -> Result<(), ApiError> {
2949    if text.starts_with('!') {
2950        return Err(ApiError::bad_request(
2951            "leading ! is reserved for shell commands",
2952        ));
2953    }
2954    if text.chars().count() > MAX_PROMPT_CHARS {
2955        return Err(ApiError::bad_request(
2956            "prompt must contain 1-65536 characters",
2957        ));
2958    }
2959    if text.trim().is_empty() && !has_images {
2960        return Err(ApiError::bad_request(
2961            "prompt must contain text or an image",
2962        ));
2963    }
2964    Ok(())
2965}
2966
2967fn validate_action(action: &ControllerAction, snapshot: &ViewerSnapshot) -> Result<(), ApiError> {
2968    match action {
2969        ControllerAction::New {
2970            workspace_id,
2971            profile_id,
2972            bundle_id,
2973            target_id,
2974            title,
2975            project_directory,
2976            dirty_ack,
2977            create_managed_worktree,
2978            mjolnir_subagents: _,
2979        } => {
2980            if !workspace_id.is_empty() {
2981                validate_public_id(workspace_id)?;
2982            }
2983            validate_public_id(profile_id)?;
2984            validate_public_id(bundle_id)?;
2985            validate_public_id(target_id)?;
2986            if let Some(title) = title {
2987                validate_title(title)?;
2988            }
2989            // An acknowledgement names repositories the preflight reported.
2990            // Unbounded or malformed entries would travel to the controller
2991            // and be compared against a real set, so they are refused here.
2992            if dirty_ack.len() > MAX_DIRTY_ACKNOWLEDGEMENTS
2993                || dirty_ack
2994                    .iter()
2995                    .any(|repository| repository.trim().is_empty() || repository.len() > 256)
2996            {
2997                return Err(ApiError::bad_request(
2998                    "dirty acknowledgement must name 0-32 repositories",
2999                ));
3000            }
3001            require_profile(snapshot, profile_id)?;
3002            require_bundle(snapshot, bundle_id)?;
3003            let target = require_target(snapshot, target_id)?;
3004            if *create_managed_worktree == Some(true) && !target.requires_project_directory {
3005                return Err(ApiError::bad_request(
3006                    "managed worktree creation requires a bare Git project",
3007                ));
3008            }
3009            if target.requires_project_directory != project_directory.is_some() {
3010                return Err(ApiError::bad_request(
3011                    "project_directory is required exactly for bare targets",
3012                ));
3013            }
3014            if let Some(directory) = project_directory
3015                && (mj_core::path_input::validate_absolute_input(directory).is_err()
3016                    || directory
3017                        .components()
3018                        .any(|component| component == Component::ParentDir))
3019            {
3020                return Err(ApiError::bad_request(
3021                    "project_directory must be an absolute safe path",
3022                ));
3023            }
3024        }
3025        ControllerAction::Resume {
3026            session_id,
3027            workspace_id,
3028            profile_id,
3029            target_id,
3030            additional_mounts,
3031            resource_allocation,
3032            ..
3033        } => {
3034            validate_public_id(session_id)?;
3035            validate_public_id(workspace_id)?;
3036            validate_public_id(profile_id)?;
3037            validate_public_id(target_id)?;
3038            let session = require_session_record(snapshot, session_id)?;
3039            require_workspace(snapshot, workspace_id)?;
3040            require_profile(snapshot, profile_id)?;
3041            require_target(snapshot, target_id)?;
3042            if session
3043                .incompatible_resume_targets
3044                .iter()
3045                .any(|incompatible| incompatible == target_id)
3046            {
3047                return Err(ApiError::bad_request(
3048                    "this session cannot resume on that target",
3049                ));
3050            }
3051            validate_resume_settings(additional_mounts.as_ref(), resource_allocation.as_ref())?;
3052        }
3053        ControllerAction::Move { request } => validate_move_request(request, snapshot)?,
3054        ControllerAction::Open { session_id }
3055        | ControllerAction::Close { session_id }
3056        | ControllerAction::ForceClose { session_id }
3057        | ControllerAction::Cancel { session_id }
3058        | ControllerAction::StartReview { session_id } => {
3059            validate_public_id(session_id)?;
3060            require_session_record(snapshot, session_id)?;
3061        }
3062        ControllerAction::ResolveReview {
3063            session_id,
3064            resolution,
3065        } => {
3066            validate_public_id(session_id)?;
3067            let session = require_session_record(snapshot, session_id)?;
3068            let Some(resolution) = resolution_from_name(resolution) else {
3069                return Err(ApiError::bad_request(
3070                    "a review is resolved by forward, dismiss, or cancel",
3071                ));
3072            };
3073            let Some(review) = session.turn_review.as_ref() else {
3074                return Err(ApiError::bad_request("no review is open for that session"));
3075            };
3076            // Cancel is always available; the rest wait for the verdict the
3077            // daemon published, which is the same gate the daemon enforces
3078            // when it actually resolves.
3079            let allowed = resolution == mj_core::review::driver::Resolution::Cancelled
3080                || review.verdict.as_ref().is_some_and(|verdict| {
3081                    resolution_name(&resolution)
3082                        .is_some_and(|name| verdict.allowed.iter().any(|allowed| allowed == name))
3083                });
3084            if !allowed {
3085                return Err(ApiError::bad_request(
3086                    "that review cannot be resolved that way yet",
3087                ));
3088            }
3089        }
3090        ControllerAction::Rename { session_id, title } => {
3091            validate_public_id(session_id)?;
3092            validate_title(title)?;
3093            let session = require_session_record(snapshot, session_id)?;
3094            if !session.capabilities.rename {
3095                return Err(ApiError::bad_request("this session cannot be renamed"));
3096            }
3097        }
3098        ControllerAction::CancelTurn { session_id } => {
3099            validate_public_id(session_id)?;
3100            let session = require_session_record(snapshot, session_id)?;
3101            if !session.capabilities.cancel_turn {
3102                return Err(ApiError::new(
3103                    StatusCode::CONFLICT,
3104                    "this session has no turn to cancel",
3105                ));
3106            }
3107        }
3108        ControllerAction::SetConfig {
3109            session_id,
3110            key,
3111            value,
3112        } => {
3113            validate_public_id(session_id)?;
3114            let session = require_session_record(snapshot, session_id)?;
3115            if !session.capabilities.set_config {
3116                return Err(ApiError::bad_request(
3117                    "this session cannot change configuration now",
3118                ));
3119            }
3120            // The harness decides what it accepts. Forwarding a key it never
3121            // advertised, or a value outside the ones it offered, asks it to
3122            // refuse something the viewer should not have offered.
3123            let option = session
3124                .config_options
3125                .iter()
3126                .find(|option| option.key == *key)
3127                .ok_or_else(|| ApiError::bad_request("this agent does not offer that setting"))?;
3128            if !option.choices.iter().any(|choice| choice.value == *value) {
3129                return Err(ApiError::bad_request(
3130                    "this agent does not offer that value for that setting",
3131                ));
3132            }
3133        }
3134        ControllerAction::SetPlanMode { session_id, .. } => {
3135            validate_public_id(session_id)?;
3136            let session = require_session_record(snapshot, session_id)?;
3137            if !session.capabilities.set_plan_mode {
3138                return Err(ApiError::bad_request(
3139                    "this session cannot change plan mode now",
3140                ));
3141            }
3142        }
3143        ControllerAction::RefreshQuota { profile_id } => {
3144            validate_public_id(profile_id)?;
3145            require_profile(snapshot, profile_id)?;
3146        }
3147        ControllerAction::RefreshCapacity { target_id } => {
3148            validate_public_id(target_id)?;
3149            require_target(snapshot, target_id)?;
3150        }
3151        ControllerAction::Prompt {
3152            session_id,
3153            text,
3154            images,
3155        } => {
3156            validate_public_id(session_id)?;
3157            let session = require_session_record(snapshot, session_id)?;
3158            if images.len() > MAX_PROMPT_IMAGES {
3159                return Err(ApiError::bad_request(
3160                    "a prompt may contain at most 10 images",
3161                ));
3162            }
3163            validate_prompt_text(text, !images.is_empty())?;
3164            if !images.is_empty() && !session.prompt_images_supported {
3165                return Err(ApiError::bad_request(
3166                    "this session does not support image prompts",
3167                ));
3168            }
3169            // Review is synchronous: the turn under review stays where the
3170            // review found it. The daemon's own submit path is what makes this
3171            // true; refusing here as well is what turns it into an immediate
3172            // answer rather than a rejected prompt.
3173            if session.turn_review.is_some() {
3174                return Err(ApiError::bad_request(
3175                    crate::review_host::PROMPT_HELD_MESSAGE,
3176                ));
3177            }
3178        }
3179        ControllerAction::RunShell {
3180            session_id,
3181            command,
3182        } => {
3183            validate_public_id(session_id)?;
3184            require_session_record(snapshot, session_id)?;
3185            if command.trim().is_empty() || command.chars().count() > MAX_PROMPT_CHARS {
3186                return Err(ApiError::bad_request(
3187                    "shell command must contain 1-65536 characters",
3188                ));
3189            }
3190        }
3191        ControllerAction::CancelShell {
3192            session_id,
3193            shell_command_id,
3194        } => {
3195            validate_public_id(session_id)?;
3196            validate_public_id(shell_command_id)?;
3197            let session = require_session_record(snapshot, session_id)?;
3198            if !session
3199                .active_user_shells
3200                .iter()
3201                .any(|shell| shell.id == *shell_command_id)
3202            {
3203                return Err(ApiError::bad_request("unknown active shell command"));
3204            }
3205        }
3206        ControllerAction::RemoveQueuedPrompt {
3207            session_id,
3208            queue_id,
3209        } => {
3210            validate_public_id(session_id)?;
3211            validate_public_id(queue_id)?;
3212            require_session_record(snapshot, session_id)?;
3213        }
3214        ControllerAction::RespondElicitation {
3215            session_id,
3216            elicitation_id,
3217            response,
3218        } => {
3219            validate_public_id(session_id)?;
3220            validate_public_id(elicitation_id)?;
3221            let session = require_session_record(snapshot, session_id)?;
3222            let request = session
3223                .pending_elicitations
3224                .iter()
3225                .find(|request| request.id == *elicitation_id)
3226                .ok_or_else(|| ApiError::not_found("unknown elicitation"))?;
3227            if serde_json::to_vec(response).map_or(usize::MAX, |encoded| encoded.len())
3228                > MAX_ELICITATION_BYTES
3229            {
3230                return Err(ApiError::bad_request("elicitation answer is too large"));
3231            }
3232            // The answer has to satisfy the question the agent actually asked.
3233            // A phone can post one for a request the session has already
3234            // replaced, and forwarding that would answer a live question with
3235            // content the agent never offered.
3236            if request.validate_response(response).is_err() {
3237                return Err(ApiError::bad_request(
3238                    "the answer does not match this elicitation request",
3239                ));
3240            }
3241        }
3242    }
3243    Ok(())
3244}
3245
3246fn validate_public_id(id: &str) -> Result<(), ApiError> {
3247    validate_id("request", id).map_err(|_| ApiError::bad_request("invalid id"))
3248}
3249
3250fn validate_title(title: &str) -> Result<(), ApiError> {
3251    if title.trim().is_empty() || title.chars().count() > MAX_TITLE_CHARS {
3252        Err(ApiError::bad_request("title must contain 1-120 characters"))
3253    } else {
3254        Ok(())
3255    }
3256}
3257
3258fn require_session_record<'a>(
3259    snapshot: &'a ViewerSnapshot,
3260    id: &str,
3261) -> Result<&'a ViewerSession, ApiError> {
3262    snapshot
3263        .sessions
3264        .iter()
3265        .find(|session| session.id == id)
3266        .ok_or_else(|| ApiError::not_found("unknown session"))
3267}
3268
3269fn require_workspace(snapshot: &ViewerSnapshot, id: &str) -> Result<(), ApiError> {
3270    snapshot
3271        .workspaces
3272        .iter()
3273        .any(|workspace| workspace.id == id)
3274        .then_some(())
3275        .ok_or_else(|| ApiError::bad_request("unknown workspace"))
3276}
3277
3278fn require_profile<'a>(
3279    snapshot: &'a ViewerSnapshot,
3280    id: &str,
3281) -> Result<&'a ViewerProfile, ApiError> {
3282    snapshot
3283        .profiles
3284        .iter()
3285        .find(|profile| profile.id == id)
3286        .ok_or_else(|| ApiError::bad_request("unknown profile"))
3287}
3288
3289fn require_target<'a>(
3290    snapshot: &'a ViewerSnapshot,
3291    id: &str,
3292) -> Result<&'a ViewerTarget, ApiError> {
3293    snapshot
3294        .targets
3295        .iter()
3296        .find(|target| target.id == id)
3297        .ok_or_else(|| ApiError::bad_request("unknown target"))
3298}
3299
3300fn require_bundle(snapshot: &ViewerSnapshot, id: &str) -> Result<(), ApiError> {
3301    snapshot
3302        .bundles
3303        .iter()
3304        .any(|bundle| bundle.id == id)
3305        .then_some(())
3306        .ok_or_else(|| ApiError::bad_request("unknown bundle"))
3307}
3308
3309#[derive(Debug, Serialize)]
3310struct ErrorBody<'a> {
3311    error: &'a str,
3312}
3313
3314#[derive(Debug)]
3315struct ApiError {
3316    status: StatusCode,
3317    message: &'static str,
3318}
3319
3320impl ApiError {
3321    const fn new(status: StatusCode, message: &'static str) -> Self {
3322        Self { status, message }
3323    }
3324
3325    const fn unauthorized() -> Self {
3326        Self::new(StatusCode::UNAUTHORIZED, "unauthorized")
3327    }
3328
3329    const fn bad_request(message: &'static str) -> Self {
3330        Self::new(StatusCode::BAD_REQUEST, message)
3331    }
3332
3333    const fn not_found(message: &'static str) -> Self {
3334        Self::new(StatusCode::NOT_FOUND, message)
3335    }
3336
3337    const fn controller_unavailable() -> Self {
3338        Self::new(StatusCode::SERVICE_UNAVAILABLE, "controller unavailable")
3339    }
3340}
3341
3342impl IntoResponse for ApiError {
3343    fn into_response(self) -> Response<Body> {
3344        (
3345            self.status,
3346            Json(ErrorBody {
3347                error: self.message,
3348            }),
3349        )
3350            .into_response()
3351    }
3352}
3353
3354fn code_locked(state: &ServerState) -> bool {
3355    state
3356        .code_guard
3357        .lock()
3358        .expect("viewer code guard poisoned")
3359        .locked_at(Instant::now())
3360}
3361
3362fn record_code_failure(state: &ServerState) {
3363    state
3364        .code_guard
3365        .lock()
3366        .expect("viewer code guard poisoned")
3367        .record_failure_at(Instant::now());
3368}
3369
3370fn reset_code_failures(state: &ServerState) {
3371    *state.code_guard.lock().expect("viewer code guard poisoned") = CodeGuard::default();
3372}
3373
3374fn generate_viewer_code() -> AnyResult<String> {
3375    // Rejection sampling avoids modulo bias in the deliberately small code
3376    // space. Online attempts are separately rate-limited.
3377    const RANGE: u32 = 1_000_000;
3378    const LIMIT: u32 = u32::MAX - (u32::MAX % RANGE);
3379    loop {
3380        let mut bytes = [0_u8; 4];
3381        getrandom::fill(&mut bytes)
3382            .map_err(|error| anyhow::anyhow!("generate Mjolnir viewer code: {error}"))?;
3383        let value = u32::from_le_bytes(bytes);
3384        if value < LIMIT {
3385            return Ok(format!("{:06}", value % RANGE));
3386        }
3387    }
3388}
3389
3390fn generate_login_token() -> AnyResult<String> {
3391    let mut token = [0_u8; 32];
3392    getrandom::fill(&mut token)
3393        .map_err(|error| anyhow::anyhow!("generate Mjolnir viewer login token: {error}"))?;
3394    Ok(base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(token))
3395}
3396
3397fn generate_cookie_key() -> AnyResult<[u8; COOKIE_KEY_BYTES]> {
3398    let mut key = [0_u8; COOKIE_KEY_BYTES];
3399    getrandom::fill(&mut key)
3400        .map_err(|error| anyhow::anyhow!("generate Mjolnir cookie key: {error}"))?;
3401    Ok(key)
3402}
3403
3404/// A random name for one viewer, minted at unlock.
3405///
3406/// The cookie used to sign only an expiry, which meant two phones unlocking in
3407/// the same second received byte-identical cookies and one phone's cookie
3408/// changed on every login. Nothing keyed to it could mean anything: a draft
3409/// would have leaked between phones and vanished on re-login. This is the
3410/// identity everything per-viewer hangs from.
3411fn generate_viewer_id() -> AnyResult<String> {
3412    let mut id = [0_u8; 16];
3413    getrandom::fill(&mut id)
3414        .map_err(|error| anyhow::anyhow!("generate Mjolnir viewer id: {error}"))?;
3415    Ok(base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(id))
3416}
3417
3418fn signed_cookie_value(key: &[u8], viewer: &str, expiry: u64) -> String {
3419    // The signed text separates its parts with a character the parts cannot
3420    // contain, so no two different pairs can produce the same signed text.
3421    let canonical = format!("{viewer}|{expiry}");
3422    let mut mac = Hmac::<Sha256>::new_from_slice(key).expect("HMAC accepts arbitrary key lengths");
3423    mac.update(canonical.as_bytes());
3424    let signature =
3425        base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes());
3426    format!("{viewer}.{expiry}.{signature}")
3427}
3428
3429/// The cookie value a viewer with no identity used to receive.
3430///
3431/// Still accepted, so a phone holding one is not signed out by a deployment.
3432/// It carries no viewer, so it stores nothing and is replaced by a three-part
3433/// cookie at its next unlock.
3434fn legacy_signed_cookie_value(key: &[u8], expiry: u64) -> String {
3435    let canonical = expiry.to_string();
3436    let mut mac = Hmac::<Sha256>::new_from_slice(key).expect("HMAC accepts arbitrary key lengths");
3437    mac.update(canonical.as_bytes());
3438    let signature =
3439        base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes());
3440    format!("{canonical}.{signature}")
3441}
3442
3443fn session_cookie_valid(key: &[u8], value: &str, now: u64) -> bool {
3444    cookie_viewer(key, value, now).is_some()
3445}
3446
3447/// Mint a signed viewer-session cookie value without the HTTP login flow.
3448///
3449/// The desktop shell pre-authorizes its WebView with this: it runs as the same
3450/// user as the daemon and reads the same persisted signing key, so possession
3451/// of the key is the credential. The cookie carries the ephemeral TTL — a
3452/// desktop window re-mints on every launch, so it never needs a long life.
3453pub fn mint_desktop_session_cookie(key: &[u8]) -> AnyResult<String> {
3454    let viewer = generate_viewer_id()?;
3455    Ok(signed_cookie_value(
3456        key,
3457        &viewer,
3458        now_unix().saturating_add(EPHEMERAL_SESSION_TTL.as_secs()),
3459    ))
3460}
3461
3462/// The viewer a cookie names, or `None` when the cookie is not valid.
3463///
3464/// A legacy two-part cookie validates and names no viewer, which is the
3465/// difference between "signed out" and "signed in with nothing stored".
3466fn cookie_viewer(key: &[u8], value: &str, now: u64) -> Option<Option<String>> {
3467    let parts = value.split('.').collect::<Vec<_>>();
3468    let (viewer, expiry, expected) = match parts.as_slice() {
3469        [viewer, expiry, _] => {
3470            let expiry_value = expiry.parse::<u64>().ok()?;
3471            (
3472                Some((*viewer).to_owned()),
3473                expiry_value,
3474                signed_cookie_value(key, viewer, expiry_value),
3475            )
3476        }
3477        [expiry, _] => {
3478            let expiry_value = expiry.parse::<u64>().ok()?;
3479            (
3480                None,
3481                expiry_value,
3482                legacy_signed_cookie_value(key, expiry_value),
3483            )
3484        }
3485        _ => return None,
3486    };
3487    if now >= expiry {
3488        return None;
3489    }
3490    constant_time_eq(expected.as_bytes(), value.as_bytes()).then_some(viewer)
3491}
3492
3493fn session_cookie_header(
3494    value: &str,
3495    max_age: Option<u64>,
3496    secure: bool,
3497) -> Result<HeaderValue, ApiError> {
3498    let mut header = format!("{COOKIE_NAME}={value}; Path=/; HttpOnly; SameSite=Strict");
3499    if secure {
3500        header.push_str("; Secure");
3501    }
3502    if let Some(max_age) = max_age {
3503        header.push_str(&format!("; Max-Age={max_age}"));
3504    }
3505    HeaderValue::from_str(&header)
3506        .map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "cookie creation failed"))
3507}
3508
3509fn clear_cookie_header(secure: bool) -> HeaderValue {
3510    let secure = if secure { "; Secure" } else { "" };
3511    HeaderValue::from_str(&format!(
3512        "{COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Strict{secure}; Max-Age=0"
3513    ))
3514    .expect("static cookie header is valid")
3515}
3516
3517/// The stored-state key for the viewer making this request.
3518///
3519/// A viewer with a legacy cookie has no identity, so it has no stored state:
3520/// it reads and writes nothing rather than sharing a bucket with every other
3521/// phone that unlocked in the same second, which is what the old whole-cookie
3522/// key amounted to.
3523fn viewer_client_id(state: &ServerState, headers: &HeaderMap) -> Option<String> {
3524    let cookie = headers
3525        .get(COOKIE)
3526        .and_then(|value| value.to_str().ok())
3527        .and_then(|header| cookie_value(header, COOKIE_NAME))?;
3528    cookie_viewer(&state.cookie_key, cookie, now_unix())
3529        .flatten()
3530        .map(|viewer| format!("phone:{viewer}"))
3531}
3532
3533fn cookie_value<'a>(header: &'a str, name: &str) -> Option<&'a str> {
3534    header
3535        .split(';')
3536        .filter_map(|part| part.trim().split_once('='))
3537        .find(|(cookie_name, _)| *cookie_name == name)
3538        .map(|(_, value)| value)
3539}
3540
3541fn constant_time_eq(left: &[u8], right: &[u8]) -> bool {
3542    if left.len() != right.len() {
3543        return false;
3544    }
3545    left.iter()
3546        .zip(right)
3547        .fold(0_u8, |difference, (left, right)| {
3548            difference | (left ^ right)
3549        })
3550        == 0
3551}
3552
3553fn now_unix() -> u64 {
3554    SystemTime::now()
3555        .duration_since(UNIX_EPOCH)
3556        .map(|elapsed| elapsed.as_secs())
3557        .unwrap_or(u64::MAX)
3558}
3559
3560const fn session_state_name(state: SessionState) -> &'static str {
3561    match state {
3562        SessionState::Provisioning => "provisioning",
3563        SessionState::Running => "running",
3564        SessionState::Disconnected => "disconnected",
3565        SessionState::Checkpointing => "checkpointing",
3566        SessionState::Closing => "closing",
3567        SessionState::Destroying => "destroying",
3568        SessionState::Stopped => "stopped",
3569        SessionState::Lost => "lost",
3570        SessionState::Error => "error",
3571        SessionState::DestroyedWithDataLoss => "destroyed-with-data-loss",
3572    }
3573}
3574
3575const fn target_kind_name(target: &TargetTemplate) -> &'static str {
3576    match target {
3577        TargetTemplate::LocalBare => "local-bare",
3578        TargetTemplate::LocalPodman { .. } => "local-podman",
3579        TargetTemplate::LocalDocker { .. } => "local-docker",
3580        TargetTemplate::AppleContainer { .. } => "apple-container",
3581        TargetTemplate::AwsEc2 { .. } => "aws-ec2",
3582        TargetTemplate::SshBare { .. } => "ssh-bare",
3583        TargetTemplate::SshPodman { .. } => "ssh-podman",
3584        TargetTemplate::SshDocker { .. } => "ssh-docker",
3585    }
3586}
3587
3588/// Every asset the browser application is built from. They are real files
3589/// under `src/web/` and `src/icons/` rather than string literals, so the
3590/// JavaScript can be read, formatted and tested as JavaScript, and so the
3591/// content-security policy below can forbid inline script outright.
3592const VIEWER_HTML: &str = include_str!("web/viewer.html");
3593const VIEWER_CSS: &str = include_str!("web/viewer.css");
3594const VIEWER_JS: &str = include_str!("web/viewer.js");
3595const MARKDOWN_JS: &str = include_str!("web/markdown.js");
3596const TOOL_OUTPUT_JS: &str = include_str!("web/tool-output.js");
3597const VOICE_WORKLET_JS: &str = include_str!("web/voice-worklet.js");
3598const VOICE_WORKER_JS: &str = include_str!("web/voice-worker.js");
3599/// A fake DOM for running the shipped renderers under Node. It is deliberately
3600/// not served: it exists so `cargo test` can exercise `markdown.js` without a
3601/// browser.
3602#[cfg(test)]
3603const TEST_DOM_JS: &str = include_str!("web/test-dom.js");
3604const SERVICE_WORKER: &str = include_str!("web/service-worker.js");
3605const MANIFEST: &str = include_str!("web/manifest.webmanifest");
3606const ICON_SVG: &str = include_str!("../src/icons/icon.svg");
3607const ICON_192: &[u8] = include_bytes!("../src/icons/icon-192.png");
3608const ICON_512: &[u8] = include_bytes!("../src/icons/icon-512.png");
3609const MASKABLE_512: &[u8] = include_bytes!("../src/icons/maskable-512.png");
3610const APPLE_TOUCH_ICON: &[u8] = include_bytes!("../src/icons/apple-touch-icon.png");
3611const MONO_FONT: &[u8] = include_bytes!("../src/fonts/jetbrains-mono.woff2");
3612
3613/// What the browser is permitted to load and execute.
3614///
3615/// `default-src 'none'` refuses everything not named below, so a future asset
3616/// has to be allowed deliberately. Script and style come only from this
3617/// origin, which is why none of either may be inline. `img-src` allows `blob:`
3618/// for browser-local attachment previews and keeps `data:` for legacy image
3619/// content rendered in a transcript.
3620const CONTENT_SECURITY_POLICY: &str = "default-src 'none'; \
3621script-src 'self'; \
3622style-src 'self'; \
3623img-src 'self' data: blob:; \
3624font-src 'self'; \
3625connect-src 'self'; \
3626manifest-src 'self'; \
3627base-uri 'none'; \
3628form-action 'none'; \
3629frame-ancestors 'none'";
3630
3631async fn viewer() -> Response<Body> {
3632    static_response("text/html; charset=utf-8", VIEWER_HTML, true)
3633}
3634
3635async fn viewer_css() -> Response<Body> {
3636    static_response("text/css; charset=utf-8", VIEWER_CSS, false)
3637}
3638
3639async fn viewer_js() -> Response<Body> {
3640    static_response("text/javascript; charset=utf-8", VIEWER_JS, false)
3641}
3642
3643async fn markdown_js() -> Response<Body> {
3644    static_response("text/javascript; charset=utf-8", MARKDOWN_JS, false)
3645}
3646
3647async fn voice_worklet_js() -> Response<Body> {
3648    static_response("text/javascript; charset=utf-8", VOICE_WORKLET_JS, false)
3649}
3650
3651async fn voice_worker_js() -> Response<Body> {
3652    static_response("text/javascript; charset=utf-8", VOICE_WORKER_JS, false)
3653}
3654
3655async fn tool_output_js() -> Response<Body> {
3656    static_response("text/javascript; charset=utf-8", TOOL_OUTPUT_JS, false)
3657}
3658
3659async fn manifest() -> Response<Body> {
3660    static_response("application/manifest+json", MANIFEST, false)
3661}
3662
3663/// The worker itself is never cached: a stale worker is what keeps a phone on
3664/// a superseded application, and it is the one asset that can never be fixed
3665/// by a later upgrade.
3666async fn service_worker() -> Response<Body> {
3667    static_response("text/javascript; charset=utf-8", SERVICE_WORKER, true)
3668}
3669
3670async fn icon() -> Response<Body> {
3671    static_response("image/svg+xml", ICON_SVG, false)
3672}
3673
3674async fn icon_192() -> Response<Body> {
3675    binary_response("image/png", ICON_192)
3676}
3677
3678async fn icon_512() -> Response<Body> {
3679    binary_response("image/png", ICON_512)
3680}
3681
3682async fn maskable_512() -> Response<Body> {
3683    binary_response("image/png", MASKABLE_512)
3684}
3685
3686async fn apple_touch_icon() -> Response<Body> {
3687    binary_response("image/png", APPLE_TOUCH_ICON)
3688}
3689
3690async fn mono_font() -> Response<Body> {
3691    binary_response("font/woff2", MONO_FONT)
3692}
3693
3694fn static_response(
3695    content_type: &'static str,
3696    body: &'static str,
3697    no_store: bool,
3698) -> Response<Body> {
3699    finish_static(Response::new(Body::from(body)), content_type, no_store)
3700}
3701
3702fn binary_response(content_type: &'static str, body: &'static [u8]) -> Response<Body> {
3703    finish_static(Response::new(Body::from(body)), content_type, false)
3704}
3705
3706/// Cacheable assets still revalidate. `no-cache` means "ask first", not "do
3707/// not store", so an upgraded viewer is picked up on the next load while an
3708/// unchanged one costs one conditional request.
3709fn finish_static(
3710    mut response: Response<Body>,
3711    content_type: &'static str,
3712    no_store: bool,
3713) -> Response<Body> {
3714    let headers = response.headers_mut();
3715    headers.insert(CONTENT_TYPE, HeaderValue::from_static(content_type));
3716    headers.insert(
3717        CACHE_CONTROL,
3718        HeaderValue::from_static(if no_store { "no-store" } else { "no-cache" }),
3719    );
3720    response
3721}
3722
3723/// Headers every response carries, applied once as a layer so no route can
3724/// forget them.
3725///
3726/// The layer also owns `no-store` for live state and authentication, rather
3727/// than leaving it to each handler. A rejected request never reaches its
3728/// handler, so a handler-set header is missing from exactly the responses that
3729/// are least worth storing.
3730async fn security_headers(request: Request, next: Next) -> Response<Body> {
3731    let live = {
3732        let path = request.uri().path();
3733        path.starts_with("/api/") || path.starts_with("/auth/")
3734    };
3735    let mut response = next.run(request).await;
3736    let headers = response.headers_mut();
3737    headers.insert(
3738        CONTENT_SECURITY_POLICY_HEADER,
3739        HeaderValue::from_static(CONTENT_SECURITY_POLICY),
3740    );
3741    headers.insert(X_CONTENT_TYPE_OPTIONS, HeaderValue::from_static("nosniff"));
3742    headers.insert(REFERRER_POLICY, HeaderValue::from_static("no-referrer"));
3743    if live {
3744        headers.insert(CACHE_CONTROL, HeaderValue::from_static("no-store"));
3745    }
3746    response
3747}
3748
3749pub fn viewer_config_options(
3750    config_options: &[agent_client_protocol::schema::v1::SessionConfigOption],
3751    facts: &mj_core::acp::AcpSessionFacts,
3752) -> Vec<ViewerConfigOption> {
3753    ["model", "effort"]
3754        .into_iter()
3755        .filter_map(|key| {
3756            let choices = mj_core::acp::session_config_choices(config_options, key);
3757            if choices.is_empty() {
3758                return None;
3759            }
3760            Some(ViewerConfigOption {
3761                key: key.to_owned(),
3762                label: key.to_owned(),
3763                current: match key {
3764                    "model" => facts.current_model(),
3765                    "effort" => facts.current_effort(),
3766                    _ => None,
3767                }
3768                .map(str::to_owned),
3769                choices: choices
3770                    .into_iter()
3771                    .map(|choice| ViewerConfigChoice {
3772                        value: choice.value,
3773                        name: choice.name,
3774                        description: choice.description,
3775                    })
3776                    .collect(),
3777            })
3778        })
3779        .collect()
3780}
3781
3782pub fn session_config_view(
3783    harness: mj_core::config::HarnessKind,
3784    state: &mj_core::relay::RelayOperationalState,
3785) -> Vec<ViewerConfigOption> {
3786    // Selectors are ACP categories; the config map supplies legacy current values.
3787    let facts = mj_core::acp::AcpSessionFacts::from_operational(
3788        harness,
3789        &state.config,
3790        &state.config_options,
3791        state.modes.as_ref(),
3792    );
3793    viewer_config_options(&state.config_options, &facts)
3794}
3795
3796#[cfg(test)]
3797mod tests {
3798    use super::*;
3799    use std::collections::BTreeMap;
3800    use std::path::Path;
3801
3802    use axum::http::Request;
3803    use http_body_util::BodyExt as _;
3804    use tower::ServiceExt as _;
3805
3806    use mj_core::config::{
3807        CONFIG_VERSION, ContainerTemplate, HarnessKind, HarnessProfile, PermissionMode,
3808        ProjectBundle, ProjectRepository, SshConnection,
3809    };
3810    use mj_core::state::{ProjectSourceIdentity, STATE_VERSION, SessionRecord};
3811
3812    #[test]
3813    fn unified_tls_backends_use_the_selected_crypto_provider() {
3814        install_rustls_crypto_provider();
3815
3816        assert!(rustls::crypto::CryptoProvider::get_default().is_some());
3817        let _builder = rustls::ServerConfig::builder();
3818    }
3819
3820    #[test]
3821    fn minted_desktop_cookie_validates_and_names_a_viewer() {
3822        let key = vec![7u8; COOKIE_KEY_BYTES];
3823        let value = mint_desktop_session_cookie(&key).unwrap();
3824        let viewer = cookie_viewer(&key, &value, now_unix());
3825        assert!(
3826            matches!(viewer, Some(Some(_))),
3827            "minted cookie must validate and carry a viewer id: {value:?}"
3828        );
3829        assert!(!session_cookie_valid(
3830            &[8u8; COOKIE_KEY_BYTES],
3831            &value,
3832            now_unix()
3833        ));
3834    }
3835
3836    pub(super) fn sample_config_state() -> (Config, AppState) {
3837        let config = Config {
3838            subagents: Default::default(),
3839            version: CONFIG_VERSION,
3840            sessions_side: Default::default(),
3841            advanced: Default::default(),
3842            show_stopped_sessions: false,
3843            newer_config_version: None,
3844            spinner: Default::default(),
3845            theme: Default::default(),
3846            phone: Default::default(),
3847            review: Default::default(),
3848            legacy_startup: (),
3849            profiles: BTreeMap::from([(
3850                "codex-1".into(),
3851                HarnessProfile {
3852                    enabled: true,
3853                    context_window_bytes: None,
3854                    kind: HarnessKind::Codex,
3855                    home: "/highly/secret/codex".into(),
3856                    environment: BTreeMap::from([("GH_TOKEN".into(), "secret-token".into())]),
3857                },
3858            )]),
3859            bundles: BTreeMap::from([(
3860                "hel".into(),
3861                ProjectBundle {
3862                    primary_repo: "hel".into(),
3863                    repositories: vec![ProjectRepository {
3864                        id: "hel".into(),
3865                        github: Some("owner/hel".into()),
3866                        local: Some("/private/source/hel".into()),
3867                        destination: "hel".into(),
3868                        git_ref: None,
3869                    }],
3870                },
3871            )]),
3872            targets: BTreeMap::from([
3873                (
3874                    "podman".into(),
3875                    TargetTemplate::LocalPodman {
3876                        container: ContainerTemplate {
3877                            image: "secret.registry/image".into(),
3878                            pull_policy: Default::default(),
3879                            platform: None,
3880                            cpus: None,
3881                            memory: None,
3882                            environment: BTreeMap::from([("TOKEN".into(), "secret-target".into())]),
3883                            workspace_storage: Default::default(),
3884                        },
3885                    },
3886                ),
3887                ("raw".into(), TargetTemplate::LocalBare),
3888            ]),
3889        };
3890        let state = AppState {
3891            subagents: Default::default(),
3892            version: STATE_VERSION,
3893            sessions: BTreeMap::from([(
3894                "session-1".into(),
3895                SessionRecord {
3896                    mjolnir_subagents: None,
3897                    create_managed_worktree: None,
3898                    workspace_id: mj_core::workspace::DEFAULT_WORKSPACE_ID.to_owned(),
3899                    archived: false,
3900                    container_cpus: None,
3901                    container_memory: None,
3902                    id: "session-1".into(),
3903                    title: "Build Hel".into(),
3904                    harness_kind: HarnessKind::Codex,
3905                    last_profile: "codex-1".into(),
3906                    bundle_id: "hel".into(),
3907                    project_directory: None,
3908                    managed_worktree: None,
3909                    target_template_id: "podman".into(),
3910                    resource_allocation: None,
3911                    additional_mounts: vec![],
3912                    state: SessionState::Running,
3913                    target: None,
3914                    native_session_id: Some("native-secret-id".into()),
3915                    acp_session_title: Some("Build Hel".into()),
3916                    session_title_override: None,
3917                    created_at: "now".into(),
3918                    updated_at: "now".into(),
3919                    viewed_through_event_ordinal: 0,
3920                    draft_input: String::new(),
3921                    last_error: Some("secret-token at /highly/secret/codex".into()),
3922                    last_checkpoint_error: None,
3923                    checkpoint: None,
3924                },
3925            )]),
3926            mount_history: BTreeMap::new(),
3927            container_sizes: BTreeMap::new(),
3928        };
3929        (config, state)
3930    }
3931
3932    type TestServer = (
3933        Router,
3934        mpsc::Receiver<ControllerRequest>,
3935        mpsc::Receiver<ReadReceiptRequest>,
3936        mpsc::Receiver<PreflightRequest>,
3937        mpsc::Receiver<ClientStateRequest>,
3938    );
3939
3940    fn app() -> TestServer {
3941        app_with_conversations(BTreeMap::new())
3942    }
3943
3944    fn app_with_move_receiver() -> (Router, mpsc::Receiver<MovePreparationRequest>) {
3945        let (config, state) = sample_config_state();
3946        let mut snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
3947        snapshot.sessions[0].capabilities.move_session = true;
3948        let (_snapshot_tx, snapshot_rx) = watch::channel(snapshot);
3949        let (_conversation_tx, conversation_rx) = watch::channel(BTreeMap::new());
3950        let (action_tx, _action_rx) = mpsc::channel(8);
3951        let (bundle_tx, _bundle_rx) = mpsc::channel(8);
3952        let (receipt_tx, _receipt_rx) = mpsc::channel(8);
3953        let (preflight_tx, _preflight_rx) = mpsc::channel(8);
3954        let (move_preparation_tx, move_preparation_rx) = mpsc::channel(8);
3955        let (client_state_tx, _client_state_rx) = mpsc::channel(8);
3956        let options = test_options(
3957            snapshot_rx,
3958            conversation_rx,
3959            action_tx,
3960            bundle_tx,
3961            receipt_tx,
3962            preflight_tx,
3963            move_preparation_tx,
3964            client_state_tx,
3965        )
3966        .with_test_credentials("123456", b"01234567890123456789012345678901");
3967        (router(options), move_preparation_rx)
3968    }
3969
3970    fn app_with_conversations(conversations: BTreeMap<String, BrowserTranscript>) -> TestServer {
3971        app_with(conversations, |_| {})
3972    }
3973
3974    fn app_with_snapshot(adjust: impl FnOnce(&mut ViewerSnapshot)) -> TestServer {
3975        app_with(BTreeMap::new(), adjust)
3976    }
3977
3978    fn app_with(
3979        conversations: BTreeMap<String, BrowserTranscript>,
3980        adjust: impl FnOnce(&mut ViewerSnapshot),
3981    ) -> TestServer {
3982        let (config, state) = sample_config_state();
3983        let mut snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
3984        adjust(&mut snapshot);
3985        let (_snapshot_tx, snapshot_rx) = watch::channel(snapshot);
3986        let (_conversation_tx, conversation_rx) = watch::channel(conversations);
3987        let (action_tx, action_rx) = mpsc::channel(8);
3988        let (bundle_tx, _bundle_rx) = mpsc::channel(8);
3989        let (receipt_tx, receipt_rx) = mpsc::channel(8);
3990        let (preflight_tx, preflight_rx) = mpsc::channel(8);
3991        let (move_preparation_tx, _move_preparation_rx) = mpsc::channel(8);
3992        let (client_state_tx, client_state_rx) = mpsc::channel(8);
3993        let options = test_options(
3994            snapshot_rx,
3995            conversation_rx,
3996            action_tx,
3997            bundle_tx,
3998            receipt_tx,
3999            preflight_tx,
4000            move_preparation_tx,
4001            client_state_tx,
4002        )
4003        .with_test_credentials("123456", b"01234567890123456789012345678901");
4004        (
4005            router(options),
4006            action_rx,
4007            receipt_rx,
4008            preflight_rx,
4009            client_state_rx,
4010        )
4011    }
4012
4013    fn app_with_bundle_receiver() -> (Router, mpsc::Receiver<BundleRequest>) {
4014        let (config, state) = sample_config_state();
4015        let (_snapshot_tx, snapshot_rx) =
4016            watch::channel(ViewerSnapshot::from_config_state(&config, &state, 1));
4017        let (_conversation_tx, conversation_rx) = watch::channel(BTreeMap::new());
4018        let (action_tx, _action_rx) = mpsc::channel(8);
4019        let (bundle_tx, bundle_rx) = mpsc::channel(8);
4020        let (receipt_tx, _receipt_rx) = mpsc::channel(8);
4021        let (preflight_tx, _preflight_rx) = mpsc::channel(8);
4022        let (move_preparation_tx, _move_preparation_rx) = mpsc::channel(8);
4023        let (client_state_tx, _client_state_rx) = mpsc::channel(8);
4024        let options = test_options(
4025            snapshot_rx,
4026            conversation_rx,
4027            action_tx,
4028            bundle_tx,
4029            receipt_tx,
4030            preflight_tx,
4031            move_preparation_tx,
4032            client_state_tx,
4033        )
4034        .with_test_credentials("123456", b"01234567890123456789012345678901");
4035        (router(options), bundle_rx)
4036    }
4037
4038    // Keep this test factory's arguments aligned with `ServerRequests`; each
4039    // channel is asserted independently by the HTTP behavior tests below.
4040    #[allow(clippy::too_many_arguments)]
4041    fn test_options(
4042        snapshot_rx: watch::Receiver<ViewerSnapshot>,
4043        conversation_rx: watch::Receiver<BTreeMap<String, BrowserTranscript>>,
4044        action_tx: mpsc::Sender<ControllerRequest>,
4045        bundle_tx: mpsc::Sender<BundleRequest>,
4046        receipt_tx: mpsc::Sender<ReadReceiptRequest>,
4047        preflight_tx: mpsc::Sender<PreflightRequest>,
4048        move_preparation_tx: mpsc::Sender<MovePreparationRequest>,
4049        client_state_tx: mpsc::Sender<ClientStateRequest>,
4050    ) -> ServerOptions {
4051        test_options_with_dictation(
4052            snapshot_rx,
4053            conversation_rx,
4054            action_tx,
4055            bundle_tx,
4056            receipt_tx,
4057            preflight_tx,
4058            move_preparation_tx,
4059            client_state_tx,
4060        )
4061        .0
4062    }
4063
4064    #[allow(clippy::too_many_arguments)]
4065    fn test_options_with_dictation(
4066        snapshot_rx: watch::Receiver<ViewerSnapshot>,
4067        conversation_rx: watch::Receiver<BTreeMap<String, BrowserTranscript>>,
4068        action_tx: mpsc::Sender<ControllerRequest>,
4069        bundle_tx: mpsc::Sender<BundleRequest>,
4070        receipt_tx: mpsc::Sender<ReadReceiptRequest>,
4071        preflight_tx: mpsc::Sender<PreflightRequest>,
4072        move_preparation_tx: mpsc::Sender<MovePreparationRequest>,
4073        client_state_tx: mpsc::Sender<ClientStateRequest>,
4074    ) -> (ServerOptions, mpsc::Receiver<DictationRequest>) {
4075        let (dictation_tx, dictation_rx) = mpsc::channel(8);
4076        let options = ServerOptions::new(
4077            "127.0.0.1:0".parse().unwrap(),
4078            snapshot_rx,
4079            conversation_rx,
4080            ServerRequests {
4081                action_tx,
4082                bundle_tx,
4083                receipt_tx,
4084                preflight_tx,
4085                move_preparation_tx,
4086                client_state_tx,
4087                dictation_tx,
4088            },
4089        )
4090        .unwrap();
4091        (options, dictation_rx)
4092    }
4093
4094    fn app_with_dictation_receiver() -> (Router, mpsc::Receiver<DictationRequest>) {
4095        let (config, state) = sample_config_state();
4096        let (_snapshot_tx, snapshot_rx) =
4097            watch::channel(ViewerSnapshot::from_config_state(&config, &state, 1));
4098        let (_conversation_tx, conversation_rx) = watch::channel(BTreeMap::new());
4099        let (action_tx, _action_rx) = mpsc::channel(8);
4100        let (bundle_tx, _bundle_rx) = mpsc::channel(8);
4101        let (receipt_tx, _receipt_rx) = mpsc::channel(8);
4102        let (preflight_tx, _preflight_rx) = mpsc::channel(8);
4103        let (move_preparation_tx, _move_preparation_rx) = mpsc::channel(8);
4104        let (client_state_tx, _client_state_rx) = mpsc::channel(8);
4105        let (options, dictation_rx) = test_options_with_dictation(
4106            snapshot_rx,
4107            conversation_rx,
4108            action_tx,
4109            bundle_tx,
4110            receipt_tx,
4111            preflight_tx,
4112            move_preparation_tx,
4113            client_state_tx,
4114        );
4115        (
4116            router(options.with_test_credentials("123456", b"01234567890123456789012345678901")),
4117            dictation_rx,
4118        )
4119    }
4120
4121    fn detached_options() -> ServerOptions {
4122        let (config, state) = sample_config_state();
4123        let (_snapshot_tx, snapshot_rx) =
4124            watch::channel(ViewerSnapshot::from_config_state(&config, &state, 1));
4125        let (_conversation_tx, conversation_rx) = watch::channel(BTreeMap::new());
4126        let (action_tx, _action_rx) = mpsc::channel(1);
4127        let (bundle_tx, _bundle_rx) = mpsc::channel(1);
4128        let (receipt_tx, _receipt_rx) = mpsc::channel(1);
4129        let (preflight_tx, _preflight_rx) = mpsc::channel(1);
4130        let (move_preparation_tx, _move_preparation_rx) = mpsc::channel(1);
4131        let (client_state_tx, _client_state_rx) = mpsc::channel(1);
4132        test_options(
4133            snapshot_rx,
4134            conversation_rx,
4135            action_tx,
4136            bundle_tx,
4137            receipt_tx,
4138            preflight_tx,
4139            move_preparation_tx,
4140            client_state_tx,
4141        )
4142    }
4143
4144    /// A valid session cookie for the test server's key.
4145    ///
4146    /// Most checks are about what an authenticated request does rather than
4147    /// about how it authenticated, and going through the login route for each
4148    /// one buys nothing.
4149    fn cookie() -> String {
4150        format!(
4151            "{COOKIE_NAME}={}",
4152            signed_cookie_value(
4153                b"01234567890123456789012345678901",
4154                "test-viewer",
4155                now_unix().saturating_add(3600)
4156            )
4157        )
4158    }
4159
4160    fn valid_wav() -> Bytes {
4161        let samples = vec![0_u8; 320];
4162        let mut wav = Vec::with_capacity(44 + samples.len());
4163        wav.extend_from_slice(b"RIFF");
4164        wav.extend_from_slice(&(36_u32 + samples.len() as u32).to_le_bytes());
4165        wav.extend_from_slice(b"WAVEfmt ");
4166        wav.extend_from_slice(&16_u32.to_le_bytes());
4167        wav.extend_from_slice(&1_u16.to_le_bytes());
4168        wav.extend_from_slice(&1_u16.to_le_bytes());
4169        wav.extend_from_slice(&16_000_u32.to_le_bytes());
4170        wav.extend_from_slice(&32_000_u32.to_le_bytes());
4171        wav.extend_from_slice(&2_u16.to_le_bytes());
4172        wav.extend_from_slice(&16_u16.to_le_bytes());
4173        wav.extend_from_slice(b"data");
4174        wav.extend_from_slice(&(samples.len() as u32).to_le_bytes());
4175        wav.extend_from_slice(&samples);
4176        Bytes::from(wav)
4177    }
4178
4179    async fn login_cookie(app: &Router) -> String {
4180        let response = app
4181            .clone()
4182            .oneshot(
4183                Request::post("/auth/session")
4184                    .header(CONTENT_TYPE, "application/json")
4185                    .body(Body::from(r#"{"code":"123456"}"#))
4186                    .unwrap(),
4187            )
4188            .await
4189            .unwrap();
4190        assert_eq!(response.status(), StatusCode::NO_CONTENT);
4191        response
4192            .headers()
4193            .get(SET_COOKIE)
4194            .unwrap()
4195            .to_str()
4196            .unwrap()
4197            .split(';')
4198            .next()
4199            .unwrap()
4200            .to_string()
4201    }
4202
4203    #[tokio::test]
4204    async fn dictation_availability_requires_auth_and_forwards_typed_request() {
4205        let (app, mut requests) = app_with_dictation_receiver();
4206        let unauthorized = app
4207            .clone()
4208            .oneshot(
4209                Request::get("/api/sessions/session-1/dictation")
4210                    .body(Body::empty())
4211                    .unwrap(),
4212            )
4213            .await
4214            .unwrap();
4215        assert_eq!(unauthorized.status(), StatusCode::UNAUTHORIZED);
4216        assert!(requests.try_recv().is_err());
4217
4218        let cookie = login_cookie(&app).await;
4219        let pending = tokio::spawn({
4220            let app = app.clone();
4221            async move {
4222                app.oneshot(
4223                    Request::get("/api/sessions/session-1/dictation")
4224                        .header(COOKIE, cookie)
4225                        .body(Body::empty())
4226                        .unwrap(),
4227                )
4228                .await
4229                .unwrap()
4230            }
4231        });
4232        let request = requests.recv().await.unwrap();
4233        assert_eq!(request.session_id, "session-1");
4234        assert!(matches!(
4235            request.operation,
4236            DictationOperation::Availability
4237        ));
4238        request
4239            .reply
4240            .send(Ok(DictationResponse::Availability {
4241                available: true,
4242                reason: None,
4243            }))
4244            .unwrap();
4245        let response = pending.await.unwrap();
4246        assert_eq!(response.status(), StatusCode::OK);
4247        let body = response.into_body().collect().await.unwrap().to_bytes();
4248        assert_eq!(&body[..], br#"{"available":true}"#);
4249    }
4250
4251    #[tokio::test]
4252    async fn dictation_rejects_bad_wav_before_controller_dispatch() {
4253        let (app, mut requests) = app_with_dictation_receiver();
4254        let cookie = login_cookie(&app).await;
4255        let response = app
4256            .oneshot(
4257                Request::post("/api/sessions/session-1/dictation")
4258                    .header(COOKIE, cookie)
4259                    .header(CONTENT_TYPE, "audio/wav")
4260                    .body(Body::from("not wav"))
4261                    .unwrap(),
4262            )
4263            .await
4264            .unwrap();
4265        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
4266        assert!(requests.try_recv().is_err());
4267    }
4268
4269    #[tokio::test]
4270    async fn dictation_rejects_a_third_upload_before_reading_its_body() {
4271        let (app, mut requests) = app_with_dictation_receiver();
4272        let cookie = login_cookie(&app).await;
4273        let request = || {
4274            Request::post("/api/sessions/session-1/dictation")
4275                .header(COOKIE, cookie.clone())
4276                .header(CONTENT_TYPE, "audio/wav")
4277                .body(Body::from(valid_wav()))
4278                .unwrap()
4279        };
4280        let first = tokio::spawn({
4281            let app = app.clone();
4282            let request = request();
4283            async move { app.oneshot(request).await.unwrap() }
4284        });
4285        let second = tokio::spawn({
4286            let app = app.clone();
4287            let request = request();
4288            async move { app.oneshot(request).await.unwrap() }
4289        });
4290        let first_request = requests.recv().await.unwrap();
4291        let second_request = requests.recv().await.unwrap();
4292        let third = app
4293            .oneshot(
4294                Request::post("/api/sessions/session-1/dictation")
4295                    .header(COOKIE, cookie)
4296                    .header(CONTENT_TYPE, "audio/wav")
4297                    .body(Body::from_stream(futures::stream::poll_fn(
4298                        |_| -> std::task::Poll<Option<Result<Bytes, std::io::Error>>> {
4299                            panic!("overloaded dictation polled its body")
4300                        },
4301                    )))
4302                    .unwrap(),
4303            )
4304            .await
4305            .unwrap();
4306        assert_eq!(third.status(), StatusCode::TOO_MANY_REQUESTS);
4307        first_request
4308            .reply
4309            .send(Ok(DictationResponse::Transcript {
4310                text: "first".into(),
4311            }))
4312            .unwrap();
4313        second_request
4314            .reply
4315            .send(Ok(DictationResponse::Transcript {
4316                text: "second".into(),
4317            }))
4318            .unwrap();
4319        assert_eq!(first.await.unwrap().status(), StatusCode::OK);
4320        assert_eq!(second.await.unwrap().status(), StatusCode::OK);
4321    }
4322
4323    #[tokio::test]
4324    async fn dictation_upload_rejects_unauthorized_missing_and_oversized_requests() {
4325        let (app, mut requests) = app_with_dictation_receiver();
4326        let response = app
4327            .clone()
4328            .oneshot(
4329                Request::post("/api/sessions/session-1/dictation")
4330                    .body(Body::from(valid_wav()))
4331                    .unwrap(),
4332            )
4333            .await
4334            .unwrap();
4335        assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
4336        let cookie = login_cookie(&app).await;
4337        let response = app
4338            .clone()
4339            .oneshot(
4340                Request::post("/api/sessions/missing/dictation")
4341                    .header(COOKIE, &cookie)
4342                    .body(Body::from(valid_wav()))
4343                    .unwrap(),
4344            )
4345            .await
4346            .unwrap();
4347        assert_eq!(response.status(), StatusCode::NOT_FOUND);
4348        // Exercise the streamed body limit without relying on Content-Length.
4349        let response = app
4350            .oneshot(
4351                Request::post("/api/sessions/session-1/dictation")
4352                    .header(COOKIE, cookie)
4353                    .body(Body::from(vec![0_u8; MAX_AUDIO_BYTES + 1]))
4354                    .unwrap(),
4355            )
4356            .await
4357            .unwrap();
4358        assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE);
4359        assert!(requests.try_recv().is_err());
4360    }
4361
4362    fn app_with_background_stop_receiver(
4363        can_stop: bool,
4364    ) -> (Router, mpsc::Receiver<BackgroundTaskStopRequest>) {
4365        let (config, state) = sample_config_state();
4366        let mut snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
4367        snapshot.sessions[0].background_tasks = vec![ViewerBackgroundTask {
4368            id: "terminal:background-1".into(),
4369            command: "cargo test".into(),
4370            started_at_ms: 1_000,
4371            can_stop,
4372        }];
4373        let (_snapshot_tx, snapshot_rx) = watch::channel(snapshot);
4374        let (_conversation_tx, conversation_rx) = watch::channel(BTreeMap::new());
4375        let (action_tx, _action_rx) = mpsc::channel(8);
4376        let (bundle_tx, _bundle_rx) = mpsc::channel(8);
4377        let (receipt_tx, _receipt_rx) = mpsc::channel(8);
4378        let (preflight_tx, _preflight_rx) = mpsc::channel(8);
4379        let (move_preparation_tx, _move_preparation_rx) = mpsc::channel(8);
4380        let (client_state_tx, _client_state_rx) = mpsc::channel(8);
4381        let (stop_tx, stop_rx) = mpsc::channel(8);
4382        let mut options = test_options(
4383            snapshot_rx,
4384            conversation_rx,
4385            action_tx,
4386            bundle_tx,
4387            receipt_tx,
4388            preflight_tx,
4389            move_preparation_tx,
4390            client_state_tx,
4391        )
4392        .with_test_credentials("123456", b"01234567890123456789012345678901");
4393        options.set_background_task_stop_tx(stop_tx);
4394        (router(options), stop_rx)
4395    }
4396
4397    #[tokio::test]
4398    async fn background_task_stop_validates_the_snapshot_and_waits_for_acknowledgement() {
4399        let (app, mut requests) = app_with_background_stop_receiver(true);
4400        let cookie = login_cookie(&app).await;
4401        let response = tokio::spawn({
4402            let app = app.clone();
4403            let cookie = cookie.clone();
4404            async move {
4405                app.oneshot(
4406                    Request::post("/api/sessions/session-1/background-tasks/stop")
4407                        .header(COOKIE, cookie)
4408                        .header(CONTENT_TYPE, "application/json")
4409                        .body(Body::from(
4410                            r#"{"background_task_id":"terminal:background-1"}"#,
4411                        ))
4412                        .unwrap(),
4413                )
4414                .await
4415                .unwrap()
4416            }
4417        });
4418        let request = requests.recv().await.unwrap();
4419        assert_eq!(request.session_id, "session-1");
4420        assert_eq!(request.background_task_id, "terminal:background-1");
4421        request.reply.send(Ok(())).unwrap();
4422        assert_eq!(response.await.unwrap().status(), StatusCode::ACCEPTED);
4423
4424        let (app, mut requests) = app_with_background_stop_receiver(false);
4425        let cookie = login_cookie(&app).await;
4426        let response = app
4427            .oneshot(
4428                Request::post("/api/sessions/session-1/background-tasks/stop")
4429                    .header(COOKIE, cookie)
4430                    .header(CONTENT_TYPE, "application/json")
4431                    .body(Body::from(
4432                        r#"{"background_task_id":"terminal:background-1"}"#,
4433                    ))
4434                    .unwrap(),
4435            )
4436            .await
4437            .unwrap();
4438        assert_eq!(response.status(), StatusCode::CONFLICT);
4439        assert!(requests.try_recv().is_err());
4440    }
4441
4442    #[tokio::test]
4443    async fn background_task_stop_reports_provider_failure_without_leaking_details() {
4444        let (app, mut requests) = app_with_background_stop_receiver(true);
4445        let cookie = login_cookie(&app).await;
4446        let response = tokio::spawn({
4447            let app = app.clone();
4448            async move {
4449                app.oneshot(
4450                    Request::post("/api/sessions/session-1/background-tasks/stop")
4451                        .header(COOKIE, cookie)
4452                        .header(CONTENT_TYPE, "application/json")
4453                        .body(Body::from(
4454                            r#"{"background_task_id":"terminal:background-1"}"#,
4455                        ))
4456                        .unwrap(),
4457                )
4458                .await
4459                .unwrap()
4460            }
4461        });
4462        let request = requests.recv().await.unwrap();
4463        request
4464            .reply
4465            .send(Err(BackgroundTaskStopFailure::Provider))
4466            .unwrap();
4467        let response = response.await.unwrap();
4468        assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
4469        let body = response.into_body().collect().await.unwrap().to_bytes();
4470        assert_eq!(
4471            &body[..],
4472            br#"{"error":"the provider could not stop this background task"}"#
4473        );
4474    }
4475
4476    #[tokio::test]
4477    async fn dictation_provider_failure_is_actionable_and_does_not_expose_details() {
4478        let (app, mut requests) = app_with_dictation_receiver();
4479        let cookie = login_cookie(&app).await;
4480        let pending = tokio::spawn(async move {
4481            app.oneshot(
4482                Request::post("/api/sessions/session-1/dictation")
4483                    .header(COOKIE, cookie)
4484                    .body(Body::from(valid_wav()))
4485                    .unwrap(),
4486            )
4487            .await
4488            .unwrap()
4489        });
4490        let request = requests.recv().await.unwrap();
4491        request
4492            .reply
4493            .send(Err(DictationError::Provider(
4494                "private provider details".into(),
4495            )))
4496            .unwrap();
4497        let response = pending.await.unwrap();
4498        assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
4499        let bytes = response.into_body().collect().await.unwrap().to_bytes();
4500        let body = String::from_utf8(bytes.to_vec()).unwrap();
4501        assert!(body.contains("transcription"));
4502        assert!(!body.contains("private provider details"));
4503    }
4504
4505    #[tokio::test]
4506    async fn dropped_dictation_handler_cancels_controller_request() {
4507        let (app, mut requests) = app_with_dictation_receiver();
4508        let cookie = login_cookie(&app).await;
4509        let pending = tokio::spawn({
4510            let app = app.clone();
4511            async move {
4512                app.oneshot(
4513                    Request::post("/api/sessions/session-1/dictation")
4514                        .header(COOKIE, cookie)
4515                        .body(Body::from(valid_wav()))
4516                        .unwrap(),
4517                )
4518                .await
4519                .unwrap()
4520            }
4521        });
4522        let request = requests.recv().await.unwrap();
4523        let cancel = request.cancel.clone();
4524        pending.abort();
4525        let _ = pending.await;
4526        assert!(cancel.is_cancelled());
4527        drop(request);
4528    }
4529
4530    #[tokio::test]
4531    async fn bundle_endpoint_authenticates_and_forwards_the_source() {
4532        let (app, mut bundles) = app_with_bundle_receiver();
4533        let unauthorized = app
4534            .clone()
4535            .oneshot(
4536                Request::post("/api/bundles")
4537                    .header(CONTENT_TYPE, "application/json")
4538                    .body(Body::from(r#"{"source":"example/app"}"#))
4539                    .unwrap(),
4540            )
4541            .await
4542            .unwrap();
4543        assert_eq!(unauthorized.status(), StatusCode::UNAUTHORIZED);
4544        assert!(bundles.try_recv().is_err());
4545
4546        let cookie = login_cookie(&app).await;
4547        let response = tokio::spawn({
4548            let app = app.clone();
4549            let cookie = cookie.clone();
4550            async move {
4551                app.oneshot(
4552                    Request::post("/api/bundles")
4553                        .header(CONTENT_TYPE, "application/json")
4554                        .header(COOKIE, cookie)
4555                        .body(Body::from(r#"{"source":"example/app"}"#))
4556                        .unwrap(),
4557                )
4558                .await
4559                .unwrap()
4560            }
4561        });
4562        let request = bundles.recv().await.expect("bundle request forwarded");
4563        assert_eq!(request.source, "example/app");
4564        request.reply.send(Ok("app".into())).unwrap();
4565        let response = response.await.unwrap();
4566        assert_eq!(response.status(), StatusCode::OK);
4567        let body = response.into_body().collect().await.unwrap().to_bytes();
4568        assert_eq!(body.as_ref(), br#"{"bundle_id":"app"}"#);
4569    }
4570
4571    #[tokio::test]
4572    async fn bundle_endpoint_rejects_empty_and_oversized_sources_before_dispatch() {
4573        for source in [String::new(), "x".repeat(MAX_BUNDLE_SOURCE_CHARS + 1)] {
4574            let (app, mut bundles) = app_with_bundle_receiver();
4575            let cookie = login_cookie(&app).await;
4576            let response = app
4577                .oneshot(
4578                    Request::post("/api/bundles")
4579                        .header(CONTENT_TYPE, "application/json")
4580                        .header(COOKIE, cookie)
4581                        .body(Body::from(
4582                            serde_json::to_string(&serde_json::json!({"source": source})).unwrap(),
4583                        ))
4584                        .unwrap(),
4585                )
4586                .await
4587                .unwrap();
4588            assert_eq!(response.status(), StatusCode::BAD_REQUEST);
4589            assert!(bundles.try_recv().is_err());
4590        }
4591    }
4592
4593    #[tokio::test]
4594    async fn bundle_endpoint_reports_invalid_source_as_a_client_error() {
4595        let (app, mut bundles) = app_with_bundle_receiver();
4596        let cookie = login_cookie(&app).await;
4597        let response = tokio::spawn({
4598            let app = app.clone();
4599            async move {
4600                app.oneshot(
4601                    Request::post("/api/bundles")
4602                        .header(CONTENT_TYPE, "application/json")
4603                        .header(COOKIE, cookie)
4604                        .body(Body::from(r#"{"source":"not a source"}"#))
4605                        .unwrap(),
4606                )
4607                .await
4608                .unwrap()
4609            }
4610        });
4611        let request = bundles.recv().await.expect("bundle request forwarded");
4612        request
4613            .reply
4614            .send(Err(BundleFailure::InvalidSource))
4615            .unwrap();
4616        let response = response.await.unwrap();
4617        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
4618        let body = response.into_body().collect().await.unwrap().to_bytes();
4619        assert!(String::from_utf8_lossy(&body).contains("GitHub owner/repository"));
4620    }
4621
4622    #[tokio::test]
4623    async fn api_requires_a_valid_signed_cookie() {
4624        let (app, _, _, _, _) = app();
4625        let unauthorized = app
4626            .clone()
4627            .oneshot(Request::get("/api/snapshot").body(Body::empty()).unwrap())
4628            .await
4629            .unwrap();
4630        assert_eq!(unauthorized.status(), StatusCode::UNAUTHORIZED);
4631
4632        let cookie = login_cookie(&app).await;
4633        let authorized = app
4634            .oneshot(
4635                Request::get("/api/snapshot")
4636                    .header(COOKIE, cookie)
4637                    .body(Body::empty())
4638                    .unwrap(),
4639            )
4640            .await
4641            .unwrap();
4642        assert_eq!(authorized.status(), StatusCode::OK);
4643    }
4644
4645    #[tokio::test]
4646    async fn qr_login_exchanges_the_secret_for_a_cookie_and_redirects_cleanly() {
4647        let (app, _, _, _, _) = app();
4648        let rejected = app
4649            .clone()
4650            .oneshot(
4651                Request::get("/auth/login?token=wrong")
4652                    .body(Body::empty())
4653                    .unwrap(),
4654            )
4655            .await
4656            .unwrap();
4657        assert_eq!(rejected.status(), StatusCode::UNAUTHORIZED);
4658
4659        let accepted = app
4660            .oneshot(
4661                Request::get("/auth/login?token=test-login-token")
4662                    .body(Body::empty())
4663                    .unwrap(),
4664            )
4665            .await
4666            .unwrap();
4667        assert_eq!(accepted.status(), StatusCode::SEE_OTHER);
4668        assert_eq!(accepted.headers().get(LOCATION).unwrap(), "/");
4669        assert_eq!(accepted.headers().get(CACHE_CONTROL).unwrap(), "no-store");
4670        assert!(accepted.headers().contains_key(SET_COOKIE));
4671    }
4672
4673    #[test]
4674    fn signed_cookie_rejects_expiry_and_tampering() {
4675        let key = b"01234567890123456789012345678901";
4676        let cookie = signed_cookie_value(key, "test-viewer", 200);
4677        assert!(session_cookie_valid(key, &cookie, 100));
4678        assert!(!session_cookie_valid(key, &cookie, 200));
4679        assert!(!session_cookie_valid(key, &format!("{cookie}x"), 100));
4680        assert!(!session_cookie_valid(b"another-key", &cookie, 100));
4681    }
4682
4683    #[test]
4684    fn generated_code_and_cookie_attributes_are_phone_safe() {
4685        let code = generate_viewer_code().unwrap();
4686        assert_eq!(code.len(), 6);
4687        assert!(code.bytes().all(|byte| byte.is_ascii_digit()));
4688        let header = session_cookie_header("signed", Some(60), true)
4689            .unwrap()
4690            .to_str()
4691            .unwrap()
4692            .to_string();
4693        assert!(header.contains("HttpOnly"));
4694        assert!(header.contains("SameSite=Strict"));
4695        assert!(header.contains("Secure"));
4696        assert!(header.contains("Max-Age=60"));
4697    }
4698
4699    #[test]
4700    fn public_snapshot_omits_homes_environment_locators_and_raw_errors() {
4701        let (config, state) = sample_config_state();
4702        let json =
4703            serde_json::to_string(&ViewerSnapshot::from_config_state(&config, &state, 9)).unwrap();
4704        assert!(!json.contains("/highly/secret"));
4705        assert!(!json.contains("secret-token"));
4706        assert!(!json.contains("secret-target"));
4707        assert!(!json.contains("secret.registry"));
4708        assert!(!json.contains("native-secret-id"));
4709        assert!(json.contains("\"has_error\":true"));
4710    }
4711
4712    #[test]
4713    fn public_snapshot_keeps_running_sessions_but_omits_disabled_profiles() {
4714        let (mut config, state) = sample_config_state();
4715        config.profiles.get_mut("codex-1").unwrap().enabled = false;
4716
4717        let snapshot = ViewerSnapshot::from_config_state(&config, &state, 9);
4718
4719        assert!(snapshot.profiles.is_empty());
4720        assert_eq!(snapshot.sessions.len(), 1);
4721        assert_eq!(snapshot.sessions[0].profile_id, "codex-1");
4722    }
4723
4724    #[test]
4725    fn target_snapshot_uses_each_raw_host_project_history_and_leaves_managed_empty() {
4726        let (mut config, mut state) = sample_config_state();
4727        config
4728            .targets
4729            .insert("raw-local".into(), TargetTemplate::LocalBare);
4730        config.targets.insert(
4731            "raw-builder".into(),
4732            TargetTemplate::SshBare {
4733                ssh: SshConnection {
4734                    host: "builder-a".into(),
4735                    user: None,
4736                    identity_file: None,
4737                    extra_args: Vec::new(),
4738                },
4739                permissions: PermissionMode::Guardian,
4740                workspace_prefix: "workspaces".into(),
4741            },
4742        );
4743        state.remember_project_directory("local", Path::new("/work/local"));
4744        state.remember_project_directory("builder-a", Path::new("/srv/builder"));
4745        state.remember_project_directory("other-host", Path::new("/not-published"));
4746
4747        let snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
4748        let target = |id: &str| {
4749            snapshot
4750                .targets
4751                .iter()
4752                .find(|target| target.id == id)
4753                .unwrap()
4754        };
4755        assert_eq!(
4756            target("raw-local").recent_project_directories,
4757            vec!["/work/local"]
4758        );
4759        assert_eq!(
4760            target("raw-builder").recent_project_directories,
4761            vec!["/srv/builder"]
4762        );
4763        assert!(target("podman").recent_project_directories.is_empty());
4764    }
4765
4766    #[test]
4767    fn public_snapshot_exposes_only_review_status_configuration() {
4768        let (mut config, state) = sample_config_state();
4769        config.review = mj_core::config::ReviewConfig {
4770            enabled: true,
4771            tier: mj_core::review::lanes::ReviewTier::Extended,
4772            profile: Some("reviewer-1".into()),
4773            model: Some("private-review-model".into()),
4774            effort: Some("private-review-effort".into()),
4775        };
4776
4777        let value =
4778            serde_json::to_value(ViewerSnapshot::from_config_state(&config, &state, 9)).unwrap();
4779
4780        assert_eq!(
4781            value.get("review_config"),
4782            Some(&serde_json::json!({
4783                "enabled": true,
4784                "tier": "extended",
4785                "profile": "reviewer-1",
4786            }))
4787        );
4788        let json = value.to_string();
4789        assert!(!json.contains("private-review-model"));
4790        assert!(!json.contains("private-review-effort"));
4791    }
4792
4793    fn sample_elicitation() -> ElicitationRequest {
4794        ElicitationRequest::from_acp_params(
4795            "elicitation-1",
4796            serde_json::json!({
4797                "sessionId": "session-1",
4798                "mode": "form",
4799                "message": "Which CI architecture should the workflow use?",
4800                "requestedSchema": {
4801                    "type": "object",
4802                    "required": ["question_0"],
4803                    "properties": {
4804                        "question_0": {
4805                            "type": "string",
4806                            "title": "CI architecture",
4807                            "oneOf": [
4808                                {"const": "reusable", "title": "Reusable workflow"},
4809                                {"const": "matrix", "title": "Matrix job"}
4810                            ]
4811                        },
4812                        "question_0_custom": {
4813                            "type": "string",
4814                            "title": "Other",
4815                            "_meta": {"_askUserQuestionCustomAnswer": {
4816                                "questionId": "question_0",
4817                                "isCustomAnswer": true
4818                            }}
4819                        }
4820                    }
4821                }
4822            }),
4823        )
4824        .expect("sample elicitation parses")
4825    }
4826
4827    fn accept(pairs: &[(&str, &str)]) -> ElicitationResponse {
4828        ElicitationResponse::Accept {
4829            content: pairs
4830                .iter()
4831                .map(|(id, value)| {
4832                    (
4833                        (*id).to_owned(),
4834                        mj_core::elicitation::ElicitationValue::String((*value).to_owned()),
4835                    )
4836                })
4837                .collect(),
4838        }
4839    }
4840
4841    fn pending_elicitation_snapshot(snapshot: &mut ViewerSnapshot) {
4842        snapshot.sessions[0].pending_elicitations = vec![sample_elicitation()];
4843    }
4844
4845    #[tokio::test]
4846    async fn elicitation_answer_is_typed_and_forwarded() {
4847        let (app, mut actions, _, _, _) = app_with_snapshot(pending_elicitation_snapshot);
4848        let cookie = login_cookie(&app).await;
4849        let response = tokio::spawn(
4850            app.oneshot(
4851                Request::post("/api/actions")
4852                    .header(COOKIE, cookie)
4853                    .header(CONTENT_TYPE, "application/json")
4854                    .body(Body::from(
4855                        r#"{"action":"respond-elicitation","session_id":"session-1","elicitation_id":"elicitation-1","response":{"action":"accept","content":{"question_0":"reusable"}}}"#,
4856                    ))
4857                    .unwrap(),
4858            ),
4859        );
4860        let action = actions.recv().await.unwrap();
4861        assert_eq!(
4862            action.action,
4863            ControllerAction::RespondElicitation {
4864                session_id: "session-1".into(),
4865                elicitation_id: "elicitation-1".into(),
4866                response: accept(&[("question_0", "reusable")]),
4867            }
4868        );
4869        action.reply.send(ActionOutcome::accepted()).unwrap();
4870        assert_eq!(
4871            response.await.unwrap().unwrap().status(),
4872            StatusCode::ACCEPTED
4873        );
4874    }
4875
4876    #[tokio::test]
4877    async fn elicitation_answer_for_an_unknown_request_is_refused_without_reaching_the_controller()
4878    {
4879        let (app, mut actions, _, _, _) = app_with_snapshot(pending_elicitation_snapshot);
4880        let cookie = login_cookie(&app).await;
4881        let response = app
4882            .oneshot(
4883                Request::post("/api/actions")
4884                    .header(COOKIE, cookie)
4885                    .header(CONTENT_TYPE, "application/json")
4886                    .body(Body::from(
4887                        r#"{"action":"respond-elicitation","session_id":"session-1","elicitation_id":"elicitation-9","response":{"action":"cancel"}}"#,
4888                    ))
4889                    .unwrap(),
4890            )
4891            .await
4892            .unwrap();
4893        assert_eq!(response.status(), StatusCode::NOT_FOUND);
4894        assert!(actions.try_recv().is_err());
4895    }
4896
4897    #[test]
4898    fn elicitation_answers_are_checked_against_the_request_the_agent_asked() {
4899        let (config, state) = sample_config_state();
4900        let mut snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
4901        pending_elicitation_snapshot(&mut snapshot);
4902        let respond = |response: ElicitationResponse| ControllerAction::RespondElicitation {
4903            session_id: "session-1".into(),
4904            elicitation_id: "elicitation-1".into(),
4905            response,
4906        };
4907
4908        assert!(validate_action(&respond(accept(&[("question_0", "matrix")])), &snapshot).is_ok());
4909        // Declining and cancelling never carry content, so they are always
4910        // answerable.
4911        assert!(validate_action(&respond(ElicitationResponse::Decline), &snapshot).is_ok());
4912        // An option the agent never offered, a field it never published, and a
4913        // missing required answer are all refused.
4914        assert!(validate_action(&respond(accept(&[("question_0", "cron")])), &snapshot).is_err());
4915        assert!(validate_action(&respond(accept(&[("smuggled", "yes")])), &snapshot).is_err());
4916        assert!(validate_action(&respond(accept(&[])), &snapshot).is_err());
4917        // A custom answer stands in for the select it belongs to, exactly as
4918        // the chat form submits it.
4919        assert!(
4920            validate_action(
4921                &respond(accept(&[("question_0_custom", "a monorepo pipeline")])),
4922                &snapshot,
4923            )
4924            .is_ok()
4925        );
4926    }
4927
4928    #[test]
4929    fn oversized_elicitation_answers_are_refused() {
4930        let (config, state) = sample_config_state();
4931        let mut snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
4932        pending_elicitation_snapshot(&mut snapshot);
4933        let long = "x".repeat(MAX_ELICITATION_BYTES);
4934        assert!(
4935            validate_action(
4936                &ControllerAction::RespondElicitation {
4937                    session_id: "session-1".into(),
4938                    elicitation_id: "elicitation-1".into(),
4939                    response: accept(&[("question_0_custom", long.as_str())]),
4940                },
4941                &snapshot,
4942            )
4943            .is_err()
4944        );
4945    }
4946
4947    /// One slice of the browser application, named by the two markers that
4948    /// bracket it in `src/web/viewer.js`.
4949    ///
4950    /// Slicing keeps each check to the functions it is about, so an unrelated
4951    /// change elsewhere in the application cannot make it fail for the wrong
4952    /// reason. The markers are ordinary source text, so a rename that moves
4953    /// them fails loudly here rather than silently testing nothing.
4954    fn viewer_source(from: &str, to: &str) -> &'static str {
4955        let start = VIEWER_JS
4956            .find(from)
4957            .unwrap_or_else(|| panic!("src/web/viewer.js no longer contains {from:?}"));
4958        let end = VIEWER_JS[start..]
4959            .find(to)
4960            .map(|offset| start + offset)
4961            .unwrap_or_else(|| {
4962                panic!("src/web/viewer.js no longer contains {to:?} after {from:?}")
4963            });
4964        &VIEWER_JS[start..end]
4965    }
4966
4967    /// Run one JavaScript check under Node.
4968    ///
4969    /// The check and the modules it imports are written to a real directory
4970    /// rather than passed to `--eval`, so a failure reports a line number a
4971    /// person can open, and so a check can import the shipped module under
4972    /// test by its real name instead of against a copy pasted into a string.
4973    fn run_web_check(name: &str, check: &str) {
4974        let directory = tempfile::tempdir().expect("temporary directory for a web check");
4975        for (file, source) in [
4976            ("test-dom.js", TEST_DOM_JS),
4977            ("markdown.js", MARKDOWN_JS),
4978            ("tool-output.js", TOOL_OUTPUT_JS),
4979        ] {
4980            std::fs::write(directory.path().join(file), source).expect("write a web module");
4981        }
4982        let path = directory.path().join(format!("{name}.mjs"));
4983        std::fs::write(&path, check).expect("write the web check");
4984        let output = std::process::Command::new("node")
4985            .arg(&path)
4986            .output()
4987            .expect("Node.js is required to exercise the web viewer");
4988        assert!(
4989            output.status.success(),
4990            "{name} failed:\nstdout:\n{}\nstderr:\n{}",
4991            String::from_utf8_lossy(&output.stdout),
4992            String::from_utf8_lossy(&output.stderr),
4993        );
4994    }
4995
4996    /// Run one JavaScript check that supplies its own environment, for the
4997    /// checks that slice a function out of `viewer.js` and drive it against a
4998    /// hand-written stub rather than importing a module.
4999    fn run_viewer_script(name: &str, script: &str) {
5000        run_web_check(name, script);
5001    }
5002
5003    #[test]
5004    fn web_configuration_repair_action_explains_missing_entries_without_a_request() {
5005        let source = viewer_source("async function runSessionAction(", "sessions.onclick");
5006        let setup = r#"
5007const pendingActions = new Set();
5008const snapshot = { sessions: [{ id: 'broken', configuration_issue: 'Restore bundle project in config.toml' }] };
5009const errorNode = { textContent: '' };
5010"#;
5011        let checks = r#"
5012await runSessionAction({ action: 'repair-config', id: 'broken' }, errorNode);
5013if (!errorNode.textContent.includes('Restore bundle project')) throw Error('repair guidance missing');
5014snapshot.sessions[0].configuration_issue = null;
5015await runSessionAction({ action: 'repair-config', id: 'broken' }, errorNode);
5016if (!errorNode.textContent.includes('repaired')) throw Error('stale configuration diagnostic');
5017"#;
5018        run_viewer_script(
5019            "configuration-repair",
5020            &format!("{setup}\n{source}\n{checks}"),
5021        );
5022    }
5023
5024    #[test]
5025    fn viewer_reports_configuration_drift_without_exposing_private_configuration() {
5026        let (mut config, state) = sample_config_state();
5027        let bundle = config.bundles.remove("hel").unwrap();
5028        let snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
5029        assert!(snapshot.sessions[0].has_error);
5030        assert!(
5031            snapshot.sessions[0]
5032                .configuration_issue
5033                .as_deref()
5034                .unwrap()
5035                .contains("missing bundle")
5036        );
5037        let json = serde_json::to_string(&snapshot).unwrap();
5038        assert!(!json.contains("secret-token"));
5039        assert!(!json.contains("/highly/secret"));
5040        config.bundles.insert("hel".into(), bundle);
5041        let repaired = ViewerSnapshot::from_config_state(&config, &state, 2);
5042        assert!(repaired.sessions[0].configuration_issue.is_none());
5043    }
5044
5045    #[test]
5046    fn web_preflight_applies_resolved_path_and_ignores_cancelled_reply() {
5047        let source = viewer_source(
5048            "async function preflightNew()",
5049            "async function advanceNew()",
5050        );
5051        let setup = r#"
5052let newDraft = { targetId: 'remote', profileId: 'codex', bundleId: 'bundle', projectDirectory: '~/project', projectDirectories: {} };
5053let pendingNewPreflight = null, pendingNewPreflightController = null;
5054function targetIsBare() { return true; }
5055function renderNewForm() {}
5056function selectedWorkspaceId() { return 'workspace'; }
5057let resolveRequest;
5058function request() { return new Promise(resolve => { resolveRequest = resolve; }); }
5059"#;
5060        let checks = r#"
5061let pending = preflightNew();
5062resolveRequest({ project_directory: '/remote/project' });
5063if (!await pending || newDraft.projectDirectory !== '/remote/project' || newDraft.projectDirectories.remote !== '/remote/project') throw Error('resolved path was not applied');
5064newDraft.projectDirectory = '~/newer';
5065pending = preflightNew();
5066pendingNewPreflightController.abort();
5067resolveRequest({ project_directory: '/remote/stale' });
5068if (await pending || newDraft.projectDirectory !== '~/newer') throw Error('cancelled reply replaced draft');
5069"#;
5070        run_viewer_script("path-preflight", &format!("{setup}\n{source}\n{checks}"));
5071    }
5072
5073    #[test]
5074    fn embedded_viewer_displays_capacity_retry_deadlines() {
5075        let source = viewer_source(
5076            "function sessionActivityLabel(",
5077            "function updateSessionActivity(",
5078        );
5079        let setup = "function isTransitioningSession() { return false; }";
5080        let checks = r#"
5081const session = { lifecycle: 'live', capacity_retry: { attempt: 2, retry_at_ms: 120000 } };
5082if (sessionActivityLabel(session, 60000) !== 'Model at capacity · retrying in 1m00s') throw Error('missing retry countdown');
5083if (sessionActivityLabel(session, 121000) !== 'Model at capacity · retrying in 0m00s') throw Error('negative retry countdown');
5084"#;
5085        run_viewer_script("capacity-retry", &format!("{setup}\n{source}\n{checks}"));
5086    }
5087
5088    #[test]
5089    fn embedded_viewer_lists_current_workspace_histories_and_retained_move_recovery() {
5090        let source = viewer_source("function isResumeSession(", "const resumeDrafts =");
5091        let setup = r#"
5092const snapshot = {
5093  sessions: [
5094    { id: "history-a", workspace_id: "workspace-a", capabilities: { resume: true } },
5095    { id: "history-b", workspace_id: "workspace-b", capabilities: { resume: true } },
5096    { id: "running-a", workspace_id: "workspace-a", lifecycle: "live", has_error: true, capabilities: { resume: false, open: false } },
5097    { id: "move-a", workspace_id: "workspace-a", capabilities: { resume: false }, move_recovery: { checkpoint_retained: true, phase: "failed" } },
5098    { id: "moving-a", workspace_id: "workspace-a", capabilities: { resume: false }, move_recovery: { checkpoint_retained: true, phase: "starting_queue" } },
5099  ],
5100};
5101function selectedWorkspaceId() { return "workspace-a"; }
5102function sessionActivityMs() { return 0; }
5103function epochMs() { return null; }
5104"#;
5105        let checks = r#"
5106const ids = workspace => resumeSessions(workspace).map(session => session.id).sort();
5107if (JSON.stringify(ids("workspace-a")) !== JSON.stringify(["history-a", "move-a"])) {
5108  throw new Error(`workspace A histories or recoveries were wrong: ${JSON.stringify(ids("workspace-a"))}`);
5109}
5110if (JSON.stringify(ids("workspace-b")) !== JSON.stringify(["history-b"])) {
5111  throw new Error(`workspace B histories were wrong: ${JSON.stringify(ids("workspace-b"))}`);
5112}
5113if (ids("missing-workspace").length !== 0) throw new Error("unknown workspace exposed sessions");
5114"#;
5115        run_viewer_script(
5116            "workspace-resume-history",
5117            &format!("{setup}\n{source}\n{checks}"),
5118        );
5119    }
5120
5121    #[test]
5122    fn embedded_viewer_sends_the_selected_resume_workspace() {
5123        let source = viewer_source("async function runSessionAction", "sessions.onclick =");
5124        let setup = r#"
5125const pendingActions = new Set();
5126const snapshot = { sessions: [] };
5127let sent = null;
5128function selectedWorkspaceId() { return "workspace-b"; }
5129function navigate() {}
5130function renderRoute() {}
5131async function refresh() {}
5132async function request(path, options) {
5133  sent = { path, body: JSON.parse(options.body) };
5134}
5135"#;
5136        let checks = r#"
5137const errorNode = { textContent: "" };
5138await runSessionAction(
5139  { action: "resume", id: "history-a", profile: "codex-1", target: "podman" },
5140  errorNode,
5141  { queue: "start" },
5142);
5143if (sent.path !== "/api/actions" || sent.body.workspace_id !== "workspace-b") {
5144  throw new Error(`resume did not carry its destination: ${JSON.stringify(sent)}`);
5145}
5146"#;
5147        run_viewer_script(
5148            "resume-workspace-destination",
5149            &format!("{setup}\n{source}\n{checks}"),
5150        );
5151    }
5152
5153    #[test]
5154    fn embedded_viewer_warns_before_stopping_an_active_session() {
5155        let source = viewer_source("async function runSessionAction", "sessions.onclick =");
5156        let setup = r#"
5157const pendingActions = new Set();
5158const snapshot = {
5159  sessions: [
5160    { id: "active", chat_phase: "running" },
5161    { id: "idle", chat_phase: "idle" },
5162  ],
5163};
5164const questions = [];
5165function confirm(question) { questions.push(question); return false; }
5166function navigate() {}
5167"#;
5168        let checks = r#"
5169const errorNode = { textContent: "" };
5170await runSessionAction({ action: "close", id: "active" }, errorNode);
5171await runSessionAction({ action: "close", id: "idle" }, errorNode);
5172if (!questions[0].startsWith("Stop active session?\n\n")) {
5173  throw new Error(`active close warning was ${JSON.stringify(questions[0])}`);
5174}
5175if (!questions[0].includes("current turn will be interrupted")) {
5176  throw new Error(`active close omitted interruption: ${JSON.stringify(questions[0])}`);
5177}
5178if (!questions[1].startsWith("Stop session?\n\n")) {
5179  throw new Error(`idle close warning was ${JSON.stringify(questions[1])}`);
5180}
5181"#;
5182        run_viewer_script(
5183            "active-session-stop-confirmation",
5184            &format!("{setup}\n{source}\n{checks}"),
5185        );
5186    }
5187
5188    /// The projection publishes what the browser needs to group and filter
5189    /// without publishing what the redaction contract keeps back. A project
5190    /// key groups two sessions in one project together and says nothing about
5191    /// where that project lives.
5192    #[test]
5193    fn the_project_key_groups_without_naming_a_path() {
5194        let (config, mut state) = sample_config_state();
5195        let first = state.sessions["session-1"].clone();
5196        let mut second = first.clone();
5197        second.id = "session-2".into();
5198        state.sessions.insert(second.id.clone(), second);
5199        let snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
5200
5201        let keys = snapshot
5202            .sessions
5203            .iter()
5204            .map(|session| session.project_key.as_str())
5205            .collect::<std::collections::BTreeSet<_>>();
5206        assert_eq!(keys.len(), 1, "two sessions in one project did not group");
5207        let key = keys.into_iter().next().expect("one key");
5208        assert!(!key.is_empty(), "the project key is empty");
5209        assert!(
5210            !key.contains('/') && !key.contains("hel"),
5211            "the project key leaks its identity: {key}"
5212        );
5213        assert_eq!(
5214            snapshot.sessions[0].project_label, "hel",
5215            "the project label should be a name a person recognises"
5216        );
5217    }
5218
5219    #[test]
5220    fn web_project_keys_follow_the_complete_repository_set() {
5221        let (mut config, mut state) = sample_config_state();
5222        let shared_bundle = config.bundles["hel"].clone();
5223        config.bundles.insert("other".into(), shared_bundle);
5224
5225        let mut other = state.sessions["session-1"].clone();
5226        other.id = "session-2".into();
5227        other.bundle_id = "other".into();
5228        state.sessions.insert(other.id.clone(), other);
5229
5230        assert_eq!(
5231            config.bundles["hel"].primary_repo,
5232            config.bundles["other"].primary_repo
5233        );
5234        let snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
5235        let first = snapshot
5236            .sessions
5237            .iter()
5238            .find(|session| session.id == "session-1")
5239            .expect("first session");
5240        let second = snapshot
5241            .sessions
5242            .iter()
5243            .find(|session| session.id == "session-2")
5244            .expect("second session");
5245
5246        assert_eq!(first.project_label, "hel");
5247        assert_eq!(second.project_label, "hel");
5248        assert_eq!(first.project_key, second.project_key);
5249
5250        let secondary = ProjectRepository {
5251            id: "secondary".into(),
5252            github: Some("owner/secondary".into()),
5253            local: None,
5254            destination: "secondary".into(),
5255            git_ref: None,
5256        };
5257        config
5258            .bundles
5259            .get_mut("other")
5260            .unwrap()
5261            .repositories
5262            .push(secondary.clone());
5263        let project_keys = |config: &Config| {
5264            ViewerSnapshot::from_config_state(config, &state, 1)
5265                .sessions
5266                .into_iter()
5267                .map(|session| session.project_key)
5268                .collect::<Vec<_>>()
5269        };
5270        let keys = project_keys(&config);
5271        assert_ne!(
5272            keys[0], keys[1],
5273            "an added repository must change the bundle identity"
5274        );
5275
5276        let first_bundle = config.bundles.get_mut("hel").unwrap();
5277        first_bundle.repositories.insert(0, secondary);
5278        first_bundle.primary_repo = "secondary".into();
5279        let keys = project_keys(&config);
5280        assert_eq!(
5281            keys[0], keys[1],
5282            "the same repository set must group together despite order or primary choice"
5283        );
5284    }
5285
5286    #[test]
5287    fn viewer_session_applies_a_resolved_source_without_publishing_it() {
5288        let (config, state) = sample_config_state();
5289        let mut viewer = ViewerSnapshot::from_config_state(&config, &state, 1)
5290            .sessions
5291            .into_iter()
5292            .next()
5293            .expect("session");
5294        let source = ProjectSourceIdentity::git_remote("git@github.com:BrokkAi/bifrost-dev.git")
5295            .expect("GitHub source");
5296
5297        viewer.set_project_source(&source);
5298
5299        assert_eq!(viewer.project_label, "bifrost-dev");
5300        assert_eq!(viewer.project_key, project_key(&source.key));
5301        let json = serde_json::to_string(&viewer).expect("serialize viewer session");
5302        assert!(!json.contains("BrokkAi"));
5303        assert!(!json.contains("github.com"));
5304    }
5305
5306    /// A phone groups and filters by the lifecycle category, so the mapping
5307    /// from the controller's precise state has to be the controller's own.
5308    #[test]
5309    fn lifecycle_categories_decide_what_the_dashboard_shows() {
5310        use ViewerLifecycleCategory::{Failed, Live, Starting, Stopped, Stopping};
5311
5312        for (state, expected, on_dashboard) in [
5313            (SessionState::Provisioning, Starting, true),
5314            (SessionState::Running, Live, true),
5315            (SessionState::Disconnected, Live, true),
5316            (SessionState::Checkpointing, Live, true),
5317            (SessionState::Closing, Stopping, true),
5318            (SessionState::Destroying, Stopping, true),
5319            (SessionState::Stopped, Stopped, false),
5320            (SessionState::Lost, Failed, false),
5321            (SessionState::Error, Failed, false),
5322            (SessionState::DestroyedWithDataLoss, Failed, false),
5323        ] {
5324            let category = ViewerLifecycleCategory::of(state);
5325            assert_eq!(category, expected, "{state:?}");
5326            assert_eq!(
5327                category.is_dashboard_visible(),
5328                on_dashboard,
5329                "{state:?} belongs on the dashboard? "
5330            );
5331        }
5332    }
5333
5334    /// Resume compatibility travels as the set the browser can offer, so it
5335    /// never has to subtract one list from another and never offers a target
5336    /// the controller would refuse.
5337    #[test]
5338    fn compatible_resume_targets_are_the_complement_of_the_incompatible_ones() {
5339        let (config, state) = sample_config_state();
5340        let snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
5341        let session = &snapshot.sessions[0];
5342        let all = config.targets.keys().cloned().collect::<Vec<_>>();
5343
5344        for target in &all {
5345            assert_ne!(
5346                session.compatible_resume_targets.contains(target),
5347                session.incompatible_resume_targets.contains(target),
5348                "target {target} is in both lists or neither"
5349            );
5350        }
5351        assert_eq!(
5352            session.compatible_resume_targets.len() + session.incompatible_resume_targets.len(),
5353            all.len(),
5354            "the two lists do not cover every target"
5355        );
5356    }
5357
5358    /// The viewer renders a control because a capability says so. An action
5359    /// whose capability is false is refused at the boundary, so a forged
5360    /// request gets the same answer a well-behaved viewer would never ask for.
5361    #[tokio::test]
5362    async fn actions_are_refused_when_their_capability_is_false() {
5363        for (body, capability) in [
5364            (
5365                r#"{"action":"cancel-turn","session_id":"session-1"}"#,
5366                "cancel_turn",
5367            ),
5368            (
5369                r#"{"action":"set-plan-mode","session_id":"session-1","active":true}"#,
5370                "set_plan_mode",
5371            ),
5372            (
5373                r#"{"action":"set-config","session_id":"session-1","key":"model","value":"x"}"#,
5374                "set_config",
5375            ),
5376        ] {
5377            let (app, mut actions, _, _, _) = app();
5378            let response = post_action(app, cookie(), body.to_owned()).await;
5379            assert!(
5380                response.status().is_client_error(),
5381                "{capability} was accepted while false: {}",
5382                response.status()
5383            );
5384            assert!(
5385                actions.try_recv().is_err(),
5386                "{capability} reached the controller while false"
5387            );
5388        }
5389    }
5390
5391    /// A setting the harness never advertised is not a setting. Forwarding one
5392    /// asks the agent to refuse something the viewer should never have offered.
5393    #[tokio::test]
5394    async fn a_config_key_the_harness_never_advertised_is_refused() {
5395        let capable = |snapshot: &mut ViewerSnapshot| {
5396            snapshot.sessions[0].capabilities.set_config = true;
5397            snapshot.sessions[0].config_options = vec![ViewerConfigOption {
5398                key: "model".into(),
5399                label: "model".into(),
5400                current: None,
5401                choices: vec![ViewerConfigChoice {
5402                    value: "sonnet".into(),
5403                    name: "Sonnet".into(),
5404                    description: None,
5405                }],
5406            }];
5407        };
5408
5409        for (body, why) in [
5410            (
5411                r#"{"action":"set-config","session_id":"session-1","key":"effort","value":"high"}"#,
5412                "an unadvertised key",
5413            ),
5414            (
5415                r#"{"action":"set-config","session_id":"session-1","key":"model","value":"gpt-9"}"#,
5416                "an unoffered value",
5417            ),
5418        ] {
5419            let (app, mut actions, _, _, _) = app_with_snapshot(capable);
5420            let response = post_action(app, cookie(), body.to_owned()).await;
5421            assert_eq!(
5422                response.status(),
5423                StatusCode::BAD_REQUEST,
5424                "{why} was accepted"
5425            );
5426            assert!(actions.try_recv().is_err(), "{why} reached the controller");
5427        }
5428
5429        // The value the harness did advertise is forwarded unchanged.
5430        let (app, mut actions, _, _, _) = app_with_snapshot(capable);
5431        let response = tokio::spawn(post_action(
5432            app,
5433            cookie(),
5434            r#"{"action":"set-config","session_id":"session-1","key":"model","value":"sonnet"}"#
5435                .to_owned(),
5436        ));
5437        let action = actions
5438            .recv()
5439            .await
5440            .expect("the action reached the controller");
5441        assert!(
5442            matches!(
5443                action.action,
5444                ControllerAction::SetConfig { ref key, ref value, .. }
5445                    if key == "model" && value == "sonnet"
5446            ),
5447            "the advertised value was not forwarded unchanged"
5448        );
5449        action.reply.send(ActionOutcome::accepted()).unwrap();
5450        assert_eq!(response.await.unwrap().status(), StatusCode::ACCEPTED);
5451    }
5452
5453    /// A dirty-worktree acknowledgement names the repositories the person was
5454    /// shown. A bare yes could be replayed against a set they never saw.
5455    #[tokio::test]
5456    async fn a_dirty_acknowledgement_is_bounded_and_names_repositories() {
5457        let oversized = (0..40)
5458            .map(|index| format!(r#""repo-{index}""#))
5459            .collect::<Vec<_>>()
5460            .join(",");
5461        for (ack, why) in [
5462            (oversized.as_str(), "an unbounded acknowledgement"),
5463            (r#""""#, "an empty repository name"),
5464        ] {
5465            let (app, mut actions, _, _, _) = app();
5466            let body = format!(
5467                r#"{{"action":"new","workspace_id":"default","profile_id":"codex-1","bundle_id":"hel","target_id":"podman","dirty_ack":[{ack}]}}"#
5468            );
5469            let response = post_action(app, cookie(), body).await;
5470            assert_eq!(response.status(), StatusCode::BAD_REQUEST, "{why}");
5471            assert!(actions.try_recv().is_err(), "{why} reached the controller");
5472        }
5473    }
5474
5475    /// A session created without a title still gets one, derived the way the
5476    /// terminal derives it, so the two surfaces name a session alike.
5477    #[tokio::test]
5478    async fn a_new_session_without_a_title_is_accepted() {
5479        let (app, mut actions, _, _, _) = app();
5480        let response = tokio::spawn(post_action(
5481            app,
5482            cookie(),
5483            r#"{"action":"new","workspace_id":"default","profile_id":"codex-1","bundle_id":"hel","target_id":"podman"}"#
5484                .to_owned(),
5485        ));
5486        // The handler answers only once the controller does, so the reply has
5487        // to be sent before the response can be read.
5488        let action = actions
5489            .recv()
5490            .await
5491            .expect("the action reached the controller");
5492        assert!(
5493            matches!(
5494                action.action,
5495                ControllerAction::New { title: None, ref workspace_id, .. }
5496                    if workspace_id == "default"
5497            ),
5498            "the workspace or the absent title did not survive the boundary"
5499        );
5500        action.reply.send(ActionOutcome::accepted()).unwrap();
5501        assert_eq!(response.await.unwrap().status(), StatusCode::ACCEPTED);
5502    }
5503
5504    /// Two phones must not share stored state, and one phone's state must
5505    /// survive its own re-login. Neither is true of a cookie that signs only
5506    /// an expiry, which is what this replaced.
5507    #[test]
5508    fn a_cookie_names_one_viewer_and_two_cookies_never_collide() {
5509        let key = b"01234567890123456789012345678901";
5510        let expiry = now_unix().saturating_add(3600);
5511        let first = signed_cookie_value(key, "viewer-a", expiry);
5512        let second = signed_cookie_value(key, "viewer-b", expiry);
5513        assert_ne!(
5514            first, second,
5515            "two viewers unlocking in the same second share a cookie"
5516        );
5517        assert_eq!(
5518            cookie_viewer(key, &first, now_unix()),
5519            Some(Some("viewer-a".to_owned()))
5520        );
5521        assert_eq!(
5522            cookie_viewer(key, &second, now_unix()),
5523            Some(Some("viewer-b".to_owned()))
5524        );
5525    }
5526
5527    /// A phone holding the previous cookie keeps working through a deployment.
5528    /// It names no viewer, so it stores nothing, which is the difference
5529    /// between signed out and signed in with nothing kept.
5530    #[test]
5531    fn a_legacy_cookie_still_authenticates_and_stores_nothing() {
5532        let key = b"01234567890123456789012345678901";
5533        let expiry = now_unix().saturating_add(3600);
5534        let legacy = legacy_signed_cookie_value(key, expiry);
5535        assert_eq!(cookie_viewer(key, &legacy, now_unix()), Some(None));
5536        assert!(session_cookie_valid(key, &legacy, now_unix()));
5537        assert!(
5538            !session_cookie_valid(key, &legacy, expiry),
5539            "an expired legacy cookie still authenticated"
5540        );
5541    }
5542
5543    /// A forged or tampered cookie names nobody.
5544    #[test]
5545    fn a_tampered_cookie_is_refused() {
5546        let key = b"01234567890123456789012345678901";
5547        let expiry = now_unix().saturating_add(3600);
5548        let honest = signed_cookie_value(key, "viewer-a", expiry);
5549        let swapped = honest.replacen("viewer-a", "viewer-b", 1);
5550        assert_eq!(cookie_viewer(key, &swapped, now_unix()), None);
5551        assert_eq!(cookie_viewer(key, "nonsense", now_unix()), None);
5552        assert_eq!(cookie_viewer(key, &format!("{expiry}."), now_unix()), None);
5553    }
5554
5555    /// A composer is for a prompt. The bound exists so one viewer cannot fill
5556    /// the daemon's database with text it never sent.
5557    #[tokio::test]
5558    async fn an_oversized_draft_is_refused_with_a_stable_code() {
5559        let (app, _, _, _, mut stored) = app();
5560        let draft = "x".repeat(64 * 1024 + 1);
5561        let response = app
5562            .oneshot(
5563                Request::put("/api/sessions/session-1/draft")
5564                    .header(COOKIE, cookie())
5565                    .header(CONTENT_TYPE, "application/json")
5566                    .body(Body::from(
5567                        serde_json::json!({ "draft": draft }).to_string(),
5568                    ))
5569                    .unwrap(),
5570            )
5571            .await
5572            .unwrap();
5573        assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE);
5574        assert!(stored.try_recv().is_err(), "an oversized draft was stored");
5575    }
5576
5577    /// A viewer with no identity has nothing stored, and is told so rather
5578    /// than being promised a persistence that is not there.
5579    #[tokio::test]
5580    async fn a_legacy_viewer_reads_empty_state_and_cannot_store_a_draft() {
5581        let key = b"01234567890123456789012345678901";
5582        let legacy = format!(
5583            "{COOKIE_NAME}={}",
5584            legacy_signed_cookie_value(key, now_unix().saturating_add(3600))
5585        );
5586
5587        let (reader, _, _, _, mut stored) = app();
5588        let response = reader
5589            .oneshot(
5590                Request::get("/api/sessions/session-1/client-state")
5591                    .header(COOKIE, legacy.clone())
5592                    .body(Body::empty())
5593                    .unwrap(),
5594            )
5595            .await
5596            .unwrap();
5597        assert_eq!(response.status(), StatusCode::OK);
5598        let body = response.into_body().collect().await.unwrap().to_bytes();
5599        let state: ViewerClientState = serde_json::from_slice(&body).unwrap();
5600        assert_eq!(state, ViewerClientState::default());
5601        assert!(
5602            stored.try_recv().is_err(),
5603            "a legacy viewer read stored state"
5604        );
5605
5606        let (writer, _, _, _, mut stored) = app();
5607        let response = writer
5608            .oneshot(
5609                Request::put("/api/sessions/session-1/draft")
5610                    .header(COOKIE, legacy)
5611                    .header(CONTENT_TYPE, "application/json")
5612                    .body(Body::from(r#"{"draft":"text"}"#))
5613                    .unwrap(),
5614            )
5615            .await
5616            .unwrap();
5617        assert_eq!(response.status(), StatusCode::CONFLICT);
5618        assert!(stored.try_recv().is_err(), "a legacy viewer stored a draft");
5619    }
5620
5621    /// A search that is not a search is refused before it reaches a database.
5622    #[tokio::test]
5623    async fn prompt_history_refuses_an_unknown_scope() {
5624        let (app, _, _, _, mut stored) = app();
5625        let response = app
5626            .oneshot(
5627                Request::get("/api/sessions/session-1/history?q=ship&scope=everything")
5628                    .header(COOKIE, cookie())
5629                    .body(Body::empty())
5630                    .unwrap(),
5631            )
5632            .await
5633            .unwrap();
5634        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
5635        assert!(
5636            stored.try_recv().is_err(),
5637            "the search reached the controller"
5638        );
5639    }
5640
5641    /// A preflight starts nothing. It answers the questions a person needs
5642    /// before committing, and it refuses an impossible combination there
5643    /// rather than after the commit.
5644    #[tokio::test]
5645    async fn a_preflight_validates_before_it_reaches_the_controller() {
5646        for (body, why) in [
5647            (
5648                r#"{"profile_id":"nope","bundle_id":"hel","target_id":"podman"}"#,
5649                "an unknown profile",
5650            ),
5651            (
5652                r#"{"profile_id":"codex-1","bundle_id":"hel","target_id":"raw"}"#,
5653                "a bare target with no directory",
5654            ),
5655        ] {
5656            let (app, _, _, mut preflights, _) = app();
5657            let response = app
5658                .oneshot(
5659                    Request::post("/api/preflight/new")
5660                        .header(COOKIE, cookie())
5661                        .header(CONTENT_TYPE, "application/json")
5662                        .body(Body::from(body))
5663                        .unwrap(),
5664                )
5665                .await
5666                .unwrap();
5667            assert_eq!(response.status(), StatusCode::BAD_REQUEST, "{why}");
5668            assert!(
5669                preflights.try_recv().is_err(),
5670                "{why} reached the controller"
5671            );
5672        }
5673    }
5674
5675    /// A bare target opens a directory the person named. The controller still
5676    /// validates that directory before answering, because the server's state
5677    /// projection cannot inspect the filesystem or an SSH host.
5678    #[tokio::test]
5679    async fn a_bare_preflight_forwards_directory_validation_to_the_controller() {
5680        let (app, _, _, mut preflights, _) = app();
5681        let response = tokio::spawn(app.oneshot(
5682                Request::post("/api/preflight/new")
5683                    .header(COOKIE, cookie())
5684                    .header(CONTENT_TYPE, "application/json")
5685                    .body(Body::from(
5686                        r#"{"profile_id":"codex-1","bundle_id":"hel","target_id":"raw","project_directory":"~/project"}"#,
5687                    ))
5688                    .unwrap(),
5689            ));
5690        let request = preflights.recv().await.expect("the controller was asked");
5691        assert_eq!(request.bundle_id, "hel");
5692        assert_eq!(request.target_id, "raw");
5693        assert_eq!(request.project_directory, Some(PathBuf::from("~/project")));
5694        request
5695            .reply
5696            .send(Ok(PreflightNew {
5697                managed_worktree: Default::default(),
5698                project_directory: Some("/remote/project".into()),
5699                remote_repairs: Vec::new(),
5700                dirty_repositories: Vec::new(),
5701                remote_repositories: Vec::new(),
5702                local_changes_excluded: false,
5703            }))
5704            .unwrap();
5705        let response = response.await.unwrap().unwrap();
5706        assert_eq!(response.status(), StatusCode::OK);
5707        let body = response.into_body().collect().await.unwrap().to_bytes();
5708        let answer: PreflightNew = serde_json::from_slice(&body).unwrap();
5709        assert!(answer.dirty_repositories.is_empty());
5710        assert_eq!(answer.project_directory, Some("/remote/project".into()));
5711    }
5712
5713    #[tokio::test]
5714    async fn a_bare_preflight_validation_failure_is_actionable_without_its_details() {
5715        let (app, _, _, mut preflights, _) = app();
5716        let response = tokio::spawn(app.oneshot(
5717            Request::post("/api/preflight/new")
5718                .header(COOKIE, cookie())
5719                .header(CONTENT_TYPE, "application/json")
5720                .body(Body::from(
5721                    r#"{"profile_id":"codex-1","bundle_id":"hel","target_id":"raw","project_directory":"/private/project"}"#,
5722                ))
5723                .unwrap(),
5724        ));
5725        let request = preflights.recv().await.expect("the controller was asked");
5726        request
5727            .reply
5728            .send(Err(PreflightFailure::Validation))
5729            .unwrap();
5730        let response = response.await.unwrap().unwrap();
5731        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
5732        let body = response.into_body().collect().await.unwrap().to_bytes();
5733        assert_eq!(
5734            serde_json::from_slice::<serde_json::Value>(&body).unwrap(),
5735            serde_json::json!({
5736                "error": "project validation failed; check that the directory exists, is accessible, and contains a Git repository with a valid HEAD"
5737            })
5738        );
5739        assert!(!String::from_utf8_lossy(&body).contains("/private/project"));
5740    }
5741
5742    #[tokio::test]
5743    async fn a_bundle_preflight_controller_failure_keeps_the_generic_service_error() {
5744        let (app, _, _, mut preflights, _) = app();
5745        let response = tokio::spawn(
5746            app.oneshot(
5747                Request::post("/api/preflight/new")
5748                    .header(COOKIE, cookie())
5749                    .header(CONTENT_TYPE, "application/json")
5750                    .body(Body::from(
5751                        r#"{"profile_id":"codex-1","bundle_id":"hel","target_id":"podman"}"#,
5752                    ))
5753                    .unwrap(),
5754            ),
5755        );
5756        let request = preflights.recv().await.expect("the controller was asked");
5757        request
5758            .reply
5759            .send(Err(PreflightFailure::Controller(
5760                "private /source/hel details".into(),
5761            )))
5762            .unwrap();
5763        let response = response.await.unwrap().unwrap();
5764        assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
5765        let body = response.into_body().collect().await.unwrap().to_bytes();
5766        assert_eq!(
5767            serde_json::from_slice::<serde_json::Value>(&body).unwrap(),
5768            serde_json::json!({"error": "the controller could not check this project"})
5769        );
5770        assert!(!String::from_utf8_lossy(&body).contains("/source/hel"));
5771    }
5772
5773    /// An isolated bundle preflight returns the network clone plan so the
5774    /// person can review it before creation.
5775    #[tokio::test]
5776    async fn a_bundle_preflight_reports_network_sources_and_excludes_local_changes() {
5777        let (app, _, _, mut preflights, _) = app();
5778        let response = tokio::spawn(
5779            app.oneshot(
5780                Request::post("/api/preflight/new")
5781                    .header(COOKIE, cookie())
5782                    .header(CONTENT_TYPE, "application/json")
5783                    .body(Body::from(
5784                        r#"{"profile_id":"codex-1","bundle_id":"hel","target_id":"podman"}"#,
5785                    ))
5786                    .unwrap(),
5787            ),
5788        );
5789        let request = preflights.recv().await.expect("the controller was asked");
5790        assert_eq!(request.bundle_id, "hel");
5791        assert_eq!(request.target_id, "podman");
5792        assert_eq!(request.project_directory, None);
5793        request
5794            .reply
5795            .send(Ok(PreflightNew {
5796                managed_worktree: Default::default(),
5797                project_directory: None,
5798                remote_repairs: Vec::new(),
5799                dirty_repositories: Vec::new(),
5800                remote_repositories: vec![PreflightRepository {
5801                    id: "hel".into(),
5802                    fetch_url: "https://github.com/example/hel.git".into(),
5803                    default_branch: "main".into(),
5804                    push_urls: vec!["ssh://git@example/hel.git".into()],
5805                }],
5806                local_changes_excluded: true,
5807            }))
5808            .unwrap();
5809        let response = response.await.unwrap().unwrap();
5810        assert_eq!(response.status(), StatusCode::OK);
5811        let body = response.into_body().collect().await.unwrap().to_bytes();
5812        let answer: PreflightNew = serde_json::from_slice(&body).unwrap();
5813        assert!(answer.dirty_repositories.is_empty());
5814        assert!(answer.local_changes_excluded);
5815        assert_eq!(answer.remote_repositories[0].default_branch, "main");
5816        assert_eq!(answer.remote_repositories[0].push_urls.len(), 1);
5817    }
5818
5819    /// Everything an agent writes goes through the Markdown renderer, so the
5820    /// renderer is where injection is stopped. These checks run the shipped
5821    /// module against a fake DOM: structure has to come out as elements, and
5822    /// markup an agent typed has to come out as text.
5823    #[test]
5824    fn the_markdown_renderer_builds_structure_and_refuses_injection() {
5825        run_web_check(
5826            "markdown",
5827            r#"import { installDocument, elements, only, check, checkEqual } from './test-dom.js';
5828installDocument();
5829const { renderMarkdown, renderDiffSummary, safeHref } = await import('./markdown.js');
5830
5831const render = source => {
5832  const host = document.createElement('section');
5833  host.append(renderMarkdown(source));
5834  return host;
5835};
5836
5837// Headings
5838checkEqual(only(render('# Title'), 'h1').textContent, 'Title', 'h1');
5839checkEqual(only(render('### Deep'), 'h3').textContent, 'Deep', 'h3');
5840
5841// Nested lists
5842const nested = render('- one\n  - inner\n- two');
5843check(elements(nested, 'ul').length === 2, 'nested list produced ' + elements(nested, 'ul').length + ' lists');
5844check(elements(elements(nested, 'ul')[0], 'li').length >= 2, 'outer list lost items');
5845
5846// Ordered lists
5847checkEqual(elements(render('1. a\n2. b'), 'ol').length, 1, 'ordered list');
5848
5849// Fenced code stays unparsed
5850const fenced = render('```rust\nlet x = *y*;\n```');
5851checkEqual(only(fenced, 'code').textContent, 'let x = *y*;', 'fenced code');
5852check(elements(fenced, 'span').some(s => s.className === 'tok-kw'), 'fenced rust untinted');
5853checkEqual(elements(fenced, 'em').length, 0, 'fence emphasised its contents');
5854checkEqual(only(fenced, 'pre').dataset.lang, 'rust', 'fence language');
5855
5856// Inline code beats emphasis
5857checkEqual(only(render('`*not em*`'), 'code').textContent, '*not em*', 'inline code');
5858checkEqual(elements(render('`*not em*`'), 'em').length, 0, 'inline code emphasised');
5859
5860// Emphasis
5861checkEqual(only(render('**bold**'), 'strong').textContent, 'bold', 'strong');
5862checkEqual(only(render('*it*'), 'em').textContent, 'it', 'em');
5863checkEqual(only(render('~~gone~~'), 'del').textContent, 'gone', 'del');
5864
5865// Tables
5866const table = render('| a | b |\n| --- | ---: |\n| 1 | 2 |');
5867checkEqual(elements(table, 'table').length, 1, 'table');
5868checkEqual(elements(table, 'th').length, 2, 'table header cells');
5869checkEqual(elements(table, 'td').length, 2, 'table body cells');
5870checkEqual(elements(table, 'th')[1].className, 'align-right', 'table alignment class');
5871checkEqual(only(table, 'div').className, 'scroll-x', 'table scroll wrapper');
5872
5873// Blockquote and rule
5874checkEqual(elements(render('> quoted'), 'blockquote').length, 1, 'blockquote');
5875checkEqual(elements(render('---'), 'hr').length, 1, 'rule');
5876
5877// XSS: markup is text, never elements
5878const injected = render('<img src=x onerror=alert(1)>');
5879checkEqual(elements(injected, 'img').length, 0, 'raw HTML became an element');
5880check(injected.textContent.includes('<img src=x onerror=alert(1)>'), 'raw HTML lost its text');
5881
5882// XSS: refused link schemes
5883for (const target of ['javascript:alert(1)', 'JaVaScRiPt:alert(1)', 'java\tscript:alert(1)', 'data:text/html,<script>', 'vbscript:x']) {
5884  const out = render(`[click](${target})`);
5885  checkEqual(elements(out, 'a').length, 0, `link scheme ${JSON.stringify(target)} was allowed`);
5886  check(out.textContent.includes('click'), `link scheme ${JSON.stringify(target)} lost its label`);
5887}
5888
5889// Accepted schemes keep their href and carry safe rel/target
5890for (const target of ['https://example.com', 'http://example.com/a', 'mailto:someone@example.com']) {
5891  const anchor = only(render(`[click](${target})`), 'a');
5892  checkEqual(anchor.getAttribute('href'), target, 'href');
5893  checkEqual(anchor.getAttribute('rel'), 'noreferrer noopener', 'rel');
5894  checkEqual(anchor.getAttribute('target'), '_blank', 'target');
5895}
5896
5897// safeHref directly
5898checkEqual(safeHref('javascript:alert(1)'), null, 'safeHref allowed javascript:');
5899checkEqual(safeHref(' https://x.test '), 'https://x.test', 'safeHref cleaned value');
5900
5901// Inline markup inside a link label
5902checkEqual(only(render('[**bold link**](https://x.test)'), 'strong').textContent, 'bold link', 'link label markup');
5903
5904// An unclosed delimiter is literal, not markup
5905checkEqual(render('a * b').textContent, 'a * b', 'unclosed emphasis');
5906checkEqual(elements(render('a * b'), 'em').length, 0, 'unclosed emphasis made an element');
5907
5908// Diff summaries: the real format from format_diffstat, two spaces and U+2212
5909const diff = renderDiffSummary(['src/main.rs  +12 −3', 'unparseable line']);
5910const items = elements(diff, 'li');
5911checkEqual(items.length, 2, 'diffstat rows');
5912checkEqual(elements(items[0], 'span')[0].textContent, 'src/main.rs', 'diffstat path');
5913checkEqual(elements(items[0], 'span')[1].textContent, '+12', 'diffstat additions');
5914checkEqual(elements(items[0], 'span')[2].textContent, '−3', 'diffstat deletions');
5915checkEqual(elements(items[1], 'span').length, 1, 'unparseable diffstat produced counts');
5916checkEqual(elements(items[1], 'span')[0].textContent, 'unparseable line', 'unparseable diffstat lost its text');
5917
5918console.log('all markdown checks passed');
5919"#,
5920        );
5921    }
5922
5923    /// Tool output is not prose, and rendering it as prose loses the parts
5924    /// that matter: which words in a command are the program and which are
5925    /// paths, where a JSON payload begins, and whether a five-thousand-line
5926    /// dump has to be paid for before anyone asks to see it.
5927    #[test]
5928    fn tool_output_is_tinted_folded_and_never_read_as_markdown() {
5929        run_web_check(
5930            "tool-output",
5931            r#"import { installDocument, elements, only, check, checkEqual, openFold } from './test-dom.js';
5932installDocument();
5933const { renderToolOutput, codeBlock, detectLang, appendCommandTokens, isPathLike } = await import(
5934  './tool-output.js'
5935);
5936
5937const classes = root => elements(root, 'span').map(s => s.className);
5938
5939// A shell command is told apart into program, subcommand, flag and path.
5940const line = document.createElement('pre');
5941appendCommandTokens(line, 'cargo test --workspace src/lib.rs');
5942const seen = classes(line);
5943check(seen.includes('cmd-program'), 'no program: ' + seen);
5944check(seen.includes('cmd-subcommand'), 'no subcommand: ' + seen);
5945check(seen.includes('cmd-flag'), 'no flag: ' + seen);
5946check(seen.includes('cmd-path'), 'no path: ' + seen);
5947checkEqual(line.textContent, 'cargo test --workspace src/lib.rs', 'command text changed');
5948
5949// An operator starts the program count again, so both programs are found.
5950const piped = document.createElement('pre');
5951appendCommandTokens(piped, 'git status && cargo build');
5952checkEqual(classes(piped).filter(c => c === 'cmd-program').length, 2, 'pipeline reset');
5953
5954// Prose with a slash is not a path; a real path is.
5955check(!isPathLike('and/or'), '"and/or" read as a path');
5956check(isPathLike('src/lib/thing.rs'), 'a real path did not');
5957check(isPathLike('./x'), 'a relative path did not');
5958check(isPathLike('Cargo.toml'), 'a file with an extension did not');
5959
5960// JSON is pretty-printed and tinted, keys apart from values.
5961const json = renderToolOutput('{"name":"hel","count":3,"ok":true}');
5962const jsonClasses = classes(json);
5963check(jsonClasses.includes('tok-key'), 'no JSON key: ' + jsonClasses);
5964check(jsonClasses.includes('tok-str'), 'no JSON string: ' + jsonClasses);
5965check(jsonClasses.includes('tok-num'), 'no JSON number: ' + jsonClasses);
5966check(jsonClasses.includes('tok-kw'), 'no JSON keyword: ' + jsonClasses);
5967check(json.textContent.includes('"name"'), 'JSON lost its content');
5968
5969// Rust is tinted; an unknown language is not.
5970const rust = codeBlock('pub fn main() {\n    let x = 1;\n}', 'rust');
5971check(classes(rust).includes('tok-kw'), 'rust keywords untinted');
5972checkEqual(only(rust, 'pre').dataset.lang, 'rust', 'rust data-lang');
5973const plain = codeBlock('nothing in particular here', 'brainfuck');
5974checkEqual(classes(plain).length, 0, 'unknown language was tinted');
5975
5976// Sniffing is conservative: a log stays plain, real code does not.
5977checkEqual(detectLang('12:03 INFO started\n12:04 INFO done\n12:05 INFO stopped'), '', 'a log was sniffed');
5978checkEqual(
5979  detectLang('fn a() {}\nfn b() {}\nlet mut x = 1;\nuse std::fmt;\nimpl Foo {}\nlet y = x.unwrap();'),
5980  'rust',
5981  'rust was not sniffed',
5982);
5983checkEqual(detectLang('--- a/x\n+++ b/x\n@@ -1 +1 @@\n-old\n+new'), 'diff', 'diff was not sniffed');
5984
5985// A long dump is one closed fold that has built nothing yet.
5986const long = Array.from({ length: 400 }, (_, i) => `line ${i}`).join('\n');
5987const folded = renderToolOutput(long);
5988checkEqual(folded.nodeName, 'DETAILS', 'a 400-line dump was not folded');
5989checkEqual(elements(folded, 'pre').length, 0, 'a closed fold built its content anyway');
5990check(only(folded, 'summary').textContent.includes('400 lines'), 'fold summary: ' + only(folded, 'summary').textContent);
5991openFold(folded);
5992checkEqual(elements(folded, 'pre').length, 1, 'an opened fold built nothing');
5993check(elements(folded, 'pre')[0].textContent.includes('line 399'), 'the fold lost its content');
5994
5995// Opening twice builds once.
5996openFold(folded);
5997checkEqual(elements(folded, 'pre').length, 1, 'reopening rebuilt the content');
5998
5999// A short dump is not folded.
6000checkEqual(renderToolOutput('one\ntwo').nodeName, 'PRE', 'a short dump was folded');
6001
6002// Tool output is never parsed as Markdown, so an underscore is an underscore.
6003const literal = renderToolOutput('a _b_ c <img src=x>');
6004checkEqual(elements(literal, 'em').length, 0, 'tool output was emphasised');
6005checkEqual(elements(literal, 'img').length, 0, 'tool output produced an element');
6006check(literal.textContent.includes('<img src=x>'), 'tool output lost its text');
6007
6008console.log('all tool-output checks passed');
6009"#,
6010        );
6011    }
6012
6013    /// The renderer's guarantee is structural — this code cannot inject markup
6014    /// because it never builds markup — and a single stray assignment would
6015    /// quietly replace it with no guarantee at all. `escapeHtml` uses
6016    /// `innerHTML` on a detached node to escape text, which is safe but is
6017    /// also exactly the shape this test exists to stop spreading, so it is
6018    /// named rather than pattern-matched.
6019    #[test]
6020    fn no_web_module_builds_markup_from_a_string() {
6021        const SINKS: [&str; 5] = [
6022            "innerHTML",
6023            "outerHTML",
6024            "insertAdjacentHTML",
6025            "document.write",
6026            "new Function",
6027        ];
6028        // There is no allowance. Every one of these sinks was removed in
6029        // Milestone 2, and the point of the test is that none comes back.
6030        const ALLOWED: [(&str, &str); 0] = [];
6031        for (name, source) in [
6032            ("viewer.js", VIEWER_JS),
6033            ("markdown.js", MARKDOWN_JS),
6034            ("tool-output.js", TOOL_OUTPUT_JS),
6035        ] {
6036            for (number, line) in source.lines().enumerate() {
6037                let trimmed = line.trim();
6038                if trimmed.starts_with("//") || trimmed.starts_with("///") {
6039                    continue;
6040                }
6041                for sink in SINKS {
6042                    if !trimmed.contains(sink) {
6043                        continue;
6044                    }
6045                    assert!(
6046                        ALLOWED
6047                            .iter()
6048                            .any(|(file, allowed)| *file == name && trimmed == *allowed),
6049                        "{name}:{} builds markup from a string: {trimmed}",
6050                        number + 1
6051                    );
6052                }
6053            }
6054        }
6055    }
6056
6057    /// The card cache is the fix for answers vanishing under snapshot polls, so
6058    /// it is exercised as JavaScript: the render source is lifted out of
6059    /// `src/web/viewer.js` and run against a stub DOM.
6060    #[test]
6061    fn embedded_viewer_keeps_elicitation_answers_across_snapshot_polls() {
6062        let source = viewer_source(
6063            "const elicitationCards = new Map()",
6064            "async function submitElicitation",
6065        );
6066        let dom = r#"
6067let replaceCalls = 0;
6068function makeEl(tag) {
6069  return {
6070    tagName: tag.toUpperCase(),
6071    children: [],
6072    options: [],
6073    selectedOptions: [],
6074    className: "",
6075    textContent: "",
6076    disabled: false,
6077    required: false,
6078    value: "",
6079    appendChild(child) {
6080      this.children.push(child);
6081      if (this.tagName === "SELECT") this.options.push(child);
6082      return child;
6083    },
6084    append(...kids) {
6085      this.children.push(...kids);
6086    },
6087    replaceChildren(...kids) {
6088      replaceCalls += 1;
6089      this.children = kids;
6090    },
6091    addEventListener() {},
6092    querySelectorAll(selector) {
6093      const found = [];
6094      const visit = node => {
6095        for (const child of node.children) {
6096          if (child.tagName === "INPUT" && (selector === "input" || child.checked)) found.push(child);
6097          visit(child);
6098        }
6099      };
6100      visit(this);
6101      return found;
6102    },
6103    querySelector(selector) { return this.querySelectorAll(selector)[0] || null; },
6104    setCustomValidity() {},
6105    reportValidity() {
6106      return true;
6107    },
6108  };
6109}
6110const created = [];
6111const document = {
6112  createElement(tag) {
6113    const el = makeEl(tag);
6114    created.push(el);
6115    return el;
6116  },
6117};
6118const elicitations = makeEl("div");
6119function el(tag, className, text) {
6120  const node = document.createElement(tag);
6121  node.className = className || "";
6122  node.textContent = text || "";
6123  return node;
6124}
6125async function submitElicitation() {}
6126"#;
6127        let checks = r#"
6128const request = {
6129  id: "elicitation-1",
6130  message: "Which CI architecture?",
6131  title: "CI",
6132  fields: [
6133    {
6134      id: "question_0",
6135      title: "CI architecture",
6136      required: false,
6137      kind: "single_select",
6138      options: [{ value: "reusable", title: "Reusable" }, { value: "matrix", title: "Matrix" }],
6139    },
6140    { id: "question_0_custom", title: "Other", required: false, kind: "text" },
6141  ],
6142};
6143const session = { id: "session-1", pending_elicitations: [request] };
6144renderElicitations(session);
6145const card = elicitations.children[0];
6146const radio = created.find((el) => el.tagName === "INPUT" && el.value === "reusable");
6147const text = created.find((el) => el.tagName === "INPUT" && el.type === "text");
6148radio.checked = true;
6149text.value = "keep me";
6150const attachments = replaceCalls;
6151renderElicitations(session);
6152if (elicitations.children[0] !== card) {
6153  throw new Error("a snapshot rebuilt the pending card");
6154}
6155if (!radio.checked || text.value !== "keep me") {
6156  throw new Error("a snapshot wiped the half-filled answer");
6157}
6158if (replaceCalls !== attachments) {
6159  throw new Error("a snapshot re-attached an unchanged card and dropped focus");
6160}
6161sentElicitations.add(elicitationKey("session-1", request.id));
6162renderElicitations(session);
6163if (elicitations.children[0] !== card) {
6164  throw new Error("a sent answer rebuilt the card");
6165}
6166if (!radio.disabled || !text.disabled) {
6167  throw new Error("a sent answer left the controls live");
6168}
6169if (!radio.checked) {
6170  throw new Error("a sent answer wiped the reply");
6171}
6172renderElicitations({ id: "session-1", pending_elicitations: [] });
6173if (elicitations.children.length !== 0 || elicitationCards.size !== 0) {
6174  throw new Error("an answered request stayed rendered");
6175}
6176if (sentElicitations.size !== 0) {
6177  throw new Error("a resolved request kept its sent marker");
6178}
6179"#;
6180        run_viewer_script(
6181            "elicitation-rendering",
6182            &format!("{dom}\n{source}\n{checks}"),
6183        );
6184    }
6185
6186    fn sample_image(pixels: usize) -> ViewerPromptImage {
6187        ViewerPromptImage {
6188            data_base64: base64::engine::general_purpose::STANDARD.encode(vec![7_u8; pixels]),
6189            mime_type: "image/png".into(),
6190            width: 32,
6191            height: 24,
6192            attachment: None,
6193        }
6194    }
6195
6196    fn sample_valid_image() -> ViewerPromptImage {
6197        ViewerPromptImage {
6198            data_base64: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
6199                .into(),
6200            mime_type: "image/png".into(),
6201            width: 1,
6202            height: 1,
6203            attachment: None,
6204        }
6205    }
6206
6207    fn image_capable(snapshot: &mut ViewerSnapshot) {
6208        snapshot.sessions[0].prompt_images_supported = true;
6209    }
6210
6211    async fn post_action(app: Router, cookie: String, body: String) -> Response<Body> {
6212        app.oneshot(
6213            Request::post("/api/actions")
6214                .header(COOKIE, cookie)
6215                .header(CONTENT_TYPE, "application/json")
6216                .body(Body::from(body))
6217                .unwrap(),
6218        )
6219        .await
6220        .unwrap()
6221    }
6222
6223    #[tokio::test]
6224    async fn image_prompt_reaches_the_controller_with_its_images() {
6225        let (app, mut actions, _, _, _) = app_with_snapshot(image_capable);
6226        let cookie = login_cookie(&app).await;
6227        let image = sample_valid_image();
6228        let body = serde_json::to_string(&ControllerAction::Prompt {
6229            session_id: "session-1".into(),
6230            text: String::new(),
6231            images: vec![image.clone(), image.clone()],
6232        })
6233        .unwrap();
6234        let response = tokio::spawn(post_action(app, cookie, body));
6235        let request = actions.recv().await.unwrap();
6236        let ControllerRequest { action, reply } = request;
6237        let ControllerAction::Prompt {
6238            session_id,
6239            text,
6240            images,
6241        } = action
6242        else {
6243            panic!("expected a prompt action")
6244        };
6245        assert_eq!(session_id, "session-1");
6246        assert!(text.is_empty());
6247        assert_eq!(images.len(), 2);
6248        assert!(
6249            images
6250                .iter()
6251                .all(|image| { image.data_base64.is_empty() && image.attachment.is_some() })
6252        );
6253        reply.send(ActionOutcome::accepted()).unwrap();
6254        assert_eq!(response.await.unwrap().status(), StatusCode::ACCEPTED);
6255    }
6256
6257    #[tokio::test]
6258    async fn browser_attachment_upload_returns_a_stored_reference_without_inline_bytes() {
6259        let (app, _, _, _, _) = app_with_snapshot(image_capable);
6260        let cookie = login_cookie(&app).await;
6261        let bytes = base64::engine::general_purpose::STANDARD
6262            .decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=")
6263            .unwrap();
6264        let response = app
6265            .oneshot(
6266                Request::post("/api/sessions/session-1/attachments")
6267                    .header(COOKIE, cookie)
6268                    .header(CONTENT_TYPE, "image/png")
6269                    .body(Body::from(bytes))
6270                    .unwrap(),
6271            )
6272            .await
6273            .unwrap();
6274        assert_eq!(response.status(), StatusCode::OK);
6275        let body = response.into_body().collect().await.unwrap().to_bytes();
6276        let image: ViewerPromptImage = serde_json::from_slice(&body).unwrap();
6277        assert!(image.data_base64.is_empty());
6278        let reference = image.attachment.expect("upload should return a reference");
6279        assert_eq!(reference.mime_type, "image/png");
6280        assert_eq!(reference.width, 1);
6281        assert_eq!(reference.height, 1);
6282        assert!(reference.size <= 700 * 1024);
6283    }
6284
6285    /// Base64 inflates an upload by a third, so two ordinary photographs pass
6286    /// the general body limit even when each one fits it. The action route
6287    /// carries prompts, so it is the route that gets the larger bound.
6288    #[tokio::test]
6289    async fn multi_image_prompts_are_accepted_over_the_general_body_limit() {
6290        let (app, mut actions, _, _, _) = app_with_snapshot(image_capable);
6291        let cookie = login_cookie(&app).await;
6292        let image = sample_valid_image();
6293        let mut body = serde_json::to_string(&ControllerAction::Prompt {
6294            session_id: "session-1".into(),
6295            text: "look at these".into(),
6296            images: vec![image.clone(), image],
6297        })
6298        .unwrap();
6299        body.push_str(&" ".repeat(MAX_BODY_BYTES));
6300        assert!(body.len() > MAX_BODY_BYTES);
6301        assert!(body.len() < MAX_PROMPT_BODY_BYTES);
6302        let response = tokio::spawn(post_action(app, cookie, body));
6303        let action = actions.recv().await.unwrap();
6304        action.reply.send(ActionOutcome::accepted()).unwrap();
6305        assert_eq!(response.await.unwrap().status(), StatusCode::ACCEPTED);
6306    }
6307
6308    #[tokio::test]
6309    async fn a_body_over_the_prompt_limit_is_still_refused() {
6310        let (app, _actions, _, _, _) = app_with_snapshot(image_capable);
6311        let cookie = login_cookie(&app).await;
6312        let image = sample_image(MAX_PROMPT_BODY_BYTES);
6313        let body = serde_json::to_string(&ControllerAction::Prompt {
6314            session_id: "session-1".into(),
6315            text: String::new(),
6316            images: vec![image],
6317        })
6318        .unwrap();
6319        assert!(body.len() > MAX_PROMPT_BODY_BYTES);
6320        let response = post_action(app, cookie, body).await;
6321        assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE);
6322    }
6323
6324    #[tokio::test]
6325    async fn malformed_image_payloads_never_reach_the_controller() {
6326        let cases = [
6327            ("aW1hZ2U=", "text/plain", 32, 24),
6328            ("aW1hZ2U=", "image/png", 0, 24),
6329            ("not base64!", "image/png", 32, 24),
6330            ("", "image/png", 32, 24),
6331        ];
6332        for (data, mime, width, height) in cases {
6333            let (app, mut actions, _, _, _) = app_with_snapshot(image_capable);
6334            let cookie = login_cookie(&app).await;
6335            let body = serde_json::to_string(&ControllerAction::Prompt {
6336                session_id: "session-1".into(),
6337                text: String::new(),
6338                images: vec![ViewerPromptImage {
6339                    data_base64: data.into(),
6340                    mime_type: mime.into(),
6341                    width,
6342                    height,
6343                    attachment: None,
6344                }],
6345            })
6346            .unwrap();
6347            let response = post_action(app, cookie, body).await;
6348            assert_eq!(
6349                response.status(),
6350                StatusCode::BAD_REQUEST,
6351                "expected {data:?}/{mime} {width}x{height} to be refused"
6352            );
6353            assert!(actions.try_recv().is_err());
6354        }
6355    }
6356
6357    #[test]
6358    fn image_prompts_need_text_or_an_image_and_an_agent_that_takes_them() {
6359        let (config, state) = sample_config_state();
6360        let mut snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
6361        let prompt = |text: &str, images: Vec<ViewerPromptImage>| ControllerAction::Prompt {
6362            session_id: "session-1".into(),
6363            text: text.into(),
6364            images,
6365        };
6366
6367        // Without the capability the session takes text only.
6368        assert!(validate_action(&prompt("ship it", Vec::new()), &snapshot).is_ok());
6369        assert!(validate_action(&prompt("", vec![sample_image(8)]), &snapshot).is_err());
6370
6371        image_capable(&mut snapshot);
6372        // An image is a prompt on its own; nothing at all is not.
6373        assert!(validate_action(&prompt("", vec![sample_image(8)]), &snapshot).is_ok());
6374        assert!(
6375            validate_action(
6376                &prompt("", vec![sample_image(8); MAX_PROMPT_IMAGES + 1]),
6377                &snapshot,
6378            )
6379            .is_err()
6380        );
6381        assert!(validate_action(&prompt("   ", Vec::new()), &snapshot).is_err());
6382        assert!(validate_action(&prompt("", Vec::new()), &snapshot).is_err());
6383        // A shell command is still a shell command.
6384        assert!(validate_action(&prompt("!ls", vec![sample_image(8)]), &snapshot).is_err());
6385    }
6386
6387    /// The composer holds a DOM, not a string, so the text a prompt sends is
6388    /// whatever this reader makes of that DOM. Run it as JavaScript.
6389    #[test]
6390    fn embedded_viewer_reads_multiline_composer_text_out_of_its_dom() {
6391        let source = viewer_source("function composerText()", "function setComposerText(");
6392        let harness = r##"
6393const Node = { TEXT_NODE: 3 };
6394function textNode(value) {
6395  return { nodeType: 3, nodeValue: value, nodeName: "#text", childNodes: [], dataset: {} };
6396}
6397function element(name, children = [], dataset = {}) {
6398  const node = { nodeType: 1, nodeName: name, dataset, childNodes: children };
6399  children.forEach((child, index) => {
6400    child.nextSibling = children[index + 1] || null;
6401  });
6402  return node;
6403}
6404let promptText = null;
6405function read(children) {
6406  promptText = element("DIV", children);
6407  return composerText();
6408}
6409"##;
6410        let checks = r#"
6411const plain = read([textNode("ship it")]);
6412if (plain !== "ship it") throw new Error(`plain text became ${JSON.stringify(plain)}`);
6413
6414const broken = read([textNode("first"), element("BR"), textNode("second")]);
6415if (broken !== "first\nsecond") throw new Error(`line break became ${JSON.stringify(broken)}`);
6416
6417// The trailing break a browser leaves behind to keep the caret on a new line
6418// is scaffolding, not a line the user typed.
6419const filler = read([
6420  textNode("first"),
6421  element("BR"),
6422  element("BR", [], { composerFiller: "true" }),
6423]);
6424if (filler !== "first\n") throw new Error(`filler break became ${JSON.stringify(filler)}`);
6425
6426const blocks = read([
6427  textNode("first"),
6428  element("DIV", [textNode("second")]),
6429  element("DIV", [textNode("third")]),
6430]);
6431if (blocks !== "first\nsecond\nthird") throw new Error(`blocks became ${JSON.stringify(blocks)}`);
6432
6433const carriage = read([textNode("first\r\nsecond")]);
6434if (carriage !== "first\nsecond") throw new Error(`CRLF became ${JSON.stringify(carriage)}`);
6435"#;
6436        run_viewer_script("composer-reader", &format!("{harness}\n{source}\n{checks}"));
6437    }
6438
6439    /// A page that declares no icon makes every browser request
6440    /// `/favicon.ico`, which this server does not have. The page therefore has
6441    /// to name an icon, and that icon has to be served.
6442    #[tokio::test]
6443    async fn viewer_declares_the_icon_route_instead_of_requesting_a_missing_favicon() {
6444        let (app, _, _, _, _) = app();
6445        let page = fetch_text(app.clone(), "/").await;
6446        assert!(page.contains(r#"rel="icon""#), "the page declares no icon");
6447        assert!(page.contains("/icon.svg"), "the page names no icon route");
6448        let icon = app
6449            .oneshot(Request::get("/icon.svg").body(Body::empty()).unwrap())
6450            .await
6451            .unwrap();
6452        assert_eq!(icon.status(), StatusCode::OK);
6453        assert_eq!(
6454            icon.headers().get(CONTENT_TYPE).unwrap(),
6455            "image/svg+xml",
6456            "the icon route does not serve an SVG"
6457        );
6458    }
6459
6460    #[tokio::test]
6461    async fn valid_action_is_typed_and_forwarded() {
6462        let (app, mut actions, _, _, _) = app();
6463        let cookie = login_cookie(&app).await;
6464        let response = tokio::spawn(
6465            app.oneshot(
6466                Request::post("/api/actions")
6467                    .header(COOKIE, cookie)
6468                    .header(CONTENT_TYPE, "application/json")
6469                    .body(Body::from(
6470                        r#"{"action":"prompt","session_id":"session-1","text":"ship it"}"#,
6471                    ))
6472                    .unwrap(),
6473            ),
6474        );
6475        let action = actions.recv().await.unwrap();
6476        assert_eq!(
6477            action.action,
6478            ControllerAction::Prompt {
6479                session_id: "session-1".into(),
6480                text: "ship it".into(),
6481                images: Vec::new(),
6482            }
6483        );
6484        action.reply.send(ActionOutcome::accepted()).unwrap();
6485        let response = response.await.unwrap().unwrap();
6486        assert_eq!(response.status(), StatusCode::ACCEPTED);
6487    }
6488
6489    #[tokio::test]
6490    async fn move_preparation_is_read_only_and_returns_the_daemon_fingerprint() {
6491        let (app, mut preparations) = app_with_move_receiver();
6492        let cookie = login_cookie(&app).await;
6493        let response = tokio::spawn(
6494            app.oneshot(
6495                Request::post("/api/moves/prepare")
6496                    .header(COOKIE, cookie)
6497                    .header(CONTENT_TYPE, "application/json")
6498                    .body(Body::from(
6499                        r#"{"session_id":"session-1","profile_id":"codex-1","target_template_id":"podman","clear_resource_allocation":false,"additional_mounts":null,"resource_allocation":null}"#,
6500                    ))
6501                    .unwrap(),
6502            ),
6503        );
6504        let request = preparations
6505            .recv()
6506            .await
6507            .expect("preparation reached daemon");
6508        assert_eq!(request.selection.session_id, "session-1");
6509        assert_eq!(request.selection.profile_id.as_deref(), Some("codex-1"));
6510        assert_eq!(
6511            request.selection.target_template_id.as_deref(),
6512            Some("podman")
6513        );
6514        request
6515            .reply
6516            .send(Ok(MovePreparation {
6517                source_unavailable: false,
6518                selection: request.selection,
6519                source_profile_id: "codex-1".into(),
6520                source_target_template_id: "podman".into(),
6521                cross_harness: false,
6522                active: true,
6523                queued_commands: vec![mj_core::state::MaterializedQueuedPrompt {
6524                    accepted_ordinal: None,
6525                    command_id: "queued-1".into(),
6526                    kind: mj_core::state::QueuedCommandKind::Prompt,
6527                    content: vec![serde_json::json!({
6528                        "type": "image",
6529                        "mimeType": "image/png",
6530                        "data": "secret-image-bytes"
6531                    })],
6532                    queued_at_ms: 1,
6533                }],
6534                fingerprint: "fingerprint".into(),
6535                operation_id: "move-1".into(),
6536            }))
6537            .unwrap();
6538        let response = response.await.unwrap().unwrap();
6539        assert_eq!(response.status(), StatusCode::OK);
6540        let body = response.into_body().collect().await.unwrap().to_bytes();
6541        let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
6542        assert_eq!(body["operation_id"], "move-1");
6543        assert_eq!(body["active"], true);
6544        assert_eq!(
6545            body["queued_commands"][0]["content"][0]["text"],
6546            "[Image attachment: image/png]"
6547        );
6548        assert!(body.to_string().contains("[Image attachment: image/png]"));
6549        assert!(!body.to_string().contains("secret-image-bytes"));
6550    }
6551
6552    #[tokio::test]
6553    async fn confirmed_move_action_forwards_the_fingerprinted_request() {
6554        let (app, mut actions, _, _, _) = app_with_snapshot(|snapshot| {
6555            snapshot.sessions[0].capabilities.move_session = true;
6556        });
6557        let cookie = login_cookie(&app).await;
6558        let response = tokio::spawn(
6559            app.oneshot(
6560                Request::post("/api/actions")
6561                    .header(COOKIE, cookie)
6562                    .header(CONTENT_TYPE, "application/json")
6563                    .body(Body::from(
6564                        r#"{"action":"move","request":{"preparation":{"selection":{"session_id":"session-1","profile_id":"codex-1","target_template_id":"podman","clear_resource_allocation":false,"additional_mounts":null,"resource_allocation":null},"source_profile_id":"codex-1","source_target_template_id":"podman","cross_harness":false,"active":false,"queued_commands":[],"fingerprint":"fingerprint","operation_id":"move-1"},"queue":null,"acknowledge_interruption":false}}"#,
6565                    ))
6566                    .unwrap(),
6567            ),
6568        );
6569        let action = actions.recv().await.expect("move action reached daemon");
6570        assert!(matches!(action.action, ControllerAction::Move { .. }));
6571        action.reply.send(ActionOutcome::accepted()).unwrap();
6572        assert_eq!(
6573            response.await.unwrap().unwrap().status(),
6574            StatusCode::ACCEPTED
6575        );
6576    }
6577
6578    #[tokio::test]
6579    async fn shell_action_is_typed_and_forwarded() {
6580        let (app, mut actions, _, _, _) = app();
6581        let cookie = login_cookie(&app).await;
6582        let response = tokio::spawn(
6583            app.oneshot(
6584                Request::post("/api/actions")
6585                    .header(COOKIE, cookie)
6586                    .header(CONTENT_TYPE, "application/json")
6587                    .body(Body::from(
6588                        r#"{"action":"run-shell","session_id":"session-1","command":"cargo test"}"#,
6589                    ))
6590                    .unwrap(),
6591            ),
6592        );
6593        let action = actions.recv().await.unwrap();
6594        assert_eq!(
6595            action.action,
6596            ControllerAction::RunShell {
6597                session_id: "session-1".into(),
6598                command: "cargo test".into(),
6599            }
6600        );
6601        action.reply.send(ActionOutcome::accepted()).unwrap();
6602        assert_eq!(
6603            response.await.unwrap().unwrap().status(),
6604            StatusCode::ACCEPTED
6605        );
6606    }
6607
6608    #[test]
6609    fn shell_action_validation_reserves_bang_prompts_and_checks_cancellation_ids() {
6610        let (config, state) = sample_config_state();
6611        let mut snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
6612        assert!(
6613            validate_action(
6614                &ControllerAction::Prompt {
6615                    session_id: "session-1".into(),
6616                    text: "!cargo test".into(),
6617                    images: Vec::new(),
6618                },
6619                &snapshot,
6620            )
6621            .is_err()
6622        );
6623        assert!(
6624            validate_action(
6625                &ControllerAction::RunShell {
6626                    session_id: "session-1".into(),
6627                    command: "cargo test".into(),
6628                },
6629                &snapshot,
6630            )
6631            .is_ok()
6632        );
6633        assert!(
6634            validate_action(
6635                &ControllerAction::CancelShell {
6636                    session_id: "session-1".into(),
6637                    shell_command_id: "shell-1".into(),
6638                },
6639                &snapshot,
6640            )
6641            .is_err()
6642        );
6643
6644        snapshot.sessions[0]
6645            .active_user_shells
6646            .push(ViewerUserShell {
6647                id: "shell-1".into(),
6648                command: "cargo test".into(),
6649                started_at_ms: Some(10),
6650            });
6651        assert!(
6652            validate_action(
6653                &ControllerAction::CancelShell {
6654                    session_id: "session-1".into(),
6655                    shell_command_id: "shell-1".into(),
6656                },
6657                &snapshot,
6658            )
6659            .is_ok()
6660        );
6661    }
6662
6663    #[tokio::test]
6664    async fn bare_new_action_forwards_an_explicit_safe_project_directory() {
6665        let (app, mut actions, _, _, _) = app();
6666        let cookie = login_cookie(&app).await;
6667        let response = tokio::spawn(
6668            app.oneshot(
6669                Request::post("/api/actions")
6670                    .header(COOKIE, cookie)
6671                    .header(CONTENT_TYPE, "application/json")
6672                    .body(Body::from(
6673                        r#"{"action":"new","profile_id":"codex-1","bundle_id":"hel","target_id":"raw","title":"Raw work","project_directory":"/work/project"}"#,
6674                    ))
6675                    .unwrap(),
6676            ),
6677        );
6678        let action = actions.recv().await.unwrap();
6679        assert_eq!(
6680            action.action,
6681            ControllerAction::New {
6682                mjolnir_subagents: None,
6683                create_managed_worktree: None,
6684                workspace_id: String::new(),
6685                profile_id: "codex-1".into(),
6686                bundle_id: "hel".into(),
6687                target_id: "raw".into(),
6688                title: Some("Raw work".into()),
6689                project_directory: Some(PathBuf::from("/work/project")),
6690                dirty_ack: Vec::new(),
6691            }
6692        );
6693        action.reply.send(ActionOutcome::accepted()).unwrap();
6694        assert_eq!(
6695            response.await.unwrap().unwrap().status(),
6696            StatusCode::ACCEPTED
6697        );
6698    }
6699
6700    #[test]
6701    fn new_action_requires_project_directory_exactly_for_bare_targets() {
6702        let (config, state) = sample_config_state();
6703        let snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
6704        let action = |target_id: &str, project_directory: Option<PathBuf>| ControllerAction::New {
6705            mjolnir_subagents: None,
6706            create_managed_worktree: None,
6707            workspace_id: String::new(),
6708            profile_id: "codex-1".into(),
6709            bundle_id: "hel".into(),
6710            target_id: target_id.into(),
6711            title: Some("New work".into()),
6712            project_directory,
6713            dirty_ack: Vec::new(),
6714        };
6715
6716        assert!(validate_action(&action("podman", None), &snapshot).is_ok());
6717        assert_eq!(
6718            validate_action(&action("podman", Some("/work".into())), &snapshot)
6719                .unwrap_err()
6720                .status,
6721            StatusCode::BAD_REQUEST
6722        );
6723        assert_eq!(
6724            validate_action(&action("raw", None), &snapshot)
6725                .unwrap_err()
6726                .status,
6727            StatusCode::BAD_REQUEST
6728        );
6729        assert_eq!(
6730            validate_action(&action("raw", Some("relative".into())), &snapshot)
6731                .unwrap_err()
6732                .status,
6733            StatusCode::BAD_REQUEST
6734        );
6735        assert_eq!(
6736            validate_action(&action("raw", Some("/work/../secret".into())), &snapshot)
6737                .unwrap_err()
6738                .status,
6739            StatusCode::BAD_REQUEST
6740        );
6741        assert!(validate_action(&action("raw", Some("/work/project".into())), &snapshot).is_ok());
6742    }
6743
6744    #[tokio::test]
6745    async fn cancel_action_is_typed_and_forwarded() {
6746        let (app, mut actions, _, _, _) = app();
6747        let cookie = login_cookie(&app).await;
6748        let response = tokio::spawn(
6749            app.oneshot(
6750                Request::post("/api/actions")
6751                    .header(COOKIE, cookie)
6752                    .header(CONTENT_TYPE, "application/json")
6753                    .body(Body::from(
6754                        r#"{"action":"cancel","session_id":"session-1"}"#,
6755                    ))
6756                    .unwrap(),
6757            ),
6758        );
6759        let action = actions.recv().await.unwrap();
6760        assert_eq!(
6761            action.action,
6762            ControllerAction::Cancel {
6763                session_id: "session-1".into(),
6764            }
6765        );
6766        action.reply.send(ActionOutcome::accepted()).unwrap();
6767        assert_eq!(
6768            response.await.unwrap().unwrap().status(),
6769            StatusCode::ACCEPTED
6770        );
6771    }
6772
6773    #[tokio::test]
6774    async fn action_validation_accepts_cross_harness_resume_and_rejects_unknown() {
6775        let (mut config, state) = sample_config_state();
6776        config.profiles.insert(
6777            "claude-1".into(),
6778            HarnessProfile {
6779                enabled: true,
6780                context_window_bytes: None,
6781                kind: HarnessKind::Claude,
6782                home: "/secret/claude".into(),
6783                environment: BTreeMap::new(),
6784            },
6785        );
6786        let mut snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
6787        snapshot.workspaces.push(ViewerWorkspace {
6788            id: "workspace-1".into(),
6789            name: "One".into(),
6790        });
6791        validate_action(
6792            &ControllerAction::Resume {
6793                session_id: "session-1".into(),
6794                workspace_id: "workspace-1".into(),
6795                profile_id: "claude-1".into(),
6796                target_id: "podman".into(),
6797                queue: ResumeQueueDisposition::Start,
6798                additional_mounts: None,
6799                resource_allocation: None,
6800            },
6801            &snapshot,
6802        )
6803        .unwrap();
6804
6805        let error = validate_action(
6806            &ControllerAction::Resume {
6807                session_id: "session-1".into(),
6808                workspace_id: "missing".into(),
6809                profile_id: "claude-1".into(),
6810                target_id: "podman".into(),
6811                queue: ResumeQueueDisposition::Start,
6812                additional_mounts: None,
6813                resource_allocation: None,
6814            },
6815            &snapshot,
6816        )
6817        .unwrap_err();
6818        assert_eq!(error.status, StatusCode::BAD_REQUEST);
6819
6820        let error = validate_action(
6821            &ControllerAction::Close {
6822                session_id: "not-managed".into(),
6823            },
6824            &snapshot,
6825        )
6826        .unwrap_err();
6827        assert_eq!(error.status, StatusCode::NOT_FOUND);
6828    }
6829
6830    /// A review the daemon is running reaches the phone whole: its tier, what
6831    /// each reviewing agent is doing, and the findings to answer.
6832    #[test]
6833    fn a_running_review_projects_to_the_phone() {
6834        use crate::review_host::{RuntimeReviewView, VerdictKind, VerdictView};
6835        use mj_core::review::driver::{Resolution, RoleState, RoleStatus, TurnReviewPhase};
6836
6837        let review = RuntimeReviewView {
6838            session_id: "session-1".into(),
6839            tier: mj_core::review::lanes::ReviewTier::Extended,
6840            phase: TurnReviewPhase::Verdict(mj_core::review::verdict::ReviewVerdict::Findings {
6841                synthesis: "[P1] src/lib.rs:1 -- unbounded retry".into(),
6842                evidence: Default::default(),
6843            }),
6844            roles: vec![
6845                RoleStatus {
6846                    role: "supervisor".into(),
6847                    label: "Supervisor".into(),
6848                    state: RoleState::Clean,
6849                },
6850                RoleStatus {
6851                    role: "tests".into(),
6852                    label: "Tests".into(),
6853                    state: RoleState::Findings,
6854                },
6855            ],
6856            status: "Enter to act".into(),
6857            verdict: Some(VerdictView {
6858                kind: VerdictKind::Findings,
6859                text: "[P1] src/lib.rs:1 -- unbounded retry".into(),
6860                allowed: vec![
6861                    Resolution::Forwarded,
6862                    Resolution::Dismissed,
6863                    Resolution::Cancelled,
6864                ],
6865            }),
6866        };
6867
6868        let projected = ViewerTurnReview::from_runtime(&review);
6869
6870        assert_eq!(projected.tier, "extended");
6871        assert_eq!(
6872            projected
6873                .roles
6874                .iter()
6875                .map(|role| (role.label.as_str(), role.state.as_str()))
6876                .collect::<Vec<_>>(),
6877            vec![("Supervisor", "done"), ("Tests", "findings")]
6878        );
6879        let verdict = projected.verdict.expect("a findings verdict travels");
6880        assert_eq!(verdict.kind, "findings");
6881        assert!(verdict.text.contains("unbounded retry"));
6882        assert_eq!(verdict.allowed, vec!["forward", "dismiss", "cancel"]);
6883    }
6884
6885    /// A phone can always cancel a review, and can only forward or dismiss one
6886    /// the daemon says is ready for it. The same gate runs in the daemon; this
6887    /// one is what makes the refusal immediate.
6888    #[test]
6889    fn resolving_a_review_is_gated_on_what_the_daemon_published() {
6890        let (config, state) = sample_config_state();
6891        let mut snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
6892
6893        let resolve = |resolution: &str| ControllerAction::ResolveReview {
6894            session_id: "session-1".into(),
6895            resolution: resolution.into(),
6896        };
6897
6898        // No review at all.
6899        let error = validate_action(&resolve("cancel"), &snapshot).unwrap_err();
6900        assert_eq!(error.status, StatusCode::BAD_REQUEST);
6901
6902        snapshot.sessions[0].turn_review = Some(ViewerTurnReview {
6903            tier: "quick".into(),
6904            status: "the reviewer is reading the change…".into(),
6905            roles: Vec::new(),
6906            verdict: None,
6907        });
6908        // Running: cancel works, the rest do not.
6909        validate_action(&resolve("cancel"), &snapshot).unwrap();
6910        assert_eq!(
6911            validate_action(&resolve("forward"), &snapshot)
6912                .unwrap_err()
6913                .status,
6914            StatusCode::BAD_REQUEST
6915        );
6916
6917        // A failed review can be dismissed but has nothing to forward.
6918        snapshot.sessions[0].turn_review = Some(ViewerTurnReview {
6919            tier: "quick".into(),
6920            status: "the review failed".into(),
6921            roles: Vec::new(),
6922            verdict: Some(ViewerReviewVerdict {
6923                kind: "failed".into(),
6924                text: "bifrost exited with 1".into(),
6925                allowed: vec!["dismiss".into(), "cancel".into()],
6926            }),
6927        });
6928        validate_action(&resolve("dismiss"), &snapshot).unwrap();
6929        assert_eq!(
6930            validate_action(&resolve("forward"), &snapshot)
6931                .unwrap_err()
6932                .status,
6933            StatusCode::BAD_REQUEST
6934        );
6935        // A resolution that is not one of the three is refused by name.
6936        assert_eq!(
6937            validate_action(&resolve("approve"), &snapshot)
6938                .unwrap_err()
6939                .status,
6940            StatusCode::BAD_REQUEST
6941        );
6942
6943        // Starting a review needs only a session that exists.
6944        validate_action(
6945            &ControllerAction::StartReview {
6946                session_id: "session-1".into(),
6947            },
6948            &snapshot,
6949        )
6950        .unwrap();
6951        assert_eq!(
6952            validate_action(
6953                &ControllerAction::StartReview {
6954                    session_id: "not-managed".into(),
6955                },
6956                &snapshot,
6957            )
6958            .unwrap_err()
6959            .status,
6960            StatusCode::NOT_FOUND
6961        );
6962    }
6963
6964    #[test]
6965    fn resume_action_refuses_a_target_the_session_cannot_use() {
6966        let (mut config, state) = sample_config_state();
6967        // A project that only exists on GitHub cannot become a checkout on this
6968        // machine, so the bare target stays out of reach for its sessions.
6969        config.bundles.get_mut("hel").unwrap().repositories[0].local = None;
6970        let mut snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
6971        snapshot.workspaces.push(ViewerWorkspace {
6972            id: "workspace-1".into(),
6973            name: "One".into(),
6974        });
6975        assert_eq!(
6976            snapshot.sessions[0].incompatible_resume_targets,
6977            vec!["raw".to_owned()]
6978        );
6979
6980        let error = validate_action(
6981            &ControllerAction::Resume {
6982                session_id: "session-1".into(),
6983                workspace_id: "workspace-1".into(),
6984                profile_id: "codex-1".into(),
6985                target_id: "raw".into(),
6986                queue: ResumeQueueDisposition::Start,
6987                additional_mounts: None,
6988                resource_allocation: None,
6989            },
6990            &snapshot,
6991        )
6992        .unwrap_err();
6993
6994        assert_eq!(error.status, StatusCode::BAD_REQUEST);
6995    }
6996
6997    #[test]
6998    fn move_confirmation_requires_interruption_ack_and_an_explicit_queue_choice() {
6999        let (config, state) = sample_config_state();
7000        let mut snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
7001        snapshot.sessions[0].capabilities.move_session = true;
7002        let selection = MoveSelection {
7003            clear_resource_allocation: false,
7004            session_id: "session-1".into(),
7005            profile_id: Some("codex-1".into()),
7006            target_template_id: Some("podman".into()),
7007            additional_mounts: None,
7008            resource_allocation: None,
7009        };
7010        let preparation = MovePreparation {
7011            source_unavailable: false,
7012            selection,
7013            source_profile_id: "codex-1".into(),
7014            source_target_template_id: "podman".into(),
7015            cross_harness: false,
7016            active: true,
7017            queued_commands: vec![mj_core::state::MaterializedQueuedPrompt {
7018                accepted_ordinal: None,
7019                command_id: "command-1".into(),
7020                kind: mj_core::state::QueuedCommandKind::Prompt,
7021                content: vec![serde_json::json!({"type": "text", "text": "continue"})],
7022                queued_at_ms: 1,
7023            }],
7024            fingerprint: "fingerprint".into(),
7025            operation_id: "move-1".into(),
7026        };
7027        let request = |queue, acknowledge_interruption| MoveSessionRequest {
7028            preparation: preparation.clone(),
7029            queue,
7030            acknowledge_interruption,
7031        };
7032        assert_eq!(
7033            validate_action(
7034                &ControllerAction::Move {
7035                    request: request(Some(ResumeQueueDisposition::Discard), false),
7036                },
7037                &snapshot,
7038            )
7039            .unwrap_err()
7040            .status,
7041            StatusCode::CONFLICT
7042        );
7043        assert_eq!(
7044            validate_action(
7045                &ControllerAction::Move {
7046                    request: request(None, true),
7047                },
7048                &snapshot,
7049            )
7050            .unwrap_err()
7051            .status,
7052            StatusCode::BAD_REQUEST
7053        );
7054        validate_action(
7055            &ControllerAction::Move {
7056                request: request(Some(ResumeQueueDisposition::Discard), true),
7057            },
7058            &snapshot,
7059        )
7060        .unwrap();
7061    }
7062
7063    #[tokio::test]
7064    async fn snapshot_endpoint_returns_only_public_projection() {
7065        let (app, _, _, _, _) = app();
7066        let cookie = login_cookie(&app).await;
7067        let response = app
7068            .oneshot(
7069                Request::get("/api/snapshot")
7070                    .header(COOKIE, cookie)
7071                    .body(Body::empty())
7072                    .unwrap(),
7073            )
7074            .await
7075            .unwrap();
7076        let body = response.into_body().collect().await.unwrap().to_bytes();
7077        let body = String::from_utf8(body.to_vec()).unwrap();
7078        assert!(body.contains("session-1"));
7079        assert!(!body.contains("secret-token"));
7080        assert!(!body.contains("native-secret-id"));
7081        assert!(!body.contains("/private/source/hel"));
7082
7083        let snapshot: serde_json::Value = serde_json::from_str(&body).unwrap();
7084        let repository = &snapshot["bundles"][0]["repositories"][0];
7085        assert_eq!(repository["id"], "hel");
7086        assert_eq!(repository["github"], "owner/hel");
7087        assert_eq!(repository["destination"], "hel");
7088        assert!(repository.get("local").is_none());
7089    }
7090
7091    #[tokio::test]
7092    async fn snapshot_clock_anchor_is_fresh_even_when_the_projection_has_not_changed() {
7093        let (app, _, _, _, _) = app_with_snapshot(|snapshot| snapshot.server_time_ms = 1);
7094        let cookie = login_cookie(&app).await;
7095        for _ in 0..2 {
7096            let before = mj_core::clock::epoch_millis();
7097            let response = app
7098                .clone()
7099                .oneshot(
7100                    Request::get("/api/snapshot")
7101                        .header(COOKIE, &cookie)
7102                        .body(Body::empty())
7103                        .unwrap(),
7104                )
7105                .await
7106                .unwrap();
7107            let body = response.into_body().collect().await.unwrap().to_bytes();
7108            let snapshot: ViewerSnapshot = serde_json::from_slice(&body).unwrap();
7109            assert!(snapshot.server_time_ms >= before);
7110            assert!(snapshot.server_time_ms <= mj_core::clock::epoch_millis());
7111        }
7112    }
7113
7114    #[tokio::test]
7115    async fn conversation_endpoint_returns_authenticated_bounded_deltas() {
7116        let transcript = BrowserTranscript {
7117            latest_seq: 8,
7118            presentation_key: "key-1".into(),
7119            window_start_seq: 3,
7120            reset: false,
7121            entries: vec![
7122                BrowserTranscriptEntry {
7123                    id: 3,
7124                    updated_seq: 3,
7125                    role: "user",
7126                    label: "You".into(),
7127                    recorded_at_ms: None,
7128                    lines: vec!["begin".into()],
7129                    glyph: "\u{276f}",
7130                    tone: "user",
7131                    tool_status: None,
7132                    diffstats: Vec::new(),
7133                },
7134                BrowserTranscriptEntry {
7135                    id: 7,
7136                    updated_seq: 8,
7137                    role: "agent",
7138                    label: "Agent".into(),
7139                    recorded_at_ms: None,
7140                    lines: vec!["live".into()],
7141                    glyph: "\u{25cf}",
7142                    tone: "agent",
7143                    tool_status: None,
7144                    diffstats: Vec::new(),
7145                },
7146            ],
7147        };
7148        let (app, _, _, _, _) =
7149            app_with_conversations(BTreeMap::from([("session-1".into(), transcript)]));
7150        let cookie = login_cookie(&app).await;
7151        let response = app
7152            .clone()
7153            .oneshot(
7154                Request::get("/api/conversations/session-1?after_seq=3")
7155                    .header(COOKIE, &cookie)
7156                    .body(Body::empty())
7157                    .unwrap(),
7158            )
7159            .await
7160            .unwrap();
7161        assert_eq!(response.status(), StatusCode::OK);
7162        let body = response.into_body().collect().await.unwrap().to_bytes();
7163        let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
7164        assert_eq!(body["latest_seq"], 8);
7165        assert_eq!(body["reset"], false);
7166        assert_eq!(body["entries"].as_array().unwrap().len(), 1);
7167        assert_eq!(body["entries"][0]["lines"][0], "live");
7168
7169        let response = app
7170            .oneshot(
7171                Request::get("/api/conversations/session-1?after_seq=8&presentation_key=stale-key")
7172                    .header(COOKIE, &cookie)
7173                    .body(Body::empty())
7174                    .unwrap(),
7175            )
7176            .await
7177            .unwrap();
7178        assert_eq!(response.status(), StatusCode::OK);
7179        let body = response.into_body().collect().await.unwrap().to_bytes();
7180        let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
7181        assert_eq!(body["reset"], true);
7182        assert_eq!(body["presentation_key"], "key-1");
7183        assert_eq!(body["entries"].as_array().unwrap().len(), 2);
7184    }
7185
7186    #[tokio::test]
7187    async fn conversation_endpoint_rejects_cached_transcript_during_transition() {
7188        let transcript = BrowserTranscript {
7189            latest_seq: 1,
7190            presentation_key: "key-1".into(),
7191            window_start_seq: 1,
7192            reset: false,
7193            entries: vec![BrowserTranscriptEntry {
7194                id: 1,
7195                updated_seq: 1,
7196                role: "agent",
7197                label: "Agent".into(),
7198                recorded_at_ms: None,
7199                lines: vec!["stale".into()],
7200                glyph: "●",
7201                tone: "agent",
7202                tool_status: None,
7203                diffstats: Vec::new(),
7204            }],
7205        };
7206        let (app, _, _, _, _) = app_with(
7207            BTreeMap::from([("session-1".into(), transcript)]),
7208            |snapshot| snapshot.sessions[0].transitioning = true,
7209        );
7210        let cookie = login_cookie(&app).await;
7211        let response = app
7212            .oneshot(
7213                Request::get("/api/conversations/session-1")
7214                    .header(COOKIE, cookie)
7215                    .body(Body::empty())
7216                    .unwrap(),
7217            )
7218            .await
7219            .unwrap();
7220        assert_eq!(response.status(), StatusCode::CONFLICT);
7221    }
7222
7223    #[tokio::test]
7224    async fn conversation_read_receipt_never_contends_with_a_running_action() {
7225        let (app, mut actions, mut receipts, _, _) = app();
7226        let cookie = login_cookie(&app).await;
7227        // A prompt for the same session stays in flight for the whole test, so
7228        // a receipt that still travelled the action pipeline would either
7229        // queue behind it or be rejected for the occupied session slot.
7230        let prompt = tokio::spawn(
7231            app.clone().oneshot(
7232                Request::post("/api/actions")
7233                    .header(COOKIE, cookie.clone())
7234                    .header(CONTENT_TYPE, "application/json")
7235                    .body(Body::from(
7236                        r#"{"action":"prompt","session_id":"session-1","text":"ship it"}"#,
7237                    ))
7238                    .unwrap(),
7239            ),
7240        );
7241        let action = actions.recv().await.unwrap();
7242
7243        let response = tokio::spawn(
7244            app.oneshot(
7245                Request::post("/api/conversations/session-1/read")
7246                    .header(COOKIE, cookie)
7247                    .header(CONTENT_TYPE, "application/json")
7248                    .body(Body::from(r#"{"through":42}"#))
7249                    .unwrap(),
7250            ),
7251        );
7252        let receipt = receipts.recv().await.unwrap();
7253        assert_eq!(receipt.session_id, "session-1");
7254        assert_eq!(receipt.through, 42);
7255        receipt.reply.send(Ok(())).unwrap();
7256        assert_eq!(
7257            response.await.unwrap().unwrap().status(),
7258            StatusCode::NO_CONTENT
7259        );
7260        assert!(
7261            actions.try_recv().is_err(),
7262            "a read receipt must not queue a controller action"
7263        );
7264
7265        action.reply.send(ActionOutcome::accepted()).unwrap();
7266        assert_eq!(
7267            prompt.await.unwrap().unwrap().status(),
7268            StatusCode::ACCEPTED
7269        );
7270    }
7271
7272    #[tokio::test]
7273    async fn each_rejected_action_keeps_its_own_status_and_guidance() {
7274        for (outcome, status, guidance) in [
7275            (
7276                ActionOutcome::Busy,
7277                StatusCode::TOO_MANY_REQUESTS,
7278                "concurrent action limit",
7279            ),
7280            (
7281                ActionOutcome::SessionBusy,
7282                StatusCode::CONFLICT,
7283                "another operation is already running",
7284            ),
7285            (
7286                ActionOutcome::NotCancellable,
7287                StatusCode::CONFLICT,
7288                "no cancellable operation",
7289            ),
7290            (
7291                ActionOutcome::Failed,
7292                StatusCode::INTERNAL_SERVER_ERROR,
7293                "could not start this action",
7294            ),
7295        ] {
7296            let (app, mut actions, _, _, _) = app();
7297            let cookie = login_cookie(&app).await;
7298            let response = tokio::spawn(
7299                app.oneshot(
7300                    Request::post("/api/actions")
7301                        .header(COOKIE, cookie)
7302                        .header(CONTENT_TYPE, "application/json")
7303                        .body(Body::from(r#"{"action":"close","session_id":"session-1"}"#))
7304                        .unwrap(),
7305                ),
7306            );
7307            let request = actions.recv().await.unwrap();
7308            request.reply.send(outcome.clone()).unwrap();
7309
7310            let response = response.await.unwrap().unwrap();
7311            assert_eq!(response.status(), status, "{outcome:?}");
7312            let body = response.into_body().collect().await.unwrap().to_bytes();
7313            let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
7314            let error = body["error"].as_str().unwrap();
7315            assert!(error.contains(guidance), "{outcome:?} answered {error:?}");
7316        }
7317    }
7318
7319    #[tokio::test]
7320    async fn the_viewer_shows_a_session_whose_action_failed_after_it_was_accepted() {
7321        // An accepted action reports its outcome only through snapshots, so
7322        // the application has to react to `has_error` for a late failure to be
7323        // visible at all.
7324        let (app, _, _, _, _) = app();
7325        let script = fetch_text(app, "/viewer.js").await;
7326        assert!(script.contains("has_error"), "viewer ignores has_error");
7327    }
7328
7329    /// Every response, not only the page, carries the policy. A header that
7330    /// depends on which handler answered is a header somebody will forget.
7331    #[tokio::test]
7332    async fn every_response_carries_the_security_headers() {
7333        for path in [
7334            "/",
7335            "/viewer.js",
7336            "/voice-worklet.js",
7337            "/voice-worker.js",
7338            "/viewer.css",
7339            "/manifest.webmanifest",
7340            "/api/snapshot",
7341        ] {
7342            let (app, _, _, _, _) = app();
7343            let response = app
7344                .oneshot(Request::get(path).body(Body::empty()).unwrap())
7345                .await
7346                .unwrap();
7347            let headers = response.headers();
7348            let policy = headers
7349                .get(CONTENT_SECURITY_POLICY_HEADER)
7350                .unwrap_or_else(|| panic!("{path} carries no content-security policy"))
7351                .to_str()
7352                .unwrap();
7353            assert!(
7354                policy.starts_with("default-src 'none';"),
7355                "{path} does not refuse unlisted sources: {policy}"
7356            );
7357            assert!(
7358                policy.contains("script-src 'self'") && !policy.contains("unsafe-inline"),
7359                "{path} permits inline script: {policy}"
7360            );
7361            assert!(
7362                policy.contains("frame-ancestors 'none'"),
7363                "{path} can be framed: {policy}"
7364            );
7365            assert_eq!(
7366                headers.get(X_CONTENT_TYPE_OPTIONS).unwrap(),
7367                "nosniff",
7368                "{path} permits content sniffing"
7369            );
7370            assert_eq!(
7371                headers.get(REFERRER_POLICY).unwrap(),
7372                "no-referrer",
7373                "{path} leaks a referrer"
7374            );
7375        }
7376    }
7377
7378    /// The policy forbids inline script and style, so the page must contain
7379    /// neither. A page that did would simply fail to run in a browser, which
7380    /// no Rust test would otherwise notice.
7381    #[tokio::test]
7382    async fn the_page_carries_no_inline_script_or_style() {
7383        let (app, _, _, _, _) = app();
7384        let page = fetch_text(app, "/").await;
7385        assert!(
7386            !page.contains("<script>") && !page.contains("<style>"),
7387            "the page inlines script or style, which the policy blocks"
7388        );
7389        assert!(
7390            page.contains(r#"src="/viewer.js""#) && page.contains(r#"href="/viewer.css""#),
7391            "the page does not load its script and style as separate assets"
7392        );
7393    }
7394
7395    /// A cached API answer is a lie about live session state, and a cached
7396    /// service worker is what keeps a phone on a superseded application.
7397    #[tokio::test]
7398    async fn live_state_and_the_service_worker_are_never_stored() {
7399        for path in ["/", "/service-worker.js", "/api/snapshot"] {
7400            let (app, _, _, _, _) = app();
7401            let response = app
7402                .oneshot(Request::get(path).body(Body::empty()).unwrap())
7403                .await
7404                .unwrap();
7405            assert_eq!(
7406                response.headers().get(CACHE_CONTROL).unwrap(),
7407                "no-store",
7408                "{path} may be stored"
7409            );
7410        }
7411    }
7412
7413    /// The worker must leave live state alone entirely rather than caching it
7414    /// and hoping the cache is fresh.
7415    #[test]
7416    fn the_service_worker_declines_to_handle_live_state() {
7417        assert!(
7418            SERVICE_WORKER.contains("url.pathname.startsWith('/api/')"),
7419            "the service worker does not exclude the API"
7420        );
7421        assert!(
7422            SERVICE_WORKER.contains("url.pathname.startsWith('/auth/')"),
7423            "the service worker does not exclude authentication"
7424        );
7425        assert!(
7426            SERVICE_WORKER.contains("caches.delete"),
7427            "the service worker never deletes a superseded cache"
7428        );
7429    }
7430
7431    /// The vendored assets have to reach the browser, not merely exist in the
7432    /// repository: the manifest names them and a phone installs from it.
7433    #[tokio::test]
7434    async fn the_installable_assets_are_served() {
7435        for (path, content_type) in [
7436            ("/icon-192.png", "image/png"),
7437            ("/icon-512.png", "image/png"),
7438            ("/maskable-512.png", "image/png"),
7439            ("/apple-touch-icon.png", "image/png"),
7440            ("/fonts/jetbrains-mono.woff2", "font/woff2"),
7441        ] {
7442            let (app, _, _, _, _) = app();
7443            let response = app
7444                .oneshot(Request::get(path).body(Body::empty()).unwrap())
7445                .await
7446                .unwrap();
7447            assert_eq!(response.status(), StatusCode::OK, "{path} is not served");
7448            assert_eq!(
7449                response.headers().get(CONTENT_TYPE).unwrap(),
7450                content_type,
7451                "{path} is served as the wrong type"
7452            );
7453        }
7454    }
7455
7456    /// Fetch one unauthenticated asset and return it as text. Serving the
7457    /// application from several files means a check about the application has
7458    /// to name the file it is about.
7459    async fn fetch_text(app: Router, path: &str) -> String {
7460        let response = app
7461            .oneshot(Request::get(path).body(Body::empty()).unwrap())
7462            .await
7463            .unwrap();
7464        assert_eq!(response.status(), StatusCode::OK, "{path} is not served");
7465        let body = response.into_body().collect().await.unwrap().to_bytes();
7466        String::from_utf8(body.to_vec()).expect("assets are UTF-8")
7467    }
7468
7469    #[tokio::test]
7470    async fn repeated_wrong_codes_lock_the_login_endpoint() {
7471        let (app, _, _, _, _) = app();
7472        let attempt = |code: &'static str| {
7473            let app = app.clone();
7474            async move {
7475                app.oneshot(
7476                    Request::post("/auth/session")
7477                        .header(CONTENT_TYPE, "application/json")
7478                        .body(Body::from(format!(r#"{{"code":"{code}"}}"#)))
7479                        .unwrap(),
7480                )
7481                .await
7482                .unwrap()
7483                .status()
7484            }
7485        };
7486        for _ in 0..MAX_CODE_FAILURES {
7487            assert_eq!(attempt("000000").await, StatusCode::UNAUTHORIZED);
7488        }
7489        assert_eq!(attempt("000000").await, StatusCode::TOO_MANY_REQUESTS);
7490        // Even the right code waits out the lockout, so guessing cannot be
7491        // hidden behind a correct-looking attempt.
7492        assert_eq!(attempt("123456").await, StatusCode::TOO_MANY_REQUESTS);
7493    }
7494
7495    #[test]
7496    fn viewer_code_lockouts_lengthen_instead_of_resetting_after_every_wait() {
7497        let serve_one_lockout = |guard: &mut CodeGuard, now: Instant| {
7498            for _ in 0..MAX_CODE_FAILURES {
7499                assert!(!guard.locked_at(now));
7500                guard.record_failure_at(now);
7501            }
7502            assert!(guard.locked_at(now));
7503            guard.locked_until.expect("the guard is locked") - now
7504        };
7505
7506        let start = Instant::now();
7507        let mut guard = CodeGuard::default();
7508        let first = serve_one_lockout(&mut guard, start);
7509        assert_eq!(first, CODE_LOCKOUT_BASE);
7510
7511        // Waiting out a lockout buys another run of attempts, not another
7512        // equally short lockout: a guard that reset here gave an attacker
7513        // MAX_CODE_FAILURES guesses every CODE_LOCKOUT_BASE for ever.
7514        let second_round = start + first;
7515        let second = serve_one_lockout(&mut guard, second_round);
7516        assert_eq!(second, CODE_LOCKOUT_BASE * 2);
7517        let third = serve_one_lockout(&mut guard, second_round + second);
7518        assert_eq!(third, CODE_LOCKOUT_BASE * 4);
7519        assert_eq!(code_lockout(u32::MAX), CODE_LOCKOUT_CAP);
7520
7521        // A correct code clears the history, so one mistyped digit tomorrow
7522        // still costs only the shortest wait.
7523        let mut recovered = CodeGuard::default();
7524        assert_eq!(serve_one_lockout(&mut recovered, start), CODE_LOCKOUT_BASE);
7525    }
7526
7527    #[test]
7528    fn persisted_cookie_key_survives_a_restart_and_stays_owner_only() {
7529        let directory = tempfile::tempdir().unwrap();
7530        let path = directory.path().join("phone-cookie-key");
7531
7532        let first = load_or_create_cookie_key(&path).unwrap();
7533        assert!(first.len() >= COOKIE_KEY_BYTES);
7534        assert_eq!(std::fs::read(&path).unwrap(), first);
7535        #[cfg(unix)]
7536        {
7537            use std::os::unix::fs::PermissionsExt as _;
7538            let mode = std::fs::metadata(&path).unwrap().permissions().mode();
7539            assert_eq!(mode & 0o777, 0o600);
7540        }
7541
7542        // Two server processes started from the same key file honour each
7543        // other's cookies; a process that kept its generated key would not.
7544        let mut restarted = detached_options();
7545        restarted
7546            .set_cookie_key(load_or_create_cookie_key(&path).unwrap())
7547            .unwrap();
7548        let mut original = detached_options();
7549        original.set_cookie_key(first.clone()).unwrap();
7550        let cookie = signed_cookie_value(&original.cookie_key, "test-viewer", 200);
7551        assert!(session_cookie_valid(&restarted.cookie_key, &cookie, 100));
7552        assert!(!session_cookie_valid(
7553            &detached_options().cookie_key,
7554            &cookie,
7555            100
7556        ));
7557
7558        // Deleting the key file is the explicit sign-everyone-out gesture.
7559        std::fs::remove_file(&path).unwrap();
7560        let rotated = load_or_create_cookie_key(&path).unwrap();
7561        assert_ne!(rotated, first);
7562        assert!(!session_cookie_valid(&rotated, &cookie, 100));
7563    }
7564
7565    #[test]
7566    fn corrupt_cookie_key_is_regenerated_instead_of_blocking_startup() {
7567        let directory = tempfile::tempdir().unwrap();
7568        let path = directory.path().join("phone-cookie-key");
7569        std::fs::write(&path, b"short").unwrap();
7570
7571        let key = load_or_create_cookie_key(&path).unwrap();
7572
7573        assert!(key.len() >= COOKIE_KEY_BYTES);
7574        assert_eq!(std::fs::read(&path).unwrap(), key);
7575        assert_eq!(load_or_create_cookie_key(&path).unwrap(), key);
7576    }
7577}