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