Skip to main content

mj_controller/
server.rs

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