Skip to main content

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