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            theme: Default::default(),
3633            phone: Default::default(),
3634            review: Default::default(),
3635            startup: Default::default(),
3636            profiles: BTreeMap::from([(
3637                "codex-1".into(),
3638                HarnessProfile {
3639                    context_window_bytes: None,
3640                    kind: HarnessKind::Codex,
3641                    home: "/highly/secret/codex".into(),
3642                    environment: BTreeMap::from([("GH_TOKEN".into(), "secret-token".into())]),
3643                },
3644            )]),
3645            bundles: BTreeMap::from([(
3646                "hel".into(),
3647                ProjectBundle {
3648                    primary_repo: "hel".into(),
3649                    repositories: vec![ProjectRepository {
3650                        id: "hel".into(),
3651                        github: Some("owner/hel".into()),
3652                        local: Some("/private/source/hel".into()),
3653                        destination: "hel".into(),
3654                        git_ref: None,
3655                    }],
3656                },
3657            )]),
3658            targets: BTreeMap::from([
3659                (
3660                    "podman".into(),
3661                    TargetTemplate::LocalPodman {
3662                        container: ContainerTemplate {
3663                            image: "secret.registry/image".into(),
3664                            pull_policy: Default::default(),
3665                            platform: None,
3666                            cpus: None,
3667                            memory: None,
3668                            environment: BTreeMap::from([("TOKEN".into(), "secret-target".into())]),
3669                            workspace_storage: Default::default(),
3670                        },
3671                    },
3672                ),
3673                ("raw".into(), TargetTemplate::LocalBare),
3674            ]),
3675        };
3676        let state = HelState {
3677            version: STATE_VERSION,
3678            sessions: BTreeMap::from([(
3679                "session-1".into(),
3680                SessionRecord {
3681                    workspace_id: hel::hel_workspace::DEFAULT_WORKSPACE_ID.to_owned(),
3682                    archived: false,
3683                    container_cpus: None,
3684                    container_memory: None,
3685                    id: "session-1".into(),
3686                    title: "Build Hel".into(),
3687                    harness_kind: HarnessKind::Codex,
3688                    last_profile: "codex-1".into(),
3689                    bundle_id: "hel".into(),
3690                    project_directory: None,
3691                    managed_worktree: None,
3692                    target_template_id: "podman".into(),
3693                    resource_allocation: None,
3694                    additional_mounts: vec![],
3695                    state: SessionState::Running,
3696                    target: None,
3697                    native_session_id: Some("native-secret-id".into()),
3698                    acp_session_title: Some("Build Hel".into()),
3699                    session_title_override: None,
3700                    created_at: "now".into(),
3701                    updated_at: "now".into(),
3702                    viewed_through_event_ordinal: 0,
3703                    draft_input: String::new(),
3704                    last_error: Some("secret-token at /highly/secret/codex".into()),
3705                    last_checkpoint_error: None,
3706                    checkpoint: None,
3707                },
3708            )]),
3709            mount_history: BTreeMap::new(),
3710            container_sizes: BTreeMap::new(),
3711        };
3712        (config, state)
3713    }
3714
3715    type TestServer = (
3716        Router,
3717        mpsc::Receiver<ControllerRequest>,
3718        mpsc::Receiver<ReadReceiptRequest>,
3719        mpsc::Receiver<PreflightRequest>,
3720        mpsc::Receiver<ClientStateRequest>,
3721    );
3722
3723    fn app() -> TestServer {
3724        app_with_conversations(BTreeMap::new())
3725    }
3726
3727    fn app_with_move_receiver() -> (Router, mpsc::Receiver<MovePreparationRequest>) {
3728        let (config, state) = sample_config_state();
3729        let mut snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
3730        snapshot.sessions[0].capabilities.move_session = true;
3731        let (_snapshot_tx, snapshot_rx) = watch::channel(snapshot);
3732        let (_conversation_tx, conversation_rx) = watch::channel(BTreeMap::new());
3733        let (action_tx, _action_rx) = mpsc::channel(8);
3734        let (bundle_tx, _bundle_rx) = mpsc::channel(8);
3735        let (receipt_tx, _receipt_rx) = mpsc::channel(8);
3736        let (preflight_tx, _preflight_rx) = mpsc::channel(8);
3737        let (move_preparation_tx, move_preparation_rx) = mpsc::channel(8);
3738        let (client_state_tx, _client_state_rx) = mpsc::channel(8);
3739        let options = test_options(
3740            snapshot_rx,
3741            conversation_rx,
3742            action_tx,
3743            bundle_tx,
3744            receipt_tx,
3745            preflight_tx,
3746            move_preparation_tx,
3747            client_state_tx,
3748        )
3749        .with_test_credentials("123456", b"01234567890123456789012345678901");
3750        (router(options), move_preparation_rx)
3751    }
3752
3753    fn app_with_conversations(conversations: BTreeMap<String, BrowserTranscript>) -> TestServer {
3754        app_with(conversations, |_| {})
3755    }
3756
3757    fn app_with_snapshot(adjust: impl FnOnce(&mut ViewerSnapshot)) -> TestServer {
3758        app_with(BTreeMap::new(), adjust)
3759    }
3760
3761    fn app_with(
3762        conversations: BTreeMap<String, BrowserTranscript>,
3763        adjust: impl FnOnce(&mut ViewerSnapshot),
3764    ) -> TestServer {
3765        let (config, state) = sample_config_state();
3766        let mut snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
3767        adjust(&mut snapshot);
3768        let (_snapshot_tx, snapshot_rx) = watch::channel(snapshot);
3769        let (_conversation_tx, conversation_rx) = watch::channel(conversations);
3770        let (action_tx, action_rx) = mpsc::channel(8);
3771        let (bundle_tx, _bundle_rx) = mpsc::channel(8);
3772        let (receipt_tx, receipt_rx) = mpsc::channel(8);
3773        let (preflight_tx, preflight_rx) = mpsc::channel(8);
3774        let (move_preparation_tx, _move_preparation_rx) = mpsc::channel(8);
3775        let (client_state_tx, client_state_rx) = mpsc::channel(8);
3776        let options = test_options(
3777            snapshot_rx,
3778            conversation_rx,
3779            action_tx,
3780            bundle_tx,
3781            receipt_tx,
3782            preflight_tx,
3783            move_preparation_tx,
3784            client_state_tx,
3785        )
3786        .with_test_credentials("123456", b"01234567890123456789012345678901");
3787        (
3788            router(options),
3789            action_rx,
3790            receipt_rx,
3791            preflight_rx,
3792            client_state_rx,
3793        )
3794    }
3795
3796    fn app_with_bundle_receiver() -> (Router, mpsc::Receiver<BundleRequest>) {
3797        let (config, state) = sample_config_state();
3798        let (_snapshot_tx, snapshot_rx) =
3799            watch::channel(ViewerSnapshot::from_config_state(&config, &state, 1));
3800        let (_conversation_tx, conversation_rx) = watch::channel(BTreeMap::new());
3801        let (action_tx, _action_rx) = mpsc::channel(8);
3802        let (bundle_tx, bundle_rx) = mpsc::channel(8);
3803        let (receipt_tx, _receipt_rx) = mpsc::channel(8);
3804        let (preflight_tx, _preflight_rx) = mpsc::channel(8);
3805        let (move_preparation_tx, _move_preparation_rx) = mpsc::channel(8);
3806        let (client_state_tx, _client_state_rx) = mpsc::channel(8);
3807        let options = test_options(
3808            snapshot_rx,
3809            conversation_rx,
3810            action_tx,
3811            bundle_tx,
3812            receipt_tx,
3813            preflight_tx,
3814            move_preparation_tx,
3815            client_state_tx,
3816        )
3817        .with_test_credentials("123456", b"01234567890123456789012345678901");
3818        (router(options), bundle_rx)
3819    }
3820
3821    // Keep this test factory's arguments aligned with `ServerRequests`; each
3822    // channel is asserted independently by the HTTP behavior tests below.
3823    #[allow(clippy::too_many_arguments)]
3824    fn test_options(
3825        snapshot_rx: watch::Receiver<ViewerSnapshot>,
3826        conversation_rx: watch::Receiver<BTreeMap<String, BrowserTranscript>>,
3827        action_tx: mpsc::Sender<ControllerRequest>,
3828        bundle_tx: mpsc::Sender<BundleRequest>,
3829        receipt_tx: mpsc::Sender<ReadReceiptRequest>,
3830        preflight_tx: mpsc::Sender<PreflightRequest>,
3831        move_preparation_tx: mpsc::Sender<MovePreparationRequest>,
3832        client_state_tx: mpsc::Sender<ClientStateRequest>,
3833    ) -> ServerOptions {
3834        test_options_with_dictation(
3835            snapshot_rx,
3836            conversation_rx,
3837            action_tx,
3838            bundle_tx,
3839            receipt_tx,
3840            preflight_tx,
3841            move_preparation_tx,
3842            client_state_tx,
3843        )
3844        .0
3845    }
3846
3847    #[allow(clippy::too_many_arguments)]
3848    fn test_options_with_dictation(
3849        snapshot_rx: watch::Receiver<ViewerSnapshot>,
3850        conversation_rx: watch::Receiver<BTreeMap<String, BrowserTranscript>>,
3851        action_tx: mpsc::Sender<ControllerRequest>,
3852        bundle_tx: mpsc::Sender<BundleRequest>,
3853        receipt_tx: mpsc::Sender<ReadReceiptRequest>,
3854        preflight_tx: mpsc::Sender<PreflightRequest>,
3855        move_preparation_tx: mpsc::Sender<MovePreparationRequest>,
3856        client_state_tx: mpsc::Sender<ClientStateRequest>,
3857    ) -> (ServerOptions, mpsc::Receiver<DictationRequest>) {
3858        let (dictation_tx, dictation_rx) = mpsc::channel(8);
3859        let options = ServerOptions::new(
3860            "127.0.0.1:0".parse().unwrap(),
3861            snapshot_rx,
3862            conversation_rx,
3863            ServerRequests {
3864                action_tx,
3865                bundle_tx,
3866                receipt_tx,
3867                preflight_tx,
3868                move_preparation_tx,
3869                client_state_tx,
3870                dictation_tx,
3871            },
3872        )
3873        .unwrap();
3874        (options, dictation_rx)
3875    }
3876
3877    fn app_with_dictation_receiver() -> (Router, mpsc::Receiver<DictationRequest>) {
3878        let (config, state) = sample_config_state();
3879        let (_snapshot_tx, snapshot_rx) =
3880            watch::channel(ViewerSnapshot::from_config_state(&config, &state, 1));
3881        let (_conversation_tx, conversation_rx) = watch::channel(BTreeMap::new());
3882        let (action_tx, _action_rx) = mpsc::channel(8);
3883        let (bundle_tx, _bundle_rx) = mpsc::channel(8);
3884        let (receipt_tx, _receipt_rx) = mpsc::channel(8);
3885        let (preflight_tx, _preflight_rx) = mpsc::channel(8);
3886        let (move_preparation_tx, _move_preparation_rx) = mpsc::channel(8);
3887        let (client_state_tx, _client_state_rx) = mpsc::channel(8);
3888        let (options, dictation_rx) = test_options_with_dictation(
3889            snapshot_rx,
3890            conversation_rx,
3891            action_tx,
3892            bundle_tx,
3893            receipt_tx,
3894            preflight_tx,
3895            move_preparation_tx,
3896            client_state_tx,
3897        );
3898        (
3899            router(options.with_test_credentials("123456", b"01234567890123456789012345678901")),
3900            dictation_rx,
3901        )
3902    }
3903
3904    fn detached_options() -> ServerOptions {
3905        let (config, state) = sample_config_state();
3906        let (_snapshot_tx, snapshot_rx) =
3907            watch::channel(ViewerSnapshot::from_config_state(&config, &state, 1));
3908        let (_conversation_tx, conversation_rx) = watch::channel(BTreeMap::new());
3909        let (action_tx, _action_rx) = mpsc::channel(1);
3910        let (bundle_tx, _bundle_rx) = mpsc::channel(1);
3911        let (receipt_tx, _receipt_rx) = mpsc::channel(1);
3912        let (preflight_tx, _preflight_rx) = mpsc::channel(1);
3913        let (move_preparation_tx, _move_preparation_rx) = mpsc::channel(1);
3914        let (client_state_tx, _client_state_rx) = mpsc::channel(1);
3915        test_options(
3916            snapshot_rx,
3917            conversation_rx,
3918            action_tx,
3919            bundle_tx,
3920            receipt_tx,
3921            preflight_tx,
3922            move_preparation_tx,
3923            client_state_tx,
3924        )
3925    }
3926
3927    /// A valid session cookie for the test server's key.
3928    ///
3929    /// Most checks are about what an authenticated request does rather than
3930    /// about how it authenticated, and going through the login route for each
3931    /// one buys nothing.
3932    fn cookie() -> String {
3933        format!(
3934            "{COOKIE_NAME}={}",
3935            signed_cookie_value(
3936                b"01234567890123456789012345678901",
3937                "test-viewer",
3938                now_unix().saturating_add(3600)
3939            )
3940        )
3941    }
3942
3943    fn valid_wav() -> Bytes {
3944        let samples = vec![0_u8; 320];
3945        let mut wav = Vec::with_capacity(44 + samples.len());
3946        wav.extend_from_slice(b"RIFF");
3947        wav.extend_from_slice(&(36_u32 + samples.len() as u32).to_le_bytes());
3948        wav.extend_from_slice(b"WAVEfmt ");
3949        wav.extend_from_slice(&16_u32.to_le_bytes());
3950        wav.extend_from_slice(&1_u16.to_le_bytes());
3951        wav.extend_from_slice(&1_u16.to_le_bytes());
3952        wav.extend_from_slice(&16_000_u32.to_le_bytes());
3953        wav.extend_from_slice(&32_000_u32.to_le_bytes());
3954        wav.extend_from_slice(&2_u16.to_le_bytes());
3955        wav.extend_from_slice(&16_u16.to_le_bytes());
3956        wav.extend_from_slice(b"data");
3957        wav.extend_from_slice(&(samples.len() as u32).to_le_bytes());
3958        wav.extend_from_slice(&samples);
3959        Bytes::from(wav)
3960    }
3961
3962    async fn login_cookie(app: &Router) -> String {
3963        let response = app
3964            .clone()
3965            .oneshot(
3966                Request::post("/auth/session")
3967                    .header(CONTENT_TYPE, "application/json")
3968                    .body(Body::from(r#"{"code":"123456"}"#))
3969                    .unwrap(),
3970            )
3971            .await
3972            .unwrap();
3973        assert_eq!(response.status(), StatusCode::NO_CONTENT);
3974        response
3975            .headers()
3976            .get(SET_COOKIE)
3977            .unwrap()
3978            .to_str()
3979            .unwrap()
3980            .split(';')
3981            .next()
3982            .unwrap()
3983            .to_string()
3984    }
3985
3986    #[tokio::test]
3987    async fn dictation_availability_requires_auth_and_forwards_typed_request() {
3988        let (app, mut requests) = app_with_dictation_receiver();
3989        let unauthorized = app
3990            .clone()
3991            .oneshot(
3992                Request::get("/api/sessions/session-1/dictation")
3993                    .body(Body::empty())
3994                    .unwrap(),
3995            )
3996            .await
3997            .unwrap();
3998        assert_eq!(unauthorized.status(), StatusCode::UNAUTHORIZED);
3999        assert!(requests.try_recv().is_err());
4000
4001        let cookie = login_cookie(&app).await;
4002        let pending = tokio::spawn({
4003            let app = app.clone();
4004            async move {
4005                app.oneshot(
4006                    Request::get("/api/sessions/session-1/dictation")
4007                        .header(COOKIE, cookie)
4008                        .body(Body::empty())
4009                        .unwrap(),
4010                )
4011                .await
4012                .unwrap()
4013            }
4014        });
4015        let request = requests.recv().await.unwrap();
4016        assert_eq!(request.session_id, "session-1");
4017        assert!(matches!(
4018            request.operation,
4019            DictationOperation::Availability
4020        ));
4021        request
4022            .reply
4023            .send(Ok(DictationResponse::Availability {
4024                available: true,
4025                reason: None,
4026            }))
4027            .unwrap();
4028        let response = pending.await.unwrap();
4029        assert_eq!(response.status(), StatusCode::OK);
4030        let body = response.into_body().collect().await.unwrap().to_bytes();
4031        assert_eq!(&body[..], br#"{"available":true}"#);
4032    }
4033
4034    #[tokio::test]
4035    async fn dictation_rejects_bad_wav_before_controller_dispatch() {
4036        let (app, mut requests) = app_with_dictation_receiver();
4037        let cookie = login_cookie(&app).await;
4038        let response = app
4039            .oneshot(
4040                Request::post("/api/sessions/session-1/dictation")
4041                    .header(COOKIE, cookie)
4042                    .header(CONTENT_TYPE, "audio/wav")
4043                    .body(Body::from("not wav"))
4044                    .unwrap(),
4045            )
4046            .await
4047            .unwrap();
4048        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
4049        assert!(requests.try_recv().is_err());
4050    }
4051
4052    #[tokio::test]
4053    async fn dictation_rejects_a_third_upload_before_reading_its_body() {
4054        let (app, mut requests) = app_with_dictation_receiver();
4055        let cookie = login_cookie(&app).await;
4056        let request = || {
4057            Request::post("/api/sessions/session-1/dictation")
4058                .header(COOKIE, cookie.clone())
4059                .header(CONTENT_TYPE, "audio/wav")
4060                .body(Body::from(valid_wav()))
4061                .unwrap()
4062        };
4063        let first = tokio::spawn({
4064            let app = app.clone();
4065            let request = request();
4066            async move { app.oneshot(request).await.unwrap() }
4067        });
4068        let second = tokio::spawn({
4069            let app = app.clone();
4070            let request = request();
4071            async move { app.oneshot(request).await.unwrap() }
4072        });
4073        let first_request = requests.recv().await.unwrap();
4074        let second_request = requests.recv().await.unwrap();
4075        let third = app
4076            .oneshot(
4077                Request::post("/api/sessions/session-1/dictation")
4078                    .header(COOKIE, cookie)
4079                    .header(CONTENT_TYPE, "audio/wav")
4080                    .body(Body::from_stream(futures::stream::poll_fn(
4081                        |_| -> std::task::Poll<Option<Result<Bytes, std::io::Error>>> {
4082                            panic!("overloaded dictation polled its body")
4083                        },
4084                    )))
4085                    .unwrap(),
4086            )
4087            .await
4088            .unwrap();
4089        assert_eq!(third.status(), StatusCode::TOO_MANY_REQUESTS);
4090        first_request
4091            .reply
4092            .send(Ok(DictationResponse::Transcript {
4093                text: "first".into(),
4094            }))
4095            .unwrap();
4096        second_request
4097            .reply
4098            .send(Ok(DictationResponse::Transcript {
4099                text: "second".into(),
4100            }))
4101            .unwrap();
4102        assert_eq!(first.await.unwrap().status(), StatusCode::OK);
4103        assert_eq!(second.await.unwrap().status(), StatusCode::OK);
4104    }
4105
4106    #[tokio::test]
4107    async fn dictation_upload_rejects_unauthorized_missing_and_oversized_requests() {
4108        let (app, mut requests) = app_with_dictation_receiver();
4109        let response = app
4110            .clone()
4111            .oneshot(
4112                Request::post("/api/sessions/session-1/dictation")
4113                    .body(Body::from(valid_wav()))
4114                    .unwrap(),
4115            )
4116            .await
4117            .unwrap();
4118        assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
4119        let cookie = login_cookie(&app).await;
4120        let response = app
4121            .clone()
4122            .oneshot(
4123                Request::post("/api/sessions/missing/dictation")
4124                    .header(COOKIE, &cookie)
4125                    .body(Body::from(valid_wav()))
4126                    .unwrap(),
4127            )
4128            .await
4129            .unwrap();
4130        assert_eq!(response.status(), StatusCode::NOT_FOUND);
4131        // Exercise the streamed body limit without relying on Content-Length.
4132        let response = app
4133            .oneshot(
4134                Request::post("/api/sessions/session-1/dictation")
4135                    .header(COOKIE, cookie)
4136                    .body(Body::from(vec![0_u8; MAX_AUDIO_BYTES + 1]))
4137                    .unwrap(),
4138            )
4139            .await
4140            .unwrap();
4141        assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE);
4142        assert!(requests.try_recv().is_err());
4143    }
4144
4145    #[tokio::test]
4146    async fn dictation_provider_failure_is_actionable_and_does_not_expose_details() {
4147        let (app, mut requests) = app_with_dictation_receiver();
4148        let cookie = login_cookie(&app).await;
4149        let pending = tokio::spawn(async move {
4150            app.oneshot(
4151                Request::post("/api/sessions/session-1/dictation")
4152                    .header(COOKIE, cookie)
4153                    .body(Body::from(valid_wav()))
4154                    .unwrap(),
4155            )
4156            .await
4157            .unwrap()
4158        });
4159        let request = requests.recv().await.unwrap();
4160        request
4161            .reply
4162            .send(Err(DictationError::Provider(
4163                "private provider details".into(),
4164            )))
4165            .unwrap();
4166        let response = pending.await.unwrap();
4167        assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
4168        let bytes = response.into_body().collect().await.unwrap().to_bytes();
4169        let body = String::from_utf8(bytes.to_vec()).unwrap();
4170        assert!(body.contains("transcription"));
4171        assert!(!body.contains("private provider details"));
4172    }
4173
4174    #[tokio::test]
4175    async fn dropped_dictation_handler_cancels_controller_request() {
4176        let (app, mut requests) = app_with_dictation_receiver();
4177        let cookie = login_cookie(&app).await;
4178        let pending = tokio::spawn({
4179            let app = app.clone();
4180            async move {
4181                app.oneshot(
4182                    Request::post("/api/sessions/session-1/dictation")
4183                        .header(COOKIE, cookie)
4184                        .body(Body::from(valid_wav()))
4185                        .unwrap(),
4186                )
4187                .await
4188                .unwrap()
4189            }
4190        });
4191        let request = requests.recv().await.unwrap();
4192        let cancel = request.cancel.clone();
4193        pending.abort();
4194        let _ = pending.await;
4195        assert!(cancel.is_cancelled());
4196        drop(request);
4197    }
4198
4199    #[tokio::test]
4200    async fn bundle_endpoint_authenticates_and_forwards_the_source() {
4201        let (app, mut bundles) = app_with_bundle_receiver();
4202        let unauthorized = app
4203            .clone()
4204            .oneshot(
4205                Request::post("/api/bundles")
4206                    .header(CONTENT_TYPE, "application/json")
4207                    .body(Body::from(r#"{"source":"example/app"}"#))
4208                    .unwrap(),
4209            )
4210            .await
4211            .unwrap();
4212        assert_eq!(unauthorized.status(), StatusCode::UNAUTHORIZED);
4213        assert!(bundles.try_recv().is_err());
4214
4215        let cookie = login_cookie(&app).await;
4216        let response = tokio::spawn({
4217            let app = app.clone();
4218            let cookie = cookie.clone();
4219            async move {
4220                app.oneshot(
4221                    Request::post("/api/bundles")
4222                        .header(CONTENT_TYPE, "application/json")
4223                        .header(COOKIE, cookie)
4224                        .body(Body::from(r#"{"source":"example/app"}"#))
4225                        .unwrap(),
4226                )
4227                .await
4228                .unwrap()
4229            }
4230        });
4231        let request = bundles.recv().await.expect("bundle request forwarded");
4232        assert_eq!(request.source, "example/app");
4233        request.reply.send(Ok("app".into())).unwrap();
4234        let response = response.await.unwrap();
4235        assert_eq!(response.status(), StatusCode::OK);
4236        let body = response.into_body().collect().await.unwrap().to_bytes();
4237        assert_eq!(body.as_ref(), br#"{"bundle_id":"app"}"#);
4238    }
4239
4240    #[tokio::test]
4241    async fn bundle_endpoint_rejects_empty_and_oversized_sources_before_dispatch() {
4242        for source in [String::new(), "x".repeat(MAX_BUNDLE_SOURCE_CHARS + 1)] {
4243            let (app, mut bundles) = app_with_bundle_receiver();
4244            let cookie = login_cookie(&app).await;
4245            let response = app
4246                .oneshot(
4247                    Request::post("/api/bundles")
4248                        .header(CONTENT_TYPE, "application/json")
4249                        .header(COOKIE, cookie)
4250                        .body(Body::from(
4251                            serde_json::to_string(&serde_json::json!({"source": source})).unwrap(),
4252                        ))
4253                        .unwrap(),
4254                )
4255                .await
4256                .unwrap();
4257            assert_eq!(response.status(), StatusCode::BAD_REQUEST);
4258            assert!(bundles.try_recv().is_err());
4259        }
4260    }
4261
4262    #[tokio::test]
4263    async fn bundle_endpoint_reports_invalid_source_as_a_client_error() {
4264        let (app, mut bundles) = app_with_bundle_receiver();
4265        let cookie = login_cookie(&app).await;
4266        let response = tokio::spawn({
4267            let app = app.clone();
4268            async move {
4269                app.oneshot(
4270                    Request::post("/api/bundles")
4271                        .header(CONTENT_TYPE, "application/json")
4272                        .header(COOKIE, cookie)
4273                        .body(Body::from(r#"{"source":"not a source"}"#))
4274                        .unwrap(),
4275                )
4276                .await
4277                .unwrap()
4278            }
4279        });
4280        let request = bundles.recv().await.expect("bundle request forwarded");
4281        request
4282            .reply
4283            .send(Err(BundleFailure::InvalidSource))
4284            .unwrap();
4285        let response = response.await.unwrap();
4286        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
4287        let body = response.into_body().collect().await.unwrap().to_bytes();
4288        assert!(String::from_utf8_lossy(&body).contains("GitHub owner/repository"));
4289    }
4290
4291    #[tokio::test]
4292    async fn api_requires_a_valid_signed_cookie() {
4293        let (app, _, _, _, _) = app();
4294        let unauthorized = app
4295            .clone()
4296            .oneshot(Request::get("/api/snapshot").body(Body::empty()).unwrap())
4297            .await
4298            .unwrap();
4299        assert_eq!(unauthorized.status(), StatusCode::UNAUTHORIZED);
4300
4301        let cookie = login_cookie(&app).await;
4302        let authorized = app
4303            .oneshot(
4304                Request::get("/api/snapshot")
4305                    .header(COOKIE, cookie)
4306                    .body(Body::empty())
4307                    .unwrap(),
4308            )
4309            .await
4310            .unwrap();
4311        assert_eq!(authorized.status(), StatusCode::OK);
4312    }
4313
4314    #[tokio::test]
4315    async fn qr_login_exchanges_the_secret_for_a_cookie_and_redirects_cleanly() {
4316        let (app, _, _, _, _) = app();
4317        let rejected = app
4318            .clone()
4319            .oneshot(
4320                Request::get("/auth/login?token=wrong")
4321                    .body(Body::empty())
4322                    .unwrap(),
4323            )
4324            .await
4325            .unwrap();
4326        assert_eq!(rejected.status(), StatusCode::UNAUTHORIZED);
4327
4328        let accepted = app
4329            .oneshot(
4330                Request::get("/auth/login?token=test-login-token")
4331                    .body(Body::empty())
4332                    .unwrap(),
4333            )
4334            .await
4335            .unwrap();
4336        assert_eq!(accepted.status(), StatusCode::SEE_OTHER);
4337        assert_eq!(accepted.headers().get(LOCATION).unwrap(), "/");
4338        assert_eq!(accepted.headers().get(CACHE_CONTROL).unwrap(), "no-store");
4339        assert!(accepted.headers().contains_key(SET_COOKIE));
4340    }
4341
4342    #[test]
4343    fn signed_cookie_rejects_expiry_and_tampering() {
4344        let key = b"01234567890123456789012345678901";
4345        let cookie = signed_cookie_value(key, "test-viewer", 200);
4346        assert!(session_cookie_valid(key, &cookie, 100));
4347        assert!(!session_cookie_valid(key, &cookie, 200));
4348        assert!(!session_cookie_valid(key, &format!("{cookie}x"), 100));
4349        assert!(!session_cookie_valid(b"another-key", &cookie, 100));
4350    }
4351
4352    #[test]
4353    fn generated_code_and_cookie_attributes_are_phone_safe() {
4354        let code = generate_viewer_code().unwrap();
4355        assert_eq!(code.len(), 6);
4356        assert!(code.bytes().all(|byte| byte.is_ascii_digit()));
4357        let header = session_cookie_header("signed", Some(60), true)
4358            .unwrap()
4359            .to_str()
4360            .unwrap()
4361            .to_string();
4362        assert!(header.contains("HttpOnly"));
4363        assert!(header.contains("SameSite=Strict"));
4364        assert!(header.contains("Secure"));
4365        assert!(header.contains("Max-Age=60"));
4366    }
4367
4368    #[test]
4369    fn public_snapshot_omits_homes_environment_locators_and_raw_errors() {
4370        let (config, state) = sample_config_state();
4371        let json =
4372            serde_json::to_string(&ViewerSnapshot::from_config_state(&config, &state, 9)).unwrap();
4373        assert!(!json.contains("/highly/secret"));
4374        assert!(!json.contains("secret-token"));
4375        assert!(!json.contains("secret-target"));
4376        assert!(!json.contains("secret.registry"));
4377        assert!(!json.contains("native-secret-id"));
4378        assert!(json.contains("\"has_error\":true"));
4379    }
4380
4381    #[test]
4382    fn target_snapshot_uses_each_raw_host_project_history_and_leaves_managed_empty() {
4383        let (mut config, mut state) = sample_config_state();
4384        config
4385            .targets
4386            .insert("raw-local".into(), TargetTemplate::LocalBare);
4387        config.targets.insert(
4388            "raw-builder".into(),
4389            TargetTemplate::SshBare {
4390                ssh: SshConnection {
4391                    host: "builder-a".into(),
4392                    user: None,
4393                    identity_file: None,
4394                    extra_args: Vec::new(),
4395                },
4396                permissions: PermissionMode::Guardian,
4397                workspace_prefix: "workspaces".into(),
4398            },
4399        );
4400        state.remember_project_directory("local", Path::new("/work/local"));
4401        state.remember_project_directory("builder-a", Path::new("/srv/builder"));
4402        state.remember_project_directory("other-host", Path::new("/not-published"));
4403
4404        let snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
4405        let target = |id: &str| {
4406            snapshot
4407                .targets
4408                .iter()
4409                .find(|target| target.id == id)
4410                .unwrap()
4411        };
4412        assert_eq!(
4413            target("raw-local").recent_project_directories,
4414            vec!["/work/local"]
4415        );
4416        assert_eq!(
4417            target("raw-builder").recent_project_directories,
4418            vec!["/srv/builder"]
4419        );
4420        assert!(target("podman").recent_project_directories.is_empty());
4421    }
4422
4423    #[test]
4424    fn public_snapshot_exposes_only_review_status_configuration() {
4425        let (mut config, state) = sample_config_state();
4426        config.review = hel::hel_config::ReviewConfig {
4427            enabled: true,
4428            tier: hel::hel_review::lanes::ReviewTier::Extended,
4429            profile: Some("reviewer-1".into()),
4430            model: Some("private-review-model".into()),
4431            effort: Some("private-review-effort".into()),
4432        };
4433
4434        let value =
4435            serde_json::to_value(ViewerSnapshot::from_config_state(&config, &state, 9)).unwrap();
4436
4437        assert_eq!(
4438            value.get("review_config"),
4439            Some(&serde_json::json!({
4440                "enabled": true,
4441                "tier": "extended",
4442                "profile": "reviewer-1",
4443            }))
4444        );
4445        let json = value.to_string();
4446        assert!(!json.contains("private-review-model"));
4447        assert!(!json.contains("private-review-effort"));
4448    }
4449
4450    fn sample_elicitation() -> ElicitationRequest {
4451        ElicitationRequest::from_acp_params(
4452            "elicitation-1",
4453            serde_json::json!({
4454                "sessionId": "session-1",
4455                "mode": "form",
4456                "message": "Which CI architecture should the workflow use?",
4457                "requestedSchema": {
4458                    "type": "object",
4459                    "required": ["question_0"],
4460                    "properties": {
4461                        "question_0": {
4462                            "type": "string",
4463                            "title": "CI architecture",
4464                            "oneOf": [
4465                                {"const": "reusable", "title": "Reusable workflow"},
4466                                {"const": "matrix", "title": "Matrix job"}
4467                            ]
4468                        },
4469                        "question_0_custom": {
4470                            "type": "string",
4471                            "title": "Other",
4472                            "_meta": {"_askUserQuestionCustomAnswer": {
4473                                "questionId": "question_0",
4474                                "isCustomAnswer": true
4475                            }}
4476                        }
4477                    }
4478                }
4479            }),
4480        )
4481        .expect("sample elicitation parses")
4482    }
4483
4484    fn accept(pairs: &[(&str, &str)]) -> ElicitationResponse {
4485        ElicitationResponse::Accept {
4486            content: pairs
4487                .iter()
4488                .map(|(id, value)| {
4489                    (
4490                        (*id).to_owned(),
4491                        hel::hel_elicitation::ElicitationValue::String((*value).to_owned()),
4492                    )
4493                })
4494                .collect(),
4495        }
4496    }
4497
4498    fn pending_elicitation_snapshot(snapshot: &mut ViewerSnapshot) {
4499        snapshot.sessions[0].pending_elicitations = vec![sample_elicitation()];
4500    }
4501
4502    #[tokio::test]
4503    async fn elicitation_answer_is_typed_and_forwarded() {
4504        let (app, mut actions, _, _, _) = app_with_snapshot(pending_elicitation_snapshot);
4505        let cookie = login_cookie(&app).await;
4506        let response = tokio::spawn(
4507            app.oneshot(
4508                Request::post("/api/actions")
4509                    .header(COOKIE, cookie)
4510                    .header(CONTENT_TYPE, "application/json")
4511                    .body(Body::from(
4512                        r#"{"action":"respond-elicitation","session_id":"session-1","elicitation_id":"elicitation-1","response":{"action":"accept","content":{"question_0":"reusable"}}}"#,
4513                    ))
4514                    .unwrap(),
4515            ),
4516        );
4517        let action = actions.recv().await.unwrap();
4518        assert_eq!(
4519            action.action,
4520            ControllerAction::RespondElicitation {
4521                session_id: "session-1".into(),
4522                elicitation_id: "elicitation-1".into(),
4523                response: accept(&[("question_0", "reusable")]),
4524            }
4525        );
4526        action.reply.send(ActionOutcome::Accepted).unwrap();
4527        assert_eq!(
4528            response.await.unwrap().unwrap().status(),
4529            StatusCode::ACCEPTED
4530        );
4531    }
4532
4533    #[tokio::test]
4534    async fn elicitation_answer_for_an_unknown_request_is_refused_without_reaching_the_controller()
4535    {
4536        let (app, mut actions, _, _, _) = app_with_snapshot(pending_elicitation_snapshot);
4537        let cookie = login_cookie(&app).await;
4538        let response = app
4539            .oneshot(
4540                Request::post("/api/actions")
4541                    .header(COOKIE, cookie)
4542                    .header(CONTENT_TYPE, "application/json")
4543                    .body(Body::from(
4544                        r#"{"action":"respond-elicitation","session_id":"session-1","elicitation_id":"elicitation-9","response":{"action":"cancel"}}"#,
4545                    ))
4546                    .unwrap(),
4547            )
4548            .await
4549            .unwrap();
4550        assert_eq!(response.status(), StatusCode::NOT_FOUND);
4551        assert!(actions.try_recv().is_err());
4552    }
4553
4554    #[test]
4555    fn elicitation_answers_are_checked_against_the_request_the_agent_asked() {
4556        let (config, state) = sample_config_state();
4557        let mut snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
4558        pending_elicitation_snapshot(&mut snapshot);
4559        let respond = |response: ElicitationResponse| ControllerAction::RespondElicitation {
4560            session_id: "session-1".into(),
4561            elicitation_id: "elicitation-1".into(),
4562            response,
4563        };
4564
4565        assert!(validate_action(&respond(accept(&[("question_0", "matrix")])), &snapshot).is_ok());
4566        // Declining and cancelling never carry content, so they are always
4567        // answerable.
4568        assert!(validate_action(&respond(ElicitationResponse::Decline), &snapshot).is_ok());
4569        // An option the agent never offered, a field it never published, and a
4570        // missing required answer are all refused.
4571        assert!(validate_action(&respond(accept(&[("question_0", "cron")])), &snapshot).is_err());
4572        assert!(validate_action(&respond(accept(&[("smuggled", "yes")])), &snapshot).is_err());
4573        assert!(validate_action(&respond(accept(&[])), &snapshot).is_err());
4574        // A custom answer stands in for the select it belongs to, exactly as
4575        // the chat form submits it.
4576        assert!(
4577            validate_action(
4578                &respond(accept(&[("question_0_custom", "a monorepo pipeline")])),
4579                &snapshot,
4580            )
4581            .is_ok()
4582        );
4583    }
4584
4585    #[test]
4586    fn oversized_elicitation_answers_are_refused() {
4587        let (config, state) = sample_config_state();
4588        let mut snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
4589        pending_elicitation_snapshot(&mut snapshot);
4590        let long = "x".repeat(MAX_ELICITATION_BYTES);
4591        assert!(
4592            validate_action(
4593                &ControllerAction::RespondElicitation {
4594                    session_id: "session-1".into(),
4595                    elicitation_id: "elicitation-1".into(),
4596                    response: accept(&[("question_0_custom", long.as_str())]),
4597                },
4598                &snapshot,
4599            )
4600            .is_err()
4601        );
4602    }
4603
4604    /// One slice of the browser application, named by the two markers that
4605    /// bracket it in `src/web/viewer.js`.
4606    ///
4607    /// Slicing keeps each check to the functions it is about, so an unrelated
4608    /// change elsewhere in the application cannot make it fail for the wrong
4609    /// reason. The markers are ordinary source text, so a rename that moves
4610    /// them fails loudly here rather than silently testing nothing.
4611    fn viewer_source(from: &str, to: &str) -> &'static str {
4612        let start = VIEWER_JS
4613            .find(from)
4614            .unwrap_or_else(|| panic!("src/web/viewer.js no longer contains {from:?}"));
4615        let end = VIEWER_JS[start..]
4616            .find(to)
4617            .map(|offset| start + offset)
4618            .unwrap_or_else(|| {
4619                panic!("src/web/viewer.js no longer contains {to:?} after {from:?}")
4620            });
4621        &VIEWER_JS[start..end]
4622    }
4623
4624    /// Run one JavaScript check under Node.
4625    ///
4626    /// The check and the modules it imports are written to a real directory
4627    /// rather than passed to `--eval`, so a failure reports a line number a
4628    /// person can open, and so a check can import the shipped module under
4629    /// test by its real name instead of against a copy pasted into a string.
4630    fn run_web_check(name: &str, check: &str) {
4631        let directory = tempfile::tempdir().expect("temporary directory for a web check");
4632        for (file, source) in [
4633            ("test-dom.js", TEST_DOM_JS),
4634            ("markdown.js", MARKDOWN_JS),
4635            ("tool-output.js", TOOL_OUTPUT_JS),
4636        ] {
4637            std::fs::write(directory.path().join(file), source).expect("write a web module");
4638        }
4639        let path = directory.path().join(format!("{name}.mjs"));
4640        std::fs::write(&path, check).expect("write the web check");
4641        let output = std::process::Command::new("node")
4642            .arg(&path)
4643            .output()
4644            .expect("Node.js is required to exercise the web viewer");
4645        assert!(
4646            output.status.success(),
4647            "{name} failed:\nstdout:\n{}\nstderr:\n{}",
4648            String::from_utf8_lossy(&output.stdout),
4649            String::from_utf8_lossy(&output.stderr),
4650        );
4651    }
4652
4653    /// Run one JavaScript check that supplies its own environment, for the
4654    /// checks that slice a function out of `viewer.js` and drive it against a
4655    /// hand-written stub rather than importing a module.
4656    fn run_viewer_script(name: &str, script: &str) {
4657        run_web_check(name, script);
4658    }
4659
4660    #[test]
4661    fn embedded_viewer_lists_current_workspace_histories_and_retained_move_recovery() {
4662        let source = viewer_source("function isResumeSession(", "const resumeDrafts =");
4663        let setup = r#"
4664const snapshot = {
4665  sessions: [
4666    { id: "history-a", workspace_id: "workspace-a", capabilities: { resume: true } },
4667    { id: "history-b", workspace_id: "workspace-b", capabilities: { resume: true } },
4668    { id: "running-a", workspace_id: "workspace-a", lifecycle: "live", has_error: true, capabilities: { resume: false, open: false } },
4669    { id: "move-a", workspace_id: "workspace-a", capabilities: { resume: false }, move_recovery: { checkpoint_retained: true, phase: "failed" } },
4670    { id: "moving-a", workspace_id: "workspace-a", capabilities: { resume: false }, move_recovery: { checkpoint_retained: true, phase: "starting_queue" } },
4671  ],
4672};
4673function selectedWorkspaceId() { return "workspace-a"; }
4674function sessionActivityMs() { return 0; }
4675function epochMs() { return null; }
4676"#;
4677        let checks = r#"
4678const ids = workspace => resumeSessions(workspace).map(session => session.id).sort();
4679if (JSON.stringify(ids("workspace-a")) !== JSON.stringify(["history-a", "move-a"])) {
4680  throw new Error(`workspace A histories or recoveries were wrong: ${JSON.stringify(ids("workspace-a"))}`);
4681}
4682if (JSON.stringify(ids("workspace-b")) !== JSON.stringify(["history-b"])) {
4683  throw new Error(`workspace B histories were wrong: ${JSON.stringify(ids("workspace-b"))}`);
4684}
4685if (ids("missing-workspace").length !== 0) throw new Error("unknown workspace exposed sessions");
4686"#;
4687        run_viewer_script(
4688            "workspace-resume-history",
4689            &format!("{setup}\n{source}\n{checks}"),
4690        );
4691    }
4692
4693    #[test]
4694    fn embedded_viewer_sends_the_selected_resume_workspace() {
4695        let source = viewer_source("async function runSessionAction", "sessions.onclick =");
4696        let setup = r#"
4697const pendingActions = new Set();
4698const snapshot = { sessions: [] };
4699let sent = null;
4700function selectedWorkspaceId() { return "workspace-b"; }
4701function navigate() {}
4702function renderRoute() {}
4703async function refresh() {}
4704async function request(path, options) {
4705  sent = { path, body: JSON.parse(options.body) };
4706}
4707"#;
4708        let checks = r#"
4709const errorNode = { textContent: "" };
4710await runSessionAction(
4711  { action: "resume", id: "history-a", profile: "codex-1", target: "podman" },
4712  errorNode,
4713  { queue: "start" },
4714);
4715if (sent.path !== "/api/actions" || sent.body.workspace_id !== "workspace-b") {
4716  throw new Error(`resume did not carry its destination: ${JSON.stringify(sent)}`);
4717}
4718"#;
4719        run_viewer_script(
4720            "resume-workspace-destination",
4721            &format!("{setup}\n{source}\n{checks}"),
4722        );
4723    }
4724
4725    #[test]
4726    fn embedded_viewer_warns_before_stopping_an_active_session() {
4727        let source = viewer_source("async function runSessionAction", "sessions.onclick =");
4728        let setup = r#"
4729const pendingActions = new Set();
4730const snapshot = {
4731  sessions: [
4732    { id: "active", chat_phase: "running" },
4733    { id: "idle", chat_phase: "idle" },
4734  ],
4735};
4736const questions = [];
4737function confirm(question) { questions.push(question); return false; }
4738function navigate() {}
4739"#;
4740        let checks = r#"
4741const errorNode = { textContent: "" };
4742await runSessionAction({ action: "close", id: "active" }, errorNode);
4743await runSessionAction({ action: "close", id: "idle" }, errorNode);
4744if (!questions[0].startsWith("Stop active session?\n\n")) {
4745  throw new Error(`active close warning was ${JSON.stringify(questions[0])}`);
4746}
4747if (!questions[0].includes("current turn will be interrupted")) {
4748  throw new Error(`active close omitted interruption: ${JSON.stringify(questions[0])}`);
4749}
4750if (!questions[1].startsWith("Stop session?\n\n")) {
4751  throw new Error(`idle close warning was ${JSON.stringify(questions[1])}`);
4752}
4753"#;
4754        run_viewer_script(
4755            "active-session-stop-confirmation",
4756            &format!("{setup}\n{source}\n{checks}"),
4757        );
4758    }
4759
4760    /// The projection publishes what the browser needs to group and filter
4761    /// without publishing what the redaction contract keeps back. A project
4762    /// key groups two sessions in one project together and says nothing about
4763    /// where that project lives.
4764    #[test]
4765    fn the_project_key_groups_without_naming_a_path() {
4766        let (config, mut state) = sample_config_state();
4767        let first = state.sessions["session-1"].clone();
4768        let mut second = first.clone();
4769        second.id = "session-2".into();
4770        state.sessions.insert(second.id.clone(), second);
4771        let snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
4772
4773        let keys = snapshot
4774            .sessions
4775            .iter()
4776            .map(|session| session.project_key.as_str())
4777            .collect::<std::collections::BTreeSet<_>>();
4778        assert_eq!(keys.len(), 1, "two sessions in one project did not group");
4779        let key = keys.into_iter().next().expect("one key");
4780        assert!(!key.is_empty(), "the project key is empty");
4781        assert!(
4782            !key.contains('/') && !key.contains("hel"),
4783            "the project key leaks its identity: {key}"
4784        );
4785        assert_eq!(
4786            snapshot.sessions[0].project_label, "hel",
4787            "the project label should be a name a person recognises"
4788        );
4789    }
4790
4791    #[test]
4792    fn web_project_keys_follow_the_complete_repository_set() {
4793        let (mut config, mut state) = sample_config_state();
4794        let shared_bundle = config.bundles["hel"].clone();
4795        config.bundles.insert("other".into(), shared_bundle);
4796
4797        let mut other = state.sessions["session-1"].clone();
4798        other.id = "session-2".into();
4799        other.bundle_id = "other".into();
4800        state.sessions.insert(other.id.clone(), other);
4801
4802        assert_eq!(
4803            config.bundles["hel"].primary_repo,
4804            config.bundles["other"].primary_repo
4805        );
4806        let snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
4807        let first = snapshot
4808            .sessions
4809            .iter()
4810            .find(|session| session.id == "session-1")
4811            .expect("first session");
4812        let second = snapshot
4813            .sessions
4814            .iter()
4815            .find(|session| session.id == "session-2")
4816            .expect("second session");
4817
4818        assert_eq!(first.project_label, "hel");
4819        assert_eq!(second.project_label, "hel");
4820        assert_eq!(first.project_key, second.project_key);
4821
4822        let secondary = ProjectRepository {
4823            id: "secondary".into(),
4824            github: Some("owner/secondary".into()),
4825            local: None,
4826            destination: "secondary".into(),
4827            git_ref: None,
4828        };
4829        config
4830            .bundles
4831            .get_mut("other")
4832            .unwrap()
4833            .repositories
4834            .push(secondary.clone());
4835        let project_keys = |config: &HelConfig| {
4836            ViewerSnapshot::from_config_state(config, &state, 1)
4837                .sessions
4838                .into_iter()
4839                .map(|session| session.project_key)
4840                .collect::<Vec<_>>()
4841        };
4842        let keys = project_keys(&config);
4843        assert_ne!(
4844            keys[0], keys[1],
4845            "an added repository must change the bundle identity"
4846        );
4847
4848        let first_bundle = config.bundles.get_mut("hel").unwrap();
4849        first_bundle.repositories.insert(0, secondary);
4850        first_bundle.primary_repo = "secondary".into();
4851        let keys = project_keys(&config);
4852        assert_eq!(
4853            keys[0], keys[1],
4854            "the same repository set must group together despite order or primary choice"
4855        );
4856    }
4857
4858    #[test]
4859    fn viewer_session_applies_a_resolved_source_without_publishing_it() {
4860        let (config, state) = sample_config_state();
4861        let mut viewer = ViewerSnapshot::from_config_state(&config, &state, 1)
4862            .sessions
4863            .into_iter()
4864            .next()
4865            .expect("session");
4866        let source = ProjectSourceIdentity::git_remote("git@github.com:BrokkAi/bifrost-dev.git")
4867            .expect("GitHub source");
4868
4869        viewer.set_project_source(&source);
4870
4871        assert_eq!(viewer.project_label, "bifrost-dev");
4872        assert_eq!(viewer.project_key, project_key(&source.key));
4873        let json = serde_json::to_string(&viewer).expect("serialize viewer session");
4874        assert!(!json.contains("BrokkAi"));
4875        assert!(!json.contains("github.com"));
4876    }
4877
4878    /// A phone groups and filters by the lifecycle category, so the mapping
4879    /// from the controller's precise state has to be the controller's own.
4880    #[test]
4881    fn lifecycle_categories_decide_what_the_dashboard_shows() {
4882        use ViewerLifecycleCategory::{Failed, Live, Starting, Stopped, Stopping};
4883
4884        for (state, expected, on_dashboard) in [
4885            (SessionState::Provisioning, Starting, true),
4886            (SessionState::Running, Live, true),
4887            (SessionState::Disconnected, Live, true),
4888            (SessionState::Checkpointing, Live, true),
4889            (SessionState::Closing, Stopping, true),
4890            (SessionState::Destroying, Stopping, true),
4891            (SessionState::Stopped, Stopped, false),
4892            (SessionState::Lost, Failed, false),
4893            (SessionState::Error, Failed, false),
4894            (SessionState::DestroyedWithDataLoss, Failed, false),
4895        ] {
4896            let category = ViewerLifecycleCategory::of(state);
4897            assert_eq!(category, expected, "{state:?}");
4898            assert_eq!(
4899                category.is_dashboard_visible(),
4900                on_dashboard,
4901                "{state:?} belongs on the dashboard? "
4902            );
4903        }
4904    }
4905
4906    /// Resume compatibility travels as the set the browser can offer, so it
4907    /// never has to subtract one list from another and never offers a target
4908    /// the controller would refuse.
4909    #[test]
4910    fn compatible_resume_targets_are_the_complement_of_the_incompatible_ones() {
4911        let (config, state) = sample_config_state();
4912        let snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
4913        let session = &snapshot.sessions[0];
4914        let all = config.targets.keys().cloned().collect::<Vec<_>>();
4915
4916        for target in &all {
4917            assert_ne!(
4918                session.compatible_resume_targets.contains(target),
4919                session.incompatible_resume_targets.contains(target),
4920                "target {target} is in both lists or neither"
4921            );
4922        }
4923        assert_eq!(
4924            session.compatible_resume_targets.len() + session.incompatible_resume_targets.len(),
4925            all.len(),
4926            "the two lists do not cover every target"
4927        );
4928    }
4929
4930    /// The viewer renders a control because a capability says so. An action
4931    /// whose capability is false is refused at the boundary, so a forged
4932    /// request gets the same answer a well-behaved viewer would never ask for.
4933    #[tokio::test]
4934    async fn actions_are_refused_when_their_capability_is_false() {
4935        for (body, capability) in [
4936            (
4937                r#"{"action":"cancel-turn","session_id":"session-1"}"#,
4938                "cancel_turn",
4939            ),
4940            (
4941                r#"{"action":"set-plan-mode","session_id":"session-1","active":true}"#,
4942                "set_plan_mode",
4943            ),
4944            (
4945                r#"{"action":"set-config","session_id":"session-1","key":"model","value":"x"}"#,
4946                "set_config",
4947            ),
4948        ] {
4949            let (app, mut actions, _, _, _) = app();
4950            let response = post_action(app, cookie(), body.to_owned()).await;
4951            assert!(
4952                response.status().is_client_error(),
4953                "{capability} was accepted while false: {}",
4954                response.status()
4955            );
4956            assert!(
4957                actions.try_recv().is_err(),
4958                "{capability} reached the controller while false"
4959            );
4960        }
4961    }
4962
4963    /// A setting the harness never advertised is not a setting. Forwarding one
4964    /// asks the agent to refuse something the viewer should never have offered.
4965    #[tokio::test]
4966    async fn a_config_key_the_harness_never_advertised_is_refused() {
4967        let capable = |snapshot: &mut ViewerSnapshot| {
4968            snapshot.sessions[0].capabilities.set_config = true;
4969            snapshot.sessions[0].config_options = vec![ViewerConfigOption {
4970                key: "model".into(),
4971                label: "model".into(),
4972                current: None,
4973                choices: vec![ViewerConfigChoice {
4974                    value: "sonnet".into(),
4975                    name: "Sonnet".into(),
4976                    description: None,
4977                }],
4978            }];
4979        };
4980
4981        for (body, why) in [
4982            (
4983                r#"{"action":"set-config","session_id":"session-1","key":"effort","value":"high"}"#,
4984                "an unadvertised key",
4985            ),
4986            (
4987                r#"{"action":"set-config","session_id":"session-1","key":"model","value":"gpt-9"}"#,
4988                "an unoffered value",
4989            ),
4990        ] {
4991            let (app, mut actions, _, _, _) = app_with_snapshot(capable);
4992            let response = post_action(app, cookie(), body.to_owned()).await;
4993            assert_eq!(
4994                response.status(),
4995                StatusCode::BAD_REQUEST,
4996                "{why} was accepted"
4997            );
4998            assert!(actions.try_recv().is_err(), "{why} reached the controller");
4999        }
5000
5001        // The value the harness did advertise is forwarded unchanged.
5002        let (app, mut actions, _, _, _) = app_with_snapshot(capable);
5003        let response = tokio::spawn(post_action(
5004            app,
5005            cookie(),
5006            r#"{"action":"set-config","session_id":"session-1","key":"model","value":"sonnet"}"#
5007                .to_owned(),
5008        ));
5009        let action = actions
5010            .recv()
5011            .await
5012            .expect("the action reached the controller");
5013        assert!(
5014            matches!(
5015                action.action,
5016                ControllerAction::SetConfig { ref key, ref value, .. }
5017                    if key == "model" && value == "sonnet"
5018            ),
5019            "the advertised value was not forwarded unchanged"
5020        );
5021        action.reply.send(ActionOutcome::Accepted).unwrap();
5022        assert_eq!(response.await.unwrap().status(), StatusCode::ACCEPTED);
5023    }
5024
5025    /// A dirty-worktree acknowledgement names the repositories the person was
5026    /// shown. A bare yes could be replayed against a set they never saw.
5027    #[tokio::test]
5028    async fn a_dirty_acknowledgement_is_bounded_and_names_repositories() {
5029        let oversized = (0..40)
5030            .map(|index| format!(r#""repo-{index}""#))
5031            .collect::<Vec<_>>()
5032            .join(",");
5033        for (ack, why) in [
5034            (oversized.as_str(), "an unbounded acknowledgement"),
5035            (r#""""#, "an empty repository name"),
5036        ] {
5037            let (app, mut actions, _, _, _) = app();
5038            let body = format!(
5039                r#"{{"action":"new","workspace_id":"default","profile_id":"codex-1","bundle_id":"hel","target_id":"podman","dirty_ack":[{ack}]}}"#
5040            );
5041            let response = post_action(app, cookie(), body).await;
5042            assert_eq!(response.status(), StatusCode::BAD_REQUEST, "{why}");
5043            assert!(actions.try_recv().is_err(), "{why} reached the controller");
5044        }
5045    }
5046
5047    /// A session created without a title still gets one, derived the way the
5048    /// terminal derives it, so the two surfaces name a session alike.
5049    #[tokio::test]
5050    async fn a_new_session_without_a_title_is_accepted() {
5051        let (app, mut actions, _, _, _) = app();
5052        let response = tokio::spawn(post_action(
5053            app,
5054            cookie(),
5055            r#"{"action":"new","workspace_id":"default","profile_id":"codex-1","bundle_id":"hel","target_id":"podman"}"#
5056                .to_owned(),
5057        ));
5058        // The handler answers only once the controller does, so the reply has
5059        // to be sent before the response can be read.
5060        let action = actions
5061            .recv()
5062            .await
5063            .expect("the action reached the controller");
5064        assert!(
5065            matches!(
5066                action.action,
5067                ControllerAction::New { title: None, ref workspace_id, .. }
5068                    if workspace_id == "default"
5069            ),
5070            "the workspace or the absent title did not survive the boundary"
5071        );
5072        action.reply.send(ActionOutcome::Accepted).unwrap();
5073        assert_eq!(response.await.unwrap().status(), StatusCode::ACCEPTED);
5074    }
5075
5076    /// Two phones must not share stored state, and one phone's state must
5077    /// survive its own re-login. Neither is true of a cookie that signs only
5078    /// an expiry, which is what this replaced.
5079    #[test]
5080    fn a_cookie_names_one_viewer_and_two_cookies_never_collide() {
5081        let key = b"01234567890123456789012345678901";
5082        let expiry = now_unix().saturating_add(3600);
5083        let first = signed_cookie_value(key, "viewer-a", expiry);
5084        let second = signed_cookie_value(key, "viewer-b", expiry);
5085        assert_ne!(
5086            first, second,
5087            "two viewers unlocking in the same second share a cookie"
5088        );
5089        assert_eq!(
5090            cookie_viewer(key, &first, now_unix()),
5091            Some(Some("viewer-a".to_owned()))
5092        );
5093        assert_eq!(
5094            cookie_viewer(key, &second, now_unix()),
5095            Some(Some("viewer-b".to_owned()))
5096        );
5097    }
5098
5099    /// A phone holding the previous cookie keeps working through a deployment.
5100    /// It names no viewer, so it stores nothing, which is the difference
5101    /// between signed out and signed in with nothing kept.
5102    #[test]
5103    fn a_legacy_cookie_still_authenticates_and_stores_nothing() {
5104        let key = b"01234567890123456789012345678901";
5105        let expiry = now_unix().saturating_add(3600);
5106        let legacy = legacy_signed_cookie_value(key, expiry);
5107        assert_eq!(cookie_viewer(key, &legacy, now_unix()), Some(None));
5108        assert!(session_cookie_valid(key, &legacy, now_unix()));
5109        assert!(
5110            !session_cookie_valid(key, &legacy, expiry),
5111            "an expired legacy cookie still authenticated"
5112        );
5113    }
5114
5115    /// A forged or tampered cookie names nobody.
5116    #[test]
5117    fn a_tampered_cookie_is_refused() {
5118        let key = b"01234567890123456789012345678901";
5119        let expiry = now_unix().saturating_add(3600);
5120        let honest = signed_cookie_value(key, "viewer-a", expiry);
5121        let swapped = honest.replacen("viewer-a", "viewer-b", 1);
5122        assert_eq!(cookie_viewer(key, &swapped, now_unix()), None);
5123        assert_eq!(cookie_viewer(key, "nonsense", now_unix()), None);
5124        assert_eq!(cookie_viewer(key, &format!("{expiry}."), now_unix()), None);
5125    }
5126
5127    /// A composer is for a prompt. The bound exists so one viewer cannot fill
5128    /// the daemon's database with text it never sent.
5129    #[tokio::test]
5130    async fn an_oversized_draft_is_refused_with_a_stable_code() {
5131        let (app, _, _, _, mut stored) = app();
5132        let draft = "x".repeat(64 * 1024 + 1);
5133        let response = app
5134            .oneshot(
5135                Request::put("/api/sessions/session-1/draft")
5136                    .header(COOKIE, cookie())
5137                    .header(CONTENT_TYPE, "application/json")
5138                    .body(Body::from(
5139                        serde_json::json!({ "draft": draft }).to_string(),
5140                    ))
5141                    .unwrap(),
5142            )
5143            .await
5144            .unwrap();
5145        assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE);
5146        assert!(stored.try_recv().is_err(), "an oversized draft was stored");
5147    }
5148
5149    /// A viewer with no identity has nothing stored, and is told so rather
5150    /// than being promised a persistence that is not there.
5151    #[tokio::test]
5152    async fn a_legacy_viewer_reads_empty_state_and_cannot_store_a_draft() {
5153        let key = b"01234567890123456789012345678901";
5154        let legacy = format!(
5155            "{COOKIE_NAME}={}",
5156            legacy_signed_cookie_value(key, now_unix().saturating_add(3600))
5157        );
5158
5159        let (reader, _, _, _, mut stored) = app();
5160        let response = reader
5161            .oneshot(
5162                Request::get("/api/sessions/session-1/client-state")
5163                    .header(COOKIE, legacy.clone())
5164                    .body(Body::empty())
5165                    .unwrap(),
5166            )
5167            .await
5168            .unwrap();
5169        assert_eq!(response.status(), StatusCode::OK);
5170        let body = response.into_body().collect().await.unwrap().to_bytes();
5171        let state: ViewerClientState = serde_json::from_slice(&body).unwrap();
5172        assert_eq!(state, ViewerClientState::default());
5173        assert!(
5174            stored.try_recv().is_err(),
5175            "a legacy viewer read stored state"
5176        );
5177
5178        let (writer, _, _, _, mut stored) = app();
5179        let response = writer
5180            .oneshot(
5181                Request::put("/api/sessions/session-1/draft")
5182                    .header(COOKIE, legacy)
5183                    .header(CONTENT_TYPE, "application/json")
5184                    .body(Body::from(r#"{"draft":"text"}"#))
5185                    .unwrap(),
5186            )
5187            .await
5188            .unwrap();
5189        assert_eq!(response.status(), StatusCode::CONFLICT);
5190        assert!(stored.try_recv().is_err(), "a legacy viewer stored a draft");
5191    }
5192
5193    /// A search that is not a search is refused before it reaches a database.
5194    #[tokio::test]
5195    async fn prompt_history_refuses_an_unknown_scope() {
5196        let (app, _, _, _, mut stored) = app();
5197        let response = app
5198            .oneshot(
5199                Request::get("/api/sessions/session-1/history?q=ship&scope=everything")
5200                    .header(COOKIE, cookie())
5201                    .body(Body::empty())
5202                    .unwrap(),
5203            )
5204            .await
5205            .unwrap();
5206        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
5207        assert!(
5208            stored.try_recv().is_err(),
5209            "the search reached the controller"
5210        );
5211    }
5212
5213    /// A preflight starts nothing. It answers the questions a person needs
5214    /// before committing, and it refuses an impossible combination there
5215    /// rather than after the commit.
5216    #[tokio::test]
5217    async fn a_preflight_validates_before_it_reaches_the_controller() {
5218        for (body, why) in [
5219            (
5220                r#"{"profile_id":"nope","bundle_id":"hel","target_id":"podman"}"#,
5221                "an unknown profile",
5222            ),
5223            (
5224                r#"{"profile_id":"codex-1","bundle_id":"hel","target_id":"raw"}"#,
5225                "a bare target with no directory",
5226            ),
5227        ] {
5228            let (app, _, _, mut preflights, _) = app();
5229            let response = app
5230                .oneshot(
5231                    Request::post("/api/preflight/new")
5232                        .header(COOKIE, cookie())
5233                        .header(CONTENT_TYPE, "application/json")
5234                        .body(Body::from(body))
5235                        .unwrap(),
5236                )
5237                .await
5238                .unwrap();
5239            assert_eq!(response.status(), StatusCode::BAD_REQUEST, "{why}");
5240            assert!(
5241                preflights.try_recv().is_err(),
5242                "{why} reached the controller"
5243            );
5244        }
5245    }
5246
5247    /// A bare target opens a directory the person named. The controller still
5248    /// validates that directory before answering, because the server's state
5249    /// projection cannot inspect the filesystem or an SSH host.
5250    #[tokio::test]
5251    async fn a_bare_preflight_forwards_directory_validation_to_the_controller() {
5252        let (app, _, _, mut preflights, _) = app();
5253        let response = tokio::spawn(app.oneshot(
5254                Request::post("/api/preflight/new")
5255                    .header(COOKIE, cookie())
5256                    .header(CONTENT_TYPE, "application/json")
5257                    .body(Body::from(
5258                        r#"{"profile_id":"codex-1","bundle_id":"hel","target_id":"raw","project_directory":"/work/project"}"#,
5259                    ))
5260                    .unwrap(),
5261            ));
5262        let request = preflights.recv().await.expect("the controller was asked");
5263        assert_eq!(request.bundle_id, "hel");
5264        assert_eq!(request.target_id, "raw");
5265        assert_eq!(
5266            request.project_directory,
5267            Some(PathBuf::from("/work/project"))
5268        );
5269        request
5270            .reply
5271            .send(Ok(PreflightNew {
5272                dirty_repositories: Vec::new(),
5273            }))
5274            .unwrap();
5275        let response = response.await.unwrap().unwrap();
5276        assert_eq!(response.status(), StatusCode::OK);
5277        let body = response.into_body().collect().await.unwrap().to_bytes();
5278        let answer: PreflightNew = serde_json::from_slice(&body).unwrap();
5279        assert!(answer.dirty_repositories.is_empty());
5280    }
5281
5282    #[tokio::test]
5283    async fn a_bare_preflight_validation_failure_is_actionable_without_its_details() {
5284        let (app, _, _, mut preflights, _) = app();
5285        let response = tokio::spawn(app.oneshot(
5286            Request::post("/api/preflight/new")
5287                .header(COOKIE, cookie())
5288                .header(CONTENT_TYPE, "application/json")
5289                .body(Body::from(
5290                    r#"{"profile_id":"codex-1","bundle_id":"hel","target_id":"raw","project_directory":"/private/project"}"#,
5291                ))
5292                .unwrap(),
5293        ));
5294        let request = preflights.recv().await.expect("the controller was asked");
5295        request
5296            .reply
5297            .send(Err(PreflightFailure::Validation))
5298            .unwrap();
5299        let response = response.await.unwrap().unwrap();
5300        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
5301        let body = response.into_body().collect().await.unwrap().to_bytes();
5302        assert_eq!(
5303            serde_json::from_slice::<serde_json::Value>(&body).unwrap(),
5304            serde_json::json!({
5305                "error": "project validation failed; check that the directory exists, is accessible, and contains a Git repository with a valid HEAD"
5306            })
5307        );
5308        assert!(!String::from_utf8_lossy(&body).contains("/private/project"));
5309    }
5310
5311    #[tokio::test]
5312    async fn a_bundle_preflight_controller_failure_keeps_the_generic_service_error() {
5313        let (app, _, _, mut preflights, _) = app();
5314        let response = tokio::spawn(
5315            app.oneshot(
5316                Request::post("/api/preflight/new")
5317                    .header(COOKIE, cookie())
5318                    .header(CONTENT_TYPE, "application/json")
5319                    .body(Body::from(
5320                        r#"{"profile_id":"codex-1","bundle_id":"hel","target_id":"podman"}"#,
5321                    ))
5322                    .unwrap(),
5323            ),
5324        );
5325        let request = preflights.recv().await.expect("the controller was asked");
5326        request
5327            .reply
5328            .send(Err(PreflightFailure::Controller(
5329                "private /source/hel details".into(),
5330            )))
5331            .unwrap();
5332        let response = response.await.unwrap().unwrap();
5333        assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
5334        let body = response.into_body().collect().await.unwrap().to_bytes();
5335        assert_eq!(
5336            serde_json::from_slice::<serde_json::Value>(&body).unwrap(),
5337            serde_json::json!({"error": "the controller could not check this project"})
5338        );
5339        assert!(!String::from_utf8_lossy(&body).contains("/source/hel"));
5340    }
5341
5342    /// A bundle preflight asks the controller, because whether a working tree
5343    /// has uncommitted changes is a fact about the disk.
5344    #[tokio::test]
5345    async fn a_bundle_preflight_reports_the_repositories_by_leaf_name() {
5346        let (app, _, _, mut preflights, _) = app();
5347        let response = tokio::spawn(
5348            app.oneshot(
5349                Request::post("/api/preflight/new")
5350                    .header(COOKIE, cookie())
5351                    .header(CONTENT_TYPE, "application/json")
5352                    .body(Body::from(
5353                        r#"{"profile_id":"codex-1","bundle_id":"hel","target_id":"podman"}"#,
5354                    ))
5355                    .unwrap(),
5356            ),
5357        );
5358        let request = preflights.recv().await.expect("the controller was asked");
5359        assert_eq!(request.bundle_id, "hel");
5360        assert_eq!(request.target_id, "podman");
5361        assert_eq!(request.project_directory, None);
5362        request
5363            .reply
5364            .send(Ok(PreflightNew {
5365                dirty_repositories: vec!["hel".into()],
5366            }))
5367            .unwrap();
5368        let response = response.await.unwrap().unwrap();
5369        assert_eq!(response.status(), StatusCode::OK);
5370        let body = response.into_body().collect().await.unwrap().to_bytes();
5371        let answer: PreflightNew = serde_json::from_slice(&body).unwrap();
5372        assert_eq!(answer.dirty_repositories, vec!["hel".to_owned()]);
5373        assert!(
5374            !String::from_utf8_lossy(&body).contains('/'),
5375            "the preflight published a path: {}",
5376            String::from_utf8_lossy(&body)
5377        );
5378    }
5379
5380    /// Everything an agent writes goes through the Markdown renderer, so the
5381    /// renderer is where injection is stopped. These checks run the shipped
5382    /// module against a fake DOM: structure has to come out as elements, and
5383    /// markup an agent typed has to come out as text.
5384    #[test]
5385    fn the_markdown_renderer_builds_structure_and_refuses_injection() {
5386        run_web_check(
5387            "markdown",
5388            r#"import { installDocument, elements, only, check, checkEqual } from './test-dom.js';
5389installDocument();
5390const { renderMarkdown, renderDiffSummary, safeHref } = await import('./markdown.js');
5391
5392const render = source => {
5393  const host = document.createElement('section');
5394  host.append(renderMarkdown(source));
5395  return host;
5396};
5397
5398// Headings
5399checkEqual(only(render('# Title'), 'h1').textContent, 'Title', 'h1');
5400checkEqual(only(render('### Deep'), 'h3').textContent, 'Deep', 'h3');
5401
5402// Nested lists
5403const nested = render('- one\n  - inner\n- two');
5404check(elements(nested, 'ul').length === 2, 'nested list produced ' + elements(nested, 'ul').length + ' lists');
5405check(elements(elements(nested, 'ul')[0], 'li').length >= 2, 'outer list lost items');
5406
5407// Ordered lists
5408checkEqual(elements(render('1. a\n2. b'), 'ol').length, 1, 'ordered list');
5409
5410// Fenced code stays unparsed
5411const fenced = render('```rust\nlet x = *y*;\n```');
5412checkEqual(only(fenced, 'code').textContent, 'let x = *y*;', 'fenced code');
5413check(elements(fenced, 'span').some(s => s.className === 'tok-kw'), 'fenced rust untinted');
5414checkEqual(elements(fenced, 'em').length, 0, 'fence emphasised its contents');
5415checkEqual(only(fenced, 'pre').dataset.lang, 'rust', 'fence language');
5416
5417// Inline code beats emphasis
5418checkEqual(only(render('`*not em*`'), 'code').textContent, '*not em*', 'inline code');
5419checkEqual(elements(render('`*not em*`'), 'em').length, 0, 'inline code emphasised');
5420
5421// Emphasis
5422checkEqual(only(render('**bold**'), 'strong').textContent, 'bold', 'strong');
5423checkEqual(only(render('*it*'), 'em').textContent, 'it', 'em');
5424checkEqual(only(render('~~gone~~'), 'del').textContent, 'gone', 'del');
5425
5426// Tables
5427const table = render('| a | b |\n| --- | ---: |\n| 1 | 2 |');
5428checkEqual(elements(table, 'table').length, 1, 'table');
5429checkEqual(elements(table, 'th').length, 2, 'table header cells');
5430checkEqual(elements(table, 'td').length, 2, 'table body cells');
5431checkEqual(elements(table, 'th')[1].className, 'align-right', 'table alignment class');
5432checkEqual(only(table, 'div').className, 'scroll-x', 'table scroll wrapper');
5433
5434// Blockquote and rule
5435checkEqual(elements(render('> quoted'), 'blockquote').length, 1, 'blockquote');
5436checkEqual(elements(render('---'), 'hr').length, 1, 'rule');
5437
5438// XSS: markup is text, never elements
5439const injected = render('<img src=x onerror=alert(1)>');
5440checkEqual(elements(injected, 'img').length, 0, 'raw HTML became an element');
5441check(injected.textContent.includes('<img src=x onerror=alert(1)>'), 'raw HTML lost its text');
5442
5443// XSS: refused link schemes
5444for (const target of ['javascript:alert(1)', 'JaVaScRiPt:alert(1)', 'java\tscript:alert(1)', 'data:text/html,<script>', 'vbscript:x']) {
5445  const out = render(`[click](${target})`);
5446  checkEqual(elements(out, 'a').length, 0, `link scheme ${JSON.stringify(target)} was allowed`);
5447  check(out.textContent.includes('click'), `link scheme ${JSON.stringify(target)} lost its label`);
5448}
5449
5450// Accepted schemes keep their href and carry safe rel/target
5451for (const target of ['https://example.com', 'http://example.com/a', 'mailto:someone@example.com']) {
5452  const anchor = only(render(`[click](${target})`), 'a');
5453  checkEqual(anchor.getAttribute('href'), target, 'href');
5454  checkEqual(anchor.getAttribute('rel'), 'noreferrer noopener', 'rel');
5455  checkEqual(anchor.getAttribute('target'), '_blank', 'target');
5456}
5457
5458// safeHref directly
5459checkEqual(safeHref('javascript:alert(1)'), null, 'safeHref allowed javascript:');
5460checkEqual(safeHref(' https://x.test '), 'https://x.test', 'safeHref cleaned value');
5461
5462// Inline markup inside a link label
5463checkEqual(only(render('[**bold link**](https://x.test)'), 'strong').textContent, 'bold link', 'link label markup');
5464
5465// An unclosed delimiter is literal, not markup
5466checkEqual(render('a * b').textContent, 'a * b', 'unclosed emphasis');
5467checkEqual(elements(render('a * b'), 'em').length, 0, 'unclosed emphasis made an element');
5468
5469// Diff summaries: the real format from format_diffstat, two spaces and U+2212
5470const diff = renderDiffSummary(['src/main.rs  +12 −3', 'unparseable line']);
5471const items = elements(diff, 'li');
5472checkEqual(items.length, 2, 'diffstat rows');
5473checkEqual(elements(items[0], 'span')[0].textContent, 'src/main.rs', 'diffstat path');
5474checkEqual(elements(items[0], 'span')[1].textContent, '+12', 'diffstat additions');
5475checkEqual(elements(items[0], 'span')[2].textContent, '−3', 'diffstat deletions');
5476checkEqual(elements(items[1], 'span').length, 1, 'unparseable diffstat produced counts');
5477checkEqual(elements(items[1], 'span')[0].textContent, 'unparseable line', 'unparseable diffstat lost its text');
5478
5479console.log('all markdown checks passed');
5480"#,
5481        );
5482    }
5483
5484    /// Tool output is not prose, and rendering it as prose loses the parts
5485    /// that matter: which words in a command are the program and which are
5486    /// paths, where a JSON payload begins, and whether a five-thousand-line
5487    /// dump has to be paid for before anyone asks to see it.
5488    #[test]
5489    fn tool_output_is_tinted_folded_and_never_read_as_markdown() {
5490        run_web_check(
5491            "tool-output",
5492            r#"import { installDocument, elements, only, check, checkEqual, openFold } from './test-dom.js';
5493installDocument();
5494const { renderToolOutput, codeBlock, detectLang, appendCommandTokens, isPathLike } = await import(
5495  './tool-output.js'
5496);
5497
5498const classes = root => elements(root, 'span').map(s => s.className);
5499
5500// A shell command is told apart into program, subcommand, flag and path.
5501const line = document.createElement('pre');
5502appendCommandTokens(line, 'cargo test --workspace src/lib.rs');
5503const seen = classes(line);
5504check(seen.includes('cmd-program'), 'no program: ' + seen);
5505check(seen.includes('cmd-subcommand'), 'no subcommand: ' + seen);
5506check(seen.includes('cmd-flag'), 'no flag: ' + seen);
5507check(seen.includes('cmd-path'), 'no path: ' + seen);
5508checkEqual(line.textContent, 'cargo test --workspace src/lib.rs', 'command text changed');
5509
5510// An operator starts the program count again, so both programs are found.
5511const piped = document.createElement('pre');
5512appendCommandTokens(piped, 'git status && cargo build');
5513checkEqual(classes(piped).filter(c => c === 'cmd-program').length, 2, 'pipeline reset');
5514
5515// Prose with a slash is not a path; a real path is.
5516check(!isPathLike('and/or'), '"and/or" read as a path');
5517check(isPathLike('src/lib/thing.rs'), 'a real path did not');
5518check(isPathLike('./x'), 'a relative path did not');
5519check(isPathLike('Cargo.toml'), 'a file with an extension did not');
5520
5521// JSON is pretty-printed and tinted, keys apart from values.
5522const json = renderToolOutput('{"name":"hel","count":3,"ok":true}');
5523const jsonClasses = classes(json);
5524check(jsonClasses.includes('tok-key'), 'no JSON key: ' + jsonClasses);
5525check(jsonClasses.includes('tok-str'), 'no JSON string: ' + jsonClasses);
5526check(jsonClasses.includes('tok-num'), 'no JSON number: ' + jsonClasses);
5527check(jsonClasses.includes('tok-kw'), 'no JSON keyword: ' + jsonClasses);
5528check(json.textContent.includes('"name"'), 'JSON lost its content');
5529
5530// Rust is tinted; an unknown language is not.
5531const rust = codeBlock('pub fn main() {\n    let x = 1;\n}', 'rust');
5532check(classes(rust).includes('tok-kw'), 'rust keywords untinted');
5533checkEqual(only(rust, 'pre').dataset.lang, 'rust', 'rust data-lang');
5534const plain = codeBlock('nothing in particular here', 'brainfuck');
5535checkEqual(classes(plain).length, 0, 'unknown language was tinted');
5536
5537// Sniffing is conservative: a log stays plain, real code does not.
5538checkEqual(detectLang('12:03 INFO started\n12:04 INFO done\n12:05 INFO stopped'), '', 'a log was sniffed');
5539checkEqual(
5540  detectLang('fn a() {}\nfn b() {}\nlet mut x = 1;\nuse std::fmt;\nimpl Foo {}\nlet y = x.unwrap();'),
5541  'rust',
5542  'rust was not sniffed',
5543);
5544checkEqual(detectLang('--- a/x\n+++ b/x\n@@ -1 +1 @@\n-old\n+new'), 'diff', 'diff was not sniffed');
5545
5546// A long dump is one closed fold that has built nothing yet.
5547const long = Array.from({ length: 400 }, (_, i) => `line ${i}`).join('\n');
5548const folded = renderToolOutput(long);
5549checkEqual(folded.nodeName, 'DETAILS', 'a 400-line dump was not folded');
5550checkEqual(elements(folded, 'pre').length, 0, 'a closed fold built its content anyway');
5551check(only(folded, 'summary').textContent.includes('400 lines'), 'fold summary: ' + only(folded, 'summary').textContent);
5552openFold(folded);
5553checkEqual(elements(folded, 'pre').length, 1, 'an opened fold built nothing');
5554check(elements(folded, 'pre')[0].textContent.includes('line 399'), 'the fold lost its content');
5555
5556// Opening twice builds once.
5557openFold(folded);
5558checkEqual(elements(folded, 'pre').length, 1, 'reopening rebuilt the content');
5559
5560// A short dump is not folded.
5561checkEqual(renderToolOutput('one\ntwo').nodeName, 'PRE', 'a short dump was folded');
5562
5563// Tool output is never parsed as Markdown, so an underscore is an underscore.
5564const literal = renderToolOutput('a _b_ c <img src=x>');
5565checkEqual(elements(literal, 'em').length, 0, 'tool output was emphasised');
5566checkEqual(elements(literal, 'img').length, 0, 'tool output produced an element');
5567check(literal.textContent.includes('<img src=x>'), 'tool output lost its text');
5568
5569console.log('all tool-output checks passed');
5570"#,
5571        );
5572    }
5573
5574    /// The renderer's guarantee is structural — this code cannot inject markup
5575    /// because it never builds markup — and a single stray assignment would
5576    /// quietly replace it with no guarantee at all. `escapeHtml` uses
5577    /// `innerHTML` on a detached node to escape text, which is safe but is
5578    /// also exactly the shape this test exists to stop spreading, so it is
5579    /// named rather than pattern-matched.
5580    #[test]
5581    fn no_web_module_builds_markup_from_a_string() {
5582        const SINKS: [&str; 5] = [
5583            "innerHTML",
5584            "outerHTML",
5585            "insertAdjacentHTML",
5586            "document.write",
5587            "new Function",
5588        ];
5589        // There is no allowance. Every one of these sinks was removed in
5590        // Milestone 2, and the point of the test is that none comes back.
5591        const ALLOWED: [(&str, &str); 0] = [];
5592        for (name, source) in [
5593            ("viewer.js", VIEWER_JS),
5594            ("markdown.js", MARKDOWN_JS),
5595            ("tool-output.js", TOOL_OUTPUT_JS),
5596        ] {
5597            for (number, line) in source.lines().enumerate() {
5598                let trimmed = line.trim();
5599                if trimmed.starts_with("//") || trimmed.starts_with("///") {
5600                    continue;
5601                }
5602                for sink in SINKS {
5603                    if !trimmed.contains(sink) {
5604                        continue;
5605                    }
5606                    assert!(
5607                        ALLOWED
5608                            .iter()
5609                            .any(|(file, allowed)| *file == name && trimmed == *allowed),
5610                        "{name}:{} builds markup from a string: {trimmed}",
5611                        number + 1
5612                    );
5613                }
5614            }
5615        }
5616    }
5617
5618    /// The card cache is the fix for answers vanishing under snapshot polls, so
5619    /// it is exercised as JavaScript: the render source is lifted out of
5620    /// `src/web/viewer.js` and run against a stub DOM.
5621    #[test]
5622    fn embedded_viewer_keeps_elicitation_answers_across_snapshot_polls() {
5623        let source = viewer_source(
5624            "const elicitationCards = new Map()",
5625            "async function submitElicitation",
5626        );
5627        let dom = r#"
5628let replaceCalls = 0;
5629function makeEl(tag) {
5630  return {
5631    tagName: tag.toUpperCase(),
5632    children: [],
5633    options: [],
5634    selectedOptions: [],
5635    className: "",
5636    textContent: "",
5637    disabled: false,
5638    required: false,
5639    value: "",
5640    appendChild(child) {
5641      this.children.push(child);
5642      if (this.tagName === "SELECT") this.options.push(child);
5643      return child;
5644    },
5645    append(...kids) {
5646      this.children.push(...kids);
5647    },
5648    replaceChildren(...kids) {
5649      replaceCalls += 1;
5650      this.children = kids;
5651    },
5652    addEventListener() {},
5653    querySelectorAll(selector) {
5654      const found = [];
5655      const visit = node => {
5656        for (const child of node.children) {
5657          if (child.tagName === "INPUT" && (selector === "input" || child.checked)) found.push(child);
5658          visit(child);
5659        }
5660      };
5661      visit(this);
5662      return found;
5663    },
5664    querySelector(selector) { return this.querySelectorAll(selector)[0] || null; },
5665    setCustomValidity() {},
5666    reportValidity() {
5667      return true;
5668    },
5669  };
5670}
5671const created = [];
5672const document = {
5673  createElement(tag) {
5674    const el = makeEl(tag);
5675    created.push(el);
5676    return el;
5677  },
5678};
5679const elicitations = makeEl("div");
5680function el(tag, className, text) {
5681  const node = document.createElement(tag);
5682  node.className = className || "";
5683  node.textContent = text || "";
5684  return node;
5685}
5686async function submitElicitation() {}
5687"#;
5688        let checks = r#"
5689const request = {
5690  id: "elicitation-1",
5691  message: "Which CI architecture?",
5692  title: "CI",
5693  fields: [
5694    {
5695      id: "question_0",
5696      title: "CI architecture",
5697      required: false,
5698      kind: "single_select",
5699      options: [{ value: "reusable", title: "Reusable" }, { value: "matrix", title: "Matrix" }],
5700    },
5701    { id: "question_0_custom", title: "Other", required: false, kind: "text" },
5702  ],
5703};
5704const session = { id: "session-1", pending_elicitations: [request] };
5705renderElicitations(session);
5706const card = elicitations.children[0];
5707const radio = created.find((el) => el.tagName === "INPUT" && el.value === "reusable");
5708const text = created.find((el) => el.tagName === "INPUT" && el.type === "text");
5709radio.checked = true;
5710text.value = "keep me";
5711const attachments = replaceCalls;
5712renderElicitations(session);
5713if (elicitations.children[0] !== card) {
5714  throw new Error("a snapshot rebuilt the pending card");
5715}
5716if (!radio.checked || text.value !== "keep me") {
5717  throw new Error("a snapshot wiped the half-filled answer");
5718}
5719if (replaceCalls !== attachments) {
5720  throw new Error("a snapshot re-attached an unchanged card and dropped focus");
5721}
5722sentElicitations.add(elicitationKey("session-1", request.id));
5723renderElicitations(session);
5724if (elicitations.children[0] !== card) {
5725  throw new Error("a sent answer rebuilt the card");
5726}
5727if (!radio.disabled || !text.disabled) {
5728  throw new Error("a sent answer left the controls live");
5729}
5730if (!radio.checked) {
5731  throw new Error("a sent answer wiped the reply");
5732}
5733renderElicitations({ id: "session-1", pending_elicitations: [] });
5734if (elicitations.children.length !== 0 || elicitationCards.size !== 0) {
5735  throw new Error("an answered request stayed rendered");
5736}
5737if (sentElicitations.size !== 0) {
5738  throw new Error("a resolved request kept its sent marker");
5739}
5740"#;
5741        run_viewer_script(
5742            "elicitation-rendering",
5743            &format!("{dom}\n{source}\n{checks}"),
5744        );
5745    }
5746
5747    fn sample_image(pixels: usize) -> ViewerPromptImage {
5748        ViewerPromptImage {
5749            data_base64: base64::engine::general_purpose::STANDARD.encode(vec![7_u8; pixels]),
5750            mime_type: "image/png".into(),
5751            width: 32,
5752            height: 24,
5753            attachment: None,
5754        }
5755    }
5756
5757    fn sample_valid_image() -> ViewerPromptImage {
5758        ViewerPromptImage {
5759            data_base64: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
5760                .into(),
5761            mime_type: "image/png".into(),
5762            width: 1,
5763            height: 1,
5764            attachment: None,
5765        }
5766    }
5767
5768    fn image_capable(snapshot: &mut ViewerSnapshot) {
5769        snapshot.sessions[0].prompt_images_supported = true;
5770    }
5771
5772    async fn post_action(app: Router, cookie: String, body: String) -> Response<Body> {
5773        app.oneshot(
5774            Request::post("/api/actions")
5775                .header(COOKIE, cookie)
5776                .header(CONTENT_TYPE, "application/json")
5777                .body(Body::from(body))
5778                .unwrap(),
5779        )
5780        .await
5781        .unwrap()
5782    }
5783
5784    #[tokio::test]
5785    async fn image_prompt_reaches_the_controller_with_its_images() {
5786        let (app, mut actions, _, _, _) = app_with_snapshot(image_capable);
5787        let cookie = login_cookie(&app).await;
5788        let image = sample_valid_image();
5789        let body = serde_json::to_string(&ControllerAction::Prompt {
5790            session_id: "session-1".into(),
5791            text: String::new(),
5792            images: vec![image.clone(), image.clone()],
5793        })
5794        .unwrap();
5795        let response = tokio::spawn(post_action(app, cookie, body));
5796        let request = actions.recv().await.unwrap();
5797        let ControllerRequest { action, reply } = request;
5798        let ControllerAction::Prompt {
5799            session_id,
5800            text,
5801            images,
5802        } = action
5803        else {
5804            panic!("expected a prompt action")
5805        };
5806        assert_eq!(session_id, "session-1");
5807        assert!(text.is_empty());
5808        assert_eq!(images.len(), 2);
5809        assert!(
5810            images
5811                .iter()
5812                .all(|image| { image.data_base64.is_empty() && image.attachment.is_some() })
5813        );
5814        reply.send(ActionOutcome::Accepted).unwrap();
5815        assert_eq!(response.await.unwrap().status(), StatusCode::ACCEPTED);
5816    }
5817
5818    #[tokio::test]
5819    async fn browser_attachment_upload_returns_a_stored_reference_without_inline_bytes() {
5820        let (app, _, _, _, _) = app_with_snapshot(image_capable);
5821        let cookie = login_cookie(&app).await;
5822        let bytes = base64::engine::general_purpose::STANDARD
5823            .decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=")
5824            .unwrap();
5825        let response = app
5826            .oneshot(
5827                Request::post("/api/sessions/session-1/attachments")
5828                    .header(COOKIE, cookie)
5829                    .header(CONTENT_TYPE, "image/png")
5830                    .body(Body::from(bytes))
5831                    .unwrap(),
5832            )
5833            .await
5834            .unwrap();
5835        assert_eq!(response.status(), StatusCode::OK);
5836        let body = response.into_body().collect().await.unwrap().to_bytes();
5837        let image: ViewerPromptImage = serde_json::from_slice(&body).unwrap();
5838        assert!(image.data_base64.is_empty());
5839        let reference = image.attachment.expect("upload should return a reference");
5840        assert_eq!(reference.mime_type, "image/png");
5841        assert_eq!(reference.width, 1);
5842        assert_eq!(reference.height, 1);
5843        assert!(reference.size <= 700 * 1024);
5844    }
5845
5846    /// Base64 inflates an upload by a third, so two ordinary photographs pass
5847    /// the general body limit even when each one fits it. The action route
5848    /// carries prompts, so it is the route that gets the larger bound.
5849    #[tokio::test]
5850    async fn multi_image_prompts_are_accepted_over_the_general_body_limit() {
5851        let (app, mut actions, _, _, _) = app_with_snapshot(image_capable);
5852        let cookie = login_cookie(&app).await;
5853        let image = sample_valid_image();
5854        let mut body = serde_json::to_string(&ControllerAction::Prompt {
5855            session_id: "session-1".into(),
5856            text: "look at these".into(),
5857            images: vec![image.clone(), image],
5858        })
5859        .unwrap();
5860        body.push_str(&" ".repeat(MAX_BODY_BYTES));
5861        assert!(body.len() > MAX_BODY_BYTES);
5862        assert!(body.len() < MAX_PROMPT_BODY_BYTES);
5863        let response = tokio::spawn(post_action(app, cookie, body));
5864        let action = actions.recv().await.unwrap();
5865        action.reply.send(ActionOutcome::Accepted).unwrap();
5866        assert_eq!(response.await.unwrap().status(), StatusCode::ACCEPTED);
5867    }
5868
5869    #[tokio::test]
5870    async fn a_body_over_the_prompt_limit_is_still_refused() {
5871        let (app, _actions, _, _, _) = app_with_snapshot(image_capable);
5872        let cookie = login_cookie(&app).await;
5873        let image = sample_image(MAX_PROMPT_BODY_BYTES);
5874        let body = serde_json::to_string(&ControllerAction::Prompt {
5875            session_id: "session-1".into(),
5876            text: String::new(),
5877            images: vec![image],
5878        })
5879        .unwrap();
5880        assert!(body.len() > MAX_PROMPT_BODY_BYTES);
5881        let response = post_action(app, cookie, body).await;
5882        assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE);
5883    }
5884
5885    #[tokio::test]
5886    async fn malformed_image_payloads_never_reach_the_controller() {
5887        let cases = [
5888            ("aW1hZ2U=", "text/plain", 32, 24),
5889            ("aW1hZ2U=", "image/png", 0, 24),
5890            ("not base64!", "image/png", 32, 24),
5891            ("", "image/png", 32, 24),
5892        ];
5893        for (data, mime, width, height) in cases {
5894            let (app, mut actions, _, _, _) = app_with_snapshot(image_capable);
5895            let cookie = login_cookie(&app).await;
5896            let body = serde_json::to_string(&ControllerAction::Prompt {
5897                session_id: "session-1".into(),
5898                text: String::new(),
5899                images: vec![ViewerPromptImage {
5900                    data_base64: data.into(),
5901                    mime_type: mime.into(),
5902                    width,
5903                    height,
5904                    attachment: None,
5905                }],
5906            })
5907            .unwrap();
5908            let response = post_action(app, cookie, body).await;
5909            assert_eq!(
5910                response.status(),
5911                StatusCode::BAD_REQUEST,
5912                "expected {data:?}/{mime} {width}x{height} to be refused"
5913            );
5914            assert!(actions.try_recv().is_err());
5915        }
5916    }
5917
5918    #[test]
5919    fn image_prompts_need_text_or_an_image_and_an_agent_that_takes_them() {
5920        let (config, state) = sample_config_state();
5921        let mut snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
5922        let prompt = |text: &str, images: Vec<ViewerPromptImage>| ControllerAction::Prompt {
5923            session_id: "session-1".into(),
5924            text: text.into(),
5925            images,
5926        };
5927
5928        // Without the capability the session takes text only.
5929        assert!(validate_action(&prompt("ship it", Vec::new()), &snapshot).is_ok());
5930        assert!(validate_action(&prompt("", vec![sample_image(8)]), &snapshot).is_err());
5931
5932        image_capable(&mut snapshot);
5933        // An image is a prompt on its own; nothing at all is not.
5934        assert!(validate_action(&prompt("", vec![sample_image(8)]), &snapshot).is_ok());
5935        assert!(
5936            validate_action(
5937                &prompt("", vec![sample_image(8); MAX_PROMPT_IMAGES + 1]),
5938                &snapshot,
5939            )
5940            .is_err()
5941        );
5942        assert!(validate_action(&prompt("   ", Vec::new()), &snapshot).is_err());
5943        assert!(validate_action(&prompt("", Vec::new()), &snapshot).is_err());
5944        // A shell command is still a shell command.
5945        assert!(validate_action(&prompt("!ls", vec![sample_image(8)]), &snapshot).is_err());
5946    }
5947
5948    /// The composer holds a DOM, not a string, so the text a prompt sends is
5949    /// whatever this reader makes of that DOM. Run it as JavaScript.
5950    #[test]
5951    fn embedded_viewer_reads_multiline_composer_text_out_of_its_dom() {
5952        let source = viewer_source("function composerText()", "function setComposerText(");
5953        let harness = r##"
5954const Node = { TEXT_NODE: 3 };
5955function textNode(value) {
5956  return { nodeType: 3, nodeValue: value, nodeName: "#text", childNodes: [], dataset: {} };
5957}
5958function element(name, children = [], dataset = {}) {
5959  const node = { nodeType: 1, nodeName: name, dataset, childNodes: children };
5960  children.forEach((child, index) => {
5961    child.nextSibling = children[index + 1] || null;
5962  });
5963  return node;
5964}
5965let promptText = null;
5966function read(children) {
5967  promptText = element("DIV", children);
5968  return composerText();
5969}
5970"##;
5971        let checks = r#"
5972const plain = read([textNode("ship it")]);
5973if (plain !== "ship it") throw new Error(`plain text became ${JSON.stringify(plain)}`);
5974
5975const broken = read([textNode("first"), element("BR"), textNode("second")]);
5976if (broken !== "first\nsecond") throw new Error(`line break became ${JSON.stringify(broken)}`);
5977
5978// The trailing break a browser leaves behind to keep the caret on a new line
5979// is scaffolding, not a line the user typed.
5980const filler = read([
5981  textNode("first"),
5982  element("BR"),
5983  element("BR", [], { composerFiller: "true" }),
5984]);
5985if (filler !== "first\n") throw new Error(`filler break became ${JSON.stringify(filler)}`);
5986
5987const blocks = read([
5988  textNode("first"),
5989  element("DIV", [textNode("second")]),
5990  element("DIV", [textNode("third")]),
5991]);
5992if (blocks !== "first\nsecond\nthird") throw new Error(`blocks became ${JSON.stringify(blocks)}`);
5993
5994const carriage = read([textNode("first\r\nsecond")]);
5995if (carriage !== "first\nsecond") throw new Error(`CRLF became ${JSON.stringify(carriage)}`);
5996"#;
5997        run_viewer_script("composer-reader", &format!("{harness}\n{source}\n{checks}"));
5998    }
5999
6000    /// A page that declares no icon makes every browser request
6001    /// `/favicon.ico`, which this server does not have. The page therefore has
6002    /// to name an icon, and that icon has to be served.
6003    #[tokio::test]
6004    async fn viewer_declares_the_icon_route_instead_of_requesting_a_missing_favicon() {
6005        let (app, _, _, _, _) = app();
6006        let page = fetch_text(app.clone(), "/").await;
6007        assert!(page.contains(r#"rel="icon""#), "the page declares no icon");
6008        assert!(page.contains("/icon.svg"), "the page names no icon route");
6009        let icon = app
6010            .oneshot(Request::get("/icon.svg").body(Body::empty()).unwrap())
6011            .await
6012            .unwrap();
6013        assert_eq!(icon.status(), StatusCode::OK);
6014        assert_eq!(
6015            icon.headers().get(CONTENT_TYPE).unwrap(),
6016            "image/svg+xml",
6017            "the icon route does not serve an SVG"
6018        );
6019    }
6020
6021    #[tokio::test]
6022    async fn valid_action_is_typed_and_forwarded() {
6023        let (app, mut actions, _, _, _) = app();
6024        let cookie = login_cookie(&app).await;
6025        let response = tokio::spawn(
6026            app.oneshot(
6027                Request::post("/api/actions")
6028                    .header(COOKIE, cookie)
6029                    .header(CONTENT_TYPE, "application/json")
6030                    .body(Body::from(
6031                        r#"{"action":"prompt","session_id":"session-1","text":"ship it"}"#,
6032                    ))
6033                    .unwrap(),
6034            ),
6035        );
6036        let action = actions.recv().await.unwrap();
6037        assert_eq!(
6038            action.action,
6039            ControllerAction::Prompt {
6040                session_id: "session-1".into(),
6041                text: "ship it".into(),
6042                images: Vec::new(),
6043            }
6044        );
6045        action.reply.send(ActionOutcome::Accepted).unwrap();
6046        let response = response.await.unwrap().unwrap();
6047        assert_eq!(response.status(), StatusCode::ACCEPTED);
6048    }
6049
6050    #[tokio::test]
6051    async fn move_preparation_is_read_only_and_returns_the_daemon_fingerprint() {
6052        let (app, mut preparations) = app_with_move_receiver();
6053        let cookie = login_cookie(&app).await;
6054        let response = tokio::spawn(
6055            app.oneshot(
6056                Request::post("/api/moves/prepare")
6057                    .header(COOKIE, cookie)
6058                    .header(CONTENT_TYPE, "application/json")
6059                    .body(Body::from(
6060                        r#"{"session_id":"session-1","profile_id":"codex-1","target_template_id":"podman","clear_resource_allocation":false,"additional_mounts":null,"resource_allocation":null}"#,
6061                    ))
6062                    .unwrap(),
6063            ),
6064        );
6065        let request = preparations
6066            .recv()
6067            .await
6068            .expect("preparation reached daemon");
6069        assert_eq!(request.selection.session_id, "session-1");
6070        assert_eq!(request.selection.profile_id.as_deref(), Some("codex-1"));
6071        assert_eq!(
6072            request.selection.target_template_id.as_deref(),
6073            Some("podman")
6074        );
6075        request
6076            .reply
6077            .send(Ok(MovePreparation {
6078                selection: request.selection,
6079                source_profile_id: "codex-1".into(),
6080                source_target_template_id: "podman".into(),
6081                cross_harness: false,
6082                active: true,
6083                queued_commands: vec![hel::hel_state::MaterializedQueuedPrompt {
6084                    command_id: "queued-1".into(),
6085                    kind: hel::hel_state::QueuedCommandKind::Prompt,
6086                    content: vec![serde_json::json!({
6087                        "type": "image",
6088                        "mimeType": "image/png",
6089                        "data": "secret-image-bytes"
6090                    })],
6091                    queued_at_ms: 1,
6092                }],
6093                fingerprint: "fingerprint".into(),
6094                operation_id: "move-1".into(),
6095            }))
6096            .unwrap();
6097        let response = response.await.unwrap().unwrap();
6098        assert_eq!(response.status(), StatusCode::OK);
6099        let body = response.into_body().collect().await.unwrap().to_bytes();
6100        let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
6101        assert_eq!(body["operation_id"], "move-1");
6102        assert_eq!(body["active"], true);
6103        assert_eq!(
6104            body["queued_commands"][0]["content"][0]["text"],
6105            "[Image attachment: image/png]"
6106        );
6107        assert!(body.to_string().contains("[Image attachment: image/png]"));
6108        assert!(!body.to_string().contains("secret-image-bytes"));
6109    }
6110
6111    #[tokio::test]
6112    async fn confirmed_move_action_forwards_the_fingerprinted_request() {
6113        let (app, mut actions, _, _, _) = app_with_snapshot(|snapshot| {
6114            snapshot.sessions[0].capabilities.move_session = true;
6115        });
6116        let cookie = login_cookie(&app).await;
6117        let response = tokio::spawn(
6118            app.oneshot(
6119                Request::post("/api/actions")
6120                    .header(COOKIE, cookie)
6121                    .header(CONTENT_TYPE, "application/json")
6122                    .body(Body::from(
6123                        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}}"#,
6124                    ))
6125                    .unwrap(),
6126            ),
6127        );
6128        let action = actions.recv().await.expect("move action reached daemon");
6129        assert!(matches!(action.action, ControllerAction::Move { .. }));
6130        action.reply.send(ActionOutcome::Accepted).unwrap();
6131        assert_eq!(
6132            response.await.unwrap().unwrap().status(),
6133            StatusCode::ACCEPTED
6134        );
6135    }
6136
6137    #[tokio::test]
6138    async fn shell_action_is_typed_and_forwarded() {
6139        let (app, mut actions, _, _, _) = app();
6140        let cookie = login_cookie(&app).await;
6141        let response = tokio::spawn(
6142            app.oneshot(
6143                Request::post("/api/actions")
6144                    .header(COOKIE, cookie)
6145                    .header(CONTENT_TYPE, "application/json")
6146                    .body(Body::from(
6147                        r#"{"action":"run-shell","session_id":"session-1","command":"cargo test"}"#,
6148                    ))
6149                    .unwrap(),
6150            ),
6151        );
6152        let action = actions.recv().await.unwrap();
6153        assert_eq!(
6154            action.action,
6155            ControllerAction::RunShell {
6156                session_id: "session-1".into(),
6157                command: "cargo test".into(),
6158            }
6159        );
6160        action.reply.send(ActionOutcome::Accepted).unwrap();
6161        assert_eq!(
6162            response.await.unwrap().unwrap().status(),
6163            StatusCode::ACCEPTED
6164        );
6165    }
6166
6167    #[test]
6168    fn shell_action_validation_reserves_bang_prompts_and_checks_cancellation_ids() {
6169        let (config, state) = sample_config_state();
6170        let mut snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
6171        assert!(
6172            validate_action(
6173                &ControllerAction::Prompt {
6174                    session_id: "session-1".into(),
6175                    text: "!cargo test".into(),
6176                    images: Vec::new(),
6177                },
6178                &snapshot,
6179            )
6180            .is_err()
6181        );
6182        assert!(
6183            validate_action(
6184                &ControllerAction::RunShell {
6185                    session_id: "session-1".into(),
6186                    command: "cargo test".into(),
6187                },
6188                &snapshot,
6189            )
6190            .is_ok()
6191        );
6192        assert!(
6193            validate_action(
6194                &ControllerAction::CancelShell {
6195                    session_id: "session-1".into(),
6196                    shell_command_id: "shell-1".into(),
6197                },
6198                &snapshot,
6199            )
6200            .is_err()
6201        );
6202
6203        snapshot.sessions[0]
6204            .active_user_shells
6205            .push(ViewerUserShell {
6206                id: "shell-1".into(),
6207                command: "cargo test".into(),
6208                started_at_ms: Some(10),
6209            });
6210        assert!(
6211            validate_action(
6212                &ControllerAction::CancelShell {
6213                    session_id: "session-1".into(),
6214                    shell_command_id: "shell-1".into(),
6215                },
6216                &snapshot,
6217            )
6218            .is_ok()
6219        );
6220    }
6221
6222    #[tokio::test]
6223    async fn bare_new_action_forwards_an_explicit_safe_project_directory() {
6224        let (app, mut actions, _, _, _) = app();
6225        let cookie = login_cookie(&app).await;
6226        let response = tokio::spawn(
6227            app.oneshot(
6228                Request::post("/api/actions")
6229                    .header(COOKIE, cookie)
6230                    .header(CONTENT_TYPE, "application/json")
6231                    .body(Body::from(
6232                        r#"{"action":"new","profile_id":"codex-1","bundle_id":"hel","target_id":"raw","title":"Raw work","project_directory":"/work/project"}"#,
6233                    ))
6234                    .unwrap(),
6235            ),
6236        );
6237        let action = actions.recv().await.unwrap();
6238        assert_eq!(
6239            action.action,
6240            ControllerAction::New {
6241                workspace_id: String::new(),
6242                profile_id: "codex-1".into(),
6243                bundle_id: "hel".into(),
6244                target_id: "raw".into(),
6245                title: Some("Raw work".into()),
6246                project_directory: Some(PathBuf::from("/work/project")),
6247                dirty_ack: Vec::new(),
6248            }
6249        );
6250        action.reply.send(ActionOutcome::Accepted).unwrap();
6251        assert_eq!(
6252            response.await.unwrap().unwrap().status(),
6253            StatusCode::ACCEPTED
6254        );
6255    }
6256
6257    #[test]
6258    fn new_action_requires_project_directory_exactly_for_bare_targets() {
6259        let (config, state) = sample_config_state();
6260        let snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
6261        let action = |target_id: &str, project_directory: Option<PathBuf>| ControllerAction::New {
6262            workspace_id: String::new(),
6263            profile_id: "codex-1".into(),
6264            bundle_id: "hel".into(),
6265            target_id: target_id.into(),
6266            title: Some("New work".into()),
6267            project_directory,
6268            dirty_ack: Vec::new(),
6269        };
6270
6271        assert!(validate_action(&action("podman", None), &snapshot).is_ok());
6272        assert_eq!(
6273            validate_action(&action("podman", Some("/work".into())), &snapshot)
6274                .unwrap_err()
6275                .status,
6276            StatusCode::BAD_REQUEST
6277        );
6278        assert_eq!(
6279            validate_action(&action("raw", None), &snapshot)
6280                .unwrap_err()
6281                .status,
6282            StatusCode::BAD_REQUEST
6283        );
6284        assert_eq!(
6285            validate_action(&action("raw", Some("relative".into())), &snapshot)
6286                .unwrap_err()
6287                .status,
6288            StatusCode::BAD_REQUEST
6289        );
6290        assert_eq!(
6291            validate_action(&action("raw", Some("/work/../secret".into())), &snapshot)
6292                .unwrap_err()
6293                .status,
6294            StatusCode::BAD_REQUEST
6295        );
6296        assert!(validate_action(&action("raw", Some("/work/project".into())), &snapshot).is_ok());
6297    }
6298
6299    #[tokio::test]
6300    async fn cancel_action_is_typed_and_forwarded() {
6301        let (app, mut actions, _, _, _) = app();
6302        let cookie = login_cookie(&app).await;
6303        let response = tokio::spawn(
6304            app.oneshot(
6305                Request::post("/api/actions")
6306                    .header(COOKIE, cookie)
6307                    .header(CONTENT_TYPE, "application/json")
6308                    .body(Body::from(
6309                        r#"{"action":"cancel","session_id":"session-1"}"#,
6310                    ))
6311                    .unwrap(),
6312            ),
6313        );
6314        let action = actions.recv().await.unwrap();
6315        assert_eq!(
6316            action.action,
6317            ControllerAction::Cancel {
6318                session_id: "session-1".into(),
6319            }
6320        );
6321        action.reply.send(ActionOutcome::Accepted).unwrap();
6322        assert_eq!(
6323            response.await.unwrap().unwrap().status(),
6324            StatusCode::ACCEPTED
6325        );
6326    }
6327
6328    #[tokio::test]
6329    async fn action_validation_accepts_cross_harness_resume_and_rejects_unknown() {
6330        let (mut config, state) = sample_config_state();
6331        config.profiles.insert(
6332            "claude-1".into(),
6333            HarnessProfile {
6334                context_window_bytes: None,
6335                kind: HarnessKind::Claude,
6336                home: "/secret/claude".into(),
6337                environment: BTreeMap::new(),
6338            },
6339        );
6340        let mut snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
6341        snapshot.workspaces.push(ViewerWorkspace {
6342            id: "workspace-1".into(),
6343            name: "One".into(),
6344        });
6345        validate_action(
6346            &ControllerAction::Resume {
6347                session_id: "session-1".into(),
6348                workspace_id: "workspace-1".into(),
6349                profile_id: "claude-1".into(),
6350                target_id: "podman".into(),
6351                queue: ResumeQueueDisposition::Start,
6352                additional_mounts: None,
6353                resource_allocation: None,
6354            },
6355            &snapshot,
6356        )
6357        .unwrap();
6358
6359        let error = validate_action(
6360            &ControllerAction::Resume {
6361                session_id: "session-1".into(),
6362                workspace_id: "missing".into(),
6363                profile_id: "claude-1".into(),
6364                target_id: "podman".into(),
6365                queue: ResumeQueueDisposition::Start,
6366                additional_mounts: None,
6367                resource_allocation: None,
6368            },
6369            &snapshot,
6370        )
6371        .unwrap_err();
6372        assert_eq!(error.status, StatusCode::BAD_REQUEST);
6373
6374        let error = validate_action(
6375            &ControllerAction::Close {
6376                session_id: "not-managed".into(),
6377            },
6378            &snapshot,
6379        )
6380        .unwrap_err();
6381        assert_eq!(error.status, StatusCode::NOT_FOUND);
6382    }
6383
6384    /// A review the daemon is running reaches the phone whole: its tier, what
6385    /// each reviewing agent is doing, and the findings to answer.
6386    #[test]
6387    fn a_running_review_projects_to_the_phone() {
6388        use crate::hel_review_host::{RuntimeReviewView, VerdictKind, VerdictView};
6389        use hel::hel_review::driver::{Resolution, RoleState, RoleStatus, TurnReviewPhase};
6390
6391        let review = RuntimeReviewView {
6392            session_id: "session-1".into(),
6393            tier: hel::hel_review::lanes::ReviewTier::Extended,
6394            phase: TurnReviewPhase::Verdict(hel::hel_review::verdict::ReviewVerdict::Findings {
6395                synthesis: "[P1] src/lib.rs:1 -- unbounded retry".into(),
6396                evidence: Default::default(),
6397            }),
6398            roles: vec![
6399                RoleStatus {
6400                    role: "supervisor".into(),
6401                    label: "Supervisor".into(),
6402                    state: RoleState::Clean,
6403                },
6404                RoleStatus {
6405                    role: "tests".into(),
6406                    label: "Tests".into(),
6407                    state: RoleState::Findings,
6408                },
6409            ],
6410            status: "Enter to act".into(),
6411            verdict: Some(VerdictView {
6412                kind: VerdictKind::Findings,
6413                text: "[P1] src/lib.rs:1 -- unbounded retry".into(),
6414                allowed: vec![
6415                    Resolution::Forwarded,
6416                    Resolution::Dismissed,
6417                    Resolution::Cancelled,
6418                ],
6419            }),
6420        };
6421
6422        let projected = ViewerTurnReview::from_runtime(&review);
6423
6424        assert_eq!(projected.tier, "extended");
6425        assert_eq!(
6426            projected
6427                .roles
6428                .iter()
6429                .map(|role| (role.label.as_str(), role.state.as_str()))
6430                .collect::<Vec<_>>(),
6431            vec![("Supervisor", "done"), ("Tests", "findings")]
6432        );
6433        let verdict = projected.verdict.expect("a findings verdict travels");
6434        assert_eq!(verdict.kind, "findings");
6435        assert!(verdict.text.contains("unbounded retry"));
6436        assert_eq!(verdict.allowed, vec!["forward", "dismiss", "cancel"]);
6437    }
6438
6439    /// A phone can always cancel a review, and can only forward or dismiss one
6440    /// the daemon says is ready for it. The same gate runs in the daemon; this
6441    /// one is what makes the refusal immediate.
6442    #[test]
6443    fn resolving_a_review_is_gated_on_what_the_daemon_published() {
6444        let (config, state) = sample_config_state();
6445        let mut snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
6446
6447        let resolve = |resolution: &str| ControllerAction::ResolveReview {
6448            session_id: "session-1".into(),
6449            resolution: resolution.into(),
6450        };
6451
6452        // No review at all.
6453        let error = validate_action(&resolve("cancel"), &snapshot).unwrap_err();
6454        assert_eq!(error.status, StatusCode::BAD_REQUEST);
6455
6456        snapshot.sessions[0].turn_review = Some(ViewerTurnReview {
6457            tier: "quick".into(),
6458            status: "the reviewer is reading the change…".into(),
6459            roles: Vec::new(),
6460            verdict: None,
6461        });
6462        // Running: cancel works, the rest do not.
6463        validate_action(&resolve("cancel"), &snapshot).unwrap();
6464        assert_eq!(
6465            validate_action(&resolve("forward"), &snapshot)
6466                .unwrap_err()
6467                .status,
6468            StatusCode::BAD_REQUEST
6469        );
6470
6471        // A failed review can be dismissed but has nothing to forward.
6472        snapshot.sessions[0].turn_review = Some(ViewerTurnReview {
6473            tier: "quick".into(),
6474            status: "the review failed".into(),
6475            roles: Vec::new(),
6476            verdict: Some(ViewerReviewVerdict {
6477                kind: "failed".into(),
6478                text: "bifrost exited with 1".into(),
6479                allowed: vec!["dismiss".into(), "cancel".into()],
6480            }),
6481        });
6482        validate_action(&resolve("dismiss"), &snapshot).unwrap();
6483        assert_eq!(
6484            validate_action(&resolve("forward"), &snapshot)
6485                .unwrap_err()
6486                .status,
6487            StatusCode::BAD_REQUEST
6488        );
6489        // A resolution that is not one of the three is refused by name.
6490        assert_eq!(
6491            validate_action(&resolve("approve"), &snapshot)
6492                .unwrap_err()
6493                .status,
6494            StatusCode::BAD_REQUEST
6495        );
6496
6497        // Starting a review needs only a session that exists.
6498        validate_action(
6499            &ControllerAction::StartReview {
6500                session_id: "session-1".into(),
6501            },
6502            &snapshot,
6503        )
6504        .unwrap();
6505        assert_eq!(
6506            validate_action(
6507                &ControllerAction::StartReview {
6508                    session_id: "not-managed".into(),
6509                },
6510                &snapshot,
6511            )
6512            .unwrap_err()
6513            .status,
6514            StatusCode::NOT_FOUND
6515        );
6516    }
6517
6518    #[test]
6519    fn resume_action_refuses_a_target_the_session_cannot_use() {
6520        let (mut config, state) = sample_config_state();
6521        // A project that only exists on GitHub cannot become a checkout on this
6522        // machine, so the bare target stays out of reach for its sessions.
6523        config.bundles.get_mut("hel").unwrap().repositories[0].local = None;
6524        let mut snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
6525        snapshot.workspaces.push(ViewerWorkspace {
6526            id: "workspace-1".into(),
6527            name: "One".into(),
6528        });
6529        assert_eq!(
6530            snapshot.sessions[0].incompatible_resume_targets,
6531            vec!["raw".to_owned()]
6532        );
6533
6534        let error = validate_action(
6535            &ControllerAction::Resume {
6536                session_id: "session-1".into(),
6537                workspace_id: "workspace-1".into(),
6538                profile_id: "codex-1".into(),
6539                target_id: "raw".into(),
6540                queue: ResumeQueueDisposition::Start,
6541                additional_mounts: None,
6542                resource_allocation: None,
6543            },
6544            &snapshot,
6545        )
6546        .unwrap_err();
6547
6548        assert_eq!(error.status, StatusCode::BAD_REQUEST);
6549    }
6550
6551    #[test]
6552    fn move_confirmation_requires_interruption_ack_and_an_explicit_queue_choice() {
6553        let (config, state) = sample_config_state();
6554        let mut snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
6555        snapshot.sessions[0].capabilities.move_session = true;
6556        let selection = MoveSelection {
6557            clear_resource_allocation: false,
6558            session_id: "session-1".into(),
6559            profile_id: Some("codex-1".into()),
6560            target_template_id: Some("podman".into()),
6561            additional_mounts: None,
6562            resource_allocation: None,
6563        };
6564        let preparation = MovePreparation {
6565            selection,
6566            source_profile_id: "codex-1".into(),
6567            source_target_template_id: "podman".into(),
6568            cross_harness: false,
6569            active: true,
6570            queued_commands: vec![hel::hel_state::MaterializedQueuedPrompt {
6571                command_id: "command-1".into(),
6572                kind: hel::hel_state::QueuedCommandKind::Prompt,
6573                content: vec![serde_json::json!({"type": "text", "text": "continue"})],
6574                queued_at_ms: 1,
6575            }],
6576            fingerprint: "fingerprint".into(),
6577            operation_id: "move-1".into(),
6578        };
6579        let request = |queue, acknowledge_interruption| MoveSessionRequest {
6580            preparation: preparation.clone(),
6581            queue,
6582            acknowledge_interruption,
6583        };
6584        assert_eq!(
6585            validate_action(
6586                &ControllerAction::Move {
6587                    request: request(Some(ResumeQueueDisposition::Discard), false),
6588                },
6589                &snapshot,
6590            )
6591            .unwrap_err()
6592            .status,
6593            StatusCode::CONFLICT
6594        );
6595        assert_eq!(
6596            validate_action(
6597                &ControllerAction::Move {
6598                    request: request(None, true),
6599                },
6600                &snapshot,
6601            )
6602            .unwrap_err()
6603            .status,
6604            StatusCode::BAD_REQUEST
6605        );
6606        validate_action(
6607            &ControllerAction::Move {
6608                request: request(Some(ResumeQueueDisposition::Discard), true),
6609            },
6610            &snapshot,
6611        )
6612        .unwrap();
6613    }
6614
6615    #[tokio::test]
6616    async fn snapshot_endpoint_returns_only_public_projection() {
6617        let (app, _, _, _, _) = app();
6618        let cookie = login_cookie(&app).await;
6619        let response = app
6620            .oneshot(
6621                Request::get("/api/snapshot")
6622                    .header(COOKIE, cookie)
6623                    .body(Body::empty())
6624                    .unwrap(),
6625            )
6626            .await
6627            .unwrap();
6628        let body = response.into_body().collect().await.unwrap().to_bytes();
6629        let body = String::from_utf8(body.to_vec()).unwrap();
6630        assert!(body.contains("session-1"));
6631        assert!(!body.contains("secret-token"));
6632        assert!(!body.contains("native-secret-id"));
6633        assert!(!body.contains("/private/source/hel"));
6634
6635        let snapshot: serde_json::Value = serde_json::from_str(&body).unwrap();
6636        let repository = &snapshot["bundles"][0]["repositories"][0];
6637        assert_eq!(repository["id"], "hel");
6638        assert_eq!(repository["github"], "owner/hel");
6639        assert_eq!(repository["destination"], "hel");
6640        assert!(repository.get("local").is_none());
6641    }
6642
6643    #[tokio::test]
6644    async fn snapshot_clock_anchor_is_fresh_even_when_the_projection_has_not_changed() {
6645        let (app, _, _, _, _) = app_with_snapshot(|snapshot| snapshot.server_time_ms = 1);
6646        let cookie = login_cookie(&app).await;
6647        for _ in 0..2 {
6648            let before = hel::clock::epoch_millis();
6649            let response = app
6650                .clone()
6651                .oneshot(
6652                    Request::get("/api/snapshot")
6653                        .header(COOKIE, &cookie)
6654                        .body(Body::empty())
6655                        .unwrap(),
6656                )
6657                .await
6658                .unwrap();
6659            let body = response.into_body().collect().await.unwrap().to_bytes();
6660            let snapshot: ViewerSnapshot = serde_json::from_slice(&body).unwrap();
6661            assert!(snapshot.server_time_ms >= before);
6662            assert!(snapshot.server_time_ms <= hel::clock::epoch_millis());
6663        }
6664    }
6665
6666    #[tokio::test]
6667    async fn conversation_endpoint_returns_authenticated_bounded_deltas() {
6668        let transcript = BrowserTranscript {
6669            latest_seq: 8,
6670            window_start_seq: 3,
6671            reset: false,
6672            entries: vec![
6673                BrowserTranscriptEntry {
6674                    id: 3,
6675                    updated_seq: 3,
6676                    role: "user",
6677                    label: "You".into(),
6678                    recorded_at_ms: None,
6679                    lines: vec!["begin".into()],
6680                    glyph: "\u{276f}",
6681                    tone: "user",
6682                    tool_status: None,
6683                    diffstats: Vec::new(),
6684                },
6685                BrowserTranscriptEntry {
6686                    id: 7,
6687                    updated_seq: 8,
6688                    role: "agent",
6689                    label: "Agent".into(),
6690                    recorded_at_ms: None,
6691                    lines: vec!["live".into()],
6692                    glyph: "\u{25cf}",
6693                    tone: "agent",
6694                    tool_status: None,
6695                    diffstats: Vec::new(),
6696                },
6697            ],
6698        };
6699        let (app, _, _, _, _) =
6700            app_with_conversations(BTreeMap::from([("session-1".into(), transcript)]));
6701        let cookie = login_cookie(&app).await;
6702        let response = app
6703            .oneshot(
6704                Request::get("/api/conversations/session-1?after_seq=3")
6705                    .header(COOKIE, cookie)
6706                    .body(Body::empty())
6707                    .unwrap(),
6708            )
6709            .await
6710            .unwrap();
6711        assert_eq!(response.status(), StatusCode::OK);
6712        let body = response.into_body().collect().await.unwrap().to_bytes();
6713        let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
6714        assert_eq!(body["latest_seq"], 8);
6715        assert_eq!(body["reset"], false);
6716        assert_eq!(body["entries"].as_array().unwrap().len(), 1);
6717        assert_eq!(body["entries"][0]["lines"][0], "live");
6718    }
6719
6720    #[tokio::test]
6721    async fn conversation_endpoint_rejects_cached_transcript_during_transition() {
6722        let transcript = BrowserTranscript {
6723            latest_seq: 1,
6724            window_start_seq: 1,
6725            reset: false,
6726            entries: vec![BrowserTranscriptEntry {
6727                id: 1,
6728                updated_seq: 1,
6729                role: "agent",
6730                label: "Agent".into(),
6731                recorded_at_ms: None,
6732                lines: vec!["stale".into()],
6733                glyph: "●",
6734                tone: "agent",
6735                tool_status: None,
6736                diffstats: Vec::new(),
6737            }],
6738        };
6739        let (app, _, _, _, _) = app_with(
6740            BTreeMap::from([("session-1".into(), transcript)]),
6741            |snapshot| snapshot.sessions[0].transitioning = true,
6742        );
6743        let cookie = login_cookie(&app).await;
6744        let response = app
6745            .oneshot(
6746                Request::get("/api/conversations/session-1")
6747                    .header(COOKIE, cookie)
6748                    .body(Body::empty())
6749                    .unwrap(),
6750            )
6751            .await
6752            .unwrap();
6753        assert_eq!(response.status(), StatusCode::CONFLICT);
6754    }
6755
6756    #[tokio::test]
6757    async fn conversation_read_receipt_never_contends_with_a_running_action() {
6758        let (app, mut actions, mut receipts, _, _) = app();
6759        let cookie = login_cookie(&app).await;
6760        // A prompt for the same session stays in flight for the whole test, so
6761        // a receipt that still travelled the action pipeline would either
6762        // queue behind it or be rejected for the occupied session slot.
6763        let prompt = tokio::spawn(
6764            app.clone().oneshot(
6765                Request::post("/api/actions")
6766                    .header(COOKIE, cookie.clone())
6767                    .header(CONTENT_TYPE, "application/json")
6768                    .body(Body::from(
6769                        r#"{"action":"prompt","session_id":"session-1","text":"ship it"}"#,
6770                    ))
6771                    .unwrap(),
6772            ),
6773        );
6774        let action = actions.recv().await.unwrap();
6775
6776        let response = tokio::spawn(
6777            app.oneshot(
6778                Request::post("/api/conversations/session-1/read")
6779                    .header(COOKIE, cookie)
6780                    .header(CONTENT_TYPE, "application/json")
6781                    .body(Body::from(r#"{"through":42}"#))
6782                    .unwrap(),
6783            ),
6784        );
6785        let receipt = receipts.recv().await.unwrap();
6786        assert_eq!(receipt.session_id, "session-1");
6787        assert_eq!(receipt.through, 42);
6788        receipt.reply.send(Ok(())).unwrap();
6789        assert_eq!(
6790            response.await.unwrap().unwrap().status(),
6791            StatusCode::NO_CONTENT
6792        );
6793        assert!(
6794            actions.try_recv().is_err(),
6795            "a read receipt must not queue a controller action"
6796        );
6797
6798        action.reply.send(ActionOutcome::Accepted).unwrap();
6799        assert_eq!(
6800            prompt.await.unwrap().unwrap().status(),
6801            StatusCode::ACCEPTED
6802        );
6803    }
6804
6805    #[tokio::test]
6806    async fn each_rejected_action_keeps_its_own_status_and_guidance() {
6807        for (outcome, status, guidance) in [
6808            (
6809                ActionOutcome::Busy,
6810                StatusCode::TOO_MANY_REQUESTS,
6811                "concurrent action limit",
6812            ),
6813            (
6814                ActionOutcome::SessionBusy,
6815                StatusCode::CONFLICT,
6816                "another operation is already running",
6817            ),
6818            (
6819                ActionOutcome::NotCancellable,
6820                StatusCode::CONFLICT,
6821                "no cancellable operation",
6822            ),
6823            (
6824                ActionOutcome::Failed,
6825                StatusCode::INTERNAL_SERVER_ERROR,
6826                "could not start this action",
6827            ),
6828        ] {
6829            let (app, mut actions, _, _, _) = app();
6830            let cookie = login_cookie(&app).await;
6831            let response = tokio::spawn(
6832                app.oneshot(
6833                    Request::post("/api/actions")
6834                        .header(COOKIE, cookie)
6835                        .header(CONTENT_TYPE, "application/json")
6836                        .body(Body::from(r#"{"action":"close","session_id":"session-1"}"#))
6837                        .unwrap(),
6838                ),
6839            );
6840            let request = actions.recv().await.unwrap();
6841            request.reply.send(outcome).unwrap();
6842
6843            let response = response.await.unwrap().unwrap();
6844            assert_eq!(response.status(), status, "{outcome:?}");
6845            let body = response.into_body().collect().await.unwrap().to_bytes();
6846            let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
6847            let error = body["error"].as_str().unwrap();
6848            assert!(error.contains(guidance), "{outcome:?} answered {error:?}");
6849        }
6850    }
6851
6852    #[tokio::test]
6853    async fn the_viewer_shows_a_session_whose_action_failed_after_it_was_accepted() {
6854        // An accepted action reports its outcome only through snapshots, so
6855        // the application has to react to `has_error` for a late failure to be
6856        // visible at all.
6857        let (app, _, _, _, _) = app();
6858        let script = fetch_text(app, "/viewer.js").await;
6859        assert!(script.contains("has_error"), "viewer ignores has_error");
6860    }
6861
6862    /// Every response, not only the page, carries the policy. A header that
6863    /// depends on which handler answered is a header somebody will forget.
6864    #[tokio::test]
6865    async fn every_response_carries_the_security_headers() {
6866        for path in [
6867            "/",
6868            "/viewer.js",
6869            "/voice-worklet.js",
6870            "/voice-worker.js",
6871            "/viewer.css",
6872            "/manifest.webmanifest",
6873            "/api/snapshot",
6874        ] {
6875            let (app, _, _, _, _) = app();
6876            let response = app
6877                .oneshot(Request::get(path).body(Body::empty()).unwrap())
6878                .await
6879                .unwrap();
6880            let headers = response.headers();
6881            let policy = headers
6882                .get(CONTENT_SECURITY_POLICY_HEADER)
6883                .unwrap_or_else(|| panic!("{path} carries no content-security policy"))
6884                .to_str()
6885                .unwrap();
6886            assert!(
6887                policy.starts_with("default-src 'none';"),
6888                "{path} does not refuse unlisted sources: {policy}"
6889            );
6890            assert!(
6891                policy.contains("script-src 'self'") && !policy.contains("unsafe-inline"),
6892                "{path} permits inline script: {policy}"
6893            );
6894            assert!(
6895                policy.contains("frame-ancestors 'none'"),
6896                "{path} can be framed: {policy}"
6897            );
6898            assert_eq!(
6899                headers.get(X_CONTENT_TYPE_OPTIONS).unwrap(),
6900                "nosniff",
6901                "{path} permits content sniffing"
6902            );
6903            assert_eq!(
6904                headers.get(REFERRER_POLICY).unwrap(),
6905                "no-referrer",
6906                "{path} leaks a referrer"
6907            );
6908        }
6909    }
6910
6911    /// The policy forbids inline script and style, so the page must contain
6912    /// neither. A page that did would simply fail to run in a browser, which
6913    /// no Rust test would otherwise notice.
6914    #[tokio::test]
6915    async fn the_page_carries_no_inline_script_or_style() {
6916        let (app, _, _, _, _) = app();
6917        let page = fetch_text(app, "/").await;
6918        assert!(
6919            !page.contains("<script>") && !page.contains("<style>"),
6920            "the page inlines script or style, which the policy blocks"
6921        );
6922        assert!(
6923            page.contains(r#"src="/viewer.js""#) && page.contains(r#"href="/viewer.css""#),
6924            "the page does not load its script and style as separate assets"
6925        );
6926    }
6927
6928    /// A cached API answer is a lie about live session state, and a cached
6929    /// service worker is what keeps a phone on a superseded application.
6930    #[tokio::test]
6931    async fn live_state_and_the_service_worker_are_never_stored() {
6932        for path in ["/", "/service-worker.js", "/api/snapshot"] {
6933            let (app, _, _, _, _) = app();
6934            let response = app
6935                .oneshot(Request::get(path).body(Body::empty()).unwrap())
6936                .await
6937                .unwrap();
6938            assert_eq!(
6939                response.headers().get(CACHE_CONTROL).unwrap(),
6940                "no-store",
6941                "{path} may be stored"
6942            );
6943        }
6944    }
6945
6946    /// The worker must leave live state alone entirely rather than caching it
6947    /// and hoping the cache is fresh.
6948    #[test]
6949    fn the_service_worker_declines_to_handle_live_state() {
6950        assert!(
6951            SERVICE_WORKER.contains("url.pathname.startsWith('/api/')"),
6952            "the service worker does not exclude the API"
6953        );
6954        assert!(
6955            SERVICE_WORKER.contains("url.pathname.startsWith('/auth/')"),
6956            "the service worker does not exclude authentication"
6957        );
6958        assert!(
6959            SERVICE_WORKER.contains("caches.delete"),
6960            "the service worker never deletes a superseded cache"
6961        );
6962    }
6963
6964    /// The vendored assets have to reach the browser, not merely exist in the
6965    /// repository: the manifest names them and a phone installs from it.
6966    #[tokio::test]
6967    async fn the_installable_assets_are_served() {
6968        for (path, content_type) in [
6969            ("/icon-192.png", "image/png"),
6970            ("/icon-512.png", "image/png"),
6971            ("/maskable-512.png", "image/png"),
6972            ("/apple-touch-icon.png", "image/png"),
6973            ("/fonts/jetbrains-mono.woff2", "font/woff2"),
6974        ] {
6975            let (app, _, _, _, _) = app();
6976            let response = app
6977                .oneshot(Request::get(path).body(Body::empty()).unwrap())
6978                .await
6979                .unwrap();
6980            assert_eq!(response.status(), StatusCode::OK, "{path} is not served");
6981            assert_eq!(
6982                response.headers().get(CONTENT_TYPE).unwrap(),
6983                content_type,
6984                "{path} is served as the wrong type"
6985            );
6986        }
6987    }
6988
6989    /// Fetch one unauthenticated asset and return it as text. Serving the
6990    /// application from several files means a check about the application has
6991    /// to name the file it is about.
6992    async fn fetch_text(app: Router, path: &str) -> String {
6993        let response = app
6994            .oneshot(Request::get(path).body(Body::empty()).unwrap())
6995            .await
6996            .unwrap();
6997        assert_eq!(response.status(), StatusCode::OK, "{path} is not served");
6998        let body = response.into_body().collect().await.unwrap().to_bytes();
6999        String::from_utf8(body.to_vec()).expect("assets are UTF-8")
7000    }
7001
7002    #[tokio::test]
7003    async fn repeated_wrong_codes_lock_the_login_endpoint() {
7004        let (app, _, _, _, _) = app();
7005        let attempt = |code: &'static str| {
7006            let app = app.clone();
7007            async move {
7008                app.oneshot(
7009                    Request::post("/auth/session")
7010                        .header(CONTENT_TYPE, "application/json")
7011                        .body(Body::from(format!(r#"{{"code":"{code}"}}"#)))
7012                        .unwrap(),
7013                )
7014                .await
7015                .unwrap()
7016                .status()
7017            }
7018        };
7019        for _ in 0..MAX_CODE_FAILURES {
7020            assert_eq!(attempt("000000").await, StatusCode::UNAUTHORIZED);
7021        }
7022        assert_eq!(attempt("000000").await, StatusCode::TOO_MANY_REQUESTS);
7023        // Even the right code waits out the lockout, so guessing cannot be
7024        // hidden behind a correct-looking attempt.
7025        assert_eq!(attempt("123456").await, StatusCode::TOO_MANY_REQUESTS);
7026    }
7027
7028    #[test]
7029    fn viewer_code_lockouts_lengthen_instead_of_resetting_after_every_wait() {
7030        let serve_one_lockout = |guard: &mut CodeGuard, now: Instant| {
7031            for _ in 0..MAX_CODE_FAILURES {
7032                assert!(!guard.locked_at(now));
7033                guard.record_failure_at(now);
7034            }
7035            assert!(guard.locked_at(now));
7036            guard.locked_until.expect("the guard is locked") - now
7037        };
7038
7039        let start = Instant::now();
7040        let mut guard = CodeGuard::default();
7041        let first = serve_one_lockout(&mut guard, start);
7042        assert_eq!(first, CODE_LOCKOUT_BASE);
7043
7044        // Waiting out a lockout buys another run of attempts, not another
7045        // equally short lockout: a guard that reset here gave an attacker
7046        // MAX_CODE_FAILURES guesses every CODE_LOCKOUT_BASE for ever.
7047        let second_round = start + first;
7048        let second = serve_one_lockout(&mut guard, second_round);
7049        assert_eq!(second, CODE_LOCKOUT_BASE * 2);
7050        let third = serve_one_lockout(&mut guard, second_round + second);
7051        assert_eq!(third, CODE_LOCKOUT_BASE * 4);
7052        assert_eq!(code_lockout(u32::MAX), CODE_LOCKOUT_CAP);
7053
7054        // A correct code clears the history, so one mistyped digit tomorrow
7055        // still costs only the shortest wait.
7056        let mut recovered = CodeGuard::default();
7057        assert_eq!(serve_one_lockout(&mut recovered, start), CODE_LOCKOUT_BASE);
7058    }
7059
7060    #[test]
7061    fn persisted_cookie_key_survives_a_restart_and_stays_owner_only() {
7062        let directory = tempfile::tempdir().unwrap();
7063        let path = directory.path().join("phone-cookie-key");
7064
7065        let first = load_or_create_cookie_key(&path).unwrap();
7066        assert!(first.len() >= COOKIE_KEY_BYTES);
7067        assert_eq!(std::fs::read(&path).unwrap(), first);
7068        #[cfg(unix)]
7069        {
7070            use std::os::unix::fs::PermissionsExt as _;
7071            let mode = std::fs::metadata(&path).unwrap().permissions().mode();
7072            assert_eq!(mode & 0o777, 0o600);
7073        }
7074
7075        // Two server processes started from the same key file honour each
7076        // other's cookies; a process that kept its generated key would not.
7077        let mut restarted = detached_options();
7078        restarted
7079            .set_cookie_key(load_or_create_cookie_key(&path).unwrap())
7080            .unwrap();
7081        let mut original = detached_options();
7082        original.set_cookie_key(first.clone()).unwrap();
7083        let cookie = signed_cookie_value(&original.cookie_key, "test-viewer", 200);
7084        assert!(session_cookie_valid(&restarted.cookie_key, &cookie, 100));
7085        assert!(!session_cookie_valid(
7086            &detached_options().cookie_key,
7087            &cookie,
7088            100
7089        ));
7090
7091        // Deleting the key file is the explicit sign-everyone-out gesture.
7092        std::fs::remove_file(&path).unwrap();
7093        let rotated = load_or_create_cookie_key(&path).unwrap();
7094        assert_ne!(rotated, first);
7095        assert!(!session_cookie_valid(&rotated, &cookie, 100));
7096    }
7097
7098    #[test]
7099    fn corrupt_cookie_key_is_regenerated_instead_of_blocking_startup() {
7100        let directory = tempfile::tempdir().unwrap();
7101        let path = directory.path().join("phone-cookie-key");
7102        std::fs::write(&path, b"short").unwrap();
7103
7104        let key = load_or_create_cookie_key(&path).unwrap();
7105
7106        assert!(key.len() >= COOKIE_KEY_BYTES);
7107        assert_eq!(std::fs::read(&path).unwrap(), key);
7108        assert_eq!(load_or_create_cookie_key(&path).unwrap(), key);
7109    }
7110}