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