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