Skip to main content

mj_controller/
hel_server.rs

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