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 theme: Default::default(),
3633 phone: Default::default(),
3634 review: Default::default(),
3635 startup: Default::default(),
3636 profiles: BTreeMap::from([(
3637 "codex-1".into(),
3638 HarnessProfile {
3639 context_window_bytes: None,
3640 kind: HarnessKind::Codex,
3641 home: "/highly/secret/codex".into(),
3642 environment: BTreeMap::from([("GH_TOKEN".into(), "secret-token".into())]),
3643 },
3644 )]),
3645 bundles: BTreeMap::from([(
3646 "hel".into(),
3647 ProjectBundle {
3648 primary_repo: "hel".into(),
3649 repositories: vec![ProjectRepository {
3650 id: "hel".into(),
3651 github: Some("owner/hel".into()),
3652 local: Some("/private/source/hel".into()),
3653 destination: "hel".into(),
3654 git_ref: None,
3655 }],
3656 },
3657 )]),
3658 targets: BTreeMap::from([
3659 (
3660 "podman".into(),
3661 TargetTemplate::LocalPodman {
3662 container: ContainerTemplate {
3663 image: "secret.registry/image".into(),
3664 pull_policy: Default::default(),
3665 platform: None,
3666 cpus: None,
3667 memory: None,
3668 environment: BTreeMap::from([("TOKEN".into(), "secret-target".into())]),
3669 workspace_storage: Default::default(),
3670 },
3671 },
3672 ),
3673 ("raw".into(), TargetTemplate::LocalBare),
3674 ]),
3675 };
3676 let state = HelState {
3677 version: STATE_VERSION,
3678 sessions: BTreeMap::from([(
3679 "session-1".into(),
3680 SessionRecord {
3681 workspace_id: hel::hel_workspace::DEFAULT_WORKSPACE_ID.to_owned(),
3682 archived: false,
3683 container_cpus: None,
3684 container_memory: None,
3685 id: "session-1".into(),
3686 title: "Build Hel".into(),
3687 harness_kind: HarnessKind::Codex,
3688 last_profile: "codex-1".into(),
3689 bundle_id: "hel".into(),
3690 project_directory: None,
3691 managed_worktree: None,
3692 target_template_id: "podman".into(),
3693 resource_allocation: None,
3694 additional_mounts: vec![],
3695 state: SessionState::Running,
3696 target: None,
3697 native_session_id: Some("native-secret-id".into()),
3698 acp_session_title: Some("Build Hel".into()),
3699 session_title_override: None,
3700 created_at: "now".into(),
3701 updated_at: "now".into(),
3702 viewed_through_event_ordinal: 0,
3703 draft_input: String::new(),
3704 last_error: Some("secret-token at /highly/secret/codex".into()),
3705 last_checkpoint_error: None,
3706 checkpoint: None,
3707 },
3708 )]),
3709 mount_history: BTreeMap::new(),
3710 container_sizes: BTreeMap::new(),
3711 };
3712 (config, state)
3713 }
3714
3715 type TestServer = (
3716 Router,
3717 mpsc::Receiver<ControllerRequest>,
3718 mpsc::Receiver<ReadReceiptRequest>,
3719 mpsc::Receiver<PreflightRequest>,
3720 mpsc::Receiver<ClientStateRequest>,
3721 );
3722
3723 fn app() -> TestServer {
3724 app_with_conversations(BTreeMap::new())
3725 }
3726
3727 fn app_with_move_receiver() -> (Router, mpsc::Receiver<MovePreparationRequest>) {
3728 let (config, state) = sample_config_state();
3729 let mut snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
3730 snapshot.sessions[0].capabilities.move_session = true;
3731 let (_snapshot_tx, snapshot_rx) = watch::channel(snapshot);
3732 let (_conversation_tx, conversation_rx) = watch::channel(BTreeMap::new());
3733 let (action_tx, _action_rx) = mpsc::channel(8);
3734 let (bundle_tx, _bundle_rx) = mpsc::channel(8);
3735 let (receipt_tx, _receipt_rx) = mpsc::channel(8);
3736 let (preflight_tx, _preflight_rx) = mpsc::channel(8);
3737 let (move_preparation_tx, move_preparation_rx) = mpsc::channel(8);
3738 let (client_state_tx, _client_state_rx) = mpsc::channel(8);
3739 let options = test_options(
3740 snapshot_rx,
3741 conversation_rx,
3742 action_tx,
3743 bundle_tx,
3744 receipt_tx,
3745 preflight_tx,
3746 move_preparation_tx,
3747 client_state_tx,
3748 )
3749 .with_test_credentials("123456", b"01234567890123456789012345678901");
3750 (router(options), move_preparation_rx)
3751 }
3752
3753 fn app_with_conversations(conversations: BTreeMap<String, BrowserTranscript>) -> TestServer {
3754 app_with(conversations, |_| {})
3755 }
3756
3757 fn app_with_snapshot(adjust: impl FnOnce(&mut ViewerSnapshot)) -> TestServer {
3758 app_with(BTreeMap::new(), adjust)
3759 }
3760
3761 fn app_with(
3762 conversations: BTreeMap<String, BrowserTranscript>,
3763 adjust: impl FnOnce(&mut ViewerSnapshot),
3764 ) -> TestServer {
3765 let (config, state) = sample_config_state();
3766 let mut snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
3767 adjust(&mut snapshot);
3768 let (_snapshot_tx, snapshot_rx) = watch::channel(snapshot);
3769 let (_conversation_tx, conversation_rx) = watch::channel(conversations);
3770 let (action_tx, action_rx) = mpsc::channel(8);
3771 let (bundle_tx, _bundle_rx) = mpsc::channel(8);
3772 let (receipt_tx, receipt_rx) = mpsc::channel(8);
3773 let (preflight_tx, preflight_rx) = mpsc::channel(8);
3774 let (move_preparation_tx, _move_preparation_rx) = mpsc::channel(8);
3775 let (client_state_tx, client_state_rx) = mpsc::channel(8);
3776 let options = test_options(
3777 snapshot_rx,
3778 conversation_rx,
3779 action_tx,
3780 bundle_tx,
3781 receipt_tx,
3782 preflight_tx,
3783 move_preparation_tx,
3784 client_state_tx,
3785 )
3786 .with_test_credentials("123456", b"01234567890123456789012345678901");
3787 (
3788 router(options),
3789 action_rx,
3790 receipt_rx,
3791 preflight_rx,
3792 client_state_rx,
3793 )
3794 }
3795
3796 fn app_with_bundle_receiver() -> (Router, mpsc::Receiver<BundleRequest>) {
3797 let (config, state) = sample_config_state();
3798 let (_snapshot_tx, snapshot_rx) =
3799 watch::channel(ViewerSnapshot::from_config_state(&config, &state, 1));
3800 let (_conversation_tx, conversation_rx) = watch::channel(BTreeMap::new());
3801 let (action_tx, _action_rx) = mpsc::channel(8);
3802 let (bundle_tx, bundle_rx) = mpsc::channel(8);
3803 let (receipt_tx, _receipt_rx) = mpsc::channel(8);
3804 let (preflight_tx, _preflight_rx) = mpsc::channel(8);
3805 let (move_preparation_tx, _move_preparation_rx) = mpsc::channel(8);
3806 let (client_state_tx, _client_state_rx) = mpsc::channel(8);
3807 let options = test_options(
3808 snapshot_rx,
3809 conversation_rx,
3810 action_tx,
3811 bundle_tx,
3812 receipt_tx,
3813 preflight_tx,
3814 move_preparation_tx,
3815 client_state_tx,
3816 )
3817 .with_test_credentials("123456", b"01234567890123456789012345678901");
3818 (router(options), bundle_rx)
3819 }
3820
3821 #[allow(clippy::too_many_arguments)]
3824 fn test_options(
3825 snapshot_rx: watch::Receiver<ViewerSnapshot>,
3826 conversation_rx: watch::Receiver<BTreeMap<String, BrowserTranscript>>,
3827 action_tx: mpsc::Sender<ControllerRequest>,
3828 bundle_tx: mpsc::Sender<BundleRequest>,
3829 receipt_tx: mpsc::Sender<ReadReceiptRequest>,
3830 preflight_tx: mpsc::Sender<PreflightRequest>,
3831 move_preparation_tx: mpsc::Sender<MovePreparationRequest>,
3832 client_state_tx: mpsc::Sender<ClientStateRequest>,
3833 ) -> ServerOptions {
3834 test_options_with_dictation(
3835 snapshot_rx,
3836 conversation_rx,
3837 action_tx,
3838 bundle_tx,
3839 receipt_tx,
3840 preflight_tx,
3841 move_preparation_tx,
3842 client_state_tx,
3843 )
3844 .0
3845 }
3846
3847 #[allow(clippy::too_many_arguments)]
3848 fn test_options_with_dictation(
3849 snapshot_rx: watch::Receiver<ViewerSnapshot>,
3850 conversation_rx: watch::Receiver<BTreeMap<String, BrowserTranscript>>,
3851 action_tx: mpsc::Sender<ControllerRequest>,
3852 bundle_tx: mpsc::Sender<BundleRequest>,
3853 receipt_tx: mpsc::Sender<ReadReceiptRequest>,
3854 preflight_tx: mpsc::Sender<PreflightRequest>,
3855 move_preparation_tx: mpsc::Sender<MovePreparationRequest>,
3856 client_state_tx: mpsc::Sender<ClientStateRequest>,
3857 ) -> (ServerOptions, mpsc::Receiver<DictationRequest>) {
3858 let (dictation_tx, dictation_rx) = mpsc::channel(8);
3859 let options = ServerOptions::new(
3860 "127.0.0.1:0".parse().unwrap(),
3861 snapshot_rx,
3862 conversation_rx,
3863 ServerRequests {
3864 action_tx,
3865 bundle_tx,
3866 receipt_tx,
3867 preflight_tx,
3868 move_preparation_tx,
3869 client_state_tx,
3870 dictation_tx,
3871 },
3872 )
3873 .unwrap();
3874 (options, dictation_rx)
3875 }
3876
3877 fn app_with_dictation_receiver() -> (Router, mpsc::Receiver<DictationRequest>) {
3878 let (config, state) = sample_config_state();
3879 let (_snapshot_tx, snapshot_rx) =
3880 watch::channel(ViewerSnapshot::from_config_state(&config, &state, 1));
3881 let (_conversation_tx, conversation_rx) = watch::channel(BTreeMap::new());
3882 let (action_tx, _action_rx) = mpsc::channel(8);
3883 let (bundle_tx, _bundle_rx) = mpsc::channel(8);
3884 let (receipt_tx, _receipt_rx) = mpsc::channel(8);
3885 let (preflight_tx, _preflight_rx) = mpsc::channel(8);
3886 let (move_preparation_tx, _move_preparation_rx) = mpsc::channel(8);
3887 let (client_state_tx, _client_state_rx) = mpsc::channel(8);
3888 let (options, dictation_rx) = test_options_with_dictation(
3889 snapshot_rx,
3890 conversation_rx,
3891 action_tx,
3892 bundle_tx,
3893 receipt_tx,
3894 preflight_tx,
3895 move_preparation_tx,
3896 client_state_tx,
3897 );
3898 (
3899 router(options.with_test_credentials("123456", b"01234567890123456789012345678901")),
3900 dictation_rx,
3901 )
3902 }
3903
3904 fn detached_options() -> ServerOptions {
3905 let (config, state) = sample_config_state();
3906 let (_snapshot_tx, snapshot_rx) =
3907 watch::channel(ViewerSnapshot::from_config_state(&config, &state, 1));
3908 let (_conversation_tx, conversation_rx) = watch::channel(BTreeMap::new());
3909 let (action_tx, _action_rx) = mpsc::channel(1);
3910 let (bundle_tx, _bundle_rx) = mpsc::channel(1);
3911 let (receipt_tx, _receipt_rx) = mpsc::channel(1);
3912 let (preflight_tx, _preflight_rx) = mpsc::channel(1);
3913 let (move_preparation_tx, _move_preparation_rx) = mpsc::channel(1);
3914 let (client_state_tx, _client_state_rx) = mpsc::channel(1);
3915 test_options(
3916 snapshot_rx,
3917 conversation_rx,
3918 action_tx,
3919 bundle_tx,
3920 receipt_tx,
3921 preflight_tx,
3922 move_preparation_tx,
3923 client_state_tx,
3924 )
3925 }
3926
3927 fn cookie() -> String {
3933 format!(
3934 "{COOKIE_NAME}={}",
3935 signed_cookie_value(
3936 b"01234567890123456789012345678901",
3937 "test-viewer",
3938 now_unix().saturating_add(3600)
3939 )
3940 )
3941 }
3942
3943 fn valid_wav() -> Bytes {
3944 let samples = vec![0_u8; 320];
3945 let mut wav = Vec::with_capacity(44 + samples.len());
3946 wav.extend_from_slice(b"RIFF");
3947 wav.extend_from_slice(&(36_u32 + samples.len() as u32).to_le_bytes());
3948 wav.extend_from_slice(b"WAVEfmt ");
3949 wav.extend_from_slice(&16_u32.to_le_bytes());
3950 wav.extend_from_slice(&1_u16.to_le_bytes());
3951 wav.extend_from_slice(&1_u16.to_le_bytes());
3952 wav.extend_from_slice(&16_000_u32.to_le_bytes());
3953 wav.extend_from_slice(&32_000_u32.to_le_bytes());
3954 wav.extend_from_slice(&2_u16.to_le_bytes());
3955 wav.extend_from_slice(&16_u16.to_le_bytes());
3956 wav.extend_from_slice(b"data");
3957 wav.extend_from_slice(&(samples.len() as u32).to_le_bytes());
3958 wav.extend_from_slice(&samples);
3959 Bytes::from(wav)
3960 }
3961
3962 async fn login_cookie(app: &Router) -> String {
3963 let response = app
3964 .clone()
3965 .oneshot(
3966 Request::post("/auth/session")
3967 .header(CONTENT_TYPE, "application/json")
3968 .body(Body::from(r#"{"code":"123456"}"#))
3969 .unwrap(),
3970 )
3971 .await
3972 .unwrap();
3973 assert_eq!(response.status(), StatusCode::NO_CONTENT);
3974 response
3975 .headers()
3976 .get(SET_COOKIE)
3977 .unwrap()
3978 .to_str()
3979 .unwrap()
3980 .split(';')
3981 .next()
3982 .unwrap()
3983 .to_string()
3984 }
3985
3986 #[tokio::test]
3987 async fn dictation_availability_requires_auth_and_forwards_typed_request() {
3988 let (app, mut requests) = app_with_dictation_receiver();
3989 let unauthorized = app
3990 .clone()
3991 .oneshot(
3992 Request::get("/api/sessions/session-1/dictation")
3993 .body(Body::empty())
3994 .unwrap(),
3995 )
3996 .await
3997 .unwrap();
3998 assert_eq!(unauthorized.status(), StatusCode::UNAUTHORIZED);
3999 assert!(requests.try_recv().is_err());
4000
4001 let cookie = login_cookie(&app).await;
4002 let pending = tokio::spawn({
4003 let app = app.clone();
4004 async move {
4005 app.oneshot(
4006 Request::get("/api/sessions/session-1/dictation")
4007 .header(COOKIE, cookie)
4008 .body(Body::empty())
4009 .unwrap(),
4010 )
4011 .await
4012 .unwrap()
4013 }
4014 });
4015 let request = requests.recv().await.unwrap();
4016 assert_eq!(request.session_id, "session-1");
4017 assert!(matches!(
4018 request.operation,
4019 DictationOperation::Availability
4020 ));
4021 request
4022 .reply
4023 .send(Ok(DictationResponse::Availability {
4024 available: true,
4025 reason: None,
4026 }))
4027 .unwrap();
4028 let response = pending.await.unwrap();
4029 assert_eq!(response.status(), StatusCode::OK);
4030 let body = response.into_body().collect().await.unwrap().to_bytes();
4031 assert_eq!(&body[..], br#"{"available":true}"#);
4032 }
4033
4034 #[tokio::test]
4035 async fn dictation_rejects_bad_wav_before_controller_dispatch() {
4036 let (app, mut requests) = app_with_dictation_receiver();
4037 let cookie = login_cookie(&app).await;
4038 let response = app
4039 .oneshot(
4040 Request::post("/api/sessions/session-1/dictation")
4041 .header(COOKIE, cookie)
4042 .header(CONTENT_TYPE, "audio/wav")
4043 .body(Body::from("not wav"))
4044 .unwrap(),
4045 )
4046 .await
4047 .unwrap();
4048 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
4049 assert!(requests.try_recv().is_err());
4050 }
4051
4052 #[tokio::test]
4053 async fn dictation_rejects_a_third_upload_before_reading_its_body() {
4054 let (app, mut requests) = app_with_dictation_receiver();
4055 let cookie = login_cookie(&app).await;
4056 let request = || {
4057 Request::post("/api/sessions/session-1/dictation")
4058 .header(COOKIE, cookie.clone())
4059 .header(CONTENT_TYPE, "audio/wav")
4060 .body(Body::from(valid_wav()))
4061 .unwrap()
4062 };
4063 let first = tokio::spawn({
4064 let app = app.clone();
4065 let request = request();
4066 async move { app.oneshot(request).await.unwrap() }
4067 });
4068 let second = tokio::spawn({
4069 let app = app.clone();
4070 let request = request();
4071 async move { app.oneshot(request).await.unwrap() }
4072 });
4073 let first_request = requests.recv().await.unwrap();
4074 let second_request = requests.recv().await.unwrap();
4075 let third = app
4076 .oneshot(
4077 Request::post("/api/sessions/session-1/dictation")
4078 .header(COOKIE, cookie)
4079 .header(CONTENT_TYPE, "audio/wav")
4080 .body(Body::from_stream(futures::stream::poll_fn(
4081 |_| -> std::task::Poll<Option<Result<Bytes, std::io::Error>>> {
4082 panic!("overloaded dictation polled its body")
4083 },
4084 )))
4085 .unwrap(),
4086 )
4087 .await
4088 .unwrap();
4089 assert_eq!(third.status(), StatusCode::TOO_MANY_REQUESTS);
4090 first_request
4091 .reply
4092 .send(Ok(DictationResponse::Transcript {
4093 text: "first".into(),
4094 }))
4095 .unwrap();
4096 second_request
4097 .reply
4098 .send(Ok(DictationResponse::Transcript {
4099 text: "second".into(),
4100 }))
4101 .unwrap();
4102 assert_eq!(first.await.unwrap().status(), StatusCode::OK);
4103 assert_eq!(second.await.unwrap().status(), StatusCode::OK);
4104 }
4105
4106 #[tokio::test]
4107 async fn dictation_upload_rejects_unauthorized_missing_and_oversized_requests() {
4108 let (app, mut requests) = app_with_dictation_receiver();
4109 let response = app
4110 .clone()
4111 .oneshot(
4112 Request::post("/api/sessions/session-1/dictation")
4113 .body(Body::from(valid_wav()))
4114 .unwrap(),
4115 )
4116 .await
4117 .unwrap();
4118 assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
4119 let cookie = login_cookie(&app).await;
4120 let response = app
4121 .clone()
4122 .oneshot(
4123 Request::post("/api/sessions/missing/dictation")
4124 .header(COOKIE, &cookie)
4125 .body(Body::from(valid_wav()))
4126 .unwrap(),
4127 )
4128 .await
4129 .unwrap();
4130 assert_eq!(response.status(), StatusCode::NOT_FOUND);
4131 let response = app
4133 .oneshot(
4134 Request::post("/api/sessions/session-1/dictation")
4135 .header(COOKIE, cookie)
4136 .body(Body::from(vec![0_u8; MAX_AUDIO_BYTES + 1]))
4137 .unwrap(),
4138 )
4139 .await
4140 .unwrap();
4141 assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE);
4142 assert!(requests.try_recv().is_err());
4143 }
4144
4145 #[tokio::test]
4146 async fn dictation_provider_failure_is_actionable_and_does_not_expose_details() {
4147 let (app, mut requests) = app_with_dictation_receiver();
4148 let cookie = login_cookie(&app).await;
4149 let pending = tokio::spawn(async move {
4150 app.oneshot(
4151 Request::post("/api/sessions/session-1/dictation")
4152 .header(COOKIE, cookie)
4153 .body(Body::from(valid_wav()))
4154 .unwrap(),
4155 )
4156 .await
4157 .unwrap()
4158 });
4159 let request = requests.recv().await.unwrap();
4160 request
4161 .reply
4162 .send(Err(DictationError::Provider(
4163 "private provider details".into(),
4164 )))
4165 .unwrap();
4166 let response = pending.await.unwrap();
4167 assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
4168 let bytes = response.into_body().collect().await.unwrap().to_bytes();
4169 let body = String::from_utf8(bytes.to_vec()).unwrap();
4170 assert!(body.contains("transcription"));
4171 assert!(!body.contains("private provider details"));
4172 }
4173
4174 #[tokio::test]
4175 async fn dropped_dictation_handler_cancels_controller_request() {
4176 let (app, mut requests) = app_with_dictation_receiver();
4177 let cookie = login_cookie(&app).await;
4178 let pending = tokio::spawn({
4179 let app = app.clone();
4180 async move {
4181 app.oneshot(
4182 Request::post("/api/sessions/session-1/dictation")
4183 .header(COOKIE, cookie)
4184 .body(Body::from(valid_wav()))
4185 .unwrap(),
4186 )
4187 .await
4188 .unwrap()
4189 }
4190 });
4191 let request = requests.recv().await.unwrap();
4192 let cancel = request.cancel.clone();
4193 pending.abort();
4194 let _ = pending.await;
4195 assert!(cancel.is_cancelled());
4196 drop(request);
4197 }
4198
4199 #[tokio::test]
4200 async fn bundle_endpoint_authenticates_and_forwards_the_source() {
4201 let (app, mut bundles) = app_with_bundle_receiver();
4202 let unauthorized = app
4203 .clone()
4204 .oneshot(
4205 Request::post("/api/bundles")
4206 .header(CONTENT_TYPE, "application/json")
4207 .body(Body::from(r#"{"source":"example/app"}"#))
4208 .unwrap(),
4209 )
4210 .await
4211 .unwrap();
4212 assert_eq!(unauthorized.status(), StatusCode::UNAUTHORIZED);
4213 assert!(bundles.try_recv().is_err());
4214
4215 let cookie = login_cookie(&app).await;
4216 let response = tokio::spawn({
4217 let app = app.clone();
4218 let cookie = cookie.clone();
4219 async move {
4220 app.oneshot(
4221 Request::post("/api/bundles")
4222 .header(CONTENT_TYPE, "application/json")
4223 .header(COOKIE, cookie)
4224 .body(Body::from(r#"{"source":"example/app"}"#))
4225 .unwrap(),
4226 )
4227 .await
4228 .unwrap()
4229 }
4230 });
4231 let request = bundles.recv().await.expect("bundle request forwarded");
4232 assert_eq!(request.source, "example/app");
4233 request.reply.send(Ok("app".into())).unwrap();
4234 let response = response.await.unwrap();
4235 assert_eq!(response.status(), StatusCode::OK);
4236 let body = response.into_body().collect().await.unwrap().to_bytes();
4237 assert_eq!(body.as_ref(), br#"{"bundle_id":"app"}"#);
4238 }
4239
4240 #[tokio::test]
4241 async fn bundle_endpoint_rejects_empty_and_oversized_sources_before_dispatch() {
4242 for source in [String::new(), "x".repeat(MAX_BUNDLE_SOURCE_CHARS + 1)] {
4243 let (app, mut bundles) = app_with_bundle_receiver();
4244 let cookie = login_cookie(&app).await;
4245 let response = app
4246 .oneshot(
4247 Request::post("/api/bundles")
4248 .header(CONTENT_TYPE, "application/json")
4249 .header(COOKIE, cookie)
4250 .body(Body::from(
4251 serde_json::to_string(&serde_json::json!({"source": source})).unwrap(),
4252 ))
4253 .unwrap(),
4254 )
4255 .await
4256 .unwrap();
4257 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
4258 assert!(bundles.try_recv().is_err());
4259 }
4260 }
4261
4262 #[tokio::test]
4263 async fn bundle_endpoint_reports_invalid_source_as_a_client_error() {
4264 let (app, mut bundles) = app_with_bundle_receiver();
4265 let cookie = login_cookie(&app).await;
4266 let response = tokio::spawn({
4267 let app = app.clone();
4268 async move {
4269 app.oneshot(
4270 Request::post("/api/bundles")
4271 .header(CONTENT_TYPE, "application/json")
4272 .header(COOKIE, cookie)
4273 .body(Body::from(r#"{"source":"not a source"}"#))
4274 .unwrap(),
4275 )
4276 .await
4277 .unwrap()
4278 }
4279 });
4280 let request = bundles.recv().await.expect("bundle request forwarded");
4281 request
4282 .reply
4283 .send(Err(BundleFailure::InvalidSource))
4284 .unwrap();
4285 let response = response.await.unwrap();
4286 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
4287 let body = response.into_body().collect().await.unwrap().to_bytes();
4288 assert!(String::from_utf8_lossy(&body).contains("GitHub owner/repository"));
4289 }
4290
4291 #[tokio::test]
4292 async fn api_requires_a_valid_signed_cookie() {
4293 let (app, _, _, _, _) = app();
4294 let unauthorized = app
4295 .clone()
4296 .oneshot(Request::get("/api/snapshot").body(Body::empty()).unwrap())
4297 .await
4298 .unwrap();
4299 assert_eq!(unauthorized.status(), StatusCode::UNAUTHORIZED);
4300
4301 let cookie = login_cookie(&app).await;
4302 let authorized = app
4303 .oneshot(
4304 Request::get("/api/snapshot")
4305 .header(COOKIE, cookie)
4306 .body(Body::empty())
4307 .unwrap(),
4308 )
4309 .await
4310 .unwrap();
4311 assert_eq!(authorized.status(), StatusCode::OK);
4312 }
4313
4314 #[tokio::test]
4315 async fn qr_login_exchanges_the_secret_for_a_cookie_and_redirects_cleanly() {
4316 let (app, _, _, _, _) = app();
4317 let rejected = app
4318 .clone()
4319 .oneshot(
4320 Request::get("/auth/login?token=wrong")
4321 .body(Body::empty())
4322 .unwrap(),
4323 )
4324 .await
4325 .unwrap();
4326 assert_eq!(rejected.status(), StatusCode::UNAUTHORIZED);
4327
4328 let accepted = app
4329 .oneshot(
4330 Request::get("/auth/login?token=test-login-token")
4331 .body(Body::empty())
4332 .unwrap(),
4333 )
4334 .await
4335 .unwrap();
4336 assert_eq!(accepted.status(), StatusCode::SEE_OTHER);
4337 assert_eq!(accepted.headers().get(LOCATION).unwrap(), "/");
4338 assert_eq!(accepted.headers().get(CACHE_CONTROL).unwrap(), "no-store");
4339 assert!(accepted.headers().contains_key(SET_COOKIE));
4340 }
4341
4342 #[test]
4343 fn signed_cookie_rejects_expiry_and_tampering() {
4344 let key = b"01234567890123456789012345678901";
4345 let cookie = signed_cookie_value(key, "test-viewer", 200);
4346 assert!(session_cookie_valid(key, &cookie, 100));
4347 assert!(!session_cookie_valid(key, &cookie, 200));
4348 assert!(!session_cookie_valid(key, &format!("{cookie}x"), 100));
4349 assert!(!session_cookie_valid(b"another-key", &cookie, 100));
4350 }
4351
4352 #[test]
4353 fn generated_code_and_cookie_attributes_are_phone_safe() {
4354 let code = generate_viewer_code().unwrap();
4355 assert_eq!(code.len(), 6);
4356 assert!(code.bytes().all(|byte| byte.is_ascii_digit()));
4357 let header = session_cookie_header("signed", Some(60), true)
4358 .unwrap()
4359 .to_str()
4360 .unwrap()
4361 .to_string();
4362 assert!(header.contains("HttpOnly"));
4363 assert!(header.contains("SameSite=Strict"));
4364 assert!(header.contains("Secure"));
4365 assert!(header.contains("Max-Age=60"));
4366 }
4367
4368 #[test]
4369 fn public_snapshot_omits_homes_environment_locators_and_raw_errors() {
4370 let (config, state) = sample_config_state();
4371 let json =
4372 serde_json::to_string(&ViewerSnapshot::from_config_state(&config, &state, 9)).unwrap();
4373 assert!(!json.contains("/highly/secret"));
4374 assert!(!json.contains("secret-token"));
4375 assert!(!json.contains("secret-target"));
4376 assert!(!json.contains("secret.registry"));
4377 assert!(!json.contains("native-secret-id"));
4378 assert!(json.contains("\"has_error\":true"));
4379 }
4380
4381 #[test]
4382 fn target_snapshot_uses_each_raw_host_project_history_and_leaves_managed_empty() {
4383 let (mut config, mut state) = sample_config_state();
4384 config
4385 .targets
4386 .insert("raw-local".into(), TargetTemplate::LocalBare);
4387 config.targets.insert(
4388 "raw-builder".into(),
4389 TargetTemplate::SshBare {
4390 ssh: SshConnection {
4391 host: "builder-a".into(),
4392 user: None,
4393 identity_file: None,
4394 extra_args: Vec::new(),
4395 },
4396 permissions: PermissionMode::Guardian,
4397 workspace_prefix: "workspaces".into(),
4398 },
4399 );
4400 state.remember_project_directory("local", Path::new("/work/local"));
4401 state.remember_project_directory("builder-a", Path::new("/srv/builder"));
4402 state.remember_project_directory("other-host", Path::new("/not-published"));
4403
4404 let snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
4405 let target = |id: &str| {
4406 snapshot
4407 .targets
4408 .iter()
4409 .find(|target| target.id == id)
4410 .unwrap()
4411 };
4412 assert_eq!(
4413 target("raw-local").recent_project_directories,
4414 vec!["/work/local"]
4415 );
4416 assert_eq!(
4417 target("raw-builder").recent_project_directories,
4418 vec!["/srv/builder"]
4419 );
4420 assert!(target("podman").recent_project_directories.is_empty());
4421 }
4422
4423 #[test]
4424 fn public_snapshot_exposes_only_review_status_configuration() {
4425 let (mut config, state) = sample_config_state();
4426 config.review = hel::hel_config::ReviewConfig {
4427 enabled: true,
4428 tier: hel::hel_review::lanes::ReviewTier::Extended,
4429 profile: Some("reviewer-1".into()),
4430 model: Some("private-review-model".into()),
4431 effort: Some("private-review-effort".into()),
4432 };
4433
4434 let value =
4435 serde_json::to_value(ViewerSnapshot::from_config_state(&config, &state, 9)).unwrap();
4436
4437 assert_eq!(
4438 value.get("review_config"),
4439 Some(&serde_json::json!({
4440 "enabled": true,
4441 "tier": "extended",
4442 "profile": "reviewer-1",
4443 }))
4444 );
4445 let json = value.to_string();
4446 assert!(!json.contains("private-review-model"));
4447 assert!(!json.contains("private-review-effort"));
4448 }
4449
4450 fn sample_elicitation() -> ElicitationRequest {
4451 ElicitationRequest::from_acp_params(
4452 "elicitation-1",
4453 serde_json::json!({
4454 "sessionId": "session-1",
4455 "mode": "form",
4456 "message": "Which CI architecture should the workflow use?",
4457 "requestedSchema": {
4458 "type": "object",
4459 "required": ["question_0"],
4460 "properties": {
4461 "question_0": {
4462 "type": "string",
4463 "title": "CI architecture",
4464 "oneOf": [
4465 {"const": "reusable", "title": "Reusable workflow"},
4466 {"const": "matrix", "title": "Matrix job"}
4467 ]
4468 },
4469 "question_0_custom": {
4470 "type": "string",
4471 "title": "Other",
4472 "_meta": {"_askUserQuestionCustomAnswer": {
4473 "questionId": "question_0",
4474 "isCustomAnswer": true
4475 }}
4476 }
4477 }
4478 }
4479 }),
4480 )
4481 .expect("sample elicitation parses")
4482 }
4483
4484 fn accept(pairs: &[(&str, &str)]) -> ElicitationResponse {
4485 ElicitationResponse::Accept {
4486 content: pairs
4487 .iter()
4488 .map(|(id, value)| {
4489 (
4490 (*id).to_owned(),
4491 hel::hel_elicitation::ElicitationValue::String((*value).to_owned()),
4492 )
4493 })
4494 .collect(),
4495 }
4496 }
4497
4498 fn pending_elicitation_snapshot(snapshot: &mut ViewerSnapshot) {
4499 snapshot.sessions[0].pending_elicitations = vec![sample_elicitation()];
4500 }
4501
4502 #[tokio::test]
4503 async fn elicitation_answer_is_typed_and_forwarded() {
4504 let (app, mut actions, _, _, _) = app_with_snapshot(pending_elicitation_snapshot);
4505 let cookie = login_cookie(&app).await;
4506 let response = tokio::spawn(
4507 app.oneshot(
4508 Request::post("/api/actions")
4509 .header(COOKIE, cookie)
4510 .header(CONTENT_TYPE, "application/json")
4511 .body(Body::from(
4512 r#"{"action":"respond-elicitation","session_id":"session-1","elicitation_id":"elicitation-1","response":{"action":"accept","content":{"question_0":"reusable"}}}"#,
4513 ))
4514 .unwrap(),
4515 ),
4516 );
4517 let action = actions.recv().await.unwrap();
4518 assert_eq!(
4519 action.action,
4520 ControllerAction::RespondElicitation {
4521 session_id: "session-1".into(),
4522 elicitation_id: "elicitation-1".into(),
4523 response: accept(&[("question_0", "reusable")]),
4524 }
4525 );
4526 action.reply.send(ActionOutcome::Accepted).unwrap();
4527 assert_eq!(
4528 response.await.unwrap().unwrap().status(),
4529 StatusCode::ACCEPTED
4530 );
4531 }
4532
4533 #[tokio::test]
4534 async fn elicitation_answer_for_an_unknown_request_is_refused_without_reaching_the_controller()
4535 {
4536 let (app, mut actions, _, _, _) = app_with_snapshot(pending_elicitation_snapshot);
4537 let cookie = login_cookie(&app).await;
4538 let response = app
4539 .oneshot(
4540 Request::post("/api/actions")
4541 .header(COOKIE, cookie)
4542 .header(CONTENT_TYPE, "application/json")
4543 .body(Body::from(
4544 r#"{"action":"respond-elicitation","session_id":"session-1","elicitation_id":"elicitation-9","response":{"action":"cancel"}}"#,
4545 ))
4546 .unwrap(),
4547 )
4548 .await
4549 .unwrap();
4550 assert_eq!(response.status(), StatusCode::NOT_FOUND);
4551 assert!(actions.try_recv().is_err());
4552 }
4553
4554 #[test]
4555 fn elicitation_answers_are_checked_against_the_request_the_agent_asked() {
4556 let (config, state) = sample_config_state();
4557 let mut snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
4558 pending_elicitation_snapshot(&mut snapshot);
4559 let respond = |response: ElicitationResponse| ControllerAction::RespondElicitation {
4560 session_id: "session-1".into(),
4561 elicitation_id: "elicitation-1".into(),
4562 response,
4563 };
4564
4565 assert!(validate_action(&respond(accept(&[("question_0", "matrix")])), &snapshot).is_ok());
4566 assert!(validate_action(&respond(ElicitationResponse::Decline), &snapshot).is_ok());
4569 assert!(validate_action(&respond(accept(&[("question_0", "cron")])), &snapshot).is_err());
4572 assert!(validate_action(&respond(accept(&[("smuggled", "yes")])), &snapshot).is_err());
4573 assert!(validate_action(&respond(accept(&[])), &snapshot).is_err());
4574 assert!(
4577 validate_action(
4578 &respond(accept(&[("question_0_custom", "a monorepo pipeline")])),
4579 &snapshot,
4580 )
4581 .is_ok()
4582 );
4583 }
4584
4585 #[test]
4586 fn oversized_elicitation_answers_are_refused() {
4587 let (config, state) = sample_config_state();
4588 let mut snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
4589 pending_elicitation_snapshot(&mut snapshot);
4590 let long = "x".repeat(MAX_ELICITATION_BYTES);
4591 assert!(
4592 validate_action(
4593 &ControllerAction::RespondElicitation {
4594 session_id: "session-1".into(),
4595 elicitation_id: "elicitation-1".into(),
4596 response: accept(&[("question_0_custom", long.as_str())]),
4597 },
4598 &snapshot,
4599 )
4600 .is_err()
4601 );
4602 }
4603
4604 fn viewer_source(from: &str, to: &str) -> &'static str {
4612 let start = VIEWER_JS
4613 .find(from)
4614 .unwrap_or_else(|| panic!("src/web/viewer.js no longer contains {from:?}"));
4615 let end = VIEWER_JS[start..]
4616 .find(to)
4617 .map(|offset| start + offset)
4618 .unwrap_or_else(|| {
4619 panic!("src/web/viewer.js no longer contains {to:?} after {from:?}")
4620 });
4621 &VIEWER_JS[start..end]
4622 }
4623
4624 fn run_web_check(name: &str, check: &str) {
4631 let directory = tempfile::tempdir().expect("temporary directory for a web check");
4632 for (file, source) in [
4633 ("test-dom.js", TEST_DOM_JS),
4634 ("markdown.js", MARKDOWN_JS),
4635 ("tool-output.js", TOOL_OUTPUT_JS),
4636 ] {
4637 std::fs::write(directory.path().join(file), source).expect("write a web module");
4638 }
4639 let path = directory.path().join(format!("{name}.mjs"));
4640 std::fs::write(&path, check).expect("write the web check");
4641 let output = std::process::Command::new("node")
4642 .arg(&path)
4643 .output()
4644 .expect("Node.js is required to exercise the web viewer");
4645 assert!(
4646 output.status.success(),
4647 "{name} failed:\nstdout:\n{}\nstderr:\n{}",
4648 String::from_utf8_lossy(&output.stdout),
4649 String::from_utf8_lossy(&output.stderr),
4650 );
4651 }
4652
4653 fn run_viewer_script(name: &str, script: &str) {
4657 run_web_check(name, script);
4658 }
4659
4660 #[test]
4661 fn embedded_viewer_lists_current_workspace_histories_and_retained_move_recovery() {
4662 let source = viewer_source("function isResumeSession(", "const resumeDrafts =");
4663 let setup = r#"
4664const snapshot = {
4665 sessions: [
4666 { id: "history-a", workspace_id: "workspace-a", capabilities: { resume: true } },
4667 { id: "history-b", workspace_id: "workspace-b", capabilities: { resume: true } },
4668 { id: "running-a", workspace_id: "workspace-a", lifecycle: "live", has_error: true, capabilities: { resume: false, open: false } },
4669 { id: "move-a", workspace_id: "workspace-a", capabilities: { resume: false }, move_recovery: { checkpoint_retained: true, phase: "failed" } },
4670 { id: "moving-a", workspace_id: "workspace-a", capabilities: { resume: false }, move_recovery: { checkpoint_retained: true, phase: "starting_queue" } },
4671 ],
4672};
4673function selectedWorkspaceId() { return "workspace-a"; }
4674function sessionActivityMs() { return 0; }
4675function epochMs() { return null; }
4676"#;
4677 let checks = r#"
4678const ids = workspace => resumeSessions(workspace).map(session => session.id).sort();
4679if (JSON.stringify(ids("workspace-a")) !== JSON.stringify(["history-a", "move-a"])) {
4680 throw new Error(`workspace A histories or recoveries were wrong: ${JSON.stringify(ids("workspace-a"))}`);
4681}
4682if (JSON.stringify(ids("workspace-b")) !== JSON.stringify(["history-b"])) {
4683 throw new Error(`workspace B histories were wrong: ${JSON.stringify(ids("workspace-b"))}`);
4684}
4685if (ids("missing-workspace").length !== 0) throw new Error("unknown workspace exposed sessions");
4686"#;
4687 run_viewer_script(
4688 "workspace-resume-history",
4689 &format!("{setup}\n{source}\n{checks}"),
4690 );
4691 }
4692
4693 #[test]
4694 fn embedded_viewer_sends_the_selected_resume_workspace() {
4695 let source = viewer_source("async function runSessionAction", "sessions.onclick =");
4696 let setup = r#"
4697const pendingActions = new Set();
4698const snapshot = { sessions: [] };
4699let sent = null;
4700function selectedWorkspaceId() { return "workspace-b"; }
4701function navigate() {}
4702function renderRoute() {}
4703async function refresh() {}
4704async function request(path, options) {
4705 sent = { path, body: JSON.parse(options.body) };
4706}
4707"#;
4708 let checks = r#"
4709const errorNode = { textContent: "" };
4710await runSessionAction(
4711 { action: "resume", id: "history-a", profile: "codex-1", target: "podman" },
4712 errorNode,
4713 { queue: "start" },
4714);
4715if (sent.path !== "/api/actions" || sent.body.workspace_id !== "workspace-b") {
4716 throw new Error(`resume did not carry its destination: ${JSON.stringify(sent)}`);
4717}
4718"#;
4719 run_viewer_script(
4720 "resume-workspace-destination",
4721 &format!("{setup}\n{source}\n{checks}"),
4722 );
4723 }
4724
4725 #[test]
4726 fn embedded_viewer_warns_before_stopping_an_active_session() {
4727 let source = viewer_source("async function runSessionAction", "sessions.onclick =");
4728 let setup = r#"
4729const pendingActions = new Set();
4730const snapshot = {
4731 sessions: [
4732 { id: "active", chat_phase: "running" },
4733 { id: "idle", chat_phase: "idle" },
4734 ],
4735};
4736const questions = [];
4737function confirm(question) { questions.push(question); return false; }
4738function navigate() {}
4739"#;
4740 let checks = r#"
4741const errorNode = { textContent: "" };
4742await runSessionAction({ action: "close", id: "active" }, errorNode);
4743await runSessionAction({ action: "close", id: "idle" }, errorNode);
4744if (!questions[0].startsWith("Stop active session?\n\n")) {
4745 throw new Error(`active close warning was ${JSON.stringify(questions[0])}`);
4746}
4747if (!questions[0].includes("current turn will be interrupted")) {
4748 throw new Error(`active close omitted interruption: ${JSON.stringify(questions[0])}`);
4749}
4750if (!questions[1].startsWith("Stop session?\n\n")) {
4751 throw new Error(`idle close warning was ${JSON.stringify(questions[1])}`);
4752}
4753"#;
4754 run_viewer_script(
4755 "active-session-stop-confirmation",
4756 &format!("{setup}\n{source}\n{checks}"),
4757 );
4758 }
4759
4760 #[test]
4765 fn the_project_key_groups_without_naming_a_path() {
4766 let (config, mut state) = sample_config_state();
4767 let first = state.sessions["session-1"].clone();
4768 let mut second = first.clone();
4769 second.id = "session-2".into();
4770 state.sessions.insert(second.id.clone(), second);
4771 let snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
4772
4773 let keys = snapshot
4774 .sessions
4775 .iter()
4776 .map(|session| session.project_key.as_str())
4777 .collect::<std::collections::BTreeSet<_>>();
4778 assert_eq!(keys.len(), 1, "two sessions in one project did not group");
4779 let key = keys.into_iter().next().expect("one key");
4780 assert!(!key.is_empty(), "the project key is empty");
4781 assert!(
4782 !key.contains('/') && !key.contains("hel"),
4783 "the project key leaks its identity: {key}"
4784 );
4785 assert_eq!(
4786 snapshot.sessions[0].project_label, "hel",
4787 "the project label should be a name a person recognises"
4788 );
4789 }
4790
4791 #[test]
4792 fn web_project_keys_follow_the_complete_repository_set() {
4793 let (mut config, mut state) = sample_config_state();
4794 let shared_bundle = config.bundles["hel"].clone();
4795 config.bundles.insert("other".into(), shared_bundle);
4796
4797 let mut other = state.sessions["session-1"].clone();
4798 other.id = "session-2".into();
4799 other.bundle_id = "other".into();
4800 state.sessions.insert(other.id.clone(), other);
4801
4802 assert_eq!(
4803 config.bundles["hel"].primary_repo,
4804 config.bundles["other"].primary_repo
4805 );
4806 let snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
4807 let first = snapshot
4808 .sessions
4809 .iter()
4810 .find(|session| session.id == "session-1")
4811 .expect("first session");
4812 let second = snapshot
4813 .sessions
4814 .iter()
4815 .find(|session| session.id == "session-2")
4816 .expect("second session");
4817
4818 assert_eq!(first.project_label, "hel");
4819 assert_eq!(second.project_label, "hel");
4820 assert_eq!(first.project_key, second.project_key);
4821
4822 let secondary = ProjectRepository {
4823 id: "secondary".into(),
4824 github: Some("owner/secondary".into()),
4825 local: None,
4826 destination: "secondary".into(),
4827 git_ref: None,
4828 };
4829 config
4830 .bundles
4831 .get_mut("other")
4832 .unwrap()
4833 .repositories
4834 .push(secondary.clone());
4835 let project_keys = |config: &HelConfig| {
4836 ViewerSnapshot::from_config_state(config, &state, 1)
4837 .sessions
4838 .into_iter()
4839 .map(|session| session.project_key)
4840 .collect::<Vec<_>>()
4841 };
4842 let keys = project_keys(&config);
4843 assert_ne!(
4844 keys[0], keys[1],
4845 "an added repository must change the bundle identity"
4846 );
4847
4848 let first_bundle = config.bundles.get_mut("hel").unwrap();
4849 first_bundle.repositories.insert(0, secondary);
4850 first_bundle.primary_repo = "secondary".into();
4851 let keys = project_keys(&config);
4852 assert_eq!(
4853 keys[0], keys[1],
4854 "the same repository set must group together despite order or primary choice"
4855 );
4856 }
4857
4858 #[test]
4859 fn viewer_session_applies_a_resolved_source_without_publishing_it() {
4860 let (config, state) = sample_config_state();
4861 let mut viewer = ViewerSnapshot::from_config_state(&config, &state, 1)
4862 .sessions
4863 .into_iter()
4864 .next()
4865 .expect("session");
4866 let source = ProjectSourceIdentity::git_remote("git@github.com:BrokkAi/bifrost-dev.git")
4867 .expect("GitHub source");
4868
4869 viewer.set_project_source(&source);
4870
4871 assert_eq!(viewer.project_label, "bifrost-dev");
4872 assert_eq!(viewer.project_key, project_key(&source.key));
4873 let json = serde_json::to_string(&viewer).expect("serialize viewer session");
4874 assert!(!json.contains("BrokkAi"));
4875 assert!(!json.contains("github.com"));
4876 }
4877
4878 #[test]
4881 fn lifecycle_categories_decide_what_the_dashboard_shows() {
4882 use ViewerLifecycleCategory::{Failed, Live, Starting, Stopped, Stopping};
4883
4884 for (state, expected, on_dashboard) in [
4885 (SessionState::Provisioning, Starting, true),
4886 (SessionState::Running, Live, true),
4887 (SessionState::Disconnected, Live, true),
4888 (SessionState::Checkpointing, Live, true),
4889 (SessionState::Closing, Stopping, true),
4890 (SessionState::Destroying, Stopping, true),
4891 (SessionState::Stopped, Stopped, false),
4892 (SessionState::Lost, Failed, false),
4893 (SessionState::Error, Failed, false),
4894 (SessionState::DestroyedWithDataLoss, Failed, false),
4895 ] {
4896 let category = ViewerLifecycleCategory::of(state);
4897 assert_eq!(category, expected, "{state:?}");
4898 assert_eq!(
4899 category.is_dashboard_visible(),
4900 on_dashboard,
4901 "{state:?} belongs on the dashboard? "
4902 );
4903 }
4904 }
4905
4906 #[test]
4910 fn compatible_resume_targets_are_the_complement_of_the_incompatible_ones() {
4911 let (config, state) = sample_config_state();
4912 let snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
4913 let session = &snapshot.sessions[0];
4914 let all = config.targets.keys().cloned().collect::<Vec<_>>();
4915
4916 for target in &all {
4917 assert_ne!(
4918 session.compatible_resume_targets.contains(target),
4919 session.incompatible_resume_targets.contains(target),
4920 "target {target} is in both lists or neither"
4921 );
4922 }
4923 assert_eq!(
4924 session.compatible_resume_targets.len() + session.incompatible_resume_targets.len(),
4925 all.len(),
4926 "the two lists do not cover every target"
4927 );
4928 }
4929
4930 #[tokio::test]
4934 async fn actions_are_refused_when_their_capability_is_false() {
4935 for (body, capability) in [
4936 (
4937 r#"{"action":"cancel-turn","session_id":"session-1"}"#,
4938 "cancel_turn",
4939 ),
4940 (
4941 r#"{"action":"set-plan-mode","session_id":"session-1","active":true}"#,
4942 "set_plan_mode",
4943 ),
4944 (
4945 r#"{"action":"set-config","session_id":"session-1","key":"model","value":"x"}"#,
4946 "set_config",
4947 ),
4948 ] {
4949 let (app, mut actions, _, _, _) = app();
4950 let response = post_action(app, cookie(), body.to_owned()).await;
4951 assert!(
4952 response.status().is_client_error(),
4953 "{capability} was accepted while false: {}",
4954 response.status()
4955 );
4956 assert!(
4957 actions.try_recv().is_err(),
4958 "{capability} reached the controller while false"
4959 );
4960 }
4961 }
4962
4963 #[tokio::test]
4966 async fn a_config_key_the_harness_never_advertised_is_refused() {
4967 let capable = |snapshot: &mut ViewerSnapshot| {
4968 snapshot.sessions[0].capabilities.set_config = true;
4969 snapshot.sessions[0].config_options = vec![ViewerConfigOption {
4970 key: "model".into(),
4971 label: "model".into(),
4972 current: None,
4973 choices: vec![ViewerConfigChoice {
4974 value: "sonnet".into(),
4975 name: "Sonnet".into(),
4976 description: None,
4977 }],
4978 }];
4979 };
4980
4981 for (body, why) in [
4982 (
4983 r#"{"action":"set-config","session_id":"session-1","key":"effort","value":"high"}"#,
4984 "an unadvertised key",
4985 ),
4986 (
4987 r#"{"action":"set-config","session_id":"session-1","key":"model","value":"gpt-9"}"#,
4988 "an unoffered value",
4989 ),
4990 ] {
4991 let (app, mut actions, _, _, _) = app_with_snapshot(capable);
4992 let response = post_action(app, cookie(), body.to_owned()).await;
4993 assert_eq!(
4994 response.status(),
4995 StatusCode::BAD_REQUEST,
4996 "{why} was accepted"
4997 );
4998 assert!(actions.try_recv().is_err(), "{why} reached the controller");
4999 }
5000
5001 let (app, mut actions, _, _, _) = app_with_snapshot(capable);
5003 let response = tokio::spawn(post_action(
5004 app,
5005 cookie(),
5006 r#"{"action":"set-config","session_id":"session-1","key":"model","value":"sonnet"}"#
5007 .to_owned(),
5008 ));
5009 let action = actions
5010 .recv()
5011 .await
5012 .expect("the action reached the controller");
5013 assert!(
5014 matches!(
5015 action.action,
5016 ControllerAction::SetConfig { ref key, ref value, .. }
5017 if key == "model" && value == "sonnet"
5018 ),
5019 "the advertised value was not forwarded unchanged"
5020 );
5021 action.reply.send(ActionOutcome::Accepted).unwrap();
5022 assert_eq!(response.await.unwrap().status(), StatusCode::ACCEPTED);
5023 }
5024
5025 #[tokio::test]
5028 async fn a_dirty_acknowledgement_is_bounded_and_names_repositories() {
5029 let oversized = (0..40)
5030 .map(|index| format!(r#""repo-{index}""#))
5031 .collect::<Vec<_>>()
5032 .join(",");
5033 for (ack, why) in [
5034 (oversized.as_str(), "an unbounded acknowledgement"),
5035 (r#""""#, "an empty repository name"),
5036 ] {
5037 let (app, mut actions, _, _, _) = app();
5038 let body = format!(
5039 r#"{{"action":"new","workspace_id":"default","profile_id":"codex-1","bundle_id":"hel","target_id":"podman","dirty_ack":[{ack}]}}"#
5040 );
5041 let response = post_action(app, cookie(), body).await;
5042 assert_eq!(response.status(), StatusCode::BAD_REQUEST, "{why}");
5043 assert!(actions.try_recv().is_err(), "{why} reached the controller");
5044 }
5045 }
5046
5047 #[tokio::test]
5050 async fn a_new_session_without_a_title_is_accepted() {
5051 let (app, mut actions, _, _, _) = app();
5052 let response = tokio::spawn(post_action(
5053 app,
5054 cookie(),
5055 r#"{"action":"new","workspace_id":"default","profile_id":"codex-1","bundle_id":"hel","target_id":"podman"}"#
5056 .to_owned(),
5057 ));
5058 let action = actions
5061 .recv()
5062 .await
5063 .expect("the action reached the controller");
5064 assert!(
5065 matches!(
5066 action.action,
5067 ControllerAction::New { title: None, ref workspace_id, .. }
5068 if workspace_id == "default"
5069 ),
5070 "the workspace or the absent title did not survive the boundary"
5071 );
5072 action.reply.send(ActionOutcome::Accepted).unwrap();
5073 assert_eq!(response.await.unwrap().status(), StatusCode::ACCEPTED);
5074 }
5075
5076 #[test]
5080 fn a_cookie_names_one_viewer_and_two_cookies_never_collide() {
5081 let key = b"01234567890123456789012345678901";
5082 let expiry = now_unix().saturating_add(3600);
5083 let first = signed_cookie_value(key, "viewer-a", expiry);
5084 let second = signed_cookie_value(key, "viewer-b", expiry);
5085 assert_ne!(
5086 first, second,
5087 "two viewers unlocking in the same second share a cookie"
5088 );
5089 assert_eq!(
5090 cookie_viewer(key, &first, now_unix()),
5091 Some(Some("viewer-a".to_owned()))
5092 );
5093 assert_eq!(
5094 cookie_viewer(key, &second, now_unix()),
5095 Some(Some("viewer-b".to_owned()))
5096 );
5097 }
5098
5099 #[test]
5103 fn a_legacy_cookie_still_authenticates_and_stores_nothing() {
5104 let key = b"01234567890123456789012345678901";
5105 let expiry = now_unix().saturating_add(3600);
5106 let legacy = legacy_signed_cookie_value(key, expiry);
5107 assert_eq!(cookie_viewer(key, &legacy, now_unix()), Some(None));
5108 assert!(session_cookie_valid(key, &legacy, now_unix()));
5109 assert!(
5110 !session_cookie_valid(key, &legacy, expiry),
5111 "an expired legacy cookie still authenticated"
5112 );
5113 }
5114
5115 #[test]
5117 fn a_tampered_cookie_is_refused() {
5118 let key = b"01234567890123456789012345678901";
5119 let expiry = now_unix().saturating_add(3600);
5120 let honest = signed_cookie_value(key, "viewer-a", expiry);
5121 let swapped = honest.replacen("viewer-a", "viewer-b", 1);
5122 assert_eq!(cookie_viewer(key, &swapped, now_unix()), None);
5123 assert_eq!(cookie_viewer(key, "nonsense", now_unix()), None);
5124 assert_eq!(cookie_viewer(key, &format!("{expiry}."), now_unix()), None);
5125 }
5126
5127 #[tokio::test]
5130 async fn an_oversized_draft_is_refused_with_a_stable_code() {
5131 let (app, _, _, _, mut stored) = app();
5132 let draft = "x".repeat(64 * 1024 + 1);
5133 let response = app
5134 .oneshot(
5135 Request::put("/api/sessions/session-1/draft")
5136 .header(COOKIE, cookie())
5137 .header(CONTENT_TYPE, "application/json")
5138 .body(Body::from(
5139 serde_json::json!({ "draft": draft }).to_string(),
5140 ))
5141 .unwrap(),
5142 )
5143 .await
5144 .unwrap();
5145 assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE);
5146 assert!(stored.try_recv().is_err(), "an oversized draft was stored");
5147 }
5148
5149 #[tokio::test]
5152 async fn a_legacy_viewer_reads_empty_state_and_cannot_store_a_draft() {
5153 let key = b"01234567890123456789012345678901";
5154 let legacy = format!(
5155 "{COOKIE_NAME}={}",
5156 legacy_signed_cookie_value(key, now_unix().saturating_add(3600))
5157 );
5158
5159 let (reader, _, _, _, mut stored) = app();
5160 let response = reader
5161 .oneshot(
5162 Request::get("/api/sessions/session-1/client-state")
5163 .header(COOKIE, legacy.clone())
5164 .body(Body::empty())
5165 .unwrap(),
5166 )
5167 .await
5168 .unwrap();
5169 assert_eq!(response.status(), StatusCode::OK);
5170 let body = response.into_body().collect().await.unwrap().to_bytes();
5171 let state: ViewerClientState = serde_json::from_slice(&body).unwrap();
5172 assert_eq!(state, ViewerClientState::default());
5173 assert!(
5174 stored.try_recv().is_err(),
5175 "a legacy viewer read stored state"
5176 );
5177
5178 let (writer, _, _, _, mut stored) = app();
5179 let response = writer
5180 .oneshot(
5181 Request::put("/api/sessions/session-1/draft")
5182 .header(COOKIE, legacy)
5183 .header(CONTENT_TYPE, "application/json")
5184 .body(Body::from(r#"{"draft":"text"}"#))
5185 .unwrap(),
5186 )
5187 .await
5188 .unwrap();
5189 assert_eq!(response.status(), StatusCode::CONFLICT);
5190 assert!(stored.try_recv().is_err(), "a legacy viewer stored a draft");
5191 }
5192
5193 #[tokio::test]
5195 async fn prompt_history_refuses_an_unknown_scope() {
5196 let (app, _, _, _, mut stored) = app();
5197 let response = app
5198 .oneshot(
5199 Request::get("/api/sessions/session-1/history?q=ship&scope=everything")
5200 .header(COOKIE, cookie())
5201 .body(Body::empty())
5202 .unwrap(),
5203 )
5204 .await
5205 .unwrap();
5206 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
5207 assert!(
5208 stored.try_recv().is_err(),
5209 "the search reached the controller"
5210 );
5211 }
5212
5213 #[tokio::test]
5217 async fn a_preflight_validates_before_it_reaches_the_controller() {
5218 for (body, why) in [
5219 (
5220 r#"{"profile_id":"nope","bundle_id":"hel","target_id":"podman"}"#,
5221 "an unknown profile",
5222 ),
5223 (
5224 r#"{"profile_id":"codex-1","bundle_id":"hel","target_id":"raw"}"#,
5225 "a bare target with no directory",
5226 ),
5227 ] {
5228 let (app, _, _, mut preflights, _) = app();
5229 let response = app
5230 .oneshot(
5231 Request::post("/api/preflight/new")
5232 .header(COOKIE, cookie())
5233 .header(CONTENT_TYPE, "application/json")
5234 .body(Body::from(body))
5235 .unwrap(),
5236 )
5237 .await
5238 .unwrap();
5239 assert_eq!(response.status(), StatusCode::BAD_REQUEST, "{why}");
5240 assert!(
5241 preflights.try_recv().is_err(),
5242 "{why} reached the controller"
5243 );
5244 }
5245 }
5246
5247 #[tokio::test]
5251 async fn a_bare_preflight_forwards_directory_validation_to_the_controller() {
5252 let (app, _, _, mut preflights, _) = app();
5253 let response = tokio::spawn(app.oneshot(
5254 Request::post("/api/preflight/new")
5255 .header(COOKIE, cookie())
5256 .header(CONTENT_TYPE, "application/json")
5257 .body(Body::from(
5258 r#"{"profile_id":"codex-1","bundle_id":"hel","target_id":"raw","project_directory":"/work/project"}"#,
5259 ))
5260 .unwrap(),
5261 ));
5262 let request = preflights.recv().await.expect("the controller was asked");
5263 assert_eq!(request.bundle_id, "hel");
5264 assert_eq!(request.target_id, "raw");
5265 assert_eq!(
5266 request.project_directory,
5267 Some(PathBuf::from("/work/project"))
5268 );
5269 request
5270 .reply
5271 .send(Ok(PreflightNew {
5272 dirty_repositories: Vec::new(),
5273 }))
5274 .unwrap();
5275 let response = response.await.unwrap().unwrap();
5276 assert_eq!(response.status(), StatusCode::OK);
5277 let body = response.into_body().collect().await.unwrap().to_bytes();
5278 let answer: PreflightNew = serde_json::from_slice(&body).unwrap();
5279 assert!(answer.dirty_repositories.is_empty());
5280 }
5281
5282 #[tokio::test]
5283 async fn a_bare_preflight_validation_failure_is_actionable_without_its_details() {
5284 let (app, _, _, mut preflights, _) = app();
5285 let response = tokio::spawn(app.oneshot(
5286 Request::post("/api/preflight/new")
5287 .header(COOKIE, cookie())
5288 .header(CONTENT_TYPE, "application/json")
5289 .body(Body::from(
5290 r#"{"profile_id":"codex-1","bundle_id":"hel","target_id":"raw","project_directory":"/private/project"}"#,
5291 ))
5292 .unwrap(),
5293 ));
5294 let request = preflights.recv().await.expect("the controller was asked");
5295 request
5296 .reply
5297 .send(Err(PreflightFailure::Validation))
5298 .unwrap();
5299 let response = response.await.unwrap().unwrap();
5300 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
5301 let body = response.into_body().collect().await.unwrap().to_bytes();
5302 assert_eq!(
5303 serde_json::from_slice::<serde_json::Value>(&body).unwrap(),
5304 serde_json::json!({
5305 "error": "project validation failed; check that the directory exists, is accessible, and contains a Git repository with a valid HEAD"
5306 })
5307 );
5308 assert!(!String::from_utf8_lossy(&body).contains("/private/project"));
5309 }
5310
5311 #[tokio::test]
5312 async fn a_bundle_preflight_controller_failure_keeps_the_generic_service_error() {
5313 let (app, _, _, mut preflights, _) = app();
5314 let response = tokio::spawn(
5315 app.oneshot(
5316 Request::post("/api/preflight/new")
5317 .header(COOKIE, cookie())
5318 .header(CONTENT_TYPE, "application/json")
5319 .body(Body::from(
5320 r#"{"profile_id":"codex-1","bundle_id":"hel","target_id":"podman"}"#,
5321 ))
5322 .unwrap(),
5323 ),
5324 );
5325 let request = preflights.recv().await.expect("the controller was asked");
5326 request
5327 .reply
5328 .send(Err(PreflightFailure::Controller(
5329 "private /source/hel details".into(),
5330 )))
5331 .unwrap();
5332 let response = response.await.unwrap().unwrap();
5333 assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
5334 let body = response.into_body().collect().await.unwrap().to_bytes();
5335 assert_eq!(
5336 serde_json::from_slice::<serde_json::Value>(&body).unwrap(),
5337 serde_json::json!({"error": "the controller could not check this project"})
5338 );
5339 assert!(!String::from_utf8_lossy(&body).contains("/source/hel"));
5340 }
5341
5342 #[tokio::test]
5345 async fn a_bundle_preflight_reports_the_repositories_by_leaf_name() {
5346 let (app, _, _, mut preflights, _) = app();
5347 let response = tokio::spawn(
5348 app.oneshot(
5349 Request::post("/api/preflight/new")
5350 .header(COOKIE, cookie())
5351 .header(CONTENT_TYPE, "application/json")
5352 .body(Body::from(
5353 r#"{"profile_id":"codex-1","bundle_id":"hel","target_id":"podman"}"#,
5354 ))
5355 .unwrap(),
5356 ),
5357 );
5358 let request = preflights.recv().await.expect("the controller was asked");
5359 assert_eq!(request.bundle_id, "hel");
5360 assert_eq!(request.target_id, "podman");
5361 assert_eq!(request.project_directory, None);
5362 request
5363 .reply
5364 .send(Ok(PreflightNew {
5365 dirty_repositories: vec!["hel".into()],
5366 }))
5367 .unwrap();
5368 let response = response.await.unwrap().unwrap();
5369 assert_eq!(response.status(), StatusCode::OK);
5370 let body = response.into_body().collect().await.unwrap().to_bytes();
5371 let answer: PreflightNew = serde_json::from_slice(&body).unwrap();
5372 assert_eq!(answer.dirty_repositories, vec!["hel".to_owned()]);
5373 assert!(
5374 !String::from_utf8_lossy(&body).contains('/'),
5375 "the preflight published a path: {}",
5376 String::from_utf8_lossy(&body)
5377 );
5378 }
5379
5380 #[test]
5385 fn the_markdown_renderer_builds_structure_and_refuses_injection() {
5386 run_web_check(
5387 "markdown",
5388 r#"import { installDocument, elements, only, check, checkEqual } from './test-dom.js';
5389installDocument();
5390const { renderMarkdown, renderDiffSummary, safeHref } = await import('./markdown.js');
5391
5392const render = source => {
5393 const host = document.createElement('section');
5394 host.append(renderMarkdown(source));
5395 return host;
5396};
5397
5398// Headings
5399checkEqual(only(render('# Title'), 'h1').textContent, 'Title', 'h1');
5400checkEqual(only(render('### Deep'), 'h3').textContent, 'Deep', 'h3');
5401
5402// Nested lists
5403const nested = render('- one\n - inner\n- two');
5404check(elements(nested, 'ul').length === 2, 'nested list produced ' + elements(nested, 'ul').length + ' lists');
5405check(elements(elements(nested, 'ul')[0], 'li').length >= 2, 'outer list lost items');
5406
5407// Ordered lists
5408checkEqual(elements(render('1. a\n2. b'), 'ol').length, 1, 'ordered list');
5409
5410// Fenced code stays unparsed
5411const fenced = render('```rust\nlet x = *y*;\n```');
5412checkEqual(only(fenced, 'code').textContent, 'let x = *y*;', 'fenced code');
5413check(elements(fenced, 'span').some(s => s.className === 'tok-kw'), 'fenced rust untinted');
5414checkEqual(elements(fenced, 'em').length, 0, 'fence emphasised its contents');
5415checkEqual(only(fenced, 'pre').dataset.lang, 'rust', 'fence language');
5416
5417// Inline code beats emphasis
5418checkEqual(only(render('`*not em*`'), 'code').textContent, '*not em*', 'inline code');
5419checkEqual(elements(render('`*not em*`'), 'em').length, 0, 'inline code emphasised');
5420
5421// Emphasis
5422checkEqual(only(render('**bold**'), 'strong').textContent, 'bold', 'strong');
5423checkEqual(only(render('*it*'), 'em').textContent, 'it', 'em');
5424checkEqual(only(render('~~gone~~'), 'del').textContent, 'gone', 'del');
5425
5426// Tables
5427const table = render('| a | b |\n| --- | ---: |\n| 1 | 2 |');
5428checkEqual(elements(table, 'table').length, 1, 'table');
5429checkEqual(elements(table, 'th').length, 2, 'table header cells');
5430checkEqual(elements(table, 'td').length, 2, 'table body cells');
5431checkEqual(elements(table, 'th')[1].className, 'align-right', 'table alignment class');
5432checkEqual(only(table, 'div').className, 'scroll-x', 'table scroll wrapper');
5433
5434// Blockquote and rule
5435checkEqual(elements(render('> quoted'), 'blockquote').length, 1, 'blockquote');
5436checkEqual(elements(render('---'), 'hr').length, 1, 'rule');
5437
5438// XSS: markup is text, never elements
5439const injected = render('<img src=x onerror=alert(1)>');
5440checkEqual(elements(injected, 'img').length, 0, 'raw HTML became an element');
5441check(injected.textContent.includes('<img src=x onerror=alert(1)>'), 'raw HTML lost its text');
5442
5443// XSS: refused link schemes
5444for (const target of ['javascript:alert(1)', 'JaVaScRiPt:alert(1)', 'java\tscript:alert(1)', 'data:text/html,<script>', 'vbscript:x']) {
5445 const out = render(`[click](${target})`);
5446 checkEqual(elements(out, 'a').length, 0, `link scheme ${JSON.stringify(target)} was allowed`);
5447 check(out.textContent.includes('click'), `link scheme ${JSON.stringify(target)} lost its label`);
5448}
5449
5450// Accepted schemes keep their href and carry safe rel/target
5451for (const target of ['https://example.com', 'http://example.com/a', 'mailto:someone@example.com']) {
5452 const anchor = only(render(`[click](${target})`), 'a');
5453 checkEqual(anchor.getAttribute('href'), target, 'href');
5454 checkEqual(anchor.getAttribute('rel'), 'noreferrer noopener', 'rel');
5455 checkEqual(anchor.getAttribute('target'), '_blank', 'target');
5456}
5457
5458// safeHref directly
5459checkEqual(safeHref('javascript:alert(1)'), null, 'safeHref allowed javascript:');
5460checkEqual(safeHref(' https://x.test '), 'https://x.test', 'safeHref cleaned value');
5461
5462// Inline markup inside a link label
5463checkEqual(only(render('[**bold link**](https://x.test)'), 'strong').textContent, 'bold link', 'link label markup');
5464
5465// An unclosed delimiter is literal, not markup
5466checkEqual(render('a * b').textContent, 'a * b', 'unclosed emphasis');
5467checkEqual(elements(render('a * b'), 'em').length, 0, 'unclosed emphasis made an element');
5468
5469// Diff summaries: the real format from format_diffstat, two spaces and U+2212
5470const diff = renderDiffSummary(['src/main.rs +12 −3', 'unparseable line']);
5471const items = elements(diff, 'li');
5472checkEqual(items.length, 2, 'diffstat rows');
5473checkEqual(elements(items[0], 'span')[0].textContent, 'src/main.rs', 'diffstat path');
5474checkEqual(elements(items[0], 'span')[1].textContent, '+12', 'diffstat additions');
5475checkEqual(elements(items[0], 'span')[2].textContent, '−3', 'diffstat deletions');
5476checkEqual(elements(items[1], 'span').length, 1, 'unparseable diffstat produced counts');
5477checkEqual(elements(items[1], 'span')[0].textContent, 'unparseable line', 'unparseable diffstat lost its text');
5478
5479console.log('all markdown checks passed');
5480"#,
5481 );
5482 }
5483
5484 #[test]
5489 fn tool_output_is_tinted_folded_and_never_read_as_markdown() {
5490 run_web_check(
5491 "tool-output",
5492 r#"import { installDocument, elements, only, check, checkEqual, openFold } from './test-dom.js';
5493installDocument();
5494const { renderToolOutput, codeBlock, detectLang, appendCommandTokens, isPathLike } = await import(
5495 './tool-output.js'
5496);
5497
5498const classes = root => elements(root, 'span').map(s => s.className);
5499
5500// A shell command is told apart into program, subcommand, flag and path.
5501const line = document.createElement('pre');
5502appendCommandTokens(line, 'cargo test --workspace src/lib.rs');
5503const seen = classes(line);
5504check(seen.includes('cmd-program'), 'no program: ' + seen);
5505check(seen.includes('cmd-subcommand'), 'no subcommand: ' + seen);
5506check(seen.includes('cmd-flag'), 'no flag: ' + seen);
5507check(seen.includes('cmd-path'), 'no path: ' + seen);
5508checkEqual(line.textContent, 'cargo test --workspace src/lib.rs', 'command text changed');
5509
5510// An operator starts the program count again, so both programs are found.
5511const piped = document.createElement('pre');
5512appendCommandTokens(piped, 'git status && cargo build');
5513checkEqual(classes(piped).filter(c => c === 'cmd-program').length, 2, 'pipeline reset');
5514
5515// Prose with a slash is not a path; a real path is.
5516check(!isPathLike('and/or'), '"and/or" read as a path');
5517check(isPathLike('src/lib/thing.rs'), 'a real path did not');
5518check(isPathLike('./x'), 'a relative path did not');
5519check(isPathLike('Cargo.toml'), 'a file with an extension did not');
5520
5521// JSON is pretty-printed and tinted, keys apart from values.
5522const json = renderToolOutput('{"name":"hel","count":3,"ok":true}');
5523const jsonClasses = classes(json);
5524check(jsonClasses.includes('tok-key'), 'no JSON key: ' + jsonClasses);
5525check(jsonClasses.includes('tok-str'), 'no JSON string: ' + jsonClasses);
5526check(jsonClasses.includes('tok-num'), 'no JSON number: ' + jsonClasses);
5527check(jsonClasses.includes('tok-kw'), 'no JSON keyword: ' + jsonClasses);
5528check(json.textContent.includes('"name"'), 'JSON lost its content');
5529
5530// Rust is tinted; an unknown language is not.
5531const rust = codeBlock('pub fn main() {\n let x = 1;\n}', 'rust');
5532check(classes(rust).includes('tok-kw'), 'rust keywords untinted');
5533checkEqual(only(rust, 'pre').dataset.lang, 'rust', 'rust data-lang');
5534const plain = codeBlock('nothing in particular here', 'brainfuck');
5535checkEqual(classes(plain).length, 0, 'unknown language was tinted');
5536
5537// Sniffing is conservative: a log stays plain, real code does not.
5538checkEqual(detectLang('12:03 INFO started\n12:04 INFO done\n12:05 INFO stopped'), '', 'a log was sniffed');
5539checkEqual(
5540 detectLang('fn a() {}\nfn b() {}\nlet mut x = 1;\nuse std::fmt;\nimpl Foo {}\nlet y = x.unwrap();'),
5541 'rust',
5542 'rust was not sniffed',
5543);
5544checkEqual(detectLang('--- a/x\n+++ b/x\n@@ -1 +1 @@\n-old\n+new'), 'diff', 'diff was not sniffed');
5545
5546// A long dump is one closed fold that has built nothing yet.
5547const long = Array.from({ length: 400 }, (_, i) => `line ${i}`).join('\n');
5548const folded = renderToolOutput(long);
5549checkEqual(folded.nodeName, 'DETAILS', 'a 400-line dump was not folded');
5550checkEqual(elements(folded, 'pre').length, 0, 'a closed fold built its content anyway');
5551check(only(folded, 'summary').textContent.includes('400 lines'), 'fold summary: ' + only(folded, 'summary').textContent);
5552openFold(folded);
5553checkEqual(elements(folded, 'pre').length, 1, 'an opened fold built nothing');
5554check(elements(folded, 'pre')[0].textContent.includes('line 399'), 'the fold lost its content');
5555
5556// Opening twice builds once.
5557openFold(folded);
5558checkEqual(elements(folded, 'pre').length, 1, 'reopening rebuilt the content');
5559
5560// A short dump is not folded.
5561checkEqual(renderToolOutput('one\ntwo').nodeName, 'PRE', 'a short dump was folded');
5562
5563// Tool output is never parsed as Markdown, so an underscore is an underscore.
5564const literal = renderToolOutput('a _b_ c <img src=x>');
5565checkEqual(elements(literal, 'em').length, 0, 'tool output was emphasised');
5566checkEqual(elements(literal, 'img').length, 0, 'tool output produced an element');
5567check(literal.textContent.includes('<img src=x>'), 'tool output lost its text');
5568
5569console.log('all tool-output checks passed');
5570"#,
5571 );
5572 }
5573
5574 #[test]
5581 fn no_web_module_builds_markup_from_a_string() {
5582 const SINKS: [&str; 5] = [
5583 "innerHTML",
5584 "outerHTML",
5585 "insertAdjacentHTML",
5586 "document.write",
5587 "new Function",
5588 ];
5589 const ALLOWED: [(&str, &str); 0] = [];
5592 for (name, source) in [
5593 ("viewer.js", VIEWER_JS),
5594 ("markdown.js", MARKDOWN_JS),
5595 ("tool-output.js", TOOL_OUTPUT_JS),
5596 ] {
5597 for (number, line) in source.lines().enumerate() {
5598 let trimmed = line.trim();
5599 if trimmed.starts_with("//") || trimmed.starts_with("///") {
5600 continue;
5601 }
5602 for sink in SINKS {
5603 if !trimmed.contains(sink) {
5604 continue;
5605 }
5606 assert!(
5607 ALLOWED
5608 .iter()
5609 .any(|(file, allowed)| *file == name && trimmed == *allowed),
5610 "{name}:{} builds markup from a string: {trimmed}",
5611 number + 1
5612 );
5613 }
5614 }
5615 }
5616 }
5617
5618 #[test]
5622 fn embedded_viewer_keeps_elicitation_answers_across_snapshot_polls() {
5623 let source = viewer_source(
5624 "const elicitationCards = new Map()",
5625 "async function submitElicitation",
5626 );
5627 let dom = r#"
5628let replaceCalls = 0;
5629function makeEl(tag) {
5630 return {
5631 tagName: tag.toUpperCase(),
5632 children: [],
5633 options: [],
5634 selectedOptions: [],
5635 className: "",
5636 textContent: "",
5637 disabled: false,
5638 required: false,
5639 value: "",
5640 appendChild(child) {
5641 this.children.push(child);
5642 if (this.tagName === "SELECT") this.options.push(child);
5643 return child;
5644 },
5645 append(...kids) {
5646 this.children.push(...kids);
5647 },
5648 replaceChildren(...kids) {
5649 replaceCalls += 1;
5650 this.children = kids;
5651 },
5652 addEventListener() {},
5653 querySelectorAll(selector) {
5654 const found = [];
5655 const visit = node => {
5656 for (const child of node.children) {
5657 if (child.tagName === "INPUT" && (selector === "input" || child.checked)) found.push(child);
5658 visit(child);
5659 }
5660 };
5661 visit(this);
5662 return found;
5663 },
5664 querySelector(selector) { return this.querySelectorAll(selector)[0] || null; },
5665 setCustomValidity() {},
5666 reportValidity() {
5667 return true;
5668 },
5669 };
5670}
5671const created = [];
5672const document = {
5673 createElement(tag) {
5674 const el = makeEl(tag);
5675 created.push(el);
5676 return el;
5677 },
5678};
5679const elicitations = makeEl("div");
5680function el(tag, className, text) {
5681 const node = document.createElement(tag);
5682 node.className = className || "";
5683 node.textContent = text || "";
5684 return node;
5685}
5686async function submitElicitation() {}
5687"#;
5688 let checks = r#"
5689const request = {
5690 id: "elicitation-1",
5691 message: "Which CI architecture?",
5692 title: "CI",
5693 fields: [
5694 {
5695 id: "question_0",
5696 title: "CI architecture",
5697 required: false,
5698 kind: "single_select",
5699 options: [{ value: "reusable", title: "Reusable" }, { value: "matrix", title: "Matrix" }],
5700 },
5701 { id: "question_0_custom", title: "Other", required: false, kind: "text" },
5702 ],
5703};
5704const session = { id: "session-1", pending_elicitations: [request] };
5705renderElicitations(session);
5706const card = elicitations.children[0];
5707const radio = created.find((el) => el.tagName === "INPUT" && el.value === "reusable");
5708const text = created.find((el) => el.tagName === "INPUT" && el.type === "text");
5709radio.checked = true;
5710text.value = "keep me";
5711const attachments = replaceCalls;
5712renderElicitations(session);
5713if (elicitations.children[0] !== card) {
5714 throw new Error("a snapshot rebuilt the pending card");
5715}
5716if (!radio.checked || text.value !== "keep me") {
5717 throw new Error("a snapshot wiped the half-filled answer");
5718}
5719if (replaceCalls !== attachments) {
5720 throw new Error("a snapshot re-attached an unchanged card and dropped focus");
5721}
5722sentElicitations.add(elicitationKey("session-1", request.id));
5723renderElicitations(session);
5724if (elicitations.children[0] !== card) {
5725 throw new Error("a sent answer rebuilt the card");
5726}
5727if (!radio.disabled || !text.disabled) {
5728 throw new Error("a sent answer left the controls live");
5729}
5730if (!radio.checked) {
5731 throw new Error("a sent answer wiped the reply");
5732}
5733renderElicitations({ id: "session-1", pending_elicitations: [] });
5734if (elicitations.children.length !== 0 || elicitationCards.size !== 0) {
5735 throw new Error("an answered request stayed rendered");
5736}
5737if (sentElicitations.size !== 0) {
5738 throw new Error("a resolved request kept its sent marker");
5739}
5740"#;
5741 run_viewer_script(
5742 "elicitation-rendering",
5743 &format!("{dom}\n{source}\n{checks}"),
5744 );
5745 }
5746
5747 fn sample_image(pixels: usize) -> ViewerPromptImage {
5748 ViewerPromptImage {
5749 data_base64: base64::engine::general_purpose::STANDARD.encode(vec![7_u8; pixels]),
5750 mime_type: "image/png".into(),
5751 width: 32,
5752 height: 24,
5753 attachment: None,
5754 }
5755 }
5756
5757 fn sample_valid_image() -> ViewerPromptImage {
5758 ViewerPromptImage {
5759 data_base64: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
5760 .into(),
5761 mime_type: "image/png".into(),
5762 width: 1,
5763 height: 1,
5764 attachment: None,
5765 }
5766 }
5767
5768 fn image_capable(snapshot: &mut ViewerSnapshot) {
5769 snapshot.sessions[0].prompt_images_supported = true;
5770 }
5771
5772 async fn post_action(app: Router, cookie: String, body: String) -> Response<Body> {
5773 app.oneshot(
5774 Request::post("/api/actions")
5775 .header(COOKIE, cookie)
5776 .header(CONTENT_TYPE, "application/json")
5777 .body(Body::from(body))
5778 .unwrap(),
5779 )
5780 .await
5781 .unwrap()
5782 }
5783
5784 #[tokio::test]
5785 async fn image_prompt_reaches_the_controller_with_its_images() {
5786 let (app, mut actions, _, _, _) = app_with_snapshot(image_capable);
5787 let cookie = login_cookie(&app).await;
5788 let image = sample_valid_image();
5789 let body = serde_json::to_string(&ControllerAction::Prompt {
5790 session_id: "session-1".into(),
5791 text: String::new(),
5792 images: vec![image.clone(), image.clone()],
5793 })
5794 .unwrap();
5795 let response = tokio::spawn(post_action(app, cookie, body));
5796 let request = actions.recv().await.unwrap();
5797 let ControllerRequest { action, reply } = request;
5798 let ControllerAction::Prompt {
5799 session_id,
5800 text,
5801 images,
5802 } = action
5803 else {
5804 panic!("expected a prompt action")
5805 };
5806 assert_eq!(session_id, "session-1");
5807 assert!(text.is_empty());
5808 assert_eq!(images.len(), 2);
5809 assert!(
5810 images
5811 .iter()
5812 .all(|image| { image.data_base64.is_empty() && image.attachment.is_some() })
5813 );
5814 reply.send(ActionOutcome::Accepted).unwrap();
5815 assert_eq!(response.await.unwrap().status(), StatusCode::ACCEPTED);
5816 }
5817
5818 #[tokio::test]
5819 async fn browser_attachment_upload_returns_a_stored_reference_without_inline_bytes() {
5820 let (app, _, _, _, _) = app_with_snapshot(image_capable);
5821 let cookie = login_cookie(&app).await;
5822 let bytes = base64::engine::general_purpose::STANDARD
5823 .decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=")
5824 .unwrap();
5825 let response = app
5826 .oneshot(
5827 Request::post("/api/sessions/session-1/attachments")
5828 .header(COOKIE, cookie)
5829 .header(CONTENT_TYPE, "image/png")
5830 .body(Body::from(bytes))
5831 .unwrap(),
5832 )
5833 .await
5834 .unwrap();
5835 assert_eq!(response.status(), StatusCode::OK);
5836 let body = response.into_body().collect().await.unwrap().to_bytes();
5837 let image: ViewerPromptImage = serde_json::from_slice(&body).unwrap();
5838 assert!(image.data_base64.is_empty());
5839 let reference = image.attachment.expect("upload should return a reference");
5840 assert_eq!(reference.mime_type, "image/png");
5841 assert_eq!(reference.width, 1);
5842 assert_eq!(reference.height, 1);
5843 assert!(reference.size <= 700 * 1024);
5844 }
5845
5846 #[tokio::test]
5850 async fn multi_image_prompts_are_accepted_over_the_general_body_limit() {
5851 let (app, mut actions, _, _, _) = app_with_snapshot(image_capable);
5852 let cookie = login_cookie(&app).await;
5853 let image = sample_valid_image();
5854 let mut body = serde_json::to_string(&ControllerAction::Prompt {
5855 session_id: "session-1".into(),
5856 text: "look at these".into(),
5857 images: vec![image.clone(), image],
5858 })
5859 .unwrap();
5860 body.push_str(&" ".repeat(MAX_BODY_BYTES));
5861 assert!(body.len() > MAX_BODY_BYTES);
5862 assert!(body.len() < MAX_PROMPT_BODY_BYTES);
5863 let response = tokio::spawn(post_action(app, cookie, body));
5864 let action = actions.recv().await.unwrap();
5865 action.reply.send(ActionOutcome::Accepted).unwrap();
5866 assert_eq!(response.await.unwrap().status(), StatusCode::ACCEPTED);
5867 }
5868
5869 #[tokio::test]
5870 async fn a_body_over_the_prompt_limit_is_still_refused() {
5871 let (app, _actions, _, _, _) = app_with_snapshot(image_capable);
5872 let cookie = login_cookie(&app).await;
5873 let image = sample_image(MAX_PROMPT_BODY_BYTES);
5874 let body = serde_json::to_string(&ControllerAction::Prompt {
5875 session_id: "session-1".into(),
5876 text: String::new(),
5877 images: vec![image],
5878 })
5879 .unwrap();
5880 assert!(body.len() > MAX_PROMPT_BODY_BYTES);
5881 let response = post_action(app, cookie, body).await;
5882 assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE);
5883 }
5884
5885 #[tokio::test]
5886 async fn malformed_image_payloads_never_reach_the_controller() {
5887 let cases = [
5888 ("aW1hZ2U=", "text/plain", 32, 24),
5889 ("aW1hZ2U=", "image/png", 0, 24),
5890 ("not base64!", "image/png", 32, 24),
5891 ("", "image/png", 32, 24),
5892 ];
5893 for (data, mime, width, height) in cases {
5894 let (app, mut actions, _, _, _) = app_with_snapshot(image_capable);
5895 let cookie = login_cookie(&app).await;
5896 let body = serde_json::to_string(&ControllerAction::Prompt {
5897 session_id: "session-1".into(),
5898 text: String::new(),
5899 images: vec![ViewerPromptImage {
5900 data_base64: data.into(),
5901 mime_type: mime.into(),
5902 width,
5903 height,
5904 attachment: None,
5905 }],
5906 })
5907 .unwrap();
5908 let response = post_action(app, cookie, body).await;
5909 assert_eq!(
5910 response.status(),
5911 StatusCode::BAD_REQUEST,
5912 "expected {data:?}/{mime} {width}x{height} to be refused"
5913 );
5914 assert!(actions.try_recv().is_err());
5915 }
5916 }
5917
5918 #[test]
5919 fn image_prompts_need_text_or_an_image_and_an_agent_that_takes_them() {
5920 let (config, state) = sample_config_state();
5921 let mut snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
5922 let prompt = |text: &str, images: Vec<ViewerPromptImage>| ControllerAction::Prompt {
5923 session_id: "session-1".into(),
5924 text: text.into(),
5925 images,
5926 };
5927
5928 assert!(validate_action(&prompt("ship it", Vec::new()), &snapshot).is_ok());
5930 assert!(validate_action(&prompt("", vec![sample_image(8)]), &snapshot).is_err());
5931
5932 image_capable(&mut snapshot);
5933 assert!(validate_action(&prompt("", vec![sample_image(8)]), &snapshot).is_ok());
5935 assert!(
5936 validate_action(
5937 &prompt("", vec![sample_image(8); MAX_PROMPT_IMAGES + 1]),
5938 &snapshot,
5939 )
5940 .is_err()
5941 );
5942 assert!(validate_action(&prompt(" ", Vec::new()), &snapshot).is_err());
5943 assert!(validate_action(&prompt("", Vec::new()), &snapshot).is_err());
5944 assert!(validate_action(&prompt("!ls", vec![sample_image(8)]), &snapshot).is_err());
5946 }
5947
5948 #[test]
5951 fn embedded_viewer_reads_multiline_composer_text_out_of_its_dom() {
5952 let source = viewer_source("function composerText()", "function setComposerText(");
5953 let harness = r##"
5954const Node = { TEXT_NODE: 3 };
5955function textNode(value) {
5956 return { nodeType: 3, nodeValue: value, nodeName: "#text", childNodes: [], dataset: {} };
5957}
5958function element(name, children = [], dataset = {}) {
5959 const node = { nodeType: 1, nodeName: name, dataset, childNodes: children };
5960 children.forEach((child, index) => {
5961 child.nextSibling = children[index + 1] || null;
5962 });
5963 return node;
5964}
5965let promptText = null;
5966function read(children) {
5967 promptText = element("DIV", children);
5968 return composerText();
5969}
5970"##;
5971 let checks = r#"
5972const plain = read([textNode("ship it")]);
5973if (plain !== "ship it") throw new Error(`plain text became ${JSON.stringify(plain)}`);
5974
5975const broken = read([textNode("first"), element("BR"), textNode("second")]);
5976if (broken !== "first\nsecond") throw new Error(`line break became ${JSON.stringify(broken)}`);
5977
5978// The trailing break a browser leaves behind to keep the caret on a new line
5979// is scaffolding, not a line the user typed.
5980const filler = read([
5981 textNode("first"),
5982 element("BR"),
5983 element("BR", [], { composerFiller: "true" }),
5984]);
5985if (filler !== "first\n") throw new Error(`filler break became ${JSON.stringify(filler)}`);
5986
5987const blocks = read([
5988 textNode("first"),
5989 element("DIV", [textNode("second")]),
5990 element("DIV", [textNode("third")]),
5991]);
5992if (blocks !== "first\nsecond\nthird") throw new Error(`blocks became ${JSON.stringify(blocks)}`);
5993
5994const carriage = read([textNode("first\r\nsecond")]);
5995if (carriage !== "first\nsecond") throw new Error(`CRLF became ${JSON.stringify(carriage)}`);
5996"#;
5997 run_viewer_script("composer-reader", &format!("{harness}\n{source}\n{checks}"));
5998 }
5999
6000 #[tokio::test]
6004 async fn viewer_declares_the_icon_route_instead_of_requesting_a_missing_favicon() {
6005 let (app, _, _, _, _) = app();
6006 let page = fetch_text(app.clone(), "/").await;
6007 assert!(page.contains(r#"rel="icon""#), "the page declares no icon");
6008 assert!(page.contains("/icon.svg"), "the page names no icon route");
6009 let icon = app
6010 .oneshot(Request::get("/icon.svg").body(Body::empty()).unwrap())
6011 .await
6012 .unwrap();
6013 assert_eq!(icon.status(), StatusCode::OK);
6014 assert_eq!(
6015 icon.headers().get(CONTENT_TYPE).unwrap(),
6016 "image/svg+xml",
6017 "the icon route does not serve an SVG"
6018 );
6019 }
6020
6021 #[tokio::test]
6022 async fn valid_action_is_typed_and_forwarded() {
6023 let (app, mut actions, _, _, _) = app();
6024 let cookie = login_cookie(&app).await;
6025 let response = tokio::spawn(
6026 app.oneshot(
6027 Request::post("/api/actions")
6028 .header(COOKIE, cookie)
6029 .header(CONTENT_TYPE, "application/json")
6030 .body(Body::from(
6031 r#"{"action":"prompt","session_id":"session-1","text":"ship it"}"#,
6032 ))
6033 .unwrap(),
6034 ),
6035 );
6036 let action = actions.recv().await.unwrap();
6037 assert_eq!(
6038 action.action,
6039 ControllerAction::Prompt {
6040 session_id: "session-1".into(),
6041 text: "ship it".into(),
6042 images: Vec::new(),
6043 }
6044 );
6045 action.reply.send(ActionOutcome::Accepted).unwrap();
6046 let response = response.await.unwrap().unwrap();
6047 assert_eq!(response.status(), StatusCode::ACCEPTED);
6048 }
6049
6050 #[tokio::test]
6051 async fn move_preparation_is_read_only_and_returns_the_daemon_fingerprint() {
6052 let (app, mut preparations) = app_with_move_receiver();
6053 let cookie = login_cookie(&app).await;
6054 let response = tokio::spawn(
6055 app.oneshot(
6056 Request::post("/api/moves/prepare")
6057 .header(COOKIE, cookie)
6058 .header(CONTENT_TYPE, "application/json")
6059 .body(Body::from(
6060 r#"{"session_id":"session-1","profile_id":"codex-1","target_template_id":"podman","clear_resource_allocation":false,"additional_mounts":null,"resource_allocation":null}"#,
6061 ))
6062 .unwrap(),
6063 ),
6064 );
6065 let request = preparations
6066 .recv()
6067 .await
6068 .expect("preparation reached daemon");
6069 assert_eq!(request.selection.session_id, "session-1");
6070 assert_eq!(request.selection.profile_id.as_deref(), Some("codex-1"));
6071 assert_eq!(
6072 request.selection.target_template_id.as_deref(),
6073 Some("podman")
6074 );
6075 request
6076 .reply
6077 .send(Ok(MovePreparation {
6078 selection: request.selection,
6079 source_profile_id: "codex-1".into(),
6080 source_target_template_id: "podman".into(),
6081 cross_harness: false,
6082 active: true,
6083 queued_commands: vec![hel::hel_state::MaterializedQueuedPrompt {
6084 command_id: "queued-1".into(),
6085 kind: hel::hel_state::QueuedCommandKind::Prompt,
6086 content: vec![serde_json::json!({
6087 "type": "image",
6088 "mimeType": "image/png",
6089 "data": "secret-image-bytes"
6090 })],
6091 queued_at_ms: 1,
6092 }],
6093 fingerprint: "fingerprint".into(),
6094 operation_id: "move-1".into(),
6095 }))
6096 .unwrap();
6097 let response = response.await.unwrap().unwrap();
6098 assert_eq!(response.status(), StatusCode::OK);
6099 let body = response.into_body().collect().await.unwrap().to_bytes();
6100 let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
6101 assert_eq!(body["operation_id"], "move-1");
6102 assert_eq!(body["active"], true);
6103 assert_eq!(
6104 body["queued_commands"][0]["content"][0]["text"],
6105 "[Image attachment: image/png]"
6106 );
6107 assert!(body.to_string().contains("[Image attachment: image/png]"));
6108 assert!(!body.to_string().contains("secret-image-bytes"));
6109 }
6110
6111 #[tokio::test]
6112 async fn confirmed_move_action_forwards_the_fingerprinted_request() {
6113 let (app, mut actions, _, _, _) = app_with_snapshot(|snapshot| {
6114 snapshot.sessions[0].capabilities.move_session = true;
6115 });
6116 let cookie = login_cookie(&app).await;
6117 let response = tokio::spawn(
6118 app.oneshot(
6119 Request::post("/api/actions")
6120 .header(COOKIE, cookie)
6121 .header(CONTENT_TYPE, "application/json")
6122 .body(Body::from(
6123 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}}"#,
6124 ))
6125 .unwrap(),
6126 ),
6127 );
6128 let action = actions.recv().await.expect("move action reached daemon");
6129 assert!(matches!(action.action, ControllerAction::Move { .. }));
6130 action.reply.send(ActionOutcome::Accepted).unwrap();
6131 assert_eq!(
6132 response.await.unwrap().unwrap().status(),
6133 StatusCode::ACCEPTED
6134 );
6135 }
6136
6137 #[tokio::test]
6138 async fn shell_action_is_typed_and_forwarded() {
6139 let (app, mut actions, _, _, _) = app();
6140 let cookie = login_cookie(&app).await;
6141 let response = tokio::spawn(
6142 app.oneshot(
6143 Request::post("/api/actions")
6144 .header(COOKIE, cookie)
6145 .header(CONTENT_TYPE, "application/json")
6146 .body(Body::from(
6147 r#"{"action":"run-shell","session_id":"session-1","command":"cargo test"}"#,
6148 ))
6149 .unwrap(),
6150 ),
6151 );
6152 let action = actions.recv().await.unwrap();
6153 assert_eq!(
6154 action.action,
6155 ControllerAction::RunShell {
6156 session_id: "session-1".into(),
6157 command: "cargo test".into(),
6158 }
6159 );
6160 action.reply.send(ActionOutcome::Accepted).unwrap();
6161 assert_eq!(
6162 response.await.unwrap().unwrap().status(),
6163 StatusCode::ACCEPTED
6164 );
6165 }
6166
6167 #[test]
6168 fn shell_action_validation_reserves_bang_prompts_and_checks_cancellation_ids() {
6169 let (config, state) = sample_config_state();
6170 let mut snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
6171 assert!(
6172 validate_action(
6173 &ControllerAction::Prompt {
6174 session_id: "session-1".into(),
6175 text: "!cargo test".into(),
6176 images: Vec::new(),
6177 },
6178 &snapshot,
6179 )
6180 .is_err()
6181 );
6182 assert!(
6183 validate_action(
6184 &ControllerAction::RunShell {
6185 session_id: "session-1".into(),
6186 command: "cargo test".into(),
6187 },
6188 &snapshot,
6189 )
6190 .is_ok()
6191 );
6192 assert!(
6193 validate_action(
6194 &ControllerAction::CancelShell {
6195 session_id: "session-1".into(),
6196 shell_command_id: "shell-1".into(),
6197 },
6198 &snapshot,
6199 )
6200 .is_err()
6201 );
6202
6203 snapshot.sessions[0]
6204 .active_user_shells
6205 .push(ViewerUserShell {
6206 id: "shell-1".into(),
6207 command: "cargo test".into(),
6208 started_at_ms: Some(10),
6209 });
6210 assert!(
6211 validate_action(
6212 &ControllerAction::CancelShell {
6213 session_id: "session-1".into(),
6214 shell_command_id: "shell-1".into(),
6215 },
6216 &snapshot,
6217 )
6218 .is_ok()
6219 );
6220 }
6221
6222 #[tokio::test]
6223 async fn bare_new_action_forwards_an_explicit_safe_project_directory() {
6224 let (app, mut actions, _, _, _) = app();
6225 let cookie = login_cookie(&app).await;
6226 let response = tokio::spawn(
6227 app.oneshot(
6228 Request::post("/api/actions")
6229 .header(COOKIE, cookie)
6230 .header(CONTENT_TYPE, "application/json")
6231 .body(Body::from(
6232 r#"{"action":"new","profile_id":"codex-1","bundle_id":"hel","target_id":"raw","title":"Raw work","project_directory":"/work/project"}"#,
6233 ))
6234 .unwrap(),
6235 ),
6236 );
6237 let action = actions.recv().await.unwrap();
6238 assert_eq!(
6239 action.action,
6240 ControllerAction::New {
6241 workspace_id: String::new(),
6242 profile_id: "codex-1".into(),
6243 bundle_id: "hel".into(),
6244 target_id: "raw".into(),
6245 title: Some("Raw work".into()),
6246 project_directory: Some(PathBuf::from("/work/project")),
6247 dirty_ack: Vec::new(),
6248 }
6249 );
6250 action.reply.send(ActionOutcome::Accepted).unwrap();
6251 assert_eq!(
6252 response.await.unwrap().unwrap().status(),
6253 StatusCode::ACCEPTED
6254 );
6255 }
6256
6257 #[test]
6258 fn new_action_requires_project_directory_exactly_for_bare_targets() {
6259 let (config, state) = sample_config_state();
6260 let snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
6261 let action = |target_id: &str, project_directory: Option<PathBuf>| ControllerAction::New {
6262 workspace_id: String::new(),
6263 profile_id: "codex-1".into(),
6264 bundle_id: "hel".into(),
6265 target_id: target_id.into(),
6266 title: Some("New work".into()),
6267 project_directory,
6268 dirty_ack: Vec::new(),
6269 };
6270
6271 assert!(validate_action(&action("podman", None), &snapshot).is_ok());
6272 assert_eq!(
6273 validate_action(&action("podman", Some("/work".into())), &snapshot)
6274 .unwrap_err()
6275 .status,
6276 StatusCode::BAD_REQUEST
6277 );
6278 assert_eq!(
6279 validate_action(&action("raw", None), &snapshot)
6280 .unwrap_err()
6281 .status,
6282 StatusCode::BAD_REQUEST
6283 );
6284 assert_eq!(
6285 validate_action(&action("raw", Some("relative".into())), &snapshot)
6286 .unwrap_err()
6287 .status,
6288 StatusCode::BAD_REQUEST
6289 );
6290 assert_eq!(
6291 validate_action(&action("raw", Some("/work/../secret".into())), &snapshot)
6292 .unwrap_err()
6293 .status,
6294 StatusCode::BAD_REQUEST
6295 );
6296 assert!(validate_action(&action("raw", Some("/work/project".into())), &snapshot).is_ok());
6297 }
6298
6299 #[tokio::test]
6300 async fn cancel_action_is_typed_and_forwarded() {
6301 let (app, mut actions, _, _, _) = app();
6302 let cookie = login_cookie(&app).await;
6303 let response = tokio::spawn(
6304 app.oneshot(
6305 Request::post("/api/actions")
6306 .header(COOKIE, cookie)
6307 .header(CONTENT_TYPE, "application/json")
6308 .body(Body::from(
6309 r#"{"action":"cancel","session_id":"session-1"}"#,
6310 ))
6311 .unwrap(),
6312 ),
6313 );
6314 let action = actions.recv().await.unwrap();
6315 assert_eq!(
6316 action.action,
6317 ControllerAction::Cancel {
6318 session_id: "session-1".into(),
6319 }
6320 );
6321 action.reply.send(ActionOutcome::Accepted).unwrap();
6322 assert_eq!(
6323 response.await.unwrap().unwrap().status(),
6324 StatusCode::ACCEPTED
6325 );
6326 }
6327
6328 #[tokio::test]
6329 async fn action_validation_accepts_cross_harness_resume_and_rejects_unknown() {
6330 let (mut config, state) = sample_config_state();
6331 config.profiles.insert(
6332 "claude-1".into(),
6333 HarnessProfile {
6334 context_window_bytes: None,
6335 kind: HarnessKind::Claude,
6336 home: "/secret/claude".into(),
6337 environment: BTreeMap::new(),
6338 },
6339 );
6340 let mut snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
6341 snapshot.workspaces.push(ViewerWorkspace {
6342 id: "workspace-1".into(),
6343 name: "One".into(),
6344 });
6345 validate_action(
6346 &ControllerAction::Resume {
6347 session_id: "session-1".into(),
6348 workspace_id: "workspace-1".into(),
6349 profile_id: "claude-1".into(),
6350 target_id: "podman".into(),
6351 queue: ResumeQueueDisposition::Start,
6352 additional_mounts: None,
6353 resource_allocation: None,
6354 },
6355 &snapshot,
6356 )
6357 .unwrap();
6358
6359 let error = validate_action(
6360 &ControllerAction::Resume {
6361 session_id: "session-1".into(),
6362 workspace_id: "missing".into(),
6363 profile_id: "claude-1".into(),
6364 target_id: "podman".into(),
6365 queue: ResumeQueueDisposition::Start,
6366 additional_mounts: None,
6367 resource_allocation: None,
6368 },
6369 &snapshot,
6370 )
6371 .unwrap_err();
6372 assert_eq!(error.status, StatusCode::BAD_REQUEST);
6373
6374 let error = validate_action(
6375 &ControllerAction::Close {
6376 session_id: "not-managed".into(),
6377 },
6378 &snapshot,
6379 )
6380 .unwrap_err();
6381 assert_eq!(error.status, StatusCode::NOT_FOUND);
6382 }
6383
6384 #[test]
6387 fn a_running_review_projects_to_the_phone() {
6388 use crate::hel_review_host::{RuntimeReviewView, VerdictKind, VerdictView};
6389 use hel::hel_review::driver::{Resolution, RoleState, RoleStatus, TurnReviewPhase};
6390
6391 let review = RuntimeReviewView {
6392 session_id: "session-1".into(),
6393 tier: hel::hel_review::lanes::ReviewTier::Extended,
6394 phase: TurnReviewPhase::Verdict(hel::hel_review::verdict::ReviewVerdict::Findings {
6395 synthesis: "[P1] src/lib.rs:1 -- unbounded retry".into(),
6396 evidence: Default::default(),
6397 }),
6398 roles: vec![
6399 RoleStatus {
6400 role: "supervisor".into(),
6401 label: "Supervisor".into(),
6402 state: RoleState::Clean,
6403 },
6404 RoleStatus {
6405 role: "tests".into(),
6406 label: "Tests".into(),
6407 state: RoleState::Findings,
6408 },
6409 ],
6410 status: "Enter to act".into(),
6411 verdict: Some(VerdictView {
6412 kind: VerdictKind::Findings,
6413 text: "[P1] src/lib.rs:1 -- unbounded retry".into(),
6414 allowed: vec![
6415 Resolution::Forwarded,
6416 Resolution::Dismissed,
6417 Resolution::Cancelled,
6418 ],
6419 }),
6420 };
6421
6422 let projected = ViewerTurnReview::from_runtime(&review);
6423
6424 assert_eq!(projected.tier, "extended");
6425 assert_eq!(
6426 projected
6427 .roles
6428 .iter()
6429 .map(|role| (role.label.as_str(), role.state.as_str()))
6430 .collect::<Vec<_>>(),
6431 vec![("Supervisor", "done"), ("Tests", "findings")]
6432 );
6433 let verdict = projected.verdict.expect("a findings verdict travels");
6434 assert_eq!(verdict.kind, "findings");
6435 assert!(verdict.text.contains("unbounded retry"));
6436 assert_eq!(verdict.allowed, vec!["forward", "dismiss", "cancel"]);
6437 }
6438
6439 #[test]
6443 fn resolving_a_review_is_gated_on_what_the_daemon_published() {
6444 let (config, state) = sample_config_state();
6445 let mut snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
6446
6447 let resolve = |resolution: &str| ControllerAction::ResolveReview {
6448 session_id: "session-1".into(),
6449 resolution: resolution.into(),
6450 };
6451
6452 let error = validate_action(&resolve("cancel"), &snapshot).unwrap_err();
6454 assert_eq!(error.status, StatusCode::BAD_REQUEST);
6455
6456 snapshot.sessions[0].turn_review = Some(ViewerTurnReview {
6457 tier: "quick".into(),
6458 status: "the reviewer is reading the change…".into(),
6459 roles: Vec::new(),
6460 verdict: None,
6461 });
6462 validate_action(&resolve("cancel"), &snapshot).unwrap();
6464 assert_eq!(
6465 validate_action(&resolve("forward"), &snapshot)
6466 .unwrap_err()
6467 .status,
6468 StatusCode::BAD_REQUEST
6469 );
6470
6471 snapshot.sessions[0].turn_review = Some(ViewerTurnReview {
6473 tier: "quick".into(),
6474 status: "the review failed".into(),
6475 roles: Vec::new(),
6476 verdict: Some(ViewerReviewVerdict {
6477 kind: "failed".into(),
6478 text: "bifrost exited with 1".into(),
6479 allowed: vec!["dismiss".into(), "cancel".into()],
6480 }),
6481 });
6482 validate_action(&resolve("dismiss"), &snapshot).unwrap();
6483 assert_eq!(
6484 validate_action(&resolve("forward"), &snapshot)
6485 .unwrap_err()
6486 .status,
6487 StatusCode::BAD_REQUEST
6488 );
6489 assert_eq!(
6491 validate_action(&resolve("approve"), &snapshot)
6492 .unwrap_err()
6493 .status,
6494 StatusCode::BAD_REQUEST
6495 );
6496
6497 validate_action(
6499 &ControllerAction::StartReview {
6500 session_id: "session-1".into(),
6501 },
6502 &snapshot,
6503 )
6504 .unwrap();
6505 assert_eq!(
6506 validate_action(
6507 &ControllerAction::StartReview {
6508 session_id: "not-managed".into(),
6509 },
6510 &snapshot,
6511 )
6512 .unwrap_err()
6513 .status,
6514 StatusCode::NOT_FOUND
6515 );
6516 }
6517
6518 #[test]
6519 fn resume_action_refuses_a_target_the_session_cannot_use() {
6520 let (mut config, state) = sample_config_state();
6521 config.bundles.get_mut("hel").unwrap().repositories[0].local = None;
6524 let mut snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
6525 snapshot.workspaces.push(ViewerWorkspace {
6526 id: "workspace-1".into(),
6527 name: "One".into(),
6528 });
6529 assert_eq!(
6530 snapshot.sessions[0].incompatible_resume_targets,
6531 vec!["raw".to_owned()]
6532 );
6533
6534 let error = validate_action(
6535 &ControllerAction::Resume {
6536 session_id: "session-1".into(),
6537 workspace_id: "workspace-1".into(),
6538 profile_id: "codex-1".into(),
6539 target_id: "raw".into(),
6540 queue: ResumeQueueDisposition::Start,
6541 additional_mounts: None,
6542 resource_allocation: None,
6543 },
6544 &snapshot,
6545 )
6546 .unwrap_err();
6547
6548 assert_eq!(error.status, StatusCode::BAD_REQUEST);
6549 }
6550
6551 #[test]
6552 fn move_confirmation_requires_interruption_ack_and_an_explicit_queue_choice() {
6553 let (config, state) = sample_config_state();
6554 let mut snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
6555 snapshot.sessions[0].capabilities.move_session = true;
6556 let selection = MoveSelection {
6557 clear_resource_allocation: false,
6558 session_id: "session-1".into(),
6559 profile_id: Some("codex-1".into()),
6560 target_template_id: Some("podman".into()),
6561 additional_mounts: None,
6562 resource_allocation: None,
6563 };
6564 let preparation = MovePreparation {
6565 selection,
6566 source_profile_id: "codex-1".into(),
6567 source_target_template_id: "podman".into(),
6568 cross_harness: false,
6569 active: true,
6570 queued_commands: vec![hel::hel_state::MaterializedQueuedPrompt {
6571 command_id: "command-1".into(),
6572 kind: hel::hel_state::QueuedCommandKind::Prompt,
6573 content: vec![serde_json::json!({"type": "text", "text": "continue"})],
6574 queued_at_ms: 1,
6575 }],
6576 fingerprint: "fingerprint".into(),
6577 operation_id: "move-1".into(),
6578 };
6579 let request = |queue, acknowledge_interruption| MoveSessionRequest {
6580 preparation: preparation.clone(),
6581 queue,
6582 acknowledge_interruption,
6583 };
6584 assert_eq!(
6585 validate_action(
6586 &ControllerAction::Move {
6587 request: request(Some(ResumeQueueDisposition::Discard), false),
6588 },
6589 &snapshot,
6590 )
6591 .unwrap_err()
6592 .status,
6593 StatusCode::CONFLICT
6594 );
6595 assert_eq!(
6596 validate_action(
6597 &ControllerAction::Move {
6598 request: request(None, true),
6599 },
6600 &snapshot,
6601 )
6602 .unwrap_err()
6603 .status,
6604 StatusCode::BAD_REQUEST
6605 );
6606 validate_action(
6607 &ControllerAction::Move {
6608 request: request(Some(ResumeQueueDisposition::Discard), true),
6609 },
6610 &snapshot,
6611 )
6612 .unwrap();
6613 }
6614
6615 #[tokio::test]
6616 async fn snapshot_endpoint_returns_only_public_projection() {
6617 let (app, _, _, _, _) = app();
6618 let cookie = login_cookie(&app).await;
6619 let response = app
6620 .oneshot(
6621 Request::get("/api/snapshot")
6622 .header(COOKIE, cookie)
6623 .body(Body::empty())
6624 .unwrap(),
6625 )
6626 .await
6627 .unwrap();
6628 let body = response.into_body().collect().await.unwrap().to_bytes();
6629 let body = String::from_utf8(body.to_vec()).unwrap();
6630 assert!(body.contains("session-1"));
6631 assert!(!body.contains("secret-token"));
6632 assert!(!body.contains("native-secret-id"));
6633 assert!(!body.contains("/private/source/hel"));
6634
6635 let snapshot: serde_json::Value = serde_json::from_str(&body).unwrap();
6636 let repository = &snapshot["bundles"][0]["repositories"][0];
6637 assert_eq!(repository["id"], "hel");
6638 assert_eq!(repository["github"], "owner/hel");
6639 assert_eq!(repository["destination"], "hel");
6640 assert!(repository.get("local").is_none());
6641 }
6642
6643 #[tokio::test]
6644 async fn snapshot_clock_anchor_is_fresh_even_when_the_projection_has_not_changed() {
6645 let (app, _, _, _, _) = app_with_snapshot(|snapshot| snapshot.server_time_ms = 1);
6646 let cookie = login_cookie(&app).await;
6647 for _ in 0..2 {
6648 let before = hel::clock::epoch_millis();
6649 let response = app
6650 .clone()
6651 .oneshot(
6652 Request::get("/api/snapshot")
6653 .header(COOKIE, &cookie)
6654 .body(Body::empty())
6655 .unwrap(),
6656 )
6657 .await
6658 .unwrap();
6659 let body = response.into_body().collect().await.unwrap().to_bytes();
6660 let snapshot: ViewerSnapshot = serde_json::from_slice(&body).unwrap();
6661 assert!(snapshot.server_time_ms >= before);
6662 assert!(snapshot.server_time_ms <= hel::clock::epoch_millis());
6663 }
6664 }
6665
6666 #[tokio::test]
6667 async fn conversation_endpoint_returns_authenticated_bounded_deltas() {
6668 let transcript = BrowserTranscript {
6669 latest_seq: 8,
6670 window_start_seq: 3,
6671 reset: false,
6672 entries: vec![
6673 BrowserTranscriptEntry {
6674 id: 3,
6675 updated_seq: 3,
6676 role: "user",
6677 label: "You".into(),
6678 recorded_at_ms: None,
6679 lines: vec!["begin".into()],
6680 glyph: "\u{276f}",
6681 tone: "user",
6682 tool_status: None,
6683 diffstats: Vec::new(),
6684 },
6685 BrowserTranscriptEntry {
6686 id: 7,
6687 updated_seq: 8,
6688 role: "agent",
6689 label: "Agent".into(),
6690 recorded_at_ms: None,
6691 lines: vec!["live".into()],
6692 glyph: "\u{25cf}",
6693 tone: "agent",
6694 tool_status: None,
6695 diffstats: Vec::new(),
6696 },
6697 ],
6698 };
6699 let (app, _, _, _, _) =
6700 app_with_conversations(BTreeMap::from([("session-1".into(), transcript)]));
6701 let cookie = login_cookie(&app).await;
6702 let response = app
6703 .oneshot(
6704 Request::get("/api/conversations/session-1?after_seq=3")
6705 .header(COOKIE, cookie)
6706 .body(Body::empty())
6707 .unwrap(),
6708 )
6709 .await
6710 .unwrap();
6711 assert_eq!(response.status(), StatusCode::OK);
6712 let body = response.into_body().collect().await.unwrap().to_bytes();
6713 let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
6714 assert_eq!(body["latest_seq"], 8);
6715 assert_eq!(body["reset"], false);
6716 assert_eq!(body["entries"].as_array().unwrap().len(), 1);
6717 assert_eq!(body["entries"][0]["lines"][0], "live");
6718 }
6719
6720 #[tokio::test]
6721 async fn conversation_endpoint_rejects_cached_transcript_during_transition() {
6722 let transcript = BrowserTranscript {
6723 latest_seq: 1,
6724 window_start_seq: 1,
6725 reset: false,
6726 entries: vec![BrowserTranscriptEntry {
6727 id: 1,
6728 updated_seq: 1,
6729 role: "agent",
6730 label: "Agent".into(),
6731 recorded_at_ms: None,
6732 lines: vec!["stale".into()],
6733 glyph: "●",
6734 tone: "agent",
6735 tool_status: None,
6736 diffstats: Vec::new(),
6737 }],
6738 };
6739 let (app, _, _, _, _) = app_with(
6740 BTreeMap::from([("session-1".into(), transcript)]),
6741 |snapshot| snapshot.sessions[0].transitioning = true,
6742 );
6743 let cookie = login_cookie(&app).await;
6744 let response = app
6745 .oneshot(
6746 Request::get("/api/conversations/session-1")
6747 .header(COOKIE, cookie)
6748 .body(Body::empty())
6749 .unwrap(),
6750 )
6751 .await
6752 .unwrap();
6753 assert_eq!(response.status(), StatusCode::CONFLICT);
6754 }
6755
6756 #[tokio::test]
6757 async fn conversation_read_receipt_never_contends_with_a_running_action() {
6758 let (app, mut actions, mut receipts, _, _) = app();
6759 let cookie = login_cookie(&app).await;
6760 let prompt = tokio::spawn(
6764 app.clone().oneshot(
6765 Request::post("/api/actions")
6766 .header(COOKIE, cookie.clone())
6767 .header(CONTENT_TYPE, "application/json")
6768 .body(Body::from(
6769 r#"{"action":"prompt","session_id":"session-1","text":"ship it"}"#,
6770 ))
6771 .unwrap(),
6772 ),
6773 );
6774 let action = actions.recv().await.unwrap();
6775
6776 let response = tokio::spawn(
6777 app.oneshot(
6778 Request::post("/api/conversations/session-1/read")
6779 .header(COOKIE, cookie)
6780 .header(CONTENT_TYPE, "application/json")
6781 .body(Body::from(r#"{"through":42}"#))
6782 .unwrap(),
6783 ),
6784 );
6785 let receipt = receipts.recv().await.unwrap();
6786 assert_eq!(receipt.session_id, "session-1");
6787 assert_eq!(receipt.through, 42);
6788 receipt.reply.send(Ok(())).unwrap();
6789 assert_eq!(
6790 response.await.unwrap().unwrap().status(),
6791 StatusCode::NO_CONTENT
6792 );
6793 assert!(
6794 actions.try_recv().is_err(),
6795 "a read receipt must not queue a controller action"
6796 );
6797
6798 action.reply.send(ActionOutcome::Accepted).unwrap();
6799 assert_eq!(
6800 prompt.await.unwrap().unwrap().status(),
6801 StatusCode::ACCEPTED
6802 );
6803 }
6804
6805 #[tokio::test]
6806 async fn each_rejected_action_keeps_its_own_status_and_guidance() {
6807 for (outcome, status, guidance) in [
6808 (
6809 ActionOutcome::Busy,
6810 StatusCode::TOO_MANY_REQUESTS,
6811 "concurrent action limit",
6812 ),
6813 (
6814 ActionOutcome::SessionBusy,
6815 StatusCode::CONFLICT,
6816 "another operation is already running",
6817 ),
6818 (
6819 ActionOutcome::NotCancellable,
6820 StatusCode::CONFLICT,
6821 "no cancellable operation",
6822 ),
6823 (
6824 ActionOutcome::Failed,
6825 StatusCode::INTERNAL_SERVER_ERROR,
6826 "could not start this action",
6827 ),
6828 ] {
6829 let (app, mut actions, _, _, _) = app();
6830 let cookie = login_cookie(&app).await;
6831 let response = tokio::spawn(
6832 app.oneshot(
6833 Request::post("/api/actions")
6834 .header(COOKIE, cookie)
6835 .header(CONTENT_TYPE, "application/json")
6836 .body(Body::from(r#"{"action":"close","session_id":"session-1"}"#))
6837 .unwrap(),
6838 ),
6839 );
6840 let request = actions.recv().await.unwrap();
6841 request.reply.send(outcome).unwrap();
6842
6843 let response = response.await.unwrap().unwrap();
6844 assert_eq!(response.status(), status, "{outcome:?}");
6845 let body = response.into_body().collect().await.unwrap().to_bytes();
6846 let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
6847 let error = body["error"].as_str().unwrap();
6848 assert!(error.contains(guidance), "{outcome:?} answered {error:?}");
6849 }
6850 }
6851
6852 #[tokio::test]
6853 async fn the_viewer_shows_a_session_whose_action_failed_after_it_was_accepted() {
6854 let (app, _, _, _, _) = app();
6858 let script = fetch_text(app, "/viewer.js").await;
6859 assert!(script.contains("has_error"), "viewer ignores has_error");
6860 }
6861
6862 #[tokio::test]
6865 async fn every_response_carries_the_security_headers() {
6866 for path in [
6867 "/",
6868 "/viewer.js",
6869 "/voice-worklet.js",
6870 "/voice-worker.js",
6871 "/viewer.css",
6872 "/manifest.webmanifest",
6873 "/api/snapshot",
6874 ] {
6875 let (app, _, _, _, _) = app();
6876 let response = app
6877 .oneshot(Request::get(path).body(Body::empty()).unwrap())
6878 .await
6879 .unwrap();
6880 let headers = response.headers();
6881 let policy = headers
6882 .get(CONTENT_SECURITY_POLICY_HEADER)
6883 .unwrap_or_else(|| panic!("{path} carries no content-security policy"))
6884 .to_str()
6885 .unwrap();
6886 assert!(
6887 policy.starts_with("default-src 'none';"),
6888 "{path} does not refuse unlisted sources: {policy}"
6889 );
6890 assert!(
6891 policy.contains("script-src 'self'") && !policy.contains("unsafe-inline"),
6892 "{path} permits inline script: {policy}"
6893 );
6894 assert!(
6895 policy.contains("frame-ancestors 'none'"),
6896 "{path} can be framed: {policy}"
6897 );
6898 assert_eq!(
6899 headers.get(X_CONTENT_TYPE_OPTIONS).unwrap(),
6900 "nosniff",
6901 "{path} permits content sniffing"
6902 );
6903 assert_eq!(
6904 headers.get(REFERRER_POLICY).unwrap(),
6905 "no-referrer",
6906 "{path} leaks a referrer"
6907 );
6908 }
6909 }
6910
6911 #[tokio::test]
6915 async fn the_page_carries_no_inline_script_or_style() {
6916 let (app, _, _, _, _) = app();
6917 let page = fetch_text(app, "/").await;
6918 assert!(
6919 !page.contains("<script>") && !page.contains("<style>"),
6920 "the page inlines script or style, which the policy blocks"
6921 );
6922 assert!(
6923 page.contains(r#"src="/viewer.js""#) && page.contains(r#"href="/viewer.css""#),
6924 "the page does not load its script and style as separate assets"
6925 );
6926 }
6927
6928 #[tokio::test]
6931 async fn live_state_and_the_service_worker_are_never_stored() {
6932 for path in ["/", "/service-worker.js", "/api/snapshot"] {
6933 let (app, _, _, _, _) = app();
6934 let response = app
6935 .oneshot(Request::get(path).body(Body::empty()).unwrap())
6936 .await
6937 .unwrap();
6938 assert_eq!(
6939 response.headers().get(CACHE_CONTROL).unwrap(),
6940 "no-store",
6941 "{path} may be stored"
6942 );
6943 }
6944 }
6945
6946 #[test]
6949 fn the_service_worker_declines_to_handle_live_state() {
6950 assert!(
6951 SERVICE_WORKER.contains("url.pathname.startsWith('/api/')"),
6952 "the service worker does not exclude the API"
6953 );
6954 assert!(
6955 SERVICE_WORKER.contains("url.pathname.startsWith('/auth/')"),
6956 "the service worker does not exclude authentication"
6957 );
6958 assert!(
6959 SERVICE_WORKER.contains("caches.delete"),
6960 "the service worker never deletes a superseded cache"
6961 );
6962 }
6963
6964 #[tokio::test]
6967 async fn the_installable_assets_are_served() {
6968 for (path, content_type) in [
6969 ("/icon-192.png", "image/png"),
6970 ("/icon-512.png", "image/png"),
6971 ("/maskable-512.png", "image/png"),
6972 ("/apple-touch-icon.png", "image/png"),
6973 ("/fonts/jetbrains-mono.woff2", "font/woff2"),
6974 ] {
6975 let (app, _, _, _, _) = app();
6976 let response = app
6977 .oneshot(Request::get(path).body(Body::empty()).unwrap())
6978 .await
6979 .unwrap();
6980 assert_eq!(response.status(), StatusCode::OK, "{path} is not served");
6981 assert_eq!(
6982 response.headers().get(CONTENT_TYPE).unwrap(),
6983 content_type,
6984 "{path} is served as the wrong type"
6985 );
6986 }
6987 }
6988
6989 async fn fetch_text(app: Router, path: &str) -> String {
6993 let response = app
6994 .oneshot(Request::get(path).body(Body::empty()).unwrap())
6995 .await
6996 .unwrap();
6997 assert_eq!(response.status(), StatusCode::OK, "{path} is not served");
6998 let body = response.into_body().collect().await.unwrap().to_bytes();
6999 String::from_utf8(body.to_vec()).expect("assets are UTF-8")
7000 }
7001
7002 #[tokio::test]
7003 async fn repeated_wrong_codes_lock_the_login_endpoint() {
7004 let (app, _, _, _, _) = app();
7005 let attempt = |code: &'static str| {
7006 let app = app.clone();
7007 async move {
7008 app.oneshot(
7009 Request::post("/auth/session")
7010 .header(CONTENT_TYPE, "application/json")
7011 .body(Body::from(format!(r#"{{"code":"{code}"}}"#)))
7012 .unwrap(),
7013 )
7014 .await
7015 .unwrap()
7016 .status()
7017 }
7018 };
7019 for _ in 0..MAX_CODE_FAILURES {
7020 assert_eq!(attempt("000000").await, StatusCode::UNAUTHORIZED);
7021 }
7022 assert_eq!(attempt("000000").await, StatusCode::TOO_MANY_REQUESTS);
7023 assert_eq!(attempt("123456").await, StatusCode::TOO_MANY_REQUESTS);
7026 }
7027
7028 #[test]
7029 fn viewer_code_lockouts_lengthen_instead_of_resetting_after_every_wait() {
7030 let serve_one_lockout = |guard: &mut CodeGuard, now: Instant| {
7031 for _ in 0..MAX_CODE_FAILURES {
7032 assert!(!guard.locked_at(now));
7033 guard.record_failure_at(now);
7034 }
7035 assert!(guard.locked_at(now));
7036 guard.locked_until.expect("the guard is locked") - now
7037 };
7038
7039 let start = Instant::now();
7040 let mut guard = CodeGuard::default();
7041 let first = serve_one_lockout(&mut guard, start);
7042 assert_eq!(first, CODE_LOCKOUT_BASE);
7043
7044 let second_round = start + first;
7048 let second = serve_one_lockout(&mut guard, second_round);
7049 assert_eq!(second, CODE_LOCKOUT_BASE * 2);
7050 let third = serve_one_lockout(&mut guard, second_round + second);
7051 assert_eq!(third, CODE_LOCKOUT_BASE * 4);
7052 assert_eq!(code_lockout(u32::MAX), CODE_LOCKOUT_CAP);
7053
7054 let mut recovered = CodeGuard::default();
7057 assert_eq!(serve_one_lockout(&mut recovered, start), CODE_LOCKOUT_BASE);
7058 }
7059
7060 #[test]
7061 fn persisted_cookie_key_survives_a_restart_and_stays_owner_only() {
7062 let directory = tempfile::tempdir().unwrap();
7063 let path = directory.path().join("phone-cookie-key");
7064
7065 let first = load_or_create_cookie_key(&path).unwrap();
7066 assert!(first.len() >= COOKIE_KEY_BYTES);
7067 assert_eq!(std::fs::read(&path).unwrap(), first);
7068 #[cfg(unix)]
7069 {
7070 use std::os::unix::fs::PermissionsExt as _;
7071 let mode = std::fs::metadata(&path).unwrap().permissions().mode();
7072 assert_eq!(mode & 0o777, 0o600);
7073 }
7074
7075 let mut restarted = detached_options();
7078 restarted
7079 .set_cookie_key(load_or_create_cookie_key(&path).unwrap())
7080 .unwrap();
7081 let mut original = detached_options();
7082 original.set_cookie_key(first.clone()).unwrap();
7083 let cookie = signed_cookie_value(&original.cookie_key, "test-viewer", 200);
7084 assert!(session_cookie_valid(&restarted.cookie_key, &cookie, 100));
7085 assert!(!session_cookie_valid(
7086 &detached_options().cookie_key,
7087 &cookie,
7088 100
7089 ));
7090
7091 std::fs::remove_file(&path).unwrap();
7093 let rotated = load_or_create_cookie_key(&path).unwrap();
7094 assert_ne!(rotated, first);
7095 assert!(!session_cookie_valid(&rotated, &cookie, 100));
7096 }
7097
7098 #[test]
7099 fn corrupt_cookie_key_is_regenerated_instead_of_blocking_startup() {
7100 let directory = tempfile::tempdir().unwrap();
7101 let path = directory.path().join("phone-cookie-key");
7102 std::fs::write(&path, b"short").unwrap();
7103
7104 let key = load_or_create_cookie_key(&path).unwrap();
7105
7106 assert!(key.len() >= COOKIE_KEY_BYTES);
7107 assert_eq!(std::fs::read(&path).unwrap(), key);
7108 assert_eq!(load_or_create_cookie_key(&path).unwrap(), key);
7109 }
7110}