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