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