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            spinner: Default::default(),
3250            phone: Default::default(),
3251            review: Default::default(),
3252            startup: Default::default(),
3253            profiles: BTreeMap::from([(
3254                "codex-1".into(),
3255                HarnessProfile {
3256                    context_window_bytes: None,
3257                    kind: HarnessKind::Codex,
3258                    home: "/highly/secret/codex".into(),
3259                    environment: BTreeMap::from([("GH_TOKEN".into(), "secret-token".into())]),
3260                },
3261            )]),
3262            bundles: BTreeMap::from([(
3263                "hel".into(),
3264                ProjectBundle {
3265                    primary_repo: "hel".into(),
3266                    repositories: vec![ProjectRepository {
3267                        id: "hel".into(),
3268                        github: Some("owner/hel".into()),
3269                        local: Some("/private/source/hel".into()),
3270                        destination: "hel".into(),
3271                        git_ref: None,
3272                    }],
3273                },
3274            )]),
3275            targets: BTreeMap::from([
3276                (
3277                    "podman".into(),
3278                    TargetTemplate::LocalPodman {
3279                        container: ContainerTemplate {
3280                            image: "secret.registry/image".into(),
3281                            pull_policy: Default::default(),
3282                            platform: None,
3283                            cpus: None,
3284                            memory: None,
3285                            environment: BTreeMap::from([("TOKEN".into(), "secret-target".into())]),
3286                            workspace_storage: Default::default(),
3287                        },
3288                    },
3289                ),
3290                ("raw".into(), TargetTemplate::LocalBare),
3291            ]),
3292        };
3293        let state = HelState {
3294            version: STATE_VERSION,
3295            sessions: BTreeMap::from([(
3296                "session-1".into(),
3297                SessionRecord {
3298                    workspace_id: hel::hel_workspace::DEFAULT_WORKSPACE_ID.to_owned(),
3299                    archived: false,
3300                    container_cpus: None,
3301                    container_memory: None,
3302                    id: "session-1".into(),
3303                    title: "Build Hel".into(),
3304                    harness_kind: HarnessKind::Codex,
3305                    last_profile: "codex-1".into(),
3306                    bundle_id: "hel".into(),
3307                    project_directory: None,
3308                    managed_worktree: None,
3309                    target_template_id: "podman".into(),
3310                    resource_allocation: None,
3311                    additional_mounts: vec![],
3312                    state: SessionState::Running,
3313                    target: None,
3314                    native_session_id: Some("native-secret-id".into()),
3315                    acp_session_title: Some("Build Hel".into()),
3316                    session_title_override: None,
3317                    created_at: "now".into(),
3318                    updated_at: "now".into(),
3319                    viewed_through_event_ordinal: 0,
3320                    draft_input: String::new(),
3321                    last_error: Some("secret-token at /highly/secret/codex".into()),
3322                    last_checkpoint_error: None,
3323                    checkpoint: None,
3324                },
3325            )]),
3326            mount_history: BTreeMap::new(),
3327            container_sizes: BTreeMap::new(),
3328        };
3329        (config, state)
3330    }
3331
3332    type TestServer = (
3333        Router,
3334        mpsc::Receiver<ControllerRequest>,
3335        mpsc::Receiver<ReadReceiptRequest>,
3336        mpsc::Receiver<PreflightRequest>,
3337        mpsc::Receiver<ClientStateRequest>,
3338    );
3339
3340    fn app() -> TestServer {
3341        app_with_conversations(BTreeMap::new())
3342    }
3343
3344    fn app_with_move_receiver() -> (Router, mpsc::Receiver<MovePreparationRequest>) {
3345        let (config, state) = sample_config_state();
3346        let mut snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
3347        snapshot.sessions[0].capabilities.move_session = true;
3348        let (_snapshot_tx, snapshot_rx) = watch::channel(snapshot);
3349        let (_conversation_tx, conversation_rx) = watch::channel(BTreeMap::new());
3350        let (action_tx, _action_rx) = mpsc::channel(8);
3351        let (bundle_tx, _bundle_rx) = mpsc::channel(8);
3352        let (receipt_tx, _receipt_rx) = mpsc::channel(8);
3353        let (preflight_tx, _preflight_rx) = mpsc::channel(8);
3354        let (move_preparation_tx, move_preparation_rx) = mpsc::channel(8);
3355        let (client_state_tx, _client_state_rx) = mpsc::channel(8);
3356        let options = test_options(
3357            snapshot_rx,
3358            conversation_rx,
3359            action_tx,
3360            bundle_tx,
3361            receipt_tx,
3362            preflight_tx,
3363            move_preparation_tx,
3364            client_state_tx,
3365        )
3366        .with_test_credentials("123456", b"01234567890123456789012345678901");
3367        (router(options), move_preparation_rx)
3368    }
3369
3370    fn app_with_conversations(conversations: BTreeMap<String, BrowserTranscript>) -> TestServer {
3371        app_with(conversations, |_| {})
3372    }
3373
3374    fn app_with_snapshot(adjust: impl FnOnce(&mut ViewerSnapshot)) -> TestServer {
3375        app_with(BTreeMap::new(), adjust)
3376    }
3377
3378    fn app_with(
3379        conversations: BTreeMap<String, BrowserTranscript>,
3380        adjust: impl FnOnce(&mut ViewerSnapshot),
3381    ) -> TestServer {
3382        let (config, state) = sample_config_state();
3383        let mut snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
3384        adjust(&mut snapshot);
3385        let (_snapshot_tx, snapshot_rx) = watch::channel(snapshot);
3386        let (_conversation_tx, conversation_rx) = watch::channel(conversations);
3387        let (action_tx, action_rx) = mpsc::channel(8);
3388        let (bundle_tx, _bundle_rx) = mpsc::channel(8);
3389        let (receipt_tx, receipt_rx) = mpsc::channel(8);
3390        let (preflight_tx, preflight_rx) = mpsc::channel(8);
3391        let (move_preparation_tx, _move_preparation_rx) = mpsc::channel(8);
3392        let (client_state_tx, client_state_rx) = mpsc::channel(8);
3393        let options = test_options(
3394            snapshot_rx,
3395            conversation_rx,
3396            action_tx,
3397            bundle_tx,
3398            receipt_tx,
3399            preflight_tx,
3400            move_preparation_tx,
3401            client_state_tx,
3402        )
3403        .with_test_credentials("123456", b"01234567890123456789012345678901");
3404        (
3405            router(options),
3406            action_rx,
3407            receipt_rx,
3408            preflight_rx,
3409            client_state_rx,
3410        )
3411    }
3412
3413    fn app_with_bundle_receiver() -> (Router, mpsc::Receiver<BundleRequest>) {
3414        let (config, state) = sample_config_state();
3415        let (_snapshot_tx, snapshot_rx) =
3416            watch::channel(ViewerSnapshot::from_config_state(&config, &state, 1));
3417        let (_conversation_tx, conversation_rx) = watch::channel(BTreeMap::new());
3418        let (action_tx, _action_rx) = mpsc::channel(8);
3419        let (bundle_tx, bundle_rx) = mpsc::channel(8);
3420        let (receipt_tx, _receipt_rx) = mpsc::channel(8);
3421        let (preflight_tx, _preflight_rx) = mpsc::channel(8);
3422        let (move_preparation_tx, _move_preparation_rx) = mpsc::channel(8);
3423        let (client_state_tx, _client_state_rx) = mpsc::channel(8);
3424        let options = test_options(
3425            snapshot_rx,
3426            conversation_rx,
3427            action_tx,
3428            bundle_tx,
3429            receipt_tx,
3430            preflight_tx,
3431            move_preparation_tx,
3432            client_state_tx,
3433        )
3434        .with_test_credentials("123456", b"01234567890123456789012345678901");
3435        (router(options), bundle_rx)
3436    }
3437
3438    // Keep this test factory's arguments aligned with `ServerRequests`; each
3439    // channel is asserted independently by the HTTP behavior tests below.
3440    #[allow(clippy::too_many_arguments)]
3441    fn test_options(
3442        snapshot_rx: watch::Receiver<ViewerSnapshot>,
3443        conversation_rx: watch::Receiver<BTreeMap<String, BrowserTranscript>>,
3444        action_tx: mpsc::Sender<ControllerRequest>,
3445        bundle_tx: mpsc::Sender<BundleRequest>,
3446        receipt_tx: mpsc::Sender<ReadReceiptRequest>,
3447        preflight_tx: mpsc::Sender<PreflightRequest>,
3448        move_preparation_tx: mpsc::Sender<MovePreparationRequest>,
3449        client_state_tx: mpsc::Sender<ClientStateRequest>,
3450    ) -> ServerOptions {
3451        ServerOptions::new(
3452            "127.0.0.1:0".parse().unwrap(),
3453            snapshot_rx,
3454            conversation_rx,
3455            ServerRequests {
3456                action_tx,
3457                bundle_tx,
3458                receipt_tx,
3459                preflight_tx,
3460                move_preparation_tx,
3461                client_state_tx,
3462            },
3463        )
3464        .unwrap()
3465    }
3466
3467    fn detached_options() -> ServerOptions {
3468        let (config, state) = sample_config_state();
3469        let (_snapshot_tx, snapshot_rx) =
3470            watch::channel(ViewerSnapshot::from_config_state(&config, &state, 1));
3471        let (_conversation_tx, conversation_rx) = watch::channel(BTreeMap::new());
3472        let (action_tx, _action_rx) = mpsc::channel(1);
3473        let (bundle_tx, _bundle_rx) = mpsc::channel(1);
3474        let (receipt_tx, _receipt_rx) = mpsc::channel(1);
3475        let (preflight_tx, _preflight_rx) = mpsc::channel(1);
3476        let (move_preparation_tx, _move_preparation_rx) = mpsc::channel(1);
3477        let (client_state_tx, _client_state_rx) = mpsc::channel(1);
3478        test_options(
3479            snapshot_rx,
3480            conversation_rx,
3481            action_tx,
3482            bundle_tx,
3483            receipt_tx,
3484            preflight_tx,
3485            move_preparation_tx,
3486            client_state_tx,
3487        )
3488    }
3489
3490    /// A valid session cookie for the test server's key.
3491    ///
3492    /// Most checks are about what an authenticated request does rather than
3493    /// about how it authenticated, and going through the login route for each
3494    /// one buys nothing.
3495    fn cookie() -> String {
3496        format!(
3497            "{COOKIE_NAME}={}",
3498            signed_cookie_value(
3499                b"01234567890123456789012345678901",
3500                "test-viewer",
3501                now_unix().saturating_add(3600)
3502            )
3503        )
3504    }
3505
3506    async fn login_cookie(app: &Router) -> String {
3507        let response = app
3508            .clone()
3509            .oneshot(
3510                Request::post("/auth/session")
3511                    .header(CONTENT_TYPE, "application/json")
3512                    .body(Body::from(r#"{"code":"123456"}"#))
3513                    .unwrap(),
3514            )
3515            .await
3516            .unwrap();
3517        assert_eq!(response.status(), StatusCode::NO_CONTENT);
3518        response
3519            .headers()
3520            .get(SET_COOKIE)
3521            .unwrap()
3522            .to_str()
3523            .unwrap()
3524            .split(';')
3525            .next()
3526            .unwrap()
3527            .to_string()
3528    }
3529
3530    #[tokio::test]
3531    async fn bundle_endpoint_authenticates_and_forwards_the_source() {
3532        let (app, mut bundles) = app_with_bundle_receiver();
3533        let unauthorized = app
3534            .clone()
3535            .oneshot(
3536                Request::post("/api/bundles")
3537                    .header(CONTENT_TYPE, "application/json")
3538                    .body(Body::from(r#"{"source":"example/app"}"#))
3539                    .unwrap(),
3540            )
3541            .await
3542            .unwrap();
3543        assert_eq!(unauthorized.status(), StatusCode::UNAUTHORIZED);
3544        assert!(bundles.try_recv().is_err());
3545
3546        let cookie = login_cookie(&app).await;
3547        let response = tokio::spawn({
3548            let app = app.clone();
3549            let cookie = cookie.clone();
3550            async move {
3551                app.oneshot(
3552                    Request::post("/api/bundles")
3553                        .header(CONTENT_TYPE, "application/json")
3554                        .header(COOKIE, cookie)
3555                        .body(Body::from(r#"{"source":"example/app"}"#))
3556                        .unwrap(),
3557                )
3558                .await
3559                .unwrap()
3560            }
3561        });
3562        let request = bundles.recv().await.expect("bundle request forwarded");
3563        assert_eq!(request.source, "example/app");
3564        request.reply.send(Ok("app".into())).unwrap();
3565        let response = response.await.unwrap();
3566        assert_eq!(response.status(), StatusCode::OK);
3567        let body = response.into_body().collect().await.unwrap().to_bytes();
3568        assert_eq!(body.as_ref(), br#"{"bundle_id":"app"}"#);
3569    }
3570
3571    #[tokio::test]
3572    async fn bundle_endpoint_rejects_empty_and_oversized_sources_before_dispatch() {
3573        for source in [String::new(), "x".repeat(MAX_BUNDLE_SOURCE_CHARS + 1)] {
3574            let (app, mut bundles) = app_with_bundle_receiver();
3575            let cookie = login_cookie(&app).await;
3576            let response = app
3577                .oneshot(
3578                    Request::post("/api/bundles")
3579                        .header(CONTENT_TYPE, "application/json")
3580                        .header(COOKIE, cookie)
3581                        .body(Body::from(
3582                            serde_json::to_string(&serde_json::json!({"source": source})).unwrap(),
3583                        ))
3584                        .unwrap(),
3585                )
3586                .await
3587                .unwrap();
3588            assert_eq!(response.status(), StatusCode::BAD_REQUEST);
3589            assert!(bundles.try_recv().is_err());
3590        }
3591    }
3592
3593    #[tokio::test]
3594    async fn bundle_endpoint_reports_invalid_source_as_a_client_error() {
3595        let (app, mut bundles) = app_with_bundle_receiver();
3596        let cookie = login_cookie(&app).await;
3597        let response = tokio::spawn({
3598            let app = app.clone();
3599            async move {
3600                app.oneshot(
3601                    Request::post("/api/bundles")
3602                        .header(CONTENT_TYPE, "application/json")
3603                        .header(COOKIE, cookie)
3604                        .body(Body::from(r#"{"source":"not a source"}"#))
3605                        .unwrap(),
3606                )
3607                .await
3608                .unwrap()
3609            }
3610        });
3611        let request = bundles.recv().await.expect("bundle request forwarded");
3612        request
3613            .reply
3614            .send(Err(BundleFailure::InvalidSource))
3615            .unwrap();
3616        let response = response.await.unwrap();
3617        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
3618        let body = response.into_body().collect().await.unwrap().to_bytes();
3619        assert!(String::from_utf8_lossy(&body).contains("GitHub owner/repository"));
3620    }
3621
3622    #[tokio::test]
3623    async fn api_requires_a_valid_signed_cookie() {
3624        let (app, _, _, _, _) = app();
3625        let unauthorized = app
3626            .clone()
3627            .oneshot(Request::get("/api/snapshot").body(Body::empty()).unwrap())
3628            .await
3629            .unwrap();
3630        assert_eq!(unauthorized.status(), StatusCode::UNAUTHORIZED);
3631
3632        let cookie = login_cookie(&app).await;
3633        let authorized = app
3634            .oneshot(
3635                Request::get("/api/snapshot")
3636                    .header(COOKIE, cookie)
3637                    .body(Body::empty())
3638                    .unwrap(),
3639            )
3640            .await
3641            .unwrap();
3642        assert_eq!(authorized.status(), StatusCode::OK);
3643    }
3644
3645    #[tokio::test]
3646    async fn qr_login_exchanges_the_secret_for_a_cookie_and_redirects_cleanly() {
3647        let (app, _, _, _, _) = app();
3648        let rejected = app
3649            .clone()
3650            .oneshot(
3651                Request::get("/auth/login?token=wrong")
3652                    .body(Body::empty())
3653                    .unwrap(),
3654            )
3655            .await
3656            .unwrap();
3657        assert_eq!(rejected.status(), StatusCode::UNAUTHORIZED);
3658
3659        let accepted = app
3660            .oneshot(
3661                Request::get("/auth/login?token=test-login-token")
3662                    .body(Body::empty())
3663                    .unwrap(),
3664            )
3665            .await
3666            .unwrap();
3667        assert_eq!(accepted.status(), StatusCode::SEE_OTHER);
3668        assert_eq!(accepted.headers().get(LOCATION).unwrap(), "/");
3669        assert_eq!(accepted.headers().get(CACHE_CONTROL).unwrap(), "no-store");
3670        assert!(accepted.headers().contains_key(SET_COOKIE));
3671    }
3672
3673    #[test]
3674    fn signed_cookie_rejects_expiry_and_tampering() {
3675        let key = b"01234567890123456789012345678901";
3676        let cookie = signed_cookie_value(key, "test-viewer", 200);
3677        assert!(session_cookie_valid(key, &cookie, 100));
3678        assert!(!session_cookie_valid(key, &cookie, 200));
3679        assert!(!session_cookie_valid(key, &format!("{cookie}x"), 100));
3680        assert!(!session_cookie_valid(b"another-key", &cookie, 100));
3681    }
3682
3683    #[test]
3684    fn generated_code_and_cookie_attributes_are_phone_safe() {
3685        let code = generate_viewer_code().unwrap();
3686        assert_eq!(code.len(), 6);
3687        assert!(code.bytes().all(|byte| byte.is_ascii_digit()));
3688        let header = session_cookie_header("signed", Some(60), true)
3689            .unwrap()
3690            .to_str()
3691            .unwrap()
3692            .to_string();
3693        assert!(header.contains("HttpOnly"));
3694        assert!(header.contains("SameSite=Strict"));
3695        assert!(header.contains("Secure"));
3696        assert!(header.contains("Max-Age=60"));
3697    }
3698
3699    #[test]
3700    fn public_snapshot_omits_homes_environment_locators_and_raw_errors() {
3701        let (config, state) = sample_config_state();
3702        let json =
3703            serde_json::to_string(&ViewerSnapshot::from_config_state(&config, &state, 9)).unwrap();
3704        assert!(!json.contains("/highly/secret"));
3705        assert!(!json.contains("secret-token"));
3706        assert!(!json.contains("secret-target"));
3707        assert!(!json.contains("secret.registry"));
3708        assert!(!json.contains("native-secret-id"));
3709        assert!(json.contains("\"has_error\":true"));
3710    }
3711
3712    #[test]
3713    fn target_snapshot_uses_each_raw_host_project_history_and_leaves_managed_empty() {
3714        let (mut config, mut state) = sample_config_state();
3715        config
3716            .targets
3717            .insert("raw-local".into(), TargetTemplate::LocalBare);
3718        config.targets.insert(
3719            "raw-builder".into(),
3720            TargetTemplate::SshBare {
3721                ssh: SshConnection {
3722                    host: "builder-a".into(),
3723                    user: None,
3724                    identity_file: None,
3725                    extra_args: Vec::new(),
3726                },
3727                permissions: PermissionMode::Guardian,
3728                workspace_prefix: "workspaces".into(),
3729            },
3730        );
3731        state.remember_project_directory("local", Path::new("/work/local"));
3732        state.remember_project_directory("builder-a", Path::new("/srv/builder"));
3733        state.remember_project_directory("other-host", Path::new("/not-published"));
3734
3735        let snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
3736        let target = |id: &str| {
3737            snapshot
3738                .targets
3739                .iter()
3740                .find(|target| target.id == id)
3741                .unwrap()
3742        };
3743        assert_eq!(
3744            target("raw-local").recent_project_directories,
3745            vec!["/work/local"]
3746        );
3747        assert_eq!(
3748            target("raw-builder").recent_project_directories,
3749            vec!["/srv/builder"]
3750        );
3751        assert!(target("podman").recent_project_directories.is_empty());
3752    }
3753
3754    #[test]
3755    fn public_snapshot_exposes_only_review_status_configuration() {
3756        let (mut config, state) = sample_config_state();
3757        config.review = hel::hel_config::ReviewConfig {
3758            enabled: true,
3759            tier: hel::hel_review::lanes::ReviewTier::Extended,
3760            profile: Some("reviewer-1".into()),
3761            model: Some("private-review-model".into()),
3762            effort: Some("private-review-effort".into()),
3763        };
3764
3765        let value =
3766            serde_json::to_value(ViewerSnapshot::from_config_state(&config, &state, 9)).unwrap();
3767
3768        assert_eq!(
3769            value.get("review_config"),
3770            Some(&serde_json::json!({
3771                "enabled": true,
3772                "tier": "extended",
3773                "profile": "reviewer-1",
3774            }))
3775        );
3776        let json = value.to_string();
3777        assert!(!json.contains("private-review-model"));
3778        assert!(!json.contains("private-review-effort"));
3779    }
3780
3781    fn sample_elicitation() -> ElicitationRequest {
3782        ElicitationRequest::from_acp_params(
3783            "elicitation-1",
3784            serde_json::json!({
3785                "sessionId": "session-1",
3786                "mode": "form",
3787                "message": "Which CI architecture should the workflow use?",
3788                "requestedSchema": {
3789                    "type": "object",
3790                    "required": ["question_0"],
3791                    "properties": {
3792                        "question_0": {
3793                            "type": "string",
3794                            "title": "CI architecture",
3795                            "oneOf": [
3796                                {"const": "reusable", "title": "Reusable workflow"},
3797                                {"const": "matrix", "title": "Matrix job"}
3798                            ]
3799                        },
3800                        "question_0_custom": {
3801                            "type": "string",
3802                            "title": "Other",
3803                            "_meta": {"_askUserQuestionCustomAnswer": {
3804                                "questionId": "question_0",
3805                                "isCustomAnswer": true
3806                            }}
3807                        }
3808                    }
3809                }
3810            }),
3811        )
3812        .expect("sample elicitation parses")
3813    }
3814
3815    fn accept(pairs: &[(&str, &str)]) -> ElicitationResponse {
3816        ElicitationResponse::Accept {
3817            content: pairs
3818                .iter()
3819                .map(|(id, value)| {
3820                    (
3821                        (*id).to_owned(),
3822                        hel::hel_elicitation::ElicitationValue::String((*value).to_owned()),
3823                    )
3824                })
3825                .collect(),
3826        }
3827    }
3828
3829    fn pending_elicitation_snapshot(snapshot: &mut ViewerSnapshot) {
3830        snapshot.sessions[0].pending_elicitations = vec![sample_elicitation()];
3831    }
3832
3833    #[tokio::test]
3834    async fn elicitation_answer_is_typed_and_forwarded() {
3835        let (app, mut actions, _, _, _) = app_with_snapshot(pending_elicitation_snapshot);
3836        let cookie = login_cookie(&app).await;
3837        let response = tokio::spawn(
3838            app.oneshot(
3839                Request::post("/api/actions")
3840                    .header(COOKIE, cookie)
3841                    .header(CONTENT_TYPE, "application/json")
3842                    .body(Body::from(
3843                        r#"{"action":"respond-elicitation","session_id":"session-1","elicitation_id":"elicitation-1","response":{"action":"accept","content":{"question_0":"reusable"}}}"#,
3844                    ))
3845                    .unwrap(),
3846            ),
3847        );
3848        let action = actions.recv().await.unwrap();
3849        assert_eq!(
3850            action.action,
3851            ControllerAction::RespondElicitation {
3852                session_id: "session-1".into(),
3853                elicitation_id: "elicitation-1".into(),
3854                response: accept(&[("question_0", "reusable")]),
3855            }
3856        );
3857        action.reply.send(ActionOutcome::Accepted).unwrap();
3858        assert_eq!(
3859            response.await.unwrap().unwrap().status(),
3860            StatusCode::ACCEPTED
3861        );
3862    }
3863
3864    #[tokio::test]
3865    async fn elicitation_answer_for_an_unknown_request_is_refused_without_reaching_the_controller()
3866    {
3867        let (app, mut actions, _, _, _) = app_with_snapshot(pending_elicitation_snapshot);
3868        let cookie = login_cookie(&app).await;
3869        let response = app
3870            .oneshot(
3871                Request::post("/api/actions")
3872                    .header(COOKIE, cookie)
3873                    .header(CONTENT_TYPE, "application/json")
3874                    .body(Body::from(
3875                        r#"{"action":"respond-elicitation","session_id":"session-1","elicitation_id":"elicitation-9","response":{"action":"cancel"}}"#,
3876                    ))
3877                    .unwrap(),
3878            )
3879            .await
3880            .unwrap();
3881        assert_eq!(response.status(), StatusCode::NOT_FOUND);
3882        assert!(actions.try_recv().is_err());
3883    }
3884
3885    #[test]
3886    fn elicitation_answers_are_checked_against_the_request_the_agent_asked() {
3887        let (config, state) = sample_config_state();
3888        let mut snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
3889        pending_elicitation_snapshot(&mut snapshot);
3890        let respond = |response: ElicitationResponse| ControllerAction::RespondElicitation {
3891            session_id: "session-1".into(),
3892            elicitation_id: "elicitation-1".into(),
3893            response,
3894        };
3895
3896        assert!(validate_action(&respond(accept(&[("question_0", "matrix")])), &snapshot).is_ok());
3897        // Declining and cancelling never carry content, so they are always
3898        // answerable.
3899        assert!(validate_action(&respond(ElicitationResponse::Decline), &snapshot).is_ok());
3900        // An option the agent never offered, a field it never published, and a
3901        // missing required answer are all refused.
3902        assert!(validate_action(&respond(accept(&[("question_0", "cron")])), &snapshot).is_err());
3903        assert!(validate_action(&respond(accept(&[("smuggled", "yes")])), &snapshot).is_err());
3904        assert!(validate_action(&respond(accept(&[])), &snapshot).is_err());
3905        // A custom answer stands in for the select it belongs to, exactly as
3906        // the chat form submits it.
3907        assert!(
3908            validate_action(
3909                &respond(accept(&[("question_0_custom", "a monorepo pipeline")])),
3910                &snapshot,
3911            )
3912            .is_ok()
3913        );
3914    }
3915
3916    #[test]
3917    fn oversized_elicitation_answers_are_refused() {
3918        let (config, state) = sample_config_state();
3919        let mut snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
3920        pending_elicitation_snapshot(&mut snapshot);
3921        let long = "x".repeat(MAX_ELICITATION_BYTES);
3922        assert!(
3923            validate_action(
3924                &ControllerAction::RespondElicitation {
3925                    session_id: "session-1".into(),
3926                    elicitation_id: "elicitation-1".into(),
3927                    response: accept(&[("question_0_custom", long.as_str())]),
3928                },
3929                &snapshot,
3930            )
3931            .is_err()
3932        );
3933    }
3934
3935    /// One slice of the browser application, named by the two markers that
3936    /// bracket it in `src/web/viewer.js`.
3937    ///
3938    /// Slicing keeps each check to the functions it is about, so an unrelated
3939    /// change elsewhere in the application cannot make it fail for the wrong
3940    /// reason. The markers are ordinary source text, so a rename that moves
3941    /// them fails loudly here rather than silently testing nothing.
3942    fn viewer_source(from: &str, to: &str) -> &'static str {
3943        let start = VIEWER_JS
3944            .find(from)
3945            .unwrap_or_else(|| panic!("src/web/viewer.js no longer contains {from:?}"));
3946        let end = VIEWER_JS[start..]
3947            .find(to)
3948            .map(|offset| start + offset)
3949            .unwrap_or_else(|| {
3950                panic!("src/web/viewer.js no longer contains {to:?} after {from:?}")
3951            });
3952        &VIEWER_JS[start..end]
3953    }
3954
3955    /// Run one JavaScript check under Node.
3956    ///
3957    /// The check and the modules it imports are written to a real directory
3958    /// rather than passed to `--eval`, so a failure reports a line number a
3959    /// person can open, and so a check can import the shipped module under
3960    /// test by its real name instead of against a copy pasted into a string.
3961    fn run_web_check(name: &str, check: &str) {
3962        let directory = tempfile::tempdir().expect("temporary directory for a web check");
3963        for (file, source) in [
3964            ("test-dom.js", TEST_DOM_JS),
3965            ("markdown.js", MARKDOWN_JS),
3966            ("tool-output.js", TOOL_OUTPUT_JS),
3967        ] {
3968            std::fs::write(directory.path().join(file), source).expect("write a web module");
3969        }
3970        let path = directory.path().join(format!("{name}.mjs"));
3971        std::fs::write(&path, check).expect("write the web check");
3972        let output = std::process::Command::new("node")
3973            .arg(&path)
3974            .output()
3975            .expect("Node.js is required to exercise the web viewer");
3976        assert!(
3977            output.status.success(),
3978            "{name} failed:\nstdout:\n{}\nstderr:\n{}",
3979            String::from_utf8_lossy(&output.stdout),
3980            String::from_utf8_lossy(&output.stderr),
3981        );
3982    }
3983
3984    /// Run one JavaScript check that supplies its own environment, for the
3985    /// checks that slice a function out of `viewer.js` and drive it against a
3986    /// hand-written stub rather than importing a module.
3987    fn run_viewer_script(name: &str, script: &str) {
3988        run_web_check(name, script);
3989    }
3990
3991    #[test]
3992    fn embedded_viewer_lists_current_workspace_histories_and_retained_move_recovery() {
3993        let source = viewer_source("function isResumeSession(", "const resumeDrafts =");
3994        let setup = r#"
3995const snapshot = {
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-a", workspace_id: "workspace-a", lifecycle: "live", has_error: true, capabilities: { resume: false, open: false } },
4000    { id: "move-a", workspace_id: "workspace-a", capabilities: { resume: false }, move_recovery: { checkpoint_retained: true, phase: "failed" } },
4001    { id: "moving-a", workspace_id: "workspace-a", capabilities: { resume: false }, move_recovery: { checkpoint_retained: true, phase: "starting_queue" } },
4002  ],
4003};
4004function selectedWorkspaceId() { return "workspace-a"; }
4005function sessionActivityMs() { return 0; }
4006function epochMs() { return null; }
4007"#;
4008        let checks = r#"
4009const ids = workspace => resumeSessions(workspace).map(session => session.id).sort();
4010if (JSON.stringify(ids("workspace-a")) !== JSON.stringify(["history-a", "move-a"])) {
4011  throw new Error(`workspace A histories or recoveries were wrong: ${JSON.stringify(ids("workspace-a"))}`);
4012}
4013if (JSON.stringify(ids("workspace-b")) !== JSON.stringify(["history-b"])) {
4014  throw new Error(`workspace B histories were wrong: ${JSON.stringify(ids("workspace-b"))}`);
4015}
4016if (ids("missing-workspace").length !== 0) throw new Error("unknown workspace exposed sessions");
4017"#;
4018        run_viewer_script(
4019            "workspace-resume-history",
4020            &format!("{setup}\n{source}\n{checks}"),
4021        );
4022    }
4023
4024    #[test]
4025    fn embedded_viewer_sends_the_selected_resume_workspace() {
4026        let source = viewer_source("async function runSessionAction", "sessions.onclick =");
4027        let setup = r#"
4028const pendingActions = new Set();
4029const snapshot = { sessions: [] };
4030let sent = null;
4031function selectedWorkspaceId() { return "workspace-b"; }
4032function navigate() {}
4033function renderRoute() {}
4034async function refresh() {}
4035async function request(path, options) {
4036  sent = { path, body: JSON.parse(options.body) };
4037}
4038"#;
4039        let checks = r#"
4040const errorNode = { textContent: "" };
4041await runSessionAction(
4042  { action: "resume", id: "history-a", profile: "codex-1", target: "podman" },
4043  errorNode,
4044  { queue: "start" },
4045);
4046if (sent.path !== "/api/actions" || sent.body.workspace_id !== "workspace-b") {
4047  throw new Error(`resume did not carry its destination: ${JSON.stringify(sent)}`);
4048}
4049"#;
4050        run_viewer_script(
4051            "resume-workspace-destination",
4052            &format!("{setup}\n{source}\n{checks}"),
4053        );
4054    }
4055
4056    #[test]
4057    fn embedded_viewer_warns_before_stopping_an_active_session() {
4058        let source = viewer_source("async function runSessionAction", "sessions.onclick =");
4059        let setup = r#"
4060const pendingActions = new Set();
4061const snapshot = {
4062  sessions: [
4063    { id: "active", chat_phase: "running" },
4064    { id: "idle", chat_phase: "idle" },
4065  ],
4066};
4067const questions = [];
4068function confirm(question) { questions.push(question); return false; }
4069function navigate() {}
4070"#;
4071        let checks = r#"
4072const errorNode = { textContent: "" };
4073await runSessionAction({ action: "close", id: "active" }, errorNode);
4074await runSessionAction({ action: "close", id: "idle" }, errorNode);
4075if (!questions[0].startsWith("Stop active session?\n\n")) {
4076  throw new Error(`active close warning was ${JSON.stringify(questions[0])}`);
4077}
4078if (!questions[0].includes("current turn will be interrupted")) {
4079  throw new Error(`active close omitted interruption: ${JSON.stringify(questions[0])}`);
4080}
4081if (!questions[1].startsWith("Stop session?\n\n")) {
4082  throw new Error(`idle close warning was ${JSON.stringify(questions[1])}`);
4083}
4084"#;
4085        run_viewer_script(
4086            "active-session-stop-confirmation",
4087            &format!("{setup}\n{source}\n{checks}"),
4088        );
4089    }
4090
4091    /// The projection publishes what the browser needs to group and filter
4092    /// without publishing what the redaction contract keeps back. A project
4093    /// key groups two sessions in one project together and says nothing about
4094    /// where that project lives.
4095    #[test]
4096    fn the_project_key_groups_without_naming_a_path() {
4097        let (config, mut state) = sample_config_state();
4098        let first = state.sessions["session-1"].clone();
4099        let mut second = first.clone();
4100        second.id = "session-2".into();
4101        state.sessions.insert(second.id.clone(), second);
4102        let snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
4103
4104        let keys = snapshot
4105            .sessions
4106            .iter()
4107            .map(|session| session.project_key.as_str())
4108            .collect::<std::collections::BTreeSet<_>>();
4109        assert_eq!(keys.len(), 1, "two sessions in one project did not group");
4110        let key = keys.into_iter().next().expect("one key");
4111        assert!(!key.is_empty(), "the project key is empty");
4112        assert!(
4113            !key.contains('/') && !key.contains("hel"),
4114            "the project key leaks its identity: {key}"
4115        );
4116        assert_eq!(
4117            snapshot.sessions[0].project_label, "hel",
4118            "the project label should be a name a person recognises"
4119        );
4120    }
4121
4122    #[test]
4123    fn web_project_keys_follow_the_complete_repository_set() {
4124        let (mut config, mut state) = sample_config_state();
4125        let shared_bundle = config.bundles["hel"].clone();
4126        config.bundles.insert("other".into(), shared_bundle);
4127
4128        let mut other = state.sessions["session-1"].clone();
4129        other.id = "session-2".into();
4130        other.bundle_id = "other".into();
4131        state.sessions.insert(other.id.clone(), other);
4132
4133        assert_eq!(
4134            config.bundles["hel"].primary_repo,
4135            config.bundles["other"].primary_repo
4136        );
4137        let snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
4138        let first = snapshot
4139            .sessions
4140            .iter()
4141            .find(|session| session.id == "session-1")
4142            .expect("first session");
4143        let second = snapshot
4144            .sessions
4145            .iter()
4146            .find(|session| session.id == "session-2")
4147            .expect("second session");
4148
4149        assert_eq!(first.project_label, "hel");
4150        assert_eq!(second.project_label, "hel");
4151        assert_eq!(first.project_key, second.project_key);
4152
4153        let secondary = ProjectRepository {
4154            id: "secondary".into(),
4155            github: Some("owner/secondary".into()),
4156            local: None,
4157            destination: "secondary".into(),
4158            git_ref: None,
4159        };
4160        config
4161            .bundles
4162            .get_mut("other")
4163            .unwrap()
4164            .repositories
4165            .push(secondary.clone());
4166        let project_keys = |config: &HelConfig| {
4167            ViewerSnapshot::from_config_state(config, &state, 1)
4168                .sessions
4169                .into_iter()
4170                .map(|session| session.project_key)
4171                .collect::<Vec<_>>()
4172        };
4173        let keys = project_keys(&config);
4174        assert_ne!(
4175            keys[0], keys[1],
4176            "an added repository must change the bundle identity"
4177        );
4178
4179        let first_bundle = config.bundles.get_mut("hel").unwrap();
4180        first_bundle.repositories.insert(0, secondary);
4181        first_bundle.primary_repo = "secondary".into();
4182        let keys = project_keys(&config);
4183        assert_eq!(
4184            keys[0], keys[1],
4185            "the same repository set must group together despite order or primary choice"
4186        );
4187    }
4188
4189    #[test]
4190    fn viewer_session_applies_a_resolved_source_without_publishing_it() {
4191        let (config, state) = sample_config_state();
4192        let mut viewer = ViewerSnapshot::from_config_state(&config, &state, 1)
4193            .sessions
4194            .into_iter()
4195            .next()
4196            .expect("session");
4197        let source = ProjectSourceIdentity::git_remote("git@github.com:BrokkAi/bifrost-dev.git")
4198            .expect("GitHub source");
4199
4200        viewer.set_project_source(&source);
4201
4202        assert_eq!(viewer.project_label, "bifrost-dev");
4203        assert_eq!(viewer.project_key, project_key(&source.key));
4204        let json = serde_json::to_string(&viewer).expect("serialize viewer session");
4205        assert!(!json.contains("BrokkAi"));
4206        assert!(!json.contains("github.com"));
4207    }
4208
4209    /// A phone groups and filters by the lifecycle category, so the mapping
4210    /// from the controller's precise state has to be the controller's own.
4211    #[test]
4212    fn lifecycle_categories_decide_what_the_dashboard_shows() {
4213        use ViewerLifecycleCategory::{Failed, Live, Starting, Stopped, Stopping};
4214
4215        for (state, expected, on_dashboard) in [
4216            (SessionState::Provisioning, Starting, true),
4217            (SessionState::Running, Live, true),
4218            (SessionState::Disconnected, Live, true),
4219            (SessionState::Checkpointing, Live, true),
4220            (SessionState::Closing, Stopping, true),
4221            (SessionState::Destroying, Stopping, true),
4222            (SessionState::Stopped, Stopped, false),
4223            (SessionState::Lost, Failed, false),
4224            (SessionState::Error, Failed, false),
4225            (SessionState::DestroyedWithDataLoss, Failed, false),
4226        ] {
4227            let category = ViewerLifecycleCategory::of(state);
4228            assert_eq!(category, expected, "{state:?}");
4229            assert_eq!(
4230                category.is_dashboard_visible(),
4231                on_dashboard,
4232                "{state:?} belongs on the dashboard? "
4233            );
4234        }
4235    }
4236
4237    /// Resume compatibility travels as the set the browser can offer, so it
4238    /// never has to subtract one list from another and never offers a target
4239    /// the controller would refuse.
4240    #[test]
4241    fn compatible_resume_targets_are_the_complement_of_the_incompatible_ones() {
4242        let (config, state) = sample_config_state();
4243        let snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
4244        let session = &snapshot.sessions[0];
4245        let all = config.targets.keys().cloned().collect::<Vec<_>>();
4246
4247        for target in &all {
4248            assert_ne!(
4249                session.compatible_resume_targets.contains(target),
4250                session.incompatible_resume_targets.contains(target),
4251                "target {target} is in both lists or neither"
4252            );
4253        }
4254        assert_eq!(
4255            session.compatible_resume_targets.len() + session.incompatible_resume_targets.len(),
4256            all.len(),
4257            "the two lists do not cover every target"
4258        );
4259    }
4260
4261    /// The viewer renders a control because a capability says so. An action
4262    /// whose capability is false is refused at the boundary, so a forged
4263    /// request gets the same answer a well-behaved viewer would never ask for.
4264    #[tokio::test]
4265    async fn actions_are_refused_when_their_capability_is_false() {
4266        for (body, capability) in [
4267            (
4268                r#"{"action":"cancel-turn","session_id":"session-1"}"#,
4269                "cancel_turn",
4270            ),
4271            (
4272                r#"{"action":"set-plan-mode","session_id":"session-1","active":true}"#,
4273                "set_plan_mode",
4274            ),
4275            (
4276                r#"{"action":"set-config","session_id":"session-1","key":"model","value":"x"}"#,
4277                "set_config",
4278            ),
4279        ] {
4280            let (app, mut actions, _, _, _) = app();
4281            let response = post_action(app, cookie(), body.to_owned()).await;
4282            assert!(
4283                response.status().is_client_error(),
4284                "{capability} was accepted while false: {}",
4285                response.status()
4286            );
4287            assert!(
4288                actions.try_recv().is_err(),
4289                "{capability} reached the controller while false"
4290            );
4291        }
4292    }
4293
4294    /// A setting the harness never advertised is not a setting. Forwarding one
4295    /// asks the agent to refuse something the viewer should never have offered.
4296    #[tokio::test]
4297    async fn a_config_key_the_harness_never_advertised_is_refused() {
4298        let capable = |snapshot: &mut ViewerSnapshot| {
4299            snapshot.sessions[0].capabilities.set_config = true;
4300            snapshot.sessions[0].config_options = vec![ViewerConfigOption {
4301                key: "model".into(),
4302                label: "model".into(),
4303                current: None,
4304                choices: vec![ViewerConfigChoice {
4305                    value: "sonnet".into(),
4306                    name: "Sonnet".into(),
4307                    description: None,
4308                }],
4309            }];
4310        };
4311
4312        for (body, why) in [
4313            (
4314                r#"{"action":"set-config","session_id":"session-1","key":"effort","value":"high"}"#,
4315                "an unadvertised key",
4316            ),
4317            (
4318                r#"{"action":"set-config","session_id":"session-1","key":"model","value":"gpt-9"}"#,
4319                "an unoffered value",
4320            ),
4321        ] {
4322            let (app, mut actions, _, _, _) = app_with_snapshot(capable);
4323            let response = post_action(app, cookie(), body.to_owned()).await;
4324            assert_eq!(
4325                response.status(),
4326                StatusCode::BAD_REQUEST,
4327                "{why} was accepted"
4328            );
4329            assert!(actions.try_recv().is_err(), "{why} reached the controller");
4330        }
4331
4332        // The value the harness did advertise is forwarded unchanged.
4333        let (app, mut actions, _, _, _) = app_with_snapshot(capable);
4334        let response = tokio::spawn(post_action(
4335            app,
4336            cookie(),
4337            r#"{"action":"set-config","session_id":"session-1","key":"model","value":"sonnet"}"#
4338                .to_owned(),
4339        ));
4340        let action = actions
4341            .recv()
4342            .await
4343            .expect("the action reached the controller");
4344        assert!(
4345            matches!(
4346                action.action,
4347                ControllerAction::SetConfig { ref key, ref value, .. }
4348                    if key == "model" && value == "sonnet"
4349            ),
4350            "the advertised value was not forwarded unchanged"
4351        );
4352        action.reply.send(ActionOutcome::Accepted).unwrap();
4353        assert_eq!(response.await.unwrap().status(), StatusCode::ACCEPTED);
4354    }
4355
4356    /// A dirty-worktree acknowledgement names the repositories the person was
4357    /// shown. A bare yes could be replayed against a set they never saw.
4358    #[tokio::test]
4359    async fn a_dirty_acknowledgement_is_bounded_and_names_repositories() {
4360        let oversized = (0..40)
4361            .map(|index| format!(r#""repo-{index}""#))
4362            .collect::<Vec<_>>()
4363            .join(",");
4364        for (ack, why) in [
4365            (oversized.as_str(), "an unbounded acknowledgement"),
4366            (r#""""#, "an empty repository name"),
4367        ] {
4368            let (app, mut actions, _, _, _) = app();
4369            let body = format!(
4370                r#"{{"action":"new","workspace_id":"default","profile_id":"codex-1","bundle_id":"hel","target_id":"podman","dirty_ack":[{ack}]}}"#
4371            );
4372            let response = post_action(app, cookie(), body).await;
4373            assert_eq!(response.status(), StatusCode::BAD_REQUEST, "{why}");
4374            assert!(actions.try_recv().is_err(), "{why} reached the controller");
4375        }
4376    }
4377
4378    /// A session created without a title still gets one, derived the way the
4379    /// terminal derives it, so the two surfaces name a session alike.
4380    #[tokio::test]
4381    async fn a_new_session_without_a_title_is_accepted() {
4382        let (app, mut actions, _, _, _) = app();
4383        let response = tokio::spawn(post_action(
4384            app,
4385            cookie(),
4386            r#"{"action":"new","workspace_id":"default","profile_id":"codex-1","bundle_id":"hel","target_id":"podman"}"#
4387                .to_owned(),
4388        ));
4389        // The handler answers only once the controller does, so the reply has
4390        // to be sent before the response can be read.
4391        let action = actions
4392            .recv()
4393            .await
4394            .expect("the action reached the controller");
4395        assert!(
4396            matches!(
4397                action.action,
4398                ControllerAction::New { title: None, ref workspace_id, .. }
4399                    if workspace_id == "default"
4400            ),
4401            "the workspace or the absent title did not survive the boundary"
4402        );
4403        action.reply.send(ActionOutcome::Accepted).unwrap();
4404        assert_eq!(response.await.unwrap().status(), StatusCode::ACCEPTED);
4405    }
4406
4407    /// Two phones must not share stored state, and one phone's state must
4408    /// survive its own re-login. Neither is true of a cookie that signs only
4409    /// an expiry, which is what this replaced.
4410    #[test]
4411    fn a_cookie_names_one_viewer_and_two_cookies_never_collide() {
4412        let key = b"01234567890123456789012345678901";
4413        let expiry = now_unix().saturating_add(3600);
4414        let first = signed_cookie_value(key, "viewer-a", expiry);
4415        let second = signed_cookie_value(key, "viewer-b", expiry);
4416        assert_ne!(
4417            first, second,
4418            "two viewers unlocking in the same second share a cookie"
4419        );
4420        assert_eq!(
4421            cookie_viewer(key, &first, now_unix()),
4422            Some(Some("viewer-a".to_owned()))
4423        );
4424        assert_eq!(
4425            cookie_viewer(key, &second, now_unix()),
4426            Some(Some("viewer-b".to_owned()))
4427        );
4428    }
4429
4430    /// A phone holding the previous cookie keeps working through a deployment.
4431    /// It names no viewer, so it stores nothing, which is the difference
4432    /// between signed out and signed in with nothing kept.
4433    #[test]
4434    fn a_legacy_cookie_still_authenticates_and_stores_nothing() {
4435        let key = b"01234567890123456789012345678901";
4436        let expiry = now_unix().saturating_add(3600);
4437        let legacy = legacy_signed_cookie_value(key, expiry);
4438        assert_eq!(cookie_viewer(key, &legacy, now_unix()), Some(None));
4439        assert!(session_cookie_valid(key, &legacy, now_unix()));
4440        assert!(
4441            !session_cookie_valid(key, &legacy, expiry),
4442            "an expired legacy cookie still authenticated"
4443        );
4444    }
4445
4446    /// A forged or tampered cookie names nobody.
4447    #[test]
4448    fn a_tampered_cookie_is_refused() {
4449        let key = b"01234567890123456789012345678901";
4450        let expiry = now_unix().saturating_add(3600);
4451        let honest = signed_cookie_value(key, "viewer-a", expiry);
4452        let swapped = honest.replacen("viewer-a", "viewer-b", 1);
4453        assert_eq!(cookie_viewer(key, &swapped, now_unix()), None);
4454        assert_eq!(cookie_viewer(key, "nonsense", now_unix()), None);
4455        assert_eq!(cookie_viewer(key, &format!("{expiry}."), now_unix()), None);
4456    }
4457
4458    /// A composer is for a prompt. The bound exists so one viewer cannot fill
4459    /// the daemon's database with text it never sent.
4460    #[tokio::test]
4461    async fn an_oversized_draft_is_refused_with_a_stable_code() {
4462        let (app, _, _, _, mut stored) = app();
4463        let draft = "x".repeat(64 * 1024 + 1);
4464        let response = app
4465            .oneshot(
4466                Request::put("/api/sessions/session-1/draft")
4467                    .header(COOKIE, cookie())
4468                    .header(CONTENT_TYPE, "application/json")
4469                    .body(Body::from(
4470                        serde_json::json!({ "draft": draft }).to_string(),
4471                    ))
4472                    .unwrap(),
4473            )
4474            .await
4475            .unwrap();
4476        assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE);
4477        assert!(stored.try_recv().is_err(), "an oversized draft was stored");
4478    }
4479
4480    /// A viewer with no identity has nothing stored, and is told so rather
4481    /// than being promised a persistence that is not there.
4482    #[tokio::test]
4483    async fn a_legacy_viewer_reads_empty_state_and_cannot_store_a_draft() {
4484        let key = b"01234567890123456789012345678901";
4485        let legacy = format!(
4486            "{COOKIE_NAME}={}",
4487            legacy_signed_cookie_value(key, now_unix().saturating_add(3600))
4488        );
4489
4490        let (reader, _, _, _, mut stored) = app();
4491        let response = reader
4492            .oneshot(
4493                Request::get("/api/sessions/session-1/client-state")
4494                    .header(COOKIE, legacy.clone())
4495                    .body(Body::empty())
4496                    .unwrap(),
4497            )
4498            .await
4499            .unwrap();
4500        assert_eq!(response.status(), StatusCode::OK);
4501        let body = response.into_body().collect().await.unwrap().to_bytes();
4502        let state: ViewerClientState = serde_json::from_slice(&body).unwrap();
4503        assert_eq!(state, ViewerClientState::default());
4504        assert!(
4505            stored.try_recv().is_err(),
4506            "a legacy viewer read stored state"
4507        );
4508
4509        let (writer, _, _, _, mut stored) = app();
4510        let response = writer
4511            .oneshot(
4512                Request::put("/api/sessions/session-1/draft")
4513                    .header(COOKIE, legacy)
4514                    .header(CONTENT_TYPE, "application/json")
4515                    .body(Body::from(r#"{"draft":"text"}"#))
4516                    .unwrap(),
4517            )
4518            .await
4519            .unwrap();
4520        assert_eq!(response.status(), StatusCode::CONFLICT);
4521        assert!(stored.try_recv().is_err(), "a legacy viewer stored a draft");
4522    }
4523
4524    /// A search that is not a search is refused before it reaches a database.
4525    #[tokio::test]
4526    async fn prompt_history_refuses_an_unknown_scope() {
4527        let (app, _, _, _, mut stored) = app();
4528        let response = app
4529            .oneshot(
4530                Request::get("/api/sessions/session-1/history?q=ship&scope=everything")
4531                    .header(COOKIE, cookie())
4532                    .body(Body::empty())
4533                    .unwrap(),
4534            )
4535            .await
4536            .unwrap();
4537        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
4538        assert!(
4539            stored.try_recv().is_err(),
4540            "the search reached the controller"
4541        );
4542    }
4543
4544    /// A preflight starts nothing. It answers the questions a person needs
4545    /// before committing, and it refuses an impossible combination there
4546    /// rather than after the commit.
4547    #[tokio::test]
4548    async fn a_preflight_validates_before_it_reaches_the_controller() {
4549        for (body, why) in [
4550            (
4551                r#"{"profile_id":"nope","bundle_id":"hel","target_id":"podman"}"#,
4552                "an unknown profile",
4553            ),
4554            (
4555                r#"{"profile_id":"codex-1","bundle_id":"hel","target_id":"raw"}"#,
4556                "a bare target with no directory",
4557            ),
4558        ] {
4559            let (app, _, _, mut preflights, _) = app();
4560            let response = app
4561                .oneshot(
4562                    Request::post("/api/preflight/new")
4563                        .header(COOKIE, cookie())
4564                        .header(CONTENT_TYPE, "application/json")
4565                        .body(Body::from(body))
4566                        .unwrap(),
4567                )
4568                .await
4569                .unwrap();
4570            assert_eq!(response.status(), StatusCode::BAD_REQUEST, "{why}");
4571            assert!(
4572                preflights.try_recv().is_err(),
4573                "{why} reached the controller"
4574            );
4575        }
4576    }
4577
4578    /// A bare target opens a directory the person named. The controller still
4579    /// validates that directory before answering, because the server's state
4580    /// projection cannot inspect the filesystem or an SSH host.
4581    #[tokio::test]
4582    async fn a_bare_preflight_forwards_directory_validation_to_the_controller() {
4583        let (app, _, _, mut preflights, _) = app();
4584        let response = tokio::spawn(app.oneshot(
4585                Request::post("/api/preflight/new")
4586                    .header(COOKIE, cookie())
4587                    .header(CONTENT_TYPE, "application/json")
4588                    .body(Body::from(
4589                        r#"{"profile_id":"codex-1","bundle_id":"hel","target_id":"raw","project_directory":"/work/project"}"#,
4590                    ))
4591                    .unwrap(),
4592            ));
4593        let request = preflights.recv().await.expect("the controller was asked");
4594        assert_eq!(request.bundle_id, "hel");
4595        assert_eq!(request.target_id, "raw");
4596        assert_eq!(
4597            request.project_directory,
4598            Some(PathBuf::from("/work/project"))
4599        );
4600        request
4601            .reply
4602            .send(Ok(PreflightNew {
4603                dirty_repositories: Vec::new(),
4604            }))
4605            .unwrap();
4606        let response = response.await.unwrap().unwrap();
4607        assert_eq!(response.status(), StatusCode::OK);
4608        let body = response.into_body().collect().await.unwrap().to_bytes();
4609        let answer: PreflightNew = serde_json::from_slice(&body).unwrap();
4610        assert!(answer.dirty_repositories.is_empty());
4611    }
4612
4613    #[tokio::test]
4614    async fn a_bare_preflight_validation_failure_is_actionable_without_its_details() {
4615        let (app, _, _, mut preflights, _) = app();
4616        let response = tokio::spawn(app.oneshot(
4617            Request::post("/api/preflight/new")
4618                .header(COOKIE, cookie())
4619                .header(CONTENT_TYPE, "application/json")
4620                .body(Body::from(
4621                    r#"{"profile_id":"codex-1","bundle_id":"hel","target_id":"raw","project_directory":"/private/project"}"#,
4622                ))
4623                .unwrap(),
4624        ));
4625        let request = preflights.recv().await.expect("the controller was asked");
4626        request
4627            .reply
4628            .send(Err(PreflightFailure::Validation))
4629            .unwrap();
4630        let response = response.await.unwrap().unwrap();
4631        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
4632        let body = response.into_body().collect().await.unwrap().to_bytes();
4633        assert_eq!(
4634            serde_json::from_slice::<serde_json::Value>(&body).unwrap(),
4635            serde_json::json!({
4636                "error": "project validation failed; check that the directory exists, is accessible, and contains a Git repository with a valid HEAD"
4637            })
4638        );
4639        assert!(!String::from_utf8_lossy(&body).contains("/private/project"));
4640    }
4641
4642    #[tokio::test]
4643    async fn a_bundle_preflight_controller_failure_keeps_the_generic_service_error() {
4644        let (app, _, _, mut preflights, _) = app();
4645        let response = tokio::spawn(
4646            app.oneshot(
4647                Request::post("/api/preflight/new")
4648                    .header(COOKIE, cookie())
4649                    .header(CONTENT_TYPE, "application/json")
4650                    .body(Body::from(
4651                        r#"{"profile_id":"codex-1","bundle_id":"hel","target_id":"podman"}"#,
4652                    ))
4653                    .unwrap(),
4654            ),
4655        );
4656        let request = preflights.recv().await.expect("the controller was asked");
4657        request
4658            .reply
4659            .send(Err(PreflightFailure::Controller(
4660                "private /source/hel details".into(),
4661            )))
4662            .unwrap();
4663        let response = response.await.unwrap().unwrap();
4664        assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
4665        let body = response.into_body().collect().await.unwrap().to_bytes();
4666        assert_eq!(
4667            serde_json::from_slice::<serde_json::Value>(&body).unwrap(),
4668            serde_json::json!({"error": "the controller could not check this project"})
4669        );
4670        assert!(!String::from_utf8_lossy(&body).contains("/source/hel"));
4671    }
4672
4673    /// A bundle preflight asks the controller, because whether a working tree
4674    /// has uncommitted changes is a fact about the disk.
4675    #[tokio::test]
4676    async fn a_bundle_preflight_reports_the_repositories_by_leaf_name() {
4677        let (app, _, _, mut preflights, _) = app();
4678        let response = tokio::spawn(
4679            app.oneshot(
4680                Request::post("/api/preflight/new")
4681                    .header(COOKIE, cookie())
4682                    .header(CONTENT_TYPE, "application/json")
4683                    .body(Body::from(
4684                        r#"{"profile_id":"codex-1","bundle_id":"hel","target_id":"podman"}"#,
4685                    ))
4686                    .unwrap(),
4687            ),
4688        );
4689        let request = preflights.recv().await.expect("the controller was asked");
4690        assert_eq!(request.bundle_id, "hel");
4691        assert_eq!(request.target_id, "podman");
4692        assert_eq!(request.project_directory, None);
4693        request
4694            .reply
4695            .send(Ok(PreflightNew {
4696                dirty_repositories: vec!["hel".into()],
4697            }))
4698            .unwrap();
4699        let response = response.await.unwrap().unwrap();
4700        assert_eq!(response.status(), StatusCode::OK);
4701        let body = response.into_body().collect().await.unwrap().to_bytes();
4702        let answer: PreflightNew = serde_json::from_slice(&body).unwrap();
4703        assert_eq!(answer.dirty_repositories, vec!["hel".to_owned()]);
4704        assert!(
4705            !String::from_utf8_lossy(&body).contains('/'),
4706            "the preflight published a path: {}",
4707            String::from_utf8_lossy(&body)
4708        );
4709    }
4710
4711    /// Everything an agent writes goes through the Markdown renderer, so the
4712    /// renderer is where injection is stopped. These checks run the shipped
4713    /// module against a fake DOM: structure has to come out as elements, and
4714    /// markup an agent typed has to come out as text.
4715    #[test]
4716    fn the_markdown_renderer_builds_structure_and_refuses_injection() {
4717        run_web_check(
4718            "markdown",
4719            r#"import { installDocument, elements, only, check, checkEqual } from './test-dom.js';
4720installDocument();
4721const { renderMarkdown, renderDiffSummary, safeHref } = await import('./markdown.js');
4722
4723const render = source => {
4724  const host = document.createElement('section');
4725  host.append(renderMarkdown(source));
4726  return host;
4727};
4728
4729// Headings
4730checkEqual(only(render('# Title'), 'h1').textContent, 'Title', 'h1');
4731checkEqual(only(render('### Deep'), 'h3').textContent, 'Deep', 'h3');
4732
4733// Nested lists
4734const nested = render('- one\n  - inner\n- two');
4735check(elements(nested, 'ul').length === 2, 'nested list produced ' + elements(nested, 'ul').length + ' lists');
4736check(elements(elements(nested, 'ul')[0], 'li').length >= 2, 'outer list lost items');
4737
4738// Ordered lists
4739checkEqual(elements(render('1. a\n2. b'), 'ol').length, 1, 'ordered list');
4740
4741// Fenced code stays unparsed
4742const fenced = render('```rust\nlet x = *y*;\n```');
4743checkEqual(only(fenced, 'code').textContent, 'let x = *y*;', 'fenced code');
4744check(elements(fenced, 'span').some(s => s.className === 'tok-kw'), 'fenced rust untinted');
4745checkEqual(elements(fenced, 'em').length, 0, 'fence emphasised its contents');
4746checkEqual(only(fenced, 'pre').dataset.lang, 'rust', 'fence language');
4747
4748// Inline code beats emphasis
4749checkEqual(only(render('`*not em*`'), 'code').textContent, '*not em*', 'inline code');
4750checkEqual(elements(render('`*not em*`'), 'em').length, 0, 'inline code emphasised');
4751
4752// Emphasis
4753checkEqual(only(render('**bold**'), 'strong').textContent, 'bold', 'strong');
4754checkEqual(only(render('*it*'), 'em').textContent, 'it', 'em');
4755checkEqual(only(render('~~gone~~'), 'del').textContent, 'gone', 'del');
4756
4757// Tables
4758const table = render('| a | b |\n| --- | ---: |\n| 1 | 2 |');
4759checkEqual(elements(table, 'table').length, 1, 'table');
4760checkEqual(elements(table, 'th').length, 2, 'table header cells');
4761checkEqual(elements(table, 'td').length, 2, 'table body cells');
4762checkEqual(elements(table, 'th')[1].className, 'align-right', 'table alignment class');
4763checkEqual(only(table, 'div').className, 'scroll-x', 'table scroll wrapper');
4764
4765// Blockquote and rule
4766checkEqual(elements(render('> quoted'), 'blockquote').length, 1, 'blockquote');
4767checkEqual(elements(render('---'), 'hr').length, 1, 'rule');
4768
4769// XSS: markup is text, never elements
4770const injected = render('<img src=x onerror=alert(1)>');
4771checkEqual(elements(injected, 'img').length, 0, 'raw HTML became an element');
4772check(injected.textContent.includes('<img src=x onerror=alert(1)>'), 'raw HTML lost its text');
4773
4774// XSS: refused link schemes
4775for (const target of ['javascript:alert(1)', 'JaVaScRiPt:alert(1)', 'java\tscript:alert(1)', 'data:text/html,<script>', 'vbscript:x']) {
4776  const out = render(`[click](${target})`);
4777  checkEqual(elements(out, 'a').length, 0, `link scheme ${JSON.stringify(target)} was allowed`);
4778  check(out.textContent.includes('click'), `link scheme ${JSON.stringify(target)} lost its label`);
4779}
4780
4781// Accepted schemes keep their href and carry safe rel/target
4782for (const target of ['https://example.com', 'http://example.com/a', 'mailto:someone@example.com']) {
4783  const anchor = only(render(`[click](${target})`), 'a');
4784  checkEqual(anchor.getAttribute('href'), target, 'href');
4785  checkEqual(anchor.getAttribute('rel'), 'noreferrer noopener', 'rel');
4786  checkEqual(anchor.getAttribute('target'), '_blank', 'target');
4787}
4788
4789// safeHref directly
4790checkEqual(safeHref('javascript:alert(1)'), null, 'safeHref allowed javascript:');
4791checkEqual(safeHref(' https://x.test '), 'https://x.test', 'safeHref cleaned value');
4792
4793// Inline markup inside a link label
4794checkEqual(only(render('[**bold link**](https://x.test)'), 'strong').textContent, 'bold link', 'link label markup');
4795
4796// An unclosed delimiter is literal, not markup
4797checkEqual(render('a * b').textContent, 'a * b', 'unclosed emphasis');
4798checkEqual(elements(render('a * b'), 'em').length, 0, 'unclosed emphasis made an element');
4799
4800// Diff summaries: the real format from format_diffstat, two spaces and U+2212
4801const diff = renderDiffSummary(['src/main.rs  +12 −3', 'unparseable line']);
4802const items = elements(diff, 'li');
4803checkEqual(items.length, 2, 'diffstat rows');
4804checkEqual(elements(items[0], 'span')[0].textContent, 'src/main.rs', 'diffstat path');
4805checkEqual(elements(items[0], 'span')[1].textContent, '+12', 'diffstat additions');
4806checkEqual(elements(items[0], 'span')[2].textContent, '−3', 'diffstat deletions');
4807checkEqual(elements(items[1], 'span').length, 1, 'unparseable diffstat produced counts');
4808checkEqual(elements(items[1], 'span')[0].textContent, 'unparseable line', 'unparseable diffstat lost its text');
4809
4810console.log('all markdown checks passed');
4811"#,
4812        );
4813    }
4814
4815    /// Tool output is not prose, and rendering it as prose loses the parts
4816    /// that matter: which words in a command are the program and which are
4817    /// paths, where a JSON payload begins, and whether a five-thousand-line
4818    /// dump has to be paid for before anyone asks to see it.
4819    #[test]
4820    fn tool_output_is_tinted_folded_and_never_read_as_markdown() {
4821        run_web_check(
4822            "tool-output",
4823            r#"import { installDocument, elements, only, check, checkEqual, openFold } from './test-dom.js';
4824installDocument();
4825const { renderToolOutput, codeBlock, detectLang, appendCommandTokens, isPathLike } = await import(
4826  './tool-output.js'
4827);
4828
4829const classes = root => elements(root, 'span').map(s => s.className);
4830
4831// A shell command is told apart into program, subcommand, flag and path.
4832const line = document.createElement('pre');
4833appendCommandTokens(line, 'cargo test --workspace src/lib.rs');
4834const seen = classes(line);
4835check(seen.includes('cmd-program'), 'no program: ' + seen);
4836check(seen.includes('cmd-subcommand'), 'no subcommand: ' + seen);
4837check(seen.includes('cmd-flag'), 'no flag: ' + seen);
4838check(seen.includes('cmd-path'), 'no path: ' + seen);
4839checkEqual(line.textContent, 'cargo test --workspace src/lib.rs', 'command text changed');
4840
4841// An operator starts the program count again, so both programs are found.
4842const piped = document.createElement('pre');
4843appendCommandTokens(piped, 'git status && cargo build');
4844checkEqual(classes(piped).filter(c => c === 'cmd-program').length, 2, 'pipeline reset');
4845
4846// Prose with a slash is not a path; a real path is.
4847check(!isPathLike('and/or'), '"and/or" read as a path');
4848check(isPathLike('src/lib/thing.rs'), 'a real path did not');
4849check(isPathLike('./x'), 'a relative path did not');
4850check(isPathLike('Cargo.toml'), 'a file with an extension did not');
4851
4852// JSON is pretty-printed and tinted, keys apart from values.
4853const json = renderToolOutput('{"name":"hel","count":3,"ok":true}');
4854const jsonClasses = classes(json);
4855check(jsonClasses.includes('tok-key'), 'no JSON key: ' + jsonClasses);
4856check(jsonClasses.includes('tok-str'), 'no JSON string: ' + jsonClasses);
4857check(jsonClasses.includes('tok-num'), 'no JSON number: ' + jsonClasses);
4858check(jsonClasses.includes('tok-kw'), 'no JSON keyword: ' + jsonClasses);
4859check(json.textContent.includes('"name"'), 'JSON lost its content');
4860
4861// Rust is tinted; an unknown language is not.
4862const rust = codeBlock('pub fn main() {\n    let x = 1;\n}', 'rust');
4863check(classes(rust).includes('tok-kw'), 'rust keywords untinted');
4864checkEqual(only(rust, 'pre').dataset.lang, 'rust', 'rust data-lang');
4865const plain = codeBlock('nothing in particular here', 'brainfuck');
4866checkEqual(classes(plain).length, 0, 'unknown language was tinted');
4867
4868// Sniffing is conservative: a log stays plain, real code does not.
4869checkEqual(detectLang('12:03 INFO started\n12:04 INFO done\n12:05 INFO stopped'), '', 'a log was sniffed');
4870checkEqual(
4871  detectLang('fn a() {}\nfn b() {}\nlet mut x = 1;\nuse std::fmt;\nimpl Foo {}\nlet y = x.unwrap();'),
4872  'rust',
4873  'rust was not sniffed',
4874);
4875checkEqual(detectLang('--- a/x\n+++ b/x\n@@ -1 +1 @@\n-old\n+new'), 'diff', 'diff was not sniffed');
4876
4877// A long dump is one closed fold that has built nothing yet.
4878const long = Array.from({ length: 400 }, (_, i) => `line ${i}`).join('\n');
4879const folded = renderToolOutput(long);
4880checkEqual(folded.nodeName, 'DETAILS', 'a 400-line dump was not folded');
4881checkEqual(elements(folded, 'pre').length, 0, 'a closed fold built its content anyway');
4882check(only(folded, 'summary').textContent.includes('400 lines'), 'fold summary: ' + only(folded, 'summary').textContent);
4883openFold(folded);
4884checkEqual(elements(folded, 'pre').length, 1, 'an opened fold built nothing');
4885check(elements(folded, 'pre')[0].textContent.includes('line 399'), 'the fold lost its content');
4886
4887// Opening twice builds once.
4888openFold(folded);
4889checkEqual(elements(folded, 'pre').length, 1, 'reopening rebuilt the content');
4890
4891// A short dump is not folded.
4892checkEqual(renderToolOutput('one\ntwo').nodeName, 'PRE', 'a short dump was folded');
4893
4894// Tool output is never parsed as Markdown, so an underscore is an underscore.
4895const literal = renderToolOutput('a _b_ c <img src=x>');
4896checkEqual(elements(literal, 'em').length, 0, 'tool output was emphasised');
4897checkEqual(elements(literal, 'img').length, 0, 'tool output produced an element');
4898check(literal.textContent.includes('<img src=x>'), 'tool output lost its text');
4899
4900console.log('all tool-output checks passed');
4901"#,
4902        );
4903    }
4904
4905    /// The renderer's guarantee is structural — this code cannot inject markup
4906    /// because it never builds markup — and a single stray assignment would
4907    /// quietly replace it with no guarantee at all. `escapeHtml` uses
4908    /// `innerHTML` on a detached node to escape text, which is safe but is
4909    /// also exactly the shape this test exists to stop spreading, so it is
4910    /// named rather than pattern-matched.
4911    #[test]
4912    fn no_web_module_builds_markup_from_a_string() {
4913        const SINKS: [&str; 5] = [
4914            "innerHTML",
4915            "outerHTML",
4916            "insertAdjacentHTML",
4917            "document.write",
4918            "new Function",
4919        ];
4920        // There is no allowance. Every one of these sinks was removed in
4921        // Milestone 2, and the point of the test is that none comes back.
4922        const ALLOWED: [(&str, &str); 0] = [];
4923        for (name, source) in [
4924            ("viewer.js", VIEWER_JS),
4925            ("markdown.js", MARKDOWN_JS),
4926            ("tool-output.js", TOOL_OUTPUT_JS),
4927        ] {
4928            for (number, line) in source.lines().enumerate() {
4929                let trimmed = line.trim();
4930                if trimmed.starts_with("//") || trimmed.starts_with("///") {
4931                    continue;
4932                }
4933                for sink in SINKS {
4934                    if !trimmed.contains(sink) {
4935                        continue;
4936                    }
4937                    assert!(
4938                        ALLOWED
4939                            .iter()
4940                            .any(|(file, allowed)| *file == name && trimmed == *allowed),
4941                        "{name}:{} builds markup from a string: {trimmed}",
4942                        number + 1
4943                    );
4944                }
4945            }
4946        }
4947    }
4948
4949    /// The card cache is the fix for answers vanishing under snapshot polls, so
4950    /// it is exercised as JavaScript: the render source is lifted out of
4951    /// `src/web/viewer.js` and run against a stub DOM.
4952    #[test]
4953    fn embedded_viewer_keeps_elicitation_answers_across_snapshot_polls() {
4954        let source = viewer_source(
4955            "const elicitationCards = new Map()",
4956            "async function submitElicitation",
4957        );
4958        let dom = r#"
4959let replaceCalls = 0;
4960function makeEl(tag) {
4961  return {
4962    tagName: tag.toUpperCase(),
4963    children: [],
4964    options: [],
4965    selectedOptions: [],
4966    className: "",
4967    textContent: "",
4968    disabled: false,
4969    required: false,
4970    value: "",
4971    appendChild(child) {
4972      this.children.push(child);
4973      if (this.tagName === "SELECT") this.options.push(child);
4974      return child;
4975    },
4976    append(...kids) {
4977      this.children.push(...kids);
4978    },
4979    replaceChildren(...kids) {
4980      replaceCalls += 1;
4981      this.children = kids;
4982    },
4983    addEventListener() {},
4984    querySelectorAll(selector) {
4985      const found = [];
4986      const visit = node => {
4987        for (const child of node.children) {
4988          if (child.tagName === "INPUT" && (selector === "input" || child.checked)) found.push(child);
4989          visit(child);
4990        }
4991      };
4992      visit(this);
4993      return found;
4994    },
4995    querySelector(selector) { return this.querySelectorAll(selector)[0] || null; },
4996    setCustomValidity() {},
4997    reportValidity() {
4998      return true;
4999    },
5000  };
5001}
5002const created = [];
5003const document = {
5004  createElement(tag) {
5005    const el = makeEl(tag);
5006    created.push(el);
5007    return el;
5008  },
5009};
5010const elicitations = makeEl("div");
5011function el(tag, className, text) {
5012  const node = document.createElement(tag);
5013  node.className = className || "";
5014  node.textContent = text || "";
5015  return node;
5016}
5017async function submitElicitation() {}
5018"#;
5019        let checks = r#"
5020const request = {
5021  id: "elicitation-1",
5022  message: "Which CI architecture?",
5023  title: "CI",
5024  fields: [
5025    {
5026      id: "question_0",
5027      title: "CI architecture",
5028      required: false,
5029      kind: "single_select",
5030      options: [{ value: "reusable", title: "Reusable" }, { value: "matrix", title: "Matrix" }],
5031    },
5032    { id: "question_0_custom", title: "Other", required: false, kind: "text" },
5033  ],
5034};
5035const session = { id: "session-1", pending_elicitations: [request] };
5036renderElicitations(session);
5037const card = elicitations.children[0];
5038const radio = created.find((el) => el.tagName === "INPUT" && el.value === "reusable");
5039const text = created.find((el) => el.tagName === "INPUT" && el.type === "text");
5040radio.checked = true;
5041text.value = "keep me";
5042const attachments = replaceCalls;
5043renderElicitations(session);
5044if (elicitations.children[0] !== card) {
5045  throw new Error("a snapshot rebuilt the pending card");
5046}
5047if (!radio.checked || text.value !== "keep me") {
5048  throw new Error("a snapshot wiped the half-filled answer");
5049}
5050if (replaceCalls !== attachments) {
5051  throw new Error("a snapshot re-attached an unchanged card and dropped focus");
5052}
5053sentElicitations.add(elicitationKey("session-1", request.id));
5054renderElicitations(session);
5055if (elicitations.children[0] !== card) {
5056  throw new Error("a sent answer rebuilt the card");
5057}
5058if (!radio.disabled || !text.disabled) {
5059  throw new Error("a sent answer left the controls live");
5060}
5061if (!radio.checked) {
5062  throw new Error("a sent answer wiped the reply");
5063}
5064renderElicitations({ id: "session-1", pending_elicitations: [] });
5065if (elicitations.children.length !== 0 || elicitationCards.size !== 0) {
5066  throw new Error("an answered request stayed rendered");
5067}
5068if (sentElicitations.size !== 0) {
5069  throw new Error("a resolved request kept its sent marker");
5070}
5071"#;
5072        run_viewer_script(
5073            "elicitation-rendering",
5074            &format!("{dom}\n{source}\n{checks}"),
5075        );
5076    }
5077
5078    fn sample_image(pixels: usize) -> ViewerPromptImage {
5079        ViewerPromptImage {
5080            data_base64: base64::engine::general_purpose::STANDARD.encode(vec![7_u8; pixels]),
5081            mime_type: "image/png".into(),
5082            width: 32,
5083            height: 24,
5084        }
5085    }
5086
5087    fn image_capable(snapshot: &mut ViewerSnapshot) {
5088        snapshot.sessions[0].prompt_images_supported = true;
5089    }
5090
5091    async fn post_action(app: Router, cookie: String, body: String) -> Response<Body> {
5092        app.oneshot(
5093            Request::post("/api/actions")
5094                .header(COOKIE, cookie)
5095                .header(CONTENT_TYPE, "application/json")
5096                .body(Body::from(body))
5097                .unwrap(),
5098        )
5099        .await
5100        .unwrap()
5101    }
5102
5103    #[tokio::test]
5104    async fn image_prompt_reaches_the_controller_with_its_images() {
5105        let (app, mut actions, _, _, _) = app_with_snapshot(image_capable);
5106        let cookie = login_cookie(&app).await;
5107        let image = sample_image(8);
5108        let body = serde_json::to_string(&ControllerAction::Prompt {
5109            session_id: "session-1".into(),
5110            text: String::new(),
5111            images: vec![image.clone(), image.clone()],
5112        })
5113        .unwrap();
5114        let response = tokio::spawn(post_action(app, cookie, body));
5115        let action = actions.recv().await.unwrap();
5116        assert_eq!(
5117            action.action,
5118            ControllerAction::Prompt {
5119                session_id: "session-1".into(),
5120                text: String::new(),
5121                images: vec![image.clone(), image],
5122            }
5123        );
5124        action.reply.send(ActionOutcome::Accepted).unwrap();
5125        assert_eq!(response.await.unwrap().status(), StatusCode::ACCEPTED);
5126    }
5127
5128    /// Base64 inflates an upload by a third, so two ordinary photographs pass
5129    /// the general body limit even when each one fits it. The action route
5130    /// carries prompts, so it is the route that gets the larger bound.
5131    #[tokio::test]
5132    async fn multi_image_prompts_are_accepted_over_the_general_body_limit() {
5133        let (app, mut actions, _, _, _) = app_with_snapshot(image_capable);
5134        let cookie = login_cookie(&app).await;
5135        let image = sample_image(MAX_BODY_BYTES / 2);
5136        let body = serde_json::to_string(&ControllerAction::Prompt {
5137            session_id: "session-1".into(),
5138            text: "look at these".into(),
5139            images: vec![image.clone(), image],
5140        })
5141        .unwrap();
5142        assert!(body.len() > MAX_BODY_BYTES);
5143        assert!(body.len() < MAX_PROMPT_BODY_BYTES);
5144        let response = tokio::spawn(post_action(app, cookie, body));
5145        let action = actions.recv().await.unwrap();
5146        action.reply.send(ActionOutcome::Accepted).unwrap();
5147        assert_eq!(response.await.unwrap().status(), StatusCode::ACCEPTED);
5148    }
5149
5150    #[tokio::test]
5151    async fn a_body_over_the_prompt_limit_is_still_refused() {
5152        let (app, _actions, _, _, _) = app_with_snapshot(image_capable);
5153        let cookie = login_cookie(&app).await;
5154        let image = sample_image(MAX_PROMPT_BODY_BYTES);
5155        let body = serde_json::to_string(&ControllerAction::Prompt {
5156            session_id: "session-1".into(),
5157            text: String::new(),
5158            images: vec![image],
5159        })
5160        .unwrap();
5161        assert!(body.len() > MAX_PROMPT_BODY_BYTES);
5162        let response = post_action(app, cookie, body).await;
5163        assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE);
5164    }
5165
5166    #[tokio::test]
5167    async fn malformed_image_payloads_never_reach_the_controller() {
5168        let cases = [
5169            ("aW1hZ2U=", "text/plain", 32, 24),
5170            ("aW1hZ2U=", "image/png", 0, 24),
5171            ("not base64!", "image/png", 32, 24),
5172            ("", "image/png", 32, 24),
5173        ];
5174        for (data, mime, width, height) in cases {
5175            let (app, mut actions, _, _, _) = app_with_snapshot(image_capable);
5176            let cookie = login_cookie(&app).await;
5177            let body = serde_json::to_string(&ControllerAction::Prompt {
5178                session_id: "session-1".into(),
5179                text: String::new(),
5180                images: vec![ViewerPromptImage {
5181                    data_base64: data.into(),
5182                    mime_type: mime.into(),
5183                    width,
5184                    height,
5185                }],
5186            })
5187            .unwrap();
5188            let response = post_action(app, cookie, body).await;
5189            assert_eq!(
5190                response.status(),
5191                StatusCode::BAD_REQUEST,
5192                "expected {data:?}/{mime} {width}x{height} to be refused"
5193            );
5194            assert!(actions.try_recv().is_err());
5195        }
5196    }
5197
5198    #[test]
5199    fn image_prompts_need_text_or_an_image_and_an_agent_that_takes_them() {
5200        let (config, state) = sample_config_state();
5201        let mut snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
5202        let prompt = |text: &str, images: Vec<ViewerPromptImage>| ControllerAction::Prompt {
5203            session_id: "session-1".into(),
5204            text: text.into(),
5205            images,
5206        };
5207
5208        // Without the capability the session takes text only.
5209        assert!(validate_action(&prompt("ship it", Vec::new()), &snapshot).is_ok());
5210        assert!(validate_action(&prompt("", vec![sample_image(8)]), &snapshot).is_err());
5211
5212        image_capable(&mut snapshot);
5213        // An image is a prompt on its own; nothing at all is not.
5214        assert!(validate_action(&prompt("", vec![sample_image(8)]), &snapshot).is_ok());
5215        assert!(validate_action(&prompt("   ", Vec::new()), &snapshot).is_err());
5216        assert!(validate_action(&prompt("", Vec::new()), &snapshot).is_err());
5217        // A shell command is still a shell command.
5218        assert!(validate_action(&prompt("!ls", vec![sample_image(8)]), &snapshot).is_err());
5219    }
5220
5221    /// The composer holds a DOM, not a string, so the text a prompt sends is
5222    /// whatever this reader makes of that DOM. Run it as JavaScript.
5223    #[test]
5224    fn embedded_viewer_reads_multiline_composer_text_out_of_its_dom() {
5225        let source = viewer_source("function composerText()", "function setComposerText(");
5226        let harness = r##"
5227const Node = { TEXT_NODE: 3 };
5228function textNode(value) {
5229  return { nodeType: 3, nodeValue: value, nodeName: "#text", childNodes: [], dataset: {} };
5230}
5231function element(name, children = [], dataset = {}) {
5232  const node = { nodeType: 1, nodeName: name, dataset, childNodes: children };
5233  children.forEach((child, index) => {
5234    child.nextSibling = children[index + 1] || null;
5235  });
5236  return node;
5237}
5238let promptText = null;
5239function read(children) {
5240  promptText = element("DIV", children);
5241  return composerText();
5242}
5243"##;
5244        let checks = r#"
5245const plain = read([textNode("ship it")]);
5246if (plain !== "ship it") throw new Error(`plain text became ${JSON.stringify(plain)}`);
5247
5248const broken = read([textNode("first"), element("BR"), textNode("second")]);
5249if (broken !== "first\nsecond") throw new Error(`line break became ${JSON.stringify(broken)}`);
5250
5251// The trailing break a browser leaves behind to keep the caret on a new line
5252// is scaffolding, not a line the user typed.
5253const filler = read([
5254  textNode("first"),
5255  element("BR"),
5256  element("BR", [], { composerFiller: "true" }),
5257]);
5258if (filler !== "first\n") throw new Error(`filler break became ${JSON.stringify(filler)}`);
5259
5260const blocks = read([
5261  textNode("first"),
5262  element("DIV", [textNode("second")]),
5263  element("DIV", [textNode("third")]),
5264]);
5265if (blocks !== "first\nsecond\nthird") throw new Error(`blocks became ${JSON.stringify(blocks)}`);
5266
5267const carriage = read([textNode("first\r\nsecond")]);
5268if (carriage !== "first\nsecond") throw new Error(`CRLF became ${JSON.stringify(carriage)}`);
5269"#;
5270        run_viewer_script("composer-reader", &format!("{harness}\n{source}\n{checks}"));
5271    }
5272
5273    /// A page that declares no icon makes every browser request
5274    /// `/favicon.ico`, which this server does not have. The page therefore has
5275    /// to name an icon, and that icon has to be served.
5276    #[tokio::test]
5277    async fn viewer_declares_the_icon_route_instead_of_requesting_a_missing_favicon() {
5278        let (app, _, _, _, _) = app();
5279        let page = fetch_text(app.clone(), "/").await;
5280        assert!(page.contains(r#"rel="icon""#), "the page declares no icon");
5281        assert!(page.contains("/icon.svg"), "the page names no icon route");
5282        let icon = app
5283            .oneshot(Request::get("/icon.svg").body(Body::empty()).unwrap())
5284            .await
5285            .unwrap();
5286        assert_eq!(icon.status(), StatusCode::OK);
5287        assert_eq!(
5288            icon.headers().get(CONTENT_TYPE).unwrap(),
5289            "image/svg+xml",
5290            "the icon route does not serve an SVG"
5291        );
5292    }
5293
5294    #[tokio::test]
5295    async fn valid_action_is_typed_and_forwarded() {
5296        let (app, mut actions, _, _, _) = app();
5297        let cookie = login_cookie(&app).await;
5298        let response = tokio::spawn(
5299            app.oneshot(
5300                Request::post("/api/actions")
5301                    .header(COOKIE, cookie)
5302                    .header(CONTENT_TYPE, "application/json")
5303                    .body(Body::from(
5304                        r#"{"action":"prompt","session_id":"session-1","text":"ship it"}"#,
5305                    ))
5306                    .unwrap(),
5307            ),
5308        );
5309        let action = actions.recv().await.unwrap();
5310        assert_eq!(
5311            action.action,
5312            ControllerAction::Prompt {
5313                session_id: "session-1".into(),
5314                text: "ship it".into(),
5315                images: Vec::new(),
5316            }
5317        );
5318        action.reply.send(ActionOutcome::Accepted).unwrap();
5319        let response = response.await.unwrap().unwrap();
5320        assert_eq!(response.status(), StatusCode::ACCEPTED);
5321    }
5322
5323    #[tokio::test]
5324    async fn move_preparation_is_read_only_and_returns_the_daemon_fingerprint() {
5325        let (app, mut preparations) = app_with_move_receiver();
5326        let cookie = login_cookie(&app).await;
5327        let response = tokio::spawn(
5328            app.oneshot(
5329                Request::post("/api/moves/prepare")
5330                    .header(COOKIE, cookie)
5331                    .header(CONTENT_TYPE, "application/json")
5332                    .body(Body::from(
5333                        r#"{"session_id":"session-1","profile_id":"codex-1","target_template_id":"podman","clear_resource_allocation":false,"additional_mounts":null,"resource_allocation":null}"#,
5334                    ))
5335                    .unwrap(),
5336            ),
5337        );
5338        let request = preparations
5339            .recv()
5340            .await
5341            .expect("preparation reached daemon");
5342        assert_eq!(request.selection.session_id, "session-1");
5343        assert_eq!(request.selection.profile_id.as_deref(), Some("codex-1"));
5344        assert_eq!(
5345            request.selection.target_template_id.as_deref(),
5346            Some("podman")
5347        );
5348        request
5349            .reply
5350            .send(Ok(MovePreparation {
5351                selection: request.selection,
5352                source_profile_id: "codex-1".into(),
5353                source_target_template_id: "podman".into(),
5354                cross_harness: false,
5355                active: true,
5356                queued_commands: vec![hel::hel_state::MaterializedQueuedPrompt {
5357                    command_id: "queued-1".into(),
5358                    kind: hel::hel_state::QueuedCommandKind::Prompt,
5359                    content: vec![serde_json::json!({
5360                        "type": "image",
5361                        "mimeType": "image/png",
5362                        "data": "secret-image-bytes"
5363                    })],
5364                    queued_at_ms: 1,
5365                }],
5366                fingerprint: "fingerprint".into(),
5367                operation_id: "move-1".into(),
5368            }))
5369            .unwrap();
5370        let response = response.await.unwrap().unwrap();
5371        assert_eq!(response.status(), StatusCode::OK);
5372        let body = response.into_body().collect().await.unwrap().to_bytes();
5373        let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
5374        assert_eq!(body["operation_id"], "move-1");
5375        assert_eq!(body["active"], true);
5376        assert_eq!(
5377            body["queued_commands"][0]["content"][0]["text"],
5378            "[Image attachment: image/png]"
5379        );
5380        assert!(body.to_string().contains("[Image attachment: image/png]"));
5381        assert!(!body.to_string().contains("secret-image-bytes"));
5382    }
5383
5384    #[tokio::test]
5385    async fn confirmed_move_action_forwards_the_fingerprinted_request() {
5386        let (app, mut actions, _, _, _) = app_with_snapshot(|snapshot| {
5387            snapshot.sessions[0].capabilities.move_session = true;
5388        });
5389        let cookie = login_cookie(&app).await;
5390        let response = tokio::spawn(
5391            app.oneshot(
5392                Request::post("/api/actions")
5393                    .header(COOKIE, cookie)
5394                    .header(CONTENT_TYPE, "application/json")
5395                    .body(Body::from(
5396                        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}}"#,
5397                    ))
5398                    .unwrap(),
5399            ),
5400        );
5401        let action = actions.recv().await.expect("move action reached daemon");
5402        assert!(matches!(action.action, ControllerAction::Move { .. }));
5403        action.reply.send(ActionOutcome::Accepted).unwrap();
5404        assert_eq!(
5405            response.await.unwrap().unwrap().status(),
5406            StatusCode::ACCEPTED
5407        );
5408    }
5409
5410    #[tokio::test]
5411    async fn shell_action_is_typed_and_forwarded() {
5412        let (app, mut actions, _, _, _) = app();
5413        let cookie = login_cookie(&app).await;
5414        let response = tokio::spawn(
5415            app.oneshot(
5416                Request::post("/api/actions")
5417                    .header(COOKIE, cookie)
5418                    .header(CONTENT_TYPE, "application/json")
5419                    .body(Body::from(
5420                        r#"{"action":"run-shell","session_id":"session-1","command":"cargo test"}"#,
5421                    ))
5422                    .unwrap(),
5423            ),
5424        );
5425        let action = actions.recv().await.unwrap();
5426        assert_eq!(
5427            action.action,
5428            ControllerAction::RunShell {
5429                session_id: "session-1".into(),
5430                command: "cargo test".into(),
5431            }
5432        );
5433        action.reply.send(ActionOutcome::Accepted).unwrap();
5434        assert_eq!(
5435            response.await.unwrap().unwrap().status(),
5436            StatusCode::ACCEPTED
5437        );
5438    }
5439
5440    #[test]
5441    fn shell_action_validation_reserves_bang_prompts_and_checks_cancellation_ids() {
5442        let (config, state) = sample_config_state();
5443        let mut snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
5444        assert!(
5445            validate_action(
5446                &ControllerAction::Prompt {
5447                    session_id: "session-1".into(),
5448                    text: "!cargo test".into(),
5449                    images: Vec::new(),
5450                },
5451                &snapshot,
5452            )
5453            .is_err()
5454        );
5455        assert!(
5456            validate_action(
5457                &ControllerAction::RunShell {
5458                    session_id: "session-1".into(),
5459                    command: "cargo test".into(),
5460                },
5461                &snapshot,
5462            )
5463            .is_ok()
5464        );
5465        assert!(
5466            validate_action(
5467                &ControllerAction::CancelShell {
5468                    session_id: "session-1".into(),
5469                    shell_command_id: "shell-1".into(),
5470                },
5471                &snapshot,
5472            )
5473            .is_err()
5474        );
5475
5476        snapshot.sessions[0]
5477            .active_user_shells
5478            .push(ViewerUserShell {
5479                id: "shell-1".into(),
5480                command: "cargo test".into(),
5481                started_at_ms: Some(10),
5482            });
5483        assert!(
5484            validate_action(
5485                &ControllerAction::CancelShell {
5486                    session_id: "session-1".into(),
5487                    shell_command_id: "shell-1".into(),
5488                },
5489                &snapshot,
5490            )
5491            .is_ok()
5492        );
5493    }
5494
5495    #[tokio::test]
5496    async fn bare_new_action_forwards_an_explicit_safe_project_directory() {
5497        let (app, mut actions, _, _, _) = app();
5498        let cookie = login_cookie(&app).await;
5499        let response = tokio::spawn(
5500            app.oneshot(
5501                Request::post("/api/actions")
5502                    .header(COOKIE, cookie)
5503                    .header(CONTENT_TYPE, "application/json")
5504                    .body(Body::from(
5505                        r#"{"action":"new","profile_id":"codex-1","bundle_id":"hel","target_id":"raw","title":"Raw work","project_directory":"/work/project"}"#,
5506                    ))
5507                    .unwrap(),
5508            ),
5509        );
5510        let action = actions.recv().await.unwrap();
5511        assert_eq!(
5512            action.action,
5513            ControllerAction::New {
5514                workspace_id: String::new(),
5515                profile_id: "codex-1".into(),
5516                bundle_id: "hel".into(),
5517                target_id: "raw".into(),
5518                title: Some("Raw work".into()),
5519                project_directory: Some(PathBuf::from("/work/project")),
5520                dirty_ack: Vec::new(),
5521            }
5522        );
5523        action.reply.send(ActionOutcome::Accepted).unwrap();
5524        assert_eq!(
5525            response.await.unwrap().unwrap().status(),
5526            StatusCode::ACCEPTED
5527        );
5528    }
5529
5530    #[test]
5531    fn new_action_requires_project_directory_exactly_for_bare_targets() {
5532        let (config, state) = sample_config_state();
5533        let snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
5534        let action = |target_id: &str, project_directory: Option<PathBuf>| ControllerAction::New {
5535            workspace_id: String::new(),
5536            profile_id: "codex-1".into(),
5537            bundle_id: "hel".into(),
5538            target_id: target_id.into(),
5539            title: Some("New work".into()),
5540            project_directory,
5541            dirty_ack: Vec::new(),
5542        };
5543
5544        assert!(validate_action(&action("podman", None), &snapshot).is_ok());
5545        assert_eq!(
5546            validate_action(&action("podman", Some("/work".into())), &snapshot)
5547                .unwrap_err()
5548                .status,
5549            StatusCode::BAD_REQUEST
5550        );
5551        assert_eq!(
5552            validate_action(&action("raw", None), &snapshot)
5553                .unwrap_err()
5554                .status,
5555            StatusCode::BAD_REQUEST
5556        );
5557        assert_eq!(
5558            validate_action(&action("raw", Some("relative".into())), &snapshot)
5559                .unwrap_err()
5560                .status,
5561            StatusCode::BAD_REQUEST
5562        );
5563        assert_eq!(
5564            validate_action(&action("raw", Some("/work/../secret".into())), &snapshot)
5565                .unwrap_err()
5566                .status,
5567            StatusCode::BAD_REQUEST
5568        );
5569        assert!(validate_action(&action("raw", Some("/work/project".into())), &snapshot).is_ok());
5570    }
5571
5572    #[tokio::test]
5573    async fn cancel_action_is_typed_and_forwarded() {
5574        let (app, mut actions, _, _, _) = app();
5575        let cookie = login_cookie(&app).await;
5576        let response = tokio::spawn(
5577            app.oneshot(
5578                Request::post("/api/actions")
5579                    .header(COOKIE, cookie)
5580                    .header(CONTENT_TYPE, "application/json")
5581                    .body(Body::from(
5582                        r#"{"action":"cancel","session_id":"session-1"}"#,
5583                    ))
5584                    .unwrap(),
5585            ),
5586        );
5587        let action = actions.recv().await.unwrap();
5588        assert_eq!(
5589            action.action,
5590            ControllerAction::Cancel {
5591                session_id: "session-1".into(),
5592            }
5593        );
5594        action.reply.send(ActionOutcome::Accepted).unwrap();
5595        assert_eq!(
5596            response.await.unwrap().unwrap().status(),
5597            StatusCode::ACCEPTED
5598        );
5599    }
5600
5601    #[tokio::test]
5602    async fn action_validation_accepts_cross_harness_resume_and_rejects_unknown() {
5603        let (mut config, state) = sample_config_state();
5604        config.profiles.insert(
5605            "claude-1".into(),
5606            HarnessProfile {
5607                context_window_bytes: None,
5608                kind: HarnessKind::Claude,
5609                home: "/secret/claude".into(),
5610                environment: BTreeMap::new(),
5611            },
5612        );
5613        let mut snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
5614        snapshot.workspaces.push(ViewerWorkspace {
5615            id: "workspace-1".into(),
5616            name: "One".into(),
5617        });
5618        validate_action(
5619            &ControllerAction::Resume {
5620                session_id: "session-1".into(),
5621                workspace_id: "workspace-1".into(),
5622                profile_id: "claude-1".into(),
5623                target_id: "podman".into(),
5624                queue: ResumeQueueDisposition::Start,
5625                additional_mounts: None,
5626                resource_allocation: None,
5627            },
5628            &snapshot,
5629        )
5630        .unwrap();
5631
5632        let error = validate_action(
5633            &ControllerAction::Resume {
5634                session_id: "session-1".into(),
5635                workspace_id: "missing".into(),
5636                profile_id: "claude-1".into(),
5637                target_id: "podman".into(),
5638                queue: ResumeQueueDisposition::Start,
5639                additional_mounts: None,
5640                resource_allocation: None,
5641            },
5642            &snapshot,
5643        )
5644        .unwrap_err();
5645        assert_eq!(error.status, StatusCode::BAD_REQUEST);
5646
5647        let error = validate_action(
5648            &ControllerAction::Close {
5649                session_id: "not-managed".into(),
5650            },
5651            &snapshot,
5652        )
5653        .unwrap_err();
5654        assert_eq!(error.status, StatusCode::NOT_FOUND);
5655    }
5656
5657    /// A review the daemon is running reaches the phone whole: its tier, what
5658    /// each reviewing agent is doing, and the findings to answer.
5659    #[test]
5660    fn a_running_review_projects_to_the_phone() {
5661        use crate::hel_review_host::{RuntimeReviewView, VerdictKind, VerdictView};
5662        use hel::hel_review::driver::{Resolution, RoleState, RoleStatus, TurnReviewPhase};
5663
5664        let review = RuntimeReviewView {
5665            session_id: "session-1".into(),
5666            tier: hel::hel_review::lanes::ReviewTier::Extended,
5667            phase: TurnReviewPhase::Verdict(hel::hel_review::verdict::ReviewVerdict::Findings {
5668                synthesis: "[P1] src/lib.rs:1 -- unbounded retry".into(),
5669                evidence: Default::default(),
5670            }),
5671            roles: vec![
5672                RoleStatus {
5673                    role: "supervisor".into(),
5674                    label: "Supervisor".into(),
5675                    state: RoleState::Clean,
5676                },
5677                RoleStatus {
5678                    role: "tests".into(),
5679                    label: "Tests".into(),
5680                    state: RoleState::Findings,
5681                },
5682            ],
5683            status: "Enter to act".into(),
5684            verdict: Some(VerdictView {
5685                kind: VerdictKind::Findings,
5686                text: "[P1] src/lib.rs:1 -- unbounded retry".into(),
5687                allowed: vec![
5688                    Resolution::Forwarded,
5689                    Resolution::Dismissed,
5690                    Resolution::Cancelled,
5691                ],
5692            }),
5693        };
5694
5695        let projected = ViewerTurnReview::from_runtime(&review);
5696
5697        assert_eq!(projected.tier, "extended");
5698        assert_eq!(
5699            projected
5700                .roles
5701                .iter()
5702                .map(|role| (role.label.as_str(), role.state.as_str()))
5703                .collect::<Vec<_>>(),
5704            vec![("Supervisor", "done"), ("Tests", "findings")]
5705        );
5706        let verdict = projected.verdict.expect("a findings verdict travels");
5707        assert_eq!(verdict.kind, "findings");
5708        assert!(verdict.text.contains("unbounded retry"));
5709        assert_eq!(verdict.allowed, vec!["forward", "dismiss", "cancel"]);
5710    }
5711
5712    /// A phone can always cancel a review, and can only forward or dismiss one
5713    /// the daemon says is ready for it. The same gate runs in the daemon; this
5714    /// one is what makes the refusal immediate.
5715    #[test]
5716    fn resolving_a_review_is_gated_on_what_the_daemon_published() {
5717        let (config, state) = sample_config_state();
5718        let mut snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
5719
5720        let resolve = |resolution: &str| ControllerAction::ResolveReview {
5721            session_id: "session-1".into(),
5722            resolution: resolution.into(),
5723        };
5724
5725        // No review at all.
5726        let error = validate_action(&resolve("cancel"), &snapshot).unwrap_err();
5727        assert_eq!(error.status, StatusCode::BAD_REQUEST);
5728
5729        snapshot.sessions[0].turn_review = Some(ViewerTurnReview {
5730            tier: "quick".into(),
5731            status: "the reviewer is reading the change…".into(),
5732            roles: Vec::new(),
5733            verdict: None,
5734        });
5735        // Running: cancel works, the rest do not.
5736        validate_action(&resolve("cancel"), &snapshot).unwrap();
5737        assert_eq!(
5738            validate_action(&resolve("forward"), &snapshot)
5739                .unwrap_err()
5740                .status,
5741            StatusCode::BAD_REQUEST
5742        );
5743
5744        // A failed review can be dismissed but has nothing to forward.
5745        snapshot.sessions[0].turn_review = Some(ViewerTurnReview {
5746            tier: "quick".into(),
5747            status: "the review failed".into(),
5748            roles: Vec::new(),
5749            verdict: Some(ViewerReviewVerdict {
5750                kind: "failed".into(),
5751                text: "bifrost exited with 1".into(),
5752                allowed: vec!["dismiss".into(), "cancel".into()],
5753            }),
5754        });
5755        validate_action(&resolve("dismiss"), &snapshot).unwrap();
5756        assert_eq!(
5757            validate_action(&resolve("forward"), &snapshot)
5758                .unwrap_err()
5759                .status,
5760            StatusCode::BAD_REQUEST
5761        );
5762        // A resolution that is not one of the three is refused by name.
5763        assert_eq!(
5764            validate_action(&resolve("approve"), &snapshot)
5765                .unwrap_err()
5766                .status,
5767            StatusCode::BAD_REQUEST
5768        );
5769
5770        // Starting a review needs only a session that exists.
5771        validate_action(
5772            &ControllerAction::StartReview {
5773                session_id: "session-1".into(),
5774            },
5775            &snapshot,
5776        )
5777        .unwrap();
5778        assert_eq!(
5779            validate_action(
5780                &ControllerAction::StartReview {
5781                    session_id: "not-managed".into(),
5782                },
5783                &snapshot,
5784            )
5785            .unwrap_err()
5786            .status,
5787            StatusCode::NOT_FOUND
5788        );
5789    }
5790
5791    #[test]
5792    fn resume_action_refuses_a_target_the_session_cannot_use() {
5793        let (mut config, state) = sample_config_state();
5794        // A project that only exists on GitHub cannot become a checkout on this
5795        // machine, so the bare target stays out of reach for its sessions.
5796        config.bundles.get_mut("hel").unwrap().repositories[0].local = None;
5797        let mut snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
5798        snapshot.workspaces.push(ViewerWorkspace {
5799            id: "workspace-1".into(),
5800            name: "One".into(),
5801        });
5802        assert_eq!(
5803            snapshot.sessions[0].incompatible_resume_targets,
5804            vec!["raw".to_owned()]
5805        );
5806
5807        let error = validate_action(
5808            &ControllerAction::Resume {
5809                session_id: "session-1".into(),
5810                workspace_id: "workspace-1".into(),
5811                profile_id: "codex-1".into(),
5812                target_id: "raw".into(),
5813                queue: ResumeQueueDisposition::Start,
5814                additional_mounts: None,
5815                resource_allocation: None,
5816            },
5817            &snapshot,
5818        )
5819        .unwrap_err();
5820
5821        assert_eq!(error.status, StatusCode::BAD_REQUEST);
5822    }
5823
5824    #[test]
5825    fn move_confirmation_requires_interruption_ack_and_an_explicit_queue_choice() {
5826        let (config, state) = sample_config_state();
5827        let mut snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
5828        snapshot.sessions[0].capabilities.move_session = true;
5829        let selection = MoveSelection {
5830            clear_resource_allocation: false,
5831            session_id: "session-1".into(),
5832            profile_id: Some("codex-1".into()),
5833            target_template_id: Some("podman".into()),
5834            additional_mounts: None,
5835            resource_allocation: None,
5836        };
5837        let preparation = MovePreparation {
5838            selection,
5839            source_profile_id: "codex-1".into(),
5840            source_target_template_id: "podman".into(),
5841            cross_harness: false,
5842            active: true,
5843            queued_commands: vec![hel::hel_state::MaterializedQueuedPrompt {
5844                command_id: "command-1".into(),
5845                kind: hel::hel_state::QueuedCommandKind::Prompt,
5846                content: vec![serde_json::json!({"type": "text", "text": "continue"})],
5847                queued_at_ms: 1,
5848            }],
5849            fingerprint: "fingerprint".into(),
5850            operation_id: "move-1".into(),
5851        };
5852        let request = |queue, acknowledge_interruption| MoveSessionRequest {
5853            preparation: preparation.clone(),
5854            queue,
5855            acknowledge_interruption,
5856        };
5857        assert_eq!(
5858            validate_action(
5859                &ControllerAction::Move {
5860                    request: request(Some(ResumeQueueDisposition::Discard), false),
5861                },
5862                &snapshot,
5863            )
5864            .unwrap_err()
5865            .status,
5866            StatusCode::CONFLICT
5867        );
5868        assert_eq!(
5869            validate_action(
5870                &ControllerAction::Move {
5871                    request: request(None, true),
5872                },
5873                &snapshot,
5874            )
5875            .unwrap_err()
5876            .status,
5877            StatusCode::BAD_REQUEST
5878        );
5879        validate_action(
5880            &ControllerAction::Move {
5881                request: request(Some(ResumeQueueDisposition::Discard), true),
5882            },
5883            &snapshot,
5884        )
5885        .unwrap();
5886    }
5887
5888    #[tokio::test]
5889    async fn snapshot_endpoint_returns_only_public_projection() {
5890        let (app, _, _, _, _) = app();
5891        let cookie = login_cookie(&app).await;
5892        let response = app
5893            .oneshot(
5894                Request::get("/api/snapshot")
5895                    .header(COOKIE, cookie)
5896                    .body(Body::empty())
5897                    .unwrap(),
5898            )
5899            .await
5900            .unwrap();
5901        let body = response.into_body().collect().await.unwrap().to_bytes();
5902        let body = String::from_utf8(body.to_vec()).unwrap();
5903        assert!(body.contains("session-1"));
5904        assert!(!body.contains("secret-token"));
5905        assert!(!body.contains("native-secret-id"));
5906        assert!(!body.contains("/private/source/hel"));
5907
5908        let snapshot: serde_json::Value = serde_json::from_str(&body).unwrap();
5909        let repository = &snapshot["bundles"][0]["repositories"][0];
5910        assert_eq!(repository["id"], "hel");
5911        assert_eq!(repository["github"], "owner/hel");
5912        assert_eq!(repository["destination"], "hel");
5913        assert!(repository.get("local").is_none());
5914    }
5915
5916    #[tokio::test]
5917    async fn snapshot_clock_anchor_is_fresh_even_when_the_projection_has_not_changed() {
5918        let (app, _, _, _, _) = app_with_snapshot(|snapshot| snapshot.server_time_ms = 1);
5919        let cookie = login_cookie(&app).await;
5920        for _ in 0..2 {
5921            let before = hel::clock::epoch_millis();
5922            let response = app
5923                .clone()
5924                .oneshot(
5925                    Request::get("/api/snapshot")
5926                        .header(COOKIE, &cookie)
5927                        .body(Body::empty())
5928                        .unwrap(),
5929                )
5930                .await
5931                .unwrap();
5932            let body = response.into_body().collect().await.unwrap().to_bytes();
5933            let snapshot: ViewerSnapshot = serde_json::from_slice(&body).unwrap();
5934            assert!(snapshot.server_time_ms >= before);
5935            assert!(snapshot.server_time_ms <= hel::clock::epoch_millis());
5936        }
5937    }
5938
5939    #[tokio::test]
5940    async fn conversation_endpoint_returns_authenticated_bounded_deltas() {
5941        let transcript = BrowserTranscript {
5942            latest_seq: 8,
5943            window_start_seq: 3,
5944            reset: false,
5945            entries: vec![
5946                BrowserTranscriptEntry {
5947                    id: 3,
5948                    updated_seq: 3,
5949                    role: "user",
5950                    label: "You".into(),
5951                    recorded_at_ms: None,
5952                    lines: vec!["begin".into()],
5953                    glyph: "\u{276f}",
5954                    tone: "user",
5955                    tool_status: None,
5956                    diffstats: Vec::new(),
5957                },
5958                BrowserTranscriptEntry {
5959                    id: 7,
5960                    updated_seq: 8,
5961                    role: "agent",
5962                    label: "Agent".into(),
5963                    recorded_at_ms: None,
5964                    lines: vec!["live".into()],
5965                    glyph: "\u{25cf}",
5966                    tone: "agent",
5967                    tool_status: None,
5968                    diffstats: Vec::new(),
5969                },
5970            ],
5971        };
5972        let (app, _, _, _, _) =
5973            app_with_conversations(BTreeMap::from([("session-1".into(), transcript)]));
5974        let cookie = login_cookie(&app).await;
5975        let response = app
5976            .oneshot(
5977                Request::get("/api/conversations/session-1?after_seq=3")
5978                    .header(COOKIE, cookie)
5979                    .body(Body::empty())
5980                    .unwrap(),
5981            )
5982            .await
5983            .unwrap();
5984        assert_eq!(response.status(), StatusCode::OK);
5985        let body = response.into_body().collect().await.unwrap().to_bytes();
5986        let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
5987        assert_eq!(body["latest_seq"], 8);
5988        assert_eq!(body["reset"], false);
5989        assert_eq!(body["entries"].as_array().unwrap().len(), 1);
5990        assert_eq!(body["entries"][0]["lines"][0], "live");
5991    }
5992
5993    #[tokio::test]
5994    async fn conversation_endpoint_rejects_cached_transcript_during_transition() {
5995        let transcript = BrowserTranscript {
5996            latest_seq: 1,
5997            window_start_seq: 1,
5998            reset: false,
5999            entries: vec![BrowserTranscriptEntry {
6000                id: 1,
6001                updated_seq: 1,
6002                role: "agent",
6003                label: "Agent".into(),
6004                recorded_at_ms: None,
6005                lines: vec!["stale".into()],
6006                glyph: "●",
6007                tone: "agent",
6008                tool_status: None,
6009                diffstats: Vec::new(),
6010            }],
6011        };
6012        let (app, _, _, _, _) = app_with(
6013            BTreeMap::from([("session-1".into(), transcript)]),
6014            |snapshot| snapshot.sessions[0].transitioning = true,
6015        );
6016        let cookie = login_cookie(&app).await;
6017        let response = app
6018            .oneshot(
6019                Request::get("/api/conversations/session-1")
6020                    .header(COOKIE, cookie)
6021                    .body(Body::empty())
6022                    .unwrap(),
6023            )
6024            .await
6025            .unwrap();
6026        assert_eq!(response.status(), StatusCode::CONFLICT);
6027    }
6028
6029    #[tokio::test]
6030    async fn conversation_read_receipt_never_contends_with_a_running_action() {
6031        let (app, mut actions, mut receipts, _, _) = app();
6032        let cookie = login_cookie(&app).await;
6033        // A prompt for the same session stays in flight for the whole test, so
6034        // a receipt that still travelled the action pipeline would either
6035        // queue behind it or be rejected for the occupied session slot.
6036        let prompt = tokio::spawn(
6037            app.clone().oneshot(
6038                Request::post("/api/actions")
6039                    .header(COOKIE, cookie.clone())
6040                    .header(CONTENT_TYPE, "application/json")
6041                    .body(Body::from(
6042                        r#"{"action":"prompt","session_id":"session-1","text":"ship it"}"#,
6043                    ))
6044                    .unwrap(),
6045            ),
6046        );
6047        let action = actions.recv().await.unwrap();
6048
6049        let response = tokio::spawn(
6050            app.oneshot(
6051                Request::post("/api/conversations/session-1/read")
6052                    .header(COOKIE, cookie)
6053                    .header(CONTENT_TYPE, "application/json")
6054                    .body(Body::from(r#"{"through":42}"#))
6055                    .unwrap(),
6056            ),
6057        );
6058        let receipt = receipts.recv().await.unwrap();
6059        assert_eq!(receipt.session_id, "session-1");
6060        assert_eq!(receipt.through, 42);
6061        receipt.reply.send(Ok(())).unwrap();
6062        assert_eq!(
6063            response.await.unwrap().unwrap().status(),
6064            StatusCode::NO_CONTENT
6065        );
6066        assert!(
6067            actions.try_recv().is_err(),
6068            "a read receipt must not queue a controller action"
6069        );
6070
6071        action.reply.send(ActionOutcome::Accepted).unwrap();
6072        assert_eq!(
6073            prompt.await.unwrap().unwrap().status(),
6074            StatusCode::ACCEPTED
6075        );
6076    }
6077
6078    #[tokio::test]
6079    async fn each_rejected_action_keeps_its_own_status_and_guidance() {
6080        for (outcome, status, guidance) in [
6081            (
6082                ActionOutcome::Busy,
6083                StatusCode::TOO_MANY_REQUESTS,
6084                "concurrent action limit",
6085            ),
6086            (
6087                ActionOutcome::SessionBusy,
6088                StatusCode::CONFLICT,
6089                "another operation is already running",
6090            ),
6091            (
6092                ActionOutcome::NotCancellable,
6093                StatusCode::CONFLICT,
6094                "no cancellable operation",
6095            ),
6096            (
6097                ActionOutcome::Failed,
6098                StatusCode::INTERNAL_SERVER_ERROR,
6099                "could not start this action",
6100            ),
6101        ] {
6102            let (app, mut actions, _, _, _) = app();
6103            let cookie = login_cookie(&app).await;
6104            let response = tokio::spawn(
6105                app.oneshot(
6106                    Request::post("/api/actions")
6107                        .header(COOKIE, cookie)
6108                        .header(CONTENT_TYPE, "application/json")
6109                        .body(Body::from(r#"{"action":"close","session_id":"session-1"}"#))
6110                        .unwrap(),
6111                ),
6112            );
6113            let request = actions.recv().await.unwrap();
6114            request.reply.send(outcome).unwrap();
6115
6116            let response = response.await.unwrap().unwrap();
6117            assert_eq!(response.status(), status, "{outcome:?}");
6118            let body = response.into_body().collect().await.unwrap().to_bytes();
6119            let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
6120            let error = body["error"].as_str().unwrap();
6121            assert!(error.contains(guidance), "{outcome:?} answered {error:?}");
6122        }
6123    }
6124
6125    #[tokio::test]
6126    async fn the_viewer_shows_a_session_whose_action_failed_after_it_was_accepted() {
6127        // An accepted action reports its outcome only through snapshots, so
6128        // the application has to react to `has_error` for a late failure to be
6129        // visible at all.
6130        let (app, _, _, _, _) = app();
6131        let script = fetch_text(app, "/viewer.js").await;
6132        assert!(script.contains("has_error"), "viewer ignores has_error");
6133    }
6134
6135    /// Every response, not only the page, carries the policy. A header that
6136    /// depends on which handler answered is a header somebody will forget.
6137    #[tokio::test]
6138    async fn every_response_carries_the_security_headers() {
6139        for path in [
6140            "/",
6141            "/viewer.js",
6142            "/viewer.css",
6143            "/manifest.webmanifest",
6144            "/api/snapshot",
6145        ] {
6146            let (app, _, _, _, _) = app();
6147            let response = app
6148                .oneshot(Request::get(path).body(Body::empty()).unwrap())
6149                .await
6150                .unwrap();
6151            let headers = response.headers();
6152            let policy = headers
6153                .get(CONTENT_SECURITY_POLICY_HEADER)
6154                .unwrap_or_else(|| panic!("{path} carries no content-security policy"))
6155                .to_str()
6156                .unwrap();
6157            assert!(
6158                policy.starts_with("default-src 'none';"),
6159                "{path} does not refuse unlisted sources: {policy}"
6160            );
6161            assert!(
6162                policy.contains("script-src 'self'") && !policy.contains("unsafe-inline"),
6163                "{path} permits inline script: {policy}"
6164            );
6165            assert!(
6166                policy.contains("frame-ancestors 'none'"),
6167                "{path} can be framed: {policy}"
6168            );
6169            assert_eq!(
6170                headers.get(X_CONTENT_TYPE_OPTIONS).unwrap(),
6171                "nosniff",
6172                "{path} permits content sniffing"
6173            );
6174            assert_eq!(
6175                headers.get(REFERRER_POLICY).unwrap(),
6176                "no-referrer",
6177                "{path} leaks a referrer"
6178            );
6179        }
6180    }
6181
6182    /// The policy forbids inline script and style, so the page must contain
6183    /// neither. A page that did would simply fail to run in a browser, which
6184    /// no Rust test would otherwise notice.
6185    #[tokio::test]
6186    async fn the_page_carries_no_inline_script_or_style() {
6187        let (app, _, _, _, _) = app();
6188        let page = fetch_text(app, "/").await;
6189        assert!(
6190            !page.contains("<script>") && !page.contains("<style>"),
6191            "the page inlines script or style, which the policy blocks"
6192        );
6193        assert!(
6194            page.contains(r#"src="/viewer.js""#) && page.contains(r#"href="/viewer.css""#),
6195            "the page does not load its script and style as separate assets"
6196        );
6197    }
6198
6199    /// A cached API answer is a lie about live session state, and a cached
6200    /// service worker is what keeps a phone on a superseded application.
6201    #[tokio::test]
6202    async fn live_state_and_the_service_worker_are_never_stored() {
6203        for path in ["/", "/service-worker.js", "/api/snapshot"] {
6204            let (app, _, _, _, _) = app();
6205            let response = app
6206                .oneshot(Request::get(path).body(Body::empty()).unwrap())
6207                .await
6208                .unwrap();
6209            assert_eq!(
6210                response.headers().get(CACHE_CONTROL).unwrap(),
6211                "no-store",
6212                "{path} may be stored"
6213            );
6214        }
6215    }
6216
6217    /// The worker must leave live state alone entirely rather than caching it
6218    /// and hoping the cache is fresh.
6219    #[test]
6220    fn the_service_worker_declines_to_handle_live_state() {
6221        assert!(
6222            SERVICE_WORKER.contains("url.pathname.startsWith('/api/')"),
6223            "the service worker does not exclude the API"
6224        );
6225        assert!(
6226            SERVICE_WORKER.contains("url.pathname.startsWith('/auth/')"),
6227            "the service worker does not exclude authentication"
6228        );
6229        assert!(
6230            SERVICE_WORKER.contains("caches.delete"),
6231            "the service worker never deletes a superseded cache"
6232        );
6233    }
6234
6235    /// The vendored assets have to reach the browser, not merely exist in the
6236    /// repository: the manifest names them and a phone installs from it.
6237    #[tokio::test]
6238    async fn the_installable_assets_are_served() {
6239        for (path, content_type) in [
6240            ("/icon-192.png", "image/png"),
6241            ("/icon-512.png", "image/png"),
6242            ("/maskable-512.png", "image/png"),
6243            ("/apple-touch-icon.png", "image/png"),
6244            ("/fonts/jetbrains-mono.woff2", "font/woff2"),
6245        ] {
6246            let (app, _, _, _, _) = app();
6247            let response = app
6248                .oneshot(Request::get(path).body(Body::empty()).unwrap())
6249                .await
6250                .unwrap();
6251            assert_eq!(response.status(), StatusCode::OK, "{path} is not served");
6252            assert_eq!(
6253                response.headers().get(CONTENT_TYPE).unwrap(),
6254                content_type,
6255                "{path} is served as the wrong type"
6256            );
6257        }
6258    }
6259
6260    /// Fetch one unauthenticated asset and return it as text. Serving the
6261    /// application from several files means a check about the application has
6262    /// to name the file it is about.
6263    async fn fetch_text(app: Router, path: &str) -> String {
6264        let response = app
6265            .oneshot(Request::get(path).body(Body::empty()).unwrap())
6266            .await
6267            .unwrap();
6268        assert_eq!(response.status(), StatusCode::OK, "{path} is not served");
6269        let body = response.into_body().collect().await.unwrap().to_bytes();
6270        String::from_utf8(body.to_vec()).expect("assets are UTF-8")
6271    }
6272
6273    #[tokio::test]
6274    async fn repeated_wrong_codes_lock_the_login_endpoint() {
6275        let (app, _, _, _, _) = app();
6276        let attempt = |code: &'static str| {
6277            let app = app.clone();
6278            async move {
6279                app.oneshot(
6280                    Request::post("/auth/session")
6281                        .header(CONTENT_TYPE, "application/json")
6282                        .body(Body::from(format!(r#"{{"code":"{code}"}}"#)))
6283                        .unwrap(),
6284                )
6285                .await
6286                .unwrap()
6287                .status()
6288            }
6289        };
6290        for _ in 0..MAX_CODE_FAILURES {
6291            assert_eq!(attempt("000000").await, StatusCode::UNAUTHORIZED);
6292        }
6293        assert_eq!(attempt("000000").await, StatusCode::TOO_MANY_REQUESTS);
6294        // Even the right code waits out the lockout, so guessing cannot be
6295        // hidden behind a correct-looking attempt.
6296        assert_eq!(attempt("123456").await, StatusCode::TOO_MANY_REQUESTS);
6297    }
6298
6299    #[test]
6300    fn viewer_code_lockouts_lengthen_instead_of_resetting_after_every_wait() {
6301        let serve_one_lockout = |guard: &mut CodeGuard, now: Instant| {
6302            for _ in 0..MAX_CODE_FAILURES {
6303                assert!(!guard.locked_at(now));
6304                guard.record_failure_at(now);
6305            }
6306            assert!(guard.locked_at(now));
6307            guard.locked_until.expect("the guard is locked") - now
6308        };
6309
6310        let start = Instant::now();
6311        let mut guard = CodeGuard::default();
6312        let first = serve_one_lockout(&mut guard, start);
6313        assert_eq!(first, CODE_LOCKOUT_BASE);
6314
6315        // Waiting out a lockout buys another run of attempts, not another
6316        // equally short lockout: a guard that reset here gave an attacker
6317        // MAX_CODE_FAILURES guesses every CODE_LOCKOUT_BASE for ever.
6318        let second_round = start + first;
6319        let second = serve_one_lockout(&mut guard, second_round);
6320        assert_eq!(second, CODE_LOCKOUT_BASE * 2);
6321        let third = serve_one_lockout(&mut guard, second_round + second);
6322        assert_eq!(third, CODE_LOCKOUT_BASE * 4);
6323        assert_eq!(code_lockout(u32::MAX), CODE_LOCKOUT_CAP);
6324
6325        // A correct code clears the history, so one mistyped digit tomorrow
6326        // still costs only the shortest wait.
6327        let mut recovered = CodeGuard::default();
6328        assert_eq!(serve_one_lockout(&mut recovered, start), CODE_LOCKOUT_BASE);
6329    }
6330
6331    #[test]
6332    fn persisted_cookie_key_survives_a_restart_and_stays_owner_only() {
6333        let directory = tempfile::tempdir().unwrap();
6334        let path = directory.path().join("phone-cookie-key");
6335
6336        let first = load_or_create_cookie_key(&path).unwrap();
6337        assert!(first.len() >= COOKIE_KEY_BYTES);
6338        assert_eq!(std::fs::read(&path).unwrap(), first);
6339        #[cfg(unix)]
6340        {
6341            use std::os::unix::fs::PermissionsExt as _;
6342            let mode = std::fs::metadata(&path).unwrap().permissions().mode();
6343            assert_eq!(mode & 0o777, 0o600);
6344        }
6345
6346        // Two server processes started from the same key file honour each
6347        // other's cookies; a process that kept its generated key would not.
6348        let mut restarted = detached_options();
6349        restarted
6350            .set_cookie_key(load_or_create_cookie_key(&path).unwrap())
6351            .unwrap();
6352        let mut original = detached_options();
6353        original.set_cookie_key(first.clone()).unwrap();
6354        let cookie = signed_cookie_value(&original.cookie_key, "test-viewer", 200);
6355        assert!(session_cookie_valid(&restarted.cookie_key, &cookie, 100));
6356        assert!(!session_cookie_valid(
6357            &detached_options().cookie_key,
6358            &cookie,
6359            100
6360        ));
6361
6362        // Deleting the key file is the explicit sign-everyone-out gesture.
6363        std::fs::remove_file(&path).unwrap();
6364        let rotated = load_or_create_cookie_key(&path).unwrap();
6365        assert_ne!(rotated, first);
6366        assert!(!session_cookie_valid(&rotated, &cookie, 100));
6367    }
6368
6369    #[test]
6370    fn corrupt_cookie_key_is_regenerated_instead_of_blocking_startup() {
6371        let directory = tempfile::tempdir().unwrap();
6372        let path = directory.path().join("phone-cookie-key");
6373        std::fs::write(&path, b"short").unwrap();
6374
6375        let key = load_or_create_cookie_key(&path).unwrap();
6376
6377        assert!(key.len() >= COOKIE_KEY_BYTES);
6378        assert_eq!(std::fs::read(&path).unwrap(), key);
6379        assert_eq!(load_or_create_cookie_key(&path).unwrap(), key);
6380    }
6381}