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