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