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 which network sources it will use 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 resolving a local repository's configured
1507/// remotes 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, an
1526/// isolated repository lacks a usable network source, or the controller-side
1527/// check itself could not complete. The HTTP surface keeps those outcomes
1528/// distinct without carrying filesystem, Git, or SSH details to the phone.
1529#[derive(Debug)]
1530pub enum PreflightFailure {
1531    Validation,
1532    /// A configured isolated-session repository cannot be used as a network
1533    /// source. The detail is safe for the phone and tells the person how to
1534    /// choose the supported raw-local path instead.
1535    InvalidRepository(String),
1536    Controller(String),
1537}
1538
1539/// One configured repository's network clone and publication destinations.
1540/// URLs have already been passed through the shared display sanitizer before
1541/// they reach a phone.
1542#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1543#[serde(deny_unknown_fields)]
1544pub struct PreflightRepository {
1545    pub id: String,
1546    pub fetch_url: String,
1547    pub default_branch: String,
1548    pub push_urls: Vec<String>,
1549}
1550
1551/// What a preflight found. Isolated sessions expose their complete network
1552/// source plan so the person can review it before creation. Raw-local targets
1553/// leave the plan empty because they use the selected checkout directly;
1554/// isolated targets set `local_changes_excluded` to make the copy boundary
1555/// explicit.
1556#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1557#[serde(deny_unknown_fields)]
1558pub struct PreflightNew {
1559    #[serde(default)]
1560    pub dirty_repositories: Vec<String>,
1561    #[serde(default)]
1562    pub remote_repositories: Vec<PreflightRepository>,
1563    pub local_changes_excluded: bool,
1564}
1565
1566/// What a phone asks about, or stores against, its own identity.
1567///
1568/// These travel on their own channel rather than as actions, for the reason a
1569/// read receipt does: they are frequent, they start nothing, and routing them
1570/// through the action pipeline would consume the session's single action slot
1571/// and reload the controller on every keystroke.
1572#[derive(Debug)]
1573pub enum ClientStateRequest {
1574    Read {
1575        client_id: String,
1576        session_id: String,
1577        reply: tokio::sync::oneshot::Sender<Result<ViewerClientState, String>>,
1578    },
1579    SaveDraft {
1580        client_id: String,
1581        session_id: String,
1582        draft: String,
1583        reply: tokio::sync::oneshot::Sender<Result<(), String>>,
1584    },
1585    MarkWorkspaceRead {
1586        client_id: String,
1587        workspace_id: String,
1588        reply: tokio::sync::oneshot::Sender<Result<(), String>>,
1589    },
1590    History {
1591        session_id: String,
1592        query: String,
1593        scope: String,
1594        reply: tokio::sync::oneshot::Sender<Result<ViewerPromptHistory, String>>,
1595    },
1596}
1597
1598#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1599#[serde(deny_unknown_fields)]
1600pub struct ViewerClientState {
1601    pub draft: String,
1602    pub through_event_ordinal: u64,
1603}
1604
1605#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1606#[serde(deny_unknown_fields)]
1607pub struct ViewerPromptHistory {
1608    pub entries: Vec<String>,
1609    /// Whether the search stopped before it ran out of history, so a phone can
1610    /// say the answer is partial rather than presenting it as complete.
1611    pub truncated: bool,
1612}
1613
1614#[derive(Debug)]
1615pub struct ReadReceiptRequest {
1616    pub client_id: String,
1617    pub session_id: String,
1618    pub through: u64,
1619    pub reply: tokio::sync::oneshot::Sender<Result<(), String>>,
1620}
1621
1622#[derive(Clone)]
1623struct ServerState {
1624    snapshot_rx: watch::Receiver<ViewerSnapshot>,
1625    conversation_rx: watch::Receiver<BTreeMap<String, BrowserTranscript>>,
1626    action_tx: mpsc::Sender<ControllerRequest>,
1627    bundle_tx: mpsc::Sender<BundleRequest>,
1628    receipt_tx: mpsc::Sender<ReadReceiptRequest>,
1629    preflight_tx: mpsc::Sender<PreflightRequest>,
1630    move_preparation_tx: mpsc::Sender<MovePreparationRequest>,
1631    client_state_tx: mpsc::Sender<ClientStateRequest>,
1632    dictation_tx: mpsc::Sender<DictationRequest>,
1633    dictation_permits: Arc<Semaphore>,
1634    dictation_probe_permits: Arc<Semaphore>,
1635    shutdown: CancellationToken,
1636    viewer_code: Arc<str>,
1637    login_token: Arc<str>,
1638    cookie_key: Arc<[u8]>,
1639    session_ttl: Duration,
1640    secure_cookie: bool,
1641    code_guard: Arc<Mutex<CodeGuard>>,
1642}
1643
1644/// Online-guessing defence for the deliberately small viewer code.
1645///
1646/// Five wrong codes lock the endpoint, and each further lockout lasts twice as
1647/// long as the one before it, up to an hour. The escalation count survives an
1648/// expired lockout, so a script cannot recover its full allowance by waiting;
1649/// a correct code clears the whole history, so one mistyped digit still costs
1650/// at most a single short wait.
1651#[derive(Debug, Default)]
1652struct CodeGuard {
1653    failures: u32,
1654    lockouts: u32,
1655    locked_until: Option<Instant>,
1656}
1657
1658impl CodeGuard {
1659    fn locked_at(&mut self, now: Instant) -> bool {
1660        match self.locked_until {
1661            Some(until) if now < until => true,
1662            Some(_) => {
1663                // The wait is served: allow a fresh run of attempts, but keep
1664                // the escalation history that makes the next wait longer.
1665                self.locked_until = None;
1666                self.failures = 0;
1667                false
1668            }
1669            None => false,
1670        }
1671    }
1672
1673    fn record_failure_at(&mut self, now: Instant) {
1674        self.failures = self.failures.saturating_add(1);
1675        if self.failures < MAX_CODE_FAILURES {
1676            return;
1677        }
1678        self.failures = 0;
1679        self.lockouts = self.lockouts.saturating_add(1);
1680        self.locked_until = Some(now + code_lockout(self.lockouts));
1681    }
1682}
1683
1684/// Doubling backoff, capped so the owner of a locked-out server is never shut
1685/// out for longer than it takes to notice.
1686fn code_lockout(lockouts: u32) -> Duration {
1687    let multiplier = 1_u32
1688        .checked_shl(lockouts.saturating_sub(1))
1689        .unwrap_or(u32::MAX);
1690    CODE_LOCKOUT_BASE
1691        .saturating_mul(multiplier)
1692        .min(CODE_LOCKOUT_CAP)
1693}
1694
1695fn router(options: ServerOptions) -> Router {
1696    let state = ServerState {
1697        snapshot_rx: options.snapshot_rx,
1698        conversation_rx: options.conversation_rx,
1699        action_tx: options.action_tx,
1700        bundle_tx: options.bundle_tx,
1701        receipt_tx: options.receipt_tx,
1702        preflight_tx: options.preflight_tx,
1703        move_preparation_tx: options.move_preparation_tx,
1704        client_state_tx: options.client_state_tx,
1705        dictation_tx: options.dictation_tx,
1706        dictation_permits: Arc::new(Semaphore::new(MAX_CONCURRENT_DICTATIONS)),
1707        dictation_probe_permits: Arc::new(Semaphore::new(MAX_CONCURRENT_DICTATIONS)),
1708        shutdown: options.shutdown,
1709        viewer_code: options.viewer_code.into(),
1710        login_token: options.login_token.into(),
1711        cookie_key: options.cookie_key.into(),
1712        session_ttl: options.session_ttl,
1713        secure_cookie: options.secure_cookie,
1714        code_guard: Arc::new(Mutex::new(CodeGuard::default())),
1715    };
1716    let protected = Router::new()
1717        .route("/api/snapshot", get(snapshot))
1718        .route("/api/conversations/{session_id}", get(conversation))
1719        .route(
1720            "/api/conversations/{session_id}/read",
1721            post(mark_conversation_read),
1722        )
1723        .route("/api/events", get(events))
1724        .route("/api/bundles", post(create_bundle))
1725        .route("/api/preflight/new", post(preflight_new))
1726        .route("/api/moves/prepare", post(prepare_move))
1727        .route("/api/sessions/{session_id}/client-state", get(client_state))
1728        .route(
1729            "/api/sessions/{session_id}/dictation",
1730            get(dictation_availability).post(upload_dictation),
1731        )
1732        .route(
1733            "/api/sessions/{session_id}/attachments",
1734            post(upload_attachment).layer(DefaultBodyLimit::max(MAX_ATTACHMENT_UPLOAD_BYTES)),
1735        )
1736        .route(
1737            "/api/sessions/{session_id}/draft",
1738            put(save_draft).layer(DefaultBodyLimit::max(MAX_DRAFT_BYTES)),
1739        )
1740        .route("/api/sessions/{session_id}/history", get(prompt_history))
1741        .route(
1742            "/api/workspaces/{workspace_id}/read",
1743            post(mark_workspace_read),
1744        )
1745        .route(
1746            "/api/actions",
1747            post(action).layer(DefaultBodyLimit::max(MAX_PROMPT_BODY_BYTES)),
1748        )
1749        .route_layer(axum::middleware::from_fn_with_state(
1750            state.clone(),
1751            require_session,
1752        ));
1753    Router::new()
1754        .route("/", get(viewer))
1755        .route("/login", get(viewer))
1756        .route("/viewer.css", get(viewer_css))
1757        .route("/viewer.js", get(viewer_js))
1758        .route("/voice-worklet.js", get(voice_worklet_js))
1759        .route("/voice-worker.js", get(voice_worker_js))
1760        .route("/markdown.js", get(markdown_js))
1761        .route("/tool-output.js", get(tool_output_js))
1762        .route("/manifest.webmanifest", get(manifest))
1763        .route("/service-worker.js", get(service_worker))
1764        .route("/icon.svg", get(icon))
1765        .route("/icon-192.png", get(icon_192))
1766        .route("/icon-512.png", get(icon_512))
1767        .route("/maskable-512.png", get(maskable_512))
1768        .route("/apple-touch-icon.png", get(apple_touch_icon))
1769        .route("/fonts/jetbrains-mono.woff2", get(mono_font))
1770        .route("/auth/session", post(create_session).delete(clear_session))
1771        .route("/auth/login", get(create_session_from_query))
1772        .merge(protected)
1773        .layer(DefaultBodyLimit::max(MAX_BODY_BYTES))
1774        .layer(axum::middleware::from_fn(security_headers))
1775        .with_state(state)
1776}
1777
1778async fn require_session(
1779    State(state): State<ServerState>,
1780    request: Request,
1781    next: Next,
1782) -> Result<Response<Body>, ApiError> {
1783    let cookie = request
1784        .headers()
1785        .get(COOKIE)
1786        .and_then(|value| value.to_str().ok())
1787        .and_then(|header| cookie_value(header, COOKIE_NAME));
1788    if cookie.is_some_and(|value| session_cookie_valid(&state.cookie_key, value, now_unix())) {
1789        Ok(next.run(request).await)
1790    } else {
1791        Err(ApiError::unauthorized())
1792    }
1793}
1794
1795#[derive(Debug, Deserialize)]
1796#[serde(deny_unknown_fields)]
1797struct LoginRequest {
1798    code: String,
1799}
1800
1801#[derive(Debug, Deserialize)]
1802#[serde(deny_unknown_fields)]
1803struct LoginQuery {
1804    token: String,
1805}
1806
1807async fn create_session_from_query(
1808    State(state): State<ServerState>,
1809    Query(query): Query<LoginQuery>,
1810) -> Result<Response<Body>, ApiError> {
1811    if !constant_time_eq(state.login_token.as_bytes(), query.token.trim().as_bytes()) {
1812        return Err(ApiError::unauthorized());
1813    }
1814    let mut response = issue_session_cookie(&state, StatusCode::SEE_OTHER)?;
1815    response
1816        .headers_mut()
1817        .insert(LOCATION, HeaderValue::from_static("/"));
1818    response
1819        .headers_mut()
1820        .insert(CACHE_CONTROL, HeaderValue::from_static("no-store"));
1821    Ok(response)
1822}
1823
1824async fn create_session(
1825    State(state): State<ServerState>,
1826    Json(request): Json<LoginRequest>,
1827) -> Result<Response<Body>, ApiError> {
1828    if code_locked(&state) {
1829        return Err(ApiError::new(
1830            StatusCode::TOO_MANY_REQUESTS,
1831            "too many incorrect codes; wait and try again",
1832        ));
1833    }
1834    if !constant_time_eq(state.viewer_code.as_bytes(), request.code.trim().as_bytes()) {
1835        record_code_failure(&state);
1836        return Err(ApiError::unauthorized());
1837    }
1838    reset_code_failures(&state);
1839    issue_session_cookie(&state, StatusCode::NO_CONTENT)
1840}
1841
1842fn issue_session_cookie(
1843    state: &ServerState,
1844    status: StatusCode,
1845) -> Result<Response<Body>, ApiError> {
1846    let ephemeral = state.session_ttl.is_zero();
1847    let validity = if ephemeral {
1848        EPHEMERAL_SESSION_TTL
1849    } else {
1850        state.session_ttl
1851    };
1852    let value = signed_cookie_value(
1853        &state.cookie_key,
1854        &generate_viewer_id().map_err(|_| {
1855            ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "cookie creation failed")
1856        })?,
1857        now_unix().saturating_add(validity.as_secs()),
1858    );
1859    let cookie = session_cookie_header(
1860        &value,
1861        (!ephemeral).then_some(validity.as_secs()),
1862        state.secure_cookie,
1863    )?;
1864    let mut response = status.into_response();
1865    response.headers_mut().insert(SET_COOKIE, cookie);
1866    Ok(response)
1867}
1868
1869async fn clear_session(State(state): State<ServerState>) -> Response<Body> {
1870    let mut response = StatusCode::NO_CONTENT.into_response();
1871    response
1872        .headers_mut()
1873        .insert(SET_COOKIE, clear_cookie_header(state.secure_cookie));
1874    response
1875}
1876
1877async fn snapshot(State(state): State<ServerState>) -> Response<Body> {
1878    let mut projection = state.snapshot_rx.borrow().clone();
1879    // A quiet session can keep the same projection for hours. Clock anchors
1880    // describe response time, not the last time that projection changed.
1881    projection.server_time_ms = hel::clock::epoch_millis();
1882    let mut response = Json(projection).into_response();
1883    response
1884        .headers_mut()
1885        .insert(CACHE_CONTROL, HeaderValue::from_static("no-store"));
1886    response
1887}
1888
1889/// Optimize and install one browser image off the async request task. The
1890/// request body is deliberately raw bytes: base64 would inflate the upload,
1891/// and the response contains only the small immutable reference the prompt
1892/// needs.
1893async fn upload_attachment(
1894    State(state): State<ServerState>,
1895    Path(session_id): Path<String>,
1896    body: Bytes,
1897) -> Result<Json<ViewerPromptImage>, ApiError> {
1898    validate_public_id(&session_id)?;
1899    let prompt_images_supported = {
1900        let snapshot = state.snapshot_rx.borrow();
1901        require_session_record(&snapshot, &session_id)?.prompt_images_supported
1902    };
1903    if !prompt_images_supported {
1904        return Err(ApiError::bad_request(
1905            "this session does not support image prompts",
1906        ));
1907    }
1908    if body.is_empty() {
1909        return Err(ApiError::bad_request("image upload must not be empty"));
1910    }
1911
1912    let result = tokio::task::spawn_blocking(move || {
1913        let optimized = optimize_image(&body).map_err(|_| {
1914            ApiError::bad_request("unsupported image format or image could not be decoded")
1915        })?;
1916        if optimized.bytes.is_empty() || optimized.bytes.len() > MAX_IMAGE_BYTES {
1917            return Err(ApiError::new(
1918                StatusCode::INTERNAL_SERVER_ERROR,
1919                "the image optimizer returned an invalid image size",
1920            ));
1921        }
1922        let reference = AttachmentRef::new(
1923            &optimized.bytes,
1924            optimized.mime_type.clone(),
1925            optimized.width,
1926            optimized.height,
1927        )
1928        .map_err(|_| {
1929            ApiError::new(
1930                StatusCode::INTERNAL_SERVER_ERROR,
1931                "could not create image attachment",
1932            )
1933        })?;
1934        let store = AttachmentStore::controller(&session_id).map_err(|_| {
1935            ApiError::new(
1936                StatusCode::INTERNAL_SERVER_ERROR,
1937                "could not open the image attachment store",
1938            )
1939        })?;
1940        store.install(&reference, &optimized.bytes).map_err(|_| {
1941            ApiError::new(
1942                StatusCode::INTERNAL_SERVER_ERROR,
1943                "could not store the image attachment",
1944            )
1945        })?;
1946        Ok(ViewerPromptImage {
1947            data_base64: String::new(),
1948            mime_type: reference.mime_type.clone(),
1949            width: reference.width,
1950            height: reference.height,
1951            attachment: Some(reference),
1952        })
1953    })
1954    .await
1955    .map_err(|_| {
1956        ApiError::new(
1957            StatusCode::INTERNAL_SERVER_ERROR,
1958            "the server could not process the image upload",
1959        )
1960    })??;
1961
1962    Ok(Json(result))
1963}
1964
1965/// Hand one validated action to the controller and answer as soon as the
1966/// controller accepts it. Waiting for completion would hold the request open
1967/// for the whole of a provision, resume or close, which mobile networks end
1968/// long before the work does — reporting failure for an action that is in fact
1969/// still running.
1970async fn action(
1971    State(state): State<ServerState>,
1972    Json(action): Json<ControllerAction>,
1973) -> Result<StatusCode, ApiError> {
1974    validate_action(&action, &state.snapshot_rx.borrow())?;
1975    let action = decode_prompt_images_off_task(action).await?;
1976    let (reply, outcome) = tokio::sync::oneshot::channel();
1977    state
1978        .action_tx
1979        .send(ControllerRequest { action, reply })
1980        .await
1981        .map_err(|_| ApiError::controller_unavailable())?;
1982    let outcome = outcome
1983        .await
1984        .map_err(|_| ApiError::controller_unavailable())?;
1985    match outcome.rejection() {
1986        Some(rejection) => Err(rejection),
1987        None => Ok(StatusCode::ACCEPTED),
1988    }
1989}
1990
1991const MAX_BUNDLE_SOURCE_CHARS: usize = 1024;
1992
1993#[derive(Debug, Deserialize)]
1994#[serde(deny_unknown_fields)]
1995struct CreateBundleRequest {
1996    source: String,
1997}
1998
1999#[derive(Debug, Serialize)]
2000struct CreateBundleResponse {
2001    bundle_id: String,
2002}
2003
2004/// Create a quick bundle through the controller's dedicated persistence path.
2005/// The control loop publishes the resulting config before resolving `reply`,
2006/// so a successful response can immediately use the returned bundle id in the
2007/// next new-session request.
2008async fn create_bundle(
2009    State(state): State<ServerState>,
2010    Json(request): Json<CreateBundleRequest>,
2011) -> Result<Json<CreateBundleResponse>, ApiError> {
2012    if request.source.trim().is_empty() {
2013        return Err(ApiError::bad_request("repository source cannot be empty"));
2014    }
2015    if request.source.chars().count() > MAX_BUNDLE_SOURCE_CHARS {
2016        return Err(ApiError::bad_request(
2017            "repository source must contain 1024 characters or fewer",
2018        ));
2019    }
2020    let (reply, result) = tokio::sync::oneshot::channel();
2021    state
2022        .bundle_tx
2023        .send(BundleRequest {
2024            source: request.source,
2025            reply,
2026        })
2027        .await
2028        .map_err(|_| ApiError::controller_unavailable())?;
2029    let bundle_id = result
2030        .await
2031        .map_err(|_| ApiError::controller_unavailable())?
2032        .map_err(|failure| match failure {
2033            BundleFailure::InvalidSource => ApiError::bad_request(
2034                "use a GitHub owner/repository or an existing Git checkout on the controller host",
2035            ),
2036            BundleFailure::Controller => ApiError::new(
2037                StatusCode::INTERNAL_SERVER_ERROR,
2038                "the controller could not create the bundle",
2039            ),
2040        })?;
2041    Ok(Json(CreateBundleResponse { bundle_id }))
2042}
2043
2044#[derive(Debug, Deserialize)]
2045struct ConversationQuery {
2046    after_seq: Option<u64>,
2047}
2048
2049async fn conversation(
2050    State(state): State<ServerState>,
2051    Path(session_id): Path<String>,
2052    Query(query): Query<ConversationQuery>,
2053) -> Result<Json<BrowserTranscript>, ApiError> {
2054    validate_public_id(&session_id)?;
2055    let transitioning = {
2056        let snapshot = state.snapshot_rx.borrow();
2057        require_session_record(&snapshot, &session_id)?.transitioning
2058    };
2059    if transitioning {
2060        return Err(ApiError::new(
2061            StatusCode::CONFLICT,
2062            "conversation unavailable while the session is transitioning",
2063        ));
2064    }
2065    let conversations = state.conversation_rx.borrow();
2066    let transcript = conversations
2067        .get(&session_id)
2068        .ok_or_else(|| ApiError::not_found("conversation unavailable"))?;
2069    let mut response = transcript.clone();
2070    if let Some(after) = query.after_seq {
2071        response.reset = after < response.window_start_seq;
2072        if !response.reset {
2073            response.entries.retain(|entry| entry.updated_seq > after);
2074        }
2075    }
2076    Ok(Json(response))
2077}
2078
2079#[derive(Debug, Deserialize)]
2080#[serde(deny_unknown_fields)]
2081struct ReadRequest {
2082    through: u64,
2083}
2084
2085async fn mark_conversation_read(
2086    State(state): State<ServerState>,
2087    Path(session_id): Path<String>,
2088    headers: HeaderMap,
2089    Json(request): Json<ReadRequest>,
2090) -> Result<StatusCode, ApiError> {
2091    validate_public_id(&session_id)?;
2092    let transitioning = {
2093        let snapshot = state.snapshot_rx.borrow();
2094        require_session_record(&snapshot, &session_id)?.transitioning
2095    };
2096    if transitioning {
2097        return Err(ApiError::new(
2098            StatusCode::CONFLICT,
2099            "conversation unavailable while the session is transitioning",
2100        ));
2101    }
2102    let (reply, result) = tokio::sync::oneshot::channel();
2103    let client_id = viewer_client_id(&state, &headers).ok_or_else(ApiError::unauthorized)?;
2104    state
2105        .receipt_tx
2106        .send(ReadReceiptRequest {
2107            client_id,
2108            session_id,
2109            through: request.through,
2110            reply,
2111        })
2112        .await
2113        .map_err(|_| ApiError::controller_unavailable())?;
2114    result
2115        .await
2116        .map_err(|_| ApiError::controller_unavailable())?
2117        .map_err(|_| ApiError::new(StatusCode::CONFLICT, "read receipt failed"))?;
2118    Ok(StatusCode::NO_CONTENT)
2119}
2120
2121#[derive(Debug, Deserialize)]
2122#[serde(deny_unknown_fields)]
2123struct PreflightNewRequest {
2124    #[serde(default)]
2125    workspace_id: String,
2126    profile_id: String,
2127    bundle_id: String,
2128    target_id: String,
2129    #[serde(default)]
2130    project_directory: Option<PathBuf>,
2131}
2132
2133/// Answer whether a new session would launch cleanly, and what to warn about.
2134///
2135/// The same validation the action itself runs happens here, so a phone learns
2136/// about an impossible combination while it can still change it rather than
2137/// after it has committed.
2138async fn preflight_new(
2139    State(state): State<ServerState>,
2140    Json(request): Json<PreflightNewRequest>,
2141) -> Result<Json<PreflightNew>, ApiError> {
2142    let project_validation = request.project_directory.is_some();
2143    let action = ControllerAction::New {
2144        workspace_id: request.workspace_id,
2145        profile_id: request.profile_id,
2146        bundle_id: request.bundle_id.clone(),
2147        target_id: request.target_id.clone(),
2148        title: None,
2149        project_directory: request.project_directory.clone(),
2150        dirty_ack: Vec::new(),
2151    };
2152    validate_action(&action, &state.snapshot_rx.borrow())?;
2153    let (reply, result) = tokio::sync::oneshot::channel();
2154    state
2155        .preflight_tx
2156        .send(PreflightRequest {
2157            bundle_id: request.bundle_id,
2158            target_id: request.target_id,
2159            project_directory: request.project_directory,
2160            reply,
2161        })
2162        .await
2163        .map_err(|_| ApiError::controller_unavailable())?;
2164    result
2165        .await
2166        .map_err(|_| ApiError::controller_unavailable())?
2167        .map(Json)
2168        .map_err(|failure| match failure {
2169            PreflightFailure::Validation if project_validation => ApiError::bad_request(
2170                "project validation failed; check that the directory exists, is accessible, and contains a Git repository with a valid HEAD",
2171            ),
2172            PreflightFailure::InvalidRepository(_) => ApiError::bad_request(
2173                "could not resolve the network repository; check its remote URL, authentication, connectivity, and default branch. Repositories without network remotes require a raw local session",
2174            ),
2175            PreflightFailure::Validation => ApiError::bad_request(
2176                "isolated session repositories need a network Git remote; use a raw local target for a local-only checkout",
2177            ),
2178            PreflightFailure::Controller(_) => ApiError::new(
2179                StatusCode::SERVICE_UNAVAILABLE,
2180                "the controller could not check this project",
2181            ),
2182        })
2183}
2184
2185/// Prepare a move without changing the source session. The returned
2186/// preparation is an expiring, fingerprinted capability: the confirmation
2187/// action must send it back verbatim, and the daemon rechecks it immediately
2188/// before interrupting work.
2189async fn prepare_move(
2190    State(state): State<ServerState>,
2191    Json(selection): Json<MoveSelection>,
2192) -> Result<Json<MovePreparation>, ApiError> {
2193    validate_move_selection(&selection, &state.snapshot_rx.borrow())?;
2194    let (reply, result) = tokio::sync::oneshot::channel();
2195    state
2196        .move_preparation_tx
2197        .send(MovePreparationRequest { selection, reply })
2198        .await
2199        .map_err(|_| ApiError::controller_unavailable())?;
2200    let preparation = result
2201        .await
2202        .map_err(|_| ApiError::controller_unavailable())?
2203        .map_err(|error| {
2204            tracing::debug!(error = %error, "move preparation was rejected");
2205            ApiError::new(
2206                StatusCode::CONFLICT,
2207                "move preparation was rejected; refresh and try again",
2208            )
2209        })?;
2210    Ok(Json(inspector_move_preparation(preparation)))
2211}
2212
2213/// Queued image bytes are replayed from the verified archive after readiness;
2214/// they are not needed by a browser confirmation. Replace them at this
2215/// boundary even if an older daemon did not already make the preparation an
2216/// inspector-only value.
2217fn inspector_move_preparation(mut preparation: MovePreparation) -> MovePreparation {
2218    for command in &mut preparation.queued_commands {
2219        for block in &mut command.content {
2220            if block.get("type").and_then(serde_json::Value::as_str) != Some("image") {
2221                continue;
2222            }
2223            let mime = block
2224                .get("mimeType")
2225                .or_else(|| block.get("mime_type"))
2226                .and_then(serde_json::Value::as_str)
2227                .unwrap_or("image");
2228            *block = serde_json::json!({
2229                "type": "text",
2230                "text": format!("[Image attachment: {mime}]")
2231            });
2232        }
2233    }
2234    preparation
2235}
2236
2237/// Ask the state channel one thing and wait for its answer.
2238async fn ask_client_state<T>(
2239    state: &ServerState,
2240    build: impl FnOnce(tokio::sync::oneshot::Sender<Result<T, String>>) -> ClientStateRequest,
2241) -> Result<T, ApiError> {
2242    let (reply, answer) = tokio::sync::oneshot::channel();
2243    state
2244        .client_state_tx
2245        .send(build(reply))
2246        .await
2247        .map_err(|_| ApiError::controller_unavailable())?;
2248    answer
2249        .await
2250        .map_err(|_| ApiError::controller_unavailable())?
2251        .map_err(|_| {
2252            ApiError::new(
2253                StatusCode::SERVICE_UNAVAILABLE,
2254                "the controller could not reach stored viewer state",
2255            )
2256        })
2257}
2258
2259/// This viewer's draft and read frontier for one session.
2260async fn client_state(
2261    State(state): State<ServerState>,
2262    Path(session_id): Path<String>,
2263    headers: HeaderMap,
2264) -> Result<Json<ViewerClientState>, ApiError> {
2265    validate_public_id(&session_id)?;
2266    require_session_record(&state.snapshot_rx.borrow(), &session_id)?;
2267    // A viewer with a legacy cookie has no identity and so has nothing stored.
2268    // Answering with an empty state is the truth, and is what lets an older
2269    // phone keep working through a deployment.
2270    let Some(client_id) = viewer_client_id(&state, &headers) else {
2271        return Ok(Json(ViewerClientState::default()));
2272    };
2273    ask_client_state(&state, |reply| ClientStateRequest::Read {
2274        client_id,
2275        session_id,
2276        reply,
2277    })
2278    .await
2279    .map(Json)
2280}
2281
2282#[derive(Debug, Serialize)]
2283struct DictationAvailability {
2284    available: bool,
2285    #[serde(skip_serializing_if = "Option::is_none")]
2286    reason: Option<String>,
2287}
2288
2289#[derive(Debug, Serialize)]
2290struct DictationTranscript {
2291    text: String,
2292}
2293
2294/// Report whether one of the session's Codex profiles has usable subscription
2295/// credentials. The controller selects profile paths from its current session
2296/// state, so this endpoint never accepts a browser-supplied credential path.
2297async fn dictation_availability(
2298    State(state): State<ServerState>,
2299    Path(session_id): Path<String>,
2300) -> Result<Json<DictationAvailability>, ApiError> {
2301    validate_public_id(&session_id)?;
2302    require_session_record(&state.snapshot_rx.borrow(), &session_id)?;
2303    let _permit = state
2304        .dictation_probe_permits
2305        .clone()
2306        .try_acquire_owned()
2307        .map_err(|_| ApiError::new(StatusCode::TOO_MANY_REQUESTS, "too many dictation requests"))?;
2308    let result = dispatch_dictation(&state, session_id, DictationOperation::Availability).await?;
2309    match result {
2310        DictationResponse::Availability { available, reason } => {
2311            Ok(Json(DictationAvailability { available, reason }))
2312        }
2313        DictationResponse::Transcript { .. } => Err(ApiError::new(
2314            StatusCode::INTERNAL_SERVER_ERROR,
2315            "the controller returned an invalid dictation response",
2316        )),
2317    }
2318}
2319
2320/// Receive one bounded WAV upload and send it to the supervised controller
2321/// request loop. The semaphore is acquired before `Request::into_body`, so a
2322/// third concurrent upload is rejected without polling its body at all.
2323async fn upload_dictation(
2324    State(state): State<ServerState>,
2325    Path(session_id): Path<String>,
2326    request: Request,
2327) -> Result<Json<DictationTranscript>, ApiError> {
2328    validate_public_id(&session_id)?;
2329    require_session_record(&state.snapshot_rx.borrow(), &session_id)?;
2330    let _permit = state
2331        .dictation_permits
2332        .clone()
2333        .try_acquire_owned()
2334        .map_err(|_| ApiError::new(StatusCode::TOO_MANY_REQUESTS, "too many dictation requests"))?;
2335
2336    if request
2337        .headers()
2338        .get(axum::http::header::CONTENT_LENGTH)
2339        .and_then(|value| value.to_str().ok())
2340        .and_then(|value| value.parse::<u64>().ok())
2341        .is_some_and(|length| length > MAX_AUDIO_BYTES as u64)
2342    {
2343        return Err(ApiError::new(
2344            StatusCode::PAYLOAD_TOO_LARGE,
2345            "audio upload is too large",
2346        ));
2347    }
2348    let body = tokio::select! {
2349        biased;
2350        _ = state.shutdown.cancelled() => return Err(ApiError::controller_unavailable()),
2351        result = tokio::time::timeout(
2352            crate::hel_dictation::DICTATION_TIMEOUT,
2353            to_bytes(request.into_body(), MAX_AUDIO_BYTES),
2354        ) => match result {
2355            Ok(result) => result.map_err(|_| {
2356                ApiError::new(StatusCode::PAYLOAD_TOO_LARGE, "audio upload is too large")
2357            })?,
2358            Err(_) => return Err(ApiError::new(
2359                StatusCode::GATEWAY_TIMEOUT,
2360                "dictation upload timed out",
2361            )),
2362        },
2363    };
2364    // A bounded WAV may still contain millions of small metadata chunks.
2365    // Keep that scan off the HTTP event loop as well as the provider work.
2366    let audio = body.clone();
2367    tokio::task::spawn_blocking(move || validate_wav(&audio))
2368        .await
2369        .map_err(|error| {
2370            tracing::warn!(%error, "dictation audio validation task failed");
2371            ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "audio validation failed")
2372        })?
2373        .map_err(dictation_api_error)?;
2374    let result =
2375        dispatch_dictation(&state, session_id, DictationOperation::Transcribe(body)).await?;
2376    match result {
2377        DictationResponse::Transcript { text } => Ok(Json(DictationTranscript { text })),
2378        DictationResponse::Availability { .. } => Err(ApiError::new(
2379            StatusCode::INTERNAL_SERVER_ERROR,
2380            "the controller returned an invalid dictation response",
2381        )),
2382    }
2383}
2384
2385/// Cancels a request as soon as Axum drops its handler future, which happens
2386/// when a browser disconnects while a provider request is still running.
2387struct DictationCancellationGuard(CancellationToken);
2388
2389impl Drop for DictationCancellationGuard {
2390    fn drop(&mut self) {
2391        self.0.cancel();
2392    }
2393}
2394
2395async fn dispatch_dictation(
2396    state: &ServerState,
2397    session_id: String,
2398    operation: DictationOperation,
2399) -> Result<DictationResponse, ApiError> {
2400    let cancel = CancellationToken::new();
2401    let _guard = DictationCancellationGuard(cancel.clone());
2402    let (reply, answer) = tokio::sync::oneshot::channel();
2403    let request = DictationRequest {
2404        session_id,
2405        operation,
2406        cancel: cancel.clone(),
2407        reply,
2408    };
2409    tokio::select! {
2410        biased;
2411        _ = state.shutdown.cancelled() => return Err(ApiError::controller_unavailable()),
2412        result = state.dictation_tx.send(request) => {
2413            result.map_err(|_| ApiError::controller_unavailable())?;
2414        }
2415    }
2416    let answer = tokio::select! {
2417        biased;
2418        _ = state.shutdown.cancelled() => return Err(ApiError::controller_unavailable()),
2419        result = answer => result.map_err(|_| ApiError::controller_unavailable())?,
2420    };
2421    answer.map_err(dictation_api_error)
2422}
2423
2424fn dictation_api_error(error: DictationError) -> ApiError {
2425    match error {
2426        DictationError::SessionNotFound => ApiError::not_found("unknown session"),
2427        DictationError::CredentialsUnavailable => ApiError::new(
2428            StatusCode::SERVICE_UNAVAILABLE,
2429            "dictation is unavailable because no Codex subscription is signed in",
2430        ),
2431        DictationError::InvalidAudio(message) => ApiError::bad_request(message),
2432        DictationError::Cancelled => {
2433            ApiError::new(StatusCode::REQUEST_TIMEOUT, "dictation cancelled")
2434        }
2435        DictationError::TimedOut => ApiError::new(
2436            StatusCode::GATEWAY_TIMEOUT,
2437            "dictation transcription timed out",
2438        ),
2439        DictationError::CredentialProbe => ApiError::new(
2440            StatusCode::SERVICE_UNAVAILABLE,
2441            "dictation credentials could not be checked",
2442        ),
2443        DictationError::Provider(error) => {
2444            tracing::warn!(%error, "Codex dictation transcription failed");
2445            ApiError::new(StatusCode::BAD_GATEWAY, "dictation transcription failed")
2446        }
2447    }
2448}
2449
2450#[derive(Debug, Deserialize)]
2451#[serde(deny_unknown_fields)]
2452struct DraftRequest {
2453    draft: String,
2454}
2455
2456async fn save_draft(
2457    State(state): State<ServerState>,
2458    Path(session_id): Path<String>,
2459    headers: HeaderMap,
2460    Json(request): Json<DraftRequest>,
2461) -> Result<StatusCode, ApiError> {
2462    validate_public_id(&session_id)?;
2463    require_session_record(&state.snapshot_rx.borrow(), &session_id)?;
2464    if request.draft.len() > MAX_DRAFT_BYTES {
2465        return Err(ApiError::new(
2466            StatusCode::PAYLOAD_TOO_LARGE,
2467            "draft must be 65536 bytes or fewer",
2468        ));
2469    }
2470    let Some(client_id) = viewer_client_id(&state, &headers) else {
2471        // Nothing to key it to. The phone keeps its draft in the composer, and
2472        // silently accepting would promise a persistence that is not there.
2473        return Err(ApiError::new(
2474            StatusCode::CONFLICT,
2475            "this viewer has no stored identity; unlock again to keep drafts",
2476        ));
2477    };
2478    ask_client_state(&state, |reply| ClientStateRequest::SaveDraft {
2479        client_id,
2480        session_id,
2481        draft: request.draft,
2482        reply,
2483    })
2484    .await?;
2485    Ok(StatusCode::NO_CONTENT)
2486}
2487
2488/// Mark every session in a workspace read, in one request.
2489///
2490/// Opening a workspace should not cost one request per session.
2491async fn mark_workspace_read(
2492    State(state): State<ServerState>,
2493    Path(workspace_id): Path<String>,
2494    headers: HeaderMap,
2495) -> Result<StatusCode, ApiError> {
2496    validate_public_id(&workspace_id)?;
2497    let Some(client_id) = viewer_client_id(&state, &headers) else {
2498        return Ok(StatusCode::NO_CONTENT);
2499    };
2500    ask_client_state(&state, |reply| ClientStateRequest::MarkWorkspaceRead {
2501        client_id,
2502        workspace_id,
2503        reply,
2504    })
2505    .await?;
2506    Ok(StatusCode::NO_CONTENT)
2507}
2508
2509#[derive(Debug, Deserialize)]
2510struct HistoryQuery {
2511    #[serde(default)]
2512    q: String,
2513    #[serde(default)]
2514    scope: Option<String>,
2515}
2516
2517/// Search this session's or this project's earlier prompts.
2518async fn prompt_history(
2519    State(state): State<ServerState>,
2520    Path(session_id): Path<String>,
2521    Query(query): Query<HistoryQuery>,
2522) -> Result<Json<ViewerPromptHistory>, ApiError> {
2523    validate_public_id(&session_id)?;
2524    require_session_record(&state.snapshot_rx.borrow(), &session_id)?;
2525    if query.q.chars().count() > MAX_TITLE_CHARS {
2526        return Err(ApiError::bad_request("search text is too long"));
2527    }
2528    let scope = query.scope.unwrap_or_else(|| "project".to_owned());
2529    if !matches!(scope.as_str(), "session" | "project" | "all") {
2530        return Err(ApiError::bad_request(
2531            "scope must be session, project or all",
2532        ));
2533    }
2534    ask_client_state(&state, |reply| ClientStateRequest::History {
2535        session_id,
2536        query: query.q,
2537        scope,
2538        reply,
2539    })
2540    .await
2541    .map(Json)
2542}
2543
2544async fn events(State(state): State<ServerState>) -> impl IntoResponse {
2545    let mut snapshots = state.snapshot_rx.clone();
2546    let (tx, rx) = mpsc::channel::<Result<Event, Infallible>>(8);
2547    tokio::spawn(async move {
2548        let initial = snapshots.borrow().revision;
2549        if tx
2550            .send(Ok(Event::default()
2551                .event("revision")
2552                .data(initial.to_string())))
2553            .await
2554            .is_err()
2555        {
2556            return;
2557        }
2558        while snapshots.changed().await.is_ok() {
2559            let revision = snapshots.borrow_and_update().revision;
2560            if tx
2561                .send(Ok(Event::default()
2562                    .event("revision")
2563                    .data(revision.to_string())))
2564                .await
2565                .is_err()
2566            {
2567                break;
2568            }
2569        }
2570    });
2571    Sse::new(ReceiverStream::new(rx)).keep_alive(KeepAlive::default())
2572}
2573
2574/// Check attached images without decoding megabytes of base64 on the task that
2575/// serves the request. Everything else about an action is cheap enough to
2576/// check inline; a full multi-image prompt is not.
2577async fn decode_prompt_images_off_task(
2578    action: ControllerAction,
2579) -> Result<ControllerAction, ApiError> {
2580    let ControllerAction::Prompt { images, .. } = &action else {
2581        return Ok(action);
2582    };
2583    if images.is_empty() {
2584        return Ok(action);
2585    }
2586    tokio::task::spawn_blocking(move || {
2587        let mut action = action;
2588        let ControllerAction::Prompt {
2589            session_id, images, ..
2590        } = &action
2591        else {
2592            unreachable!("only prompt actions carry images")
2593        };
2594        validate_prompt_images(images)?;
2595        let store = AttachmentStore::controller(session_id).map_err(|_| {
2596            ApiError::new(
2597                StatusCode::INTERNAL_SERVER_ERROR,
2598                "could not open the image attachment store",
2599            )
2600        })?;
2601        let ControllerAction::Prompt { images, .. } = &mut action else {
2602            unreachable!("only prompt actions carry images")
2603        };
2604        for image in images {
2605            if let Some(reference) = image.attachment.clone() {
2606                // Reading through this session's store both verifies the
2607                // digest and prevents a reference from another session being
2608                // smuggled into a prompt.
2609                store
2610                    .read(&reference)
2611                    .map_err(|_| ApiError::bad_request("the image attachment is unavailable"))?;
2612                image.data_base64.clear();
2613                image.mime_type = reference.mime_type;
2614                image.width = reference.width;
2615                image.height = reference.height;
2616            } else {
2617                let bytes = base64::engine::general_purpose::STANDARD
2618                    .decode(&image.data_base64)
2619                    .map_err(|_| ApiError::bad_request("image data must be valid base64"))?;
2620                let optimized = optimize_image(&bytes).map_err(|_| {
2621                    ApiError::bad_request("unsupported image format or image could not be decoded")
2622                })?;
2623                let reference = AttachmentRef::new(
2624                    &optimized.bytes,
2625                    optimized.mime_type.clone(),
2626                    optimized.width,
2627                    optimized.height,
2628                )
2629                .map_err(|_| {
2630                    ApiError::bad_request("the inline image could not become an attachment")
2631                })?;
2632                store.install(&reference, &optimized.bytes).map_err(|_| {
2633                    ApiError::new(
2634                        StatusCode::INTERNAL_SERVER_ERROR,
2635                        "could not store the image attachment",
2636                    )
2637                })?;
2638                image.data_base64.clear();
2639                image.attachment = Some(reference);
2640                image.mime_type = optimized.mime_type;
2641                image.width = optimized.width;
2642                image.height = optimized.height;
2643            }
2644        }
2645        Ok(action)
2646    })
2647    .await
2648    .map_err(|_| {
2649        ApiError::new(
2650            StatusCode::INTERNAL_SERVER_ERROR,
2651            "the server could not check the attached images",
2652        )
2653    })?
2654}
2655
2656fn validate_prompt_images(images: &[ViewerPromptImage]) -> Result<(), ApiError> {
2657    if images.len() > MAX_PROMPT_IMAGES {
2658        return Err(ApiError::bad_request(
2659            "a prompt may contain at most 10 images",
2660        ));
2661    }
2662    for image in images {
2663        if !image.mime_type.starts_with("image/") {
2664            return Err(ApiError::bad_request(
2665                "image mime type must start with image/",
2666            ));
2667        }
2668        if image.width == 0 || image.height == 0 {
2669            return Err(ApiError::bad_request(
2670                "image dimensions must be greater than zero",
2671            ));
2672        }
2673        if let Some(reference) = &image.attachment {
2674            if !image.data_base64.is_empty() {
2675                return Err(ApiError::bad_request(
2676                    "an image cannot contain both inline data and an attachment",
2677                ));
2678            }
2679            if reference.mime_type != image.mime_type
2680                || reference.width != image.width
2681                || reference.height != image.height
2682            {
2683                return Err(ApiError::bad_request(
2684                    "image attachment metadata does not match the prompt",
2685                ));
2686            }
2687            continue;
2688        }
2689        let bytes = base64::engine::general_purpose::STANDARD
2690            .decode(&image.data_base64)
2691            .map_err(|_| ApiError::bad_request("image data must be valid base64"))?;
2692        if bytes.is_empty() {
2693            return Err(ApiError::bad_request("image data must not be empty"));
2694        }
2695    }
2696    Ok(())
2697}
2698
2699const MAX_MOVE_QUEUE_ITEMS: usize = 256;
2700const MAX_MOVE_MOUNTS: usize = 32;
2701
2702fn validate_move_selection(
2703    selection: &MoveSelection,
2704    snapshot: &ViewerSnapshot,
2705) -> Result<(), ApiError> {
2706    validate_public_id(&selection.session_id)?;
2707    if selection.profile_id.is_none() && selection.target_template_id.is_none() {
2708        return Err(ApiError::bad_request(
2709            "move must select a profile, a target, or both",
2710        ));
2711    }
2712    if selection.clear_resource_allocation && selection.resource_allocation.is_some() {
2713        return Err(ApiError::bad_request(
2714            "clear resource sizing cannot be combined with an explicit allocation",
2715        ));
2716    }
2717    if let Some(profile_id) = selection.profile_id.as_deref() {
2718        validate_public_id(profile_id)?;
2719        require_profile(snapshot, profile_id)?;
2720    }
2721    if let Some(target_id) = selection.target_template_id.as_deref() {
2722        validate_public_id(target_id)?;
2723        require_target(snapshot, target_id)?;
2724        let session = require_session_record(snapshot, &selection.session_id)?;
2725        if session
2726            .incompatible_resume_targets
2727            .iter()
2728            .any(|id| id == target_id)
2729        {
2730            return Err(ApiError::bad_request(
2731                "this session cannot resume on that target",
2732            ));
2733        }
2734    } else {
2735        require_session_record(snapshot, &selection.session_id)?;
2736    }
2737    if let Some(mounts) = &selection.additional_mounts {
2738        validate_move_mounts(mounts)?;
2739    }
2740    let session = require_session_record(snapshot, &selection.session_id)?;
2741    let retryable_move = session
2742        .move_recovery
2743        .as_ref()
2744        .is_some_and(|recovery| recovery.checkpoint_retained);
2745    if !session.capabilities.move_session && !retryable_move {
2746        return Err(ApiError::new(
2747            StatusCode::CONFLICT,
2748            "this session cannot be moved now",
2749        ));
2750    }
2751    Ok(())
2752}
2753
2754fn validate_move_mounts(mounts: &[AdditionalMount]) -> Result<(), ApiError> {
2755    if mounts.len() > MAX_MOVE_MOUNTS {
2756        return Err(ApiError::bad_request("a move may carry at most 32 mounts"));
2757    }
2758    for mount in mounts {
2759        for path in [&mount.source, &mount.destination] {
2760            if !path.is_absolute()
2761                || path
2762                    .components()
2763                    .any(|component| component == Component::ParentDir)
2764            {
2765                return Err(ApiError::bad_request(
2766                    "move mount paths must be absolute and must not contain '..'",
2767                ));
2768            }
2769        }
2770    }
2771    Ok(())
2772}
2773
2774fn validate_resume_settings(
2775    additional_mounts: Option<&Vec<AdditionalMount>>,
2776    resource_allocation: Option<&SessionResourceAllocation>,
2777) -> Result<(), ApiError> {
2778    if let Some(mounts) = additional_mounts {
2779        validate_move_mounts(mounts)?;
2780    }
2781    if let Some(allocation) = resource_allocation {
2782        allocation
2783            .validate()
2784            .map_err(|_| ApiError::bad_request("resource allocation is invalid"))?;
2785    }
2786    Ok(())
2787}
2788
2789fn validate_move_request(
2790    request: &MoveSessionRequest,
2791    snapshot: &ViewerSnapshot,
2792) -> Result<(), ApiError> {
2793    let preparation = &request.preparation;
2794    validate_move_selection(&preparation.selection, snapshot)?;
2795    let session = require_session_record(snapshot, &preparation.selection.session_id)?;
2796    if preparation.operation_id.trim().is_empty() || preparation.fingerprint.trim().is_empty() {
2797        return Err(ApiError::bad_request(
2798            "move confirmation is missing its preparation identity",
2799        ));
2800    }
2801    if preparation.queued_commands.len() > MAX_MOVE_QUEUE_ITEMS {
2802        return Err(ApiError::bad_request(
2803            "move queue is too large; prepare again",
2804        ));
2805    }
2806    let active_now = preparation.active
2807        || session.chat_phase == ViewerChatPhase::Running
2808        || !session.active_user_shells.is_empty();
2809    if active_now && !request.acknowledge_interruption {
2810        return Err(ApiError::new(
2811            StatusCode::CONFLICT,
2812            "confirm that the active turn may be interrupted",
2813        ));
2814    }
2815    if !preparation.queued_commands.is_empty() && request.queue.is_none() {
2816        return Err(ApiError::bad_request(
2817            "choose whether queued work is discarded or started after the move",
2818        ));
2819    }
2820    Ok(())
2821}
2822
2823fn validate_action(action: &ControllerAction, snapshot: &ViewerSnapshot) -> Result<(), ApiError> {
2824    match action {
2825        ControllerAction::New {
2826            workspace_id,
2827            profile_id,
2828            bundle_id,
2829            target_id,
2830            title,
2831            project_directory,
2832            dirty_ack,
2833        } => {
2834            if !workspace_id.is_empty() {
2835                validate_public_id(workspace_id)?;
2836            }
2837            validate_public_id(profile_id)?;
2838            validate_public_id(bundle_id)?;
2839            validate_public_id(target_id)?;
2840            if let Some(title) = title {
2841                validate_title(title)?;
2842            }
2843            // An acknowledgement names repositories the preflight reported.
2844            // Unbounded or malformed entries would travel to the controller
2845            // and be compared against a real set, so they are refused here.
2846            if dirty_ack.len() > MAX_DIRTY_ACKNOWLEDGEMENTS
2847                || dirty_ack
2848                    .iter()
2849                    .any(|repository| repository.trim().is_empty() || repository.len() > 256)
2850            {
2851                return Err(ApiError::bad_request(
2852                    "dirty acknowledgement must name 0-32 repositories",
2853                ));
2854            }
2855            require_profile(snapshot, profile_id)?;
2856            require_bundle(snapshot, bundle_id)?;
2857            let target = require_target(snapshot, target_id)?;
2858            if target.requires_project_directory != project_directory.is_some() {
2859                return Err(ApiError::bad_request(
2860                    "project_directory is required exactly for bare targets",
2861                ));
2862            }
2863            if let Some(directory) = project_directory
2864                && (!directory.is_absolute()
2865                    || directory
2866                        .components()
2867                        .any(|component| component == Component::ParentDir))
2868            {
2869                return Err(ApiError::bad_request(
2870                    "project_directory must be an absolute safe path",
2871                ));
2872            }
2873        }
2874        ControllerAction::Resume {
2875            session_id,
2876            workspace_id,
2877            profile_id,
2878            target_id,
2879            additional_mounts,
2880            resource_allocation,
2881            ..
2882        } => {
2883            validate_public_id(session_id)?;
2884            validate_public_id(workspace_id)?;
2885            validate_public_id(profile_id)?;
2886            validate_public_id(target_id)?;
2887            let session = require_session_record(snapshot, session_id)?;
2888            require_workspace(snapshot, workspace_id)?;
2889            require_profile(snapshot, profile_id)?;
2890            require_target(snapshot, target_id)?;
2891            if session
2892                .incompatible_resume_targets
2893                .iter()
2894                .any(|incompatible| incompatible == target_id)
2895            {
2896                return Err(ApiError::bad_request(
2897                    "this session cannot resume on that target",
2898                ));
2899            }
2900            validate_resume_settings(additional_mounts.as_ref(), resource_allocation.as_ref())?;
2901        }
2902        ControllerAction::Move { request } => validate_move_request(request, snapshot)?,
2903        ControllerAction::Open { session_id }
2904        | ControllerAction::Close { session_id }
2905        | ControllerAction::Cancel { session_id }
2906        | ControllerAction::StartReview { session_id } => {
2907            validate_public_id(session_id)?;
2908            require_session_record(snapshot, session_id)?;
2909        }
2910        ControllerAction::ResolveReview {
2911            session_id,
2912            resolution,
2913        } => {
2914            validate_public_id(session_id)?;
2915            let session = require_session_record(snapshot, session_id)?;
2916            let Some(resolution) = resolution_from_name(resolution) else {
2917                return Err(ApiError::bad_request(
2918                    "a review is resolved by forward, dismiss, or cancel",
2919                ));
2920            };
2921            let Some(review) = session.turn_review.as_ref() else {
2922                return Err(ApiError::bad_request("no review is open for that session"));
2923            };
2924            // Cancel is always available; the rest wait for the verdict the
2925            // daemon published, which is the same gate the daemon enforces
2926            // when it actually resolves.
2927            let allowed = resolution == hel::hel_review::driver::Resolution::Cancelled
2928                || review.verdict.as_ref().is_some_and(|verdict| {
2929                    resolution_name(&resolution)
2930                        .is_some_and(|name| verdict.allowed.iter().any(|allowed| allowed == name))
2931                });
2932            if !allowed {
2933                return Err(ApiError::bad_request(
2934                    "that review cannot be resolved that way yet",
2935                ));
2936            }
2937        }
2938        ControllerAction::Rename { session_id, title } => {
2939            validate_public_id(session_id)?;
2940            validate_title(title)?;
2941            let session = require_session_record(snapshot, session_id)?;
2942            if !session.capabilities.rename {
2943                return Err(ApiError::bad_request("this session cannot be renamed"));
2944            }
2945        }
2946        ControllerAction::CancelTurn { session_id } => {
2947            validate_public_id(session_id)?;
2948            let session = require_session_record(snapshot, session_id)?;
2949            if !session.capabilities.cancel_turn {
2950                return Err(ApiError::new(
2951                    StatusCode::CONFLICT,
2952                    "this session has no turn to cancel",
2953                ));
2954            }
2955        }
2956        ControllerAction::SetConfig {
2957            session_id,
2958            key,
2959            value,
2960        } => {
2961            validate_public_id(session_id)?;
2962            let session = require_session_record(snapshot, session_id)?;
2963            if !session.capabilities.set_config {
2964                return Err(ApiError::bad_request(
2965                    "this session cannot change configuration now",
2966                ));
2967            }
2968            // The harness decides what it accepts. Forwarding a key it never
2969            // advertised, or a value outside the ones it offered, asks it to
2970            // refuse something the viewer should not have offered.
2971            let option = session
2972                .config_options
2973                .iter()
2974                .find(|option| option.key == *key)
2975                .ok_or_else(|| ApiError::bad_request("this agent does not offer that setting"))?;
2976            if !option.choices.iter().any(|choice| choice.value == *value) {
2977                return Err(ApiError::bad_request(
2978                    "this agent does not offer that value for that setting",
2979                ));
2980            }
2981        }
2982        ControllerAction::SetPlanMode { session_id, .. } => {
2983            validate_public_id(session_id)?;
2984            let session = require_session_record(snapshot, session_id)?;
2985            if !session.capabilities.set_plan_mode {
2986                return Err(ApiError::bad_request(
2987                    "this session cannot change plan mode now",
2988                ));
2989            }
2990        }
2991        ControllerAction::RefreshQuota { profile_id } => {
2992            validate_public_id(profile_id)?;
2993            require_profile(snapshot, profile_id)?;
2994        }
2995        ControllerAction::RefreshCapacity { target_id } => {
2996            validate_public_id(target_id)?;
2997            require_target(snapshot, target_id)?;
2998        }
2999        ControllerAction::Prompt {
3000            session_id,
3001            text,
3002            images,
3003        } => {
3004            validate_public_id(session_id)?;
3005            let session = require_session_record(snapshot, session_id)?;
3006            if images.len() > MAX_PROMPT_IMAGES {
3007                return Err(ApiError::bad_request(
3008                    "a prompt may contain at most 10 images",
3009                ));
3010            }
3011            if text.starts_with('!') {
3012                return Err(ApiError::bad_request(
3013                    "leading ! is reserved for shell commands",
3014                ));
3015            }
3016            if text.chars().count() > MAX_PROMPT_CHARS {
3017                return Err(ApiError::bad_request(
3018                    "prompt must contain 1-65536 characters",
3019                ));
3020            }
3021            if text.trim().is_empty() && images.is_empty() {
3022                return Err(ApiError::bad_request(
3023                    "prompt must contain text or an image",
3024                ));
3025            }
3026            if !images.is_empty() && !session.prompt_images_supported {
3027                return Err(ApiError::bad_request(
3028                    "this session does not support image prompts",
3029                ));
3030            }
3031            // Review is synchronous: the turn under review stays where the
3032            // review found it. The daemon's own submit path is what makes this
3033            // true; refusing here as well is what turns it into an immediate
3034            // answer rather than a rejected prompt.
3035            if session.turn_review.is_some() {
3036                return Err(ApiError::bad_request(
3037                    crate::hel_review_host::PROMPT_HELD_MESSAGE,
3038                ));
3039            }
3040        }
3041        ControllerAction::RunShell {
3042            session_id,
3043            command,
3044        } => {
3045            validate_public_id(session_id)?;
3046            require_session_record(snapshot, session_id)?;
3047            if command.trim().is_empty() || command.chars().count() > MAX_PROMPT_CHARS {
3048                return Err(ApiError::bad_request(
3049                    "shell command must contain 1-65536 characters",
3050                ));
3051            }
3052        }
3053        ControllerAction::CancelShell {
3054            session_id,
3055            shell_command_id,
3056        } => {
3057            validate_public_id(session_id)?;
3058            validate_public_id(shell_command_id)?;
3059            let session = require_session_record(snapshot, session_id)?;
3060            if !session
3061                .active_user_shells
3062                .iter()
3063                .any(|shell| shell.id == *shell_command_id)
3064            {
3065                return Err(ApiError::bad_request("unknown active shell command"));
3066            }
3067        }
3068        ControllerAction::RemoveQueuedPrompt {
3069            session_id,
3070            queue_id,
3071        } => {
3072            validate_public_id(session_id)?;
3073            validate_public_id(queue_id)?;
3074            require_session_record(snapshot, session_id)?;
3075        }
3076        ControllerAction::RespondElicitation {
3077            session_id,
3078            elicitation_id,
3079            response,
3080        } => {
3081            validate_public_id(session_id)?;
3082            validate_public_id(elicitation_id)?;
3083            let session = require_session_record(snapshot, session_id)?;
3084            let request = session
3085                .pending_elicitations
3086                .iter()
3087                .find(|request| request.id == *elicitation_id)
3088                .ok_or_else(|| ApiError::not_found("unknown elicitation"))?;
3089            if serde_json::to_vec(response).map_or(usize::MAX, |encoded| encoded.len())
3090                > MAX_ELICITATION_BYTES
3091            {
3092                return Err(ApiError::bad_request("elicitation answer is too large"));
3093            }
3094            // The answer has to satisfy the question the agent actually asked.
3095            // A phone can post one for a request the session has already
3096            // replaced, and forwarding that would answer a live question with
3097            // content the agent never offered.
3098            if request.validate_response(response).is_err() {
3099                return Err(ApiError::bad_request(
3100                    "the answer does not match this elicitation request",
3101                ));
3102            }
3103        }
3104    }
3105    Ok(())
3106}
3107
3108fn validate_public_id(id: &str) -> Result<(), ApiError> {
3109    validate_id("request", id).map_err(|_| ApiError::bad_request("invalid id"))
3110}
3111
3112fn validate_title(title: &str) -> Result<(), ApiError> {
3113    if title.trim().is_empty() || title.chars().count() > MAX_TITLE_CHARS {
3114        Err(ApiError::bad_request("title must contain 1-120 characters"))
3115    } else {
3116        Ok(())
3117    }
3118}
3119
3120fn require_session_record<'a>(
3121    snapshot: &'a ViewerSnapshot,
3122    id: &str,
3123) -> Result<&'a ViewerSession, ApiError> {
3124    snapshot
3125        .sessions
3126        .iter()
3127        .find(|session| session.id == id)
3128        .ok_or_else(|| ApiError::not_found("unknown session"))
3129}
3130
3131fn require_workspace(snapshot: &ViewerSnapshot, id: &str) -> Result<(), ApiError> {
3132    snapshot
3133        .workspaces
3134        .iter()
3135        .any(|workspace| workspace.id == id)
3136        .then_some(())
3137        .ok_or_else(|| ApiError::bad_request("unknown workspace"))
3138}
3139
3140fn require_profile<'a>(
3141    snapshot: &'a ViewerSnapshot,
3142    id: &str,
3143) -> Result<&'a ViewerProfile, ApiError> {
3144    snapshot
3145        .profiles
3146        .iter()
3147        .find(|profile| profile.id == id)
3148        .ok_or_else(|| ApiError::bad_request("unknown profile"))
3149}
3150
3151fn require_target<'a>(
3152    snapshot: &'a ViewerSnapshot,
3153    id: &str,
3154) -> Result<&'a ViewerTarget, ApiError> {
3155    snapshot
3156        .targets
3157        .iter()
3158        .find(|target| target.id == id)
3159        .ok_or_else(|| ApiError::bad_request("unknown target"))
3160}
3161
3162fn require_bundle(snapshot: &ViewerSnapshot, id: &str) -> Result<(), ApiError> {
3163    snapshot
3164        .bundles
3165        .iter()
3166        .any(|bundle| bundle.id == id)
3167        .then_some(())
3168        .ok_or_else(|| ApiError::bad_request("unknown bundle"))
3169}
3170
3171#[derive(Debug, Serialize)]
3172struct ErrorBody<'a> {
3173    error: &'a str,
3174}
3175
3176#[derive(Debug)]
3177struct ApiError {
3178    status: StatusCode,
3179    message: &'static str,
3180}
3181
3182impl ApiError {
3183    const fn new(status: StatusCode, message: &'static str) -> Self {
3184        Self { status, message }
3185    }
3186
3187    const fn unauthorized() -> Self {
3188        Self::new(StatusCode::UNAUTHORIZED, "unauthorized")
3189    }
3190
3191    const fn bad_request(message: &'static str) -> Self {
3192        Self::new(StatusCode::BAD_REQUEST, message)
3193    }
3194
3195    const fn not_found(message: &'static str) -> Self {
3196        Self::new(StatusCode::NOT_FOUND, message)
3197    }
3198
3199    const fn controller_unavailable() -> Self {
3200        Self::new(StatusCode::SERVICE_UNAVAILABLE, "controller unavailable")
3201    }
3202}
3203
3204impl IntoResponse for ApiError {
3205    fn into_response(self) -> Response<Body> {
3206        (
3207            self.status,
3208            Json(ErrorBody {
3209                error: self.message,
3210            }),
3211        )
3212            .into_response()
3213    }
3214}
3215
3216fn code_locked(state: &ServerState) -> bool {
3217    state
3218        .code_guard
3219        .lock()
3220        .expect("viewer code guard poisoned")
3221        .locked_at(Instant::now())
3222}
3223
3224fn record_code_failure(state: &ServerState) {
3225    state
3226        .code_guard
3227        .lock()
3228        .expect("viewer code guard poisoned")
3229        .record_failure_at(Instant::now());
3230}
3231
3232fn reset_code_failures(state: &ServerState) {
3233    *state.code_guard.lock().expect("viewer code guard poisoned") = CodeGuard::default();
3234}
3235
3236fn generate_viewer_code() -> AnyResult<String> {
3237    // Rejection sampling avoids modulo bias in the deliberately small code
3238    // space. Online attempts are separately rate-limited.
3239    const RANGE: u32 = 1_000_000;
3240    const LIMIT: u32 = u32::MAX - (u32::MAX % RANGE);
3241    loop {
3242        let mut bytes = [0_u8; 4];
3243        getrandom::fill(&mut bytes)
3244            .map_err(|error| anyhow::anyhow!("generate Mjolnir viewer code: {error}"))?;
3245        let value = u32::from_le_bytes(bytes);
3246        if value < LIMIT {
3247            return Ok(format!("{:06}", value % RANGE));
3248        }
3249    }
3250}
3251
3252fn generate_login_token() -> AnyResult<String> {
3253    let mut token = [0_u8; 32];
3254    getrandom::fill(&mut token)
3255        .map_err(|error| anyhow::anyhow!("generate Mjolnir viewer login token: {error}"))?;
3256    Ok(base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(token))
3257}
3258
3259fn generate_cookie_key() -> AnyResult<[u8; COOKIE_KEY_BYTES]> {
3260    let mut key = [0_u8; COOKIE_KEY_BYTES];
3261    getrandom::fill(&mut key)
3262        .map_err(|error| anyhow::anyhow!("generate Mjolnir cookie key: {error}"))?;
3263    Ok(key)
3264}
3265
3266/// A random name for one viewer, minted at unlock.
3267///
3268/// The cookie used to sign only an expiry, which meant two phones unlocking in
3269/// the same second received byte-identical cookies and one phone's cookie
3270/// changed on every login. Nothing keyed to it could mean anything: a draft
3271/// would have leaked between phones and vanished on re-login. This is the
3272/// identity everything per-viewer hangs from.
3273fn generate_viewer_id() -> AnyResult<String> {
3274    let mut id = [0_u8; 16];
3275    getrandom::fill(&mut id)
3276        .map_err(|error| anyhow::anyhow!("generate Mjolnir viewer id: {error}"))?;
3277    Ok(base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(id))
3278}
3279
3280fn signed_cookie_value(key: &[u8], viewer: &str, expiry: u64) -> String {
3281    // The signed text separates its parts with a character the parts cannot
3282    // contain, so no two different pairs can produce the same signed text.
3283    let canonical = format!("{viewer}|{expiry}");
3284    let mut mac = Hmac::<Sha256>::new_from_slice(key).expect("HMAC accepts arbitrary key lengths");
3285    mac.update(canonical.as_bytes());
3286    let signature =
3287        base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes());
3288    format!("{viewer}.{expiry}.{signature}")
3289}
3290
3291/// The cookie value a viewer with no identity used to receive.
3292///
3293/// Still accepted, so a phone holding one is not signed out by a deployment.
3294/// It carries no viewer, so it stores nothing and is replaced by a three-part
3295/// cookie at its next unlock.
3296fn legacy_signed_cookie_value(key: &[u8], expiry: u64) -> String {
3297    let canonical = expiry.to_string();
3298    let mut mac = Hmac::<Sha256>::new_from_slice(key).expect("HMAC accepts arbitrary key lengths");
3299    mac.update(canonical.as_bytes());
3300    let signature =
3301        base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes());
3302    format!("{canonical}.{signature}")
3303}
3304
3305fn session_cookie_valid(key: &[u8], value: &str, now: u64) -> bool {
3306    cookie_viewer(key, value, now).is_some()
3307}
3308
3309/// Mint a signed viewer-session cookie value without the HTTP login flow.
3310///
3311/// The desktop shell pre-authorizes its WebView with this: it runs as the same
3312/// user as the daemon and reads the same persisted signing key, so possession
3313/// of the key is the credential. The cookie carries the ephemeral TTL — a
3314/// desktop window re-mints on every launch, so it never needs a long life.
3315pub fn mint_desktop_session_cookie(key: &[u8]) -> AnyResult<String> {
3316    let viewer = generate_viewer_id()?;
3317    Ok(signed_cookie_value(
3318        key,
3319        &viewer,
3320        now_unix().saturating_add(EPHEMERAL_SESSION_TTL.as_secs()),
3321    ))
3322}
3323
3324/// The viewer a cookie names, or `None` when the cookie is not valid.
3325///
3326/// A legacy two-part cookie validates and names no viewer, which is the
3327/// difference between "signed out" and "signed in with nothing stored".
3328fn cookie_viewer(key: &[u8], value: &str, now: u64) -> Option<Option<String>> {
3329    let parts = value.split('.').collect::<Vec<_>>();
3330    let (viewer, expiry, expected) = match parts.as_slice() {
3331        [viewer, expiry, _] => {
3332            let expiry_value = expiry.parse::<u64>().ok()?;
3333            (
3334                Some((*viewer).to_owned()),
3335                expiry_value,
3336                signed_cookie_value(key, viewer, expiry_value),
3337            )
3338        }
3339        [expiry, _] => {
3340            let expiry_value = expiry.parse::<u64>().ok()?;
3341            (
3342                None,
3343                expiry_value,
3344                legacy_signed_cookie_value(key, expiry_value),
3345            )
3346        }
3347        _ => return None,
3348    };
3349    if now >= expiry {
3350        return None;
3351    }
3352    constant_time_eq(expected.as_bytes(), value.as_bytes()).then_some(viewer)
3353}
3354
3355fn session_cookie_header(
3356    value: &str,
3357    max_age: Option<u64>,
3358    secure: bool,
3359) -> Result<HeaderValue, ApiError> {
3360    let mut header = format!("{COOKIE_NAME}={value}; Path=/; HttpOnly; SameSite=Strict");
3361    if secure {
3362        header.push_str("; Secure");
3363    }
3364    if let Some(max_age) = max_age {
3365        header.push_str(&format!("; Max-Age={max_age}"));
3366    }
3367    HeaderValue::from_str(&header)
3368        .map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "cookie creation failed"))
3369}
3370
3371fn clear_cookie_header(secure: bool) -> HeaderValue {
3372    let secure = if secure { "; Secure" } else { "" };
3373    HeaderValue::from_str(&format!(
3374        "{COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Strict{secure}; Max-Age=0"
3375    ))
3376    .expect("static cookie header is valid")
3377}
3378
3379/// The stored-state key for the viewer making this request.
3380///
3381/// A viewer with a legacy cookie has no identity, so it has no stored state:
3382/// it reads and writes nothing rather than sharing a bucket with every other
3383/// phone that unlocked in the same second, which is what the old whole-cookie
3384/// key amounted to.
3385fn viewer_client_id(state: &ServerState, headers: &HeaderMap) -> Option<String> {
3386    let cookie = headers
3387        .get(COOKIE)
3388        .and_then(|value| value.to_str().ok())
3389        .and_then(|header| cookie_value(header, COOKIE_NAME))?;
3390    cookie_viewer(&state.cookie_key, cookie, now_unix())
3391        .flatten()
3392        .map(|viewer| format!("phone:{viewer}"))
3393}
3394
3395fn cookie_value<'a>(header: &'a str, name: &str) -> Option<&'a str> {
3396    header
3397        .split(';')
3398        .filter_map(|part| part.trim().split_once('='))
3399        .find(|(cookie_name, _)| *cookie_name == name)
3400        .map(|(_, value)| value)
3401}
3402
3403fn constant_time_eq(left: &[u8], right: &[u8]) -> bool {
3404    if left.len() != right.len() {
3405        return false;
3406    }
3407    left.iter()
3408        .zip(right)
3409        .fold(0_u8, |difference, (left, right)| {
3410            difference | (left ^ right)
3411        })
3412        == 0
3413}
3414
3415fn now_unix() -> u64 {
3416    SystemTime::now()
3417        .duration_since(UNIX_EPOCH)
3418        .map(|elapsed| elapsed.as_secs())
3419        .unwrap_or(u64::MAX)
3420}
3421
3422const fn session_state_name(state: SessionState) -> &'static str {
3423    match state {
3424        SessionState::Provisioning => "provisioning",
3425        SessionState::Running => "running",
3426        SessionState::Disconnected => "disconnected",
3427        SessionState::Checkpointing => "checkpointing",
3428        SessionState::Closing => "closing",
3429        SessionState::Destroying => "destroying",
3430        SessionState::Stopped => "stopped",
3431        SessionState::Lost => "lost",
3432        SessionState::Error => "error",
3433        SessionState::DestroyedWithDataLoss => "destroyed-with-data-loss",
3434    }
3435}
3436
3437const fn target_kind_name(target: &TargetTemplate) -> &'static str {
3438    match target {
3439        TargetTemplate::LocalBare => "local-bare",
3440        TargetTemplate::LocalPodman { .. } => "local-podman",
3441        TargetTemplate::LocalDocker { .. } => "local-docker",
3442        TargetTemplate::AppleContainer { .. } => "apple-container",
3443        TargetTemplate::AwsEc2 { .. } => "aws-ec2",
3444        TargetTemplate::SshBare { .. } => "ssh-bare",
3445        TargetTemplate::SshPodman { .. } => "ssh-podman",
3446        TargetTemplate::SshDocker { .. } => "ssh-docker",
3447    }
3448}
3449
3450/// Every asset the browser application is built from. They are real files
3451/// under `src/web/` and `src/icons/` rather than string literals, so the
3452/// JavaScript can be read, formatted and tested as JavaScript, and so the
3453/// content-security policy below can forbid inline script outright.
3454const VIEWER_HTML: &str = include_str!("web/viewer.html");
3455const VIEWER_CSS: &str = include_str!("web/viewer.css");
3456const VIEWER_JS: &str = include_str!("web/viewer.js");
3457const MARKDOWN_JS: &str = include_str!("web/markdown.js");
3458const TOOL_OUTPUT_JS: &str = include_str!("web/tool-output.js");
3459const VOICE_WORKLET_JS: &str = include_str!("web/voice-worklet.js");
3460const VOICE_WORKER_JS: &str = include_str!("web/voice-worker.js");
3461/// A fake DOM for running the shipped renderers under Node. It is deliberately
3462/// not served: it exists so `cargo test` can exercise `markdown.js` without a
3463/// browser.
3464#[cfg(test)]
3465const TEST_DOM_JS: &str = include_str!("web/test-dom.js");
3466const SERVICE_WORKER: &str = include_str!("web/service-worker.js");
3467const MANIFEST: &str = include_str!("web/manifest.webmanifest");
3468const ICON_SVG: &str = include_str!("../src/icons/icon.svg");
3469const ICON_192: &[u8] = include_bytes!("../src/icons/icon-192.png");
3470const ICON_512: &[u8] = include_bytes!("../src/icons/icon-512.png");
3471const MASKABLE_512: &[u8] = include_bytes!("../src/icons/maskable-512.png");
3472const APPLE_TOUCH_ICON: &[u8] = include_bytes!("../src/icons/apple-touch-icon.png");
3473const MONO_FONT: &[u8] = include_bytes!("../src/fonts/jetbrains-mono.woff2");
3474
3475/// What the browser is permitted to load and execute.
3476///
3477/// `default-src 'none'` refuses everything not named below, so a future asset
3478/// has to be allowed deliberately. Script and style come only from this
3479/// origin, which is why none of either may be inline. `img-src` allows `blob:`
3480/// for browser-local attachment previews and keeps `data:` for legacy image
3481/// content rendered in a transcript.
3482const CONTENT_SECURITY_POLICY: &str = "default-src 'none'; \
3483script-src 'self'; \
3484style-src 'self'; \
3485img-src 'self' data: blob:; \
3486font-src 'self'; \
3487connect-src 'self'; \
3488manifest-src 'self'; \
3489base-uri 'none'; \
3490form-action 'none'; \
3491frame-ancestors 'none'";
3492
3493async fn viewer() -> Response<Body> {
3494    static_response("text/html; charset=utf-8", VIEWER_HTML, true)
3495}
3496
3497async fn viewer_css() -> Response<Body> {
3498    static_response("text/css; charset=utf-8", VIEWER_CSS, false)
3499}
3500
3501async fn viewer_js() -> Response<Body> {
3502    static_response("text/javascript; charset=utf-8", VIEWER_JS, false)
3503}
3504
3505async fn markdown_js() -> Response<Body> {
3506    static_response("text/javascript; charset=utf-8", MARKDOWN_JS, false)
3507}
3508
3509async fn voice_worklet_js() -> Response<Body> {
3510    static_response("text/javascript; charset=utf-8", VOICE_WORKLET_JS, false)
3511}
3512
3513async fn voice_worker_js() -> Response<Body> {
3514    static_response("text/javascript; charset=utf-8", VOICE_WORKER_JS, false)
3515}
3516
3517async fn tool_output_js() -> Response<Body> {
3518    static_response("text/javascript; charset=utf-8", TOOL_OUTPUT_JS, false)
3519}
3520
3521async fn manifest() -> Response<Body> {
3522    static_response("application/manifest+json", MANIFEST, false)
3523}
3524
3525/// The worker itself is never cached: a stale worker is what keeps a phone on
3526/// a superseded application, and it is the one asset that can never be fixed
3527/// by a later upgrade.
3528async fn service_worker() -> Response<Body> {
3529    static_response("text/javascript; charset=utf-8", SERVICE_WORKER, true)
3530}
3531
3532async fn icon() -> Response<Body> {
3533    static_response("image/svg+xml", ICON_SVG, false)
3534}
3535
3536async fn icon_192() -> Response<Body> {
3537    binary_response("image/png", ICON_192)
3538}
3539
3540async fn icon_512() -> Response<Body> {
3541    binary_response("image/png", ICON_512)
3542}
3543
3544async fn maskable_512() -> Response<Body> {
3545    binary_response("image/png", MASKABLE_512)
3546}
3547
3548async fn apple_touch_icon() -> Response<Body> {
3549    binary_response("image/png", APPLE_TOUCH_ICON)
3550}
3551
3552async fn mono_font() -> Response<Body> {
3553    binary_response("font/woff2", MONO_FONT)
3554}
3555
3556fn static_response(
3557    content_type: &'static str,
3558    body: &'static str,
3559    no_store: bool,
3560) -> Response<Body> {
3561    finish_static(Response::new(Body::from(body)), content_type, no_store)
3562}
3563
3564fn binary_response(content_type: &'static str, body: &'static [u8]) -> Response<Body> {
3565    finish_static(Response::new(Body::from(body)), content_type, false)
3566}
3567
3568/// Cacheable assets still revalidate. `no-cache` means "ask first", not "do
3569/// not store", so an upgraded viewer is picked up on the next load while an
3570/// unchanged one costs one conditional request.
3571fn finish_static(
3572    mut response: Response<Body>,
3573    content_type: &'static str,
3574    no_store: bool,
3575) -> Response<Body> {
3576    let headers = response.headers_mut();
3577    headers.insert(CONTENT_TYPE, HeaderValue::from_static(content_type));
3578    headers.insert(
3579        CACHE_CONTROL,
3580        HeaderValue::from_static(if no_store { "no-store" } else { "no-cache" }),
3581    );
3582    response
3583}
3584
3585/// Headers every response carries, applied once as a layer so no route can
3586/// forget them.
3587///
3588/// The layer also owns `no-store` for live state and authentication, rather
3589/// than leaving it to each handler. A rejected request never reaches its
3590/// handler, so a handler-set header is missing from exactly the responses that
3591/// are least worth storing.
3592async fn security_headers(request: Request, next: Next) -> Response<Body> {
3593    let live = {
3594        let path = request.uri().path();
3595        path.starts_with("/api/") || path.starts_with("/auth/")
3596    };
3597    let mut response = next.run(request).await;
3598    let headers = response.headers_mut();
3599    headers.insert(
3600        CONTENT_SECURITY_POLICY_HEADER,
3601        HeaderValue::from_static(CONTENT_SECURITY_POLICY),
3602    );
3603    headers.insert(X_CONTENT_TYPE_OPTIONS, HeaderValue::from_static("nosniff"));
3604    headers.insert(REFERRER_POLICY, HeaderValue::from_static("no-referrer"));
3605    if live {
3606        headers.insert(CACHE_CONTROL, HeaderValue::from_static("no-store"));
3607    }
3608    response
3609}
3610
3611#[cfg(test)]
3612mod tests {
3613    use super::*;
3614    use std::collections::BTreeMap;
3615    use std::path::Path;
3616
3617    use axum::http::Request;
3618    use http_body_util::BodyExt as _;
3619    use tower::ServiceExt as _;
3620
3621    use hel::hel_config::{
3622        CONFIG_VERSION, ContainerTemplate, HarnessKind, HarnessProfile, PermissionMode,
3623        ProjectBundle, ProjectRepository, SshConnection,
3624    };
3625    use hel::hel_state::{ProjectSourceIdentity, STATE_VERSION, SessionRecord};
3626
3627    #[test]
3628    fn unified_tls_backends_use_the_selected_crypto_provider() {
3629        install_rustls_crypto_provider();
3630
3631        assert!(rustls::crypto::CryptoProvider::get_default().is_some());
3632        let _builder = rustls::ServerConfig::builder();
3633    }
3634
3635    #[test]
3636    fn minted_desktop_cookie_validates_and_names_a_viewer() {
3637        let key = vec![7u8; COOKIE_KEY_BYTES];
3638        let value = mint_desktop_session_cookie(&key).unwrap();
3639        let viewer = cookie_viewer(&key, &value, now_unix());
3640        assert!(
3641            matches!(viewer, Some(Some(_))),
3642            "minted cookie must validate and carry a viewer id: {value:?}"
3643        );
3644        assert!(!session_cookie_valid(
3645            &[8u8; COOKIE_KEY_BYTES],
3646            &value,
3647            now_unix()
3648        ));
3649    }
3650
3651    fn sample_config_state() -> (HelConfig, HelState) {
3652        let config = HelConfig {
3653            version: CONFIG_VERSION,
3654            sessions_side: Default::default(),
3655            advanced: Default::default(),
3656            show_stopped_sessions: true,
3657            newer_config_version: None,
3658            spinner: Default::default(),
3659            theme: Default::default(),
3660            phone: Default::default(),
3661            review: Default::default(),
3662            startup: Default::default(),
3663            profiles: BTreeMap::from([(
3664                "codex-1".into(),
3665                HarnessProfile {
3666                    context_window_bytes: None,
3667                    kind: HarnessKind::Codex,
3668                    home: "/highly/secret/codex".into(),
3669                    environment: BTreeMap::from([("GH_TOKEN".into(), "secret-token".into())]),
3670                },
3671            )]),
3672            bundles: BTreeMap::from([(
3673                "hel".into(),
3674                ProjectBundle {
3675                    primary_repo: "hel".into(),
3676                    repositories: vec![ProjectRepository {
3677                        id: "hel".into(),
3678                        github: Some("owner/hel".into()),
3679                        local: Some("/private/source/hel".into()),
3680                        destination: "hel".into(),
3681                        git_ref: None,
3682                    }],
3683                },
3684            )]),
3685            targets: BTreeMap::from([
3686                (
3687                    "podman".into(),
3688                    TargetTemplate::LocalPodman {
3689                        container: ContainerTemplate {
3690                            image: "secret.registry/image".into(),
3691                            pull_policy: Default::default(),
3692                            platform: None,
3693                            cpus: None,
3694                            memory: None,
3695                            environment: BTreeMap::from([("TOKEN".into(), "secret-target".into())]),
3696                            workspace_storage: Default::default(),
3697                        },
3698                    },
3699                ),
3700                ("raw".into(), TargetTemplate::LocalBare),
3701            ]),
3702        };
3703        let state = HelState {
3704            version: STATE_VERSION,
3705            sessions: BTreeMap::from([(
3706                "session-1".into(),
3707                SessionRecord {
3708                    workspace_id: hel::hel_workspace::DEFAULT_WORKSPACE_ID.to_owned(),
3709                    archived: false,
3710                    container_cpus: None,
3711                    container_memory: None,
3712                    id: "session-1".into(),
3713                    title: "Build Hel".into(),
3714                    harness_kind: HarnessKind::Codex,
3715                    last_profile: "codex-1".into(),
3716                    bundle_id: "hel".into(),
3717                    project_directory: None,
3718                    managed_worktree: None,
3719                    target_template_id: "podman".into(),
3720                    resource_allocation: None,
3721                    additional_mounts: vec![],
3722                    state: SessionState::Running,
3723                    target: None,
3724                    native_session_id: Some("native-secret-id".into()),
3725                    acp_session_title: Some("Build Hel".into()),
3726                    session_title_override: None,
3727                    created_at: "now".into(),
3728                    updated_at: "now".into(),
3729                    viewed_through_event_ordinal: 0,
3730                    draft_input: String::new(),
3731                    last_error: Some("secret-token at /highly/secret/codex".into()),
3732                    last_checkpoint_error: None,
3733                    checkpoint: None,
3734                },
3735            )]),
3736            mount_history: BTreeMap::new(),
3737            container_sizes: BTreeMap::new(),
3738        };
3739        (config, state)
3740    }
3741
3742    type TestServer = (
3743        Router,
3744        mpsc::Receiver<ControllerRequest>,
3745        mpsc::Receiver<ReadReceiptRequest>,
3746        mpsc::Receiver<PreflightRequest>,
3747        mpsc::Receiver<ClientStateRequest>,
3748    );
3749
3750    fn app() -> TestServer {
3751        app_with_conversations(BTreeMap::new())
3752    }
3753
3754    fn app_with_move_receiver() -> (Router, mpsc::Receiver<MovePreparationRequest>) {
3755        let (config, state) = sample_config_state();
3756        let mut snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
3757        snapshot.sessions[0].capabilities.move_session = true;
3758        let (_snapshot_tx, snapshot_rx) = watch::channel(snapshot);
3759        let (_conversation_tx, conversation_rx) = watch::channel(BTreeMap::new());
3760        let (action_tx, _action_rx) = mpsc::channel(8);
3761        let (bundle_tx, _bundle_rx) = mpsc::channel(8);
3762        let (receipt_tx, _receipt_rx) = mpsc::channel(8);
3763        let (preflight_tx, _preflight_rx) = mpsc::channel(8);
3764        let (move_preparation_tx, move_preparation_rx) = mpsc::channel(8);
3765        let (client_state_tx, _client_state_rx) = mpsc::channel(8);
3766        let options = test_options(
3767            snapshot_rx,
3768            conversation_rx,
3769            action_tx,
3770            bundle_tx,
3771            receipt_tx,
3772            preflight_tx,
3773            move_preparation_tx,
3774            client_state_tx,
3775        )
3776        .with_test_credentials("123456", b"01234567890123456789012345678901");
3777        (router(options), move_preparation_rx)
3778    }
3779
3780    fn app_with_conversations(conversations: BTreeMap<String, BrowserTranscript>) -> TestServer {
3781        app_with(conversations, |_| {})
3782    }
3783
3784    fn app_with_snapshot(adjust: impl FnOnce(&mut ViewerSnapshot)) -> TestServer {
3785        app_with(BTreeMap::new(), adjust)
3786    }
3787
3788    fn app_with(
3789        conversations: BTreeMap<String, BrowserTranscript>,
3790        adjust: impl FnOnce(&mut ViewerSnapshot),
3791    ) -> TestServer {
3792        let (config, state) = sample_config_state();
3793        let mut snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
3794        adjust(&mut snapshot);
3795        let (_snapshot_tx, snapshot_rx) = watch::channel(snapshot);
3796        let (_conversation_tx, conversation_rx) = watch::channel(conversations);
3797        let (action_tx, action_rx) = mpsc::channel(8);
3798        let (bundle_tx, _bundle_rx) = mpsc::channel(8);
3799        let (receipt_tx, receipt_rx) = mpsc::channel(8);
3800        let (preflight_tx, preflight_rx) = mpsc::channel(8);
3801        let (move_preparation_tx, _move_preparation_rx) = mpsc::channel(8);
3802        let (client_state_tx, client_state_rx) = mpsc::channel(8);
3803        let options = test_options(
3804            snapshot_rx,
3805            conversation_rx,
3806            action_tx,
3807            bundle_tx,
3808            receipt_tx,
3809            preflight_tx,
3810            move_preparation_tx,
3811            client_state_tx,
3812        )
3813        .with_test_credentials("123456", b"01234567890123456789012345678901");
3814        (
3815            router(options),
3816            action_rx,
3817            receipt_rx,
3818            preflight_rx,
3819            client_state_rx,
3820        )
3821    }
3822
3823    fn app_with_bundle_receiver() -> (Router, mpsc::Receiver<BundleRequest>) {
3824        let (config, state) = sample_config_state();
3825        let (_snapshot_tx, snapshot_rx) =
3826            watch::channel(ViewerSnapshot::from_config_state(&config, &state, 1));
3827        let (_conversation_tx, conversation_rx) = watch::channel(BTreeMap::new());
3828        let (action_tx, _action_rx) = mpsc::channel(8);
3829        let (bundle_tx, bundle_rx) = mpsc::channel(8);
3830        let (receipt_tx, _receipt_rx) = mpsc::channel(8);
3831        let (preflight_tx, _preflight_rx) = mpsc::channel(8);
3832        let (move_preparation_tx, _move_preparation_rx) = mpsc::channel(8);
3833        let (client_state_tx, _client_state_rx) = mpsc::channel(8);
3834        let options = test_options(
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        .with_test_credentials("123456", b"01234567890123456789012345678901");
3845        (router(options), bundle_rx)
3846    }
3847
3848    // Keep this test factory's arguments aligned with `ServerRequests`; each
3849    // channel is asserted independently by the HTTP behavior tests below.
3850    #[allow(clippy::too_many_arguments)]
3851    fn test_options(
3852        snapshot_rx: watch::Receiver<ViewerSnapshot>,
3853        conversation_rx: watch::Receiver<BTreeMap<String, BrowserTranscript>>,
3854        action_tx: mpsc::Sender<ControllerRequest>,
3855        bundle_tx: mpsc::Sender<BundleRequest>,
3856        receipt_tx: mpsc::Sender<ReadReceiptRequest>,
3857        preflight_tx: mpsc::Sender<PreflightRequest>,
3858        move_preparation_tx: mpsc::Sender<MovePreparationRequest>,
3859        client_state_tx: mpsc::Sender<ClientStateRequest>,
3860    ) -> ServerOptions {
3861        test_options_with_dictation(
3862            snapshot_rx,
3863            conversation_rx,
3864            action_tx,
3865            bundle_tx,
3866            receipt_tx,
3867            preflight_tx,
3868            move_preparation_tx,
3869            client_state_tx,
3870        )
3871        .0
3872    }
3873
3874    #[allow(clippy::too_many_arguments)]
3875    fn test_options_with_dictation(
3876        snapshot_rx: watch::Receiver<ViewerSnapshot>,
3877        conversation_rx: watch::Receiver<BTreeMap<String, BrowserTranscript>>,
3878        action_tx: mpsc::Sender<ControllerRequest>,
3879        bundle_tx: mpsc::Sender<BundleRequest>,
3880        receipt_tx: mpsc::Sender<ReadReceiptRequest>,
3881        preflight_tx: mpsc::Sender<PreflightRequest>,
3882        move_preparation_tx: mpsc::Sender<MovePreparationRequest>,
3883        client_state_tx: mpsc::Sender<ClientStateRequest>,
3884    ) -> (ServerOptions, mpsc::Receiver<DictationRequest>) {
3885        let (dictation_tx, dictation_rx) = mpsc::channel(8);
3886        let options = ServerOptions::new(
3887            "127.0.0.1:0".parse().unwrap(),
3888            snapshot_rx,
3889            conversation_rx,
3890            ServerRequests {
3891                action_tx,
3892                bundle_tx,
3893                receipt_tx,
3894                preflight_tx,
3895                move_preparation_tx,
3896                client_state_tx,
3897                dictation_tx,
3898            },
3899        )
3900        .unwrap();
3901        (options, dictation_rx)
3902    }
3903
3904    fn app_with_dictation_receiver() -> (Router, mpsc::Receiver<DictationRequest>) {
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(8);
3910        let (bundle_tx, _bundle_rx) = mpsc::channel(8);
3911        let (receipt_tx, _receipt_rx) = mpsc::channel(8);
3912        let (preflight_tx, _preflight_rx) = mpsc::channel(8);
3913        let (move_preparation_tx, _move_preparation_rx) = mpsc::channel(8);
3914        let (client_state_tx, _client_state_rx) = mpsc::channel(8);
3915        let (options, dictation_rx) = test_options_with_dictation(
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            router(options.with_test_credentials("123456", b"01234567890123456789012345678901")),
3927            dictation_rx,
3928        )
3929    }
3930
3931    fn detached_options() -> ServerOptions {
3932        let (config, state) = sample_config_state();
3933        let (_snapshot_tx, snapshot_rx) =
3934            watch::channel(ViewerSnapshot::from_config_state(&config, &state, 1));
3935        let (_conversation_tx, conversation_rx) = watch::channel(BTreeMap::new());
3936        let (action_tx, _action_rx) = mpsc::channel(1);
3937        let (bundle_tx, _bundle_rx) = mpsc::channel(1);
3938        let (receipt_tx, _receipt_rx) = mpsc::channel(1);
3939        let (preflight_tx, _preflight_rx) = mpsc::channel(1);
3940        let (move_preparation_tx, _move_preparation_rx) = mpsc::channel(1);
3941        let (client_state_tx, _client_state_rx) = mpsc::channel(1);
3942        test_options(
3943            snapshot_rx,
3944            conversation_rx,
3945            action_tx,
3946            bundle_tx,
3947            receipt_tx,
3948            preflight_tx,
3949            move_preparation_tx,
3950            client_state_tx,
3951        )
3952    }
3953
3954    /// A valid session cookie for the test server's key.
3955    ///
3956    /// Most checks are about what an authenticated request does rather than
3957    /// about how it authenticated, and going through the login route for each
3958    /// one buys nothing.
3959    fn cookie() -> String {
3960        format!(
3961            "{COOKIE_NAME}={}",
3962            signed_cookie_value(
3963                b"01234567890123456789012345678901",
3964                "test-viewer",
3965                now_unix().saturating_add(3600)
3966            )
3967        )
3968    }
3969
3970    fn valid_wav() -> Bytes {
3971        let samples = vec![0_u8; 320];
3972        let mut wav = Vec::with_capacity(44 + samples.len());
3973        wav.extend_from_slice(b"RIFF");
3974        wav.extend_from_slice(&(36_u32 + samples.len() as u32).to_le_bytes());
3975        wav.extend_from_slice(b"WAVEfmt ");
3976        wav.extend_from_slice(&16_u32.to_le_bytes());
3977        wav.extend_from_slice(&1_u16.to_le_bytes());
3978        wav.extend_from_slice(&1_u16.to_le_bytes());
3979        wav.extend_from_slice(&16_000_u32.to_le_bytes());
3980        wav.extend_from_slice(&32_000_u32.to_le_bytes());
3981        wav.extend_from_slice(&2_u16.to_le_bytes());
3982        wav.extend_from_slice(&16_u16.to_le_bytes());
3983        wav.extend_from_slice(b"data");
3984        wav.extend_from_slice(&(samples.len() as u32).to_le_bytes());
3985        wav.extend_from_slice(&samples);
3986        Bytes::from(wav)
3987    }
3988
3989    async fn login_cookie(app: &Router) -> String {
3990        let response = app
3991            .clone()
3992            .oneshot(
3993                Request::post("/auth/session")
3994                    .header(CONTENT_TYPE, "application/json")
3995                    .body(Body::from(r#"{"code":"123456"}"#))
3996                    .unwrap(),
3997            )
3998            .await
3999            .unwrap();
4000        assert_eq!(response.status(), StatusCode::NO_CONTENT);
4001        response
4002            .headers()
4003            .get(SET_COOKIE)
4004            .unwrap()
4005            .to_str()
4006            .unwrap()
4007            .split(';')
4008            .next()
4009            .unwrap()
4010            .to_string()
4011    }
4012
4013    #[tokio::test]
4014    async fn dictation_availability_requires_auth_and_forwards_typed_request() {
4015        let (app, mut requests) = app_with_dictation_receiver();
4016        let unauthorized = app
4017            .clone()
4018            .oneshot(
4019                Request::get("/api/sessions/session-1/dictation")
4020                    .body(Body::empty())
4021                    .unwrap(),
4022            )
4023            .await
4024            .unwrap();
4025        assert_eq!(unauthorized.status(), StatusCode::UNAUTHORIZED);
4026        assert!(requests.try_recv().is_err());
4027
4028        let cookie = login_cookie(&app).await;
4029        let pending = tokio::spawn({
4030            let app = app.clone();
4031            async move {
4032                app.oneshot(
4033                    Request::get("/api/sessions/session-1/dictation")
4034                        .header(COOKIE, cookie)
4035                        .body(Body::empty())
4036                        .unwrap(),
4037                )
4038                .await
4039                .unwrap()
4040            }
4041        });
4042        let request = requests.recv().await.unwrap();
4043        assert_eq!(request.session_id, "session-1");
4044        assert!(matches!(
4045            request.operation,
4046            DictationOperation::Availability
4047        ));
4048        request
4049            .reply
4050            .send(Ok(DictationResponse::Availability {
4051                available: true,
4052                reason: None,
4053            }))
4054            .unwrap();
4055        let response = pending.await.unwrap();
4056        assert_eq!(response.status(), StatusCode::OK);
4057        let body = response.into_body().collect().await.unwrap().to_bytes();
4058        assert_eq!(&body[..], br#"{"available":true}"#);
4059    }
4060
4061    #[tokio::test]
4062    async fn dictation_rejects_bad_wav_before_controller_dispatch() {
4063        let (app, mut requests) = app_with_dictation_receiver();
4064        let cookie = login_cookie(&app).await;
4065        let response = app
4066            .oneshot(
4067                Request::post("/api/sessions/session-1/dictation")
4068                    .header(COOKIE, cookie)
4069                    .header(CONTENT_TYPE, "audio/wav")
4070                    .body(Body::from("not wav"))
4071                    .unwrap(),
4072            )
4073            .await
4074            .unwrap();
4075        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
4076        assert!(requests.try_recv().is_err());
4077    }
4078
4079    #[tokio::test]
4080    async fn dictation_rejects_a_third_upload_before_reading_its_body() {
4081        let (app, mut requests) = app_with_dictation_receiver();
4082        let cookie = login_cookie(&app).await;
4083        let request = || {
4084            Request::post("/api/sessions/session-1/dictation")
4085                .header(COOKIE, cookie.clone())
4086                .header(CONTENT_TYPE, "audio/wav")
4087                .body(Body::from(valid_wav()))
4088                .unwrap()
4089        };
4090        let first = tokio::spawn({
4091            let app = app.clone();
4092            let request = request();
4093            async move { app.oneshot(request).await.unwrap() }
4094        });
4095        let second = tokio::spawn({
4096            let app = app.clone();
4097            let request = request();
4098            async move { app.oneshot(request).await.unwrap() }
4099        });
4100        let first_request = requests.recv().await.unwrap();
4101        let second_request = requests.recv().await.unwrap();
4102        let third = app
4103            .oneshot(
4104                Request::post("/api/sessions/session-1/dictation")
4105                    .header(COOKIE, cookie)
4106                    .header(CONTENT_TYPE, "audio/wav")
4107                    .body(Body::from_stream(futures::stream::poll_fn(
4108                        |_| -> std::task::Poll<Option<Result<Bytes, std::io::Error>>> {
4109                            panic!("overloaded dictation polled its body")
4110                        },
4111                    )))
4112                    .unwrap(),
4113            )
4114            .await
4115            .unwrap();
4116        assert_eq!(third.status(), StatusCode::TOO_MANY_REQUESTS);
4117        first_request
4118            .reply
4119            .send(Ok(DictationResponse::Transcript {
4120                text: "first".into(),
4121            }))
4122            .unwrap();
4123        second_request
4124            .reply
4125            .send(Ok(DictationResponse::Transcript {
4126                text: "second".into(),
4127            }))
4128            .unwrap();
4129        assert_eq!(first.await.unwrap().status(), StatusCode::OK);
4130        assert_eq!(second.await.unwrap().status(), StatusCode::OK);
4131    }
4132
4133    #[tokio::test]
4134    async fn dictation_upload_rejects_unauthorized_missing_and_oversized_requests() {
4135        let (app, mut requests) = app_with_dictation_receiver();
4136        let response = app
4137            .clone()
4138            .oneshot(
4139                Request::post("/api/sessions/session-1/dictation")
4140                    .body(Body::from(valid_wav()))
4141                    .unwrap(),
4142            )
4143            .await
4144            .unwrap();
4145        assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
4146        let cookie = login_cookie(&app).await;
4147        let response = app
4148            .clone()
4149            .oneshot(
4150                Request::post("/api/sessions/missing/dictation")
4151                    .header(COOKIE, &cookie)
4152                    .body(Body::from(valid_wav()))
4153                    .unwrap(),
4154            )
4155            .await
4156            .unwrap();
4157        assert_eq!(response.status(), StatusCode::NOT_FOUND);
4158        // Exercise the streamed body limit without relying on Content-Length.
4159        let response = app
4160            .oneshot(
4161                Request::post("/api/sessions/session-1/dictation")
4162                    .header(COOKIE, cookie)
4163                    .body(Body::from(vec![0_u8; MAX_AUDIO_BYTES + 1]))
4164                    .unwrap(),
4165            )
4166            .await
4167            .unwrap();
4168        assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE);
4169        assert!(requests.try_recv().is_err());
4170    }
4171
4172    #[tokio::test]
4173    async fn dictation_provider_failure_is_actionable_and_does_not_expose_details() {
4174        let (app, mut requests) = app_with_dictation_receiver();
4175        let cookie = login_cookie(&app).await;
4176        let pending = tokio::spawn(async move {
4177            app.oneshot(
4178                Request::post("/api/sessions/session-1/dictation")
4179                    .header(COOKIE, cookie)
4180                    .body(Body::from(valid_wav()))
4181                    .unwrap(),
4182            )
4183            .await
4184            .unwrap()
4185        });
4186        let request = requests.recv().await.unwrap();
4187        request
4188            .reply
4189            .send(Err(DictationError::Provider(
4190                "private provider details".into(),
4191            )))
4192            .unwrap();
4193        let response = pending.await.unwrap();
4194        assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
4195        let bytes = response.into_body().collect().await.unwrap().to_bytes();
4196        let body = String::from_utf8(bytes.to_vec()).unwrap();
4197        assert!(body.contains("transcription"));
4198        assert!(!body.contains("private provider details"));
4199    }
4200
4201    #[tokio::test]
4202    async fn dropped_dictation_handler_cancels_controller_request() {
4203        let (app, mut requests) = app_with_dictation_receiver();
4204        let cookie = login_cookie(&app).await;
4205        let pending = tokio::spawn({
4206            let app = app.clone();
4207            async move {
4208                app.oneshot(
4209                    Request::post("/api/sessions/session-1/dictation")
4210                        .header(COOKIE, cookie)
4211                        .body(Body::from(valid_wav()))
4212                        .unwrap(),
4213                )
4214                .await
4215                .unwrap()
4216            }
4217        });
4218        let request = requests.recv().await.unwrap();
4219        let cancel = request.cancel.clone();
4220        pending.abort();
4221        let _ = pending.await;
4222        assert!(cancel.is_cancelled());
4223        drop(request);
4224    }
4225
4226    #[tokio::test]
4227    async fn bundle_endpoint_authenticates_and_forwards_the_source() {
4228        let (app, mut bundles) = app_with_bundle_receiver();
4229        let unauthorized = app
4230            .clone()
4231            .oneshot(
4232                Request::post("/api/bundles")
4233                    .header(CONTENT_TYPE, "application/json")
4234                    .body(Body::from(r#"{"source":"example/app"}"#))
4235                    .unwrap(),
4236            )
4237            .await
4238            .unwrap();
4239        assert_eq!(unauthorized.status(), StatusCode::UNAUTHORIZED);
4240        assert!(bundles.try_recv().is_err());
4241
4242        let cookie = login_cookie(&app).await;
4243        let response = tokio::spawn({
4244            let app = app.clone();
4245            let cookie = cookie.clone();
4246            async move {
4247                app.oneshot(
4248                    Request::post("/api/bundles")
4249                        .header(CONTENT_TYPE, "application/json")
4250                        .header(COOKIE, cookie)
4251                        .body(Body::from(r#"{"source":"example/app"}"#))
4252                        .unwrap(),
4253                )
4254                .await
4255                .unwrap()
4256            }
4257        });
4258        let request = bundles.recv().await.expect("bundle request forwarded");
4259        assert_eq!(request.source, "example/app");
4260        request.reply.send(Ok("app".into())).unwrap();
4261        let response = response.await.unwrap();
4262        assert_eq!(response.status(), StatusCode::OK);
4263        let body = response.into_body().collect().await.unwrap().to_bytes();
4264        assert_eq!(body.as_ref(), br#"{"bundle_id":"app"}"#);
4265    }
4266
4267    #[tokio::test]
4268    async fn bundle_endpoint_rejects_empty_and_oversized_sources_before_dispatch() {
4269        for source in [String::new(), "x".repeat(MAX_BUNDLE_SOURCE_CHARS + 1)] {
4270            let (app, mut bundles) = app_with_bundle_receiver();
4271            let cookie = login_cookie(&app).await;
4272            let response = app
4273                .oneshot(
4274                    Request::post("/api/bundles")
4275                        .header(CONTENT_TYPE, "application/json")
4276                        .header(COOKIE, cookie)
4277                        .body(Body::from(
4278                            serde_json::to_string(&serde_json::json!({"source": source})).unwrap(),
4279                        ))
4280                        .unwrap(),
4281                )
4282                .await
4283                .unwrap();
4284            assert_eq!(response.status(), StatusCode::BAD_REQUEST);
4285            assert!(bundles.try_recv().is_err());
4286        }
4287    }
4288
4289    #[tokio::test]
4290    async fn bundle_endpoint_reports_invalid_source_as_a_client_error() {
4291        let (app, mut bundles) = app_with_bundle_receiver();
4292        let cookie = login_cookie(&app).await;
4293        let response = tokio::spawn({
4294            let app = app.clone();
4295            async move {
4296                app.oneshot(
4297                    Request::post("/api/bundles")
4298                        .header(CONTENT_TYPE, "application/json")
4299                        .header(COOKIE, cookie)
4300                        .body(Body::from(r#"{"source":"not a source"}"#))
4301                        .unwrap(),
4302                )
4303                .await
4304                .unwrap()
4305            }
4306        });
4307        let request = bundles.recv().await.expect("bundle request forwarded");
4308        request
4309            .reply
4310            .send(Err(BundleFailure::InvalidSource))
4311            .unwrap();
4312        let response = response.await.unwrap();
4313        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
4314        let body = response.into_body().collect().await.unwrap().to_bytes();
4315        assert!(String::from_utf8_lossy(&body).contains("GitHub owner/repository"));
4316    }
4317
4318    #[tokio::test]
4319    async fn api_requires_a_valid_signed_cookie() {
4320        let (app, _, _, _, _) = app();
4321        let unauthorized = app
4322            .clone()
4323            .oneshot(Request::get("/api/snapshot").body(Body::empty()).unwrap())
4324            .await
4325            .unwrap();
4326        assert_eq!(unauthorized.status(), StatusCode::UNAUTHORIZED);
4327
4328        let cookie = login_cookie(&app).await;
4329        let authorized = app
4330            .oneshot(
4331                Request::get("/api/snapshot")
4332                    .header(COOKIE, cookie)
4333                    .body(Body::empty())
4334                    .unwrap(),
4335            )
4336            .await
4337            .unwrap();
4338        assert_eq!(authorized.status(), StatusCode::OK);
4339    }
4340
4341    #[tokio::test]
4342    async fn qr_login_exchanges_the_secret_for_a_cookie_and_redirects_cleanly() {
4343        let (app, _, _, _, _) = app();
4344        let rejected = app
4345            .clone()
4346            .oneshot(
4347                Request::get("/auth/login?token=wrong")
4348                    .body(Body::empty())
4349                    .unwrap(),
4350            )
4351            .await
4352            .unwrap();
4353        assert_eq!(rejected.status(), StatusCode::UNAUTHORIZED);
4354
4355        let accepted = app
4356            .oneshot(
4357                Request::get("/auth/login?token=test-login-token")
4358                    .body(Body::empty())
4359                    .unwrap(),
4360            )
4361            .await
4362            .unwrap();
4363        assert_eq!(accepted.status(), StatusCode::SEE_OTHER);
4364        assert_eq!(accepted.headers().get(LOCATION).unwrap(), "/");
4365        assert_eq!(accepted.headers().get(CACHE_CONTROL).unwrap(), "no-store");
4366        assert!(accepted.headers().contains_key(SET_COOKIE));
4367    }
4368
4369    #[test]
4370    fn signed_cookie_rejects_expiry_and_tampering() {
4371        let key = b"01234567890123456789012345678901";
4372        let cookie = signed_cookie_value(key, "test-viewer", 200);
4373        assert!(session_cookie_valid(key, &cookie, 100));
4374        assert!(!session_cookie_valid(key, &cookie, 200));
4375        assert!(!session_cookie_valid(key, &format!("{cookie}x"), 100));
4376        assert!(!session_cookie_valid(b"another-key", &cookie, 100));
4377    }
4378
4379    #[test]
4380    fn generated_code_and_cookie_attributes_are_phone_safe() {
4381        let code = generate_viewer_code().unwrap();
4382        assert_eq!(code.len(), 6);
4383        assert!(code.bytes().all(|byte| byte.is_ascii_digit()));
4384        let header = session_cookie_header("signed", Some(60), true)
4385            .unwrap()
4386            .to_str()
4387            .unwrap()
4388            .to_string();
4389        assert!(header.contains("HttpOnly"));
4390        assert!(header.contains("SameSite=Strict"));
4391        assert!(header.contains("Secure"));
4392        assert!(header.contains("Max-Age=60"));
4393    }
4394
4395    #[test]
4396    fn public_snapshot_omits_homes_environment_locators_and_raw_errors() {
4397        let (config, state) = sample_config_state();
4398        let json =
4399            serde_json::to_string(&ViewerSnapshot::from_config_state(&config, &state, 9)).unwrap();
4400        assert!(!json.contains("/highly/secret"));
4401        assert!(!json.contains("secret-token"));
4402        assert!(!json.contains("secret-target"));
4403        assert!(!json.contains("secret.registry"));
4404        assert!(!json.contains("native-secret-id"));
4405        assert!(json.contains("\"has_error\":true"));
4406    }
4407
4408    #[test]
4409    fn target_snapshot_uses_each_raw_host_project_history_and_leaves_managed_empty() {
4410        let (mut config, mut state) = sample_config_state();
4411        config
4412            .targets
4413            .insert("raw-local".into(), TargetTemplate::LocalBare);
4414        config.targets.insert(
4415            "raw-builder".into(),
4416            TargetTemplate::SshBare {
4417                ssh: SshConnection {
4418                    host: "builder-a".into(),
4419                    user: None,
4420                    identity_file: None,
4421                    extra_args: Vec::new(),
4422                },
4423                permissions: PermissionMode::Guardian,
4424                workspace_prefix: "workspaces".into(),
4425            },
4426        );
4427        state.remember_project_directory("local", Path::new("/work/local"));
4428        state.remember_project_directory("builder-a", Path::new("/srv/builder"));
4429        state.remember_project_directory("other-host", Path::new("/not-published"));
4430
4431        let snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
4432        let target = |id: &str| {
4433            snapshot
4434                .targets
4435                .iter()
4436                .find(|target| target.id == id)
4437                .unwrap()
4438        };
4439        assert_eq!(
4440            target("raw-local").recent_project_directories,
4441            vec!["/work/local"]
4442        );
4443        assert_eq!(
4444            target("raw-builder").recent_project_directories,
4445            vec!["/srv/builder"]
4446        );
4447        assert!(target("podman").recent_project_directories.is_empty());
4448    }
4449
4450    #[test]
4451    fn public_snapshot_exposes_only_review_status_configuration() {
4452        let (mut config, state) = sample_config_state();
4453        config.review = hel::hel_config::ReviewConfig {
4454            enabled: true,
4455            tier: hel::hel_review::lanes::ReviewTier::Extended,
4456            profile: Some("reviewer-1".into()),
4457            model: Some("private-review-model".into()),
4458            effort: Some("private-review-effort".into()),
4459        };
4460
4461        let value =
4462            serde_json::to_value(ViewerSnapshot::from_config_state(&config, &state, 9)).unwrap();
4463
4464        assert_eq!(
4465            value.get("review_config"),
4466            Some(&serde_json::json!({
4467                "enabled": true,
4468                "tier": "extended",
4469                "profile": "reviewer-1",
4470            }))
4471        );
4472        let json = value.to_string();
4473        assert!(!json.contains("private-review-model"));
4474        assert!(!json.contains("private-review-effort"));
4475    }
4476
4477    fn sample_elicitation() -> ElicitationRequest {
4478        ElicitationRequest::from_acp_params(
4479            "elicitation-1",
4480            serde_json::json!({
4481                "sessionId": "session-1",
4482                "mode": "form",
4483                "message": "Which CI architecture should the workflow use?",
4484                "requestedSchema": {
4485                    "type": "object",
4486                    "required": ["question_0"],
4487                    "properties": {
4488                        "question_0": {
4489                            "type": "string",
4490                            "title": "CI architecture",
4491                            "oneOf": [
4492                                {"const": "reusable", "title": "Reusable workflow"},
4493                                {"const": "matrix", "title": "Matrix job"}
4494                            ]
4495                        },
4496                        "question_0_custom": {
4497                            "type": "string",
4498                            "title": "Other",
4499                            "_meta": {"_askUserQuestionCustomAnswer": {
4500                                "questionId": "question_0",
4501                                "isCustomAnswer": true
4502                            }}
4503                        }
4504                    }
4505                }
4506            }),
4507        )
4508        .expect("sample elicitation parses")
4509    }
4510
4511    fn accept(pairs: &[(&str, &str)]) -> ElicitationResponse {
4512        ElicitationResponse::Accept {
4513            content: pairs
4514                .iter()
4515                .map(|(id, value)| {
4516                    (
4517                        (*id).to_owned(),
4518                        hel::hel_elicitation::ElicitationValue::String((*value).to_owned()),
4519                    )
4520                })
4521                .collect(),
4522        }
4523    }
4524
4525    fn pending_elicitation_snapshot(snapshot: &mut ViewerSnapshot) {
4526        snapshot.sessions[0].pending_elicitations = vec![sample_elicitation()];
4527    }
4528
4529    #[tokio::test]
4530    async fn elicitation_answer_is_typed_and_forwarded() {
4531        let (app, mut actions, _, _, _) = app_with_snapshot(pending_elicitation_snapshot);
4532        let cookie = login_cookie(&app).await;
4533        let response = tokio::spawn(
4534            app.oneshot(
4535                Request::post("/api/actions")
4536                    .header(COOKIE, cookie)
4537                    .header(CONTENT_TYPE, "application/json")
4538                    .body(Body::from(
4539                        r#"{"action":"respond-elicitation","session_id":"session-1","elicitation_id":"elicitation-1","response":{"action":"accept","content":{"question_0":"reusable"}}}"#,
4540                    ))
4541                    .unwrap(),
4542            ),
4543        );
4544        let action = actions.recv().await.unwrap();
4545        assert_eq!(
4546            action.action,
4547            ControllerAction::RespondElicitation {
4548                session_id: "session-1".into(),
4549                elicitation_id: "elicitation-1".into(),
4550                response: accept(&[("question_0", "reusable")]),
4551            }
4552        );
4553        action.reply.send(ActionOutcome::Accepted).unwrap();
4554        assert_eq!(
4555            response.await.unwrap().unwrap().status(),
4556            StatusCode::ACCEPTED
4557        );
4558    }
4559
4560    #[tokio::test]
4561    async fn elicitation_answer_for_an_unknown_request_is_refused_without_reaching_the_controller()
4562    {
4563        let (app, mut actions, _, _, _) = app_with_snapshot(pending_elicitation_snapshot);
4564        let cookie = login_cookie(&app).await;
4565        let response = app
4566            .oneshot(
4567                Request::post("/api/actions")
4568                    .header(COOKIE, cookie)
4569                    .header(CONTENT_TYPE, "application/json")
4570                    .body(Body::from(
4571                        r#"{"action":"respond-elicitation","session_id":"session-1","elicitation_id":"elicitation-9","response":{"action":"cancel"}}"#,
4572                    ))
4573                    .unwrap(),
4574            )
4575            .await
4576            .unwrap();
4577        assert_eq!(response.status(), StatusCode::NOT_FOUND);
4578        assert!(actions.try_recv().is_err());
4579    }
4580
4581    #[test]
4582    fn elicitation_answers_are_checked_against_the_request_the_agent_asked() {
4583        let (config, state) = sample_config_state();
4584        let mut snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
4585        pending_elicitation_snapshot(&mut snapshot);
4586        let respond = |response: ElicitationResponse| ControllerAction::RespondElicitation {
4587            session_id: "session-1".into(),
4588            elicitation_id: "elicitation-1".into(),
4589            response,
4590        };
4591
4592        assert!(validate_action(&respond(accept(&[("question_0", "matrix")])), &snapshot).is_ok());
4593        // Declining and cancelling never carry content, so they are always
4594        // answerable.
4595        assert!(validate_action(&respond(ElicitationResponse::Decline), &snapshot).is_ok());
4596        // An option the agent never offered, a field it never published, and a
4597        // missing required answer are all refused.
4598        assert!(validate_action(&respond(accept(&[("question_0", "cron")])), &snapshot).is_err());
4599        assert!(validate_action(&respond(accept(&[("smuggled", "yes")])), &snapshot).is_err());
4600        assert!(validate_action(&respond(accept(&[])), &snapshot).is_err());
4601        // A custom answer stands in for the select it belongs to, exactly as
4602        // the chat form submits it.
4603        assert!(
4604            validate_action(
4605                &respond(accept(&[("question_0_custom", "a monorepo pipeline")])),
4606                &snapshot,
4607            )
4608            .is_ok()
4609        );
4610    }
4611
4612    #[test]
4613    fn oversized_elicitation_answers_are_refused() {
4614        let (config, state) = sample_config_state();
4615        let mut snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
4616        pending_elicitation_snapshot(&mut snapshot);
4617        let long = "x".repeat(MAX_ELICITATION_BYTES);
4618        assert!(
4619            validate_action(
4620                &ControllerAction::RespondElicitation {
4621                    session_id: "session-1".into(),
4622                    elicitation_id: "elicitation-1".into(),
4623                    response: accept(&[("question_0_custom", long.as_str())]),
4624                },
4625                &snapshot,
4626            )
4627            .is_err()
4628        );
4629    }
4630
4631    /// One slice of the browser application, named by the two markers that
4632    /// bracket it in `src/web/viewer.js`.
4633    ///
4634    /// Slicing keeps each check to the functions it is about, so an unrelated
4635    /// change elsewhere in the application cannot make it fail for the wrong
4636    /// reason. The markers are ordinary source text, so a rename that moves
4637    /// them fails loudly here rather than silently testing nothing.
4638    fn viewer_source(from: &str, to: &str) -> &'static str {
4639        let start = VIEWER_JS
4640            .find(from)
4641            .unwrap_or_else(|| panic!("src/web/viewer.js no longer contains {from:?}"));
4642        let end = VIEWER_JS[start..]
4643            .find(to)
4644            .map(|offset| start + offset)
4645            .unwrap_or_else(|| {
4646                panic!("src/web/viewer.js no longer contains {to:?} after {from:?}")
4647            });
4648        &VIEWER_JS[start..end]
4649    }
4650
4651    /// Run one JavaScript check under Node.
4652    ///
4653    /// The check and the modules it imports are written to a real directory
4654    /// rather than passed to `--eval`, so a failure reports a line number a
4655    /// person can open, and so a check can import the shipped module under
4656    /// test by its real name instead of against a copy pasted into a string.
4657    fn run_web_check(name: &str, check: &str) {
4658        let directory = tempfile::tempdir().expect("temporary directory for a web check");
4659        for (file, source) in [
4660            ("test-dom.js", TEST_DOM_JS),
4661            ("markdown.js", MARKDOWN_JS),
4662            ("tool-output.js", TOOL_OUTPUT_JS),
4663        ] {
4664            std::fs::write(directory.path().join(file), source).expect("write a web module");
4665        }
4666        let path = directory.path().join(format!("{name}.mjs"));
4667        std::fs::write(&path, check).expect("write the web check");
4668        let output = std::process::Command::new("node")
4669            .arg(&path)
4670            .output()
4671            .expect("Node.js is required to exercise the web viewer");
4672        assert!(
4673            output.status.success(),
4674            "{name} failed:\nstdout:\n{}\nstderr:\n{}",
4675            String::from_utf8_lossy(&output.stdout),
4676            String::from_utf8_lossy(&output.stderr),
4677        );
4678    }
4679
4680    /// Run one JavaScript check that supplies its own environment, for the
4681    /// checks that slice a function out of `viewer.js` and drive it against a
4682    /// hand-written stub rather than importing a module.
4683    fn run_viewer_script(name: &str, script: &str) {
4684        run_web_check(name, script);
4685    }
4686
4687    #[test]
4688    fn embedded_viewer_lists_current_workspace_histories_and_retained_move_recovery() {
4689        let source = viewer_source("function isResumeSession(", "const resumeDrafts =");
4690        let setup = r#"
4691const snapshot = {
4692  sessions: [
4693    { id: "history-a", workspace_id: "workspace-a", capabilities: { resume: true } },
4694    { id: "history-b", workspace_id: "workspace-b", capabilities: { resume: true } },
4695    { id: "running-a", workspace_id: "workspace-a", lifecycle: "live", has_error: true, capabilities: { resume: false, open: false } },
4696    { id: "move-a", workspace_id: "workspace-a", capabilities: { resume: false }, move_recovery: { checkpoint_retained: true, phase: "failed" } },
4697    { id: "moving-a", workspace_id: "workspace-a", capabilities: { resume: false }, move_recovery: { checkpoint_retained: true, phase: "starting_queue" } },
4698  ],
4699};
4700function selectedWorkspaceId() { return "workspace-a"; }
4701function sessionActivityMs() { return 0; }
4702function epochMs() { return null; }
4703"#;
4704        let checks = r#"
4705const ids = workspace => resumeSessions(workspace).map(session => session.id).sort();
4706if (JSON.stringify(ids("workspace-a")) !== JSON.stringify(["history-a", "move-a"])) {
4707  throw new Error(`workspace A histories or recoveries were wrong: ${JSON.stringify(ids("workspace-a"))}`);
4708}
4709if (JSON.stringify(ids("workspace-b")) !== JSON.stringify(["history-b"])) {
4710  throw new Error(`workspace B histories were wrong: ${JSON.stringify(ids("workspace-b"))}`);
4711}
4712if (ids("missing-workspace").length !== 0) throw new Error("unknown workspace exposed sessions");
4713"#;
4714        run_viewer_script(
4715            "workspace-resume-history",
4716            &format!("{setup}\n{source}\n{checks}"),
4717        );
4718    }
4719
4720    #[test]
4721    fn embedded_viewer_sends_the_selected_resume_workspace() {
4722        let source = viewer_source("async function runSessionAction", "sessions.onclick =");
4723        let setup = r#"
4724const pendingActions = new Set();
4725const snapshot = { sessions: [] };
4726let sent = null;
4727function selectedWorkspaceId() { return "workspace-b"; }
4728function navigate() {}
4729function renderRoute() {}
4730async function refresh() {}
4731async function request(path, options) {
4732  sent = { path, body: JSON.parse(options.body) };
4733}
4734"#;
4735        let checks = r#"
4736const errorNode = { textContent: "" };
4737await runSessionAction(
4738  { action: "resume", id: "history-a", profile: "codex-1", target: "podman" },
4739  errorNode,
4740  { queue: "start" },
4741);
4742if (sent.path !== "/api/actions" || sent.body.workspace_id !== "workspace-b") {
4743  throw new Error(`resume did not carry its destination: ${JSON.stringify(sent)}`);
4744}
4745"#;
4746        run_viewer_script(
4747            "resume-workspace-destination",
4748            &format!("{setup}\n{source}\n{checks}"),
4749        );
4750    }
4751
4752    #[test]
4753    fn embedded_viewer_warns_before_stopping_an_active_session() {
4754        let source = viewer_source("async function runSessionAction", "sessions.onclick =");
4755        let setup = r#"
4756const pendingActions = new Set();
4757const snapshot = {
4758  sessions: [
4759    { id: "active", chat_phase: "running" },
4760    { id: "idle", chat_phase: "idle" },
4761  ],
4762};
4763const questions = [];
4764function confirm(question) { questions.push(question); return false; }
4765function navigate() {}
4766"#;
4767        let checks = r#"
4768const errorNode = { textContent: "" };
4769await runSessionAction({ action: "close", id: "active" }, errorNode);
4770await runSessionAction({ action: "close", id: "idle" }, errorNode);
4771if (!questions[0].startsWith("Stop active session?\n\n")) {
4772  throw new Error(`active close warning was ${JSON.stringify(questions[0])}`);
4773}
4774if (!questions[0].includes("current turn will be interrupted")) {
4775  throw new Error(`active close omitted interruption: ${JSON.stringify(questions[0])}`);
4776}
4777if (!questions[1].startsWith("Stop session?\n\n")) {
4778  throw new Error(`idle close warning was ${JSON.stringify(questions[1])}`);
4779}
4780"#;
4781        run_viewer_script(
4782            "active-session-stop-confirmation",
4783            &format!("{setup}\n{source}\n{checks}"),
4784        );
4785    }
4786
4787    /// The projection publishes what the browser needs to group and filter
4788    /// without publishing what the redaction contract keeps back. A project
4789    /// key groups two sessions in one project together and says nothing about
4790    /// where that project lives.
4791    #[test]
4792    fn the_project_key_groups_without_naming_a_path() {
4793        let (config, mut state) = sample_config_state();
4794        let first = state.sessions["session-1"].clone();
4795        let mut second = first.clone();
4796        second.id = "session-2".into();
4797        state.sessions.insert(second.id.clone(), second);
4798        let snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
4799
4800        let keys = snapshot
4801            .sessions
4802            .iter()
4803            .map(|session| session.project_key.as_str())
4804            .collect::<std::collections::BTreeSet<_>>();
4805        assert_eq!(keys.len(), 1, "two sessions in one project did not group");
4806        let key = keys.into_iter().next().expect("one key");
4807        assert!(!key.is_empty(), "the project key is empty");
4808        assert!(
4809            !key.contains('/') && !key.contains("hel"),
4810            "the project key leaks its identity: {key}"
4811        );
4812        assert_eq!(
4813            snapshot.sessions[0].project_label, "hel",
4814            "the project label should be a name a person recognises"
4815        );
4816    }
4817
4818    #[test]
4819    fn web_project_keys_follow_the_complete_repository_set() {
4820        let (mut config, mut state) = sample_config_state();
4821        let shared_bundle = config.bundles["hel"].clone();
4822        config.bundles.insert("other".into(), shared_bundle);
4823
4824        let mut other = state.sessions["session-1"].clone();
4825        other.id = "session-2".into();
4826        other.bundle_id = "other".into();
4827        state.sessions.insert(other.id.clone(), other);
4828
4829        assert_eq!(
4830            config.bundles["hel"].primary_repo,
4831            config.bundles["other"].primary_repo
4832        );
4833        let snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
4834        let first = snapshot
4835            .sessions
4836            .iter()
4837            .find(|session| session.id == "session-1")
4838            .expect("first session");
4839        let second = snapshot
4840            .sessions
4841            .iter()
4842            .find(|session| session.id == "session-2")
4843            .expect("second session");
4844
4845        assert_eq!(first.project_label, "hel");
4846        assert_eq!(second.project_label, "hel");
4847        assert_eq!(first.project_key, second.project_key);
4848
4849        let secondary = ProjectRepository {
4850            id: "secondary".into(),
4851            github: Some("owner/secondary".into()),
4852            local: None,
4853            destination: "secondary".into(),
4854            git_ref: None,
4855        };
4856        config
4857            .bundles
4858            .get_mut("other")
4859            .unwrap()
4860            .repositories
4861            .push(secondary.clone());
4862        let project_keys = |config: &HelConfig| {
4863            ViewerSnapshot::from_config_state(config, &state, 1)
4864                .sessions
4865                .into_iter()
4866                .map(|session| session.project_key)
4867                .collect::<Vec<_>>()
4868        };
4869        let keys = project_keys(&config);
4870        assert_ne!(
4871            keys[0], keys[1],
4872            "an added repository must change the bundle identity"
4873        );
4874
4875        let first_bundle = config.bundles.get_mut("hel").unwrap();
4876        first_bundle.repositories.insert(0, secondary);
4877        first_bundle.primary_repo = "secondary".into();
4878        let keys = project_keys(&config);
4879        assert_eq!(
4880            keys[0], keys[1],
4881            "the same repository set must group together despite order or primary choice"
4882        );
4883    }
4884
4885    #[test]
4886    fn viewer_session_applies_a_resolved_source_without_publishing_it() {
4887        let (config, state) = sample_config_state();
4888        let mut viewer = ViewerSnapshot::from_config_state(&config, &state, 1)
4889            .sessions
4890            .into_iter()
4891            .next()
4892            .expect("session");
4893        let source = ProjectSourceIdentity::git_remote("git@github.com:BrokkAi/bifrost-dev.git")
4894            .expect("GitHub source");
4895
4896        viewer.set_project_source(&source);
4897
4898        assert_eq!(viewer.project_label, "bifrost-dev");
4899        assert_eq!(viewer.project_key, project_key(&source.key));
4900        let json = serde_json::to_string(&viewer).expect("serialize viewer session");
4901        assert!(!json.contains("BrokkAi"));
4902        assert!(!json.contains("github.com"));
4903    }
4904
4905    /// A phone groups and filters by the lifecycle category, so the mapping
4906    /// from the controller's precise state has to be the controller's own.
4907    #[test]
4908    fn lifecycle_categories_decide_what_the_dashboard_shows() {
4909        use ViewerLifecycleCategory::{Failed, Live, Starting, Stopped, Stopping};
4910
4911        for (state, expected, on_dashboard) in [
4912            (SessionState::Provisioning, Starting, true),
4913            (SessionState::Running, Live, true),
4914            (SessionState::Disconnected, Live, true),
4915            (SessionState::Checkpointing, Live, true),
4916            (SessionState::Closing, Stopping, true),
4917            (SessionState::Destroying, Stopping, true),
4918            (SessionState::Stopped, Stopped, false),
4919            (SessionState::Lost, Failed, false),
4920            (SessionState::Error, Failed, false),
4921            (SessionState::DestroyedWithDataLoss, Failed, false),
4922        ] {
4923            let category = ViewerLifecycleCategory::of(state);
4924            assert_eq!(category, expected, "{state:?}");
4925            assert_eq!(
4926                category.is_dashboard_visible(),
4927                on_dashboard,
4928                "{state:?} belongs on the dashboard? "
4929            );
4930        }
4931    }
4932
4933    /// Resume compatibility travels as the set the browser can offer, so it
4934    /// never has to subtract one list from another and never offers a target
4935    /// the controller would refuse.
4936    #[test]
4937    fn compatible_resume_targets_are_the_complement_of_the_incompatible_ones() {
4938        let (config, state) = sample_config_state();
4939        let snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
4940        let session = &snapshot.sessions[0];
4941        let all = config.targets.keys().cloned().collect::<Vec<_>>();
4942
4943        for target in &all {
4944            assert_ne!(
4945                session.compatible_resume_targets.contains(target),
4946                session.incompatible_resume_targets.contains(target),
4947                "target {target} is in both lists or neither"
4948            );
4949        }
4950        assert_eq!(
4951            session.compatible_resume_targets.len() + session.incompatible_resume_targets.len(),
4952            all.len(),
4953            "the two lists do not cover every target"
4954        );
4955    }
4956
4957    /// The viewer renders a control because a capability says so. An action
4958    /// whose capability is false is refused at the boundary, so a forged
4959    /// request gets the same answer a well-behaved viewer would never ask for.
4960    #[tokio::test]
4961    async fn actions_are_refused_when_their_capability_is_false() {
4962        for (body, capability) in [
4963            (
4964                r#"{"action":"cancel-turn","session_id":"session-1"}"#,
4965                "cancel_turn",
4966            ),
4967            (
4968                r#"{"action":"set-plan-mode","session_id":"session-1","active":true}"#,
4969                "set_plan_mode",
4970            ),
4971            (
4972                r#"{"action":"set-config","session_id":"session-1","key":"model","value":"x"}"#,
4973                "set_config",
4974            ),
4975        ] {
4976            let (app, mut actions, _, _, _) = app();
4977            let response = post_action(app, cookie(), body.to_owned()).await;
4978            assert!(
4979                response.status().is_client_error(),
4980                "{capability} was accepted while false: {}",
4981                response.status()
4982            );
4983            assert!(
4984                actions.try_recv().is_err(),
4985                "{capability} reached the controller while false"
4986            );
4987        }
4988    }
4989
4990    /// A setting the harness never advertised is not a setting. Forwarding one
4991    /// asks the agent to refuse something the viewer should never have offered.
4992    #[tokio::test]
4993    async fn a_config_key_the_harness_never_advertised_is_refused() {
4994        let capable = |snapshot: &mut ViewerSnapshot| {
4995            snapshot.sessions[0].capabilities.set_config = true;
4996            snapshot.sessions[0].config_options = vec![ViewerConfigOption {
4997                key: "model".into(),
4998                label: "model".into(),
4999                current: None,
5000                choices: vec![ViewerConfigChoice {
5001                    value: "sonnet".into(),
5002                    name: "Sonnet".into(),
5003                    description: None,
5004                }],
5005            }];
5006        };
5007
5008        for (body, why) in [
5009            (
5010                r#"{"action":"set-config","session_id":"session-1","key":"effort","value":"high"}"#,
5011                "an unadvertised key",
5012            ),
5013            (
5014                r#"{"action":"set-config","session_id":"session-1","key":"model","value":"gpt-9"}"#,
5015                "an unoffered value",
5016            ),
5017        ] {
5018            let (app, mut actions, _, _, _) = app_with_snapshot(capable);
5019            let response = post_action(app, cookie(), body.to_owned()).await;
5020            assert_eq!(
5021                response.status(),
5022                StatusCode::BAD_REQUEST,
5023                "{why} was accepted"
5024            );
5025            assert!(actions.try_recv().is_err(), "{why} reached the controller");
5026        }
5027
5028        // The value the harness did advertise is forwarded unchanged.
5029        let (app, mut actions, _, _, _) = app_with_snapshot(capable);
5030        let response = tokio::spawn(post_action(
5031            app,
5032            cookie(),
5033            r#"{"action":"set-config","session_id":"session-1","key":"model","value":"sonnet"}"#
5034                .to_owned(),
5035        ));
5036        let action = actions
5037            .recv()
5038            .await
5039            .expect("the action reached the controller");
5040        assert!(
5041            matches!(
5042                action.action,
5043                ControllerAction::SetConfig { ref key, ref value, .. }
5044                    if key == "model" && value == "sonnet"
5045            ),
5046            "the advertised value was not forwarded unchanged"
5047        );
5048        action.reply.send(ActionOutcome::Accepted).unwrap();
5049        assert_eq!(response.await.unwrap().status(), StatusCode::ACCEPTED);
5050    }
5051
5052    /// A dirty-worktree acknowledgement names the repositories the person was
5053    /// shown. A bare yes could be replayed against a set they never saw.
5054    #[tokio::test]
5055    async fn a_dirty_acknowledgement_is_bounded_and_names_repositories() {
5056        let oversized = (0..40)
5057            .map(|index| format!(r#""repo-{index}""#))
5058            .collect::<Vec<_>>()
5059            .join(",");
5060        for (ack, why) in [
5061            (oversized.as_str(), "an unbounded acknowledgement"),
5062            (r#""""#, "an empty repository name"),
5063        ] {
5064            let (app, mut actions, _, _, _) = app();
5065            let body = format!(
5066                r#"{{"action":"new","workspace_id":"default","profile_id":"codex-1","bundle_id":"hel","target_id":"podman","dirty_ack":[{ack}]}}"#
5067            );
5068            let response = post_action(app, cookie(), body).await;
5069            assert_eq!(response.status(), StatusCode::BAD_REQUEST, "{why}");
5070            assert!(actions.try_recv().is_err(), "{why} reached the controller");
5071        }
5072    }
5073
5074    /// A session created without a title still gets one, derived the way the
5075    /// terminal derives it, so the two surfaces name a session alike.
5076    #[tokio::test]
5077    async fn a_new_session_without_a_title_is_accepted() {
5078        let (app, mut actions, _, _, _) = app();
5079        let response = tokio::spawn(post_action(
5080            app,
5081            cookie(),
5082            r#"{"action":"new","workspace_id":"default","profile_id":"codex-1","bundle_id":"hel","target_id":"podman"}"#
5083                .to_owned(),
5084        ));
5085        // The handler answers only once the controller does, so the reply has
5086        // to be sent before the response can be read.
5087        let action = actions
5088            .recv()
5089            .await
5090            .expect("the action reached the controller");
5091        assert!(
5092            matches!(
5093                action.action,
5094                ControllerAction::New { title: None, ref workspace_id, .. }
5095                    if workspace_id == "default"
5096            ),
5097            "the workspace or the absent title did not survive the boundary"
5098        );
5099        action.reply.send(ActionOutcome::Accepted).unwrap();
5100        assert_eq!(response.await.unwrap().status(), StatusCode::ACCEPTED);
5101    }
5102
5103    /// Two phones must not share stored state, and one phone's state must
5104    /// survive its own re-login. Neither is true of a cookie that signs only
5105    /// an expiry, which is what this replaced.
5106    #[test]
5107    fn a_cookie_names_one_viewer_and_two_cookies_never_collide() {
5108        let key = b"01234567890123456789012345678901";
5109        let expiry = now_unix().saturating_add(3600);
5110        let first = signed_cookie_value(key, "viewer-a", expiry);
5111        let second = signed_cookie_value(key, "viewer-b", expiry);
5112        assert_ne!(
5113            first, second,
5114            "two viewers unlocking in the same second share a cookie"
5115        );
5116        assert_eq!(
5117            cookie_viewer(key, &first, now_unix()),
5118            Some(Some("viewer-a".to_owned()))
5119        );
5120        assert_eq!(
5121            cookie_viewer(key, &second, now_unix()),
5122            Some(Some("viewer-b".to_owned()))
5123        );
5124    }
5125
5126    /// A phone holding the previous cookie keeps working through a deployment.
5127    /// It names no viewer, so it stores nothing, which is the difference
5128    /// between signed out and signed in with nothing kept.
5129    #[test]
5130    fn a_legacy_cookie_still_authenticates_and_stores_nothing() {
5131        let key = b"01234567890123456789012345678901";
5132        let expiry = now_unix().saturating_add(3600);
5133        let legacy = legacy_signed_cookie_value(key, expiry);
5134        assert_eq!(cookie_viewer(key, &legacy, now_unix()), Some(None));
5135        assert!(session_cookie_valid(key, &legacy, now_unix()));
5136        assert!(
5137            !session_cookie_valid(key, &legacy, expiry),
5138            "an expired legacy cookie still authenticated"
5139        );
5140    }
5141
5142    /// A forged or tampered cookie names nobody.
5143    #[test]
5144    fn a_tampered_cookie_is_refused() {
5145        let key = b"01234567890123456789012345678901";
5146        let expiry = now_unix().saturating_add(3600);
5147        let honest = signed_cookie_value(key, "viewer-a", expiry);
5148        let swapped = honest.replacen("viewer-a", "viewer-b", 1);
5149        assert_eq!(cookie_viewer(key, &swapped, now_unix()), None);
5150        assert_eq!(cookie_viewer(key, "nonsense", now_unix()), None);
5151        assert_eq!(cookie_viewer(key, &format!("{expiry}."), now_unix()), None);
5152    }
5153
5154    /// A composer is for a prompt. The bound exists so one viewer cannot fill
5155    /// the daemon's database with text it never sent.
5156    #[tokio::test]
5157    async fn an_oversized_draft_is_refused_with_a_stable_code() {
5158        let (app, _, _, _, mut stored) = app();
5159        let draft = "x".repeat(64 * 1024 + 1);
5160        let response = app
5161            .oneshot(
5162                Request::put("/api/sessions/session-1/draft")
5163                    .header(COOKIE, cookie())
5164                    .header(CONTENT_TYPE, "application/json")
5165                    .body(Body::from(
5166                        serde_json::json!({ "draft": draft }).to_string(),
5167                    ))
5168                    .unwrap(),
5169            )
5170            .await
5171            .unwrap();
5172        assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE);
5173        assert!(stored.try_recv().is_err(), "an oversized draft was stored");
5174    }
5175
5176    /// A viewer with no identity has nothing stored, and is told so rather
5177    /// than being promised a persistence that is not there.
5178    #[tokio::test]
5179    async fn a_legacy_viewer_reads_empty_state_and_cannot_store_a_draft() {
5180        let key = b"01234567890123456789012345678901";
5181        let legacy = format!(
5182            "{COOKIE_NAME}={}",
5183            legacy_signed_cookie_value(key, now_unix().saturating_add(3600))
5184        );
5185
5186        let (reader, _, _, _, mut stored) = app();
5187        let response = reader
5188            .oneshot(
5189                Request::get("/api/sessions/session-1/client-state")
5190                    .header(COOKIE, legacy.clone())
5191                    .body(Body::empty())
5192                    .unwrap(),
5193            )
5194            .await
5195            .unwrap();
5196        assert_eq!(response.status(), StatusCode::OK);
5197        let body = response.into_body().collect().await.unwrap().to_bytes();
5198        let state: ViewerClientState = serde_json::from_slice(&body).unwrap();
5199        assert_eq!(state, ViewerClientState::default());
5200        assert!(
5201            stored.try_recv().is_err(),
5202            "a legacy viewer read stored state"
5203        );
5204
5205        let (writer, _, _, _, mut stored) = app();
5206        let response = writer
5207            .oneshot(
5208                Request::put("/api/sessions/session-1/draft")
5209                    .header(COOKIE, legacy)
5210                    .header(CONTENT_TYPE, "application/json")
5211                    .body(Body::from(r#"{"draft":"text"}"#))
5212                    .unwrap(),
5213            )
5214            .await
5215            .unwrap();
5216        assert_eq!(response.status(), StatusCode::CONFLICT);
5217        assert!(stored.try_recv().is_err(), "a legacy viewer stored a draft");
5218    }
5219
5220    /// A search that is not a search is refused before it reaches a database.
5221    #[tokio::test]
5222    async fn prompt_history_refuses_an_unknown_scope() {
5223        let (app, _, _, _, mut stored) = app();
5224        let response = app
5225            .oneshot(
5226                Request::get("/api/sessions/session-1/history?q=ship&scope=everything")
5227                    .header(COOKIE, cookie())
5228                    .body(Body::empty())
5229                    .unwrap(),
5230            )
5231            .await
5232            .unwrap();
5233        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
5234        assert!(
5235            stored.try_recv().is_err(),
5236            "the search reached the controller"
5237        );
5238    }
5239
5240    /// A preflight starts nothing. It answers the questions a person needs
5241    /// before committing, and it refuses an impossible combination there
5242    /// rather than after the commit.
5243    #[tokio::test]
5244    async fn a_preflight_validates_before_it_reaches_the_controller() {
5245        for (body, why) in [
5246            (
5247                r#"{"profile_id":"nope","bundle_id":"hel","target_id":"podman"}"#,
5248                "an unknown profile",
5249            ),
5250            (
5251                r#"{"profile_id":"codex-1","bundle_id":"hel","target_id":"raw"}"#,
5252                "a bare target with no directory",
5253            ),
5254        ] {
5255            let (app, _, _, mut preflights, _) = app();
5256            let response = app
5257                .oneshot(
5258                    Request::post("/api/preflight/new")
5259                        .header(COOKIE, cookie())
5260                        .header(CONTENT_TYPE, "application/json")
5261                        .body(Body::from(body))
5262                        .unwrap(),
5263                )
5264                .await
5265                .unwrap();
5266            assert_eq!(response.status(), StatusCode::BAD_REQUEST, "{why}");
5267            assert!(
5268                preflights.try_recv().is_err(),
5269                "{why} reached the controller"
5270            );
5271        }
5272    }
5273
5274    /// A bare target opens a directory the person named. The controller still
5275    /// validates that directory before answering, because the server's state
5276    /// projection cannot inspect the filesystem or an SSH host.
5277    #[tokio::test]
5278    async fn a_bare_preflight_forwards_directory_validation_to_the_controller() {
5279        let (app, _, _, mut preflights, _) = app();
5280        let response = tokio::spawn(app.oneshot(
5281                Request::post("/api/preflight/new")
5282                    .header(COOKIE, cookie())
5283                    .header(CONTENT_TYPE, "application/json")
5284                    .body(Body::from(
5285                        r#"{"profile_id":"codex-1","bundle_id":"hel","target_id":"raw","project_directory":"/work/project"}"#,
5286                    ))
5287                    .unwrap(),
5288            ));
5289        let request = preflights.recv().await.expect("the controller was asked");
5290        assert_eq!(request.bundle_id, "hel");
5291        assert_eq!(request.target_id, "raw");
5292        assert_eq!(
5293            request.project_directory,
5294            Some(PathBuf::from("/work/project"))
5295        );
5296        request
5297            .reply
5298            .send(Ok(PreflightNew {
5299                dirty_repositories: Vec::new(),
5300                remote_repositories: Vec::new(),
5301                local_changes_excluded: false,
5302            }))
5303            .unwrap();
5304        let response = response.await.unwrap().unwrap();
5305        assert_eq!(response.status(), StatusCode::OK);
5306        let body = response.into_body().collect().await.unwrap().to_bytes();
5307        let answer: PreflightNew = serde_json::from_slice(&body).unwrap();
5308        assert!(answer.dirty_repositories.is_empty());
5309    }
5310
5311    #[tokio::test]
5312    async fn a_bare_preflight_validation_failure_is_actionable_without_its_details() {
5313        let (app, _, _, mut preflights, _) = app();
5314        let response = tokio::spawn(app.oneshot(
5315            Request::post("/api/preflight/new")
5316                .header(COOKIE, cookie())
5317                .header(CONTENT_TYPE, "application/json")
5318                .body(Body::from(
5319                    r#"{"profile_id":"codex-1","bundle_id":"hel","target_id":"raw","project_directory":"/private/project"}"#,
5320                ))
5321                .unwrap(),
5322        ));
5323        let request = preflights.recv().await.expect("the controller was asked");
5324        request
5325            .reply
5326            .send(Err(PreflightFailure::Validation))
5327            .unwrap();
5328        let response = response.await.unwrap().unwrap();
5329        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
5330        let body = response.into_body().collect().await.unwrap().to_bytes();
5331        assert_eq!(
5332            serde_json::from_slice::<serde_json::Value>(&body).unwrap(),
5333            serde_json::json!({
5334                "error": "project validation failed; check that the directory exists, is accessible, and contains a Git repository with a valid HEAD"
5335            })
5336        );
5337        assert!(!String::from_utf8_lossy(&body).contains("/private/project"));
5338    }
5339
5340    #[tokio::test]
5341    async fn a_bundle_preflight_controller_failure_keeps_the_generic_service_error() {
5342        let (app, _, _, mut preflights, _) = app();
5343        let response = tokio::spawn(
5344            app.oneshot(
5345                Request::post("/api/preflight/new")
5346                    .header(COOKIE, cookie())
5347                    .header(CONTENT_TYPE, "application/json")
5348                    .body(Body::from(
5349                        r#"{"profile_id":"codex-1","bundle_id":"hel","target_id":"podman"}"#,
5350                    ))
5351                    .unwrap(),
5352            ),
5353        );
5354        let request = preflights.recv().await.expect("the controller was asked");
5355        request
5356            .reply
5357            .send(Err(PreflightFailure::Controller(
5358                "private /source/hel details".into(),
5359            )))
5360            .unwrap();
5361        let response = response.await.unwrap().unwrap();
5362        assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
5363        let body = response.into_body().collect().await.unwrap().to_bytes();
5364        assert_eq!(
5365            serde_json::from_slice::<serde_json::Value>(&body).unwrap(),
5366            serde_json::json!({"error": "the controller could not check this project"})
5367        );
5368        assert!(!String::from_utf8_lossy(&body).contains("/source/hel"));
5369    }
5370
5371    /// An isolated bundle preflight returns the network clone plan so the
5372    /// person can review it before creation.
5373    #[tokio::test]
5374    async fn a_bundle_preflight_reports_network_sources_and_excludes_local_changes() {
5375        let (app, _, _, mut preflights, _) = app();
5376        let response = tokio::spawn(
5377            app.oneshot(
5378                Request::post("/api/preflight/new")
5379                    .header(COOKIE, cookie())
5380                    .header(CONTENT_TYPE, "application/json")
5381                    .body(Body::from(
5382                        r#"{"profile_id":"codex-1","bundle_id":"hel","target_id":"podman"}"#,
5383                    ))
5384                    .unwrap(),
5385            ),
5386        );
5387        let request = preflights.recv().await.expect("the controller was asked");
5388        assert_eq!(request.bundle_id, "hel");
5389        assert_eq!(request.target_id, "podman");
5390        assert_eq!(request.project_directory, None);
5391        request
5392            .reply
5393            .send(Ok(PreflightNew {
5394                dirty_repositories: Vec::new(),
5395                remote_repositories: vec![PreflightRepository {
5396                    id: "hel".into(),
5397                    fetch_url: "https://github.com/example/hel.git".into(),
5398                    default_branch: "main".into(),
5399                    push_urls: vec!["ssh://git@example/hel.git".into()],
5400                }],
5401                local_changes_excluded: true,
5402            }))
5403            .unwrap();
5404        let response = response.await.unwrap().unwrap();
5405        assert_eq!(response.status(), StatusCode::OK);
5406        let body = response.into_body().collect().await.unwrap().to_bytes();
5407        let answer: PreflightNew = serde_json::from_slice(&body).unwrap();
5408        assert!(answer.dirty_repositories.is_empty());
5409        assert!(answer.local_changes_excluded);
5410        assert_eq!(answer.remote_repositories[0].default_branch, "main");
5411        assert_eq!(answer.remote_repositories[0].push_urls.len(), 1);
5412    }
5413
5414    /// Everything an agent writes goes through the Markdown renderer, so the
5415    /// renderer is where injection is stopped. These checks run the shipped
5416    /// module against a fake DOM: structure has to come out as elements, and
5417    /// markup an agent typed has to come out as text.
5418    #[test]
5419    fn the_markdown_renderer_builds_structure_and_refuses_injection() {
5420        run_web_check(
5421            "markdown",
5422            r#"import { installDocument, elements, only, check, checkEqual } from './test-dom.js';
5423installDocument();
5424const { renderMarkdown, renderDiffSummary, safeHref } = await import('./markdown.js');
5425
5426const render = source => {
5427  const host = document.createElement('section');
5428  host.append(renderMarkdown(source));
5429  return host;
5430};
5431
5432// Headings
5433checkEqual(only(render('# Title'), 'h1').textContent, 'Title', 'h1');
5434checkEqual(only(render('### Deep'), 'h3').textContent, 'Deep', 'h3');
5435
5436// Nested lists
5437const nested = render('- one\n  - inner\n- two');
5438check(elements(nested, 'ul').length === 2, 'nested list produced ' + elements(nested, 'ul').length + ' lists');
5439check(elements(elements(nested, 'ul')[0], 'li').length >= 2, 'outer list lost items');
5440
5441// Ordered lists
5442checkEqual(elements(render('1. a\n2. b'), 'ol').length, 1, 'ordered list');
5443
5444// Fenced code stays unparsed
5445const fenced = render('```rust\nlet x = *y*;\n```');
5446checkEqual(only(fenced, 'code').textContent, 'let x = *y*;', 'fenced code');
5447check(elements(fenced, 'span').some(s => s.className === 'tok-kw'), 'fenced rust untinted');
5448checkEqual(elements(fenced, 'em').length, 0, 'fence emphasised its contents');
5449checkEqual(only(fenced, 'pre').dataset.lang, 'rust', 'fence language');
5450
5451// Inline code beats emphasis
5452checkEqual(only(render('`*not em*`'), 'code').textContent, '*not em*', 'inline code');
5453checkEqual(elements(render('`*not em*`'), 'em').length, 0, 'inline code emphasised');
5454
5455// Emphasis
5456checkEqual(only(render('**bold**'), 'strong').textContent, 'bold', 'strong');
5457checkEqual(only(render('*it*'), 'em').textContent, 'it', 'em');
5458checkEqual(only(render('~~gone~~'), 'del').textContent, 'gone', 'del');
5459
5460// Tables
5461const table = render('| a | b |\n| --- | ---: |\n| 1 | 2 |');
5462checkEqual(elements(table, 'table').length, 1, 'table');
5463checkEqual(elements(table, 'th').length, 2, 'table header cells');
5464checkEqual(elements(table, 'td').length, 2, 'table body cells');
5465checkEqual(elements(table, 'th')[1].className, 'align-right', 'table alignment class');
5466checkEqual(only(table, 'div').className, 'scroll-x', 'table scroll wrapper');
5467
5468// Blockquote and rule
5469checkEqual(elements(render('> quoted'), 'blockquote').length, 1, 'blockquote');
5470checkEqual(elements(render('---'), 'hr').length, 1, 'rule');
5471
5472// XSS: markup is text, never elements
5473const injected = render('<img src=x onerror=alert(1)>');
5474checkEqual(elements(injected, 'img').length, 0, 'raw HTML became an element');
5475check(injected.textContent.includes('<img src=x onerror=alert(1)>'), 'raw HTML lost its text');
5476
5477// XSS: refused link schemes
5478for (const target of ['javascript:alert(1)', 'JaVaScRiPt:alert(1)', 'java\tscript:alert(1)', 'data:text/html,<script>', 'vbscript:x']) {
5479  const out = render(`[click](${target})`);
5480  checkEqual(elements(out, 'a').length, 0, `link scheme ${JSON.stringify(target)} was allowed`);
5481  check(out.textContent.includes('click'), `link scheme ${JSON.stringify(target)} lost its label`);
5482}
5483
5484// Accepted schemes keep their href and carry safe rel/target
5485for (const target of ['https://example.com', 'http://example.com/a', 'mailto:someone@example.com']) {
5486  const anchor = only(render(`[click](${target})`), 'a');
5487  checkEqual(anchor.getAttribute('href'), target, 'href');
5488  checkEqual(anchor.getAttribute('rel'), 'noreferrer noopener', 'rel');
5489  checkEqual(anchor.getAttribute('target'), '_blank', 'target');
5490}
5491
5492// safeHref directly
5493checkEqual(safeHref('javascript:alert(1)'), null, 'safeHref allowed javascript:');
5494checkEqual(safeHref(' https://x.test '), 'https://x.test', 'safeHref cleaned value');
5495
5496// Inline markup inside a link label
5497checkEqual(only(render('[**bold link**](https://x.test)'), 'strong').textContent, 'bold link', 'link label markup');
5498
5499// An unclosed delimiter is literal, not markup
5500checkEqual(render('a * b').textContent, 'a * b', 'unclosed emphasis');
5501checkEqual(elements(render('a * b'), 'em').length, 0, 'unclosed emphasis made an element');
5502
5503// Diff summaries: the real format from format_diffstat, two spaces and U+2212
5504const diff = renderDiffSummary(['src/main.rs  +12 −3', 'unparseable line']);
5505const items = elements(diff, 'li');
5506checkEqual(items.length, 2, 'diffstat rows');
5507checkEqual(elements(items[0], 'span')[0].textContent, 'src/main.rs', 'diffstat path');
5508checkEqual(elements(items[0], 'span')[1].textContent, '+12', 'diffstat additions');
5509checkEqual(elements(items[0], 'span')[2].textContent, '−3', 'diffstat deletions');
5510checkEqual(elements(items[1], 'span').length, 1, 'unparseable diffstat produced counts');
5511checkEqual(elements(items[1], 'span')[0].textContent, 'unparseable line', 'unparseable diffstat lost its text');
5512
5513console.log('all markdown checks passed');
5514"#,
5515        );
5516    }
5517
5518    /// Tool output is not prose, and rendering it as prose loses the parts
5519    /// that matter: which words in a command are the program and which are
5520    /// paths, where a JSON payload begins, and whether a five-thousand-line
5521    /// dump has to be paid for before anyone asks to see it.
5522    #[test]
5523    fn tool_output_is_tinted_folded_and_never_read_as_markdown() {
5524        run_web_check(
5525            "tool-output",
5526            r#"import { installDocument, elements, only, check, checkEqual, openFold } from './test-dom.js';
5527installDocument();
5528const { renderToolOutput, codeBlock, detectLang, appendCommandTokens, isPathLike } = await import(
5529  './tool-output.js'
5530);
5531
5532const classes = root => elements(root, 'span').map(s => s.className);
5533
5534// A shell command is told apart into program, subcommand, flag and path.
5535const line = document.createElement('pre');
5536appendCommandTokens(line, 'cargo test --workspace src/lib.rs');
5537const seen = classes(line);
5538check(seen.includes('cmd-program'), 'no program: ' + seen);
5539check(seen.includes('cmd-subcommand'), 'no subcommand: ' + seen);
5540check(seen.includes('cmd-flag'), 'no flag: ' + seen);
5541check(seen.includes('cmd-path'), 'no path: ' + seen);
5542checkEqual(line.textContent, 'cargo test --workspace src/lib.rs', 'command text changed');
5543
5544// An operator starts the program count again, so both programs are found.
5545const piped = document.createElement('pre');
5546appendCommandTokens(piped, 'git status && cargo build');
5547checkEqual(classes(piped).filter(c => c === 'cmd-program').length, 2, 'pipeline reset');
5548
5549// Prose with a slash is not a path; a real path is.
5550check(!isPathLike('and/or'), '"and/or" read as a path');
5551check(isPathLike('src/lib/thing.rs'), 'a real path did not');
5552check(isPathLike('./x'), 'a relative path did not');
5553check(isPathLike('Cargo.toml'), 'a file with an extension did not');
5554
5555// JSON is pretty-printed and tinted, keys apart from values.
5556const json = renderToolOutput('{"name":"hel","count":3,"ok":true}');
5557const jsonClasses = classes(json);
5558check(jsonClasses.includes('tok-key'), 'no JSON key: ' + jsonClasses);
5559check(jsonClasses.includes('tok-str'), 'no JSON string: ' + jsonClasses);
5560check(jsonClasses.includes('tok-num'), 'no JSON number: ' + jsonClasses);
5561check(jsonClasses.includes('tok-kw'), 'no JSON keyword: ' + jsonClasses);
5562check(json.textContent.includes('"name"'), 'JSON lost its content');
5563
5564// Rust is tinted; an unknown language is not.
5565const rust = codeBlock('pub fn main() {\n    let x = 1;\n}', 'rust');
5566check(classes(rust).includes('tok-kw'), 'rust keywords untinted');
5567checkEqual(only(rust, 'pre').dataset.lang, 'rust', 'rust data-lang');
5568const plain = codeBlock('nothing in particular here', 'brainfuck');
5569checkEqual(classes(plain).length, 0, 'unknown language was tinted');
5570
5571// Sniffing is conservative: a log stays plain, real code does not.
5572checkEqual(detectLang('12:03 INFO started\n12:04 INFO done\n12:05 INFO stopped'), '', 'a log was sniffed');
5573checkEqual(
5574  detectLang('fn a() {}\nfn b() {}\nlet mut x = 1;\nuse std::fmt;\nimpl Foo {}\nlet y = x.unwrap();'),
5575  'rust',
5576  'rust was not sniffed',
5577);
5578checkEqual(detectLang('--- a/x\n+++ b/x\n@@ -1 +1 @@\n-old\n+new'), 'diff', 'diff was not sniffed');
5579
5580// A long dump is one closed fold that has built nothing yet.
5581const long = Array.from({ length: 400 }, (_, i) => `line ${i}`).join('\n');
5582const folded = renderToolOutput(long);
5583checkEqual(folded.nodeName, 'DETAILS', 'a 400-line dump was not folded');
5584checkEqual(elements(folded, 'pre').length, 0, 'a closed fold built its content anyway');
5585check(only(folded, 'summary').textContent.includes('400 lines'), 'fold summary: ' + only(folded, 'summary').textContent);
5586openFold(folded);
5587checkEqual(elements(folded, 'pre').length, 1, 'an opened fold built nothing');
5588check(elements(folded, 'pre')[0].textContent.includes('line 399'), 'the fold lost its content');
5589
5590// Opening twice builds once.
5591openFold(folded);
5592checkEqual(elements(folded, 'pre').length, 1, 'reopening rebuilt the content');
5593
5594// A short dump is not folded.
5595checkEqual(renderToolOutput('one\ntwo').nodeName, 'PRE', 'a short dump was folded');
5596
5597// Tool output is never parsed as Markdown, so an underscore is an underscore.
5598const literal = renderToolOutput('a _b_ c <img src=x>');
5599checkEqual(elements(literal, 'em').length, 0, 'tool output was emphasised');
5600checkEqual(elements(literal, 'img').length, 0, 'tool output produced an element');
5601check(literal.textContent.includes('<img src=x>'), 'tool output lost its text');
5602
5603console.log('all tool-output checks passed');
5604"#,
5605        );
5606    }
5607
5608    /// The renderer's guarantee is structural — this code cannot inject markup
5609    /// because it never builds markup — and a single stray assignment would
5610    /// quietly replace it with no guarantee at all. `escapeHtml` uses
5611    /// `innerHTML` on a detached node to escape text, which is safe but is
5612    /// also exactly the shape this test exists to stop spreading, so it is
5613    /// named rather than pattern-matched.
5614    #[test]
5615    fn no_web_module_builds_markup_from_a_string() {
5616        const SINKS: [&str; 5] = [
5617            "innerHTML",
5618            "outerHTML",
5619            "insertAdjacentHTML",
5620            "document.write",
5621            "new Function",
5622        ];
5623        // There is no allowance. Every one of these sinks was removed in
5624        // Milestone 2, and the point of the test is that none comes back.
5625        const ALLOWED: [(&str, &str); 0] = [];
5626        for (name, source) in [
5627            ("viewer.js", VIEWER_JS),
5628            ("markdown.js", MARKDOWN_JS),
5629            ("tool-output.js", TOOL_OUTPUT_JS),
5630        ] {
5631            for (number, line) in source.lines().enumerate() {
5632                let trimmed = line.trim();
5633                if trimmed.starts_with("//") || trimmed.starts_with("///") {
5634                    continue;
5635                }
5636                for sink in SINKS {
5637                    if !trimmed.contains(sink) {
5638                        continue;
5639                    }
5640                    assert!(
5641                        ALLOWED
5642                            .iter()
5643                            .any(|(file, allowed)| *file == name && trimmed == *allowed),
5644                        "{name}:{} builds markup from a string: {trimmed}",
5645                        number + 1
5646                    );
5647                }
5648            }
5649        }
5650    }
5651
5652    /// The card cache is the fix for answers vanishing under snapshot polls, so
5653    /// it is exercised as JavaScript: the render source is lifted out of
5654    /// `src/web/viewer.js` and run against a stub DOM.
5655    #[test]
5656    fn embedded_viewer_keeps_elicitation_answers_across_snapshot_polls() {
5657        let source = viewer_source(
5658            "const elicitationCards = new Map()",
5659            "async function submitElicitation",
5660        );
5661        let dom = r#"
5662let replaceCalls = 0;
5663function makeEl(tag) {
5664  return {
5665    tagName: tag.toUpperCase(),
5666    children: [],
5667    options: [],
5668    selectedOptions: [],
5669    className: "",
5670    textContent: "",
5671    disabled: false,
5672    required: false,
5673    value: "",
5674    appendChild(child) {
5675      this.children.push(child);
5676      if (this.tagName === "SELECT") this.options.push(child);
5677      return child;
5678    },
5679    append(...kids) {
5680      this.children.push(...kids);
5681    },
5682    replaceChildren(...kids) {
5683      replaceCalls += 1;
5684      this.children = kids;
5685    },
5686    addEventListener() {},
5687    querySelectorAll(selector) {
5688      const found = [];
5689      const visit = node => {
5690        for (const child of node.children) {
5691          if (child.tagName === "INPUT" && (selector === "input" || child.checked)) found.push(child);
5692          visit(child);
5693        }
5694      };
5695      visit(this);
5696      return found;
5697    },
5698    querySelector(selector) { return this.querySelectorAll(selector)[0] || null; },
5699    setCustomValidity() {},
5700    reportValidity() {
5701      return true;
5702    },
5703  };
5704}
5705const created = [];
5706const document = {
5707  createElement(tag) {
5708    const el = makeEl(tag);
5709    created.push(el);
5710    return el;
5711  },
5712};
5713const elicitations = makeEl("div");
5714function el(tag, className, text) {
5715  const node = document.createElement(tag);
5716  node.className = className || "";
5717  node.textContent = text || "";
5718  return node;
5719}
5720async function submitElicitation() {}
5721"#;
5722        let checks = r#"
5723const request = {
5724  id: "elicitation-1",
5725  message: "Which CI architecture?",
5726  title: "CI",
5727  fields: [
5728    {
5729      id: "question_0",
5730      title: "CI architecture",
5731      required: false,
5732      kind: "single_select",
5733      options: [{ value: "reusable", title: "Reusable" }, { value: "matrix", title: "Matrix" }],
5734    },
5735    { id: "question_0_custom", title: "Other", required: false, kind: "text" },
5736  ],
5737};
5738const session = { id: "session-1", pending_elicitations: [request] };
5739renderElicitations(session);
5740const card = elicitations.children[0];
5741const radio = created.find((el) => el.tagName === "INPUT" && el.value === "reusable");
5742const text = created.find((el) => el.tagName === "INPUT" && el.type === "text");
5743radio.checked = true;
5744text.value = "keep me";
5745const attachments = replaceCalls;
5746renderElicitations(session);
5747if (elicitations.children[0] !== card) {
5748  throw new Error("a snapshot rebuilt the pending card");
5749}
5750if (!radio.checked || text.value !== "keep me") {
5751  throw new Error("a snapshot wiped the half-filled answer");
5752}
5753if (replaceCalls !== attachments) {
5754  throw new Error("a snapshot re-attached an unchanged card and dropped focus");
5755}
5756sentElicitations.add(elicitationKey("session-1", request.id));
5757renderElicitations(session);
5758if (elicitations.children[0] !== card) {
5759  throw new Error("a sent answer rebuilt the card");
5760}
5761if (!radio.disabled || !text.disabled) {
5762  throw new Error("a sent answer left the controls live");
5763}
5764if (!radio.checked) {
5765  throw new Error("a sent answer wiped the reply");
5766}
5767renderElicitations({ id: "session-1", pending_elicitations: [] });
5768if (elicitations.children.length !== 0 || elicitationCards.size !== 0) {
5769  throw new Error("an answered request stayed rendered");
5770}
5771if (sentElicitations.size !== 0) {
5772  throw new Error("a resolved request kept its sent marker");
5773}
5774"#;
5775        run_viewer_script(
5776            "elicitation-rendering",
5777            &format!("{dom}\n{source}\n{checks}"),
5778        );
5779    }
5780
5781    fn sample_image(pixels: usize) -> ViewerPromptImage {
5782        ViewerPromptImage {
5783            data_base64: base64::engine::general_purpose::STANDARD.encode(vec![7_u8; pixels]),
5784            mime_type: "image/png".into(),
5785            width: 32,
5786            height: 24,
5787            attachment: None,
5788        }
5789    }
5790
5791    fn sample_valid_image() -> ViewerPromptImage {
5792        ViewerPromptImage {
5793            data_base64: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
5794                .into(),
5795            mime_type: "image/png".into(),
5796            width: 1,
5797            height: 1,
5798            attachment: None,
5799        }
5800    }
5801
5802    fn image_capable(snapshot: &mut ViewerSnapshot) {
5803        snapshot.sessions[0].prompt_images_supported = true;
5804    }
5805
5806    async fn post_action(app: Router, cookie: String, body: String) -> Response<Body> {
5807        app.oneshot(
5808            Request::post("/api/actions")
5809                .header(COOKIE, cookie)
5810                .header(CONTENT_TYPE, "application/json")
5811                .body(Body::from(body))
5812                .unwrap(),
5813        )
5814        .await
5815        .unwrap()
5816    }
5817
5818    #[tokio::test]
5819    async fn image_prompt_reaches_the_controller_with_its_images() {
5820        let (app, mut actions, _, _, _) = app_with_snapshot(image_capable);
5821        let cookie = login_cookie(&app).await;
5822        let image = sample_valid_image();
5823        let body = serde_json::to_string(&ControllerAction::Prompt {
5824            session_id: "session-1".into(),
5825            text: String::new(),
5826            images: vec![image.clone(), image.clone()],
5827        })
5828        .unwrap();
5829        let response = tokio::spawn(post_action(app, cookie, body));
5830        let request = actions.recv().await.unwrap();
5831        let ControllerRequest { action, reply } = request;
5832        let ControllerAction::Prompt {
5833            session_id,
5834            text,
5835            images,
5836        } = action
5837        else {
5838            panic!("expected a prompt action")
5839        };
5840        assert_eq!(session_id, "session-1");
5841        assert!(text.is_empty());
5842        assert_eq!(images.len(), 2);
5843        assert!(
5844            images
5845                .iter()
5846                .all(|image| { image.data_base64.is_empty() && image.attachment.is_some() })
5847        );
5848        reply.send(ActionOutcome::Accepted).unwrap();
5849        assert_eq!(response.await.unwrap().status(), StatusCode::ACCEPTED);
5850    }
5851
5852    #[tokio::test]
5853    async fn browser_attachment_upload_returns_a_stored_reference_without_inline_bytes() {
5854        let (app, _, _, _, _) = app_with_snapshot(image_capable);
5855        let cookie = login_cookie(&app).await;
5856        let bytes = base64::engine::general_purpose::STANDARD
5857            .decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=")
5858            .unwrap();
5859        let response = app
5860            .oneshot(
5861                Request::post("/api/sessions/session-1/attachments")
5862                    .header(COOKIE, cookie)
5863                    .header(CONTENT_TYPE, "image/png")
5864                    .body(Body::from(bytes))
5865                    .unwrap(),
5866            )
5867            .await
5868            .unwrap();
5869        assert_eq!(response.status(), StatusCode::OK);
5870        let body = response.into_body().collect().await.unwrap().to_bytes();
5871        let image: ViewerPromptImage = serde_json::from_slice(&body).unwrap();
5872        assert!(image.data_base64.is_empty());
5873        let reference = image.attachment.expect("upload should return a reference");
5874        assert_eq!(reference.mime_type, "image/png");
5875        assert_eq!(reference.width, 1);
5876        assert_eq!(reference.height, 1);
5877        assert!(reference.size <= 700 * 1024);
5878    }
5879
5880    /// Base64 inflates an upload by a third, so two ordinary photographs pass
5881    /// the general body limit even when each one fits it. The action route
5882    /// carries prompts, so it is the route that gets the larger bound.
5883    #[tokio::test]
5884    async fn multi_image_prompts_are_accepted_over_the_general_body_limit() {
5885        let (app, mut actions, _, _, _) = app_with_snapshot(image_capable);
5886        let cookie = login_cookie(&app).await;
5887        let image = sample_valid_image();
5888        let mut body = serde_json::to_string(&ControllerAction::Prompt {
5889            session_id: "session-1".into(),
5890            text: "look at these".into(),
5891            images: vec![image.clone(), image],
5892        })
5893        .unwrap();
5894        body.push_str(&" ".repeat(MAX_BODY_BYTES));
5895        assert!(body.len() > MAX_BODY_BYTES);
5896        assert!(body.len() < MAX_PROMPT_BODY_BYTES);
5897        let response = tokio::spawn(post_action(app, cookie, body));
5898        let action = actions.recv().await.unwrap();
5899        action.reply.send(ActionOutcome::Accepted).unwrap();
5900        assert_eq!(response.await.unwrap().status(), StatusCode::ACCEPTED);
5901    }
5902
5903    #[tokio::test]
5904    async fn a_body_over_the_prompt_limit_is_still_refused() {
5905        let (app, _actions, _, _, _) = app_with_snapshot(image_capable);
5906        let cookie = login_cookie(&app).await;
5907        let image = sample_image(MAX_PROMPT_BODY_BYTES);
5908        let body = serde_json::to_string(&ControllerAction::Prompt {
5909            session_id: "session-1".into(),
5910            text: String::new(),
5911            images: vec![image],
5912        })
5913        .unwrap();
5914        assert!(body.len() > MAX_PROMPT_BODY_BYTES);
5915        let response = post_action(app, cookie, body).await;
5916        assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE);
5917    }
5918
5919    #[tokio::test]
5920    async fn malformed_image_payloads_never_reach_the_controller() {
5921        let cases = [
5922            ("aW1hZ2U=", "text/plain", 32, 24),
5923            ("aW1hZ2U=", "image/png", 0, 24),
5924            ("not base64!", "image/png", 32, 24),
5925            ("", "image/png", 32, 24),
5926        ];
5927        for (data, mime, width, height) in cases {
5928            let (app, mut actions, _, _, _) = app_with_snapshot(image_capable);
5929            let cookie = login_cookie(&app).await;
5930            let body = serde_json::to_string(&ControllerAction::Prompt {
5931                session_id: "session-1".into(),
5932                text: String::new(),
5933                images: vec![ViewerPromptImage {
5934                    data_base64: data.into(),
5935                    mime_type: mime.into(),
5936                    width,
5937                    height,
5938                    attachment: None,
5939                }],
5940            })
5941            .unwrap();
5942            let response = post_action(app, cookie, body).await;
5943            assert_eq!(
5944                response.status(),
5945                StatusCode::BAD_REQUEST,
5946                "expected {data:?}/{mime} {width}x{height} to be refused"
5947            );
5948            assert!(actions.try_recv().is_err());
5949        }
5950    }
5951
5952    #[test]
5953    fn image_prompts_need_text_or_an_image_and_an_agent_that_takes_them() {
5954        let (config, state) = sample_config_state();
5955        let mut snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
5956        let prompt = |text: &str, images: Vec<ViewerPromptImage>| ControllerAction::Prompt {
5957            session_id: "session-1".into(),
5958            text: text.into(),
5959            images,
5960        };
5961
5962        // Without the capability the session takes text only.
5963        assert!(validate_action(&prompt("ship it", Vec::new()), &snapshot).is_ok());
5964        assert!(validate_action(&prompt("", vec![sample_image(8)]), &snapshot).is_err());
5965
5966        image_capable(&mut snapshot);
5967        // An image is a prompt on its own; nothing at all is not.
5968        assert!(validate_action(&prompt("", vec![sample_image(8)]), &snapshot).is_ok());
5969        assert!(
5970            validate_action(
5971                &prompt("", vec![sample_image(8); MAX_PROMPT_IMAGES + 1]),
5972                &snapshot,
5973            )
5974            .is_err()
5975        );
5976        assert!(validate_action(&prompt("   ", Vec::new()), &snapshot).is_err());
5977        assert!(validate_action(&prompt("", Vec::new()), &snapshot).is_err());
5978        // A shell command is still a shell command.
5979        assert!(validate_action(&prompt("!ls", vec![sample_image(8)]), &snapshot).is_err());
5980    }
5981
5982    /// The composer holds a DOM, not a string, so the text a prompt sends is
5983    /// whatever this reader makes of that DOM. Run it as JavaScript.
5984    #[test]
5985    fn embedded_viewer_reads_multiline_composer_text_out_of_its_dom() {
5986        let source = viewer_source("function composerText()", "function setComposerText(");
5987        let harness = r##"
5988const Node = { TEXT_NODE: 3 };
5989function textNode(value) {
5990  return { nodeType: 3, nodeValue: value, nodeName: "#text", childNodes: [], dataset: {} };
5991}
5992function element(name, children = [], dataset = {}) {
5993  const node = { nodeType: 1, nodeName: name, dataset, childNodes: children };
5994  children.forEach((child, index) => {
5995    child.nextSibling = children[index + 1] || null;
5996  });
5997  return node;
5998}
5999let promptText = null;
6000function read(children) {
6001  promptText = element("DIV", children);
6002  return composerText();
6003}
6004"##;
6005        let checks = r#"
6006const plain = read([textNode("ship it")]);
6007if (plain !== "ship it") throw new Error(`plain text became ${JSON.stringify(plain)}`);
6008
6009const broken = read([textNode("first"), element("BR"), textNode("second")]);
6010if (broken !== "first\nsecond") throw new Error(`line break became ${JSON.stringify(broken)}`);
6011
6012// The trailing break a browser leaves behind to keep the caret on a new line
6013// is scaffolding, not a line the user typed.
6014const filler = read([
6015  textNode("first"),
6016  element("BR"),
6017  element("BR", [], { composerFiller: "true" }),
6018]);
6019if (filler !== "first\n") throw new Error(`filler break became ${JSON.stringify(filler)}`);
6020
6021const blocks = read([
6022  textNode("first"),
6023  element("DIV", [textNode("second")]),
6024  element("DIV", [textNode("third")]),
6025]);
6026if (blocks !== "first\nsecond\nthird") throw new Error(`blocks became ${JSON.stringify(blocks)}`);
6027
6028const carriage = read([textNode("first\r\nsecond")]);
6029if (carriage !== "first\nsecond") throw new Error(`CRLF became ${JSON.stringify(carriage)}`);
6030"#;
6031        run_viewer_script("composer-reader", &format!("{harness}\n{source}\n{checks}"));
6032    }
6033
6034    /// A page that declares no icon makes every browser request
6035    /// `/favicon.ico`, which this server does not have. The page therefore has
6036    /// to name an icon, and that icon has to be served.
6037    #[tokio::test]
6038    async fn viewer_declares_the_icon_route_instead_of_requesting_a_missing_favicon() {
6039        let (app, _, _, _, _) = app();
6040        let page = fetch_text(app.clone(), "/").await;
6041        assert!(page.contains(r#"rel="icon""#), "the page declares no icon");
6042        assert!(page.contains("/icon.svg"), "the page names no icon route");
6043        let icon = app
6044            .oneshot(Request::get("/icon.svg").body(Body::empty()).unwrap())
6045            .await
6046            .unwrap();
6047        assert_eq!(icon.status(), StatusCode::OK);
6048        assert_eq!(
6049            icon.headers().get(CONTENT_TYPE).unwrap(),
6050            "image/svg+xml",
6051            "the icon route does not serve an SVG"
6052        );
6053    }
6054
6055    #[tokio::test]
6056    async fn valid_action_is_typed_and_forwarded() {
6057        let (app, mut actions, _, _, _) = app();
6058        let cookie = login_cookie(&app).await;
6059        let response = tokio::spawn(
6060            app.oneshot(
6061                Request::post("/api/actions")
6062                    .header(COOKIE, cookie)
6063                    .header(CONTENT_TYPE, "application/json")
6064                    .body(Body::from(
6065                        r#"{"action":"prompt","session_id":"session-1","text":"ship it"}"#,
6066                    ))
6067                    .unwrap(),
6068            ),
6069        );
6070        let action = actions.recv().await.unwrap();
6071        assert_eq!(
6072            action.action,
6073            ControllerAction::Prompt {
6074                session_id: "session-1".into(),
6075                text: "ship it".into(),
6076                images: Vec::new(),
6077            }
6078        );
6079        action.reply.send(ActionOutcome::Accepted).unwrap();
6080        let response = response.await.unwrap().unwrap();
6081        assert_eq!(response.status(), StatusCode::ACCEPTED);
6082    }
6083
6084    #[tokio::test]
6085    async fn move_preparation_is_read_only_and_returns_the_daemon_fingerprint() {
6086        let (app, mut preparations) = app_with_move_receiver();
6087        let cookie = login_cookie(&app).await;
6088        let response = tokio::spawn(
6089            app.oneshot(
6090                Request::post("/api/moves/prepare")
6091                    .header(COOKIE, cookie)
6092                    .header(CONTENT_TYPE, "application/json")
6093                    .body(Body::from(
6094                        r#"{"session_id":"session-1","profile_id":"codex-1","target_template_id":"podman","clear_resource_allocation":false,"additional_mounts":null,"resource_allocation":null}"#,
6095                    ))
6096                    .unwrap(),
6097            ),
6098        );
6099        let request = preparations
6100            .recv()
6101            .await
6102            .expect("preparation reached daemon");
6103        assert_eq!(request.selection.session_id, "session-1");
6104        assert_eq!(request.selection.profile_id.as_deref(), Some("codex-1"));
6105        assert_eq!(
6106            request.selection.target_template_id.as_deref(),
6107            Some("podman")
6108        );
6109        request
6110            .reply
6111            .send(Ok(MovePreparation {
6112                selection: request.selection,
6113                source_profile_id: "codex-1".into(),
6114                source_target_template_id: "podman".into(),
6115                cross_harness: false,
6116                active: true,
6117                queued_commands: vec![hel::hel_state::MaterializedQueuedPrompt {
6118                    command_id: "queued-1".into(),
6119                    kind: hel::hel_state::QueuedCommandKind::Prompt,
6120                    content: vec![serde_json::json!({
6121                        "type": "image",
6122                        "mimeType": "image/png",
6123                        "data": "secret-image-bytes"
6124                    })],
6125                    queued_at_ms: 1,
6126                }],
6127                fingerprint: "fingerprint".into(),
6128                operation_id: "move-1".into(),
6129            }))
6130            .unwrap();
6131        let response = response.await.unwrap().unwrap();
6132        assert_eq!(response.status(), StatusCode::OK);
6133        let body = response.into_body().collect().await.unwrap().to_bytes();
6134        let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
6135        assert_eq!(body["operation_id"], "move-1");
6136        assert_eq!(body["active"], true);
6137        assert_eq!(
6138            body["queued_commands"][0]["content"][0]["text"],
6139            "[Image attachment: image/png]"
6140        );
6141        assert!(body.to_string().contains("[Image attachment: image/png]"));
6142        assert!(!body.to_string().contains("secret-image-bytes"));
6143    }
6144
6145    #[tokio::test]
6146    async fn confirmed_move_action_forwards_the_fingerprinted_request() {
6147        let (app, mut actions, _, _, _) = app_with_snapshot(|snapshot| {
6148            snapshot.sessions[0].capabilities.move_session = true;
6149        });
6150        let cookie = login_cookie(&app).await;
6151        let response = tokio::spawn(
6152            app.oneshot(
6153                Request::post("/api/actions")
6154                    .header(COOKIE, cookie)
6155                    .header(CONTENT_TYPE, "application/json")
6156                    .body(Body::from(
6157                        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}}"#,
6158                    ))
6159                    .unwrap(),
6160            ),
6161        );
6162        let action = actions.recv().await.expect("move action reached daemon");
6163        assert!(matches!(action.action, ControllerAction::Move { .. }));
6164        action.reply.send(ActionOutcome::Accepted).unwrap();
6165        assert_eq!(
6166            response.await.unwrap().unwrap().status(),
6167            StatusCode::ACCEPTED
6168        );
6169    }
6170
6171    #[tokio::test]
6172    async fn shell_action_is_typed_and_forwarded() {
6173        let (app, mut actions, _, _, _) = app();
6174        let cookie = login_cookie(&app).await;
6175        let response = tokio::spawn(
6176            app.oneshot(
6177                Request::post("/api/actions")
6178                    .header(COOKIE, cookie)
6179                    .header(CONTENT_TYPE, "application/json")
6180                    .body(Body::from(
6181                        r#"{"action":"run-shell","session_id":"session-1","command":"cargo test"}"#,
6182                    ))
6183                    .unwrap(),
6184            ),
6185        );
6186        let action = actions.recv().await.unwrap();
6187        assert_eq!(
6188            action.action,
6189            ControllerAction::RunShell {
6190                session_id: "session-1".into(),
6191                command: "cargo test".into(),
6192            }
6193        );
6194        action.reply.send(ActionOutcome::Accepted).unwrap();
6195        assert_eq!(
6196            response.await.unwrap().unwrap().status(),
6197            StatusCode::ACCEPTED
6198        );
6199    }
6200
6201    #[test]
6202    fn shell_action_validation_reserves_bang_prompts_and_checks_cancellation_ids() {
6203        let (config, state) = sample_config_state();
6204        let mut snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
6205        assert!(
6206            validate_action(
6207                &ControllerAction::Prompt {
6208                    session_id: "session-1".into(),
6209                    text: "!cargo test".into(),
6210                    images: Vec::new(),
6211                },
6212                &snapshot,
6213            )
6214            .is_err()
6215        );
6216        assert!(
6217            validate_action(
6218                &ControllerAction::RunShell {
6219                    session_id: "session-1".into(),
6220                    command: "cargo test".into(),
6221                },
6222                &snapshot,
6223            )
6224            .is_ok()
6225        );
6226        assert!(
6227            validate_action(
6228                &ControllerAction::CancelShell {
6229                    session_id: "session-1".into(),
6230                    shell_command_id: "shell-1".into(),
6231                },
6232                &snapshot,
6233            )
6234            .is_err()
6235        );
6236
6237        snapshot.sessions[0]
6238            .active_user_shells
6239            .push(ViewerUserShell {
6240                id: "shell-1".into(),
6241                command: "cargo test".into(),
6242                started_at_ms: Some(10),
6243            });
6244        assert!(
6245            validate_action(
6246                &ControllerAction::CancelShell {
6247                    session_id: "session-1".into(),
6248                    shell_command_id: "shell-1".into(),
6249                },
6250                &snapshot,
6251            )
6252            .is_ok()
6253        );
6254    }
6255
6256    #[tokio::test]
6257    async fn bare_new_action_forwards_an_explicit_safe_project_directory() {
6258        let (app, mut actions, _, _, _) = app();
6259        let cookie = login_cookie(&app).await;
6260        let response = tokio::spawn(
6261            app.oneshot(
6262                Request::post("/api/actions")
6263                    .header(COOKIE, cookie)
6264                    .header(CONTENT_TYPE, "application/json")
6265                    .body(Body::from(
6266                        r#"{"action":"new","profile_id":"codex-1","bundle_id":"hel","target_id":"raw","title":"Raw work","project_directory":"/work/project"}"#,
6267                    ))
6268                    .unwrap(),
6269            ),
6270        );
6271        let action = actions.recv().await.unwrap();
6272        assert_eq!(
6273            action.action,
6274            ControllerAction::New {
6275                workspace_id: String::new(),
6276                profile_id: "codex-1".into(),
6277                bundle_id: "hel".into(),
6278                target_id: "raw".into(),
6279                title: Some("Raw work".into()),
6280                project_directory: Some(PathBuf::from("/work/project")),
6281                dirty_ack: Vec::new(),
6282            }
6283        );
6284        action.reply.send(ActionOutcome::Accepted).unwrap();
6285        assert_eq!(
6286            response.await.unwrap().unwrap().status(),
6287            StatusCode::ACCEPTED
6288        );
6289    }
6290
6291    #[test]
6292    fn new_action_requires_project_directory_exactly_for_bare_targets() {
6293        let (config, state) = sample_config_state();
6294        let snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
6295        let action = |target_id: &str, project_directory: Option<PathBuf>| ControllerAction::New {
6296            workspace_id: String::new(),
6297            profile_id: "codex-1".into(),
6298            bundle_id: "hel".into(),
6299            target_id: target_id.into(),
6300            title: Some("New work".into()),
6301            project_directory,
6302            dirty_ack: Vec::new(),
6303        };
6304
6305        assert!(validate_action(&action("podman", None), &snapshot).is_ok());
6306        assert_eq!(
6307            validate_action(&action("podman", Some("/work".into())), &snapshot)
6308                .unwrap_err()
6309                .status,
6310            StatusCode::BAD_REQUEST
6311        );
6312        assert_eq!(
6313            validate_action(&action("raw", None), &snapshot)
6314                .unwrap_err()
6315                .status,
6316            StatusCode::BAD_REQUEST
6317        );
6318        assert_eq!(
6319            validate_action(&action("raw", Some("relative".into())), &snapshot)
6320                .unwrap_err()
6321                .status,
6322            StatusCode::BAD_REQUEST
6323        );
6324        assert_eq!(
6325            validate_action(&action("raw", Some("/work/../secret".into())), &snapshot)
6326                .unwrap_err()
6327                .status,
6328            StatusCode::BAD_REQUEST
6329        );
6330        assert!(validate_action(&action("raw", Some("/work/project".into())), &snapshot).is_ok());
6331    }
6332
6333    #[tokio::test]
6334    async fn cancel_action_is_typed_and_forwarded() {
6335        let (app, mut actions, _, _, _) = app();
6336        let cookie = login_cookie(&app).await;
6337        let response = tokio::spawn(
6338            app.oneshot(
6339                Request::post("/api/actions")
6340                    .header(COOKIE, cookie)
6341                    .header(CONTENT_TYPE, "application/json")
6342                    .body(Body::from(
6343                        r#"{"action":"cancel","session_id":"session-1"}"#,
6344                    ))
6345                    .unwrap(),
6346            ),
6347        );
6348        let action = actions.recv().await.unwrap();
6349        assert_eq!(
6350            action.action,
6351            ControllerAction::Cancel {
6352                session_id: "session-1".into(),
6353            }
6354        );
6355        action.reply.send(ActionOutcome::Accepted).unwrap();
6356        assert_eq!(
6357            response.await.unwrap().unwrap().status(),
6358            StatusCode::ACCEPTED
6359        );
6360    }
6361
6362    #[tokio::test]
6363    async fn action_validation_accepts_cross_harness_resume_and_rejects_unknown() {
6364        let (mut config, state) = sample_config_state();
6365        config.profiles.insert(
6366            "claude-1".into(),
6367            HarnessProfile {
6368                context_window_bytes: None,
6369                kind: HarnessKind::Claude,
6370                home: "/secret/claude".into(),
6371                environment: BTreeMap::new(),
6372            },
6373        );
6374        let mut snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
6375        snapshot.workspaces.push(ViewerWorkspace {
6376            id: "workspace-1".into(),
6377            name: "One".into(),
6378        });
6379        validate_action(
6380            &ControllerAction::Resume {
6381                session_id: "session-1".into(),
6382                workspace_id: "workspace-1".into(),
6383                profile_id: "claude-1".into(),
6384                target_id: "podman".into(),
6385                queue: ResumeQueueDisposition::Start,
6386                additional_mounts: None,
6387                resource_allocation: None,
6388            },
6389            &snapshot,
6390        )
6391        .unwrap();
6392
6393        let error = validate_action(
6394            &ControllerAction::Resume {
6395                session_id: "session-1".into(),
6396                workspace_id: "missing".into(),
6397                profile_id: "claude-1".into(),
6398                target_id: "podman".into(),
6399                queue: ResumeQueueDisposition::Start,
6400                additional_mounts: None,
6401                resource_allocation: None,
6402            },
6403            &snapshot,
6404        )
6405        .unwrap_err();
6406        assert_eq!(error.status, StatusCode::BAD_REQUEST);
6407
6408        let error = validate_action(
6409            &ControllerAction::Close {
6410                session_id: "not-managed".into(),
6411            },
6412            &snapshot,
6413        )
6414        .unwrap_err();
6415        assert_eq!(error.status, StatusCode::NOT_FOUND);
6416    }
6417
6418    /// A review the daemon is running reaches the phone whole: its tier, what
6419    /// each reviewing agent is doing, and the findings to answer.
6420    #[test]
6421    fn a_running_review_projects_to_the_phone() {
6422        use crate::hel_review_host::{RuntimeReviewView, VerdictKind, VerdictView};
6423        use hel::hel_review::driver::{Resolution, RoleState, RoleStatus, TurnReviewPhase};
6424
6425        let review = RuntimeReviewView {
6426            session_id: "session-1".into(),
6427            tier: hel::hel_review::lanes::ReviewTier::Extended,
6428            phase: TurnReviewPhase::Verdict(hel::hel_review::verdict::ReviewVerdict::Findings {
6429                synthesis: "[P1] src/lib.rs:1 -- unbounded retry".into(),
6430                evidence: Default::default(),
6431            }),
6432            roles: vec![
6433                RoleStatus {
6434                    role: "supervisor".into(),
6435                    label: "Supervisor".into(),
6436                    state: RoleState::Clean,
6437                },
6438                RoleStatus {
6439                    role: "tests".into(),
6440                    label: "Tests".into(),
6441                    state: RoleState::Findings,
6442                },
6443            ],
6444            status: "Enter to act".into(),
6445            verdict: Some(VerdictView {
6446                kind: VerdictKind::Findings,
6447                text: "[P1] src/lib.rs:1 -- unbounded retry".into(),
6448                allowed: vec![
6449                    Resolution::Forwarded,
6450                    Resolution::Dismissed,
6451                    Resolution::Cancelled,
6452                ],
6453            }),
6454        };
6455
6456        let projected = ViewerTurnReview::from_runtime(&review);
6457
6458        assert_eq!(projected.tier, "extended");
6459        assert_eq!(
6460            projected
6461                .roles
6462                .iter()
6463                .map(|role| (role.label.as_str(), role.state.as_str()))
6464                .collect::<Vec<_>>(),
6465            vec![("Supervisor", "done"), ("Tests", "findings")]
6466        );
6467        let verdict = projected.verdict.expect("a findings verdict travels");
6468        assert_eq!(verdict.kind, "findings");
6469        assert!(verdict.text.contains("unbounded retry"));
6470        assert_eq!(verdict.allowed, vec!["forward", "dismiss", "cancel"]);
6471    }
6472
6473    /// A phone can always cancel a review, and can only forward or dismiss one
6474    /// the daemon says is ready for it. The same gate runs in the daemon; this
6475    /// one is what makes the refusal immediate.
6476    #[test]
6477    fn resolving_a_review_is_gated_on_what_the_daemon_published() {
6478        let (config, state) = sample_config_state();
6479        let mut snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
6480
6481        let resolve = |resolution: &str| ControllerAction::ResolveReview {
6482            session_id: "session-1".into(),
6483            resolution: resolution.into(),
6484        };
6485
6486        // No review at all.
6487        let error = validate_action(&resolve("cancel"), &snapshot).unwrap_err();
6488        assert_eq!(error.status, StatusCode::BAD_REQUEST);
6489
6490        snapshot.sessions[0].turn_review = Some(ViewerTurnReview {
6491            tier: "quick".into(),
6492            status: "the reviewer is reading the change…".into(),
6493            roles: Vec::new(),
6494            verdict: None,
6495        });
6496        // Running: cancel works, the rest do not.
6497        validate_action(&resolve("cancel"), &snapshot).unwrap();
6498        assert_eq!(
6499            validate_action(&resolve("forward"), &snapshot)
6500                .unwrap_err()
6501                .status,
6502            StatusCode::BAD_REQUEST
6503        );
6504
6505        // A failed review can be dismissed but has nothing to forward.
6506        snapshot.sessions[0].turn_review = Some(ViewerTurnReview {
6507            tier: "quick".into(),
6508            status: "the review failed".into(),
6509            roles: Vec::new(),
6510            verdict: Some(ViewerReviewVerdict {
6511                kind: "failed".into(),
6512                text: "bifrost exited with 1".into(),
6513                allowed: vec!["dismiss".into(), "cancel".into()],
6514            }),
6515        });
6516        validate_action(&resolve("dismiss"), &snapshot).unwrap();
6517        assert_eq!(
6518            validate_action(&resolve("forward"), &snapshot)
6519                .unwrap_err()
6520                .status,
6521            StatusCode::BAD_REQUEST
6522        );
6523        // A resolution that is not one of the three is refused by name.
6524        assert_eq!(
6525            validate_action(&resolve("approve"), &snapshot)
6526                .unwrap_err()
6527                .status,
6528            StatusCode::BAD_REQUEST
6529        );
6530
6531        // Starting a review needs only a session that exists.
6532        validate_action(
6533            &ControllerAction::StartReview {
6534                session_id: "session-1".into(),
6535            },
6536            &snapshot,
6537        )
6538        .unwrap();
6539        assert_eq!(
6540            validate_action(
6541                &ControllerAction::StartReview {
6542                    session_id: "not-managed".into(),
6543                },
6544                &snapshot,
6545            )
6546            .unwrap_err()
6547            .status,
6548            StatusCode::NOT_FOUND
6549        );
6550    }
6551
6552    #[test]
6553    fn resume_action_refuses_a_target_the_session_cannot_use() {
6554        let (mut config, state) = sample_config_state();
6555        // A project that only exists on GitHub cannot become a checkout on this
6556        // machine, so the bare target stays out of reach for its sessions.
6557        config.bundles.get_mut("hel").unwrap().repositories[0].local = None;
6558        let mut snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
6559        snapshot.workspaces.push(ViewerWorkspace {
6560            id: "workspace-1".into(),
6561            name: "One".into(),
6562        });
6563        assert_eq!(
6564            snapshot.sessions[0].incompatible_resume_targets,
6565            vec!["raw".to_owned()]
6566        );
6567
6568        let error = validate_action(
6569            &ControllerAction::Resume {
6570                session_id: "session-1".into(),
6571                workspace_id: "workspace-1".into(),
6572                profile_id: "codex-1".into(),
6573                target_id: "raw".into(),
6574                queue: ResumeQueueDisposition::Start,
6575                additional_mounts: None,
6576                resource_allocation: None,
6577            },
6578            &snapshot,
6579        )
6580        .unwrap_err();
6581
6582        assert_eq!(error.status, StatusCode::BAD_REQUEST);
6583    }
6584
6585    #[test]
6586    fn move_confirmation_requires_interruption_ack_and_an_explicit_queue_choice() {
6587        let (config, state) = sample_config_state();
6588        let mut snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
6589        snapshot.sessions[0].capabilities.move_session = true;
6590        let selection = MoveSelection {
6591            clear_resource_allocation: false,
6592            session_id: "session-1".into(),
6593            profile_id: Some("codex-1".into()),
6594            target_template_id: Some("podman".into()),
6595            additional_mounts: None,
6596            resource_allocation: None,
6597        };
6598        let preparation = MovePreparation {
6599            selection,
6600            source_profile_id: "codex-1".into(),
6601            source_target_template_id: "podman".into(),
6602            cross_harness: false,
6603            active: true,
6604            queued_commands: vec![hel::hel_state::MaterializedQueuedPrompt {
6605                command_id: "command-1".into(),
6606                kind: hel::hel_state::QueuedCommandKind::Prompt,
6607                content: vec![serde_json::json!({"type": "text", "text": "continue"})],
6608                queued_at_ms: 1,
6609            }],
6610            fingerprint: "fingerprint".into(),
6611            operation_id: "move-1".into(),
6612        };
6613        let request = |queue, acknowledge_interruption| MoveSessionRequest {
6614            preparation: preparation.clone(),
6615            queue,
6616            acknowledge_interruption,
6617        };
6618        assert_eq!(
6619            validate_action(
6620                &ControllerAction::Move {
6621                    request: request(Some(ResumeQueueDisposition::Discard), false),
6622                },
6623                &snapshot,
6624            )
6625            .unwrap_err()
6626            .status,
6627            StatusCode::CONFLICT
6628        );
6629        assert_eq!(
6630            validate_action(
6631                &ControllerAction::Move {
6632                    request: request(None, true),
6633                },
6634                &snapshot,
6635            )
6636            .unwrap_err()
6637            .status,
6638            StatusCode::BAD_REQUEST
6639        );
6640        validate_action(
6641            &ControllerAction::Move {
6642                request: request(Some(ResumeQueueDisposition::Discard), true),
6643            },
6644            &snapshot,
6645        )
6646        .unwrap();
6647    }
6648
6649    #[tokio::test]
6650    async fn snapshot_endpoint_returns_only_public_projection() {
6651        let (app, _, _, _, _) = app();
6652        let cookie = login_cookie(&app).await;
6653        let response = app
6654            .oneshot(
6655                Request::get("/api/snapshot")
6656                    .header(COOKIE, cookie)
6657                    .body(Body::empty())
6658                    .unwrap(),
6659            )
6660            .await
6661            .unwrap();
6662        let body = response.into_body().collect().await.unwrap().to_bytes();
6663        let body = String::from_utf8(body.to_vec()).unwrap();
6664        assert!(body.contains("session-1"));
6665        assert!(!body.contains("secret-token"));
6666        assert!(!body.contains("native-secret-id"));
6667        assert!(!body.contains("/private/source/hel"));
6668
6669        let snapshot: serde_json::Value = serde_json::from_str(&body).unwrap();
6670        let repository = &snapshot["bundles"][0]["repositories"][0];
6671        assert_eq!(repository["id"], "hel");
6672        assert_eq!(repository["github"], "owner/hel");
6673        assert_eq!(repository["destination"], "hel");
6674        assert!(repository.get("local").is_none());
6675    }
6676
6677    #[tokio::test]
6678    async fn snapshot_clock_anchor_is_fresh_even_when_the_projection_has_not_changed() {
6679        let (app, _, _, _, _) = app_with_snapshot(|snapshot| snapshot.server_time_ms = 1);
6680        let cookie = login_cookie(&app).await;
6681        for _ in 0..2 {
6682            let before = hel::clock::epoch_millis();
6683            let response = app
6684                .clone()
6685                .oneshot(
6686                    Request::get("/api/snapshot")
6687                        .header(COOKIE, &cookie)
6688                        .body(Body::empty())
6689                        .unwrap(),
6690                )
6691                .await
6692                .unwrap();
6693            let body = response.into_body().collect().await.unwrap().to_bytes();
6694            let snapshot: ViewerSnapshot = serde_json::from_slice(&body).unwrap();
6695            assert!(snapshot.server_time_ms >= before);
6696            assert!(snapshot.server_time_ms <= hel::clock::epoch_millis());
6697        }
6698    }
6699
6700    #[tokio::test]
6701    async fn conversation_endpoint_returns_authenticated_bounded_deltas() {
6702        let transcript = BrowserTranscript {
6703            latest_seq: 8,
6704            window_start_seq: 3,
6705            reset: false,
6706            entries: vec![
6707                BrowserTranscriptEntry {
6708                    id: 3,
6709                    updated_seq: 3,
6710                    role: "user",
6711                    label: "You".into(),
6712                    recorded_at_ms: None,
6713                    lines: vec!["begin".into()],
6714                    glyph: "\u{276f}",
6715                    tone: "user",
6716                    tool_status: None,
6717                    diffstats: Vec::new(),
6718                },
6719                BrowserTranscriptEntry {
6720                    id: 7,
6721                    updated_seq: 8,
6722                    role: "agent",
6723                    label: "Agent".into(),
6724                    recorded_at_ms: None,
6725                    lines: vec!["live".into()],
6726                    glyph: "\u{25cf}",
6727                    tone: "agent",
6728                    tool_status: None,
6729                    diffstats: Vec::new(),
6730                },
6731            ],
6732        };
6733        let (app, _, _, _, _) =
6734            app_with_conversations(BTreeMap::from([("session-1".into(), transcript)]));
6735        let cookie = login_cookie(&app).await;
6736        let response = app
6737            .oneshot(
6738                Request::get("/api/conversations/session-1?after_seq=3")
6739                    .header(COOKIE, cookie)
6740                    .body(Body::empty())
6741                    .unwrap(),
6742            )
6743            .await
6744            .unwrap();
6745        assert_eq!(response.status(), StatusCode::OK);
6746        let body = response.into_body().collect().await.unwrap().to_bytes();
6747        let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
6748        assert_eq!(body["latest_seq"], 8);
6749        assert_eq!(body["reset"], false);
6750        assert_eq!(body["entries"].as_array().unwrap().len(), 1);
6751        assert_eq!(body["entries"][0]["lines"][0], "live");
6752    }
6753
6754    #[tokio::test]
6755    async fn conversation_endpoint_rejects_cached_transcript_during_transition() {
6756        let transcript = BrowserTranscript {
6757            latest_seq: 1,
6758            window_start_seq: 1,
6759            reset: false,
6760            entries: vec![BrowserTranscriptEntry {
6761                id: 1,
6762                updated_seq: 1,
6763                role: "agent",
6764                label: "Agent".into(),
6765                recorded_at_ms: None,
6766                lines: vec!["stale".into()],
6767                glyph: "●",
6768                tone: "agent",
6769                tool_status: None,
6770                diffstats: Vec::new(),
6771            }],
6772        };
6773        let (app, _, _, _, _) = app_with(
6774            BTreeMap::from([("session-1".into(), transcript)]),
6775            |snapshot| snapshot.sessions[0].transitioning = true,
6776        );
6777        let cookie = login_cookie(&app).await;
6778        let response = app
6779            .oneshot(
6780                Request::get("/api/conversations/session-1")
6781                    .header(COOKIE, cookie)
6782                    .body(Body::empty())
6783                    .unwrap(),
6784            )
6785            .await
6786            .unwrap();
6787        assert_eq!(response.status(), StatusCode::CONFLICT);
6788    }
6789
6790    #[tokio::test]
6791    async fn conversation_read_receipt_never_contends_with_a_running_action() {
6792        let (app, mut actions, mut receipts, _, _) = app();
6793        let cookie = login_cookie(&app).await;
6794        // A prompt for the same session stays in flight for the whole test, so
6795        // a receipt that still travelled the action pipeline would either
6796        // queue behind it or be rejected for the occupied session slot.
6797        let prompt = tokio::spawn(
6798            app.clone().oneshot(
6799                Request::post("/api/actions")
6800                    .header(COOKIE, cookie.clone())
6801                    .header(CONTENT_TYPE, "application/json")
6802                    .body(Body::from(
6803                        r#"{"action":"prompt","session_id":"session-1","text":"ship it"}"#,
6804                    ))
6805                    .unwrap(),
6806            ),
6807        );
6808        let action = actions.recv().await.unwrap();
6809
6810        let response = tokio::spawn(
6811            app.oneshot(
6812                Request::post("/api/conversations/session-1/read")
6813                    .header(COOKIE, cookie)
6814                    .header(CONTENT_TYPE, "application/json")
6815                    .body(Body::from(r#"{"through":42}"#))
6816                    .unwrap(),
6817            ),
6818        );
6819        let receipt = receipts.recv().await.unwrap();
6820        assert_eq!(receipt.session_id, "session-1");
6821        assert_eq!(receipt.through, 42);
6822        receipt.reply.send(Ok(())).unwrap();
6823        assert_eq!(
6824            response.await.unwrap().unwrap().status(),
6825            StatusCode::NO_CONTENT
6826        );
6827        assert!(
6828            actions.try_recv().is_err(),
6829            "a read receipt must not queue a controller action"
6830        );
6831
6832        action.reply.send(ActionOutcome::Accepted).unwrap();
6833        assert_eq!(
6834            prompt.await.unwrap().unwrap().status(),
6835            StatusCode::ACCEPTED
6836        );
6837    }
6838
6839    #[tokio::test]
6840    async fn each_rejected_action_keeps_its_own_status_and_guidance() {
6841        for (outcome, status, guidance) in [
6842            (
6843                ActionOutcome::Busy,
6844                StatusCode::TOO_MANY_REQUESTS,
6845                "concurrent action limit",
6846            ),
6847            (
6848                ActionOutcome::SessionBusy,
6849                StatusCode::CONFLICT,
6850                "another operation is already running",
6851            ),
6852            (
6853                ActionOutcome::NotCancellable,
6854                StatusCode::CONFLICT,
6855                "no cancellable operation",
6856            ),
6857            (
6858                ActionOutcome::Failed,
6859                StatusCode::INTERNAL_SERVER_ERROR,
6860                "could not start this action",
6861            ),
6862        ] {
6863            let (app, mut actions, _, _, _) = app();
6864            let cookie = login_cookie(&app).await;
6865            let response = tokio::spawn(
6866                app.oneshot(
6867                    Request::post("/api/actions")
6868                        .header(COOKIE, cookie)
6869                        .header(CONTENT_TYPE, "application/json")
6870                        .body(Body::from(r#"{"action":"close","session_id":"session-1"}"#))
6871                        .unwrap(),
6872                ),
6873            );
6874            let request = actions.recv().await.unwrap();
6875            request.reply.send(outcome).unwrap();
6876
6877            let response = response.await.unwrap().unwrap();
6878            assert_eq!(response.status(), status, "{outcome:?}");
6879            let body = response.into_body().collect().await.unwrap().to_bytes();
6880            let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
6881            let error = body["error"].as_str().unwrap();
6882            assert!(error.contains(guidance), "{outcome:?} answered {error:?}");
6883        }
6884    }
6885
6886    #[tokio::test]
6887    async fn the_viewer_shows_a_session_whose_action_failed_after_it_was_accepted() {
6888        // An accepted action reports its outcome only through snapshots, so
6889        // the application has to react to `has_error` for a late failure to be
6890        // visible at all.
6891        let (app, _, _, _, _) = app();
6892        let script = fetch_text(app, "/viewer.js").await;
6893        assert!(script.contains("has_error"), "viewer ignores has_error");
6894    }
6895
6896    /// Every response, not only the page, carries the policy. A header that
6897    /// depends on which handler answered is a header somebody will forget.
6898    #[tokio::test]
6899    async fn every_response_carries_the_security_headers() {
6900        for path in [
6901            "/",
6902            "/viewer.js",
6903            "/voice-worklet.js",
6904            "/voice-worker.js",
6905            "/viewer.css",
6906            "/manifest.webmanifest",
6907            "/api/snapshot",
6908        ] {
6909            let (app, _, _, _, _) = app();
6910            let response = app
6911                .oneshot(Request::get(path).body(Body::empty()).unwrap())
6912                .await
6913                .unwrap();
6914            let headers = response.headers();
6915            let policy = headers
6916                .get(CONTENT_SECURITY_POLICY_HEADER)
6917                .unwrap_or_else(|| panic!("{path} carries no content-security policy"))
6918                .to_str()
6919                .unwrap();
6920            assert!(
6921                policy.starts_with("default-src 'none';"),
6922                "{path} does not refuse unlisted sources: {policy}"
6923            );
6924            assert!(
6925                policy.contains("script-src 'self'") && !policy.contains("unsafe-inline"),
6926                "{path} permits inline script: {policy}"
6927            );
6928            assert!(
6929                policy.contains("frame-ancestors 'none'"),
6930                "{path} can be framed: {policy}"
6931            );
6932            assert_eq!(
6933                headers.get(X_CONTENT_TYPE_OPTIONS).unwrap(),
6934                "nosniff",
6935                "{path} permits content sniffing"
6936            );
6937            assert_eq!(
6938                headers.get(REFERRER_POLICY).unwrap(),
6939                "no-referrer",
6940                "{path} leaks a referrer"
6941            );
6942        }
6943    }
6944
6945    /// The policy forbids inline script and style, so the page must contain
6946    /// neither. A page that did would simply fail to run in a browser, which
6947    /// no Rust test would otherwise notice.
6948    #[tokio::test]
6949    async fn the_page_carries_no_inline_script_or_style() {
6950        let (app, _, _, _, _) = app();
6951        let page = fetch_text(app, "/").await;
6952        assert!(
6953            !page.contains("<script>") && !page.contains("<style>"),
6954            "the page inlines script or style, which the policy blocks"
6955        );
6956        assert!(
6957            page.contains(r#"src="/viewer.js""#) && page.contains(r#"href="/viewer.css""#),
6958            "the page does not load its script and style as separate assets"
6959        );
6960    }
6961
6962    /// A cached API answer is a lie about live session state, and a cached
6963    /// service worker is what keeps a phone on a superseded application.
6964    #[tokio::test]
6965    async fn live_state_and_the_service_worker_are_never_stored() {
6966        for path in ["/", "/service-worker.js", "/api/snapshot"] {
6967            let (app, _, _, _, _) = app();
6968            let response = app
6969                .oneshot(Request::get(path).body(Body::empty()).unwrap())
6970                .await
6971                .unwrap();
6972            assert_eq!(
6973                response.headers().get(CACHE_CONTROL).unwrap(),
6974                "no-store",
6975                "{path} may be stored"
6976            );
6977        }
6978    }
6979
6980    /// The worker must leave live state alone entirely rather than caching it
6981    /// and hoping the cache is fresh.
6982    #[test]
6983    fn the_service_worker_declines_to_handle_live_state() {
6984        assert!(
6985            SERVICE_WORKER.contains("url.pathname.startsWith('/api/')"),
6986            "the service worker does not exclude the API"
6987        );
6988        assert!(
6989            SERVICE_WORKER.contains("url.pathname.startsWith('/auth/')"),
6990            "the service worker does not exclude authentication"
6991        );
6992        assert!(
6993            SERVICE_WORKER.contains("caches.delete"),
6994            "the service worker never deletes a superseded cache"
6995        );
6996    }
6997
6998    /// The vendored assets have to reach the browser, not merely exist in the
6999    /// repository: the manifest names them and a phone installs from it.
7000    #[tokio::test]
7001    async fn the_installable_assets_are_served() {
7002        for (path, content_type) in [
7003            ("/icon-192.png", "image/png"),
7004            ("/icon-512.png", "image/png"),
7005            ("/maskable-512.png", "image/png"),
7006            ("/apple-touch-icon.png", "image/png"),
7007            ("/fonts/jetbrains-mono.woff2", "font/woff2"),
7008        ] {
7009            let (app, _, _, _, _) = app();
7010            let response = app
7011                .oneshot(Request::get(path).body(Body::empty()).unwrap())
7012                .await
7013                .unwrap();
7014            assert_eq!(response.status(), StatusCode::OK, "{path} is not served");
7015            assert_eq!(
7016                response.headers().get(CONTENT_TYPE).unwrap(),
7017                content_type,
7018                "{path} is served as the wrong type"
7019            );
7020        }
7021    }
7022
7023    /// Fetch one unauthenticated asset and return it as text. Serving the
7024    /// application from several files means a check about the application has
7025    /// to name the file it is about.
7026    async fn fetch_text(app: Router, path: &str) -> String {
7027        let response = app
7028            .oneshot(Request::get(path).body(Body::empty()).unwrap())
7029            .await
7030            .unwrap();
7031        assert_eq!(response.status(), StatusCode::OK, "{path} is not served");
7032        let body = response.into_body().collect().await.unwrap().to_bytes();
7033        String::from_utf8(body.to_vec()).expect("assets are UTF-8")
7034    }
7035
7036    #[tokio::test]
7037    async fn repeated_wrong_codes_lock_the_login_endpoint() {
7038        let (app, _, _, _, _) = app();
7039        let attempt = |code: &'static str| {
7040            let app = app.clone();
7041            async move {
7042                app.oneshot(
7043                    Request::post("/auth/session")
7044                        .header(CONTENT_TYPE, "application/json")
7045                        .body(Body::from(format!(r#"{{"code":"{code}"}}"#)))
7046                        .unwrap(),
7047                )
7048                .await
7049                .unwrap()
7050                .status()
7051            }
7052        };
7053        for _ in 0..MAX_CODE_FAILURES {
7054            assert_eq!(attempt("000000").await, StatusCode::UNAUTHORIZED);
7055        }
7056        assert_eq!(attempt("000000").await, StatusCode::TOO_MANY_REQUESTS);
7057        // Even the right code waits out the lockout, so guessing cannot be
7058        // hidden behind a correct-looking attempt.
7059        assert_eq!(attempt("123456").await, StatusCode::TOO_MANY_REQUESTS);
7060    }
7061
7062    #[test]
7063    fn viewer_code_lockouts_lengthen_instead_of_resetting_after_every_wait() {
7064        let serve_one_lockout = |guard: &mut CodeGuard, now: Instant| {
7065            for _ in 0..MAX_CODE_FAILURES {
7066                assert!(!guard.locked_at(now));
7067                guard.record_failure_at(now);
7068            }
7069            assert!(guard.locked_at(now));
7070            guard.locked_until.expect("the guard is locked") - now
7071        };
7072
7073        let start = Instant::now();
7074        let mut guard = CodeGuard::default();
7075        let first = serve_one_lockout(&mut guard, start);
7076        assert_eq!(first, CODE_LOCKOUT_BASE);
7077
7078        // Waiting out a lockout buys another run of attempts, not another
7079        // equally short lockout: a guard that reset here gave an attacker
7080        // MAX_CODE_FAILURES guesses every CODE_LOCKOUT_BASE for ever.
7081        let second_round = start + first;
7082        let second = serve_one_lockout(&mut guard, second_round);
7083        assert_eq!(second, CODE_LOCKOUT_BASE * 2);
7084        let third = serve_one_lockout(&mut guard, second_round + second);
7085        assert_eq!(third, CODE_LOCKOUT_BASE * 4);
7086        assert_eq!(code_lockout(u32::MAX), CODE_LOCKOUT_CAP);
7087
7088        // A correct code clears the history, so one mistyped digit tomorrow
7089        // still costs only the shortest wait.
7090        let mut recovered = CodeGuard::default();
7091        assert_eq!(serve_one_lockout(&mut recovered, start), CODE_LOCKOUT_BASE);
7092    }
7093
7094    #[test]
7095    fn persisted_cookie_key_survives_a_restart_and_stays_owner_only() {
7096        let directory = tempfile::tempdir().unwrap();
7097        let path = directory.path().join("phone-cookie-key");
7098
7099        let first = load_or_create_cookie_key(&path).unwrap();
7100        assert!(first.len() >= COOKIE_KEY_BYTES);
7101        assert_eq!(std::fs::read(&path).unwrap(), first);
7102        #[cfg(unix)]
7103        {
7104            use std::os::unix::fs::PermissionsExt as _;
7105            let mode = std::fs::metadata(&path).unwrap().permissions().mode();
7106            assert_eq!(mode & 0o777, 0o600);
7107        }
7108
7109        // Two server processes started from the same key file honour each
7110        // other's cookies; a process that kept its generated key would not.
7111        let mut restarted = detached_options();
7112        restarted
7113            .set_cookie_key(load_or_create_cookie_key(&path).unwrap())
7114            .unwrap();
7115        let mut original = detached_options();
7116        original.set_cookie_key(first.clone()).unwrap();
7117        let cookie = signed_cookie_value(&original.cookie_key, "test-viewer", 200);
7118        assert!(session_cookie_valid(&restarted.cookie_key, &cookie, 100));
7119        assert!(!session_cookie_valid(
7120            &detached_options().cookie_key,
7121            &cookie,
7122            100
7123        ));
7124
7125        // Deleting the key file is the explicit sign-everyone-out gesture.
7126        std::fs::remove_file(&path).unwrap();
7127        let rotated = load_or_create_cookie_key(&path).unwrap();
7128        assert_ne!(rotated, first);
7129        assert!(!session_cookie_valid(&rotated, &cookie, 100));
7130    }
7131
7132    #[test]
7133    fn corrupt_cookie_key_is_regenerated_instead_of_blocking_startup() {
7134        let directory = tempfile::tempdir().unwrap();
7135        let path = directory.path().join("phone-cookie-key");
7136        std::fs::write(&path, b"short").unwrap();
7137
7138        let key = load_or_create_cookie_key(&path).unwrap();
7139
7140        assert!(key.len() >= COOKIE_KEY_BYTES);
7141        assert_eq!(std::fs::read(&path).unwrap(), key);
7142        assert_eq!(load_or_create_cookie_key(&path).unwrap(), key);
7143    }
7144}