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