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