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